From f3b369e572b4a689a2e3c0337a0ea4be5da31fd6 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 2 Oct 2026 15:55:34 -0700 Subject: [PATCH] feat: a notebook's colour recolours its binder art on the desktop and in iOS's list The binder is three layers, its back and tabs, the cover currentColor paints and what lies on the cover, which iOS's asset catalog links to and composes with the cover as a template. The desktop shades the colour as it shades a section's, for each appearance. Assisted-by: claude-opus-5.5 --- .../Notebook.imageset/Contents.json | 16 ++++++++++++ .../Notebook.imageset/notebook.svg | 1 + .../NotebookBack.imageset/Contents.json | 16 ++++++++++++ .../NotebookBack.imageset/notebook-back.svg | 1 + .../NotebookCover.imageset/Contents.json | 16 ++++++++++++ .../NotebookCover.imageset/notebook-cover.svg | 1 + apps/ios/Snowbound/Lists.swift | 3 +-- apps/ios/Snowbound/Notebooks.swift | 16 +++++++++++- apps/ios/Snowbound/Prototypes.swift | 4 +-- crates/draw/src/render/icon.rs | 25 ++++++++++++++++--- .../snowbound/assets/icons/notebook-back.svg | 14 +++++++++++ .../snowbound/assets/icons/notebook-cover.svg | 3 +++ crates/snowbound/assets/icons/notebook.svg | 21 ++++------------ crates/snowbound/src/art.rs | 10 +++++++- crates/snowbound/src/main.rs | 10 +++++--- crates/snowbound/src/menus.rs | 2 +- crates/snowbound/src/palette.rs | 4 +-- crates/snowbound/src/properties.rs | 2 +- crates/snowbound/src/sidebar.rs | 7 ++++-- crates/snowbound/src/sync.rs | 2 +- 20 files changed, 138 insertions(+), 36 deletions(-) create mode 100644 apps/ios/Snowbound/Assets.xcassets/Notebook.imageset/Contents.json create mode 120000 apps/ios/Snowbound/Assets.xcassets/Notebook.imageset/notebook.svg create mode 100644 apps/ios/Snowbound/Assets.xcassets/NotebookBack.imageset/Contents.json create mode 120000 apps/ios/Snowbound/Assets.xcassets/NotebookBack.imageset/notebook-back.svg create mode 100644 apps/ios/Snowbound/Assets.xcassets/NotebookCover.imageset/Contents.json create mode 120000 apps/ios/Snowbound/Assets.xcassets/NotebookCover.imageset/notebook-cover.svg create mode 100644 crates/snowbound/assets/icons/notebook-back.svg create mode 100644 crates/snowbound/assets/icons/notebook-cover.svg diff --git a/apps/ios/Snowbound/Assets.xcassets/Notebook.imageset/Contents.json b/apps/ios/Snowbound/Assets.xcassets/Notebook.imageset/Contents.json new file mode 100644 index 0000000000000000000000000000000000000000..76f6e8720706c05356c7dad8ead62cb90da88b6d --- /dev/null +++ b/apps/ios/Snowbound/Assets.xcassets/Notebook.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "notebook.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/apps/ios/Snowbound/Assets.xcassets/Notebook.imageset/notebook.svg b/apps/ios/Snowbound/Assets.xcassets/Notebook.imageset/notebook.svg new file mode 120000 index 0000000000000000000000000000000000000000..12209db51e60353b127d53b0d945349624f5db19 --- /dev/null +++ b/apps/ios/Snowbound/Assets.xcassets/Notebook.imageset/notebook.svg @@ -0,0 +1 @@ +../../../../../crates/snowbound/assets/icons/notebook.svg \ No newline at end of file diff --git a/apps/ios/Snowbound/Assets.xcassets/NotebookBack.imageset/Contents.json b/apps/ios/Snowbound/Assets.xcassets/NotebookBack.imageset/Contents.json new file mode 100644 index 0000000000000000000000000000000000000000..2ba53ad9a963afde07fc042c8f967f75722edd36 --- /dev/null +++ b/apps/ios/Snowbound/Assets.xcassets/NotebookBack.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "notebook-back.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/apps/ios/Snowbound/Assets.xcassets/NotebookBack.imageset/notebook-back.svg b/apps/ios/Snowbound/Assets.xcassets/NotebookBack.imageset/notebook-back.svg new file mode 120000 index 0000000000000000000000000000000000000000..2d0c53812d7bad84a1798e7ddb8beb22b57f5cd9 --- /dev/null +++ b/apps/ios/Snowbound/Assets.xcassets/NotebookBack.imageset/notebook-back.svg @@ -0,0 +1 @@ +../../../../../crates/snowbound/assets/icons/notebook-back.svg \ No newline at end of file diff --git a/apps/ios/Snowbound/Assets.xcassets/NotebookCover.imageset/Contents.json b/apps/ios/Snowbound/Assets.xcassets/NotebookCover.imageset/Contents.json new file mode 100644 index 0000000000000000000000000000000000000000..e431fe0e19567ced5a3ecf19ae694eed178fd020 --- /dev/null +++ b/apps/ios/Snowbound/Assets.xcassets/NotebookCover.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "notebook-cover.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/apps/ios/Snowbound/Assets.xcassets/NotebookCover.imageset/notebook-cover.svg b/apps/ios/Snowbound/Assets.xcassets/NotebookCover.imageset/notebook-cover.svg new file mode 120000 index 0000000000000000000000000000000000000000..f3365539ce53d390585c441f1f990d0e42df1129 --- /dev/null +++ b/apps/ios/Snowbound/Assets.xcassets/NotebookCover.imageset/notebook-cover.svg @@ -0,0 +1 @@ +../../../../../crates/snowbound/assets/icons/notebook-cover.svg \ No newline at end of file diff --git a/apps/ios/Snowbound/Lists.swift b/apps/ios/Snowbound/Lists.swift index 35a0ca01c1b9eb1ab1e7f80c50acf5125c1a8c27..f21abbe15f369c6b5a66f60203fe30484c30fd7f 100644 --- a/apps/ios/Snowbound/Lists.swift +++ b/apps/ios/Snowbound/Lists.swift @@ -307,8 +307,7 @@ 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.fill") - content.imageProperties.tintColor = notebook.color + content.image = .notebook(notebook.color) var details: [String] = [] if case .server(let server) = notebook.source { details.append(server.host) } if let state = attention[id]?.state { diff --git a/apps/ios/Snowbound/Notebooks.swift b/apps/ios/Snowbound/Notebooks.swift index 3db56082965da243ae83a71207667f6b4a47749c..88f959e34e99093a551b56921eb91a92d7b38982 100644 --- a/apps/ios/Snowbound/Notebooks.swift +++ b/apps/ios/Snowbound/Notebooks.swift @@ -140,6 +140,20 @@ struct Tab: Decodable, Equatable { var uiColor: UIColor { UIColor(rgb: color) } } +extension UIImage { + /// A notebook's binder, the desktop's art, `side` points square: its cover in `color`, or + /// the art's own orange. + static func notebook(_ color: UIColor?, side: CGFloat = 28) -> UIImage { + let frame = CGRect(x: 0, y: 0, width: side, height: side) + let cover = color ?? UIColor(rgb: [0xf3, 0x9c, 0x28]) + return UIGraphicsImageRenderer(size: frame.size).image { _ in + UIImage(named: "NotebookBack")?.draw(in: frame) + UIImage(named: "NotebookCover")?.withTintColor(cover, renderingMode: .alwaysOriginal).draw(in: frame) + UIImage(named: "Notebook")?.draw(in: frame) + }.withRenderingMode(.alwaysOriginal) + } +} + extension UIViewController { /// Tells the reader `title` and `message` in an alert they dismiss with OK. func alert(_ title: String, _ message: String) { @@ -175,7 +189,7 @@ 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. + /// The notebook's colour, which its binder's cover takes 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? diff --git a/apps/ios/Snowbound/Prototypes.swift b/apps/ios/Snowbound/Prototypes.swift index cea5b2d3b02cb5f8fbc1eb7a9be78291bc112038..f957a7f21f12206a62c76ab08cc808210d0ab2c3 100644 --- a/apps/ios/Snowbound/Prototypes.swift +++ b/apps/ios/Snowbound/Prototypes.swift @@ -38,7 +38,7 @@ extension Prototype { ) -> UIContentUnavailableConfiguration? { guard Notebooks.all.isEmpty, !ICloud.signedIn || ICloud.documents != nil else { return nil } var welcome = UIContentUnavailableConfiguration.empty() - welcome.image = UIImage(systemName: "book.closed") + welcome.image = .notebook(nil, side: 64) welcome.text = "No Notebooks Yet" welcome.secondaryText = ICloud.documents != nil @@ -286,7 +286,7 @@ enum QuickNote { let notebooks = Notebooks.all.filter { $0.tabs.contains(where: \.readable) } let choices = notebooks.map { notebook in UIMenu( - title: notebook.name, image: UIImage(systemName: "book.closed"), + title: notebook.name, image: .notebook(notebook.color), children: notebook.tabs.filter(\.readable).map { tab in let chosen = Place(notebook: notebook.id, section: tab.path) let action = UIAction( diff --git a/crates/draw/src/render/icon.rs b/crates/draw/src/render/icon.rs index 8b4574381d3835d3a2f988e0e18c874529c495c2..0167a1c1b0312e437c653da77fb4a34f3d6c9b08 100644 --- a/crates/draw/src/render/icon.rs +++ b/crates/draw/src/render/icon.rs @@ -33,7 +33,7 @@ impl Palette { /// Rasterizes 16×16 SVG sources over each other at a whole `size` of device pixels. Paths are filled and stroked with `#rrggbb`, `currentColor`, /// which paints the linear `ink`, or two-stop `linearGradient`s with SVG's defaults /// (`objectBoundingBox` unless `userSpaceOnUse`, `x1`..`y2`, stop offsets, pad spread; no -/// `gradientTransform`). `fill-opacity` and `stroke-opacity` apply, and strokes have round +/// `gradientTransform`). `fill-opacity`, `stroke-opacity` and `stop-opacity` apply, and strokes have round /// caps and joins. A slot's paths keep the shading among their colours: the hue turn, /// saturation scale and lightness shift that carry the mean of the slot's colours in a /// source onto the palette's colour apply to each of them. A source's `image`s of PNG @@ -141,7 +141,8 @@ pub(crate) fn rasterize(sources: &[&str], size: u32, ink: [f32; 3], palette: &Pa let [x, y] = [index % side as usize, index / side as usize] .map(|device| device as f32 + 0.5) .map(|device| device * 16.0 / size); - over(pixel, color([x, y]), alpha); + let color = color([x, y]); + over(pixel, color, alpha * color[3]); } } } @@ -287,8 +288,12 @@ impl Paint { .children() .filter(|node| node.has_tag_name("stop")) .map(|node| { + let [red, green, blue, _] = hex(node.attribute("stop-color").unwrap()); + let opacity = node + .attribute("stop-opacity") + .map_or(1.0, |value| value.parse().unwrap()); ( - hex(node.attribute("stop-color").unwrap()), + [red, green, blue, opacity], node.attribute("offset") .map_or(0.0, |offset| number(offset, 1.0)), ) @@ -567,6 +572,20 @@ mod tests { assert_eq!(alpha(1, 8), 64); } + #[test] + fn stop_opacity_fades_a_gradient() { + const FADE: &str = r##""##; + let image = rasterize(&[FADE], 16, [1.0; 3], &Palette::default()); + let alpha = |y: usize| image.data[(y * 16 + 8) * 4 + 3]; + assert!( + alpha(0) > 240 && alpha(15) < 15, + "{} {}", + alpha(0), + alpha(15) + ); + assert!((i32::from(alpha(8)) - 120).abs() < 10, "{}", alpha(8)); + } + /// A 2×1 PNG, red then half-transparent blue, fits the icon's width, centred, and paints /// beneath the paths of later sources. #[test] diff --git a/crates/snowbound/assets/icons/notebook-back.svg b/crates/snowbound/assets/icons/notebook-back.svg new file mode 100644 index 0000000000000000000000000000000000000000..872d4f47707214cf3f5eb106a14bf2f7a13a3e8b --- /dev/null +++ b/crates/snowbound/assets/icons/notebook-back.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/crates/snowbound/assets/icons/notebook-cover.svg b/crates/snowbound/assets/icons/notebook-cover.svg new file mode 100644 index 0000000000000000000000000000000000000000..ad542b2a560edd7e27e88a61b8e583952d7ef65a --- /dev/null +++ b/crates/snowbound/assets/icons/notebook-cover.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/assets/icons/notebook.svg b/crates/snowbound/assets/icons/notebook.svg index 9ba6a7f690207e1e0bd1bb5295c6d652241512ce..f8d356f52f14bd1d5a4b60ef30c817ff284d4cb4 100644 --- a/crates/snowbound/assets/icons/notebook.svg +++ b/crates/snowbound/assets/icons/notebook.svg @@ -1,21 +1,10 @@ - - - - - + + - - - - - - - - - - - + + + diff --git a/crates/snowbound/src/art.rs b/crates/snowbound/src/art.rs index c13dd6adc92fa9d322651a05ac4cfaa973ae36de..ae7b4fe72debcf94cca1c646f4f7ea02cf8db9da 100644 --- a/crates/snowbound/src/art.rs +++ b/crates/snowbound/src/art.rs @@ -27,10 +27,18 @@ pub const INDENT: &[&str] = art!("icons/indent"); 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: its back and tabs, the cover `currentColor` paints, and what lies on the cover. +/// iOS layers the same files. +pub const NOTEBOOK: &[&str] = &[ + include_str!("../assets/icons/notebook-back.svg"), + include_str!("../assets/icons/notebook-cover.svg"), + include_str!("../assets/icons/notebook.svg"), +]; /// A notebook iCloud Drive keeps: its glyph with a cloud at the corner. pub const NOTEBOOK_ICLOUD: &[&str] = &[ NOTEBOOK[0], + NOTEBOOK[1], + NOTEBOOK[2], include_str!("../assets/icons/icloud-badge.svg"), ]; pub const NUMBERING: &[&str] = art!("icons/numbering"); diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index ce1f294ad0ac219fd2ae0e4b70e94063cfef2b0d..17cacb4f0dc04e53db3fb00e96ef82c44d50d66e 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -4474,10 +4474,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) +/// The colour a notebook's cover takes in `theme`: its COLORREF's hue at the shade a section's +/// takes, or the glyph's own orange without one. +fn notebook_color(theme: &Theme, color: Option) -> [f32; 4] { + color.map_or(draw::srgb(0xf3, 0x9c, 0x28), |color| { + theme.section(canvas::gpu::colorref(color)).accent + }) } /// OneNote's information bar for conflicting changes, clicked anywhere: above a page with diff --git a/crates/snowbound/src/menus.rs b/crates/snowbound/src/menus.rs index 1995ca308721b1a2a603c40f478abeed060e7b90..c316fe3adaf26d20b684929aac62589670ea9ab2 100644 --- a/crates/snowbound/src/menus.rs +++ b/crates/snowbound/src/menus.rs @@ -578,7 +578,7 @@ impl State { tint: place .path .is_empty() - .then(|| crate::notebook_color(library.color())), + .then(|| crate::notebook_color(theme, library.color())), }) .collect() } diff --git a/crates/snowbound/src/palette.rs b/crates/snowbound/src/palette.rs index 528fa9e4123ef455fe573efdda64192049414f3e..4aca480a7d674177eb4441de8f9532b385619866 100644 --- a/crates/snowbound/src/palette.rs +++ b/crates/snowbound/src/palette.rs @@ -97,7 +97,7 @@ impl State { .map(|library| Row { text: library.name.clone(), icon: Some(art::NOTEBOOK), - tint: Some(crate::notebook_color(library.color())), + tint: Some(crate::notebook_color(&self.ui.theme, library.color())), target: Some(Target::Notebook(Arc::clone(library))), ..Row::default() }), @@ -288,7 +288,7 @@ impl State { .to_owned(), after: "Closed".to_owned(), icon: Some(art::NOTEBOOK), - tint: Some(crate::notebook_color(None)), + tint: Some(crate::notebook_color(&self.ui.theme, None)), target: Some(target), ..Row::default() }); diff --git a/crates/snowbound/src/properties.rs b/crates/snowbound/src/properties.rs index d6ad958bd3088342af5681d845d735d2b1cee263..da21d467243ba97d48b6338550e2ff111ced347c 100644 --- a/crates/snowbound/src/properties.rs +++ b/crates/snowbound/src/properties.rs @@ -169,7 +169,7 @@ impl State { .map(|&(color, text)| Item { text, icon: Some(art::SWATCH), - tint: Some(crate::notebook_color(Some(color))), + tint: Some(crate::notebook_color(&theme, Some(color))), current: Some(color) == shown, ..Item::default() }) diff --git a/crates/snowbound/src/sidebar.rs b/crates/snowbound/src/sidebar.rs index ebd1eb7057ea8d15956921b4c11bec44afb1734d..6d4420d2dda45b678d4063af838fdff7fbf8b96e 100644 --- a/crates/snowbound/src/sidebar.rs +++ b/crates/snowbound/src/sidebar.rs @@ -613,7 +613,7 @@ impl Leading { 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::Notebook(icon, color) => (icon, crate::notebook_color(theme, color)), Self::Section(section) => (art::SECTION, theme.section(section).accent), } } @@ -867,7 +867,10 @@ impl crate::State { ..Spec::default() }, ); - let tint = crate::notebook_color(self.notebook().and_then(|library| library.color())); + let tint = crate::notebook_color( + &self.ui.theme, + 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(); diff --git a/crates/snowbound/src/sync.rs b/crates/snowbound/src/sync.rs index 5ef992d1a02bf6bb73aa37a4f4e64dd5b7bb6f27..10454611d0abce12e73a826c7fd11ea03b50d707 100644 --- a/crates/snowbound/src/sync.rs +++ b/crates/snowbound/src/sync.rs @@ -681,7 +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)), + tint: Some(crate::notebook_color(&theme, facts.color)), bold: true, overflow: Overflow::Ellipsis, ..Spec::default() -- 2.54.0