diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 9d190083e..94b6b01e7 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -107,7 +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. + // anything that never reached a control (collapsed ribbon, tab switch) is dropped. One + // 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(); @@ -194,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/app/keys.rs b/crates/ui-egui/src/app/keys.rs index 3490bd01c..1facc5a4f 100644 --- a/crates/ui-egui/src/app/keys.rs +++ b/crates/ui-egui/src/app/keys.rs @@ -59,7 +59,22 @@ 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) => { + // 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 { + self.ui.keytip_invoke = Some(id); + } + } } } } 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..6c397f26c 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::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). + ("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..174d519eb 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": "control", + "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/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/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/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/src/window_ui.rs b/crates/ui-egui/src/window_ui.rs index cef736ecc..d68b7e3d8 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. @@ -98,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; } @@ -108,7 +120,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/keytips_generated.rs b/crates/ui-egui/tests/it/keytips_generated.rs index 3dc225a76..600a36319 100644 --- a/crates/ui-egui/tests/it/keytips_generated.rs +++ b/crates/ui-egui/tests/it/keytips_generated.rs @@ -2754,6 +2754,308 @@ 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().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] +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 +3068,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/crates/ui-egui/tests/it/legacy_keytips.rs b/crates/ui-egui/tests/it/legacy_keytips.rs new file mode 100644 index 000000000..885c70f8f --- /dev/null +++ b/crates/ui-egui/tests/it/legacy_keytips.rs @@ -0,0 +1,112 @@ +//! 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"); +} + +#[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")); +} + +#[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")); +} + +#[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); +} + +#[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/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 4608f843c..4f0627ba3 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -64,6 +64,7 @@ mod interaction; mod interface_scale; mod keyboard_shortcuts; mod keytips_generated; +mod legacy_keytips; mod macro_editor; mod macros_ui; mod menu_hover; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5a8a20783..85f63d42d 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); 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 | @@ -238,6 +238,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-03 | minor | Paste Special: every option has Excel's access key (A, F, V, T, C, N, X, W, R, U; O, D, S, M, I; B, E; L pastes the link at once), not only F, V, T, C and E (#1482) | | 2026-11-03 | minor | Draw tab: the stroke drawn under the pointer while dragging a pen is the ink that will be committed, in the pen's colour and thickness (translucent for a highlighter), instead of always blue at 2 pt (#1786) | | 2026-11-03 | minor | Copy puts a cell that holds a quote on the clipboard as it is (`say "hi"`), quoting only text with a tab or line break, as Excel does; pasting text reads a field as quoted only when its closing quote ends it, so `"hi" there` pastes with its quotes (#2225) | 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,