Let a gesture name the manual section that shows it

The help sheet says which move does a thing, and the manual has a
picture of the thing being done, but nothing joined the two: a user
reading "Pinch it with two fingers" had no way from there to the GIF of
it.

A GESTURE tag takes an optional `manual:` field naming a heading of
docs/manual/README.md by its anchor. The scan checks every one against
the anchors the bundled page is rendered with and fails when the manual
has no such heading, so renaming a section cannot leave the sheet
linking to the top of the page; gestures-check carries the same failure
into CI. The anchor goes into gesture_book.rs as a new field, and into
docs/gestures.md as a "See it" link to manual/README.md#anchor. The help
sheet draws a "See it" button beside the title of each gesture that has
one, which opens the bundled manual at that section.

The field is additive: a tag without it is unchanged, and no gesture
carries one yet.
This commit is contained in:
2026-09-24 23:24:17 -04:00
parent 352e59498b
commit 7591738c73
7 changed files with 178 additions and 12 deletions
File diff suppressed because one or more lines are too long
+79 -1
View File
@@ -57,6 +57,15 @@
//! 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;
@@ -79,6 +88,8 @@ pub struct Gesture {
/// 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,
}
@@ -105,7 +116,7 @@ impl Gesture {
}
/// A key that may appear in a gesture block.
const KEYS: &[&str] = &["where", "touch", "pointer", "keys", "why"];
const KEYS: &[&str] = &["where", "touch", "pointer", "keys", "why", "manual"];
/// Something wrong with a tag, reported rather than dropped.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -211,6 +222,7 @@ pub fn extract_from_text(text: &str, path: &str) -> (Vec<Gesture>, Vec<GesturePr
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,
};
@@ -285,6 +297,29 @@ fn split_key(body: &str) -> Option<(String, String)> {
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>)> {
@@ -339,6 +374,11 @@ pub fn render_markdown(gestures: &[Gesture]) -> String {
for (modality, how) in g.routes() {
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"));
}
@@ -381,6 +421,8 @@ pub fn render_rust(gestures: &[Gesture]) -> String {
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");
@@ -402,6 +444,10 @@ pub fn render_rust(gestures: &[Gesture]) -> String {
" keys: {},\n",
quote(g.keys.as_deref().unwrap_or(""))
));
r.push_str(&format!(
" manual: {},\n",
quote(g.manual.as_deref().unwrap_or(""))
));
r.push_str(" },\n");
}
}
@@ -590,6 +636,38 @@ pub fn zoom() {}
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 = "\
+9
View File
@@ -161,6 +161,15 @@ fn run_gestures(base: &Path, check: bool) -> Result<()> {
problems.extend(p);
}
// A `manual:` must name a section the manual has, checked against the
// same anchors the bundled page is rendered with.
let manual_source = std::fs::read_to_string(base.join(MANUAL_SOURCE))
.with_context(|| format!("reading {MANUAL_SOURCE}"))?;
problems.extend(gestures::check_manual(
&found,
&manual::anchors(&manual_source),
));
println!("files scanned {}", files.len());
println!("gestures found {}", found.len());
println!("places {}", gestures::by_section(&found).len());
+53
View File
@@ -18,6 +18,8 @@ pub struct Gesture {
pub touch: &'static str,
pub pointer: &'static str,
pub keys: &'static str,
/// The manual section that shows it, a heading anchor; empty for none.
pub manual: &'static str,
}
/// Every gesture, in the order the sections were first seen in the source.
@@ -28,6 +30,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Press \"pick\" in the group's heading, then tap something neutral in the picture",
pointer: "Press \"pick\", then click something neutral",
keys: "",
manual: "",
},
Gesture {
title: "Magnify the photograph by any amount",
@@ -35,6 +38,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Pinch it with two fingers",
pointer: "The scroll wheel over it",
keys: "",
manual: "",
},
Gesture {
title: "Move a magnified photograph about",
@@ -42,6 +46,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Drag it",
pointer: "Drag it",
keys: "",
manual: "",
},
Gesture {
title: "Paint a mask by hand",
@@ -49,6 +54,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Choose Paint or Erase, then drag on the photograph",
pointer: "Choose Paint or Erase, then drag",
keys: "",
manual: "",
},
Gesture {
title: "Take back the last change",
@@ -56,6 +62,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Tap the step above the current one in the History list",
pointer: "Click it, or press Undo in the History header",
keys: "Ctrl+Z",
manual: "",
},
Gesture {
title: "Do it again after taking it back",
@@ -63,6 +70,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Tap the step below the current one in the History list",
pointer: "Click it, or press Redo in the History header",
keys: "Ctrl+Shift+Z",
manual: "",
},
Gesture {
title: "Copy the settings from this photograph",
@@ -70,6 +78,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Press Copy in the top bar",
pointer: "Press Copy in the top bar",
keys: "Ctrl+C",
manual: "",
},
Gesture {
title: "Paste the settings onto this photograph",
@@ -77,6 +86,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Press Paste in the top bar",
pointer: "Press Paste in the top bar",
keys: "Ctrl+V",
manual: "",
},
Gesture {
title: "Choose which kinds of edit a copy carries",
@@ -84,6 +94,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Open Presets and toggle the kinds",
pointer: "Open Presets and toggle the kinds",
keys: "Ctrl+Shift+C, which offers Copy beside them",
manual: "",
},
Gesture {
title: "Export this photograph as the last one was",
@@ -91,6 +102,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Press Export in the top bar",
pointer: "Press Export in the top bar",
keys: "Ctrl+Shift+E",
manual: "",
},
Gesture {
title: "Choose how to export, then export",
@@ -98,6 +110,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Open Settings, then Export defaults",
pointer: "Open Settings, then Export defaults",
keys: "Ctrl+E",
manual: "",
},
Gesture {
title: "Change which group of adjustments is on screen",
@@ -105,6 +118,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Tap a group in the rail down the left",
pointer: "Click a group in the strip above the develop column",
keys: "[ and ] step through them, wrapping round through \"everything\"",
manual: "",
},
Gesture {
title: "Look at the photograph at 1:1",
@@ -112,6 +126,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Double-tap the photograph",
pointer: "Double-click it, or press the zoom readout floating over the canvas",
keys: "Z",
manual: "",
},
Gesture {
title: "Give this photograph a colour label",
@@ -119,6 +134,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Tap Label in the top bar, then a colour",
pointer: "Click Label in the top bar, then a colour",
keys: "6 red, 7 yellow, 8 green, 9 blue; the same key again takes it off",
manual: "",
},
Gesture {
title: "Move to the next or previous photograph",
@@ -126,6 +142,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Tap a frame in the roll along the foot of the canvas",
pointer: "Click a frame in the roll",
keys: "Right arrow, D or space for the next; left arrow or A for the one before",
manual: "",
},
Gesture {
title: "See the photograph before you edited it",
@@ -133,6 +150,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Press and hold \"Before\"",
pointer: "Press and hold \"Before\"",
keys: "Hold \\",
manual: "",
},
Gesture {
title: "Put one control back to its default",
@@ -140,6 +158,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Double-tap its track",
pointer: "Double-click its track, or right-click it",
keys: "R, for the control last moved",
manual: "",
},
Gesture {
title: "See the photograph as a snapshot had it",
@@ -147,6 +166,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Press and hold the eye beside the snapshot",
pointer: "Press and hold the eye beside the snapshot",
keys: "",
manual: "",
},
Gesture {
title: "Keep the photograph as it is now, under a name",
@@ -154,6 +174,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Type a name in the History panel and press Snapshot",
pointer: "Type a name in the History panel and press Snapshot",
keys: "",
manual: "",
},
Gesture {
title: "Show or hide one mask layer",
@@ -161,6 +182,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Tap the ring at the head of its row",
pointer: "Click the ring at the head of its row",
keys: "H, for the selected layer history step, unlike holding \"Before\" — the layer really is off until it is switched back on.",
manual: "",
},
Gesture {
title: "Show or hide one mask on the photograph",
@@ -168,6 +190,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Tap the eye on its row",
pointer: "Click the eye on its row",
keys: "",
manual: "",
},
Gesture {
title: "Change how a part joins its mask",
@@ -175,6 +198,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Tap the + / − / ∩ chip on the part's row",
pointer: "Click the + / − / ∩ chip on the part's row",
keys: "",
manual: "",
},
Gesture {
title: "Leave one part out of a mask, and put it back",
@@ -182,6 +206,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Tap the ring on the part's row",
pointer: "Click the ring on the part's row",
keys: "",
manual: "",
},
Gesture {
title: "Pick a collection up to rearrange the tree",
@@ -189,6 +214,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Press and hold it until it lifts, then drag it",
pointer: "Drag it, or hold it until it lifts and then drag",
keys: "",
manual: "",
},
Gesture {
title: "Act on a collection — rename, nest, un-nest, delete",
@@ -196,6 +222,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Press and hold the collection, then let go without moving",
pointer: "Right-click it",
keys: "",
manual: "",
},
Gesture {
title: "Take a collection back out of the one it is nested in",
@@ -203,6 +230,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Hold it, then drag it onto \"All photographs\" — or let go and choose \"Move to top level\"",
pointer: "Drag it onto \"All photographs\", or right-click it and choose \"Move to top level\"",
keys: "",
manual: "",
},
Gesture {
title: "Rename a collection",
@@ -210,6 +238,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Hold the collection, then \"Rename\"",
pointer: "Double-click its name, or right-click it and choose \"Rename\"",
keys: "",
manual: "",
},
Gesture {
title: "Pull a face out of the wrong person",
@@ -217,6 +246,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Tap the faces that do not belong, then \"Split off\"",
pointer: "Click the faces that do not belong, then \"Split off\"",
keys: "",
manual: "",
},
Gesture {
title: "Rule on a suggested face",
@@ -224,6 +254,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Tick to confirm it, cross to reject it",
pointer: "Tick to confirm it, cross to reject it",
keys: "",
manual: "",
},
Gesture {
title: "See a person's photographs",
@@ -231,6 +262,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Choose them in the rail, then \"Show photos\"",
pointer: "Choose them in the rail, then \"Show photos\"",
keys: "",
manual: "",
},
Gesture {
title: "Change how faces are grouped",
@@ -238,6 +270,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "\"Grouping…\", move the dials, then Regroup",
pointer: "\"Grouping…\", move the dials, then Regroup",
keys: "",
manual: "",
},
Gesture {
title: "Start selecting several photographs",
@@ -245,6 +278,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Press and hold a photograph, or press Select in the header",
pointer: "Ctrl-click, or press Select in the header",
keys: "",
manual: "",
},
Gesture {
title: "Add or remove one photograph",
@@ -252,6 +286,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "While selecting, tap it",
pointer: "Ctrl-click it",
keys: "",
manual: "",
},
Gesture {
title: "Leave selecting",
@@ -259,6 +294,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Press Done in the header",
pointer: "Press Done in the header",
keys: "Escape",
manual: "",
},
Gesture {
title: "Pick a photograph up to drag it",
@@ -266,6 +302,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Press and hold it until a ring opens around it, then drag",
pointer: "Drag it",
keys: "",
manual: "",
},
Gesture {
title: "Select a range",
@@ -273,6 +310,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "While selecting, press \"Select to…\", then tap the last photograph of the run",
pointer: "Shift-click the last photograph of the run",
keys: "",
manual: "",
},
Gesture {
title: "Take the blinks out of a burst",
@@ -280,6 +318,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Narrow to a person, then tap \"Eyes open\" beside their name on the filter bar",
pointer: "Narrow to a person, then click \"Eyes open\" beside their name on the filter bar",
keys: "",
manual: "",
},
Gesture {
title: "Find photographs with two people in them",
@@ -287,6 +326,7 @@ pub const GESTURES: &[Gesture] = &[
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: "",
manual: "",
},
Gesture {
title: "Show only photographs with one colour label",
@@ -294,6 +334,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Tap its chip in the filter bar",
pointer: "Click its chip in the filter bar",
keys: "",
manual: "",
},
Gesture {
title: "Export the selection as the last export was",
@@ -301,6 +342,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Select them, then Export in the selection bar",
pointer: "Select them, then Export in the selection bar",
keys: "Ctrl+Shift+E, or Ctrl+E to see the export settings first",
manual: "",
},
Gesture {
title: "Paste copied settings onto the selection",
@@ -308,6 +350,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Select them, then \"Paste to N\" in the selection bar",
pointer: "Select them, then \"Paste to N\"",
keys: "Ctrl+V",
manual: "",
},
Gesture {
title: "Show only photographs with some number of stars",
@@ -315,6 +358,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Tap a star chip in the filter bar",
pointer: "Click a star chip in the filter bar",
keys: "Hold F and tap a digit for exactly that many stars, or two digits for everything between them; tap F alone to show every rating again",
manual: "",
},
Gesture {
title: "Give photographs a colour label",
@@ -322,6 +366,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Select them, then Label in the selection bar and tap a colour",
pointer: "Select them, then Label in the selection bar and click a colour",
keys: "6 red, 7 yellow, 8 green, 9 blue, with the pointer over it or on the selection; the same key again takes the label off",
manual: "",
},
Gesture {
title: "Resize the thumbnails",
@@ -329,6 +374,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Pinch the grid with two fingers",
pointer: "Ctrl and the scroll wheel",
keys: "",
manual: "",
},
Gesture {
title: "File photographs in a collection",
@@ -336,6 +382,7 @@ pub const GESTURES: &[Gesture] = &[
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: "",
manual: "",
},
Gesture {
title: "Open a photograph",
@@ -343,6 +390,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Tap it — a single tap, any length",
pointer: "Click it",
keys: "",
manual: "",
},
Gesture {
title: "Rate a photograph without opening it",
@@ -350,6 +398,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Tap a star on the cell",
pointer: "Hover the cell, then click a star",
keys: "0 to 5 with the pointer over it, or on the selection",
manual: "",
},
Gesture {
title: "Choose the frame a folded burst shows",
@@ -357,6 +406,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Open the burst, then tap the ring on the frame you want",
pointer: "Open the burst, then click the ring on the frame you want",
keys: "",
manual: "",
},
Gesture {
title: "Drop the selection but keep selecting",
@@ -364,6 +414,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "Press Clear in the selection strip",
pointer: "Press Clear in the selection strip",
keys: "",
manual: "",
},
Gesture {
title: "Select everything the grid is showing",
@@ -371,6 +422,7 @@ pub const GESTURES: &[Gesture] = &[
touch: "While selecting, press \"Select all\"",
pointer: "While selecting, press \"Select all\"",
keys: "Ctrl+A",
manual: "",
},
Gesture {
title: "Take photographs out of a collection",
@@ -378,5 +430,6 @@ pub const GESTURES: &[Gesture] = &[
touch: "Select them, then \"Collections…\" in the selection bar",
pointer: "Select them, then \"Collections…\" in the selection bar",
keys: "",
manual: "",
},
];
+5
View File
@@ -38,6 +38,9 @@ pub struct Row {
pub touch: String,
pub pointer: String,
pub keys: String,
/// The manual section that shows the gesture, as a heading anchor; empty
/// where there is none, and the sheet then offers no "See it".
pub manual: String,
}
/// Build the sheet's rows from the generated table.
@@ -67,6 +70,7 @@ fn rows_from(gestures: &[Gesture]) -> Vec<Row> {
touch: g.touch.to_string(),
pointer: g.pointer.to_string(),
keys: g.keys.to_string(),
manual: g.manual.to_string(),
});
}
out
@@ -83,6 +87,7 @@ mod tests {
touch: "Tap",
pointer: "",
keys: "",
manual: "",
}
}
+1
View File
@@ -195,6 +195,7 @@ pub fn wire<F>(
touch: r.touch.into(),
pointer: r.pointer.into(),
keys: r.keys.into(),
manual: r.manual.into(),
})
.collect::<Vec<_>>(),
)));
+26 -6
View File
@@ -38,6 +38,10 @@ export struct GestureRow {
touch: string,
pointer: string,
keys: string,
// The manual section that shows the gesture being made, as a heading
// anchor; empty where the manual has none. Checked against the manual by
// the generator, so a non-empty one always lands on a heading.
manual: string,
}
// One route: how a modality performs the gesture.
@@ -148,12 +152,28 @@ export component GestureSheet inherits Rectangle {
font-weight: 700;
}
if r.heading == "": Text {
text: r.title;
color: Theme.ink;
font-size: Theme.text;
font-weight: 600;
wrap: word-wrap;
// The title, and beside it "See it" where the manual
// shows the gesture. On the title's line rather than
// under the routes, so it is found while reading the
// title — the moment of "what does that look like?".
if r.heading == "": HorizontalLayout {
spacing: Theme.gap;
Text {
text: r.title;
color: Theme.ink;
font-size: Theme.text;
font-weight: 600;
wrap: word-wrap;
horizontal-stretch: 1;
vertical-alignment: center;
}
if r.manual != "": Button {
text: "See it";
accessible-label: "See " + r.title + " in the manual";
clicked => { root.open-manual(r.manual); }
}
}
if r.touch != "": Route {