Merge PR #1083: Colour menus: More Colors… and Recent Colors (#928)

ROADMAP.md: all progress-log rows kept.
docs/ui-parity.md: one Last updated line, change notes combined; both history rows kept; ribbon row wording tidied.
This commit is contained in:
aiongg
2026-10-22 02:36:40 +00:00
7 changed files with 297 additions and 30 deletions
+2
View File
@@ -134,6 +134,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|---|---|
| 2026-10-13 | Formula-entry Command/Ctrl-click inserts another reference with the locale's argument separator; Shift-click extends from a stable, sheet-aware anchor. Pointer tests cover repeated clicks, reversed ranges, ordinary replacement, typed separators, Spanish syntax, sheet switches and keyboard continuation. Readiness estimates unchanged. |
| 2026-10-13 | `formulas.evaluate` (`gridcraft-cli eval`, MCP `evaluate_formula`) refuses what entering the formula in a cell refuses, with the reason (`=SUM(1;2)`: "expected `,` or `)`"), instead of `#NAME?` for any formula that doesn't parse |
| 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) |
@@ -195,6 +196,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) |
+1 -1
View File
@@ -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) }}),
+22 -16
View File
@@ -676,15 +676,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");
@@ -693,15 +696,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);
+1 -1
View File
@@ -209,7 +209,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();
}
+169 -10
View File
@@ -144,23 +144,84 @@ pub fn committed_text(ui: &mut Ui, id: egui::Id, current: &str, width: Option<f3
None
}
/// Our colour palette (theme row + tints + standard colours), 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.
/// How many custom colours the palette's Recent Colors row remembers.
pub const RECENT_COLORS: usize = 10;
/// `#RRGGBB` for a colour.
pub fn hex_of(rgb: [u8; 3]) -> 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<u32> = t.chars().map(|c| c.to_digit(16)).collect::<Option<_>>()?;
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<String>, 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<String> {
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<String> {
palette(ui, theme_colors, none_label, false)
}
fn palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str, more_colors: bool) -> Option<String> {
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<MoreColors> = 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<String> = 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);
}
}
+99
View File
@@ -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…");
}
+3 -2
View File
@@ -1,6 +1,6 @@
# UI parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Ctrl/Option-drag a sheet tab drops a copy, #971; formula-entry Command/Ctrl-click adds arguments and Shift-click extends a range) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Ctrl/Option-drag a sheet tab drops a copy, #971; formula-entry Command/Ctrl-click adds arguments and Shift-click extends a range; More Colors… and Recent Colors, #928) · **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
@@ -16,7 +16,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes
|---|---|---|---|
| 2026-10-13 | minor | Dragging a sheet tab with Ctrl held (Cmd or Option on macOS) drops a copy of the sheet where the tab is released, with the copy cursor (#971) |
| 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 |
@@ -164,6 +164,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
|---|---|---|
| 2026-10-19 | minor | Ctrl+Enter (`range.fill`) gives each cell what typing the input there gives: a General cell gets the automatic number format (`5%`, `$5`, `3/4/2026`, `=TODAY()`) even when it already held a value, and a cell with its own format, a formatted row or column, or Text keeps it. Cells that held a value used to keep General (`5%` showed 0.05), and empty cells took the active cell's format |
| 2026-10-18 | minor | Keytip letters typed with left Alt held continue the sequence (Alt+H, O, A / Alt+H, O, I ran nothing before: Alt+letter cancelled keytips); other Alt combinations, AltGr and macOS Option are unchanged (#1846) |
| 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-13 | minor | Formula-entry Command/Ctrl-click adds arguments and Shift-click extends references; ordinary replacement and dragging retain their behavior. Readiness estimates unchanged. |