Merge branch 'master' into feat/library-toolbar

# Conflicts:
#	docs/gestures.md
#	docs/traceability.md
This commit is contained in:
2026-08-30 14:18:21 +02:00
59 changed files with 7992 additions and 389 deletions
+141 -9
View File
@@ -649,6 +649,7 @@ export component PhotoRoll inherits Rectangle {
}
}
// TRACES: NFR-A11Y-3
// A row of five stars, readable at a glance and clickable to set a rating.
//
// **Filled versus empty carries the meaning, not colour.** NFR-A11Y-3 forbids
@@ -796,6 +797,7 @@ export component StarStrip inherits Rectangle {
}
}
// TRACES: NFR-A11Y-3
// The pick/reject mark.
//
// A shape rather than a colour, for the same NFR-A11Y-3 reason as the stars:
@@ -1203,6 +1205,40 @@ export component LibraryGrid inherits Rectangle {
// keys: Escape
in property <bool> select-mode: false;
callback toggle-select-mode();
/// TRACES: FR-CAT-7 | FR-UI-4
/// The photograph a press has held long enough to pick up, or `-1`.
///
/// **The visible half of a gesture that was folklore.** A finger on a cell
/// is ambiguous — it may be starting a scroll or taking hold of a
/// photograph — and Slint resolves that by giving the `Flickable` the first
/// half-second: any press that travels more than a few pixels vertically
/// inside it becomes a scroll, and the drag never begins. Only a fast
/// sideways flick, or waiting the half-second out, ever picked a
/// photograph up, and nothing on the screen said so. The user's account of
/// it was that dragging "sometimes works".
///
/// So the wait is given a mark. The same hold that turns on selection mode
/// sets this, a ring opens outward around the cell, and from that moment
/// the drag is the only thing the finger can be doing — the grid below is
/// no longer `interactive`, so there is no scroll left to lose to. The cue
/// can only ever arrive *after* the ambiguity has passed, which is the
/// honest direction: once the ring is open, dragging works.
///
/// A row of the loaded window, like every other row here. It is cleared
/// when the press ends and when a drag finishes, and the grid cannot
/// scroll while it is set, so it cannot outlive the window it indexes.
// GESTURE: Pick a photograph up to drag it
// where: Library grid
// touch: Press and hold it until a ring opens around it, then drag
// pointer: Drag it
// why: A finger on a photograph might be starting a scroll, and for
// the first half-second the grid assumes it is. Holding says
// otherwise, and the ring is the grid saying it heard — from
// there the drag cannot be lost to a scroll. A mouse never
// waits: the cursor is precise enough that a sideways drag is
// unambiguous from the first pixel.
in-out property <int> held-row: -1;
/// TRACES: FR-CAT-5
// --- reordering a manual collection (FR-CAT-7) --------------------------
//
@@ -1285,15 +1321,29 @@ export component LibraryGrid inherits Rectangle {
/// selected. The name arrives from the sheet below rather than being
/// invented by Rust and corrected afterwards — see `naming`.
callback collection-from-selection(string);
/// 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.
/// **What arms the drag, not what it carries.**
///
/// `DragArea` refuses to begin a drag while its `data` is empty, and it
/// asks on every pointer event — including the first one, long before
/// anything is being dragged. A binding that calls a callback is evaluated
/// once and cached, because there is nothing for Slint to invalidate it on,
/// so whatever this answers the first time a finger touches a cell is what
/// that cell's `DragArea` believes for the rest of its life.
///
/// It is therefore *not* the payload the drop reads, whatever it looks
/// like: what travels is `dragging` in `collections_ui`, recorded by
/// `drag-started` and read back by `dropped-on`. See the Rust side for why
/// this has to answer something non-empty unconditionally.
pure callback drag-payload() -> data-transfer;
/// What travels under the cursor: the dragged thumbnail, or a fanned stack
/// of them where several are being carried. Composited in Rust, because
/// Slint accepts one bitmap here and cannot draw a pile of images into it.
in property <image> drag-image;
/// A drag began on this cell. Lets Rust promote an unselected cell to the
/// selection before the payload is read.
/// A drag began on this cell.
///
/// This, not `drag-payload`, is where what the drag carries is decided: it
/// fires with the selection as it stands at the moment the drag starts, and
/// lets Rust promote an unselected cell into the selection first.
callback drag-started(int);
/// The drag ended — dropped or cancelled. Clears the transient UI state.
callback drag-finished();
@@ -2516,11 +2566,27 @@ export component LibraryGrid inherits Rectangle {
// --- the grid -----------------------------------------------------
//
// `interactive` stays true: `DragArea` and `Flickable` arbitrate
// properly, so dragging a cell drags the cell and dragging the
// background still flicks the grid. (This is the part a hand-rolled
// TouchArea gesture could not do — see the drag comments above.)
// **Scrolls until a photograph has been picked up, and not after.**
//
// `DragArea` and `Flickable` do arbitrate, but not evenly: the
// Flickable claims any press that travels more than eight pixels
// along its own axis within half a second of landing, and it holds
// that claim until the finger lifts. That is right for the ordinary
// case — a finger that moves is almost always scrolling — and it is
// why dragging the background still flicks the grid.
//
// It is wrong once the user has said otherwise. `held-row` is that
// saying: a press that has stayed put long enough to be a pick-up,
// marked on the cell so the user can see it. From there this stops
// being interactive and the drag has nothing left to lose to.
//
// Today the hold outlasts the Flickable's window anyway, so this
// mostly makes an accident into a guarantee — the arbitration stops
// depending on two constants in different crates staying in the
// order they happen to be in. The wheel is unaffected: `interactive`
// does not gate it.
if root.total > 0: grid-scroll := Flickable {
interactive: root.held-row < 0;
// Ctrl+wheel resizes the cells; a plain wheel is declined and
// falls through to the Flickable's own scrolling. Two jobs on
// one gesture, distinguished by the modifier — the convention
@@ -2740,6 +2806,7 @@ export component LibraryGrid inherits Rectangle {
// is what a join table means, and it is why the modifier-free
// gesture must not be `move`.
allow-copy: true;
// Not the payload — the arming. See `drag-payload`.
data: root.drag-payload();
// What travels under the cursor is the photograph itself — and
// where several are being dragged, a stack of them. Composited
@@ -2815,7 +2882,19 @@ export component LibraryGrid inherits Rectangle {
// photograph, which is exactly the wrong feedback for a
// gesture whose whole job is to say "this one". One
// treatment, drawn inside, in one place.
border-width: cell-touch.has-hover ? 1px : 0px;
//
// **Not drawn at all once there has been a finger.** A
// hand has no hover to give, so on a tablet this ring can
// only ever be wrong — and it is wrong in the worst way,
// because it is the selection ring's own colour a pixel
// thinner. `has-hover` is not reliably cleared on touch:
// a release normally brings an `Exit` with it, but the one
// that ends a pinch does not, so every pinch to resize the
// thumbnails left a ring around whichever cell a finger
// happened to have started on. The grid then showed boxes
// around photographs that were not selected, with no way
// to tell them from ones that were. See `touched`.
border-width: cell-touch.has-hover && !root.touched ? 1px : 0px;
border-color: Theme.selected-ring;
clip: true;
@@ -3055,6 +3134,8 @@ export component LibraryGrid inherits Rectangle {
root.cell-clicked(i);
}
self.click-pending = false;
// Down again, whether or not it was ever up.
root.held-row = -1;
root.cell-press-ended();
}
// `cancel` is the important ending: the Flickable
@@ -3063,6 +3144,12 @@ export component LibraryGrid inherits Rectangle {
// would come to rest as a long press and select it.
if (ev.kind == PointerEventKind.cancel) {
self.click-pending = false;
// Including the cancel a starting drag sends:
// by then the `DragArea` has the gesture and
// `lifted` is the mark that matters, so there
// is no scroll left to lose and nothing to
// keep the ring open for.
root.held-row = -1;
root.cell-press-ended();
}
}
@@ -3236,6 +3323,51 @@ export component LibraryGrid inherits Rectangle {
border-radius: 1.5px;
}
}
// **The photograph is in your hand now.**
//
// A ring that opens outward around the cell a press has held
// long enough to pick up (see `held-row`), so the wait the
// gesture needs has something to end in. Without it the user
// is holding a finger on glass with no way to know whether
// anything has happened — which is what made dragging feel
// like a coin toss.
//
// **Drawn after the cells, not on one.** Cells are one `for`,
// and z-order inside a `for` is the loop order — a cell that
// grew past its bounds would stand over its left and top
// neighbours and be cut off by its right and bottom ones,
// which reads as a rendering fault rather than as a lift. One
// element after the loop is above every cell by construction,
// and there is only ever one photograph in the hand.
//
// `visible` rather than `if`, so it has somewhere to animate
// *from*: an `if` builds the ring at its final size and it
// would appear rather than open.
//
// `Theme.active` and 3px, deliberately unlike the selection
// ring inside the cell — two marks that meant different things
// in one colour is the mistake the hover ring made.
Rectangle {
property <length> pitch: root.cell-size + Theme.gap;
property <length> reach: root.held-row >= 0 ? 5px : 0px;
visible: root.held-row >= 0;
x: Theme.gap
+ mod(root.held-row + root.offset, root.columns) * self.pitch
- self.reach;
y: Theme.gap
+ floor((root.held-row + root.offset) / root.columns) * self.pitch
- self.reach;
width: root.cell-size + 2 * self.reach;
height: root.cell-size + 2 * self.reach;
animate x, y, width, height { duration: 120ms; easing: ease-out; }
background: transparent;
border-width: 3px;
border-color: Theme.active;
border-radius: Theme.radius;
}
}
}