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
+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 = "\