Keytips: keytips.json source + xtask generator (issues #33, #49)

The sequence table is no longer hand-maintained Rust: keytips.json is the
single human-edited source (one line per sequence, grouped by tab, pending
reserves sequences for future commands), and `cargo xtask keytips`
regenerates keytip_table/generated.rs deterministically. `cargo xtask ci`
now fails if the generated table is stale.

- Action::Pending: reserved sequences show their badge and invoke silently
  until the command exists (bind-all policy).
- Intermediate group levels are implicit: the matcher waits whenever a
  pressed letter starts a deeper sequence; only terminal entries are
  listed (two-letter units like Draw's "JI" work with no extra rows).
- First populated sequence beyond the legacy set: W,V,G toggles gridlines,
  proven end-to-end by view_gridlines_sequence_runs_end_to_end.
This commit is contained in:
James 2026-10-11 19:36:14 +03:00
parent eba3957a21
commit e90ebbbbe2
8 changed files with 217 additions and 40 deletions

View File

@ -11,6 +11,9 @@ pub enum Action {
Tab(&'static str),
Command(&'static str),
Dialog(&'static str),
/// The target command does not exist yet: the sequence is reserved, its badge shows, and
/// invoking it is a silent no-op until the command lands (see keytips.json `pending`).
Pending(&'static str),
}
#[derive(Default)]

View File

@ -1,13 +1,16 @@
//! 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.
//! The matcher in [`super::KeyTips::process`] and the badge painter in `ribbon.rs` both read
//! [`SEQUENCES`], so a sequence is added in exactly one place: `keytips.json`.
//!
//! 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.
//! `keytips.json` is the human-edited source of truth (grouped, commented, one line per
//! sequence; `pending` reserves sequences for commands that do not exist yet). `generated.rs`
//! is produced from it by `cargo xtask keytips` — edit the JSON, not the generated file.
use super::Action;
pub(crate) use super::Action;
mod generated;
pub use generated::SEQUENCES;
/// What a fully-matched sequence does.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@ -20,38 +23,6 @@ pub enum Entry {
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")),
// 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.
("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)
@ -79,7 +50,7 @@ pub fn is_group_level(seq: &str) -> bool {
fn matches_target(action: &Action, target: &str) -> bool {
match action {
Action::Command(id) => *id == target,
Action::Command(id) | Action::Pending(id) => *id == target,
Action::Dialog(name) => format!("dialog:{name}") == target,
Action::Tab(_) => false,
}

View File

@ -0,0 +1,27 @@
//! GENERATED from keytips.json by `cargo xtask keytips`. Edit the JSON, not this file.
use super::{Action, Entry};
/// All known keytip sequences. Intermediate levels are implicit: the matcher waits
/// whenever a pressed letter starts a deeper sequence, so only terminal entries are
/// listed here. See keytip_table.rs for the helpers that read this table.
pub const SEQUENCES: &[(&str, Entry)] = &[
("H", Entry::Tab("Home")),
("N", Entry::Tab("Insert")),
("JI", Entry::Tab("Draw")), // Two-letter unit. Deeper Draw letters differ between Excel and GridCraft; left blank for future contributors.
("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")), // Legacy Edit-menu path (Excel 2003); leads to the same Home menus.
("O", Entry::Tab("Home")), // Legacy Format-menu path (Excel 2003); leads to the same Home menus.
("HAR", Entry::Finish(Action::Command("home.alignRight"))),
("HAL", Entry::Finish(Action::Command("home.alignLeft"))),
("HAC", Entry::Finish(Action::Command("home.alignCenter"))),
("HVS", Entry::Finish(Action::Dialog("pasteSpecial"))),
("ES", Entry::Finish(Action::Dialog("pasteSpecial"))), // Legacy Edit menu.
("HOI", Entry::Finish(Action::Command("home.autofitColumnWidth"))),
("OCA", Entry::Finish(Action::Command("home.autofitColumnWidth"))), // Legacy Format menu.
("WVG", Entry::Finish(Action::Command("view.gridlines"))),
];

View File

@ -0,0 +1,35 @@
{
"doc": [
"Single source of truth for Excel-style sequential access keys (Alt+H+X+X style).",
"The generated keytip_table/generated.rs is produced from this file: `cargo xtask keytips`.",
"Adding or removing a shortcut is a one-line edit here. Rules:",
" - seq: the access-key sequence, letters and digits only (two-letter units like \"JI\" are one entry).",
" - kind: \"tab\" (switches a ribbon tab), \"command\" (runs a command id), \"dialog\" (opens a dialog by name),",
" \"continue\" (an explicit group level with no deeper sequences yet).",
" - Intermediate levels need no entries: the matcher waits automatically when deeper sequences exist.",
" - pending: true = the command does not exist yet; the sequence stays reserved and silent until it lands.",
" - Keep entries grouped by tab and sorted by sequence within a group; the FIRST entry for a tab is its canonical badge (legacy aliases come after it)."
],
"sequences": [
{ "seq": "H", "kind": "tab", "target": "Home" },
{ "seq": "N", "kind": "tab", "target": "Insert" },
{ "seq": "JI", "kind": "tab", "target": "Draw", "note": "Two-letter unit. Deeper Draw letters differ between Excel and GridCraft; left blank for future contributors." },
{ "seq": "P", "kind": "tab", "target": "Page Layout" },
{ "seq": "M", "kind": "tab", "target": "Formulas" },
{ "seq": "A", "kind": "tab", "target": "Data" },
{ "seq": "R", "kind": "tab", "target": "Review" },
{ "seq": "W", "kind": "tab", "target": "View" },
{ "seq": "E", "kind": "tab", "target": "Home", "note": "Legacy Edit-menu path (Excel 2003); leads to the same Home menus." },
{ "seq": "O", "kind": "tab", "target": "Home", "note": "Legacy Format-menu path (Excel 2003); leads to the same Home menus." },
{ "seq": "HAR", "kind": "command", "target": "home.alignRight" },
{ "seq": "HAL", "kind": "command", "target": "home.alignLeft" },
{ "seq": "HAC", "kind": "command", "target": "home.alignCenter" },
{ "seq": "HVS", "kind": "dialog", "target": "pasteSpecial" },
{ "seq": "ES", "kind": "dialog", "target": "pasteSpecial", "note": "Legacy Edit menu." },
{ "seq": "HOI", "kind": "command", "target": "home.autofitColumnWidth" },
{ "seq": "OCA", "kind": "command", "target": "home.autofitColumnWidth", "note": "Legacy Format menu." },
{ "seq": "WVG", "kind": "command", "target": "view.gridlines" }
]
}

View File

@ -1015,6 +1015,9 @@ impl SheetApp {
}
keytips::Action::Command(id) => self.run_or_alert(id, json!({})),
keytips::Action::Dialog(name) => self.open_dialog(name, json!({})),
// Reserved sequence: the command does not exist yet. Badge shows, invocation
// is silent (see keytips.json `pending`).
keytips::Action::Pending(_) => {}
}
}
}

View File

@ -298,7 +298,7 @@ fn held_keys_do_not_toggle_twice_or_leak_repeated_letters() {
#[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")] {
for (k, tab) in [(Key::N, "Insert"), (Key::P, "Page Layout"), (Key::M, "Formulas"), (Key::A, "Data"), (Key::R, "Review")] {
key(&mut h, Key::F10); // arm
letter(&mut h, k, false);
assert_eq!(h.state().ui.ribbon_tab, tab, "letter {k:?}");
@ -307,6 +307,28 @@ fn tab_letters_switch_to_their_ribbon_tabs() {
}
}
#[test]
fn view_gridlines_sequence_runs_end_to_end() {
let mut h = harness(blank());
let gridlines = |h: &Harness| h.state().session.active().unwrap().wb.active().unwrap().show_gridlines;
assert!(gridlines(&h), "gridlines start visible");
key(&mut h, Key::F10);
letter(&mut h, Key::W, false);
// W has deeper sequences: it stays as an active level so View badges show.
assert_eq!(h.state().ui.ribbon_tab, "View");
assert_eq!(h.state().keytips.prefix(), Some("W"));
letter(&mut h, Key::V, false);
assert_eq!(h.state().keytips.prefix(), Some("WV"), "implicit group level waits for the last key");
letter(&mut h, Key::G, false);
assert_eq!(h.state().keytips.prefix(), None, "the finished sequence clears the prefix");
assert!(!gridlines(&h), "WVG toggles gridlines off");
key(&mut h, Key::F10);
for k in [Key::W, Key::V, Key::G] {
letter(&mut h, k, false);
}
assert!(gridlines(&h), "WVG toggles gridlines back on");
}
#[test]
fn two_letter_unit_waits_after_first_letter_then_fires() {
let mut h = harness(blank());

111
xtask/src/keytips.rs Normal file
View File

@ -0,0 +1,111 @@
//! Regenerate the keytip sequence table from `keytips.json`.
//!
//! `cargo xtask keytips` regenerate crates/ui-egui/src/keytips/generated.rs
//! `cargo xtask keytips --check` fail if the generated file differs from the JSON source
use std::fs;
use std::path::Path;
const TABS: &[&str] = &["Home", "Insert", "Draw", "Page Layout", "Formulas", "Data", "Review", "View", "Automate"];
pub fn run(root: &Path, rest: &[&str]) -> Result<(), String> {
let json_path = root.join("crates/ui-egui/src/keytips/keytips.json");
let out_path = root.join("crates/ui-egui/src/keytips/keytip_table/generated.rs");
let generated = generate(&fs::read_to_string(&json_path).map_err(|e| format!("read {}: {e}", json_path.display()))?)?;
if rest.contains(&"--check") {
let on_disk = fs::read_to_string(&out_path).map_err(|e| format!("read {}: {e}", out_path.display()))?;
if on_disk != generated {
return Err(format!("{} is out of date — run `cargo xtask keytips` and commit the result", out_path.display()));
}
println!("keytips: {} up to date", out_path.display());
return Ok(());
}
fs::write(&out_path, &generated).map_err(|e| format!("write {}: {e}", out_path.display()))?;
println!("keytips: wrote {}", out_path.display());
Ok(())
}
pub fn generate(source: &str) -> Result<String, String> {
let doc: serde_json::Value = serde_json::from_str(source).map_err(|e| format!("invalid JSON: {e}"))?;
let seqs = doc.get("sequences").and_then(|v| v.as_array()).ok_or("missing `sequences` array")?;
let mut rows: Vec<(String, String, String)> = Vec::new();
let mut seen = std::collections::HashSet::new();
for (i, entry) in seqs.iter().enumerate() {
let at = format!("entry {}", i + 1);
let seq = entry.get("seq").and_then(|v| v.as_str()).ok_or_else(|| format!("{at}: missing `seq`"))?;
if seq.is_empty() || seq.len() > 4 || !seq.bytes().all(|b| b.is_ascii_alphanumeric()) {
return Err(format!("{at}: seq {seq:?} must be 1-4 ASCII letters/digits"));
}
if !seen.insert(seq.to_string()) {
return Err(format!("{at}: duplicate seq {seq:?}"));
}
let kind = entry.get("kind").and_then(|v| v.as_str()).ok_or_else(|| format!("{at}: missing `kind`"))?;
let target = entry.get("target").and_then(|v| v.as_str()).unwrap_or("");
let pending = entry.get("pending").and_then(|v| v.as_bool()).unwrap_or(false);
let note = entry.get("note").and_then(|v| v.as_str()).unwrap_or("");
let excel = entry.get("excel").and_then(|v| v.as_str()).unwrap_or("");
if pending && kind != "command" {
return Err(format!("{at}: `pending` is only valid with kind `command`"));
}
let value = match kind {
"tab" => {
if target.is_empty() || !TABS.contains(&target) {
return Err(format!("{at}: tab target {target:?} is not one of {TABS:?}"));
}
format!("Entry::Tab({target:?})")
}
"command" => {
if target.is_empty() {
return Err(format!("{at}: command needs a `target` command id"));
}
if pending { format!("Entry::Finish(Action::Pending({target:?}))") } else { format!("Entry::Finish(Action::Command({target:?}))") }
}
"dialog" => {
if target.is_empty() {
return Err(format!("{at}: dialog needs a `target` dialog name"));
}
format!("Entry::Finish(Action::Dialog({target:?}))")
}
"continue" => "Entry::Continue".to_string(),
other => return Err(format!("{at}: unknown kind {other:?}")),
};
let mut comment = String::new();
if pending {
comment.push_str(" pending");
}
if !excel.is_empty() {
if !comment.is_empty() {
comment.push(';');
}
comment.push_str(&format!(" Excel: {excel}"));
}
if !note.is_empty() {
if !comment.is_empty() {
comment.push(';');
}
comment.push_str(&format!(" {note}"));
}
rows.push((seq.to_string(), value, comment));
}
// JSON order is authoritative and curated: the first entry for a tab is its canonical
// badge (legacy aliases come later), and groups stay together. Do not sort.
let mut out = String::from(
"//! GENERATED from keytips.json by `cargo xtask keytips`. Edit the JSON, not this file.\n\n\
use super::{Action, Entry};\n\n\
/// All known keytip sequences. Intermediate levels are implicit: the matcher waits\n\
/// whenever a pressed letter starts a deeper sequence, so only terminal entries are\n\
/// listed here. See keytip_table.rs for the helpers that read this table.\n\
pub const SEQUENCES: &[(&str, Entry)] = &[\n",
);
for (seq, value, comment) in &rows {
out.push_str(&format!(" ({seq:?}, {value}),"));
if !comment.is_empty() {
out.push_str(&format!(" //{comment}"));
}
out.push('\n');
}
out.push_str("];\n");
Ok(out)
}

View File

@ -7,6 +7,7 @@
mod assets;
mod ico;
mod keytips;
mod layers;
#[cfg_attr(not(feature = "engine"), allow(dead_code))]
mod parity;
@ -27,6 +28,8 @@ commands:
wasm cargo check --target wasm32-unknown-unknown for the L0-L5 crates (+ gridcraft-web)
parity [--check] [--out PATH]
write docs/parity-checklist.md from gridcraft_engine::catalog (implemented vs missing ids)
keytips [--check]
regenerate crates/ui-egui/src/keytips/generated.rs from keytips.json (--check: fail if stale)
version [set X.Y.Z[-pre]]
print or set the workspace version ([workspace.package] in Cargo.toml)
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
@ -44,6 +47,7 @@ fn main() -> ExitCode {
Some("ci") => cmd_ci(),
Some("stats") => stats::run(&root(), rest.contains(&"--exact")),
Some("parity") => parity::run(&root(), &rest),
Some("keytips") => keytips::run(&root(), &rest),
Some("ico") => ico::run(&rest),
Some("version") => version::run(&root(), &rest),
Some("-h" | "--help" | "help") | None => {
@ -194,6 +198,7 @@ fn cmd_ci() -> Result<(), String> {
}
}),
),
("keytips", Box::new(|| keytips::run(&root(), &["--check"]))),
];
let mut done = Vec::new();
for (name, f) in &steps {