authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-03 13:26:07-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-03 13:39:45-07:00
logf729362858ad787f269bc50144f0c765a84b366f
treefcc7a17f132f966130fbe9932e076cb4f3f7ec6c
parent292bb9cf4d5f1dc50b873b5eb9a3c46b562a57fe
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: page colours preview live on the page as they are hovered or picked, the backgrounds and templates galleries dock beside the page, and a new page's Solid Color opens the page colours over its tile

Assisted-by: claude-opus-5.5

10 files changed, 317 insertions(+), 288 deletions(-)

arc/ui.md+2-1
......@@ -27,7 +27,8 @@ it is, and it settles fast. Popups open and close on short timed curves,
2727slow enough to follow: a menu swings out of the pointer or its button as it
2828fades in, a combo's field widens into its list, and a dialog swings up into
2929place near the window's top over a dimmed window, as Windows opens a window; the
30command palette swings in the same way, undimmed. A filtered list shows its new
30command palette swings in the same way, undimmed, as does a dialog docked inside the
31page it changes, such as the backgrounds gallery. A filtered list shows its new
3132results at once. On GNOME and KDE, menus instead
3233look and move as the desktop's own (see [platforms](platforms.md)). A segmented control
3334slides a raised face along a track, as macOS, libadwaita and Windows 11 draw theirs, and on
crates/snowbound/src/background.rs+16-6
......@@ -4,7 +4,7 @@
44use crate::{
55 art,
66 commands::Choice,
7 templates::{TILE, Thumbnails, View},
7 templates::{TILE, Thumbnails},
88};
99use canvas::{
1010 gpu::{Paper, colorref},
......@@ -42,7 +42,8 @@ enum Cell {
4242
4343/// Builds popup `id` beside `anchor` while it is open, for a page on `paper` (its own colour
4444/// aside) coloured `color` and ruled with `rule_lines`, and the custom colour picker it
45/// opens. Show All opens every template's art over the page through `view`. Returns the
45/// opens, or with `colors_only` only its page colours. Show All opens the backgrounds
46/// gallery. Sets `preview` to the page colour highlighted or being picked. Returns the
4647/// choice made.
4748#[allow(clippy::too_many_arguments)]
4849pub fn menu(
......@@ -50,10 +51,11 @@ pub fn menu(
5051 id: Id,
5152 anchor: Anchor,
5253 thumbnails: &mut Thumbnails,
53 view: &mut View,
5454 paper: Paper,
5555 color: Option<u32>,
5656 rule_lines: Option<RuleLines>,
57 colors_only: bool,
58 preview: &mut Option<Option<u32>>,
5759) -> Option<Choice> {
5860 let picker = id.child("custom");
5961 // A pale blue to start from on a page without a colour.
......@@ -74,6 +76,9 @@ pub fn menu(
7476 red, green, blue, 0,
7577 ]))));
7678 }
79 if let Some([red, green, blue]) = ui::popup::picking(ui, picker) {
80 *preview = Some(Some(u32::from_le_bytes([red, green, blue, 0])));
81 }
7782 let custom = color.filter(|color| !PAGE_COLORS.iter().any(|(_, listed)| listed == color));
7883 let cells: Vec<Cell> = std::iter::once(Cell::Color(None))
7984 .chain(
......@@ -148,7 +153,8 @@ pub fn menu(
148153 },
149154 );
150155 };
151 let chosen = ui::popup::gallery(ui, id, anchor, &groups, &current, |ui, index| {
156 let groups = if colors_only { &groups[..1] } else { &groups };
157 let chosen = ui::popup::gallery(ui, id, anchor, groups, &current, |ui, index| {
152158 let name = match cells[index] {
153159 Cell::Color(Some(color)) => PAGE_COLORS
154160 .iter()
......@@ -236,7 +242,11 @@ pub fn menu(
236242 }
237243 Cell::Rules(Some(index)) => rule_thumbnail(ui, paper, RULE_LINES[index]),
238244 }
239 })?;
245 });
246 if let Some(Cell::Color(color)) = ui::popup::highlight(ui, id).map(|index| cells[index]) {
247 *preview = Some(color);
248 }
249 let chosen = chosen?;
240250 match cells[chosen] {
241251 Cell::Color(color) => Some(Choice::PageColor(color)),
242252 Cell::Custom => {
......@@ -245,7 +255,7 @@ pub fn menu(
245255 }
246256 Cell::Art(name) => Some(Choice::Art(name)),
247257 Cell::ShowAll => {
248 *view = View::Art;
258 ui.open_popup(crate::templates::backgrounds());
249259 None
250260 }
251261 Cell::Rules(index) => Some(Choice::RuleLines(index)),
crates/snowbound/src/main.rs+19-9
......@@ -718,8 +718,8 @@ enum Command {
718718 space: ExGuid,
719719 path: String,
720720 },
721 /// Gives the open page a template's background or colour.
722 Template(templates::Choice),
721 /// Gives the open page a template's background.
722 Template(&'static str),
723723 OpenPage(ExGuid),
724724 /// Shows a page's conflict pages in the list and opens the newest, or hides them.
725725 Versions {
......@@ -902,8 +902,8 @@ struct State {
902902 dismissed: HashSet<ExGuid>,
903903 /// A page just created, whose title takes the caret once it opens.
904904 title_focus: Option<ExGuid>,
905 /// What the template strip shows over a blank page.
906 templates: templates::View,
905 /// The page colour a menu previews over the open page's own this frame.
906 page_color_preview: Option<Option<u32>>,
907907 media: recording::Media,
908908 /// Whether playback highlights the notes linked to the moment playing.
909909 see_playback: bool,
......@@ -1349,7 +1349,7 @@ impl State {
13491349 .map(|id| notebook::sidecar::themes::successor(&id).to_owned()),
13501350 live_options: stored.live,
13511351 drag: None,
1352 templates: templates::View::Strip,
1352 page_color_preview: None,
13531353 media: Default::default(),
13541354 see_playback: true,
13551355 thumbnails: templates::Thumbnails::default(),
......@@ -1560,7 +1560,12 @@ impl State {
15601560 fn layout(&mut self, size: [f32; 2], scale: f32) -> Result<(), Box<dyn Error>> {
15611561 self.ui.begin(size, scale, Instant::now());
15621562 platform::cover_border_line(&mut self.ui, size[0]);
1563 let previewed = self.page_color_preview;
15631564 let (section, open_tab, open_page) = self.build()?;
1565 // The page's frame and tab were built with last frame's preview.
1566 if self.page_color_preview != previewed {
1567 self.ui.wake_after(std::time::Duration::ZERO);
1568 }
15641569 self.options_dialog();
15651570 self.themes_dialog();
15661571 self.print_dialog();
......@@ -1677,6 +1682,7 @@ impl State {
16771682 // The page, and the open page's tab joined to it, take the page's colour.
16781683 let mut theme = self.ui.theme.clone();
16791684 theme.paper = self.paper().color;
1685 self.page_color_preview = None;
16801686 platform::update_menu(|| self.statuses());
16811687 let welcome = self.session.is_none()
16821688 && !self.temporary
......@@ -3049,7 +3055,6 @@ impl State {
30493055 menu,
30503056 anchor,
30513057 &mut self.thumbnails,
3052 &mut self.templates,
30533058 // The page's own colour aside.
30543059 canvas::gpu::Paper {
30553060 color: ui.theme.paper,
......@@ -3057,6 +3062,8 @@ impl State {
30573062 },
30583063 self.view.editor.page_color(),
30593064 self.view.editor.rule_lines(),
3065 false,
3066 &mut self.page_color_preview,
30603067 )
30613068 },
30623069 |_| None,
......@@ -3624,7 +3631,7 @@ impl State {
36243631 self.move_page(space, path)?;
36253632 self.edited(Vec::new());
36263633 }
3627 Command::Template(choice) => self.apply_template(choice)?,
3634 Command::Template(name) => self.apply_template(name)?,
36283635 Command::Page(Request::EditDate(field)) => self.edit_date(field),
36293636 Command::Page(Request::Copy(clip)) => self.clipboard.set(clip)?,
36303637 Command::Page(Request::Paste) => self.paste()?,
......@@ -4557,13 +4564,16 @@ impl State {
45574564 self.window.request_redraw();
45584565 }
45594566
4560 /// The paper the open page lies on: the theme's, in the page's colour.
4567 /// The paper the open page lies on: the theme's, in the page's colour or the one previewed.
45614568 fn paper(&self) -> canvas::gpu::Paper {
45624569 canvas::gpu::Paper {
45634570 color: self.ui.theme.paper,
45644571 ink: self.ui.theme.paper_ink,
45654572 }
4566 .colored(self.view.editor.page_color())
4573 .colored(
4574 self.page_color_preview
4575 .unwrap_or_else(|| self.view.editor.page_color()),
4576 )
45674577 }
45684578
45694579 /// The colours of what the page area shows in place of a page, which follow the page's:
crates/snowbound/src/manage.rs+32-59
......@@ -180,23 +180,13 @@ impl State {
180180 Ok(())
181181 }
182182
183 /// Gives the open page `choice`'s background: a template's art in place of any it
184 /// had, or a page colour instead of art.
185 pub(crate) fn apply_template(
186 &mut self,
187 choice: crate::templates::Choice,
188 ) -> Result<(), Box<dyn Error>> {
189 use crate::templates::Choice;
190 let template = match choice {
191 Choice::Template(name) => {
192 Some(canvas::template::find(name).ok_or("That template is not available")?)
193 }
194 _ => None,
195 };
196 self.with_art(template, move |state, art| {
183 /// Gives the open page template `name`'s art in place of any it had.
184 pub(crate) fn apply_template(&mut self, name: &'static str) -> Result<(), Box<dyn Error>> {
185 let template = canvas::template::find(name).ok_or("That template is not available")?;
186 self.with_art(Some(template), move |state, art| {
197187 state.persist()?;
198188 let session = state.session.as_ref().ok_or("No section is open")?;
199 let ops = template_ops(&session.section.page(session.space)?, choice, art)?;
189 let ops = template_ops(&session.section.page(session.space)?, name, art)?;
200190 state.edit_page(ops)
201191 })
202192 }
......@@ -561,7 +551,6 @@ impl State {
561551 self.persist().unwrap_or_else(|error| eprintln!("{error}"));
562552 self.session = None;
563553 self.sectionless = None;
564 self.templates = crate::templates::View::Strip;
565554 match self
566555 .notebooks
567556 .iter()
......@@ -966,35 +955,23 @@ impl State {
966955 }
967956}
968957
969/// The ops giving `page` `choice`'s background: its template art or a page colour, in
970/// place of the background it had.
958/// The ops giving `page` template `name`'s art, `art`, in place of the background it had,
959/// and no page colour.
971960pub fn template_ops(
972961 page: &Page,
973 choice: crate::templates::Choice,
962 name: &str,
974963 art: Vec<Image>,
975964) -> Result<Vec<PageOp>, Box<dyn Error>> {
976 use crate::templates::Choice;
977 let mut ops = match choice {
978 Choice::Template(_) | Choice::Color(_) => art_ops(page, art),
979 Choice::More | Choice::Dismiss | Choice::Colors => return Ok(Vec::new()),
980 };
981 match choice {
982 Choice::Template(name) => {
983 if page.color.is_some() {
984 ops.push(PageOp::Color(None));
985 }
986 // The one template with content worth keeping.
987 if name == "Informal Meeting Notes" {
988 ops.extend(onestore::op::lower_page(
989 page,
990 &crate::meeting::content(page)?,
991 )?);
992 }
993 }
994 Choice::Color(index) => {
995 ops.push(PageOp::Color(Some(canvas::template::PAGE_COLORS[index].1)))
996 }
997 Choice::More | Choice::Dismiss | Choice::Colors => {}
965 let mut ops = art_ops(page, art);
966 if page.color.is_some() {
967 ops.push(PageOp::Color(None));
968 }
969 // The one template with content worth keeping.
970 if name == "Informal Meeting Notes" {
971 ops.extend(onestore::op::lower_page(
972 page,
973 &crate::meeting::content(page)?,
974 )?);
998975 }
999976 Ok(ops)
1000977}
......@@ -1031,7 +1008,6 @@ fn art_ops(page: &Page, art: Vec<Image>) -> Vec<PageOp> {
10311008#[cfg(test)]
10321009mod tests {
10331010 use super::*;
1034 use crate::templates::Choice;
10351011 use std::path::Path;
10361012
10371013 const AUTHOR: &str = "Rust Author";
......@@ -1153,27 +1129,27 @@ mod tests {
11531129 let [ivy, teal, subpage, deleted, meeting] = spaces[..] else {
11541130 unreachable!()
11551131 };
1156 let background = |space: ExGuid, choice: Choice| {
1157 let art = match choice {
1158 Choice::Template(name) => canvas::template::find(name).unwrap().pictures().unwrap(),
1159 _ => Vec::new(),
1160 };
1161 template_ops(&page(&file, space), choice, art)
1132 let background = |space: ExGuid, name: &str| {
1133 let art = canvas::template::find(name).unwrap().pictures().unwrap();
1134 template_ops(&page(&file, space), name, art)
11621135 .unwrap()
11631136 .into_iter()
11641137 .map(|op| Op::Page { space, op })
11651138 .collect::<Vec<_>>()
11661139 };
1167 edit(&file, background(ivy, Choice::Template("Ivy")));
1140 edit(&file, background(ivy, "Ivy"));
1141 edit(&file, background(meeting, "Informal Meeting Notes"));
1142 let (_, teal_color) = canvas::template::PAGE_COLORS
1143 .iter()
1144 .find(|(name, _)| *name == "Teal")
1145 .unwrap();
11681146 edit(
11691147 &file,
1170 background(meeting, Choice::Template("Informal Meeting Notes")),
1148 vec![Op::Page {
1149 space: teal,
1150 op: PageOp::Color(Some(*teal_color)),
1151 }],
11711152 );
1172 let teal_index = canvas::template::PAGE_COLORS
1173 .iter()
1174 .position(|(name, _)| *name == "Teal")
1175 .unwrap();
1176 edit(&file, background(teal, Choice::Color(teal_index)));
11771153 edit(
11781154 &file,
11791155 vec![Op::Section(SectionOp::Pages(vec![
......@@ -1275,10 +1251,7 @@ mod tests {
12751251 ]
12761252 );
12771253 let shown = page(&file, teal);
1278 assert_eq!(
1279 shown.color,
1280 Some(canvas::template::PAGE_COLORS[teal_index].1)
1281 );
1254 assert_eq!(shown.color, Some(*teal_color));
12821255 assert!(shown.date_text().is_some());
12831256 assert!(
12841257 page(&file, ivy)
crates/snowbound/src/print.rs+9-6
......@@ -530,12 +530,15 @@ mod tests {
530530 };
531531 let create = |creation| Op::Section(SectionOp::Create(creation));
532532 (section.apply("Author", edit(vec![create(dated), create(written)]))).unwrap();
533 let meeting = crate::templates::Choice::Template("Informal Meeting Notes");
534 let ops = crate::manage::template_ops(&section.page(space).unwrap(), meeting, Vec::new())
535 .unwrap()
536 .into_iter()
537 .map(|op| Op::Page { space, op })
538 .collect();
533 let ops = crate::manage::template_ops(
534 &section.page(space).unwrap(),
535 "Informal Meeting Notes",
536 Vec::new(),
537 )
538 .unwrap()
539 .into_iter()
540 .map(|op| Op::Page { space, op })
541 .collect();
539542 section.apply("Author", edit(ops)).unwrap();
540543 // The template titles the page; its title goes, its text stays.
541544 let untitled = crate::rename::retitled(&section.page(space).unwrap(), space, String::new());
crates/snowbound/src/templates.rs+143-202
......@@ -1,19 +1,18 @@
11//! The template strip a new page shows where its body would start until the body is typed
2//! in, and the gallery of every template it leads to.
2//! in, and the galleries of every template docked beside the page.
33
44use crate::art;
55use canvas::{
66 gpu::Paper,
7 template::{PAGE_COLORS, TEMPLATES, Template},
7 template::{TEMPLATES, Template},
88};
99use draw::RasterImage;
1010use std::collections::HashMap;
11use ui::{Axis, Flags, Spec, Theme, Ui, fill, px};
11use ui::{Anchor, Axis, Flags, Id, Spec, Theme, Ui, fill, px};
1212
1313/// What a thumbnail shows: the top of a letter page, in points from the page origin.
1414const REGION: [f32; 4] = [-72.0, -40.0, 540.0, 419.0];
1515pub(crate) const TILE: [f32; 2] = [112.0, 84.0];
16const SWATCH: f32 = 24.0;
1716const LABEL: f32 = 22.0;
1817const GAP: f32 = 12.0;
1918const PAD: f32 = 10.0;
......@@ -30,26 +29,28 @@ const STRIP: [(Choice, &str); 5] = [
3029];
3130
3231#[derive(Clone, Copy, Debug, PartialEq)]
33pub enum Choice {
32enum Choice {
3433 Template(&'static str),
35 /// An index into `PAGE_COLORS`.
36 Color(usize),
37 /// Shows or hides the page colours.
34 /// Opens the Page Color menu over its tile.
3835 Colors,
3936 More,
4037 Dismiss,
4138}
4239
43/// What the template strip shows.
44#[derive(Clone, Copy, Debug, Default, PartialEq)]
45pub enum View {
46 #[default]
47 Strip,
48 /// The strip with the page colours beneath it.
49 Colors,
50 Gallery,
51 /// Every template's art, from the Page Color menu, to lie behind the open page.
52 Art,
40/// The gallery of every template's art, from the Page Color menu's Show All, to lie behind
41/// the open page.
42pub fn backgrounds() -> Id {
43 Id::ROOT.child("backgrounds")
44}
45
46/// The gallery of every template, from the strip's More templates.
47fn templates() -> Id {
48 Id::ROOT.child("templates")
49}
50
51/// The page colours the strip's Solid Color opens.
52fn colors() -> Id {
53 Id::ROOT.child("strip colors")
5354}
5455
5556/// A thumbnail's art: each raster and where it lies in the tile.
......@@ -87,20 +88,17 @@ impl Thumbnails {
8788 }
8889}
8990
90/// The strip at `position` in the page box, `room` wide at most, with the page colours
91/// beneath while `colors`. Picks that do not fit give way from the end; More templates
92/// stays.
93#[allow(clippy::too_many_arguments)]
91/// The strip at `position` in the page box, `room` wide at most. Picks that do not fit give
92/// way from the end; More templates stays. Returns the pick and the Solid Color tile, where
93/// shown.
9494fn strip(
9595 ui: &mut Ui,
9696 theme: &Theme,
9797 thumbnails: &mut Thumbnails,
9898 paper: Paper,
99 dark: bool,
10099 position: [f32; 2],
101100 room: f32,
102 colors: bool,
103) -> Option<Choice> {
101) -> (Option<Choice>, Option<Id>) {
104102 let more = (Choice::More, "More templates");
105103 let mut used = 2.0 * PAD + tile_width(ui, more.1);
106104 let mut shown = Vec::new();
......@@ -117,6 +115,7 @@ fn strip(
117115 .sum::<f32>()
118116 - GAP;
119117 let mut chosen = None;
118 let mut solid = None;
120119 ui.open(
121120 "templates",
122121 Spec {
......@@ -128,7 +127,6 @@ fn strip(
128127 shadow: Some(theme.shadow),
129128 radius: 8.0,
130129 pad: [PAD, PAD],
131 gap: 8.0,
132130 ..Spec::default()
133131 },
134132 );
......@@ -141,46 +139,16 @@ fn strip(
141139 },
142140 );
143141 for (index, (choice, label)) in shown.into_iter().enumerate() {
144 if tile(ui, theme, thumbnails, paper, dark, index, choice, label) {
142 let width = tile_width(ui, label);
143 let tile = tile(ui, theme, thumbnails, paper, index, choice, label, width);
144 if choice == Choice::Colors {
145 solid = Some(tile);
146 }
147 if ui.signal(tile).clicked {
145148 chosen = Some(choice);
146149 }
147150 }
148151 ui.close();
149 let per_row = (((room - 2.0 * PAD + 6.0) / (SWATCH + 6.0)).floor() as usize).max(1);
150 for (row, swatches) in PAGE_COLORS.chunks(per_row).enumerate().filter(|_| colors) {
151 ui.open(
152 ("colors", row),
153 Spec {
154 size: [ui::children(), px(SWATCH)],
155 gap: 6.0,
156 ..Spec::default()
157 },
158 );
159 for (index, (name, color)) in swatches
160 .iter()
161 .enumerate()
162 .map(|(index, color)| (row * per_row + index, color))
163 {
164 let swatch = ui.leaf(
165 (index, *name),
166 Spec {
167 flags: Flags::CLICKABLE,
168 size: [px(SWATCH), px(SWATCH)],
169 fill: Some(page_color(*color, paper, dark)),
170 border: Some(theme.chip),
171 hover_border: Some(theme.accent),
172 radius: 4.0,
173 role: Some(accesskit::Role::Button),
174 ..Spec::default()
175 },
176 );
177 crate::name(ui, ui.id((index, *name)), name);
178 if swatch.clicked {
179 chosen = Some(Choice::Color(index));
180 }
181 }
182 ui.close();
183 }
184152 let close = ui.leaf(
185153 "dismiss",
186154 Spec {
......@@ -203,37 +171,45 @@ fn strip(
203171 chosen = Some(Choice::Dismiss);
204172 }
205173 ui.close();
206 chosen
174 (chosen, solid)
207175}
208176
209/// Every template, or with `art` only their art, as a scrolling grid over the page box
210/// `size` big.
177/// Dialog `id`, titled `title`, of every template docked inside the page box's trailing edge,
178/// `height` tall, while it is open. Returns the template chosen; it stays open to choose again.
179#[allow(clippy::too_many_arguments)]
211180fn gallery(
212181 ui: &mut Ui,
213182 theme: &Theme,
214183 thumbnails: &mut Thumbnails,
215184 paper: Paper,
216 dark: bool,
217 size: [f32; 2],
218 art: bool,
219) -> Option<Choice> {
185 id: Id,
186 title: &str,
187 height: f32,
188) -> Option<&'static str> {
189 if !ui.popup_open(id) {
190 return None;
191 }
220192 let mut chosen = None;
221 let margin = 24.0;
222 ui.open(
223 "gallery",
193 let column = TILE[0] + 40.0;
194 ui.open_as(
195 id,
224196 Spec {
225 flags: Flags::FLOAT | Flags::CLICKABLE,
226197 axis: Axis::Y,
227 size: [px(size[0] - 2.0 * margin), px(size[1] - 2.0 * margin)],
228 position: [margin, margin],
198 size: [px(2.0 * column + GAP + 2.0 * PAD), px(height)],
229199 fill: Some(theme.popup),
200 border: Some(theme.chip),
230201 shadow: Some(theme.shadow),
231202 radius: 8.0,
232203 pad: [PAD, PAD],
233204 gap: 8.0,
205 anchor: Some(Anchor::Dock(crate::page())),
206 role: Some(accesskit::Role::Dialog),
234207 ..Spec::default()
235208 },
236209 );
210 if let Some(node) = ui.access(id) {
211 node.set_label(title);
212 }
237213 ui.open(
238214 "header",
239215 Spec {
......@@ -245,12 +221,14 @@ fn gallery(
245221 "title",
246222 Spec {
247223 size: [fill(), px(24.0)],
248 text: Some(if art { "Backgrounds" } else { "Page Templates" }),
224 text: Some(title),
225 bold: true,
226 role: Some(accesskit::Role::Heading),
249227 ..Spec::default()
250228 },
251229 );
252230 if ui::shell::tool_button(ui, "close", art::CLOSE, theme.text_dim, None).clicked {
253 chosen = Some(Choice::Dismiss);
231 ui.close_popup(id);
254232 }
255233 crate::name(ui, ui.id("close"), "Close");
256234 ui.close();
......@@ -264,21 +242,7 @@ fn gallery(
264242 ..Spec::default()
265243 },
266244 );
267 let column = TILE[0] + 40.0;
268 let columns =
269 (((size[0] - 2.0 * margin - 2.0 * PAD + GAP) / (column + GAP)).floor() as usize).max(1);
270 let choices: Vec<(Choice, &str)> = TEMPLATES
271 .iter()
272 .map(|template| (Choice::Template(template.name), template.name))
273 .chain(
274 PAGE_COLORS
275 .iter()
276 .enumerate()
277 .map(|(index, (name, _))| (Choice::Color(index), *name))
278 .filter(|_| !art),
279 )
280 .collect();
281 for (row, choices) in choices.chunks(columns).enumerate() {
245 for (row, templates) in TEMPLATES.chunks(2).enumerate() {
282246 ui.open(
283247 row,
284248 Spec {
......@@ -287,9 +251,20 @@ fn gallery(
287251 ..Spec::default()
288252 },
289253 );
290 for (index, (choice, label)) in choices.iter().enumerate() {
291 if tile(ui, theme, thumbnails, paper, dark, index, *choice, label) {
292 chosen = Some(*choice);
254 for (index, template) in templates.iter().enumerate() {
255 let choice = Choice::Template(template.name);
256 let tile = tile(
257 ui,
258 theme,
259 thumbnails,
260 paper,
261 index,
262 choice,
263 template.name,
264 column,
265 );
266 if ui.signal(tile).clicked {
267 chosen = Some(template.name);
293268 }
294269 }
295270 ui.close();
......@@ -299,43 +274,26 @@ fn gallery(
299274 chosen
300275}
301276
302/// A page colour as the paper shows it: on dark paper, its hue at the paper's lightness.
303fn page_color(color: u32, paper: Paper, dark: bool) -> [f32; 4] {
304 let color = canvas::gpu::colorref(color);
305 if !dark {
306 return color;
307 }
308 let [lightness, ..] = draw::oklab(paper.color);
309 let [_, a, b] = draw::oklab(color);
310 let [red, green, blue] = draw::from_oklab([lightness + 0.04, 2.0 * a, 2.0 * b]);
311 [red, green, blue, 1.0]
312}
313
314277/// How wide a tile labelled `label` stands: its thumbnail, or the label where it is wider.
315278fn tile_width(ui: &mut Ui, label: &str) -> f32 {
316279 TILE[0].max(ui.measure(label)[0] + 4.0)
317280}
318281
319/// One template's thumbnail over its label, as wide as the thumbnail or the label; true
320/// when clicked.
282/// One template's thumbnail over its label, `width` wide; returns the thumbnail, which takes
283/// the click.
321284#[allow(clippy::too_many_arguments)]
322285fn tile(
323286 ui: &mut Ui,
324287 theme: &Theme,
325288 thumbnails: &mut Thumbnails,
326289 paper: Paper,
327 dark: bool,
328290 part: usize,
329291 choice: Choice,
330292 label: &str,
331) -> bool {
293 width: f32,
294) -> Id {
332295 let scale = ui.scale();
333 let width = tile_width(ui, label);
334 let fill_color = match choice {
335 Choice::Color(index) => page_color(PAGE_COLORS[index].1, paper, dark),
336 Choice::More | Choice::Dismiss => theme.base,
337 Choice::Template(_) | Choice::Colors => paper.color,
338 };
296 let dark = paper.ink[0] > paper.color[0];
339297 ui.open(
340298 (part, label),
341299 Spec {
......@@ -350,7 +308,11 @@ fn tile(
350308 Spec {
351309 flags: Flags::CLICKABLE | Flags::CLIP,
352310 size: [px(TILE[0]), px(TILE[1])],
353 fill: Some(fill_color),
311 fill: Some(if choice == Choice::More {
312 theme.base
313 } else {
314 paper.color
315 }),
354316 border: Some(theme.chip),
355317 hover_border: Some(theme.accent),
356318 radius: 4.0,
......@@ -383,10 +345,11 @@ fn tile(
383345 }
384346 }
385347 }
386 // A band of each of the first page colours.
348 // A band of each of the first page colours, as the page shows them.
387349 Choice::Colors => {
388350 let bands = 4;
389 for (index, (_, color)) in PAGE_COLORS.iter().take(bands).enumerate() {
351 for (index, (_, color)) in canvas::template::PAGE_COLORS.iter().take(bands).enumerate()
352 {
390353 let band = TILE[0] / bands as f32;
391354 ui.leaf(
392355 ("band", index),
......@@ -394,13 +357,13 @@ fn tile(
394357 flags: Flags::FLOAT,
395358 size: [px(band), px(TILE[1])],
396359 position: [band * index as f32, 0.0],
397 fill: Some(page_color(*color, paper, dark)),
360 fill: Some(paper.colored(Some(*color)).color),
398361 ..Spec::default()
399362 },
400363 );
401364 }
402365 }
403 _ => {}
366 Choice::More | Choice::Dismiss => {}
404367 }
405368 ui.close();
406369 ui.leaf(
......@@ -414,121 +377,99 @@ fn tile(
414377 },
415378 );
416379 ui.close();
417 ui.signal(id).clicked
380 id
418381}
419382
420383impl crate::State {
421 /// Over a page with nothing typed in its body: the strip where the body would start,
422 /// or the gallery it opened.
384 /// The template galleries while open, and over a page with nothing typed in its body the
385 /// strip where the body would start.
423386 pub(crate) fn template_strip(&mut self, theme: &Theme) {
424387 let Some(session) = &self.session else {
425388 return;
426389 };
427 if self.templates == View::Art {
428 return self.art_gallery(theme);
429 }
430390 let space = session.space;
391 let read_only = session.read_only();
392 let Some(rect) = self.ui.laid_out(crate::page()) else {
393 return;
394 };
395 // Thumbnails show templates on plain paper, not the page's colour.
396 let paper = Paper {
397 color: self.ui.theme.paper,
398 ink: self.ui.theme.paper_ink,
399 };
400 let height = rect[3] - rect[1] - 8.0;
401 if let Some(name) = gallery(
402 &mut self.ui,
403 theme,
404 &mut self.thumbnails,
405 paper,
406 backgrounds(),
407 "Backgrounds",
408 height,
409 ) {
410 self.choose(crate::commands::Choice::Art(Some(name)));
411 }
431412 let editor = &self.view.editor;
432413 let blank = editor
433414 .visible_outlines()
434415 .chain(editor.caret_outline())
435416 .filter(|outline| !outline.title)
436417 .all(canvas::editor::TextOutline::is_empty);
437 if !blank || session.read_only() || self.dismissed.contains(&space) {
438 self.templates = View::Strip;
418 if !blank || read_only || self.dismissed.contains(&space) {
439419 return;
440420 }
441 let (Some(rect), Some(start)) = (self.ui.laid_out(crate::page()), editor.body_start())
442 else {
421 let Some(start) = editor.body_start() else {
443422 return;
444423 };
424 let (color, rule_lines) = (editor.page_color(), editor.rule_lines());
445425 let viewport = self.view.viewport;
446426 let scale = self.ui.scale();
447427 let position = [
448428 (viewport.origin[0] + start[0] * viewport.scale) / scale,
449429 (viewport.origin[1] + (start[1] + LINES_ABOVE) * viewport.scale) / scale,
450430 ];
451 // Thumbnails show templates on plain paper, not the page's colour.
452 let paper = Paper {
453 color: self.ui.theme.paper,
454 ink: self.ui.theme.paper_ink,
455 };
456 let dark = paper.ink[0] > paper.color[0];
457 let chosen = match self.templates {
458 View::Gallery => {
459 let size = [rect[2] - rect[0], rect[3] - rect[1]];
460 gallery(
461 &mut self.ui,
462 theme,
463 &mut self.thumbnails,
464 paper,
465 dark,
466 size,
467 false,
468 )
469 }
470 View::Art => unreachable!("The art gallery is built alone"),
471 view => strip(
472 &mut self.ui,
473 theme,
474 &mut self.thumbnails,
475 paper,
476 dark,
477 position,
478 rect[2] - rect[0] - position[0] - 24.0,
479 view == View::Colors,
480 ),
481 };
431 let (chosen, solid) = strip(
432 &mut self.ui,
433 theme,
434 &mut self.thumbnails,
435 paper,
436 position,
437 rect[2] - rect[0] - position[0] - 24.0,
438 );
482439 match chosen {
483 Some(Choice::More) => self.templates = View::Gallery,
484 Some(Choice::Colors) => {
485 self.templates = if self.templates == View::Colors {
486 View::Strip
487 } else {
488 View::Colors
489 }
490 }
491 Some(Choice::Dismiss) if self.templates == View::Gallery => {
492 self.templates = View::Strip
493 }
440 Some(Choice::Template(name)) => self.commands.push(crate::Command::Template(name)),
441 Some(Choice::Colors) => self.ui.open_popup(colors()),
442 Some(Choice::More) => self.ui.open_popup(templates()),
494443 Some(Choice::Dismiss) => {
495444 self.dismissed.insert(space);
496445 }
497 Some(choice) => {
498 self.templates = View::Strip;
499 self.commands.push(crate::Command::Template(choice));
500 }
501446 None => {}
502447 }
503 }
504
505 /// The Page Color menu's Show All: every template's art, one of which goes behind the
506 /// open page.
507 fn art_gallery(&mut self, theme: &Theme) {
508 let Some(rect) = self.ui.laid_out(crate::page()) else {
509 return;
510 };
511 let paper = Paper {
512 color: self.ui.theme.paper,
513 ink: self.ui.theme.paper_ink,
514 };
515 let dark = paper.ink[0] > paper.color[0];
516 let size = [rect[2] - rect[0], rect[3] - rect[1]];
517 match gallery(
448 if let Some(name) = gallery(
518449 &mut self.ui,
519450 theme,
520451 &mut self.thumbnails,
521452 paper,
522 dark,
523 size,
524 true,
453 templates(),
454 "Page Templates",
455 height,
525456 ) {
526 Some(Choice::Template(name)) => {
527 self.templates = View::Strip;
528 self.choose(crate::commands::Choice::Art(Some(name)));
529 }
530 Some(Choice::Dismiss) => self.templates = View::Strip,
531 _ => {}
457 self.commands.push(crate::Command::Template(name));
458 }
459 if let Some(choice) = solid.and_then(|tile| {
460 crate::background::menu(
461 &mut self.ui,
462 colors(),
463 Anchor::Below(tile),
464 &mut self.thumbnails,
465 paper,
466 color,
467 rule_lines,
468 true,
469 &mut self.page_color_preview,
470 )
471 }) {
472 self.choose(choice);
532473 }
533474 }
534475}
crates/ui/src/layout.rs+6-1
......@@ -62,7 +62,11 @@ fn resolve(nodes: &mut [Built], popup: &[usize], index: usize, states: &HashMap<
6262 let around = match anchor {
6363 Anchor::Point([x, y]) => [x, y, x, y],
6464 Anchor::Dialog | Anchor::Top => [0.0; 4],
65 Anchor::Below(id) | Anchor::Tip(id) | Anchor::Right(id) | Anchor::Over(id) => {
65 Anchor::Below(id)
66 | Anchor::Tip(id)
67 | Anchor::Right(id)
68 | Anchor::Over(id)
69 | Anchor::Dock(id) => {
6670 let (rect, holder) = laid_out(id).unwrap_or_default();
6771 let [left, top, right, bottom] = rect;
6872 match anchor {
......@@ -75,6 +79,7 @@ fn resolve(nodes: &mut [Built], popup: &[usize], index: usize, states: &HashMap<
7579 [left, top - pad_y, right, bottom + pad_y]
7680 }
7781 Anchor::Over(_) => [left - pad_x, top - pad_y, right + pad_x, bottom + pad_y],
82 Anchor::Dock(_) => rect,
7883 _ => [left, top - PAD, right, bottom + PAD],
7984 }
8085 }
crates/ui/src/lib.rs+12-3
......@@ -240,6 +240,9 @@ pub enum Anchor {
240240 /// Where a dialog opens, swinging in as one does but leaving the interface undimmed, as a
241241 /// command palette opens.
242242 Top,
243 /// Inside the box along its trailing edge, a dialog leaving the rest of the box in view and
244 /// the interface undimmed, as a side panel docks.
245 Dock(Id),
243246}
244247
245248impl Anchor {
......@@ -262,6 +265,10 @@ impl Anchor {
262265 return ((room - size) / 2.0).max(POPUP_MARGIN);
263266 }
264267 Anchor::Dialog | Anchor::Top => return (room / 8.0).clamp(POPUP_MARGIN, most),
268 Anchor::Dock(_) if axis == 0 => {
269 return (around[2] - size - POPUP_MARGIN).clamp(POPUP_MARGIN, most);
270 }
271 Anchor::Dock(_) => return (around[1] + POPUP_MARGIN).clamp(POPUP_MARGIN, most),
265272 };
266273 let [low, high] = [around[axis], around[axis + 2]];
267274 let (first, second) = if along == Some(axis) {
......@@ -280,7 +287,7 @@ impl Anchor {
280287
281288 /// Seconds the popup takes to open and to close.
282289 fn durations(self) -> [f32; 2] {
283 if matches!(self, Anchor::Dialog | Anchor::Top) {
290 if matches!(self, Anchor::Dialog | Anchor::Top | Anchor::Dock(_)) {
284291 DIALOG
285292 } else {
286293 POPUP
......@@ -603,10 +610,12 @@ impl Placed {
603610 Anchor::Right(_) => [around[2], around[1]],
604611 Anchor::Over(_) => [around[0], around[1]],
605612 Anchor::Dialog | Anchor::Top => [(rect[0] + rect[2]) / 2.0, rect[1]],
613 Anchor::Dock(_) => [rect[2], (rect[1] + rect[3]) / 2.0],
606614 };
607615 let (from, tilt) = match anchor {
608616 _ if !grows => (1.0, 0.0),
609617 Anchor::Dialog | Anchor::Top => (0.95, 0.2),
618 Anchor::Dock(_) => (0.95, 0.0),
610619 // Over a box, the popup widens out of it in layout instead of growing.
611620 Anchor::Over(_) => (1.0, 0.2),
612621 _ => (0.94, 0.2),
......@@ -1573,7 +1582,7 @@ impl Ui {
15731582 /// How popups beside `anchor` open and close; dialogs and the palette always swing as
15741583 /// Snowbound's own.
15751584 fn popup_motion(&self, anchor: Anchor) -> PopupMotion {
1576 if matches!(anchor, Anchor::Dialog | Anchor::Top) {
1585 if matches!(anchor, Anchor::Dialog | Anchor::Top | Anchor::Dock(_)) {
15771586 PopupMotion::Grow
15781587 } else {
15791588 self.theme.menu().motion
......@@ -1650,7 +1659,7 @@ impl Ui {
16501659 if let Some(color) = node.shadow {
16511660 let (own, menu);
16521661 let shadows: &[Shadow] = match node.anchor {
1653 Some(Anchor::Dialog) | None => {
1662 Some(Anchor::Dialog | Anchor::Dock(_)) | None => {
16541663 let [blur, drop] = if node.anchor.is_some() {
16551664 POPUP_SHADOW
16561665 } else {
crates/ui/src/popup.rs+24-1
......@@ -890,7 +890,8 @@ fn highlighted(ui: &mut Ui, id: Id, cell: Option<Id>) {
890890}
891891
892892/// Routes the pointer and keys over open popup `id`'s `count` cells, keeping its highlight,
893/// which `step` moves for each arrow. Returns the highlight and the cell a click or Enter chose.
893/// which `step` moves for each arrow and the pointer leaving the popup clears. Returns the
894/// highlight and the cell a click or Enter chose.
894895fn pick_cell(
895896 ui: &mut Ui,
896897 id: Id,
......@@ -910,6 +911,15 @@ fn pick_cell(
910911 ],
911912 );
912913 let mut highlight = state(ui, id).highlight.map(|cell| cell as usize);
914 let outside =
915 ui.pointer()
916 .zip(ui.laid_out(id))
917 .is_some_and(|([x, y], [left, top, right, bottom])| {
918 x < left || x >= right || y < top || y >= bottom
919 });
920 if outside && ui.moved {
921 highlight = None;
922 }
913923 let mut chosen = None;
914924 for index in 0..count {
915925 let signal = ui.signal(cell(index));
......@@ -930,6 +940,13 @@ fn pick_cell(
930940 (highlight, chosen)
931941}
932942
943/// The cell open popup `id`, a grid or gallery, highlights: under the pointer or reached by
944/// the arrows.
945pub fn highlight(ui: &Ui, id: Id) -> Option<usize> {
946 let popup = ui.popups.iter().find(|popup| popup.id == id)?;
947 popup.highlight.map(|cell| cell as usize)
948}
949
933950/// Builds popup `id` as a grid of `size` columns and rows beside `anchor` while it is open,
934951/// lit from its corner to the cell pointed at, as Office's table picker is. Returns the
935952/// columns and rows chosen.
......@@ -1369,6 +1386,12 @@ pub fn color_picker(
13691386 None
13701387}
13711388
1389/// The colour open colour picker `id` shows, sRGB, before it is applied.
1390pub fn picking(ui: &Ui, id: Id) -> Option<[u8; 3]> {
1391 let popup = ui.popups.iter().find(|popup| popup.id == id)?;
1392 popup.picked.map(from_hsl)
1393}
1394
13721395/// Hue in degrees, saturation and lightness of an sRGB colour.
13731396fn to_hsl(rgb: [u8; 3]) -> [f32; 3] {
13741397 let [red, green, blue] = rgb.map(|channel| f32::from(channel) / 255.0);
crates/ui/src/tests.rs+54
......@@ -1840,6 +1840,60 @@ fn colour_grids_move_in_two_dimensions_and_choose_a_swatch_or_none() {
18401840 assert_eq!(build(&mut ui), Some(None));
18411841}
18421842
1843#[test]
1844fn colour_grids_highlight_the_swatch_pointed_at_until_the_pointer_leaves() {
1845 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
1846 let grid = Id::ROOT.child("colours");
1847 let swatches = [([1.0, 0.0, 0.0, 1.0], ""), ([0.0, 1.0, 0.0, 1.0], "")];
1848 let build = |ui: &mut Ui| {
1849 frame(ui, |ui| {
1850 let anchor = below(ui);
1851 popup::colors(ui, grid, anchor, "Automatic", &swatches, 2);
1852 });
1853 };
1854 build(&mut ui);
1855 ui.open_popup(grid);
1856 for _ in 0..40 {
1857 build(&mut ui);
1858 }
1859 let rect = ui.laid_out(grid.child(("cell", 2_usize))).unwrap();
1860 ui.event(Event::PointerMoved([rect[0] + 4.0, rect[1] + 4.0]));
1861 build(&mut ui);
1862 assert_eq!(popup::highlight(&ui, grid), Some(2));
1863 ui.event(Event::PointerMoved([399.0, 299.0]));
1864 build(&mut ui);
1865 assert_eq!(popup::highlight(&ui, grid), None);
1866}
1867
1868#[test]
1869fn docked_popups_lie_inside_their_box_along_its_trailing_edge() {
1870 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
1871 let dock = Id::ROOT.child("dock");
1872 let build = |ui: &mut Ui| {
1873 frame(ui, |ui| {
1874 let page = spot(ui, [20.0, 40.0, 380.0, 290.0]);
1875 if ui.popup_open(dock) {
1876 ui.open_as(
1877 dock,
1878 Spec {
1879 size: [px(100.0), px(200.0)],
1880 anchor: Some(Anchor::Dock(page)),
1881 ..Spec::default()
1882 },
1883 );
1884 ui.close();
1885 }
1886 });
1887 };
1888 build(&mut ui);
1889 ui.open_popup(dock);
1890 for _ in 0..40 {
1891 build(&mut ui);
1892 }
1893 let [left, top, right, bottom] = ui.laid_out(dock).unwrap();
1894 assert_eq!([left, top, right, bottom], [276.0, 44.0, 376.0, 244.0]);
1895}
1896
18431897#[test]
18441898fn table_pickers_choose_columns_and_rows_by_keys_or_a_click() {
18451899 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);