Let a manual collection be put in the order it is meant to be seen in

`collection_members.position` and `Sort::CollectionPosition` have been in the
catalog since collections were, and nothing above dr-catalog has ever written
or read either: `collections::set_order` had no callers, and the grid ordered
everything by capture time whatever it was scoped to — dr-ui does not construct
a `Query` at all, it has its own `GRID_ORDER` constant. So a manual collection
was a set with an order nobody could see or change.

Three pieces, because it could not be fewer:

`grid_order_for` decides the ordering from the scope, and both readers take it
from there. That is the load-bearing part. An ordinal only names a photograph
relative to an ordering, so the window read and the span read have to agree —
a shift-click resolved through a different ORDER BY than the cells were drawn
with selects a different run than the one on screen, and the user finds out
when the export runs. `read_ids_span` already stated that invariant about
`GRID_ORDER`; this widens it to an ordering that depends on the scope.

Only a single manual collection has one. A set draws its descendants' images
too, and two children's positions are unrelated integers that interleave
arbitrarily; a smart collection has no member rows to carry a position at all.
Both fall back to capture time and refuse the drop rather than pretending.

The drop is on the cell, on whichever half of it the finger landed — the
trailing edge is the only way to name the last place in a collection, since
there is no cell beyond the last one to drop in front of.

`reordered` is pure and the membership is rewritten whole. `set_order` sets the
positions it is given and leaves the rest, so a partial write would interleave
the moved run with rows nobody touched; and it is read unfiltered, so what the
filter is hiding keeps its place relative to what the user can see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-29 23:18:06 +02:00
co-authored by Claude Opus 5
parent 9d11554c71
commit f5edd49b6b
6 changed files with 575 additions and 28 deletions
+10
View File
@@ -310,6 +310,14 @@ export component AppWindow inherits Window {
/// TRACES: FR-CAT-5
callback library-clear-selection();
callback library-select-all();
/// TRACES: FR-CAT-7
/// Whether the grid is showing something with a manual order to change —
/// a single manual collection, not a set and not a saved filter.
in property <bool> library-reorderable: false;
/// Move the selection beside the photograph at this row of the loaded
/// window: after it where the drop landed on the cell's trailing half,
/// before it otherwise.
callback library-reorder-to(int, bool);
callback library-collection-from-selection(string);
/// TRACES: FR-CAT-6
in property <string> library-range-from;
@@ -1442,6 +1450,8 @@ in property <bool> panel-visible: true;
toggle-date-range() => { root.library-toggle-date-range(); }
clear-selection() => { root.library-clear-selection(); }
select-all() => { root.library-select-all(); }
reorderable: root.library-reorderable;
reorder-to(row, after) => { root.library-reorder-to(row, after); }
collection-from-selection(name) => {
root.library-collection-from-selection(name);
}
+78
View File
@@ -1240,6 +1240,27 @@ export component LibraryGrid inherits Rectangle {
in property <bool> select-mode: false;
callback toggle-select-mode();
/// TRACES: FR-CAT-5
// --- reordering a manual collection (FR-CAT-7) --------------------------
//
// `collection_members.position` and `Sort::CollectionPosition` have existed
// in the catalog since collections did, and nothing above it ever wrote or
// read them: the grid ordered everything by capture time, always. This is
// the gesture that makes the column mean something.
//
// Only where there is a manual order to change — a single manual collection
// with no children. A set interleaves two children's unrelated positions
// and a smart collection has no member rows at all, so both fall back to
// capture time and refuse the drop rather than pretending.
/// Whether a drop on a cell should move photographs within the collection
/// being shown. Set by Rust from the scope, because what a scope *is* is a
/// catalog question.
in property <bool> reorderable: false;
/// Move the selection so it sits beside the photograph at this row of the
/// loaded window — before it, or after it where the drop landed on the
/// cell's trailing half. The trailing half is what makes the last position
/// reachable at all; without it there is no cell to drop "before".
callback reorder-to(int, bool);
/// Drop the selection without leaving select mode.
callback clear-selection();
/// TRACES: FR-CAT-5 | FR-UI-4
@@ -2777,6 +2798,41 @@ export component LibraryGrid inherits Rectangle {
}
drag-finished(action) => { root.drag-finished(); }
// Where a reorder lands. Behind the cell's content and
// before it in the file, for the reason `TreeRow`'s drop
// area gives: a `DropArea` only takes part in a drag, so it
// does not block the presses the cell's own TouchArea
// needs — but drawn first it cannot paint over the
// thumbnail either.
//
// Present on every cell rather than wrapped in an `if`, so
// the marker below can name it. `can-drop` is where the
// refusal lives, which also means the cursor says no while
// the user can still aim somewhere else.
reorder-drop := DropArea {
width: 100%;
height: 100%;
/// Whether the run would land after this photograph
/// rather than before it. Tracked during the hover so
/// the marker can move to the edge the drop will
/// actually use.
property <bool> after: false;
can-drop(ev) => {
if (!root.reorderable) {
return DragAction.none;
}
self.after = ev.position.x > self.width / 2;
return DragAction.copy;
}
dropped(ev) => {
root.reorder-to(i, ev.position.x > self.width / 2);
return DragAction.copy;
}
}
Rectangle {
// Lifted cells shrink toward their own centre, as though pulled
// off the page. Inset rather than scaled: Slint has no transform
@@ -3051,6 +3107,28 @@ export component LibraryGrid inherits Rectangle {
}
}
}
// Where the run would land. A bar in the gutter beside the
// cell it would sit next to, on whichever side the drop
// will actually use — the trailing edge is what makes the
// last place in a collection reachable, and a marker that
// did not move with it would be pointing at the wrong gap
// half the time.
//
// Last child of the `DragArea`, so it draws over the
// thumbnail rather than under it. In the gutter rather than
// on the cell, because a bar drawn *on* the first cell of a
// row reads as belonging to that cell instead of to the
// space before it.
if reorder-drop.has-drag: Rectangle {
x: reorder-drop.after
? parent.width + Theme.gap / 2 - 1.5px
: -Theme.gap / 2 - 1.5px;
y: 0;
width: 3px;
height: parent.height;
background: Theme.active;
border-radius: 1.5px;
}
}
}