Give the drag a pick-up, so it stops losing to the scroll

Dragging a photograph out of the grid worked about half the time, and
nothing on the screen explained the other half.

`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 holds that claim until the finger
lifts. So a drag toward the sidebar only ever began two ways: a flick
sideways clean enough that the finger never wandered eight pixels
vertically, or a wait of half a second before moving at all. Both are
real gestures and neither was written down.

The wait is now the gesture, and it has a mark. The long press that
already turns on selection mode also picks the photograph up: a ring
opens around the cell and the grid stops scrolling under it, so from
that moment the drag is the only thing the finger can be doing. The cue
can only arrive after the ambiguity has passed, which is the right way
round — when the photograph lifts, dragging it works.

Two details worth naming. The hold is now armed even when selection mode
is already on; it used to be skipped there, on the grounds that there
was no mode left to switch on — but that is precisely the state a
forty-image drag starts from, so the one gesture that most needed a
pick-up was the one with none. And the ring is drawn after the cell
loop rather than on the cell: z-order inside a `for` is loop order, so a
cell grown past its bounds would stand over two neighbours and be cut
off by the other two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 13:20:56 +02:00
co-authored by Claude Opus 5
parent 7b5f62019b
commit b34e786f01
6 changed files with 195 additions and 43 deletions
+107 -4
View File
@@ -1295,6 +1295,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) --------------------------
//
@@ -2695,11 +2729,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
@@ -3243,6 +3293,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
@@ -3251,6 +3303,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();
}
}
@@ -3424,6 +3482,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;
}
}
}