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
+6
View File
@@ -7,6 +7,7 @@ import { HistoryPanel, HistoryRow } from "history.slint";
import { LaunchScreen } from "launch.slint";
import { IdentityScreen, IdentityPerson, IdentityFace } from "identity.slint";
import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll, KeywordRow, PersonChip } from "library.slint";
import { GestureRow } from "gestures.slint";
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
import { CollectionsPanel, CollectionRow, OfflinePrompt } from "collections.slint";
import { HistogramPanel, HistogramView } from "histogram.slint";
@@ -17,6 +18,7 @@ import { ImportPage } from "import.slint";
import { StatusBar, InfoPanel } from "develop.slint";
export { LibraryCell, TimelineBar, CollectionRow, ActivityRow, HistogramView, PersonChip }
export { GestureRow }
export { ViewMode, GradientHandle, HandleRole, SpotHandle, SpotRole }
export component AppWindow inherits Window {
@@ -617,6 +619,9 @@ export component AppWindow inherits Window {
callback library-filter-person-cleared(int);
callback library-filter-people-mode-toggled();
/// Everyone the library knows, for the filter bar's people tray.
/// TRACES: FR-UI-4
/// The gesture reference's rows, read from the generated table.
in property <[GestureRow]> library-gestures;
in property <[PersonChip]> library-people;
callback library-people-listed();
callback library-filter-person-toggled(int);
@@ -1589,6 +1594,7 @@ in property <bool> panel-visible: true;
filter-people: root.library-filter-people;
filter-people-all: root.library-filter-people-all;
people: root.library-people;
gestures: root.library-gestures;
filter-min-rating: root.library-filter-min-rating;
filter-unjudged: root.library-filter-unjudged;
filter-flag: root.library-filter-flag;
+180
View File
@@ -0,0 +1,180 @@
// TRACES: FR-UI-2 | FR-UI-4
//
// The gesture reference, as a sheet.
//
// # Why the application carries one at all
//
// FR-UI-4: a gesture with no visible counterpart is a feature only its author
// knows about. Most of the grid's vocabulary now has one — Select, "Select to…"
// and Select all are buttons that say what they do — but that a *hold* does the
// same thing faster, or that two fingers resize the thumbnails, could still only
// be discovered by accident. The buttons make the gestures usable; this makes
// them knowable.
//
// # Why the rows come from Rust and not from this file
//
// Every line here is generated from the comment beside the code that implements
// the gesture (`gesture_book.rs`, and `tools/traceability` that writes it). A
// sheet with the text typed into it would be a second description of one
// behaviour, and the second description is always the one that goes stale: the
// code is exercised whenever somebody uses the application, and the help screen
// is exercised never. This file draws whatever it is handed and knows nothing
// about what a gesture is.
import { Theme } from "theme.slint";
import { Button, Caption } from "widgets.slint";
// One line of the sheet: a section heading, or a gesture and its routes.
//
// Flat, and not a list of lists, because a sheet whose sections scrolled
// independently is not one list — it is several, in a box. `heading` non-empty
// is what marks the two apart; Slint has no sum type to say it better.
export struct GestureRow {
heading: string,
title: string,
// Empty where the gesture has no counterpart in that modality. Drawn as
// nothing at all rather than as an empty label, so a touch-only gesture
// does not read as one whose pointer half is broken.
touch: string,
pointer: string,
keys: string,
}
// One route: how a modality performs the gesture.
component Route inherits HorizontalLayout {
in property <string> modality;
in property <string> how;
spacing: Theme.gap-sm;
Caption {
text: root.modality;
// A fixed column so the three routes line up down the sheet. Without
// it "Touch", "Pointer" and "Keyboard" each set their own left edge for
// the text beside them, and a list of forty reads as ragged prose.
width: 64px;
horizontal-alignment: right;
}
Text {
text: root.how;
color: Theme.ink;
font-size: Theme.text-sm;
wrap: word-wrap;
horizontal-stretch: 1;
}
}
export component GestureSheet inherits Rectangle {
in property <[GestureRow]> rows;
callback close();
background: #000000CC;
// Swallows the taps that miss the card, and closes. First, so the card's
// own controls sit above it — the same scrim, card and dismissal the
// library's other sheets use, because a user who has filed a selection
// already knows how this works.
TouchArea {
clicked => { root.close(); }
}
Rectangle {
width: min(520px, parent.width - 2 * Theme.gap-lg);
height: min(560px, parent.height - 2 * Theme.gap-lg);
x: (parent.width - self.width) / 2;
// Centred, unlike the naming sheet: nothing here takes the keyboard, so
// there is no keyboard to sit above.
y: (parent.height - self.height) / 2;
background: Theme.surface;
border-radius: Theme.radius;
border-width: 1px;
border-color: Theme.rule;
// Stops a press on the card reaching the scrim behind it.
TouchArea { }
VerticalLayout {
padding: Theme.gap-lg;
spacing: Theme.gap;
Text {
text: "How to drive the grid";
color: Theme.ink;
font-size: Theme.text-lg;
font-weight: 600;
}
// The list scrolls; the title and the button do not, so a long
// vocabulary never pushes the way out off the bottom of the card.
Flickable {
vertical-stretch: 1;
viewport-width: self.width;
viewport-height: list.preferred-height;
list := VerticalLayout {
width: parent.viewport-width;
spacing: Theme.gap-sm;
alignment: start;
for r[i] in root.rows: VerticalLayout {
spacing: 2px;
// A heading and a gesture are the same row type, so
// each half is drawn under its own condition rather
// than by two repeaters over one filtered list — which
// would be two passes that could disagree about order.
// Space above a heading, and none above the first:
// the gap is what separates a section from the one
// before it, and there is nothing before the first.
//
// An empty Rectangle rather than a `y` offset on the
// heading — a child of a layout may not set its own
// `y`, because the layout is already setting it.
if r.heading != "" && i > 0: Rectangle {
height: Theme.gap;
}
if r.heading != "": Text {
text: r.heading;
color: Theme.ink-dim;
font-size: Theme.text-sm;
font-weight: 700;
}
if r.heading == "": Text {
text: r.title;
color: Theme.ink;
font-size: Theme.text;
font-weight: 600;
wrap: word-wrap;
}
if r.touch != "": Route {
modality: "Touch";
how: r.touch;
}
if r.pointer != "": Route {
modality: "Pointer";
how: r.pointer;
}
if r.keys != "": Route {
modality: "Keyboard";
how: r.keys;
}
}
}
}
HorizontalLayout {
alignment: end;
Button {
text: "Done";
primary: true;
clicked => { root.close(); }
}
}
}
}
}
+35
View File
@@ -14,6 +14,7 @@ import { Button, IconButton, Label, Value, Caption, EmptyState, FilterChip, Prog
// two lists of collections that could disagree about what exists is one list
// too many.
import { CollectionRow } from "collections.slint";
import { GestureSheet, GestureRow } from "gestures.slint";
// TRACES: FR-CAT-5
// One keyword in the keywording sheet, already answered against the selection.
@@ -896,6 +897,9 @@ component HeaderActions inherits HorizontalLayout {
callback open-import();
callback open-settings();
callback open-people();
/// TRACES: FR-UI-4
/// Open the gesture reference.
callback open-gestures();
spacing: Theme.gap;
@@ -1068,6 +1072,18 @@ component HeaderActions inherits HorizontalLayout {
clicked => { root.open-people(); }
}
// TRACES: FR-UI-4
// What the gestures are, for the ones that have no button of their own.
// Beside Settings because it is about the application rather than about
// the photographs, and unconditional for the same reason Settings is: a
// reference you can only reach in some states is one you look for in the
// state where you needed it and do not find.
Button {
text: "Gestures";
y: root.centred ? (root.row-height - self.height) / 2 : 0;
clicked => { root.open-gestures(); }
}
// Last in the row, and unconditional. The buttons before it come and go
// with what the grid is showing; settings is always reachable, and a
// control that moved as its neighbours appeared would be hunted for each
@@ -1223,6 +1239,13 @@ export component LibraryGrid inherits Rectangle {
callback open-import();
callback open-settings();
callback open-people();
/// TRACES: FR-UI-4
/// The gesture reference's rows, from Rust — which reads them from the
/// generated table. See gestures.slint for why they cannot be written here.
in property <[GestureRow]> gestures;
/// Whether the reference is up. Local, like `naming` and `filing`: nothing
/// in Rust needs to know a sheet is open.
property <bool> helping: false;
// --- selection and drag ---
//
@@ -1900,6 +1923,7 @@ export component LibraryGrid inherits Rectangle {
open-import => { root.open-import(); }
open-settings => { root.open-settings(); }
open-people => { root.open-people(); }
open-gestures => { root.helping = true; }
}
// Compact: one button in place of six. Labelled rather than a
@@ -1986,6 +2010,7 @@ export component LibraryGrid inherits Rectangle {
open-import => { root.open-import(); }
open-settings => { root.open-settings(); }
open-people => { root.open-people(); }
open-gestures => { root.helping = true; }
}
}
}
@@ -3800,6 +3825,16 @@ export component LibraryGrid inherits Rectangle {
}
}
// TRACES: FR-UI-2 | FR-UI-4
// The gesture reference. Last of the sheets, and above them all, because it
// is the one a user opens *because* another one confused them.
if root.helping: GestureSheet {
width: 100%;
height: 100%;
rows: root.gestures;
close => { root.helping = false; }
}
// --- the naming sheet (FR-CAT-5, FR-CAT-7) ------------------------------
//
// Why a sheet at all, rather than the sidebar's rename field: see `naming`