Merge master into wave-2

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

# Conflicts:
#	docs/traceability.md
This commit is contained in:
2026-08-30 09:24:37 +02:00
11 changed files with 523 additions and 92 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 {
@@ -627,6 +629,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);
@@ -1599,6 +1604,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`