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,...@@ -27,7 +27,8 @@ it is, and it settles fast. Popups open and close on short timed curves,
27slow enough to follow: a menu swings out of the pointer or its button as it27slow enough to follow: a menu swings out of the pointer or its button as it
28fades in, a combo's field widens into its list, and a dialog swings up into28fades in, a combo's field widens into its list, and a dialog swings up into
29place near the window's top over a dimmed window, as Windows opens a window; the29place 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 new30command 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
31results at once. On GNOME and KDE, menus instead32results at once. On GNOME and KDE, menus instead
32look and move as the desktop's own (see [platforms](platforms.md)). A segmented control33look and move as the desktop's own (see [platforms](platforms.md)). A segmented control
33slides a raised face along a track, as macOS, libadwaita and Windows 11 draw theirs, and on34slides 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 @@...@@ -4,7 +4,7 @@
4use crate::{4use crate::{
5 art,5 art,
6 commands::Choice,6 commands::Choice,
7 templates::{TILE, Thumbnails, View},7 templates::{TILE, Thumbnails},
8};8};
9use canvas::{9use canvas::{
10 gpu::{Paper, colorref},10 gpu::{Paper, colorref},
...@@ -42,7 +42,8 @@ enum Cell {...@@ -42,7 +42,8 @@ enum Cell {
4242
43/// Builds popup `id` beside `anchor` while it is open, for a page on `paper` (its own colour43/// Builds popup `id` beside `anchor` while it is open, for a page on `paper` (its own colour
44/// aside) coloured `color` and ruled with `rule_lines`, and the custom colour picker it44/// 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 the45/// 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
46/// choice made.47/// choice made.
47#[allow(clippy::too_many_arguments)]48#[allow(clippy::too_many_arguments)]
48pub fn menu(49pub fn menu(
...@@ -50,10 +51,11 @@ pub fn menu(...@@ -50,10 +51,11 @@ pub fn menu(
50 id: Id,51 id: Id,
51 anchor: Anchor,52 anchor: Anchor,
52 thumbnails: &mut Thumbnails,53 thumbnails: &mut Thumbnails,
53 view: &mut View,
54 paper: Paper,54 paper: Paper,
55 color: Option<u32>,55 color: Option<u32>,
56 rule_lines: Option<RuleLines>,56 rule_lines: Option<RuleLines>,
57 colors_only: bool,
58 preview: &mut Option<Option<u32>>,
57) -> Option<Choice> {59) -> Option<Choice> {
58 let picker = id.child("custom");60 let picker = id.child("custom");
59 // A pale blue to start from on a page without a colour.61 // A pale blue to start from on a page without a colour.
...@@ -74,6 +76,9 @@ pub fn menu(...@@ -74,6 +76,9 @@ pub fn menu(
74 red, green, blue, 0,76 red, green, blue, 0,
75 ]))));77 ]))));
76 }78 }
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 }
77 let custom = color.filter(|color| !PAGE_COLORS.iter().any(|(_, listed)| listed == color));82 let custom = color.filter(|color| !PAGE_COLORS.iter().any(|(_, listed)| listed == color));
78 let cells: Vec<Cell> = std::iter::once(Cell::Color(None))83 let cells: Vec<Cell> = std::iter::once(Cell::Color(None))
79 .chain(84 .chain(
...@@ -148,7 +153,8 @@ pub fn menu(...@@ -148,7 +153,8 @@ pub fn menu(
148 },153 },
149 );154 );
150 };155 };
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| {
152 let name = match cells[index] {158 let name = match cells[index] {
153 Cell::Color(Some(color)) => PAGE_COLORS159 Cell::Color(Some(color)) => PAGE_COLORS
154 .iter()160 .iter()
...@@ -236,7 +242,11 @@ pub fn menu(...@@ -236,7 +242,11 @@ pub fn menu(
236 }242 }
237 Cell::Rules(Some(index)) => rule_thumbnail(ui, paper, RULE_LINES[index]),243 Cell::Rules(Some(index)) => rule_thumbnail(ui, paper, RULE_LINES[index]),
238 }244 }
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?;
240 match cells[chosen] {250 match cells[chosen] {
241 Cell::Color(color) => Some(Choice::PageColor(color)),251 Cell::Color(color) => Some(Choice::PageColor(color)),
242 Cell::Custom => {252 Cell::Custom => {
...@@ -245,7 +255,7 @@ pub fn menu(...@@ -245,7 +255,7 @@ pub fn menu(
245 }255 }
246 Cell::Art(name) => Some(Choice::Art(name)),256 Cell::Art(name) => Some(Choice::Art(name)),
247 Cell::ShowAll => {257 Cell::ShowAll => {
248 *view = View::Art;258 ui.open_popup(crate::templates::backgrounds());
249 None259 None
250 }260 }
251 Cell::Rules(index) => Some(Choice::RuleLines(index)),261 Cell::Rules(index) => Some(Choice::RuleLines(index)),
crates/snowbound/src/main.rs+19-9
...@@ -718,8 +718,8 @@ enum Command {...@@ -718,8 +718,8 @@ enum Command {
718 space: ExGuid,718 space: ExGuid,
719 path: String,719 path: String,
720 },720 },
721 /// Gives the open page a template's background or colour.721 /// Gives the open page a template's background.
722 Template(templates::Choice),722 Template(&'static str),
723 OpenPage(ExGuid),723 OpenPage(ExGuid),
724 /// Shows a page's conflict pages in the list and opens the newest, or hides them.724 /// Shows a page's conflict pages in the list and opens the newest, or hides them.
725 Versions {725 Versions {
...@@ -902,8 +902,8 @@ struct State {...@@ -902,8 +902,8 @@ struct State {
902 dismissed: HashSet<ExGuid>,902 dismissed: HashSet<ExGuid>,
903 /// A page just created, whose title takes the caret once it opens.903 /// A page just created, whose title takes the caret once it opens.
904 title_focus: Option<ExGuid>,904 title_focus: Option<ExGuid>,
905 /// What the template strip shows over a blank page.905 /// The page colour a menu previews over the open page's own this frame.
906 templates: templates::View,906 page_color_preview: Option<Option<u32>>,
907 media: recording::Media,907 media: recording::Media,
908 /// Whether playback highlights the notes linked to the moment playing.908 /// Whether playback highlights the notes linked to the moment playing.
909 see_playback: bool,909 see_playback: bool,
...@@ -1349,7 +1349,7 @@ impl State {...@@ -1349,7 +1349,7 @@ impl State {
1349 .map(|id| notebook::sidecar::themes::successor(&id).to_owned()),1349 .map(|id| notebook::sidecar::themes::successor(&id).to_owned()),
1350 live_options: stored.live,1350 live_options: stored.live,
1351 drag: None,1351 drag: None,
1352 templates: templates::View::Strip,1352 page_color_preview: None,
1353 media: Default::default(),1353 media: Default::default(),
1354 see_playback: true,1354 see_playback: true,
1355 thumbnails: templates::Thumbnails::default(),1355 thumbnails: templates::Thumbnails::default(),
...@@ -1560,7 +1560,12 @@ impl State {...@@ -1560,7 +1560,12 @@ impl State {
1560 fn layout(&mut self, size: [f32; 2], scale: f32) -> Result<(), Box<dyn Error>> {1560 fn layout(&mut self, size: [f32; 2], scale: f32) -> Result<(), Box<dyn Error>> {
1561 self.ui.begin(size, scale, Instant::now());1561 self.ui.begin(size, scale, Instant::now());
1562 platform::cover_border_line(&mut self.ui, size[0]);1562 platform::cover_border_line(&mut self.ui, size[0]);
1563 let previewed = self.page_color_preview;
1563 let (section, open_tab, open_page) = self.build()?;1564 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 }
1564 self.options_dialog();1569 self.options_dialog();
1565 self.themes_dialog();1570 self.themes_dialog();
1566 self.print_dialog();1571 self.print_dialog();
...@@ -1677,6 +1682,7 @@ impl State {...@@ -1677,6 +1682,7 @@ impl State {
1677 // The page, and the open page's tab joined to it, take the page's colour.1682 // The page, and the open page's tab joined to it, take the page's colour.
1678 let mut theme = self.ui.theme.clone();1683 let mut theme = self.ui.theme.clone();
1679 theme.paper = self.paper().color;1684 theme.paper = self.paper().color;
1685 self.page_color_preview = None;
1680 platform::update_menu(|| self.statuses());1686 platform::update_menu(|| self.statuses());
1681 let welcome = self.session.is_none()1687 let welcome = self.session.is_none()
1682 && !self.temporary1688 && !self.temporary
...@@ -3049,7 +3055,6 @@ impl State {...@@ -3049,7 +3055,6 @@ impl State {
3049 menu,3055 menu,
3050 anchor,3056 anchor,
3051 &mut self.thumbnails,3057 &mut self.thumbnails,
3052 &mut self.templates,
3053 // The page's own colour aside.3058 // The page's own colour aside.
3054 canvas::gpu::Paper {3059 canvas::gpu::Paper {
3055 color: ui.theme.paper,3060 color: ui.theme.paper,
...@@ -3057,6 +3062,8 @@ impl State {...@@ -3057,6 +3062,8 @@ impl State {
3057 },3062 },
3058 self.view.editor.page_color(),3063 self.view.editor.page_color(),
3059 self.view.editor.rule_lines(),3064 self.view.editor.rule_lines(),
3065 false,
3066 &mut self.page_color_preview,
3060 )3067 )
3061 },3068 },
3062 |_| None,3069 |_| None,
...@@ -3624,7 +3631,7 @@ impl State {...@@ -3624,7 +3631,7 @@ impl State {
3624 self.move_page(space, path)?;3631 self.move_page(space, path)?;
3625 self.edited(Vec::new());3632 self.edited(Vec::new());
3626 }3633 }
3627 Command::Template(choice) => self.apply_template(choice)?,3634 Command::Template(name) => self.apply_template(name)?,
3628 Command::Page(Request::EditDate(field)) => self.edit_date(field),3635 Command::Page(Request::EditDate(field)) => self.edit_date(field),
3629 Command::Page(Request::Copy(clip)) => self.clipboard.set(clip)?,3636 Command::Page(Request::Copy(clip)) => self.clipboard.set(clip)?,
3630 Command::Page(Request::Paste) => self.paste()?,3637 Command::Page(Request::Paste) => self.paste()?,
...@@ -4557,13 +4564,16 @@ impl State {...@@ -4557,13 +4564,16 @@ impl State {
4557 self.window.request_redraw();4564 self.window.request_redraw();
4558 }4565 }
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.
4561 fn paper(&self) -> canvas::gpu::Paper {4568 fn paper(&self) -> canvas::gpu::Paper {
4562 canvas::gpu::Paper {4569 canvas::gpu::Paper {
4563 color: self.ui.theme.paper,4570 color: self.ui.theme.paper,
4564 ink: self.ui.theme.paper_ink,4571 ink: self.ui.theme.paper_ink,
4565 }4572 }
4566 .colored(self.view.editor.page_color())4573 .colored(
4574 self.page_color_preview
4575 .unwrap_or_else(|| self.view.editor.page_color()),
4576 )
4567 }4577 }
45684578
4569 /// The colours of what the page area shows in place of a page, which follow the page's:4579 /// 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 {...@@ -180,23 +180,13 @@ impl State {
180 Ok(())180 Ok(())
181 }181 }
182182
183 /// Gives the open page `choice`'s background: a template's art in place of any it183 /// Gives the open page template `name`'s art in place of any it had.
184 /// had, or a page colour instead of art.184 pub(crate) fn apply_template(&mut self, name: &'static str) -> Result<(), Box<dyn Error>> {
185 pub(crate) fn apply_template(185 let template = canvas::template::find(name).ok_or("That template is not available")?;
186 &mut self,186 self.with_art(Some(template), move |state, art| {
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| {
197 state.persist()?;187 state.persist()?;
198 let session = state.session.as_ref().ok_or("No section is open")?;188 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)?;
200 state.edit_page(ops)190 state.edit_page(ops)
201 })191 })
202 }192 }
...@@ -561,7 +551,6 @@ impl State {...@@ -561,7 +551,6 @@ impl State {
561 self.persist().unwrap_or_else(|error| eprintln!("{error}"));551 self.persist().unwrap_or_else(|error| eprintln!("{error}"));
562 self.session = None;552 self.session = None;
563 self.sectionless = None;553 self.sectionless = None;
564 self.templates = crate::templates::View::Strip;
565 match self554 match self
566 .notebooks555 .notebooks
567 .iter()556 .iter()
...@@ -966,35 +955,23 @@ impl State {...@@ -966,35 +955,23 @@ impl State {
966 }955 }
967}956}
968957
969/// The ops giving `page` `choice`'s background: its template art or a page colour, in958/// The ops giving `page` template `name`'s art, `art`, in place of the background it had,
970/// place of the background it had.959/// and no page colour.
971pub fn template_ops(960pub fn template_ops(
972 page: &Page,961 page: &Page,
973 choice: crate::templates::Choice,962 name: &str,
974 art: Vec<Image>,963 art: Vec<Image>,
975) -> Result<Vec<PageOp>, Box<dyn Error>> {964) -> Result<Vec<PageOp>, Box<dyn Error>> {
976 use crate::templates::Choice;965 let mut ops = art_ops(page, art);
977 let mut ops = match choice {966 if page.color.is_some() {
978 Choice::Template(_) | Choice::Color(_) => art_ops(page, art),967 ops.push(PageOp::Color(None));
979 Choice::More | Choice::Dismiss | Choice::Colors => return Ok(Vec::new()),968 }
980 };969 // The one template with content worth keeping.
981 match choice {970 if name == "Informal Meeting Notes" {
982 Choice::Template(name) => {971 ops.extend(onestore::op::lower_page(
983 if page.color.is_some() {972 page,
984 ops.push(PageOp::Color(None));973 &crate::meeting::content(page)?,
985 }974 )?);
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 => {}
998 }975 }
999 Ok(ops)976 Ok(ops)
1000}977}
...@@ -1031,7 +1008,6 @@ fn art_ops(page: &Page, art: Vec<Image>) -> Vec<PageOp> {...@@ -1031,7 +1008,6 @@ fn art_ops(page: &Page, art: Vec<Image>) -> Vec<PageOp> {
1031#[cfg(test)]1008#[cfg(test)]
1032mod tests {1009mod tests {
1033 use super::*;1010 use super::*;
1034 use crate::templates::Choice;
1035 use std::path::Path;1011 use std::path::Path;
10361012
1037 const AUTHOR: &str = "Rust Author";1013 const AUTHOR: &str = "Rust Author";
...@@ -1153,27 +1129,27 @@ mod tests {...@@ -1153,27 +1129,27 @@ mod tests {
1153 let [ivy, teal, subpage, deleted, meeting] = spaces[..] else {1129 let [ivy, teal, subpage, deleted, meeting] = spaces[..] else {
1154 unreachable!()1130 unreachable!()
1155 };1131 };
1156 let background = |space: ExGuid, choice: Choice| {1132 let background = |space: ExGuid, name: &str| {
1157 let art = match choice {1133 let art = canvas::template::find(name).unwrap().pictures().unwrap();
1158 Choice::Template(name) => canvas::template::find(name).unwrap().pictures().unwrap(),1134 template_ops(&page(&file, space), name, art)
1159 _ => Vec::new(),
1160 };
1161 template_ops(&page(&file, space), choice, art)
1162 .unwrap()1135 .unwrap()
1163 .into_iter()1136 .into_iter()
1164 .map(|op| Op::Page { space, op })1137 .map(|op| Op::Page { space, op })
1165 .collect::<Vec<_>>()1138 .collect::<Vec<_>>()
1166 };1139 };
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();
1168 edit(1146 edit(
1169 &file,1147 &file,
1170 background(meeting, Choice::Template("Informal Meeting Notes")),1148 vec![Op::Page {
1149 space: teal,
1150 op: PageOp::Color(Some(*teal_color)),
1151 }],
1171 );1152 );
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)));
1177 edit(1153 edit(
1178 &file,1154 &file,
1179 vec![Op::Section(SectionOp::Pages(vec![1155 vec![Op::Section(SectionOp::Pages(vec![
...@@ -1275,10 +1251,7 @@ mod tests {...@@ -1275,10 +1251,7 @@ mod tests {
1275 ]1251 ]
1276 );1252 );
1277 let shown = page(&file, teal);1253 let shown = page(&file, teal);
1278 assert_eq!(1254 assert_eq!(shown.color, Some(*teal_color));
1279 shown.color,
1280 Some(canvas::template::PAGE_COLORS[teal_index].1)
1281 );
1282 assert!(shown.date_text().is_some());1255 assert!(shown.date_text().is_some());
1283 assert!(1256 assert!(
1284 page(&file, ivy)1257 page(&file, ivy)
crates/snowbound/src/print.rs+9-6
...@@ -530,12 +530,15 @@ mod tests {...@@ -530,12 +530,15 @@ mod tests {
530 };530 };
531 let create = |creation| Op::Section(SectionOp::Create(creation));531 let create = |creation| Op::Section(SectionOp::Create(creation));
532 (section.apply("Author", edit(vec![create(dated), create(written)]))).unwrap();532 (section.apply("Author", edit(vec![create(dated), create(written)]))).unwrap();
533 let meeting = crate::templates::Choice::Template("Informal Meeting Notes");533 let ops = crate::manage::template_ops(
534 let ops = crate::manage::template_ops(&section.page(space).unwrap(), meeting, Vec::new())534 &section.page(space).unwrap(),
535 .unwrap()535 "Informal Meeting Notes",
536 .into_iter()536 Vec::new(),
537 .map(|op| Op::Page { space, op })537 )
538 .collect();538 .unwrap()
539 .into_iter()
540 .map(|op| Op::Page { space, op })
541 .collect();
539 section.apply("Author", edit(ops)).unwrap();542 section.apply("Author", edit(ops)).unwrap();
540 // The template titles the page; its title goes, its text stays.543 // The template titles the page; its title goes, its text stays.
541 let untitled = crate::rename::retitled(&section.page(space).unwrap(), space, String::new());544 let untitled = crate::rename::retitled(&section.page(space).unwrap(), space, String::new());
crates/snowbound/src/templates.rs+143-202
...@@ -1,19 +1,18 @@...@@ -1,19 +1,18 @@
1//! The template strip a new page shows where its body would start until the body is typed1//! 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
4use crate::art;4use crate::art;
5use canvas::{5use canvas::{
6 gpu::Paper,6 gpu::Paper,
7 template::{PAGE_COLORS, TEMPLATES, Template},7 template::{TEMPLATES, Template},
8};8};
9use draw::RasterImage;9use draw::RasterImage;
10use std::collections::HashMap;10use std::collections::HashMap;
11use ui::{Axis, Flags, Spec, Theme, Ui, fill, px};11use ui::{Anchor, Axis, Flags, Id, Spec, Theme, Ui, fill, px};
1212
13/// What a thumbnail shows: the top of a letter page, in points from the page origin.13/// What a thumbnail shows: the top of a letter page, in points from the page origin.
14const REGION: [f32; 4] = [-72.0, -40.0, 540.0, 419.0];14const REGION: [f32; 4] = [-72.0, -40.0, 540.0, 419.0];
15pub(crate) const TILE: [f32; 2] = [112.0, 84.0];15pub(crate) const TILE: [f32; 2] = [112.0, 84.0];
16const SWATCH: f32 = 24.0;
17const LABEL: f32 = 22.0;16const LABEL: f32 = 22.0;
18const GAP: f32 = 12.0;17const GAP: f32 = 12.0;
19const PAD: f32 = 10.0;18const PAD: f32 = 10.0;
...@@ -30,26 +29,28 @@ const STRIP: [(Choice, &str); 5] = [...@@ -30,26 +29,28 @@ const STRIP: [(Choice, &str); 5] = [
30];29];
3130
32#[derive(Clone, Copy, Debug, PartialEq)]31#[derive(Clone, Copy, Debug, PartialEq)]
33pub enum Choice {32enum Choice {
34 Template(&'static str),33 Template(&'static str),
35 /// An index into `PAGE_COLORS`.34 /// Opens the Page Color menu over its tile.
36 Color(usize),
37 /// Shows or hides the page colours.
38 Colors,35 Colors,
39 More,36 More,
40 Dismiss,37 Dismiss,
41}38}
4239
43/// What the template strip shows.40/// The gallery of every template's art, from the Page Color menu's Show All, to lie behind
44#[derive(Clone, Copy, Debug, Default, PartialEq)]41/// the open page.
45pub enum View {42pub fn backgrounds() -> Id {
46 #[default]43 Id::ROOT.child("backgrounds")
47 Strip,44}
48 /// The strip with the page colours beneath it.45
49 Colors,46/// The gallery of every template, from the strip's More templates.
50 Gallery,47fn templates() -> Id {
51 /// Every template's art, from the Page Color menu, to lie behind the open page.48 Id::ROOT.child("templates")
52 Art,49}
50
51/// The page colours the strip's Solid Color opens.
52fn colors() -> Id {
53 Id::ROOT.child("strip colors")
53}54}
5455
55/// A thumbnail's art: each raster and where it lies in the tile.56/// A thumbnail's art: each raster and where it lies in the tile.
...@@ -87,20 +88,17 @@ impl Thumbnails {...@@ -87,20 +88,17 @@ impl Thumbnails {
87 }88 }
88}89}
8990
90/// The strip at `position` in the page box, `room` wide at most, with the page colours91/// The strip at `position` in the page box, `room` wide at most. Picks that do not fit give
91/// beneath while `colors`. Picks that do not fit give way from the end; More templates92/// way from the end; More templates stays. Returns the pick and the Solid Color tile, where
92/// stays.93/// shown.
93#[allow(clippy::too_many_arguments)]
94fn strip(94fn strip(
95 ui: &mut Ui,95 ui: &mut Ui,
96 theme: &Theme,96 theme: &Theme,
97 thumbnails: &mut Thumbnails,97 thumbnails: &mut Thumbnails,
98 paper: Paper,98 paper: Paper,
99 dark: bool,
100 position: [f32; 2],99 position: [f32; 2],
101 room: f32,100 room: f32,
102 colors: bool,101) -> (Option<Choice>, Option<Id>) {
103) -> Option<Choice> {
104 let more = (Choice::More, "More templates");102 let more = (Choice::More, "More templates");
105 let mut used = 2.0 * PAD + tile_width(ui, more.1);103 let mut used = 2.0 * PAD + tile_width(ui, more.1);
106 let mut shown = Vec::new();104 let mut shown = Vec::new();
...@@ -117,6 +115,7 @@ fn strip(...@@ -117,6 +115,7 @@ fn strip(
117 .sum::<f32>()115 .sum::<f32>()
118 - GAP;116 - GAP;
119 let mut chosen = None;117 let mut chosen = None;
118 let mut solid = None;
120 ui.open(119 ui.open(
121 "templates",120 "templates",
122 Spec {121 Spec {
...@@ -128,7 +127,6 @@ fn strip(...@@ -128,7 +127,6 @@ fn strip(
128 shadow: Some(theme.shadow),127 shadow: Some(theme.shadow),
129 radius: 8.0,128 radius: 8.0,
130 pad: [PAD, PAD],129 pad: [PAD, PAD],
131 gap: 8.0,
132 ..Spec::default()130 ..Spec::default()
133 },131 },
134 );132 );
...@@ -141,46 +139,16 @@ fn strip(...@@ -141,46 +139,16 @@ fn strip(
141 },139 },
142 );140 );
143 for (index, (choice, label)) in shown.into_iter().enumerate() {141 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 {
145 chosen = Some(choice);148 chosen = Some(choice);
146 }149 }
147 }150 }
148 ui.close();151 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 }
184 let close = ui.leaf(152 let close = ui.leaf(
185 "dismiss",153 "dismiss",
186 Spec {154 Spec {
...@@ -203,37 +171,45 @@ fn strip(...@@ -203,37 +171,45 @@ fn strip(
203 chosen = Some(Choice::Dismiss);171 chosen = Some(Choice::Dismiss);
204 }172 }
205 ui.close();173 ui.close();
206 chosen174 (chosen, solid)
207}175}
208176
209/// Every template, or with `art` only their art, as a scrolling grid over the page box177/// Dialog `id`, titled `title`, of every template docked inside the page box's trailing edge,
210/// `size` big.178/// `height` tall, while it is open. Returns the template chosen; it stays open to choose again.
179#[allow(clippy::too_many_arguments)]
211fn gallery(180fn gallery(
212 ui: &mut Ui,181 ui: &mut Ui,
213 theme: &Theme,182 theme: &Theme,
214 thumbnails: &mut Thumbnails,183 thumbnails: &mut Thumbnails,
215 paper: Paper,184 paper: Paper,
216 dark: bool,185 id: Id,
217 size: [f32; 2],186 title: &str,
218 art: bool,187 height: f32,
219) -> Option<Choice> {188) -> Option<&'static str> {
189 if !ui.popup_open(id) {
190 return None;
191 }
220 let mut chosen = None;192 let mut chosen = None;
221 let margin = 24.0;193 let column = TILE[0] + 40.0;
222 ui.open(194 ui.open_as(
223 "gallery",195 id,
224 Spec {196 Spec {
225 flags: Flags::FLOAT | Flags::CLICKABLE,
226 axis: Axis::Y,197 axis: Axis::Y,
227 size: [px(size[0] - 2.0 * margin), px(size[1] - 2.0 * margin)],198 size: [px(2.0 * column + GAP + 2.0 * PAD), px(height)],
228 position: [margin, margin],
229 fill: Some(theme.popup),199 fill: Some(theme.popup),
200 border: Some(theme.chip),
230 shadow: Some(theme.shadow),201 shadow: Some(theme.shadow),
231 radius: 8.0,202 radius: 8.0,
232 pad: [PAD, PAD],203 pad: [PAD, PAD],
233 gap: 8.0,204 gap: 8.0,
205 anchor: Some(Anchor::Dock(crate::page())),
206 role: Some(accesskit::Role::Dialog),
234 ..Spec::default()207 ..Spec::default()
235 },208 },
236 );209 );
210 if let Some(node) = ui.access(id) {
211 node.set_label(title);
212 }
237 ui.open(213 ui.open(
238 "header",214 "header",
239 Spec {215 Spec {
...@@ -245,12 +221,14 @@ fn gallery(...@@ -245,12 +221,14 @@ fn gallery(
245 "title",221 "title",
246 Spec {222 Spec {
247 size: [fill(), px(24.0)],223 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),
249 ..Spec::default()227 ..Spec::default()
250 },228 },
251 );229 );
252 if ui::shell::tool_button(ui, "close", art::CLOSE, theme.text_dim, None).clicked {230 if ui::shell::tool_button(ui, "close", art::CLOSE, theme.text_dim, None).clicked {
253 chosen = Some(Choice::Dismiss);231 ui.close_popup(id);
254 }232 }
255 crate::name(ui, ui.id("close"), "Close");233 crate::name(ui, ui.id("close"), "Close");
256 ui.close();234 ui.close();
...@@ -264,21 +242,7 @@ fn gallery(...@@ -264,21 +242,7 @@ fn gallery(
264 ..Spec::default()242 ..Spec::default()
265 },243 },
266 );244 );
267 let column = TILE[0] + 40.0;245 for (row, templates) in TEMPLATES.chunks(2).enumerate() {
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() {
282 ui.open(246 ui.open(
283 row,247 row,
284 Spec {248 Spec {
...@@ -287,9 +251,20 @@ fn gallery(...@@ -287,9 +251,20 @@ fn gallery(
287 ..Spec::default()251 ..Spec::default()
288 },252 },
289 );253 );
290 for (index, (choice, label)) in choices.iter().enumerate() {254 for (index, template) in templates.iter().enumerate() {
291 if tile(ui, theme, thumbnails, paper, dark, index, *choice, label) {255 let choice = Choice::Template(template.name);
292 chosen = Some(*choice);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);
293 }268 }
294 }269 }
295 ui.close();270 ui.close();
...@@ -299,43 +274,26 @@ fn gallery(...@@ -299,43 +274,26 @@ fn gallery(
299 chosen274 chosen
300}275}
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
314/// How wide a tile labelled `label` stands: its thumbnail, or the label where it is wider.277/// How wide a tile labelled `label` stands: its thumbnail, or the label where it is wider.
315fn tile_width(ui: &mut Ui, label: &str) -> f32 {278fn tile_width(ui: &mut Ui, label: &str) -> f32 {
316 TILE[0].max(ui.measure(label)[0] + 4.0)279 TILE[0].max(ui.measure(label)[0] + 4.0)
317}280}
318281
319/// One template's thumbnail over its label, as wide as the thumbnail or the label; true282/// One template's thumbnail over its label, `width` wide; returns the thumbnail, which takes
320/// when clicked.283/// the click.
321#[allow(clippy::too_many_arguments)]284#[allow(clippy::too_many_arguments)]
322fn tile(285fn tile(
323 ui: &mut Ui,286 ui: &mut Ui,
324 theme: &Theme,287 theme: &Theme,
325 thumbnails: &mut Thumbnails,288 thumbnails: &mut Thumbnails,
326 paper: Paper,289 paper: Paper,
327 dark: bool,
328 part: usize,290 part: usize,
329 choice: Choice,291 choice: Choice,
330 label: &str,292 label: &str,
331) -> bool {293 width: f32,
294) -> Id {
332 let scale = ui.scale();295 let scale = ui.scale();
333 let width = tile_width(ui, label);296 let dark = paper.ink[0] > paper.color[0];
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 };
339 ui.open(297 ui.open(
340 (part, label),298 (part, label),
341 Spec {299 Spec {
...@@ -350,7 +308,11 @@ fn tile(...@@ -350,7 +308,11 @@ fn tile(
350 Spec {308 Spec {
351 flags: Flags::CLICKABLE | Flags::CLIP,309 flags: Flags::CLICKABLE | Flags::CLIP,
352 size: [px(TILE[0]), px(TILE[1])],310 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 }),
354 border: Some(theme.chip),316 border: Some(theme.chip),
355 hover_border: Some(theme.accent),317 hover_border: Some(theme.accent),
356 radius: 4.0,318 radius: 4.0,
...@@ -383,10 +345,11 @@ fn tile(...@@ -383,10 +345,11 @@ fn tile(
383 }345 }
384 }346 }
385 }347 }
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.
387 Choice::Colors => {349 Choice::Colors => {
388 let bands = 4;350 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 {
390 let band = TILE[0] / bands as f32;353 let band = TILE[0] / bands as f32;
391 ui.leaf(354 ui.leaf(
392 ("band", index),355 ("band", index),
...@@ -394,13 +357,13 @@ fn tile(...@@ -394,13 +357,13 @@ fn tile(
394 flags: Flags::FLOAT,357 flags: Flags::FLOAT,
395 size: [px(band), px(TILE[1])],358 size: [px(band), px(TILE[1])],
396 position: [band * index as f32, 0.0],359 position: [band * index as f32, 0.0],
397 fill: Some(page_color(*color, paper, dark)),360 fill: Some(paper.colored(Some(*color)).color),
398 ..Spec::default()361 ..Spec::default()
399 },362 },
400 );363 );
401 }364 }
402 }365 }
403 _ => {}366 Choice::More | Choice::Dismiss => {}
404 }367 }
405 ui.close();368 ui.close();
406 ui.leaf(369 ui.leaf(
...@@ -414,121 +377,99 @@ fn tile(...@@ -414,121 +377,99 @@ fn tile(
414 },377 },
415 );378 );
416 ui.close();379 ui.close();
417 ui.signal(id).clicked380 id
418}381}
419382
420impl crate::State {383impl crate::State {
421 /// Over a page with nothing typed in its body: the strip where the body would start,384 /// The template galleries while open, and over a page with nothing typed in its body the
422 /// or the gallery it opened.385 /// strip where the body would start.
423 pub(crate) fn template_strip(&mut self, theme: &Theme) {386 pub(crate) fn template_strip(&mut self, theme: &Theme) {
424 let Some(session) = &self.session else {387 let Some(session) = &self.session else {
425 return;388 return;
426 };389 };
427 if self.templates == View::Art {
428 return self.art_gallery(theme);
429 }
430 let space = session.space;390 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 }
431 let editor = &self.view.editor;412 let editor = &self.view.editor;
432 let blank = editor413 let blank = editor
433 .visible_outlines()414 .visible_outlines()
434 .chain(editor.caret_outline())415 .chain(editor.caret_outline())
435 .filter(|outline| !outline.title)416 .filter(|outline| !outline.title)
436 .all(canvas::editor::TextOutline::is_empty);417 .all(canvas::editor::TextOutline::is_empty);
437 if !blank || session.read_only() || self.dismissed.contains(&space) {418 if !blank || read_only || self.dismissed.contains(&space) {
438 self.templates = View::Strip;
439 return;419 return;
440 }420 }
441 let (Some(rect), Some(start)) = (self.ui.laid_out(crate::page()), editor.body_start())421 let Some(start) = editor.body_start() else {
442 else {
443 return;422 return;
444 };423 };
424 let (color, rule_lines) = (editor.page_color(), editor.rule_lines());
445 let viewport = self.view.viewport;425 let viewport = self.view.viewport;
446 let scale = self.ui.scale();426 let scale = self.ui.scale();
447 let position = [427 let position = [
448 (viewport.origin[0] + start[0] * viewport.scale) / scale,428 (viewport.origin[0] + start[0] * viewport.scale) / scale,
449 (viewport.origin[1] + (start[1] + LINES_ABOVE) * viewport.scale) / scale,429 (viewport.origin[1] + (start[1] + LINES_ABOVE) * viewport.scale) / scale,
450 ];430 ];
451 // Thumbnails show templates on plain paper, not the page's colour.431 let (chosen, solid) = strip(
452 let paper = Paper {432 &mut self.ui,
453 color: self.ui.theme.paper,433 theme,
454 ink: self.ui.theme.paper_ink,434 &mut self.thumbnails,
455 };435 paper,
456 let dark = paper.ink[0] > paper.color[0];436 position,
457 let chosen = match self.templates {437 rect[2] - rect[0] - position[0] - 24.0,
458 View::Gallery => {438 );
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 };
482 match chosen {439 match chosen {
483 Some(Choice::More) => self.templates = View::Gallery,440 Some(Choice::Template(name)) => self.commands.push(crate::Command::Template(name)),
484 Some(Choice::Colors) => {441 Some(Choice::Colors) => self.ui.open_popup(colors()),
485 self.templates = if self.templates == View::Colors {442 Some(Choice::More) => self.ui.open_popup(templates()),
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 }
494 Some(Choice::Dismiss) => {443 Some(Choice::Dismiss) => {
495 self.dismissed.insert(space);444 self.dismissed.insert(space);
496 }445 }
497 Some(choice) => {
498 self.templates = View::Strip;
499 self.commands.push(crate::Command::Template(choice));
500 }
501 None => {}446 None => {}
502 }447 }
503 }448 if let Some(name) = gallery(
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(
518 &mut self.ui,449 &mut self.ui,
519 theme,450 theme,
520 &mut self.thumbnails,451 &mut self.thumbnails,
521 paper,452 paper,
522 dark,453 templates(),
523 size,454 "Page Templates",
524 true,455 height,
525 ) {456 ) {
526 Some(Choice::Template(name)) => {457 self.commands.push(crate::Command::Template(name));
527 self.templates = View::Strip;458 }
528 self.choose(crate::commands::Choice::Art(Some(name)));459 if let Some(choice) = solid.and_then(|tile| {
529 }460 crate::background::menu(
530 Some(Choice::Dismiss) => self.templates = View::Strip,461 &mut self.ui,
531 _ => {}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);
532 }473 }
533 }474 }
534}475}
crates/ui/src/layout.rs+6-1
...@@ -62,7 +62,11 @@ fn resolve(nodes: &mut [Built], popup: &[usize], index: usize, states: &HashMap<...@@ -62,7 +62,11 @@ fn resolve(nodes: &mut [Built], popup: &[usize], index: usize, states: &HashMap<
62 let around = match anchor {62 let around = match anchor {
63 Anchor::Point([x, y]) => [x, y, x, y],63 Anchor::Point([x, y]) => [x, y, x, y],
64 Anchor::Dialog | Anchor::Top => [0.0; 4],64 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) => {
66 let (rect, holder) = laid_out(id).unwrap_or_default();70 let (rect, holder) = laid_out(id).unwrap_or_default();
67 let [left, top, right, bottom] = rect;71 let [left, top, right, bottom] = rect;
68 match anchor {72 match anchor {
...@@ -75,6 +79,7 @@ fn resolve(nodes: &mut [Built], popup: &[usize], index: usize, states: &HashMap<...@@ -75,6 +79,7 @@ fn resolve(nodes: &mut [Built], popup: &[usize], index: usize, states: &HashMap<
75 [left, top - pad_y, right, bottom + pad_y]79 [left, top - pad_y, right, bottom + pad_y]
76 }80 }
77 Anchor::Over(_) => [left - pad_x, top - pad_y, right + pad_x, bottom + pad_y],81 Anchor::Over(_) => [left - pad_x, top - pad_y, right + pad_x, bottom + pad_y],
82 Anchor::Dock(_) => rect,
78 _ => [left, top - PAD, right, bottom + PAD],83 _ => [left, top - PAD, right, bottom + PAD],
79 }84 }
80 }85 }
crates/ui/src/lib.rs+12-3
...@@ -240,6 +240,9 @@ pub enum Anchor {...@@ -240,6 +240,9 @@ pub enum Anchor {
240 /// Where a dialog opens, swinging in as one does but leaving the interface undimmed, as a240 /// Where a dialog opens, swinging in as one does but leaving the interface undimmed, as a
241 /// command palette opens.241 /// command palette opens.
242 Top,242 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),
243}246}
244247
245impl Anchor {248impl Anchor {
...@@ -262,6 +265,10 @@ impl Anchor {...@@ -262,6 +265,10 @@ impl Anchor {
262 return ((room - size) / 2.0).max(POPUP_MARGIN);265 return ((room - size) / 2.0).max(POPUP_MARGIN);
263 }266 }
264 Anchor::Dialog | Anchor::Top => return (room / 8.0).clamp(POPUP_MARGIN, most),267 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),
265 };272 };
266 let [low, high] = [around[axis], around[axis + 2]];273 let [low, high] = [around[axis], around[axis + 2]];
267 let (first, second) = if along == Some(axis) {274 let (first, second) = if along == Some(axis) {
...@@ -280,7 +287,7 @@ impl Anchor {...@@ -280,7 +287,7 @@ impl Anchor {
280287
281 /// Seconds the popup takes to open and to close.288 /// Seconds the popup takes to open and to close.
282 fn durations(self) -> [f32; 2] {289 fn durations(self) -> [f32; 2] {
283 if matches!(self, Anchor::Dialog | Anchor::Top) {290 if matches!(self, Anchor::Dialog | Anchor::Top | Anchor::Dock(_)) {
284 DIALOG291 DIALOG
285 } else {292 } else {
286 POPUP293 POPUP
...@@ -603,10 +610,12 @@ impl Placed {...@@ -603,10 +610,12 @@ impl Placed {
603 Anchor::Right(_) => [around[2], around[1]],610 Anchor::Right(_) => [around[2], around[1]],
604 Anchor::Over(_) => [around[0], around[1]],611 Anchor::Over(_) => [around[0], around[1]],
605 Anchor::Dialog | Anchor::Top => [(rect[0] + rect[2]) / 2.0, rect[1]],612 Anchor::Dialog | Anchor::Top => [(rect[0] + rect[2]) / 2.0, rect[1]],
613 Anchor::Dock(_) => [rect[2], (rect[1] + rect[3]) / 2.0],
606 };614 };
607 let (from, tilt) = match anchor {615 let (from, tilt) = match anchor {
608 _ if !grows => (1.0, 0.0),616 _ if !grows => (1.0, 0.0),
609 Anchor::Dialog | Anchor::Top => (0.95, 0.2),617 Anchor::Dialog | Anchor::Top => (0.95, 0.2),
618 Anchor::Dock(_) => (0.95, 0.0),
610 // Over a box, the popup widens out of it in layout instead of growing.619 // Over a box, the popup widens out of it in layout instead of growing.
611 Anchor::Over(_) => (1.0, 0.2),620 Anchor::Over(_) => (1.0, 0.2),
612 _ => (0.94, 0.2),621 _ => (0.94, 0.2),
...@@ -1573,7 +1582,7 @@ impl Ui {...@@ -1573,7 +1582,7 @@ impl Ui {
1573 /// How popups beside `anchor` open and close; dialogs and the palette always swing as1582 /// How popups beside `anchor` open and close; dialogs and the palette always swing as
1574 /// Snowbound's own.1583 /// Snowbound's own.
1575 fn popup_motion(&self, anchor: Anchor) -> PopupMotion {1584 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(_)) {
1577 PopupMotion::Grow1586 PopupMotion::Grow
1578 } else {1587 } else {
1579 self.theme.menu().motion1588 self.theme.menu().motion
...@@ -1650,7 +1659,7 @@ impl Ui {...@@ -1650,7 +1659,7 @@ impl Ui {
1650 if let Some(color) = node.shadow {1659 if let Some(color) = node.shadow {
1651 let (own, menu);1660 let (own, menu);
1652 let shadows: &[Shadow] = match node.anchor {1661 let shadows: &[Shadow] = match node.anchor {
1653 Some(Anchor::Dialog) | None => {1662 Some(Anchor::Dialog | Anchor::Dock(_)) | None => {
1654 let [blur, drop] = if node.anchor.is_some() {1663 let [blur, drop] = if node.anchor.is_some() {
1655 POPUP_SHADOW1664 POPUP_SHADOW
1656 } else {1665 } else {
crates/ui/src/popup.rs+24-1
...@@ -890,7 +890,8 @@ fn highlighted(ui: &mut Ui, id: Id, cell: Option<Id>) {...@@ -890,7 +890,8 @@ fn highlighted(ui: &mut Ui, id: Id, cell: Option<Id>) {
890}890}
891891
892/// Routes the pointer and keys over open popup `id`'s `count` cells, keeping its highlight,892/// 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.
894fn pick_cell(895fn pick_cell(
895 ui: &mut Ui,896 ui: &mut Ui,
896 id: Id,897 id: Id,
...@@ -910,6 +911,15 @@ fn pick_cell(...@@ -910,6 +911,15 @@ fn pick_cell(
910 ],911 ],
911 );912 );
912 let mut highlight = state(ui, id).highlight.map(|cell| cell as usize);913 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 }
913 let mut chosen = None;923 let mut chosen = None;
914 for index in 0..count {924 for index in 0..count {
915 let signal = ui.signal(cell(index));925 let signal = ui.signal(cell(index));
...@@ -930,6 +940,13 @@ fn pick_cell(...@@ -930,6 +940,13 @@ fn pick_cell(
930 (highlight, chosen)940 (highlight, chosen)
931}941}
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
933/// Builds popup `id` as a grid of `size` columns and rows beside `anchor` while it is open,950/// Builds popup `id` as a grid of `size` columns and rows beside `anchor` while it is open,
934/// lit from its corner to the cell pointed at, as Office's table picker is. Returns the951/// lit from its corner to the cell pointed at, as Office's table picker is. Returns the
935/// columns and rows chosen.952/// columns and rows chosen.
...@@ -1369,6 +1386,12 @@ pub fn color_picker(...@@ -1369,6 +1386,12 @@ pub fn color_picker(
1369 None1386 None
1370}1387}
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
1372/// Hue in degrees, saturation and lightness of an sRGB colour.1395/// Hue in degrees, saturation and lightness of an sRGB colour.
1373fn to_hsl(rgb: [u8; 3]) -> [f32; 3] {1396fn to_hsl(rgb: [u8; 3]) -> [f32; 3] {
1374 let [red, green, blue] = rgb.map(|channel| f32::from(channel) / 255.0);1397 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() {...@@ -1840,6 +1840,60 @@ fn colour_grids_move_in_two_dimensions_and_choose_a_swatch_or_none() {
1840 assert_eq!(build(&mut ui), Some(None));1840 assert_eq!(build(&mut ui), Some(None));
1841}1841}
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
1843#[test]1897#[test]
1844fn table_pickers_choose_columns_and_rows_by_keys_or_a_click() {1898fn table_pickers_choose_columns_and_rows_by_keys_or_a_click() {
1845 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);1899 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);