From f729362858ad787f269bc50144f0c765a84b366f Mon Sep 17 00:00:00 2001 From: clover caruso Date: Sat, 3 Oct 2026 13:26:07 -0700 Subject: [PATCH] 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 --- arc/ui.md | 3 +- crates/snowbound/src/background.rs | 22 +- crates/snowbound/src/main.rs | 28 ++- crates/snowbound/src/manage.rs | 91 +++----- crates/snowbound/src/print.rs | 15 +- crates/snowbound/src/templates.rs | 345 ++++++++++++----------------- crates/ui/src/layout.rs | 7 +- crates/ui/src/lib.rs | 15 +- crates/ui/src/popup.rs | 25 ++- crates/ui/src/tests.rs | 54 +++++ 10 files changed, 317 insertions(+), 288 deletions(-) diff --git a/arc/ui.md b/arc/ui.md index 91a7b7d425f0c4bab20a2c71b5d222c9fbda2eab..6efc225c48d9635067f23e3129198b7f8466fd6d 100644 --- a/arc/ui.md +++ b/arc/ui.md @@ -27,7 +27,8 @@ it is, and it settles fast. Popups open and close on short timed curves, slow enough to follow: a menu swings out of the pointer or its button as it fades in, a combo's field widens into its list, and a dialog swings up into place near the window's top over a dimmed window, as Windows opens a window; the -command palette swings in the same way, undimmed. A filtered list shows its new +command palette swings in the same way, undimmed, as does a dialog docked inside the +page it changes, such as the backgrounds gallery. A filtered list shows its new results at once. On GNOME and KDE, menus instead look and move as the desktop's own (see [platforms](platforms.md)). A segmented control slides a raised face along a track, as macOS, libadwaita and Windows 11 draw theirs, and on diff --git a/crates/snowbound/src/background.rs b/crates/snowbound/src/background.rs index 4285d9bba55543073e7bc118acdae9e9a2066003..887659892a3ab98cd13f67861cddc20623e3c3cd 100644 --- a/crates/snowbound/src/background.rs +++ b/crates/snowbound/src/background.rs @@ -4,7 +4,7 @@ use crate::{ art, commands::Choice, - templates::{TILE, Thumbnails, View}, + templates::{TILE, Thumbnails}, }; use canvas::{ gpu::{Paper, colorref}, @@ -42,7 +42,8 @@ enum Cell { /// Builds popup `id` beside `anchor` while it is open, for a page on `paper` (its own colour /// aside) coloured `color` and ruled with `rule_lines`, and the custom colour picker it -/// opens. Show All opens every template's art over the page through `view`. Returns the +/// opens, or with `colors_only` only its page colours. Show All opens the backgrounds +/// gallery. Sets `preview` to the page colour highlighted or being picked. Returns the /// choice made. #[allow(clippy::too_many_arguments)] pub fn menu( @@ -50,10 +51,11 @@ pub fn menu( id: Id, anchor: Anchor, thumbnails: &mut Thumbnails, - view: &mut View, paper: Paper, color: Option, rule_lines: Option, + colors_only: bool, + preview: &mut Option>, ) -> Option { let picker = id.child("custom"); // A pale blue to start from on a page without a colour. @@ -74,6 +76,9 @@ pub fn menu( red, green, blue, 0, ])))); } + if let Some([red, green, blue]) = ui::popup::picking(ui, picker) { + *preview = Some(Some(u32::from_le_bytes([red, green, blue, 0]))); + } let custom = color.filter(|color| !PAGE_COLORS.iter().any(|(_, listed)| listed == color)); let cells: Vec = std::iter::once(Cell::Color(None)) .chain( @@ -148,7 +153,8 @@ pub fn menu( }, ); }; - let chosen = ui::popup::gallery(ui, id, anchor, &groups, ¤t, |ui, index| { + let groups = if colors_only { &groups[..1] } else { &groups }; + let chosen = ui::popup::gallery(ui, id, anchor, groups, ¤t, |ui, index| { let name = match cells[index] { Cell::Color(Some(color)) => PAGE_COLORS .iter() @@ -236,7 +242,11 @@ pub fn menu( } Cell::Rules(Some(index)) => rule_thumbnail(ui, paper, RULE_LINES[index]), } - })?; + }); + if let Some(Cell::Color(color)) = ui::popup::highlight(ui, id).map(|index| cells[index]) { + *preview = Some(color); + } + let chosen = chosen?; match cells[chosen] { Cell::Color(color) => Some(Choice::PageColor(color)), Cell::Custom => { @@ -245,7 +255,7 @@ pub fn menu( } Cell::Art(name) => Some(Choice::Art(name)), Cell::ShowAll => { - *view = View::Art; + ui.open_popup(crate::templates::backgrounds()); None } Cell::Rules(index) => Some(Choice::RuleLines(index)), diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 49db60a8bb0f184217d3d46eab71658210c570ce..7cd5758ed65b078d944808da777fe4fc373d3618 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -718,8 +718,8 @@ enum Command { space: ExGuid, path: String, }, - /// Gives the open page a template's background or colour. - Template(templates::Choice), + /// Gives the open page a template's background. + Template(&'static str), OpenPage(ExGuid), /// Shows a page's conflict pages in the list and opens the newest, or hides them. Versions { @@ -902,8 +902,8 @@ struct State { dismissed: HashSet, /// A page just created, whose title takes the caret once it opens. title_focus: Option, - /// What the template strip shows over a blank page. - templates: templates::View, + /// The page colour a menu previews over the open page's own this frame. + page_color_preview: Option>, media: recording::Media, /// Whether playback highlights the notes linked to the moment playing. see_playback: bool, @@ -1349,7 +1349,7 @@ impl State { .map(|id| notebook::sidecar::themes::successor(&id).to_owned()), live_options: stored.live, drag: None, - templates: templates::View::Strip, + page_color_preview: None, media: Default::default(), see_playback: true, thumbnails: templates::Thumbnails::default(), @@ -1560,7 +1560,12 @@ impl State { fn layout(&mut self, size: [f32; 2], scale: f32) -> Result<(), Box> { self.ui.begin(size, scale, Instant::now()); platform::cover_border_line(&mut self.ui, size[0]); + let previewed = self.page_color_preview; let (section, open_tab, open_page) = self.build()?; + // The page's frame and tab were built with last frame's preview. + if self.page_color_preview != previewed { + self.ui.wake_after(std::time::Duration::ZERO); + } self.options_dialog(); self.themes_dialog(); self.print_dialog(); @@ -1677,6 +1682,7 @@ impl State { // The page, and the open page's tab joined to it, take the page's colour. let mut theme = self.ui.theme.clone(); theme.paper = self.paper().color; + self.page_color_preview = None; platform::update_menu(|| self.statuses()); let welcome = self.session.is_none() && !self.temporary @@ -3049,7 +3055,6 @@ impl State { menu, anchor, &mut self.thumbnails, - &mut self.templates, // The page's own colour aside. canvas::gpu::Paper { color: ui.theme.paper, @@ -3057,6 +3062,8 @@ impl State { }, self.view.editor.page_color(), self.view.editor.rule_lines(), + false, + &mut self.page_color_preview, ) }, |_| None, @@ -3624,7 +3631,7 @@ impl State { self.move_page(space, path)?; self.edited(Vec::new()); } - Command::Template(choice) => self.apply_template(choice)?, + Command::Template(name) => self.apply_template(name)?, Command::Page(Request::EditDate(field)) => self.edit_date(field), Command::Page(Request::Copy(clip)) => self.clipboard.set(clip)?, Command::Page(Request::Paste) => self.paste()?, @@ -4557,13 +4564,16 @@ impl State { self.window.request_redraw(); } - /// The paper the open page lies on: the theme's, in the page's colour. + /// The paper the open page lies on: the theme's, in the page's colour or the one previewed. fn paper(&self) -> canvas::gpu::Paper { canvas::gpu::Paper { color: self.ui.theme.paper, ink: self.ui.theme.paper_ink, } - .colored(self.view.editor.page_color()) + .colored( + self.page_color_preview + .unwrap_or_else(|| self.view.editor.page_color()), + ) } /// The colours of what the page area shows in place of a page, which follow the page's: diff --git a/crates/snowbound/src/manage.rs b/crates/snowbound/src/manage.rs index 10a25c0fdfc4a609ba03888b56444a31fa164946..0b1c9b864ef178041936ab75caf42e49da2565a0 100644 --- a/crates/snowbound/src/manage.rs +++ b/crates/snowbound/src/manage.rs @@ -180,23 +180,13 @@ impl State { Ok(()) } - /// Gives the open page `choice`'s background: a template's art in place of any it - /// had, or a page colour instead of art. - pub(crate) fn apply_template( - &mut self, - choice: crate::templates::Choice, - ) -> Result<(), Box> { - use crate::templates::Choice; - let template = match choice { - Choice::Template(name) => { - Some(canvas::template::find(name).ok_or("That template is not available")?) - } - _ => None, - }; - self.with_art(template, move |state, art| { + /// Gives the open page template `name`'s art in place of any it had. + pub(crate) fn apply_template(&mut self, name: &'static str) -> Result<(), Box> { + let template = canvas::template::find(name).ok_or("That template is not available")?; + self.with_art(Some(template), move |state, art| { state.persist()?; let session = state.session.as_ref().ok_or("No section is open")?; - let ops = template_ops(&session.section.page(session.space)?, choice, art)?; + let ops = template_ops(&session.section.page(session.space)?, name, art)?; state.edit_page(ops) }) } @@ -561,7 +551,6 @@ impl State { self.persist().unwrap_or_else(|error| eprintln!("{error}")); self.session = None; self.sectionless = None; - self.templates = crate::templates::View::Strip; match self .notebooks .iter() @@ -966,35 +955,23 @@ impl State { } } -/// The ops giving `page` `choice`'s background: its template art or a page colour, in -/// place of the background it had. +/// The ops giving `page` template `name`'s art, `art`, in place of the background it had, +/// and no page colour. pub fn template_ops( page: &Page, - choice: crate::templates::Choice, + name: &str, art: Vec, ) -> Result, Box> { - use crate::templates::Choice; - let mut ops = match choice { - Choice::Template(_) | Choice::Color(_) => art_ops(page, art), - Choice::More | Choice::Dismiss | Choice::Colors => return Ok(Vec::new()), - }; - match choice { - Choice::Template(name) => { - if page.color.is_some() { - ops.push(PageOp::Color(None)); - } - // The one template with content worth keeping. - if name == "Informal Meeting Notes" { - ops.extend(onestore::op::lower_page( - page, - &crate::meeting::content(page)?, - )?); - } - } - Choice::Color(index) => { - ops.push(PageOp::Color(Some(canvas::template::PAGE_COLORS[index].1))) - } - Choice::More | Choice::Dismiss | Choice::Colors => {} + let mut ops = art_ops(page, art); + if page.color.is_some() { + ops.push(PageOp::Color(None)); + } + // The one template with content worth keeping. + if name == "Informal Meeting Notes" { + ops.extend(onestore::op::lower_page( + page, + &crate::meeting::content(page)?, + )?); } Ok(ops) } @@ -1031,7 +1008,6 @@ fn art_ops(page: &Page, art: Vec) -> Vec { #[cfg(test)] mod tests { use super::*; - use crate::templates::Choice; use std::path::Path; const AUTHOR: &str = "Rust Author"; @@ -1153,27 +1129,27 @@ mod tests { let [ivy, teal, subpage, deleted, meeting] = spaces[..] else { unreachable!() }; - let background = |space: ExGuid, choice: Choice| { - let art = match choice { - Choice::Template(name) => canvas::template::find(name).unwrap().pictures().unwrap(), - _ => Vec::new(), - }; - template_ops(&page(&file, space), choice, art) + let background = |space: ExGuid, name: &str| { + let art = canvas::template::find(name).unwrap().pictures().unwrap(); + template_ops(&page(&file, space), name, art) .unwrap() .into_iter() .map(|op| Op::Page { space, op }) .collect::>() }; - edit(&file, background(ivy, Choice::Template("Ivy"))); + edit(&file, background(ivy, "Ivy")); + edit(&file, background(meeting, "Informal Meeting Notes")); + let (_, teal_color) = canvas::template::PAGE_COLORS + .iter() + .find(|(name, _)| *name == "Teal") + .unwrap(); edit( &file, - background(meeting, Choice::Template("Informal Meeting Notes")), + vec![Op::Page { + space: teal, + op: PageOp::Color(Some(*teal_color)), + }], ); - let teal_index = canvas::template::PAGE_COLORS - .iter() - .position(|(name, _)| *name == "Teal") - .unwrap(); - edit(&file, background(teal, Choice::Color(teal_index))); edit( &file, vec![Op::Section(SectionOp::Pages(vec![ @@ -1275,10 +1251,7 @@ mod tests { ] ); let shown = page(&file, teal); - assert_eq!( - shown.color, - Some(canvas::template::PAGE_COLORS[teal_index].1) - ); + assert_eq!(shown.color, Some(*teal_color)); assert!(shown.date_text().is_some()); assert!( page(&file, ivy) diff --git a/crates/snowbound/src/print.rs b/crates/snowbound/src/print.rs index 0043bac4e5087c824470e92194ba756950bcfc7f..dc82626e5ad165695a2bf9732ce5e5ee8f779d21 100644 --- a/crates/snowbound/src/print.rs +++ b/crates/snowbound/src/print.rs @@ -530,12 +530,15 @@ mod tests { }; let create = |creation| Op::Section(SectionOp::Create(creation)); (section.apply("Author", edit(vec![create(dated), create(written)]))).unwrap(); - let meeting = crate::templates::Choice::Template("Informal Meeting Notes"); - let ops = crate::manage::template_ops(§ion.page(space).unwrap(), meeting, Vec::new()) - .unwrap() - .into_iter() - .map(|op| Op::Page { space, op }) - .collect(); + let ops = crate::manage::template_ops( + §ion.page(space).unwrap(), + "Informal Meeting Notes", + Vec::new(), + ) + .unwrap() + .into_iter() + .map(|op| Op::Page { space, op }) + .collect(); section.apply("Author", edit(ops)).unwrap(); // The template titles the page; its title goes, its text stays. let untitled = crate::rename::retitled(§ion.page(space).unwrap(), space, String::new()); diff --git a/crates/snowbound/src/templates.rs b/crates/snowbound/src/templates.rs index e6c3220179dde877af74d68766e752dcbec124fa..7c82d7e511e0367072bdee2feb464ff54be5b327 100644 --- a/crates/snowbound/src/templates.rs +++ b/crates/snowbound/src/templates.rs @@ -1,19 +1,18 @@ //! The template strip a new page shows where its body would start until the body is typed -//! in, and the gallery of every template it leads to. +//! in, and the galleries of every template docked beside the page. use crate::art; use canvas::{ gpu::Paper, - template::{PAGE_COLORS, TEMPLATES, Template}, + template::{TEMPLATES, Template}, }; use draw::RasterImage; use std::collections::HashMap; -use ui::{Axis, Flags, Spec, Theme, Ui, fill, px}; +use ui::{Anchor, Axis, Flags, Id, Spec, Theme, Ui, fill, px}; /// What a thumbnail shows: the top of a letter page, in points from the page origin. const REGION: [f32; 4] = [-72.0, -40.0, 540.0, 419.0]; pub(crate) const TILE: [f32; 2] = [112.0, 84.0]; -const SWATCH: f32 = 24.0; const LABEL: f32 = 22.0; const GAP: f32 = 12.0; const PAD: f32 = 10.0; @@ -30,26 +29,28 @@ const STRIP: [(Choice, &str); 5] = [ ]; #[derive(Clone, Copy, Debug, PartialEq)] -pub enum Choice { +enum Choice { Template(&'static str), - /// An index into `PAGE_COLORS`. - Color(usize), - /// Shows or hides the page colours. + /// Opens the Page Color menu over its tile. Colors, More, Dismiss, } -/// What the template strip shows. -#[derive(Clone, Copy, Debug, Default, PartialEq)] -pub enum View { - #[default] - Strip, - /// The strip with the page colours beneath it. - Colors, - Gallery, - /// Every template's art, from the Page Color menu, to lie behind the open page. - Art, +/// The gallery of every template's art, from the Page Color menu's Show All, to lie behind +/// the open page. +pub fn backgrounds() -> Id { + Id::ROOT.child("backgrounds") +} + +/// The gallery of every template, from the strip's More templates. +fn templates() -> Id { + Id::ROOT.child("templates") +} + +/// The page colours the strip's Solid Color opens. +fn colors() -> Id { + Id::ROOT.child("strip colors") } /// A thumbnail's art: each raster and where it lies in the tile. @@ -87,20 +88,17 @@ impl Thumbnails { } } -/// The strip at `position` in the page box, `room` wide at most, with the page colours -/// beneath while `colors`. Picks that do not fit give way from the end; More templates -/// stays. -#[allow(clippy::too_many_arguments)] +/// The strip at `position` in the page box, `room` wide at most. Picks that do not fit give +/// way from the end; More templates stays. Returns the pick and the Solid Color tile, where +/// shown. fn strip( ui: &mut Ui, theme: &Theme, thumbnails: &mut Thumbnails, paper: Paper, - dark: bool, position: [f32; 2], room: f32, - colors: bool, -) -> Option { +) -> (Option, Option) { let more = (Choice::More, "More templates"); let mut used = 2.0 * PAD + tile_width(ui, more.1); let mut shown = Vec::new(); @@ -117,6 +115,7 @@ fn strip( .sum::() - GAP; let mut chosen = None; + let mut solid = None; ui.open( "templates", Spec { @@ -128,7 +127,6 @@ fn strip( shadow: Some(theme.shadow), radius: 8.0, pad: [PAD, PAD], - gap: 8.0, ..Spec::default() }, ); @@ -141,46 +139,16 @@ fn strip( }, ); for (index, (choice, label)) in shown.into_iter().enumerate() { - if tile(ui, theme, thumbnails, paper, dark, index, choice, label) { + let width = tile_width(ui, label); + let tile = tile(ui, theme, thumbnails, paper, index, choice, label, width); + if choice == Choice::Colors { + solid = Some(tile); + } + if ui.signal(tile).clicked { chosen = Some(choice); } } ui.close(); - let per_row = (((room - 2.0 * PAD + 6.0) / (SWATCH + 6.0)).floor() as usize).max(1); - for (row, swatches) in PAGE_COLORS.chunks(per_row).enumerate().filter(|_| colors) { - ui.open( - ("colors", row), - Spec { - size: [ui::children(), px(SWATCH)], - gap: 6.0, - ..Spec::default() - }, - ); - for (index, (name, color)) in swatches - .iter() - .enumerate() - .map(|(index, color)| (row * per_row + index, color)) - { - let swatch = ui.leaf( - (index, *name), - Spec { - flags: Flags::CLICKABLE, - size: [px(SWATCH), px(SWATCH)], - fill: Some(page_color(*color, paper, dark)), - border: Some(theme.chip), - hover_border: Some(theme.accent), - radius: 4.0, - role: Some(accesskit::Role::Button), - ..Spec::default() - }, - ); - crate::name(ui, ui.id((index, *name)), name); - if swatch.clicked { - chosen = Some(Choice::Color(index)); - } - } - ui.close(); - } let close = ui.leaf( "dismiss", Spec { @@ -203,37 +171,45 @@ fn strip( chosen = Some(Choice::Dismiss); } ui.close(); - chosen + (chosen, solid) } -/// Every template, or with `art` only their art, as a scrolling grid over the page box -/// `size` big. +/// Dialog `id`, titled `title`, of every template docked inside the page box's trailing edge, +/// `height` tall, while it is open. Returns the template chosen; it stays open to choose again. +#[allow(clippy::too_many_arguments)] fn gallery( ui: &mut Ui, theme: &Theme, thumbnails: &mut Thumbnails, paper: Paper, - dark: bool, - size: [f32; 2], - art: bool, -) -> Option { + id: Id, + title: &str, + height: f32, +) -> Option<&'static str> { + if !ui.popup_open(id) { + return None; + } let mut chosen = None; - let margin = 24.0; - ui.open( - "gallery", + let column = TILE[0] + 40.0; + ui.open_as( + id, Spec { - flags: Flags::FLOAT | Flags::CLICKABLE, axis: Axis::Y, - size: [px(size[0] - 2.0 * margin), px(size[1] - 2.0 * margin)], - position: [margin, margin], + size: [px(2.0 * column + GAP + 2.0 * PAD), px(height)], fill: Some(theme.popup), + border: Some(theme.chip), shadow: Some(theme.shadow), radius: 8.0, pad: [PAD, PAD], gap: 8.0, + anchor: Some(Anchor::Dock(crate::page())), + role: Some(accesskit::Role::Dialog), ..Spec::default() }, ); + if let Some(node) = ui.access(id) { + node.set_label(title); + } ui.open( "header", Spec { @@ -245,12 +221,14 @@ fn gallery( "title", Spec { size: [fill(), px(24.0)], - text: Some(if art { "Backgrounds" } else { "Page Templates" }), + text: Some(title), + bold: true, + role: Some(accesskit::Role::Heading), ..Spec::default() }, ); if ui::shell::tool_button(ui, "close", art::CLOSE, theme.text_dim, None).clicked { - chosen = Some(Choice::Dismiss); + ui.close_popup(id); } crate::name(ui, ui.id("close"), "Close"); ui.close(); @@ -264,21 +242,7 @@ fn gallery( ..Spec::default() }, ); - let column = TILE[0] + 40.0; - let columns = - (((size[0] - 2.0 * margin - 2.0 * PAD + GAP) / (column + GAP)).floor() as usize).max(1); - let choices: Vec<(Choice, &str)> = TEMPLATES - .iter() - .map(|template| (Choice::Template(template.name), template.name)) - .chain( - PAGE_COLORS - .iter() - .enumerate() - .map(|(index, (name, _))| (Choice::Color(index), *name)) - .filter(|_| !art), - ) - .collect(); - for (row, choices) in choices.chunks(columns).enumerate() { + for (row, templates) in TEMPLATES.chunks(2).enumerate() { ui.open( row, Spec { @@ -287,9 +251,20 @@ fn gallery( ..Spec::default() }, ); - for (index, (choice, label)) in choices.iter().enumerate() { - if tile(ui, theme, thumbnails, paper, dark, index, *choice, label) { - chosen = Some(*choice); + for (index, template) in templates.iter().enumerate() { + let choice = Choice::Template(template.name); + let tile = tile( + ui, + theme, + thumbnails, + paper, + index, + choice, + template.name, + column, + ); + if ui.signal(tile).clicked { + chosen = Some(template.name); } } ui.close(); @@ -299,43 +274,26 @@ fn gallery( chosen } -/// A page colour as the paper shows it: on dark paper, its hue at the paper's lightness. -fn page_color(color: u32, paper: Paper, dark: bool) -> [f32; 4] { - let color = canvas::gpu::colorref(color); - if !dark { - return color; - } - let [lightness, ..] = draw::oklab(paper.color); - let [_, a, b] = draw::oklab(color); - let [red, green, blue] = draw::from_oklab([lightness + 0.04, 2.0 * a, 2.0 * b]); - [red, green, blue, 1.0] -} - /// How wide a tile labelled `label` stands: its thumbnail, or the label where it is wider. fn tile_width(ui: &mut Ui, label: &str) -> f32 { TILE[0].max(ui.measure(label)[0] + 4.0) } -/// One template's thumbnail over its label, as wide as the thumbnail or the label; true -/// when clicked. +/// One template's thumbnail over its label, `width` wide; returns the thumbnail, which takes +/// the click. #[allow(clippy::too_many_arguments)] fn tile( ui: &mut Ui, theme: &Theme, thumbnails: &mut Thumbnails, paper: Paper, - dark: bool, part: usize, choice: Choice, label: &str, -) -> bool { + width: f32, +) -> Id { let scale = ui.scale(); - let width = tile_width(ui, label); - let fill_color = match choice { - Choice::Color(index) => page_color(PAGE_COLORS[index].1, paper, dark), - Choice::More | Choice::Dismiss => theme.base, - Choice::Template(_) | Choice::Colors => paper.color, - }; + let dark = paper.ink[0] > paper.color[0]; ui.open( (part, label), Spec { @@ -350,7 +308,11 @@ fn tile( Spec { flags: Flags::CLICKABLE | Flags::CLIP, size: [px(TILE[0]), px(TILE[1])], - fill: Some(fill_color), + fill: Some(if choice == Choice::More { + theme.base + } else { + paper.color + }), border: Some(theme.chip), hover_border: Some(theme.accent), radius: 4.0, @@ -383,10 +345,11 @@ fn tile( } } } - // A band of each of the first page colours. + // A band of each of the first page colours, as the page shows them. Choice::Colors => { let bands = 4; - for (index, (_, color)) in PAGE_COLORS.iter().take(bands).enumerate() { + for (index, (_, color)) in canvas::template::PAGE_COLORS.iter().take(bands).enumerate() + { let band = TILE[0] / bands as f32; ui.leaf( ("band", index), @@ -394,13 +357,13 @@ fn tile( flags: Flags::FLOAT, size: [px(band), px(TILE[1])], position: [band * index as f32, 0.0], - fill: Some(page_color(*color, paper, dark)), + fill: Some(paper.colored(Some(*color)).color), ..Spec::default() }, ); } } - _ => {} + Choice::More | Choice::Dismiss => {} } ui.close(); ui.leaf( @@ -414,121 +377,99 @@ fn tile( }, ); ui.close(); - ui.signal(id).clicked + id } impl crate::State { - /// Over a page with nothing typed in its body: the strip where the body would start, - /// or the gallery it opened. + /// The template galleries while open, and over a page with nothing typed in its body the + /// strip where the body would start. pub(crate) fn template_strip(&mut self, theme: &Theme) { let Some(session) = &self.session else { return; }; - if self.templates == View::Art { - return self.art_gallery(theme); - } let space = session.space; + let read_only = session.read_only(); + let Some(rect) = self.ui.laid_out(crate::page()) else { + return; + }; + // Thumbnails show templates on plain paper, not the page's colour. + let paper = Paper { + color: self.ui.theme.paper, + ink: self.ui.theme.paper_ink, + }; + let height = rect[3] - rect[1] - 8.0; + if let Some(name) = gallery( + &mut self.ui, + theme, + &mut self.thumbnails, + paper, + backgrounds(), + "Backgrounds", + height, + ) { + self.choose(crate::commands::Choice::Art(Some(name))); + } let editor = &self.view.editor; let blank = editor .visible_outlines() .chain(editor.caret_outline()) .filter(|outline| !outline.title) .all(canvas::editor::TextOutline::is_empty); - if !blank || session.read_only() || self.dismissed.contains(&space) { - self.templates = View::Strip; + if !blank || read_only || self.dismissed.contains(&space) { return; } - let (Some(rect), Some(start)) = (self.ui.laid_out(crate::page()), editor.body_start()) - else { + let Some(start) = editor.body_start() else { return; }; + let (color, rule_lines) = (editor.page_color(), editor.rule_lines()); let viewport = self.view.viewport; let scale = self.ui.scale(); let position = [ (viewport.origin[0] + start[0] * viewport.scale) / scale, (viewport.origin[1] + (start[1] + LINES_ABOVE) * viewport.scale) / scale, ]; - // Thumbnails show templates on plain paper, not the page's colour. - let paper = Paper { - color: self.ui.theme.paper, - ink: self.ui.theme.paper_ink, - }; - let dark = paper.ink[0] > paper.color[0]; - let chosen = match self.templates { - View::Gallery => { - let size = [rect[2] - rect[0], rect[3] - rect[1]]; - gallery( - &mut self.ui, - theme, - &mut self.thumbnails, - paper, - dark, - size, - false, - ) - } - View::Art => unreachable!("The art gallery is built alone"), - view => strip( - &mut self.ui, - theme, - &mut self.thumbnails, - paper, - dark, - position, - rect[2] - rect[0] - position[0] - 24.0, - view == View::Colors, - ), - }; + let (chosen, solid) = strip( + &mut self.ui, + theme, + &mut self.thumbnails, + paper, + position, + rect[2] - rect[0] - position[0] - 24.0, + ); match chosen { - Some(Choice::More) => self.templates = View::Gallery, - Some(Choice::Colors) => { - self.templates = if self.templates == View::Colors { - View::Strip - } else { - View::Colors - } - } - Some(Choice::Dismiss) if self.templates == View::Gallery => { - self.templates = View::Strip - } + Some(Choice::Template(name)) => self.commands.push(crate::Command::Template(name)), + Some(Choice::Colors) => self.ui.open_popup(colors()), + Some(Choice::More) => self.ui.open_popup(templates()), Some(Choice::Dismiss) => { self.dismissed.insert(space); } - Some(choice) => { - self.templates = View::Strip; - self.commands.push(crate::Command::Template(choice)); - } None => {} } - } - - /// The Page Color menu's Show All: every template's art, one of which goes behind the - /// open page. - fn art_gallery(&mut self, theme: &Theme) { - let Some(rect) = self.ui.laid_out(crate::page()) else { - return; - }; - let paper = Paper { - color: self.ui.theme.paper, - ink: self.ui.theme.paper_ink, - }; - let dark = paper.ink[0] > paper.color[0]; - let size = [rect[2] - rect[0], rect[3] - rect[1]]; - match gallery( + if let Some(name) = gallery( &mut self.ui, theme, &mut self.thumbnails, paper, - dark, - size, - true, + templates(), + "Page Templates", + height, ) { - Some(Choice::Template(name)) => { - self.templates = View::Strip; - self.choose(crate::commands::Choice::Art(Some(name))); - } - Some(Choice::Dismiss) => self.templates = View::Strip, - _ => {} + self.commands.push(crate::Command::Template(name)); + } + if let Some(choice) = solid.and_then(|tile| { + crate::background::menu( + &mut self.ui, + colors(), + Anchor::Below(tile), + &mut self.thumbnails, + paper, + color, + rule_lines, + true, + &mut self.page_color_preview, + ) + }) { + self.choose(choice); } } } diff --git a/crates/ui/src/layout.rs b/crates/ui/src/layout.rs index 5a9f4203eb805b4390196afefe8b77f0e118e065..63f0fffb13e919e031674c0eaddd0d19622d1cac 100644 --- a/crates/ui/src/layout.rs +++ b/crates/ui/src/layout.rs @@ -62,7 +62,11 @@ fn resolve(nodes: &mut [Built], popup: &[usize], index: usize, states: &HashMap< let around = match anchor { Anchor::Point([x, y]) => [x, y, x, y], Anchor::Dialog | Anchor::Top => [0.0; 4], - Anchor::Below(id) | Anchor::Tip(id) | Anchor::Right(id) | Anchor::Over(id) => { + Anchor::Below(id) + | Anchor::Tip(id) + | Anchor::Right(id) + | Anchor::Over(id) + | Anchor::Dock(id) => { let (rect, holder) = laid_out(id).unwrap_or_default(); let [left, top, right, bottom] = rect; match anchor { @@ -75,6 +79,7 @@ fn resolve(nodes: &mut [Built], popup: &[usize], index: usize, states: &HashMap< [left, top - pad_y, right, bottom + pad_y] } Anchor::Over(_) => [left - pad_x, top - pad_y, right + pad_x, bottom + pad_y], + Anchor::Dock(_) => rect, _ => [left, top - PAD, right, bottom + PAD], } } diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 796580640613a36bbf3d6d83d5d47cc77bad1220..372d2809750f25547bbe1d7165af95b33c7da83e 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -240,6 +240,9 @@ pub enum Anchor { /// Where a dialog opens, swinging in as one does but leaving the interface undimmed, as a /// command palette opens. Top, + /// Inside the box along its trailing edge, a dialog leaving the rest of the box in view and + /// the interface undimmed, as a side panel docks. + Dock(Id), } impl Anchor { @@ -262,6 +265,10 @@ impl Anchor { return ((room - size) / 2.0).max(POPUP_MARGIN); } Anchor::Dialog | Anchor::Top => return (room / 8.0).clamp(POPUP_MARGIN, most), + Anchor::Dock(_) if axis == 0 => { + return (around[2] - size - POPUP_MARGIN).clamp(POPUP_MARGIN, most); + } + Anchor::Dock(_) => return (around[1] + POPUP_MARGIN).clamp(POPUP_MARGIN, most), }; let [low, high] = [around[axis], around[axis + 2]]; let (first, second) = if along == Some(axis) { @@ -280,7 +287,7 @@ impl Anchor { /// Seconds the popup takes to open and to close. fn durations(self) -> [f32; 2] { - if matches!(self, Anchor::Dialog | Anchor::Top) { + if matches!(self, Anchor::Dialog | Anchor::Top | Anchor::Dock(_)) { DIALOG } else { POPUP @@ -603,10 +610,12 @@ impl Placed { Anchor::Right(_) => [around[2], around[1]], Anchor::Over(_) => [around[0], around[1]], Anchor::Dialog | Anchor::Top => [(rect[0] + rect[2]) / 2.0, rect[1]], + Anchor::Dock(_) => [rect[2], (rect[1] + rect[3]) / 2.0], }; let (from, tilt) = match anchor { _ if !grows => (1.0, 0.0), Anchor::Dialog | Anchor::Top => (0.95, 0.2), + Anchor::Dock(_) => (0.95, 0.0), // Over a box, the popup widens out of it in layout instead of growing. Anchor::Over(_) => (1.0, 0.2), _ => (0.94, 0.2), @@ -1573,7 +1582,7 @@ impl Ui { /// How popups beside `anchor` open and close; dialogs and the palette always swing as /// Snowbound's own. fn popup_motion(&self, anchor: Anchor) -> PopupMotion { - if matches!(anchor, Anchor::Dialog | Anchor::Top) { + if matches!(anchor, Anchor::Dialog | Anchor::Top | Anchor::Dock(_)) { PopupMotion::Grow } else { self.theme.menu().motion @@ -1650,7 +1659,7 @@ impl Ui { if let Some(color) = node.shadow { let (own, menu); let shadows: &[Shadow] = match node.anchor { - Some(Anchor::Dialog) | None => { + Some(Anchor::Dialog | Anchor::Dock(_)) | None => { let [blur, drop] = if node.anchor.is_some() { POPUP_SHADOW } else { diff --git a/crates/ui/src/popup.rs b/crates/ui/src/popup.rs index f11931f28ee16f2386b9a71a7539fe3bf2da3203..5e5df35604582d94fb22a6831bbfd41248c5d224 100644 --- a/crates/ui/src/popup.rs +++ b/crates/ui/src/popup.rs @@ -890,7 +890,8 @@ fn highlighted(ui: &mut Ui, id: Id, cell: Option) { } /// Routes the pointer and keys over open popup `id`'s `count` cells, keeping its highlight, -/// which `step` moves for each arrow. Returns the highlight and the cell a click or Enter chose. +/// which `step` moves for each arrow and the pointer leaving the popup clears. Returns the +/// highlight and the cell a click or Enter chose. fn pick_cell( ui: &mut Ui, id: Id, @@ -910,6 +911,15 @@ fn pick_cell( ], ); let mut highlight = state(ui, id).highlight.map(|cell| cell as usize); + let outside = + ui.pointer() + .zip(ui.laid_out(id)) + .is_some_and(|([x, y], [left, top, right, bottom])| { + x < left || x >= right || y < top || y >= bottom + }); + if outside && ui.moved { + highlight = None; + } let mut chosen = None; for index in 0..count { let signal = ui.signal(cell(index)); @@ -930,6 +940,13 @@ fn pick_cell( (highlight, chosen) } +/// The cell open popup `id`, a grid or gallery, highlights: under the pointer or reached by +/// the arrows. +pub fn highlight(ui: &Ui, id: Id) -> Option { + let popup = ui.popups.iter().find(|popup| popup.id == id)?; + popup.highlight.map(|cell| cell as usize) +} + /// Builds popup `id` as a grid of `size` columns and rows beside `anchor` while it is open, /// lit from its corner to the cell pointed at, as Office's table picker is. Returns the /// columns and rows chosen. @@ -1369,6 +1386,12 @@ pub fn color_picker( None } +/// The colour open colour picker `id` shows, sRGB, before it is applied. +pub fn picking(ui: &Ui, id: Id) -> Option<[u8; 3]> { + let popup = ui.popups.iter().find(|popup| popup.id == id)?; + popup.picked.map(from_hsl) +} + /// Hue in degrees, saturation and lightness of an sRGB colour. fn to_hsl(rgb: [u8; 3]) -> [f32; 3] { let [red, green, blue] = rgb.map(|channel| f32::from(channel) / 255.0); diff --git a/crates/ui/src/tests.rs b/crates/ui/src/tests.rs index 0834cd0bdb40cf4cfe324733979497d6a72cacc7..26eda90514f5fe8c1cc71bcf26246dc6154b5885 100644 --- a/crates/ui/src/tests.rs +++ b/crates/ui/src/tests.rs @@ -1840,6 +1840,60 @@ fn colour_grids_move_in_two_dimensions_and_choose_a_swatch_or_none() { assert_eq!(build(&mut ui), Some(None)); } +#[test] +fn colour_grids_highlight_the_swatch_pointed_at_until_the_pointer_leaves() { + let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); + let grid = Id::ROOT.child("colours"); + let swatches = [([1.0, 0.0, 0.0, 1.0], ""), ([0.0, 1.0, 0.0, 1.0], "")]; + let build = |ui: &mut Ui| { + frame(ui, |ui| { + let anchor = below(ui); + popup::colors(ui, grid, anchor, "Automatic", &swatches, 2); + }); + }; + build(&mut ui); + ui.open_popup(grid); + for _ in 0..40 { + build(&mut ui); + } + let rect = ui.laid_out(grid.child(("cell", 2_usize))).unwrap(); + ui.event(Event::PointerMoved([rect[0] + 4.0, rect[1] + 4.0])); + build(&mut ui); + assert_eq!(popup::highlight(&ui, grid), Some(2)); + ui.event(Event::PointerMoved([399.0, 299.0])); + build(&mut ui); + assert_eq!(popup::highlight(&ui, grid), None); +} + +#[test] +fn docked_popups_lie_inside_their_box_along_its_trailing_edge() { + let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); + let dock = Id::ROOT.child("dock"); + let build = |ui: &mut Ui| { + frame(ui, |ui| { + let page = spot(ui, [20.0, 40.0, 380.0, 290.0]); + if ui.popup_open(dock) { + ui.open_as( + dock, + Spec { + size: [px(100.0), px(200.0)], + anchor: Some(Anchor::Dock(page)), + ..Spec::default() + }, + ); + ui.close(); + } + }); + }; + build(&mut ui); + ui.open_popup(dock); + for _ in 0..40 { + build(&mut ui); + } + let [left, top, right, bottom] = ui.laid_out(dock).unwrap(); + assert_eq!([left, top, right, bottom], [276.0, 44.0, 376.0, 244.0]); +} + #[test] fn table_pickers_choose_columns_and_rows_by_keys_or_a_click() { let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); -- 2.54.0