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:
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -372,6 +372,11 @@ pub fn render_markdown(gestures: &[Gesture]) -> String {
|
||||
for g in list {
|
||||
m.push_str(&format!("\n### {}\n\n", g.title));
|
||||
for (modality, how) in g.routes() {
|
||||
let how = if modality == "Keyboard" {
|
||||
crate::chord::render_keys(how, true)
|
||||
} else {
|
||||
how.to_string()
|
||||
};
|
||||
m.push_str(&format!("- **{modality}** — {how}\n"));
|
||||
}
|
||||
if let Some(anchor) = &g.manual {
|
||||
@@ -442,7 +447,10 @@ pub fn render_rust(gestures: &[Gesture]) -> String {
|
||||
));
|
||||
r.push_str(&format!(
|
||||
" keys: {},\n",
|
||||
quote(g.keys.as_deref().unwrap_or(""))
|
||||
quote(&crate::chord::render_keys(
|
||||
g.keys.as_deref().unwrap_or(""),
|
||||
false
|
||||
))
|
||||
));
|
||||
r.push_str(&format!(
|
||||
" manual: {},\n",
|
||||
|
||||
@@ -0,0 +1,605 @@
|
||||
//! TRACES: FR-UI-5 | FR-DEV-16 | FR-UI-4
|
||||
//! Every key the application answers is in the gesture book, and every key the
|
||||
//! book names is answered.
|
||||
//!
|
||||
//! # The failure this closes
|
||||
//!
|
||||
//! The gesture book ([`crate::gestures`]) is generated from `GESTURE:` tags,
|
||||
//! so it cannot describe a gesture nobody tagged — but nothing made anyone tag
|
||||
//! one. Arrow keys walked the grid, `P` picked, `Delete` trashed and `F2`
|
||||
//! renamed for months with no line in the book, and a tag could just as well
|
||||
//! name a key whose handler had been deleted. The tags were trusted in both
|
||||
//! directions, and they were right about half the keys.
|
||||
//!
|
||||
//! # Why the handlers compare a canonical string, and not a Rust keymap
|
||||
//!
|
||||
//! There were two ways to make the bound keys machine-readable.
|
||||
//!
|
||||
//! **A keymap table in Rust**, which both dispatches and is read by the
|
||||
//! generator. It is the tidier data structure and the wrong home for the
|
||||
//! dispatch. The handlers' state lives in Slint: the F chord's held flag, which
|
||||
//! sheet is open, which adjustment group is on screen, whether a text field has
|
||||
//! focus. Moving the decision to Rust means either mirroring all of that into
|
||||
//! Rust or passing it across on every key, and a key handler has to answer
|
||||
//! `accept` or `reject` synchronously — which is the part that decides whether
|
||||
//! Escape reaches the shell. And the table would be a callback every window
|
||||
//! must remember to install: forget it once, in a test harness or on Android,
|
||||
//! and every key in the application is silently dead.
|
||||
//!
|
||||
//! **A static extractor over the Slint handlers**, which is what this is — but
|
||||
//! over a shape designed to be extracted, not over whatever a handler happened
|
||||
//! to be written as. Reading `event.text == "z"` and inferring its modifiers
|
||||
//! from the `if` around it is the fragile version: `event.modifiers.shift`
|
||||
//! appears as a guard, as a negated guard, as an argument, and in a nested
|
||||
//! `if` that splits Ctrl+E from Ctrl+Shift+E. So the handlers no longer read
|
||||
//! `event.text` at all. They compare one canonical string:
|
||||
//!
|
||||
//! ```text
|
||||
//! if (Keys.chord(event) == "Ctrl+Shift+Z") { … }
|
||||
//! ```
|
||||
//!
|
||||
//! `Keys.chord` (`ui/dr-ui/ui/keys.slint`) folds the text and every modifier
|
||||
//! into the spelling [`crate::chord`] defines, so the literal *is* the whole
|
||||
//! binding — key and modifiers — and the handler dispatches on exactly the
|
||||
//! string this module reads. There is nothing to infer.
|
||||
//!
|
||||
//! What keeps that honest:
|
||||
//!
|
||||
//! 1. `event.text` may appear in no `.slint` file but `keys.slint`. A handler
|
||||
//! that went back to reading it would be a binding this cannot see.
|
||||
//! 2. Every literal compared against `Keys.chord(…)` must already be
|
||||
//! canonical. `"ctrl+z"` would parse, and would never match.
|
||||
//! 3. The names `keys.slint` can produce must be exactly [`crate::chord::NAMED`]:
|
||||
//! the Slint half of the vocabulary cannot grow or lose a key on its own.
|
||||
//! 4. A handler that binds anything carries a `// KEYMAP: <where>` comment
|
||||
//! above it, naming the `where:` its keys are documented under. That is how
|
||||
//! a key in the develop handler is held to the Develop section while a tag
|
||||
//! for it sits in `controls.slint`, beside the control it resets.
|
||||
//!
|
||||
//! Then the two directions, on (place, canonical chord):
|
||||
//!
|
||||
//! - **(a)** a key some handler binds that no `GESTURE:` tag in that place
|
||||
//! names;
|
||||
//! - **(b)** a key some tag names that no handler in that place binds.
|
||||
//!
|
||||
//! A tag's `keys:` names its keys between backticks — see
|
||||
//! [`crate::chord::chords_in`] — so the field stays a sentence for a person.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use crate::chord::{self, Chord};
|
||||
use crate::gestures::{Gesture, GestureProblem};
|
||||
|
||||
/// The file that owns the raw key text, and the only one allowed to read it.
|
||||
pub const KEYS_FILE: &str = "keys.slint";
|
||||
|
||||
/// One key a handler answers.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Binding {
|
||||
/// The `KEYMAP:` of the handler — a gesture section.
|
||||
pub section: String,
|
||||
/// Canonical chord.
|
||||
pub chord: String,
|
||||
pub file: String,
|
||||
pub line: usize,
|
||||
}
|
||||
|
||||
fn problem(file: &str, line: usize, what: String) -> GestureProblem {
|
||||
GestureProblem {
|
||||
file: file.to_string(),
|
||||
line,
|
||||
what,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every binding in one `.slint` file, and whatever breaks the rules above.
|
||||
pub fn extract_bindings(text: &str, path: &str) -> (Vec<Binding>, Vec<GestureProblem>) {
|
||||
let mut out = Vec::new();
|
||||
let mut problems = Vec::new();
|
||||
let is_keys_file = path.ends_with(KEYS_FILE);
|
||||
let code = code_only(text);
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let code_lines: Vec<&str> = code.lines().collect();
|
||||
|
||||
if !is_keys_file {
|
||||
for (i, l) in code_lines.iter().enumerate() {
|
||||
if l.contains("event.text") {
|
||||
problems.push(problem(
|
||||
path,
|
||||
i + 1,
|
||||
"reads `event.text` — compare `Keys.chord(event)` against a canonical \
|
||||
chord instead, or the key checker cannot see this binding"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (i, l) in code_lines.iter().enumerate() {
|
||||
let is_handler =
|
||||
(l.contains("key-pressed(") || l.contains("key-released(")) && l.contains("=>");
|
||||
if !is_handler {
|
||||
continue;
|
||||
}
|
||||
let section = keymap_above(&lines, i);
|
||||
let body_end = block_end(&code_lines, i);
|
||||
let mut found_any = false;
|
||||
for (j, cl) in code_lines.iter().enumerate().take(body_end + 1).skip(i) {
|
||||
for lit in compared_literals(cl, lines[j]) {
|
||||
found_any = true;
|
||||
match Chord::parse(&lit) {
|
||||
Ok(c) if c.canonical() == lit => {
|
||||
if let Some(s) = §ion {
|
||||
out.push(Binding {
|
||||
section: s.clone(),
|
||||
chord: lit,
|
||||
file: path.to_string(),
|
||||
line: j + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(c) => problems.push(problem(
|
||||
path,
|
||||
j + 1,
|
||||
format!(
|
||||
"compares against \"{lit}\", which `Keys.chord` never produces — \
|
||||
write \"{}\"",
|
||||
c.canonical()
|
||||
),
|
||||
)),
|
||||
Err(e) => problems.push(problem(path, j + 1, format!("\"{lit}\": {e}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
if found_any && section.is_none() {
|
||||
problems.push(problem(
|
||||
path,
|
||||
i + 1,
|
||||
"a key handler with bindings and no `// KEYMAP: <where>` comment above it, \
|
||||
so its keys cannot be held to a place in the gesture book"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
(out, problems)
|
||||
}
|
||||
|
||||
/// The `KEYMAP:` in the run of comment lines directly above line `at`.
|
||||
fn keymap_above(lines: &[&str], at: usize) -> Option<String> {
|
||||
let mut i = at;
|
||||
while i > 0 {
|
||||
i -= 1;
|
||||
let t = lines[i].trim_start();
|
||||
let Some(body) = t.strip_prefix("//") else {
|
||||
break;
|
||||
};
|
||||
if let Some(rest) = body.trim_start().strip_prefix("KEYMAP:") {
|
||||
return Some(rest.trim().to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The line on which the brace block opened on line `at` closes.
|
||||
///
|
||||
/// Works on text with comments and strings already blanked, so braces inside
|
||||
/// either do not count.
|
||||
fn block_end(code_lines: &[&str], at: usize) -> usize {
|
||||
let mut depth = 0i32;
|
||||
let mut opened = false;
|
||||
for (i, l) in code_lines.iter().enumerate().skip(at) {
|
||||
for ch in l.chars() {
|
||||
match ch {
|
||||
'{' => {
|
||||
depth += 1;
|
||||
opened = true;
|
||||
}
|
||||
'}' => depth -= 1,
|
||||
_ => {}
|
||||
}
|
||||
if opened && depth == 0 {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
code_lines.len().saturating_sub(1)
|
||||
}
|
||||
|
||||
/// The literals compared against `Keys.chord(…)` on one line.
|
||||
///
|
||||
/// `code` is the line with strings blanked, used to find the call; `raw` is
|
||||
/// the original, used to read the literal. The two have the same byte offsets.
|
||||
fn compared_literals(code: &str, raw: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut from = 0;
|
||||
while let Some(at) = code[from..].find("Keys.chord(") {
|
||||
let start = from + at;
|
||||
let Some(close) = code[start..].find(')') else {
|
||||
break;
|
||||
};
|
||||
let after = start + close + 1;
|
||||
from = after;
|
||||
let rest = code[after..].trim_start();
|
||||
let Some(op_rest) = rest.strip_prefix("==").or_else(|| rest.strip_prefix("!=")) else {
|
||||
continue;
|
||||
};
|
||||
let lit_at = code.len() - op_rest.trim_start().len();
|
||||
if let Some(lit) = read_literal(&raw[lit_at..]) {
|
||||
out.push(lit);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A Slint string literal at the start of `s`, unescaped.
|
||||
fn read_literal(s: &str) -> Option<String> {
|
||||
let mut chars = s.chars();
|
||||
if chars.next()? != '"' {
|
||||
return None;
|
||||
}
|
||||
let mut out = String::new();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'"' => return Some(out),
|
||||
'\\' => match chars.next()? {
|
||||
'n' => out.push('\n'),
|
||||
't' => out.push('\t'),
|
||||
other => out.push(other),
|
||||
},
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The text with every comment and the inside of every string literal
|
||||
/// replaced by spaces, keeping byte offsets and line breaks.
|
||||
fn code_only(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut in_str = false;
|
||||
let mut in_line_comment = false;
|
||||
let mut in_block_comment = false;
|
||||
let mut escape = false;
|
||||
let bytes: Vec<char> = text.chars().collect();
|
||||
let mut i = 0;
|
||||
let blank = |c: char, out: &mut String| {
|
||||
if c == '\n' {
|
||||
out.push('\n');
|
||||
} else {
|
||||
for _ in 0..c.len_utf8() {
|
||||
out.push(' ');
|
||||
}
|
||||
}
|
||||
};
|
||||
while i < bytes.len() {
|
||||
let c = bytes[i];
|
||||
let next = bytes.get(i + 1).copied();
|
||||
if in_line_comment {
|
||||
if c == '\n' {
|
||||
in_line_comment = false;
|
||||
}
|
||||
blank(c, &mut out);
|
||||
} else if in_block_comment {
|
||||
if c == '*' && next == Some('/') {
|
||||
in_block_comment = false;
|
||||
out.push_str(" ");
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
blank(c, &mut out);
|
||||
} else if in_str {
|
||||
if escape {
|
||||
escape = false;
|
||||
blank(c, &mut out);
|
||||
} else if c == '\\' {
|
||||
escape = true;
|
||||
blank(c, &mut out);
|
||||
} else if c == '"' {
|
||||
in_str = false;
|
||||
out.push('"');
|
||||
} else {
|
||||
blank(c, &mut out);
|
||||
}
|
||||
} else if c == '/' && next == Some('/') {
|
||||
in_line_comment = true;
|
||||
out.push_str(" ");
|
||||
i += 2;
|
||||
continue;
|
||||
} else if c == '/' && next == Some('*') {
|
||||
in_block_comment = true;
|
||||
out.push_str(" ");
|
||||
i += 2;
|
||||
continue;
|
||||
} else {
|
||||
if c == '"' {
|
||||
in_str = true;
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The named keys `keys.slint`'s `named` function can return.
|
||||
///
|
||||
/// Read as the literals of its `return "…";` lines, between `function named`
|
||||
/// and the next function.
|
||||
pub fn slint_named_keys(text: &str) -> BTreeSet<String> {
|
||||
let mut out = BTreeSet::new();
|
||||
let mut inside = false;
|
||||
for l in text.lines() {
|
||||
let t = l.trim_start();
|
||||
if t.starts_with("//") {
|
||||
continue;
|
||||
}
|
||||
if t.contains("function ") {
|
||||
inside = t.contains("function named(");
|
||||
continue;
|
||||
}
|
||||
if !inside {
|
||||
continue;
|
||||
}
|
||||
if let Some(rest) = t.split("return ").nth(1) {
|
||||
if let Some(lit) = read_literal(rest.trim_start()) {
|
||||
if !lit.is_empty() {
|
||||
out.insert(lit);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Hold the Slint half of the vocabulary to the Rust half.
|
||||
pub fn check_vocabulary(slint_names: &BTreeSet<String>, path: &str) -> Vec<GestureProblem> {
|
||||
let rust: BTreeSet<String> = chord::NAMED.iter().map(|n| n.name.to_string()).collect();
|
||||
let mut problems = Vec::new();
|
||||
for missing in rust.difference(slint_names) {
|
||||
problems.push(problem(
|
||||
path,
|
||||
1,
|
||||
format!("`Keys.named` never returns \"{missing}\", which chord.rs names"),
|
||||
));
|
||||
}
|
||||
for extra in slint_names.difference(&rust) {
|
||||
problems.push(problem(
|
||||
path,
|
||||
1,
|
||||
format!("`Keys.named` returns \"{extra}\", which chord.rs does not know"),
|
||||
));
|
||||
}
|
||||
problems
|
||||
}
|
||||
|
||||
/// Both directions: bound but undocumented, documented but unbound.
|
||||
pub fn cross_check(gestures: &[Gesture], bindings: &[Binding]) -> Vec<GestureProblem> {
|
||||
let mut problems = Vec::new();
|
||||
|
||||
// (section, chord) → where the tag naming it is.
|
||||
let mut documented: BTreeMap<(String, String), (String, usize, String)> = BTreeMap::new();
|
||||
for g in gestures {
|
||||
let Some(keys) = &g.keys else { continue };
|
||||
match chord::chords_in(keys) {
|
||||
Ok(chords) => {
|
||||
for c in chords {
|
||||
documented
|
||||
.entry((g.section.clone(), c.canonical()))
|
||||
.or_insert_with(|| (g.file.clone(), g.line, g.title.clone()));
|
||||
}
|
||||
}
|
||||
Err(e) => problems.push(problem(
|
||||
&g.file,
|
||||
g.line,
|
||||
format!("gesture `{}`: `keys:` {e}", g.title),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
let mut bound: BTreeMap<(String, String), (String, usize)> = BTreeMap::new();
|
||||
for b in bindings {
|
||||
bound
|
||||
.entry((b.section.clone(), b.chord.clone()))
|
||||
.or_insert_with(|| (b.file.clone(), b.line));
|
||||
}
|
||||
|
||||
for ((section, chord), (file, line)) in &bound {
|
||||
if !documented.contains_key(&(section.clone(), chord.clone())) {
|
||||
problems.push(problem(
|
||||
file,
|
||||
*line,
|
||||
format!(
|
||||
"`{chord}` is bound in {section} but no GESTURE tag with \
|
||||
`where: {section}` names it in its `keys:`"
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
for ((section, chord), (file, line, title)) in &documented {
|
||||
if !bound.contains_key(&(section.clone(), chord.clone())) {
|
||||
problems.push(problem(
|
||||
file,
|
||||
*line,
|
||||
format!(
|
||||
"gesture `{title}` names `{chord}`, but no key handler marked \
|
||||
`KEYMAP: {section}` binds it"
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
problems
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::gestures::extract_from_text;
|
||||
|
||||
/// A handler in the shape the application writes, and a tag for each key.
|
||||
const HANDLER: &str = r#"
|
||||
FocusScope {
|
||||
// KEYMAP: Grid
|
||||
key-pressed(event) => {
|
||||
if (Keys.chord(event) == "Ctrl+Z") { undo(); return accept; }
|
||||
if (Keys.chord(event) == "Left" || Keys.chord(event) == "Shift+Left") {
|
||||
step(-1);
|
||||
return accept;
|
||||
}
|
||||
return reject;
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
const TAGS: &str = "
|
||||
// GESTURE: Undo
|
||||
// where: Grid
|
||||
// pointer: Click Undo
|
||||
// keys: `Ctrl+Z`
|
||||
// GESTURE: Step back
|
||||
// where: Grid
|
||||
// pointer: Click the previous frame
|
||||
// keys: `Left`, or `Shift+Left` to take the selection with you
|
||||
";
|
||||
|
||||
fn run(handler: &str, tags: &str) -> Vec<GestureProblem> {
|
||||
let (b, mut p) = extract_bindings(handler, "grid.slint");
|
||||
let (g, gp) = extract_from_text(tags, "tags.slint");
|
||||
p.extend(gp);
|
||||
p.extend(cross_check(&g, &b));
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documented_keys_pass() {
|
||||
let p = run(HANDLER, TAGS);
|
||||
assert!(p.is_empty(), "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bound_key_with_no_tag_fails() {
|
||||
let handler = HANDLER.replace(
|
||||
"return reject;",
|
||||
"if (Keys.chord(event) == \"Delete\") { trash(); return accept; }\n return reject;",
|
||||
);
|
||||
let p = run(&handler, TAGS);
|
||||
assert_eq!(p.len(), 1, "{p:?}");
|
||||
assert!(
|
||||
p[0].what.contains("`Delete` is bound in Grid"),
|
||||
"{}",
|
||||
p[0].what
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tagged_key_no_handler_binds_fails() {
|
||||
let tags = TAGS.replace("`Ctrl+Z`", "`Ctrl+Z` or `Ctrl+Y`");
|
||||
let p = run(HANDLER, &tags);
|
||||
assert_eq!(p.len(), 1, "{p:?}");
|
||||
assert!(p[0].what.contains("names `Ctrl+Y`"), "{}", p[0].what);
|
||||
}
|
||||
|
||||
/// A key documented in one place does not cover the same key in another.
|
||||
#[test]
|
||||
fn a_key_documented_elsewhere_does_not_count() {
|
||||
let tags = TAGS.replace(
|
||||
"where: Grid\n// pointer: Click Undo",
|
||||
"where: Develop\n// pointer: Click Undo",
|
||||
);
|
||||
let p = run(HANDLER, &tags);
|
||||
assert_eq!(p.len(), 2, "{p:?}");
|
||||
}
|
||||
|
||||
/// Spelling differences between the tag and the handler are not drift.
|
||||
#[test]
|
||||
fn the_tag_may_spell_a_key_any_accepted_way() {
|
||||
let tags = TAGS
|
||||
.replace("`Ctrl+Z`", "`control+z`")
|
||||
.replace("`Left`", "`←`");
|
||||
let p = run(HANDLER, &tags);
|
||||
assert!(p.is_empty(), "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_event_text_is_refused() {
|
||||
let handler = HANDLER.replace(
|
||||
"return reject;",
|
||||
"if (event.text == \"q\") { quit(); }\n return reject;",
|
||||
);
|
||||
let p = run(&handler, TAGS);
|
||||
assert!(p.iter().any(|x| x.what.contains("event.text")), "{p:?}");
|
||||
}
|
||||
|
||||
/// A comment mentioning it is not a read.
|
||||
#[test]
|
||||
fn event_text_in_a_comment_is_fine() {
|
||||
let handler = HANDLER.replace(
|
||||
"return reject;",
|
||||
"// not event.text any more\n return reject;",
|
||||
);
|
||||
assert!(run(&handler, TAGS).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_literal_that_is_not_canonical_is_refused() {
|
||||
let handler = HANDLER.replace("\"Ctrl+Z\"", "\"ctrl+z\"");
|
||||
let p = run(&handler, TAGS);
|
||||
assert!(
|
||||
p.iter().any(|x| x.what.contains("write \"Ctrl+Z\"")),
|
||||
"{p:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_handler_without_keymap_is_refused() {
|
||||
let handler = HANDLER.replace("// KEYMAP: Grid", "// the grid's keys");
|
||||
let p = run(&handler, TAGS);
|
||||
assert!(p.iter().any(|x| x.what.contains("KEYMAP")), "{p:?}");
|
||||
}
|
||||
|
||||
/// The handler ends where its braces do: a comparison after it belongs to
|
||||
/// nothing, and a `}` in a string does not end it early.
|
||||
#[test]
|
||||
fn the_block_is_found_by_its_braces() {
|
||||
let handler = r#"
|
||||
// KEYMAP: Grid
|
||||
key-pressed(event) => {
|
||||
if (label == "}") { }
|
||||
if (Keys.chord(event) == "Ctrl+Z") { return accept; }
|
||||
return reject;
|
||||
}
|
||||
function other() { if (Keys.chord(e) == "Q") {} }
|
||||
"#;
|
||||
let (b, p) = extract_bindings(handler, "x.slint");
|
||||
assert!(p.is_empty(), "{p:?}");
|
||||
assert_eq!(b.len(), 1, "{b:?}");
|
||||
assert_eq!(b[0].chord, "Ctrl+Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_backslash_literal_is_unescaped() {
|
||||
let handler =
|
||||
"// KEYMAP: Grid\nkey-pressed(event) => {\n if (Keys.chord(event) == \"\\\\\") {}\n}\n";
|
||||
let (b, p) = extract_bindings(handler, "x.slint");
|
||||
assert!(p.is_empty(), "{p:?}");
|
||||
assert_eq!(b[0].chord, "\\");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_slint_vocabulary_is_read_from_named() {
|
||||
let slint = r#"
|
||||
export global Keys {
|
||||
pure function named(text: string) -> string {
|
||||
if (text == Key.LeftArrow) { return "Left"; }
|
||||
return "";
|
||||
}
|
||||
public pure function chord(event: KeyEvent) -> string {
|
||||
return "Nope";
|
||||
}
|
||||
}
|
||||
"#;
|
||||
let names = slint_named_keys(slint);
|
||||
assert_eq!(names.into_iter().collect::<Vec<_>>(), ["Left"]);
|
||||
let p = check_vocabulary(&slint_named_keys(slint), "keys.slint");
|
||||
assert!(p.iter().any(|x| x.what.contains("\"Right\"")), "{p:?}");
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,9 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
pub mod chord;
|
||||
pub mod gestures;
|
||||
pub mod keymap;
|
||||
pub mod manual;
|
||||
|
||||
/// Requirement ID prefixes that participate in coverage.
|
||||
@@ -705,6 +707,8 @@ pub fn scan() {}
|
||||
for (name, src) in [
|
||||
("lib.rs", include_str!("lib.rs")),
|
||||
("gestures.rs", include_str!("gestures.rs")),
|
||||
("chord.rs", include_str!("chord.rs")),
|
||||
("keymap.rs", include_str!("keymap.rs")),
|
||||
("main.rs", include_str!("main.rs")),
|
||||
] {
|
||||
let fixtures_begin = src
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
//! ```
|
||||
//!
|
||||
//! The gesture half scans a different tag out of the same files — see
|
||||
//! [`traceability::gestures`] for what it is and why it lives here.
|
||||
//! [`traceability::gestures`] for what it is and why it lives here — and holds
|
||||
//! the keys the Slint handlers bind to the keys those tags name, in both
|
||||
//! directions ([`traceability::keymap`]).
|
||||
//!
|
||||
//! The gate fails hard on a *misconfigured run* — zero requirements parsed, or
|
||||
//! zero source files scanned — rather than reporting a plausible-looking 0%.
|
||||
@@ -142,6 +144,8 @@ fn run_gestures(base: &Path, check: bool) -> Result<()> {
|
||||
|
||||
let mut found = Vec::new();
|
||||
let mut problems = Vec::new();
|
||||
let mut bindings = Vec::new();
|
||||
let mut keys_file_seen = false;
|
||||
for file in &files {
|
||||
let text = std::fs::read_to_string(file).unwrap_or_default();
|
||||
let rel = file
|
||||
@@ -159,6 +163,18 @@ fn run_gestures(base: &Path, check: bool) -> Result<()> {
|
||||
let (g, p) = gestures::extract_from_text(&text, &rel);
|
||||
found.extend(g);
|
||||
problems.extend(p);
|
||||
if rel.ends_with(".slint") {
|
||||
let (b, p) = keymap::extract_bindings(&text, &rel);
|
||||
bindings.extend(b);
|
||||
problems.extend(p);
|
||||
if rel.ends_with(&format!("/{}", keymap::KEYS_FILE)) {
|
||||
keys_file_seen = true;
|
||||
problems.extend(keymap::check_vocabulary(
|
||||
&keymap::slint_named_keys(&text),
|
||||
&rel,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A `manual:` must name a section the manual has, checked against the
|
||||
@@ -173,6 +189,7 @@ fn run_gestures(base: &Path, check: bool) -> Result<()> {
|
||||
println!("files scanned {}", files.len());
|
||||
println!("gestures found {}", found.len());
|
||||
println!("places {}", gestures::by_section(&found).len());
|
||||
println!("keys bound {}", bindings.len());
|
||||
|
||||
if !problems.is_empty() {
|
||||
for p in &problems {
|
||||
@@ -181,6 +198,26 @@ fn run_gestures(base: &Path, check: bool) -> Result<()> {
|
||||
bail!("{} malformed gesture tag(s)", problems.len());
|
||||
}
|
||||
|
||||
// The key half, after the tags parse: a key cannot be held to a tag that
|
||||
// did not. A run that found no bindings, or no `keys.slint`, read the
|
||||
// wrong tree — the application has keys, so zero is a misconfiguration.
|
||||
if !keys_file_seen || bindings.is_empty() {
|
||||
bail!(
|
||||
"no {} or no key bindings found — misconfigured, not an application without keys",
|
||||
keymap::KEYS_FILE
|
||||
);
|
||||
}
|
||||
let key_problems = keymap::cross_check(&found, &bindings);
|
||||
if !key_problems.is_empty() {
|
||||
for p in &key_problems {
|
||||
println!(" {p}");
|
||||
}
|
||||
bail!(
|
||||
"{} key(s) bound and undocumented, or documented and unbound",
|
||||
key_problems.len()
|
||||
);
|
||||
}
|
||||
|
||||
let doc = gestures::render_markdown(&found);
|
||||
let table = gestures::render_rust(&found);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user