From 384e5ef14c8d95bb74b186fe57af0cc578fa9276 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 2 Oct 2026 14:10:05 -0700 Subject: [PATCH] feat: a notebook's colour tints its book in the sidebar, on the notebook button and in iOS's list OneNote 2010's navigation bar draws each notebook's icon in the colour Notebook Properties picks. The notebook glyph's cover now paints in its tint, shaded over it, and every place that draws a particular notebook passes its colour: the sidebar's rows, the notebook button (the notebook shown), the palette, Move's destinations and the sync popup. A notebook without a colour, and Open Existing, keep the glyph's orange. A notebook in iCloud Drive shows its tinted book with a small cloud at the corner instead of the plain cloud. ui's Spec gains `tint`, an icon colour apart from the label's. Notebook Properties' colour menu shows a swatch of each colour. iOS reads the colour with sb_library_color and tints the list's book. Assisted-by: claude-opus-5.5 --- apps/ios/Snowbound/Lists.swift | 3 +- apps/ios/Snowbound/Notebooks.swift | 8 +- crates/mobile/include/snowbound.h | 1 + crates/mobile/src/library.rs | 14 ++++ crates/mobile/src/tests.rs | 3 +- .../snowbound/assets/icons/icloud-badge.svg | 7 ++ crates/snowbound/assets/icons/notebook.svg | 5 +- crates/snowbound/assets/icons/swatch.svg | 4 + crates/snowbound/src/art.rs | 6 ++ crates/snowbound/src/commands.rs | 2 +- crates/snowbound/src/main.rs | 12 ++- crates/snowbound/src/menus.rs | 5 +- crates/snowbound/src/palette.rs | 2 + crates/snowbound/src/properties.rs | 4 +- crates/snowbound/src/sidebar.rs | 75 +++++++++++++------ crates/snowbound/src/sync.rs | 5 ++ crates/ui/src/lib.rs | 7 +- 17 files changed, 127 insertions(+), 36 deletions(-) create mode 100644 crates/snowbound/assets/icons/icloud-badge.svg create mode 100644 crates/snowbound/assets/icons/swatch.svg diff --git a/apps/ios/Snowbound/Lists.swift b/apps/ios/Snowbound/Lists.swift index 725d16780fff5ca43cefc58209e6722178606fea..accb0a6f23cfb69dc31a18987428ca18b43ca67b 100644 --- a/apps/ios/Snowbound/Lists.swift +++ b/apps/ios/Snowbound/Lists.swift @@ -232,7 +232,8 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke case .notebook(let id): guard let notebook = notebook(id) else { break } content.text = notebook.name - content.image = UIImage(systemName: "book.closed") + content.image = UIImage(systemName: "book.closed.fill") + content.imageProperties.tintColor = notebook.color if case .server(let server) = notebook.source { content.secondaryText = server.host } let more = UIButton(type: .system) more.setImage(UIImage(systemName: "ellipsis.circle"), for: .normal) diff --git a/apps/ios/Snowbound/Notebooks.swift b/apps/ios/Snowbound/Notebooks.swift index bf45c3e19f0c55ed00057bc7efc5304fd50630bc..3db56082965da243ae83a71207667f6b4a47749c 100644 --- a/apps/ios/Snowbound/Notebooks.swift +++ b/apps/ios/Snowbound/Notebooks.swift @@ -175,6 +175,8 @@ final class Notebook { private(set) var name: String private(set) var handle: OpaquePointer? private(set) var tabs: [Tab] = [] + /// The notebook's colour, which tints its book in the list; none where it has none. + private(set) var color: UIColor? /// Why the notebook could not be opened, while it cannot. private(set) var problem: String? /// The Files location being read, held open while the notebook is. @@ -288,8 +290,12 @@ final class Notebook { return done() } let pointer = Int(bitPattern: handle) - background({ decode([Tab].self, sb_library_sections(OpaquePointer(bitPattern: pointer))) }) { [self] tabs in + background({ () -> ([Tab]?, Int32) in + let library = OpaquePointer(bitPattern: pointer) + return (decode([Tab].self, sb_library_sections(library)), sb_library_color(library)) + }) { [self] (tabs, color) in if let tabs { self.tabs = tabs } + self.color = color < 0 ? nil : UIColor(rgb: [16, 8, 0].map { UInt8(truncatingIfNeeded: color >> $0) }) download() done() } diff --git a/crates/mobile/include/snowbound.h b/crates/mobile/include/snowbound.h index d008f053c78056e82bdd5bb9dfdefb807d138975..15acffebb715f69856fcf8625111206ad97a4aac 100644 --- a/crates/mobile/include/snowbound.h +++ b/crates/mobile/include/snowbound.h @@ -29,6 +29,7 @@ bool sb_notebook_create(const char *path, const char *cache, const char *author, char **error); bool sb_notebook_moved(const char *cache, const char *from, const char *to, char **error); char *sb_library_sections(const Library *library); +int32_t sb_library_color(const Library *library); char *sb_library_new_section(const Library *library, const char *folder, const char *name, const char *author, const char *date, const char *time); char *sb_library_search(const Library *library, const Section *open, const char *path, const char *query); diff --git a/crates/mobile/src/library.rs b/crates/mobile/src/library.rs index f28813276c4ee3aa780407c9ad927026c328702b..7033efe767343116959aa443c7bc8be79afcfeed 100644 --- a/crates/mobile/src/library.rs +++ b/crates/mobile/src/library.rs @@ -541,6 +541,11 @@ impl Library { Ok(unlocked) } + /// The notebook's colour, COLORREF, as its table of contents held it when last read. + fn color(&self) -> Option { + self.notebook().as_ref()?.catalog().toc.as_ref()?.color + } + /// The notebook's sections, read again. pub(crate) fn tabs(&self) -> Result> { if let Place::File(file) = &self.place { @@ -1405,6 +1410,15 @@ pub extern "C" fn sb_library_sections(library: &Library) -> *mut c_char { json(library.tabs()) } +/// The notebook's colour as `0xRRGGBB`, as its sections were last read; -1 where it has none. +#[unsafe(no_mangle)] +pub extern "C" fn sb_library_color(library: &Library) -> i32 { + library.color().map_or(-1, |color| { + let [red, green, blue] = srgb(color); + i32::from_be_bytes([0, red, green, blue]) + }) +} + /// Creates a section named `name` in the group at catalog path `folder` ("" for the /// notebook's top) holding one page titled with `date` and `time`, as OneNote creates one; /// returns its catalog path, or null. diff --git a/crates/mobile/src/tests.rs b/crates/mobile/src/tests.rs index e817e21cc56bde88e0510f0657d731e549f7e7b3..d6131c87f34d66a19073eb979bdeb7ebaa890591 100644 --- a/crates/mobile/src/tests.rs +++ b/crates/mobile/src/tests.rs @@ -105,7 +105,7 @@ fn a_notebook_folder_lists_sections_in_order_with_groups_and_colours() { } #[test] -fn a_new_notebook_opens_with_one_section_and_refuses_a_taken_name() { +fn a_new_notebook_opens_with_one_section_in_its_colour_and_refuses_a_taken_name() { let directory = tempfile::tempdir().unwrap(); let root = directory.path().join("Mine"); let cache = directory.path().join("cache"); @@ -128,6 +128,7 @@ fn a_new_notebook_opens_with_one_section_and_refuses_a_taken_name() { let tabs = library.tabs().unwrap(); assert_eq!(tabs.len(), 1); assert!(tabs[0].readable); + assert_eq!(library::sb_library_color(&library), 0x91baae); assert!(!create(&mut error)); assert!(!error.is_null()); unsafe { sb_string_free(error) }; diff --git a/crates/snowbound/assets/icons/icloud-badge.svg b/crates/snowbound/assets/icons/icloud-badge.svg new file mode 100644 index 0000000000000000000000000000000000000000..887192d52bd057eaed8681c77239f14428a7befe --- /dev/null +++ b/crates/snowbound/assets/icons/icloud-badge.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/snowbound/assets/icons/notebook.svg b/crates/snowbound/assets/icons/notebook.svg index 08dd0bce284fc498e3128c043e2b98e3cb13601f..9ba6a7f690207e1e0bd1bb5295c6d652241512ce 100644 --- a/crates/snowbound/assets/icons/notebook.svg +++ b/crates/snowbound/assets/icons/notebook.svg @@ -3,7 +3,7 @@ - + @@ -13,7 +13,8 @@ - + + diff --git a/crates/snowbound/assets/icons/swatch.svg b/crates/snowbound/assets/icons/swatch.svg new file mode 100644 index 0000000000000000000000000000000000000000..ad34276bd7033adc3b9623beb2183752a10b6fbc --- /dev/null +++ b/crates/snowbound/assets/icons/swatch.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/snowbound/src/art.rs b/crates/snowbound/src/art.rs index 88d3ad5095f03532c027cec6f37bbcd6ef701166..c13dd6adc92fa9d322651a05ac4cfaa973ae36de 100644 --- a/crates/snowbound/src/art.rs +++ b/crates/snowbound/src/art.rs @@ -28,6 +28,11 @@ pub const INFO: &[&str] = art!("icons/info"); pub const ITALIC: &[&str] = art!("icons/italic"); pub const LINK: &[&str] = art!("icons/link"); pub const NOTEBOOK: &[&str] = art!("icons/notebook"); +/// A notebook iCloud Drive keeps: its glyph with a cloud at the corner. +pub const NOTEBOOK_ICLOUD: &[&str] = &[ + NOTEBOOK[0], + include_str!("../assets/icons/icloud-badge.svg"), +]; pub const NUMBERING: &[&str] = art!("icons/numbering"); pub const OPTIONS: &[&str] = art!("icons/options"); pub const OUTDENT: &[&str] = art!("icons/outdent"); @@ -67,6 +72,7 @@ pub const RECORD_AUDIO: &[&str] = art!("icons/record-audio"); pub const RECORD_VIDEO: &[&str] = art!("icons/record-video"); pub const SCREEN_CLIPPING: &[&str] = art!("icons/screen-clipping"); pub const SPELLING: &[&str] = art!("icons/spelling"); +pub const SWATCH: &[&str] = art!("icons/swatch"); pub const SYMBOL: &[&str] = art!("icons/symbol"); pub const SYNC_BUSY: &[&str] = art!("icons/sync-busy"); pub const SYNC_DONE: &[&str] = art!("icons/sync-done"); diff --git a/crates/snowbound/src/commands.rs b/crates/snowbound/src/commands.rs index 48281d2feadc27b0ec405d859bf1fcbcc4f93ed8..5ed94f56d7fed3749c413c7bb93deba8d0656f10 100644 --- a/crates/snowbound/src/commands.rs +++ b/crates/snowbound/src/commands.rs @@ -1373,7 +1373,7 @@ impl State { } /// The notebook the open section, or the notebook showing none, belongs to. - fn notebook(&self) -> Option<&Arc> { + pub(crate) fn notebook(&self) -> Option<&Arc> { self.session .as_ref() .map(|session| &session.library) diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 487abcc2ef21fdd7af1f116534d00a73247be39d..b15b401fe89c227f5b91c551af59edcaa5ce1fee 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -1724,7 +1724,7 @@ impl State { if self.navigation_bar_right { self.sidebar(&theme, sidebar); } - self.sidebar_button(&theme, height); + self.sidebar_button(height); self.ui.close(); self.context_menu(); return Ok((section, open_tab, None)); @@ -1737,7 +1737,7 @@ impl State { if self.navigation_bar_right { self.sidebar(&theme, sidebar); } - self.sidebar_button(&theme, height); + self.sidebar_button(height); self.ui.close(); self.context_menu(); return Ok((section, open_tab, None)); @@ -1823,7 +1823,7 @@ impl State { self.sidebar(&theme, sidebar); } self.task_pane(&theme); - self.sidebar_button(&theme, height); + self.sidebar_button(height); self.ui.close(); self.context_menu(); // No tab stands on the frame in Full Page View. @@ -4467,6 +4467,12 @@ fn section_color(color: Option) -> [f32; 4] { color.map_or(draw::srgb(0x8a, 0xa8, 0xe4), canvas::gpu::colorref) } +/// The colour a notebook's glyph takes from its COLORREF, as OneNote's navigation bar tints +/// it; the glyph's own orange without one. +fn notebook_color(color: Option) -> [f32; 4] { + color.map_or(draw::srgb(0xf3, 0x9c, 0x28), canvas::gpu::colorref) +} + /// OneNote's information bar for conflicting changes, clicked anywhere: above a page with /// conflict pages it shows or hides them; above a conflict page it opens OneNote's menu, /// whose previous and next changes `steps` enables and whose Copy Page To lists `sections`. diff --git a/crates/snowbound/src/menus.rs b/crates/snowbound/src/menus.rs index 0de513afef25bbf19e2e5177371e8145521e4c1d..1995ca308721b1a2a603c40f478abeed060e7b90 100644 --- a/crates/snowbound/src/menus.rs +++ b/crates/snowbound/src/menus.rs @@ -575,7 +575,10 @@ impl State { } else { crate::art::SECTION_GROUP }, - tint: None, + tint: place + .path + .is_empty() + .then(|| crate::notebook_color(library.color())), }) .collect() } diff --git a/crates/snowbound/src/palette.rs b/crates/snowbound/src/palette.rs index 0e4d1e5ef1232261f3a8cf8638cfcfae30c60217..528fa9e4123ef455fe573efdda64192049414f3e 100644 --- a/crates/snowbound/src/palette.rs +++ b/crates/snowbound/src/palette.rs @@ -97,6 +97,7 @@ impl State { .map(|library| Row { text: library.name.clone(), icon: Some(art::NOTEBOOK), + tint: Some(crate::notebook_color(library.color())), target: Some(Target::Notebook(Arc::clone(library))), ..Row::default() }), @@ -287,6 +288,7 @@ impl State { .to_owned(), after: "Closed".to_owned(), icon: Some(art::NOTEBOOK), + tint: Some(crate::notebook_color(None)), target: Some(target), ..Row::default() }); diff --git a/crates/snowbound/src/properties.rs b/crates/snowbound/src/properties.rs index 2ad450211c1a1fa49d0801f0f04f46d00108a3ce..c6fb26081a46655fdaace7b0023a9604dd07bad2 100644 --- a/crates/snowbound/src/properties.rs +++ b/crates/snowbound/src/properties.rs @@ -139,8 +139,8 @@ impl State { .iter() .map(|&(color, text)| Item { text, - icon: Some(art::SECTION), - tint: Some(theme.section(crate::section_color(Some(color))).accent), + icon: Some(art::SWATCH), + tint: Some(crate::notebook_color(Some(color))), current: Some(color) == shown, ..Item::default() }) diff --git a/crates/snowbound/src/sidebar.rs b/crates/snowbound/src/sidebar.rs index 4c2d22fd082e84e321d72221463f299141a786ea..6157f96272572afcbfdd8d0324dfc2d0dbe398cd 100644 --- a/crates/snowbound/src/sidebar.rs +++ b/crates/snowbound/src/sidebar.rs @@ -208,11 +208,14 @@ fn sidebar( ui.id(("notebook", index)), Row { label: &library.name, - icon: Leading::Icon(if library.in_icloud() { - art::ICLOUD - } else { - art::NOTEBOOK - }), + icon: Leading::Notebook( + if library.in_icloud() { + art::NOTEBOOK_ICLOUD + } else { + art::NOTEBOOK + }, + library.color(), + ), depth: 0, dim: library.notebook.is_err(), fold: Some(unfolded), @@ -250,9 +253,24 @@ fn sidebar( }, ); for (part, icon, label, chosen) in [ - ("new", art::PLUS, "New Notebook", Action::NewNotebook), - ("open", art::NOTEBOOK, "Open Existing", Action::OpenNotebook), - ("options", art::OPTIONS, "Options", Action::Options), + ( + "new", + Leading::Icon(art::PLUS), + "New Notebook", + Action::NewNotebook, + ), + ( + "open", + Leading::Notebook(art::NOTEBOOK, None), + "Open Existing", + Action::OpenNotebook, + ), + ( + "options", + Leading::Icon(art::OPTIONS), + "Options", + Action::Options, + ), ] { let (row, _) = tree_row( ui, @@ -260,7 +278,7 @@ fn sidebar( ui.id(part), Row { label, - icon: Leading::Icon(icon), + icon, depth: 0, dim: false, fold: None, @@ -584,10 +602,23 @@ fn entry(ui: &Ui, tree: &mut Tree, notebook: usize, path: &str, id: Id, group: b #[derive(Clone, Copy)] enum Leading { Icon(&'static [&'static str]), + /// A notebook's glyph, drawn in its colour (COLORREF). + Notebook(&'static [&'static str], Option), /// A section's tab, drawn in its colour. Section([f32; 4]), } +impl Leading { + /// The art and the colour its `currentColor` paints in `theme`. + fn art(self, theme: &Theme) -> (&'static [&'static str], [f32; 4]) { + match self { + Self::Icon(icon) => (icon, theme.text), + Self::Notebook(icon, color) => (icon, crate::notebook_color(color)), + Self::Section(section) => (art::SECTION, theme.section(section).accent), + } + } +} + struct Row<'a> { label: &'a str, icon: Leading, @@ -633,10 +664,7 @@ fn tree_row(ui: &mut Ui, tree: &mut Tree, id: Id, row: Row) -> (Signal, bool) { } } let color = if row.dim { theme.text_dim } else { theme.text }; - let (icon, [red, green, blue, _]) = match row.icon { - Leading::Icon(icon) => (icon, theme.text), - Leading::Section(section) => (art::SECTION, theme.section(section).accent), - }; + let (icon, [red, green, blue, _]) = row.icon.art(theme); // Coloured art keeps its colours, so a dim row fades its icon. let alpha = if row.dim { 0.5 } else { 1.0 }; ui.leaf( @@ -814,7 +842,7 @@ impl crate::State { /// The notebook button, floating at the body's corner over the section tabs' row, `height` /// tall as it eases, or over the sidebar's header while that is open. - pub(crate) fn sidebar_button(&mut self, theme: &Theme, height: f32) { + pub(crate) fn sidebar_button(&mut self, height: f32) { if self.temporary { return; } @@ -839,7 +867,8 @@ impl crate::State { ..Spec::default() }, ); - if ui::shell::tool_button(&mut self.ui, "button", art::NOTEBOOK, theme.text, None).clicked { + let tint = crate::notebook_color(self.notebook().and_then(|library| library.color())); + if ui::shell::tool_button(&mut self.ui, "button", art::NOTEBOOK, tint, None).clicked { self.sidebar = !self.sidebar; self.save_settings(); } @@ -1155,7 +1184,7 @@ impl crate::State { #[cfg(target_os = "linux")] let install = crate::desktop::installable().then_some(( "install", - art::PLUS, + Leading::Icon(art::PLUS), "Install Snowbound", crate::Command::Install, )); @@ -1169,19 +1198,19 @@ impl crate::State { [ ( "new", - art::PLUS, + Leading::Icon(art::PLUS), "New Notebook", crate::Command::NewNotebook, ), ( "open", - art::NOTEBOOK, + Leading::Notebook(art::NOTEBOOK, None), "Open Existing", crate::Command::OpenNotebook, ), ( "server", - art::SERVER, + Leading::Icon(art::SERVER), "Open Notebook from Server…", crate::Command::OpenFromServer(None), ), @@ -1189,7 +1218,7 @@ impl crate::State { .into_iter() .chain(crate::guide::OFFERED.then_some(( "guide", - art::PAGE, + Leading::Icon(art::PAGE), "Open the Snowbound Guide", crate::Command::OpenGuide, ))) @@ -1214,7 +1243,7 @@ impl crate::State { } else { ( "No sections in this notebook", - vec![("new", art::PLUS, "New Section", new)], + vec![("new", Leading::Icon(art::PLUS), "New Section", new)], ) }; self.notice(id, title, &[], buttons); @@ -1227,7 +1256,7 @@ impl crate::State { id: ui::Id, title: &str, servers: &[String], - buttons: Vec<(&str, &'static [&'static str], &str, crate::Command)>, + buttons: Vec<(&str, Leading, &str, crate::Command)>, ) { const BUTTON: [f32; 2] = [240.0, 32.0]; let theme = self.page_area_theme(); @@ -1266,12 +1295,14 @@ impl crate::State { ); let mut chosen = None; for (part, icon, label, command) in buttons { + let (icon, tint) = icon.art(&theme); let button = self.ui.leaf( part, Spec { flags: Flags::CLICKABLE, size: [fill(), px(BUTTON[1])], icon: Some(icon), + tint: Some(tint), text: Some(label), fill: Some(theme.chip), hover_fill: Some(theme.hover()), diff --git a/crates/snowbound/src/sync.rs b/crates/snowbound/src/sync.rs index 1056de58714a2f08de9a1f9f3cf5ed053d500b40..5ef992d1a02bf6bb73aa37a4f4e64dd5b7bb6f27 100644 --- a/crates/snowbound/src/sync.rs +++ b/crates/snowbound/src/sync.rs @@ -249,6 +249,8 @@ pub(crate) fn control(ui: &mut Ui, session: &Session, update: &update::Status, t /// What the popup shows of the notebook. struct Facts<'a> { name: &'a str, + /// The notebook's colour, COLORREF. + color: Option, /// The notebook's folder or address in full. location: &'a str, place: Vec, @@ -679,6 +681,7 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { size: [fill(), px(line)], text: Some(facts.name), icon: Some(art::NOTEBOOK), + tint: Some(crate::notebook_color(facts.color)), bold: true, overflow: Overflow::Ellipsis, ..Spec::default() @@ -1026,6 +1029,7 @@ impl State { }; let facts = Facts { name: &library.name, + color: library.color(), location: &library.location, place: library.place(), notice: library.notice.as_deref(), @@ -1132,6 +1136,7 @@ mod tests { fn facts(sections: Vec<(String, SyncStatus)>) -> Facts<'static> { Facts { name: "OneNote", + color: None, location: "/Volumes/clover/Documents/OneNote", place: ["zenith", "clover", "Documents"] .map(str::to_owned) diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 65a2af6580e46e104ad81c01ae70d6f0d10abbc8..b363eb19e347b84623cd61aa4a910501e844473c 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -368,8 +368,9 @@ pub struct Spec<'a> { pub hover_border: Option<[f32; 4]>, pub radius: f32, pub shape: Shape, - /// 16 px artwork before the label, tinted with the label's colour. + /// 16 px artwork before the label, tinted with `tint` or else the label's colour. pub icon: Option<&'static [&'static str]>, + pub tint: Option<[f32; 4]>, /// A 16 px picture in the icon's place beside a label; without one, the picture fills /// the box inside its padding. pub image: Option<&'a RasterImage>, @@ -522,6 +523,7 @@ struct Built { radius: f32, shape: Shape, icon: Option<&'static [&'static str]>, + tint: [f32; 4], image: Option, pad: [f32; 2], gap: f32, @@ -1705,7 +1707,7 @@ impl Ui { self.display.push(Display::Icon { sources, origin: [x, top], - tint: node.color, + tint: node.tint, }); x += ICON + ICON_GAP; } else if let Some(image) = &node.image { @@ -2030,6 +2032,7 @@ impl Built { radius: spec.radius, shape: spec.shape, icon: spec.icon, + tint: spec.tint.or(spec.color).unwrap_or(text), image: spec.image.cloned(), pad: spec.pad, gap: spec.gap, -- 2.54.0