Files
DarkRoom/tools/traceability/src/gestures.rs
T
dtourolleandClaude Opus 5 091306f736
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Desktop (Linux) (push) Failing after 1h16m50s
Build and test / Layer separation (push) Successful in 39s
Traceability / Requirement traces (push) Successful in 50s
Build and test / Android (aarch64) (push) Successful in 22m27s
Gate the gesture vocabulary the way the matrix is gated
Three holes, all found by the gate catching itself out.

**Prose that mentions the tag was read as a tag.** `dr-ui`'s module list
carries a comment saying where the generated table comes from, and it
names `GESTURE:` in passing; the scan extracted that sentence fragment as
a gesture with no place and no way to perform it. A tag must now *open*
its comment. A line that merely mentions it is describing the mechanism,
not declaring a member of it, and position is the only thing that tells
the two apart — which also makes the string-literal guard fall out for
free rather than being a special case.

**Neither artefact was regenerated on commit.** They cite line numbers,
so they go stale on anything that moves a line — the sheet commit made
the document wrong about every gesture in `library.slint` without
touching a single one. The pre-commit hook that already keeps the matrix
in step now keeps these too, and unlike the matrix it *fails* rather than
shrugging when the scan does: a matrix that will not build leaves a stale
one in place, where a malformed gesture block means a user about to be
told the wrong thing.

**CI did not check them at all.** It does now, blocking. The matrix is
read; the gesture table is *shown to somebody using the application*, and
a stale one tells them to perform a gesture that no longer exists — from
which they will conclude the application is broken rather than the page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 00:51:13 +02:00

613 lines
23 KiB
Rust

//! TRACES: FR-UI-4 | NFR-OPS-1
//! 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.
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>,
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"];
/// 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"),
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()))
}
/// 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() {
m.push_str(&format!("- **{modality}** — {how}\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("}\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(g.keys.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");
}
#[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");
}
}