diff --git a/crates/ui-egui/src/keytips.rs b/crates/ui-egui/src/keytips.rs index a5bf155bb..d75a544fa 100644 --- a/crates/ui-egui/src/keytips.rs +++ b/crates/ui-egui/src/keytips.rs @@ -1,10 +1,14 @@ //! 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), } @@ -92,21 +96,16 @@ impl KeyTips { } } 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" => { + match keytip_table::lookup(&next) { + Some(keytip_table::Entry::Tab(tab)) => { self.prefix = Some(next); - actions.push(Action::Home); + 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), + // 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 000000000..950364720 --- /dev/null +++ b/crates/ui-egui/src/keytips/keytip_table.rs @@ -0,0 +1,165 @@ +//! The keytip sequence table: every Excel-style Alt access sequence and what it does. +//! +//! This is the single source of truth for sequential access keys. Both the matcher in +//! [`super::KeyTips::process`] and the badge painter in `ribbon.rs` read from here, so a +//! sequence is added in exactly one place. +//! +//! Regenerated (T5) from `plan/keytips-map-results.json` — the Excel-sequence collection and +//! catalog join. Do not hand-edit entries without updating that source data. + +use super::Action; + +/// 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), +} + +/// All known sequences, in no particular order (lookup is linear; sizes are small). +pub const SEQUENCES: &[(&str, Entry)] = &[ + // Ribbon tabs. Legacy Edit/Format paths (E/O/OC) lead to the same Home menus. + ("H", Entry::Tab("Home")), + ("N", Entry::Tab("Insert")), + ("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")), + ("O", Entry::Tab("Home")), + // Home tab groups. + ("HA", Entry::Continue), + ("HV", Entry::Continue), + ("HO", Entry::Continue), + ("OC", Entry::Continue), + // Home > Alignment. + ("HAR", Entry::Finish(Action::Command("home.alignRight"))), + ("HAL", Entry::Finish(Action::Command("home.alignLeft"))), + ("HAC", Entry::Finish(Action::Command("home.alignCenter"))), + // Paste Special — ribbon and legacy routes. + ("HVS", Entry::Finish(Action::Dialog("pasteSpecial"))), + ("ES", Entry::Finish(Action::Dialog("pasteSpecial"))), + // AutoFit column width — ribbon and legacy routes. + ("HOI", Entry::Finish(Action::Command("home.autofitColumnWidth"))), + ("OCA", Entry::Finish(Action::Command("home.autofitColumnWidth"))), +]; + +/// 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 one-letter sequence that switches to `tab_name`, if that tab has one. +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), + _ => None, + }) +} + +fn matches_target(action: &Action, target: &str) -> bool { + match action { + Action::Command(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 (Continue) sequence rather than the finish itself. + let candidate = &seq[..prefix.len() + 1]; + if matches!(lookup(candidate), Some(Entry::Continue)) && !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 lookup(prefix).is_none() { + return Vec::new(); + } + sequences_for(target).into_iter().filter(|seq| seq.len() > prefix.len() && seq.starts_with(prefix)).collect() +} + +#[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")] + { + assert_eq!(tab_sequence(tab), Some(letter), "tab {tab}"); + } + assert_eq!(tab_sequence("Draw"), None); + 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 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("E", "dialog:pasteSpecial"), Some("ES")); + 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"); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9ef0d50b9..9c240e3c9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1007,17 +1007,10 @@ 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!({})), diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index c2728a37f..dff93f0a5 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}; @@ -203,8 +204,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)); } } }); @@ -328,14 +331,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 +349,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() { @@ -576,12 +574,7 @@ 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() { @@ -700,10 +693,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); diff --git a/crates/ui-egui/tests/keytips.rs b/crates/ui-egui/tests/keytips.rs index 272f863d6..02a02b1af 100644 --- a/crates/ui-egui/tests/keytips.rs +++ b/crates/ui-egui/tests/keytips.rs @@ -295,6 +295,19 @@ 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); + } +} + #[test] fn ordinary_typing_editor_shortcuts_and_altgr_remain_text_input() { let mut h = harness(blank());