This commit is contained in:
Brandon Thomas committed 2026-11-03 09:55:37 -05:00
commit 56cc52eb39
14 files changed
+848 -8

No files matched your search

+6 -1
View File
@@ -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<u64> = 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
+16 -1
View File
@@ -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);
}
}
}
}
}
@@ -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"]);
@@ -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)
];
+301
View File
@@ -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",
+5
View File
@@ -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,
}
+9 -1
View File
@@ -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);
+3 -1
View File
@@ -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 [
+18 -1
View File
@@ -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)) {
+303 -1
View File
@@ -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);
}
+112
View File
@@ -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);
}
+1
View File
@@ -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;
+2 -1
View File
@@ -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) |
+5
View File
@@ -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,