From eba3957a210682bddafbab5041f82051f4fd4879 Mon Sep 17 00:00:00 2001 From: James Date: Sun, 11 Oct 2026 19:30:21 +0300 Subject: [PATCH] Keytips: multi-letter units and implicit wait levels (Draw = "JI") Excel shows some access keys as one multi-letter unit (Draw's "JI") that fires only once complete. The matcher now follows the trie shape: - A pressed letter that matches a complete sequence fires immediately. - If it only starts deeper sequences, the matcher waits for more input (implicit group level, no table entry required). - A tab with deeper sequences stays as an active level so group badges show; a terminal unit clears the prefix. - tab_sequence() returns multi-letter tab units as one badge. Draw: Alt+JI switches to the Draw tab; deeper Draw letters are left blank for future contributors (GridCraft's Draw differs from Excel's). --- crates/ui-egui/src/keytips.rs | 8 +++- crates/ui-egui/src/keytips/keytip_table.rs | 45 +++++++++++++++++----- crates/ui-egui/tests/keytips.rs | 35 ++++++++++++++++- 3 files changed, 76 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/keytips.rs b/crates/ui-egui/src/keytips.rs index d75a544fa..0d45980ef 100644 --- a/crates/ui-egui/src/keytips.rs +++ b/crates/ui-egui/src/keytips.rs @@ -98,11 +98,17 @@ impl KeyTips { let next = format!("{}{key_name}", self.prefix.as_deref().unwrap_or_default(), key_name = key.name()); match keytip_table::lookup(&next) { Some(keytip_table::Entry::Tab(tab)) => { - self.prefix = Some(next); + // 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)); } 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 index 950364720..df42f12d1 100644 --- a/crates/ui-egui/src/keytips/keytip_table.rs +++ b/crates/ui-egui/src/keytips/keytip_table.rs @@ -30,6 +30,9 @@ pub const SEQUENCES: &[(&str, Entry)] = &[ ("A", Entry::Tab("Data")), ("R", Entry::Tab("Review")), ("W", Entry::Tab("View")), + // Draw is a two-letter unit in Excel: the badge shows "JI" as one keytip, and the + // matcher waits after "J" (implicit level — no table entry needed). + ("JI", Entry::Tab("Draw")), ("E", Entry::Tab("Home")), ("O", Entry::Tab("Home")), // Home tab groups. @@ -59,14 +62,21 @@ pub fn has_deeper(prefix: &str) -> bool { SEQUENCES.iter().any(|(seq, _)| seq.len() > prefix.len() && seq.starts_with(prefix)) } -/// The one-letter sequence that switches to `tab_name`, if that tab has one. +/// 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 && seq.len() == 1 => Some(*seq), + 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) => *id == target, @@ -96,9 +106,9 @@ pub fn group_extension(prefix: &str, targets: &[&str]) -> Vec<&'static str> { continue; } // The intermediate one key past the prefix is the group badge, if it is a - // group-level (Continue) sequence rather than the finish itself. + // group-level sequence (explicit or implicit) rather than the finish itself. let candidate = &seq[..prefix.len() + 1]; - if matches!(lookup(candidate), Some(Entry::Continue)) && !out.contains(&candidate) { + if is_group_level(candidate) && !out.contains(&candidate) { out.push(candidate); } } @@ -114,8 +124,8 @@ pub fn item_letter(prefix: &str, target: &str) -> Option<&'static str> { /// 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 lookup(prefix).is_none() { - return Vec::new(); + 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() } @@ -126,11 +136,18 @@ mod tests { #[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")] - { + 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("Draw"), None); assert_eq!(tab_sequence("Automate"), None); } @@ -141,6 +158,16 @@ mod tests { 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"]); diff --git a/crates/ui-egui/tests/keytips.rs b/crates/ui-egui/tests/keytips.rs index 02a02b1af..c684ddbe3 100644 --- a/crates/ui-egui/tests/keytips.rs +++ b/crates/ui-egui/tests/keytips.rs @@ -302,12 +302,43 @@ fn tab_letters_switch_to_their_ribbon_tabs() { key(&mut h, Key::F10); // arm letter(&mut h, k, false); assert_eq!(h.state().ui.ribbon_tab, tab, "letter {k:?}"); + assert_eq!(h.state().keytips.prefix(), None, "terminal tab units finish cleanly"); 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); } } +#[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());