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