diff --git a/crates/ui-egui/src/keytips.rs b/crates/ui-egui/src/keytips.rs index 0d45980..85bb214 100644 --- a/crates/ui-egui/src/keytips.rs +++ b/crates/ui-egui/src/keytips.rs @@ -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)] diff --git a/crates/ui-egui/src/keytips/keytip_table.rs b/crates/ui-egui/src/keytips/keytip_table.rs index df42f12..638b6dd 100644 --- a/crates/ui-egui/src/keytips/keytip_table.rs +++ b/crates/ui-egui/src/keytips/keytip_table.rs @@ -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 { 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, } diff --git a/crates/ui-egui/src/keytips/keytip_table/generated.rs b/crates/ui-egui/src/keytips/keytip_table/generated.rs new file mode 100644 index 0000000..446cbb2 --- /dev/null +++ b/crates/ui-egui/src/keytips/keytip_table/generated.rs @@ -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"))), +]; diff --git a/crates/ui-egui/src/keytips/keytips.json b/crates/ui-egui/src/keytips/keytips.json new file mode 100644 index 0000000..13489f1 --- /dev/null +++ b/crates/ui-egui/src/keytips/keytips.json @@ -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" } + ] +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9c240e3..90287b7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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(_) => {} } } } diff --git a/crates/ui-egui/tests/keytips.rs b/crates/ui-egui/tests/keytips.rs index c684ddb..7ac66f7 100644 --- a/crates/ui-egui/tests/keytips.rs +++ b/crates/ui-egui/tests/keytips.rs @@ -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()); diff --git a/xtask/src/keytips.rs b/xtask/src/keytips.rs new file mode 100644 index 0000000..286a043 --- /dev/null +++ b/xtask/src/keytips.rs @@ -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 { + 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) +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 603801f..14e9af3 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -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 {