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,