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 {...@@ -29,7 +29,7 @@ pub(crate) fn before_lion() -> bool {
29}29}
3030
31/// Makes the window textured, AppKit's gradient running from the title through `row` points31/// 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 it32/// of content below it, as 10.6's unified toolbars run theirs. Where frames are transparent it
33/// shows through. Returns whether the system draws windows so, which only 10.6 does here.33/// shows through. Returns whether the system draws windows so, which only 10.6 does here.
34pub fn textured(window: &Window, row: f32) -> bool {34pub fn textured(window: &Window, row: f32) -> bool {
35 if !before_lion() {35 if !before_lion() {
crates/snowbound/src/art.rs+1
...@@ -55,6 +55,7 @@ pub const FORMAT_PAINTER: &[&str] = art!("icons/format-painter");...@@ -55,6 +55,7 @@ pub const FORMAT_PAINTER: &[&str] = art!("icons/format-painter");
55pub const FORWARD: &[&str] = art!("icons/forward");55pub const FORWARD: &[&str] = art!("icons/forward");
56pub const INSERT_SPACE: &[&str] = art!("icons/insert-space");56pub const INSERT_SPACE: &[&str] = art!("icons/insert-space");
57pub const PAGE_COLOR: &[&str] = art!("icons/page-color");57pub const PAGE_COLOR: &[&str] = art!("icons/page-color");
58pub const CUSTOM_COLOR: &[&str] = art!("icons/custom-color");
58pub const PASTE: &[&str] = art!("icons/paste");59pub const PASTE: &[&str] = art!("icons/paste");
59pub const RECORD_AUDIO: &[&str] = art!("icons/record-audio");60pub const RECORD_AUDIO: &[&str] = art!("icons/record-audio");
60pub const RECORD_VIDEO: &[&str] = art!("icons/record-video");61pub const RECORD_VIDEO: &[&str] = art!("icons/record-video");
crates/snowbound/src/background.rs+122-36
...@@ -2,8 +2,9 @@...@@ -2,8 +2,9 @@
2//! lines.2//! lines.
33
4use crate::{4use crate::{
5 art,
5 commands::Choice,6 commands::Choice,
6 templates::{TILE, Thumbnails},7 templates::{TILE, Thumbnails, View},
7};8};
8use canvas::{9use canvas::{
9 gpu::{Paper, colorref},10 gpu::{Paper, colorref},
...@@ -17,38 +18,84 @@ const SWATCH: [f32; 2] = [40.0, 32.0];...@@ -17,38 +18,84 @@ const SWATCH: [f32; 2] = [40.0, 32.0];
17const RULES: f32 = 40.0;18const RULES: f32 = 40.0;
18/// Points of page per point of a rule-line thumbnail.19/// Points of page per point of a rule-line thumbnail.
19const SHRINK: f32 = 4.0;20const SHRINK: f32 = 4.0;
20/// The art offered after None.21/// The art offered between None and Show All.
21const ART: [&str; 5] = ["Ivy", "Purple Clouds", "Notebook", "Bamboo", "Blue Clouds"];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
23/// Builds popup `id` beside `anchor` while it is open, for a page on `paper` (its own colour43/// Builds popup `id` beside `anchor` while it is open, for a page on `paper` (its own colour
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.
25#[allow(clippy::too_many_arguments)]47#[allow(clippy::too_many_arguments)]
26pub fn menu(48pub fn menu(
27 ui: &mut Ui,49 ui: &mut Ui,
28 id: Id,50 id: Id,
29 anchor: Anchor,51 anchor: Anchor,
30 thumbnails: &mut Thumbnails,52 thumbnails: &mut Thumbnails,
53 view: &mut View,
31 paper: Paper,54 paper: Paper,
32 color: Option<u32>,55 color: Option<u32>,
33 rule_lines: Option<RuleLines>,56 rule_lines: Option<RuleLines>,
34) -> Option<Choice> {57) -> Option<Choice> {
35 let cells: Vec<Choice> = std::iter::once(Choice::PageColor(None))58 let picker = id.child("custom");
36 .chain((0..PAGE_COLORS.len()).map(|index| Choice::PageColor(Some(index))))59 // A pale blue to start from on a page without a colour.
37 .chain(std::iter::once(Choice::Art(None)))60 let [red, green, blue, _] = color.unwrap_or(0x00f8eedd).to_le_bytes();
38 .chain(ART.map(|name| Choice::Art(Some(name))))61 if let Some([red, green, blue]) = ui::popup::color_picker(
39 .chain(std::iter::once(Choice::RuleLines(None)))62 ui,
40 .chain((0..RULE_LINES.len()).map(|index| Choice::RuleLines(Some(index))))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))))
41 .collect();88 .collect();
42 let groups = [89 let groups = [
43 ui::popup::Group {90 ui::popup::Group {
44 heading: "Page Color",91 heading: "Page Color",
45 cells: 1 + PAGE_COLORS.len(),92 cells: 2 + PAGE_COLORS.len(),
46 columns: COLUMNS,93 columns: COLUMNS,
47 size: SWATCH,94 size: SWATCH,
48 },95 },
49 ui::popup::Group {96 ui::popup::Group {
50 heading: "Background",97 heading: "Background",
51 cells: 1 + ART.len(),98 cells: 2 + ART.len(),
52 columns: 3,99 columns: 3,
53 size: [TILE[0] + 8.0, TILE[1] + 8.0],100 size: [TILE[0] + 8.0, TILE[1] + 8.0],
54 },101 },
...@@ -60,13 +107,12 @@ pub fn menu(...@@ -60,13 +107,12 @@ pub fn menu(
60 },107 },
61 ];108 ];
62 let shown = [109 let shown = [
63 Choice::PageColor(color.map(|color| {110 if custom.is_some() {
64 PAGE_COLORS111 Cell::Custom
65 .iter()112 } else {
66 .position(|(_, listed)| *listed == color)113 Cell::Color(color)
67 .unwrap_or(usize::MAX)114 },
68 })),115 Cell::Rules(rule_lines.map(|lines| {
69 Choice::RuleLines(rule_lines.map(|lines| {
70 RULE_LINES116 RULE_LINES
71 .iter()117 .iter()
72 .position(|(_, listed)| {118 .position(|(_, listed)| {
...@@ -79,42 +125,71 @@ pub fn menu(...@@ -79,42 +125,71 @@ pub fn menu(
79 ];125 ];
80 let current: Vec<usize> = shown126 let current: Vec<usize> = shown
81 .iter()127 .iter()
82 .filter_map(|choice| cells.iter().position(|cell| cell == choice))128 .filter_map(|shown| cells.iter().position(|cell| cell == shown))
83 .collect();129 .collect();
84 let theme = ui.theme.clone();130 let theme = ui.theme.clone();
85 let scale = ui.scale();131 let scale = ui.scale();
86 let dark = paper.ink[0] > paper.color[0];132 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 };
87 let chosen = ui::popup::gallery(ui, id, anchor, &groups, &current, |ui, index| {148 let chosen = ui::popup::gallery(ui, id, anchor, &groups, &current, |ui, index| {
88 match cells[index] {149 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)) => {
90 ui.leaf(153 ui.leaf(
91 "none",154 "swatch",
92 Spec {155 Spec {
93 size: [fill(), fill()],156 size: [fill(), fill()],
94 text: Some("None"),157 fill: Some(paper.colored(Some(color)).color),
95 font_size: Some(theme.font_size - 2.0),
96 fill: Some(paper.color),
97 border: Some(theme.chip),158 border: Some(theme.chip),
98 radius: 2.0,159 radius: 2.0,
99 center: true,
100 ..Spec::default()160 ..Spec::default()
101 },161 },
102 );162 );
103 }163 }
104 Choice::PageColor(Some(index)) => {164 // The rainbow, or the page's custom colour with the rainbow in its corner.
105 let (name, color) = PAGE_COLORS[index];165 Cell::Custom => {
106 ui.leaf(166 ui.open(
107 name,167 "custom",
108 Spec {168 Spec {
109 size: [fill(), fill()],169 size: [fill(), fill()],
110 fill: Some(paper.colored(Some(color)).color),170 fill: custom.map(|color| paper.colored(Some(color)).color),
111 border: Some(theme.chip),171 border: custom.map(|_| theme.chip),
112 radius: 2.0,172 radius: 2.0,
173 icon: custom.is_none().then_some(art::CUSTOM_COLOR),
174 center: true,
113 ..Spec::default()175 ..Spec::default()
114 },176 },
115 );177 );
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();
116 }191 }
117 Choice::Art(Some(name)) => {192 Cell::Art(Some(name)) => {
118 ui.open(193 ui.open(
119 name,194 name,
120 Spec {195 Spec {
...@@ -143,11 +218,22 @@ pub fn menu(...@@ -143,11 +218,22 @@ pub fn menu(
143 }218 }
144 ui.close();219 ui.close();
145 }220 }
146 Choice::RuleLines(Some(index)) => rule_thumbnail(ui, paper, RULE_LINES[index]),221 Cell::Rules(Some(index)) => rule_thumbnail(ui, paper, RULE_LINES[index]),
147 _ => unreachable!("The menu offers colours, art and rule lines"),
148 }222 }
149 })?;223 })?;
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 }
151}237}
152238
153/// A corner of a page ruled with `lines`, drawn a quarter size.239/// 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 {...@@ -82,8 +82,8 @@ pub enum Choice {
82 List(Option<ListStyle>),82 List(Option<ListStyle>),
83 /// An empty table of rows by columns.83 /// An empty table of rows by columns.
84 Table([usize; 2]),84 Table([usize; 2]),
85 /// An index into `PAGE_COLORS`, or `None` for no colour.85 /// A page colour, COLORREF, or `None` for no colour.
86 PageColor(Option<usize>),86 PageColor(Option<u32>),
87 /// An index into `RULE_LINES`, or `None` for none.87 /// An index into `RULE_LINES`, or `None` for none.
88 RuleLines(Option<usize>),88 RuleLines(Option<usize>),
89 /// A template whose art becomes the page's background, or `None` for none.89 /// A template whose art becomes the page's background, or `None` for none.
...@@ -815,7 +815,6 @@ impl State {...@@ -815,7 +815,6 @@ impl State {
815 return Ok(());815 return Ok(());
816 }816 }
817 Choice::PageColor(color) => {817 Choice::PageColor(color) => {
818 let color = color.map(|index| canvas::template::PAGE_COLORS[index].1);
819 return self.paper_page(color, self.view.editor.rule_lines());818 return self.paper_page(color, self.view.editor.rule_lines());
820 }819 }
821 Choice::Art(name) => {820 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...@@ -254,9 +254,10 @@ unsafe extern "C" fn hit_nothing(_: &AnyObject, _: Sel, _: NSPoint) -> *mut AnyO
254/// through it to the view it lies in.254/// through it to the view it lies in.
255pub fn install_backdrop(window: &Window) -> bool {255pub fn install_backdrop(window: &Window) -> bool {
256 MainThreadMarker::new().expect("Views belong to the main thread");256 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.
258 let Some(effect) = AnyClass::get("NSVisualEffectView") else {259 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);
260 };261 };
261 let RawWindowHandle::AppKit(handle) =262 let RawWindowHandle::AppKit(handle) =
262 window.window_handle().expect("Live AppKit window").as_raw()263 window.window_handle().expect("Live AppKit window").as_raw()
crates/snowbound/src/main.rs+34-29
...@@ -68,11 +68,10 @@ use winit::{...@@ -68,11 +68,10 @@ use winit::{
68/// Height of the toolbar's row, which is the title bar where the platform lets it: a68/// Height of the toolbar's row, which is the title bar where the platform lets it: a
69/// unified compact toolbar's on macOS.69/// unified compact toolbar's on macOS.
70const TITLE: f32 = 38.0;70const 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.
72const GAP: f32 = 6.0;72const GAP: f32 = 6.0;
73const EDGE: f32 = 8.0;
74const TAB_ROW: f32 = 28.0;73const 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.
76const FRAME: f32 = 6.0;75const FRAME: f32 = 6.0;
77const PAGE_LIST: f32 = 240.0;76const PAGE_LIST: f32 = 240.0;
78/// Extensions of the pictures Insert, Picture offers: those the page both stores and draws.77/// Extensions of the pictures Insert, Picture offers: those the page both stores and draws.
...@@ -593,6 +592,11 @@ fn strip() -> Id {...@@ -593,6 +592,11 @@ fn strip() -> Id {
593 Id::ROOT.child("strip")592 Id::ROOT.child("strip")
594}593}
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
596fn page() -> Id {600fn page() -> Id {
597 Id::ROOT.child("page")601 Id::ROOT.child("page")
598}602}
...@@ -1042,14 +1046,18 @@ impl State {...@@ -1042,14 +1046,18 @@ impl State {
1042 ..Spec::default()1046 ..Spec::default()
1043 },1047 },
1044 );1048 );
1045 let tabs = self.ui.id("tabs");
1046 let height = self1049 let height = self
1047 .ui1050 .ui
1048 .animate(tabs, if self.full_page { 0.0 } else { TAB_ROW });1051 .animate(tab_row(), if self.full_page { 0.0 } else { TAB_ROW });
1049 let tab_row = self.ui.open(1052 let drags = self.chrome_drags();
1050 "tabs",1053 let tab_row = self.ui.open_as(
1054 tab_row(),
1051 Spec {1055 Spec {
1052 flags: Flags::CLIP,1056 flags: if drags {
1057 Flags::CLIP | Flags::CLICKABLE
1058 } else {
1059 Flags::CLIP
1060 },
1053 size: [fill(), px(height)],1061 size: [fill(), px(height)],
1054 fill: Some(theme.strip),1062 fill: Some(theme.strip),
1055 pad: [FRAME, 0.0],1063 pad: [FRAME, 0.0],
...@@ -1387,6 +1395,12 @@ impl State {...@@ -1387,6 +1395,12 @@ impl State {
1387 );1395 );
1388 }1396 }
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
1390 /// The toolbar: one row of groups that fold as the window narrows. Where the platform1404 /// The toolbar: one row of groups that fold as the window narrows. Where the platform
1391 /// lets it, the row is the window's title bar, and on the welcome page, without `tools`,1405 /// lets it, the row is the window's title bar, and on the welcome page, without `tools`,
1392 /// that is all it is.1406 /// that is all it is.
...@@ -1395,8 +1409,7 @@ impl State {...@@ -1395,8 +1409,7 @@ impl State {
1395 if !tools && !title {1409 if !tools && !title {
1396 return Ok(());1410 return Ok(());
1397 }1411 }
1398 // Over the title's gradient, the row's empty space drags the window as the title does.1412 let strip_row = self.chrome_drags();
1399 let strip_row = title || self.surface.translucent();
1400 let spec = Spec {1413 let spec = Spec {
1401 flags: if strip_row {1414 flags: if strip_row {
1402 Flags::CLICKABLE1415 Flags::CLICKABLE
...@@ -1414,7 +1427,7 @@ impl State {...@@ -1414,7 +1427,7 @@ impl State {
1414 } else {1427 } else {
1415 self.ui.open("toolbar", spec);1428 self.ui.open("toolbar", spec);
1416 }1429 }
1417 let lead = if title { platform::LEADING } else { EDGE };1430 let lead = if title { platform::LEADING } else { FRAME };
1418 self.ui.leaf(1431 self.ui.leaf(
1419 "lead",1432 "lead",
1420 Spec {1433 Spec {
...@@ -1439,7 +1452,7 @@ impl State {...@@ -1439,7 +1452,7 @@ impl State {
1439 self.ui.leaf(1452 self.ui.leaf(
1440 "trail",1453 "trail",
1441 Spec {1454 Spec {
1442 size: [px(EDGE - GAP), px(1.0)],1455 size: [px(FRAME - GAP), px(1.0)],
1443 ..Spec::default()1456 ..Spec::default()
1444 },1457 },
1445 );1458 );
...@@ -1662,7 +1675,7 @@ impl State {...@@ -1662,7 +1675,7 @@ impl State {
1662 choice = group(1675 choice = group(
1663 ui,1676 ui,
1664 "character",1677 "character",
1665 6,1678 5,
1666 |ui| {1679 |ui| {
1667 divider(ui, theme);1680 divider(ui, theme);
1668 let mut choice = None;1681 let mut choice = None;
...@@ -1757,7 +1770,7 @@ impl State {...@@ -1757,7 +1770,7 @@ impl State {
1757 choice = group(1770 choice = group(
1758 ui,1771 ui,
1759 "paragraph",1772 "paragraph",
1760 5,1773 4,
1761 |ui| {1774 |ui| {
1762 divider(ui, theme);1775 divider(ui, theme);
1763 let mut choice = None;1776 let mut choice = None;
...@@ -1885,7 +1898,7 @@ impl State {...@@ -1885,7 +1898,7 @@ impl State {
1885 choice = group(1898 choice = group(
1886 ui,1899 ui,
1887 "tags",1900 "tags",
1888 7,1901 6,
1889 |ui| {1902 |ui| {
1890 divider(ui, theme);1903 divider(ui, theme);
1891 let mut choice = None;1904 let mut choice = None;
...@@ -1950,17 +1963,7 @@ impl State {...@@ -1950,17 +1963,7 @@ impl State {
1950 },1963 },
1951 );1964 );
1952 if let Some(session) = session {1965 if let Some(session) = session {
1953 ui.open(1966 sync::control(ui, session, theme);
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();
1964 }1967 }
1965 // Spelling awaits a platform spell checker.1968 // Spelling awaits a platform spell checker.
1966 let views = [1969 let views = [
...@@ -1991,9 +1994,11 @@ impl State {...@@ -1991,9 +1994,11 @@ impl State {
1991 toolbar_popup("page color"),1994 toolbar_popup("page color"),
1992 anchor,1995 anchor,
1993 &mut self.thumbnails,1996 &mut self.thumbnails,
1997 &mut self.templates,
1998 // The page's own colour aside.
1994 canvas::gpu::Paper {1999 canvas::gpu::Paper {
1995 color: theme.paper,2000 color: ui.theme.paper,
1996 ink: theme.paper_ink,2001 ink: ui.theme.paper_ink,
1997 },2002 },
1998 self.view.editor.page_color(),2003 self.view.editor.page_color(),
1999 self.view.editor.rule_lines(),2004 self.view.editor.rule_lines(),
...@@ -3111,7 +3116,7 @@ impl State {...@@ -3111,7 +3116,7 @@ impl State {
3111 pressed: true,3116 pressed: true,
3112 at,3117 at,
3113 } = event3118 } = 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())
3115 {3120 {
3116 let double = self.strip_press.is_some_and(|last| {3121 let double = self.strip_press.is_some_and(|last| {
3117 at.saturating_duration_since(last) <= platform::double_click_interval()3122 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 {...@@ -71,47 +71,26 @@ fn when(time: u64) -> String {
71 }71 }
72}72}
7373
74/// The status in the toolbar, which opens the popup: its label, or where `compact` its icon,74/// The status's icon in the toolbar, named in its tooltip, which opens the popup.
75/// naming it in a tooltip.75pub(crate) fn control(ui: &mut Ui, session: &Session, theme: &Theme) {
76pub(crate) fn control(ui: &mut Ui, session: &Session, theme: &Theme, compact: bool) {
77 let strong = ui.popup_open(id()) || session.sync.error.is_some() && !library::offline();76 let strong = ui.popup_open(id()) || session.sync.error.is_some() && !library::offline();
78 let (label, icon, _) = describe(&session.sync);77 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 };
93 ui.open_as(78 ui.open_as(
94 shown,79 button(),
95 if compact {80 Spec {
96 Spec {81 flags: Flags::CLICKABLE,
97 size: [px(TOOL), px(TOOL)],82 size: [px(TOOL), px(TOOL)],
98 icon: Some(icon),83 icon: Some(icon),
99 ..spec84 color: Some(if strong { theme.text } else { theme.text_dim }),
100 }85 hover_fill: Some(theme.hover()),
101 } else {86 radius: 4.0,
102 Spec {87 center: true,
103 size: [fit(), px(TOOL)],88 ..Spec::default()
104 text: Some(label),
105 pad: [8.0, 0.0],
106 ..spec
107 }
108 },89 },
109 );90 );
110 ui.close();91 ui.close();
111 if compact {92 ui::popup::tooltip(ui, label, "", None);
112 ui::popup::tooltip(ui, label, "", None);93 if ui.signal(button()).clicked {
113 }
114 if ui.signal(shown).clicked {
115 if ui.popup_open(id()) {94 if ui.popup_open(id()) {
116 ui.close_popup(id());95 ui.close_popup(id());
117 } else {96 } else {
crates/snowbound/src/templates.rs+51-4
...@@ -48,6 +48,8 @@ pub enum View {...@@ -48,6 +48,8 @@ pub enum View {
48 /// The strip with the page colours beneath it.48 /// The strip with the page colours beneath it.
49 Colors,49 Colors,
50 Gallery,50 Gallery,
51 /// Every template's art, from the Page Color menu, to lie behind the open page.
52 Art,
51}53}
5254
53/// A thumbnail's art: each raster and where it lies in the tile.55/// A thumbnail's art: each raster and where it lies in the tile.
...@@ -199,7 +201,8 @@ fn strip(...@@ -199,7 +201,8 @@ fn strip(
199 chosen201 chosen
200}202}
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.
203fn gallery(206fn gallery(
204 ui: &mut Ui,207 ui: &mut Ui,
205 theme: &Theme,208 theme: &Theme,
...@@ -207,6 +210,7 @@ fn gallery(...@@ -207,6 +210,7 @@ fn gallery(
207 paper: Paper,210 paper: Paper,
208 dark: bool,211 dark: bool,
209 size: [f32; 2],212 size: [f32; 2],
213 art: bool,
210) -> Option<Choice> {214) -> Option<Choice> {
211 let mut chosen = None;215 let mut chosen = None;
212 let margin = 24.0;216 let margin = 24.0;
...@@ -236,7 +240,7 @@ fn gallery(...@@ -236,7 +240,7 @@ fn gallery(
236 "title",240 "title",
237 Spec {241 Spec {
238 size: [fill(), px(24.0)],242 size: [fill(), px(24.0)],
239 text: Some("Page Templates"),243 text: Some(if art { "Backgrounds" } else { "Page Templates" }),
240 ..Spec::default()244 ..Spec::default()
241 },245 },
242 );246 );
...@@ -264,7 +268,8 @@ fn gallery(...@@ -264,7 +268,8 @@ fn gallery(
264 PAGE_COLORS268 PAGE_COLORS
265 .iter()269 .iter()
266 .enumerate()270 .enumerate()
267 .map(|(index, (name, _))| (Choice::Color(index), *name)),271 .map(|(index, (name, _))| (Choice::Color(index), *name))
272 .filter(|_| !art),
268 )273 )
269 .collect();274 .collect();
270 for (row, choices) in choices.chunks(columns).enumerate() {275 for (row, choices) in choices.chunks(columns).enumerate() {
...@@ -411,6 +416,9 @@ impl crate::State {...@@ -411,6 +416,9 @@ impl crate::State {
411 let Some(session) = &self.session else {416 let Some(session) = &self.session else {
412 return;417 return;
413 };418 };
419 if self.templates == View::Art {
420 return self.art_gallery(theme);
421 }
414 let space = session.space;422 let space = session.space;
415 let editor = &self.view.editor;423 let editor = &self.view.editor;
416 let blank = editor424 let blank = editor
...@@ -440,8 +448,17 @@ impl crate::State {...@@ -440,8 +448,17 @@ impl crate::State {
440 let chosen = match self.templates {448 let chosen = match self.templates {
441 View::Gallery => {449 View::Gallery => {
442 let size = [rect[2] - rect[0], rect[3] - rect[1]];450 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 )
444 }460 }
461 View::Art => unreachable!("The art gallery is built alone"),
445 view => strip(462 view => strip(
446 &mut self.ui,463 &mut self.ui,
447 theme,464 theme,
...@@ -475,4 +492,34 @@ impl crate::State {...@@ -475,4 +492,34 @@ impl crate::State {
475 None => {}492 None => {}
476 }493 }
477 }494 }
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 }
478}525}
crates/ui/src/layout.rs+8-5
...@@ -3,8 +3,8 @@ use std::collections::HashMap;...@@ -3,8 +3,8 @@ use std::collections::HashMap;
33
4/// Sizes each box on both axes, then places it: standalone sizes, sizes taken from4/// Sizes each box on both axes, then places it: standalone sizes, sizes taken from
5/// ancestors (pre-order), sizes summed from children (post-order), overflow taken back5/// 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 least6/// (by folding a row's groups for what space sized by ancestors can't give, then from that
7/// strict boxes first), and positions along each parent's flow. Labels too wide for their7/// space, then from the least strict boxes first), and positions along each parent's flow. Labels too wide for their
8/// solved width wrap or shorten before heights are solved. Boxes are in build order, so8/// solved width wrap or shorten before heights are solved. Boxes are in build order, so
9/// index order is pre-order.9/// index order is pre-order.
10pub(crate) fn solve(10pub(crate) fn solve(
...@@ -75,10 +75,12 @@ pub(crate) fn solve(...@@ -75,10 +75,12 @@ pub(crate) fn solve(
75 if along(node, axis) {75 if along(node, axis) {
76 let space =76 let space =
77 |child: &&usize| matches!(nodes[**child].size[axis].size, Size::Fraction(_));77 |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);
79 let mut excess = flow(nodes, index, axis) - room;79 let mut excess = flow(nodes, index, axis) - room;
80 excess = give_back(nodes, &space, axis, excess);80 // Groups fold only for what the space can't give, so the room a fold frees
81 while excess > 0.0 && axis == 0 {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 {
82 let Some(group) = children84 let Some(group) = children
83 .iter()85 .iter()
84 .copied()86 .copied()
...@@ -96,6 +98,7 @@ pub(crate) fn solve(...@@ -96,6 +98,7 @@ pub(crate) fn solve(
96 excess -= nodes[group].computed[0] - width;98 excess -= nodes[group].computed[0] - width;
97 nodes[group].computed[0] = width;99 nodes[group].computed[0] = width;
98 }100 }
101 excess = give_back(nodes, &space, axis, excess);
99 let mut tiers: Vec<f32> = sized102 let mut tiers: Vec<f32> = sized
100 .iter()103 .iter()
101 .map(|child| strictness(nodes, *child, axis))104 .map(|child| strictness(nodes, *child, axis))
crates/ui/src/lib.rs+3
...@@ -542,6 +542,8 @@ struct Popup {...@@ -542,6 +542,8 @@ struct Popup {
542 /// Its filter field's text, and the key of the row the keyboard or pointer last chose.542 /// Its filter field's text, and the key of the row the keyboard or pointer last chose.
543 query: String,543 query: String,
544 highlight: Option<u64>,544 highlight: Option<u64>,
545 /// A colour picker's hue in degrees, saturation and lightness, once it has one.
546 picked: Option<[f32; 3]>,
545 /// The height its results ease from and to, and the seconds since they set out.547 /// The height its results ease from and to, and the seconds since they set out.
546 height: Option<[f32; 3]>,548 height: Option<[f32; 3]>,
547 opened: Instant,549 opened: Instant,
...@@ -1000,6 +1002,7 @@ impl Ui {...@@ -1000,6 +1002,7 @@ impl Ui {
1000 focus: self.focus,1002 focus: self.focus,
1001 query: String::new(),1003 query: String::new(),
1002 highlight: None,1004 highlight: None,
1005 picked: None,
1003 height: None,1006 height: None,
1004 opened: self.now,1007 opened: self.now,
1005 });1008 });
crates/ui/src/popup.rs+221
...@@ -814,6 +814,227 @@ pub fn gallery(...@@ -814,6 +814,227 @@ pub fn gallery(
814 None814 None
815}815}
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
817/// Opens popup `id`'s panel `width` wide beside `anchor`; the caller closes it.1038/// Opens popup `id`'s panel `width` wide beside `anchor`; the caller closes it.
818fn surface(ui: &mut Ui, id: Id, anchor: Anchor, width: f32) {1039fn surface(ui: &mut Ui, id: Id, anchor: Anchor, width: f32) {
819 let style = ui.theme.menu();1040 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() {...@@ -209,7 +209,8 @@ fn a_row_squeezes_its_boxes_only_once_every_group_is_folded() {
209 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);209 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
210 let rects = folding_row(&mut ui, 130.0, &[0, 1]);210 let rects = folding_row(&mut ui, 130.0, &[0, 1]);
211 // 80 + 20 + 20 fits 130 with 10 to spare, taken by the space.211 // 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]));
213 let rects = folding_row(&mut ui, 100.0, &[0, 1]);214 let rects = folding_row(&mut ui, 100.0, &[0, 1]);
214 assert_eq!(rects[0], Some([0.0, 0.0, 60.0, 20.0]));215 assert_eq!(rects[0], Some([0.0, 0.0, 60.0, 20.0]));
215 assert_eq!(rects[4], Some([80.0, 0.0, 100.0, 20.0]));216 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() {...@@ -2516,3 +2517,29 @@ fn the_wheel_scrolls_overflowing_tabs_sideways_either_way_it_turns() {
2516 let back = ui.rect(shell::tab_id(row, 0)).unwrap()[0];2517 let back = ui.rect(shell::tab_id(row, 0)).unwrap()[0];
2517 assert!((back - scrolled - 30.0).abs() < 0.5, "{scrolled} to {back}");2518 assert!((back - scrolled - 30.0).abs() < 0.5, "{scrolled} to {back}");
2518}2519}
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}