Put the gesture reference in the application

The document the previous commit generates is for somebody reading the
repository. The person who needs it most is holding a tablet, has just
discovered that a hold does something, and has nowhere to ask what else
does.

So the same scan writes a table the application draws: a "Gestures"
button beside Settings, a sheet with the same scrim and dismissal as the
ones that file and name, and every gesture grouped by where it applies
with its touch, pointer and keyboard routes side by side. Not the `why` —
that is the argument for the design and belongs in the document; on a
phone-sized card it would bury the one line the sheet was opened to read.

The sheet's file knows nothing about what a gesture is. It draws the rows
it is handed, and the rows come from the generated table, because a help
screen with its text typed into it is a second description of one
behaviour — and the second description is always the one that goes stale.
The commit before this deleted a gesture; a hand-kept sheet would still
be describing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 00:30:21 +02:00
co-authored by Claude Opus 5
parent 31a3580f9d
commit 1cd5ab6815
7 changed files with 419 additions and 44 deletions
+131
View File
@@ -0,0 +1,131 @@
//! TRACES: FR-UI-2 | FR-UI-4
//! The gesture help sheet's model.
//!
//! FR-UI-4's rule is that a gesture with no visible counterpart is a feature
//! only its author knows about. Most of the grid's vocabulary now *has* a
//! visible counterpart — Select, "Select to…", Select all are buttons — but
//! knowing that a hold does the same thing faster, or that two fingers resize
//! the thumbnails, still had to be discovered by accident.
//!
//! This is the list that says so. It is built from [`crate::gesture_book`],
//! which is generated from the comment beside each implementation, so a sheet
//! describing a gesture the application does not have is not possible to write:
//! there is no file to write it in.
//!
//! # Why the rows are flat
//!
//! A section heading and a gesture are one model here, distinguished by
//! `heading` being non-empty, rather than a list of lists. Slint has no nested
//! repeater that keeps its scrolling in one place, and a sheet whose sections
//! scrolled independently is not one list — it is several, in a box.
use crate::gesture_book::{Gesture, GESTURES};
/// One line of the sheet: a heading, or a gesture with its routes.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Row {
/// Non-empty on a section heading, and then nothing else is set.
///
/// The sheet decides which of the two a row is by asking whether this is
/// empty, in Slint, where the drawing happens. There is deliberately no
/// `is_heading()` beside it: a predicate here that the drawing code did not
/// call would be a second definition of the same distinction, free to drift
/// from the one that is actually used.
pub heading: String,
pub title: String,
/// Empty where the gesture has no counterpart in that modality; the sheet
/// draws nothing rather than an empty label.
pub touch: String,
pub pointer: String,
pub keys: String,
}
/// Build the sheet's rows from the generated table.
///
/// The table is already ordered so a section's gestures are together — the
/// generator groups it that way — so this emits a heading whenever the section
/// changes rather than sorting again. Two passes that both decide the order is
/// how the document and the sheet come to disagree.
pub fn rows() -> Vec<Row> {
rows_from(GESTURES)
}
fn rows_from(gestures: &[Gesture]) -> Vec<Row> {
let mut out = Vec::with_capacity(gestures.len() + 4);
let mut section = "";
for g in gestures {
if g.section != section {
section = g.section;
out.push(Row {
heading: g.section.to_string(),
..Row::default()
});
}
out.push(Row {
heading: String::new(),
title: g.title.to_string(),
touch: g.touch.to_string(),
pointer: g.pointer.to_string(),
keys: g.keys.to_string(),
});
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn g(section: &'static str, title: &'static str) -> Gesture {
Gesture {
title,
section,
touch: "Tap",
pointer: "",
keys: "",
}
}
#[test]
fn each_section_gets_one_heading_before_its_gestures() {
let rows = rows_from(&[g("Grid", "A"), g("Grid", "B"), g("People", "C")]);
let shape: Vec<&str> = rows
.iter()
.map(|r| if r.heading.is_empty() { "g" } else { "H" })
.collect();
assert_eq!(shape, ["H", "g", "g", "H", "g"]);
assert_eq!(rows[0].heading, "Grid");
assert_eq!(rows[3].heading, "People");
}
/// The generated table groups sections together, so a repeat is a bug in
/// the generator rather than something to defend against by sorting here —
/// but a heading emitted twice would silently split a section in the sheet
/// while the document showed it whole, so it is worth stating.
#[test]
fn a_heading_is_not_repeated_within_a_run() {
let rows = rows_from(&[g("Grid", "A"), g("Grid", "B"), g("Grid", "C")]);
assert_eq!(rows.iter().filter(|r| !r.heading.is_empty()).count(), 1);
}
#[test]
fn nothing_in_makes_nothing_out() {
assert!(rows_from(&[]).is_empty());
}
/// The sheet is only worth opening if there is something in it, and the
/// generated table is the only thing that can put something there.
#[test]
fn the_real_table_is_not_empty_and_every_row_can_be_performed() {
let rows = rows();
assert!(rows.len() > 5, "the generated gesture table looks empty");
for r in rows.iter().filter(|r| r.heading.is_empty()) {
assert!(!r.title.is_empty());
assert!(
!r.touch.is_empty() || !r.pointer.is_empty() || !r.keys.is_empty(),
"`{}` tells the user no way to perform it",
r.title
);
}
}
}
+5
View File
@@ -27,6 +27,11 @@ mod develop;
mod display_ui;
mod export;
pub mod faces;
// Generated from the `GESTURE:` comments beside the code that implements each
// one — see `tools/traceability`. Regenerate with
// `cargo run -p traceability -- gestures`; CI fails if it has drifted.
mod gesture_book;
mod gestures;
mod gradient;
mod histogram;
pub mod identity;
+19 -1
View File
@@ -22,7 +22,7 @@ use dr_types::FormatFilter;
use slint::{ComponentHandle, Model as _};
use crate::library::{self, ScanMessage, ThumbnailMessage};
use crate::{AppWindow, KeywordRow, LibraryCell, PersonChip, TimelineBar};
use crate::{AppWindow, GestureRow, KeywordRow, LibraryCell, PersonChip, TimelineBar};
/// A screenful before the grid has reported its geometry.
///
@@ -4512,6 +4512,24 @@ pub fn wire<F>(
// after it, which is the whole point of showing them.
window.set_library_touched(cfg!(target_os = "android"));
// TRACES: FR-UI-4
// The gesture reference. Pushed once, here, rather than on demand: the
// table is a compiled-in constant, so there is nothing to be fresh about
// and nothing to recompute — a callback to fill it would only be a way for
// it to be empty the first time the sheet opens.
window.set_library_gestures(slint::ModelRc::new(slint::VecModel::from(
crate::gestures::rows()
.into_iter()
.map(|r| GestureRow {
heading: r.heading.into(),
title: r.title.into(),
touch: r.touch.into(),
pointer: r.pointer.into(),
keys: r.keys.into(),
})
.collect::<Vec<_>>(),
)));
// Shared rather than moved: a click and `Return` both open an image, and
// they are two callbacks.
let on_open_image = Rc::new(on_open_image);