Select photographs with a finger, and take a collection with you

Two things a tablet could not do. Both existed for a pointer and had no
touch form at all, which on Android meant the collection sidebar was
somewhere to look at rather than somewhere to file into.

**Selecting more than one.** Ctrl-click and shift-click are the only ways
into a multi-selection, and touch has neither. Holding a cell now enters
selection mode, where a tap toggles — reported to Rust as a ctrl-press, so
it goes through the same `apply_press` as everything else rather than
growing a second copy of the selection rules. A double tap takes the run
between where selecting began and there: the touch form of shift-click,
and the reason the anchor from *before* the double tap has to be
remembered, since both of its taps move the anchor onto the cell being
tapped. A "Select" button does the same thing where a gesture would go
undiscovered (FR-UI-4).

**Filing without a drag.** A one-finger drag beginning in the grid belongs
to the Flickable that scrolls it — that is the arbitration working, not a
bug to route around — so the selection can now be filed from a sheet
listing the sidebar's own rows. Copy by default, as the drag has always
been; moving out of the collection being shown is a switch, because it is
the one that takes something away.

**Taking a collection offline.** The machinery was there and reachable only
by scoping the grid to a collection and finding a button behind a
disclosure. Holding a collection's name now asks the question directly, and
the tray on a row and the header button ask the same one — three
affordances doing two different things is how a user comes to avoid all
three. The question is asked rather than a toggle flipped because both
answers are expensive: one downloads gigabytes, the other deletes them, and
the counts and sizes go in the buttons where they are read before the tap.

`Cache::release` is new and is the destructive half `unpin` deliberately is
not. "Remove the local copies" is asked by someone whose device is full,
and withdrawing a promise while leaving the bytes for a future eviction to
notice is not an answer to it. It unpins before forgetting, or the next pin
fetch would dutifully download everything it just deleted.

The sidebar's trays read `tier_actual`, never `tier_desired`: the question
is whether these will open on the aeroplane, and a pin whose download has
not run yet answers no.

