Let a collection be picked up, rearranged, and emptied after the fact

Collections could be made and filled and never reorganised. Nesting had
a drag; un-nesting had nothing, in either direction — "All photographs"
refused every drop, which is right for a photograph and wrong for a
collection, which has a top level to be returned to. So a collection put
inside another was in there permanently. Right-click deleted an *empty*
collection outright and refused otherwise, which is wrong in both
directions at once: destructive with no confirmation, and no way at all
to delete a collection that held anything without emptying it by hand,
child by child. And a photograph could only leave the collection the
grid was scoped to, since that is the only one a button in the header
can name — the cell's badge says a photograph is in three collections
and never which three.

Three ways in, one vocabulary:

**Hold a row.** The tree is inside a Flickable, which claims any drag
beginning inside it, so with a finger a drag on a row is a scroll until
something says otherwise. The hold is that something. It lifts the row —
drawn before anything moves, so the gesture says it has been understood
— and then what the user does decides which of two things they meant:
move, and it is a rearrangement; let go, and it is the row menu. The
same fork the grid already uses to tell hold-to-select from drag-to-file.
`decide_release` is that fork, and it is tested, because getting it
wrong one way puts a sheet over every tidied tree and the other way
makes the menu unreachable by touch.

**The row menu.** Rename, new collection inside, move to top level,
keep offline, delete. Deleting asks once when there is anything to lose
and says what survives: the photographs stay in the library, and nested
collections move up rather than going with it — which is what the
catalog does, and what a user would never assume. An empty collection
goes on the first press, because a dialogue about losing nothing is how
people learn to dismiss dialogues.

**"Collections…" on a selection.** Every collection the selection is
filed in, each with a count — "3 of 40", so nobody takes forty
photographs out of a collection thirty-seven were never in — and a way
out of any of them without navigating there first.

The long press used to open the offline question by itself. That
question is one item in this menu now: there is one hold per row, and
while it was spent on a single action nothing else the tree can do had
a touch route at all. Nothing is lost — the tray on the row keeps its
tap, and the question gains a full-width control in place of a 30px
icon in a row shorter than the touch minimum.

