authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 17:44:51-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 19:55:46-07:00
log2b6b27a5f40c444729b8e94f419110deb57fe633
tree830a02a16dd9766d64af66a39cae110655960682
parent3d84683db457cfb9c19b314a937cbce6b1b8091d
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: a style theme can colour a style "Theme", its section's accent

Beside Automatic, the Themes dialog's colour picker offers Theme: the page's section's hue at a fixed shade, grey for a grey section and OneNote's blue for none. Pages store the resolved colour, so OneNote 2010 draws it; themes.json keeps "accent": true beside the uncoloured section's colour, which versions without the flag take. Every writer resolves from the section colour in the shared files, so users agree and a page restyles only when its section's colour changes. Assisted-by: claude-opus-5.5

10 files changed, 257 insertions(+), 94 deletions(-)

crates/mobile/src/lib.rs+7-7
......@@ -156,8 +156,11 @@ impl Canvas {
156156 page.host_viewport = true;
157157 let markdown;
158158 (page.snap_to_grid, page.editor.default_font, markdown) = options().clone();
159 page.editor.markdown =
160 markdown.then(|| notebook::sidecar::themes::built_in().swap_remove(0).sheet());
159 page.editor.markdown = markdown.then(|| {
160 notebook::sidecar::themes::built_in()
161 .swap_remove(0)
162 .sheet(None)
163 });
161164 Ok(Self {
162165 page,
163166 space,
......@@ -733,10 +736,7 @@ impl View {
733736 surface.configure(gpu.renderer.device(), &config);
734737 let (mut page, read_only) = section.shared.page(space)?;
735738 // The page wears its theme: style objects it gives otherwise are restyled first.
736 let sheet = section
737 .theme(page.identity)
738 .map(|theme| theme.sheet())
739 .unwrap_or_default();
739 let sheet = section.sheet(page.identity);
740740 let restyle = onestore::op::restyle(&page, &sheet)?;
741741 if !read_only && !restyle.is_empty() {
742742 section.shared.apply(page_edit(space, restyle))?;
......@@ -1118,7 +1118,7 @@ pub extern "C" fn sb_view_style(view: &mut View, place: u8) -> bool {
11181118 let editor = &view.canvas.page.editor;
11191119 let style = editor.styles.get(*name).cloned().unwrap_or_else(|| {
11201120 let theme = built_in().swap_remove(0);
1121 definition(name, &theme.styles[*name])
1121 definition(name, &theme.styles[*name], None)
11221122 });
11231123 let result = view
11241124 .canvas
crates/mobile/src/library.rs+20-4
......@@ -810,13 +810,29 @@ impl Section {
810810 ])
811811 }
812812
813 /// The theme page `identity` of this section wears, if any scope names one.
814 pub(crate) fn theme(
813 /// The paragraph styles page `identity` of this section wears in its theme, in this
814 /// section's colour; none where no scope names a theme.
815 pub(crate) fn sheet(
815816 &self,
816817 identity: Option<[u8; 16]>,
817 ) -> Option<notebook::sidecar::themes::Theme> {
818 ) -> std::collections::BTreeMap<String, onestore::page::Definition> {
818819 let section = self.shared.section.identity().ok();
819 self.themes().effective(section, identity)
820 let Some(theme) = self.themes().effective(section, identity) else {
821 return Default::default();
822 };
823 let color = section.and_then(|section| {
824 let notebook = self.library.notebook();
825 let listed = notebook
826 .as_ref()?
827 .catalog()
828 .sections()
829 .find(|listed| listed.file_id == section)?;
830 match listed.state {
831 SectionState::Readable { color, .. } => color,
832 _ => None,
833 }
834 });
835 theme.sheet(color)
820836 }
821837
822838 /// The section at catalog `path` of `library`, its edits naming `author`.
crates/notebook/src/discover.rs+1-1
......@@ -69,7 +69,7 @@ impl Folder {
6969 }
7070
7171 /// The sections of this folder and every group under it, in catalog order.
72 pub(crate) fn sections(&self) -> impl Iterator<Item = &Section> {
72 pub fn sections(&self) -> impl Iterator<Item = &Section> {
7373 self.folders().flat_map(|folder| &folder.sections)
7474 }
7575}
crates/notebook/src/sidecar/themes.rs+91-17
......@@ -38,8 +38,12 @@ pub struct ThemeStyle {
3838 pub size: f32,
3939 pub bold: bool,
4040 pub italic: bool,
41 /// `#rrggbb`, or none for automatic.
41 /// `#rrggbb`, or none for automatic. Under `accent`, the accent of a section without a
42 /// colour, which versions that don't know `accent` take.
4243 pub color: Option<String>,
44 /// Takes the page's section's accent (the "Theme" colour) in place of `color`.
45 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
46 pub accent: bool,
4347 /// Space above and below the paragraph, in points.
4448 pub before: f32,
4549 pub after: f32,
......@@ -224,16 +228,65 @@ fn is_built_in(id: &str) -> bool {
224228 built_in().iter().any(|theme| theme.id == id)
225229}
226230
227/// Stored style `name`'s paragraph style as `style` gives it: every character flag set, as
228/// OneNote 2010 writes its own, headings followed by Normal.
229pub fn definition(name: &str, style: &ThemeStyle) -> Definition {
230 let color = style
231 .color
232 .as_deref()
233 .and_then(|hex| u32::from_str_radix(hex.strip_prefix('#')?, 16).ok())
234 .map_or(0xff00_0000, |rgb| {
235 (rgb >> 16) | (rgb & 0xff00) | ((rgb & 0xff) << 16)
236 });
231/// The accent of a section coloured `section` (a COLORREF; none for OneNote's None), as a
232/// COLORREF: its hue at a fixed saturation and lightness, grey staying grey. Pages store it,
233/// so it never changes: versions resolving it apart would restyle each other's pages.
234pub fn accent(section: Option<u32>) -> u32 {
235 // OneNote's blue for a section without a colour, as Snowbound's tabs show one.
236 let [red, green, blue, _] = section.unwrap_or(0x00E4_A88A).to_le_bytes();
237 let [red, green, blue] = [red, green, blue].map(|byte| f32::from(byte) / 255.0);
238 let (max, min) = (red.max(green).max(blue), red.min(green).min(blue));
239 let range = max - min;
240 let sector = if range == 0.0 {
241 0.0
242 } else if max == red {
243 (green - blue) / range
244 } else if max == green {
245 (blue - red) / range + 2.0
246 } else {
247 (red - green) / range + 4.0
248 };
249 let hue = (sector * 60.0).rem_euclid(360.0);
250 let (saturation, lightness) = (if range == 0.0 { 0.0 } else { 0.60 }, 0.45);
251 let chroma = (1.0 - (2.0 * lightness - 1.0f32).abs()) * saturation;
252 let x = chroma * (1.0 - ((hue / 60.0) % 2.0 - 1.0).abs());
253 let [r, g, b] = match hue {
254 h if h < 60.0 => [chroma, x, 0.0],
255 h if h < 120.0 => [x, chroma, 0.0],
256 h if h < 180.0 => [0.0, chroma, x],
257 h if h < 240.0 => [0.0, x, chroma],
258 h if h < 300.0 => [x, 0.0, chroma],
259 _ => [chroma, 0.0, x],
260 };
261 let byte = |value: f32| {
262 ((value + lightness - chroma / 2.0) * 255.0)
263 .round()
264 .clamp(0.0, 255.0) as u32
265 };
266 byte(r) | byte(g) << 8 | byte(b) << 16
267}
268
269/// A COLORREF as `#rrggbb`, as themes keep colours.
270pub fn color_hex(colorref: u32) -> String {
271 let [red, green, blue, _] = colorref.to_le_bytes();
272 format!("#{red:02X}{green:02X}{blue:02X}")
273}
274
275impl ThemeStyle {
276 /// The style's colour as a COLORREF in a section coloured `section`; none for automatic.
277 pub fn colorref(&self, section: Option<u32>) -> Option<u32> {
278 if self.accent {
279 return Some(accent(section));
280 }
281 let rgb = u32::from_str_radix(self.color.as_deref()?.strip_prefix('#')?, 16).ok()?;
282 Some((rgb >> 16) | (rgb & 0xff00) | ((rgb & 0xff) << 16))
283 }
284}
285
286/// Stored style `name`'s paragraph style as `style` gives it in a section coloured `section`:
287/// every character flag set, as OneNote 2010 writes its own, headings followed by Normal.
288pub fn definition(name: &str, style: &ThemeStyle, section: Option<u32>) -> Definition {
289 let color = style.colorref(section).unwrap_or(0xff00_0000);
237290 // Paragraph spacing is stored in half inches.
238291 let stored = |points: f32| points / 36.0 * 36.0;
239292 Definition {
......@@ -261,11 +314,12 @@ pub fn definition(name: &str, style: &ThemeStyle) -> Definition {
261314}
262315
263316impl Theme {
264 /// The paragraph style definitions the theme gives, by stored name.
265 pub fn sheet(&self) -> BTreeMap<String, Definition> {
317 /// The paragraph style definitions the theme gives in a section coloured `section`, by
318 /// stored name.
319 pub fn sheet(&self, section: Option<u32>) -> BTreeMap<String, Definition> {
266320 self.styles
267321 .iter()
268 .map(|(name, style)| (name.clone(), definition(name, style)))
322 .map(|(name, style)| (name.clone(), definition(name, style, section)))
269323 .collect()
270324 }
271325}
......@@ -284,6 +338,7 @@ fn style(
284338 bold,
285339 italic,
286340 color: color.map(Into::into),
341 accent: false,
287342 before,
288343 after,
289344 }
......@@ -398,7 +453,7 @@ mod tests {
398453 let document = onestore::document::Document::parse(&index).unwrap();
399454 let (space, _) = document.pages().unwrap()[0];
400455 let page = onestore::page::Page::from_space(&document, space).unwrap();
401 let sheet = built_in()[0].sheet();
456 let sheet = built_in()[0].sheet(None);
402457 for (name, definition) in &sheet {
403458 assert!(
404459 page.definitions.values().any(|stored| stored == definition),
......@@ -432,7 +487,7 @@ mod tests {
432487 .into_iter()
433488 .find(|theme| theme.id == "manuscript")
434489 .unwrap()
435 .sheet();
490 .sheet(None);
436491 let calibri: Vec<&str> = page
437492 .definitions
438493 .values()
......@@ -470,10 +525,29 @@ mod tests {
470525 assert!(onestore::op::restyle(&healed, &sheet).unwrap().is_empty());
471526 }
472527
528 /// The "Theme" colour is the section's hue at the accent's shade, OneNote's blue for a
529 /// section without a colour, and grey for a grey section.
530 #[test]
531 fn a_theme_coloured_style_takes_its_section_s_accent() {
532 assert_eq!(color_hex(accent(None)), "#2E5CB8");
533 assert_eq!(color_hex(accent(Some(0x0000_00FF))), "#B82E2E");
534 assert_eq!(color_hex(accent(Some(0x0080_8080))), "#737373");
535 let style = ThemeStyle {
536 accent: true,
537 ..style("Arial", 16.0, BOLD, Some("#2E5CB8"), NONE)
538 };
539 let red = definition("h1", &style, Some(0x0000_00FF));
540 assert_eq!(red.format.color, Some(0x002E_2EB8));
541 // Versions that don't know the accent read the colour beside it.
542 let json = serde_json::to_value(&style).unwrap();
543 assert_eq!(json["color"], "#2E5CB8");
544 assert_eq!(json["accent"], true);
545 }
546
473547 #[test]
474548 fn every_built_in_theme_names_every_gallery_style() {
475549 for theme in built_in() {
476 let sheet = theme.sheet();
550 let sheet = theme.sheet(None);
477551 for (name, _) in STYLES {
478552 assert!(sheet.contains_key(name), "{} {name}", theme.id);
479553 }
crates/snowbound/src/commands.rs+5-1
......@@ -136,7 +136,11 @@ pub fn offered(id: Id) -> bool {
136136
137137/// What the editor's Markdown shortcuts take: OneNote 2010's gallery styles when `on`.
138138pub fn markdown(on: bool) -> Option<BTreeMap<String, onestore::page::Definition>> {
139 on.then(|| notebook::sidecar::themes::built_in().swap_remove(0).sheet())
139 on.then(|| {
140 notebook::sidecar::themes::built_in()
141 .swap_remove(0)
142 .sheet(None)
143 })
140144}
141145
142146/// What a menu or toolbar offers: a command, or one of a list's entries.
crates/snowbound/src/library.rs+12
......@@ -1026,6 +1026,18 @@ impl Library {
10261026 .map(|section| section.file_id)
10271027 }
10281028
1029 /// The colour of the section at catalog `path`, COLORREF; none for OneNote's None.
1030 pub fn section_color(&self, path: &str) -> Option<u32> {
1031 let listed = folders(self.catalog()?, |_| true)
1032 .into_iter()
1033 .flat_map(|folder| &folder.sections)
1034 .find(|section| section.path == path)?;
1035 match listed.state {
1036 SectionState::Readable { color, .. } => color,
1037 _ => None,
1038 }
1039 }
1040
10291041 /// The first readable section, searching groups after sections, as OneNote opens a
10301042 /// notebook.
10311043 pub fn first_section(&self) -> Option<String> {
crates/snowbound/src/main.rs+4-1
......@@ -2177,6 +2177,7 @@ impl State {
21772177 let session = self.session.as_ref();
21782178 let update = self.updates.status();
21792179 let sheet = self.gallery_sheet();
2180 let section = self.section_color();
21802181 let drawing_pens = self.pens();
21812182 let ui = &mut self.ui;
21822183 let text = theme.text;
......@@ -2269,7 +2270,9 @@ impl State {
22692270 .find(|(name, _)| state.style.as_deref() == Some(*name))
22702271 .map_or("Styles".to_owned(), |(_, label)| format!("Styles: {label}"));
22712272 ui::popup::tooltip(ui, &shown, "", None);
2272 if let Some(id) = themes::gallery(ui, styles, anchor, &sheet, state.style.as_deref()) {
2273 if let Some(id) =
2274 themes::gallery(ui, styles, anchor, &sheet, section, state.style.as_deref())
2275 {
22732276 choice = Some(Choice::Command(id));
22742277 }
22752278 let combo = ui.id("font");
crates/snowbound/src/menus.rs+5-3
......@@ -709,13 +709,14 @@ impl State {
709709 (Target::Section { library, path }, Action::Theme) => {
710710 if let Some(identity) = library.section_identity(&path) {
711711 let scope = notebook::sidecar::themes::Scope::section(identity);
712 self.show_themes(crate::themes::Scope::Section, library, scope);
712 let color = library.section_color(&path);
713 self.show_themes(crate::themes::Scope::Section, library, scope, color);
713714 }
714715 None
715716 }
716717 (Target::Notebook(library), Action::Theme) => {
717718 let scope = notebook::sidecar::themes::Scope::Notebook;
718 self.show_themes(crate::themes::Scope::Notebook, library, scope);
719 self.show_themes(crate::themes::Scope::Notebook, library, scope, None);
719720 None
720721 }
721722 (Target::Section { library, path }, Action::Color(color)) => Some(Command::Structure(
......@@ -843,7 +844,8 @@ impl State {
843844 let identity = session.section.page(space).ok()?.identity?;
844845 let library = Arc::clone(&session.library);
845846 let scope = notebook::sidecar::themes::Scope::page(identity);
846 self.show_themes(crate::themes::Scope::Page, library, scope);
847 let color = self.section_color();
848 self.show_themes(crate::themes::Scope::Page, library, scope, color);
847849 None
848850 }
849851 Action::Versions(show) => Some(Command::History { page: space, show }),
crates/snowbound/src/themes.rs+70-40
......@@ -44,6 +44,8 @@ pub struct Dialog {
4444 selected: usize,
4545 /// The style the controls edit, by its place in the gallery.
4646 style: usize,
47 /// The colour of the section the theme is chosen for, which "Theme" colours take.
48 section: Option<u32>,
4749}
4850
4951fn id() -> Id {
......@@ -57,29 +59,24 @@ fn popup(name: &str) -> Id {
5759/// Spacing the dialog offers above and below a style, in points.
5860const SPACING: [f32; 9] = [0.0, 2.0, 3.0, 4.0, 6.0, 8.0, 10.0, 12.0, 18.0];
5961
60/// A style's colour as `#rrggbb` from a COLORREF, and back.
61fn hex(colorref: u32) -> String {
62 let [red, green, blue, _] = colorref.to_le_bytes();
63 format!("#{red:02X}{green:02X}{blue:02X}")
64}
65
66fn colorref(hex: &str) -> Option<u32> {
67 let rgb = u32::from_str_radix(hex.strip_prefix('#')?, 16).ok()?;
68 Some((rgb >> 16) | (rgb & 0xff00) | ((rgb & 0xff) << 16))
69}
70
71/// A line of `text` in `style` as a page shows it, scaled down to fit a menu row up to
72/// `largest` pixels, its colour moved onto the popup's paper so it reads in either scheme.
73pub(crate) fn preview(ui: &mut Ui, part: &str, text: &str, style: &ThemeStyle, largest: f32) {
62/// A line of `text` in `style` as a page in a section coloured `section` shows it, scaled
63/// down to fit a menu row up to `largest` pixels, its colour moved onto the popup's paper so
64/// it reads in either scheme.
65pub(crate) fn preview(
66 ui: &mut Ui,
67 part: &str,
68 text: &str,
69 style: &ThemeStyle,
70 section: Option<u32>,
71 largest: f32,
72) {
7473 let theme = ui.theme.clone();
7574 let paper = canvas::gpu::Paper {
7675 color: theme.popup,
7776 ink: theme.text,
7877 };
7978 let color = style
80 .color
81 .as_deref()
82 .and_then(colorref)
79 .colorref(section)
8380 .map_or(theme.text, |color| paper.tint(canvas::gpu::colorref(color)));
8481 ui.leaf(
8582 part,
......@@ -113,6 +110,12 @@ impl State {
113110 Some((Arc::clone(library), target))
114111 }
115112
113 /// The colour of the open section, which "Theme" colours take.
114 pub(crate) fn section_color(&self) -> Option<u32> {
115 let session = self.session.as_ref()?;
116 session.tabs.get(session.tab)?.color
117 }
118
116119 /// The theme the open page wears, if any scope names one.
117120 pub(crate) fn page_theme(&self) -> Option<Theme> {
118121 let session = self.session.as_ref()?;
......@@ -134,7 +137,7 @@ impl State {
134137 /// Stored style `name` as the gallery applies it on the open page.
135138 pub(crate) fn gallery_style(&self, name: &str) -> Definition {
136139 let theme = self.gallery_sheet();
137 stored::definition(name, &theme.styles[name])
140 stored::definition(name, &theme.styles[name], self.section_color())
138141 }
139142
140143 /// Dresses the open page in its theme: new text and Enter take the theme's styles, and
......@@ -142,7 +145,9 @@ impl State {
142145 /// keeps what it holds.
143146 pub(crate) fn wear_theme(&mut self) -> Result<(), Box<dyn Error>> {
144147 let theme = self.page_theme();
145 let sheet = theme.as_ref().map(Theme::sheet).unwrap_or_default();
148 let sheet = (theme.as_ref())
149 .map(|theme| theme.sheet(self.section_color()))
150 .unwrap_or_default();
146151 self.view.editor.styles = sheet.clone();
147152 let Some(session) = &self.session else {
148153 return Ok(());
......@@ -164,15 +169,18 @@ impl State {
164169 let Some((library, target)) = self.theme_target(scope) else {
165170 return;
166171 };
167 self.show_themes(scope, library, target);
172 let section = self.section_color();
173 self.show_themes(scope, library, target, section);
168174 }
169175
170 /// Opens the Themes dialog choosing a theme for `target`, which `scope` names.
176 /// Opens the Themes dialog choosing a theme for `target`, which `scope` names, in a
177 /// section coloured `section`.
171178 pub(crate) fn show_themes(
172179 &mut self,
173180 scope: Scope,
174181 library: Arc<Library>,
175182 target: stored::Scope,
183 section: Option<u32>,
176184 ) {
177185 let themes = library.themes();
178186 let assigned = themes.assigned(&target).map(|theme| theme.id);
......@@ -189,6 +197,7 @@ impl State {
189197 deleted: Vec::new(),
190198 selected,
191199 style: 0,
200 section,
192201 });
193202 self.ui.open_popup(id());
194203 }
......@@ -274,7 +283,14 @@ impl State {
274283 ..Spec::default()
275284 },
276285 );
277 preview(ui, "name", &listed.name, &listed.styles["h2"], 15.0);
286 preview(
287 ui,
288 "name",
289 &listed.name,
290 &listed.styles["h2"],
291 dialog.section,
292 15.0,
293 );
278294 if let Some(node) = ui.access(item) {
279295 node.set_label(listed.name.as_str());
280296 node.set_selected(shown);
......@@ -449,24 +465,28 @@ impl State {
449465 });
450466 field(ui, "Color:", &mut |ui| {
451467 let button = ui.id("color");
452 let shown = style.color.clone().unwrap_or_else(|| "Automatic".into());
453 ui::shell::combo(ui, "color", "Color", &shown, 120.0, popup("colors"), true);
468 let shown = match (&style.color, style.accent) {
469 (_, true) => "Theme",
470 (Some(color), false) => color,
471 (None, false) => "Automatic",
472 };
473 ui::shell::combo(ui, "color", "Color", shown, 120.0, popup("colors"), true);
454474 let swatches: Vec<_> = crate::FONT_COLORS
455475 .iter()
456476 .map(|&(color, name)| (canvas::gpu::colorref(color), name))
457477 .collect();
458478 let anchor = Anchor::Below(ui.rect(button).unwrap_or_default());
459 if let Some(chosen) =
460 ui::popup::colors(ui, popup("colors"), anchor, "Automatic", &swatches, 10)
479 let buttons = ["Automatic", "Theme"];
480 if let Some(picked) =
481 ui::popup::color_grid(ui, popup("colors"), anchor, &buttons, &swatches, 10)
461482 {
462 let color = chosen.and_then(|chosen| {
463 crate::FONT_COLORS
464 .iter()
465 .zip(&swatches)
466 .find(|(_, (swatch, _))| *swatch == chosen)
467 .map(|((color, _), _)| hex(*color))
468 });
469 change = Some(Change::Color(color));
483 change = match picked {
484 Ok(chosen) => (swatches.iter())
485 .position(|(swatch, _)| *swatch == chosen)
486 .map(|at| Change::Color(Some(stored::color_hex(crate::FONT_COLORS[at].0)))),
487 Err(0) => Some(Change::Color(None)),
488 Err(_) => Some(Change::Accent),
489 };
470490 }
471491 });
472492 for (part, label, value) in [
......@@ -527,7 +547,7 @@ impl State {
527547 ..Spec::default()
528548 },
529549 );
530 preview(ui, "text", label, style, 26.0);
550 preview(ui, "text", label, style, dialog.section, 26.0);
531551 ui.close();
532552 if ui.signal(line).clicked {
533553 dialog.style = at;
......@@ -632,6 +652,8 @@ enum Change {
632652 Bold,
633653 Italic,
634654 Color(Option<String>),
655 /// The section's accent, the "Theme" colour.
656 Accent,
635657 /// Space above (true) or below, in points.
636658 Spacing(bool, f32),
637659}
......@@ -643,7 +665,14 @@ impl Change {
643665 Self::Size(size) => style.size = size,
644666 Self::Bold => style.bold = !style.bold,
645667 Self::Italic => style.italic = !style.italic,
646 Self::Color(color) => style.color = color,
668 Self::Color(color) => {
669 style.color = color;
670 style.accent = false;
671 }
672 Self::Accent => {
673 style.color = Some(stored::color_hex(stored::accent(None)));
674 style.accent = true;
675 }
647676 Self::Spacing(true, points) => style.before = points,
648677 Self::Spacing(false, points) => style.after = points,
649678 }
......@@ -656,12 +685,13 @@ fn fresh_id(now: u64, count: usize) -> String {
656685}
657686
658687/// The Styles gallery under the toolbar's Styles button: the eleven styles drawn in
659/// `sheet`, the one at the caret outlined, then the theme commands. Returns the command chosen.
688/// `sheet` in a section coloured `section`, the one at the caret outlined, then the theme commands. Returns the command chosen.
660689pub(crate) fn gallery(
661690 ui: &mut Ui,
662691 menu: Id,
663692 anchor: Anchor,
664693 sheet: &Theme,
694 section: Option<u32>,
665695 current: Option<&str>,
666696) -> Option<crate::commands::Id> {
667697 use crate::commands::Id as Cmd;
......@@ -689,7 +719,7 @@ pub(crate) fn gallery(
689719 .get(index)
690720 {
691721 Some((name, label)) => {
692 preview(ui, "preview", label, &sheet.styles[*name], 24.0);
722 preview(ui, "preview", label, &sheet.styles[*name], section, 24.0);
693723 if let Some(node) = ui.access(ui.id("preview")) {
694724 node.set_label(*label);
695725 }
......@@ -785,7 +815,7 @@ mod tests {
785815 let source = onestore::create_section(&file, "", "Author").unwrap();
786816 let (space, before) = page(&source);
787817 let mut editor = CanvasEditor::from_page(before, &mut engine).unwrap();
788 let sheet = theme.sheet();
818 let sheet = theme.sheet(None);
789819 editor.styles = sheet.clone();
790820 let body = editor
791821 .outlines()
......@@ -864,7 +894,7 @@ mod tests {
864894 }
865895 assert!(op::restyle(&stored, &sheet).unwrap().is_empty());
866896 // Another theme restyles the page whole, keeping the names.
867 let other = built_in().swap_remove(3).sheet();
897 let other = built_in().swap_remove(3).sheet(None);
868898 let mut copy = onestore::Section::open(&arena, written.clone()).unwrap();
869899 copy.apply(
870900 "Author",
crates/ui/src/popup.rs+42-20
......@@ -752,26 +752,46 @@ pub fn colors(
752752 swatches: &[([f32; 4], &str)],
753753 columns: usize,
754754) -> Option<Option<[f32; 4]>> {
755 color_grid(ui, id, anchor, &[none], swatches, columns).map(Result::ok)
756}
757
758/// `colors` under a button for each of `buttons`: returns the swatch chosen, or the place of
759/// the button.
760pub fn color_grid(
761 ui: &mut Ui,
762 id: Id,
763 anchor: Anchor,
764 buttons: &[&str],
765 swatches: &[([f32; 4], &str)],
766 columns: usize,
767) -> Option<Result<[f32; 4], usize>> {
755768 if !ui.popup_open(id) {
756769 return None;
757770 }
758 // The button is cell 0 and the swatches follow it.
771 // The buttons are the first cells and the swatches follow them.
759772 let cell = |index: usize| id.child(("cell", index));
760 let count = swatches.len() + 1;
773 let first = buttons.len();
774 let count = swatches.len() + first;
761775 let (highlight, chosen) = pick_cell(ui, id, count, cell, |key, highlight| {
762776 match (key, highlight) {
763777 (_, None) => 0,
764778 (NamedKey::ArrowLeft, Some(at)) => at.saturating_sub(1),
765779 (NamedKey::ArrowRight, Some(at)) => (at + 1).min(count - 1),
766 (NamedKey::ArrowUp, Some(at)) => at.saturating_sub(columns),
767 (_, Some(0)) => 1,
780 (NamedKey::ArrowUp, Some(at)) if at < first + columns => {
781 at.min(first).saturating_sub(1)
782 }
783 (NamedKey::ArrowUp, Some(at)) => at - columns,
784 (_, Some(at)) if at < first => at + 1,
768785 (_, Some(at)) if at + columns < count => at + columns,
769786 (_, Some(at)) => at,
770787 }
771788 });
772789 if let Some(index) = chosen {
773790 ui.close_popup(id);
774 return Some((index > 0).then(|| swatches[index - 1].0));
791 return Some(match index.checked_sub(first) {
792 Some(swatch) => Ok(swatches[swatch].0),
793 None => Err(index),
794 });
775795 }
776796
777797 let theme = ui.theme.clone();
......@@ -783,20 +803,22 @@ pub fn colors(
783803 anchor,
784804 columns as f32 * CELL + 2.0 * ui.theme.menu().pad,
785805 );
786 ui.open_as(
787 cell(0),
788 Spec {
789 flags: Flags::CLICKABLE,
790 size: [fill(), px(ROW)],
791 text: Some(none),
792 fill: lit(0),
793 radius: 4.0,
794 pad: [8.0, 0.0],
795 role: Some(Role::MenuItem),
796 ..Spec::default()
797 },
798 );
799 ui.close();
806 for (index, button) in buttons.iter().enumerate() {
807 ui.open_as(
808 cell(index),
809 Spec {
810 flags: Flags::CLICKABLE,
811 size: [fill(), px(ROW)],
812 text: Some(button),
813 fill: lit(index),
814 radius: 4.0,
815 pad: [8.0, 0.0],
816 role: Some(Role::MenuItem),
817 ..Spec::default()
818 },
819 );
820 ui.close();
821 }
800822 for (row, colors) in swatches.chunks(columns).enumerate() {
801823 ui.open(
802824 ("row", row),
......@@ -806,7 +828,7 @@ pub fn colors(
806828 },
807829 );
808830 for (column, (color, name)) in colors.iter().enumerate() {
809 let index = 1 + row * columns + column;
831 let index = first + row * columns + column;
810832 ui.open_as(
811833 cell(index),
812834 Spec {