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 23:36:02 +03:00
parent eba3957a21
commit e90ebbbbe2
8 changed files with 217 additions and 40 deletions
+111
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)
}
+5
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 {