The row-press handler moves to `collections_ui` with the rest of what a
collection row does; it lived in `library_ui` only because it opened
that prompt.
This commit is contained in:
2026-09-07 19:59:44 +02:00
parent 577bbd82b0
commit 1e171c6d31
8 changed files with 1525 additions and 163 deletions
+113 -1
View File
@@ -9,7 +9,8 @@ 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 { CollectionsPanel, CollectionRow, OfflinePrompt, CollectionMenu,
MembershipSheet, MembershipRow } from "collections.slint";
import { HistogramPanel, HistogramView } from "histogram.slint";
import { RecoveryPrompt } from "recovery.slint";
import { PresetSheet, ScopeChips, ScopeKind } from "presets.slint";
@@ -619,12 +620,55 @@ export component AppWindow inherits Window {
callback collection-toggle(int);
callback collection-new();
callback collection-menu(int);
// --- the row menu the gesture above opens (FR-CAT-7) -------------------
//
// Rust owns every one of these. What the menu offers depends on counts it
// has to query — whether the collection is nested, what deleting would
// take — and the confirmation step is a policy decision, not a local bit
// the sheet can flip for itself.
/// The collection the menu is about. Empty closes it.
in property <string> collection-menu-title;
/// "12 photographs · 2 collections inside".
in property <string> collection-menu-detail;
/// Whether it has a parent, and so somewhere to be promoted out to.
in property <bool> collection-menu-nested: false;
/// Whether it is a saved filter, which cannot hold a nested collection.
in property <bool> collection-menu-smart: false;
/// Whether a delete has been asked once and awaits confirmation.
in property <bool> collection-menu-confirming: false;
/// What the delete would take with it, and what survives it.
in property <string> collection-menu-confirm-detail;
in property <string> collection-menu-delete-label;
/// TRACES: FR-NC-6a
/// Whether the collection the menu is about is kept on this device.
in property <bool> collection-menu-pinned: false;
callback collection-menu-rename();
callback collection-menu-new-child();
/// Move it back to the top level. The inverse of the drag that nests one,
/// which until now had no gesture at all.
callback collection-menu-promote();
/// TRACES: FR-NC-6a
/// Hand the offline question over to the handler the tray on the row
/// already uses, rather than a second route to the same prompt.
callback collection-menu-offline();
callback collection-menu-delete();
callback collection-menu-dismiss();
/// TRACES: FR-NC-6a
/// Ask about a collection's local copies: the tray on a row, or a long
/// press anywhere on it.
callback collection-offline-menu(int);
/// A row is held or released — the long press is timed in Rust.
callback collection-row-press(int, bool);
// --- picking a collection up (FR-UI-3, FR-UI-4) ------------------------
/// Which collection the hold has picked up, by id. 0 is none.
in property <int> collection-lifted: 0;
/// A row's drag crossed the threshold, or ended.
callback collection-drag-active(int, bool);
/// Whether "All photographs" would take the drag currently in flight.
in property <bool> collection-root-drop-allowed: false;
/// A collection was dropped on "All photographs": out to the top level.
callback collection-dropped-on-root();
/// TRACES: FR-CAT-7 | FR-UI-4
/// File the grid's selection in a collection without dragging it there:
/// the target's id, and whether to take the images out of the collection
@@ -717,6 +761,18 @@ export component AppWindow inherits Window {
callback library-cell-pressed(int, bool, bool);
callback library-remove-from-collection();
// --- where the selection is filed (FR-CAT-7) ---------------------------
//
// `library-remove-from-collection` can only ever mean the collection the
// grid is scoped to — it is the only one that button can name. This sheet
// is the other half: which collections the selection is actually in, and
// the way out of any of them without navigating there first.
in property <bool> membership-open: false;
in property <[MembershipRow]> membership-rows;
callback library-open-membership();
callback membership-remove(int);
callback membership-dismiss();
// The keyboard cursor: where the arrows are in the library, as an image
// ordinal. Rust owns it — clamping it needs the library's length, and
// moving it may have to swap the loaded window underneath.
@@ -1298,6 +1354,19 @@ in property <bool> panel-visible: true;
root.recovery-dismiss();
return accept;
}
// The row menu and the membership sheet are drawn over the
// offline prompt, so Back has to reach them first — and the
// menu's own confirmation is a step Back must be able to take
// one at a time, which is why the dismiss handlers in Rust
// back out of a pending delete rather than closing outright.
if (root.collection-menu-title != "") {
root.collection-menu-dismiss();
return accept;
}
if (root.membership-open) {
root.membership-dismiss();
return accept;
}
if (root.offline-prompt-title != "") {
root.offline-prompt-dismiss();
return accept;
@@ -1610,6 +1679,10 @@ in property <bool> panel-visible: true;
row-menu(id) => { root.collection-menu(id); }
offline-menu(id) => { root.collection-offline-menu(id); }
row-press(id, down) => { root.collection-row-press(id, down); }
lifted-id: root.collection-lifted;
drag-active(id, on) => { root.collection-drag-active(id, on); }
root-drop-allowed: root.collection-root-drop-allowed;
dropped-on-root() => { root.collection-dropped-on-root(); }
renaming-id: root.collection-renaming;
rename-start(id) => { root.collection-rename-start(id); }
@@ -1766,6 +1839,7 @@ in property <bool> panel-visible: true;
remove-from-collection() => {
root.library-remove-from-collection();
}
open-membership() => { root.library-open-membership(); }
filter-people: root.library-filter-people;
filter-people-all: root.library-filter-people-all;
@@ -3194,6 +3268,44 @@ in property <bool> panel-visible: true;
dismiss() => { root.offline-prompt-dismiss(); }
}
// TRACES: FR-CAT-7 | FR-UI-4
// The row menu and the membership sheet, above the offline prompt for
// the same reason it sits above the shell: both are opened from a
// 232px sidebar that disappears entirely in the compact layout, so
// neither can be drawn inside the thing that raises it.
//
// The two never coexist — one is about a collection, the other about a
// selection — but they are laid out in the order Back unwinds them,
// which is the order they are checked in above.
MembershipSheet {
width: 100%;
height: 100%;
open: root.membership-open;
selected-count: root.library-selected-count;
rows: root.membership-rows;
remove(id) => { root.membership-remove(id); }
dismiss() => { root.membership-dismiss(); }
}
CollectionMenu {
width: 100%;
height: 100%;
title: root.collection-menu-title;
detail: root.collection-menu-detail;
nested: root.collection-menu-nested;
smart: root.collection-menu-smart;
confirming: root.collection-menu-confirming;
confirm-detail: root.collection-menu-confirm-detail;
delete-label: root.collection-menu-delete-label;
pinned: root.collection-menu-pinned;
rename() => { root.collection-menu-rename(); }
new-child() => { root.collection-menu-new-child(); }
promote() => { root.collection-menu-promote(); }
offline() => { root.collection-menu-offline(); }
delete() => { root.collection-menu-delete(); }
dismiss() => { root.collection-menu-dismiss(); }
}
// Last, and therefore over everything including the settings page and
// the offline prompt. Not a preference about layering: this is asked
// before the grid exists, and nothing else in the window is about a