Fail CI when a key is bound but not in the gesture book, or listed but not bound

The gesture book is generated from GESTURE tags, so it could not describe a
gesture nobody tagged, but nothing made anyone tag one. The arrow keys, Enter,
P, X, U, Delete, F1 and F2 all worked in the grid with no line in the help
sheet, and a tag could name a key whose handler had gone.

Key handlers now compare one canonical string, Keys.chord(event) == "Ctrl+Z",
instead of reading event.text and the modifiers themselves. keys.slint folds
the key and its modifiers into that spelling, so the literal in the handler is
the whole binding and the checker reads exactly what the handler dispatches
on. Each handler carries a KEYMAP comment naming the gesture-book section its
keys belong to, and a tag's keys field names its keys between backticks.
gestures-check now fails when a handler binds a key no tag in that section
names, when a tag names a key no handler there binds, when any .slint file
other than keys.slint reads event.text, when a compared literal is not
canonical, and when keys.slint's named keys drift from the Rust list.

Spellings are normalised in one place, chord.rs: Ctrl+z, Control+Z and
LeftArrow all mean what the handler's "Ctrl+Z" and "Left" mean. Shift and Alt
count only for letters and named keys, because on the French layout every
digit needs shift and a 6 has to be a 6 however it was typed.

A Rust keymap that both dispatched and was read by the generator was the
alternative. It would have moved the handlers' decisions away from the Slint
state they depend on, and a window that forgot to install it would have had
no working keys at all.

