mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-01 22:56:54 -06:00
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:
@@ -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 => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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!({})),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user