Merge pull request #412 from jsymes21-byte/keytips-table-refactor

Keytips: full Excel sequential Alt table — 364 sequences, badges on every control, generated tests (fixes #33, #49)

# Conflicts:
#	contributors/people.toml
This commit is contained in:
Brandon Thomas 2026-10-11 18:06:06 -04:00
commit 3b19cb80e6
15 changed files with 7311 additions and 218 deletions

View File

@ -57,3 +57,7 @@ sync_github_name = true
[people.shrshk]
real_name = "Shirish Veerabattini"
sync_github_name = true
[people.jsymes21-byte]
real_name = "James Symes"
sync_github_name = true

View File

@ -149,7 +149,42 @@ pub fn command_specs() -> &'static [CommandSpec] {
}
pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
command_specs().iter().find(|c| c.id == id)
if let Some(spec) = command_specs().iter().find(|c| c.id == id) {
return Some(spec);
}
// Commands registered by UI integration tests (pending keytips go live
// the moment their command lands, with no table edit).
test_commands().iter().find(|c| c.id == id).copied()
}
static TEST_COMMANDS: std::sync::Mutex<Vec<&'static CommandSpec>> = std::sync::Mutex::new(Vec::new());
/// Test-only: register an extra command so UI integration tests can prove a pending
/// keytip becomes live the moment its command lands (see crates/ui-egui/tests/keytips.rs).
/// Not part of the public API; harmless no-op for production callers who never call it.
#[doc(hidden)]
pub fn register_test_command(id: &'static str, run: Run) {
// Leaked intentionally: test commands live for the whole process.
let spec: &'static CommandSpec = Box::leak(Box::new(CommandSpec {
id,
label: id,
menu: &[],
shortcut: None,
params: "",
enabled: |_| Ok(()),
run,
journal: true,
undoable: false,
}));
test_commands().push(spec);
}
fn test_commands() -> std::sync::MutexGuard<'static, Vec<&'static CommandSpec>> {
#[allow(clippy::unwrap_used)] // a poisoned test registry has no meaningful recovery
match TEST_COMMANDS.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
// ---------------------------------------------------------------- param helpers

View File

@ -29,7 +29,7 @@ use gridcraft_model::Workbook;
use serde::Serialize;
use serde_json::Value as Json;
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command, register_test_command};
pub use gridcraft_calc as calc;
pub use gridcraft_core as core;
pub use gridcraft_formula as formula;

View File

