mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-30 21:56:53 -06:00
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:
@@ -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 => {}
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user