TRACES: FR-CAT-7 | FR-NC-6a | FR-NC-6b | FR-NC-6c | FR-UI-2 | FR-UI-3 | FR-UI-4

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 12:27:29 +02:00
co-authored by Claude Opus 5
parent a8b28136a6
commit d913e50948
7 changed files with 1739 additions and 52 deletions
+301 -3
View File
@@ -10,6 +10,10 @@
import { Theme } from "theme.slint";
import { Button, IconButton, Label, Value, Caption, EmptyState, FilterChip, ProgressBar, Icon } from "widgets.slint";
// The filing sheet lists the same rows the sidebar draws, from the same model:
// two lists of collections that could disagree about what exists is one list
// too many.
import { CollectionRow } from "collections.slint";
// One bar of the capture-time histogram.
export struct TimelineBar {
@@ -449,6 +453,11 @@ export component FlagMark inherits Rectangle {
component HeaderActions inherits HorizontalLayout {
in property <string> scope-label;
in property <int> selected-count: 0;
/// TRACES: FR-UI-2 | FR-UI-4
/// Whether taps are selecting rather than opening. The non-gesture half of
/// touch multi-selection: the long press is the quick way in, and this is
/// the way that can be *found*.
in property <bool> select-mode: false;
in property <bool> scanning: false;
in property <bool> syncing: false;
in property <bool> scope-pinned: false;
@@ -469,6 +478,9 @@ component HeaderActions inherits HorizontalLayout {
callback cancel-export();
callback paste-settings-to-selection();
callback remove-from-collection();
/// Open the sheet that files the selection in a collection.
callback add-to-collection();
callback toggle-select-mode();
callback change-library();
callback toggle-pin-scope();
callback sync-now();
@@ -477,6 +489,35 @@ component HeaderActions inherits HorizontalLayout {
spacing: Theme.gap;
// TRACES: FR-UI-2 | FR-UI-4 | FR-CAT-7
// Selection mode, first in the row because it is what the rest of the row
// acts on: every button after this one appears once something is selected.
//
// It exists at all because the desktop's way in — ctrl-click, shift-click —
// has no touch equivalent, and the drag that files a photograph in a
// collection needs a *selection* before it can carry more than one. On a
// tablet the long press does this faster; a gesture with no visible
// counterpart is a feature only its author knows about (FR-UI-4).
//
// "Done" rather than "Selecting": the label should say what pressing it
// does, and the inverted fill already says which state we are in.
Button {
text: root.select-mode ? "Done" : "Select";
active: root.select-mode;
y: root.centred ? (root.row-height - self.height) / 2 : 0;
clicked => { root.toggle-select-mode(); }
}
// TRACES: FR-CAT-7 | FR-UI-4
// File the selection in a collection without dragging it there. The drag
// is the faster gesture with a pointer and impossible with one finger on a
// grid that scrolls, which is the whole reason this button exists.
if root.selected-count > 0: Button {
text: "Add to collection";
y: root.centred ? (root.row-height - self.height) / 2 : 0;
clicked => { root.add-to-collection(); }
}
// TRACES: FR-DEV-6
// Batch-apply the copied settings. Shown only with both a selection and a
// clipboard, because it is meaningless without either — and because a
@@ -544,9 +585,13 @@ component HeaderActions inherits HorizontalLayout {
// request, and a button that meant either depending on invisible state
// would be a trap.
if root.scope-label != "": Button {
// No tick on the pinned label: `active` already inverts the button,
// No tick on the kept label: `active` already inverts the button,
// which says the same thing without a symbol inside a string.
text: root.scope-pinned ? "Pinned" : "Pin offline";
//
// It opens the same question the sidebar's tray and a held row open,
// rather than pinning outright — see the note on the callback in
// `library_ui::wire`.
text: root.scope-pinned ? "Kept offline" : "Keep offline";
active: root.scope-pinned;
enabled: !root.scanning;
y: root.centred ? (root.row-height - self.height) / 2 : 0;
@@ -718,6 +763,29 @@ export component LibraryGrid inherits Rectangle {
/// Modifier state at press time, so Rust can decide replace / add / extend
/// without the .slint file encoding the selection policy.
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
/// turn a scroll into a selection. Rust owns that timer: Slint has no
/// long-press gesture, and a hand-rolled one here would need a `Timer` per
/// 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
/// header's button are two doors into one state rather than two states that
/// 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.
in property <bool> select-mode: false;
callback toggle-select-mode();
/// The drag payload: the selected image ids, wrapped by Rust. Called when a
/// drag starts, so it always reflects the selection as it is at that moment.
pure callback drag-payload() -> data-transfer;
@@ -828,6 +896,31 @@ export component LibraryGrid inherits Rectangle {
/// thing this button does.
callback remove-from-collection();
// --- filing the selection (FR-CAT-7, FR-UI-4) ---------------------------
//
// The drag onto the sidebar is the fast way to file photographs, and it is
// a pointer gesture: a one-finger drag beginning inside the grid belongs to
// the Flickable that scrolls it, which is the arbitration described at the
// top of `collections_ui.rs` working exactly as it should. So touch needs a
// way in that is not a drag, and that is this sheet.
//
// The rows are the sidebar's own model, passed through rather than queried
// again: two lists of collections is one list too many, and the one that
// goes stale is always the one nobody is looking at.
in property <[CollectionRow]> collections;
/// Whether the sheet is up. Local, because it is a disclosure rather than a
/// preference — nothing outside this file needs to know it is open, and
/// what closes it is choosing a collection or dismissing it.
property <bool> filing: false;
/// Whether the images should *leave* the collection being shown, rather
/// than being filed in a second one as well. Only meaningful while scoped,
/// and reset every time the sheet opens: a destructive default that
/// remembered itself between uses is how photographs go missing.
property <bool> filing-moves: false;
/// File the selection: the target collection's id, and whether to take it
/// out of the one currently being shown.
callback file-in-collection(int, bool);
// Cell geometry. Columns are derived from the available width so the grid
// reflows with the window rather than fixing a count (FR-UI-1).
// Zoomable, so the grid serves both jobs: fewer, larger images for
@@ -943,6 +1036,17 @@ export component LibraryGrid inherits Rectangle {
cancel-export => { root.cancel-export(); }
paste-settings-to-selection => { root.paste-settings-to-selection(); }
remove-from-collection => { root.remove-from-collection(); }
select-mode: root.select-mode;
toggle-select-mode => { root.toggle-select-mode(); }
add-to-collection => {
// Reset every time it opens: see `filing-moves`.
root.filing-moves = false;
root.filing = true;
// The disclosure has served its purpose and would
// otherwise sit behind the sheet, so the row it belongs
// to is still there when the sheet closes.
root.actions-open = false;
}
change-library => { root.change-library(); }
toggle-pin-scope => { root.toggle-pin-scope(); }
sync-now => { root.sync-now(); }
@@ -994,6 +1098,17 @@ export component LibraryGrid inherits Rectangle {
cancel-export => { root.cancel-export(); }
paste-settings-to-selection => { root.paste-settings-to-selection(); }
remove-from-collection => { root.remove-from-collection(); }
select-mode: root.select-mode;
toggle-select-mode => { root.toggle-select-mode(); }
add-to-collection => {
// Reset every time it opens: see `filing-moves`.
root.filing-moves = false;
root.filing = true;
// The disclosure has served its purpose and would
// otherwise sit behind the sheet, so the row it belongs
// to is still there when the sheet closes.
root.actions-open = false;
}
change-library => { root.change-library(); }
toggle-pin-scope => { root.toggle-pin-scope(); }
sync-now => { root.sync-now(); }
@@ -1300,6 +1415,23 @@ export component LibraryGrid inherits Rectangle {
init => { self.focus(); }
key-pressed(event) => {
// TRACES: FR-UI-2 | FR-UI-4
// Back and Escape close what is open here, innermost
// first, before the shell above gets to read them as
// "leave the library". On Android that is the system Back
// button, and a sheet it walked straight past would leave
// the user out of the grid with their selection gone.
if (event.text == Key.Back || event.text == Key.Escape) {
if (root.filing) {
root.filing = false;
return accept;
}
if (root.select-mode) {
root.toggle-select-mode();
return accept;
}
return reject;
}
if (event.text == "0") { root.judged(0, -1); return accept; }
if (event.text == "1") { root.judged(1, -1); return accept; }
if (event.text == "2") { root.judged(2, -1); return accept; }
@@ -1678,14 +1810,30 @@ export component LibraryGrid inherits Rectangle {
// Selected on *press*, not on release: the drag that may
// follow reads the selection to build its payload, and by
// release the pointer is over the sidebar.
//
// In selection mode the press is reported as though ctrl
// were held. That is not a shortcut: toggling one cell
// while keeping the rest *is* what ctrl-click means, and
// giving touch its own policy would be a second copy of
// the rules in `collections_ui::apply_press` to keep in
// step with the first.
pointer-event(ev) => {
if (ev.kind == PointerEventKind.down) {
root.cell-pressed(
i,
ev.modifiers.control,
ev.modifiers.control || root.select-mode,
ev.modifiers.shift,
);
}
// Both endings, and `cancel` is the important one:
// the Flickable takes the pointer as soon as the
// finger travels, so without this a scroll that
// began on a cell would come to rest as a long
// press and select it.
if (ev.kind == PointerEventKind.up
|| ev.kind == PointerEventKind.cancel) {
root.cell-press-ended();
}
}
// A *plain* click opens the image; a modified one is purely
@@ -1695,6 +1843,11 @@ export component LibraryGrid inherits Rectangle {
// records it and Rust decides — `cell-clicked` is only
// honoured when the press was unmodified.
clicked => { root.cell-clicked(i); }
// 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); }
}
// --- the rating strip, ABOVE the cell's own hit area ---
@@ -1735,4 +1888,149 @@ export component LibraryGrid inherits Rectangle {
}
}
// --- the filing sheet (FR-CAT-7, FR-UI-4) -------------------------------
//
// "Put these in…", for the times a drag is not available: one finger on a
// scrolling grid, or a selection made across a scrub where the sidebar has
// long since been closed to give the photographs the width.
//
// Last in the file, so it draws over the grid — and outside the
// VerticalLayout above, so appearing does not reflow the header and the
// cells underneath it.
if root.filing: Rectangle {
background: #000000CC;
// Swallows the taps that miss the card, and closes. First, so the
// card's own controls sit above it.
TouchArea {
clicked => { root.filing = false; }
}
Rectangle {
width: min(420px, parent.width - 2 * Theme.gap-lg);
// Tall enough for the list, but never taller than the window: a
// library with forty collections must still leave the buttons on
// screen, which is what the Flickable inside is for.
height: min(sheet.preferred-height, parent.height - 2 * Theme.gap-lg);
x: (parent.width - self.width) / 2;
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 { }
sheet := VerticalLayout {
padding: Theme.gap-lg;
spacing: Theme.gap;
Text {
text: root.selected-count == 1
? "File 1 photograph in…"
: "File " + root.selected-count + " photographs in…";
color: Theme.ink;
font-size: Theme.text-lg;
font-weight: 600;
wrap: word-wrap;
}
// Filing is a *copy*: a photograph can be in as many
// collections as it belongs in, which is what a join table
// means and what the drag has always done. Moving is the
// exception and has to be asked for, because it is the one
// that takes something away.
if root.scope-label != "": Button {
text: root.filing-moves
? "Moving out of " + root.scope-label
: "Also keep in " + root.scope-label;
active: root.filing-moves;
clicked => { root.filing-moves = !root.filing-moves; }
}
Rectangle { height: 1px; background: Theme.rule; }
Flickable {
vertical-stretch: 1;
// A floor, so the list is not squeezed out of existence by
// the buttons around it on a short window.
min-height: 120px;
viewport-height: root.collections.length * (Theme.touch-target + 2px);
for row[i] in root.collections: Rectangle {
y: i * (Theme.touch-target + 2px);
width: parent.width;
// A full touch target per row, where the sidebar's
// equivalent is 26px. The sidebar is a place to look;
// this is a place to hit once, with a thumb, holding a
// selection that took a minute to build (FR-UI-3).
height: Theme.touch-target;
background: row-touch.pressed ? Theme.pressed
: (row-touch.has-hover ? Theme.hover : transparent);
border-radius: Theme.radius-sm;
// A saved filter's membership is its selector, so it
// cannot be filed into — the same refusal the sidebar
// makes on a drag, made here before the tap rather
// than after it.
opacity: row.smart ? 0.4 : 1.0;
HorizontalLayout {
padding-left: Theme.gap-sm + row.depth * Theme.indent;
padding-right: Theme.gap-sm;
spacing: Theme.gap-sm;
Icon {
name: row.smart ? "collection-smart" : "collection";
ink: Theme.ink-faint;
size: 14px;
y: (parent.height - self.height) / 2;
}
Text {
text: row.name;
color: Theme.ink;
font-size: Theme.text;
vertical-alignment: center;
overflow: elide;
horizontal-stretch: 1;
}
Text {
text: row.smart ? "computed"
: (row.deep-count > 0 ? row.deep-count + "" : "");
color: Theme.ink-faint;
font-size: Theme.text-sm;
vertical-alignment: center;
}
}
row-touch := TouchArea {
enabled: !row.smart;
clicked => {
root.file-in-collection(row.id, root.filing-moves);
root.filing = false;
}
}
}
if root.collections.length == 0: Text {
text: "No collections yet. Make one with + in the sidebar.";
color: Theme.ink-faint;
font-size: Theme.text-sm;
wrap: word-wrap;
width: parent.width;
}
}
Rectangle { height: 1px; background: Theme.rule; }
Button {
text: "Cancel";
clicked => { root.filing = false; }
}
}
}
}
}