The keys that were already bound and undocumented are now tagged.
This commit is contained in:
2026-09-24 23:42:25 -04:00
parent 150e53e878
commit 9d1e31ffbb
15 changed files with 1696 additions and 258 deletions
+515
View File
@@ -0,0 +1,515 @@
//! TRACES: FR-UI-5 | FR-DEV-16
//! The one place a key is spelled.
//!
//! A key reaches this repository in three spellings: the text Slint hands a
//! `key-pressed` handler (`"\u{F702}"` for the left arrow, `"Z"` for a shifted
//! z), the literal a handler compares against, and the words a `GESTURE:` tag
//! uses to tell a person which key to press. The checker in
//! [`crate::keymap`] only works if all three meet in one vocabulary, and this
//! is it.
//!
//! # The canonical form
//!
//! `Ctrl+Alt+Shift+Meta+Key`, modifiers in that order, each present only when
//! held. The key is one of:
//!
//! - a **named key** from [`NAMED`] — `Left`, `Enter`, `Escape`, `F1`, `Space`
//! and the rest;
//! - a **letter**, upper case: `Z`. Case is not a key — caps lock during a long
//! cull must not stop `P` picking — so `z` and `Z` are one key, and shift is
//! read from the modifier rather than from the case;
//! - any **other printable character**, as itself: `[`, `6`, `\`, `=`, with
//! `+` spelled `Plus` because it is the separator.
//!
//! **Shift and Alt count only for letters and named keys.** For every other
//! character the layout has already applied them: `}` is what shift does to
//! `]` on one keyboard and not another, and on the French layout this
//! application's author uses, *every digit* needs shift. A binding of `6` has
//! to answer the key that produces a 6, whatever it took to produce it, so the
//! canonical form of a shifted 6 is `6` — and a spelling like `Shift+6` is
//! refused rather than accepted as a binding no keyboard can reach.
//!
//! The same rules are implemented once more, in `ui/dr-ui/ui/keys.slint`,
//! because the application compares keys in Slint and cannot call this crate.
//! That copy is held to this one by the checker: it reads the names the Slint
//! function can produce and fails if they are not exactly [`NAMED`], and it
//! fails if a handler compares against a literal that is not canonical here.
use std::fmt;
/// A named key: its canonical spelling, what a person sees, and the other
/// spellings accepted for it in a tag.
///
/// `shown` is what the help sheet prints. The arrows get arrows; everything
/// else is already the word on the keycap.
pub struct Named {
pub name: &'static str,
pub shown: &'static str,
pub aliases: &'static [&'static str],
}
/// Every named key a binding may use. Mirrored in `keys.slint`'s `named`.
pub const NAMED: &[Named] = &[
Named {
name: "Left",
shown: "←",
aliases: &["leftarrow", "left arrow", "←"],
},
Named {
name: "Right",
shown: "→",
aliases: &["rightarrow", "right arrow", "→"],
},
Named {
name: "Up",
shown: "↑",
aliases: &["uparrow", "up arrow", "↑"],
},
Named {
name: "Down",
shown: "↓",
aliases: &["downarrow", "down arrow", "↓"],
},
Named {
name: "Home",
shown: "Home",
aliases: &[],
},
Named {
name: "End",
shown: "End",
aliases: &[],
},
Named {
name: "PageUp",
shown: "Page Up",
aliases: &["page up", "pgup"],
},
Named {
name: "PageDown",
shown: "Page Down",
aliases: &["page down", "pgdn"],
},
Named {
name: "Enter",
shown: "Enter",
aliases: &["return"],
},
Named {
name: "Escape",
shown: "Escape",
aliases: &["esc"],
},
Named {
name: "Back",
shown: "Back",
aliases: &[],
},
Named {
name: "Tab",
shown: "Tab",
aliases: &["backtab"],
},
Named {
name: "Backspace",
shown: "Backspace",
aliases: &[],
},
Named {
name: "Delete",
shown: "Delete",
aliases: &["del"],
},
Named {
name: "Insert",
shown: "Insert",
aliases: &["ins"],
},
Named {
name: "Space",
shown: "Space",
aliases: &["spacebar", " "],
},
Named {
name: "F1",
shown: "F1",
aliases: &[],
},
Named {
name: "F2",
shown: "F2",
aliases: &[],
},
Named {
name: "F3",
shown: "F3",
aliases: &[],
},
Named {
name: "F4",
shown: "F4",
aliases: &[],
},
Named {
name: "F5",
shown: "F5",
aliases: &[],
},
Named {
name: "F6",
shown: "F6",
aliases: &[],
},
Named {
name: "F7",
shown: "F7",
aliases: &[],
},
Named {
name: "F8",
shown: "F8",
aliases: &[],
},
Named {
name: "F9",
shown: "F9",
aliases: &[],
},
Named {
name: "F10",
shown: "F10",
aliases: &[],
},
Named {
name: "F11",
shown: "F11",
aliases: &[],
},
Named {
name: "F12",
shown: "F12",
aliases: &[],
},
];
/// One key with the modifiers held for it.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Chord {
pub ctrl: bool,
pub alt: bool,
pub shift: bool,
pub meta: bool,
/// Canonical: a [`NAMED`] name, an upper-case letter, `Plus`, or the
/// printable character itself.
pub key: String,
}
impl Chord {
/// Parse any accepted spelling: `Ctrl+z`, `Control+Z`, `ctrl+shift+z`,
/// `LeftArrow`, `←`, `Esc`, `Ctrl++`.
pub fn parse(spelling: &str) -> Result<Chord, String> {
let s = spelling.trim();
if s.is_empty() {
return Err("an empty key".into());
}
// `+` is both the separator and a key. A spelling ending in `++`, or
// that is `+` alone, has the plus key as its last part.
let (mods, key) = if s == "+" {
("", "+")
} else if let Some(head) = s.strip_suffix("++") {
(head, "+")
} else {
match s.rsplit_once('+') {
Some((head, key)) => (head, key),
None => ("", s),
}
};
let mut c = Chord {
ctrl: false,
alt: false,
shift: false,
meta: false,
key: String::new(),
};
if !mods.is_empty() {
for m in mods.split('+') {
let slot = match m.trim().to_ascii_lowercase().as_str() {
"ctrl" | "control" | "ctl" => &mut c.ctrl,
"alt" | "option" | "opt" => &mut c.alt,
"shift" => &mut c.shift,
"meta" | "super" | "win" => &mut c.meta,
other => return Err(format!("`{other}` is not a modifier in `{spelling}`")),
};
if *slot {
return Err(format!("a modifier named twice in `{spelling}`"));
}
*slot = true;
}
}
let (key, counts_shift) = canonical_key(key)
.ok_or_else(|| format!("`{}` is not a key this vocabulary knows", key.trim()))?;
if !counts_shift && (c.shift || c.alt) {
return Err(format!(
"`{spelling}`: shift and alt are part of the character on a printable key \
(the layout decides what they make), so they cannot be bound — write the \
character itself"
));
}
c.key = key;
Ok(c)
}
/// The canonical spelling: what `Keys.chord` in `keys.slint` produces and
/// what a handler's literal must be.
pub fn canonical(&self) -> String {
self.with(&self.key)
}
/// What the help sheet prints: canonical, with arrows drawn as arrows.
pub fn shown(&self) -> String {
let key = NAMED
.iter()
.find(|n| n.name == self.key)
.map_or(self.key.as_str(), |n| n.shown);
self.with(key)
}
fn with(&self, key: &str) -> String {
let mut s = String::new();
for (on, word) in [
(self.ctrl, "Ctrl+"),
(self.alt, "Alt+"),
(self.shift, "Shift+"),
(self.meta, "Meta+"),
] {
if on {
s.push_str(word);
}
}
s.push_str(key);
s
}
}
impl fmt::Display for Chord {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.canonical())
}
}
/// The canonical key for one spelling, and whether shift and alt count for it.
fn canonical_key(spelling: &str) -> Option<(String, bool)> {
// A lone space is a key and must not be trimmed away; anything longer is
// a word and its padding is not part of it.
let raw = if spelling == " " {
spelling
} else {
spelling.trim()
};
let lower = raw.to_lowercase();
for n in NAMED {
if n.name.to_ascii_lowercase() == lower || n.aliases.contains(&lower.as_str()) {
return Some((n.name.to_string(), true));
}
}
if lower == "plus" {
return Some(("Plus".into(), false));
}
let mut chars = raw.chars();
let c = chars.next()?;
if chars.next().is_some() {
return None;
}
if c == '+' {
return Some(("Plus".into(), false));
}
if c.is_control() {
return None;
}
let upper: String = c.to_uppercase().collect();
let lower: String = c.to_lowercase().collect();
if upper != lower {
Some((upper, true))
} else {
Some((c.to_string(), false))
}
}
/// Every chord a `keys:` field names, in order, or why one would not parse.
///
/// A chord is written between backticks — `` `Ctrl+Z` `` — which is what lets
/// the field stay a sentence for a person ("the same key again takes it off")
/// while every key in it is still read by a machine. Two single-character
/// chords joined by an en dash or " to " are a range: `` `0`–`5` `` names all
/// six digits, because writing out six backticked digits helps nobody.
pub fn chords_in(keys: &str) -> Result<Vec<Chord>, String> {
let parts: Vec<&str> = keys.split('`').collect();
if parts.len().is_multiple_of(2) {
return Err("an unclosed backtick".into());
}
let tokens: Vec<(usize, &str)> = parts
.iter()
.enumerate()
.filter(|(i, _)| i % 2 == 1)
.map(|(i, t)| (i, *t))
.collect();
if tokens.is_empty() {
return Err(
"names no key — write each key between backticks, e.g. `Ctrl+Z`, so the \
checker can hold it to a handler"
.into(),
);
}
let mut out: Vec<Chord> = Vec::new();
let mut prev: Option<(usize, Chord)> = None;
for (i, tok) in tokens {
let chord = Chord::parse(tok)?;
if let Some((pi, p)) = &prev {
let between = parts[pi + 1];
if *pi + 2 == i && (between == "–" || between == " to ") {
for mid in range_between(p, &chord)? {
out.push(mid);
}
}
}
out.push(chord.clone());
prev = Some((i, chord));
}
Ok(out)
}
/// The chords strictly between two ends of a written range.
fn range_between(a: &Chord, b: &Chord) -> Result<Vec<Chord>, String> {
let (ca, cb) = (single(&a.key), single(&b.key));
let (Some(ca), Some(cb)) = (ca, cb) else {
return Err(format!(
"`{a}`–`{b}` is not a range: only single characters make one"
));
};
if !(ca.is_ascii_alphanumeric() && cb.is_ascii_alphanumeric()) || ca >= cb {
return Err(format!("`{a}`–`{b}` is not an ascending range"));
}
Ok(((ca as u8 + 1)..(cb as u8))
.map(|m| Chord {
key: (m as char).to_string(),
..a.clone()
})
.collect())
}
fn single(s: &str) -> Option<char> {
let mut c = s.chars();
let first = c.next()?;
c.next().is_none().then_some(first)
}
/// A `keys:` field as a person reads it: every chord in its shown form.
///
/// `markdown` keeps the backticks, so the document sets keys as code; the
/// application's sheet drops them. A chord that does not parse is left as
/// written — the checker reports it, and rendering is not the place to fail.
pub fn render_keys(keys: &str, markdown: bool) -> String {
let mut out = String::new();
for (i, part) in keys.split('`').enumerate() {
if i % 2 == 0 {
out.push_str(part);
continue;
}
let shown = Chord::parse(part).map_or_else(|_| part.to_string(), |c| c.shown());
if markdown {
out.push('`');
out.push_str(&shown);
out.push('`');
} else {
out.push_str(&shown);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn canon(s: &str) -> String {
Chord::parse(s).unwrap().canonical()
}
/// The spellings a person writes for one key all meet in one place.
#[test]
fn spellings_of_one_chord_agree() {
for s in ["Ctrl+Z", "Control+z", "ctrl+z", " Ctrl + Z "] {
assert_eq!(canon(s), "Ctrl+Z", "{s}");
}
for s in ["Left", "LeftArrow", "←", "left arrow"] {
assert_eq!(canon(s), "Left", "{s}");
}
assert_eq!(canon("Shift+Ctrl+z"), "Ctrl+Shift+Z");
assert_eq!(canon("Esc"), "Escape");
assert_eq!(canon("Return"), "Enter");
assert_eq!(canon("PgUp"), "PageUp");
}
#[test]
fn plus_is_a_key_as_well_as_the_separator() {
assert_eq!(canon("Ctrl++"), "Ctrl+Plus");
assert_eq!(canon("+"), "Plus");
assert_eq!(canon("Ctrl+Plus"), "Ctrl+Plus");
assert_eq!(canon("Ctrl+-"), "Ctrl+-");
assert_eq!(canon("Ctrl+="), "Ctrl+=");
}
/// The French layout needs shift for every digit, so a binding of `6` is
/// the character, and `Shift+6` is a binding no handler could receive.
#[test]
fn shift_on_a_printable_character_is_refused() {
assert!(Chord::parse("Shift+6").is_err());
assert!(Chord::parse("Alt+[").is_err());
assert_eq!(canon("Shift+Left"), "Shift+Left");
assert_eq!(canon("Shift+a"), "Shift+A");
assert_eq!(canon("Ctrl+0"), "Ctrl+0");
}
#[test]
fn nonsense_is_refused() {
assert!(Chord::parse("").is_err());
assert!(Chord::parse("Hyper+Z").is_err());
assert!(Chord::parse("Ctrl+Ctrl+Z").is_err());
assert!(Chord::parse("Banana").is_err());
}
#[test]
fn the_sheet_draws_arrows() {
assert_eq!(Chord::parse("Shift+Left").unwrap().shown(), "Shift+←");
assert_eq!(
render_keys("`Right` or `D` for the next", false),
"→ or D for the next"
);
assert_eq!(render_keys("`ctrl+z`", true), "`Ctrl+Z`");
}
#[test]
fn a_field_names_its_chords_between_backticks() {
let c = chords_in("`Right`, `D` or `Space` for the next").unwrap();
let names: Vec<String> = c.iter().map(Chord::canonical).collect();
assert_eq!(names, ["Right", "D", "Space"]);
}
#[test]
fn a_dash_between_two_digits_is_a_range() {
let c = chords_in("`0`–`5` with the pointer over it").unwrap();
let names: Vec<String> = c.iter().map(Chord::canonical).collect();
assert_eq!(names, ["0", "1", "2", "3", "4", "5"]);
let c = chords_in("`6` red, `9` blue").unwrap();
assert_eq!(c.len(), 2, "a comma is not a range");
}
/// Prose naming a key without backticks would be a key nobody checks.
#[test]
fn a_field_with_no_backticked_key_is_refused() {
assert!(chords_in("Hold backslash").is_err());
assert!(chords_in("`Ctrl+Z").is_err());
}
}