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 00:25:42 +02:00
11 changed files with 1271 additions and 373 deletions
+3 -5
View File
@@ -544,7 +544,6 @@ export component AppWindow inherits Window {
callback library-cell-press-ended();
/// TRACES: FR-UI-2 | FR-UI-4
/// Two taps on a cell: in selection mode, the far end of a range.
callback library-cell-double-clicked(int);
/// Renaming started on a row, by id — a double-click, or `F2`.
callback collection-rename-start(int);
/// A rename was committed: the collection's id and the new name.
@@ -594,7 +593,7 @@ export component AppWindow inherits Window {
callback library-drag-finished();
in property <int> library-selected-count: 0;
callback library-cell-pressed(int, bool, bool, bool);
callback library-cell-pressed(int, bool, bool);
callback library-remove-from-collection();
// The keyboard cursor: where the arrows are in the library, as an image
@@ -1568,11 +1567,10 @@ in property <bool> panel-visible: true;
open-settings() => { root.settings-open(); }
open-people() => { root.identity-open(); }
cell-pressed(i, ctrl, shift, touch) => {
root.library-cell-pressed(i, ctrl, shift, touch);
cell-pressed(i, ctrl, shift) => {
root.library-cell-pressed(i, ctrl, shift);
}
cell-press-ended() => { root.library-cell-press-ended(); }
cell-double-clicked(i) => { root.library-cell-double-clicked(i); }
select-mode: root.library-select-mode;
toggle-select-mode() => { root.library-toggle-select-mode(); }
// The sidebar's rows, not a second model: the sheet files
+32
View File
@@ -127,6 +127,14 @@ component FaceCell inherits Rectangle {
}
}
// GESTURE: Pull a face out of the wrong person
// where: People
// touch: Tap the faces that do not belong, then "Split off"
// pointer: Click the faces that do not belong, then "Split off"
// why: Grouping over-merges on siblings, on parents and
// children, and on the same person a decade apart, so
// splitting is as prominent as merging. A tool that can
// only merge makes its own errors permanent.
TouchArea {
clicked => { root.toggle-pick(); }
}
@@ -139,6 +147,14 @@ component FaceCell inherits Rectangle {
// Only a suggestion needs ruling on. Once confirmed, the pair
// collapses to the label — there is nothing left to decide, and
// leaving the buttons would invite an accidental un-confirm.
// GESTURE: Rule on a suggested face
// where: People
// touch: Tick to confirm it, cross to reject it
// pointer: Tick to confirm it, cross to reject it
// why: A face is either the system's guess or the user's
// judgement, and the two are never conflated. A
// rejection is remembered, so the face is not suggested
// for that person again.
if !face.confirmed: IconButton {
icon: "check";
clicked => { root.confirm(); }
@@ -526,6 +542,13 @@ export component IdentityScreen inherits Rectangle {
// The way back from a face to the photographs. This is the
// point of having identified anybody, and without it the
// screen is a filing cabinet with no drawer handles.
// GESTURE: See a person's photographs
// where: People
// touch: Choose them in the rail, then "Show photos"
// pointer: Choose them in the rail, then "Show photos"
// why: This is the point of having identified anybody.
// Without it the screen is a filing cabinet with no
// drawer handles.
if root.selected-person >= 0: Button {
text: "Show photos";
clicked => { root.show-photos(root.selected-person, false); }
@@ -556,6 +579,15 @@ export component IdentityScreen inherits Rectangle {
// next to the button that applies them and the rail that shows
// what they did, and a value changed three screens away from
// its effect is a value nobody can tune.
// GESTURE: Change how faces are grouped
// where: People
// touch: "Grouping…", move the dials, then Regroup
// pointer: "Grouping…", move the dials, then Regroup
// why: The right match confidence is a property of your
// library, not of the model. "What would this do?"
// answers for this library without writing
// anything; names, confirmations and the groups you
// have set aside are kept whatever the dials say.
Button {
text: "Grouping…";
active: root.grouping-open;
+137 -25
View File
@@ -1240,15 +1240,7 @@ export component LibraryGrid inherits Rectangle {
// also why there is no badge position to compute here any more.
/// Modifier state at press time, so Rust can decide replace / add / extend
/// without the .slint file encoding the selection policy.
///
/// The last argument is **whether a finger did it**, and it is here because
/// a finger and a pointer need different rules about what counts as a tap.
/// A mouse click is over in tens of milliseconds and means it; a hand
/// brushing a tablet on its way somewhere else produces exactly the same
/// press-and-release, and used to land the user in develop. Rust holds the
/// rule (`collections_ui::TAP_MIN_MS`) beside the hold timer it has to sit
/// between — this file only reports which kind of contact it was.
callback cell-pressed(int, bool, bool, bool);
callback cell-pressed(int, bool, bool);
/// TRACES: FR-UI-2 | FR-UI-4
/// The press on a cell ended — lifted, or taken away by the Flickable when
/// the finger travelled. Cancels the long-press timer that would otherwise
@@ -1257,12 +1249,6 @@ export component LibraryGrid inherits Rectangle {
/// visible cell.
callback cell-press-ended();
/// TRACES: FR-UI-2 | FR-UI-4
/// Two taps on the same cell. In selection mode this is the touch form of
/// shift-click: everything from where the selection started to here. Rust
/// decides — outside that mode a double tap is two ordinary clicks and the
/// first has already opened the image.
callback cell-double-clicked(int);
/// TRACES: FR-UI-2 | FR-UI-4
/// Whether a tap selects rather than opens.
///
/// Held in Rust beside the selection it modifies, so the long press and the
@@ -1270,6 +1256,28 @@ export component LibraryGrid inherits Rectangle {
/// can disagree. In this mode a plain tap toggles a cell — exactly what
/// ctrl-click does with a pointer, which is why the press below passes it
/// as the ctrl flag rather than as a third selection policy.
// GESTURE: Start selecting several photographs
// where: Library grid
// touch: Press and hold a photograph, or press Select in the header
// pointer: Ctrl-click, or press Select in the header
// why: Touch has no ctrl, so without a mode there is no way to
// select a second photograph — the first tap would open it.
// The hold is the fast way in and the button is the one that
// can be found.
//
// GESTURE: Add or remove one photograph
// where: Library grid
// touch: While selecting, tap it
// pointer: Ctrl-click it
// why: While selecting, a tap never opens. That is the whole point
// of the mode: one meaning per gesture at a time. Press Done to
// get tap-to-open back.
//
// GESTURE: Leave selecting
// where: Library grid
// touch: Press Done in the header
// pointer: Press Done in the header
// keys: Escape
in property <bool> select-mode: false;
callback toggle-select-mode();
/// TRACES: FR-CAT-5
@@ -1326,6 +1334,17 @@ export component LibraryGrid inherits Rectangle {
/// no state for it, because it arrives as `cell-pressed`'s shift argument
/// and lands in `apply_press` as the shift-click it already knows how to
/// apply.
// GESTURE: Select a range
// where: Library grid
// touch: While selecting, press "Select to…", then tap the last
// photograph of the run
// pointer: Shift-click the last photograph of the run
// why: This replaced a double tap, which had no visible state and
// could take forty photographs by accident. The run is resolved
// by the catalog rather than by what is on screen, so the grid
// can scroll between the two taps — the ranges that hurt on a
// tablet are longer than a screenful, which is exactly where a
// finger sweep runs out.
property <bool> ranging: false;
// A range armed against nothing has no anchor to extend from, and the strip
@@ -2054,6 +2073,18 @@ export component LibraryGrid inherits Rectangle {
// belongs on the filter bar; this chip is the whole feature's
// front door and the tray below is where both terms and the
// any/all choice actually are.
// GESTURE: Find photographs with two people in them
// where: Library grid
// 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"
// why: "Any of them" is a union and "all of them" is an
// intersection. The tray is where both terms and the
// choice between them live, because a filter belongs
// on the filter bar.
FilterChip {
icon: root.people-tray ? "chevron-down" : "chevron-right";
label: "People";
@@ -2333,6 +2364,13 @@ export component LibraryGrid inherits Rectangle {
// The way out that is not "undo every tap". Distinct from
// "Done", which leaves select mode entirely: clearing keeps the
// mode, so the next selection can start straight away.
// GESTURE: Drop the selection but keep selecting
// where: Library grid
// touch: Press Clear in the selection strip
// pointer: Press Clear in the selection strip
// why: Distinct from Done, which leaves the mode
// entirely. Clearing keeps it, so the next
// selection can start straight away.
if !root.ranging: Button {
text: "Clear";
y: (parent.height - self.height) / 2;
@@ -2343,6 +2381,14 @@ export component LibraryGrid inherits Rectangle {
// do by hand: a scoped grid of two hundred frames is two hundred
// taps otherwise, and "all of them, except those three" is a far
// more common shape than the taps it took to say it.
// GESTURE: Select everything the grid is showing
// where: Library grid
// touch: While selecting, press "Select all"
// pointer: While selecting, press "Select all"
// why: A scoped grid of two hundred frames is two hundred
// taps otherwise, and "all of them, except those
// three" is a far more common shape than the taps it
// took to say it.
if !root.ranging: Button {
text: "Select all";
y: (parent.height - self.height) / 2;
@@ -2776,6 +2822,13 @@ export component LibraryGrid inherits Rectangle {
// tall sat at the top of a viewport thousands of rows long and
// was under the fingers only while the grid had not been
// scrolled. Anywhere else the gesture found nothing to land on.
// GESTURE: Resize the thumbnails
// where: Library grid
// touch: Pinch the grid with two fingers
// pointer: Ctrl and the scroll wheel
// why: There is no wheel on a tablet, so without the
// pinch the cell size could only be changed by a
// control a finger cannot reach.
grid-pinch := ScaleRotateGestureHandler {
width: 100%;
height: parent.viewport-height;
@@ -2918,6 +2971,17 @@ export component LibraryGrid inherits Rectangle {
visible: cell.period-heading != "";
}
// GESTURE: File photographs in a collection
// where: Library grid
// 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
// why: The selection is what the drag carries, which is
// why selecting several is worth the mode: forty
// photographs file in one gesture.
for cell[i] in root.cells: DragArea {
// Cells are positioned at their **absolute** place in the
// library, not their index in the loaded window: the window
@@ -3137,13 +3201,47 @@ export component LibraryGrid inherits Rectangle {
// `clicked` fires before the `up` that follows it, so
// it can only raise a flag — the decision to open has
// to wait for the event that names the finger.
// GESTURE: Open a photograph
// where: Library grid
// touch: Tap it — a single tap, any length
// pointer: Click it
// why: A tap opens; a tap that *moved* does not.
// Travel is what separates a deliberate tap
// from a hand brushing past, and it is the
// only thing that does: the two are the same
// length. An earlier version required the
// finger to dwell 120 ms instead, and that
// rejected ordinary taps — a real tap is
// often quicker than a brush.
property <int> down-finger: -1;
property <bool> click-pending: false;
// Where the press landed, so the release can tell how
// far the finger travelled.
//
// The Flickable already cancels a press that becomes a
// scroll, but only once it has claimed the gesture,
// which takes more travel than a graze has. This
// catches the rest: a contact that slid across the cell
// and lifted was reaching for something else.
property <length> press-x: 0;
property <length> press-y: 0;
/// How far a finger may slide and still be a tap.
///
/// A thumb's contact patch is wider than this, so no
/// stationary tap approaches it; a hand moving across
/// the glass passes it within one frame. Below about
/// this the number stops describing intent and starts
/// describing how steady the user's hand is.
property <length> tap-slop: 12px;
pointer-event(ev) => {
if (ev.kind == PointerEventKind.down) {
self.down-finger = ev.touch-finger-id;
self.click-pending = false;
self.press-x = self.mouse-x;
self.press-y = self.mouse-y;
// A finger, not a pointer — see `touched`.
if (ev.touch-finger-id != 0) {
root.touched = true;
@@ -3164,10 +3262,6 @@ export component LibraryGrid inherits Rectangle {
i,
ev.modifiers.control || root.select-mode,
ev.modifiers.shift || root.ranging,
// Finger id 0 is the mouse — the same
// convention the pinch arbitration a
// few lines up already relies on.
ev.touch-finger-id != 0,
);
// One shot. The range was between two taps
// and the second has landed; left armed, it
@@ -3187,7 +3281,9 @@ export component LibraryGrid inherits Rectangle {
// unmodified.
if (self.click-pending
&& !root.pinching
&& ev.touch-finger-id == self.down-finger) {
&& ev.touch-finger-id == self.down-finger
&& abs(self.mouse-x - self.press-x) < self.tap-slop
&& abs(self.mouse-y - self.press-y) < self.tap-slop) {
root.cell-clicked(i);
}
self.click-pending = false;
@@ -3204,12 +3300,28 @@ export component LibraryGrid inherits Rectangle {
}
clicked => { self.click-pending = true; }
// The far end of a range, in selection mode. Slint
// delivers `clicked` for the first tap as well, which
// is why the toggling is idempotent-by-union in Rust
// rather than this file trying to swallow one of them.
double-clicked => { root.cell-double-clicked(i); }
// **No `double-clicked` here, deliberately.** A double
// tap used to take a range in selection mode. It was
// the only gesture touch had for shift-click, and
// "Select to…" replaced it with a control that says
// what it is about to do. Leaving both meant two quick
// taps on one cell — a thing a hand does by accident —
// silently selecting a run of forty photographs, with
// no visible state to explain where they came from.
//
// Now two taps are two toggles and land back where they
// started, which is the only thing a user can predict
// from what is on screen.
// GESTURE: Rate a photograph without opening it
// where: Library grid
// touch: Tap a star on the cell
// pointer: Hover the cell, then click a star
// keys: 0 to 5 on the selection
// why: A star has to take the press without it
// also reaching the cell, or every rating
// throws the user into develop.
//
// --- the rating strip, INSIDE the cell's hit area ---
//
// **A child of `cell-touch`, not a sibling of it.** Two