authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-29 09:15:51-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-29 09:32:41-07:00
log24912d53294b686bf8bfebdd7ab2e17ca14aba1f
tree03559933d286545395d2cef8b1b12ff3f29f3ac6
parentcc021c98d7181a67b148b9ce001f9411260accd5
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

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

14 files changed, 541 insertions(+), 116 deletions(-)

crates/notebook/examples/scratch_bgsync.rs created+42
......@@ -0,0 +1,42 @@
1//! Scratch lab writer (delete before reporting): appends TEXT to the first body paragraph of
2//! the first page of a share-relative section. `scratch_bgsync ADDRESS PATH TEXT`
3use notebook::{
4 Replica, SmbRemote,
5 smb::{Client, Credentials},
6};
7use onestore::op::{Edit, Op, PageOp};
8use std::time::Duration;
9
10fn main() -> Result<(), Box<dyn std::error::Error>> {
11 let args: Vec<_> = std::env::args().skip(1).collect();
12 let [address, path, with] = &args[..] else {
13 return Err("scratch_bgsync ADDRESS PATH TEXT".into());
14 };
15 let connect = || Client::connect(address, "agent", Credentials::default(), Duration::from_secs(5));
16 let source = connect()?.read_storage(path, 1 << 26)?;
17 let directory = tempfile::tempdir()?;
18 let replica = Replica::create(directory.path().join("cache.sqlite"), &source)?;
19 let space = replica.pages()?[0].0;
20 let page = replica.page(space)?;
21 let (text, len) = page
22 .objects
23 .iter()
24 .find_map(|object| match object {
25 onestore::page::PageObject::Outline(outline) => outline
26 .paragraphs
27 .iter()
28 .find_map(|p| p.text().map(|t| (t.id, t.text.text().encode_utf16().count() as u32))),
29 _ => None,
30 })
31 .ok_or("no text")?;
32 replica.apply(
33 "Lab",
34 Edit {
35 at: 134_000_000_000_000_000,
36 ops: vec![Op::Page { space, op: PageOp::Text { text, range: len..len, with: with.clone() } }],
37 },
38 )?;
39 let mut remote = SmbRemote::new(connect()?, path.clone(), 1 << 26);
40 println!("{:?}", replica.sync_once(&mut remote)?.edit);
41 Ok(())
42}
crates/snowbound/assets/icons/custom-color.svg created+11
......@@ -0,0 +1,11 @@
1<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
2 <path d="M8 8L8.00 1.00A7 7 0 0 1 12.95 3.05Z" fill="#ff4d4d"/>
3 <path d="M8 8L12.95 3.05A7 7 0 0 1 15.00 8.00Z" fill="#ffa53d"/>
4 <path d="M8 8L15.00 8.00A7 7 0 0 1 12.95 12.95Z" fill="#ffe14d"/>
5 <path d="M8 8L12.95 12.95A7 7 0 0 1 8.00 15.00Z" fill="#5fd35f"/>
6 <path d="M8 8L8.00 15.00A7 7 0 0 1 3.05 12.95Z" fill="#4dc3ff"/>
7 <path d="M8 8L3.05 12.95A7 7 0 0 1 1.00 8.00Z" fill="#5a6cff"/>
8 <path d="M8 8L1.00 8.00A7 7 0 0 1 3.05 3.05Z" fill="#b45aff"/>
9 <path d="M8 8L3.05 3.05A7 7 0 0 1 8.00 1.00Z" fill="#ff5ac8"/>
10 <circle cx="8" cy="8" r="2.5" fill="#fff"/>
11</svg>
crates/snowbound/src/aqua.rs+1-1
......@@ -29,7 +29,7 @@ pub(crate) fn before_lion() -> bool {
2929}
3030
3131/// Makes the window textured, AppKit's gradient running from the title through `row` points
32/// of content below it, as 10.6's unified toolbars do. Where frames are transparent it
32/// of content below it, as 10.6's unified toolbars run theirs. Where frames are transparent it
3333/// shows through. Returns whether the system draws windows so, which only 10.6 does here.
3434pub fn textured(window: &Window, row: f32) -> bool {
3535 if !before_lion() {
crates/snowbound/src/art.rs+1
......@@ -55,6 +55,7 @@ pub const FORMAT_PAINTER: &[&str] = art!("icons/format-painter");
5555pub const FORWARD: &[&str] = art!("icons/forward");
5656pub const INSERT_SPACE: &[&str] = art!("icons/insert-space");
5757pub const PAGE_COLOR: &[&str] = art!("icons/page-color");
58pub const CUSTOM_COLOR: &[&str] = art!("icons/custom-color");
5859pub const PASTE: &[&str] = art!("icons/paste");
5960pub const RECORD_AUDIO: &[&str] = art!("icons/record-audio");
6061pub const RECORD_VIDEO: &[&str] = art!("icons/record-video");
crates/snowbound/src/background.rs+122-36
......@@ -2,8 +2,9 @@
22//! lines.
33
44use crate::{
5 art,
56 commands::Choice,
6 templates::{TILE, Thumbnails},
7 templates::{TILE, Thumbnails, View},
78};
89use canvas::{
910 gpu::{Paper, colorref},
......@@ -17,38 +18,84 @@ const SWATCH: [f32; 2] = [40.0, 32.0];
1718const RULES: f32 = 40.0;
1819/// Points of page per point of a rule-line thumbnail.
1920const SHRINK: f32 = 4.0;
20/// The art offered after None.
21const ART: [&str; 5] = ["Ivy", "Purple Clouds", "Notebook", "Bamboo", "Blue Clouds"];
21/// The art offered between None and Show All.
22const ART: [&str; 7] = [
23 "Ivy",
24 "Purple Clouds",
25 "Notebook",
26 "Blue Clouds",
27 "Tulips",
28 "Bamboo",
29 "Sparks",
30];
31
32#[derive(Clone, Copy, PartialEq)]
33enum Cell {
34 /// A COLORREF, or none.
35 Color(Option<u32>),
36 Custom,
37 Art(Option<&'static str>),
38 ShowAll,
39 /// An index into `RULE_LINES`, or none.
40 Rules(Option<usize>),
41}
2242
2343/// Builds popup `id` beside `anchor` while it is open, for a page on `paper` (its own colour
24/// aside) coloured `color` and ruled with `rule_lines`. Returns the choice made.
44/// aside) coloured `color` and ruled with `rule_lines`, and the custom colour picker it
45/// opens. Show All opens every template's art over the page through `view`. Returns the
46/// choice made.
2547#[allow(clippy::too_many_arguments)]
2648pub fn menu(
2749 ui: &mut Ui,
2850 id: Id,
2951 anchor: Anchor,
3052 thumbnails: &mut Thumbnails,
53 view: &mut View,
3154 paper: Paper,
3255 color: Option<u32>,
3356 rule_lines: Option<RuleLines>,
3457) -> Option<Choice> {
35 let cells: Vec<Choice> = std::iter::once(Choice::PageColor(None))
36 .chain((0..PAGE_COLORS.len()).map(|index| Choice::PageColor(Some(index))))
37 .chain(std::iter::once(Choice::Art(None)))
38 .chain(ART.map(|name| Choice::Art(Some(name))))
39 .chain(std::iter::once(Choice::RuleLines(None)))
40 .chain((0..RULE_LINES.len()).map(|index| Choice::RuleLines(Some(index))))
58 let picker = id.child("custom");
59 // A pale blue to start from on a page without a colour.
60 let [red, green, blue, _] = color.unwrap_or(0x00f8eedd).to_le_bytes();
61 if let Some([red, green, blue]) = ui::popup::color_picker(
62 ui,
63 picker,
64 anchor,
65 "Custom Color",
66 [red, green, blue],
67 |[red, green, blue]| {
68 paper
69 .colored(Some(u32::from_le_bytes([red, green, blue, 0])))
70 .color
71 },
72 ) {
73 return Some(Choice::PageColor(Some(u32::from_le_bytes([
74 red, green, blue, 0,
75 ]))));
76 }
77 let custom = color.filter(|color| !PAGE_COLORS.iter().any(|(_, listed)| listed == color));
78 let cells: Vec<Cell> = std::iter::once(Cell::Color(None))
79 .chain(
80 PAGE_COLORS
81 .iter()
82 .map(|(_, color)| Cell::Color(Some(*color))),
83 )
84 .chain([Cell::Custom, Cell::Art(None)])
85 .chain(ART.map(|name| Cell::Art(Some(name))))
86 .chain([Cell::ShowAll, Cell::Rules(None)])
87 .chain((0..RULE_LINES.len()).map(|index| Cell::Rules(Some(index))))
4188 .collect();
4289 let groups = [
4390 ui::popup::Group {
4491 heading: "Page Color",
45 cells: 1 + PAGE_COLORS.len(),
92 cells: 2 + PAGE_COLORS.len(),
4693 columns: COLUMNS,
4794 size: SWATCH,
4895 },
4996 ui::popup::Group {
5097 heading: "Background",
51 cells: 1 + ART.len(),
98 cells: 2 + ART.len(),
5299 columns: 3,
53100 size: [TILE[0] + 8.0, TILE[1] + 8.0],
54101 },
......@@ -60,13 +107,12 @@ pub fn menu(
60107 },
61108 ];
62109 let shown = [
63 Choice::PageColor(color.map(|color| {
64 PAGE_COLORS
65 .iter()
66 .position(|(_, listed)| *listed == color)
67 .unwrap_or(usize::MAX)
68 })),
69 Choice::RuleLines(rule_lines.map(|lines| {
110 if custom.is_some() {
111 Cell::Custom
112 } else {
113 Cell::Color(color)
114 },
115 Cell::Rules(rule_lines.map(|lines| {
70116 RULE_LINES
71117 .iter()
72118 .position(|(_, listed)| {
......@@ -79,42 +125,71 @@ pub fn menu(
79125 ];
80126 let current: Vec<usize> = shown
81127 .iter()
82 .filter_map(|choice| cells.iter().position(|cell| cell == choice))
128 .filter_map(|shown| cells.iter().position(|cell| cell == shown))
83129 .collect();
84130 let theme = ui.theme.clone();
85131 let scale = ui.scale();
86132 let dark = paper.ink[0] > paper.color[0];
133 let label = |ui: &mut Ui, text: &str| {
134 ui.leaf(
135 "label",
136 Spec {
137 size: [fill(), fill()],
138 text: Some(text),
139 font_size: Some(theme.font_size - 2.0),
140 fill: Some(paper.color),
141 border: Some(theme.chip),
142 radius: 2.0,
143 center: true,
144 ..Spec::default()
145 },
146 );
147 };
87148 let chosen = ui::popup::gallery(ui, id, anchor, &groups, &current, |ui, index| {
88149 match cells[index] {
89 Choice::PageColor(None) | Choice::Art(None) | Choice::RuleLines(None) => {
150 Cell::Color(None) | Cell::Art(None) | Cell::Rules(None) => label(ui, "None"),
151 Cell::ShowAll => label(ui, "Show All…"),
152 Cell::Color(Some(color)) => {
90153 ui.leaf(
91 "none",
154 "swatch",
92155 Spec {
93156 size: [fill(), fill()],
94 text: Some("None"),
95 font_size: Some(theme.font_size - 2.0),
96 fill: Some(paper.color),
157 fill: Some(paper.colored(Some(color)).color),
97158 border: Some(theme.chip),
98159 radius: 2.0,
99 center: true,
100160 ..Spec::default()
101161 },
102162 );
103163 }
104 Choice::PageColor(Some(index)) => {
105 let (name, color) = PAGE_COLORS[index];
106 ui.leaf(
107 name,
164 // The rainbow, or the page's custom colour with the rainbow in its corner.
165 Cell::Custom => {
166 ui.open(
167 "custom",
108168 Spec {
109169 size: [fill(), fill()],
110 fill: Some(paper.colored(Some(color)).color),
111 border: Some(theme.chip),
170 fill: custom.map(|color| paper.colored(Some(color)).color),
171 border: custom.map(|_| theme.chip),
112172 radius: 2.0,
173 icon: custom.is_none().then_some(art::CUSTOM_COLOR),
174 center: true,
113175 ..Spec::default()
114176 },
115177 );
178 if custom.is_some() {
179 ui.leaf(
180 "rainbow",
181 Spec {
182 flags: Flags::FLOAT,
183 size: [px(12.0), px(12.0)],
184 position: [SWATCH[0] - 22.0, SWATCH[1] - 22.0],
185 icon: Some(art::CUSTOM_COLOR),
186 ..Spec::default()
187 },
188 );
189 }
190 ui.close();
116191 }
117 Choice::Art(Some(name)) => {
192 Cell::Art(Some(name)) => {
118193 ui.open(
119194 name,
120195 Spec {
......@@ -143,11 +218,22 @@ pub fn menu(
143218 }
144219 ui.close();
145220 }
146 Choice::RuleLines(Some(index)) => rule_thumbnail(ui, paper, RULE_LINES[index]),
147 _ => unreachable!("The menu offers colours, art and rule lines"),
221 Cell::Rules(Some(index)) => rule_thumbnail(ui, paper, RULE_LINES[index]),
148222 }
149223 })?;
150 Some(cells[chosen].clone())
224 match cells[chosen] {
225 Cell::Color(color) => Some(Choice::PageColor(color)),
226 Cell::Custom => {
227 ui.open_popup(picker);
228 None
229 }
230 Cell::Art(name) => Some(Choice::Art(name)),
231 Cell::ShowAll => {
232 *view = View::Art;
233 None
234 }
235 Cell::Rules(index) => Some(Choice::RuleLines(index)),
236 }
151237}
152238
153239/// A corner of a page ruled with `lines`, drawn a quarter size.
crates/snowbound/src/commands.rs+2-3
......@@ -82,8 +82,8 @@ pub enum Choice {
8282 List(Option<ListStyle>),
8383 /// An empty table of rows by columns.
8484 Table([usize; 2]),
85 /// An index into `PAGE_COLORS`, or `None` for no colour.
86 PageColor(Option<usize>),
85 /// A page colour, COLORREF, or `None` for no colour.
86 PageColor(Option<u32>),
8787 /// An index into `RULE_LINES`, or `None` for none.
8888 RuleLines(Option<usize>),
8989 /// A template whose art becomes the page's background, or `None` for none.
......@@ -815,7 +815,6 @@ impl State {
815815 return Ok(());
816816 }
817817 Choice::PageColor(color) => {
818 let color = color.map(|index| canvas::template::PAGE_COLORS[index].1);
819818 return self.paper_page(color, self.view.editor.rule_lines());
820819 }
821820 Choice::Art(name) => {
crates/snowbound/src/macos.rs+3-2
......@@ -254,9 +254,10 @@ unsafe extern "C" fn hit_nothing(_: &AnyObject, _: Sel, _: NSPoint) -> *mut AnyO
254254/// through it to the view it lies in.
255255pub fn install_backdrop(window: &Window) -> bool {
256256 MainThreadMarker::new().expect("Views belong to the main thread");
257 // Before 10.10 AppKit has no materials; 10.6's textured window stands in.
257 // Before 10.10 AppKit has no materials; 10.6's textured window stands in, its gradient
258 // running through the toolbar and the tab row to the notebook's frame.
258259 let Some(effect) = AnyClass::get("NSVisualEffectView") else {
259 return crate::aqua::textured(window, crate::TITLE);
260 return crate::aqua::textured(window, crate::TITLE + crate::TAB_ROW);
260261 };
261262 let RawWindowHandle::AppKit(handle) =
262263 window.window_handle().expect("Live AppKit window").as_raw()
crates/snowbound/src/main.rs+34-29
......@@ -68,11 +68,10 @@ use winit::{
6868/// Height of the toolbar's row, which is the title bar where the platform lets it: a
6969/// unified compact toolbar's on macOS.
7070const TITLE: f32 = 38.0;
71/// Space between toolbar groups, and the toolbar's margin at an edge without window controls.
71/// Space between toolbar groups.
7272const GAP: f32 = 6.0;
73const EDGE: f32 = 8.0;
7473const TAB_ROW: f32 = 28.0;
75/// Width of the section colour around the page.
74/// Width of the section colour around the page, and the rows' margin at the window's sides.
7675const FRAME: f32 = 6.0;
7776const PAGE_LIST: f32 = 240.0;
7877/// Extensions of the pictures Insert, Picture offers: those the page both stores and draws.
......@@ -593,6 +592,11 @@ fn strip() -> Id {
593592 Id::ROOT.child("strip")
594593}
595594
595/// The section tabs' row, whose empty space drags the window as the strip's does.
596fn tab_row() -> Id {
597 Id::ROOT.child("tab row")
598}
599
596600fn page() -> Id {
597601 Id::ROOT.child("page")
598602}
......@@ -1042,14 +1046,18 @@ impl State {
10421046 ..Spec::default()
10431047 },
10441048 );
1045 let tabs = self.ui.id("tabs");
10461049 let height = self
10471050 .ui
1048 .animate(tabs, if self.full_page { 0.0 } else { TAB_ROW });
1049 let tab_row = self.ui.open(
1050 "tabs",
1051 .animate(tab_row(), if self.full_page { 0.0 } else { TAB_ROW });
1052 let drags = self.chrome_drags();
1053 let tab_row = self.ui.open_as(
1054 tab_row(),
10511055 Spec {
1052 flags: Flags::CLIP,
1056 flags: if drags {
1057 Flags::CLIP | Flags::CLICKABLE
1058 } else {
1059 Flags::CLIP
1060 },
10531061 size: [fill(), px(height)],
10541062 fill: Some(theme.strip),
10551063 pad: [FRAME, 0.0],
......@@ -1387,6 +1395,12 @@ impl State {
13871395 );
13881396 }
13891397
1398 /// Whether the chrome's empty space drags the window: where it is the title bar, or
1399 /// over the title's gradient.
1400 fn chrome_drags(&self) -> bool {
1401 !platform::system_titlebar(&self.window) || self.surface.translucent()
1402 }
1403
13901404 /// The toolbar: one row of groups that fold as the window narrows. Where the platform
13911405 /// lets it, the row is the window's title bar, and on the welcome page, without `tools`,
13921406 /// that is all it is.
......@@ -1395,8 +1409,7 @@ impl State {
13951409 if !tools && !title {
13961410 return Ok(());
13971411 }
1398 // Over the title's gradient, the row's empty space drags the window as the title does.
1399 let strip_row = title || self.surface.translucent();
1412 let strip_row = self.chrome_drags();
14001413 let spec = Spec {
14011414 flags: if strip_row {
14021415 Flags::CLICKABLE
......@@ -1414,7 +1427,7 @@ impl State {
14141427 } else {
14151428 self.ui.open("toolbar", spec);
14161429 }
1417 let lead = if title { platform::LEADING } else { EDGE };
1430 let lead = if title { platform::LEADING } else { FRAME };
14181431 self.ui.leaf(
14191432 "lead",
14201433 Spec {
......@@ -1439,7 +1452,7 @@ impl State {
14391452 self.ui.leaf(
14401453 "trail",
14411454 Spec {
1442 size: [px(EDGE - GAP), px(1.0)],
1455 size: [px(FRAME - GAP), px(1.0)],
14431456 ..Spec::default()
14441457 },
14451458 );
......@@ -1662,7 +1675,7 @@ impl State {
16621675 choice = group(
16631676 ui,
16641677 "character",
1665 6,
1678 5,
16661679 |ui| {
16671680 divider(ui, theme);
16681681 let mut choice = None;
......@@ -1757,7 +1770,7 @@ impl State {
17571770 choice = group(
17581771 ui,
17591772 "paragraph",
1760 5,
1773 4,
17611774 |ui| {
17621775 divider(ui, theme);
17631776 let mut choice = None;
......@@ -1885,7 +1898,7 @@ impl State {
18851898 choice = group(
18861899 ui,
18871900 "tags",
1888 7,
1901 6,
18891902 |ui| {
18901903 divider(ui, theme);
18911904 let mut choice = None;
......@@ -1950,17 +1963,7 @@ impl State {
19501963 },
19511964 );
19521965 if let Some(session) = session {
1953 ui.open(
1954 "sync",
1955 Spec {
1956 fold: Some(4),
1957 ..Spec::default()
1958 },
1959 );
1960 for compact in [false, true] {
1961 sync::control(ui, session, theme, compact);
1962 }
1963 ui.close();
1966 sync::control(ui, session, theme);
19641967 }
19651968 // Spelling awaits a platform spell checker.
19661969 let views = [
......@@ -1991,9 +1994,11 @@ impl State {
19911994 toolbar_popup("page color"),
19921995 anchor,
19931996 &mut self.thumbnails,
1997 &mut self.templates,
1998 // The page's own colour aside.
19941999 canvas::gpu::Paper {
1995 color: theme.paper,
1996 ink: theme.paper_ink,
2000 color: ui.theme.paper,
2001 ink: ui.theme.paper_ink,
19972002 },
19982003 self.view.editor.page_color(),
19992004 self.view.editor.rule_lines(),
......@@ -3111,7 +3116,7 @@ impl State {
31113116 pressed: true,
31123117 at,
31133118 } = event
3114 && self.ui.box_at(self.pointer) == Some(strip())
3119 && matches!(self.ui.box_at(self.pointer), Some(id) if id == strip() || id == tab_row())
31153120 {
31163121 let double = self.strip_press.is_some_and(|last| {
31173122 at.saturating_duration_since(last) <= platform::double_click_interval()
crates/snowbound/src/sync.rs+14-35
......@@ -71,47 +71,26 @@ fn when(time: u64) -> String {
7171 }
7272}
7373
74/// The status in the toolbar, which opens the popup: its label, or where `compact` its icon,
75/// naming it in a tooltip.
76pub(crate) fn control(ui: &mut Ui, session: &Session, theme: &Theme, compact: bool) {
74/// The status's icon in the toolbar, named in its tooltip, which opens the popup.
75pub(crate) fn control(ui: &mut Ui, session: &Session, theme: &Theme) {
7776 let strong = ui.popup_open(id()) || session.sync.error.is_some() && !library::offline();
7877 let (label, icon, _) = describe(&session.sync);
79 let spec = Spec {
80 flags: Flags::CLICKABLE,
81 color: Some(if strong { theme.text } else { theme.text_dim }),
82 hover_fill: Some(theme.hover()),
83 radius: 4.0,
84 center: true,
85 ..Spec::default()
86 };
87 // The popup opens from the label's box, which stands where the icon shows.
88 let shown = if compact {
89 button().child("icon")
90 } else {
91 button()
92 };
9378 ui.open_as(
94 shown,
95 if compact {
96 Spec {
97 size: [px(TOOL), px(TOOL)],
98 icon: Some(icon),
99 ..spec
100 }
101 } else {
102 Spec {
103 size: [fit(), px(TOOL)],
104 text: Some(label),
105 pad: [8.0, 0.0],
106 ..spec
107 }
79 button(),
80 Spec {
81 flags: Flags::CLICKABLE,
82 size: [px(TOOL), px(TOOL)],
83 icon: Some(icon),
84 color: Some(if strong { theme.text } else { theme.text_dim }),
85 hover_fill: Some(theme.hover()),
86 radius: 4.0,
87 center: true,
88 ..Spec::default()
10889 },
10990 );
11091 ui.close();
111 if compact {
112 ui::popup::tooltip(ui, label, "", None);
113 }
114 if ui.signal(shown).clicked {
92 ui::popup::tooltip(ui, label, "", None);
93 if ui.signal(button()).clicked {
11594 if ui.popup_open(id()) {
11695 ui.close_popup(id());
11796 } else {
crates/snowbound/src/templates.rs+51-4
......@@ -48,6 +48,8 @@ pub enum View {
4848 /// The strip with the page colours beneath it.
4949 Colors,
5050 Gallery,
51 /// Every template's art, from the Page Color menu, to lie behind the open page.
52 Art,
5153}
5254
5355/// A thumbnail's art: each raster and where it lies in the tile.
......@@ -199,7 +201,8 @@ fn strip(
199201 chosen
200202}
201203
202/// Every template, as a scrolling grid over the page box `size` big.
204/// Every template, or with `art` only their art, as a scrolling grid over the page box
205/// `size` big.
203206fn gallery(
204207 ui: &mut Ui,
205208 theme: &Theme,
......@@ -207,6 +210,7 @@ fn gallery(
207210 paper: Paper,
208211 dark: bool,
209212 size: [f32; 2],
213 art: bool,
210214) -> Option<Choice> {
211215 let mut chosen = None;
212216 let margin = 24.0;
......@@ -236,7 +240,7 @@ fn gallery(
236240 "title",
237241 Spec {
238242 size: [fill(), px(24.0)],
239 text: Some("Page Templates"),
243 text: Some(if art { "Backgrounds" } else { "Page Templates" }),
240244 ..Spec::default()
241245 },
242246 );
......@@ -264,7 +268,8 @@ fn gallery(
264268 PAGE_COLORS
265269 .iter()
266270 .enumerate()
267 .map(|(index, (name, _))| (Choice::Color(index), *name)),
271 .map(|(index, (name, _))| (Choice::Color(index), *name))
272 .filter(|_| !art),
268273 )
269274 .collect();
270275 for (row, choices) in choices.chunks(columns).enumerate() {
......@@ -411,6 +416,9 @@ impl crate::State {
411416 let Some(session) = &self.session else {
412417 return;
413418 };
419 if self.templates == View::Art {
420 return self.art_gallery(theme);
421 }
414422 let space = session.space;
415423 let editor = &self.view.editor;
416424 let blank = editor
......@@ -440,8 +448,17 @@ impl crate::State {
440448 let chosen = match self.templates {
441449 View::Gallery => {
442450 let size = [rect[2] - rect[0], rect[3] - rect[1]];
443 gallery(&mut self.ui, theme, &mut self.thumbnails, paper, dark, size)
451 gallery(
452 &mut self.ui,
453 theme,
454 &mut self.thumbnails,
455 paper,
456 dark,
457 size,
458 false,
459 )
444460 }
461 View::Art => unreachable!("The art gallery is built alone"),
445462 view => strip(
446463 &mut self.ui,
447464 theme,
......@@ -475,4 +492,34 @@ impl crate::State {
475492 None => {}
476493 }
477494 }
495
496 /// The Page Color menu's Show All: every template's art, one of which goes behind the
497 /// open page.
498 fn art_gallery(&mut self, theme: &Theme) {
499 let Some(rect) = self.ui.rect(crate::page()) else {
500 return;
501 };
502 let paper = Paper {
503 color: self.ui.theme.paper,
504 ink: self.ui.theme.paper_ink,
505 };
506 let dark = paper.ink[0] > paper.color[0];
507 let size = [rect[2] - rect[0], rect[3] - rect[1]];
508 match gallery(
509 &mut self.ui,
510 theme,
511 &mut self.thumbnails,
512 paper,
513 dark,
514 size,
515 true,
516 ) {
517 Some(Choice::Template(name)) => {
518 self.templates = View::Strip;
519 self.choose(crate::commands::Choice::Art(Some(name)));
520 }
521 Some(Choice::Dismiss) => self.templates = View::Strip,
522 _ => {}
523 }
524 }
478525}
crates/ui/src/layout.rs+8-5
......@@ -3,8 +3,8 @@ use std::collections::HashMap;
33
44/// Sizes each box on both axes, then places it: standalone sizes, sizes taken from
55/// ancestors (pre-order), sizes summed from children (post-order), overflow taken back
6/// (from space sized by ancestors, then by folding a row's groups, then from the least
7/// strict boxes first), and positions along each parent's flow. Labels too wide for their
6/// (by folding a row's groups for what space sized by ancestors can't give, then from that
7/// space, then from the least strict boxes first), and positions along each parent's flow. Labels too wide for their
88/// solved width wrap or shorten before heights are solved. Boxes are in build order, so
99/// index order is pre-order.
1010pub(crate) fn solve(
......@@ -75,10 +75,12 @@ pub(crate) fn solve(
7575 if along(node, axis) {
7676 let space =
7777 |child: &&usize| matches!(nodes[**child].size[axis].size, Size::Fraction(_));
78 let (space, sized): (Vec<_>, Vec<_>) = children.iter().partition(space);
78 let (space, sized): (Vec<usize>, Vec<_>) = children.iter().partition(space);
7979 let mut excess = flow(nodes, index, axis) - room;
80 excess = give_back(nodes, &space, axis, excess);
81 while excess > 0.0 && axis == 0 {
80 // Groups fold only for what the space can't give, so the room a fold frees
81 // goes back to the space.
82 let spare: f32 = space.iter().map(|child| nodes[*child].computed[axis]).sum();
83 while excess > spare && axis == 0 {
8284 let Some(group) = children
8385 .iter()
8486 .copied()
......@@ -96,6 +98,7 @@ pub(crate) fn solve(
9698 excess -= nodes[group].computed[0] - width;
9799 nodes[group].computed[0] = width;
98100 }
101 excess = give_back(nodes, &space, axis, excess);
99102 let mut tiers: Vec<f32> = sized
100103 .iter()
101104 .map(|child| strictness(nodes, *child, axis))
crates/ui/src/lib.rs+3
......@@ -542,6 +542,8 @@ struct Popup {
542542 /// Its filter field's text, and the key of the row the keyboard or pointer last chose.
543543 query: String,
544544 highlight: Option<u64>,
545 /// A colour picker's hue in degrees, saturation and lightness, once it has one.
546 picked: Option<[f32; 3]>,
545547 /// The height its results ease from and to, and the seconds since they set out.
546548 height: Option<[f32; 3]>,
547549 opened: Instant,
......@@ -1000,6 +1002,7 @@ impl Ui {
10001002 focus: self.focus,
10011003 query: String::new(),
10021004 highlight: None,
1005 picked: None,
10031006 height: None,
10041007 opened: self.now,
10051008 });
crates/ui/src/popup.rs+221
......@@ -814,6 +814,227 @@ pub fn gallery(
814814 None
815815}
816816
817/// A colour picker's hue and lightness field, and its saturation bar beneath.
818const FIELD: [f32; 2] = [256.0, 128.0];
819const BAR: f32 = 14.0;
820/// Columns the field is drawn in, each a gradient from white to the hue and to black.
821const HUES: usize = 64;
822
823/// Builds popup `id` beside `anchor` while it is open as a colour picker titled `title`,
824/// starting from `initial`, sRGB: hue across a field drawn fully saturated, lightness down
825/// it, saturation along a bar, and a preview of the colour as `preview` shows it, linear
826/// RGBA. Returns the colour applied.
827pub fn color_picker(
828 ui: &mut Ui,
829 id: Id,
830 anchor: Anchor,
831 title: &str,
832 initial: [u8; 3],
833 preview: impl Fn([u8; 3]) -> [f32; 4],
834) -> Option<[u8; 3]> {
835 if !ui.popup_open(id) {
836 return None;
837 }
838 let [field, bar] = ["field", "saturation"].map(|part| id.child(part));
839 let apply = id.child("footer").child("apply");
840 let mut picked = state(ui, id).picked.unwrap_or_else(|| to_hsl(initial));
841 let pointer = ui.pointer();
842 let along = |ui: &Ui, part: Id| {
843 let [left, top, right, bottom] = ui.rect(part)?;
844 let [x, y] = pointer?;
845 Some([
846 ((x - left) / (right - left)).clamp(0.0, 1.0),
847 ((y - top) / (bottom - top)).clamp(0.0, 1.0),
848 ])
849 };
850 let held = |ui: &mut Ui, part: Id| {
851 let signal = ui.signal(part);
852 signal.pressed || signal.dragging
853 };
854 if held(ui, field)
855 && let Some([x, y]) = along(ui, field)
856 {
857 picked[0] = x * 360.0;
858 picked[2] = 1.0 - y;
859 }
860 if held(ui, bar)
861 && let Some([x, _]) = along(ui, bar)
862 {
863 picked[1] = x;
864 }
865 let keys = navigation(ui, &[id], &[NamedKey::Enter]);
866 state(ui, id).picked = Some(picked);
867 let rgb = from_hsl(picked);
868 if ui.signal(apply).clicked || !keys.is_empty() {
869 ui.close_popup(id);
870 return Some(rgb);
871 }
872
873 let theme = ui.theme.clone();
874 surface(ui, id, anchor, FIELD[0] + 2.0 * ui.theme.menu().pad);
875 let color = |hsl| {
876 let [red, green, blue] = from_hsl(hsl);
877 draw::srgb(red, green, blue)
878 };
879 let [hue, saturation, lightness] = picked;
880 ui.leaf(
881 "heading",
882 Spec {
883 size: [fill(), px(MENU_ROW)],
884 text: Some(title),
885 font_size: Some(theme.font_size - 2.0),
886 bold: true,
887 color: Some(theme.text_dim),
888 pad: [PAD, 0.0],
889 ..Spec::default()
890 },
891 );
892 ui.open_as(
893 field,
894 Spec {
895 flags: Flags::CLICKABLE,
896 size: [px(FIELD[0]), px(FIELD[1])],
897 ..Spec::default()
898 },
899 );
900 let column = FIELD[0] / HUES as f32;
901 for index in 0..HUES {
902 let shade = (index as f32 + 0.5) / HUES as f32 * 360.0;
903 for (half, [top, bottom]) in [[1.0, 0.5], [0.5, 0.0]].into_iter().enumerate() {
904 ui.leaf(
905 (index, half),
906 Spec {
907 flags: Flags::FLOAT,
908 position: [index as f32 * column, half as f32 * FIELD[1] / 2.0],
909 size: [px(column + 0.5), px(FIELD[1] / 2.0)],
910 fill: Some(color([shade, 1.0, top])),
911 gradient: Some(color([shade, 1.0, bottom])),
912 ..Spec::default()
913 },
914 );
915 }
916 }
917 // A white ring inside a dark one, seen on any colour.
918 let ring = |ui: &mut Ui, part: &str, at: [f32; 2]| {
919 for (index, (radius, color)) in [(6.0, [0.0, 0.0, 0.0, 0.6]), (5.0, [1.0; 4])]
920 .into_iter()
921 .enumerate()
922 {
923 ui.leaf(
924 (part, index),
925 Spec {
926 flags: Flags::FLOAT,
927 position: [at[0] - radius, at[1] - radius],
928 size: [px(2.0 * radius), px(2.0 * radius)],
929 border: Some(color),
930 radius,
931 ..Spec::default()
932 },
933 );
934 }
935 };
936 ring(
937 ui,
938 "ring",
939 [hue / 360.0 * FIELD[0], (1.0 - lightness) * FIELD[1]],
940 );
941 ui.close();
942 ui.open_as(
943 bar,
944 Spec {
945 flags: Flags::CLICKABLE,
946 size: [px(FIELD[0]), px(BAR)],
947 radius: 2.0,
948 ..Spec::default()
949 },
950 );
951 let steps = 32;
952 let step = FIELD[0] / steps as f32;
953 for index in 0..steps {
954 ui.leaf(
955 index,
956 Spec {
957 flags: Flags::FLOAT,
958 position: [index as f32 * step, 0.0],
959 size: [px(step + 0.5), px(BAR)],
960 fill: Some(color([hue, index as f32 / (steps - 1) as f32, lightness])),
961 ..Spec::default()
962 },
963 );
964 }
965 ring(ui, "ring", [saturation * FIELD[0], BAR / 2.0]);
966 ui.close();
967 ui.open(
968 "footer",
969 Spec {
970 size: [fill(), px(ROW)],
971 gap: 8.0,
972 ..Spec::default()
973 },
974 );
975 ui.leaf(
976 "preview",
977 Spec {
978 size: [px(2.0 * ROW), px(ROW)],
979 fill: Some(preview(rgb)),
980 border: Some(theme.chip),
981 radius: 4.0,
982 ..Spec::default()
983 },
984 );
985 let [red, green, blue] = rgb;
986 let hex = format!("#{red:02X}{green:02X}{blue:02X}");
987 ui.leaf(
988 "hex",
989 Spec {
990 size: [fill(), px(ROW)],
991 text: Some(&hex),
992 color: Some(theme.text_dim),
993 ..Spec::default()
994 },
995 );
996 crate::button(ui, "apply", "Apply");
997 ui.close();
998 ui.close();
999 None
1000}
1001
1002/// Hue in degrees, saturation and lightness of an sRGB colour.
1003fn to_hsl(rgb: [u8; 3]) -> [f32; 3] {
1004 let [red, green, blue] = rgb.map(|channel| f32::from(channel) / 255.0);
1005 let (most, least) = (red.max(green).max(blue), red.min(green).min(blue));
1006 let lightness = (most + least) / 2.0;
1007 let chroma = most - least;
1008 if chroma == 0.0 {
1009 return [0.0, 0.0, lightness];
1010 }
1011 let saturation = chroma / (1.0 - (2.0 * lightness - 1.0).abs());
1012 let sector = if most == red {
1013 ((green - blue) / chroma).rem_euclid(6.0)
1014 } else if most == green {
1015 (blue - red) / chroma + 2.0
1016 } else {
1017 (red - green) / chroma + 4.0
1018 };
1019 [sector * 60.0, saturation, lightness]
1020}
1021
1022fn from_hsl([hue, saturation, lightness]: [f32; 3]) -> [u8; 3] {
1023 let chroma = (1.0 - (2.0 * lightness - 1.0).abs()) * saturation;
1024 let sector = (hue / 60.0).rem_euclid(6.0);
1025 let second = chroma * (1.0 - (sector % 2.0 - 1.0).abs());
1026 let [red, green, blue] = match sector as u32 {
1027 0 => [chroma, second, 0.0],
1028 1 => [second, chroma, 0.0],
1029 2 => [0.0, chroma, second],
1030 3 => [0.0, second, chroma],
1031 4 => [second, 0.0, chroma],
1032 _ => [chroma, 0.0, second],
1033 };
1034 let base = lightness - chroma / 2.0;
1035 [red, green, blue].map(|channel| ((channel + base) * 255.0).round().clamp(0.0, 255.0) as u8)
1036}
1037
8171038/// Opens popup `id`'s panel `width` wide beside `anchor`; the caller closes it.
8181039fn surface(ui: &mut Ui, id: Id, anchor: Anchor, width: f32) {
8191040 let style = ui.theme.menu();
crates/ui/src/tests.rs+28-1
......@@ -209,7 +209,8 @@ fn a_row_squeezes_its_boxes_only_once_every_group_is_folded() {
209209 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
210210 let rects = folding_row(&mut ui, 130.0, &[0, 1]);
211211 // 80 + 20 + 20 fits 130 with 10 to spare, taken by the space.
212 assert_eq!(rects[0], Some([0.0, 0.0, 80.0, 20.0]));
212 assert_eq!(rects[0], Some([10.0, 0.0, 90.0, 20.0]));
213 assert_eq!(rects[4], Some([110.0, 0.0, 130.0, 20.0]));
213214 let rects = folding_row(&mut ui, 100.0, &[0, 1]);
214215 assert_eq!(rects[0], Some([0.0, 0.0, 60.0, 20.0]));
215216 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() {
25162517 let back = ui.rect(shell::tab_id(row, 0)).unwrap()[0];
25172518 assert!((back - scrolled - 30.0).abs() < 0.5, "{scrolled} to {back}");
25182519}
2520
2521#[test]
2522fn a_colour_picker_applies_its_colour_and_keeps_it_through_hsl() {
2523 let mut ui = Ui::new(Theme::light(), DOUBLE_CLICK);
2524 let picker = Id::ROOT.child("picker");
2525 let build = |ui: &mut Ui| {
2526 let mut chosen = None;
2527 frame(ui, |ui| {
2528 chosen = popup::color_picker(
2529 ui,
2530 picker,
2531 BELOW,
2532 "Custom Color",
2533 [0xd4, 0xf9, 0xf2],
2534 |_| [1.0; 4],
2535 );
2536 });
2537 chosen
2538 };
2539 build(&mut ui);
2540 ui.open_popup(picker);
2541 assert_eq!(build(&mut ui), None);
2542 ui.event(key(NamedKey::Enter));
2543 assert_eq!(build(&mut ui), Some([0xd4, 0xf9, 0xf2]));
2544 assert!(!ui.popup_open(picker));
2545}