Keytips: table-driven matcher and badge painting (issues #33, #49)

Move the hardcoded keytip sequence list into keytip_table.rs as the single
source of truth for both the matcher and the ribbon badge painter.

- Generalize Action::Home into Action::Tab(&'static str); the legacy
  Edit/Format prefix special-case in lib.rs is absorbed into the table.
- Badge helpers: tab_sequence (tabs), group_extension (menu anchors),
  item_letter (menu items), control_badges (command controls).
- Add tab letters for Insert (N), Page Layout (P), Formulas (M), Data (A),
  Review (R), View (W); Draw/Automate stay unbadged until contextual
  letters land.
- Tests: 5 table unit tests + tab_letters_switch_to_their_ribbon_tabs
  integration test. All 7 xtask ci gates green.
This commit is contained in:
James
2026-10-11 23:36:02 +03:00
parent a5137872c1
commit b415d2e835
5 changed files with 226 additions and 45 deletions
+13 -14
View File
@@ -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 => {}
}
}
}
+165
View File
@@ -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<Entry> {
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:<name>`).
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");
}
}
+2 -9
View File
@@ -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!({})),
+33 -22
View File
@@ -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);
+13
View File
@@ -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());