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).
This commit is contained in:
James
2026-10-11 23:36:02 +03:00
parent b415d2e835
commit eba3957a21
3 changed files with 76 additions and 12 deletions
+7 -1
View File
@@ -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 => {}
+36 -9
View File
@@ -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"]);
+33 -2
View File
@@ -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());