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 {...@@ -156,8 +156,11 @@ impl Canvas {
156 page.host_viewport = true;156 page.host_viewport = true;
157 let markdown;157 let markdown;
158 (page.snap_to_grid, page.editor.default_font, markdown) = options().clone();158 (page.snap_to_grid, page.editor.default_font, markdown) = options().clone();
159 page.editor.markdown =159 page.editor.markdown = markdown.then(|| {
160 markdown.then(|| notebook::sidecar::themes::built_in().swap_remove(0).sheet());160 notebook::sidecar::themes::built_in()
161 .swap_remove(0)
162 .sheet(None)
163 });
161 Ok(Self {164 Ok(Self {
162 page,165 page,
163 space,166 space,
...@@ -733,10 +736,7 @@ impl View {...@@ -733,10 +736,7 @@ impl View {
733 surface.configure(gpu.renderer.device(), &config);736 surface.configure(gpu.renderer.device(), &config);
734 let (mut page, read_only) = section.shared.page(space)?;737 let (mut page, read_only) = section.shared.page(space)?;
735 // The page wears its theme: style objects it gives otherwise are restyled first.738 // The page wears its theme: style objects it gives otherwise are restyled first.
736 let sheet = section739 let sheet = section.sheet(page.identity);
737 .theme(page.identity)
738 .map(|theme| theme.sheet())
739 .unwrap_or_default();
740 let restyle = onestore::op::restyle(&page, &sheet)?;740 let restyle = onestore::op::restyle(&page, &sheet)?;
741 if !read_only && !restyle.is_empty() {741 if !read_only && !restyle.is_empty() {
742 section.shared.apply(page_edit(space, restyle))?;742 section.shared.apply(page_edit(space, restyle))?;
...@@ -1118,7 +1118,7 @@ pub extern "C" fn sb_view_style(view: &mut View, place: u8) -> bool {...@@ -1118,7 +1118,7 @@ pub extern "C" fn sb_view_style(view: &mut View, place: u8) -> bool {
1118 let editor = &view.canvas.page.editor;1118 let editor = &view.canvas.page.editor;
1119 let style = editor.styles.get(*name).cloned().unwrap_or_else(|| {1119 let style = editor.styles.get(*name).cloned().unwrap_or_else(|| {
1120 let theme = built_in().swap_remove(0);1120 let theme = built_in().swap_remove(0);
1121 definition(name, &theme.styles[*name])1121 definition(name, &theme.styles[*name], None)
1122 });1122 });
1123 let result = view1123 let result = view
1124 .canvas1124 .canvas
crates/mobile/src/library.rs+20-4
...@@ -810,13 +810,29 @@ impl Section {...@@ -810,13 +810,29 @@ impl Section {
810 ])810 ])
811 }811 }
812812
813 /// The theme page `identity` of this section wears, if any scope names one.813 /// The paragraph styles page `identity` of this section wears in its theme, in this
814 pub(crate) fn theme(814 /// section's colour; none where no scope names a theme.
815 pub(crate) fn sheet(
815 &self,816 &self,
816 identity: Option<[u8; 16]>,817 identity: Option<[u8; 16]>,
817 ) -> Option<notebook::sidecar::themes::Theme> {818 ) -> std::collections::BTreeMap<String, onestore::page::Definition> {
818 let section = self.shared.section.identity().ok();819 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)
820 }836 }
821837
822 /// The section at catalog `path` of `library`, its edits naming `author`.838 /// The section at catalog `path` of `library`, its edits naming `author`.
crates/notebook/src/discover.rs+1-1
...@@ -69,7 +69,7 @@ impl Folder {...@@ -69,7 +69,7 @@ impl Folder {
69 }69 }
7070
71 /// The sections of this folder and every group under it, in catalog order.71 /// 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> {
73 self.folders().flat_map(|folder| &folder.sections)73 self.folders().flat_map(|folder| &folder.sections)
74 }74 }
75}75}
crates/notebook/src/sidecar/themes.rs+91-17
...@@ -38,8 +38,12 @@ pub struct ThemeStyle {...@@ -38,8 +38,12 @@ pub struct ThemeStyle {
38 pub size: f32,38 pub size: f32,
39 pub bold: bool,39 pub bold: bool,
40 pub italic: bool,40 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.
42 pub color: Option<String>,43 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,
43 /// Space above and below the paragraph, in points.47 /// Space above and below the paragraph, in points.
44 pub before: f32,48 pub before: f32,
45 pub after: f32,49 pub after: f32,
...@@ -224,16 +228,65 @@ fn is_built_in(id: &str) -> bool {...@@ -224,16 +228,65 @@ fn is_built_in(id: &str) -> bool {
224 built_in().iter().any(|theme| theme.id == id)228 built_in().iter().any(|theme| theme.id == id)
225}229}
226230
227/// Stored style `name`'s paragraph style as `style` gives it: every character flag set, as231/// The accent of a section coloured `section` (a COLORREF; none for OneNote's None), as a
228/// OneNote 2010 writes its own, headings followed by Normal.232/// COLORREF: its hue at a fixed saturation and lightness, grey staying grey. Pages store it,
229pub fn definition(name: &str, style: &ThemeStyle) -> Definition {233/// so it never changes: versions resolving it apart would restyle each other's pages.
230 let color = style234pub fn accent(section: Option<u32>) -> u32 {
231 .color235 // OneNote's blue for a section without a colour, as Snowbound's tabs show one.
232 .as_deref()236 let [red, green, blue, _] = section.unwrap_or(0x00E4_A88A).to_le_bytes();
233 .and_then(|hex| u32::from_str_radix(hex.strip_prefix('#')?, 16).ok())237 let [red, green, blue] = [red, green, blue].map(|byte| f32::from(byte) / 255.0);
234 .map_or(0xff00_0000, |rgb| {238 let (max, min) = (red.max(green).max(blue), red.min(green).min(blue));
235 (rgb >> 16) | (rgb & 0xff00) | ((rgb & 0xff) << 16)239 let range = max - min;
236 });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);
237 // Paragraph spacing is stored in half inches.290 // Paragraph spacing is stored in half inches.
238 let stored = |points: f32| points / 36.0 * 36.0;291 let stored = |points: f32| points / 36.0 * 36.0;
239 Definition {292 Definition {
...@@ -261,11 +314,12 @@ pub fn definition(name: &str, style: &ThemeStyle) -> Definition {...@@ -261,11 +314,12 @@ pub fn definition(name: &str, style: &ThemeStyle) -> Definition {
261}314}
262315
263impl Theme {316impl Theme {
264 /// The paragraph style definitions the theme gives, by stored name.317 /// The paragraph style definitions the theme gives in a section coloured `section`, by
265 pub fn sheet(&self) -> BTreeMap<String, Definition> {318 /// stored name.
319 pub fn sheet(&self, section: Option<u32>) -> BTreeMap<String, Definition> {
266 self.styles320 self.styles
267 .iter()321 .iter()
268 .map(|(name, style)| (name.clone(), definition(name, style)))322 .map(|(name, style)| (name.clone(), definition(name, style, section)))
269 .collect()323 .collect()
270 }324 }
271}325}
...@@ -284,6 +338,7 @@ fn style(...@@ -284,6 +338,7 @@ fn style(
284 bold,338 bold,
285 italic,339 italic,
286 color: color.map(Into::into),340 color: color.map(Into::into),
341 accent: false,
287 before,342 before,
288 after,343 after,
289 }344 }
...@@ -398,7 +453,7 @@ mod tests {...@@ -398,7 +453,7 @@ mod tests {
398 let document = onestore::document::Document::parse(&index).unwrap();453 let document = onestore::document::Document::parse(&index).unwrap();
399 let (space, _) = document.pages().unwrap()[0];454 let (space, _) = document.pages().unwrap()[0];
400 let page = onestore::page::Page::from_space(&document, space).unwrap();455 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);
402 for (name, definition) in &sheet {457 for (name, definition) in &sheet {
403 assert!(458 assert!(
404 page.definitions.values().any(|stored| stored == definition),459 page.definitions.values().any(|stored| stored == definition),
...@@ -432,7 +487,7 @@ mod tests {...@@ -432,7 +487,7 @@ mod tests {
432 .into_iter()487 .into_iter()
433 .find(|theme| theme.id == "manuscript")488 .find(|theme| theme.id == "manuscript")
434 .unwrap()489 .unwrap()
435 .sheet();490 .sheet(None);
436 let calibri: Vec<&str> = page491 let calibri: Vec<&str> = page
437 .definitions492 .definitions
438 .values()493 .values()
...@@ -470,10 +525,29 @@ mod tests {...@@ -470,10 +525,29 @@ mod tests {
470 assert!(onestore::op::restyle(&healed, &sheet).unwrap().is_empty());525 assert!(onestore::op::restyle(&healed, &sheet).unwrap().is_empty());
471 }526 }
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
473 #[test]547 #[test]
474 fn every_built_in_theme_names_every_gallery_style() {548 fn every_built_in_theme_names_every_gallery_style() {
475 for theme in built_in() {549 for theme in built_in() {
476 let sheet = theme.sheet();550 let sheet = theme.sheet(None);
477 for (name, _) in STYLES {551 for (name, _) in STYLES {
478 assert!(sheet.contains_key(name), "{} {name}", theme.id);552 assert!(sheet.contains_key(name), "{} {name}", theme.id);
479 }553 }
crates/snowbound/src/commands.rs+5-1
...@@ -136,7 +136,11 @@ pub fn offered(id: Id) -> bool {...@@ -136,7 +136,11 @@ pub fn offered(id: Id) -> bool {
136136
137/// What the editor's Markdown shortcuts take: OneNote 2010's gallery styles when `on`.137/// What the editor's Markdown shortcuts take: OneNote 2010's gallery styles when `on`.
138pub fn markdown(on: bool) -> Option<BTreeMap<String, onestore::page::Definition>> {138pub 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 })
140}144}
141145
142/// What a menu or toolbar offers: a command, or one of a list's entries.146/// 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 {...@@ -1026,6 +1026,18 @@ impl Library {
1026 .map(|section| section.file_id)1026 .map(|section| section.file_id)
1027 }1027 }
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
1029 /// The first readable section, searching groups after sections, as OneNote opens a1041 /// The first readable section, searching groups after sections, as OneNote opens a
1030 /// notebook.1042 /// notebook.
1031 pub fn first_section(&self) -> Option<String> {1043 pub fn first_section(&self) -> Option<String> {
crates/snowbound/src/main.rs+4-1
...@@ -2177,6 +2177,7 @@ impl State {...@@ -2177,6 +2177,7 @@ impl State {
2177 let session = self.session.as_ref();2177 let session = self.session.as_ref();
2178 let update = self.updates.status();2178 let update = self.updates.status();
2179 let sheet = self.gallery_sheet();2179 let sheet = self.gallery_sheet();
2180 let section = self.section_color();
2180 let drawing_pens = self.pens();2181 let drawing_pens = self.pens();
2181 let ui = &mut self.ui;2182 let ui = &mut self.ui;
2182 let text = theme.text;2183 let text = theme.text;
...@@ -2269,7 +2270,9 @@ impl State {...@@ -2269,7 +2270,9 @@ impl State {
2269 .find(|(name, _)| state.style.as_deref() == Some(*name))2270 .find(|(name, _)| state.style.as_deref() == Some(*name))
2270 .map_or("Styles".to_owned(), |(_, label)| format!("Styles: {label}"));2271 .map_or("Styles".to_owned(), |(_, label)| format!("Styles: {label}"));
2271 ui::popup::tooltip(ui, &shown, "", None);2272 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 {
2273 choice = Some(Choice::Command(id));2276 choice = Some(Choice::Command(id));
2274 }2277 }
2275 let combo = ui.id("font");2278 let combo = ui.id("font");
crates/snowbound/src/menus.rs+5-3
...@@ -709,13 +709,14 @@ impl State {...@@ -709,13 +709,14 @@ impl State {
709 (Target::Section { library, path }, Action::Theme) => {709 (Target::Section { library, path }, Action::Theme) => {
710 if let Some(identity) = library.section_identity(&path) {710 if let Some(identity) = library.section_identity(&path) {
711 let scope = notebook::sidecar::themes::Scope::section(identity);711 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);
713 }714 }
714 None715 None
715 }716 }
716 (Target::Notebook(library), Action::Theme) => {717 (Target::Notebook(library), Action::Theme) => {
717 let scope = notebook::sidecar::themes::Scope::Notebook;718 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);
719 None720 None
720 }721 }
721 (Target::Section { library, path }, Action::Color(color)) => Some(Command::Structure(722 (Target::Section { library, path }, Action::Color(color)) => Some(Command::Structure(
...@@ -843,7 +844,8 @@ impl State {...@@ -843,7 +844,8 @@ impl State {
843 let identity = session.section.page(space).ok()?.identity?;844 let identity = session.section.page(space).ok()?.identity?;
844 let library = Arc::clone(&session.library);845 let library = Arc::clone(&session.library);
845 let scope = notebook::sidecar::themes::Scope::page(identity);846 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);
847 None849 None
848 }850 }
849 Action::Versions(show) => Some(Command::History { page: space, show }),851 Action::Versions(show) => Some(Command::History { page: space, show }),
crates/snowbound/src/themes.rs+70-40
...@@ -44,6 +44,8 @@ pub struct Dialog {...@@ -44,6 +44,8 @@ pub struct Dialog {
44 selected: usize,44 selected: usize,
45 /// The style the controls edit, by its place in the gallery.45 /// The style the controls edit, by its place in the gallery.
46 style: usize,46 style: usize,
47 /// The colour of the section the theme is chosen for, which "Theme" colours take.
48 section: Option<u32>,
47}49}
4850
49fn id() -> Id {51fn id() -> Id {
...@@ -57,29 +59,24 @@ fn popup(name: &str) -> Id {...@@ -57,29 +59,24 @@ fn popup(name: &str) -> Id {
57/// Spacing the dialog offers above and below a style, in points.59/// Spacing the dialog offers above and below a style, in points.
58const SPACING: [f32; 9] = [0.0, 2.0, 3.0, 4.0, 6.0, 8.0, 10.0, 12.0, 18.0];60const 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.62/// A line of `text` in `style` as a page in a section coloured `section` shows it, scaled
61fn hex(colorref: u32) -> String {63/// down to fit a menu row up to `largest` pixels, its colour moved onto the popup's paper so
62 let [red, green, blue, _] = colorref.to_le_bytes();64/// it reads in either scheme.
63 format!("#{red:02X}{green:02X}{blue:02X}")65pub(crate) fn preview(
64}66 ui: &mut Ui,
6567 part: &str,
66fn colorref(hex: &str) -> Option<u32> {68 text: &str,
67 let rgb = u32::from_str_radix(hex.strip_prefix('#')?, 16).ok()?;69 style: &ThemeStyle,
68 Some((rgb >> 16) | (rgb & 0xff00) | ((rgb & 0xff) << 16))70 section: Option<u32>,
69}71 largest: f32,
7072) {
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) {
74 let theme = ui.theme.clone();73 let theme = ui.theme.clone();
75 let paper = canvas::gpu::Paper {74 let paper = canvas::gpu::Paper {
76 color: theme.popup,75 color: theme.popup,
77 ink: theme.text,76 ink: theme.text,
78 };77 };
79 let color = style78 let color = style
80 .color79 .colorref(section)
81 .as_deref()
82 .and_then(colorref)
83 .map_or(theme.text, |color| paper.tint(canvas::gpu::colorref(color)));80 .map_or(theme.text, |color| paper.tint(canvas::gpu::colorref(color)));
84 ui.leaf(81 ui.leaf(
85 part,82 part,
...@@ -113,6 +110,12 @@ impl State {...@@ -113,6 +110,12 @@ impl State {
113 Some((Arc::clone(library), target))110 Some((Arc::clone(library), target))
114 }111 }
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
116 /// The theme the open page wears, if any scope names one.119 /// The theme the open page wears, if any scope names one.
117 pub(crate) fn page_theme(&self) -> Option<Theme> {120 pub(crate) fn page_theme(&self) -> Option<Theme> {
118 let session = self.session.as_ref()?;121 let session = self.session.as_ref()?;
...@@ -134,7 +137,7 @@ impl State {...@@ -134,7 +137,7 @@ impl State {
134 /// Stored style `name` as the gallery applies it on the open page.137 /// Stored style `name` as the gallery applies it on the open page.
135 pub(crate) fn gallery_style(&self, name: &str) -> Definition {138 pub(crate) fn gallery_style(&self, name: &str) -> Definition {
136 let theme = self.gallery_sheet();139 let theme = self.gallery_sheet();
137 stored::definition(name, &theme.styles[name])140 stored::definition(name, &theme.styles[name], self.section_color())
138 }141 }
139142
140 /// Dresses the open page in its theme: new text and Enter take the theme's styles, and143 /// Dresses the open page in its theme: new text and Enter take the theme's styles, and
...@@ -142,7 +145,9 @@ impl State {...@@ -142,7 +145,9 @@ impl State {
142 /// keeps what it holds.145 /// keeps what it holds.
143 pub(crate) fn wear_theme(&mut self) -> Result<(), Box<dyn Error>> {146 pub(crate) fn wear_theme(&mut self) -> Result<(), Box<dyn Error>> {
144 let theme = self.page_theme();147 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();
146 self.view.editor.styles = sheet.clone();151 self.view.editor.styles = sheet.clone();
147 let Some(session) = &self.session else {152 let Some(session) = &self.session else {
148 return Ok(());153 return Ok(());
...@@ -164,15 +169,18 @@ impl State {...@@ -164,15 +169,18 @@ impl State {
164 let Some((library, target)) = self.theme_target(scope) else {169 let Some((library, target)) = self.theme_target(scope) else {
165 return;170 return;
166 };171 };
167 self.show_themes(scope, library, target);172 let section = self.section_color();
173 self.show_themes(scope, library, target, section);
168 }174 }
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`.
171 pub(crate) fn show_themes(178 pub(crate) fn show_themes(
172 &mut self,179 &mut self,
173 scope: Scope,180 scope: Scope,
174 library: Arc<Library>,181 library: Arc<Library>,
175 target: stored::Scope,182 target: stored::Scope,
183 section: Option<u32>,
176 ) {184 ) {
177 let themes = library.themes();185 let themes = library.themes();
178 let assigned = themes.assigned(&target).map(|theme| theme.id);186 let assigned = themes.assigned(&target).map(|theme| theme.id);
...@@ -189,6 +197,7 @@ impl State {...@@ -189,6 +197,7 @@ impl State {
189 deleted: Vec::new(),197 deleted: Vec::new(),
190 selected,198 selected,
191 style: 0,199 style: 0,
200 section,
192 });201 });
193 self.ui.open_popup(id());202 self.ui.open_popup(id());
194 }203 }
...@@ -274,7 +283,14 @@ impl State {...@@ -274,7 +283,14 @@ impl State {
274 ..Spec::default()283 ..Spec::default()
275 },284 },
276 );285 );
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 );
278 if let Some(node) = ui.access(item) {294 if let Some(node) = ui.access(item) {
279 node.set_label(listed.name.as_str());295 node.set_label(listed.name.as_str());
280 node.set_selected(shown);296 node.set_selected(shown);
...@@ -449,24 +465,28 @@ impl State {...@@ -449,24 +465,28 @@ impl State {
449 });465 });
450 field(ui, "Color:", &mut |ui| {466 field(ui, "Color:", &mut |ui| {
451 let button = ui.id("color");467 let button = ui.id("color");
452 let shown = style.color.clone().unwrap_or_else(|| "Automatic".into());468 let shown = match (&style.color, style.accent) {
453 ui::shell::combo(ui, "color", "Color", &shown, 120.0, popup("colors"), true);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);
454 let swatches: Vec<_> = crate::FONT_COLORS474 let swatches: Vec<_> = crate::FONT_COLORS
455 .iter()475 .iter()
456 .map(|&(color, name)| (canvas::gpu::colorref(color), name))476 .map(|&(color, name)| (canvas::gpu::colorref(color), name))
457 .collect();477 .collect();
458 let anchor = Anchor::Below(ui.rect(button).unwrap_or_default());478 let anchor = Anchor::Below(ui.rect(button).unwrap_or_default());
459 if let Some(chosen) =479 let buttons = ["Automatic", "Theme"];
460 ui::popup::colors(ui, popup("colors"), anchor, "Automatic", &swatches, 10)480 if let Some(picked) =
481 ui::popup::color_grid(ui, popup("colors"), anchor, &buttons, &swatches, 10)
461 {482 {
462 let color = chosen.and_then(|chosen| {483 change = match picked {
463 crate::FONT_COLORS484 Ok(chosen) => (swatches.iter())
464 .iter()485 .position(|(swatch, _)| *swatch == chosen)
465 .zip(&swatches)486 .map(|at| Change::Color(Some(stored::color_hex(crate::FONT_COLORS[at].0)))),
466 .find(|(_, (swatch, _))| *swatch == chosen)487 Err(0) => Some(Change::Color(None)),
467 .map(|((color, _), _)| hex(*color))488 Err(_) => Some(Change::Accent),
468 });489 };
469 change = Some(Change::Color(color));
470 }490 }
471 });491 });
472 for (part, label, value) in [492 for (part, label, value) in [
...@@ -527,7 +547,7 @@ impl State {...@@ -527,7 +547,7 @@ impl State {
527 ..Spec::default()547 ..Spec::default()
528 },548 },
529 );549 );
530 preview(ui, "text", label, style, 26.0);550 preview(ui, "text", label, style, dialog.section, 26.0);
531 ui.close();551 ui.close();
532 if ui.signal(line).clicked {552 if ui.signal(line).clicked {
533 dialog.style = at;553 dialog.style = at;
...@@ -632,6 +652,8 @@ enum Change {...@@ -632,6 +652,8 @@ enum Change {
632 Bold,652 Bold,
633 Italic,653 Italic,
634 Color(Option<String>),654 Color(Option<String>),
655 /// The section's accent, the "Theme" colour.
656 Accent,
635 /// Space above (true) or below, in points.657 /// Space above (true) or below, in points.
636 Spacing(bool, f32),658 Spacing(bool, f32),
637}659}
...@@ -643,7 +665,14 @@ impl Change {...@@ -643,7 +665,14 @@ impl Change {
643 Self::Size(size) => style.size = size,665 Self::Size(size) => style.size = size,
644 Self::Bold => style.bold = !style.bold,666 Self::Bold => style.bold = !style.bold,
645 Self::Italic => style.italic = !style.italic,667 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 }
647 Self::Spacing(true, points) => style.before = points,676 Self::Spacing(true, points) => style.before = points,
648 Self::Spacing(false, points) => style.after = points,677 Self::Spacing(false, points) => style.after = points,
649 }678 }
...@@ -656,12 +685,13 @@ fn fresh_id(now: u64, count: usize) -> String {...@@ -656,12 +685,13 @@ fn fresh_id(now: u64, count: usize) -> String {
656}685}
657686
658/// The Styles gallery under the toolbar's Styles button: the eleven styles drawn in687/// 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.
660pub(crate) fn gallery(689pub(crate) fn gallery(
661 ui: &mut Ui,690 ui: &mut Ui,
662 menu: Id,691 menu: Id,
663 anchor: Anchor,692 anchor: Anchor,
664 sheet: &Theme,693 sheet: &Theme,
694 section: Option<u32>,
665 current: Option<&str>,695 current: Option<&str>,
666) -> Option<crate::commands::Id> {696) -> Option<crate::commands::Id> {
667 use crate::commands::Id as Cmd;697 use crate::commands::Id as Cmd;
...@@ -689,7 +719,7 @@ pub(crate) fn gallery(...@@ -689,7 +719,7 @@ pub(crate) fn gallery(
689 .get(index)719 .get(index)
690 {720 {
691 Some((name, label)) => {721 Some((name, label)) => {
692 preview(ui, "preview", label, &sheet.styles[*name], 24.0);722 preview(ui, "preview", label, &sheet.styles[*name], section, 24.0);
693 if let Some(node) = ui.access(ui.id("preview")) {723 if let Some(node) = ui.access(ui.id("preview")) {
694 node.set_label(*label);724 node.set_label(*label);
695 }725 }
...@@ -785,7 +815,7 @@ mod tests {...@@ -785,7 +815,7 @@ mod tests {
785 let source = onestore::create_section(&file, "", "Author").unwrap();815 let source = onestore::create_section(&file, "", "Author").unwrap();
786 let (space, before) = page(&source);816 let (space, before) = page(&source);
787 let mut editor = CanvasEditor::from_page(before, &mut engine).unwrap();817 let mut editor = CanvasEditor::from_page(before, &mut engine).unwrap();
788 let sheet = theme.sheet();818 let sheet = theme.sheet(None);
789 editor.styles = sheet.clone();819 editor.styles = sheet.clone();
790 let body = editor820 let body = editor
791 .outlines()821 .outlines()
...@@ -864,7 +894,7 @@ mod tests {...@@ -864,7 +894,7 @@ mod tests {
864 }894 }
865 assert!(op::restyle(&stored, &sheet).unwrap().is_empty());895 assert!(op::restyle(&stored, &sheet).unwrap().is_empty());
866 // Another theme restyles the page whole, keeping the names.896 // 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);
868 let mut copy = onestore::Section::open(&arena, written.clone()).unwrap();898 let mut copy = onestore::Section::open(&arena, written.clone()).unwrap();
869 copy.apply(899 copy.apply(
870 "Author",900 "Author",
crates/ui/src/popup.rs+42-20
...@@ -752,26 +752,46 @@ pub fn colors(...@@ -752,26 +752,46 @@ pub fn colors(
752 swatches: &[([f32; 4], &str)],752 swatches: &[([f32; 4], &str)],
753 columns: usize,753 columns: usize,
754) -> Option<Option<[f32; 4]>> {754) -> 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>> {
755 if !ui.popup_open(id) {768 if !ui.popup_open(id) {
756 return None;769 return None;
757 }770 }
758 // The button is cell 0 and the swatches follow it.771 // The buttons are the first cells and the swatches follow them.
759 let cell = |index: usize| id.child(("cell", index));772 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;
761 let (highlight, chosen) = pick_cell(ui, id, count, cell, |key, highlight| {775 let (highlight, chosen) = pick_cell(ui, id, count, cell, |key, highlight| {
762 match (key, highlight) {776 match (key, highlight) {
763 (_, None) => 0,777 (_, None) => 0,
764 (NamedKey::ArrowLeft, Some(at)) => at.saturating_sub(1),778 (NamedKey::ArrowLeft, Some(at)) => at.saturating_sub(1),
765 (NamedKey::ArrowRight, Some(at)) => (at + 1).min(count - 1),779 (NamedKey::ArrowRight, Some(at)) => (at + 1).min(count - 1),
766 (NamedKey::ArrowUp, Some(at)) => at.saturating_sub(columns),780 (NamedKey::ArrowUp, Some(at)) if at < first + columns => {
767 (_, Some(0)) => 1,781 at.min(first).saturating_sub(1)
782 }
783 (NamedKey::ArrowUp, Some(at)) => at - columns,
784 (_, Some(at)) if at < first => at + 1,
768 (_, Some(at)) if at + columns < count => at + columns,785 (_, Some(at)) if at + columns < count => at + columns,
769 (_, Some(at)) => at,786 (_, Some(at)) => at,
770 }787 }
771 });788 });
772 if let Some(index) = chosen {789 if let Some(index) = chosen {
773 ui.close_popup(id);790 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 });
775 }795 }
776796
777 let theme = ui.theme.clone();797 let theme = ui.theme.clone();
...@@ -783,20 +803,22 @@ pub fn colors(...@@ -783,20 +803,22 @@ pub fn colors(
783 anchor,803 anchor,
784 columns as f32 * CELL + 2.0 * ui.theme.menu().pad,804 columns as f32 * CELL + 2.0 * ui.theme.menu().pad,
785 );805 );
786 ui.open_as(806 for (index, button) in buttons.iter().enumerate() {
787 cell(0),807 ui.open_as(
788 Spec {808 cell(index),
789 flags: Flags::CLICKABLE,809 Spec {
790 size: [fill(), px(ROW)],810 flags: Flags::CLICKABLE,
791 text: Some(none),811 size: [fill(), px(ROW)],
792 fill: lit(0),812 text: Some(button),
793 radius: 4.0,813 fill: lit(index),
794 pad: [8.0, 0.0],814 radius: 4.0,
795 role: Some(Role::MenuItem),815 pad: [8.0, 0.0],
796 ..Spec::default()816 role: Some(Role::MenuItem),
797 },817 ..Spec::default()
798 );818 },
799 ui.close();819 );
820 ui.close();
821 }
800 for (row, colors) in swatches.chunks(columns).enumerate() {822 for (row, colors) in swatches.chunks(columns).enumerate() {
801 ui.open(823 ui.open(
802 ("row", row),824 ("row", row),
...@@ -806,7 +828,7 @@ pub fn colors(...@@ -806,7 +828,7 @@ pub fn colors(
806 },828 },
807 );829 );
808 for (column, (color, name)) in colors.iter().enumerate() {830 for (column, (color, name)) in colors.iter().enumerate() {
809 let index = 1 + row * columns + column;831 let index = first + row * columns + column;
810 ui.open_as(832 ui.open_as(
811 cell(index),833 cell(index),
812 Spec {834 Spec {