From 3c0749e685861b07916db57c7368830541b1b0d7 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 02:31:45 -0600 Subject: [PATCH 1/6] #941: Excel 2003 menu access keys (Alt+I, R; Alt+D, S; ...) Adds 43 legacy sequences to keytips.json (Insert, Edit, Format, Data, View, Tools menus and File > Page Setup), each running the command or dialog the modern key tip already uses. Alt+I, D, V and T are implicit levels with no ribbon tab; badges are not painted under them, as in Excel. The generator checks Options (a Backstage page) by its page. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/keytips/keytip_table.rs | 23 ++ .../src/keytips/keytip_table/generated.rs | 45 ++- crates/ui-egui/src/keytips/keytips.json | 301 +++++++++++++++++ crates/ui-egui/src/ribbon/controls.rs | 10 +- crates/ui-egui/tests/it/keytips_generated.rs | 303 +++++++++++++++++- docs/ui-parity.md | 3 +- xtask/src/keytips.rs | 5 + 7 files changed, 686 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/keytips/keytip_table.rs b/crates/ui-egui/src/keytips/keytip_table.rs index 87c85befd..502e7336f 100644 --- a/crates/ui-egui/src/keytips/keytip_table.rs +++ b/crates/ui-egui/src/keytips/keytip_table.rs @@ -48,6 +48,13 @@ pub fn is_group_level(seq: &str) -> bool { matches!(lookup(seq), Some(Entry::Continue)) || (lookup(seq).is_none() && has_deeper(seq)) } +/// False under a legacy Excel 2003 menu letter that has no ribbon tab (Alt+I, D, V, T): Excel +/// paints no badges for those, so the ribbon stays as it is while the sequence is typed. +pub fn paints_badges(prefix: &str) -> bool { + let Some(root) = prefix.get(..1) else { return true }; + SEQUENCES.iter().any(|(seq, entry)| matches!(entry, Entry::Tab(_)) && seq.starts_with(root)) +} + fn matches_target(action: &Action, target: &str) -> bool { match action { Action::Command(id) | Action::CommandWith(id, _) | Action::Pending(id) | Action::Control(id) => *id == target, @@ -214,6 +221,22 @@ mod tests { assert!(!has_deeper("X")); } + #[test] + fn legacy_menu_letters_finish_without_badges() { + assert_eq!(lookup("IR"), Some(Entry::Finish(Action::Command("home.insertRows")))); + assert_eq!(lookup("DS"), Some(Entry::Finish(Action::Dialog("sort")))); + assert_eq!(lookup("EAA"), Some(Entry::Finish(Action::Command("edit.clearAll")))); + for root in ["I", "D", "V", "T"] { + assert!(is_group_level(root), "{root} waits for the menu letter"); + assert!(!paints_badges(root), "{root} has no tab, so no badges"); + } + assert!(paints_badges(""), "tab badges"); + assert!(paints_badges("HO")); + assert!(paints_badges("J"), "the Draw tab's two-letter unit"); + assert!(paints_badges("E"), "E and O stay aliases of Home"); + assert_eq!(sequences_for("home.insertRows"), vec!["HIR", "IR"], "the ribbon sequence stays first"); + } + #[test] fn group_extension_paints_only_continue_levels() { assert_eq!(group_extension("H", &["dialog:pasteSpecial"]), vec!["HV"]); diff --git a/crates/ui-egui/src/keytips/keytip_table/generated.rs b/crates/ui-egui/src/keytips/keytip_table/generated.rs index 3124a4b1f..c3a343671 100644 --- a/crates/ui-egui/src/keytips/keytip_table/generated.rs +++ b/crates/ui-egui/src/keytips/keytip_table/generated.rs @@ -372,5 +372,48 @@ pub const SEQUENCES: &[(&str, Entry)] = &[ ("FN", Entry::Finish(Action::Command("file.new"))), // Excel: New page; New page ("FO", Entry::Finish(Action::Command("file.open"))), // Excel: Open page; fires immediately in Excel; deeper aliases dropped as unreachable ("FP", Entry::Finish(Action::Command("file.print"))), // Excel: Print page; containment(Print) - ("Q", Entry::Finish(Action::Pending("ui.search"))), // pending; Excel: Search / Tell Me; Alt+Q search box does not exist yet (issue #49) + ("IR", Entry::Finish(Action::Command("home.insertRows"))), // Excel: Insert > Rows; Legacy Excel 2003 menu path (#941). + ("IC", Entry::Finish(Action::Command("home.insertColumns"))), // Excel: Insert > Columns; Legacy Excel 2003 menu path (#941). + ("IE", Entry::Finish(Action::Dialog("insertCells"))), // Excel: Insert > Cells; Legacy Excel 2003 menu path (#941). + ("IW", Entry::Finish(Action::Command("home.insertSheet"))), // Excel: Insert > Worksheet; Legacy Excel 2003 menu path (#941). + ("IF", Entry::Finish(Action::Dialog("insertFunction"))), // Excel: Insert > Function; Legacy Excel 2003 menu path (#941). + ("IND", Entry::Finish(Action::Dialog("defineName"))), // Excel: Insert > Name > Define; Legacy Excel 2003 menu path (#941). + ("IS", Entry::Finish(Action::Command("insert.symbol"))), // Excel: Insert > Symbol; Legacy Excel 2003 menu path (#941). + ("ED", Entry::Finish(Action::Dialog("deleteCells"))), // Excel: Edit > Delete; Legacy Excel 2003 menu path (#941). + ("EM", Entry::Finish(Action::Dialog("moveSheet"))), // Excel: Edit > Move or Copy Sheet; Legacy Excel 2003 menu path (#941). + ("EL", Entry::Finish(Action::Command("home.deleteSheet"))), // Excel: Edit > Delete Sheet; Legacy Excel 2003 menu path (#941). + ("EAA", Entry::Finish(Action::Command("edit.clearAll"))), // Excel: Edit > Clear > All; Legacy Excel 2003 menu path (#941). + ("EAC", Entry::Finish(Action::Command("edit.clearContents"))), // Excel: Edit > Clear > Contents; Legacy Excel 2003 menu path (#941). + ("EAM", Entry::Finish(Action::Command("edit.clearComments"))), // Excel: Edit > Clear > Comments; Legacy Excel 2003 menu path (#941). + ("EF", Entry::Finish(Action::Dialog("find"))), // Excel: Edit > Find; Legacy Excel 2003 menu path (#941). + ("EG", Entry::Finish(Action::Dialog("goTo"))), // Excel: Edit > Go To; Legacy Excel 2003 menu path (#941). + ("ORA", Entry::Finish(Action::Command("home.autofitRowHeight"))), // Excel: Format > Row > AutoFit; Legacy Excel 2003 menu path (#941). + ("ORE", Entry::Finish(Action::Dialog("rowHeight"))), // Excel: Format > Row > Height; Legacy Excel 2003 menu path (#941). + ("ORH", Entry::Finish(Action::Command("home.hideRows"))), // Excel: Format > Row > Hide; Legacy Excel 2003 menu path (#941). + ("ORU", Entry::Finish(Action::Command("home.unhideRows"))), // Excel: Format > Row > Unhide; Legacy Excel 2003 menu path (#941). + ("OCW", Entry::Finish(Action::Dialog("columnWidth"))), // Excel: Format > Column > Width; Legacy Excel 2003 menu path (#941). + ("OCH", Entry::Finish(Action::Command("home.hideColumns"))), // Excel: Format > Column > Hide; Legacy Excel 2003 menu path (#941). + ("OCU", Entry::Finish(Action::Command("home.unhideColumns"))), // Excel: Format > Column > Unhide; Legacy Excel 2003 menu path (#941). + ("OHR", Entry::Finish(Action::Dialog("renameSheet"))), // Excel: Format > Sheet > Rename; Legacy Excel 2003 menu path (#941). + ("OHH", Entry::Finish(Action::Command("sheet.hide"))), // Excel: Format > Sheet > Hide; Legacy Excel 2003 menu path (#941). + ("OHU", Entry::Finish(Action::Command("sheet.unhide"))), // Excel: Format > Sheet > Unhide; Legacy Excel 2003 menu path (#941). + ("OE", Entry::Finish(Action::Command("home.formatCells"))), // Excel: Format > Cells; Legacy Excel 2003 menu path (#941). + ("OD", Entry::Finish(Action::Command("home.conditionalFormat"))), // Excel: Format > Conditional Formatting; Legacy Excel 2003 menu path (#941). + ("DFF", Entry::Finish(Action::Command("data.filter"))), // Excel: Data > Filter > AutoFilter; Legacy Excel 2003 menu path (#941). + ("DS", Entry::Finish(Action::Dialog("sort"))), // Excel: Data > Sort; Legacy Excel 2003 menu path (#941). + ("DGG", Entry::Finish(Action::Command("data.group"))), // Excel: Data > Group and Outline > Group; Legacy Excel 2003 menu path (#941). + ("DGU", Entry::Finish(Action::Command("data.ungroup"))), // Excel: Data > Group and Outline > Ungroup; Legacy Excel 2003 menu path (#941). + ("DGS", Entry::Finish(Action::Command("data.showDetail"))), // Excel: Data > Group and Outline > Show Detail; Legacy Excel 2003 menu path (#941). + ("DGH", Entry::Finish(Action::Command("data.hideDetail"))), // Excel: Data > Group and Outline > Hide Detail; Legacy Excel 2003 menu path (#941). + ("DL", Entry::Finish(Action::Command("data.validation"))), // Excel: Data > Validation; Legacy Excel 2003 menu path (#941). + ("DT", Entry::Finish(Action::Command("data.dataTable"))), // Excel: Data > Table (what-if data table); Legacy Excel 2003 menu path (#941). + ("VN", Entry::Finish(Action::Command("view.normal"))), // Excel: View > Normal; Legacy Excel 2003 menu path (#941). + ("VP", Entry::Finish(Action::Command("view.pageBreakPreview"))), // Excel: View > Page Break Preview; Legacy Excel 2003 menu path (#941). + ("VZ", Entry::Finish(Action::Dialog("zoom"))), // Excel: View > Zoom; Legacy Excel 2003 menu path (#941). + ("TO", Entry::Finish(Action::Dialog("options"))), // Excel: Tools > Options; Legacy Excel 2003 menu path (#941). + ("TUA", Entry::Finish(Action::Command("formulas.removeArrows"))), // Excel: Tools > Formula Auditing > Remove All Arrows; Legacy Excel 2003 menu path (#941). + ("TUD", Entry::Finish(Action::Command("formulas.traceDependents"))), // Excel: Tools > Formula Auditing > Trace Dependents; Legacy Excel 2003 menu path (#941). + ("TUT", Entry::Finish(Action::Command("formulas.tracePrecedents"))), // Excel: Tools > Formula Auditing > Trace Precedents; Legacy Excel 2003 menu path (#941). + ("FU", Entry::Finish(Action::Dialog("pageSetup"))), // Excel: File > Page Setup; Legacy Excel 2003 menu path (#941). + ("Q", Entry::Finish(Action::Pending("ui.search"))), // pending; Excel: Search / Tell Me; Alt+Q search box does not exist yet (issue #49) ]; diff --git a/crates/ui-egui/src/keytips/keytips.json b/crates/ui-egui/src/keytips/keytips.json index 90eab4909..5c96d1d9f 100644 --- a/crates/ui-egui/src/keytips/keytips.json +++ b/crates/ui-egui/src/keytips/keytips.json @@ -2582,6 +2582,307 @@ "excel": "Print page", "note": "containment(Print)" }, + { + "seq": "IR", + "kind": "command", + "target": "home.insertRows", + "excel": "Insert > Rows", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "IC", + "kind": "command", + "target": "home.insertColumns", + "excel": "Insert > Columns", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "IE", + "kind": "dialog", + "target": "insertCells", + "excel": "Insert > Cells", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "IW", + "kind": "command", + "target": "home.insertSheet", + "excel": "Insert > Worksheet", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "IF", + "kind": "dialog", + "target": "insertFunction", + "excel": "Insert > Function", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "IND", + "kind": "dialog", + "target": "defineName", + "excel": "Insert > Name > Define", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "IS", + "kind": "command", + "target": "insert.symbol", + "excel": "Insert > Symbol", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "ED", + "kind": "dialog", + "target": "deleteCells", + "excel": "Edit > Delete", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "EM", + "kind": "dialog", + "target": "moveSheet", + "excel": "Edit > Move or Copy Sheet", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "EL", + "kind": "command", + "target": "home.deleteSheet", + "excel": "Edit > Delete Sheet", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "EAA", + "kind": "command", + "target": "edit.clearAll", + "excel": "Edit > Clear > All", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "EAC", + "kind": "command", + "target": "edit.clearContents", + "excel": "Edit > Clear > Contents", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "EAM", + "kind": "command", + "target": "edit.clearComments", + "excel": "Edit > Clear > Comments", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "EF", + "kind": "dialog", + "target": "find", + "excel": "Edit > Find", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "EG", + "kind": "dialog", + "target": "goTo", + "excel": "Edit > Go To", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "ORA", + "kind": "command", + "target": "home.autofitRowHeight", + "excel": "Format > Row > AutoFit", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "ORE", + "kind": "dialog", + "target": "rowHeight", + "excel": "Format > Row > Height", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "ORH", + "kind": "command", + "target": "home.hideRows", + "excel": "Format > Row > Hide", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "ORU", + "kind": "command", + "target": "home.unhideRows", + "excel": "Format > Row > Unhide", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "OCW", + "kind": "dialog", + "target": "columnWidth", + "excel": "Format > Column > Width", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "OCH", + "kind": "command", + "target": "home.hideColumns", + "excel": "Format > Column > Hide", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "OCU", + "kind": "command", + "target": "home.unhideColumns", + "excel": "Format > Column > Unhide", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "OHR", + "kind": "dialog", + "target": "renameSheet", + "excel": "Format > Sheet > Rename", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "OHH", + "kind": "command", + "target": "sheet.hide", + "excel": "Format > Sheet > Hide", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "OHU", + "kind": "command", + "target": "sheet.unhide", + "excel": "Format > Sheet > Unhide", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "OE", + "kind": "command", + "target": "home.formatCells", + "excel": "Format > Cells", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "OD", + "kind": "command", + "target": "home.conditionalFormat", + "excel": "Format > Conditional Formatting", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "DFF", + "kind": "command", + "target": "data.filter", + "excel": "Data > Filter > AutoFilter", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "DS", + "kind": "dialog", + "target": "sort", + "excel": "Data > Sort", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "DGG", + "kind": "command", + "target": "data.group", + "excel": "Data > Group and Outline > Group", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "DGU", + "kind": "command", + "target": "data.ungroup", + "excel": "Data > Group and Outline > Ungroup", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "DGS", + "kind": "command", + "target": "data.showDetail", + "excel": "Data > Group and Outline > Show Detail", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "DGH", + "kind": "command", + "target": "data.hideDetail", + "excel": "Data > Group and Outline > Hide Detail", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "DL", + "kind": "command", + "target": "data.validation", + "excel": "Data > Validation", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "DT", + "kind": "command", + "target": "data.dataTable", + "excel": "Data > Table (what-if data table)", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "VN", + "kind": "command", + "target": "view.normal", + "excel": "View > Normal", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "VP", + "kind": "command", + "target": "view.pageBreakPreview", + "excel": "View > Page Break Preview", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "VZ", + "kind": "dialog", + "target": "zoom", + "excel": "View > Zoom", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "TO", + "kind": "dialog", + "target": "options", + "excel": "Tools > Options", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "TUA", + "kind": "command", + "target": "formulas.removeArrows", + "excel": "Tools > Formula Auditing > Remove All Arrows", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "TUD", + "kind": "command", + "target": "formulas.traceDependents", + "excel": "Tools > Formula Auditing > Trace Dependents", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "TUT", + "kind": "command", + "target": "formulas.tracePrecedents", + "excel": "Tools > Formula Auditing > Trace Precedents", + "note": "Legacy Excel 2003 menu path (#941)." + }, + { + "seq": "FU", + "kind": "dialog", + "target": "pageSetup", + "excel": "File > Page Setup", + "note": "Legacy Excel 2003 menu path (#941)." + }, { "seq": "Q", "kind": "command", diff --git a/crates/ui-egui/src/ribbon/controls.rs b/crates/ui-egui/src/ribbon/controls.rs index 774a2b40b..5d10e13e0 100644 --- a/crates/ui-egui/src/ribbon/controls.rs +++ b/crates/ui-egui/src/ribbon/controls.rs @@ -27,7 +27,15 @@ pub(super) fn act(app: &mut SheetApp, id: &str, params: serde_json::Value) { /// Keytips are painted, not focusable widgets: the grid retains keyboard focus. pub(super) fn keytip(app: &SheetApp, ui: &Ui, sequence: &str, at: egui::Pos2) { - let Some(rest) = app.keytips.prefix().and_then(|prefix| sequence.strip_prefix(prefix)).filter(|s| !s.is_empty()) else { return }; + let Some(rest) = app + .keytips + .prefix() + .filter(|prefix| keytip_table::paints_badges(prefix)) + .and_then(|prefix| sequence.strip_prefix(prefix)) + .filter(|s| !s.is_empty()) + else { + return; + }; let t = Tokens::get(ui.ctx()); let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("ribbon_keytips"))).with_clip_rect(ui.clip_rect()); let text = painter.layout_no_wrap(rest.into(), theme::ui_bold(10.5), t.text); diff --git a/crates/ui-egui/tests/it/keytips_generated.rs b/crates/ui-egui/tests/it/keytips_generated.rs index 3dc225a76..976e1fdbf 100644 --- a/crates/ui-egui/tests/it/keytips_generated.rs +++ b/crates/ui-egui/tests/it/keytips_generated.rs @@ -2754,6 +2754,307 @@ fn keytip_fp_() { assert!(h.state().session.journal.iter().any(|(id, _)| id == "file.print"), "FP must journal file.print"); } +#[test] +fn keytip_ir_() { + let mut h = harness(blank()); + sequence(&mut h, "IR", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.insertRows"), "IR must journal home.insertRows"); +} + +#[test] +fn keytip_ic_() { + let mut h = harness(blank()); + sequence(&mut h, "IC", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.insertColumns"), "IC must journal home.insertColumns"); +} + +#[test] +fn keytip_ie_() { + let mut h = harness(blank()); + sequence(&mut h, "IE", false); + assert!(h.state().dialog.is_some(), "IE must open the insertCells dialog"); +} + +#[test] +fn keytip_iw_() { + let mut h = harness(blank()); + sequence(&mut h, "IW", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.insertSheet"), "IW must journal home.insertSheet"); +} + +#[test] +fn keytip_if_() { + let mut h = harness(blank()); + sequence(&mut h, "IF", false); + assert!(h.state().dialog.is_some(), "IF must open the insertFunction dialog"); +} + +#[test] +fn keytip_ind_() { + let mut h = harness(blank()); + sequence(&mut h, "IND", false); + assert!(h.state().dialog.is_some(), "IND must open the defineName dialog"); +} + +#[test] +fn keytip_is_() { + let mut h = harness(blank()); + sequence(&mut h, "IS", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "insert.symbol"), "IS must journal insert.symbol"); +} + +#[test] +fn keytip_ed_() { + let mut h = harness(blank()); + sequence(&mut h, "ED", false); + assert!(h.state().dialog.is_some(), "ED must open the deleteCells dialog"); +} + +#[test] +fn keytip_em_() { + let mut h = harness(blank()); + sequence(&mut h, "EM", false); + assert!(h.state().dialog.is_some(), "EM must open the moveSheet dialog"); +} + +#[test] +fn keytip_el_() { + let mut h = harness(blank()); + sequence(&mut h, "EL", false); + assert!(h.state().message.is_some() || h.state().toast.is_some(), "EL must invoke home.deleteSheet and surface its missing-params error"); +} + +#[test] +fn keytip_eaa_() { + let mut h = harness(blank()); + sequence(&mut h, "EAA", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "edit.clearAll"), "EAA must journal edit.clearAll"); +} + +#[test] +fn keytip_eac_() { + let mut h = harness(blank()); + sequence(&mut h, "EAC", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "edit.clearContents"), "EAC must journal edit.clearContents"); +} + +#[test] +fn keytip_eam_() { + let mut h = harness(blank()); + sequence(&mut h, "EAM", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "edit.clearComments"), "EAM must journal edit.clearComments"); +} + +#[test] +fn keytip_ef_() { + let mut h = harness(blank()); + sequence(&mut h, "EF", false); + assert!(h.state().dialog.is_some(), "EF must open the find dialog"); +} + +#[test] +fn keytip_eg_() { + let mut h = harness(blank()); + sequence(&mut h, "EG", false); + assert!(h.state().dialog.is_some(), "EG must open the goTo dialog"); +} + +#[test] +fn keytip_ora_() { + let mut h = harness(blank()); + sequence(&mut h, "ORA", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.autofitRowHeight"), "ORA must journal home.autofitRowHeight"); +} + +#[test] +fn keytip_ore_() { + let mut h = harness(blank()); + sequence(&mut h, "ORE", false); + assert!(h.state().dialog.is_some(), "ORE must open the rowHeight dialog"); +} + +#[test] +fn keytip_orh_() { + let mut h = harness(blank()); + sequence(&mut h, "ORH", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.hideRows"), "ORH must journal home.hideRows"); +} + +#[test] +fn keytip_oru_() { + let mut h = harness(blank()); + sequence(&mut h, "ORU", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.unhideRows"), "ORU must journal home.unhideRows"); +} + +#[test] +fn keytip_ocw_() { + let mut h = harness(blank()); + sequence(&mut h, "OCW", false); + assert!(h.state().dialog.is_some(), "OCW must open the columnWidth dialog"); +} + +#[test] +fn keytip_och_() { + let mut h = harness(blank()); + sequence(&mut h, "OCH", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.hideColumns"), "OCH must journal home.hideColumns"); +} + +#[test] +fn keytip_ocu_() { + let mut h = harness(blank()); + sequence(&mut h, "OCU", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.unhideColumns"), "OCU must journal home.unhideColumns"); +} + +#[test] +fn keytip_ohr_() { + let mut h = harness(blank()); + sequence(&mut h, "OHR", false); + assert!(h.state().dialog.is_some(), "OHR must open the renameSheet dialog"); +} + +#[test] +fn keytip_ohh_() { + let mut h = harness(blank()); + sequence(&mut h, "OHH", false); + assert!(h.state().message.is_some() || h.state().toast.is_some(), "OHH must invoke sheet.hide and surface its missing-params error"); +} + +#[test] +fn keytip_ohu_() { + let mut h = harness(blank()); + sequence(&mut h, "OHU", false); + assert!(h.state().message.is_some() || h.state().toast.is_some(), "OHU must invoke sheet.unhide and surface its missing-params error"); +} + +#[test] +fn keytip_oe_() { + let mut h = harness(blank()); + sequence(&mut h, "OE", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.formatCells"), "OE must journal home.formatCells"); +} + +#[test] +fn keytip_od_() { + let mut h = harness(blank()); + sequence(&mut h, "OD", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.conditionalFormat"), "OD must journal home.conditionalFormat"); +} + +#[test] +fn keytip_dff_() { + let mut h = harness(blank()); + sequence(&mut h, "DFF", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.filter"), "DFF must journal data.filter"); +} + +#[test] +fn keytip_ds_() { + let mut h = harness(blank()); + sequence(&mut h, "DS", false); + assert!(h.state().dialog.is_some(), "DS must open the sort dialog"); +} + +#[test] +fn keytip_dgg_() { + let mut h = harness(blank()); + sequence(&mut h, "DGG", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.group"), "DGG must journal data.group"); +} + +#[test] +fn keytip_dgu_() { + let mut h = harness(blank()); + sequence(&mut h, "DGU", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.ungroup"), "DGU must journal data.ungroup"); +} + +#[test] +fn keytip_dgs_() { + let mut h = harness(blank()); + sequence(&mut h, "DGS", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.showDetail"), "DGS must journal data.showDetail"); +} + +#[test] +fn keytip_dgh_() { + let mut h = harness(blank()); + sequence(&mut h, "DGH", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.hideDetail"), "DGH must journal data.hideDetail"); +} + +#[test] +fn keytip_dl_() { + let mut h = harness(blank()); + sequence(&mut h, "DL", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.validation"), "DL must journal data.validation"); +} + +#[test] +fn keytip_dt_() { + let mut h = harness(blank()); + sequence(&mut h, "DT", false); + assert!(h.state().message.is_some() || h.state().toast.is_some(), "DT must invoke data.dataTable and surface its missing-params error"); +} + +#[test] +fn keytip_vn_() { + let mut h = harness(blank()); + sequence(&mut h, "VN", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "view.normal"), "VN must journal view.normal"); +} + +#[test] +fn keytip_vp_() { + let mut h = harness(blank()); + sequence(&mut h, "VP", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "view.pageBreakPreview"), "VP must journal view.pageBreakPreview"); +} + +#[test] +fn keytip_vz_() { + let mut h = harness(blank()); + sequence(&mut h, "VZ", false); + assert!(h.state().dialog.is_some(), "VZ must open the zoom dialog"); +} + +#[test] +fn keytip_to_() { + let mut h = harness(blank()); + sequence(&mut h, "TO", false); + assert!(h.state().backstage.is_some(), "TO must open the Options page"); +} + +#[test] +fn keytip_tua_() { + let mut h = harness(blank()); + sequence(&mut h, "TUA", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "formulas.removeArrows"), "TUA must journal formulas.removeArrows"); +} + +#[test] +fn keytip_tud_() { + let mut h = harness(blank()); + sequence(&mut h, "TUD", false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "TUD must run formulas.traceDependents cleanly"); +} + +#[test] +fn keytip_tut_() { + let mut h = harness(blank()); + sequence(&mut h, "TUT", false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "TUT must run formulas.tracePrecedents cleanly"); +} + +#[test] +fn keytip_fu_() { + let mut h = harness(blank()); + sequence(&mut h, "FU", false); + assert!(h.state().dialog.is_some(), "FU must open the pageSetup dialog"); +} + #[test] fn keytip_q_() { let mut h = harness(blank()); @@ -2766,7 +3067,7 @@ fn keytip_q_() { #[test] fn generated_test_count_matches_the_table() { - const EXPECTED: usize = 367; + const EXPECTED: usize = 410; assert_eq!(gridcraft_ui_egui::keytips::keytip_table::SEQUENCES.len(), EXPECTED); } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 125182d27..3d51e0e65 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -21,7 +21,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Formulas › Function Library | Insert Function, AutoSum, Recently Used and a menu per function category | yes (10-24): a big Insert Function button and three columns of labelled small buttons, the layout Excel uses when the window is not wide enough for a big button per category; each category's book icon has its own colour; a category menu shows each function's description on hover and ends with Insert Function…, which opens the dialog on that category; More Functions cascades into Statistical, Engineering, Cube, Information, Compatibility and Web; AutoSum is a split button whose menu (Sum, Average, Count Numbers, Max, Min, More Functions…) Home › Editing shares; Recently Used has the MR key tip | the wide layout with a big button per category is not offered; a function counts as recently used once its formula is entered, not when Insert Function's OK is pressed | | 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 | -| Key tips (Alt / F10 then letters) | yes, every ribbon control | **initial** (#43, 10-10): Alt or F10 shows key tips on Home for alignment, Paste Special and AutoFit plus legacy Alt sequences; other tabs and controls not yet (#33, #49) | 3–5 h | +| Key tips (Alt / F10 then letters) | yes, every ribbon control | **initial** (#43, 10-10): Alt or F10 shows key tips on Home for alignment, Paste Special and AutoFit plus the Excel 2003 menu keys (Alt+I, E, O, D, V, T and Alt+F, U, such as Alt+I, R or Alt+D, S), which paint no badges, as in Excel (#941); other tabs and controls not yet (#33, #49) | 3–5 h | | Formula bar with Name Box, expand, function hints | yes | yes; grows with its content from one to five lines (Alt+Enter, wrapped text, or viewing a multi-line cell), then scrolls with the caret in view, the Name Box and buttons staying on the first line; the chevron pins it at five; the in-cell editor shows every line | done; no drag-to-resize handle | | Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | Ctrl-drag (Cmd or Option on macOS) drops a copy (#971); active tab revealed on navigation and resize (#681); manual wheel/trackpad browsing preserved; rename focus bug on macOS (#145) | | Formula bar with Name Box, expand, function hints | yes | yes | done; dynamic-array spill children show the source formula muted and read-only, using current spill ownership | @@ -237,6 +237,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Excel 2003 menu access keys: 43 legacy sequences (Insert, Edit, Format, Data, View, Tools and File ▸ Page Setup, e.g. Alt+I, R inserts rows, Alt+E, A, A clears all, Alt+D, S sorts, Alt+T, O opens Options) run the existing commands and dialogs; Alt+I, D, V and T paint no badges, as in Excel (#941, #2311) | | 2026-11-02 | minor | RefEdit reference boxes in dialogs: pick a range on the grid, collapse the dialog while selecting; Goal Seek, Solver (with the Model Range of Load/Save) and Data Analysis use them; while collapsed the arrow keys move the selection and Shift+arrow extends it, F4 cycles a reference's anchors, a click elsewhere keeps the box picking, a dialog opened over a picking one puts the selection back, OK stays on the sheet its command showed, and scripted cancel and confirm end a pick like the buttons; the dialog's sheet is followed through the recorded undo steps, so a sheet deleted and recreated under its name is not taken for it and an undo of the deletion brings it back; a box the dialog stops showing is no longer picked into | | 2026-11-02 | minor | Python mode edits a cell's code in the formula bar's code editor (Ctrl+Alt+Shift+P, `=PY` then Tab, editing a Python cell), with the PY badge beside fx; its keys apply in input order, also several in one frame, and Ctrl+Enter commits through `python.set` (empty code clears the cell), keeping the code and showing the command's message when the code is refused | | 2026-11-02 | minor | Insert › Symbols: Equation split button (access keys N E), a gallery of examples, and an equation editor with quick-insert buttons, the error underlined at its position and a live preview; double-clicking an equation edits it | diff --git a/xtask/src/keytips.rs b/xtask/src/keytips.rs index 5b7137455..b662dd101 100644 --- a/xtask/src/keytips.rs +++ b/xtask/src/keytips.rs @@ -323,6 +323,11 @@ fn render_tests(entries: &[Entry], probe: &Probe) -> String { id = e.target, ), }, + // Options is a Backstage page rather than a dialog box. + "dialog" if e.target == "options" => format!( + "sequence(&mut h, {seq:?}, false);\n assert!(h.state().backstage.is_some(), \"{seq} must open the Options page\");", + seq = e.seq, + ), "dialog" => format!( "sequence(&mut h, {seq:?}, false);\n assert!(h.state().dialog.is_some(), \"{seq} must open the {dialog} dialog\");", seq = e.seq, From 6fcb94f7e3873be4136cb38e219f99f03bd654ac Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 03:03:58 -0600 Subject: [PATCH 2/6] #941: Alt+I, S opens the Symbol gallery instead of re-setting the active cell Addresses CodeRabbit review on #3250, "Open a symbol picker instead of re-parsing the cell": the legacy key ran insert.symbol with no symbol, which set the active cell to its own text again (re-reading it: text could turn into a number, a Python cell lost its code). It now routes to the ribbon's Symbol control: a control key tip whose control is on another tab shows that tab first and hands the request to the next frame, when the control is drawn, and the Symbol button opens its gallery on its key tip. Nothing is inserted until a symbol is picked. Test: legacy_keytips::alt_i_s_opens_the_symbol_gallery_and_leaves_the_cell_alone (keytips regenerated with cargo xtask keytips). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/app.rs | 5 +-- crates/ui-egui/src/app/keys.rs | 13 +++++++- .../src/keytips/keytip_table/generated.rs | 2 +- crates/ui-egui/src/keytips/keytips.json | 2 +- crates/ui-egui/src/lib.rs | 5 +++ crates/ui-egui/src/ribbon/insert.rs | 4 ++- crates/ui-egui/tests/it/keytips_generated.rs | 3 +- crates/ui-egui/tests/it/legacy_keytips.rs | 33 +++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + 9 files changed, 61 insertions(+), 7 deletions(-) create mode 100644 crates/ui-egui/tests/it/legacy_keytips.rs diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 9d190083e..762a8c704 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -107,8 +107,9 @@ impl SheetApp { pub fn logic(&mut self, ctx: &egui::Context) { self.ensure_text_width(ctx); // A keytip control request lives one frame: the ribbon consumes it during `ui`, and - // anything that never reached a control (collapsed ribbon, tab switch) is dropped. - self.ui.keytip_invoke = None; + // anything that never reached a control (collapsed ribbon, tab switch) is dropped. One + // made with a switch to the control's tab waits for the frame that draws that tab. + self.ui.keytip_invoke = self.ui.keytip_invoke_next.take(); // Floating pictures' textures go with their workbook, even when no sheet is painted. let open: Vec = self.session.documents().iter().map(|d| d.uid).collect(); floating_pictures::forget_closed(ctx, &open); diff --git a/crates/ui-egui/src/app/keys.rs b/crates/ui-egui/src/app/keys.rs index 3490bd01c..b490d8557 100644 --- a/crates/ui-egui/src/app/keys.rs +++ b/crates/ui-egui/src/app/keys.rs @@ -59,7 +59,18 @@ impl SheetApp { // Route to the ribbon control that owns this keytip: it opens its dropdown or // runs its click handler with the right params (the engine command alone would // be missing them). - keytips::Action::Control(id) => self.ui.keytip_invoke = Some(id), + // + // A control on another tab (a legacy menu key such as Alt+I, S for the Symbol + // gallery) shows its tab first, so the control is drawn and takes the request. + keytips::Action::Control(id) => { + if let Some(tab) = gridcraft_engine::find_command(id).and_then(|c| c.menu.first()).filter(|t| **t != self.ui.ribbon_tab) { + self.run_or_alert("ui.ribbonTab", json!({"tab": tab})); + self.ui.ribbon_collapsed = false; + self.ui.keytip_invoke_next = Some(id); + } else { + self.ui.keytip_invoke = Some(id); + } + } } } } diff --git a/crates/ui-egui/src/keytips/keytip_table/generated.rs b/crates/ui-egui/src/keytips/keytip_table/generated.rs index c3a343671..6c397f26c 100644 --- a/crates/ui-egui/src/keytips/keytip_table/generated.rs +++ b/crates/ui-egui/src/keytips/keytip_table/generated.rs @@ -378,7 +378,7 @@ pub const SEQUENCES: &[(&str, Entry)] = &[ ("IW", Entry::Finish(Action::Command("home.insertSheet"))), // Excel: Insert > Worksheet; Legacy Excel 2003 menu path (#941). ("IF", Entry::Finish(Action::Dialog("insertFunction"))), // Excel: Insert > Function; Legacy Excel 2003 menu path (#941). ("IND", Entry::Finish(Action::Dialog("defineName"))), // Excel: Insert > Name > Define; Legacy Excel 2003 menu path (#941). - ("IS", Entry::Finish(Action::Command("insert.symbol"))), // Excel: Insert > Symbol; Legacy Excel 2003 menu path (#941). + ("IS", Entry::Finish(Action::Control("insert.symbol"))), // Excel: Insert > Symbol; Legacy Excel 2003 menu path (#941). ("ED", Entry::Finish(Action::Dialog("deleteCells"))), // Excel: Edit > Delete; Legacy Excel 2003 menu path (#941). ("EM", Entry::Finish(Action::Dialog("moveSheet"))), // Excel: Edit > Move or Copy Sheet; Legacy Excel 2003 menu path (#941). ("EL", Entry::Finish(Action::Command("home.deleteSheet"))), // Excel: Edit > Delete Sheet; Legacy Excel 2003 menu path (#941). diff --git a/crates/ui-egui/src/keytips/keytips.json b/crates/ui-egui/src/keytips/keytips.json index 5c96d1d9f..174d519eb 100644 --- a/crates/ui-egui/src/keytips/keytips.json +++ b/crates/ui-egui/src/keytips/keytips.json @@ -2626,7 +2626,7 @@ }, { "seq": "IS", - "kind": "command", + "kind": "control", "target": "insert.symbol", "excel": "Insert > Symbol", "note": "Legacy Excel 2003 menu path (#941)." diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 95f861f14..a0637d966 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -102,6 +102,10 @@ pub struct UiState { /// [`keytips::Action::Control`], consumed (and cleared) by the control's render site. #[serde(skip)] pub keytip_invoke: Option<&'static str>, + /// A keytip control request for the next frame: its tab was switched to this frame, so its + /// control is drawn only then. + #[serde(skip)] + pub keytip_invoke_next: Option<&'static str>, /// Interface language. English by default (so tests and headless renders never depend on the /// host's locale); the desktop app starts from [`i18n::Language::system`] when `ui.json` has /// no usable language (see [`i18n::saved_language`]). An unknown saved value reads as English @@ -143,6 +147,7 @@ impl Default for UiState { autosave: false, recent: vec![], keytip_invoke: None, + keytip_invoke_next: None, language: i18n::Language::En, interface_scale: 100, } diff --git a/crates/ui-egui/src/ribbon/insert.rs b/crates/ui-egui/src/ribbon/insert.rs index 595b74657..c347c896a 100644 --- a/crates/ui-egui/src/ribbon/insert.rs +++ b/crates/ui-egui/src/ribbon/insert.rs @@ -213,7 +213,9 @@ pub(super) fn insert(app: &mut SheetApp, ui: &mut Ui) { }); let sym = big_button(ui, Icon::Symbol, "Symbol", "Symbol", true); control_keytip(app, ui, &sym, "insert.symbol"); - egui::Popup::menu(&sym).show(|ui| { + // Opened by its key tip too (Alt+I, S): the gallery picks the symbol, nothing is inserted before. + let forced = invoke(app, "insert.symbol"); + egui::Popup::menu(&sym).open_memory(popup_command(forced, sym.clicked())).show(|ui| { ui.set_width(260.0); ui.horizontal_wrapped(|ui| { for ch in [ diff --git a/crates/ui-egui/tests/it/keytips_generated.rs b/crates/ui-egui/tests/it/keytips_generated.rs index 976e1fdbf..600a36319 100644 --- a/crates/ui-egui/tests/it/keytips_generated.rs +++ b/crates/ui-egui/tests/it/keytips_generated.rs @@ -2800,7 +2800,8 @@ fn keytip_ind_() { fn keytip_is_() { let mut h = harness(blank()); sequence(&mut h, "IS", false); - assert!(h.state().session.journal.iter().any(|(id, _)| id == "insert.symbol"), "IS must journal insert.symbol"); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "IS must route to its control without an error box"); + assert_eq!(h.state().keytips.prefix(), None); } #[test] diff --git a/crates/ui-egui/tests/it/legacy_keytips.rs b/crates/ui-egui/tests/it/legacy_keytips.rs new file mode 100644 index 000000000..fa6ca37ed --- /dev/null +++ b/crates/ui-egui/tests/it/legacy_keytips.rs @@ -0,0 +1,33 @@ +//! Excel 2003 menu access keys that open a ribbon control rather than run a command (#941). + +use egui::os::OperatingSystem; +use egui::{Event, Key, Modifiers}; +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +fn press(h: &mut egui_kittest::Harness<'static, SheetApp>, key: Key) { + let ev = |pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }; + h.input_mut().events.extend([ev(true), Event::Text(key.name().to_ascii_lowercase()), ev(false)]); + h.run_steps(2); +} + +#[test] +fn alt_i_s_opens_the_symbol_gallery_and_leaves_the_cell_alone() { + // Insert > Symbol picks a symbol; running insert.symbol with no symbol set the active cell + // to its own text again, re-reading it (a Python cell lost its code, text could turn into a + // number). From the Home tab the keys show the Insert tab's gallery instead. + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": "'007"})).unwrap(); + let mut h = crate::common::os_harness(s, OperatingSystem::Nix); + h.run_steps(4); + for key in [Key::F10, Key::I, Key::S] { + press(&mut h, key); + } + h.run_steps(4); + assert!(!h.state().session.journal.iter().any(|(id, _)| id == "insert.symbol"), "nothing is inserted until a symbol is picked"); + assert_eq!(h.state().ui.ribbon_tab, "Insert"); + assert!(h.query_by_label("€").is_some(), "the Symbol gallery is open"); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index f1240dcd6..eedef2148 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -63,6 +63,7 @@ mod interaction; mod interface_scale; mod keyboard_shortcuts; mod keytips_generated; +mod legacy_keytips; mod macro_editor; mod macros_ui; mod menu_hover; From 9ce599d978d8bebc2361e9ca59d89704ec326d39 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 03:25:52 -0600 Subject: [PATCH 3/6] #941: a control key tip expands a collapsed ribbon before it opens the control Addresses CodeRabbit review on #3250, "Expand the ribbon for a same-tab control request": with the Insert tab chosen but the ribbon collapsed, Alt+I, S went to the same-tab branch and the Symbol gallery was never drawn. A collapsed ribbon is now expanded like a tab switch, and the request waits for the frame that draws the control. Test: legacy_keytips::alt_i_s_expands_a_collapsed_ribbon_on_the_insert_tab. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/app/keys.rs | 8 ++++++-- crates/ui-egui/tests/it/legacy_keytips.rs | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/app/keys.rs b/crates/ui-egui/src/app/keys.rs index b490d8557..1facc5a4f 100644 --- a/crates/ui-egui/src/app/keys.rs +++ b/crates/ui-egui/src/app/keys.rs @@ -63,8 +63,12 @@ impl SheetApp { // A control on another tab (a legacy menu key such as Alt+I, S for the Symbol // gallery) shows its tab first, so the control is drawn and takes the request. keytips::Action::Control(id) => { - if let Some(tab) = gridcraft_engine::find_command(id).and_then(|c| c.menu.first()).filter(|t| **t != self.ui.ribbon_tab) { - self.run_or_alert("ui.ribbonTab", json!({"tab": tab})); + // A collapsed ribbon draws no controls either: it is expanded the same way. + let other_tab = gridcraft_engine::find_command(id).and_then(|c| c.menu.first()).filter(|t| **t != self.ui.ribbon_tab); + if other_tab.is_some() || self.ui.ribbon_collapsed { + if let Some(tab) = other_tab { + self.run_or_alert("ui.ribbonTab", json!({"tab": tab})); + } self.ui.ribbon_collapsed = false; self.ui.keytip_invoke_next = Some(id); } else { diff --git a/crates/ui-egui/tests/it/legacy_keytips.rs b/crates/ui-egui/tests/it/legacy_keytips.rs index fa6ca37ed..c2490f2bf 100644 --- a/crates/ui-egui/tests/it/legacy_keytips.rs +++ b/crates/ui-egui/tests/it/legacy_keytips.rs @@ -31,3 +31,22 @@ fn alt_i_s_opens_the_symbol_gallery_and_leaves_the_cell_alone() { assert_eq!(h.state().ui.ribbon_tab, "Insert"); assert!(h.query_by_label("€").is_some(), "the Symbol gallery is open"); } + +#[test] +fn alt_i_s_expands_a_collapsed_ribbon_on_the_insert_tab() { + // With the Insert tab already chosen but the ribbon collapsed, the gallery's control isn't + // drawn: the keys expand the ribbon first. + let mut s = Session::new(); + s.new_workbook(); + let mut h = crate::common::os_harness(s, OperatingSystem::Nix); + h.state_mut().run("ui.ribbonTab", json!({"tab": "Insert"})).unwrap(); + h.state_mut().ui.ribbon_collapsed = true; + h.run_steps(4); + for key in [Key::F10, Key::I, Key::S] { + press(&mut h, key); + } + h.run_steps(4); + assert!(!h.state().ui.ribbon_collapsed); + assert!(h.query_by_label("€").is_some(), "the Symbol gallery is open"); + assert!(!h.state().session.journal.iter().any(|(id, _)| id == "insert.symbol")); +} From d964c1cb661fb2a2ca38672d13d2259825c47cfa Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 03:39:43 -0600 Subject: [PATCH 4/6] #941: a waiting key tip request stays with the window that made it Addresses CodeRabbit review on #3250, "Keep deferred requests with their window": the request waiting for the next frame was shared across windows, so the root window, drawn first, could take a second window's Alt+I, S. It is now parked with each window's interface state and moved into the one-frame request when that window's ui begins. Test: legacy_keytips::a_waiting_key_tip_request_stays_with_its_window. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/app.rs | 8 ++++++-- crates/ui-egui/src/window_ui.rs | 4 ++++ crates/ui-egui/tests/it/legacy_keytips.rs | 19 +++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 762a8c704..94b6b01e7 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -108,8 +108,9 @@ impl SheetApp { self.ensure_text_width(ctx); // A keytip control request lives one frame: the ribbon consumes it during `ui`, and // anything that never reached a control (collapsed ribbon, tab switch) is dropped. One - // made with a switch to the control's tab waits for the frame that draws that tab. - self.ui.keytip_invoke = self.ui.keytip_invoke_next.take(); + // made with a switch to the control's tab waits, with its window, for the next `ui` of + // that window. + self.ui.keytip_invoke = None; // Floating pictures' textures go with their workbook, even when no sheet is painted. let open: Vec = self.session.documents().iter().map(|d| d.uid).collect(); floating_pictures::forget_closed(ctx, &open); @@ -195,6 +196,9 @@ impl SheetApp { let t0 = now_ms(); // A script may have switched windows since the last frame. self.follow_active_window(); + // This window's key tip request that waited for this frame (its tab or ribbon was just + // shown); any other window's request was dropped before it. + self.ui.keytip_invoke = self.ui.keytip_invoke_next.take(); text_box::before_ui(self, &ctx); // Another window came forward without going through a command (which settles drafts // first): the draft goes into its own window instead of floating over this one. If its diff --git a/crates/ui-egui/src/window_ui.rs b/crates/ui-egui/src/window_ui.rs index cef736ecc..93f2c699a 100644 --- a/crates/ui-egui/src/window_ui.rs +++ b/crates/ui-egui/src/window_ui.rs @@ -31,6 +31,8 @@ pub struct WindowUi { ribbon_tab: String, /// The status bar's Ready/Enter/Edit, which follows the window's cell draft. mode: gridcraft_engine::Mode, + /// A key tip's control request waiting for the frame that draws this window's ribbon. + keytip_invoke_next: Option<&'static str>, } impl WindowUi { @@ -70,6 +72,7 @@ impl SheetApp { open_switch_windows, ribbon_tab, mode, + keytip_invoke_next, } = w; std::mem::swap(&mut self.editor, editor); std::mem::swap(&mut self.text_box_editor, text_box_editor); @@ -86,6 +89,7 @@ impl SheetApp { std::mem::swap(&mut self.open_switch_windows, open_switch_windows); std::mem::swap(&mut self.ui.ribbon_tab, ribbon_tab); std::mem::swap(&mut self.session.mode, mode); + std::mem::swap(&mut self.ui.keytip_invoke_next, keytip_invoke_next); } /// The window whose interface state is in the per-window fields. diff --git a/crates/ui-egui/tests/it/legacy_keytips.rs b/crates/ui-egui/tests/it/legacy_keytips.rs index c2490f2bf..57deea0aa 100644 --- a/crates/ui-egui/tests/it/legacy_keytips.rs +++ b/crates/ui-egui/tests/it/legacy_keytips.rs @@ -50,3 +50,22 @@ fn alt_i_s_expands_a_collapsed_ribbon_on_the_insert_tab() { assert!(h.query_by_label("€").is_some(), "the Symbol gallery is open"); assert!(!h.state().session.journal.iter().any(|(id, _)| id == "insert.symbol")); } + +#[test] +fn a_waiting_key_tip_request_stays_with_its_window() { + // A request waiting for the frame that draws its control belongs to the window whose keys + // made it: another window drawn first must not take it. + let mut s = Session::new(); + s.new_workbook(); + let mut h = crate::common::os_harness(s, OperatingSystem::Nix); + h.run_steps(2); + let first = h.state().session.active_window().unwrap(); + h.state_mut().run("view.newWindow", json!({})).unwrap(); + let second = h.state().session.active_window().unwrap(); + h.state_mut().show_window(second); + h.state_mut().ui.keytip_invoke_next = Some("insert.symbol"); + h.state_mut().show_window(first); + assert_eq!(h.state().ui.keytip_invoke_next, None, "the first window doesn't see the second window's request"); + h.state_mut().show_window(second); + assert_eq!(h.state().ui.keytip_invoke_next, Some("insert.symbol")); +} From 68e87f0a6863c21c4669cc2bf3143def1150459a Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 04:01:25 -0600 Subject: [PATCH 5/6] #941: a hidden window drops its waiting key tip request Addresses CodeRabbit review on #3250, "Clear deferred key-tip requests when parking a hidden window": a hidden window isn't drawn, so a request it parked would fire whenever it was shown again (the Symbol gallery opening long after the keys). Parking a hidden window now drops it; the rest of its interface state is kept. Test: legacy_keytips::a_hidden_window_drops_its_waiting_key_tip_request. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/window_ui.rs | 7 ++++++- crates/ui-egui/tests/it/legacy_keytips.rs | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/window_ui.rs b/crates/ui-egui/src/window_ui.rs index 93f2c699a..e3f07715b 100644 --- a/crates/ui-egui/src/window_ui.rs +++ b/crates/ui-egui/src/window_ui.rs @@ -112,7 +112,12 @@ impl SheetApp { }; let mut outgoing = WindowUi::fresh(&self.grid, &self.ui.ribbon_tab); self.swap_window_ui(&mut outgoing); - if self.session.window(previous).is_some() { + if let Some(window) = self.session.window(previous) { + // A hidden window isn't drawn: a key tip request waiting for its next frame would + // fire whenever it is shown again, so it goes. + if window.hidden { + outgoing.keytip_invoke_next = None; + } self.parked.insert(previous, outgoing); } if let Some(mut incoming) = active.and_then(|w| self.parked.remove(&w)) { diff --git a/crates/ui-egui/tests/it/legacy_keytips.rs b/crates/ui-egui/tests/it/legacy_keytips.rs index 57deea0aa..c75a6a087 100644 --- a/crates/ui-egui/tests/it/legacy_keytips.rs +++ b/crates/ui-egui/tests/it/legacy_keytips.rs @@ -69,3 +69,23 @@ fn a_waiting_key_tip_request_stays_with_its_window() { h.state_mut().show_window(second); assert_eq!(h.state().ui.keytip_invoke_next, Some("insert.symbol")); } + +#[test] +fn a_hidden_window_drops_its_waiting_key_tip_request() { + // A hidden window isn't drawn, so its waiting request would fire when it was shown again, + // long after the keys: hiding the window drops it. + let mut s = Session::new(); + s.new_workbook(); + let mut h = crate::common::os_harness(s, OperatingSystem::Nix); + h.run_steps(2); + let first = h.state().session.active_window().unwrap(); + h.state_mut().run("view.newWindow", json!({})).unwrap(); + let second = h.state().session.active_window().unwrap(); + h.state_mut().show_window(second); + h.state_mut().ui.keytip_invoke_next = Some("insert.symbol"); + h.state_mut().run("view.hideWindow", json!({"window": second})).unwrap(); + h.state_mut().show_window(first); + h.state_mut().run("view.unhideWindow", json!({"window": second})).unwrap(); + h.state_mut().show_window(second); + assert_eq!(h.state().ui.keytip_invoke_next, None); +} From 2bdec6c0a11e9d9ef8cfe91ed6618aafc2be9486 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 04:44:40 -0600 Subject: [PATCH 6/6] #941: a window hidden while parked drops its key tip request; parity row names the badge-less keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses CodeRabbit review on #3250: - "Clear parked keytip requests when their window becomes hidden": a window hidden from another window (View › Hide while it isn't in front) kept the request it had parked, and showing it again opened the control. follow_active_window now drops the waiting request of every parked window that is hidden. - "Correct the no-badge description in the parity table": only Alt+I, D, V and T (no ribbon tab) paint no badges. Test: legacy_keytips::a_window_hidden_from_another_window_drops_its_parked_request. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/window_ui.rs | 8 ++++++++ crates/ui-egui/tests/it/legacy_keytips.rs | 21 +++++++++++++++++++++ docs/ui-parity.md | 2 +- 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/window_ui.rs b/crates/ui-egui/src/window_ui.rs index e3f07715b..d68b7e3d8 100644 --- a/crates/ui-egui/src/window_ui.rs +++ b/crates/ui-egui/src/window_ui.rs @@ -102,6 +102,14 @@ impl SheetApp { /// in (a window never seen before starts fresh). A closed window's state is dropped. pub fn follow_active_window(&mut self) { let active = self.session.active_window(); + // A window hidden while parked (from another window's View › Hide) isn't drawn either: + // its waiting key tip request goes, as it does for a window hidden as it is left. + let session = &self.session; + for (id, parked) in &mut self.parked { + if session.window(*id).is_some_and(|w| w.hidden) { + parked.keytip_invoke_next = None; + } + } if active == self.ui_window { return; } diff --git a/crates/ui-egui/tests/it/legacy_keytips.rs b/crates/ui-egui/tests/it/legacy_keytips.rs index c75a6a087..885c70f8f 100644 --- a/crates/ui-egui/tests/it/legacy_keytips.rs +++ b/crates/ui-egui/tests/it/legacy_keytips.rs @@ -89,3 +89,24 @@ fn a_hidden_window_drops_its_waiting_key_tip_request() { h.state_mut().show_window(second); assert_eq!(h.state().ui.keytip_invoke_next, None); } + +#[test] +fn a_window_hidden_from_another_window_drops_its_parked_request() { + // Hidden while another window is in front (its request already parked), the window still + // loses the request, so showing it again doesn't open the control. + let mut s = Session::new(); + s.new_workbook(); + let mut h = crate::common::os_harness(s, OperatingSystem::Nix); + h.run_steps(2); + let first = h.state().session.active_window().unwrap(); + h.state_mut().run("view.newWindow", json!({})).unwrap(); + let second = h.state().session.active_window().unwrap(); + h.state_mut().show_window(second); + h.state_mut().ui.keytip_invoke_next = Some("insert.symbol"); + h.state_mut().enter_window(first); + h.state_mut().run("view.hideWindow", json!({"window": second})).unwrap(); + h.state_mut().show_window(first); + h.state_mut().run("view.unhideWindow", json!({"window": second})).unwrap(); + h.state_mut().show_window(second); + assert_eq!(h.state().ui.keytip_invoke_next, None); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 3d51e0e65..e9d98725c 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -21,7 +21,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Formulas › Function Library | Insert Function, AutoSum, Recently Used and a menu per function category | yes (10-24): a big Insert Function button and three columns of labelled small buttons, the layout Excel uses when the window is not wide enough for a big button per category; each category's book icon has its own colour; a category menu shows each function's description on hover and ends with Insert Function…, which opens the dialog on that category; More Functions cascades into Statistical, Engineering, Cube, Information, Compatibility and Web; AutoSum is a split button whose menu (Sum, Average, Count Numbers, Max, Min, More Functions…) Home › Editing shares; Recently Used has the MR key tip | the wide layout with a big button per category is not offered; a function counts as recently used once its formula is entered, not when Insert Function's OK is pressed | | 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 | -| Key tips (Alt / F10 then letters) | yes, every ribbon control | **initial** (#43, 10-10): Alt or F10 shows key tips on Home for alignment, Paste Special and AutoFit plus the Excel 2003 menu keys (Alt+I, E, O, D, V, T and Alt+F, U, such as Alt+I, R or Alt+D, S), which paint no badges, as in Excel (#941); other tabs and controls not yet (#33, #49) | 3–5 h | +| Key tips (Alt / F10 then letters) | yes, every ribbon control | **initial** (#43, 10-10): Alt or F10 shows key tips on Home for alignment, Paste Special and AutoFit plus the Excel 2003 menu keys (Alt+I, E, O, D, V, T and Alt+F, U, such as Alt+I, R or Alt+D, S); Alt+I, D, V and T, which have no ribbon tab, paint no badges, as in Excel (#941); other tabs and controls not yet (#33, #49) | 3–5 h | | Formula bar with Name Box, expand, function hints | yes | yes; grows with its content from one to five lines (Alt+Enter, wrapped text, or viewing a multi-line cell), then scrolls with the caret in view, the Name Box and buttons staying on the first line; the chevron pins it at five; the in-cell editor shows every line | done; no drag-to-resize handle | | Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | Ctrl-drag (Cmd or Option on macOS) drops a copy (#971); active tab revealed on navigation and resize (#681); manual wheel/trackpad browsing preserved; rename focus bug on macOS (#145) | | Formula bar with Name Box, expand, function hints | yes | yes | done; dynamic-array spill children show the source formula muted and read-only, using current spill ownership |