From 24912d53294b686bf8bfebdd7ab2e17ca14aba1f Mon Sep 17 00:00:00 2001 From: clover caruso Date: Tue, 29 Sep 2026 09:15:51 -0700 Subject: [PATCH] feat: custom page colours, more backgrounds, sync icon, draggable tab row, and the 10.6 gradient through the tabs - Page Color menu: a third row of background art ending in Show All, which opens the template art gallery for any page; a rainbow Custom Color slot opens a hue, lightness and saturation picker. - The sync status is always an icon, with its state as the tooltip. - Folded toolbar groups hand their width back to the row, so trailing controls sit flush with the page frame. - Empty tab-row space drags the window and double-click zooms it. - Mac OS X 10.6: the unified gradient runs through the tab row to the notebook frame. Assisted-by: claude-opus-5.5 --- crates/notebook/examples/scratch_bgsync.rs | 42 ++++ .../snowbound/assets/icons/custom-color.svg | 11 + crates/snowbound/src/aqua.rs | 2 +- crates/snowbound/src/art.rs | 1 + crates/snowbound/src/background.rs | 170 ++++++++++---- crates/snowbound/src/commands.rs | 5 +- crates/snowbound/src/macos.rs | 5 +- crates/snowbound/src/main.rs | 63 ++--- crates/snowbound/src/sync.rs | 49 ++-- crates/snowbound/src/templates.rs | 55 ++++- crates/ui/src/layout.rs | 13 +- crates/ui/src/lib.rs | 3 + crates/ui/src/popup.rs | 221 ++++++++++++++++++ crates/ui/src/tests.rs | 29 ++- 14 files changed, 547 insertions(+), 122 deletions(-) create mode 100644 crates/notebook/examples/scratch_bgsync.rs create mode 100644 crates/snowbound/assets/icons/custom-color.svg diff --git a/crates/notebook/examples/scratch_bgsync.rs b/crates/notebook/examples/scratch_bgsync.rs new file mode 100644 index 0000000000000000000000000000000000000000..5608de2f4f315edb01272dbf7c3fc75e6fc08283 --- /dev/null +++ b/crates/notebook/examples/scratch_bgsync.rs @@ -0,0 +1,42 @@ +//! Scratch lab writer (delete before reporting): appends TEXT to the first body paragraph of +//! the first page of a share-relative section. `scratch_bgsync ADDRESS PATH TEXT` +use notebook::{ + Replica, SmbRemote, + smb::{Client, Credentials}, +}; +use onestore::op::{Edit, Op, PageOp}; +use std::time::Duration; + +fn main() -> Result<(), Box> { + let args: Vec<_> = std::env::args().skip(1).collect(); + let [address, path, with] = &args[..] else { + return Err("scratch_bgsync ADDRESS PATH TEXT".into()); + }; + let connect = || Client::connect(address, "agent", Credentials::default(), Duration::from_secs(5)); + let source = connect()?.read_storage(path, 1 << 26)?; + let directory = tempfile::tempdir()?; + let replica = Replica::create(directory.path().join("cache.sqlite"), &source)?; + let space = replica.pages()?[0].0; + let page = replica.page(space)?; + let (text, len) = page + .objects + .iter() + .find_map(|object| match object { + onestore::page::PageObject::Outline(outline) => outline + .paragraphs + .iter() + .find_map(|p| p.text().map(|t| (t.id, t.text.text().encode_utf16().count() as u32))), + _ => None, + }) + .ok_or("no text")?; + replica.apply( + "Lab", + Edit { + at: 134_000_000_000_000_000, + ops: vec![Op::Page { space, op: PageOp::Text { text, range: len..len, with: with.clone() } }], + }, + )?; + let mut remote = SmbRemote::new(connect()?, path.clone(), 1 << 26); + println!("{:?}", replica.sync_once(&mut remote)?.edit); + Ok(()) +} diff --git a/crates/snowbound/assets/icons/custom-color.svg b/crates/snowbound/assets/icons/custom-color.svg new file mode 100644 index 0000000000000000000000000000000000000000..dabda014f81754f7bcfb155137194e6196bcd75c --- /dev/null +++ b/crates/snowbound/assets/icons/custom-color.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/snowbound/src/aqua.rs b/crates/snowbound/src/aqua.rs index 4ad03391dfcbd105b25bfda25d62ba74fd2ac6ab..3945bc8c008404b52c0e6ee2b4931f7973fa7408 100644 --- a/crates/snowbound/src/aqua.rs +++ b/crates/snowbound/src/aqua.rs @@ -29,7 +29,7 @@ pub(crate) fn before_lion() -> bool { } /// Makes the window textured, AppKit's gradient running from the title through `row` points -/// of content below it, as 10.6's unified toolbars do. Where frames are transparent it +/// of content below it, as 10.6's unified toolbars run theirs. Where frames are transparent it /// shows through. Returns whether the system draws windows so, which only 10.6 does here. pub fn textured(window: &Window, row: f32) -> bool { if !before_lion() { diff --git a/crates/snowbound/src/art.rs b/crates/snowbound/src/art.rs index 9be17b1ef36889f6b7ee668193d237be5d5f2a48..74c3fffb528dc2a1619d7212304b2142e124aa5a 100644 --- a/crates/snowbound/src/art.rs +++ b/crates/snowbound/src/art.rs @@ -55,6 +55,7 @@ pub const FORMAT_PAINTER: &[&str] = art!("icons/format-painter"); pub const FORWARD: &[&str] = art!("icons/forward"); pub const INSERT_SPACE: &[&str] = art!("icons/insert-space"); pub const PAGE_COLOR: &[&str] = art!("icons/page-color"); +pub const CUSTOM_COLOR: &[&str] = art!("icons/custom-color"); pub const PASTE: &[&str] = art!("icons/paste"); pub const RECORD_AUDIO: &[&str] = art!("icons/record-audio"); pub const RECORD_VIDEO: &[&str] = art!("icons/record-video"); diff --git a/crates/snowbound/src/background.rs b/crates/snowbound/src/background.rs index 2a709d9618dfc0c17d75b25059e7ddf1a1faf274..2c18911499bd489390bbddc685dbb022a1240203 100644 --- a/crates/snowbound/src/background.rs +++ b/crates/snowbound/src/background.rs @@ -2,8 +2,9 @@ //! lines. use crate::{ + art, commands::Choice, - templates::{TILE, Thumbnails}, + templates::{TILE, Thumbnails, View}, }; use canvas::{ gpu::{Paper, colorref}, @@ -17,38 +18,84 @@ const SWATCH: [f32; 2] = [40.0, 32.0]; const RULES: f32 = 40.0; /// Points of page per point of a rule-line thumbnail. const SHRINK: f32 = 4.0; -/// The art offered after None. -const ART: [&str; 5] = ["Ivy", "Purple Clouds", "Notebook", "Bamboo", "Blue Clouds"]; +/// The art offered between None and Show All. +const ART: [&str; 7] = [ + "Ivy", + "Purple Clouds", + "Notebook", + "Blue Clouds", + "Tulips", + "Bamboo", + "Sparks", +]; + +#[derive(Clone, Copy, PartialEq)] +enum Cell { + /// A COLORREF, or none. + Color(Option), + Custom, + Art(Option<&'static str>), + ShowAll, + /// An index into `RULE_LINES`, or none. + Rules(Option), +} /// 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`. Returns the choice made. +/// 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 +/// choice made. #[allow(clippy::too_many_arguments)] pub fn menu( ui: &mut Ui, id: Id, anchor: Anchor, thumbnails: &mut Thumbnails, + view: &mut View, paper: Paper, color: Option, rule_lines: Option, ) -> Option { - let cells: Vec = std::iter::once(Choice::PageColor(None)) - .chain((0..PAGE_COLORS.len()).map(|index| Choice::PageColor(Some(index)))) - .chain(std::iter::once(Choice::Art(None))) - .chain(ART.map(|name| Choice::Art(Some(name)))) - .chain(std::iter::once(Choice::RuleLines(None))) - .chain((0..RULE_LINES.len()).map(|index| Choice::RuleLines(Some(index)))) + let picker = id.child("custom"); + // A pale blue to start from on a page without a colour. + let [red, green, blue, _] = color.unwrap_or(0x00f8eedd).to_le_bytes(); + if let Some([red, green, blue]) = ui::popup::color_picker( + ui, + picker, + anchor, + "Custom Color", + [red, green, blue], + |[red, green, blue]| { + paper + .colored(Some(u32::from_le_bytes([red, green, blue, 0]))) + .color + }, + ) { + return Some(Choice::PageColor(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( + PAGE_COLORS + .iter() + .map(|(_, color)| Cell::Color(Some(*color))), + ) + .chain([Cell::Custom, Cell::Art(None)]) + .chain(ART.map(|name| Cell::Art(Some(name)))) + .chain([Cell::ShowAll, Cell::Rules(None)]) + .chain((0..RULE_LINES.len()).map(|index| Cell::Rules(Some(index)))) .collect(); let groups = [ ui::popup::Group { heading: "Page Color", - cells: 1 + PAGE_COLORS.len(), + cells: 2 + PAGE_COLORS.len(), columns: COLUMNS, size: SWATCH, }, ui::popup::Group { heading: "Background", - cells: 1 + ART.len(), + cells: 2 + ART.len(), columns: 3, size: [TILE[0] + 8.0, TILE[1] + 8.0], }, @@ -60,13 +107,12 @@ pub fn menu( }, ]; let shown = [ - Choice::PageColor(color.map(|color| { - PAGE_COLORS - .iter() - .position(|(_, listed)| *listed == color) - .unwrap_or(usize::MAX) - })), - Choice::RuleLines(rule_lines.map(|lines| { + if custom.is_some() { + Cell::Custom + } else { + Cell::Color(color) + }, + Cell::Rules(rule_lines.map(|lines| { RULE_LINES .iter() .position(|(_, listed)| { @@ -79,32 +125,33 @@ pub fn menu( ]; let current: Vec = shown .iter() - .filter_map(|choice| cells.iter().position(|cell| cell == choice)) + .filter_map(|shown| cells.iter().position(|cell| cell == shown)) .collect(); let theme = ui.theme.clone(); let scale = ui.scale(); let dark = paper.ink[0] > paper.color[0]; + let label = |ui: &mut Ui, text: &str| { + ui.leaf( + "label", + Spec { + size: [fill(), fill()], + text: Some(text), + font_size: Some(theme.font_size - 2.0), + fill: Some(paper.color), + border: Some(theme.chip), + radius: 2.0, + center: true, + ..Spec::default() + }, + ); + }; let chosen = ui::popup::gallery(ui, id, anchor, &groups, ¤t, |ui, index| { match cells[index] { - Choice::PageColor(None) | Choice::Art(None) | Choice::RuleLines(None) => { + Cell::Color(None) | Cell::Art(None) | Cell::Rules(None) => label(ui, "None"), + Cell::ShowAll => label(ui, "Show All…"), + Cell::Color(Some(color)) => { ui.leaf( - "none", - Spec { - size: [fill(), fill()], - text: Some("None"), - font_size: Some(theme.font_size - 2.0), - fill: Some(paper.color), - border: Some(theme.chip), - radius: 2.0, - center: true, - ..Spec::default() - }, - ); - } - Choice::PageColor(Some(index)) => { - let (name, color) = PAGE_COLORS[index]; - ui.leaf( - name, + "swatch", Spec { size: [fill(), fill()], fill: Some(paper.colored(Some(color)).color), @@ -114,7 +161,35 @@ pub fn menu( }, ); } - Choice::Art(Some(name)) => { + // The rainbow, or the page's custom colour with the rainbow in its corner. + Cell::Custom => { + ui.open( + "custom", + Spec { + size: [fill(), fill()], + fill: custom.map(|color| paper.colored(Some(color)).color), + border: custom.map(|_| theme.chip), + radius: 2.0, + icon: custom.is_none().then_some(art::CUSTOM_COLOR), + center: true, + ..Spec::default() + }, + ); + if custom.is_some() { + ui.leaf( + "rainbow", + Spec { + flags: Flags::FLOAT, + size: [px(12.0), px(12.0)], + position: [SWATCH[0] - 22.0, SWATCH[1] - 22.0], + icon: Some(art::CUSTOM_COLOR), + ..Spec::default() + }, + ); + } + ui.close(); + } + Cell::Art(Some(name)) => { ui.open( name, Spec { @@ -143,11 +218,22 @@ pub fn menu( } ui.close(); } - Choice::RuleLines(Some(index)) => rule_thumbnail(ui, paper, RULE_LINES[index]), - _ => unreachable!("The menu offers colours, art and rule lines"), + Cell::Rules(Some(index)) => rule_thumbnail(ui, paper, RULE_LINES[index]), } })?; - Some(cells[chosen].clone()) + match cells[chosen] { + Cell::Color(color) => Some(Choice::PageColor(color)), + Cell::Custom => { + ui.open_popup(picker); + None + } + Cell::Art(name) => Some(Choice::Art(name)), + Cell::ShowAll => { + *view = View::Art; + None + } + Cell::Rules(index) => Some(Choice::RuleLines(index)), + } } /// A corner of a page ruled with `lines`, drawn a quarter size. diff --git a/crates/snowbound/src/commands.rs b/crates/snowbound/src/commands.rs index b5bc521080fd18a501f43e880795f149893c4f8f..420ee3f08b9a9315d2b6f74054240d7670965d94 100644 --- a/crates/snowbound/src/commands.rs +++ b/crates/snowbound/src/commands.rs @@ -82,8 +82,8 @@ pub enum Choice { List(Option), /// An empty table of rows by columns. Table([usize; 2]), - /// An index into `PAGE_COLORS`, or `None` for no colour. - PageColor(Option), + /// A page colour, COLORREF, or `None` for no colour. + PageColor(Option), /// An index into `RULE_LINES`, or `None` for none. RuleLines(Option), /// A template whose art becomes the page's background, or `None` for none. @@ -815,7 +815,6 @@ impl State { return Ok(()); } Choice::PageColor(color) => { - let color = color.map(|index| canvas::template::PAGE_COLORS[index].1); return self.paper_page(color, self.view.editor.rule_lines()); } Choice::Art(name) => { diff --git a/crates/snowbound/src/macos.rs b/crates/snowbound/src/macos.rs index 1858d5e573b063d325b4219bfbb761225be71977..21ef151bf0d0118cddc65db877471adfd1b9608e 100644 --- a/crates/snowbound/src/macos.rs +++ b/crates/snowbound/src/macos.rs @@ -254,9 +254,10 @@ unsafe extern "C" fn hit_nothing(_: &AnyObject, _: Sel, _: NSPoint) -> *mut AnyO /// through it to the view it lies in. pub fn install_backdrop(window: &Window) -> bool { MainThreadMarker::new().expect("Views belong to the main thread"); - // Before 10.10 AppKit has no materials; 10.6's textured window stands in. + // Before 10.10 AppKit has no materials; 10.6's textured window stands in, its gradient + // running through the toolbar and the tab row to the notebook's frame. let Some(effect) = AnyClass::get("NSVisualEffectView") else { - return crate::aqua::textured(window, crate::TITLE); + return crate::aqua::textured(window, crate::TITLE + crate::TAB_ROW); }; let RawWindowHandle::AppKit(handle) = window.window_handle().expect("Live AppKit window").as_raw() diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 885971bbd4c69485bab4e505f88227c9e733611b..29f523d3b770f19bfde553e3d26fd671b6de8f73 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -68,11 +68,10 @@ use winit::{ /// Height of the toolbar's row, which is the title bar where the platform lets it: a /// unified compact toolbar's on macOS. const TITLE: f32 = 38.0; -/// Space between toolbar groups, and the toolbar's margin at an edge without window controls. +/// Space between toolbar groups. const GAP: f32 = 6.0; -const EDGE: f32 = 8.0; const TAB_ROW: f32 = 28.0; -/// Width of the section colour around the page. +/// Width of the section colour around the page, and the rows' margin at the window's sides. const FRAME: f32 = 6.0; const PAGE_LIST: f32 = 240.0; /// Extensions of the pictures Insert, Picture offers: those the page both stores and draws. @@ -593,6 +592,11 @@ fn strip() -> Id { Id::ROOT.child("strip") } +/// The section tabs' row, whose empty space drags the window as the strip's does. +fn tab_row() -> Id { + Id::ROOT.child("tab row") +} + fn page() -> Id { Id::ROOT.child("page") } @@ -1042,14 +1046,18 @@ impl State { ..Spec::default() }, ); - let tabs = self.ui.id("tabs"); let height = self .ui - .animate(tabs, if self.full_page { 0.0 } else { TAB_ROW }); - let tab_row = self.ui.open( - "tabs", + .animate(tab_row(), if self.full_page { 0.0 } else { TAB_ROW }); + let drags = self.chrome_drags(); + let tab_row = self.ui.open_as( + tab_row(), Spec { - flags: Flags::CLIP, + flags: if drags { + Flags::CLIP | Flags::CLICKABLE + } else { + Flags::CLIP + }, size: [fill(), px(height)], fill: Some(theme.strip), pad: [FRAME, 0.0], @@ -1387,6 +1395,12 @@ impl State { ); } + /// Whether the chrome's empty space drags the window: where it is the title bar, or + /// over the title's gradient. + fn chrome_drags(&self) -> bool { + !platform::system_titlebar(&self.window) || self.surface.translucent() + } + /// The toolbar: one row of groups that fold as the window narrows. Where the platform /// lets it, the row is the window's title bar, and on the welcome page, without `tools`, /// that is all it is. @@ -1395,8 +1409,7 @@ impl State { if !tools && !title { return Ok(()); } - // Over the title's gradient, the row's empty space drags the window as the title does. - let strip_row = title || self.surface.translucent(); + let strip_row = self.chrome_drags(); let spec = Spec { flags: if strip_row { Flags::CLICKABLE @@ -1414,7 +1427,7 @@ impl State { } else { self.ui.open("toolbar", spec); } - let lead = if title { platform::LEADING } else { EDGE }; + let lead = if title { platform::LEADING } else { FRAME }; self.ui.leaf( "lead", Spec { @@ -1439,7 +1452,7 @@ impl State { self.ui.leaf( "trail", Spec { - size: [px(EDGE - GAP), px(1.0)], + size: [px(FRAME - GAP), px(1.0)], ..Spec::default() }, ); @@ -1662,7 +1675,7 @@ impl State { choice = group( ui, "character", - 6, + 5, |ui| { divider(ui, theme); let mut choice = None; @@ -1757,7 +1770,7 @@ impl State { choice = group( ui, "paragraph", - 5, + 4, |ui| { divider(ui, theme); let mut choice = None; @@ -1885,7 +1898,7 @@ impl State { choice = group( ui, "tags", - 7, + 6, |ui| { divider(ui, theme); let mut choice = None; @@ -1950,17 +1963,7 @@ impl State { }, ); if let Some(session) = session { - ui.open( - "sync", - Spec { - fold: Some(4), - ..Spec::default() - }, - ); - for compact in [false, true] { - sync::control(ui, session, theme, compact); - } - ui.close(); + sync::control(ui, session, theme); } // Spelling awaits a platform spell checker. let views = [ @@ -1991,9 +1994,11 @@ impl State { toolbar_popup("page color"), anchor, &mut self.thumbnails, + &mut self.templates, + // The page's own colour aside. canvas::gpu::Paper { - color: theme.paper, - ink: theme.paper_ink, + color: ui.theme.paper, + ink: ui.theme.paper_ink, }, self.view.editor.page_color(), self.view.editor.rule_lines(), @@ -3111,7 +3116,7 @@ impl State { pressed: true, at, } = event - && self.ui.box_at(self.pointer) == Some(strip()) + && matches!(self.ui.box_at(self.pointer), Some(id) if id == strip() || id == tab_row()) { let double = self.strip_press.is_some_and(|last| { at.saturating_duration_since(last) <= platform::double_click_interval() diff --git a/crates/snowbound/src/sync.rs b/crates/snowbound/src/sync.rs index eda9b6c19a213d6e19c3caa1211afe318fdcaa18..4e498fb752bf33dbe4622997f63acf36aeae7a38 100644 --- a/crates/snowbound/src/sync.rs +++ b/crates/snowbound/src/sync.rs @@ -71,47 +71,26 @@ fn when(time: u64) -> String { } } -/// The status in the toolbar, which opens the popup: its label, or where `compact` its icon, -/// naming it in a tooltip. -pub(crate) fn control(ui: &mut Ui, session: &Session, theme: &Theme, compact: bool) { +/// The status's icon in the toolbar, named in its tooltip, which opens the popup. +pub(crate) fn control(ui: &mut Ui, session: &Session, theme: &Theme) { let strong = ui.popup_open(id()) || session.sync.error.is_some() && !library::offline(); let (label, icon, _) = describe(&session.sync); - let spec = Spec { - flags: Flags::CLICKABLE, - color: Some(if strong { theme.text } else { theme.text_dim }), - hover_fill: Some(theme.hover()), - radius: 4.0, - center: true, - ..Spec::default() - }; - // The popup opens from the label's box, which stands where the icon shows. - let shown = if compact { - button().child("icon") - } else { - button() - }; ui.open_as( - shown, - if compact { - Spec { - size: [px(TOOL), px(TOOL)], - icon: Some(icon), - ..spec - } - } else { - Spec { - size: [fit(), px(TOOL)], - text: Some(label), - pad: [8.0, 0.0], - ..spec - } + button(), + Spec { + flags: Flags::CLICKABLE, + size: [px(TOOL), px(TOOL)], + icon: Some(icon), + color: Some(if strong { theme.text } else { theme.text_dim }), + hover_fill: Some(theme.hover()), + radius: 4.0, + center: true, + ..Spec::default() }, ); ui.close(); - if compact { - ui::popup::tooltip(ui, label, "", None); - } - if ui.signal(shown).clicked { + ui::popup::tooltip(ui, label, "", None); + if ui.signal(button()).clicked { if ui.popup_open(id()) { ui.close_popup(id()); } else { diff --git a/crates/snowbound/src/templates.rs b/crates/snowbound/src/templates.rs index 941ca8efe05f00b4b8e6ffc62942371b02d8fef9..496cdb59daaacd54909a3c0a490a11ac9f66ff44 100644 --- a/crates/snowbound/src/templates.rs +++ b/crates/snowbound/src/templates.rs @@ -48,6 +48,8 @@ pub enum View { /// 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, } /// A thumbnail's art: each raster and where it lies in the tile. @@ -199,7 +201,8 @@ fn strip( chosen } -/// Every template, as a scrolling grid over the page box `size` big. +/// Every template, or with `art` only their art, as a scrolling grid over the page box +/// `size` big. fn gallery( ui: &mut Ui, theme: &Theme, @@ -207,6 +210,7 @@ fn gallery( paper: Paper, dark: bool, size: [f32; 2], + art: bool, ) -> Option { let mut chosen = None; let margin = 24.0; @@ -236,7 +240,7 @@ fn gallery( "title", Spec { size: [fill(), px(24.0)], - text: Some("Page Templates"), + text: Some(if art { "Backgrounds" } else { "Page Templates" }), ..Spec::default() }, ); @@ -264,7 +268,8 @@ fn gallery( PAGE_COLORS .iter() .enumerate() - .map(|(index, (name, _))| (Choice::Color(index), *name)), + .map(|(index, (name, _))| (Choice::Color(index), *name)) + .filter(|_| !art), ) .collect(); for (row, choices) in choices.chunks(columns).enumerate() { @@ -411,6 +416,9 @@ impl crate::State { let Some(session) = &self.session else { return; }; + if self.templates == View::Art { + return self.art_gallery(theme); + } let space = session.space; let editor = &self.view.editor; let blank = editor @@ -440,8 +448,17 @@ impl crate::State { 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) + 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, @@ -475,4 +492,34 @@ impl crate::State { 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.rect(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( + &mut self.ui, + theme, + &mut self.thumbnails, + paper, + dark, + size, + true, + ) { + Some(Choice::Template(name)) => { + self.templates = View::Strip; + self.choose(crate::commands::Choice::Art(Some(name))); + } + Some(Choice::Dismiss) => self.templates = View::Strip, + _ => {} + } + } } diff --git a/crates/ui/src/layout.rs b/crates/ui/src/layout.rs index e496441c6a71ff2da813e535a102110ff6df39ff..c1aa77ebd8ad56b68022d766b1d61818628d0ded 100644 --- a/crates/ui/src/layout.rs +++ b/crates/ui/src/layout.rs @@ -3,8 +3,8 @@ use std::collections::HashMap; /// Sizes each box on both axes, then places it: standalone sizes, sizes taken from /// ancestors (pre-order), sizes summed from children (post-order), overflow taken back -/// (from space sized by ancestors, then by folding a row's groups, then from the least -/// strict boxes first), and positions along each parent's flow. Labels too wide for their +/// (by folding a row's groups for what space sized by ancestors can't give, then from that +/// space, then from the least strict boxes first), and positions along each parent's flow. Labels too wide for their /// solved width wrap or shorten before heights are solved. Boxes are in build order, so /// index order is pre-order. pub(crate) fn solve( @@ -75,10 +75,12 @@ pub(crate) fn solve( if along(node, axis) { let space = |child: &&usize| matches!(nodes[**child].size[axis].size, Size::Fraction(_)); - let (space, sized): (Vec<_>, Vec<_>) = children.iter().partition(space); + let (space, sized): (Vec, Vec<_>) = children.iter().partition(space); let mut excess = flow(nodes, index, axis) - room; - excess = give_back(nodes, &space, axis, excess); - while excess > 0.0 && axis == 0 { + // Groups fold only for what the space can't give, so the room a fold frees + // goes back to the space. + let spare: f32 = space.iter().map(|child| nodes[*child].computed[axis]).sum(); + while excess > spare && axis == 0 { let Some(group) = children .iter() .copied() @@ -96,6 +98,7 @@ pub(crate) fn solve( excess -= nodes[group].computed[0] - width; nodes[group].computed[0] = width; } + excess = give_back(nodes, &space, axis, excess); let mut tiers: Vec = sized .iter() .map(|child| strictness(nodes, *child, axis)) diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 7f2467804556dc5338f3d506ba5a0f149fae6a05..b9e03f0f6f97d06abb84eb3826ab15930067d507 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -542,6 +542,8 @@ struct Popup { /// Its filter field's text, and the key of the row the keyboard or pointer last chose. query: String, highlight: Option, + /// A colour picker's hue in degrees, saturation and lightness, once it has one. + picked: Option<[f32; 3]>, /// The height its results ease from and to, and the seconds since they set out. height: Option<[f32; 3]>, opened: Instant, @@ -1000,6 +1002,7 @@ impl Ui { focus: self.focus, query: String::new(), highlight: None, + picked: None, height: None, opened: self.now, }); diff --git a/crates/ui/src/popup.rs b/crates/ui/src/popup.rs index ff35cea6d8bd864bdd3e13aa1c6f8ac5be0a4bce..4ffb7534af4ee799ceaa7e5042a7bd3fd28dc3bd 100644 --- a/crates/ui/src/popup.rs +++ b/crates/ui/src/popup.rs @@ -814,6 +814,227 @@ pub fn gallery( None } +/// A colour picker's hue and lightness field, and its saturation bar beneath. +const FIELD: [f32; 2] = [256.0, 128.0]; +const BAR: f32 = 14.0; +/// Columns the field is drawn in, each a gradient from white to the hue and to black. +const HUES: usize = 64; + +/// Builds popup `id` beside `anchor` while it is open as a colour picker titled `title`, +/// starting from `initial`, sRGB: hue across a field drawn fully saturated, lightness down +/// it, saturation along a bar, and a preview of the colour as `preview` shows it, linear +/// RGBA. Returns the colour applied. +pub fn color_picker( + ui: &mut Ui, + id: Id, + anchor: Anchor, + title: &str, + initial: [u8; 3], + preview: impl Fn([u8; 3]) -> [f32; 4], +) -> Option<[u8; 3]> { + if !ui.popup_open(id) { + return None; + } + let [field, bar] = ["field", "saturation"].map(|part| id.child(part)); + let apply = id.child("footer").child("apply"); + let mut picked = state(ui, id).picked.unwrap_or_else(|| to_hsl(initial)); + let pointer = ui.pointer(); + let along = |ui: &Ui, part: Id| { + let [left, top, right, bottom] = ui.rect(part)?; + let [x, y] = pointer?; + Some([ + ((x - left) / (right - left)).clamp(0.0, 1.0), + ((y - top) / (bottom - top)).clamp(0.0, 1.0), + ]) + }; + let held = |ui: &mut Ui, part: Id| { + let signal = ui.signal(part); + signal.pressed || signal.dragging + }; + if held(ui, field) + && let Some([x, y]) = along(ui, field) + { + picked[0] = x * 360.0; + picked[2] = 1.0 - y; + } + if held(ui, bar) + && let Some([x, _]) = along(ui, bar) + { + picked[1] = x; + } + let keys = navigation(ui, &[id], &[NamedKey::Enter]); + state(ui, id).picked = Some(picked); + let rgb = from_hsl(picked); + if ui.signal(apply).clicked || !keys.is_empty() { + ui.close_popup(id); + return Some(rgb); + } + + let theme = ui.theme.clone(); + surface(ui, id, anchor, FIELD[0] + 2.0 * ui.theme.menu().pad); + let color = |hsl| { + let [red, green, blue] = from_hsl(hsl); + draw::srgb(red, green, blue) + }; + let [hue, saturation, lightness] = picked; + ui.leaf( + "heading", + Spec { + size: [fill(), px(MENU_ROW)], + text: Some(title), + font_size: Some(theme.font_size - 2.0), + bold: true, + color: Some(theme.text_dim), + pad: [PAD, 0.0], + ..Spec::default() + }, + ); + ui.open_as( + field, + Spec { + flags: Flags::CLICKABLE, + size: [px(FIELD[0]), px(FIELD[1])], + ..Spec::default() + }, + ); + let column = FIELD[0] / HUES as f32; + for index in 0..HUES { + let shade = (index as f32 + 0.5) / HUES as f32 * 360.0; + for (half, [top, bottom]) in [[1.0, 0.5], [0.5, 0.0]].into_iter().enumerate() { + ui.leaf( + (index, half), + Spec { + flags: Flags::FLOAT, + position: [index as f32 * column, half as f32 * FIELD[1] / 2.0], + size: [px(column + 0.5), px(FIELD[1] / 2.0)], + fill: Some(color([shade, 1.0, top])), + gradient: Some(color([shade, 1.0, bottom])), + ..Spec::default() + }, + ); + } + } + // A white ring inside a dark one, seen on any colour. + let ring = |ui: &mut Ui, part: &str, at: [f32; 2]| { + for (index, (radius, color)) in [(6.0, [0.0, 0.0, 0.0, 0.6]), (5.0, [1.0; 4])] + .into_iter() + .enumerate() + { + ui.leaf( + (part, index), + Spec { + flags: Flags::FLOAT, + position: [at[0] - radius, at[1] - radius], + size: [px(2.0 * radius), px(2.0 * radius)], + border: Some(color), + radius, + ..Spec::default() + }, + ); + } + }; + ring( + ui, + "ring", + [hue / 360.0 * FIELD[0], (1.0 - lightness) * FIELD[1]], + ); + ui.close(); + ui.open_as( + bar, + Spec { + flags: Flags::CLICKABLE, + size: [px(FIELD[0]), px(BAR)], + radius: 2.0, + ..Spec::default() + }, + ); + let steps = 32; + let step = FIELD[0] / steps as f32; + for index in 0..steps { + ui.leaf( + index, + Spec { + flags: Flags::FLOAT, + position: [index as f32 * step, 0.0], + size: [px(step + 0.5), px(BAR)], + fill: Some(color([hue, index as f32 / (steps - 1) as f32, lightness])), + ..Spec::default() + }, + ); + } + ring(ui, "ring", [saturation * FIELD[0], BAR / 2.0]); + ui.close(); + ui.open( + "footer", + Spec { + size: [fill(), px(ROW)], + gap: 8.0, + ..Spec::default() + }, + ); + ui.leaf( + "preview", + Spec { + size: [px(2.0 * ROW), px(ROW)], + fill: Some(preview(rgb)), + border: Some(theme.chip), + radius: 4.0, + ..Spec::default() + }, + ); + let [red, green, blue] = rgb; + let hex = format!("#{red:02X}{green:02X}{blue:02X}"); + ui.leaf( + "hex", + Spec { + size: [fill(), px(ROW)], + text: Some(&hex), + color: Some(theme.text_dim), + ..Spec::default() + }, + ); + crate::button(ui, "apply", "Apply"); + ui.close(); + ui.close(); + None +} + +/// 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); + let (most, least) = (red.max(green).max(blue), red.min(green).min(blue)); + let lightness = (most + least) / 2.0; + let chroma = most - least; + if chroma == 0.0 { + return [0.0, 0.0, lightness]; + } + let saturation = chroma / (1.0 - (2.0 * lightness - 1.0).abs()); + let sector = if most == red { + ((green - blue) / chroma).rem_euclid(6.0) + } else if most == green { + (blue - red) / chroma + 2.0 + } else { + (red - green) / chroma + 4.0 + }; + [sector * 60.0, saturation, lightness] +} + +fn from_hsl([hue, saturation, lightness]: [f32; 3]) -> [u8; 3] { + let chroma = (1.0 - (2.0 * lightness - 1.0).abs()) * saturation; + let sector = (hue / 60.0).rem_euclid(6.0); + let second = chroma * (1.0 - (sector % 2.0 - 1.0).abs()); + let [red, green, blue] = match sector as u32 { + 0 => [chroma, second, 0.0], + 1 => [second, chroma, 0.0], + 2 => [0.0, chroma, second], + 3 => [0.0, second, chroma], + 4 => [second, 0.0, chroma], + _ => [chroma, 0.0, second], + }; + let base = lightness - chroma / 2.0; + [red, green, blue].map(|channel| ((channel + base) * 255.0).round().clamp(0.0, 255.0) as u8) +} + /// Opens popup `id`'s panel `width` wide beside `anchor`; the caller closes it. fn surface(ui: &mut Ui, id: Id, anchor: Anchor, width: f32) { let style = ui.theme.menu(); diff --git a/crates/ui/src/tests.rs b/crates/ui/src/tests.rs index 5e84bec039746ff1695f9bc9890dfd2c015988d0..4a4ef37824aa5cf992ad51906534d0c83f8a250e 100644 --- a/crates/ui/src/tests.rs +++ b/crates/ui/src/tests.rs @@ -209,7 +209,8 @@ fn a_row_squeezes_its_boxes_only_once_every_group_is_folded() { let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); let rects = folding_row(&mut ui, 130.0, &[0, 1]); // 80 + 20 + 20 fits 130 with 10 to spare, taken by the space. - assert_eq!(rects[0], Some([0.0, 0.0, 80.0, 20.0])); + assert_eq!(rects[0], Some([10.0, 0.0, 90.0, 20.0])); + assert_eq!(rects[4], Some([110.0, 0.0, 130.0, 20.0])); let rects = folding_row(&mut ui, 100.0, &[0, 1]); assert_eq!(rects[0], Some([0.0, 0.0, 60.0, 20.0])); assert_eq!(rects[4], Some([80.0, 0.0, 100.0, 20.0])); @@ -2516,3 +2517,29 @@ fn the_wheel_scrolls_overflowing_tabs_sideways_either_way_it_turns() { let back = ui.rect(shell::tab_id(row, 0)).unwrap()[0]; assert!((back - scrolled - 30.0).abs() < 0.5, "{scrolled} to {back}"); } + +#[test] +fn a_colour_picker_applies_its_colour_and_keeps_it_through_hsl() { + let mut ui = Ui::new(Theme::light(), DOUBLE_CLICK); + let picker = Id::ROOT.child("picker"); + let build = |ui: &mut Ui| { + let mut chosen = None; + frame(ui, |ui| { + chosen = popup::color_picker( + ui, + picker, + BELOW, + "Custom Color", + [0xd4, 0xf9, 0xf2], + |_| [1.0; 4], + ); + }); + chosen + }; + build(&mut ui); + ui.open_popup(picker); + assert_eq!(build(&mut ui), None); + ui.event(key(NamedKey::Enter)); + assert_eq!(build(&mut ui), Some([0xd4, 0xf9, 0xf2])); + assert!(!ui.popup_open(picker)); +} -- 2.54.0