diff --git a/crates/mobile/src/lib.rs b/crates/mobile/src/lib.rs index 86b011ad6118f64547a62277af5eb9e12f6e3ede..ec4281c7ce8ee3bcbefa75cd49bb17210afea0ef 100644 --- a/crates/mobile/src/lib.rs +++ b/crates/mobile/src/lib.rs @@ -156,8 +156,11 @@ impl Canvas { page.host_viewport = true; let markdown; (page.snap_to_grid, page.editor.default_font, markdown) = options().clone(); - page.editor.markdown = - markdown.then(|| notebook::sidecar::themes::built_in().swap_remove(0).sheet()); + page.editor.markdown = markdown.then(|| { + notebook::sidecar::themes::built_in() + .swap_remove(0) + .sheet(None) + }); Ok(Self { page, space, @@ -733,10 +736,7 @@ impl View { surface.configure(gpu.renderer.device(), &config); let (mut page, read_only) = section.shared.page(space)?; // The page wears its theme: style objects it gives otherwise are restyled first. - let sheet = section - .theme(page.identity) - .map(|theme| theme.sheet()) - .unwrap_or_default(); + let sheet = section.sheet(page.identity); let restyle = onestore::op::restyle(&page, &sheet)?; if !read_only && !restyle.is_empty() { section.shared.apply(page_edit(space, restyle))?; @@ -1118,7 +1118,7 @@ pub extern "C" fn sb_view_style(view: &mut View, place: u8) -> bool { let editor = &view.canvas.page.editor; let style = editor.styles.get(*name).cloned().unwrap_or_else(|| { let theme = built_in().swap_remove(0); - definition(name, &theme.styles[*name]) + definition(name, &theme.styles[*name], None) }); let result = view .canvas diff --git a/crates/mobile/src/library.rs b/crates/mobile/src/library.rs index f8bc42ed49e51bf95232a199f49cfc07a9859f50..993caaf77378bbc7540ffd8499c1eb0202b9fe7d 100644 --- a/crates/mobile/src/library.rs +++ b/crates/mobile/src/library.rs @@ -810,13 +810,29 @@ impl Section { ]) } - /// The theme page `identity` of this section wears, if any scope names one. - pub(crate) fn theme( + /// The paragraph styles page `identity` of this section wears in its theme, in this + /// section's colour; none where no scope names a theme. + pub(crate) fn sheet( &self, identity: Option<[u8; 16]>, - ) -> Option { + ) -> std::collections::BTreeMap { let section = self.shared.section.identity().ok(); - self.themes().effective(section, identity) + let Some(theme) = self.themes().effective(section, identity) else { + return Default::default(); + }; + let color = section.and_then(|section| { + let notebook = self.library.notebook(); + let listed = notebook + .as_ref()? + .catalog() + .sections() + .find(|listed| listed.file_id == section)?; + match listed.state { + SectionState::Readable { color, .. } => color, + _ => None, + } + }); + theme.sheet(color) } /// The section at catalog `path` of `library`, its edits naming `author`. diff --git a/crates/notebook/src/discover.rs b/crates/notebook/src/discover.rs index 80b65461a241fc42e7cad78b564b7c5a3faffe67..7b194d230e5c223a053fc49c3e07e1a9ee6a929c 100644 --- a/crates/notebook/src/discover.rs +++ b/crates/notebook/src/discover.rs @@ -69,7 +69,7 @@ impl Folder { } /// The sections of this folder and every group under it, in catalog order. - pub(crate) fn sections(&self) -> impl Iterator { + pub fn sections(&self) -> impl Iterator { self.folders().flat_map(|folder| &folder.sections) } } diff --git a/crates/notebook/src/sidecar/themes.rs b/crates/notebook/src/sidecar/themes.rs index 24a55ae49fc673d7b1cefa9bf523893ca4659fe0..610c782049d5cfe59613f0033782733e24a3b564 100644 --- a/crates/notebook/src/sidecar/themes.rs +++ b/crates/notebook/src/sidecar/themes.rs @@ -38,8 +38,12 @@ pub struct ThemeStyle { pub size: f32, pub bold: bool, pub italic: bool, - /// `#rrggbb`, or none for automatic. + /// `#rrggbb`, or none for automatic. Under `accent`, the accent of a section without a + /// colour, which versions that don't know `accent` take. pub color: Option, + /// Takes the page's section's accent (the "Theme" colour) in place of `color`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub accent: bool, /// Space above and below the paragraph, in points. pub before: f32, pub after: f32, @@ -224,16 +228,65 @@ fn is_built_in(id: &str) -> bool { built_in().iter().any(|theme| theme.id == id) } -/// Stored style `name`'s paragraph style as `style` gives it: every character flag set, as -/// OneNote 2010 writes its own, headings followed by Normal. -pub fn definition(name: &str, style: &ThemeStyle) -> Definition { - let color = style - .color - .as_deref() - .and_then(|hex| u32::from_str_radix(hex.strip_prefix('#')?, 16).ok()) - .map_or(0xff00_0000, |rgb| { - (rgb >> 16) | (rgb & 0xff00) | ((rgb & 0xff) << 16) - }); +/// The accent of a section coloured `section` (a COLORREF; none for OneNote's None), as a +/// COLORREF: its hue at a fixed saturation and lightness, grey staying grey. Pages store it, +/// so it never changes: versions resolving it apart would restyle each other's pages. +pub fn accent(section: Option) -> u32 { + // OneNote's blue for a section without a colour, as Snowbound's tabs show one. + let [red, green, blue, _] = section.unwrap_or(0x00E4_A88A).to_le_bytes(); + let [red, green, blue] = [red, green, blue].map(|byte| f32::from(byte) / 255.0); + let (max, min) = (red.max(green).max(blue), red.min(green).min(blue)); + let range = max - min; + let sector = if range == 0.0 { + 0.0 + } else if max == red { + (green - blue) / range + } else if max == green { + (blue - red) / range + 2.0 + } else { + (red - green) / range + 4.0 + }; + let hue = (sector * 60.0).rem_euclid(360.0); + let (saturation, lightness) = (if range == 0.0 { 0.0 } else { 0.60 }, 0.45); + let chroma = (1.0 - (2.0 * lightness - 1.0f32).abs()) * saturation; + let x = chroma * (1.0 - ((hue / 60.0) % 2.0 - 1.0).abs()); + let [r, g, b] = match hue { + h if h < 60.0 => [chroma, x, 0.0], + h if h < 120.0 => [x, chroma, 0.0], + h if h < 180.0 => [0.0, chroma, x], + h if h < 240.0 => [0.0, x, chroma], + h if h < 300.0 => [x, 0.0, chroma], + _ => [chroma, 0.0, x], + }; + let byte = |value: f32| { + ((value + lightness - chroma / 2.0) * 255.0) + .round() + .clamp(0.0, 255.0) as u32 + }; + byte(r) | byte(g) << 8 | byte(b) << 16 +} + +/// A COLORREF as `#rrggbb`, as themes keep colours. +pub fn color_hex(colorref: u32) -> String { + let [red, green, blue, _] = colorref.to_le_bytes(); + format!("#{red:02X}{green:02X}{blue:02X}") +} + +impl ThemeStyle { + /// The style's colour as a COLORREF in a section coloured `section`; none for automatic. + pub fn colorref(&self, section: Option) -> Option { + if self.accent { + return Some(accent(section)); + } + let rgb = u32::from_str_radix(self.color.as_deref()?.strip_prefix('#')?, 16).ok()?; + Some((rgb >> 16) | (rgb & 0xff00) | ((rgb & 0xff) << 16)) + } +} + +/// Stored style `name`'s paragraph style as `style` gives it in a section coloured `section`: +/// every character flag set, as OneNote 2010 writes its own, headings followed by Normal. +pub fn definition(name: &str, style: &ThemeStyle, section: Option) -> Definition { + let color = style.colorref(section).unwrap_or(0xff00_0000); // Paragraph spacing is stored in half inches. let stored = |points: f32| points / 36.0 * 36.0; Definition { @@ -261,11 +314,12 @@ pub fn definition(name: &str, style: &ThemeStyle) -> Definition { } impl Theme { - /// The paragraph style definitions the theme gives, by stored name. - pub fn sheet(&self) -> BTreeMap { + /// The paragraph style definitions the theme gives in a section coloured `section`, by + /// stored name. + pub fn sheet(&self, section: Option) -> BTreeMap { self.styles .iter() - .map(|(name, style)| (name.clone(), definition(name, style))) + .map(|(name, style)| (name.clone(), definition(name, style, section))) .collect() } } @@ -284,6 +338,7 @@ fn style( bold, italic, color: color.map(Into::into), + accent: false, before, after, } @@ -398,7 +453,7 @@ mod tests { let document = onestore::document::Document::parse(&index).unwrap(); let (space, _) = document.pages().unwrap()[0]; let page = onestore::page::Page::from_space(&document, space).unwrap(); - let sheet = built_in()[0].sheet(); + let sheet = built_in()[0].sheet(None); for (name, definition) in &sheet { assert!( page.definitions.values().any(|stored| stored == definition), @@ -432,7 +487,7 @@ mod tests { .into_iter() .find(|theme| theme.id == "manuscript") .unwrap() - .sheet(); + .sheet(None); let calibri: Vec<&str> = page .definitions .values() @@ -470,10 +525,29 @@ mod tests { assert!(onestore::op::restyle(&healed, &sheet).unwrap().is_empty()); } + /// The "Theme" colour is the section's hue at the accent's shade, OneNote's blue for a + /// section without a colour, and grey for a grey section. + #[test] + fn a_theme_coloured_style_takes_its_section_s_accent() { + assert_eq!(color_hex(accent(None)), "#2E5CB8"); + assert_eq!(color_hex(accent(Some(0x0000_00FF))), "#B82E2E"); + assert_eq!(color_hex(accent(Some(0x0080_8080))), "#737373"); + let style = ThemeStyle { + accent: true, + ..style("Arial", 16.0, BOLD, Some("#2E5CB8"), NONE) + }; + let red = definition("h1", &style, Some(0x0000_00FF)); + assert_eq!(red.format.color, Some(0x002E_2EB8)); + // Versions that don't know the accent read the colour beside it. + let json = serde_json::to_value(&style).unwrap(); + assert_eq!(json["color"], "#2E5CB8"); + assert_eq!(json["accent"], true); + } + #[test] fn every_built_in_theme_names_every_gallery_style() { for theme in built_in() { - let sheet = theme.sheet(); + let sheet = theme.sheet(None); for (name, _) in STYLES { assert!(sheet.contains_key(name), "{} {name}", theme.id); } diff --git a/crates/snowbound/src/commands.rs b/crates/snowbound/src/commands.rs index 8a182a62e6bb83c2b6601c0839d7796a9e1a67c8..60c44f588b294105f00333c88ed30b9d200cb028 100644 --- a/crates/snowbound/src/commands.rs +++ b/crates/snowbound/src/commands.rs @@ -136,7 +136,11 @@ pub fn offered(id: Id) -> bool { /// What the editor's Markdown shortcuts take: OneNote 2010's gallery styles when `on`. pub fn markdown(on: bool) -> Option> { - on.then(|| notebook::sidecar::themes::built_in().swap_remove(0).sheet()) + on.then(|| { + notebook::sidecar::themes::built_in() + .swap_remove(0) + .sheet(None) + }) } /// What a menu or toolbar offers: a command, or one of a list's entries. diff --git a/crates/snowbound/src/library.rs b/crates/snowbound/src/library.rs index 910a8c0b1d6f2534eaf1319b98997d68991d67f6..cbbb25997a97fc1ffd889f9cf4d2aa2bdd1cdf9c 100644 --- a/crates/snowbound/src/library.rs +++ b/crates/snowbound/src/library.rs @@ -1026,6 +1026,18 @@ impl Library { .map(|section| section.file_id) } + /// The colour of the section at catalog `path`, COLORREF; none for OneNote's None. + pub fn section_color(&self, path: &str) -> Option { + let listed = folders(self.catalog()?, |_| true) + .into_iter() + .flat_map(|folder| &folder.sections) + .find(|section| section.path == path)?; + match listed.state { + SectionState::Readable { color, .. } => color, + _ => None, + } + } + /// The first readable section, searching groups after sections, as OneNote opens a /// notebook. pub fn first_section(&self) -> Option { diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index d16d195301afe305848e34344875e0aed6c792d7..34ea45cde11a961956ad3bb00ece27b7be883b7e 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -2177,6 +2177,7 @@ impl State { let session = self.session.as_ref(); let update = self.updates.status(); let sheet = self.gallery_sheet(); + let section = self.section_color(); let drawing_pens = self.pens(); let ui = &mut self.ui; let text = theme.text; @@ -2269,7 +2270,9 @@ impl State { .find(|(name, _)| state.style.as_deref() == Some(*name)) .map_or("Styles".to_owned(), |(_, label)| format!("Styles: {label}")); ui::popup::tooltip(ui, &shown, "", None); - if let Some(id) = themes::gallery(ui, styles, anchor, &sheet, state.style.as_deref()) { + if let Some(id) = + themes::gallery(ui, styles, anchor, &sheet, section, state.style.as_deref()) + { choice = Some(Choice::Command(id)); } let combo = ui.id("font"); diff --git a/crates/snowbound/src/menus.rs b/crates/snowbound/src/menus.rs index c3fa6857dd8f84299e7a007a648e702ee0b5b794..5b22e86dc0c59089c137281ee709ba9b3afaa992 100644 --- a/crates/snowbound/src/menus.rs +++ b/crates/snowbound/src/menus.rs @@ -709,13 +709,14 @@ impl State { (Target::Section { library, path }, Action::Theme) => { if let Some(identity) = library.section_identity(&path) { let scope = notebook::sidecar::themes::Scope::section(identity); - self.show_themes(crate::themes::Scope::Section, library, scope); + let color = library.section_color(&path); + self.show_themes(crate::themes::Scope::Section, library, scope, color); } None } (Target::Notebook(library), Action::Theme) => { let scope = notebook::sidecar::themes::Scope::Notebook; - self.show_themes(crate::themes::Scope::Notebook, library, scope); + self.show_themes(crate::themes::Scope::Notebook, library, scope, None); None } (Target::Section { library, path }, Action::Color(color)) => Some(Command::Structure( @@ -843,7 +844,8 @@ impl State { let identity = session.section.page(space).ok()?.identity?; let library = Arc::clone(&session.library); let scope = notebook::sidecar::themes::Scope::page(identity); - self.show_themes(crate::themes::Scope::Page, library, scope); + let color = self.section_color(); + self.show_themes(crate::themes::Scope::Page, library, scope, color); None } Action::Versions(show) => Some(Command::History { page: space, show }), diff --git a/crates/snowbound/src/themes.rs b/crates/snowbound/src/themes.rs index ace5be3ce0b8e68da5730c8ca194fc4e49750b79..ec6557246b06bd5e5b6202af376ab370570de245 100644 --- a/crates/snowbound/src/themes.rs +++ b/crates/snowbound/src/themes.rs @@ -44,6 +44,8 @@ pub struct Dialog { selected: usize, /// The style the controls edit, by its place in the gallery. style: usize, + /// The colour of the section the theme is chosen for, which "Theme" colours take. + section: Option, } fn id() -> Id { @@ -57,29 +59,24 @@ fn popup(name: &str) -> Id { /// Spacing the dialog offers above and below a style, in points. const SPACING: [f32; 9] = [0.0, 2.0, 3.0, 4.0, 6.0, 8.0, 10.0, 12.0, 18.0]; -/// A style's colour as `#rrggbb` from a COLORREF, and back. -fn hex(colorref: u32) -> String { - let [red, green, blue, _] = colorref.to_le_bytes(); - format!("#{red:02X}{green:02X}{blue:02X}") -} - -fn colorref(hex: &str) -> Option { - let rgb = u32::from_str_radix(hex.strip_prefix('#')?, 16).ok()?; - Some((rgb >> 16) | (rgb & 0xff00) | ((rgb & 0xff) << 16)) -} - -/// A line of `text` in `style` as a page shows it, scaled down to fit a menu row up to -/// `largest` pixels, its colour moved onto the popup's paper so it reads in either scheme. -pub(crate) fn preview(ui: &mut Ui, part: &str, text: &str, style: &ThemeStyle, largest: f32) { +/// A line of `text` in `style` as a page in a section coloured `section` shows it, scaled +/// down to fit a menu row up to `largest` pixels, its colour moved onto the popup's paper so +/// it reads in either scheme. +pub(crate) fn preview( + ui: &mut Ui, + part: &str, + text: &str, + style: &ThemeStyle, + section: Option, + largest: f32, +) { let theme = ui.theme.clone(); let paper = canvas::gpu::Paper { color: theme.popup, ink: theme.text, }; let color = style - .color - .as_deref() - .and_then(colorref) + .colorref(section) .map_or(theme.text, |color| paper.tint(canvas::gpu::colorref(color))); ui.leaf( part, @@ -113,6 +110,12 @@ impl State { Some((Arc::clone(library), target)) } + /// The colour of the open section, which "Theme" colours take. + pub(crate) fn section_color(&self) -> Option { + let session = self.session.as_ref()?; + session.tabs.get(session.tab)?.color + } + /// The theme the open page wears, if any scope names one. pub(crate) fn page_theme(&self) -> Option { let session = self.session.as_ref()?; @@ -134,7 +137,7 @@ impl State { /// Stored style `name` as the gallery applies it on the open page. pub(crate) fn gallery_style(&self, name: &str) -> Definition { let theme = self.gallery_sheet(); - stored::definition(name, &theme.styles[name]) + stored::definition(name, &theme.styles[name], self.section_color()) } /// Dresses the open page in its theme: new text and Enter take the theme's styles, and @@ -142,7 +145,9 @@ impl State { /// keeps what it holds. pub(crate) fn wear_theme(&mut self) -> Result<(), Box> { let theme = self.page_theme(); - let sheet = theme.as_ref().map(Theme::sheet).unwrap_or_default(); + let sheet = (theme.as_ref()) + .map(|theme| theme.sheet(self.section_color())) + .unwrap_or_default(); self.view.editor.styles = sheet.clone(); let Some(session) = &self.session else { return Ok(()); @@ -164,15 +169,18 @@ impl State { let Some((library, target)) = self.theme_target(scope) else { return; }; - self.show_themes(scope, library, target); + let section = self.section_color(); + self.show_themes(scope, library, target, section); } - /// Opens the Themes dialog choosing a theme for `target`, which `scope` names. + /// Opens the Themes dialog choosing a theme for `target`, which `scope` names, in a + /// section coloured `section`. pub(crate) fn show_themes( &mut self, scope: Scope, library: Arc, target: stored::Scope, + section: Option, ) { let themes = library.themes(); let assigned = themes.assigned(&target).map(|theme| theme.id); @@ -189,6 +197,7 @@ impl State { deleted: Vec::new(), selected, style: 0, + section, }); self.ui.open_popup(id()); } @@ -274,7 +283,14 @@ impl State { ..Spec::default() }, ); - preview(ui, "name", &listed.name, &listed.styles["h2"], 15.0); + preview( + ui, + "name", + &listed.name, + &listed.styles["h2"], + dialog.section, + 15.0, + ); if let Some(node) = ui.access(item) { node.set_label(listed.name.as_str()); node.set_selected(shown); @@ -449,24 +465,28 @@ impl State { }); field(ui, "Color:", &mut |ui| { let button = ui.id("color"); - let shown = style.color.clone().unwrap_or_else(|| "Automatic".into()); - ui::shell::combo(ui, "color", "Color", &shown, 120.0, popup("colors"), true); + let shown = match (&style.color, style.accent) { + (_, true) => "Theme", + (Some(color), false) => color, + (None, false) => "Automatic", + }; + ui::shell::combo(ui, "color", "Color", shown, 120.0, popup("colors"), true); let swatches: Vec<_> = crate::FONT_COLORS .iter() .map(|&(color, name)| (canvas::gpu::colorref(color), name)) .collect(); let anchor = Anchor::Below(ui.rect(button).unwrap_or_default()); - if let Some(chosen) = - ui::popup::colors(ui, popup("colors"), anchor, "Automatic", &swatches, 10) + let buttons = ["Automatic", "Theme"]; + if let Some(picked) = + ui::popup::color_grid(ui, popup("colors"), anchor, &buttons, &swatches, 10) { - let color = chosen.and_then(|chosen| { - crate::FONT_COLORS - .iter() - .zip(&swatches) - .find(|(_, (swatch, _))| *swatch == chosen) - .map(|((color, _), _)| hex(*color)) - }); - change = Some(Change::Color(color)); + change = match picked { + Ok(chosen) => (swatches.iter()) + .position(|(swatch, _)| *swatch == chosen) + .map(|at| Change::Color(Some(stored::color_hex(crate::FONT_COLORS[at].0)))), + Err(0) => Some(Change::Color(None)), + Err(_) => Some(Change::Accent), + }; } }); for (part, label, value) in [ @@ -527,7 +547,7 @@ impl State { ..Spec::default() }, ); - preview(ui, "text", label, style, 26.0); + preview(ui, "text", label, style, dialog.section, 26.0); ui.close(); if ui.signal(line).clicked { dialog.style = at; @@ -632,6 +652,8 @@ enum Change { Bold, Italic, Color(Option), + /// The section's accent, the "Theme" colour. + Accent, /// Space above (true) or below, in points. Spacing(bool, f32), } @@ -643,7 +665,14 @@ impl Change { Self::Size(size) => style.size = size, Self::Bold => style.bold = !style.bold, Self::Italic => style.italic = !style.italic, - Self::Color(color) => style.color = color, + Self::Color(color) => { + style.color = color; + style.accent = false; + } + Self::Accent => { + style.color = Some(stored::color_hex(stored::accent(None))); + style.accent = true; + } Self::Spacing(true, points) => style.before = points, Self::Spacing(false, points) => style.after = points, } @@ -656,12 +685,13 @@ fn fresh_id(now: u64, count: usize) -> String { } /// The Styles gallery under the toolbar's Styles button: the eleven styles drawn in -/// `sheet`, the one at the caret outlined, then the theme commands. Returns the command chosen. +/// `sheet` in a section coloured `section`, the one at the caret outlined, then the theme commands. Returns the command chosen. pub(crate) fn gallery( ui: &mut Ui, menu: Id, anchor: Anchor, sheet: &Theme, + section: Option, current: Option<&str>, ) -> Option { use crate::commands::Id as Cmd; @@ -689,7 +719,7 @@ pub(crate) fn gallery( .get(index) { Some((name, label)) => { - preview(ui, "preview", label, &sheet.styles[*name], 24.0); + preview(ui, "preview", label, &sheet.styles[*name], section, 24.0); if let Some(node) = ui.access(ui.id("preview")) { node.set_label(*label); } @@ -785,7 +815,7 @@ mod tests { let source = onestore::create_section(&file, "", "Author").unwrap(); let (space, before) = page(&source); let mut editor = CanvasEditor::from_page(before, &mut engine).unwrap(); - let sheet = theme.sheet(); + let sheet = theme.sheet(None); editor.styles = sheet.clone(); let body = editor .outlines() @@ -864,7 +894,7 @@ mod tests { } assert!(op::restyle(&stored, &sheet).unwrap().is_empty()); // Another theme restyles the page whole, keeping the names. - let other = built_in().swap_remove(3).sheet(); + let other = built_in().swap_remove(3).sheet(None); let mut copy = onestore::Section::open(&arena, written.clone()).unwrap(); copy.apply( "Author", diff --git a/crates/ui/src/popup.rs b/crates/ui/src/popup.rs index 046474847c736b6e6115a8c5ac0a95e74ddb4ccb..a5c1a8ff27a33701889926bd8738aee0bda7df61 100644 --- a/crates/ui/src/popup.rs +++ b/crates/ui/src/popup.rs @@ -752,26 +752,46 @@ pub fn colors( swatches: &[([f32; 4], &str)], columns: usize, ) -> Option> { + color_grid(ui, id, anchor, &[none], swatches, columns).map(Result::ok) +} + +/// `colors` under a button for each of `buttons`: returns the swatch chosen, or the place of +/// the button. +pub fn color_grid( + ui: &mut Ui, + id: Id, + anchor: Anchor, + buttons: &[&str], + swatches: &[([f32; 4], &str)], + columns: usize, +) -> Option> { if !ui.popup_open(id) { return None; } - // The button is cell 0 and the swatches follow it. + // The buttons are the first cells and the swatches follow them. let cell = |index: usize| id.child(("cell", index)); - let count = swatches.len() + 1; + let first = buttons.len(); + let count = swatches.len() + first; let (highlight, chosen) = pick_cell(ui, id, count, cell, |key, highlight| { match (key, highlight) { (_, None) => 0, (NamedKey::ArrowLeft, Some(at)) => at.saturating_sub(1), (NamedKey::ArrowRight, Some(at)) => (at + 1).min(count - 1), - (NamedKey::ArrowUp, Some(at)) => at.saturating_sub(columns), - (_, Some(0)) => 1, + (NamedKey::ArrowUp, Some(at)) if at < first + columns => { + at.min(first).saturating_sub(1) + } + (NamedKey::ArrowUp, Some(at)) => at - columns, + (_, Some(at)) if at < first => at + 1, (_, Some(at)) if at + columns < count => at + columns, (_, Some(at)) => at, } }); if let Some(index) = chosen { ui.close_popup(id); - return Some((index > 0).then(|| swatches[index - 1].0)); + return Some(match index.checked_sub(first) { + Some(swatch) => Ok(swatches[swatch].0), + None => Err(index), + }); } let theme = ui.theme.clone(); @@ -783,20 +803,22 @@ pub fn colors( anchor, columns as f32 * CELL + 2.0 * ui.theme.menu().pad, ); - ui.open_as( - cell(0), - Spec { - flags: Flags::CLICKABLE, - size: [fill(), px(ROW)], - text: Some(none), - fill: lit(0), - radius: 4.0, - pad: [8.0, 0.0], - role: Some(Role::MenuItem), - ..Spec::default() - }, - ); - ui.close(); + for (index, button) in buttons.iter().enumerate() { + ui.open_as( + cell(index), + Spec { + flags: Flags::CLICKABLE, + size: [fill(), px(ROW)], + text: Some(button), + fill: lit(index), + radius: 4.0, + pad: [8.0, 0.0], + role: Some(Role::MenuItem), + ..Spec::default() + }, + ); + ui.close(); + } for (row, colors) in swatches.chunks(columns).enumerate() { ui.open( ("row", row), @@ -806,7 +828,7 @@ pub fn colors( }, ); for (column, (color, name)) in colors.iter().enumerate() { - let index = 1 + row * columns + column; + let index = first + row * columns + column; ui.open_as( cell(index), Spec {