From d3cc40ca20d138b8b6a61503bd6b04e81d2e974d Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:56:53 +0200 Subject: [PATCH 1/2] =?UTF-8?q?Colour=20menus:=20More=20Colors=E2=80=A6=20?= =?UTF-8?q?and=20Recent=20Colors=20(#928)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The palette every colour menu shares ends with the custom colours last used (Recent Colors, newest first, ten at most, each once) and a More Colors… button that opens a picker in the menu: the saturation/value area with its hue bar, egui's red-green-blue boxes, a hex box (#RRGGBB, RRGGBB, #RGB) with the new colour beside it, OK and Cancel. OK applies the colour like a swatch and remembers it. The ribbon's Fill Color and Font Color menus close only on a click outside them or after a pick, so the picker stays open while it is used; the Format Cells pickers show it inline. The tab-colour and chart-series menus are sub-menus that close on any click inside, so they show Recent Colors but not the picker. --- crates/ui-egui/src/panes.rs | 2 +- crates/ui-egui/src/ribbon.rs | 38 +++--- crates/ui-egui/src/tabs.rs | 2 +- crates/ui-egui/src/widgets.rs | 179 ++++++++++++++++++++++++++-- crates/ui-egui/tests/more_colors.rs | 99 +++++++++++++++ 5 files changed, 292 insertions(+), 28 deletions(-) create mode 100644 crates/ui-egui/tests/more_colors.rs diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index aaa9bec32..a8a7e35f4 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -241,7 +241,7 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) { ui.label(&name); ui.label(egui::RichText::new(&s.values).small().color(Color32::from_gray(110))); ui.menu_button("Fill…", |ui| { - if let Some(c) = crate::widgets::color_palette(ui, &theme.colors, "Automatic") { + if let Some(c) = crate::widgets::color_palette_fixed(ui, &theme.colors, "Automatic") { app.run_or_alert( "chart.formatSelection", json!({"chart": id, "series": i, "color": if c == "none" { Json::Null } else { json!(c) }}), diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 9c6d89eeb..70a312ad1 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -669,15 +669,18 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { act(app, "home.fillColor", json!({"color": fill})); } let forced = invoke(app, "home.fillColor"); - egui::Popup::menu(&fa).open_memory(popup_command(forced, fa.clicked())).show(|ui| { - if let Some(c) = color_palette(ui, &theme_colors(app), "No Fill") { - if c != "none" { - app.grid.last_fill = c.clone(); + egui::Popup::menu(&fa) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .open_memory(popup_command(forced, fa.clicked())) + .show(|ui| { + if let Some(c) = color_palette(ui, &theme_colors(app), "No Fill") { + if c != "none" { + app.grid.last_fill = c.clone(); + } + act(app, "home.fillColor", json!({"color": c})); + ui.close(); } - act(app, "home.fillColor", json!({"color": c})); - ui.close(); - } - }); + }); let fc = app.grid.last_font_color.clone(); let fc_c = gridcraft_engine::model::Color::from_hex(&fc).and_then(|c| c.resolve(&Default::default())).map(theme::color32); let (c, ca) = split_button(ui, Icon::FontColor, fc_c, "Font Color"); @@ -686,15 +689,18 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { act(app, "home.fontColor", json!({"color": fc})); } let forced = invoke(app, "home.fontColor"); - egui::Popup::menu(&ca).open_memory(popup_command(forced, ca.clicked())).show(|ui| { - if let Some(c) = color_palette(ui, &theme_colors(app), "Automatic") { - if c != "none" { - app.grid.last_font_color = c.clone(); + egui::Popup::menu(&ca) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .open_memory(popup_command(forced, ca.clicked())) + .show(|ui| { + if let Some(c) = color_palette(ui, &theme_colors(app), "Automatic") { + if c != "none" { + app.grid.last_font_color = c.clone(); + } + act(app, "home.fontColor", json!({"color": if c == "none" { "auto".to_string() } else { c }})); + ui.close(); } - act(app, "home.fontColor", json!({"color": if c == "none" { "auto".to_string() } else { c }})); - ui.close(); - } - }); + }); }); }); sep(ui); diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index b77524bb9..d24ed48e7 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -186,7 +186,7 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { } ui.menu_button("Tab Color", |ui| { let colors = app.session.active().map(|d| d.wb.theme.colors).unwrap_or_default(); - if let Some(c) = crate::widgets::color_palette(ui, &colors, "No Color") { + if let Some(c) = crate::widgets::color_palette_fixed(ui, &colors, "No Color") { app.run_or_alert("sheet.tabColor", json!({"sheet": i, "color": c})); ui.close(); } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index e24105b49..eefe983ce 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -144,23 +144,84 @@ pub fn committed_text(ui: &mut Ui, id: egui::Id, current: &str, width: Option String { + format!("#{:02X}{:02X}{:02X}", rgb[0], rgb[1], rgb[2]) +} + +/// A colour typed as `#RRGGBB`, `RRGGBB`, `#RGB` or `RGB` (any case, spaces around it ignored). +pub fn parse_hex(text: &str) -> Option<[u8; 3]> { + let t = text.trim(); + let t = t.strip_prefix('#').unwrap_or(t); + let digits: Vec = t.chars().map(|c| c.to_digit(16)).collect::>()?; + let byte = |hi: u32, lo: u32| u8::try_from(hi * 16 + lo).ok(); + match digits[..] { + [r, g, b] => Some([byte(r, r)?, byte(g, g)?, byte(b, b)?]), + [r1, r2, g1, g2, b1, b2] => Some([byte(r1, r2)?, byte(g1, g2)?, byte(b1, b2)?]), + _ => None, + } +} + +/// Puts `hex` first in the recent colours, once, keeping at most [`RECENT_COLORS`]. +pub fn remember_color(recent: &mut Vec, hex: &str) { + let hex = hex.to_ascii_uppercase(); + recent.retain(|h| *h != hex); + recent.insert(0, hex); + recent.truncate(RECENT_COLORS); +} + +fn recent_colors_id() -> egui::Id { + egui::Id::new("gridcraft.recent_colors") +} + +/// The More Colors… picker's state while it is open in a palette. +#[derive(Clone)] +struct MoreColors { + hsva: egui::ecolor::Hsva, + hex: String, +} + +impl MoreColors { + fn new(rgb: [u8; 3]) -> Self { + Self { hsva: egui::ecolor::Hsva::from_srgb(rgb), hex: hex_of(rgb) } + } +} + +/// Our colour palette (theme row + tints + standard colours, then Recent Colors and More Colors…), +/// returns a picked hex colour, `Some("none")` for No Fill/Automatic. Arrow keys move a highlight +/// through the swatches and Enter applies; the key events are stripped so the grid behind the popup +/// does not also react (not while More Colors… is open, whose boxes take the keys). pub fn color_palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str) -> Option { + palette(ui, theme_colors, none_label, true) +} + +/// [`color_palette`] without More Colors…, for a menu that closes on any click inside it (a sub-menu +/// of a context menu), where the picker would vanish as soon as it is used. Recent Colors stays. +pub fn color_palette_fixed(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str) -> Option { + palette(ui, theme_colors, none_label, false) +} + +fn palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str, more_colors: bool) -> Option { let mut picked = None; const ROWS: usize = 7; // 6 tint rows + 1 standard row const COLS: usize = 10; let pal_id = ui.id().with("__color_palette"); let mut cur: Option<(usize, usize)> = ui.data_mut(|d| d.get_temp(pal_id)).unwrap_or(None); + let more_id = pal_id.with("more"); + let mut more: Option = ui.data_mut(|d| d.get_temp(more_id)).unwrap_or(None); let (mut dl, mut dr, mut du, mut dd, mut ent) = (false, false, false, false, false); - ui.input(|i| { - dl = i.key_pressed(egui::Key::ArrowLeft); - dr = i.key_pressed(egui::Key::ArrowRight); - du = i.key_pressed(egui::Key::ArrowUp); - dd = i.key_pressed(egui::Key::ArrowDown); - ent = i.key_pressed(egui::Key::Enter); - }); + if more.is_none() { + ui.input(|i| { + dl = i.key_pressed(egui::Key::ArrowLeft); + dr = i.key_pressed(egui::Key::ArrowRight); + du = i.key_pressed(egui::Key::ArrowUp); + dd = i.key_pressed(egui::Key::ArrowDown); + ent = i.key_pressed(egui::Key::Enter); + }); + } if dl || dr || du || dd { cur = match cur { None => Some(( @@ -266,6 +327,73 @@ pub fn color_palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str) -> } } }); + // Recent Colors: the custom colours picked below, newest first. + let mut recent: Vec = ui.ctx().data(|d| d.get_temp(recent_colors_id())).unwrap_or_default(); + if !recent.is_empty() { + ui.label(egui::RichText::new("Recent Colors").small()); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + for hex in &recent { + let c = parse_hex(hex).map_or(Color32::BLACK, |c| Color32::from_rgb(c[0], c[1], c[2])); + let (r, resp) = ui.allocate_exact_size(vec2(16.0, 14.0), Sense::click()); + ui.painter().rect_filled(r, 1.0, c); + ui.painter().rect_stroke(r, 1.0, Stroke::new(1.0, Color32::from_gray(200)), StrokeKind::Inside); + if resp.hovered() { + ui.painter().rect_stroke(r.expand(1.0), 1.0, Stroke::new(1.5, Color32::from_rgb(0xE8, 0x7D, 0x2B)), StrokeKind::Outside); + } + if resp.on_hover_text(hex).clicked() { + picked = Some(hex.clone()); + } + } + }); + } + if more_colors { + ui.separator(); + } + if more_colors && ui.button("More Colors…").clicked() { + let start = recent.first().and_then(|h| parse_hex(h)).unwrap_or([0x44, 0x72, 0xC4]); + more = Some(MoreColors::new(start)); + } + if let Some(m) = more.as_mut() { + let mut done = false; + // The saturation / value area with its hue bar and egui's red-green-blue boxes, then the hex + // box with the new colour beside it. + if egui::color_picker::color_picker_hsva_2d(ui, &mut m.hsva, egui::color_picker::Alpha::Opaque) { + m.hex = hex_of(m.hsva.to_srgb()); + } + ui.horizontal(|ui| { + let label = ui.label("Hex"); + let r = ui.add(egui::TextEdit::singleline(&mut m.hex).desired_width(72.0).char_limit(7)).labelled_by(label.id); + if r.changed() + && let Some(c) = parse_hex(&m.hex) + { + m.hsva = egui::ecolor::Hsva::from_srgb(c); + } + let c = m.hsva.to_srgb(); + let (sw, _) = ui.allocate_exact_size(vec2(36.0, 18.0), Sense::hover()); + ui.painter().rect_filled(sw, 1.0, Color32::from_rgb(c[0], c[1], c[2])); + ui.painter().rect_stroke(sw, 1.0, Stroke::new(1.0, Color32::from_gray(160)), StrokeKind::Inside); + ui.label(egui::RichText::new("New").small()); + }); + ui.horizontal(|ui| { + if ui.button(" OK ").clicked() { + // A hex still being typed that is not a colour falls back to the picker's. + let c = parse_hex(&m.hex).unwrap_or_else(|| m.hsva.to_srgb()); + let hex = hex_of(c); + remember_color(&mut recent, &hex); + picked = Some(hex); + done = true; + } + if ui.button("Cancel").clicked() { + done = true; + } + }); + if done { + more = None; + } + } + ui.ctx().data_mut(|d| d.insert_temp(recent_colors_id(), recent)); + ui.data_mut(|d| d.insert_temp(more_id, more)); ui.data_mut(|d| d.insert_temp(pal_id, cur)); picked } @@ -362,3 +490,34 @@ pub fn notice_bar(app: &mut SheetApp, ui: &mut Ui) { app.notice = None; } } + +#[cfg(test)] +mod color_tests { + use super::*; + + #[test] + fn hex_is_read_in_the_forms_people_type() { + assert_eq!(parse_hex("#1A2b3C"), Some([0x1A, 0x2B, 0x3C])); + assert_eq!(parse_hex(" 1a2b3c "), Some([0x1A, 0x2B, 0x3C])); + assert_eq!(parse_hex("#f80"), Some([0xFF, 0x88, 0x00])); + for bad in ["", "#", "#12", "#1234", "#12345", "#1234567", "#12345G", "##123456", "é12345"] { + assert_eq!(parse_hex(bad), None, "{bad:?}"); + } + assert_eq!(hex_of([0, 10, 255]), "#000AFF"); + } + + #[test] + fn recent_colours_keep_the_newest_first_once_each_and_at_most_ten() { + let mut r = Vec::new(); + for i in 0..12u8 { + remember_color(&mut r, &hex_of([i, 0, 0])); + } + assert_eq!(r.len(), RECENT_COLORS); + assert_eq!(r[0], "#0B0000"); + remember_color(&mut r, "#050000"); + remember_color(&mut r, "#0b0000"); + assert_eq!(&r[..2], ["#0B0000", "#050000"]); + assert_eq!(r.iter().filter(|h| *h == "#050000").count(), 1); + assert_eq!(r.len(), RECENT_COLORS); + } +} diff --git a/crates/ui-egui/tests/more_colors.rs b/crates/ui-egui/tests/more_colors.rs new file mode 100644 index 000000000..72704d50b --- /dev/null +++ b/crates/ui-egui/tests/more_colors.rs @@ -0,0 +1,99 @@ +//! The colour menus' More Colors… and Recent Colors, through the real egui input path. + +use egui::{Event, Key, Modifiers, os::OperatingSystem}; +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness() -> Harness { + let mut s = Session::new(); + s.new_workbook(); + let mut h = egui_kittest::Harness::builder().with_os(OperatingSystem::Nix).with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(s, Default::default()), + ); + h.run_steps(4); + h +} + +fn press(h: &mut Harness, key: Key, modifiers: Modifiers) { + let ev = |pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }; + h.input_mut().events.extend([ev(true), ev(false)]); + h.run_steps(2); +} + +/// H, H opens the Fill Color palette. +fn open_fill_palette(h: &mut Harness) { + for k in [Key::F10, Key::H, Key::H] { + press(h, k, Modifiers::NONE); + } + h.run_steps(3); +} + +fn type_hex(h: &mut Harness, hex: &str) { + h.get_by_label("Hex").click(); + h.run_steps(2); + press(h, Key::A, Modifiers::COMMAND); + h.input_mut().events.push(Event::Text(hex.to_string())); + h.run_steps(3); +} + +#[test] +fn more_colors_applies_the_typed_colour_and_remembers_it() { + let mut h = harness(); + open_fill_palette(&mut h); + assert!(h.query_by_label("Recent Colors").is_none(), "nothing is recent before a custom colour is used"); + h.get_by_label("More Colors…").click(); + h.run_steps(3); + type_hex(&mut h, "#12AB34"); + h.get_by_label(" OK ").click(); + h.run_steps(4); + assert_eq!(h.state().grid.last_fill, "#12AB34"); + assert_eq!(h.state().session.journal.iter().filter(|(id, _)| id == "home.fillColor").count(), 1); + assert!(h.query_by_label("Theme Colors").is_none(), "the palette closes after OK"); + // The next time any colour menu opens, the colour is the first Recent Color. + open_fill_palette(&mut h); + h.get_by_label("Recent Colors"); + assert!(h.query_by_label("Hex").is_none(), "More Colors… starts closed"); +} + +#[test] +fn a_colour_that_is_not_hex_does_not_apply_and_cancel_applies_nothing() { + let mut h = harness(); + open_fill_palette(&mut h); + h.get_by_label("More Colors…").click(); + h.run_steps(3); + // Enter in the Hex box does not apply the palette's highlighted swatch or close the menu. + type_hex(&mut h, "#12AB"); + press(&mut h, Key::Enter, Modifiers::NONE); + assert!(h.state().session.journal.iter().all(|(id, _)| id != "home.fillColor")); + h.get_by_label("Hex"); + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert!(h.state().session.journal.iter().all(|(id, _)| id != "home.fillColor")); + assert!(h.query_by_label("Hex").is_none()); + assert!(h.query_by_label("Recent Colors").is_none(), "Cancel remembers nothing"); +} + +#[test] +fn the_font_colour_menu_has_them_too_and_shares_the_recent_colours() { + let mut h = harness(); + open_fill_palette(&mut h); + h.get_by_label("More Colors…").click(); + h.run_steps(3); + type_hex(&mut h, "#00FF7F"); + h.get_by_label(" OK ").click(); + h.run_steps(4); + for k in [Key::F10, Key::H, Key::F, Key::C] { + press(&mut h, k, Modifiers::NONE); + } + h.run_steps(3); + h.get_by_label("Automatic"); + h.get_by_label("Recent Colors"); + h.get_by_label("More Colors…"); +} From dcef8f12b22ca71d83268357b3059d870cbd2ad2 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:56:53 +0200 Subject: [PATCH 2/2] Docs: update the progress docs for this change --- ROADMAP.md | 2 ++ docs/ui-parity.md | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..b80839e7a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-13 | Colour menus: Fill Color, Font Color and the Format Cells pickers end with Recent Colors (the last ten custom colours) and More Colors… (hue/saturation area, RGB, hex box, preview, OK/Cancel) (#928) | | 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) | | 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) | | 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) | @@ -193,6 +194,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | Progress log: More Colors… and Recent Colors in the colour menus (#928) | | 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row | | 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) | | 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 22e221e69..bc3a3ae35 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (horizontal scrollbar in the sheet tab bar, #840) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (More Colors… and Recent Colors (#928); previously minor: horizontal scrollbar in the sheet tab bar, #840) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -15,7 +15,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Element | Excel | GridCraft | Status | |---|---|---|---| | Title bar, Quick Access Toolbar, AutoSave toggle | yes | yes | done; macOS window controls misaligned (#170), Dock icon size (#169) | -| Ribbon: tabs, groups, split buttons, galleries, contextual tabs | yes | yes (Table Design, Chart Design, Format, PivotTable, Header & Footer tabs) | done; missing contextual tabs for pictures, shapes, sparklines, slicers | +| Ribbon: tabs, groups, split buttons, galleries, contextual tabs | yes | yes (Table Design, Chart Design, Format, PivotTable, Header & Footer tabs) Fill Color, Font Color and the Format Cells colour pickers end with Recent Colors and More Colors… (#928). | done; missing contextual tabs for pictures, shapes, sparklines, slicers | | Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing (File ▸ Options is the one missing File command) | | Ribbon: tabs, groups, split buttons, galleries, contextual tabs | yes | yes (Table Design, Chart Design, Format, PivotTable tabs) | done; missing contextual tabs for pictures, shapes, sparklines, header/footer, slicers | | Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing | @@ -161,6 +161,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | More Colors… and Recent Colors (#928) | | 2026-10-12 | minor | The File view's recent list tells when each workbook was last changed, read off the UI thread | | 2026-10-13 | minor | Horizontal scrollbar in the sheet tab bar: drag, track click pages, frozen columns excluded from the page (#840) | | 2026-10-12 | minor | Ctrl+wheel zoom steps along a ladder of percentages about 20% apart that never skips 100%, gliding to each step with animations on, so a notch out undoes a notch in (it drifted: 100 → 122 → 101); Excel steps 15 points, see decisions.md |