Extract the gesture vocabulary from the code that implements it

Every gesture the application has was documented in the comment beside
the `TouchArea` that implements it. Excellent comments, and unreachable
by anyone not reading the source — which is the FR-UI-4 failure in a
different costume: a gesture nobody can find is a feature only its author
knows about.

Writing them out again in a hand-kept help page is the failure this
avoids. Two descriptions of one gesture drift, and it is always the prose
that drifts: the code is exercised every time somebody uses the
application and the page is exercised never. A help screen confidently
describing a double tap the grid stopped honouring last week is worse
than no help screen — and the grid did stop honouring one, in the commit
before this.

So the comment beside the implementation stays the only copy, and a
`GESTURE:` block beside it is scanned into two artefacts: `docs/gestures.md`
for a reader, and a Rust table for the application to draw a help sheet
from. Both committed, both gated, so neither can quietly stop describing
the code.

It lives in the traceability crate because it is the same operation on
the same input — walk the tree, pull structured tags out of comments,
render, fail if the committed artefact has moved. Only the vocabulary is
new. It scans `ui` and `apps` alone: a gesture needs an interface to be
performed on, and excluding `tools` is also what stops the scanner
extracting its own worked examples as broken gestures.

Fifteen gestures so far, across the library grid and the People screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 00:09:49 +02:00
co-authored by Claude Opus 5
parent 20c368d3fc
commit 31a3580f9d
6 changed files with 1012 additions and 0 deletions
+147
View File
@@ -0,0 +1,147 @@
# How the application is driven
<!-- GENERATED FILE — do not edit by hand. -->
<!-- Regenerate: cargo run -p traceability -- gestures -->
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.
15 gestures, in 2 places.
## People
### Pull a face out of the wrong person
- **Touch** — Tap the faces that do not belong, then "Split off"
- **Pointer** — Click the faces that do not belong, then "Split off"
Grouping over-merges on siblings, on parents and children, and on the same person a decade apart, so splitting is as prominent as merging. A tool that can only merge makes its own errors permanent.
<sub>`ui/dr-ui/ui/identity.slint:130`</sub>
### Rule on a suggested face
- **Touch** — Tick to confirm it, cross to reject it
- **Pointer** — Tick to confirm it, cross to reject it
A face is either the system's guess or the user's judgement, and the two are never conflated. A rejection is remembered, so the face is not suggested for that person again.
<sub>`ui/dr-ui/ui/identity.slint:150`</sub>
### See a person's photographs
- **Touch** — Choose them in the rail, then "Show photos"
- **Pointer** — Choose them in the rail, then "Show photos"
This is the point of having identified anybody. Without it the screen is a filing cabinet with no drawer handles.
<sub>`ui/dr-ui/ui/identity.slint:545`</sub>
### Change how faces are grouped
- **Touch** — "Grouping…", move the dials, then Regroup
- **Pointer** — "Grouping…", move the dials, then Regroup
The right match confidence is a property of your library, not of the model. "What would this do?" answers for this library without writing anything; names, confirmations and the groups you have set aside are kept whatever the dials say.
<sub>`ui/dr-ui/ui/identity.slint:582`</sub>
## Library grid
### Start selecting several photographs
- **Touch** — Press and hold a photograph, or press Select in the header
- **Pointer** — Ctrl-click, or press Select in the header
Touch has no ctrl, so without a mode there is no way to select a second photograph — the first tap would open it. The hold is the fast way in and the button is the one that can be found.
<sub>`ui/dr-ui/ui/library.slint:1259`</sub>
### Add or remove one photograph
- **Touch** — While selecting, tap it
- **Pointer** — Ctrl-click it
While selecting, a tap never opens. That is the whole point of the mode: one meaning per gesture at a time. Press Done to get tap-to-open back.
<sub>`ui/dr-ui/ui/library.slint:1268`</sub>
### Leave selecting
- **Touch** — Press Done in the header
- **Pointer** — Press Done in the header
- **Keyboard** — Escape
<sub>`ui/dr-ui/ui/library.slint:1276`</sub>
### Select a range
- **Touch** — While selecting, press "Select to…", then tap the last photograph of the run
- **Pointer** — Shift-click the last photograph of the run
This replaced a double tap, which had no visible state and could take forty photographs by accident. The run is resolved by the catalog rather than by what is on screen, so the grid can scroll between the two taps — the ranges that hurt on a tablet are longer than a screenful, which is exactly where a finger sweep runs out.
<sub>`ui/dr-ui/ui/library.slint:1337`</sub>
### Find photographs with two people in them
- **Touch** — Open the People chip on the filter bar, tap each name, then switch the chip beside them to "all of them"
- **Pointer** — Open the People chip on the filter bar, click each name, then switch the chip beside them to "all of them"
"Any of them" is a union and "all of them" is an intersection. The tray is where both terms and the choice between them live, because a filter belongs on the filter bar.
<sub>`ui/dr-ui/ui/library.slint:2076`</sub>
### Drop the selection but keep selecting
- **Touch** — Press Clear in the selection strip
- **Pointer** — Press Clear in the selection strip
Distinct from Done, which leaves the mode entirely. Clearing keeps it, so the next selection can start straight away.
<sub>`ui/dr-ui/ui/library.slint:2367`</sub>
### Select everything the grid is showing
- **Touch** — While selecting, press "Select all"
- **Pointer** — While selecting, press "Select all"
A scoped grid of two hundred frames is two hundred taps otherwise, and "all of them, except those three" is a far more common shape than the taps it took to say it.
<sub>`ui/dr-ui/ui/library.slint:2384`</sub>
### Resize the thumbnails
- **Touch** — Pinch the grid with two fingers
- **Pointer** — Ctrl and the scroll wheel
There is no wheel on a tablet, so without the pinch the cell size could only be changed by a control a finger cannot reach.
<sub>`ui/dr-ui/ui/library.slint:2825`</sub>
### File photographs in a collection
- **Touch** — Drag a photograph — or a whole selection — onto a collection in the sidebar. Starting a drag stops the press becoming a hold, so it cannot leave you in selection mode.
- **Pointer** — Drag a photograph — or a whole selection — onto a collection in the sidebar
The selection is what the drag carries, which is why selecting several is worth the mode: forty photographs file in one gesture.
<sub>`ui/dr-ui/ui/library.slint:2974`</sub>
### Open a photograph
- **Touch** — Tap it — a single tap, any length
- **Pointer** — Click it
A tap opens; a tap that *moved* does not. Travel is what separates a deliberate tap from a hand brushing past, and it is the only thing that does: the two are the same length. An earlier version required the finger to dwell 120 ms instead, and that rejected ordinary taps — a real tap is often quicker than a brush.
<sub>`ui/dr-ui/ui/library.slint:3204`</sub>
### Rate a photograph without opening it
- **Touch** — Tap a star on the cell
- **Pointer** — Hover the cell, then click a star
- **Keyboard** — 0 to 5 on the selection
A star has to take the press without it also reaching the cell, or every rating throws the user into develop.
<sub>`ui/dr-ui/ui/library.slint:3316`</sub>
+596
View File
@@ -0,0 +1,596 @@
//! 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.
//!
//! A block runs from the `GESTURE:` 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() {
let Some(pos) = lines[i].find("GESTURE:") else {
i += 1;
continue;
};
// Only in a comment. A `GESTURE:` inside a string literal — this very
// file's doc comment shows the tag, and so does the scanner's own test
// fixture — is not a tag.
if !is_comment(lines[i]) {
i += 1;
continue;
}
let start = i;
let title = lines[i][pos + "GESTURE:".len()..].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)
}
/// Whether a line is a `//` comment and nothing else of substance before it.
///
/// Deliberately strict about what precedes the marker: `let s = "// GESTURE:"`
/// has code in front of it and is not a comment line. Trailing comments after
/// code are not a place gesture blocks are written, so refusing them costs
/// nothing and keeps string literals out of the document.
fn is_comment(line: &str) -> bool {
let t = line.trim_start();
t.starts_with("//")
}
/// 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());
}
/// 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");
}
}
+2
View File
@@ -25,6 +25,8 @@ use std::path::{Path, PathBuf};
use serde::Serialize;
pub mod gestures;
/// Requirement ID prefixes that participate in coverage.
///
/// Test identifiers (UT, IT) are a separate taxonomy: they are *evidence* for
+105
View File
@@ -4,8 +4,13 @@
//! traces report # write docs/traceability.md
//! traces json # machine-readable, to stdout
//! traces check # gate: non-zero exit on failure
//! traces gestures # write docs/gestures.md and the app's gesture table
//! traces gestures-check # gate: non-zero exit if either has drifted
//! ```
//!
//! The gesture half scans a different tag out of the same files — see
//! [`traceability::gestures`] for what it is and why it lives here.
//!
//! The gate fails hard on a *misconfigured run* — zero requirements parsed, or
//! zero source files scanned — rather than reporting a plausible-looking 0%.
//! A gate that cannot distinguish "nothing is tagged" from "I read nothing" is
@@ -32,10 +37,36 @@ const SOURCE_ROOTS: &[&str] = &["core", "ui", "platform", "apps", "tools"];
/// Structural failures below are unconditional and do not depend on this.
const MIN_COVERAGE: f64 = 0.0;
/// Where the extracted gesture vocabulary is written.
///
/// Two artefacts from one scan: the document a person reads, and the table the
/// application draws its help sheet from. Both committed, both gated, so
/// neither can quietly stop describing the code.
const GESTURE_DOC: &str = "docs/gestures.md";
const GESTURE_TABLE: &str = "ui/dr-ui/src/gesture_book.rs";
/// Directories scanned for gestures.
///
/// Narrower than `SOURCE_ROOTS`, and not merely as an optimisation. A gesture
/// is something a *user* performs, so it can only be declared where there is an
/// interface to perform it on: `ui` and the two application shells. `core` has
/// no pointer and no finger.
///
/// Excluding `tools` is what stops this scanner reading its own documentation.
/// The tag has to appear in this crate — in the doc comment that teaches the
/// format, and in the fixtures that test the parser — and every one of those
/// appearances was being extracted as a broken gesture. A scanner that indexes
/// its own examples is a scanner nobody can document.
const GESTURE_ROOTS: &[&str] = &["ui", "apps"];
fn main() -> Result<()> {
let base = repo_root()?;
let mode = std::env::args().nth(1).unwrap_or_else(|| "report".into());
if mode.starts_with("gestures") {
return run_gestures(&base, mode == "gestures-check");
}
let req_path = base.join("docs/requirements.md");
let markdown = std::fs::read_to_string(&req_path)
.with_context(|| format!("reading {}", req_path.display()))?;
@@ -86,6 +117,80 @@ fn main() -> Result<()> {
Ok(())
}
/// Scan the gesture vocabulary, and either write it or check it has not moved.
///
/// Writing and checking share every step but the last, so they are one function
/// with a flag rather than two that could come to disagree about what the
/// artefact should contain — which is the only way a gate like this fails
/// dishonestly.
fn run_gestures(base: &Path, check: bool) -> Result<()> {
let files = collect_sources(base, GESTURE_ROOTS);
if files.is_empty() {
bail!("no source files scanned — misconfigured, not zero gestures");
}
let mut found = Vec::new();
let mut problems = Vec::new();
for file in &files {
let text = std::fs::read_to_string(file).unwrap_or_default();
let rel = file
.strip_prefix(base)
.unwrap_or(file)
.to_string_lossy()
.to_string();
// Never the table this command writes. It quotes the tag in its own
// header, so scanning it fed every generated gesture back in as a
// malformed one — and a generator that consumes its own output cannot
// converge.
if rel == GESTURE_TABLE {
continue;
}
let (g, p) = gestures::extract_from_text(&text, &rel);
found.extend(g);
problems.extend(p);
}
println!("files scanned {}", files.len());
println!("gestures found {}", found.len());
println!("places {}", gestures::by_section(&found).len());
if !problems.is_empty() {
for p in &problems {
println!(" {p}");
}
bail!("{} malformed gesture tag(s)", problems.len());
}
let doc = gestures::render_markdown(&found);
let table = gestures::render_rust(&found);
if check {
// Read back rather than trusting a timestamp: the artefacts are
// committed, and what matters is whether the file in the tree says what
// the source says, however it got there.
let mut stale = Vec::new();
for (path, want) in [(GESTURE_DOC, &doc), (GESTURE_TABLE, &table)] {
let have = std::fs::read_to_string(base.join(path)).unwrap_or_default();
if have != *want {
stale.push(path);
}
}
if !stale.is_empty() {
bail!(
"{} is stale — run: cargo run -p traceability -- gestures",
stale.join(" and ")
);
}
println!("\ngesture gate: PASS");
return Ok(());
}
std::fs::write(base.join(GESTURE_DOC), &doc)?;
std::fs::write(base.join(GESTURE_TABLE), &table)?;
println!("\nwrote {GESTURE_DOC} and {GESTURE_TABLE}");
Ok(())
}
/// Structural checks that fail regardless of the coverage threshold.
fn gate(files: &[PathBuf], defined: &DefinedRequirements, cov: &Coverage) -> Result<()> {
// A run that parsed nothing is misconfigured, not passing.
+130
View File
@@ -0,0 +1,130 @@
// @generated by `cargo run -p traceability -- gestures`. Do not edit.
//
// TRACES: FR-UI-4
//! The gesture vocabulary, as the application states it.
//!
//! Extracted from the tagged comments beside the code that implements each
//! one, so the help sheet cannot describe a gesture the application does not
//! have. Edit the comment beside the implementation, then regenerate with
//! `cargo run -p traceability -- gestures`.
/// One documented interaction, as drawn.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Gesture {
pub title: &'static str,
/// The place it applies, and the heading it is grouped under.
pub section: &'static str,
/// Empty where the gesture has no counterpart in that modality.
pub touch: &'static str,
pub pointer: &'static str,
pub keys: &'static str,
}
/// Every gesture, in the order the sections were first seen in the source.
pub const GESTURES: &[Gesture] = &[
Gesture {
title: "Pull a face out of the wrong person",
section: "People",
touch: "Tap the faces that do not belong, then \"Split off\"",
pointer: "Click the faces that do not belong, then \"Split off\"",
keys: "",
},
Gesture {
title: "Rule on a suggested face",
section: "People",
touch: "Tick to confirm it, cross to reject it",
pointer: "Tick to confirm it, cross to reject it",
keys: "",
},
Gesture {
title: "See a person's photographs",
section: "People",
touch: "Choose them in the rail, then \"Show photos\"",
pointer: "Choose them in the rail, then \"Show photos\"",
keys: "",
},
Gesture {
title: "Change how faces are grouped",
section: "People",
touch: "\"Grouping…\", move the dials, then Regroup",
pointer: "\"Grouping…\", move the dials, then Regroup",
keys: "",
},
Gesture {
title: "Start selecting several photographs",
section: "Library grid",
touch: "Press and hold a photograph, or press Select in the header",
pointer: "Ctrl-click, or press Select in the header",
keys: "",
},
Gesture {
title: "Add or remove one photograph",
section: "Library grid",
touch: "While selecting, tap it",
pointer: "Ctrl-click it",
keys: "",
},
Gesture {
title: "Leave selecting",
section: "Library grid",
touch: "Press Done in the header",
pointer: "Press Done in the header",
keys: "Escape",
},
Gesture {
title: "Select a range",
section: "Library grid",
touch: "While selecting, press \"Select to…\", then tap the last photograph of the run",
pointer: "Shift-click the last photograph of the run",
keys: "",
},
Gesture {
title: "Find photographs with two people in them",
section: "Library grid",
touch: "Open the People chip on the filter bar, tap each name, then switch the chip beside them to \"all of them\"",
pointer: "Open the People chip on the filter bar, click each name, then switch the chip beside them to \"all of them\"",
keys: "",
},
Gesture {
title: "Drop the selection but keep selecting",
section: "Library grid",
touch: "Press Clear in the selection strip",
pointer: "Press Clear in the selection strip",
keys: "",
},
Gesture {
title: "Select everything the grid is showing",
section: "Library grid",
touch: "While selecting, press \"Select all\"",
pointer: "While selecting, press \"Select all\"",
keys: "",
},
Gesture {
title: "Resize the thumbnails",
section: "Library grid",
touch: "Pinch the grid with two fingers",
pointer: "Ctrl and the scroll wheel",
keys: "",
},
Gesture {
title: "File photographs in a collection",
section: "Library grid",
touch: "Drag a photograph — or a whole selection — onto a collection in the sidebar. Starting a drag stops the press becoming a hold, so it cannot leave you in selection mode.",
pointer: "Drag a photograph — or a whole selection — onto a collection in the sidebar",
keys: "",
},
Gesture {
title: "Open a photograph",
section: "Library grid",
touch: "Tap it — a single tap, any length",
pointer: "Click it",
keys: "",
},
Gesture {
title: "Rate a photograph without opening it",
section: "Library grid",
touch: "Tap a star on the cell",
pointer: "Hover the cell, then click a star",
keys: "0 to 5 on the selection",
},
];
+32
View File
@@ -127,6 +127,14 @@ component FaceCell inherits Rectangle {
}
}
// GESTURE: Pull a face out of the wrong person
// where: People
// touch: Tap the faces that do not belong, then "Split off"
// pointer: Click the faces that do not belong, then "Split off"
// why: Grouping over-merges on siblings, on parents and
// children, and on the same person a decade apart, so
// splitting is as prominent as merging. A tool that can
// only merge makes its own errors permanent.
TouchArea {
clicked => { root.toggle-pick(); }
}
@@ -139,6 +147,14 @@ component FaceCell inherits Rectangle {
// Only a suggestion needs ruling on. Once confirmed, the pair
// collapses to the label — there is nothing left to decide, and
// leaving the buttons would invite an accidental un-confirm.
// GESTURE: Rule on a suggested face
// where: People
// touch: Tick to confirm it, cross to reject it
// pointer: Tick to confirm it, cross to reject it
// why: A face is either the system's guess or the user's
// judgement, and the two are never conflated. A
// rejection is remembered, so the face is not suggested
// for that person again.
if !face.confirmed: IconButton {
icon: "check";
clicked => { root.confirm(); }
@@ -526,6 +542,13 @@ export component IdentityScreen inherits Rectangle {
// The way back from a face to the photographs. This is the
// point of having identified anybody, and without it the
// screen is a filing cabinet with no drawer handles.
// GESTURE: See a person's photographs
// where: People
// touch: Choose them in the rail, then "Show photos"
// pointer: Choose them in the rail, then "Show photos"
// why: This is the point of having identified anybody.
// Without it the screen is a filing cabinet with no
// drawer handles.
if root.selected-person >= 0: Button {
text: "Show photos";
clicked => { root.show-photos(root.selected-person, false); }
@@ -556,6 +579,15 @@ export component IdentityScreen inherits Rectangle {
// next to the button that applies them and the rail that shows
// what they did, and a value changed three screens away from
// its effect is a value nobody can tune.
// GESTURE: Change how faces are grouped
// where: People
// touch: "Grouping…", move the dials, then Regroup
// pointer: "Grouping…", move the dials, then Regroup
// why: The right match confidence is a property of your
// library, not of the model. "What would this do?"
// answers for this library without writing
// anything; names, confirmations and the groups you
// have set aside are kept whatever the dials say.
Button {
text: "Grouping…";
active: root.grouping-open;