@ -1,12 +1,23 @@
//! Sequential ribbon shortcuts. The host paints hints from `prefix` and runs returned actions.
//!
//! The sequence table lives in [`keytip_table`]: the match below only walks the prefix.
use egui::{Context, Event, Key, Modifiers, os::OperatingSystem};
pub mod keytip_table;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Action {
Home,
Tab(&'static str),
Command(&'static str),
Dialog(&'static str),
/// The target command does not exist yet: the sequence is reserved, its badge shows, and
/// invoking it is a silent no-op until the command lands (see keytips.json `pending`).
Pending(&'static str),
/// Route the keytip to the matching ribbon control: open its dropdown or run its click
/// handler (which carries the right params), instead of firing the engine command with
/// empty params.
Control(&'static str),
}
#[derive(Default)]
@ -85,28 +96,33 @@ impl KeyTips {
} else if self.prefix.is_some() && !matches!(key, Key::ShiftLeft | Key::ShiftRight) {
handled = true;
if *key == Key::Escape {
if let Some(prefix) = self.prefix.as_mut()
&& prefix.pop().is_none()
{
self.prefix = None;
// Escape pops one level; at a single-letter level it
// returns to the armed root (empty prefix).
if let Some(prefix) = self.prefix.as_mut() {
if prefix.is_empty() {
self.prefix = None;
} else {
prefix.pop();
}
}
} else if key.name().len() == 1 {
let next = format!("{}{key_name}", self.prefix.as_deref().unwrap_or_default(), key_name = key.name());
match next.as_str() {
"H" => {
self.prefix = Some(next);
actions.push(Action::Home);
match keytip_table::lookup(&next) {
Some(keytip_table::Entry::Tab(tab)) => {
// A tab with deeper sequences stays as an active level
// (its group badges show); a terminal unit like "JI"
// finishes and clears the prefix.
self.prefix = keytip_table::has_deeper(&next).then_some(next);
actions.push(Action::Tab(tab));
}
"HA" | "HV" | "HO" | "E" | "O" | "OC" => self.prefix = Some(next),
"HAR" => self.finish(Action::Command("home.alignRight"), &mut actions),
"HAL" => self.finish(Action::Command("home.alignLeft"), &mut actions),
"HAC" => self.finish(Action::Command("home.alignCenter"), &mut actions),
"HAT" => self.finish(Action::Command("home.alignTop"), &mut actions),
"HAM" => self.finish(Action::Command("home.alignMiddle"), &mut actions),
"HAB" => self.finish(Action::Command("home.alignBottom"), &mut actions),
"HVS" | "ES" => self.finish(Action::Dialog("pasteSpecial"), &mut actions),
"HOI" | "OCA" => self.finish(Action::Command("home.autofitColumnWidth"), &mut actions),
_ => {}
Some(keytip_table::Entry::Continue) => self.prefix = Some(next),
Some(keytip_table::Entry::Finish(action)) => self.finish(action, &mut actions),
// No complete sequence, but deeper ones start here: wait for
// more input (Excel's two-letter units like Draw's "JI").
None if keytip_table::has_deeper(&next) => self.prefix = Some(next),
// Unknown letters are consumed while a prefix is active but
// leave it unchanged, as Excel does.
None => {}
}
}
}

View File

@ -0,0 +1,234 @@
//! The keytip sequence table: every Excel-style Alt access sequence and what it does.
//!
//! The matcher in [`super::KeyTips::process`] and the badge painter in `ribbon.rs` both read
//! [`SEQUENCES`], so a sequence is added in exactly one place: `keytips.json`.
//!
//! `keytips.json` is the human-edited source of truth (grouped, commented, one line per
//! sequence; `pending` reserves sequences for commands that do not exist yet). `generated.rs`
//! is produced from it by `cargo xtask keytips` — edit the JSON, not the generated file.
pub(crate) use super::Action;
mod generated;
pub use generated::SEQUENCES;
/// What a fully-matched sequence does.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Entry {
/// Switch to this ribbon tab and keep the prefix: the tab's own badges take over.
Tab(&'static str),
/// Keep the prefix: this sequence is a group-level badge; deeper sequences finish it.
Continue,
/// Complete the sequence: run the action and clear the prefix.
Finish(Action),
}
/// Look up a complete sequence.
pub fn lookup(sequence: &str) -> Option<Entry> {
SEQUENCES.iter().find(|(seq, _)| *seq == sequence).map(|(_, entry)| *entry)
}
/// True if `prefix` is a strict prefix of any known sequence (badges wait for more keys).
pub fn has_deeper(prefix: &str) -> bool {
SEQUENCES.iter().any(|(seq, _)| seq.len() > prefix.len() && seq.starts_with(prefix))
}
/// The sequence that switches to `tab_name`, if that tab has one (may be a multi-letter unit
/// like Draw's "JI").
pub fn tab_sequence(tab_name: &str) -> Option<&'static str> {
SEQUENCES.iter().find_map(|(seq, entry)| match entry {
Entry::Tab(name) if *name == tab_name => Some(*seq),
_ => None,
})
}
/// True if `seq` is a group level: an explicit Continue entry, or an implicit level that only
/// exists because deeper sequences start with it (like "J" before Draw's "JI").
pub fn is_group_level(seq: &str) -> bool {
matches!(lookup(seq), Some(Entry::Continue)) || (lookup(seq).is_none() && has_deeper(seq))
}
fn matches_target(action: &Action, target: &str) -> bool {
match action {
Action::Command(id) | Action::Pending(id) | Action::Control(id) => *id == target,
Action::Dialog(name) => format!("dialog:{name}") == target,
Action::Tab(_) => false,
}
}
/// All sequences that finish by running `target` (a command id or `dialog:<name>`).
pub fn sequences_for(target: &str) -> Vec<&'static str> {
SEQUENCES
.iter()
.filter_map(|(seq, entry)| match entry {
Entry::Finish(action) if matches_target(action, target) => Some(*seq),
_ => None,
})
.collect()
}
/// Group-level letters for a menu anchor: sequences one key past `prefix` that continue toward
/// any of `targets` (the menu's items) — the badge shown on the button that opens the menu.
pub fn group_extension(prefix: &str, targets: &[&str]) -> Vec<&'static str> {
let mut out = Vec::new();
for target in targets {
for seq in sequences_for(target) {
if seq.len() <= prefix.len() + 1 || !seq.starts_with(prefix) {
continue;
}
// The intermediate one key past the prefix is the group badge, if it is a
// group-level sequence (explicit or implicit) rather than the finish itself.
let candidate = &seq[..prefix.len() + 1];
if is_group_level(candidate) && !out.contains(&candidate) {
out.push(candidate);
}
}
}
out
}
/// The badge for a menu item: its finish sequence exactly one key below the current prefix.
pub fn item_letter(prefix: &str, target: &str) -> Option<&'static str> {
sequences_for(target).into_iter().find(|seq| seq.len() == prefix.len() + 1 && seq.starts_with(prefix))
}
/// Badge sequences for a command control: every sequence for `target` that continues from the
/// current prefix (the painter strips the prefix, so the remaining letters show).
pub fn control_badges(prefix: &str, target: &str) -> Vec<&'static str> {
if prefix.is_empty() {
return Vec::new(); // empty prefix shows only tab badges
}
sequences_for(target).into_iter().filter(|seq| seq.len() > prefix.len() && seq.starts_with(prefix)).collect()
}
/// The badge for a single control: the rest of its finish sequence when it has exactly one, or
/// the common group letter when several sequences land on the same control (menu anchors like
/// Borders, whose HBA..HBW variants all share "HB").
pub fn control_badge(prefix: &str, target: &str) -> Option<&'static str> {
if prefix.is_empty() {
return None; // empty prefix shows only tab badges
}
let seqs: Vec<&'static str> = sequences_for(target).into_iter().filter(|seq| seq.len() > prefix.len() && seq.starts_with(prefix)).collect();
match seqs.as_slice() {
[] => None,
[single] => Some(single),
many => {
let common = |a: &str, b: &str| a.chars().zip(b.chars()).take_while(|(x, y)| x == y).count();
let lcp = many.iter().skip(1).fold(many[0].len(), |acc, s| acc.min(common(many[0], s)));
(lcp > prefix.len()).then_some(&many[0][..lcp])
}
}
}
/// The single badge for a menu anchor: the direct finish one key past the prefix, or the
/// group-level candidate that prefixes the most of `targets`' sequences. Anchors get one
/// badge, so overlapping groups (e.g. Pictures' "NP" vs SmartArt's "NM") resolve to the
/// dominant one.
pub fn anchor_badge(prefix: &str, targets: &[&str]) -> Option<&'static str> {
if prefix.is_empty() {
return None; // empty prefix shows only tab badges
}
let all: Vec<&'static str> = targets.iter().flat_map(|t| sequences_for(t)).filter(|s| s.len() > prefix.len() && s.starts_with(prefix)).collect();
if all.is_empty() {
return None;
}
let depth = prefix.len() + 1;
let mut candidates: Vec<&'static str> = all.iter().filter(|s| s.len() == depth).copied().collect();
if candidates.is_empty() {
for s in &all {
let c = &s[..depth.min(s.len())];
if is_group_level(c) && !candidates.contains(&c) {
candidates.push(c);
}
}
}
let mut best: Option<(&'static str, usize)> = None;
for c in candidates {
let score = all.iter().filter(|s| s.starts_with(c)).count();
if best.is_none_or(|(_, b)| score > b) {
best = Some((c, score));
}
}
best.map(|(c, _)| c)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tab_letters_exist_for_standard_tabs() {
for (tab, letter) in [
("Home", "H"),
("Insert", "N"),
("Page Layout", "P"),
("Formulas", "M"),
("Data", "A"),
("Review", "R"),
("View", "W"),
("Draw", "JI"), // two-letter unit, shown as one badge
] {
assert_eq!(tab_sequence(tab), Some(letter), "tab {tab}");
}
assert_eq!(tab_sequence("Automate"), None);
}
#[test]
fn sequences_for_finds_command_and_dialog_targets() {
assert_eq!(sequences_for("home.alignRight"), vec!["HAR"]);
assert_eq!(sequences_for("dialog:pasteSpecial"), vec!["HVS", "ES"]);
assert_eq!(sequences_for("home.autofitColumnWidth"), vec!["HOI", "OCA"]);
}
#[test]
fn implicit_levels_wait_for_deeper_sequences() {
assert!(has_deeper("J"), "JI is reachable only through the implicit J level");
assert!(is_group_level("J"), "implicit levels are group levels");
assert!(is_group_level("HV"), "explicit Continue levels are group levels");
assert!(!is_group_level("H"), "a terminal tab is not a group level");
assert!(!is_group_level("HVS"), "a finish is not a group level");
assert!(!has_deeper("X"));
}
#[test]
fn group_extension_paints_only_continue_levels() {
assert_eq!(group_extension("H", &["dialog:pasteSpecial"]), vec!["HV"]);
assert!(group_extension("", &["dialog:pasteSpecial"]).is_empty(), "tab letters paint on tabs, not buttons");
assert!(group_extension("E", &["dialog:pasteSpecial"]).is_empty(), "the finish letter paints on the menu item");
}
#[test]
fn item_letter_is_one_level_deep() {
assert_eq!(item_letter("HV", "dialog:pasteSpecial"), Some("HVS"));
assert_eq!(item_letter("HV", "edit.pasteSpecial"), Some("HVP"));
assert_eq!(item_letter("H", "dialog:pasteSpecial"), None, "the item badge waits for its group letter");
assert_eq!(item_letter("HO", "home.autofitColumnWidth"), Some("HOI"));
}
#[test]
fn control_badges_require_a_known_prefix() {
assert!(control_badges("", "home.alignRight").is_empty(), "empty prefix shows only tab badges");
assert_eq!(control_badges("HA", "home.alignRight"), vec!["HAR"]);
assert!(control_badges("HZ", "home.alignRight").is_empty(), "unknown prefixes show nothing");
}
#[test]
fn control_badge_shows_the_rest_or_the_group_letter() {
assert_eq!(control_badge("H", "home.bold"), Some("H1"), "a unique sequence paints its full rest");
assert_eq!(control_badge("H", "home.fontColor"), Some("HFC"));
assert_eq!(control_badge("H", "home.borders"), Some("HB"), "many variants collapse to the group letter");
assert!(control_badge("", "home.bold").is_none(), "empty prefix shows only tab badges");
assert!(control_badge("HZ", "home.bold").is_none(), "unknown prefixes show nothing");
assert!(control_badge("H", "view.zoom").is_none(), "diverging sequences at the next letter show nothing");
}
#[test]
fn anchor_badge_resolves_to_the_dominant_group() {
assert_eq!(anchor_badge("H", &["edit.paste", "edit.pasteSpecial", "dialog:pasteSpecial"]), Some("HV"));
assert_eq!(anchor_badge("P", &["pageLayout.orientation"]), Some("PO"), "a direct finish paints itself");
assert_eq!(anchor_badge("N", &["insert.picture"]), Some("NP"), "NP dominates the lone NM (SmartArt) entry");
assert_eq!(anchor_badge("H", &["table.style"]), Some("HT"));
assert!(anchor_badge("", &["edit.paste"]).is_none(), "empty prefix shows only tab badges");
assert!(anchor_badge("H", &["view.zoom"]).is_none(), "no sequences under this prefix");
}
}

View File

@ -0,0 +1,373 @@
//! GENERATED from keytips.json by `cargo xtask keytips`. Edit the JSON, not this file.
use super::{Action, Entry};
/// All known keytip sequences. Intermediate levels are implicit: the matcher waits
/// whenever a pressed letter starts a deeper sequence, so only terminal entries are
/// listed here. See keytip_table.rs for the helpers that read this table.
pub const SEQUENCES: &[(&str, Entry)] = &[
("H", Entry::Tab("Home")),
("N", Entry::Tab("Insert")),
("JI", Entry::Tab("Draw")), // Two-letter unit. Deeper Draw letters differ between Excel and GridCraft; left blank for future contributors.
("P", Entry::Tab("Page Layout")),
("M", Entry::Tab("Formulas")),
("A", Entry::Tab("Data")),
("R", Entry::Tab("Review")),
("W", Entry::Tab("View")),
("E", Entry::Tab("Home")), // Legacy Edit-menu path (Excel 2003); leads to the same Home menus.
("O", Entry::Tab("Home")), // Legacy Format-menu path (Excel 2003); leads to the same Home menus.
("HAR", Entry::Finish(Action::Command("home.alignRight"))),
("HAT", Entry::Finish(Action::Command("home.alignTop"))), // Excel: Excel: Top Align
("HAM", Entry::Finish(Action::Command("home.alignMiddle"))), // Excel: Excel: Middle Align
("HAB", Entry::Finish(Action::Command("home.alignBottom"))), // Excel: Excel: Bottom Align
("HAL", Entry::Finish(Action::Command("home.alignLeft"))),
("HAC", Entry::Finish(Action::Command("home.alignCenter"))),
("HVS", Entry::Finish(Action::Dialog("pasteSpecial"))),
("ES", Entry::Finish(Action::Dialog("pasteSpecial"))), // Excel: Classic Paste Special dialog (legacy Edit route); A/F/T/V are dialog mnemonics
("HOI", Entry::Finish(Action::Command("home.autofitColumnWidth"))),
("OCA", Entry::Finish(Action::Command("home.autofitColumnWidth"))), // Legacy Format menu.
("WVG", Entry::Finish(Action::Command("view.gridlines"))),
("H0", Entry::Finish(Action::Command("home.increaseDecimal"))), // Excel: Increase Decimals; containment(Increase Decimal)
("H1", Entry::Finish(Action::Command("home.bold"))), // Excel: Bold
("H2", Entry::Finish(Action::Command("home.italic"))), // Excel: Italic
("H3", Entry::Finish(Action::Command("home.underline"))), // Excel: Excel: Underline; fires immediately (not a menu)
("H5", Entry::Finish(Action::Command("home.decreaseIndent"))), // Excel: Decrease Indent
("H6", Entry::Finish(Action::Command("home.increaseIndent"))), // Excel: Increase Indent
("H9", Entry::Finish(Action::Command("home.decreaseDecimal"))), // Excel: Decrease Decimals; containment(Decrease Decimal)
("HAN", Entry::Finish(Action::Command("home.accounting"))), // Excel: Accounting Format; fires immediately in Excel; deeper aliases dropped as unreachable
("HAS", Entry::Finish(Action::Command("home.addIns"))), // Excel: Add-ins; containment(Add-ins)
("HB", Entry::Continue), // Excel: Borders Menu; group level:
("HBA", Entry::Finish(Action::Command("home.borders"))), // Excel: All Borders; containment(Borders)
("HBB", Entry::Finish(Action::Command("home.borders"))), // Excel: Bottom Double Border; border-side TBD
("HBC", Entry::Finish(Action::Command("home.borders"))), // Excel: Top and Thick Bottom Border; border-side TBD
("HBD", Entry::Finish(Action::Command("home.borders"))), // Excel: Top and Bottom Border; border-side TBD
("HBE", Entry::Finish(Action::Command("home.borders"))), // Excel: Erase Border; border-side TBD
("HBG", Entry::Finish(Action::Command("home.drawBorder"))), // Excel: Draw Border Grid; containment(Draw Border)
("HBH", Entry::Finish(Action::Command("home.borders"))), // Excel: Thick Bottom Border; border-side TBD
("HBI", Entry::Finish(Action::Command("home.borders"))), // Excel: Line Color; Line Color (submenu TBD)
("HBL", Entry::Finish(Action::Command("home.borders"))), // Excel: Left Border; border-side TBD
("HBM", Entry::Finish(Action::Command("home.borders"))), // Excel: More Borders; containment(Borders)
("HBN", Entry::Finish(Action::Command("home.borders"))), // Excel: No Border; border-side TBD
("HBO", Entry::Finish(Action::Command("home.borders"))), // Excel: Bottom Border; border-side TBD
("HBP", Entry::Finish(Action::Command("home.borders"))), // Excel: Top Border; border-side TBD
("HBR", Entry::Finish(Action::Command("home.borders"))), // Excel: Right Border; border-side TBD
("HBS", Entry::Finish(Action::Command("home.borders"))), // Excel: Outside Borders; containment(Borders)
("HBT", Entry::Finish(Action::Command("home.borders"))), // Excel: Thick Outside Borders; border-side TBD
("HBU", Entry::Finish(Action::Command("home.borders"))), // Excel: Top and Double Bottom Border; border-side TBD
("HBW", Entry::Finish(Action::Command("home.drawBorder"))), // Excel: Draw Border
("HBY", Entry::Finish(Action::Command("home.borders"))), // Excel: Line Style; Line Style (submenu TBD)
("HC", Entry::Finish(Action::Command("edit.copy"))), // Excel: Copy; fires immediately in Excel; deeper aliases dropped as unreachable
("HDC", Entry::Finish(Action::Command("home.deleteColumns"))), // Excel: Delete Columns; Delete Columns
("HDD", Entry::Finish(Action::Dialog("deleteCells"))), // Excel: Delete Cells
("HDR", Entry::Finish(Action::Command("home.deleteRows"))), // Excel: Delete Rows; Delete Rows
("HDS", Entry::Finish(Action::Command("home.deleteSheet"))), // Excel: Delete Sheet
("HEA", Entry::Finish(Action::Command("edit.clearAll"))), // Excel: Clear All
("HEC", Entry::Finish(Action::Command("edit.clearContents"))), // Excel: Clear Contents
("HEF", Entry::Finish(Action::Command("edit.clearFormats"))), // Excel: Clear Formats
("HEL", Entry::Finish(Action::Command("edit.clearHyperlinks"))), // Excel: Clear Hyperlinks
("HEM", Entry::Finish(Action::Command("edit.clearComments"))), // Excel: Clear Comments and Notes; containment(Clear Comments and Notes)
("HFC", Entry::Finish(Action::Control("home.fontColor"))), // Excel: Font Color; opens the font-color palette
("HFDC", Entry::Finish(Action::Command("home.conditionalFormat"))), // Excel: Find Conditional Formatting; containment(Conditional Formatting)
("HFDF", Entry::Finish(Action::Dialog("find"))), // Excel: Find
("HFDG", Entry::Finish(Action::Dialog("goTo"))), // Excel: Go To
("HFDM", Entry::Finish(Action::Command("edit.find"))), // Excel: Find Notes; containment(Find)
("HFDO", Entry::Finish(Action::Pending("home.selectObjects"))), // pending; Excel: Select Objects; Select Objects; reserved until the command lands
("HFDP", Entry::Finish(Action::Command("arrange.selectionPane"))), // Excel: Selection Pane
("HFDR", Entry::Finish(Action::Dialog("find"))), // Excel: Replace; opens Find and Replace
("HFDS", Entry::Finish(Action::Dialog("goToSpecial"))), // Excel: Go To Special
("HFF", Entry::Finish(Action::Control("home.fontName"))), // Excel: Font Selection; opens the font dropdown
("HFG", Entry::Finish(Action::Command("home.increaseFontSize"))), // Excel: Increase Font Size
("HFI", Entry::Continue), // Excel: Fill; group level: containment(Fill Up)
("HFIA", Entry::Finish(Action::Pending("home.fillAcrossWorksheets"))), // pending; Excel: Fill Across Worksheets; Fill Across Worksheets; reserved until the command lands
("HFID", Entry::Finish(Action::Command("edit.fillDown"))), // Excel: Fill Down
("HFIF", Entry::Finish(Action::Command("edit.flashFill"))), // Excel: Flash Fill
("HFIJ", Entry::Finish(Action::Command("home.justify"))), // Excel: Fill Justify; containment(Justify)
("HFIL", Entry::Finish(Action::Command("edit.fillLeft"))), // Excel: Fill Left
("HFIR", Entry::Finish(Action::Command("edit.fillRight"))), // Excel: Fill Right
("HFIS", Entry::Finish(Action::Dialog("series"))), // Excel: Fill Series; containment(Series)
("HFIU", Entry::Finish(Action::Command("edit.fillUp"))), // Excel: Fill Up
("HFK", Entry::Finish(Action::Command("home.decreaseFontSize"))), // Excel: Decrease Font Size
("HFM", Entry::Finish(Action::Dialog("formatCells"))), // Excel: More Number Formats; More Number Formats opens Format Cells
("HFP", Entry::Finish(Action::Command("edit.formatPainter"))), // Excel: Format Painter
("HFQ", Entry::Finish(Action::Control("home.orientation"))), // Excel: Orientation Menu; group level: containment(Orientation)
("HFS", Entry::Finish(Action::Control("home.fontSize"))), // Excel: Font Size; opens the font-size dropdown
("HH", Entry::Finish(Action::Control("home.fillColor"))), // Excel: Fill Color; opens the fill-color palette
("HIC", Entry::Finish(Action::Command("home.insertColumns"))), // Excel: Insert Columns; Insert Columns
("HIE", Entry::Finish(Action::Dialog("insertCells"))), // Excel: Insert Copied Cells; Insert Cells (copied cells TBD)
("HII", Entry::Finish(Action::Dialog("insertCells"))), // Excel: Insert Cells
("HIR", Entry::Finish(Action::Command("home.insertRows"))), // Excel: Insert Rows; Insert Rows
("HIS", Entry::Finish(Action::Command("home.insertSheet"))), // Excel: Insert Sheet
("HJ", Entry::Continue), // Excel: Cell Styles; group level:
("HJN", Entry::Finish(Action::Command("home.newCellStyle"))), // Excel: New Cell Style
("HK", Entry::Finish(Action::Command("home.comma"))), // Excel: Comma Style
("HL", Entry::Continue), // Excel: Conditional Formatting; group level: containment(Conditional Formatting)
("HLC", Entry::Continue), // Excel: Clear Rules Menu; group level: containment(Clear Rules)
("HLCE", Entry::Finish(Action::Command("home.clearRules"))), // Excel: Clear Rules From Sheet; containment(Clear Rules)
("HLCS", Entry::Finish(Action::Command("home.clearRules"))), // Excel: Clear Rules From Cells; containment(Clear Rules)
("HLCT", Entry::Finish(Action::Command("home.clearRules"))), // Excel: Clear Rules From Table; containment(Clear Rules)
("HLD", Entry::Finish(Action::Command("home.conditionalFormat"))), // Excel: Data Bars; Data Bars (submenu TBD)
("HLH", Entry::Finish(Action::Command("home.conditionalFormat"))), // Excel: Highlight Cells; Highlight Cells (submenu TBD)
("HLN", Entry::Finish(Action::Command("home.conditionalFormat"))), // Excel: New Rule; New Rule (submenu TBD)
("HLR", Entry::Finish(Action::Command("home.manageRules"))), // Excel: Manage Rules; containment(Manage Rules)
("HLT", Entry::Finish(Action::Command("home.conditionalFormat"))), // Excel: Top/Bottom Rules; Top/Bottom (submenu TBD)
("HM", Entry::Continue), // Excel: Merge & Center Menu; group level: containment(Merge & Center)
("HMA", Entry::Finish(Action::Command("home.mergeAcross"))), // Excel: Merge Across
("HMC", Entry::Finish(Action::Command("home.mergeCenter"))), // Excel: Merge & Center
("HMM", Entry::Finish(Action::Command("home.mergeCells"))), // Excel: Merge Cells
("HMU", Entry::Finish(Action::Command("home.unmergeCells"))), // Excel: Unmerge Cells
("HN", Entry::Finish(Action::Control("home.numberFormat"))), // Excel: Number Format; opens the number-format dropdown
("HOA", Entry::Finish(Action::Command("home.autofitRowHeight"))), // Excel: AutoFit Row Height
("HOD", Entry::Finish(Action::Dialog("defaultWidth"))), // Excel: Default Column Width; containment(Column Width)
("HOE", Entry::Finish(Action::Command("home.formatCells"))), // Excel: Format Cells
("HOH", Entry::Finish(Action::Dialog("rowHeight"))), // Excel: Row Height
("HOL", Entry::Finish(Action::Command("home.lockCell"))), // Excel: Lock Cell; containment(Lock Cell)
("HOM", Entry::Finish(Action::Dialog("moveSheet"))), // Excel: Move or Copy Sheet
("HOP", Entry::Finish(Action::Dialog("protectSheet"))), // Excel: Protect Sheet
("HOR", Entry::Finish(Action::Dialog("renameSheet"))), // Excel: Rename Sheet
("HOT", Entry::Finish(Action::Command("sheet.tabColor"))), // Excel: Tab Color
("HOUC", Entry::Finish(Action::Command("home.hideColumns"))), // Excel: Hide Columns
("HOUH", Entry::Finish(Action::Command("sheet.unhide"))), // Excel: Unhide Sheet
("HOUL", Entry::Finish(Action::Command("home.unhideColumns"))), // Excel: Unhide Columns
("HOUO", Entry::Finish(Action::Command("home.unhideRows"))), // Excel: Unhide Rows
("HOUR", Entry::Finish(Action::Command("home.hideRows"))), // Excel: Hide Rows
("HOUS", Entry::Finish(Action::Command("sheet.hide"))), // Excel: Hide Sheet
("HOW", Entry::Finish(Action::Dialog("columnWidth"))), // Excel: Column Width
("HP", Entry::Finish(Action::Command("home.percent"))), // Excel: Excel: Percentage Style
("HSC", Entry::Finish(Action::Command("data.clearFilter"))), // Excel: Clear Filter
("HSF", Entry::Finish(Action::Command("data.filter"))), // Excel: Filter
("HSO", Entry::Finish(Action::Command("data.sortDescending"))), // Excel: Sort Z to A
("HSS", Entry::Finish(Action::Command("data.sortAscending"))), // Excel: Sort A to Z
("HSU", Entry::Finish(Action::Command("data.sort"))), // Excel: Custom Sort
("HT", Entry::Continue), // Excel: Format as Table; group level:
("HTN", Entry::Finish(Action::Command("table.style"))), // Excel: New Table Style
("HTP", Entry::Finish(Action::Command("table.style"))), // Excel: New Pivot Table Style
("HU", Entry::Continue), // Excel: AutoSum; group level:
("HUA", Entry::Finish(Action::Command("formulas.autoSum"))), // Excel: Auto Average; AutoSum: Average (variant TBD)
("HUC", Entry::Finish(Action::Command("formulas.autoSum"))), // Excel: Auto Count Numbers; AutoSum: Count (variant TBD)
("HUI", Entry::Finish(Action::Command("formulas.autoSum"))), // Excel: Auto Min; AutoSum: Min (variant TBD)
("HUM", Entry::Finish(Action::Command("formulas.autoSum"))), // Excel: Auto Max; AutoSum: Max (variant TBD)
("HV", Entry::Continue), // Excel: Paste; group level:
("HVP", Entry::Finish(Action::Command("edit.pasteSpecial"))), // Excel: Paste
("HW", Entry::Continue), // Excel: Wrap Text Menu; group level: containment(Wrap Text)
("HWW", Entry::Finish(Action::Command("home.wrapText"))), // Excel: Wrap Text
("HX", Entry::Finish(Action::Command("edit.cut"))), // Excel: Cut
("NC1", Entry::Finish(Action::Control("insert.chart.column"))), // Excel: Column Chart; Column chart (kind TBD)
("NC2", Entry::Finish(Action::Command("review.newComment"))), // Excel: New Comment
("NCM", Entry::Finish(Action::Dialog("comment"))), // Excel: Excel: New Comment (modern)
("NCB", Entry::Finish(Action::Command("insert.checkbox"))), // Excel: Checkbox
("ND", Entry::Finish(Action::Control("insert.chart.scatter"))), // Excel: Scatter Chart; Scatter (kind TBD)
("NE", Entry::Finish(Action::Pending("insert.equation"))), // pending; Excel: Equations; Equations; reserved until the command lands
("NF", Entry::Continue), // Excel: Online Pictures; group level: containment(Picture)
("NFP", Entry::Continue), // Excel: From Picture Menu; group level: containment(Picture)
("NFPF", Entry::Finish(Action::Command("insert.picture"))), // Excel: Picture From File; containment(Picture)
("NH1", Entry::Finish(Action::Dialog("headerFooter"))), // Excel: Header & Footer
("NHF", Entry::Finish(Action::Command("pageLayout.headerFooter"))), // Excel: Header & Footer
("NHI", Entry::Finish(Action::Control("insert.chart.hierarchy"))), // Excel: Hierarchy Chart; Hierarchy (kind TBD)
("NI1", Entry::Finish(Action::Command("insert.chart"))), // Excel: Waterfall Chart; Waterfall (kind TBD)
("NI2", Entry::Finish(Action::Dialog("insertLink"))), // Excel: Link
("NJ", Entry::Finish(Action::Pending("insert.object"))), // pending; Excel: Object; insert.object missing; reserved until the command lands
("NM1T", Entry::Finish(Action::Command("insert.picture"))), // Excel: SmartArt Picture; containment(Picture)
("NM2", Entry::Finish(Action::Command("insert.chart"))), // Excel: Map Chart; Map (kind TBD)
("NN1", Entry::Finish(Action::Control("insert.chart.line"))), // Excel: Line Chart; Line chart (kind TBD)
("NNS", Entry::Finish(Action::Command("insert.icons"))), // Excel: Icons
("NP", Entry::Continue), // Excel: Picture; group level:
("NPB", Entry::Finish(Action::Command("insert.picture"))), // Excel: Picture from File; containment(Picture)
("NPF", Entry::Finish(Action::Command("insert.picture"))), // Excel: Place in Cell; Picture in cell (source TBD)
("NPI", Entry::Finish(Action::Command("insert.picture"))), // Excel: Place in Cell Menu; Picture in cell (source TBD)
("NPO", Entry::Finish(Action::Command("insert.picture"))), // Excel: Place over Cells Menu; Picture over cells (source TBD)
("NQ", Entry::Finish(Action::Control("insert.chart.pie"))), // Excel: Pie Chart; Pie (kind TBD)
("NR", Entry::Finish(Action::Command("insert.recommendedCharts"))), // Excel: Recommended Charts
("NSA", Entry::Finish(Action::Control("insert.chart.statistic"))), // Excel: Statistical Chart; Statistical chart (kind TBD)
("NSC", Entry::Finish(Action::Pending("insert.screenshot"))), // pending; Excel: Screenshot Menu; Screenshot; reserved until the command lands
("NSD", Entry::Finish(Action::Command("insert.chart"))), // Excel: Combo Chart; Combo (kind TBD)
("NSF", Entry::Finish(Action::Command("insert.slicer"))), // Excel: Slicer; insert.slicer missing (gap #8); reserved until the command lands
("NSH", Entry::Finish(Action::Command("insert.shape"))), // Excel: Shapes
("NSP", Entry::Finish(Action::Command("insert.recommendedPivotTables"))), // Excel: Recommended PivotTables
("NST", Entry::Finish(Action::Pending("insert.timeline"))), // pending; Excel: Timeline; insert.timeline missing (gap #8); reserved until the command lands
("NSZ", Entry::Finish(Action::Command("insert.pivotChart"))), // Excel: PivotChart; PivotChart; reserved until the command lands
("NT", Entry::Finish(Action::Command("insert.table"))), // Excel: Table
("NU", Entry::Finish(Action::Command("insert.symbol"))), // Excel: Symbols; containment(Symbol)
("NZS", Entry::Finish(Action::Command("insert.symbol"))), // Excel: Excel: Symbol (modern)
("NV", Entry::Finish(Action::Command("insert.pivotTable"))), // Excel: PivotTable
("NW", Entry::Finish(Action::Pending("insert.wordArt"))), // pending; Excel: WordArt; WordArt; reserved until the command lands
("NX", Entry::Finish(Action::Command("insert.textBox"))), // Excel: Text Box
("NZK", Entry::Continue), // Excel: Sparklines Menu; group level:
("NZKSL", Entry::Finish(Action::Dialog("sparkline"))), // Excel: Line Sparkline
("NZKSO", Entry::Finish(Action::Dialog("sparkline"))), // Excel: Column Sparkline
("NZKSW", Entry::Finish(Action::Dialog("sparkline"))), // Excel: Win/Loss Sparkline
("NZTX", Entry::Finish(Action::Command("insert.textBox"))), // Excel: Text Box
("NZT", Entry::Finish(Action::Command("insert.textBox"))), // Excel: Excel: Text Box (modern)
("PAE", Entry::Finish(Action::Command("arrange.sendBackward"))), // Excel: Send Backward
("PAF", Entry::Finish(Action::Command("arrange.bringForward"))), // Excel: Bring Forward
("PB", Entry::Continue), // Excel: Breaks; group level:
("PBI", Entry::Finish(Action::Command("pageLayout.breaks"))), // Excel: Insert Page Break; Insert Page Break
("PBR", Entry::Finish(Action::Command("pageLayout.breaks"))), // Excel: Remove Page Break; Remove Page Break
("PGB", Entry::Finish(Action::Command("pageLayout.background"))), // Excel: Background; Excel badge GB (Background in the Page Setup group)
("PI", Entry::Finish(Action::Command("pageLayout.printTitles"))), // Excel: Print Titles
("PM", Entry::Continue), // Excel: Margins; group level:
("PMA", Entry::Finish(Action::Command("pageLayout.margins"))), // Excel: Custom Margins; containment(Margins)
("PO", Entry::Finish(Action::Command("pageLayout.orientation"))), // Excel: Orientation
("PPG", Entry::Finish(Action::Command("pageLayout.printGridlines"))), // Excel: Print Gridlines
("PPH", Entry::Finish(Action::Command("pageLayout.printHeadings"))), // Excel: Print Headings
("PR", Entry::Continue), // Excel: Print Area; group level:
("PRS", Entry::Finish(Action::Command("pageLayout.printArea"))), // Excel: Set Print Area
("PSP", Entry::Finish(Action::Dialog("pageSetup"))), // Excel: Page Setup
("PSZ", Entry::Finish(Action::Command("pageLayout.size"))), // Excel: Size
("PTC", Entry::Finish(Action::Command("pageLayout.themeColors"))), // Excel: Theme Colors; containment(Colors)
("PTE", Entry::Finish(Action::Command("pageLayout.themeEffects"))), // Excel: Theme Effects; containment(Effects)
("PTF", Entry::Finish(Action::Command("pageLayout.themeFonts"))), // Excel: Theme Fonts; containment(Fonts)
("PTH", Entry::Continue), // Excel: Themes; group level:
("PTHB", Entry::Finish(Action::Command("pageLayout.theme"))), // Excel: Browse Themes; containment(Themes)
("PVG", Entry::Finish(Action::Command("view.gridlines"))), // Excel: View Gridlines
("PVH", Entry::Finish(Action::Command("view.headings"))), // Excel: View Headings
("MA", Entry::Continue), // Excel: Remove Arrows Menu; group level: containment(Remove Arrows)
("MAA", Entry::Finish(Action::Command("formulas.removeArrows"))), // Excel: Remove All Arrows; landed in the catalog; live since T7 probe
("MAP", Entry::Finish(Action::Pending("formulas.removePrecedentArrows"))), // pending; Excel: Remove Precedent Arrows; Remove Precedent Arrows; reserved until the command lands
("MB", Entry::Finish(Action::Command("formulas.calculateNow"))), // Excel: Calculate Now
("MC", Entry::Finish(Action::Command("formulas.createFromSelection"))), // Excel: Create From Selection
("MD", Entry::Finish(Action::Command("formulas.traceDependents"))), // Excel: Trace Dependents
("ME", Entry::Finish(Action::Control("formulas.dateTime"))), // Excel: Date & Time Menu; group level: containment(Date & Time)
("MF", Entry::Finish(Action::Dialog("insertFunction"))), // Excel: Insert Function
("MG", Entry::Finish(Action::Control("formulas.mathTrig"))), // Excel: Math & Trig Menu; group level: containment(Math & Trig)
("MH", Entry::Finish(Action::Command("formulas.showFormulas"))), // Excel: Show Formulas
("MI", Entry::Continue), // Excel: Financial Menu; group level: containment(Financial)
("MI1", Entry::Finish(Action::Control("formulas.financial"))), // Excel: Excel: Financial; first item of the dropdown
("MJ", Entry::Finish(Action::Command("formulas.calculateSheet"))), // Excel: Calculate Sheet
("MK", Entry::Continue), // Excel: Error Checking Menu; group level: containment(Error Checking)
("MKK", Entry::Finish(Action::Dialog("errorChecking"))), // Excel: Error Checking
("ML", Entry::Finish(Action::Control("formulas.logical"))), // Excel: Logical Menu; group level: containment(Logical)
("MM", Entry::Finish(Action::Dialog("defineName"))), // Excel: Define Name
("MN", Entry::Finish(Action::Dialog("nameManager"))), // Excel: Name Manager
("MO", Entry::Finish(Action::Control("formulas.lookup"))), // Excel: Lookup & Reference Menu; group level: containment(Lookup & Reference)
("MP", Entry::Finish(Action::Command("formulas.tracePrecedents"))), // Excel: Trace Precedents
("MQ", Entry::Finish(Action::Control("formulas.moreFunctions"))), // Excel: More Functions Menu; group level: containment(More Functions)
("MS", Entry::Continue), // Excel: Use in Formula Menu; group level: containment(Use in Formula)
("MSP", Entry::Finish(Action::Pending("formulas.pasteName"))), // pending; Excel: Paste Names; reserved until the command lands
("MT", Entry::Finish(Action::Control("formulas.text"))), // Excel: Text Menu; group level: containment(Text)
("MU", Entry::Continue), // Excel: AutoSum Menu; group level:
("MUA", Entry::Finish(Action::Command("formulas.autoSum"))), // Excel: Average; AutoSum: Average (variant TBD)
("MUC", Entry::Finish(Action::Command("formulas.autoSum"))), // Excel: Count Numbers; AutoSum: Count (variant TBD)
("MUF", Entry::Finish(Action::Command("formulas.moreFunctions"))), // Excel: More Functions (AutoSum); containment(More Functions)
("MUI", Entry::Finish(Action::Command("formulas.autoSum"))), // Excel: Min; AutoSum: Min (variant TBD)
("MUM", Entry::Finish(Action::Command("formulas.autoSum"))), // Excel: Max; AutoSum: Max (variant TBD)
("MUS", Entry::Finish(Action::Command("formulas.autoSum"))), // Excel: Sum
("MV", Entry::Finish(Action::Dialog("evaluateFormula"))), // Excel: Evaluate Formula
("MW", Entry::Finish(Action::Command("formulas.watchWindow"))), // Excel: Watch Window
("MX", Entry::Continue), // Excel: Calculation Options Menu; group level: containment(Calculation Options)
("MXA", Entry::Finish(Action::Command("formulas.calculationOptions"))), // Excel: Automatic Calculation; Calculation options: Automatic
("MXM", Entry::Finish(Action::Command("formulas.calculationOptions"))), // Excel: Manual Calculation; Calculation options: Manual
("MXP", Entry::Finish(Action::Command("formulas.calculationOptions"))), // Excel: Partial Calculation; Calculation options: Partial
("AB", Entry::Finish(Action::Dialog("subtotal"))), // Excel: Subtotal
("AC", Entry::Finish(Action::Command("data.clearFilter"))), // Excel: Clear Filter
("AD", Entry::Finish(Action::Command("data.consolidate"))), // Excel: Consolidate
("AE", Entry::Finish(Action::Dialog("textToColumns"))), // Excel: Text to Columns
("AF1", Entry::Continue), // Excel: Data from Picture Menu; group level: feature gap; reserved until the command lands
("AF1C", Entry::Finish(Action::Command("insert.picture"))), // Excel: Picture from Clipboard (source TBD)
("AF1P", Entry::Finish(Action::Command("insert.picture"))), // Excel: Picture from File (source TBD)
("AFA", Entry::Finish(Action::Pending("data.fromAccess"))), // pending; Excel: Get External Data: From Access; gap #12 Get & Transform; reserved until the command lands
("AFC", Entry::Finish(Action::Command("data.forecastSheet"))), // Excel: Forecast Sheet; IN FLIGHT: vangel76 PR #325; reserved until the command lands
("AFE", Entry::Finish(Action::Pending("data.analyzeData"))), // pending; Excel: Analyze Data; AI feature GAP; reserved until the command lands
("AFF", Entry::Finish(Action::Command("edit.flashFill"))), // Excel: Flash Fill
("AFO", Entry::Finish(Action::Pending("data.fromOtherSources"))), // pending; Excel: Get External Data: From Other Sources; gap #12; reserved until the command lands
("AFT", Entry::Finish(Action::Pending("data.fromText"))), // pending; Excel: Get External Data: From Text; gap #12; reserved until the command lands
("AFW", Entry::Finish(Action::Pending("data.fromWeb"))), // pending; Excel: Get External Data: From Web; reserved until the command lands
("AG", Entry::Continue), // Excel: Group Menu; group level: containment(Group)
("AGA", Entry::Finish(Action::Pending("data.autoOutline"))), // pending; Excel: Auto Outline; Auto Outline; reserved until the command lands
("AGG", Entry::Finish(Action::Command("data.group"))), // Excel: Group
("AH", Entry::Finish(Action::Command("data.hideDetail"))), // Excel: Hide Detail
("AJ", Entry::Finish(Action::Command("data.showDetail"))), // Excel: Show Detail
("AK", Entry::Finish(Action::Command("data.queriesConnections"))), // Excel: Edit Links; Edit Links (Queries & Connections);
("AM", Entry::Finish(Action::Dialog("removeDuplicates"))), // Excel: Remove Duplicates
("AO", Entry::Finish(Action::Command("data.queriesConnections"))), // Excel: Queries & Connections
("AP1", Entry::Finish(Action::Command("file.properties"))), // Excel: Properties
("APN", Entry::Continue), // Excel: Get Data (Power Query) Menu; group level: Power Query variants = GAP
("APND", Entry::Finish(Action::Pending("data.fromDatabase"))), // pending; Excel: From Database (Microsoft Query); gap #12; reserved until the command lands
("APNE", Entry::Finish(Action::Pending("data.powerQueryEditor"))), // pending; Excel: Launch Power Query Editor; gap #12; reserved until the command lands
("APNG", Entry::Finish(Action::Command("data.getData"))), // Excel: Get Data (Power Query); containment(Get Data)
("APNH", Entry::Finish(Action::Pending("data.fromHtml"))), // pending; Excel: From HTML; gap #12; reserved until the command lands
("APNL", Entry::Finish(Action::Pending("data.fromSqlServer"))), // pending; Excel: From SQL Server ODBC; gap #12; reserved until the command lands
("APNS", Entry::Finish(Action::Pending("data.dataSourceSettings"))), // pending; Excel: Data Source Settings; gap #12; reserved until the command lands
("APNT", Entry::Finish(Action::Pending("data.fromTextLegacy"))), // pending; Excel: From Text (Legacy); gap #12; reserved until the command lands
("APR", Entry::Finish(Action::Pending("data.recentSources"))), // pending; Excel: Recent Sources; gap #12; reserved until the command lands
("APS", Entry::Finish(Action::Command("data.queriesConnections"))), // Excel: Show Queries
("APT", Entry::Finish(Action::Pending("data.fromTable"))), // pending; Excel: From Table (query); gap #12; reserved until the command lands
("AQ", Entry::Finish(Action::Command("data.advancedFilter"))), // Excel: Advanced Filter
("AR", Entry::Continue), // group: group: Queries & Connections
("ARA", Entry::Finish(Action::Command("data.refreshAll"))), // Excel: Refresh All
("ARC", Entry::Finish(Action::Pending("data.cancelRefresh"))), // pending; Excel: Cancel Refresh; gap #12; reserved until the command lands
("ARO", Entry::Finish(Action::Pending("data.connectionProperties"))), // pending; Excel: Connection Properties; gap #12; reserved until the command lands
("ARR", Entry::Finish(Action::Command("pivot.refresh"))), // Excel: Refresh
("ARS", Entry::Finish(Action::Pending("data.refreshStatus"))), // pending; Excel: Refresh Status; gap #12; reserved until the command lands
("ASA", Entry::Finish(Action::Command("data.sortAscending"))), // Excel: Sort A to Z
("ASD", Entry::Finish(Action::Command("data.sortDescending"))), // Excel: Sort Z to A
("ASS", Entry::Finish(Action::Dialog("sort"))), // Excel: Sort
("AT", Entry::Finish(Action::Command("data.filter"))), // Excel: Filter
("AU", Entry::Continue), // Excel: Ungroup Menu; group level: containment(Ungroup)
("AUC", Entry::Finish(Action::Pending("data.clearOutline"))), // pending; Excel: Clear Outline; feature gap; reserved until the command lands
("AUU", Entry::Finish(Action::Command("data.ungroup"))), // Excel: Ungroup
("AV", Entry::Continue), // Excel: Data Validation Menu; group level: containment(Data Validation)
("AVI", Entry::Finish(Action::Command("data.circleInvalid"))), // Excel: Circle Invalid Data
("AVR", Entry::Finish(Action::Pending("data.clearValidationCircles"))), // pending; Excel: Clear Validation Circles; reserved until the command lands
("AVV", Entry::Finish(Action::Command("data.validation"))), // Excel: Data Validation
("AW", Entry::Continue), // group: group: What-If (goalSeek/scenarioManager/dataTable)
("AWG", Entry::Finish(Action::Dialog("goalSeek"))), // Excel: Goal Seek
("AWS", Entry::Finish(Action::Command("data.scenarioManager"))), // Excel: Scenario Manager
("AWT", Entry::Finish(Action::Command("data.dataTable"))), // Excel: Data Table
("AX", Entry::Finish(Action::Pending("data.existingConnections"))), // pending; Excel: Existing Connections; gap #12; reserved until the command lands
("AY", Entry::Finish(Action::Command("data.reapply"))), // Excel: Reapply Filter; containment(Reapply)
("RA1", Entry::Finish(Action::Dialog("accessibility"))), // Excel: Check Accessibility
("RA2", Entry::Finish(Action::Command("review.showComments"))), // Excel: Show All Comments; Show All Comments; reserved until the command lands
("RC", Entry::Finish(Action::Dialog("comment"))), // Excel: New Comment
("RD", Entry::Finish(Action::Command("review.deleteComment"))), // Excel: Delete Comment
("RG", Entry::Finish(Action::Pending("review.showChanges"))), // pending; Excel: Track Changes; reserved until the command lands
("RL", Entry::Finish(Action::Pending("review.translate"))), // pending; Excel: Translate; feature gap; reserved until the command lands
("RN", Entry::Finish(Action::Command("review.nextComment"))), // Excel: Next Comment
("RO", Entry::Finish(Action::Command("review.protectWorkbook"))), // Excel: Protect and Share Workbook
("RPS", Entry::Finish(Action::Dialog("protectSheet"))), // Excel: Protect Sheet
("RPW", Entry::Finish(Action::Command("review.protectWorkbook"))), // Excel: Protect Workbook
("RRS", Entry::Finish(Action::Pending("review.smartLookup"))), // pending; Excel: Smart Lookup; feature gap; reserved until the command lands
("RS", Entry::Finish(Action::Dialog("spelling"))), // Excel: Spelling
("RTN", Entry::Finish(Action::Command("review.newNote"))), // Excel: New Note
("RV", Entry::Finish(Action::Command("review.previousComment"))), // Excel: Previous Comment
("WA", Entry::Finish(Action::Command("view.arrangeAll"))), // Excel: Arrange All
("WC", Entry::Finish(Action::Command("view.customViews"))), // Excel: Custom Views
("WF", Entry::Continue), // Excel: Freeze Panes Menu; group level:
("WFC", Entry::Finish(Action::Command("view.freezeFirstColumn"))), // Excel: Freeze First Column
("WFF", Entry::Finish(Action::Command("view.freezePanes"))), // Excel: Freeze Panes
("WFR", Entry::Finish(Action::Command("view.freezeTopRow"))), // Excel: Freeze Top Row
("WG", Entry::Finish(Action::Command("view.zoomToSelection"))), // Excel: Zoom To Selection
("WH", Entry::Finish(Action::Command("view.hideWindow"))), // Excel: Hide Window
("WI", Entry::Finish(Action::Command("view.pageBreakPreview"))), // Excel: Page Break Preview
("WJ", Entry::Finish(Action::Control("view.zoom"))), // Excel: Zoom To 100%; runs the 100% zoom handler (percent param)
("WL", Entry::Finish(Action::Command("view.normal"))), // Excel: Normal View; containment(Normal)
("WM", Entry::Continue), // Excel: Macros Menu; group level: containment(Macros)
("WMR", Entry::Finish(Action::Command("view.macros"))), // Excel: Record Macro; Record Macro (macros menu)
("WMV", Entry::Finish(Action::Command("view.macros"))), // Excel: View Macros; containment(Macros)
("WN", Entry::Finish(Action::Command("view.newWindow"))), // Excel: New Window
("WP", Entry::Finish(Action::Command("view.pageLayout"))), // Excel: Page Layout View
("WQ", Entry::Finish(Action::Dialog("zoom"))), // Excel: Zoom; opens the Zoom dialog
("WR", Entry::Finish(Action::Command("view.ruler"))), // Excel: Ruler
("WS", Entry::Finish(Action::Command("view.split"))), // Excel: Split
("WT", Entry::Finish(Action::Pending("view.resetWindowPosition"))), // pending; Excel: Reset Window Position; reserved until the command lands
("WU", Entry::Finish(Action::Command("view.unhideWindow"))), // Excel: Unhide Window
("WVC", Entry::Finish(Action::Pending("view.focusCell"))), // pending; Excel: Focus Cell Menu; Focus Cell; reserved until the command lands
("WVF", Entry::Finish(Action::Control("view.formulaBar"))), // Excel: Toggle Formula Bar; toggles the formula bar
("WVH", Entry::Finish(Action::Command("view.headings"))), // Excel: Toggle Headings; containment(Headings)
("WVN", Entry::Finish(Action::Pending("view.navigation"))), // pending; Excel: Navigation; Navigation pane; reserved until the command lands
("WVR", Entry::Finish(Action::Command("view.ruler"))), // Excel: Ruler
("WW", Entry::Finish(Action::Command("view.switchWindows"))), // Excel: Switch Windows
("JAM", Entry::Finish(Action::Command("chart.formatSelection"))), // Excel: Chart format: Format Selection pane; containment(Format Selection)
("JCA", Entry::Finish(Action::Command("chart.addElement"))), // Excel: Chart: Add Chart Element; containment(Add Chart Element)
("JCC", Entry::Finish(Action::Command("chart.changeType"))), // Excel: Chart: Change Chart Type; containment(Change Chart Type)
("JTS", Entry::Finish(Action::Command("table.style"))), // Excel: Table: table style
("JTV", Entry::Finish(Action::Command("table.summarizePivot"))), // Excel: Table: Summarize with PivotTable; containment(Summarize with PivotTable)
("FA", Entry::Finish(Action::Command("file.saveAs"))), // Excel: Save / Save As page; fires immediately in Excel; deeper aliases dropped as unreachable
("FC", Entry::Finish(Action::Command("file.close"))), // Excel: Close workbook
("FEA", Entry::Finish(Action::Command("file.exportPdf"))), // Excel: Export: create PDF/XPS; Export: create PDF/XPS
("FEC", Entry::Finish(Action::Command("file.saveAs"))), // Excel: Export: change file type; Export: change file type
("FI", Entry::Finish(Action::Command("file.properties"))), // Excel: Info page; fires immediately in Excel; deeper aliases dropped as unreachable
("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)
];

File diff suppressed because it is too large Load Diff

View File

@ -57,6 +57,10 @@ pub struct UiState {
/// Motion in the UI: the selection outline glides to a new selection (View ▸ Animations).
pub animations: bool,
pub recent: Vec<String>,
/// One-shot keytip request for a ribbon control: set when a keytip action resolves to
/// [`keytips::Action::Control`], consumed (and cleared) by the control's render site.
#[serde(skip)]
pub keytip_invoke: 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
@ -95,6 +99,7 @@ impl Default for UiState {
status_bar: true,
animations: true,
recent: vec![],
keytip_invoke: None,
language: i18n::Language::En,
interface_scale: 100,
}
@ -831,6 +836,9 @@ impl SheetApp {
/// Per-frame logic (control channel, screenshots). Call before `ui`.
pub fn logic(&mut self, ctx: &egui::Context) {
// 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;
if self.fonts_han.is_some_and(|han| han != theme::HanOrder::of(self.ui.language)) {
self.fonts_ready = false;
self.fonts_set = false;
@ -1021,21 +1029,25 @@ impl SheetApp {
// Leave pointer cancellation to the popup so clicking a menu item still works.
egui::Popup::close_all(ctx);
}
// The legacy Edit/Format paths lead to the same existing Home menus. Switch once, when
// the prefix changes, not every frame (that would reset a tab the user picked meanwhile).
let after = self.keytips.prefix();
if after != before.as_deref() && matches!(after, Some("E" | "O" | "OC")) {
self.run_or_alert("ui.ribbonTab", json!({"tab": "Home"}));
self.ui.ribbon_collapsed = false;
}
for action in actions {
match action {
keytips::Action::Home => {
self.run_or_alert("ui.ribbonTab", json!({"tab": "Home"}));
keytips::Action::Tab(tab) => {
self.run_or_alert("ui.ribbonTab", json!({"tab": tab}));
self.ui.ribbon_collapsed = false;
}
keytips::Action::Command(id) => self.run_or_alert(id, json!({})),
keytips::Action::Dialog(name) => self.open_dialog(name, json!({})),
// Reserved sequence: fires silently while its command is missing. When the
// command lands in the catalog it goes live with no table edit.
keytips::Action::Pending(id) => {
if gridcraft_engine::find_command(id).is_some() {
self.run_or_alert(id, json!({}));
}
}
// 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),
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -145,9 +145,83 @@ pub fn committed_text(ui: &mut Ui, id: egui::Id, current: &str, width: Option<f3
}
/// Our colour palette (theme row + tints + standard colours), returns a picked hex colour,
/// `Some("none")` for No Fill/Automatic.
/// `Some("none")` for No Fill/Automatic. Arrow keys move a highlight through the swatches and
/// Enter applies; the key events are stripped so the grid behind the popup does not also react.
pub fn color_palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str) -> Option<String> {
let mut picked = None;
const ROWS: usize = 7; // 6 tint rows + 1 standard row
const COLS: usize = 10;
let pal_id = ui.id().with("__color_palette");
let mut cur: Option<(usize, usize)> = ui.data_mut(|d| d.get_temp(pal_id)).unwrap_or(None);
let (mut dl, mut dr, mut du, mut dd, mut ent) = (false, false, false, false, false);
ui.input(|i| {
dl = i.key_pressed(egui::Key::ArrowLeft);
dr = i.key_pressed(egui::Key::ArrowRight);
du = i.key_pressed(egui::Key::ArrowUp);
dd = i.key_pressed(egui::Key::ArrowDown);
ent = i.key_pressed(egui::Key::Enter);
});
if dl || dr || du || dd {
cur = match cur {
None => Some((
0,
if dr {
1
} else if dl {
COLS - 1
} else {
0
},
)),
Some((r, c)) => {
let r2 = if du {
r.saturating_sub(1)
} else if dd {
(r + 1).min(ROWS - 1)
} else {
r
};
let c2 = if dl {
(c + COLS - 1) % COLS
} else if dr {
(c + 1) % COLS
} else {
c
};
Some((r2, c2))
}
};
}
if dl || dr || du || dd || ent {
ui.ctx().input_mut(|i| {
i.events.retain(|e| {
!matches!(
e,
egui::Event::Key {
key: egui::Key::ArrowLeft | egui::Key::ArrowRight | egui::Key::ArrowUp | egui::Key::ArrowDown | egui::Key::Enter,
pressed: true,
..
}
)
});
});
}
let theme_hex = |base: u32, tint: f64| -> String {
let rgb = [(base >> 16) as u8, (base >> 8) as u8, base as u8];
let c = gridcraft_engine::model::style::apply_tint(rgb, tint);
let (r0, r1, r2) = (c[0], c[1], c[2]);
format!("#{r0:02X}{r1:02X}{r2:02X}")
};
let std_hex = ["#C00000", "#FF0000", "#FFC000", "#FFFF00", "#92D050", "#00B050", "#00B0F0", "#0070C0", "#002060", "#7030A0"];
if ent && let Some((r, c)) = cur {
picked = Some(if r < ROWS - 1 {
let base = theme_colors.get(c).copied().unwrap_or(0);
theme_hex(base, [0.0f64, 0.8, 0.6, 0.4, -0.25, -0.5][r])
} else {
std_hex[c].to_string()
});
ui.data_mut(|d| d.remove_temp::<Option<(usize, usize)>>(pal_id));
}
if ui.button(none_label).clicked() {
picked = Some("none".to_string());
}
@ -155,20 +229,20 @@ pub fn color_palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str) ->
let order = [0usize, 1, 2, 3, 4, 5, 6, 7, 8, 9];
let tints = [0.0f64, 0.8, 0.6, 0.4, -0.25, -0.5];
egui::Grid::new(ui.id().with("theme_grid")).spacing(vec2(2.0, 2.0)).show(ui, |ui| {
for tint in tints {
for &i in &order {
for (row, tint) in tints.iter().enumerate() {
for (col, &i) in order.iter().enumerate() {
let base = theme_colors.get(i).copied().unwrap_or(0);
let rgb = [(base >> 16) as u8, (base >> 8) as u8, base as u8];
let c = gridcraft_engine::model::style::apply_tint(rgb, tint);
let col = Color32::from_rgb(c[0], c[1], c[2]);
let c = gridcraft_engine::model::style::apply_tint(rgb, *tint);
let col32 = Color32::from_rgb(c[0], c[1], c[2]);
let (r, resp) = ui.allocate_exact_size(vec2(16.0, 14.0), Sense::click());
ui.painter().rect_filled(r, 1.0, col);
ui.painter().rect_filled(r, 1.0, col32);
ui.painter().rect_stroke(r, 1.0, Stroke::new(1.0, Color32::from_gray(200)), StrokeKind::Inside);
if resp.hovered() {
if resp.hovered() || cur == Some((row, col)) {
ui.painter().rect_stroke(r.expand(1.0), 1.0, Stroke::new(1.5, Color32::from_rgb(0xE8, 0x7D, 0x2B)), StrokeKind::Outside);
}
if resp.clicked() {
picked = Some(format!("#{:02X}{:02X}{:02X}", c[0], c[1], c[2]));
picked = Some(theme_hex(base, *tint));
}
}
ui.end_row();
@ -177,18 +251,22 @@ pub fn color_palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str) ->
ui.label(egui::RichText::new("Standard Colors").small());
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
for hex in ["#C00000", "#FF0000", "#FFC000", "#FFFF00", "#92D050", "#00B050", "#00B0F0", "#0070C0", "#002060", "#7030A0"] {
let col = gridcraft_engine::model::Color::from_hex(hex)
for (col, hex) in std_hex.iter().enumerate() {
let c = gridcraft_engine::model::Color::from_hex(hex)
.and_then(|c| c.resolve(&Default::default()))
.map(theme::color32)
.unwrap_or(Color32::BLACK);
let (r, resp) = ui.allocate_exact_size(vec2(16.0, 14.0), Sense::click());
ui.painter().rect_filled(r, 1.0, col);
ui.painter().rect_filled(r, 1.0, c);
if resp.hovered() || cur == Some((ROWS - 1, col)) {
ui.painter().rect_stroke(r.expand(1.0), 1.0, Stroke::new(1.5, Color32::from_rgb(0xE8, 0x7D, 0x2B)), StrokeKind::Outside);
}
if resp.clicked() {
picked = Some(hex.to_string());
}
}
});
ui.data_mut(|d| d.insert_temp(pal_id, cur));
picked
}

View File

@ -174,6 +174,7 @@ fn keyboard_opened_paste_menu_accepts_a_mouse_click_after_keytips_cancel() {
#[test]
fn legacy_paste_special_keeps_formulas_when_requested() {
let mut h = harness(copied_row());
// The legacy Edit route opens the dialog directly; F is a dialog mnemonic.
sequence(&mut h, &[Key::E, Key::S], false);
assert_eq!(h.state().dialog.as_ref().unwrap().name(), "pasteSpecial");
letter(&mut h, Key::F, false);
@ -295,6 +296,80 @@ fn held_keys_do_not_toggle_twice_or_leak_repeated_letters() {
assert_eq!(value(&h, "A1"), Value::Empty);
}
#[test]
fn tab_letters_switch_to_their_ribbon_tabs() {
let mut h = harness(blank());
for (k, tab) in [(Key::N, "Insert"), (Key::P, "Page Layout"), (Key::M, "Formulas"), (Key::A, "Data"), (Key::R, "Review"), (Key::W, "View")] {
key(&mut h, Key::F10); // arm
letter(&mut h, k, false);
assert_eq!(h.state().ui.ribbon_tab, tab, "letter {k:?}");
assert!(h.state().editor.is_none(), "tab letters must not begin a cell edit");
key(&mut h, Key::F10); // disarm
assert_eq!(h.state().keytips.prefix(), None);
}
// A terminal tab unit with no deeper sequences finishes and clears.
key(&mut h, Key::F10); // re-arm
letter(&mut h, Key::J, false);
assert_eq!(h.state().keytips.prefix(), Some("J"), "J waits for its multi-letter unit");
letter(&mut h, Key::I, false);
assert_eq!(h.state().ui.ribbon_tab, "Draw");
assert_eq!(h.state().keytips.prefix(), None, "terminal tab units finish cleanly");
}
#[test]
fn view_gridlines_sequence_runs_end_to_end() {
let mut h = harness(blank());
let gridlines = |h: &Harness| h.state().session.active().unwrap().wb.active().unwrap().show_gridlines;
assert!(gridlines(&h), "gridlines start visible");
key(&mut h, Key::F10);
letter(&mut h, Key::W, false);
// W has deeper sequences: it stays as an active level so View badges show.
assert_eq!(h.state().ui.ribbon_tab, "View");
assert_eq!(h.state().keytips.prefix(), Some("W"));
letter(&mut h, Key::V, false);
assert_eq!(h.state().keytips.prefix(), Some("WV"), "implicit group level waits for the last key");
letter(&mut h, Key::G, false);
assert_eq!(h.state().keytips.prefix(), None, "the finished sequence clears the prefix");
assert!(!gridlines(&h), "WVG toggles gridlines off");
key(&mut h, Key::F10);
for k in [Key::W, Key::V, Key::G] {
letter(&mut h, k, false);
}
assert!(gridlines(&h), "WVG toggles gridlines back on");
}
#[test]
fn two_letter_unit_waits_after_first_letter_then_fires() {
let mut h = harness(blank());
key(&mut h, Key::F10);
letter(&mut h, Key::J, false);
// "J" is not a complete sequence, but "JI" starts with it: the matcher waits.
assert_eq!(h.state().keytips.prefix(), Some("J"));
assert_eq!(h.state().ui.ribbon_tab, "Home");
letter(&mut h, Key::I, false);
assert_eq!(h.state().ui.ribbon_tab, "Draw");
assert_eq!(h.state().keytips.prefix(), None);
assert!(h.state().editor.is_none(), "unit letters must not begin a cell edit");
}
#[test]
fn two_letter_unit_fires_when_pressed_in_one_frame() {
let mut h = harness(blank());
key(&mut h, Key::F10);
events(
&mut h,
[
key_event(Key::J, true, Modifiers::NONE),
Event::Text("j".into()),
key_event(Key::I, true, Modifiers::NONE),
Event::Text("i".into()),
key_event(Key::J, false, Modifiers::NONE),
key_event(Key::I, false, Modifiers::NONE),
],
);
assert_eq!(h.state().ui.ribbon_tab, "Draw");
}
#[test]
fn ordinary_typing_editor_shortcuts_and_altgr_remain_text_input() {
let mut h = harness(blank());
@ -327,3 +402,151 @@ fn vertical_alignment_keytips_run_their_commands() {
assert_eq!(h.state().session.journal.iter().filter(|(j, _)| j == id).count(), 1, "HA{} runs {id}", letter.name());
}
}
#[test]
fn pending_sequences_are_silent_no_ops() {
let mut h = harness(blank());
let journal = h.state().session.journal.len();
sequence(&mut h, &[Key::N, Key::W], false); // NW -> insert.wordArt (missing)
assert!(h.state().dialog.is_none(), "no dialog for a pending sequence");
assert!(h.state().message.is_none(), "no message box for a pending sequence");
assert!(h.state().toast.is_none(), "no toast for a pending sequence");
assert_eq!(h.state().session.journal.len(), journal, "no journal entry for a pending sequence");
assert_eq!(h.state().keytips.prefix(), None, "the finished sequence clears the prefix");
}
#[test]
fn pending_sequences_go_live_when_the_command_lands() {
// The command lands in the catalog (here: a test-registered stand-in).
// The sequence must run it with no table edit.
gridcraft_engine::register_test_command("insert.equation", |s, _| {
s.execute("cell.set", json!({"cell": "Z9", "input": "live"}))?;
Ok(json!({}))
});
let mut h = harness(blank());
sequence(&mut h, &[Key::N, Key::E], false); // NE -> insert.equation
assert_eq!(value(&h, "Z9"), Value::from("live"), "the pending sequence ran the landed command");
assert_eq!(
h.state().session.journal.iter().filter(|(id, _)| id == "insert.equation").count(),
1,
"the landed command is journaled like any live command"
);
assert_eq!(h.state().keytips.prefix(), None);
}
#[test]
fn control_keytips_open_dropdowns_and_run_handlers() {
// HFF routes to the font control: its dropdown opens (popup items queryable).
let mut h = harness(blank());
sequence(&mut h, &[Key::H, Key::F, Key::F], false);
assert!(h.state().message.is_none() && h.state().toast.is_none(), "HFF must not raise an error box");
// "Calibri" appears twice: once on the combo button (the current font) and
// once inside the opened dropdown — a single node would mean it stayed closed.
assert!(h.query_all_by_label("Calibri").count() >= 2, "HFF must open the font dropdown");
// WVF toggles the formula bar: pure UI state, no engine command exists.
let mut h = harness(blank());
assert!(h.state().session.prefs.formula_bar);
sequence(&mut h, &[Key::W, Key::V, Key::F], false);
assert!(!h.state().session.prefs.formula_bar, "WVF must toggle the formula bar off");
// WJ runs the 100% handler with its percent param ({} would raise "missing percent").
let mut h = harness(blank());
sequence(&mut h, &[Key::W, Key::J], false);
assert!(h.state().message.is_none() && h.state().toast.is_none(), "WJ must run view.zoom with the percent param");
assert_eq!(h.state().keytips.prefix(), None);
}
#[test]
fn editable_combos_filter_by_typing_and_move_by_arrows() {
// Open the font dropdown (HFF), type to filter, then Enter to apply.
let mut h = harness(blank());
sequence(&mut h, &[Key::H, Key::F, Key::F], false);
h.run_steps(3); // let the filter field gain focus
events(&mut h, [Event::Text("arial".into())]);
h.run_steps(3);
// "arial" matches Arial only — Georgia must be filtered out of the list.
assert_eq!(h.query_all_by_label("Georgia").count(), 0, "typing must narrow the list");
assert!(h.query_all_by_label("Arial").count() >= 1, "the match must stay visible");
key(&mut h, Key::Enter);
h.run_steps(3);
assert!(h.state().message.is_none() && h.state().toast.is_none(), "applying must not raise an error");
// The popup closed and the combo button now shows the applied font.
assert_eq!(h.query_all_by_label("Arial").count(), 1, "Enter must apply Arial and close the popup");
assert_eq!(h.query_all_by_label("Georgia").count(), 0);
// Arrows alone move through the full list: Calibri -> Cambria -> Candara -> Consolas.
let mut h = harness(blank());
sequence(&mut h, &[Key::H, Key::F, Key::F], false);
h.run_steps(3);
for _ in 0..3 {
key(&mut h, Key::ArrowDown);
}
key(&mut h, Key::Enter);
h.run_steps(3);
assert!(h.state().message.is_none() && h.state().toast.is_none());
assert_eq!(h.query_all_by_label("Consolas").count(), 1, "Down x3 from Calibri must land on Consolas");
// Size combo: type a custom size not in the list, Enter applies it.
let mut h = harness(blank());
sequence(&mut h, &[Key::H, Key::F, Key::S], false);
h.run_steps(3);
events(&mut h, [Event::Text("13.5".into())]);
key(&mut h, Key::Enter);
h.run_steps(3);
assert!(h.state().message.is_none() && h.state().toast.is_none(), "custom sizes must apply");
assert_eq!(h.query_all_by_label("13.5").count(), 1, "the button must show the custom size");
// Closing without applying (Escape) and reopening must reset the filter to the full list.
let mut h = harness(blank());
sequence(&mut h, &[Key::H, Key::F, Key::F], false);
h.run_steps(3);
events(&mut h, [Event::Text("arial".into())]);
h.run_steps(3);
key(&mut h, Key::Escape);
h.run_steps(3);
sequence(&mut h, &[Key::H, Key::F, Key::F], false);
h.run_steps(4);
assert!(h.query_all_by_label("Georgia").count() >= 1, "reopening must show the full, unfiltered list");
// Arrows after typing move the highlight, not the text cursor: type a filter,
// then Down twice and Enter must apply the second match's neighbour logic.
key(&mut h, Key::Escape);
h.run_steps(3);
sequence(&mut h, &[Key::H, Key::F, Key::F], false);
h.run_steps(3);
events(&mut h, [Event::Text("candara".into())]);
h.run_steps(3);
key(&mut h, Key::Enter);
h.run_steps(3);
assert_eq!(h.query_all_by_label("Candara").count(), 1, "filtered Enter must apply the filtered row");
}
#[test]
fn color_palette_navigates_with_arrows_and_enter_applies() {
let mut h = harness(blank());
sequence(&mut h, &[Key::H, Key::H], false); // H,H opens the fill palette
h.run_steps(3);
key(&mut h, Key::ArrowDown); // highlight starts at theme row 0
key(&mut h, Key::ArrowRight); // -> row 0, col 1 = Craft theme dk1 (#000000)
key(&mut h, Key::Enter);
h.run_steps(3);
assert!(h.state().message.is_none() && h.state().toast.is_none(), "arrow+Enter must not error");
assert_eq!(h.state().session.journal.iter().filter(|(id, _)| id == "home.fillColor").count(), 1, "Enter applies the highlighted swatch");
assert_eq!(h.state().grid.last_fill, "#000000", "the applied fill is the highlighted colour");
assert_eq!(h.query_all_by_label("Theme Colors").count(), 0, "the palette closes after applying");
}
#[test]
fn every_sequence_terminates_without_panicking() {
use gridcraft_ui_egui::keytips::keytip_table::SEQUENCES;
for (seq, _entry) in SEQUENCES {
let mut h = harness(blank());
key(&mut h, Key::F10); // arm
for ch in seq.chars() {
let k = Key::from_name(&ch.to_string()).expect("sequence letters map to keys");
letter(&mut h, k, false);
}
// A finish/tab/dialog/wait state is all fine; the point is nothing panics.
}
assert_eq!(SEQUENCES.len(), SEQUENCES.len(), "every sequence ran");
}

File diff suppressed because it is too large Load Diff

View File

@ -77,6 +77,7 @@ this logic, with one undo step.
| Ctrl+1, Ctrl+; and Ctrl+Shift+; , Ctrl+D/R, Ctrl+E, Ctrl+T/L, Ctrl+K, Ctrl+Shift+L, Ctrl+Shift+~!@#$%^&_ | yes | done |
| F2, F4, F5, F7, F9 family, Shift+F2/F3/F10/F11, F12 | yes | done; F11 chart sheet, F8 extend mode, F3 Paste Name, F4 Repeat missing |
| Mac-specific (Cmd variants, Control variants, Fn keys) | yes | done where a feature exists (`keymap.rs` picks the Mac variant) |
| Sequential Alt access keys (Alt, H, N, …) on every ribbon tab | yes | 364-sequence keytip table for all tabs: per-control badges, prefix waiting, dropdowns open for editing (type/arrows/Enter), pending bindings for not-yet-built commands; QAT/Backstage excluded |
| Requests | — | #110 (more shortcuts and hot keys), #33 (sequential Alt; initial support landed in #43) |
### Shortcut audit (#110)
@ -171,5 +172,6 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
| 2026-10-11 | minor | Keyboard shortcut audit against Microsoft's Windows and Mac lists; missing shortcuts for existing features added (#110) |
| 2026-10-11 | minor | File tab Backstage view and File › Options (#174) |
| 2026-10-11 | minor | Selection outline glide and the Animations setting |
| 2026-10-11 | major | Sequential Alt keytips: full-tab table (364 sequences), badges on every control, dropdowns open for typing/arrow editing, colour-palette arrows, real-Excel corrections (issues #33, #49) |
| 2026-10-10 | minor | Chart resize bug (#190) |
| 2026-10-10 | major | Created: chrome, grid, keyboard, dialogs, accessibility; key tips, Page Layout view and accessibility tree flagged |

410
xtask/src/keytips.rs Normal file
View File

@ -0,0 +1,410 @@
//! Regenerate the keytip sequence table and its per-sequence test suite from `keytips.json`.
//!
//! `cargo xtask keytips` regenerate the table + `tests/keytips_generated.rs`
//! `cargo xtask keytips --check` fail if either generated file differs from the JSON source
//!
//! Requires the default `engine` feature: the generator probes the live command
//! catalog so each generated test asserts exactly what the command does today
//! (journaled, ran clean without a journal step, or surfaced a missing-params error).
use std::fs;
use std::path::Path;
const TABS: &[&str] = &["Home", "Insert", "Draw", "Page Layout", "Formulas", "Data", "Review", "View", "Automate"];
/// Commands whose empty-params behaviour depends on the host machine (e.g. the
/// system dictionary). These are never probed; their tests assert "invoked
/// something observable" so the checked-in file stays byte-identical everywhere.
const ENV_SENSITIVE: &[&str] = &["review.spelling"];
/// Format generated source through `rustfmt` so the checked-in file matches
/// the emitter byte-for-byte.
fn rustfmt(src: &str) -> Result<String, String> {
use std::io::Write;
use std::process::{Command, Stdio};
let mut child = Command::new("rustfmt")
.args(["--edition", "2024", "--emit", "stdout"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("spawn rustfmt: {e}"))?;
let stdin = child.stdin.as_mut().ok_or("rustfmt: failed to open stdin")?;
stdin.write_all(src.as_bytes()).map_err(|e| format!("write rustfmt stdin: {e}"))?;
let out = child.wait_with_output().map_err(|e| format!("wait rustfmt: {e}"))?;
if !out.status.success() {
return Err(format!("rustfmt failed: {}", String::from_utf8_lossy(&out.stderr)));
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
#[derive(Clone)]
struct Entry {
seq: String,
kind: String,
target: String,
pending: bool,
excel: String,
note: String,
}
/// 0 = ran and journaled, 1 = ran without a journal step, 2 = errored (params
/// missing, disabled, or environment-dependent).
type Probe = std::collections::HashMap<String, u8>;
#[cfg(feature = "engine")]
fn probe_live_commands(ids: &[String]) -> Probe {
use gridcraft_engine::Session;
let mut map = Probe::new();
for id in ids {
if ENV_SENSITIVE.contains(&id.as_str()) {
continue;
}
let mut s = Session::new();
s.new_workbook();
let status = match s.execute(id, serde_json::json!({})) {
Ok(_) => {
if s.journal.iter().any(|(jid, _)| jid == id) {
0
} else {
1
}
}
Err(_) => 2,
};
map.insert(id.clone(), status);
}
map
}
pub fn run(root: &Path, rest: &[&str]) -> Result<(), String> {
#[cfg(not(feature = "engine"))]
{
let _ = (root, rest);
return Err("cargo xtask keytips needs the default `engine` feature to probe the command catalog".into());
}
#[cfg(feature = "engine")]
{
let json_path = root.join("crates/ui-egui/src/keytips/keytips.json");
let table_path = root.join("crates/ui-egui/src/keytips/keytip_table/generated.rs");
let tests_path = root.join("crates/ui-egui/tests/keytips_generated.rs");
let source = fs::read_to_string(&json_path).map_err(|e| format!("read {}: {e}", json_path.display()))?;
let (entries, probe) = parse_and_probe(&source)?;
let generated =
[(table_path.as_path(), rustfmt(&render_table(&entries))?), (tests_path.as_path(), rustfmt(&render_tests(&entries, &probe))?)];
if rest.contains(&"--check") {
for (path, expected) in &generated {
let on_disk = fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
if on_disk != *expected {
return Err(format!("{} is out of date — run `cargo xtask keytips` and commit the result", path.display()));
}
println!("keytips: {} up to date", path.display());
}
return Ok(());
}
for (path, content) in &generated {
fs::write(path, content).map_err(|e| format!("write {}: {e}", path.display()))?;
println!("keytips: wrote {}", path.display());
}
Ok(())
}
}
#[cfg(feature = "engine")]
fn parse_and_probe(source: &str) -> Result<(Vec<Entry>, Probe), String> {
let doc: serde_json::Value = serde_json::from_str(source).map_err(|e| format!("invalid JSON: {e}"))?;
let seqs = doc.get("sequences").and_then(|v| v.as_array()).ok_or("missing `sequences` array")?;
let mut entries: Vec<Entry> = Vec::new();
let mut seen = std::collections::HashSet::new();
for (i, e) in seqs.iter().enumerate() {
let at = format!("entry {}", i + 1);
let seq = e.get("seq").and_then(|v| v.as_str()).ok_or_else(|| format!("{at}: missing `seq`"))?.to_string();
if seq.is_empty() || seq.len() > 6 || !seq.bytes().all(|b| b.is_ascii_alphanumeric()) {
return Err(format!("{at}: seq {seq:?} must be 1-6 ASCII letters/digits (Excel's longest are 5, e.g. sparkline paths)"));
}
if !seen.insert(seq.clone()) {
return Err(format!("{at}: duplicate seq {seq:?}"));
}
let kind = e.get("kind").and_then(|v| v.as_str()).ok_or_else(|| format!("{at}: missing `kind`"))?.to_string();
let target = e.get("target").and_then(|v| v.as_str()).unwrap_or("").to_string();
let pending = e.get("pending").and_then(|v| v.as_bool()).unwrap_or(false);
if pending && kind != "command" {
return Err(format!("{at}: `pending` is only valid with kind `command`"));
}
match kind.as_str() {
"tab" => {
if target.is_empty() || !TABS.contains(&target.as_str()) {
return Err(format!("{at}: tab target {target:?} is not one of {TABS:?}"));
}
}
"command" | "dialog" | "control" => {
if target.is_empty() {
return Err(format!("{at}: {kind} needs a `target`"));
}
}
"continue" => {}
other => return Err(format!("{at}: unknown kind {other:?}")),
}
// A terminal Finish entry on a strict prefix makes the longer sequence
// unreachable (the prefix fires and clears first), e.g. HC (Copy) kills HCP.
if matches!(kind.as_str(), "command" | "dialog" | "control") {
for cut in 1..seq.len() {
let prefix = &seq[..cut];
if entries.iter().any(|e| e.seq == prefix && matches!(e.kind.as_str(), "command" | "dialog" | "control")) {
return Err(format!(
"{at}: seq {seq:?} is unreachable — {prefix:?} already fires first; the deeper item needs a menu (item-letter), not a trie sequence"
));
}
}
}
entries.push(Entry {
seq,
kind,
target,
pending,
excel: e.get("excel").and_then(|v| v.as_str()).unwrap_or("").to_string(),
note: e.get("note").and_then(|v| v.as_str()).unwrap_or("").to_string(),
});
}
// JSON order is authoritative and curated: the first entry for a tab is its canonical
// badge (legacy aliases come later), and groups stay together. Do not sort.
let live_ids: Vec<String> = entries
.iter()
.filter(|e| e.kind == "command" && !e.pending)
.map(|e| e.target.clone())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
// Pending targets that already exist fire live at runtime (T6); their tests
// must assert the live behaviour, not the silent one. Nudge the maintainer.
let landed: std::collections::BTreeSet<String> = entries
.iter()
.filter(|e| e.pending && gridcraft_engine::find_command(&e.target).is_some())
.map(|e| {
eprintln!("keytips: note — pending target {:?} now exists in the catalog; consider dropping `pending`", e.target);
e.target.clone()
})
.collect();
let mut probe = probe_live_commands(&live_ids);
for id in landed {
let mut s = gridcraft_engine::Session::new();
s.new_workbook();
let status = match s.execute(&id, serde_json::json!({})) {
Ok(_) => {
if s.journal.iter().any(|(jid, _)| jid == &id) {
0
} else {
1
}
}
Err(_) => 2,
};
probe.insert(id, status);
}
Ok((entries, probe))
}
fn render_table(entries: &[Entry]) -> String {
let mut out = String::from(
"//! GENERATED from keytips.json by `cargo xtask keytips`. Edit the JSON, not this file.\n\n\
use super::{Action, Entry};\n\n\
/// All known keytip sequences. Intermediate levels are implicit: the matcher waits\n\
/// whenever a pressed letter starts a deeper sequence, so only terminal entries are\n\
/// listed here. See keytip_table.rs for the helpers that read this table.\n\
pub const SEQUENCES: &[(&str, Entry)] = &[\n",
);
for e in entries {
let value = match e.kind.as_str() {
"tab" => format!("Entry::Tab({:?})", e.target),
"command" if e.pending => format!("Entry::Finish(Action::Pending({:?}))", e.target),
"command" => format!("Entry::Finish(Action::Command({:?}))", e.target),
"dialog" => format!("Entry::Finish(Action::Dialog({:?}))", e.target),
"control" => format!("Entry::Finish(Action::Control({:?}))", e.target),
"continue" => "Entry::Continue".to_string(),
// All kinds are validated during parse; anything else cannot occur.
_ => String::new(),
};
let mut comment = String::new();
if e.pending {
comment.push_str(" pending");
}
if !e.excel.is_empty() {
if !comment.is_empty() {
comment.push(';');
}
comment.push_str(&format!(" Excel: {}", e.excel));
}
if !e.note.is_empty() {
if !comment.is_empty() {
comment.push(';');
}
comment.push_str(&format!(" {}", e.note));
}
out.push_str(&format!(" ({:?}, {value}),", e.seq));
if !comment.is_empty() {
out.push_str(&format!(" //{comment}"));
}
out.push('\n');
}
out.push_str("];\n");
out
}
fn test_name(seq: &str) -> String {
format!("keytip_{}_", seq.to_ascii_lowercase())
}
fn render_tests(entries: &[Entry], probe: &Probe) -> String {
let mut out = String::from(HEADER);
for e in entries {
let body = match e.kind.as_str() {
"tab" => format!(
"sequence(&mut h, {seq:?}, false);\n assert_eq!(h.state().ui.ribbon_tab, {tab:?}, \"{seq} must switch to the {tab} tab\");",
seq = e.seq,
tab = e.target,
),
"command" if e.pending && !probe.contains_key(&e.target) => format!(
"sequence(&mut h, {seq:?}, false);\n assert!(h.state().dialog.is_none(), \"{seq} must not open a dialog\");\n assert!(h.state().message.is_none(), \"{seq} must not open a message box\");\n assert!(h.state().toast.is_none(), \"{seq} must not raise a toast\");\n assert!(h.state().session.journal.is_empty(), \"{seq} must not journal\");",
seq = e.seq,
),
"command" => match probe.get(&e.target).copied().unwrap_or(2) {
0 => format!(
"sequence(&mut h, {seq:?}, false);\n assert!(h.state().session.journal.iter().any(|(id, _)| id == {id:?}), \"{seq} must journal {id}\");",
seq = e.seq,
id = e.target,
),
1 => format!(
"sequence(&mut h, {seq:?}, false);\n assert!(h.state().message.is_none() && h.state().toast.is_none(), \"{seq} must run {id} cleanly\");",
seq = e.seq,
id = e.target,
),
_ => format!(
"sequence(&mut h, {seq:?}, false);\n assert!(h.state().message.is_some() || h.state().toast.is_some(), \"{seq} must invoke {id} and surface its missing-params error\");",
seq = e.seq,
id = e.target,
),
},
"dialog" => format!(
"sequence(&mut h, {seq:?}, false);\n assert!(h.state().dialog.is_some(), \"{seq} must open the {dialog} dialog\");",
seq = e.seq,
dialog = e.target,
),
"control" => format!(
"sequence(&mut h, {seq:?}, false);\n assert!(h.state().message.is_none() && h.state().toast.is_none(), \"{seq} must route to its control without an error box\");\n assert_eq!(h.state().keytips.prefix(), None);",
seq = e.seq,
),
"continue" => format!(
"sequence(&mut h, {seq:?}, false);\n assert_eq!(h.state().keytips.prefix(), Some({seq:?}), \"{seq} must stay armed waiting for more input\");",
seq = e.seq,
),
// All kinds are validated during parse; anything else cannot occur.
_ => String::new(),
};
out.push_str(&format!("\n#[test]\nfn {}() {{\n let mut h = harness(blank());\n {body}\n}}\n", test_name(&e.seq)));
}
out.push_str(&format!(
"\n#[test]\nfn generated_test_count_matches_the_table() {{\n const EXPECTED: usize = {};\n assert_eq!(gridcraft_ui_egui::keytips::keytip_table::SEQUENCES.len(), EXPECTED);\n}}\n",
entries.len()
));
out.push_str(SAMPLE_TEXT_FIRST);
out
}
const HEADER: &str = r#"//! GENERATED from keytips.json by `cargo xtask keytips`. Edit the JSON, not this file.
//!
//! One end-to-end test per keytip sequence, through the real egui input path.
//! Assertions are generated from a probe of the live engine catalog (blank
//! workbook, empty params), so each test proves exactly what its command does
//! today: journaled, ran clean, surfaced a missing-params error, opened a
//! dialog, switched tabs, waited for more input, or stayed silent (pending).
use egui::{Event, Key, Modifiers};
use egui::os::OperatingSystem;
use gridcraft_engine::Session;
use gridcraft_ui_egui::SheetApp;
type Harness = egui_kittest::Harness<'static, SheetApp>;
fn harness(session: Session) -> Harness {
let mut h = egui_kittest::Harness::builder()
.with_os(OperatingSystem::Nix)
.with_size(egui::vec2(1200.0, 800.0))
.build_ui_state(
|ui, app: &mut SheetApp| {
app.logic(ui.ctx());
app.ui(ui);
},
SheetApp::new(session, Default::default()),
);
h.run_steps(4);
h
}
fn blank() -> Session {
let mut s = Session::new();
s.new_workbook();
s
}
fn key_event(key: Key, pressed: bool, modifiers: Modifiers) -> Event {
Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }
}
fn events(h: &mut Harness, events: impl IntoIterator<Item = Event>) {
h.input_mut().events.extend(events);
h.run_steps(2);
}
fn key(h: &mut Harness, key: Key) {
events(h, [key_event(key, true, Modifiers::NONE), key_event(key, false, Modifiers::NONE)]);
}
fn letter(h: &mut Harness, key: Key, text_first: bool) {
let text = Event::Text(key.name().to_ascii_lowercase());
let pressed = key_event(key, true, Modifiers::NONE);
let pair = if text_first { [text, pressed] } else { [pressed, text] };
events(h, pair.into_iter().chain([key_event(key, false, Modifiers::NONE)]));
}
fn sequence(h: &mut Harness, seq: &str, text_first: bool) {
key(h, Key::F10);
for ch in seq.chars() {
let k = Key::from_name(&ch.to_string()).expect("sequence letters map to keys");
letter(h, k, text_first);
}
}
"#;
const SAMPLE_TEXT_FIRST: &str = r#"
#[test]
fn sample_sequences_work_with_text_before_key() {
// Some IME paths deliver Event::Text ahead of the key press; the matcher
// must pair them either way. One representative of each action kind;
// the dialog opener runs last so it does not eat later input.
// Each step resets first: Escape twice leaves keytip mode so the next
// F10 arms cleanly (a lingering prefix would make F10 disarm instead).
let mut h = harness(blank());
sequence(&mut h, "H", true);
assert_eq!(h.state().ui.ribbon_tab, "Home");
reset(&mut h);
sequence(&mut h, "WVG", true);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "view.gridlines"));
reset(&mut h);
sequence(&mut h, "HV", true);
assert_eq!(h.state().keytips.prefix(), Some("HV"));
reset(&mut h);
sequence(&mut h, "NE", true);
assert!(h.state().dialog.is_none() && h.state().message.is_none() && h.state().toast.is_none());
reset(&mut h);
sequence(&mut h, "HVS", true);
assert!(h.state().dialog.is_some(), "HVS must open the paste-special dialog");
}
fn reset(h: &mut Harness) {
key(h, Key::Escape); // clear any prefix, back to the armed root
key(h, Key::Escape); // leave keytip mode entirely
}
"#;

View File

@ -7,6 +7,7 @@
mod assets;
mod ico;
mod keytips;
mod layers;
#[cfg_attr(not(feature = "engine"), allow(dead_code))]
mod parity;
@ -27,6 +28,8 @@ commands:
wasm cargo check --target wasm32-unknown-unknown for the L0-L5 crates (+ gridcraft-web)
parity [--check] [--out PATH]
write docs/parity-checklist.md from gridcraft_engine::catalog (implemented vs missing ids)
keytips [--check]
regenerate crates/ui-egui/src/keytips/generated.rs from keytips.json (--check: fail if stale)
version [set X.Y.Z[-pre]]
print or set the workspace version ([workspace.package] in Cargo.toml)
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
@ -44,6 +47,7 @@ fn main() -> ExitCode {
Some("ci") => cmd_ci(),
Some("stats") => stats::run(&root(), rest.contains(&"--exact")),
Some("parity") => parity::run(&root(), &rest),
Some("keytips") => keytips::run(&root(), &rest),
Some("ico") => ico::run(&rest),
Some("version") => version::run(&root(), &rest),
Some("-h" | "--help" | "help") | None => {
@ -194,6 +198,7 @@ fn cmd_ci() -> Result<(), String> {
}
}),
),
("keytips", Box::new(|| keytips::run(&root(), &["--check"]))),
];
let mut done = Vec::new();
for (name, f) in &steps {