diff --git a/contributors/people.toml b/contributors/people.toml index 0099324..5d343e9 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -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 + diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 12ef907..50865a2 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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> = 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 diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 857a13a..3aceab0 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/ui-egui/src/keytips.rs b/crates/ui-egui/src/keytips.rs index a5bf155..e1163d9 100644 --- a/crates/ui-egui/src/keytips.rs +++ b/crates/ui-egui/src/keytips.rs @@ -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 => {} } } } diff --git a/crates/ui-egui/src/keytips/keytip_table.rs b/crates/ui-egui/src/keytips/keytip_table.rs new file mode 100644 index 0000000..9e2735f --- /dev/null +++ b/crates/ui-egui/src/keytips/keytip_table.rs @@ -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 { + 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:`). +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"); + } +} diff --git a/crates/ui-egui/src/keytips/keytip_table/generated.rs b/crates/ui-egui/src/keytips/keytip_table/generated.rs new file mode 100644 index 0000000..143c157 --- /dev/null +++ b/crates/ui-egui/src/keytips/keytip_table/generated.rs @@ -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) +]; diff --git a/crates/ui-egui/src/keytips/keytips.json b/crates/ui-egui/src/keytips/keytips.json new file mode 100644 index 0000000..cbb8e04 --- /dev/null +++ b/crates/ui-egui/src/keytips/keytips.json @@ -0,0 +1,2526 @@ +{ + "doc": [ + "Single source of truth for Excel-style sequential access keys (Alt+H+X+X style).", + "The generated keytip_table/generated.rs is produced from this file: `cargo xtask keytips`.", + "Adding or removing a shortcut is a one-line edit here. Rules:", + " - seq: the access-key sequence, letters and digits only (two-letter units like \"JI\" are one entry).", + " - kind: \"tab\" (switches a ribbon tab), \"command\" (runs a command id), \"dialog\" (opens a dialog by name),", + " \"continue\" (an explicit group level with no deeper sequences yet).", + " - Intermediate levels need no entries: the matcher waits automatically when deeper sequences exist.", + " - pending: true = the command does not exist yet; the sequence stays reserved and silent until it lands.", + " - Keep entries grouped by tab and sorted by sequence within a group; the FIRST entry for a tab is its canonical badge (legacy aliases come after it)." + ], + "sequences": [ + { + "seq": "H", + "kind": "tab", + "target": "Home" + }, + { + "seq": "N", + "kind": "tab", + "target": "Insert" + }, + { + "seq": "JI", + "kind": "tab", + "target": "Draw", + "note": "Two-letter unit. Deeper Draw letters differ between Excel and GridCraft; left blank for future contributors." + }, + { + "seq": "P", + "kind": "tab", + "target": "Page Layout" + }, + { + "seq": "M", + "kind": "tab", + "target": "Formulas" + }, + { + "seq": "A", + "kind": "tab", + "target": "Data" + }, + { + "seq": "R", + "kind": "tab", + "target": "Review" + }, + { + "seq": "W", + "kind": "tab", + "target": "View" + }, + { + "seq": "E", + "kind": "tab", + "target": "Home", + "note": "Legacy Edit-menu path (Excel 2003); leads to the same Home menus." + }, + { + "seq": "O", + "kind": "tab", + "target": "Home", + "note": "Legacy Format-menu path (Excel 2003); leads to the same Home menus." + }, + { + "seq": "HAR", + "kind": "command", + "target": "home.alignRight" + }, + { + "seq": "HAT", + "kind": "command", + "target": "home.alignTop", + "excel": "Excel: Top Align" + }, + { + "seq": "HAM", + "kind": "command", + "target": "home.alignMiddle", + "excel": "Excel: Middle Align" + }, + { + "seq": "HAB", + "kind": "command", + "target": "home.alignBottom", + "excel": "Excel: Bottom Align" + }, + { + "seq": "HAL", + "kind": "command", + "target": "home.alignLeft" + }, + { + "seq": "HAC", + "kind": "command", + "target": "home.alignCenter" + }, + { + "seq": "HVS", + "kind": "dialog", + "target": "pasteSpecial" + }, + { + "seq": "ES", + "kind": "dialog", + "target": "pasteSpecial", + "excel": "Classic Paste Special dialog (legacy Edit route); A/F/T/V are dialog mnemonics" + }, + { + "seq": "HOI", + "kind": "command", + "target": "home.autofitColumnWidth" + }, + { + "seq": "OCA", + "kind": "command", + "target": "home.autofitColumnWidth", + "note": "Legacy Format menu." + }, + { + "seq": "WVG", + "kind": "command", + "target": "view.gridlines" + }, + { + "seq": "H0", + "kind": "command", + "target": "home.increaseDecimal", + "excel": "Increase Decimals", + "note": "containment(Increase Decimal)" + }, + { + "seq": "H1", + "kind": "command", + "target": "home.bold", + "excel": "Bold", + "note": "" + }, + { + "seq": "H2", + "kind": "command", + "target": "home.italic", + "excel": "Italic", + "note": "" + }, + { + "seq": "H3", + "kind": "command", + "excel": "Excel: Underline; fires immediately (not a menu)", + "target": "home.underline" + }, + { + "seq": "H5", + "kind": "command", + "target": "home.decreaseIndent", + "excel": "Decrease Indent", + "note": "" + }, + { + "seq": "H6", + "kind": "command", + "target": "home.increaseIndent", + "excel": "Increase Indent", + "note": "" + }, + { + "seq": "H9", + "kind": "command", + "target": "home.decreaseDecimal", + "excel": "Decrease Decimals", + "note": "containment(Decrease Decimal)" + }, + { + "seq": "HAN", + "kind": "command", + "excel": "Accounting Format", + "note": "fires immediately in Excel; deeper aliases dropped as unreachable", + "target": "home.accounting" + }, + { + "seq": "HAS", + "kind": "command", + "target": "home.addIns", + "excel": "Add-ins", + "note": "containment(Add-ins)" + }, + { + "seq": "HB", + "kind": "continue", + "excel": "Borders Menu", + "note": "group level:" + }, + { + "seq": "HBA", + "kind": "command", + "target": "home.borders", + "excel": "All Borders", + "note": "containment(Borders)" + }, + { + "seq": "HBB", + "kind": "command", + "target": "home.borders", + "excel": "Bottom Double Border", + "note": "border-side TBD" + }, + { + "seq": "HBC", + "kind": "command", + "target": "home.borders", + "excel": "Top and Thick Bottom Border", + "note": "border-side TBD" + }, + { + "seq": "HBD", + "kind": "command", + "target": "home.borders", + "excel": "Top and Bottom Border", + "note": "border-side TBD" + }, + { + "seq": "HBE", + "kind": "command", + "target": "home.borders", + "excel": "Erase Border", + "note": "border-side TBD" + }, + { + "seq": "HBG", + "kind": "command", + "target": "home.drawBorder", + "excel": "Draw Border Grid", + "note": "containment(Draw Border)" + }, + { + "seq": "HBH", + "kind": "command", + "target": "home.borders", + "excel": "Thick Bottom Border", + "note": "border-side TBD" + }, + { + "seq": "HBI", + "kind": "command", + "target": "home.borders", + "excel": "Line Color", + "note": "Line Color (submenu TBD)" + }, + { + "seq": "HBL", + "kind": "command", + "target": "home.borders", + "excel": "Left Border", + "note": "border-side TBD" + }, + { + "seq": "HBM", + "kind": "command", + "target": "home.borders", + "excel": "More Borders", + "note": "containment(Borders)" + }, + { + "seq": "HBN", + "kind": "command", + "target": "home.borders", + "excel": "No Border", + "note": "border-side TBD" + }, + { + "seq": "HBO", + "kind": "command", + "target": "home.borders", + "excel": "Bottom Border", + "note": "border-side TBD" + }, + { + "seq": "HBP", + "kind": "command", + "target": "home.borders", + "excel": "Top Border", + "note": "border-side TBD" + }, + { + "seq": "HBR", + "kind": "command", + "target": "home.borders", + "excel": "Right Border", + "note": "border-side TBD" + }, + { + "seq": "HBS", + "kind": "command", + "target": "home.borders", + "excel": "Outside Borders", + "note": "containment(Borders)" + }, + { + "seq": "HBT", + "kind": "command", + "target": "home.borders", + "excel": "Thick Outside Borders", + "note": "border-side TBD" + }, + { + "seq": "HBU", + "kind": "command", + "target": "home.borders", + "excel": "Top and Double Bottom Border", + "note": "border-side TBD" + }, + { + "seq": "HBW", + "kind": "command", + "target": "home.drawBorder", + "excel": "Draw Border", + "note": "" + }, + { + "seq": "HBY", + "kind": "command", + "target": "home.borders", + "excel": "Line Style", + "note": "Line Style (submenu TBD)" + }, + { + "seq": "HC", + "kind": "command", + "excel": "Copy", + "note": "fires immediately in Excel; deeper aliases dropped as unreachable", + "target": "edit.copy" + }, + { + "seq": "HDC", + "kind": "command", + "target": "home.deleteColumns", + "excel": "Delete Columns", + "note": "Delete Columns" + }, + { + "seq": "HDD", + "kind": "dialog", + "target": "deleteCells", + "excel": "Delete Cells", + "note": "" + }, + { + "seq": "HDR", + "kind": "command", + "target": "home.deleteRows", + "excel": "Delete Rows", + "note": "Delete Rows" + }, + { + "seq": "HDS", + "kind": "command", + "target": "home.deleteSheet", + "excel": "Delete Sheet", + "note": "" + }, + { + "seq": "HEA", + "kind": "command", + "target": "edit.clearAll", + "excel": "Clear All", + "note": "" + }, + { + "seq": "HEC", + "kind": "command", + "target": "edit.clearContents", + "excel": "Clear Contents", + "note": "" + }, + { + "seq": "HEF", + "kind": "command", + "target": "edit.clearFormats", + "excel": "Clear Formats", + "note": "" + }, + { + "seq": "HEL", + "kind": "command", + "target": "edit.clearHyperlinks", + "excel": "Clear Hyperlinks", + "note": "" + }, + { + "seq": "HEM", + "kind": "command", + "target": "edit.clearComments", + "excel": "Clear Comments and Notes", + "note": "containment(Clear Comments and Notes)" + }, + { + "seq": "HFC", + "kind": "control", + "target": "home.fontColor", + "excel": "Font Color", + "note": "opens the font-color palette" + }, + { + "seq": "HFDC", + "kind": "command", + "target": "home.conditionalFormat", + "excel": "Find Conditional Formatting", + "note": "containment(Conditional Formatting)" + }, + { + "seq": "HFDF", + "kind": "dialog", + "target": "find", + "excel": "Find", + "note": "" + }, + { + "seq": "HFDG", + "kind": "dialog", + "target": "goTo", + "excel": "Go To", + "note": "" + }, + { + "seq": "HFDM", + "kind": "command", + "target": "edit.find", + "excel": "Find Notes", + "note": "containment(Find)" + }, + { + "seq": "HFDO", + "kind": "command", + "target": "home.selectObjects", + "pending": true, + "excel": "Select Objects", + "note": "Select Objects; reserved until the command lands" + }, + { + "seq": "HFDP", + "kind": "command", + "target": "arrange.selectionPane", + "excel": "Selection Pane", + "note": "" + }, + { + "seq": "HFDR", + "kind": "dialog", + "target": "find", + "excel": "Replace", + "note": "opens Find and Replace" + }, + { + "seq": "HFDS", + "kind": "dialog", + "target": "goToSpecial", + "excel": "Go To Special", + "note": "" + }, + { + "seq": "HFF", + "kind": "control", + "target": "home.fontName", + "excel": "Font Selection", + "note": "opens the font dropdown" + }, + { + "seq": "HFG", + "kind": "command", + "target": "home.increaseFontSize", + "excel": "Increase Font Size", + "note": "" + }, + { + "seq": "HFI", + "kind": "continue", + "excel": "Fill", + "note": "group level: containment(Fill Up)" + }, + { + "seq": "HFIA", + "kind": "command", + "target": "home.fillAcrossWorksheets", + "pending": true, + "excel": "Fill Across Worksheets", + "note": "Fill Across Worksheets; reserved until the command lands" + }, + { + "seq": "HFID", + "kind": "command", + "target": "edit.fillDown", + "excel": "Fill Down", + "note": "" + }, + { + "seq": "HFIF", + "kind": "command", + "target": "edit.flashFill", + "excel": "Flash Fill", + "note": "" + }, + { + "seq": "HFIJ", + "kind": "command", + "target": "home.justify", + "excel": "Fill Justify", + "note": "containment(Justify)" + }, + { + "seq": "HFIL", + "kind": "command", + "target": "edit.fillLeft", + "excel": "Fill Left", + "note": "" + }, + { + "seq": "HFIR", + "kind": "command", + "target": "edit.fillRight", + "excel": "Fill Right", + "note": "" + }, + { + "seq": "HFIS", + "kind": "dialog", + "target": "series", + "excel": "Fill Series", + "note": "containment(Series)" + }, + { + "seq": "HFIU", + "kind": "command", + "target": "edit.fillUp", + "excel": "Fill Up", + "note": "" + }, + { + "seq": "HFK", + "kind": "command", + "target": "home.decreaseFontSize", + "excel": "Decrease Font Size", + "note": "" + }, + { + "seq": "HFM", + "kind": "dialog", + "target": "formatCells", + "excel": "More Number Formats", + "note": "More Number Formats opens Format Cells" + }, + { + "seq": "HFP", + "kind": "command", + "target": "edit.formatPainter", + "excel": "Format Painter", + "note": "" + }, + { + "seq": "HFQ", + "kind": "control", + "excel": "Orientation Menu", + "note": "group level: containment(Orientation)", + "target": "home.orientation" + }, + { + "seq": "HFS", + "kind": "control", + "target": "home.fontSize", + "excel": "Font Size", + "note": "opens the font-size dropdown" + }, + { + "seq": "HH", + "kind": "control", + "target": "home.fillColor", + "excel": "Fill Color", + "note": "opens the fill-color palette" + }, + { + "seq": "HIC", + "kind": "command", + "target": "home.insertColumns", + "excel": "Insert Columns", + "note": "Insert Columns" + }, + { + "seq": "HIE", + "kind": "dialog", + "target": "insertCells", + "excel": "Insert Copied Cells", + "note": "Insert Cells (copied cells TBD)" + }, + { + "seq": "HII", + "kind": "dialog", + "target": "insertCells", + "excel": "Insert Cells", + "note": "" + }, + { + "seq": "HIR", + "kind": "command", + "target": "home.insertRows", + "excel": "Insert Rows", + "note": "Insert Rows" + }, + { + "seq": "HIS", + "kind": "command", + "target": "home.insertSheet", + "excel": "Insert Sheet", + "note": "" + }, + { + "seq": "HJ", + "kind": "continue", + "excel": "Cell Styles", + "note": "group level:" + }, + { + "seq": "HJN", + "kind": "command", + "target": "home.newCellStyle", + "excel": "New Cell Style", + "note": "" + }, + { + "seq": "HK", + "kind": "command", + "target": "home.comma", + "excel": "Comma Style", + "note": "" + }, + { + "seq": "HL", + "kind": "continue", + "excel": "Conditional Formatting", + "note": "group level: containment(Conditional Formatting)" + }, + { + "seq": "HLC", + "kind": "continue", + "excel": "Clear Rules Menu", + "note": "group level: containment(Clear Rules)" + }, + { + "seq": "HLCE", + "kind": "command", + "target": "home.clearRules", + "excel": "Clear Rules From Sheet", + "note": "containment(Clear Rules)" + }, + { + "seq": "HLCS", + "kind": "command", + "target": "home.clearRules", + "excel": "Clear Rules From Cells", + "note": "containment(Clear Rules)" + }, + { + "seq": "HLCT", + "kind": "command", + "target": "home.clearRules", + "excel": "Clear Rules From Table", + "note": "containment(Clear Rules)" + }, + { + "seq": "HLD", + "kind": "command", + "target": "home.conditionalFormat", + "excel": "Data Bars", + "note": "Data Bars (submenu TBD)" + }, + { + "seq": "HLH", + "kind": "command", + "target": "home.conditionalFormat", + "excel": "Highlight Cells", + "note": "Highlight Cells (submenu TBD)" + }, + { + "seq": "HLN", + "kind": "command", + "target": "home.conditionalFormat", + "excel": "New Rule", + "note": "New Rule (submenu TBD)" + }, + { + "seq": "HLR", + "kind": "command", + "target": "home.manageRules", + "excel": "Manage Rules", + "note": "containment(Manage Rules)" + }, + { + "seq": "HLT", + "kind": "command", + "target": "home.conditionalFormat", + "excel": "Top/Bottom Rules", + "note": "Top/Bottom (submenu TBD)" + }, + { + "seq": "HM", + "kind": "continue", + "excel": "Merge & Center Menu", + "note": "group level: containment(Merge & Center)" + }, + { + "seq": "HMA", + "kind": "command", + "target": "home.mergeAcross", + "excel": "Merge Across", + "note": "" + }, + { + "seq": "HMC", + "kind": "command", + "target": "home.mergeCenter", + "excel": "Merge & Center", + "note": "" + }, + { + "seq": "HMM", + "kind": "command", + "target": "home.mergeCells", + "excel": "Merge Cells", + "note": "" + }, + { + "seq": "HMU", + "kind": "command", + "target": "home.unmergeCells", + "excel": "Unmerge Cells", + "note": "" + }, + { + "seq": "HN", + "kind": "control", + "target": "home.numberFormat", + "excel": "Number Format", + "note": "opens the number-format dropdown" + }, + { + "seq": "HOA", + "kind": "command", + "target": "home.autofitRowHeight", + "excel": "AutoFit Row Height", + "note": "" + }, + { + "seq": "HOD", + "kind": "dialog", + "target": "defaultWidth", + "excel": "Default Column Width", + "note": "containment(Column Width)" + }, + { + "seq": "HOE", + "kind": "command", + "target": "home.formatCells", + "excel": "Format Cells", + "note": "" + }, + { + "seq": "HOH", + "kind": "dialog", + "target": "rowHeight", + "excel": "Row Height", + "note": "" + }, + { + "seq": "HOL", + "kind": "command", + "target": "home.lockCell", + "excel": "Lock Cell", + "note": "containment(Lock Cell)" + }, + { + "seq": "HOM", + "kind": "dialog", + "target": "moveSheet", + "excel": "Move or Copy Sheet", + "note": "" + }, + { + "seq": "HOP", + "kind": "dialog", + "target": "protectSheet", + "excel": "Protect Sheet", + "note": "" + }, + { + "seq": "HOR", + "kind": "dialog", + "target": "renameSheet", + "excel": "Rename Sheet", + "note": "" + }, + { + "seq": "HOT", + "kind": "command", + "target": "sheet.tabColor", + "excel": "Tab Color", + "note": "" + }, + { + "seq": "HOUC", + "kind": "command", + "target": "home.hideColumns", + "excel": "Hide Columns", + "note": "" + }, + { + "seq": "HOUH", + "kind": "command", + "target": "sheet.unhide", + "excel": "Unhide Sheet", + "note": "" + }, + { + "seq": "HOUL", + "kind": "command", + "target": "home.unhideColumns", + "excel": "Unhide Columns", + "note": "" + }, + { + "seq": "HOUO", + "kind": "command", + "target": "home.unhideRows", + "excel": "Unhide Rows", + "note": "" + }, + { + "seq": "HOUR", + "kind": "command", + "target": "home.hideRows", + "excel": "Hide Rows", + "note": "" + }, + { + "seq": "HOUS", + "kind": "command", + "target": "sheet.hide", + "excel": "Hide Sheet", + "note": "" + }, + { + "seq": "HOW", + "kind": "dialog", + "target": "columnWidth", + "excel": "Column Width", + "note": "" + }, + { + "seq": "HP", + "kind": "command", + "target": "home.percent", + "excel": "Excel: Percentage Style" + }, + { + "seq": "HSC", + "kind": "command", + "target": "data.clearFilter", + "excel": "Clear Filter", + "note": "" + }, + { + "seq": "HSF", + "kind": "command", + "target": "data.filter", + "excel": "Filter", + "note": "" + }, + { + "seq": "HSO", + "kind": "command", + "target": "data.sortDescending", + "excel": "Sort Z to A", + "note": "" + }, + { + "seq": "HSS", + "kind": "command", + "target": "data.sortAscending", + "excel": "Sort A to Z", + "note": "" + }, + { + "seq": "HSU", + "kind": "command", + "target": "data.sort", + "excel": "Custom Sort", + "note": "" + }, + { + "seq": "HT", + "kind": "continue", + "excel": "Format as Table", + "note": "group level:" + }, + { + "seq": "HTN", + "kind": "command", + "target": "table.style", + "excel": "New Table Style", + "note": "" + }, + { + "seq": "HTP", + "kind": "command", + "target": "table.style", + "excel": "New Pivot Table Style", + "note": "" + }, + { + "seq": "HU", + "kind": "continue", + "excel": "AutoSum", + "note": "group level:" + }, + { + "seq": "HUA", + "kind": "command", + "target": "formulas.autoSum", + "excel": "Auto Average", + "note": "AutoSum: Average (variant TBD)" + }, + { + "seq": "HUC", + "kind": "command", + "target": "formulas.autoSum", + "excel": "Auto Count Numbers", + "note": "AutoSum: Count (variant TBD)" + }, + { + "seq": "HUI", + "kind": "command", + "target": "formulas.autoSum", + "excel": "Auto Min", + "note": "AutoSum: Min (variant TBD)" + }, + { + "seq": "HUM", + "kind": "command", + "target": "formulas.autoSum", + "excel": "Auto Max", + "note": "AutoSum: Max (variant TBD)" + }, + { + "seq": "HV", + "kind": "continue", + "excel": "Paste", + "note": "group level:" + }, + { + "seq": "HVP", + "kind": "command", + "target": "edit.pasteSpecial", + "excel": "Paste", + "note": "" + }, + { + "seq": "HW", + "kind": "continue", + "excel": "Wrap Text Menu", + "note": "group level: containment(Wrap Text)" + }, + { + "seq": "HWW", + "kind": "command", + "target": "home.wrapText", + "excel": "Wrap Text", + "note": "" + }, + { + "seq": "HX", + "kind": "command", + "target": "edit.cut", + "excel": "Cut", + "note": "" + }, + { + "seq": "NC1", + "kind": "control", + "target": "insert.chart.column", + "excel": "Column Chart", + "note": "Column chart (kind TBD)" + }, + { + "seq": "NC2", + "kind": "command", + "target": "review.newComment", + "excel": "New Comment", + "note": "" + }, + { + "seq": "NCM", + "kind": "dialog", + "target": "comment", + "excel": "Excel: New Comment (modern)" + }, + { + "seq": "NCB", + "kind": "command", + "target": "insert.checkbox", + "excel": "Checkbox", + "note": "" + }, + { + "seq": "ND", + "kind": "control", + "target": "insert.chart.scatter", + "excel": "Scatter Chart", + "note": "Scatter (kind TBD)" + }, + { + "seq": "NE", + "kind": "command", + "target": "insert.equation", + "pending": true, + "excel": "Equations", + "note": "Equations; reserved until the command lands" + }, + { + "seq": "NF", + "kind": "continue", + "excel": "Online Pictures", + "note": "group level: containment(Picture)" + }, + { + "seq": "NFP", + "kind": "continue", + "excel": "From Picture Menu", + "note": "group level: containment(Picture)" + }, + { + "seq": "NFPF", + "kind": "command", + "target": "insert.picture", + "excel": "Picture From File", + "note": "containment(Picture)" + }, + { + "seq": "NH1", + "kind": "dialog", + "target": "headerFooter", + "excel": "Header & Footer", + "note": "" + }, + { + "seq": "NHF", + "kind": "command", + "target": "pageLayout.headerFooter", + "excel": "Header & Footer", + "note": "" + }, + { + "seq": "NHI", + "kind": "control", + "target": "insert.chart.hierarchy", + "excel": "Hierarchy Chart", + "note": "Hierarchy (kind TBD)" + }, + { + "seq": "NI1", + "kind": "command", + "target": "insert.chart", + "excel": "Waterfall Chart", + "note": "Waterfall (kind TBD)" + }, + { + "seq": "NI2", + "kind": "dialog", + "target": "insertLink", + "excel": "Link", + "note": "" + }, + { + "seq": "NJ", + "kind": "command", + "target": "insert.object", + "pending": true, + "excel": "Object", + "note": "insert.object missing; reserved until the command lands" + }, + { + "seq": "NM1T", + "kind": "command", + "target": "insert.picture", + "excel": "SmartArt Picture", + "note": "containment(Picture)" + }, + { + "seq": "NM2", + "kind": "command", + "target": "insert.chart", + "excel": "Map Chart", + "note": "Map (kind TBD)" + }, + { + "seq": "NN1", + "kind": "control", + "target": "insert.chart.line", + "excel": "Line Chart", + "note": "Line chart (kind TBD)" + }, + { + "seq": "NNS", + "kind": "command", + "target": "insert.icons", + "excel": "Icons", + "note": "" + }, + { + "seq": "NP", + "kind": "continue", + "excel": "Picture", + "note": "group level:" + }, + { + "seq": "NPB", + "kind": "command", + "target": "insert.picture", + "excel": "Picture from File", + "note": "containment(Picture)" + }, + { + "seq": "NPF", + "kind": "command", + "target": "insert.picture", + "excel": "Place in Cell", + "note": "Picture in cell (source TBD)" + }, + { + "seq": "NPI", + "kind": "command", + "target": "insert.picture", + "excel": "Place in Cell Menu", + "note": "Picture in cell (source TBD)" + }, + { + "seq": "NPO", + "kind": "command", + "target": "insert.picture", + "excel": "Place over Cells Menu", + "note": "Picture over cells (source TBD)" + }, + { + "seq": "NQ", + "kind": "control", + "target": "insert.chart.pie", + "excel": "Pie Chart", + "note": "Pie (kind TBD)" + }, + { + "seq": "NR", + "kind": "command", + "target": "insert.recommendedCharts", + "excel": "Recommended Charts", + "note": "" + }, + { + "seq": "NSA", + "kind": "control", + "target": "insert.chart.statistic", + "excel": "Statistical Chart", + "note": "Statistical chart (kind TBD)" + }, + { + "seq": "NSC", + "kind": "command", + "target": "insert.screenshot", + "pending": true, + "excel": "Screenshot Menu", + "note": "Screenshot; reserved until the command lands" + }, + { + "seq": "NSD", + "kind": "command", + "target": "insert.chart", + "excel": "Combo Chart", + "note": "Combo (kind TBD)" + }, + { + "seq": "NSF", + "kind": "command", + "target": "insert.slicer", + "pending": false, + "excel": "Slicer", + "note": "insert.slicer missing (gap #8); reserved until the command lands" + }, + { + "seq": "NSH", + "kind": "command", + "target": "insert.shape", + "excel": "Shapes", + "note": "" + }, + { + "seq": "NSP", + "kind": "command", + "target": "insert.recommendedPivotTables", + "excel": "Recommended PivotTables", + "note": "" + }, + { + "seq": "NST", + "kind": "command", + "target": "insert.timeline", + "pending": true, + "excel": "Timeline", + "note": "insert.timeline missing (gap #8); reserved until the command lands" + }, + { + "seq": "NSZ", + "kind": "command", + "target": "insert.pivotChart", + "pending": false, + "excel": "PivotChart", + "note": "PivotChart; reserved until the command lands" + }, + { + "seq": "NT", + "kind": "command", + "target": "insert.table", + "excel": "Table", + "note": "" + }, + { + "seq": "NU", + "kind": "command", + "target": "insert.symbol", + "excel": "Symbols", + "note": "containment(Symbol)" + }, + { + "seq": "NZS", + "kind": "command", + "target": "insert.symbol", + "excel": "Excel: Symbol (modern)" + }, + { + "seq": "NV", + "kind": "command", + "target": "insert.pivotTable", + "excel": "PivotTable", + "note": "" + }, + { + "seq": "NW", + "kind": "command", + "target": "insert.wordArt", + "pending": true, + "excel": "WordArt", + "note": "WordArt; reserved until the command lands" + }, + { + "seq": "NX", + "kind": "command", + "target": "insert.textBox", + "excel": "Text Box", + "note": "" + }, + { + "seq": "NZK", + "kind": "continue", + "excel": "Sparklines Menu", + "note": "group level:" + }, + { + "seq": "NZKSL", + "kind": "dialog", + "target": "sparkline", + "excel": "Line Sparkline", + "note": "" + }, + { + "seq": "NZKSO", + "kind": "dialog", + "target": "sparkline", + "excel": "Column Sparkline", + "note": "" + }, + { + "seq": "NZKSW", + "kind": "dialog", + "target": "sparkline", + "excel": "Win/Loss Sparkline", + "note": "" + }, + { + "seq": "NZTX", + "kind": "command", + "target": "insert.textBox", + "excel": "Text Box", + "note": "" + }, + { + "seq": "NZT", + "kind": "command", + "target": "insert.textBox", + "excel": "Excel: Text Box (modern)" + }, + { + "seq": "PAE", + "kind": "command", + "target": "arrange.sendBackward", + "excel": "Send Backward", + "note": "" + }, + { + "seq": "PAF", + "kind": "command", + "target": "arrange.bringForward", + "excel": "Bring Forward", + "note": "" + }, + { + "seq": "PB", + "kind": "continue", + "excel": "Breaks", + "note": "group level:" + }, + { + "seq": "PBI", + "kind": "command", + "target": "pageLayout.breaks", + "excel": "Insert Page Break", + "note": "Insert Page Break" + }, + { + "seq": "PBR", + "kind": "command", + "target": "pageLayout.breaks", + "excel": "Remove Page Break", + "note": "Remove Page Break" + }, + { + "seq": "PGB", + "kind": "command", + "target": "pageLayout.background", + "excel": "Background", + "note": "Excel badge GB (Background in the Page Setup group)" + }, + { + "seq": "PI", + "kind": "command", + "target": "pageLayout.printTitles", + "excel": "Print Titles", + "note": "" + }, + { + "seq": "PM", + "kind": "continue", + "excel": "Margins", + "note": "group level:" + }, + { + "seq": "PMA", + "kind": "command", + "target": "pageLayout.margins", + "excel": "Custom Margins", + "note": "containment(Margins)" + }, + { + "seq": "PO", + "kind": "command", + "target": "pageLayout.orientation", + "excel": "Orientation", + "note": "" + }, + { + "seq": "PPG", + "kind": "command", + "target": "pageLayout.printGridlines", + "excel": "Print Gridlines", + "note": "" + }, + { + "seq": "PPH", + "kind": "command", + "target": "pageLayout.printHeadings", + "excel": "Print Headings", + "note": "" + }, + { + "seq": "PR", + "kind": "continue", + "excel": "Print Area", + "note": "group level:" + }, + { + "seq": "PRS", + "kind": "command", + "target": "pageLayout.printArea", + "excel": "Set Print Area", + "note": "" + }, + { + "seq": "PSP", + "kind": "dialog", + "target": "pageSetup", + "excel": "Page Setup", + "note": "" + }, + { + "seq": "PSZ", + "kind": "command", + "target": "pageLayout.size", + "excel": "Size", + "note": "" + }, + { + "seq": "PTC", + "kind": "command", + "target": "pageLayout.themeColors", + "excel": "Theme Colors", + "note": "containment(Colors)" + }, + { + "seq": "PTE", + "kind": "command", + "target": "pageLayout.themeEffects", + "excel": "Theme Effects", + "note": "containment(Effects)" + }, + { + "seq": "PTF", + "kind": "command", + "target": "pageLayout.themeFonts", + "excel": "Theme Fonts", + "note": "containment(Fonts)" + }, + { + "seq": "PTH", + "kind": "continue", + "excel": "Themes", + "note": "group level:" + }, + { + "seq": "PTHB", + "kind": "command", + "target": "pageLayout.theme", + "excel": "Browse Themes", + "note": "containment(Themes)" + }, + { + "seq": "PVG", + "kind": "command", + "target": "view.gridlines", + "excel": "View Gridlines", + "note": "" + }, + { + "seq": "PVH", + "kind": "command", + "target": "view.headings", + "excel": "View Headings", + "note": "" + }, + { + "seq": "MA", + "kind": "continue", + "excel": "Remove Arrows Menu", + "note": "group level: containment(Remove Arrows)" + }, + { + "seq": "MAA", + "kind": "command", + "target": "formulas.removeArrows", + "excel": "Remove All Arrows", + "note": "landed in the catalog; live since T7 probe" + }, + { + "seq": "MAP", + "kind": "command", + "target": "formulas.removePrecedentArrows", + "pending": true, + "excel": "Remove Precedent Arrows", + "note": "Remove Precedent Arrows; reserved until the command lands" + }, + { + "seq": "MB", + "kind": "command", + "target": "formulas.calculateNow", + "excel": "Calculate Now", + "note": "" + }, + { + "seq": "MC", + "kind": "command", + "target": "formulas.createFromSelection", + "excel": "Create From Selection", + "note": "" + }, + { + "seq": "MD", + "kind": "command", + "target": "formulas.traceDependents", + "excel": "Trace Dependents", + "note": "" + }, + { + "seq": "ME", + "kind": "control", + "excel": "Date & Time Menu", + "note": "group level: containment(Date & Time)", + "target": "formulas.dateTime" + }, + { + "seq": "MF", + "kind": "dialog", + "target": "insertFunction", + "excel": "Insert Function", + "note": "" + }, + { + "seq": "MG", + "kind": "control", + "excel": "Math & Trig Menu", + "note": "group level: containment(Math & Trig)", + "target": "formulas.mathTrig" + }, + { + "seq": "MH", + "kind": "command", + "target": "formulas.showFormulas", + "excel": "Show Formulas", + "note": "" + }, + { + "seq": "MI", + "kind": "continue", + "excel": "Financial Menu", + "note": "group level: containment(Financial)" + }, + { + "seq": "MI1", + "kind": "control", + "target": "formulas.financial", + "excel": "Excel: Financial; first item of the dropdown" + }, + { + "seq": "MJ", + "kind": "command", + "target": "formulas.calculateSheet", + "excel": "Calculate Sheet", + "note": "" + }, + { + "seq": "MK", + "kind": "continue", + "excel": "Error Checking Menu", + "note": "group level: containment(Error Checking)" + }, + { + "seq": "MKK", + "kind": "dialog", + "target": "errorChecking", + "excel": "Error Checking", + "note": "" + }, + { + "seq": "ML", + "kind": "control", + "excel": "Logical Menu", + "note": "group level: containment(Logical)", + "target": "formulas.logical" + }, + { + "seq": "MM", + "kind": "dialog", + "target": "defineName", + "excel": "Define Name", + "note": "" + }, + { + "seq": "MN", + "kind": "dialog", + "target": "nameManager", + "excel": "Name Manager", + "note": "" + }, + { + "seq": "MO", + "kind": "control", + "excel": "Lookup & Reference Menu", + "note": "group level: containment(Lookup & Reference)", + "target": "formulas.lookup" + }, + { + "seq": "MP", + "kind": "command", + "target": "formulas.tracePrecedents", + "excel": "Trace Precedents", + "note": "" + }, + { + "seq": "MQ", + "kind": "control", + "excel": "More Functions Menu", + "note": "group level: containment(More Functions)", + "target": "formulas.moreFunctions" + }, + { + "seq": "MS", + "kind": "continue", + "excel": "Use in Formula Menu", + "note": "group level: containment(Use in Formula)" + }, + { + "seq": "MSP", + "kind": "command", + "target": "formulas.pasteName", + "excel": "Paste Names", + "note": "reserved until the command lands", + "pending": true + }, + { + "seq": "MT", + "kind": "control", + "excel": "Text Menu", + "note": "group level: containment(Text)", + "target": "formulas.text" + }, + { + "seq": "MU", + "kind": "continue", + "excel": "AutoSum Menu", + "note": "group level:" + }, + { + "seq": "MUA", + "kind": "command", + "target": "formulas.autoSum", + "excel": "Average", + "note": "AutoSum: Average (variant TBD)" + }, + { + "seq": "MUC", + "kind": "command", + "target": "formulas.autoSum", + "excel": "Count Numbers", + "note": "AutoSum: Count (variant TBD)" + }, + { + "seq": "MUF", + "kind": "command", + "target": "formulas.moreFunctions", + "excel": "More Functions (AutoSum)", + "note": "containment(More Functions)" + }, + { + "seq": "MUI", + "kind": "command", + "target": "formulas.autoSum", + "excel": "Min", + "note": "AutoSum: Min (variant TBD)" + }, + { + "seq": "MUM", + "kind": "command", + "target": "formulas.autoSum", + "excel": "Max", + "note": "AutoSum: Max (variant TBD)" + }, + { + "seq": "MUS", + "kind": "command", + "target": "formulas.autoSum", + "excel": "Sum", + "note": "" + }, + { + "seq": "MV", + "kind": "dialog", + "target": "evaluateFormula", + "excel": "Evaluate Formula", + "note": "" + }, + { + "seq": "MW", + "kind": "command", + "target": "formulas.watchWindow", + "excel": "Watch Window", + "note": "" + }, + { + "seq": "MX", + "kind": "continue", + "excel": "Calculation Options Menu", + "note": "group level: containment(Calculation Options)" + }, + { + "seq": "MXA", + "kind": "command", + "target": "formulas.calculationOptions", + "excel": "Automatic Calculation", + "note": "Calculation options: Automatic" + }, + { + "seq": "MXM", + "kind": "command", + "target": "formulas.calculationOptions", + "excel": "Manual Calculation", + "note": "Calculation options: Manual" + }, + { + "seq": "MXP", + "kind": "command", + "target": "formulas.calculationOptions", + "excel": "Partial Calculation", + "note": "Calculation options: Partial" + }, + { + "seq": "AB", + "kind": "dialog", + "target": "subtotal", + "excel": "Subtotal", + "note": "" + }, + { + "seq": "AC", + "kind": "command", + "target": "data.clearFilter", + "excel": "Clear Filter", + "note": "" + }, + { + "seq": "AD", + "kind": "command", + "target": "data.consolidate", + "excel": "Consolidate", + "note": "" + }, + { + "seq": "AE", + "kind": "dialog", + "target": "textToColumns", + "excel": "Text to Columns", + "note": "" + }, + { + "seq": "AF1", + "kind": "continue", + "excel": "Data from Picture Menu", + "note": "group level: feature gap; reserved until the command lands" + }, + { + "seq": "AF1C", + "kind": "command", + "target": "insert.picture", + "excel": "Picture from Clipboard (source TBD)" + }, + { + "seq": "AF1P", + "kind": "command", + "target": "insert.picture", + "excel": "Picture from File (source TBD)" + }, + { + "seq": "AFA", + "kind": "command", + "target": "data.fromAccess", + "pending": true, + "excel": "Get External Data: From Access", + "note": "gap #12 Get & Transform; reserved until the command lands" + }, + { + "seq": "AFC", + "kind": "command", + "target": "data.forecastSheet", + "pending": false, + "excel": "Forecast Sheet", + "note": "IN FLIGHT: vangel76 PR #325; reserved until the command lands" + }, + { + "seq": "AFE", + "kind": "command", + "target": "data.analyzeData", + "pending": true, + "excel": "Analyze Data", + "note": "AI feature GAP; reserved until the command lands" + }, + { + "seq": "AFF", + "kind": "command", + "target": "edit.flashFill", + "excel": "Flash Fill", + "note": "" + }, + { + "seq": "AFO", + "kind": "command", + "target": "data.fromOtherSources", + "pending": true, + "excel": "Get External Data: From Other Sources", + "note": "gap #12; reserved until the command lands" + }, + { + "seq": "AFT", + "kind": "command", + "target": "data.fromText", + "pending": true, + "excel": "Get External Data: From Text", + "note": "gap #12; reserved until the command lands" + }, + { + "seq": "AFW", + "kind": "command", + "target": "data.fromWeb", + "pending": true, + "excel": "Get External Data: From Web", + "note": "reserved until the command lands" + }, + { + "seq": "AG", + "kind": "continue", + "excel": "Group Menu", + "note": "group level: containment(Group)" + }, + { + "seq": "AGA", + "kind": "command", + "target": "data.autoOutline", + "pending": true, + "excel": "Auto Outline", + "note": "Auto Outline; reserved until the command lands" + }, + { + "seq": "AGG", + "kind": "command", + "target": "data.group", + "excel": "Group", + "note": "" + }, + { + "seq": "AH", + "kind": "command", + "target": "data.hideDetail", + "excel": "Hide Detail", + "note": "" + }, + { + "seq": "AJ", + "kind": "command", + "target": "data.showDetail", + "excel": "Show Detail", + "note": "" + }, + { + "seq": "AK", + "kind": "command", + "target": "data.queriesConnections", + "excel": "Edit Links", + "note": "Edit Links (Queries & Connections);" + }, + { + "seq": "AM", + "kind": "dialog", + "target": "removeDuplicates", + "excel": "Remove Duplicates", + "note": "" + }, + { + "seq": "AO", + "kind": "command", + "target": "data.queriesConnections", + "excel": "Queries & Connections", + "note": "" + }, + { + "seq": "AP1", + "kind": "command", + "target": "file.properties", + "excel": "Properties", + "note": "" + }, + { + "seq": "APN", + "kind": "continue", + "excel": "Get Data (Power Query) Menu", + "note": "group level: Power Query variants = GAP" + }, + { + "seq": "APND", + "kind": "command", + "target": "data.fromDatabase", + "pending": true, + "excel": "From Database (Microsoft Query)", + "note": "gap #12; reserved until the command lands" + }, + { + "seq": "APNE", + "kind": "command", + "target": "data.powerQueryEditor", + "pending": true, + "excel": "Launch Power Query Editor", + "note": "gap #12; reserved until the command lands" + }, + { + "seq": "APNG", + "kind": "command", + "target": "data.getData", + "excel": "Get Data (Power Query)", + "note": "containment(Get Data)" + }, + { + "seq": "APNH", + "kind": "command", + "target": "data.fromHtml", + "pending": true, + "excel": "From HTML", + "note": "gap #12; reserved until the command lands" + }, + { + "seq": "APNL", + "kind": "command", + "target": "data.fromSqlServer", + "pending": true, + "excel": "From SQL Server ODBC", + "note": "gap #12; reserved until the command lands" + }, + { + "seq": "APNS", + "kind": "command", + "target": "data.dataSourceSettings", + "pending": true, + "excel": "Data Source Settings", + "note": "gap #12; reserved until the command lands" + }, + { + "seq": "APNT", + "kind": "command", + "target": "data.fromTextLegacy", + "pending": true, + "excel": "From Text (Legacy)", + "note": "gap #12; reserved until the command lands" + }, + { + "seq": "APR", + "kind": "command", + "target": "data.recentSources", + "pending": true, + "excel": "Recent Sources", + "note": "gap #12; reserved until the command lands" + }, + { + "seq": "APS", + "kind": "command", + "target": "data.queriesConnections", + "excel": "Show Queries", + "note": "" + }, + { + "seq": "APT", + "kind": "command", + "target": "data.fromTable", + "pending": true, + "excel": "From Table (query)", + "note": "gap #12; reserved until the command lands" + }, + { + "seq": "AQ", + "kind": "command", + "target": "data.advancedFilter", + "excel": "Advanced Filter", + "note": "" + }, + { + "seq": "AR", + "kind": "continue", + "note": "group: group: Queries & Connections" + }, + { + "seq": "ARA", + "kind": "command", + "target": "data.refreshAll", + "excel": "Refresh All", + "note": "" + }, + { + "seq": "ARC", + "kind": "command", + "target": "data.cancelRefresh", + "pending": true, + "excel": "Cancel Refresh", + "note": "gap #12; reserved until the command lands" + }, + { + "seq": "ARO", + "kind": "command", + "target": "data.connectionProperties", + "pending": true, + "excel": "Connection Properties", + "note": "gap #12; reserved until the command lands" + }, + { + "seq": "ARR", + "kind": "command", + "target": "pivot.refresh", + "excel": "Refresh", + "note": "" + }, + { + "seq": "ARS", + "kind": "command", + "target": "data.refreshStatus", + "pending": true, + "excel": "Refresh Status", + "note": "gap #12; reserved until the command lands" + }, + { + "seq": "ASA", + "kind": "command", + "target": "data.sortAscending", + "excel": "Sort A to Z", + "note": "" + }, + { + "seq": "ASD", + "kind": "command", + "target": "data.sortDescending", + "excel": "Sort Z to A", + "note": "" + }, + { + "seq": "ASS", + "kind": "dialog", + "target": "sort", + "excel": "Sort", + "note": "" + }, + { + "seq": "AT", + "kind": "command", + "target": "data.filter", + "excel": "Filter", + "note": "" + }, + { + "seq": "AU", + "kind": "continue", + "excel": "Ungroup Menu", + "note": "group level: containment(Ungroup)" + }, + { + "seq": "AUC", + "kind": "command", + "target": "data.clearOutline", + "pending": true, + "excel": "Clear Outline", + "note": "feature gap; reserved until the command lands" + }, + { + "seq": "AUU", + "kind": "command", + "target": "data.ungroup", + "excel": "Ungroup", + "note": "" + }, + { + "seq": "AV", + "kind": "continue", + "excel": "Data Validation Menu", + "note": "group level: containment(Data Validation)" + }, + { + "seq": "AVI", + "kind": "command", + "target": "data.circleInvalid", + "excel": "Circle Invalid Data", + "note": "" + }, + { + "seq": "AVR", + "kind": "command", + "target": "data.clearValidationCircles", + "pending": true, + "excel": "Clear Validation Circles", + "note": "reserved until the command lands" + }, + { + "seq": "AVV", + "kind": "command", + "target": "data.validation", + "excel": "Data Validation", + "note": "" + }, + { + "seq": "AW", + "kind": "continue", + "note": "group: group: What-If (goalSeek/scenarioManager/dataTable)" + }, + { + "seq": "AWG", + "kind": "dialog", + "target": "goalSeek", + "excel": "Goal Seek", + "note": "" + }, + { + "seq": "AWS", + "kind": "command", + "target": "data.scenarioManager", + "excel": "Scenario Manager", + "note": "" + }, + { + "seq": "AWT", + "kind": "command", + "target": "data.dataTable", + "excel": "Data Table", + "note": "" + }, + { + "seq": "AX", + "kind": "command", + "target": "data.existingConnections", + "pending": true, + "excel": "Existing Connections", + "note": "gap #12; reserved until the command lands" + }, + { + "seq": "AY", + "kind": "command", + "target": "data.reapply", + "excel": "Reapply Filter", + "note": "containment(Reapply)" + }, + { + "seq": "RA1", + "kind": "dialog", + "target": "accessibility", + "excel": "Check Accessibility", + "note": "" + }, + { + "seq": "RA2", + "kind": "command", + "target": "review.showComments", + "pending": false, + "excel": "Show All Comments", + "note": "Show All Comments; reserved until the command lands" + }, + { + "seq": "RC", + "kind": "dialog", + "target": "comment", + "excel": "New Comment", + "note": "" + }, + { + "seq": "RD", + "kind": "command", + "target": "review.deleteComment", + "excel": "Delete Comment", + "note": "" + }, + { + "seq": "RG", + "kind": "command", + "target": "review.showChanges", + "pending": true, + "excel": "Track Changes", + "note": "reserved until the command lands" + }, + { + "seq": "RL", + "kind": "command", + "target": "review.translate", + "pending": true, + "excel": "Translate", + "note": "feature gap; reserved until the command lands" + }, + { + "seq": "RN", + "kind": "command", + "target": "review.nextComment", + "pending": false, + "excel": "Next Comment", + "note": "" + }, + { + "seq": "RO", + "kind": "command", + "target": "review.protectWorkbook", + "excel": "Protect and Share Workbook" + }, + { + "seq": "RPS", + "kind": "dialog", + "target": "protectSheet", + "excel": "Protect Sheet", + "note": "" + }, + { + "seq": "RPW", + "kind": "command", + "target": "review.protectWorkbook", + "excel": "Protect Workbook", + "note": "" + }, + { + "seq": "RRS", + "kind": "command", + "target": "review.smartLookup", + "pending": true, + "excel": "Smart Lookup", + "note": "feature gap; reserved until the command lands" + }, + { + "seq": "RS", + "kind": "dialog", + "target": "spelling", + "excel": "Spelling", + "note": "" + }, + { + "seq": "RTN", + "kind": "command", + "target": "review.newNote", + "excel": "New Note", + "note": "" + }, + { + "seq": "RV", + "kind": "command", + "target": "review.previousComment", + "pending": false, + "excel": "Previous Comment", + "note": "" + }, + { + "seq": "WA", + "kind": "command", + "target": "view.arrangeAll", + "excel": "Arrange All", + "note": "" + }, + { + "seq": "WC", + "kind": "command", + "target": "view.customViews", + "excel": "Custom Views", + "note": "" + }, + { + "seq": "WF", + "kind": "continue", + "excel": "Freeze Panes Menu", + "note": "group level:" + }, + { + "seq": "WFC", + "kind": "command", + "target": "view.freezeFirstColumn", + "excel": "Freeze First Column", + "note": "" + }, + { + "seq": "WFF", + "kind": "command", + "target": "view.freezePanes", + "excel": "Freeze Panes", + "note": "" + }, + { + "seq": "WFR", + "kind": "command", + "target": "view.freezeTopRow", + "excel": "Freeze Top Row", + "note": "" + }, + { + "seq": "WG", + "kind": "command", + "target": "view.zoomToSelection", + "excel": "Zoom To Selection", + "note": "" + }, + { + "seq": "WH", + "kind": "command", + "target": "view.hideWindow", + "excel": "Hide Window", + "note": "" + }, + { + "seq": "WI", + "kind": "command", + "target": "view.pageBreakPreview", + "excel": "Page Break Preview", + "note": "" + }, + { + "seq": "WJ", + "kind": "control", + "target": "view.zoom", + "excel": "Zoom To 100%", + "note": "runs the 100% zoom handler (percent param)" + }, + { + "seq": "WL", + "kind": "command", + "target": "view.normal", + "excel": "Normal View", + "note": "containment(Normal)" + }, + { + "seq": "WM", + "kind": "continue", + "excel": "Macros Menu", + "note": "group level: containment(Macros)" + }, + { + "seq": "WMR", + "kind": "command", + "target": "view.macros", + "excel": "Record Macro", + "note": "Record Macro (macros menu)" + }, + { + "seq": "WMV", + "kind": "command", + "target": "view.macros", + "excel": "View Macros", + "note": "containment(Macros)" + }, + { + "seq": "WN", + "kind": "command", + "target": "view.newWindow", + "excel": "New Window", + "note": "" + }, + { + "seq": "WP", + "kind": "command", + "target": "view.pageLayout", + "excel": "Page Layout View", + "note": "" + }, + { + "seq": "WQ", + "kind": "dialog", + "target": "zoom", + "excel": "Zoom", + "note": "opens the Zoom dialog" + }, + { + "seq": "WR", + "kind": "command", + "target": "view.ruler", + "excel": "Ruler", + "note": "" + }, + { + "seq": "WS", + "kind": "command", + "target": "view.split", + "excel": "Split", + "note": "" + }, + { + "seq": "WT", + "kind": "command", + "target": "view.resetWindowPosition", + "pending": true, + "excel": "Reset Window Position", + "note": "reserved until the command lands" + }, + { + "seq": "WU", + "kind": "command", + "target": "view.unhideWindow", + "excel": "Unhide Window", + "note": "" + }, + { + "seq": "WVC", + "kind": "command", + "target": "view.focusCell", + "pending": true, + "excel": "Focus Cell Menu", + "note": "Focus Cell; reserved until the command lands" + }, + { + "seq": "WVF", + "kind": "control", + "target": "view.formulaBar", + "excel": "Toggle Formula Bar", + "note": "toggles the formula bar" + }, + { + "seq": "WVH", + "kind": "command", + "target": "view.headings", + "excel": "Toggle Headings", + "note": "containment(Headings)" + }, + { + "seq": "WVN", + "kind": "command", + "target": "view.navigation", + "pending": true, + "excel": "Navigation", + "note": "Navigation pane; reserved until the command lands" + }, + { + "seq": "WVR", + "kind": "command", + "target": "view.ruler", + "excel": "Ruler", + "note": "" + }, + { + "seq": "WW", + "kind": "command", + "target": "view.switchWindows", + "excel": "Switch Windows", + "note": "" + }, + { + "seq": "JAM", + "kind": "command", + "target": "chart.formatSelection", + "excel": "Chart format: Format Selection pane", + "note": "containment(Format Selection)" + }, + { + "seq": "JCA", + "kind": "command", + "target": "chart.addElement", + "excel": "Chart: Add Chart Element", + "note": "containment(Add Chart Element)" + }, + { + "seq": "JCC", + "kind": "command", + "target": "chart.changeType", + "excel": "Chart: Change Chart Type", + "note": "containment(Change Chart Type)" + }, + { + "seq": "JTS", + "kind": "command", + "target": "table.style", + "excel": "Table: table style", + "note": "" + }, + { + "seq": "JTV", + "kind": "command", + "target": "table.summarizePivot", + "excel": "Table: Summarize with PivotTable", + "note": "containment(Summarize with PivotTable)" + }, + { + "seq": "FA", + "kind": "command", + "excel": "Save / Save As page", + "note": "fires immediately in Excel; deeper aliases dropped as unreachable", + "target": "file.saveAs" + }, + { + "seq": "FC", + "kind": "command", + "target": "file.close", + "excel": "Close workbook" + }, + { + "seq": "FEA", + "kind": "command", + "target": "file.exportPdf", + "excel": "Export: create PDF/XPS", + "note": "Export: create PDF/XPS" + }, + { + "seq": "FEC", + "kind": "command", + "target": "file.saveAs", + "excel": "Export: change file type", + "note": "Export: change file type" + }, + { + "seq": "FI", + "kind": "command", + "excel": "Info page", + "note": "fires immediately in Excel; deeper aliases dropped as unreachable", + "target": "file.properties" + }, + { + "seq": "FN", + "kind": "command", + "target": "file.new", + "excel": "New page", + "note": "New page" + }, + { + "seq": "FO", + "kind": "command", + "excel": "Open page", + "note": "fires immediately in Excel; deeper aliases dropped as unreachable", + "target": "file.open" + }, + { + "seq": "FP", + "kind": "command", + "target": "file.print", + "excel": "Print page", + "note": "containment(Print)" + }, + { + "seq": "Q", + "kind": "command", + "target": "ui.search", + "pending": true, + "excel": "Search / Tell Me", + "note": "Alt+Q search box does not exist yet (issue #49)" + } + ] +} \ No newline at end of file diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 60a5afd..ec29139 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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, + /// 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), } } } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index c2728a3..e8291ac 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -6,6 +6,7 @@ use serde_json::json; use crate::SheetApp; use crate::icons::{self, Icon}; +use crate::keytips::keytip_table; use crate::theme::{self, Tokens}; use crate::widgets::{big_button, color_palette, icon_button, small_button, split_button, toggle_button}; @@ -62,7 +63,9 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { app.grid.toggle_autosave(); } ui.add_space(6.0); - if icon_button(ui, Icon::Home, t.text_dim, "Home", vec2(26.0, ch)).clicked() { + let __k = icon_button(ui, Icon::Home, t.text_dim, "Home", vec2(26.0, ch)); + control_keytip(app, ui, &__k, "start"); + if __k.clicked() { app.open_dialog("start", json!({})); } if icon_button(ui, Icon::Save, t.text_dim, "Save (⌘S)", vec2(26.0, ch)).clicked() @@ -121,7 +124,9 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.add_space(8.0); - if icon_button(ui, Icon::Search, t.text_dim, "Search commands (⌘⇧Space)", vec2(28.0, ch)).clicked() { + let __k = icon_button(ui, Icon::Search, t.text_dim, "Search commands (⌘⇧Space)", vec2(28.0, ch)); + control_keytip(app, ui, &__k, "commandSearch"); + if __k.clicked() { app.open_dialog("commandSearch", json!({})); } let share = small_button(ui, Icon::Share, "Share", "Share: export or save a copy", true); @@ -134,7 +139,9 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { } } }); - if icon_button(ui, Icon::Comment, t.text_dim, "Comments", vec2(28.0, ch)).clicked() { + let __k = icon_button(ui, Icon::Comment, t.text_dim, "Comments", vec2(28.0, ch)); + control_keytip(app, ui, &__k, "comments"); + if __k.clicked() { app.open_dialog("comments", json!({})); } // Quick light/dark switch (#194); right-click offers System as well. The same @@ -203,8 +210,10 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { if resp.double_clicked() { app.ui.ribbon_collapsed = !collapsed; } - if tab == "Home" && app.keytips.prefix() == Some("") { - keytip(app, ui, "H", pos2(r.center().x, r.bottom() - 1.0)); + if app.keytips.prefix() == Some("") + && let Some(sequence) = keytip_table::tab_sequence(tab) + { + keytip(app, ui, sequence, pos2(r.center().x, r.bottom() - 1.0)); } } }); @@ -320,6 +329,146 @@ fn keytip(app: &SheetApp, ui: &Ui, sequence: &str, at: egui::Pos2) { painter.galley(rect.min + vec2(3.5, 1.5), text, t.text); } +/// Paint the badge for a control that runs command `id`, when the armed prefix matches. +fn control_keytip(app: &SheetApp, ui: &Ui, response: &egui::Response, id: &str) { + if let Some(sequence) = keytip_table::control_badge(app.keytips.prefix().unwrap_or_default(), id) { + keytip(app, ui, sequence, response.rect.center_bottom()); + } +} + +/// Paint the single badge for a menu anchor whose popup items run `targets`. +fn anchor_keytip(app: &SheetApp, ui: &Ui, response: &egui::Response, targets: &[&str]) { + if let Some(sequence) = keytip_table::anchor_badge(app.keytips.prefix().unwrap_or_default(), targets) { + keytip(app, ui, sequence, response.rect.center_bottom()); + } +} + +/// Consume a one-shot keytip request for control `id` (set by `Action::Control`). +fn invoke(app: &mut SheetApp, id: &str) -> bool { + if app.ui.keytip_invoke == Some(id) { + app.ui.keytip_invoke = None; + true + } else { + false + } +} + +/// Force-open command for a popup: either a keytip invoke (open it) or a click (toggle). +fn popup_command(forced: bool, clicked: bool) -> Option { + if forced { Some(egui::SetOpenCommand::Bool(true)) } else { clicked.then_some(egui::SetOpenCommand::Toggle) } +} + +/// Per-control state of an [`editable_combo`]: the filter text and highlighted row. +#[derive(Clone, Default)] +struct ComboState { + text: String, + sel: i32, + was_open: bool, +} + +/// What the user did with an [`editable_combo`]. +enum ComboResult { + None, + /// A row was applied: clicked or Enter on the highlighted row. + Picked(String), + /// Enter with free text that matches no row (e.g. a custom font size). + Entered(String), +} + +/// Excel-style dropdown: a filter field (type to narrow the list) plus arrow/Enter selection. +/// `items` are `(value, display)`; the value is what comes back. When `forced` (keytip invoke) +/// or clicked, the popup opens, focuses the filter field, and selects its text. +fn editable_combo( + ui: &mut Ui, + anchor: &egui::Response, + salt: &'static str, + current_value: &str, + items: &[(&str, String)], + forced: bool, +) -> ComboResult { + let popup_id = anchor.id.with("popup"); + let state_id = anchor.id.with(salt).with("__combo_state"); + let mut st = ui.data_mut(|d| d.get_temp::(state_id)).unwrap_or_default(); + let open_now = egui::Popup::is_id_open(ui.ctx(), popup_id); + if open_now && !st.was_open { + st.text = String::new(); + st.sel = items.iter().position(|(v, _)| *v == current_value).map(|i| i as i32).unwrap_or(0); + // The reset lands on the frame AFTER the popup opens, which usually has no input of + // its own — without a repaint request the stale filtered list stays on screen. + ui.ctx().request_repaint(); + } + st.was_open = open_now; + let mut result = ComboResult::None; + egui::Popup::menu(anchor).open_memory(popup_command(forced, anchor.clicked())).show(|ui| { + // Arrows and Enter navigate the list, not the filter field's cursor. Capture them and + // strip them from the input so the TextEdit never sees them. + let (mut down, mut up, mut enter) = (false, false, false); + ui.input(|i| { + down = i.key_pressed(egui::Key::ArrowDown); + up = i.key_pressed(egui::Key::ArrowUp); + enter = i.key_pressed(egui::Key::Enter); + }); + if down || up || enter { + ui.ctx().input_mut(|i| { + i.events.retain(|e| { + !matches!(e, egui::Event::Key { key: egui::Key::ArrowDown | egui::Key::ArrowUp | egui::Key::Enter, pressed: true, .. }) + }); + }); + } + let text_id = anchor.id.with(salt).with("__combo_text"); + let te = ui.add(egui::TextEdit::singleline(&mut st.text).id(text_id).desired_width(220.0).hint_text("Type to filter…")); + if open_now && !te.has_focus() { + ui.ctx().memory_mut(|m| m.request_focus(text_id)); + // Select all once focus lands so typing replaces the filter. + if let Some(mut s) = egui::text_edit::TextEditState::load(ui.ctx(), text_id) { + let len = st.text.chars().count(); + s.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(len)))); + s.store(ui.ctx(), text_id); + } + } + let lower = st.text.to_lowercase(); + let filtered: Vec = items.iter().enumerate().filter(|(_, (_, d))| d.to_lowercase().contains(&lower)).map(|(i, _)| i).collect(); + if !filtered.is_empty() { + let n = filtered.len() as i32; + if down { + st.sel = (st.sel + 1).rem_euclid(n); + } + if up { + st.sel = (st.sel - 1).rem_euclid(n); + } + } + if enter { + if filtered.is_empty() { + let t = st.text.trim().to_string(); + if !t.is_empty() { + result = ComboResult::Entered(t); + } + } else { + let idx = filtered[st.sel.clamp(0, filtered.len() as i32 - 1) as usize]; + result = ComboResult::Picked(items[idx].0.to_string()); + } + } + ui.separator(); + egui::ScrollArea::vertical().max_height(240.0).show(ui, |ui| { + for i in &filtered { + let (value, disp) = &items[*i]; + let row = ui.add(egui::Button::selectable(*i as i32 == st.sel, egui::RichText::new(disp)).min_size(vec2(220.0, 20.0))); + if row.clicked() { + result = ComboResult::Picked(value.to_string()); + } + if row.hovered() { + st.sel = *i as i32; + } + } + }); + if !matches!(result, ComboResult::None) { + ui.close(); + } + }); + ui.data_mut(|d| d.insert_temp(state_id, st)); + result +} + fn menu_items(app: &mut SheetApp, ui: &mut Ui, items: &[(&str, &str, serde_json::Value)]) { for (label, id, p) in items { if *label == "-" { @@ -328,14 +477,7 @@ fn menu_items(app: &mut SheetApp, ui: &mut Ui, items: &[(&str, &str, serde_json: } let lang = app.ui.language; let response = ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(220.0, 22.0))); - let sequence = match (app.keytips.prefix(), *id) { - (Some("HV"), "dialog:pasteSpecial") => Some("HVS"), - (Some("E"), "dialog:pasteSpecial") => Some("ES"), - (Some("HO"), "home.autofitColumnWidth") => Some("HOI"), - (Some("OC"), "home.autofitColumnWidth") => Some("OCA"), - _ => None, - }; - if let Some(sequence) = sequence { + if let Some(sequence) = keytip_table::item_letter(app.keytips.prefix().unwrap_or_default(), id) { keytip(app, ui, sequence, pos2(response.rect.right() - 10.0, response.rect.center().y)); } if response.clicked() { @@ -353,8 +495,10 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { let st = active_style(app); // Clipboard let paste = big_button(ui, Icon::Paste, "Paste", "Paste (⌘V)", true); - if app.keytips.prefix() == Some("H") { - keytip(app, ui, "HV", paste.rect.center_bottom()); + for sequence in + keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["edit.paste", "edit.pasteSpecial", "dialog:pasteSpecial"]) + { + keytip(app, ui, sequence, paste.rect.center_bottom()); } if paste.clicked() { if let Some(text) = app.grid.system_clipboard.take() { @@ -405,14 +549,19 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { ); }); ui.vertical(|ui| { - if icon_button(ui, Icon::Cut, t.text, "Cut (⌘X)", vec2(26.0, 23.0)).clicked() { + let __k = icon_button(ui, Icon::Cut, t.text, "Cut (⌘X)", vec2(26.0, 23.0)); + control_keytip(app, ui, &__k, "edit.cut"); + if __k.clicked() { app.copy_to_clipboard(ui.ctx(), "edit.cut"); } - if icon_button(ui, Icon::Copy, t.text, "Copy (⌘C)", vec2(26.0, 23.0)).clicked() { + let __k = icon_button(ui, Icon::Copy, t.text, "Copy (⌘C)", vec2(26.0, 23.0)); + control_keytip(app, ui, &__k, "edit.copy"); + if __k.clicked() { app.copy_to_clipboard(ui.ctx(), "edit.copy"); } let fp_on = app.session.format_painter.is_some(); let fp = toggle_button(ui, Icon::Brush, fp_on, "Format Painter (double-click to keep it on)"); + control_keytip(app, ui, &fp, "edit.formatPainter"); if fp.double_clicked() { act(app, "edit.formatPainter", json!({"sticky": true})); } else if fp.clicked() { @@ -429,21 +578,30 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { ui.horizontal(|ui| { font_combo(app, ui, &st); size_combo(app, ui, &st); - if icon_button(ui, Icon::Plus, t.text, "Increase Font Size", vec2(22.0, 23.0)).clicked() { + let __k = icon_button(ui, Icon::Plus, t.text, "Increase Font Size", vec2(22.0, 23.0)); + control_keytip(app, ui, &__k, "home.increaseFontSize"); + if __k.clicked() { act(app, "home.increaseFontSize", json!({})); } - if icon_button(ui, Icon::Minus, t.text, "Decrease Font Size", vec2(22.0, 23.0)).clicked() { + let __k = icon_button(ui, Icon::Minus, t.text, "Decrease Font Size", vec2(22.0, 23.0)); + control_keytip(app, ui, &__k, "home.decreaseFontSize"); + if __k.clicked() { act(app, "home.decreaseFontSize", json!({})); } }); ui.horizontal(|ui| { - if toggle_button(ui, Icon::Bold, st.font.bold, "Bold (⌘B)").clicked() { + let __k = toggle_button(ui, Icon::Bold, st.font.bold, "Bold (⌘B)"); + control_keytip(app, ui, &__k, "home.bold"); + if __k.clicked() { act(app, "home.bold", json!({})); } - if toggle_button(ui, Icon::Italic, st.font.italic, "Italic (⌘I)").clicked() { + let __k = toggle_button(ui, Icon::Italic, st.font.italic, "Italic (⌘I)"); + control_keytip(app, ui, &__k, "home.italic"); + if __k.clicked() { act(app, "home.italic", json!({})); } let (u, ua) = split_button(ui, Icon::Underline, None, "Underline (⌘U)"); + control_keytip(app, ui, &ua, "home.underline"); if u { act(app, "home.underline", json!({})); } @@ -457,10 +615,13 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { ], ); }); - if toggle_button(ui, Icon::Strike, st.font.strike, "Strikethrough").clicked() { + let __k = toggle_button(ui, Icon::Strike, st.font.strike, "Strikethrough"); + control_keytip(app, ui, &__k, "home.strikethrough"); + if __k.clicked() { act(app, "home.strikethrough", json!({})); } let (b, ba) = split_button(ui, Icon::Borders, None, "Borders"); + control_keytip(app, ui, &ba, "home.borders"); if b { act(app, "home.borders", json!({"preset": app.grid.last_border.clone()})); } @@ -495,10 +656,12 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { let fill = app.grid.last_fill.clone(); let fill_c = gridcraft_engine::model::Color::from_hex(&fill).and_then(|c| c.resolve(&Default::default())).map(theme::color32); let (f, fa) = split_button(ui, Icon::Fill, fill_c, "Fill Color"); + control_keytip(app, ui, &fa, "home.fillColor"); if f { act(app, "home.fillColor", json!({"color": fill})); } - egui::Popup::menu(&fa).show(|ui| { + let forced = invoke(app, "home.fillColor"); + egui::Popup::menu(&fa).open_memory(popup_command(forced, fa.clicked())).show(|ui| { if let Some(c) = color_palette(ui, &theme_colors(app), "No Fill") { if c != "none" { app.grid.last_fill = c.clone(); @@ -510,10 +673,12 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { let fc = app.grid.last_font_color.clone(); let fc_c = gridcraft_engine::model::Color::from_hex(&fc).and_then(|c| c.resolve(&Default::default())).map(theme::color32); let (c, ca) = split_button(ui, Icon::FontColor, fc_c, "Font Color"); + control_keytip(app, ui, &ca, "home.fontColor"); if c { act(app, "home.fontColor", json!({"color": fc})); } - egui::Popup::menu(&ca).show(|ui| { + let forced = invoke(app, "home.fontColor"); + egui::Popup::menu(&ca).open_memory(popup_command(forced, ca.clicked())).show(|ui| { if let Some(c) = color_palette(ui, &theme_colors(app), "Automatic") { if c != "none" { app.grid.last_font_color = c.clone(); @@ -533,21 +698,16 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { (Icon::AlignMiddle, VAlign::Center, "home.alignMiddle", "Middle Align"), (Icon::AlignBottom, VAlign::Bottom, "home.alignBottom", "Bottom Align"), ] { - let response = toggle_button(ui, icon, st.align.v == v, tip); - if matches!(app.keytips.prefix(), Some("H" | "HA")) { - let sequence = match v { - VAlign::Top => "HAT", - VAlign::Center => "HAM", - _ => "HAB", - }; - keytip(app, ui, sequence, response.rect.center_bottom()); - } - if response.clicked() { + let __k = toggle_button(ui, icon, st.align.v == v, tip); + control_keytip(app, ui, &__k, id); + if __k.clicked() { act(app, id, json!({})); } } let o = small_button(ui, Icon::Orientation, "", "Orientation", true); - egui::Popup::menu(&o).show(|ui| { + control_keytip(app, ui, &o, "home.orientation"); + let forced_o = invoke(app, "home.orientation"); + egui::Popup::menu(&o).open_memory(popup_command(forced_o, o.clicked())).show(|ui| { menu_items( app, ui, @@ -564,6 +724,7 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { ); }); let wrap = toggle_button(ui, Icon::Wrap, st.align.wrap, "Wrap Text"); + control_keytip(app, ui, &wrap, "home.wrapText"); if wrap.clicked() { act(app, "home.wrapText", json!({})); } @@ -576,25 +737,25 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { (Icon::AlignRight, HAlign::Right, "home.alignRight", "Align Right"), ] { let response = toggle_button(ui, icon, st.align.h == h, tip); - if matches!(app.keytips.prefix(), Some("H" | "HA")) { - let sequence = match h { - HAlign::Left => "HAL", - HAlign::Center => "HAC", - _ => "HAR", - }; + for sequence in keytip_table::control_badges(app.keytips.prefix().unwrap_or_default(), id) { keytip(app, ui, sequence, response.rect.center_bottom()); } if response.clicked() { act(app, id, json!({})); } } - if icon_button(ui, Icon::IndentDec, t.text, "Decrease Indent", vec2(24.0, 23.0)).clicked() { + let __k = icon_button(ui, Icon::IndentDec, t.text, "Decrease Indent", vec2(24.0, 23.0)); + control_keytip(app, ui, &__k, "home.decreaseIndent"); + if __k.clicked() { act(app, "home.decreaseIndent", json!({})); } - if icon_button(ui, Icon::IndentInc, t.text, "Increase Indent", vec2(24.0, 23.0)).clicked() { + let __k = icon_button(ui, Icon::IndentInc, t.text, "Increase Indent", vec2(24.0, 23.0)); + control_keytip(app, ui, &__k, "home.increaseIndent"); + if __k.clicked() { act(app, "home.increaseIndent", json!({})); } let (m, ma) = split_button(ui, Icon::Merge, None, "Merge & Center"); + control_keytip(app, ui, &ma, "home.mergeCenter"); if m { act(app, "home.mergeCenter", json!({})); } @@ -619,6 +780,7 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { number_combo(app, ui, &st); ui.horizontal(|ui| { let (c, ca) = split_button(ui, Icon::Currency, None, "Accounting Number Format"); + control_keytip(app, ui, &ca, "home.accounting"); if c { act(app, "home.accounting", json!({})); } @@ -648,16 +810,24 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { ], ); }); - if icon_button(ui, Icon::Percent, t.text, "Percent Style", vec2(24.0, 23.0)).clicked() { + let __k = icon_button(ui, Icon::Percent, t.text, "Percent Style", vec2(24.0, 23.0)); + control_keytip(app, ui, &__k, "home.percent"); + if __k.clicked() { act(app, "home.percent", json!({})); } - if icon_button(ui, Icon::Comma, t.text, "Comma Style", vec2(24.0, 23.0)).clicked() { + let __k = icon_button(ui, Icon::Comma, t.text, "Comma Style", vec2(24.0, 23.0)); + control_keytip(app, ui, &__k, "home.comma"); + if __k.clicked() { act(app, "home.comma", json!({})); } - if icon_button(ui, Icon::DecInc, t.text, "Increase Decimal", vec2(26.0, 23.0)).clicked() { + let __k = icon_button(ui, Icon::DecInc, t.text, "Increase Decimal", vec2(26.0, 23.0)); + control_keytip(app, ui, &__k, "home.increaseDecimal"); + if __k.clicked() { act(app, "home.increaseDecimal", json!({})); } - if icon_button(ui, Icon::DecDec, t.text, "Decrease Decimal", vec2(26.0, 23.0)).clicked() { + let __k = icon_button(ui, Icon::DecDec, t.text, "Decrease Decimal", vec2(26.0, 23.0)); + control_keytip(app, ui, &__k, "home.decreaseDecimal"); + if __k.clicked() { act(app, "home.decreaseDecimal", json!({})); } }); @@ -665,15 +835,24 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { sep(ui); // Styles let cf = big_button(ui, Icon::CondFormat, "Conditional\nFormatting", "Conditional Formatting", true); + anchor_keytip(app, ui, &cf, &["home.conditionalFormat", "home.manageRules", "home.clearRules"]); egui::Popup::menu(&cf).show(|ui| cf_menu(app, ui)); let ft = big_button(ui, Icon::FormatTable, "Format\nas Table", "Format as Table", true); + anchor_keytip(app, ui, &ft, &["table.style"]); egui::Popup::menu(&ft).show(|ui| table_gallery(app, ui, "home.formatAsTable")); let cs = big_button(ui, Icon::CellStyles, "Cell\nStyles", "Cell Styles", true); + anchor_keytip(app, ui, &cs, &["home.newCellStyle"]); egui::Popup::menu(&cs).show(|ui| cell_style_gallery(app, ui)); sep(ui); // Cells ui.vertical(|ui| { let ins = small_button(ui, Icon::Insert, "Insert", "Insert", true); + for sequence in keytip_table::group_extension( + app.keytips.prefix().unwrap_or_default(), + &["dialog:insertCells", "home.insertRows", "home.insertColumns", "home.insertSheet"], + ) { + keytip(app, ui, sequence, ins.rect.center_bottom()); + } egui::Popup::menu(&ins).show(|ui| { menu_items( app, @@ -687,6 +866,12 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { ); }); let del = small_button(ui, Icon::Delete, "Delete", "Delete", true); + for sequence in keytip_table::group_extension( + app.keytips.prefix().unwrap_or_default(), + &["dialog:deleteCells", "home.deleteRows", "home.deleteColumns", "home.deleteSheet"], + ) { + keytip(app, ui, sequence, del.rect.center_bottom()); + } egui::Popup::menu(&del).show(|ui| { menu_items( app, @@ -700,10 +885,28 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { ); }); let fmt = small_button(ui, Icon::Format, "Format", "Format", true); - match app.keytips.prefix() { - Some("H") => keytip(app, ui, "HO", fmt.rect.center_bottom()), - Some("O") => keytip(app, ui, "OC", fmt.rect.center_bottom()), - _ => {} + for sequence in keytip_table::group_extension( + app.keytips.prefix().unwrap_or_default(), + &[ + "dialog:rowHeight", + "home.autofitRowHeight", + "dialog:columnWidth", + "home.autofitColumnWidth", + "dialog:defaultWidth", + "home.hideRows", + "home.hideColumns", + "home.unhideRows", + "home.unhideColumns", + "sheet.hide", + "sheet.unhide", + "dialog:renameSheet", + "dialog:moveSheet", + "dialog:protectSheet", + "home.lockCell", + "dialog:formatCells", + ], + ) { + keytip(app, ui, sequence, fmt.rect.center_bottom()); } let format_keys = matches!(app.keytips.prefix(), Some("HO" | "OC")); let mut format_popup = egui::Popup::menu(&fmt); @@ -743,6 +946,7 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { ui.vertical(|ui| { ui.horizontal(|ui| { let (s, sa) = split_button(ui, Icon::Sum, None, "AutoSum (⌘⇧T)"); + control_keytip(app, ui, &sa, "formulas.autoSum"); if s { act(app, "formulas.autoSum", json!({})); } @@ -759,6 +963,12 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { }); }); let fill = small_button(ui, Icon::FillDown, "", "Fill", true); + for sequence in keytip_table::group_extension( + app.keytips.prefix().unwrap_or_default(), + &["edit.fillDown", "edit.fillRight", "edit.fillUp", "edit.fillLeft", "dialog:series", "edit.flashFill"], + ) { + keytip(app, ui, sequence, fill.rect.center_bottom()); + } egui::Popup::menu(&fill).show(|ui| { menu_items( app, @@ -774,6 +984,12 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { ); }); let clear = small_button(ui, Icon::Eraser, "", "Clear", true); + for sequence in keytip_table::group_extension( + app.keytips.prefix().unwrap_or_default(), + &["edit.clearAll", "edit.clearFormats", "edit.clearContents", "edit.clearComments", "edit.clearHyperlinks"], + ) { + keytip(app, ui, sequence, clear.rect.center_bottom()); + } egui::Popup::menu(&clear).show(|ui| { menu_items( app, @@ -789,6 +1005,12 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { }); }); let sf = big_button(ui, Icon::SortFilter, "Sort &\nFilter", "Sort & Filter", true); + for sequence in keytip_table::group_extension( + app.keytips.prefix().unwrap_or_default(), + &["data.sortAscending", "data.sortDescending", "dialog:sort", "data.filter", "data.clearFilter", "data.reapply"], + ) { + keytip(app, ui, sequence, sf.rect.center_bottom()); + } egui::Popup::menu(&sf).show(|ui| { menu_items( app, @@ -805,6 +1027,12 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { ); }); let fs = big_button(ui, Icon::Find, "Find &\nSelect", "Find & Select", true); + for sequence in keytip_table::group_extension( + app.keytips.prefix().unwrap_or_default(), + &["dialog:find", "dialog:goTo", "dialog:goToSpecial", "edit.goToSpecial"], + ) { + keytip(app, ui, sequence, fs.rect.center_bottom()); + } egui::Popup::menu(&fs).show(|ui| { menu_items( app, @@ -826,16 +1054,13 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { } fn font_combo(app: &mut SheetApp, ui: &mut Ui, st: &Style) { - let mut name = st.font.name.clone(); - let before = name.clone(); - egui::ComboBox::from_id_salt("font_name").width(150.0).selected_text(egui::RichText::new(&name).font(theme::ui_font(12.5))).show_ui(ui, |ui| { - for f in crate::fonts::families() { - let fam = theme::cell_family(&f, false, false); - ui.selectable_value(&mut name, f.clone(), egui::RichText::new(&f).font(egui::FontId::new(14.0, fam))); - } - }); - if name != before { - act(app, "home.fontName", json!({"name": name})); + let name = st.font.name.clone(); + let resp = ui.add_sized([150.0, 24.0], egui::Button::new(egui::RichText::new(&name).font(theme::ui_font(12.5)))); + control_keytip(app, ui, &resp, "home.fontName"); + let forced = invoke(app, "home.fontName"); + let items: Vec<(&str, String)> = FONTS.iter().map(|f| (*f, f.to_string())).collect(); + if let ComboResult::Picked(value) = editable_combo(ui, &resp, "font", &name, &items, forced) { + act(app, "home.fontName", json!({"name": value})); } } @@ -860,16 +1085,24 @@ pub const FONTS: &[&str] = &[ ]; fn size_combo(app: &mut SheetApp, ui: &mut Ui, st: &Style) { - let mut size = st.font.size; - let before = size; + let size = st.font.size; let label = if size.fract() == 0.0 { format!("{}", size as i32) } else { format!("{size}") }; - egui::ComboBox::from_id_salt("font_size").width(52.0).selected_text(egui::RichText::new(label).font(theme::ui_font(12.5))).show_ui(ui, |ui| { - for s in [8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 22.0, 24.0, 26.0, 28.0, 36.0, 48.0, 72.0] { - ui.selectable_value(&mut size, s, format!("{}", s as i32)); + let resp = ui.add_sized([52.0, 24.0], egui::Button::new(egui::RichText::new(&label).font(theme::ui_font(12.5)))); + control_keytip(app, ui, &resp, "home.fontSize"); + let forced = invoke(app, "home.fontSize"); + let sizes = [8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 22.0, 24.0, 26.0, 28.0, 36.0, 48.0, 72.0]; + let labels: Vec = sizes.iter().map(|s| format!("{}", *s as i32)).collect(); + let items: Vec<(&str, String)> = labels.iter().map(|s| (s.as_str(), s.clone())).collect(); + let current = if size.fract() == 0.0 { format!("{}", size as i32) } else { format!("{size}") }; + let mut apply = |s: &str| { + if let Ok(v) = s.parse::() { + act(app, "home.fontSize", json!({"size": v})); } - }); - if size != before { - act(app, "home.fontSize", json!({"size": size})); + }; + match editable_combo(ui, &resp, "size", ¤t, &items, forced) { + ComboResult::Picked(value) => apply(&value), + ComboResult::Entered(text) => apply(&text), + _ => {} } } @@ -879,30 +1112,24 @@ fn number_combo(app: &mut SheetApp, ui: &mut Ui, st: &Style) { let current = format!("{kind:?}"); let lang = app.ui.language; let sample = app.session.active().and_then(|d| d.wb.active().map(|sh| sh.value(d.selection.active))).unwrap_or_default(); - let mut picked: Option<&str> = None; - egui::ComboBox::from_id_salt("number_format") - .width(150.0) - .selected_text(egui::RichText::new(lang.tr(¤t)).font(theme::ui_font(12.5))) - .show_ui(ui, |ui| { - for name in - ["General", "Number", "Currency", "Accounting", "Short Date", "Long Date", "Time", "Percentage", "Fraction", "Scientific", "Text"] - { - let code = gridcraft_engine::cmd::format::format_code_for(name); - let preview = app.session.active().map(|d| gridcraft_engine::display::format(&sample, code, &d.wb).text).unwrap_or_default(); - let r = ui.add(egui::Button::selectable(current == name, format!("{:<12} {preview}", lang.tr(name))).min_size(vec2(240.0, 22.0))); - if r.clicked() { - picked = Some(name); - } - } - ui.separator(); - if ui.button(lang.tr("More Number Formats…")).clicked() { - picked = Some("__more"); - } - }); - match picked { - Some("__more") => app.open_dialog("formatCells", json!({"tab": "Number"})), - Some(n) => act(app, "home.numberFormat", json!({"format": n})), - None => {} + let resp = ui.add_sized([150.0, 24.0], egui::Button::new(egui::RichText::new(lang.tr(¤t)).font(theme::ui_font(12.5)))); + control_keytip(app, ui, &resp, "home.numberFormat"); + let forced = invoke(app, "home.numberFormat"); + let names = ["General", "Number", "Currency", "Accounting", "Short Date", "Long Date", "Time", "Percentage", "Fraction", "Scientific", "Text"]; + let items: Vec<(&str, String)> = names + .iter() + .map(|n| { + let code = gridcraft_engine::cmd::format::format_code_for(n); + let preview = app.session.active().map(|d| gridcraft_engine::display::format(&sample, code, &d.wb).text).unwrap_or_default(); + (*n, format!("{:<12} {preview}", lang.tr(n))) + }) + .chain(std::iter::once(("__more", format!("{:>12}", lang.tr("More Number Formats…"))))) + .collect(); + if let ComboResult::Picked(value) = editable_combo(ui, &resp, "number", ¤t, &items, forced) { + match value.as_str() { + "__more" => app.open_dialog("formatCells", json!({"tab": "Number"})), + n => act(app, "home.numberFormat", json!({"format": n})), + } } } @@ -1135,14 +1362,19 @@ fn cell_style_gallery(app: &mut SheetApp, ui: &mut Ui) { } fn insert(app: &mut SheetApp, ui: &mut Ui) { - if big_button(ui, Icon::PivotTable, "PivotTable", "PivotTable", false).clicked() { + let __k = big_button(ui, Icon::PivotTable, "PivotTable", "PivotTable", false); + control_keytip(app, ui, &__k, "insert.pivotTable"); + if __k.clicked() { act(app, "insert.pivotTable", json!({})); } - if big_button(ui, Icon::Table, "Table", "Table (⌘T)", false).clicked() { + let __k = big_button(ui, Icon::Table, "Table", "Table (⌘T)", false); + control_keytip(app, ui, &__k, "insert.table"); + if __k.clicked() { act(app, "insert.table", json!({})); } sep(ui); let pic = big_button(ui, Icon::Picture, "Pictures", "Insert a picture", true); + anchor_keytip(app, ui, &pic, &["insert.picture"]); pic.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), "Pictures")); egui::Popup::menu(&pic).show(|ui| { for (label, placement) in [("Place in Cell", "cell"), ("Place over Cells", "overCells")] { @@ -1153,6 +1385,7 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) { } }); let shapes = big_button(ui, Icon::Shapes, "Shapes", "Shapes", true); + anchor_keytip(app, ui, &shapes, &["insert.shape"]); egui::Popup::menu(&shapes).show(|ui| { for (label, kind) in [ ("Rectangle", "rectangle"), @@ -1169,6 +1402,7 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) { } }); let icn = big_button(ui, Icon::Theme, "Icons", "Insert an icon", true); + anchor_keytip(app, ui, &icn, &["insert.icons"]); egui::Popup::menu(&icn).show(|ui| { ui.set_width(260.0); egui::Grid::new("icon_lib").show(ui, |ui| { @@ -1189,16 +1423,18 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) { }); }); sep(ui); - if big_button(ui, Icon::Chart, "Recommended\nCharts", "Recommended Charts", false).clicked() { - app.open_dialog("recommendedCharts", json!({})); + let __k = big_button(ui, Icon::Chart, "Recommended\nCharts", "Recommended Charts", false); + control_keytip(app, ui, &__k, "insert.recommendedCharts"); + if __k.clicked() { + act(app, "insert.recommendedCharts", json!({})); } ui.vertical(|ui| { ui.horizontal(|ui| { - for (icon, tip, kind, subs) in [ + for (icon, tip, cid, subs) in [ ( Icon::ChartBar, "Column or Bar", - "column", + "insert.chart.column", vec![ ("Clustered Column", "column", ""), ("Stacked Column", "column", "stacked"), @@ -1210,7 +1446,7 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) { ( Icon::ChartLine, "Line or Area", - "line", + "insert.chart.line", vec![ ("Line", "line", ""), ("Line with Markers", "line", "markers"), @@ -1219,11 +1455,12 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) { ("Stacked Area", "area", "stacked"), ], ), - (Icon::ChartPie, "Pie or Doughnut", "pie", vec![("Pie", "pie", ""), ("Doughnut", "doughnut", "")]), + (Icon::ChartPie, "Pie or Doughnut", "insert.chart.pie", vec![("Pie", "pie", ""), ("Doughnut", "doughnut", "")]), ] { let r = small_button(ui, icon, "", tip, true); - let _ = kind; - egui::Popup::menu(&r).show(|ui| { + control_keytip(app, ui, &r, cid); + let forced_c = invoke(app, cid); + egui::Popup::menu(&r).open_memory(popup_command(forced_c, r.clicked())).show(|ui| { for (label, k, s) in &subs { if ui.button(*label).clicked() { act(app, "insert.chart", json!({"type": k, "subtype": s})); @@ -1233,16 +1470,18 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) { } }); ui.horizontal(|ui| { - for (icon, tip, subs) in [ + for (icon, tip, cid, subs) in [ ( Icon::ChartScatter, "Scatter or Bubble", + "insert.chart.scatter", vec![("Scatter", "scatter", ""), ("Scatter with Lines", "scatter", "lines"), ("Bubble", "bubble", "")], ), - (Icon::Chart, "Statistic", vec![("Histogram", "histogram", ""), ("Box & Whisker", "boxWhisker", "")]), + (Icon::Chart, "Statistic", "insert.chart.statistic", vec![("Histogram", "histogram", ""), ("Box & Whisker", "boxWhisker", "")]), ( Icon::Theme, "Hierarchy & more", + "insert.chart.hierarchy", vec![ ("Treemap", "treemap", ""), ("Sunburst", "sunburst", ""), @@ -1255,7 +1494,9 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) { ), ] { let r = small_button(ui, icon, "", tip, true); - egui::Popup::menu(&r).show(|ui| { + control_keytip(app, ui, &r, cid); + let forced_c = invoke(app, cid); + egui::Popup::menu(&r).open_memory(popup_command(forced_c, r.clicked())).show(|ui| { for (label, k, s) in &subs { if ui.button(*label).clicked() { act(app, "insert.chart", json!({"type": k, "subtype": s})); @@ -1271,6 +1512,7 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) { } sep(ui); let sp = big_button(ui, Icon::Sparkline, "Sparklines", "Sparklines", true); + anchor_keytip(app, ui, &sp, &["dialog:sparkline"]); egui::Popup::menu(&sp).show(|ui| { for (label, kind) in [("Line", "line"), ("Column", "column"), ("Win/Loss", "winLoss")] { if ui.button(label).clicked() { @@ -1284,21 +1526,30 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) { act(app, "insert.checkbox", json!({})); } sep(ui); - if big_button(ui, Icon::Link, "Link", "Insert Link (⌘K)", false).clicked() { + let __k = big_button(ui, Icon::Link, "Link", "Insert Link (⌘K)", false); + control_keytip(app, ui, &__k, "dialog:insertLink"); + if __k.clicked() { app.open_dialog("insertLink", json!({})); } - if big_button(ui, Icon::Comment, "Comment", "New Comment", false).clicked() { + let __k = big_button(ui, Icon::Comment, "Comment", "New Comment", false); + control_keytip(app, ui, &__k, "dialog:comment"); + if __k.clicked() { app.open_dialog("comment", json!({"threaded": true})); } sep(ui); - if big_button(ui, Icon::TextBox, "Text\nBox", "Text Box", false).clicked() { + let __k = big_button(ui, Icon::TextBox, "Text\nBox", "Text Box", false); + control_keytip(app, ui, &__k, "insert.textBox"); + if __k.clicked() { act(app, "insert.textBox", json!({})); } - if big_button(ui, Icon::PageLayout, "Header &\nFooter", "Header & Footer", false).clicked() { + let __k = big_button(ui, Icon::PageLayout, "Header &\nFooter", "Header & Footer", false); + control_keytip(app, ui, &__k, "dialog:headerFooter"); + if __k.clicked() { // As in Excel: Page Layout view, editing the header in place (the dialog is under Page Setup). crate::paper_view::start_editing(app); } let sym = big_button(ui, Icon::Symbol, "Symbol", "Symbol", true); + control_keytip(app, ui, &sym, "insert.symbol"); egui::Popup::menu(&sym).show(|ui| { ui.set_width(260.0); ui.horizontal_wrapped(|ui| { @@ -1316,7 +1567,9 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) { fn draw(app: &mut SheetApp, ui: &mut Ui) { let tool = app.session.draw_tool.clone(); - if big_button(ui, Icon::Shapes, "Select\nObjects", "Select and move ink and shapes", false).clicked() { + let __k = big_button(ui, Icon::Shapes, "Select\nObjects", "Select and move ink and shapes", false); + control_keytip(app, ui, &__k, "draw.lasso"); + if __k.clicked() { act(app, "draw.lasso", json!({})); } sep(ui); @@ -1339,13 +1592,16 @@ fn draw(app: &mut SheetApp, ui: &mut Ui) { act(app, "draw.eraser", json!({})); } sep(ui); - if big_button(ui, Icon::Shapes, "Ink to\nShape", "Convert the last ink stroke to a shape", false).clicked() { + let __k = big_button(ui, Icon::Shapes, "Ink to\nShape", "Convert the last ink stroke to a shape", false); + control_keytip(app, ui, &__k, "draw.inkToShape"); + if __k.clicked() { act(app, "draw.inkToShape", json!({})); } } fn page_layout(app: &mut SheetApp, ui: &mut Ui) { let th = big_button(ui, Icon::Theme, "Themes", "Themes", true); + anchor_keytip(app, ui, &th, &["pageLayout.theme"]); egui::Popup::menu(&th).show(|ui| { for (name, colors) in gridcraft_engine::cmd::view::themes() { ui.horizontal(|ui| { @@ -1361,6 +1617,9 @@ fn page_layout(app: &mut SheetApp, ui: &mut Ui) { }); sep(ui); let m = big_button(ui, Icon::Margins, "Margins", "Margins", true); + for sequence in keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["pageLayout.margins", "dialog:pageSetup"]) { + keytip(app, ui, sequence, m.rect.center_bottom()); + } egui::Popup::menu(&m).show(|ui| { menu_items( app, @@ -1374,6 +1633,7 @@ fn page_layout(app: &mut SheetApp, ui: &mut Ui) { ); }); let o = big_button(ui, Icon::Orient, "Page Layout|Orientation", "Page Layout|Orientation", true); + control_keytip(app, ui, &o, "pageLayout.orientation"); egui::Popup::menu(&o).show(|ui| { menu_items( app, @@ -1385,6 +1645,7 @@ fn page_layout(app: &mut SheetApp, ui: &mut Ui) { ); }); let s = big_button(ui, Icon::Paper, "Size", "Paper Size", true); + control_keytip(app, ui, &s, "pageLayout.size"); egui::Popup::menu(&s).show(|ui| { for p in ["Letter", "Legal", "Tabloid", "Executive", "A3", "A4", "A5"] { if ui.button(p).clicked() { @@ -1393,6 +1654,7 @@ fn page_layout(app: &mut SheetApp, ui: &mut Ui) { } }); let pa = big_button(ui, Icon::PrintArea, "Print\nArea", "Print Area", true); + anchor_keytip(app, ui, &pa, &["pageLayout.printArea"]); egui::Popup::menu(&pa).show(|ui| { menu_items( app, @@ -1401,6 +1663,9 @@ fn page_layout(app: &mut SheetApp, ui: &mut Ui) { ); }); let br = big_button(ui, Icon::PageBreak, "Breaks", "Page Breaks", true); + for sequence in keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["pageLayout.breaks"]) { + keytip(app, ui, sequence, br.rect.center_bottom()); + } egui::Popup::menu(&br).show(|ui| { menu_items( app, @@ -1412,7 +1677,9 @@ fn page_layout(app: &mut SheetApp, ui: &mut Ui) { ], ); }); - if big_button(ui, Icon::Sheet, "Print\nTitles", "Print Titles", false).clicked() { + let __k = big_button(ui, Icon::Sheet, "Print\nTitles", "Print Titles", false); + control_keytip(app, ui, &__k, "pageLayout.printTitles"); + if __k.clicked() { app.open_dialog("pageSetup", json!({"tab": "Sheet"})); } sep(ui); @@ -1428,23 +1695,31 @@ fn page_layout(app: &mut SheetApp, ui: &mut Ui) { ui.vertical(|ui| { ui.label(egui::RichText::new(app.ui.language.tr("Gridlines")).strong().small()); let mut v = sh.0; - if ui.checkbox(&mut v, screen).changed() { - act(app, "view.gridlines", json!({"on": v})); + let resp = ui.checkbox(&mut v, screen); + control_keytip(app, ui, &resp, "view.gridlines"); + if resp.changed() || invoke(app, "view.gridlines") { + act(app, "view.gridlines", json!({"on": !v})); } let mut p = sh.2; - if ui.checkbox(&mut p, print).changed() { - act(app, "pageLayout.printGridlines", json!({"on": p})); + let resp = ui.checkbox(&mut p, print); + control_keytip(app, ui, &resp, "pageLayout.printGridlines"); + if resp.changed() || invoke(app, "pageLayout.printGridlines") { + act(app, "pageLayout.printGridlines", json!({"on": !p})); } }); ui.vertical(|ui| { ui.label(egui::RichText::new(app.ui.language.tr("Headings")).strong().small()); let mut v = sh.1; - if ui.checkbox(&mut v, screen).changed() { - act(app, "view.headings", json!({"on": v})); + let resp = ui.checkbox(&mut v, screen); + control_keytip(app, ui, &resp, "view.headings"); + if resp.changed() || invoke(app, "view.headings") { + act(app, "view.headings", json!({"on": !v})); } let mut p = sh.3; - if ui.checkbox(&mut p, print).changed() { - act(app, "pageLayout.printHeadings", json!({"on": p})); + let resp = ui.checkbox(&mut p, print); + control_keytip(app, ui, &resp, "pageLayout.printHeadings"); + if resp.changed() || invoke(app, "pageLayout.printHeadings") { + act(app, "pageLayout.printHeadings", json!({"on": !p})); } }); let rtl = app.session.active().and_then(|d| d.wb.active()).is_some_and(|s| s.right_to_left); @@ -1459,24 +1734,29 @@ fn page_layout(app: &mut SheetApp, ui: &mut Ui) { } fn formulas(app: &mut SheetApp, ui: &mut Ui) { - if big_button(ui, Icon::Function, "Insert\nFunction", "Insert Function (⇧F3)", false).clicked() { + let __k = big_button(ui, Icon::Function, "Insert\nFunction", "Insert Function (⇧F3)", false); + control_keytip(app, ui, &__k, "dialog:insertFunction"); + if __k.clicked() { app.open_dialog("insertFunction", json!({})); } let s = big_button(ui, Icon::Sum, "AutoSum", "AutoSum", true); + control_keytip(app, ui, &s, "formulas.autoSum"); if s.clicked() { act(app, "formulas.autoSum", json!({})); } - for (label, cat) in [ - ("Financial", "Financial"), - ("Logical", "Logical"), - ("Text", "Text"), - ("Date &\nTime", "DateTime"), - ("Lookup &\nReference", "Lookup"), - ("Math &\nTrig", "MathTrig"), - ("More\nFunctions", "Statistical"), + for (label, cat, cid) in [ + ("Financial", "Financial", "formulas.financial"), + ("Logical", "Logical", "formulas.logical"), + ("Text", "Text", "formulas.text"), + ("Date &\nTime", "DateTime", "formulas.dateTime"), + ("Lookup &\nReference", "Lookup", "formulas.lookup"), + ("Math &\nTrig", "MathTrig", "formulas.mathTrig"), + ("More\nFunctions", "Statistical", "formulas.moreFunctions"), ] { let r = big_button(ui, Icon::Book, label, label, true); - egui::Popup::menu(&r).show(|ui| { + control_keytip(app, ui, &r, cid); + let forced_c = invoke(app, cid); + egui::Popup::menu(&r).open_memory(popup_command(forced_c, r.clicked())).show(|ui| { ui.set_max_height(420.0); egui::ScrollArea::vertical().show(ui, |ui| { for f in gridcraft_engine::cmd::formulas::function_list() { @@ -1497,14 +1777,19 @@ fn formulas(app: &mut SheetApp, ui: &mut Ui) { }); } sep(ui); - if big_button(ui, Icon::Name, "Name\nManager", "Name Manager", false).clicked() { + let __k = big_button(ui, Icon::Name, "Name\nManager", "Name Manager", false); + control_keytip(app, ui, &__k, "dialog:nameManager"); + if __k.clicked() { app.open_dialog("nameManager", json!({})); } ui.vertical(|ui| { - if small_button(ui, Icon::Name, "Define Name", "Define Name", false).clicked() { + let __k = small_button(ui, Icon::Name, "Define Name", "Define Name", false); + control_keytip(app, ui, &__k, "dialog:defineName"); + if __k.clicked() { app.open_dialog("defineName", json!({})); } let use_in = small_button(ui, Icon::Function, "Use in Formula", "Use in Formula", true); + anchor_keytip(app, ui, &use_in, &["formulas.pasteName"]); egui::Popup::menu(&use_in).show(|ui| { let names: Vec = app.session.active().map(|d| d.wb.names.iter().map(|n| n.name.clone()).collect()).unwrap_or_default(); for n in names { @@ -1514,7 +1799,9 @@ fn formulas(app: &mut SheetApp, ui: &mut Ui) { } } }); - if small_button(ui, Icon::Table, "Create from Selection", "Create from Selection", false).clicked() { + let __k = small_button(ui, Icon::Table, "Create from Selection", "Create from Selection", false); + control_keytip(app, ui, &__k, "formulas.createFromSelection"); + if __k.clicked() { act(app, "formulas.createFromSelection", json!({})); } }); @@ -1535,13 +1822,19 @@ fn formulas(app: &mut SheetApp, ui: &mut Ui) { } }); ui.vertical(|ui| { - if small_button(ui, Icon::Fx, "Show Formulas", "Show Formulas (⌃`)", false).clicked() { + let __k = small_button(ui, Icon::Fx, "Show Formulas", "Show Formulas (⌃`)", false); + control_keytip(app, ui, &__k, "formulas.showFormulas"); + if __k.clicked() { act(app, "formulas.showFormulas", json!({})); } - if small_button(ui, Icon::Validation, "Error Checking", "Error Checking", false).clicked() { + let __k = small_button(ui, Icon::Validation, "Error Checking", "Error Checking", false); + control_keytip(app, ui, &__k, "dialog:errorChecking"); + if __k.clicked() { app.open_dialog("errorChecking", json!({})); } - if small_button(ui, Icon::Calc, "Evaluate Formula", "Evaluate Formula", false).clicked() { + let __k = small_button(ui, Icon::Calc, "Evaluate Formula", "Evaluate Formula", false); + control_keytip(app, ui, &__k, "dialog:evaluateFormula"); + if __k.clicked() { app.open_dialog("evaluateFormula", json!({})); } }); @@ -1550,6 +1843,9 @@ fn formulas(app: &mut SheetApp, ui: &mut Ui) { } sep(ui); let co = big_button(ui, Icon::Calc, "Calculation\nOptions", "Calculation Options", true); + for sequence in keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["formulas.calculationOptions"]) { + keytip(app, ui, sequence, co.rect.center_bottom()); + } egui::Popup::menu(&co).show(|ui| { menu_items( app, @@ -1562,10 +1858,14 @@ fn formulas(app: &mut SheetApp, ui: &mut Ui) { ); }); ui.vertical(|ui| { - if small_button(ui, Icon::Calc, "Calculate Now", "Calculate Now (F9)", false).clicked() { + let __k = small_button(ui, Icon::Calc, "Calculate Now", "Calculate Now (F9)", false); + control_keytip(app, ui, &__k, "formulas.calculateNow"); + if __k.clicked() { act(app, "formulas.calculateNow", json!({})); } - if small_button(ui, Icon::Sheet, "Calculate Sheet", "Calculate Sheet (⇧F9)", false).clicked() { + let __k = small_button(ui, Icon::Sheet, "Calculate Sheet", "Calculate Sheet (⇧F9)", false); + control_keytip(app, ui, &__k, "formulas.calculateSheet"); + if __k.clicked() { act(app, "formulas.calculateSheet", json!({})); } }); @@ -1573,43 +1873,65 @@ fn formulas(app: &mut SheetApp, ui: &mut Ui) { fn data(app: &mut SheetApp, ui: &mut Ui) { let gd = big_button(ui, Icon::Folder, "Get Data\n(Text/CSV)", "Import a text or CSV file", false); + control_keytip(app, ui, &gd, "data.getData"); if gd.clicked() { app.open_dialog("open", json!({})); } sep(ui); ui.vertical(|ui| { - if small_button(ui, Icon::SortAsc, "", "Sort A to Z", false).clicked() { + let __k = small_button(ui, Icon::SortAsc, "", "Sort A to Z", false); + control_keytip(app, ui, &__k, "data.sortAscending"); + if __k.clicked() { act(app, "data.sortAscending", json!({})); } - if small_button(ui, Icon::SortDesc, "", "Sort Z to A", false).clicked() { + let __k = small_button(ui, Icon::SortDesc, "", "Sort Z to A", false); + control_keytip(app, ui, &__k, "data.sortDescending"); + if __k.clicked() { act(app, "data.sortDescending", json!({})); } }); - if big_button(ui, Icon::SortFilter, "Sort", "Custom Sort", false).clicked() { + let __k = big_button(ui, Icon::SortFilter, "Sort", "Custom Sort", false); + control_keytip(app, ui, &__k, "dialog:sort"); + if __k.clicked() { app.open_dialog("sort", json!({})); } - if big_button(ui, Icon::Filter, "Filter", "Filter (⌘⇧F)", false).clicked() { + let __k = big_button(ui, Icon::Filter, "Filter", "Filter (⌘⇧F)", false); + control_keytip(app, ui, &__k, "data.filter"); + if __k.clicked() { act(app, "data.filter", json!({})); } ui.vertical(|ui| { - if small_button(ui, Icon::Eraser, "Clear", "Clear Filter", false).clicked() { + let __k = small_button(ui, Icon::Eraser, "Clear", "Clear Filter", false); + control_keytip(app, ui, &__k, "data.clearFilter"); + if __k.clicked() { act(app, "data.clearFilter", json!({})); } - if small_button(ui, Icon::Redo, "Reapply", "Reapply", false).clicked() { + let __k = small_button(ui, Icon::Redo, "Reapply", "Reapply", false); + control_keytip(app, ui, &__k, "data.reapply"); + if __k.clicked() { act(app, "data.reapply", json!({})); } }); sep(ui); - if big_button(ui, Icon::TextColumns, "Text to\nColumns", "Text to Columns", false).clicked() { + let __k = big_button(ui, Icon::TextColumns, "Text to\nColumns", "Text to Columns", false); + control_keytip(app, ui, &__k, "dialog:textToColumns"); + if __k.clicked() { app.open_dialog("textToColumns", json!({})); } - if big_button(ui, Icon::FillDown, "Flash\nFill", "Flash Fill (⌘E)", false).clicked() { + let __k = big_button(ui, Icon::FillDown, "Flash\nFill", "Flash Fill (⌘E)", false); + control_keytip(app, ui, &__k, "edit.flashFill"); + if __k.clicked() { act(app, "edit.flashFill", json!({})); } - if big_button(ui, Icon::Duplicates, "Remove\nDuplicates", "Remove Duplicates", false).clicked() { + let __k = big_button(ui, Icon::Duplicates, "Remove\nDuplicates", "Remove Duplicates", false); + control_keytip(app, ui, &__k, "dialog:removeDuplicates"); + if __k.clicked() { app.open_dialog("removeDuplicates", json!({})); } let dv = big_button(ui, Icon::Validation, "Data\nValidation", "Data Validation", true); + for sequence in keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["data.validation", "data.circleInvalid"]) { + keytip(app, ui, sequence, dv.rect.center_bottom()); + } egui::Popup::menu(&dv).show(|ui| { menu_items( app, @@ -1623,6 +1945,11 @@ fn data(app: &mut SheetApp, ui: &mut Ui) { }); sep(ui); let wi = big_button(ui, Icon::Calc, "What-If\nAnalysis", "What-If Analysis", true); + for sequence in + keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["dialog:goalSeek", "data.scenarioManager", "data.dataTable"]) + { + keytip(app, ui, sequence, wi.rect.center_bottom()); + } egui::Popup::menu(&wi).show(|ui| { menu_items( app, @@ -1639,13 +1966,19 @@ fn data(app: &mut SheetApp, ui: &mut Ui) { } sep(ui); ui.vertical(|ui| { - if small_button(ui, Icon::Group, "Group", "Group", false).clicked() { + let __k = small_button(ui, Icon::Group, "Group", "Group", false); + control_keytip(app, ui, &__k, "data.group"); + if __k.clicked() { act(app, "data.group", json!({})); } - if small_button(ui, Icon::Ungroup, "Ungroup", "Ungroup", false).clicked() { + let __k = small_button(ui, Icon::Ungroup, "Ungroup", "Ungroup", false); + control_keytip(app, ui, &__k, "data.ungroup"); + if __k.clicked() { act(app, "data.ungroup", json!({})); } - if small_button(ui, Icon::Subtotal, "Subtotal", "Subtotal", false).clicked() { + let __k = small_button(ui, Icon::Subtotal, "Subtotal", "Subtotal", false); + control_keytip(app, ui, &__k, "dialog:subtotal"); + if __k.clicked() { app.open_dialog("subtotal", json!({})); } }); @@ -1668,20 +2001,33 @@ fn data(app: &mut SheetApp, ui: &mut Ui) { } fn review(app: &mut SheetApp, ui: &mut Ui) { - if big_button(ui, Icon::Spell, "Spelling", "Spelling (F7)", false).clicked() { + let __k = big_button(ui, Icon::Spell, "Spelling", "Spelling (F7)", false); + control_keytip(app, ui, &__k, "dialog:spelling"); + if __k.clicked() { app.open_dialog("spelling", json!({})); } - if big_button(ui, Icon::Calc, "Workbook\nStatistics", "Workbook Statistics", false).clicked() { + let __k = big_button(ui, Icon::Calc, "Workbook\nStatistics", "Workbook Statistics", false); + control_keytip(app, ui, &__k, "statistics"); + if __k.clicked() { app.open_dialog("statistics", json!({})); } - if big_button(ui, Icon::Validation, "Check\nAccessibility", "Check Accessibility", false).clicked() { + let __k = big_button(ui, Icon::Validation, "Check\nAccessibility", "Check Accessibility", false); + control_keytip(app, ui, &__k, "dialog:accessibility"); + if __k.clicked() { app.open_dialog("accessibility", json!({})); } sep(ui); - if big_button(ui, Icon::Comment, "New\nComment", "New Comment", false).clicked() { + let __k = big_button(ui, Icon::Comment, "New\nComment", "New Comment", false); + control_keytip(app, ui, &__k, "dialog:comment"); + if __k.clicked() { app.open_dialog("comment", json!({"threaded": true})); } - if big_button(ui, Icon::Delete, "Delete", "Delete Comment", false).clicked() { + if big_button(ui, Icon::Comment, "Show\nComments", "Comments pane", false).clicked() { + app.grid.pane = Some("comments".into()); + } + let __k = big_button(ui, Icon::Delete, "Delete", "Delete Comment", false); + control_keytip(app, ui, &__k, "review.deleteComment"); + if __k.clicked() { act(app, "review.deleteComment", json!({})); } ui.vertical(|ui| { @@ -1706,6 +2052,7 @@ fn review(app: &mut SheetApp, ui: &mut Ui) { } } let notes = big_button(ui, Icon::Note, "Notes", "Notes", true); + anchor_keytip(app, ui, ¬es, &["review.nextComment"]); egui::Popup::menu(¬es).show(|ui| { menu_items( app, @@ -1721,14 +2068,18 @@ fn review(app: &mut SheetApp, ui: &mut Ui) { }); sep(ui); let protected = app.session.active().and_then(|d| d.wb.active().map(|s| s.is_protected())).unwrap_or(false); - if big_button(ui, Icon::Lock, if protected { "Unprotect\nSheet" } else { "Protect\nSheet" }, "Protect Sheet", false).clicked() { + let __k = big_button(ui, Icon::Lock, if protected { "Unprotect\nSheet" } else { "Protect\nSheet" }, "Protect Sheet", false); + control_keytip(app, ui, &__k, "dialog:protectSheet"); + if __k.clicked() { if protected { app.open_dialog("unprotectSheet", json!({})); } else { app.open_dialog("protectSheet", json!({})); } } - if big_button(ui, Icon::Book, "Protect\nWorkbook", "Protect Workbook structure", false).clicked() { + let __k = big_button(ui, Icon::Book, "Protect\nWorkbook", "Protect Workbook structure", false); + control_keytip(app, ui, &__k, "review.protectWorkbook"); + if __k.clicked() { crate::backstage::protect_workbook(app); } if big_button(ui, Icon::Validation, "Allow Edit\nRanges", "Allow Edit Ranges", false).clicked() { @@ -1744,7 +2095,9 @@ fn view(app: &mut SheetApp, ui: &mut Ui) { (Icon::PageBreak, "Page Break\nPreview", "view.pageBreakPreview"), (Icon::PageLayout, "Page\nLayout", "view.pageLayout"), ] { - if big_button(ui, icon, label, label, false).clicked() { + let __k = big_button(ui, icon, label, label, false); + control_keytip(app, ui, &__k, cmd); + if __k.clicked() { act(app, cmd, json!({})); } } @@ -1756,35 +2109,54 @@ fn view(app: &mut SheetApp, ui: &mut Ui) { ui.vertical(|ui| { // Rulers exist only in Page Layout view. let mut ruler = !app.session.hide_ruler; - if ui.add_enabled(page_layout, egui::Checkbox::new(&mut ruler, lang.tr("Ruler"))).changed() { + let ruler_resp = ui.add_enabled(page_layout, egui::Checkbox::new(&mut ruler, lang.tr("Ruler"))); + control_keytip(app, ui, &ruler_resp, "view.ruler"); + if ruler_resp.changed() { act(app, "view.ruler", json!({"on": ruler})); } let mut fb = app.session.prefs.formula_bar; - if ui.checkbox(&mut fb, lang.tr("Formula Bar")).changed() { - act(app, "view.formulaBar", json!({"on": fb})); + let resp = ui.checkbox(&mut fb, lang.tr("Formula Bar")); + control_keytip(app, ui, &resp, "view.formulaBar"); + if resp.changed() || invoke(app, "view.formulaBar") { + act(app, "view.formulaBar", json!({"on": !fb})); } let mut g = s.0; - if ui.checkbox(&mut g, lang.tr("Gridlines")).changed() { + let resp = ui.checkbox(&mut g, lang.tr("Gridlines")); + control_keytip(app, ui, &resp, "view.gridlines"); + if resp.changed() { act(app, "view.gridlines", json!({"on": g})); } let mut h = s.1; - if ui.checkbox(&mut h, lang.tr("Headings")).changed() { + let resp = ui.checkbox(&mut h, lang.tr("Headings")); + control_keytip(app, ui, &resp, "view.headings"); + if resp.changed() { act(app, "view.headings", json!({"on": h})); } }); sep(ui); - if big_button(ui, Icon::Zoom, "Zoom", "Zoom…", false).clicked() { + let __k = big_button(ui, Icon::Zoom, "Zoom", "Zoom…", false); + control_keytip(app, ui, &__k, "dialog:zoom"); + if __k.clicked() { app.open_dialog("zoom", json!({})); } - if big_button(ui, Icon::Search, "100%", "Zoom to 100%", false).clicked() { - act(app, "view.zoom100", json!({})); + let __k = big_button(ui, Icon::Search, "100%", "Zoom to 100%", false); + control_keytip(app, ui, &__k, "view.zoom"); + if __k.clicked() || invoke(app, "view.zoom") { + act(app, "view.zoom", json!({"percent": 100})); } - if big_button(ui, Icon::Zoom, "Zoom to\nSelection", "Zoom to Selection", false).clicked() { + let __k = big_button(ui, Icon::Zoom, "Zoom to\nSelection", "Zoom to Selection", false); + control_keytip(app, ui, &__k, "view.zoomToSelection"); + if __k.clicked() { let r = app.grid.cells_rect.unwrap_or(Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 700.0))); act(app, "view.zoomToSelection", json!({"viewWidth": r.width(), "viewHeight": r.height()})); } sep(ui); let fp = big_button(ui, Icon::Freeze, "Freeze\nPanes", "Freeze Panes", true); + for sequence in + keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["view.freezePanes", "view.freezeTopRow", "view.freezeFirstColumn"]) + { + keytip(app, ui, sequence, fp.rect.center_bottom()); + } egui::Popup::menu(&fp).show(|ui| { menu_items( app, @@ -1856,16 +2228,24 @@ fn automate(app: &mut SheetApp, ui: &mut Ui) { if big_button(ui, Icon::Script, "Macros", "Macros and scripts in this workbook (Alt+F8)", false).clicked() { app.open_dialog("macros", json!({})); } - if big_button(ui, Icon::Script, "Command\nPalette", "Search and run any command", false).clicked() { + let __k = big_button(ui, Icon::Script, "Command\nPalette", "Search and run any command", false); + control_keytip(app, ui, &__k, "commandSearch"); + if __k.clicked() { app.open_dialog("commandSearch", json!({})); } - if big_button(ui, Icon::Script, "Agent\nControl", "How agents drive GridCraft (MCP / control channel)", false).clicked() { + let __k = big_button(ui, Icon::Script, "Agent\nControl", "How agents drive GridCraft (MCP / control channel)", false); + control_keytip(app, ui, &__k, "agents"); + if __k.clicked() { app.open_dialog("agents", json!({})); } - if big_button(ui, Icon::Script, "Action\nJournal", "Every command run in this session (replayable)", false).clicked() { + let __k = big_button(ui, Icon::Script, "Action\nJournal", "Every command run in this session (replayable)", false); + control_keytip(app, ui, &__k, "journal"); + if __k.clicked() { app.open_dialog("journal", json!({})); } - if big_button(ui, Icon::Script, "About\nGridCraft", "Version, contributors and the AI models that helped", false).clicked() { + let __k = big_button(ui, Icon::Script, "About\nGridCraft", "Version, contributors and the AI models that helped", false); + control_keytip(app, ui, &__k, "about"); + if __k.clicked() { app.open_dialog("about", json!({})); } } @@ -1888,10 +2268,14 @@ fn table_design(app: &mut SheetApp, ui: &mut Ui) { } }); sep(ui); - if big_button(ui, Icon::Duplicates, "Remove\nDuplicates", "Remove Duplicates", false).clicked() { + let __k = big_button(ui, Icon::Duplicates, "Remove\nDuplicates", "Remove Duplicates", false); + control_keytip(app, ui, &__k, "data.removeDuplicates"); + if __k.clicked() { act(app, "data.removeDuplicates", json!({})); } - if big_button(ui, Icon::Table, "Convert\nto Range", "Convert to Range", false).clicked() { + let __k = big_button(ui, Icon::Table, "Convert\nto Range", "Convert to Range", false); + control_keytip(app, ui, &__k, "table.convertToRange"); + if __k.clicked() { act(app, "table.convertToRange", json!({"table": tname})); } sep(ui); @@ -1925,12 +2309,16 @@ fn table_design(app: &mut SheetApp, ui: &mut Ui) { }); sep(ui); let st = big_button(ui, Icon::FormatTable, "Table\nStyles", "Table Styles", true); + anchor_keytip(app, ui, &st, &["table.style"]); egui::Popup::menu(&st).show(|ui| table_gallery(app, ui, "table.style")); } fn chart_design(app: &mut SheetApp, ui: &mut Ui) { let Some(id) = app.selected_chart else { return }; let ae = big_button(ui, Icon::Chart, "Add Chart\nElement", "Add Chart Element", true); + for sequence in keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["chart.set"]) { + keytip(app, ui, sequence, ae.rect.center_bottom()); + } egui::Popup::menu(&ae).show(|ui| { menu_items( app, @@ -1948,13 +2336,21 @@ fn chart_design(app: &mut SheetApp, ui: &mut Ui) { ], ); }); - if big_button(ui, Icon::Redo, "Switch\nRow/Column", "Switch Row/Column", false).clicked() { + let __k = big_button(ui, Icon::Redo, "Switch\nRow/Column", "Switch Row/Column", false); + control_keytip(app, ui, &__k, "chart.switchRowColumn"); + if __k.clicked() { act(app, "chart.switchRowColumn", json!({"chart": id})); } if big_button(ui, Icon::Table, "Select\nData", "Select Data", false).clicked() { app.open_dialog("selectData", json!({"chart": id})); } let ct = big_button(ui, Icon::ChartBar, "Change\nChart Type", "Change Chart Type", true); + for sequence in keytip_table::group_extension( + app.keytips.prefix().unwrap_or_default(), + &["column", "bar", "line", "area", "pie", "doughnut", "scatter", "radar", "waterfall", "funnel", "treemap"], + ) { + keytip(app, ui, sequence, ct.rect.center_bottom()); + } egui::Popup::menu(&ct).show(|ui| { for (label, k, s) in [ ("Clustered Column", "column", ""), @@ -1978,10 +2374,14 @@ fn chart_design(app: &mut SheetApp, ui: &mut Ui) { if big_button(ui, Icon::Settings, "Format\nPane", "Format Chart pane", false).clicked() { app.grid.pane = Some("formatChart".into()); } - if big_button(ui, Icon::TextBox, "Chart\nTitle", "Edit chart title", false).clicked() { + let __k = big_button(ui, Icon::TextBox, "Chart\nTitle", "Edit chart title", false); + control_keytip(app, ui, &__k, "chartTitle"); + if __k.clicked() { app.open_dialog("chartTitle", json!({"chart": id})); } - if big_button(ui, Icon::Delete, "Delete\nChart", "Delete chart", false).clicked() { + let __k = big_button(ui, Icon::Delete, "Delete\nChart", "Delete chart", false); + control_keytip(app, ui, &__k, "chart.delete"); + if __k.clicked() { act(app, "chart.delete", json!({"chart": id})); app.selected_chart = None; } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index a006c6b..e24105b 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -145,9 +145,83 @@ pub fn committed_text(ui: &mut Ui, id: egui::Id, current: &str, width: Option Option { 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::>(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 } diff --git a/crates/ui-egui/tests/keytips.rs b/crates/ui-egui/tests/keytips.rs index 272f863..24be742 100644 --- a/crates/ui-egui/tests/keytips.rs +++ b/crates/ui-egui/tests/keytips.rs @@ -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"); +} diff --git a/crates/ui-egui/tests/keytips_generated.rs b/crates/ui-egui/tests/keytips_generated.rs new file mode 100644 index 0000000..50c5731 --- /dev/null +++ b/crates/ui-egui/tests/keytips_generated.rs @@ -0,0 +1,2775 @@ +//! 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::os::OperatingSystem; +use egui::{Event, Key, Modifiers}; +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) { + 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); + } +} + +#[test] +fn keytip_h_() { + let mut h = harness(blank()); + sequence(&mut h, "H", false); + assert_eq!(h.state().ui.ribbon_tab, "Home", "H must switch to the Home tab"); +} + +#[test] +fn keytip_n_() { + let mut h = harness(blank()); + sequence(&mut h, "N", false); + assert_eq!(h.state().ui.ribbon_tab, "Insert", "N must switch to the Insert tab"); +} + +#[test] +fn keytip_ji_() { + let mut h = harness(blank()); + sequence(&mut h, "JI", false); + assert_eq!(h.state().ui.ribbon_tab, "Draw", "JI must switch to the Draw tab"); +} + +#[test] +fn keytip_p_() { + let mut h = harness(blank()); + sequence(&mut h, "P", false); + assert_eq!(h.state().ui.ribbon_tab, "Page Layout", "P must switch to the Page Layout tab"); +} + +#[test] +fn keytip_m_() { + let mut h = harness(blank()); + sequence(&mut h, "M", false); + assert_eq!(h.state().ui.ribbon_tab, "Formulas", "M must switch to the Formulas tab"); +} + +#[test] +fn keytip_a_() { + let mut h = harness(blank()); + sequence(&mut h, "A", false); + assert_eq!(h.state().ui.ribbon_tab, "Data", "A must switch to the Data tab"); +} + +#[test] +fn keytip_r_() { + let mut h = harness(blank()); + sequence(&mut h, "R", false); + assert_eq!(h.state().ui.ribbon_tab, "Review", "R must switch to the Review tab"); +} + +#[test] +fn keytip_w_() { + let mut h = harness(blank()); + sequence(&mut h, "W", false); + assert_eq!(h.state().ui.ribbon_tab, "View", "W must switch to the View tab"); +} + +#[test] +fn keytip_e_() { + let mut h = harness(blank()); + sequence(&mut h, "E", false); + assert_eq!(h.state().ui.ribbon_tab, "Home", "E must switch to the Home tab"); +} + +#[test] +fn keytip_o_() { + let mut h = harness(blank()); + sequence(&mut h, "O", false); + assert_eq!(h.state().ui.ribbon_tab, "Home", "O must switch to the Home tab"); +} + +#[test] +fn keytip_har_() { + let mut h = harness(blank()); + sequence(&mut h, "HAR", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.alignRight"), "HAR must journal home.alignRight"); +} + +#[test] +fn keytip_hat_() { + let mut h = harness(blank()); + sequence(&mut h, "HAT", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.alignTop"), "HAT must journal home.alignTop"); +} + +#[test] +fn keytip_ham_() { + let mut h = harness(blank()); + sequence(&mut h, "HAM", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.alignMiddle"), "HAM must journal home.alignMiddle"); +} + +#[test] +fn keytip_hab_() { + let mut h = harness(blank()); + sequence(&mut h, "HAB", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.alignBottom"), "HAB must journal home.alignBottom"); +} + +#[test] +fn keytip_hal_() { + let mut h = harness(blank()); + sequence(&mut h, "HAL", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.alignLeft"), "HAL must journal home.alignLeft"); +} + +#[test] +fn keytip_hac_() { + let mut h = harness(blank()); + sequence(&mut h, "HAC", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.alignCenter"), "HAC must journal home.alignCenter"); +} + +#[test] +fn keytip_hvs_() { + let mut h = harness(blank()); + sequence(&mut h, "HVS", false); + assert!(h.state().dialog.is_some(), "HVS must open the pasteSpecial dialog"); +} + +#[test] +fn keytip_es_() { + let mut h = harness(blank()); + sequence(&mut h, "ES", false); + assert!(h.state().dialog.is_some(), "ES must open the pasteSpecial dialog"); +} + +#[test] +fn keytip_hoi_() { + let mut h = harness(blank()); + sequence(&mut h, "HOI", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.autofitColumnWidth"), "HOI must journal home.autofitColumnWidth"); +} + +#[test] +fn keytip_oca_() { + let mut h = harness(blank()); + sequence(&mut h, "OCA", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.autofitColumnWidth"), "OCA must journal home.autofitColumnWidth"); +} + +#[test] +fn keytip_wvg_() { + let mut h = harness(blank()); + sequence(&mut h, "WVG", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "view.gridlines"), "WVG must journal view.gridlines"); +} + +#[test] +fn keytip_h0_() { + let mut h = harness(blank()); + sequence(&mut h, "H0", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.increaseDecimal"), "H0 must journal home.increaseDecimal"); +} + +#[test] +fn keytip_h1_() { + let mut h = harness(blank()); + sequence(&mut h, "H1", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.bold"), "H1 must journal home.bold"); +} + +#[test] +fn keytip_h2_() { + let mut h = harness(blank()); + sequence(&mut h, "H2", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.italic"), "H2 must journal home.italic"); +} + +#[test] +fn keytip_h3_() { + let mut h = harness(blank()); + sequence(&mut h, "H3", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.underline"), "H3 must journal home.underline"); +} + +#[test] +fn keytip_h5_() { + let mut h = harness(blank()); + sequence(&mut h, "H5", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.decreaseIndent"), "H5 must journal home.decreaseIndent"); +} + +#[test] +fn keytip_h6_() { + let mut h = harness(blank()); + sequence(&mut h, "H6", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.increaseIndent"), "H6 must journal home.increaseIndent"); +} + +#[test] +fn keytip_h9_() { + let mut h = harness(blank()); + sequence(&mut h, "H9", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.decreaseDecimal"), "H9 must journal home.decreaseDecimal"); +} + +#[test] +fn keytip_han_() { + let mut h = harness(blank()); + sequence(&mut h, "HAN", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.accounting"), "HAN must journal home.accounting"); +} + +#[test] +fn keytip_has_() { + let mut h = harness(blank()); + sequence(&mut h, "HAS", false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "HAS must run home.addIns cleanly"); +} + +#[test] +fn keytip_hb_() { + let mut h = harness(blank()); + sequence(&mut h, "HB", false); + assert_eq!(h.state().keytips.prefix(), Some("HB"), "HB must stay armed waiting for more input"); +} + +#[test] +fn keytip_hba_() { + let mut h = harness(blank()); + sequence(&mut h, "HBA", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.borders"), "HBA must journal home.borders"); +} + +#[test] +fn keytip_hbb_() { + let mut h = harness(blank()); + sequence(&mut h, "HBB", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.borders"), "HBB must journal home.borders"); +} + +#[test] +fn keytip_hbc_() { + let mut h = harness(blank()); + sequence(&mut h, "HBC", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.borders"), "HBC must journal home.borders"); +} + +#[test] +fn keytip_hbd_() { + let mut h = harness(blank()); + sequence(&mut h, "HBD", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.borders"), "HBD must journal home.borders"); +} + +#[test] +fn keytip_hbe_() { + let mut h = harness(blank()); + sequence(&mut h, "HBE", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.borders"), "HBE must journal home.borders"); +} + +#[test] +fn keytip_hbg_() { + let mut h = harness(blank()); + sequence(&mut h, "HBG", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.drawBorder"), "HBG must journal home.drawBorder"); +} + +#[test] +fn keytip_hbh_() { + let mut h = harness(blank()); + sequence(&mut h, "HBH", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.borders"), "HBH must journal home.borders"); +} + +#[test] +fn keytip_hbi_() { + let mut h = harness(blank()); + sequence(&mut h, "HBI", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.borders"), "HBI must journal home.borders"); +} + +#[test] +fn keytip_hbl_() { + let mut h = harness(blank()); + sequence(&mut h, "HBL", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.borders"), "HBL must journal home.borders"); +} + +#[test] +fn keytip_hbm_() { + let mut h = harness(blank()); + sequence(&mut h, "HBM", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.borders"), "HBM must journal home.borders"); +} + +#[test] +fn keytip_hbn_() { + let mut h = harness(blank()); + sequence(&mut h, "HBN", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.borders"), "HBN must journal home.borders"); +} + +#[test] +fn keytip_hbo_() { + let mut h = harness(blank()); + sequence(&mut h, "HBO", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.borders"), "HBO must journal home.borders"); +} + +#[test] +fn keytip_hbp_() { + let mut h = harness(blank()); + sequence(&mut h, "HBP", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.borders"), "HBP must journal home.borders"); +} + +#[test] +fn keytip_hbr_() { + let mut h = harness(blank()); + sequence(&mut h, "HBR", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.borders"), "HBR must journal home.borders"); +} + +#[test] +fn keytip_hbs_() { + let mut h = harness(blank()); + sequence(&mut h, "HBS", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.borders"), "HBS must journal home.borders"); +} + +#[test] +fn keytip_hbt_() { + let mut h = harness(blank()); + sequence(&mut h, "HBT", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.borders"), "HBT must journal home.borders"); +} + +#[test] +fn keytip_hbu_() { + let mut h = harness(blank()); + sequence(&mut h, "HBU", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.borders"), "HBU must journal home.borders"); +} + +#[test] +fn keytip_hbw_() { + let mut h = harness(blank()); + sequence(&mut h, "HBW", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.drawBorder"), "HBW must journal home.drawBorder"); +} + +#[test] +fn keytip_hby_() { + let mut h = harness(blank()); + sequence(&mut h, "HBY", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.borders"), "HBY must journal home.borders"); +} + +#[test] +fn keytip_hc_() { + let mut h = harness(blank()); + sequence(&mut h, "HC", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "edit.copy"), "HC must journal edit.copy"); +} + +#[test] +fn keytip_hdc_() { + let mut h = harness(blank()); + sequence(&mut h, "HDC", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.deleteColumns"), "HDC must journal home.deleteColumns"); +} + +#[test] +fn keytip_hdd_() { + let mut h = harness(blank()); + sequence(&mut h, "HDD", false); + assert!(h.state().dialog.is_some(), "HDD must open the deleteCells dialog"); +} + +#[test] +fn keytip_hdr_() { + let mut h = harness(blank()); + sequence(&mut h, "HDR", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.deleteRows"), "HDR must journal home.deleteRows"); +} + +#[test] +fn keytip_hds_() { + let mut h = harness(blank()); + sequence(&mut h, "HDS", false); + assert!(h.state().message.is_some() || h.state().toast.is_some(), "HDS must invoke home.deleteSheet and surface its missing-params error"); +} + +#[test] +fn keytip_hea_() { + let mut h = harness(blank()); + sequence(&mut h, "HEA", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "edit.clearAll"), "HEA must journal edit.clearAll"); +} + +#[test] +fn keytip_hec_() { + let mut h = harness(blank()); + sequence(&mut h, "HEC", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "edit.clearContents"), "HEC must journal edit.clearContents"); +} + +#[test] +fn keytip_hef_() { + let mut h = harness(blank()); + sequence(&mut h, "HEF", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "edit.clearFormats"), "HEF must journal edit.clearFormats"); +} + +#[test] +fn keytip_hel_() { + let mut h = harness(blank()); + sequence(&mut h, "HEL", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "edit.clearHyperlinks"), "HEL must journal edit.clearHyperlinks"); +} + +#[test] +fn keytip_hem_() { + let mut h = harness(blank()); + sequence(&mut h, "HEM", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "edit.clearComments"), "HEM must journal edit.clearComments"); +} + +#[test] +fn keytip_hfc_() { + let mut h = harness(blank()); + sequence(&mut h, "HFC", false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "HFC must route to its control without an error box"); + assert_eq!(h.state().keytips.prefix(), None); +} + +#[test] +fn keytip_hfdc_() { + let mut h = harness(blank()); + sequence(&mut h, "HFDC", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.conditionalFormat"), "HFDC must journal home.conditionalFormat"); +} + +#[test] +fn keytip_hfdf_() { + let mut h = harness(blank()); + sequence(&mut h, "HFDF", false); + assert!(h.state().dialog.is_some(), "HFDF must open the find dialog"); +} + +#[test] +fn keytip_hfdg_() { + let mut h = harness(blank()); + sequence(&mut h, "HFDG", false); + assert!(h.state().dialog.is_some(), "HFDG must open the goTo dialog"); +} + +#[test] +fn keytip_hfdm_() { + let mut h = harness(blank()); + sequence(&mut h, "HFDM", false); + assert!(h.state().message.is_some() || h.state().toast.is_some(), "HFDM must invoke edit.find and surface its missing-params error"); +} + +#[test] +fn keytip_hfdo_() { + let mut h = harness(blank()); + sequence(&mut h, "HFDO", false); + assert!(h.state().dialog.is_none(), "HFDO must not open a dialog"); + assert!(h.state().message.is_none(), "HFDO must not open a message box"); + assert!(h.state().toast.is_none(), "HFDO must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "HFDO must not journal"); +} + +#[test] +fn keytip_hfdp_() { + let mut h = harness(blank()); + sequence(&mut h, "HFDP", false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "HFDP must run arrange.selectionPane cleanly"); +} + +#[test] +fn keytip_hfdr_() { + let mut h = harness(blank()); + sequence(&mut h, "HFDR", false); + assert!(h.state().dialog.is_some(), "HFDR must open the find dialog"); +} + +#[test] +fn keytip_hfds_() { + let mut h = harness(blank()); + sequence(&mut h, "HFDS", false); + assert!(h.state().dialog.is_some(), "HFDS must open the goToSpecial dialog"); +} + +#[test] +fn keytip_hff_() { + let mut h = harness(blank()); + sequence(&mut h, "HFF", false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "HFF must route to its control without an error box"); + assert_eq!(h.state().keytips.prefix(), None); +} + +#[test] +fn keytip_hfg_() { + let mut h = harness(blank()); + sequence(&mut h, "HFG", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.increaseFontSize"), "HFG must journal home.increaseFontSize"); +} + +#[test] +fn keytip_hfi_() { + let mut h = harness(blank()); + sequence(&mut h, "HFI", false); + assert_eq!(h.state().keytips.prefix(), Some("HFI"), "HFI must stay armed waiting for more input"); +} + +#[test] +fn keytip_hfia_() { + let mut h = harness(blank()); + sequence(&mut h, "HFIA", false); + assert!(h.state().dialog.is_none(), "HFIA must not open a dialog"); + assert!(h.state().message.is_none(), "HFIA must not open a message box"); + assert!(h.state().toast.is_none(), "HFIA must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "HFIA must not journal"); +} + +#[test] +fn keytip_hfid_() { + let mut h = harness(blank()); + sequence(&mut h, "HFID", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "edit.fillDown"), "HFID must journal edit.fillDown"); +} + +#[test] +fn keytip_hfif_() { + let mut h = harness(blank()); + sequence(&mut h, "HFIF", false); + assert!(h.state().message.is_some() || h.state().toast.is_some(), "HFIF must invoke edit.flashFill and surface its missing-params error"); +} + +#[test] +fn keytip_hfij_() { + let mut h = harness(blank()); + sequence(&mut h, "HFIJ", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.justify"), "HFIJ must journal home.justify"); +} + +#[test] +fn keytip_hfil_() { + let mut h = harness(blank()); + sequence(&mut h, "HFIL", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "edit.fillLeft"), "HFIL must journal edit.fillLeft"); +} + +#[test] +fn keytip_hfir_() { + let mut h = harness(blank()); + sequence(&mut h, "HFIR", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "edit.fillRight"), "HFIR must journal edit.fillRight"); +} + +#[test] +fn keytip_hfis_() { + let mut h = harness(blank()); + sequence(&mut h, "HFIS", false); + assert!(h.state().dialog.is_some(), "HFIS must open the series dialog"); +} + +#[test] +fn keytip_hfiu_() { + let mut h = harness(blank()); + sequence(&mut h, "HFIU", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "edit.fillUp"), "HFIU must journal edit.fillUp"); +} + +#[test] +fn keytip_hfk_() { + let mut h = harness(blank()); + sequence(&mut h, "HFK", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.decreaseFontSize"), "HFK must journal home.decreaseFontSize"); +} + +#[test] +fn keytip_hfm_() { + let mut h = harness(blank()); + sequence(&mut h, "HFM", false); + assert!(h.state().dialog.is_some(), "HFM must open the formatCells dialog"); +} + +#[test] +fn keytip_hfp_() { + let mut h = harness(blank()); + sequence(&mut h, "HFP", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "edit.formatPainter"), "HFP must journal edit.formatPainter"); +} + +#[test] +fn keytip_hfq_() { + let mut h = harness(blank()); + sequence(&mut h, "HFQ", false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "HFQ must route to its control without an error box"); + assert_eq!(h.state().keytips.prefix(), None); +} + +#[test] +fn keytip_hfs_() { + let mut h = harness(blank()); + sequence(&mut h, "HFS", false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "HFS must route to its control without an error box"); + assert_eq!(h.state().keytips.prefix(), None); +} + +#[test] +fn keytip_hh_() { + let mut h = harness(blank()); + sequence(&mut h, "HH", false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "HH must route to its control without an error box"); + assert_eq!(h.state().keytips.prefix(), None); +} + +#[test] +fn keytip_hic_() { + let mut h = harness(blank()); + sequence(&mut h, "HIC", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.insertColumns"), "HIC must journal home.insertColumns"); +} + +#[test] +fn keytip_hie_() { + let mut h = harness(blank()); + sequence(&mut h, "HIE", false); + assert!(h.state().dialog.is_some(), "HIE must open the insertCells dialog"); +} + +#[test] +fn keytip_hii_() { + let mut h = harness(blank()); + sequence(&mut h, "HII", false); + assert!(h.state().dialog.is_some(), "HII must open the insertCells dialog"); +} + +#[test] +fn keytip_hir_() { + let mut h = harness(blank()); + sequence(&mut h, "HIR", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.insertRows"), "HIR must journal home.insertRows"); +} + +#[test] +fn keytip_his_() { + let mut h = harness(blank()); + sequence(&mut h, "HIS", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.insertSheet"), "HIS must journal home.insertSheet"); +} + +#[test] +fn keytip_hj_() { + let mut h = harness(blank()); + sequence(&mut h, "HJ", false); + assert_eq!(h.state().keytips.prefix(), Some("HJ"), "HJ must stay armed waiting for more input"); +} + +#[test] +fn keytip_hjn_() { + let mut h = harness(blank()); + sequence(&mut h, "HJN", false); + assert!(h.state().message.is_some() || h.state().toast.is_some(), "HJN must invoke home.newCellStyle and surface its missing-params error"); +} + +#[test] +fn keytip_hk_() { + let mut h = harness(blank()); + sequence(&mut h, "HK", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.comma"), "HK must journal home.comma"); +} + +#[test] +fn keytip_hl_() { + let mut h = harness(blank()); + sequence(&mut h, "HL", false); + assert_eq!(h.state().keytips.prefix(), Some("HL"), "HL must stay armed waiting for more input"); +} + +#[test] +fn keytip_hlc_() { + let mut h = harness(blank()); + sequence(&mut h, "HLC", false); + assert_eq!(h.state().keytips.prefix(), Some("HLC"), "HLC must stay armed waiting for more input"); +} + +#[test] +fn keytip_hlce_() { + let mut h = harness(blank()); + sequence(&mut h, "HLCE", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.clearRules"), "HLCE must journal home.clearRules"); +} + +#[test] +fn keytip_hlcs_() { + let mut h = harness(blank()); + sequence(&mut h, "HLCS", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.clearRules"), "HLCS must journal home.clearRules"); +} + +#[test] +fn keytip_hlct_() { + let mut h = harness(blank()); + sequence(&mut h, "HLCT", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.clearRules"), "HLCT must journal home.clearRules"); +} + +#[test] +fn keytip_hld_() { + let mut h = harness(blank()); + sequence(&mut h, "HLD", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.conditionalFormat"), "HLD must journal home.conditionalFormat"); +} + +#[test] +fn keytip_hlh_() { + let mut h = harness(blank()); + sequence(&mut h, "HLH", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.conditionalFormat"), "HLH must journal home.conditionalFormat"); +} + +#[test] +fn keytip_hln_() { + let mut h = harness(blank()); + sequence(&mut h, "HLN", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.conditionalFormat"), "HLN must journal home.conditionalFormat"); +} + +#[test] +fn keytip_hlr_() { + let mut h = harness(blank()); + sequence(&mut h, "HLR", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.manageRules"), "HLR must journal home.manageRules"); +} + +#[test] +fn keytip_hlt_() { + let mut h = harness(blank()); + sequence(&mut h, "HLT", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.conditionalFormat"), "HLT must journal home.conditionalFormat"); +} + +#[test] +fn keytip_hm_() { + let mut h = harness(blank()); + sequence(&mut h, "HM", false); + assert_eq!(h.state().keytips.prefix(), Some("HM"), "HM must stay armed waiting for more input"); +} + +#[test] +fn keytip_hma_() { + let mut h = harness(blank()); + sequence(&mut h, "HMA", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.mergeAcross"), "HMA must journal home.mergeAcross"); +} + +#[test] +fn keytip_hmc_() { + let mut h = harness(blank()); + sequence(&mut h, "HMC", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.mergeCenter"), "HMC must journal home.mergeCenter"); +} + +#[test] +fn keytip_hmm_() { + let mut h = harness(blank()); + sequence(&mut h, "HMM", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.mergeCells"), "HMM must journal home.mergeCells"); +} + +#[test] +fn keytip_hmu_() { + let mut h = harness(blank()); + sequence(&mut h, "HMU", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.unmergeCells"), "HMU must journal home.unmergeCells"); +} + +#[test] +fn keytip_hn_() { + let mut h = harness(blank()); + sequence(&mut h, "HN", false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "HN must route to its control without an error box"); + assert_eq!(h.state().keytips.prefix(), None); +} + +#[test] +fn keytip_hoa_() { + let mut h = harness(blank()); + sequence(&mut h, "HOA", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.autofitRowHeight"), "HOA must journal home.autofitRowHeight"); +} + +#[test] +fn keytip_hod_() { + let mut h = harness(blank()); + sequence(&mut h, "HOD", false); + assert!(h.state().dialog.is_some(), "HOD must open the defaultWidth dialog"); +} + +#[test] +fn keytip_hoe_() { + let mut h = harness(blank()); + sequence(&mut h, "HOE", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.formatCells"), "HOE must journal home.formatCells"); +} + +#[test] +fn keytip_hoh_() { + let mut h = harness(blank()); + sequence(&mut h, "HOH", false); + assert!(h.state().dialog.is_some(), "HOH must open the rowHeight dialog"); +} + +#[test] +fn keytip_hol_() { + let mut h = harness(blank()); + sequence(&mut h, "HOL", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.lockCell"), "HOL must journal home.lockCell"); +} + +#[test] +fn keytip_hom_() { + let mut h = harness(blank()); + sequence(&mut h, "HOM", false); + assert!(h.state().dialog.is_some(), "HOM must open the moveSheet dialog"); +} + +#[test] +fn keytip_hop_() { + let mut h = harness(blank()); + sequence(&mut h, "HOP", false); + assert!(h.state().dialog.is_some(), "HOP must open the protectSheet dialog"); +} + +#[test] +fn keytip_hor_() { + let mut h = harness(blank()); + sequence(&mut h, "HOR", false); + assert!(h.state().dialog.is_some(), "HOR must open the renameSheet dialog"); +} + +#[test] +fn keytip_hot_() { + let mut h = harness(blank()); + sequence(&mut h, "HOT", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "sheet.tabColor"), "HOT must journal sheet.tabColor"); +} + +#[test] +fn keytip_houc_() { + let mut h = harness(blank()); + sequence(&mut h, "HOUC", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.hideColumns"), "HOUC must journal home.hideColumns"); +} + +#[test] +fn keytip_houh_() { + let mut h = harness(blank()); + sequence(&mut h, "HOUH", false); + assert!(h.state().message.is_some() || h.state().toast.is_some(), "HOUH must invoke sheet.unhide and surface its missing-params error"); +} + +#[test] +fn keytip_houl_() { + let mut h = harness(blank()); + sequence(&mut h, "HOUL", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.unhideColumns"), "HOUL must journal home.unhideColumns"); +} + +#[test] +fn keytip_houo_() { + let mut h = harness(blank()); + sequence(&mut h, "HOUO", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.unhideRows"), "HOUO must journal home.unhideRows"); +} + +#[test] +fn keytip_hour_() { + let mut h = harness(blank()); + sequence(&mut h, "HOUR", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.hideRows"), "HOUR must journal home.hideRows"); +} + +#[test] +fn keytip_hous_() { + let mut h = harness(blank()); + sequence(&mut h, "HOUS", false); + assert!(h.state().message.is_some() || h.state().toast.is_some(), "HOUS must invoke sheet.hide and surface its missing-params error"); +} + +#[test] +fn keytip_how_() { + let mut h = harness(blank()); + sequence(&mut h, "HOW", false); + assert!(h.state().dialog.is_some(), "HOW must open the columnWidth dialog"); +} + +#[test] +fn keytip_hp_() { + let mut h = harness(blank()); + sequence(&mut h, "HP", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.percent"), "HP must journal home.percent"); +} + +#[test] +fn keytip_hsc_() { + let mut h = harness(blank()); + sequence(&mut h, "HSC", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.clearFilter"), "HSC must journal data.clearFilter"); +} + +#[test] +fn keytip_hsf_() { + let mut h = harness(blank()); + sequence(&mut h, "HSF", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.filter"), "HSF must journal data.filter"); +} + +#[test] +fn keytip_hso_() { + let mut h = harness(blank()); + sequence(&mut h, "HSO", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.sortDescending"), "HSO must journal data.sortDescending"); +} + +#[test] +fn keytip_hss_() { + let mut h = harness(blank()); + sequence(&mut h, "HSS", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.sortAscending"), "HSS must journal data.sortAscending"); +} + +#[test] +fn keytip_hsu_() { + let mut h = harness(blank()); + sequence(&mut h, "HSU", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.sort"), "HSU must journal data.sort"); +} + +#[test] +fn keytip_ht_() { + let mut h = harness(blank()); + sequence(&mut h, "HT", false); + assert_eq!(h.state().keytips.prefix(), Some("HT"), "HT must stay armed waiting for more input"); +} + +#[test] +fn keytip_htn_() { + let mut h = harness(blank()); + sequence(&mut h, "HTN", false); + assert!(h.state().message.is_some() || h.state().toast.is_some(), "HTN must invoke table.style and surface its missing-params error"); +} + +#[test] +fn keytip_htp_() { + let mut h = harness(blank()); + sequence(&mut h, "HTP", false); + assert!(h.state().message.is_some() || h.state().toast.is_some(), "HTP must invoke table.style and surface its missing-params error"); +} + +#[test] +fn keytip_hu_() { + let mut h = harness(blank()); + sequence(&mut h, "HU", false); + assert_eq!(h.state().keytips.prefix(), Some("HU"), "HU must stay armed waiting for more input"); +} + +#[test] +fn keytip_hua_() { + let mut h = harness(blank()); + sequence(&mut h, "HUA", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "formulas.autoSum"), "HUA must journal formulas.autoSum"); +} + +#[test] +fn keytip_huc_() { + let mut h = harness(blank()); + sequence(&mut h, "HUC", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "formulas.autoSum"), "HUC must journal formulas.autoSum"); +} + +#[test] +fn keytip_hui_() { + let mut h = harness(blank()); + sequence(&mut h, "HUI", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "formulas.autoSum"), "HUI must journal formulas.autoSum"); +} + +#[test] +fn keytip_hum_() { + let mut h = harness(blank()); + sequence(&mut h, "HUM", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "formulas.autoSum"), "HUM must journal formulas.autoSum"); +} + +#[test] +fn keytip_hv_() { + let mut h = harness(blank()); + sequence(&mut h, "HV", false); + assert_eq!(h.state().keytips.prefix(), Some("HV"), "HV must stay armed waiting for more input"); +} + +#[test] +fn keytip_hvp_() { + let mut h = harness(blank()); + sequence(&mut h, "HVP", false); + assert!(h.state().message.is_some() || h.state().toast.is_some(), "HVP must invoke edit.pasteSpecial and surface its missing-params error"); +} + +#[test] +fn keytip_hw_() { + let mut h = harness(blank()); + sequence(&mut h, "HW", false); + assert_eq!(h.state().keytips.prefix(), Some("HW"), "HW must stay armed waiting for more input"); +} + +#[test] +fn keytip_hww_() { + let mut h = harness(blank()); + sequence(&mut h, "HWW", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.wrapText"), "HWW must journal home.wrapText"); +} + +#[test] +fn keytip_hx_() { + let mut h = harness(blank()); + sequence(&mut h, "HX", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "edit.cut"), "HX must journal edit.cut"); +} + +#[test] +fn keytip_nc1_() { + let mut h = harness(blank()); + sequence(&mut h, "NC1", false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "NC1 must route to its control without an error box"); + assert_eq!(h.state().keytips.prefix(), None); +} + +#[test] +fn keytip_nc2_() { + let mut h = harness(blank()); + sequence(&mut h, "NC2", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "review.newComment"), "NC2 must journal review.newComment"); +} + +#[test] +fn keytip_ncm_() { + let mut h = harness(blank()); + sequence(&mut h, "NCM", false); + assert!(h.state().dialog.is_some(), "NCM must open the comment dialog"); +} + +#[test] +fn keytip_ncb_() { + let mut h = harness(blank()); + sequence(&mut h, "NCB", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "insert.checkbox"), "NCB must journal insert.checkbox"); +} + +#[test] +fn keytip_nd_() { + let mut h = harness(blank()); + sequence(&mut h, "ND", false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "ND must route to its control without an error box"); + assert_eq!(h.state().keytips.prefix(), None); +} + +#[test] +fn keytip_ne_() { + let mut h = harness(blank()); + sequence(&mut h, "NE", false); + assert!(h.state().dialog.is_none(), "NE must not open a dialog"); + assert!(h.state().message.is_none(), "NE must not open a message box"); + assert!(h.state().toast.is_none(), "NE must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "NE must not journal"); +} + +#[test] +fn keytip_nf_() { + let mut h = harness(blank()); + sequence(&mut h, "NF", false); + assert_eq!(h.state().keytips.prefix(), Some("NF"), "NF must stay armed waiting for more input"); +} + +#[test] +fn keytip_nfp_() { + let mut h = harness(blank()); + sequence(&mut h, "NFP", false); + assert_eq!(h.state().keytips.prefix(), Some("NFP"), "NFP must stay armed waiting for more input"); +} + +#[test] +fn keytip_nfpf_() { + let mut h = harness(blank()); + sequence(&mut h, "NFPF", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "insert.picture"), "NFPF must journal insert.picture"); +} + +#[test] +fn keytip_nh1_() { + let mut h = harness(blank()); + sequence(&mut h, "NH1", false); + assert!(h.state().dialog.is_some(), "NH1 must open the headerFooter dialog"); +} + +#[test] +fn keytip_nhf_() { + let mut h = harness(blank()); + sequence(&mut h, "NHF", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "pageLayout.headerFooter"), "NHF must journal pageLayout.headerFooter"); +} + +#[test] +fn keytip_nhi_() { + let mut h = harness(blank()); + sequence(&mut h, "NHI", false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "NHI must route to its control without an error box"); + assert_eq!(h.state().keytips.prefix(), None); +} + +#[test] +fn keytip_ni1_() { + let mut h = harness(blank()); + sequence(&mut h, "NI1", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "insert.chart"), "NI1 must journal insert.chart"); +} + +#[test] +fn keytip_ni2_() { + let mut h = harness(blank()); + sequence(&mut h, "NI2", false); + assert!(h.state().dialog.is_some(), "NI2 must open the insertLink dialog"); +} + +#[test] +fn keytip_nj_() { + let mut h = harness(blank()); + sequence(&mut h, "NJ", false); + assert!(h.state().dialog.is_none(), "NJ must not open a dialog"); + assert!(h.state().message.is_none(), "NJ must not open a message box"); + assert!(h.state().toast.is_none(), "NJ must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "NJ must not journal"); +} + +#[test] +fn keytip_nm1t_() { + let mut h = harness(blank()); + sequence(&mut h, "NM1T", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "insert.picture"), "NM1T must journal insert.picture"); +} + +#[test] +fn keytip_nm2_() { + let mut h = harness(blank()); + sequence(&mut h, "NM2", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "insert.chart"), "NM2 must journal insert.chart"); +} + +#[test] +fn keytip_nn1_() { + let mut h = harness(blank()); + sequence(&mut h, "NN1", false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "NN1 must route to its control without an error box"); + assert_eq!(h.state().keytips.prefix(), None); +} + +#[test] +fn keytip_nns_() { + let mut h = harness(blank()); + sequence(&mut h, "NNS", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "insert.icons"), "NNS must journal insert.icons"); +} + +#[test] +fn keytip_np_() { + let mut h = harness(blank()); + sequence(&mut h, "NP", false); + assert_eq!(h.state().keytips.prefix(), Some("NP"), "NP must stay armed waiting for more input"); +} + +#[test] +fn keytip_npb_() { + let mut h = harness(blank()); + sequence(&mut h, "NPB", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "insert.picture"), "NPB must journal insert.picture"); +} + +#[test] +fn keytip_npf_() { + let mut h = harness(blank()); + sequence(&mut h, "NPF", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "insert.picture"), "NPF must journal insert.picture"); +} + +#[test] +fn keytip_npi_() { + let mut h = harness(blank()); + sequence(&mut h, "NPI", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "insert.picture"), "NPI must journal insert.picture"); +} + +#[test] +fn keytip_npo_() { + let mut h = harness(blank()); + sequence(&mut h, "NPO", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "insert.picture"), "NPO must journal insert.picture"); +} + +#[test] +fn keytip_nq_() { + let mut h = harness(blank()); + sequence(&mut h, "NQ", false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "NQ must route to its control without an error box"); + assert_eq!(h.state().keytips.prefix(), None); +} + +#[test] +fn keytip_nr_() { + let mut h = harness(blank()); + sequence(&mut h, "NR", false); + assert!(h.state().message.is_some() || h.state().toast.is_some(), "NR must invoke insert.recommendedCharts and surface its missing-params error"); +} + +#[test] +fn keytip_nsa_() { + let mut h = harness(blank()); + sequence(&mut h, "NSA", false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "NSA must route to its control without an error box"); + assert_eq!(h.state().keytips.prefix(), None); +} + +#[test] +fn keytip_nsc_() { + let mut h = harness(blank()); + sequence(&mut h, "NSC", false); + assert!(h.state().dialog.is_none(), "NSC must not open a dialog"); + assert!(h.state().message.is_none(), "NSC must not open a message box"); + assert!(h.state().toast.is_none(), "NSC must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "NSC must not journal"); +} + +#[test] +fn keytip_nsd_() { + let mut h = harness(blank()); + sequence(&mut h, "NSD", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "insert.chart"), "NSD must journal insert.chart"); +} + +#[test] +fn keytip_nsf_() { + let mut h = harness(blank()); + sequence(&mut h, "NSF", false); + assert!(h.state().message.is_some() || h.state().toast.is_some(), "NSF must invoke insert.slicer and surface its missing-params error"); +} + +#[test] +fn keytip_nsh_() { + let mut h = harness(blank()); + sequence(&mut h, "NSH", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "insert.shape"), "NSH must journal insert.shape"); +} + +#[test] +fn keytip_nsp_() { + let mut h = harness(blank()); + sequence(&mut h, "NSP", false); + assert!( + h.state().message.is_some() || h.state().toast.is_some(), + "NSP must invoke insert.recommendedPivotTables and surface its missing-params error" + ); +} + +#[test] +fn keytip_nst_() { + let mut h = harness(blank()); + sequence(&mut h, "NST", false); + assert!(h.state().dialog.is_none(), "NST must not open a dialog"); + assert!(h.state().message.is_none(), "NST must not open a message box"); + assert!(h.state().toast.is_none(), "NST must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "NST must not journal"); +} + +#[test] +fn keytip_nsz_() { + let mut h = harness(blank()); + sequence(&mut h, "NSZ", false); + assert!(h.state().message.is_some() || h.state().toast.is_some(), "NSZ must invoke insert.pivotChart and surface its missing-params error"); +} + +#[test] +fn keytip_nt_() { + let mut h = harness(blank()); + sequence(&mut h, "NT", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "insert.table"), "NT must journal insert.table"); +} + +#[test] +fn keytip_nu_() { + let mut h = harness(blank()); + sequence(&mut h, "NU", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "insert.symbol"), "NU must journal insert.symbol"); +} + +#[test] +fn keytip_nzs_() { + let mut h = harness(blank()); + sequence(&mut h, "NZS", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "insert.symbol"), "NZS must journal insert.symbol"); +} + +#[test] +fn keytip_nv_() { + let mut h = harness(blank()); + sequence(&mut h, "NV", false); + assert!(h.state().message.is_some() || h.state().toast.is_some(), "NV must invoke insert.pivotTable and surface its missing-params error"); +} + +#[test] +fn keytip_nw_() { + let mut h = harness(blank()); + sequence(&mut h, "NW", false); + assert!(h.state().dialog.is_none(), "NW must not open a dialog"); + assert!(h.state().message.is_none(), "NW must not open a message box"); + assert!(h.state().toast.is_none(), "NW must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "NW must not journal"); +} + +#[test] +fn keytip_nx_() { + let mut h = harness(blank()); + sequence(&mut h, "NX", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "insert.textBox"), "NX must journal insert.textBox"); +} + +#[test] +fn keytip_nzk_() { + let mut h = harness(blank()); + sequence(&mut h, "NZK", false); + assert_eq!(h.state().keytips.prefix(), Some("NZK"), "NZK must stay armed waiting for more input"); +} + +#[test] +fn keytip_nzksl_() { + let mut h = harness(blank()); + sequence(&mut h, "NZKSL", false); + assert!(h.state().dialog.is_some(), "NZKSL must open the sparkline dialog"); +} + +#[test] +fn keytip_nzkso_() { + let mut h = harness(blank()); + sequence(&mut h, "NZKSO", false); + assert!(h.state().dialog.is_some(), "NZKSO must open the sparkline dialog"); +} + +#[test] +fn keytip_nzksw_() { + let mut h = harness(blank()); + sequence(&mut h, "NZKSW", false); + assert!(h.state().dialog.is_some(), "NZKSW must open the sparkline dialog"); +} + +#[test] +fn keytip_nztx_() { + let mut h = harness(blank()); + sequence(&mut h, "NZTX", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "insert.textBox"), "NZTX must journal insert.textBox"); +} + +#[test] +fn keytip_nzt_() { + let mut h = harness(blank()); + sequence(&mut h, "NZT", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "insert.textBox"), "NZT must journal insert.textBox"); +} + +#[test] +fn keytip_pae_() { + let mut h = harness(blank()); + sequence(&mut h, "PAE", false); + assert!(h.state().message.is_some() || h.state().toast.is_some(), "PAE must invoke arrange.sendBackward and surface its missing-params error"); +} + +#[test] +fn keytip_paf_() { + let mut h = harness(blank()); + sequence(&mut h, "PAF", false); + assert!(h.state().message.is_some() || h.state().toast.is_some(), "PAF must invoke arrange.bringForward and surface its missing-params error"); +} + +#[test] +fn keytip_pb_() { + let mut h = harness(blank()); + sequence(&mut h, "PB", false); + assert_eq!(h.state().keytips.prefix(), Some("PB"), "PB must stay armed waiting for more input"); +} + +#[test] +fn keytip_pbi_() { + let mut h = harness(blank()); + sequence(&mut h, "PBI", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "pageLayout.breaks"), "PBI must journal pageLayout.breaks"); +} + +#[test] +fn keytip_pbr_() { + let mut h = harness(blank()); + sequence(&mut h, "PBR", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "pageLayout.breaks"), "PBR must journal pageLayout.breaks"); +} + +#[test] +fn keytip_pgb_() { + let mut h = harness(blank()); + sequence(&mut h, "PGB", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "pageLayout.background"), "PGB must journal pageLayout.background"); +} + +#[test] +fn keytip_pi_() { + let mut h = harness(blank()); + sequence(&mut h, "PI", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "pageLayout.printTitles"), "PI must journal pageLayout.printTitles"); +} + +#[test] +fn keytip_pm_() { + let mut h = harness(blank()); + sequence(&mut h, "PM", false); + assert_eq!(h.state().keytips.prefix(), Some("PM"), "PM must stay armed waiting for more input"); +} + +#[test] +fn keytip_pma_() { + let mut h = harness(blank()); + sequence(&mut h, "PMA", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "pageLayout.margins"), "PMA must journal pageLayout.margins"); +} + +#[test] +fn keytip_po_() { + let mut h = harness(blank()); + sequence(&mut h, "PO", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "pageLayout.orientation"), "PO must journal pageLayout.orientation"); +} + +#[test] +fn keytip_ppg_() { + let mut h = harness(blank()); + sequence(&mut h, "PPG", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "pageLayout.printGridlines"), "PPG must journal pageLayout.printGridlines"); +} + +#[test] +fn keytip_pph_() { + let mut h = harness(blank()); + sequence(&mut h, "PPH", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "pageLayout.printHeadings"), "PPH must journal pageLayout.printHeadings"); +} + +#[test] +fn keytip_pr_() { + let mut h = harness(blank()); + sequence(&mut h, "PR", false); + assert_eq!(h.state().keytips.prefix(), Some("PR"), "PR must stay armed waiting for more input"); +} + +#[test] +fn keytip_prs_() { + let mut h = harness(blank()); + sequence(&mut h, "PRS", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "pageLayout.printArea"), "PRS must journal pageLayout.printArea"); +} + +#[test] +fn keytip_psp_() { + let mut h = harness(blank()); + sequence(&mut h, "PSP", false); + assert!(h.state().dialog.is_some(), "PSP must open the pageSetup dialog"); +} + +#[test] +fn keytip_psz_() { + let mut h = harness(blank()); + sequence(&mut h, "PSZ", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "pageLayout.size"), "PSZ must journal pageLayout.size"); +} + +#[test] +fn keytip_ptc_() { + let mut h = harness(blank()); + sequence(&mut h, "PTC", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "pageLayout.themeColors"), "PTC must journal pageLayout.themeColors"); +} + +#[test] +fn keytip_pte_() { + let mut h = harness(blank()); + sequence(&mut h, "PTE", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "pageLayout.themeEffects"), "PTE must journal pageLayout.themeEffects"); +} + +#[test] +fn keytip_ptf_() { + let mut h = harness(blank()); + sequence(&mut h, "PTF", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "pageLayout.themeFonts"), "PTF must journal pageLayout.themeFonts"); +} + +#[test] +fn keytip_pth_() { + let mut h = harness(blank()); + sequence(&mut h, "PTH", false); + assert_eq!(h.state().keytips.prefix(), Some("PTH"), "PTH must stay armed waiting for more input"); +} + +#[test] +fn keytip_pthb_() { + let mut h = harness(blank()); + sequence(&mut h, "PTHB", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "pageLayout.theme"), "PTHB must journal pageLayout.theme"); +} + +#[test] +fn keytip_pvg_() { + let mut h = harness(blank()); + sequence(&mut h, "PVG", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "view.gridlines"), "PVG must journal view.gridlines"); +} + +#[test] +fn keytip_pvh_() { + let mut h = harness(blank()); + sequence(&mut h, "PVH", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "view.headings"), "PVH must journal view.headings"); +} + +#[test] +fn keytip_ma_() { + let mut h = harness(blank()); + sequence(&mut h, "MA", false); + assert_eq!(h.state().keytips.prefix(), Some("MA"), "MA must stay armed waiting for more input"); +} + +#[test] +fn keytip_maa_() { + let mut h = harness(blank()); + sequence(&mut h, "MAA", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "formulas.removeArrows"), "MAA must journal formulas.removeArrows"); +} + +#[test] +fn keytip_map_() { + let mut h = harness(blank()); + sequence(&mut h, "MAP", false); + assert!(h.state().dialog.is_none(), "MAP must not open a dialog"); + assert!(h.state().message.is_none(), "MAP must not open a message box"); + assert!(h.state().toast.is_none(), "MAP must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "MAP must not journal"); +} + +#[test] +fn keytip_mb_() { + let mut h = harness(blank()); + sequence(&mut h, "MB", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "formulas.calculateNow"), "MB must journal formulas.calculateNow"); +} + +#[test] +fn keytip_mc_() { + let mut h = harness(blank()); + sequence(&mut h, "MC", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "formulas.createFromSelection"), "MC must journal formulas.createFromSelection"); +} + +#[test] +fn keytip_md_() { + let mut h = harness(blank()); + sequence(&mut h, "MD", false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "MD must run formulas.traceDependents cleanly"); +} + +#[test] +fn keytip_me_() { + let mut h = harness(blank()); + sequence(&mut h, "ME", false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "ME must route to its control without an error box"); + assert_eq!(h.state().keytips.prefix(), None); +} + +#[test] +fn keytip_mf_() { + let mut h = harness(blank()); + sequence(&mut h, "MF", false); + assert!(h.state().dialog.is_some(), "MF must open the insertFunction dialog"); +} + +#[test] +fn keytip_mg_() { + let mut h = harness(blank()); + sequence(&mut h, "MG", false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "MG must route to its control without an error box"); + assert_eq!(h.state().keytips.prefix(), None); +} + +#[test] +fn keytip_mh_() { + let mut h = harness(blank()); + sequence(&mut h, "MH", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "formulas.showFormulas"), "MH must journal formulas.showFormulas"); +} + +#[test] +fn keytip_mi_() { + let mut h = harness(blank()); + sequence(&mut h, "MI", false); + assert_eq!(h.state().keytips.prefix(), Some("MI"), "MI must stay armed waiting for more input"); +} + +#[test] +fn keytip_mi1_() { + let mut h = harness(blank()); + sequence(&mut h, "MI1", false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "MI1 must route to its control without an error box"); + assert_eq!(h.state().keytips.prefix(), None); +} + +#[test] +fn keytip_mj_() { + let mut h = harness(blank()); + sequence(&mut h, "MJ", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "formulas.calculateSheet"), "MJ must journal formulas.calculateSheet"); +} + +#[test] +fn keytip_mk_() { + let mut h = harness(blank()); + sequence(&mut h, "MK", false); + assert_eq!(h.state().keytips.prefix(), Some("MK"), "MK must stay armed waiting for more input"); +} + +#[test] +fn keytip_mkk_() { + let mut h = harness(blank()); + sequence(&mut h, "MKK", false); + assert!(h.state().dialog.is_some(), "MKK must open the errorChecking dialog"); +} + +#[test] +fn keytip_ml_() { + let mut h = harness(blank()); + sequence(&mut h, "ML", false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "ML must route to its control without an error box"); + assert_eq!(h.state().keytips.prefix(), None); +} + +#[test] +fn keytip_mm_() { + let mut h = harness(blank()); + sequence(&mut h, "MM", false); + assert!(h.state().dialog.is_some(), "MM must open the defineName dialog"); +} + +#[test] +fn keytip_mn_() { + let mut h = harness(blank()); + sequence(&mut h, "MN", false); + assert!(h.state().dialog.is_some(), "MN must open the nameManager dialog"); +} + +#[test] +fn keytip_mo_() { + let mut h = harness(blank()); + sequence(&mut h, "MO", false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "MO must route to its control without an error box"); + assert_eq!(h.state().keytips.prefix(), None); +} + +#[test] +fn keytip_mp_() { + let mut h = harness(blank()); + sequence(&mut h, "MP", false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "MP must run formulas.tracePrecedents cleanly"); +} + +#[test] +fn keytip_mq_() { + let mut h = harness(blank()); + sequence(&mut h, "MQ", false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "MQ must route to its control without an error box"); + assert_eq!(h.state().keytips.prefix(), None); +} + +#[test] +fn keytip_ms_() { + let mut h = harness(blank()); + sequence(&mut h, "MS", false); + assert_eq!(h.state().keytips.prefix(), Some("MS"), "MS must stay armed waiting for more input"); +} + +#[test] +fn keytip_msp_() { + let mut h = harness(blank()); + sequence(&mut h, "MSP", false); + assert!(h.state().dialog.is_none(), "MSP must not open a dialog"); + assert!(h.state().message.is_none(), "MSP must not open a message box"); + assert!(h.state().toast.is_none(), "MSP must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "MSP must not journal"); +} + +#[test] +fn keytip_mt_() { + let mut h = harness(blank()); + sequence(&mut h, "MT", false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "MT must route to its control without an error box"); + assert_eq!(h.state().keytips.prefix(), None); +} + +#[test] +fn keytip_mu_() { + let mut h = harness(blank()); + sequence(&mut h, "MU", false); + assert_eq!(h.state().keytips.prefix(), Some("MU"), "MU must stay armed waiting for more input"); +} + +#[test] +fn keytip_mua_() { + let mut h = harness(blank()); + sequence(&mut h, "MUA", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "formulas.autoSum"), "MUA must journal formulas.autoSum"); +} + +#[test] +fn keytip_muc_() { + let mut h = harness(blank()); + sequence(&mut h, "MUC", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "formulas.autoSum"), "MUC must journal formulas.autoSum"); +} + +#[test] +fn keytip_muf_() { + let mut h = harness(blank()); + sequence(&mut h, "MUF", false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "MUF must run formulas.moreFunctions cleanly"); +} + +#[test] +fn keytip_mui_() { + let mut h = harness(blank()); + sequence(&mut h, "MUI", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "formulas.autoSum"), "MUI must journal formulas.autoSum"); +} + +#[test] +fn keytip_mum_() { + let mut h = harness(blank()); + sequence(&mut h, "MUM", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "formulas.autoSum"), "MUM must journal formulas.autoSum"); +} + +#[test] +fn keytip_mus_() { + let mut h = harness(blank()); + sequence(&mut h, "MUS", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "formulas.autoSum"), "MUS must journal formulas.autoSum"); +} + +#[test] +fn keytip_mv_() { + let mut h = harness(blank()); + sequence(&mut h, "MV", false); + assert!(h.state().dialog.is_some(), "MV must open the evaluateFormula dialog"); +} + +#[test] +fn keytip_mw_() { + let mut h = harness(blank()); + sequence(&mut h, "MW", false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "MW must run formulas.watchWindow cleanly"); +} + +#[test] +fn keytip_mx_() { + let mut h = harness(blank()); + sequence(&mut h, "MX", false); + assert_eq!(h.state().keytips.prefix(), Some("MX"), "MX must stay armed waiting for more input"); +} + +#[test] +fn keytip_mxa_() { + let mut h = harness(blank()); + sequence(&mut h, "MXA", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "formulas.calculationOptions"), "MXA must journal formulas.calculationOptions"); +} + +#[test] +fn keytip_mxm_() { + let mut h = harness(blank()); + sequence(&mut h, "MXM", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "formulas.calculationOptions"), "MXM must journal formulas.calculationOptions"); +} + +#[test] +fn keytip_mxp_() { + let mut h = harness(blank()); + sequence(&mut h, "MXP", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "formulas.calculationOptions"), "MXP must journal formulas.calculationOptions"); +} + +#[test] +fn keytip_ab_() { + let mut h = harness(blank()); + sequence(&mut h, "AB", false); + assert!(h.state().dialog.is_some(), "AB must open the subtotal dialog"); +} + +#[test] +fn keytip_ac_() { + let mut h = harness(blank()); + sequence(&mut h, "AC", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.clearFilter"), "AC must journal data.clearFilter"); +} + +#[test] +fn keytip_ad_() { + let mut h = harness(blank()); + sequence(&mut h, "AD", false); + assert!(h.state().message.is_some() || h.state().toast.is_some(), "AD must invoke data.consolidate and surface its missing-params error"); +} + +#[test] +fn keytip_ae_() { + let mut h = harness(blank()); + sequence(&mut h, "AE", false); + assert!(h.state().dialog.is_some(), "AE must open the textToColumns dialog"); +} + +#[test] +fn keytip_af1_() { + let mut h = harness(blank()); + sequence(&mut h, "AF1", false); + assert_eq!(h.state().keytips.prefix(), Some("AF1"), "AF1 must stay armed waiting for more input"); +} + +#[test] +fn keytip_af1c_() { + let mut h = harness(blank()); + sequence(&mut h, "AF1C", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "insert.picture"), "AF1C must journal insert.picture"); +} + +#[test] +fn keytip_af1p_() { + let mut h = harness(blank()); + sequence(&mut h, "AF1P", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "insert.picture"), "AF1P must journal insert.picture"); +} + +#[test] +fn keytip_afa_() { + let mut h = harness(blank()); + sequence(&mut h, "AFA", false); + assert!(h.state().dialog.is_none(), "AFA must not open a dialog"); + assert!(h.state().message.is_none(), "AFA must not open a message box"); + assert!(h.state().toast.is_none(), "AFA must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "AFA must not journal"); +} + +#[test] +fn keytip_afc_() { + let mut h = harness(blank()); + sequence(&mut h, "AFC", false); + assert!(h.state().message.is_some() || h.state().toast.is_some(), "AFC must invoke data.forecastSheet and surface its missing-params error"); +} + +#[test] +fn keytip_afe_() { + let mut h = harness(blank()); + sequence(&mut h, "AFE", false); + assert!(h.state().dialog.is_none(), "AFE must not open a dialog"); + assert!(h.state().message.is_none(), "AFE must not open a message box"); + assert!(h.state().toast.is_none(), "AFE must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "AFE must not journal"); +} + +#[test] +fn keytip_aff_() { + let mut h = harness(blank()); + sequence(&mut h, "AFF", false); + assert!(h.state().message.is_some() || h.state().toast.is_some(), "AFF must invoke edit.flashFill and surface its missing-params error"); +} + +#[test] +fn keytip_afo_() { + let mut h = harness(blank()); + sequence(&mut h, "AFO", false); + assert!(h.state().dialog.is_none(), "AFO must not open a dialog"); + assert!(h.state().message.is_none(), "AFO must not open a message box"); + assert!(h.state().toast.is_none(), "AFO must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "AFO must not journal"); +} + +#[test] +fn keytip_aft_() { + let mut h = harness(blank()); + sequence(&mut h, "AFT", false); + assert!(h.state().dialog.is_none(), "AFT must not open a dialog"); + assert!(h.state().message.is_none(), "AFT must not open a message box"); + assert!(h.state().toast.is_none(), "AFT must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "AFT must not journal"); +} + +#[test] +fn keytip_afw_() { + let mut h = harness(blank()); + sequence(&mut h, "AFW", false); + assert!(h.state().dialog.is_none(), "AFW must not open a dialog"); + assert!(h.state().message.is_none(), "AFW must not open a message box"); + assert!(h.state().toast.is_none(), "AFW must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "AFW must not journal"); +} + +#[test] +fn keytip_ag_() { + let mut h = harness(blank()); + sequence(&mut h, "AG", false); + assert_eq!(h.state().keytips.prefix(), Some("AG"), "AG must stay armed waiting for more input"); +} + +#[test] +fn keytip_aga_() { + let mut h = harness(blank()); + sequence(&mut h, "AGA", false); + assert!(h.state().dialog.is_none(), "AGA must not open a dialog"); + assert!(h.state().message.is_none(), "AGA must not open a message box"); + assert!(h.state().toast.is_none(), "AGA must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "AGA must not journal"); +} + +#[test] +fn keytip_agg_() { + let mut h = harness(blank()); + sequence(&mut h, "AGG", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.group"), "AGG must journal data.group"); +} + +#[test] +fn keytip_ah_() { + let mut h = harness(blank()); + sequence(&mut h, "AH", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.hideDetail"), "AH must journal data.hideDetail"); +} + +#[test] +fn keytip_aj_() { + let mut h = harness(blank()); + sequence(&mut h, "AJ", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.showDetail"), "AJ must journal data.showDetail"); +} + +#[test] +fn keytip_ak_() { + let mut h = harness(blank()); + sequence(&mut h, "AK", false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "AK must run data.queriesConnections cleanly"); +} + +#[test] +fn keytip_am_() { + let mut h = harness(blank()); + sequence(&mut h, "AM", false); + assert!(h.state().dialog.is_some(), "AM must open the removeDuplicates dialog"); +} + +#[test] +fn keytip_ao_() { + let mut h = harness(blank()); + sequence(&mut h, "AO", false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "AO must run data.queriesConnections cleanly"); +} + +#[test] +fn keytip_ap1_() { + let mut h = harness(blank()); + sequence(&mut h, "AP1", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "file.properties"), "AP1 must journal file.properties"); +} + +#[test] +fn keytip_apn_() { + let mut h = harness(blank()); + sequence(&mut h, "APN", false); + assert_eq!(h.state().keytips.prefix(), Some("APN"), "APN must stay armed waiting for more input"); +} + +#[test] +fn keytip_apnd_() { + let mut h = harness(blank()); + sequence(&mut h, "APND", false); + assert!(h.state().dialog.is_none(), "APND must not open a dialog"); + assert!(h.state().message.is_none(), "APND must not open a message box"); + assert!(h.state().toast.is_none(), "APND must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "APND must not journal"); +} + +#[test] +fn keytip_apne_() { + let mut h = harness(blank()); + sequence(&mut h, "APNE", false); + assert!(h.state().dialog.is_none(), "APNE must not open a dialog"); + assert!(h.state().message.is_none(), "APNE must not open a message box"); + assert!(h.state().toast.is_none(), "APNE must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "APNE must not journal"); +} + +#[test] +fn keytip_apng_() { + let mut h = harness(blank()); + sequence(&mut h, "APNG", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.getData"), "APNG must journal data.getData"); +} + +#[test] +fn keytip_apnh_() { + let mut h = harness(blank()); + sequence(&mut h, "APNH", false); + assert!(h.state().dialog.is_none(), "APNH must not open a dialog"); + assert!(h.state().message.is_none(), "APNH must not open a message box"); + assert!(h.state().toast.is_none(), "APNH must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "APNH must not journal"); +} + +#[test] +fn keytip_apnl_() { + let mut h = harness(blank()); + sequence(&mut h, "APNL", false); + assert!(h.state().dialog.is_none(), "APNL must not open a dialog"); + assert!(h.state().message.is_none(), "APNL must not open a message box"); + assert!(h.state().toast.is_none(), "APNL must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "APNL must not journal"); +} + +#[test] +fn keytip_apns_() { + let mut h = harness(blank()); + sequence(&mut h, "APNS", false); + assert!(h.state().dialog.is_none(), "APNS must not open a dialog"); + assert!(h.state().message.is_none(), "APNS must not open a message box"); + assert!(h.state().toast.is_none(), "APNS must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "APNS must not journal"); +} + +#[test] +fn keytip_apnt_() { + let mut h = harness(blank()); + sequence(&mut h, "APNT", false); + assert!(h.state().dialog.is_none(), "APNT must not open a dialog"); + assert!(h.state().message.is_none(), "APNT must not open a message box"); + assert!(h.state().toast.is_none(), "APNT must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "APNT must not journal"); +} + +#[test] +fn keytip_apr_() { + let mut h = harness(blank()); + sequence(&mut h, "APR", false); + assert!(h.state().dialog.is_none(), "APR must not open a dialog"); + assert!(h.state().message.is_none(), "APR must not open a message box"); + assert!(h.state().toast.is_none(), "APR must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "APR must not journal"); +} + +#[test] +fn keytip_aps_() { + let mut h = harness(blank()); + sequence(&mut h, "APS", false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "APS must run data.queriesConnections cleanly"); +} + +#[test] +fn keytip_apt_() { + let mut h = harness(blank()); + sequence(&mut h, "APT", false); + assert!(h.state().dialog.is_none(), "APT must not open a dialog"); + assert!(h.state().message.is_none(), "APT must not open a message box"); + assert!(h.state().toast.is_none(), "APT must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "APT must not journal"); +} + +#[test] +fn keytip_aq_() { + let mut h = harness(blank()); + sequence(&mut h, "AQ", false); + assert!(h.state().message.is_some() || h.state().toast.is_some(), "AQ must invoke data.advancedFilter and surface its missing-params error"); +} + +#[test] +fn keytip_ar_() { + let mut h = harness(blank()); + sequence(&mut h, "AR", false); + assert_eq!(h.state().keytips.prefix(), Some("AR"), "AR must stay armed waiting for more input"); +} + +#[test] +fn keytip_ara_() { + let mut h = harness(blank()); + sequence(&mut h, "ARA", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.refreshAll"), "ARA must journal data.refreshAll"); +} + +#[test] +fn keytip_arc_() { + let mut h = harness(blank()); + sequence(&mut h, "ARC", false); + assert!(h.state().dialog.is_none(), "ARC must not open a dialog"); + assert!(h.state().message.is_none(), "ARC must not open a message box"); + assert!(h.state().toast.is_none(), "ARC must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "ARC must not journal"); +} + +#[test] +fn keytip_aro_() { + let mut h = harness(blank()); + sequence(&mut h, "ARO", false); + assert!(h.state().dialog.is_none(), "ARO must not open a dialog"); + assert!(h.state().message.is_none(), "ARO must not open a message box"); + assert!(h.state().toast.is_none(), "ARO must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "ARO must not journal"); +} + +#[test] +fn keytip_arr_() { + let mut h = harness(blank()); + sequence(&mut h, "ARR", false); + assert!(h.state().message.is_some() || h.state().toast.is_some(), "ARR must invoke pivot.refresh and surface its missing-params error"); +} + +#[test] +fn keytip_ars_() { + let mut h = harness(blank()); + sequence(&mut h, "ARS", false); + assert!(h.state().dialog.is_none(), "ARS must not open a dialog"); + assert!(h.state().message.is_none(), "ARS must not open a message box"); + assert!(h.state().toast.is_none(), "ARS must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "ARS must not journal"); +} + +#[test] +fn keytip_asa_() { + let mut h = harness(blank()); + sequence(&mut h, "ASA", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.sortAscending"), "ASA must journal data.sortAscending"); +} + +#[test] +fn keytip_asd_() { + let mut h = harness(blank()); + sequence(&mut h, "ASD", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.sortDescending"), "ASD must journal data.sortDescending"); +} + +#[test] +fn keytip_ass_() { + let mut h = harness(blank()); + sequence(&mut h, "ASS", false); + assert!(h.state().dialog.is_some(), "ASS must open the sort dialog"); +} + +#[test] +fn keytip_at_() { + let mut h = harness(blank()); + sequence(&mut h, "AT", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.filter"), "AT must journal data.filter"); +} + +#[test] +fn keytip_au_() { + let mut h = harness(blank()); + sequence(&mut h, "AU", false); + assert_eq!(h.state().keytips.prefix(), Some("AU"), "AU must stay armed waiting for more input"); +} + +#[test] +fn keytip_auc_() { + let mut h = harness(blank()); + sequence(&mut h, "AUC", false); + assert!(h.state().dialog.is_none(), "AUC must not open a dialog"); + assert!(h.state().message.is_none(), "AUC must not open a message box"); + assert!(h.state().toast.is_none(), "AUC must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "AUC must not journal"); +} + +#[test] +fn keytip_auu_() { + let mut h = harness(blank()); + sequence(&mut h, "AUU", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.ungroup"), "AUU must journal data.ungroup"); +} + +#[test] +fn keytip_av_() { + let mut h = harness(blank()); + sequence(&mut h, "AV", false); + assert_eq!(h.state().keytips.prefix(), Some("AV"), "AV must stay armed waiting for more input"); +} + +#[test] +fn keytip_avi_() { + let mut h = harness(blank()); + sequence(&mut h, "AVI", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.circleInvalid"), "AVI must journal data.circleInvalid"); +} + +#[test] +fn keytip_avr_() { + let mut h = harness(blank()); + sequence(&mut h, "AVR", false); + assert!(h.state().dialog.is_none(), "AVR must not open a dialog"); + assert!(h.state().message.is_none(), "AVR must not open a message box"); + assert!(h.state().toast.is_none(), "AVR must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "AVR must not journal"); +} + +#[test] +fn keytip_avv_() { + let mut h = harness(blank()); + sequence(&mut h, "AVV", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.validation"), "AVV must journal data.validation"); +} + +#[test] +fn keytip_aw_() { + let mut h = harness(blank()); + sequence(&mut h, "AW", false); + assert_eq!(h.state().keytips.prefix(), Some("AW"), "AW must stay armed waiting for more input"); +} + +#[test] +fn keytip_awg_() { + let mut h = harness(blank()); + sequence(&mut h, "AWG", false); + assert!(h.state().dialog.is_some(), "AWG must open the goalSeek dialog"); +} + +#[test] +fn keytip_aws_() { + let mut h = harness(blank()); + sequence(&mut h, "AWS", false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "AWS must run data.scenarioManager cleanly"); +} + +#[test] +fn keytip_awt_() { + let mut h = harness(blank()); + sequence(&mut h, "AWT", false); + assert!(h.state().message.is_some() || h.state().toast.is_some(), "AWT must invoke data.dataTable and surface its missing-params error"); +} + +#[test] +fn keytip_ax_() { + let mut h = harness(blank()); + sequence(&mut h, "AX", false); + assert!(h.state().dialog.is_none(), "AX must not open a dialog"); + assert!(h.state().message.is_none(), "AX must not open a message box"); + assert!(h.state().toast.is_none(), "AX must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "AX must not journal"); +} + +#[test] +fn keytip_ay_() { + let mut h = harness(blank()); + sequence(&mut h, "AY", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.reapply"), "AY must journal data.reapply"); +} + +#[test] +fn keytip_ra1_() { + let mut h = harness(blank()); + sequence(&mut h, "RA1", false); + assert!(h.state().dialog.is_some(), "RA1 must open the accessibility dialog"); +} + +#[test] +fn keytip_ra2_() { + let mut h = harness(blank()); + sequence(&mut h, "RA2", false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "RA2 must run review.showComments cleanly"); +} + +#[test] +fn keytip_rc_() { + let mut h = harness(blank()); + sequence(&mut h, "RC", false); + assert!(h.state().dialog.is_some(), "RC must open the comment dialog"); +} + +#[test] +fn keytip_rd_() { + let mut h = harness(blank()); + sequence(&mut h, "RD", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "review.deleteComment"), "RD must journal review.deleteComment"); +} + +#[test] +fn keytip_rg_() { + let mut h = harness(blank()); + sequence(&mut h, "RG", false); + assert!(h.state().dialog.is_none(), "RG must not open a dialog"); + assert!(h.state().message.is_none(), "RG must not open a message box"); + assert!(h.state().toast.is_none(), "RG must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "RG must not journal"); +} + +#[test] +fn keytip_rl_() { + let mut h = harness(blank()); + sequence(&mut h, "RL", false); + assert!(h.state().dialog.is_none(), "RL must not open a dialog"); + assert!(h.state().message.is_none(), "RL must not open a message box"); + assert!(h.state().toast.is_none(), "RL must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "RL must not journal"); +} + +#[test] +fn keytip_rn_() { + let mut h = harness(blank()); + sequence(&mut h, "RN", false); + assert!(h.state().message.is_some() || h.state().toast.is_some(), "RN must invoke review.nextComment and surface its missing-params error"); +} + +#[test] +fn keytip_ro_() { + let mut h = harness(blank()); + sequence(&mut h, "RO", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "review.protectWorkbook"), "RO must journal review.protectWorkbook"); +} + +#[test] +fn keytip_rps_() { + let mut h = harness(blank()); + sequence(&mut h, "RPS", false); + assert!(h.state().dialog.is_some(), "RPS must open the protectSheet dialog"); +} + +#[test] +fn keytip_rpw_() { + let mut h = harness(blank()); + sequence(&mut h, "RPW", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "review.protectWorkbook"), "RPW must journal review.protectWorkbook"); +} + +#[test] +fn keytip_rrs_() { + let mut h = harness(blank()); + sequence(&mut h, "RRS", false); + assert!(h.state().dialog.is_none(), "RRS must not open a dialog"); + assert!(h.state().message.is_none(), "RRS must not open a message box"); + assert!(h.state().toast.is_none(), "RRS must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "RRS must not journal"); +} + +#[test] +fn keytip_rs_() { + let mut h = harness(blank()); + sequence(&mut h, "RS", false); + assert!(h.state().dialog.is_some(), "RS must open the spelling dialog"); +} + +#[test] +fn keytip_rtn_() { + let mut h = harness(blank()); + sequence(&mut h, "RTN", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "review.newNote"), "RTN must journal review.newNote"); +} + +#[test] +fn keytip_rv_() { + let mut h = harness(blank()); + sequence(&mut h, "RV", false); + assert!(h.state().message.is_some() || h.state().toast.is_some(), "RV must invoke review.previousComment and surface its missing-params error"); +} + +#[test] +fn keytip_wa_() { + let mut h = harness(blank()); + sequence(&mut h, "WA", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "view.arrangeAll"), "WA must journal view.arrangeAll"); +} + +#[test] +fn keytip_wc_() { + let mut h = harness(blank()); + sequence(&mut h, "WC", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "view.customViews"), "WC must journal view.customViews"); +} + +#[test] +fn keytip_wf_() { + let mut h = harness(blank()); + sequence(&mut h, "WF", false); + assert_eq!(h.state().keytips.prefix(), Some("WF"), "WF must stay armed waiting for more input"); +} + +#[test] +fn keytip_wfc_() { + let mut h = harness(blank()); + sequence(&mut h, "WFC", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "view.freezeFirstColumn"), "WFC must journal view.freezeFirstColumn"); +} + +#[test] +fn keytip_wff_() { + let mut h = harness(blank()); + sequence(&mut h, "WFF", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "view.freezePanes"), "WFF must journal view.freezePanes"); +} + +#[test] +fn keytip_wfr_() { + let mut h = harness(blank()); + sequence(&mut h, "WFR", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "view.freezeTopRow"), "WFR must journal view.freezeTopRow"); +} + +#[test] +fn keytip_wg_() { + let mut h = harness(blank()); + sequence(&mut h, "WG", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "view.zoomToSelection"), "WG must journal view.zoomToSelection"); +} + +#[test] +fn keytip_wh_() { + let mut h = harness(blank()); + sequence(&mut h, "WH", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "view.hideWindow"), "WH must journal view.hideWindow"); +} + +#[test] +fn keytip_wi_() { + let mut h = harness(blank()); + sequence(&mut h, "WI", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "view.pageBreakPreview"), "WI must journal view.pageBreakPreview"); +} + +#[test] +fn keytip_wj_() { + let mut h = harness(blank()); + sequence(&mut h, "WJ", false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "WJ must route to its control without an error box"); + assert_eq!(h.state().keytips.prefix(), None); +} + +#[test] +fn keytip_wl_() { + let mut h = harness(blank()); + sequence(&mut h, "WL", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "view.normal"), "WL must journal view.normal"); +} + +#[test] +fn keytip_wm_() { + let mut h = harness(blank()); + sequence(&mut h, "WM", false); + assert_eq!(h.state().keytips.prefix(), Some("WM"), "WM must stay armed waiting for more input"); +} + +#[test] +fn keytip_wmr_() { + let mut h = harness(blank()); + sequence(&mut h, "WMR", false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "WMR must run view.macros cleanly"); +} + +#[test] +fn keytip_wmv_() { + let mut h = harness(blank()); + sequence(&mut h, "WMV", false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "WMV must run view.macros cleanly"); +} + +#[test] +fn keytip_wn_() { + let mut h = harness(blank()); + sequence(&mut h, "WN", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "view.newWindow"), "WN must journal view.newWindow"); +} + +#[test] +fn keytip_wp_() { + let mut h = harness(blank()); + sequence(&mut h, "WP", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "view.pageLayout"), "WP must journal view.pageLayout"); +} + +#[test] +fn keytip_wq_() { + let mut h = harness(blank()); + sequence(&mut h, "WQ", false); + assert!(h.state().dialog.is_some(), "WQ must open the zoom dialog"); +} + +#[test] +fn keytip_wr_() { + let mut h = harness(blank()); + sequence(&mut h, "WR", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "view.ruler"), "WR must journal view.ruler"); +} + +#[test] +fn keytip_ws_() { + let mut h = harness(blank()); + sequence(&mut h, "WS", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "view.split"), "WS must journal view.split"); +} + +#[test] +fn keytip_wt_() { + let mut h = harness(blank()); + sequence(&mut h, "WT", false); + assert!(h.state().dialog.is_none(), "WT must not open a dialog"); + assert!(h.state().message.is_none(), "WT must not open a message box"); + assert!(h.state().toast.is_none(), "WT must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "WT must not journal"); +} + +#[test] +fn keytip_wu_() { + let mut h = harness(blank()); + sequence(&mut h, "WU", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "view.unhideWindow"), "WU must journal view.unhideWindow"); +} + +#[test] +fn keytip_wvc_() { + let mut h = harness(blank()); + sequence(&mut h, "WVC", false); + assert!(h.state().dialog.is_none(), "WVC must not open a dialog"); + assert!(h.state().message.is_none(), "WVC must not open a message box"); + assert!(h.state().toast.is_none(), "WVC must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "WVC must not journal"); +} + +#[test] +fn keytip_wvf_() { + let mut h = harness(blank()); + sequence(&mut h, "WVF", false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "WVF must route to its control without an error box"); + assert_eq!(h.state().keytips.prefix(), None); +} + +#[test] +fn keytip_wvh_() { + let mut h = harness(blank()); + sequence(&mut h, "WVH", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "view.headings"), "WVH must journal view.headings"); +} + +#[test] +fn keytip_wvn_() { + let mut h = harness(blank()); + sequence(&mut h, "WVN", false); + assert!(h.state().dialog.is_none(), "WVN must not open a dialog"); + assert!(h.state().message.is_none(), "WVN must not open a message box"); + assert!(h.state().toast.is_none(), "WVN must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "WVN must not journal"); +} + +#[test] +fn keytip_wvr_() { + let mut h = harness(blank()); + sequence(&mut h, "WVR", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "view.ruler"), "WVR must journal view.ruler"); +} + +#[test] +fn keytip_ww_() { + let mut h = harness(blank()); + sequence(&mut h, "WW", false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "WW must run view.switchWindows cleanly"); +} + +#[test] +fn keytip_jam_() { + let mut h = harness(blank()); + sequence(&mut h, "JAM", false); + assert!(h.state().message.is_some() || h.state().toast.is_some(), "JAM must invoke chart.formatSelection and surface its missing-params error"); +} + +#[test] +fn keytip_jca_() { + let mut h = harness(blank()); + sequence(&mut h, "JCA", false); + assert!(h.state().message.is_some() || h.state().toast.is_some(), "JCA must invoke chart.addElement and surface its missing-params error"); +} + +#[test] +fn keytip_jcc_() { + let mut h = harness(blank()); + sequence(&mut h, "JCC", false); + assert!(h.state().message.is_some() || h.state().toast.is_some(), "JCC must invoke chart.changeType and surface its missing-params error"); +} + +#[test] +fn keytip_jts_() { + let mut h = harness(blank()); + sequence(&mut h, "JTS", false); + assert!(h.state().message.is_some() || h.state().toast.is_some(), "JTS must invoke table.style and surface its missing-params error"); +} + +#[test] +fn keytip_jtv_() { + let mut h = harness(blank()); + sequence(&mut h, "JTV", false); + assert!(h.state().message.is_some() || h.state().toast.is_some(), "JTV must invoke table.summarizePivot and surface its missing-params error"); +} + +#[test] +fn keytip_fa_() { + let mut h = harness(blank()); + sequence(&mut h, "FA", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "file.saveAs"), "FA must journal file.saveAs"); +} + +#[test] +fn keytip_fc_() { + let mut h = harness(blank()); + sequence(&mut h, "FC", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "file.close"), "FC must journal file.close"); +} + +#[test] +fn keytip_fea_() { + let mut h = harness(blank()); + sequence(&mut h, "FEA", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "file.exportPdf"), "FEA must journal file.exportPdf"); +} + +#[test] +fn keytip_fec_() { + let mut h = harness(blank()); + sequence(&mut h, "FEC", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "file.saveAs"), "FEC must journal file.saveAs"); +} + +#[test] +fn keytip_fi_() { + let mut h = harness(blank()); + sequence(&mut h, "FI", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "file.properties"), "FI must journal file.properties"); +} + +#[test] +fn keytip_fn_() { + let mut h = harness(blank()); + sequence(&mut h, "FN", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "file.new"), "FN must journal file.new"); +} + +#[test] +fn keytip_fo_() { + let mut h = harness(blank()); + sequence(&mut h, "FO", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "file.open"), "FO must journal file.open"); +} + +#[test] +fn keytip_fp_() { + let mut h = harness(blank()); + sequence(&mut h, "FP", false); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "file.print"), "FP must journal file.print"); +} + +#[test] +fn keytip_q_() { + let mut h = harness(blank()); + sequence(&mut h, "Q", false); + assert!(h.state().dialog.is_none(), "Q must not open a dialog"); + assert!(h.state().message.is_none(), "Q must not open a message box"); + assert!(h.state().toast.is_none(), "Q must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "Q must not journal"); +} + +#[test] +fn generated_test_count_matches_the_table() { + const EXPECTED: usize = 364; + assert_eq!(gridcraft_ui_egui::keytips::keytip_table::SEQUENCES.len(), EXPECTED); +} + +#[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 +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index fd9c74d..10abcbc 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -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 | diff --git a/xtask/src/keytips.rs b/xtask/src/keytips.rs new file mode 100644 index 0000000..3684722 --- /dev/null +++ b/xtask/src/keytips.rs @@ -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 { + 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; + +#[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, 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 = 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 = entries + .iter() + .filter(|e| e.kind == "command" && !e.pending) + .map(|e| e.target.clone()) + .collect::>() + .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 = 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) { + 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 +} +"#; diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 603801f..14e9af3 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -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 {