Files
DarkRoom/tools/traceability/src/gestures.rs
T
dtourolle 9d1e31ffbb 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.
2026-09-24 23:42:25 -04:00

699 lines
26 KiB
Rust

//! TRACES: FR-UI-4
//! Interaction documentation, extracted from the code that implements it.
//!
//! # Why this exists at all
//!
//! FR-UI-4 says a gesture with no visible counterpart is a feature only its
//! author knows about. The application has grown a real gesture vocabulary —
//! hold to select, arm a range and tap its end, pinch to resize the grid, drag
//! a selection onto a collection — and every one of them was documented only in
//! the comment beside the `TouchArea` that implements it. Excellent comments,
//! and unreachable by anyone who is not reading the source.
//!
//! Writing them out a second time, in a hand-kept help page, is the failure
//! this file is built to avoid. Two descriptions of one gesture drift, and the
//! one that drifts is always the prose: the code is exercised every time
//! somebody uses the application and the page is exercised never. A help screen
//! that confidently describes a double-tap the grid stopped honouring six
//! months ago is worse than no help screen.
//!
//! So the comment beside the implementation is the only copy. This scans it out
//! into two artefacts — `docs/gestures.md` for a reader, and a generated Rust
//! table the application itself displays — and CI fails if either has drifted
//! from the source. The same discipline, and the same gate, the requirements
//! matrix already runs under.
//!
//! # Why it lives in the traceability crate
//!
//! It is the same operation on the same input: walk the source tree, pull
//! structured tags out of comments, render, and fail if the committed artefact
//! no longer matches. Everything but the tag vocabulary is shared — the file
//! walk, the extension list, the exclusions — and a second crate would have had
//! to duplicate all of it to add one parser.
//!
//! # The tag
//!
//! ```text
//! // GESTURE: Select a range
//! // where: Library grid
//! // touch: "Select to…", then tap the last photograph
//! // pointer: Shift-click
//! // why: A finger sweep runs out at the edge of the screen, and the
//! // ranges that hurt are longer than a screenful.
//! ```
//!
//! Works in `.rs` and `.slint` alike, because both comment with `//` and the
//! gestures live in both — the arbitration in Rust, the affordance in Slint.
//!
//! **The tag opens its comment or it is not a tag.** A line that merely
//! mentions it — a sentence about the vocabulary, a worked example in a doc
//! comment like the one above — is describing the mechanism rather than
//! declaring a gesture, and position is the only thing that tells the two
//! apart.
//!
//! A block runs from that line to the first line that is not a comment, or to a
//! comment line that is empty after its marker. A line reading
//! `key: value` sets a field; any other line inside the block continues the
//! field before it, which is what lets `why` run to a sentence. An unrecognised
//! key is an error rather than a silently dropped line: `pointr:` should not
//! quietly cost a gesture its desktop half.
//!
//! # `manual:`, the section that shows it
//!
//! Optional. It names a heading of `docs/manual/README.md` by its anchor —
//! `manual: looking-closer`, with or without the `#` — and the help sheet then
//! offers "See it", opening the bundled manual there, where a picture shows
//! the move being made. The anchor must exist: [`check_manual`] fails the scan
//! when the manual has no such heading, so renaming a section cannot leave a
//! link on the sheet that opens the manual at the top.
use std::collections::BTreeMap;
use serde::Serialize;
/// One documented interaction.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct Gesture {
/// What it does, in the user's terms — "Select a range", not "arm ranging".
pub title: String,
/// The place it applies, and the heading it is grouped under.
pub section: String,
/// What a finger does. `None` where the gesture is pointer-only.
pub touch: Option<String>,
/// What a mouse or trackpad does.
pub pointer: Option<String>,
/// The keyboard route, where there is one.
pub keys: Option<String>,
/// Why it works this way. Shown in the document, not in the application:
/// a user consulting the help sheet wants the gesture, and a reader of the
/// document wants the argument.
pub why: Option<String>,
/// The manual section that shows it, as a heading anchor without `#`.
pub manual: Option<String>,
pub file: String,
pub line: usize,
}
impl Gesture {
/// Every way in, longest-lived first.
///
/// Touch leads because that is the modality the gesture vocabulary was
/// grown for: ctrl and shift already documented themselves by existing on
/// every other application, and the hold-and-tap sequences did not.
pub fn routes(&self) -> Vec<(&'static str, &str)> {
let mut out = Vec::new();
if let Some(t) = &self.touch {
out.push(("Touch", t.as_str()));
}
if let Some(p) = &self.pointer {
out.push(("Pointer", p.as_str()));
}
if let Some(k) = &self.keys {
out.push(("Keyboard", k.as_str()));
}
out
}
}
/// A key that may appear in a gesture block.
const KEYS: &[&str] = &["where", "touch", "pointer", "keys", "why", "manual"];
/// Something wrong with a tag, reported rather than dropped.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GestureProblem {
pub file: String,
pub line: usize,
pub what: String,
}
impl std::fmt::Display for GestureProblem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}:{}: {}", self.file, self.line, self.what)
}
}
/// Extract every gesture block from one file's text.
///
/// Returns what parsed and what did not. Both, deliberately: a malformed tag
/// that merely vanished would leave a gesture undocumented and everything
/// looking fine, which is the failure mode this whole file exists to prevent.
pub fn extract_from_text(text: &str, path: &str) -> (Vec<Gesture>, Vec<GestureProblem>) {
let lines: Vec<&str> = text.lines().collect();
let mut out = Vec::new();
let mut problems = Vec::new();
let mut i = 0;
while i < lines.len() {
// **The tag must open its comment**, not merely appear somewhere in
// one. Prose that mentions it — "generated from the `GESTURE:` comments
// beside the code" — is talking *about* the vocabulary, not declaring a
// member of it, and the two are only distinguishable by position. The
// gate caught exactly that sentence in `dr-ui`'s own module list, which
// is the right outcome for a typo and the wrong one for a description.
//
// This also excludes a tag inside a string literal, which has code in
// front of it and so does not open a comment at all.
let Some(body) = comment_body(lines[i]) else {
i += 1;
continue;
};
let Some(title) = body.trim_start().strip_prefix("GESTURE:") else {
i += 1;
continue;
};
let start = i;
let title = title.trim().to_string();
let mut fields: BTreeMap<String, String> = BTreeMap::new();
let mut last: Option<String> = None;
i += 1;
while i < lines.len() {
let Some(body) = comment_body(lines[i]) else {
break;
};
if body.trim().is_empty() {
break;
}
// The next gesture ends this one. Without this, two blocks written
// back to back — which is how a screen's gestures naturally get
// listed — had the first swallow the second's title as a
// continuation of its last field, and the second vanished.
if body.contains("GESTURE:") {
break;
}
match split_key(body) {
Some((key, value)) => {
if !KEYS.contains(&key.as_str()) {
problems.push(GestureProblem {
file: path.to_string(),
line: i + 1,
what: format!(
"unknown gesture key `{key}` (expected one of {})",
KEYS.join(", ")
),
});
}
fields.insert(key.clone(), value.trim().to_string());
last = Some(key);
}
// A continuation of the field above it, joined with a single
// space: the source wraps at the column the file wraps at, and
// those line breaks are not part of the sentence.
None => match &last {
Some(key) => {
let entry = fields.entry(key.clone()).or_default();
if !entry.is_empty() {
entry.push(' ');
}
entry.push_str(body.trim());
}
None => break,
},
}
i += 1;
}
let take = |k: &str| fields.get(k).filter(|v| !v.is_empty()).cloned();
let g = Gesture {
title: title.clone(),
section: take("where").unwrap_or_default(),
touch: take("touch"),
pointer: take("pointer"),
keys: take("keys"),
why: take("why"),
manual: take("manual").map(|m| m.trim_start_matches('#').to_string()),
file: path.to_string(),
line: start + 1,
};
// Validated here rather than at the gate, so the problem names the line
// that caused it.
if g.title.is_empty() {
problems.push(GestureProblem {
file: path.to_string(),
line: start + 1,
what: "gesture has no title".into(),
});
}
if g.section.is_empty() {
problems.push(GestureProblem {
file: path.to_string(),
line: start + 1,
what: format!("gesture `{}` has no `where:`", g.title),
});
}
if g.routes().is_empty() {
problems.push(GestureProblem {
file: path.to_string(),
line: start + 1,
what: format!(
"gesture `{}` says how to do it in no modality — needs at least one of \
touch, pointer or keys",
g.title
),
});
}
out.push(g);
}
(out, problems)
}
/// The text of a comment line, marker stripped. `None` for anything else.
///
/// `///` and `//!` are stripped to the same thing: a gesture block written as a
/// doc comment on the function that implements it is the natural place for one,
/// and it should not need a different tag.
fn comment_body(line: &str) -> Option<&str> {
let t = line.trim_start();
let rest = t.strip_prefix("//")?;
Some(
rest.strip_prefix('!')
.or_else(|| rest.strip_prefix('/'))
.unwrap_or(rest),
)
}
/// Split ` key: value` into its halves.
///
/// The key must be a bare word against the left of the comment body, which is
/// what keeps a colon inside prose — "so: the run is resolved by the catalog" —
/// from being read as a new field. A continuation is indented past its key, and
/// the key it continues is a single word; requiring both is enough to tell them
/// apart without counting spaces.
fn split_key(body: &str) -> Option<(String, String)> {
let (head, tail) = body.split_once(':')?;
let key = head.trim();
if key.is_empty() || !key.chars().all(|c| c.is_ascii_alphabetic()) {
return None;
}
// A key sits at the start of the comment body, under a shallow indent. A
// continuation line is pushed further right to line up under its value.
let indent = body.len() - body.trim_start().len();
if indent > 5 {
return None;
}
Some((key.to_ascii_lowercase(), tail.to_string()))
}
/// Every `manual:` that names a section the manual does not have.
///
/// `anchors` is [`crate::manual::anchors`] of the README — the ids the bundled
/// page gives its headings — so this and the page cannot disagree about what
/// exists.
pub fn check_manual(gestures: &[Gesture], anchors: &[String]) -> Vec<GestureProblem> {
gestures
.iter()
.filter_map(|g| {
let m = g.manual.as_ref()?;
(!anchors.iter().any(|a| a == m)).then(|| GestureProblem {
file: g.file.clone(),
line: g.line,
what: format!(
"gesture `{}` names manual section `#{m}`, which docs/manual/README.md \
has no heading for",
g.title
),
})
})
.collect()
}
/// Group gestures under their `where:`, keeping the order the sections were
/// first seen so the document reads in the order a user meets them.
pub fn by_section(gestures: &[Gesture]) -> Vec<(String, Vec<&Gesture>)> {
let mut order: Vec<String> = Vec::new();
let mut map: BTreeMap<String, Vec<&Gesture>> = BTreeMap::new();
for g in gestures {
if !map.contains_key(&g.section) {
order.push(g.section.clone());
}
map.entry(g.section.clone()).or_default().push(g);
}
order
.into_iter()
.map(|s| {
let v = map.remove(&s).unwrap_or_default();
(s, v)
})
.collect()
}
// ── rendering ─────────────────────────────────────────────────────────────
/// The reader's artefact: `docs/gestures.md`.
///
/// Carries `why` where the application's sheet does not. Someone reading the
/// document is deciding whether a gesture is right; someone holding the tablet
/// has already decided and wants the move.
pub fn render_markdown(gestures: &[Gesture]) -> String {
let mut m = String::new();
m.push_str("# How the application is driven\n\n");
m.push_str("<!-- GENERATED FILE — do not edit by hand. -->\n");
m.push_str("<!-- Regenerate: cargo run -p traceability -- gestures -->\n\n");
m.push_str(
"Every entry here is extracted from the comment beside the code that implements it, \
so this file cannot describe a gesture the application does not have. Add one by \
writing a `GESTURE:` block next to the implementation; there is nowhere else to \
write it.\n\n",
);
let places = by_section(gestures).len();
m.push_str(&format!(
"{} gesture{}, in {} place{}.\n",
gestures.len(),
if gestures.len() == 1 { "" } else { "s" },
places,
if places == 1 { "" } else { "s" },
));
for (section, list) in by_section(gestures) {
m.push_str(&format!("\n## {section}\n"));
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 {
m.push_str(&format!(
"- **See it** — [in the manual](manual/README.md#{anchor})\n"
));
}
if let Some(why) = &g.why {
m.push_str(&format!("\n{why}\n"));
}
m.push_str(&format!("\n<sub>`{}:{}`</sub>\n", g.file, g.line));
}
}
m
}
/// The application's artefact: a Rust table it displays.
///
/// Generated and *committed*, not built in a `build.rs`. Two reasons. A
/// generated file that only exists in `OUT_DIR` cannot be read in review, and
/// this one is user-facing text that should be reviewed like any other. And the
/// scanner walks the whole source tree, which is not something to do on every
/// incremental build of the crate being scanned.
///
/// `why` is deliberately not carried across. It is the argument for the design
/// and belongs in the document; putting it on a phone-sized help sheet would
/// bury the one line the user opened the sheet to read.
pub fn render_rust(gestures: &[Gesture]) -> String {
let mut r = String::new();
r.push_str("// @generated by `cargo run -p traceability -- gestures`. Do not edit.\n");
r.push_str("//\n");
r.push_str("// TRACES: FR-UI-4\n");
r.push_str("//! The gesture vocabulary, as the application states it.\n");
r.push_str("//!\n");
r.push_str("//! Extracted from the tagged comments beside the code that implements each\n");
r.push_str("//! one, so the help sheet cannot describe a gesture the application does not\n");
r.push_str("//! have. Edit the comment beside the implementation, then regenerate with\n");
r.push_str("//! `cargo run -p traceability -- gestures`.\n\n");
r.push_str("/// One documented interaction, as drawn.\n");
r.push_str("#[derive(Debug, Clone, Copy, PartialEq, Eq)]\n");
r.push_str("pub struct Gesture {\n");
r.push_str(" pub title: &'static str,\n");
r.push_str(" /// The place it applies, and the heading it is grouped under.\n");
r.push_str(" pub section: &'static str,\n");
r.push_str(" /// Empty where the gesture has no counterpart in that modality.\n");
r.push_str(" pub touch: &'static str,\n");
r.push_str(" pub pointer: &'static str,\n");
r.push_str(" pub keys: &'static str,\n");
r.push_str(" /// The manual section that shows it, a heading anchor; empty for none.\n");
r.push_str(" pub manual: &'static str,\n");
r.push_str("}\n\n");
r.push_str("/// Every gesture, in the order the sections were first seen in the source.\n");
r.push_str("pub const GESTURES: &[Gesture] = &[\n");
for (_, list) in by_section(gestures) {
for g in list {
r.push_str(" Gesture {\n");
r.push_str(&format!(" title: {},\n", quote(&g.title)));
r.push_str(&format!(" section: {},\n", quote(&g.section)));
r.push_str(&format!(
" touch: {},\n",
quote(g.touch.as_deref().unwrap_or(""))
));
r.push_str(&format!(
" pointer: {},\n",
quote(g.pointer.as_deref().unwrap_or(""))
));
r.push_str(&format!(
" keys: {},\n",
quote(&crate::chord::render_keys(
g.keys.as_deref().unwrap_or(""),
false
))
));
r.push_str(&format!(
" manual: {},\n",
quote(g.manual.as_deref().unwrap_or(""))
));
r.push_str(" },\n");
}
}
r.push_str("];\n");
r
}
/// A Rust string literal for arbitrary gesture text.
///
/// The text is prose written by hand and routinely contains quotation marks —
/// `"Select to…", then tap the last photograph` — so escaping is not optional.
fn quote(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
_ => out.push(c),
}
}
out.push('"');
out
}
#[cfg(test)]
mod tests {
use super::*;
/// The shape every real tag is written in.
const SAMPLE: &str = "\
// GESTURE: Select a range
// where: Library grid
// touch: \"Select to…\", then tap the last photograph
// pointer: Shift-click
// why: A finger sweep runs out at the edge of the
// screen.
property <bool> ranging: false;
";
#[test]
fn a_block_parses_every_field() {
let (g, p) = extract_from_text(SAMPLE, "library.slint");
assert!(p.is_empty(), "{p:?}");
assert_eq!(g.len(), 1);
assert_eq!(g[0].title, "Select a range");
assert_eq!(g[0].section, "Library grid");
assert_eq!(
g[0].touch.as_deref(),
Some("\"Select to…\", then tap the last photograph")
);
assert_eq!(g[0].pointer.as_deref(), Some("Shift-click"));
assert_eq!(g[0].line, 1);
}
/// The source wraps where the file wraps; the sentence does not.
#[test]
fn a_continuation_joins_with_one_space() {
let (g, _) = extract_from_text(SAMPLE, "x.slint");
assert_eq!(
g[0].why.as_deref(),
Some("A finger sweep runs out at the edge of the screen.")
);
}
/// A colon in prose is not a new field, or `why` could never contain one.
#[test]
fn a_colon_inside_a_continuation_is_not_a_key() {
let text = "\
// GESTURE: Hold to select
// where: Library grid
// touch: Press and hold
// why: The platform convention: every gallery on the device
// opens a selection this way.
";
let (g, p) = extract_from_text(text, "x.rs");
assert!(p.is_empty(), "{p:?}");
assert_eq!(
g[0].why.as_deref(),
Some(
"The platform convention: every gallery on the device opens a selection this way."
)
);
}
/// A typo must not quietly cost a gesture its desktop half.
#[test]
fn an_unknown_key_is_reported() {
let text = "\
// GESTURE: Open a photograph
// where: Library grid
// touch: Tap it
// pointr: Click it
";
let (_, p) = extract_from_text(text, "x.rs");
assert_eq!(p.len(), 1, "{p:?}");
assert!(p[0].what.contains("pointr"), "{}", p[0].what);
}
/// A gesture nobody can perform is a documentation bug, not a gesture.
#[test]
fn a_gesture_with_no_modality_is_reported() {
let text = "// GESTURE: Do a thing\n// where: Somewhere\n";
let (_, p) = extract_from_text(text, "x.rs");
assert!(p.iter().any(|x| x.what.contains("no modality")), "{p:?}");
}
#[test]
fn a_gesture_with_no_place_is_reported() {
let text = "// GESTURE: Do a thing\n// touch: Tap\n";
let (_, p) = extract_from_text(text, "x.rs");
assert!(p.iter().any(|x| x.what.contains("`where:`")), "{p:?}");
}
/// The tag inside this crate's own doc comments and test fixtures must not
/// become a gesture in the user's help sheet.
#[test]
fn a_tag_inside_a_string_literal_is_not_a_gesture() {
let text = "let s = \"GESTURE: not a real one\";\n";
let (g, p) = extract_from_text(text, "x.rs");
assert!(g.is_empty(), "{g:?}");
assert!(p.is_empty());
}
/// Prose *about* the vocabulary is not a member of it. The gate caught this
/// exact sentence in `dr-ui`'s module list, where a comment explaining
/// where the generated table comes from named the tag in passing.
#[test]
fn a_sentence_that_mentions_the_tag_is_not_a_gesture() {
let text = "\
// Generated from the `GESTURE:` comments beside the code that implements
// each one — see `tools/traceability`.
mod gesture_book;
";
let (g, p) = extract_from_text(text, "lib.rs");
assert!(g.is_empty(), "{g:?}");
assert!(p.is_empty(), "{p:?}");
}
/// A blank comment line ends the block, so ordinary prose beneath a gesture
/// is not swallowed into its `why`.
#[test]
fn a_blank_comment_line_ends_the_block() {
let text = "\
// GESTURE: Tap to open
// where: Library grid
// touch: Tap a photograph
//
// This paragraph is about something else entirely.
";
let (g, _) = extract_from_text(text, "x.rs");
assert_eq!(g[0].touch.as_deref(), Some("Tap a photograph"));
assert!(g[0].why.is_none(), "{:?}", g[0].why);
}
/// A doc comment is the natural place for a gesture on the function that
/// implements it, and must not need a different tag.
#[test]
fn a_doc_comment_carries_a_gesture_too() {
let text = "\
/// GESTURE: Pinch the grid
/// where: Library grid
/// touch: Pinch to resize the thumbnails
pub fn zoom() {}
";
let (g, p) = extract_from_text(text, "x.rs");
assert!(p.is_empty(), "{p:?}");
assert_eq!(g[0].title, "Pinch the grid");
}
/// Two blocks back to back, which is how a screen's gestures get listed.
#[test]
fn one_block_does_not_swallow_the_next() {
let text = "\
// GESTURE: First
// where: Grid
// touch: Tap
// GESTURE: Second
// where: Grid
// touch: Hold
";
let (g, p) = extract_from_text(text, "x.rs");
assert!(p.is_empty(), "{p:?}");
assert_eq!(g.len(), 2, "{g:?}");
assert_eq!(g[0].touch.as_deref(), Some("Tap"));
assert_eq!(g[1].title, "Second");
}
/// `manual:` is read, with or without its `#`, and a section the manual
/// does not have is a problem rather than a link to the top of the page.
#[test]
fn a_manual_anchor_must_name_a_section_the_manual_has() {
let text = "\
// GESTURE: Zoom
// where: Develop
// touch: Pinch
// manual: #looking-closer
// GESTURE: Pan
// where: Develop
// touch: Drag
// manual: looking-further
";
let (g, p) = extract_from_text(text, "x.slint");
assert!(p.is_empty(), "{p:?}");
assert_eq!(g[0].manual.as_deref(), Some("looking-closer"));
let anchors = crate::manual::anchors("## Looking closer\n");
let problems = check_manual(&g, &anchors);
assert_eq!(problems.len(), 1, "{problems:?}");
assert!(problems[0].what.contains("looking-further"));
assert_eq!(problems[0].line, 5);
}
#[test]
fn a_manual_anchor_is_linked_from_the_document_and_carried_in_the_table() {
let text = "// GESTURE: Zoom\n// where: Develop\n// touch: Pinch\n// manual: looking-closer\n";
let (g, _) = extract_from_text(text, "x.slint");
assert!(render_markdown(&g).contains("(manual/README.md#looking-closer)"));
assert!(render_rust(&g).contains("manual: \"looking-closer\","));
}
#[test]
fn sections_keep_the_order_they_were_first_seen() {
let text = "\
// GESTURE: B
// where: Second
// touch: x
// GESTURE: A
// where: First
// touch: x
// GESTURE: C
// where: Second
// touch: x
";
let (g, _) = extract_from_text(text, "x.rs");
let grouped = by_section(&g);
assert_eq!(grouped[0].0, "Second");
assert_eq!(grouped[0].1.len(), 2);
assert_eq!(grouped[1].0, "First");
}
}