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
+36 -11
View File
@@ -599,8 +599,12 @@ pub fn commit_press(window: &AppWindow, ctl: &Rc<CollectionsController>, ids: &[
pub fn cancel_press(window: &AppWindow, ctl: &Rc<CollectionsController>, ids: &[ImageId]) {
// The press is being unmade, so what it left for the release to finish is
// unmade with it. Cleared before the early return: a press that changed
// nothing to undo can still have deferred a removal.
// nothing to undo can still have deferred a removal — and a finger that
// held long enough to pick a photograph up before the second one landed
// has to put it back down, or the ring stays open around a cell nobody is
// touching and the grid stays frozen with it.
ctl.pending_toggle.set(None);
window.set_library_held_row(-1);
let Some(undo) = ctl.press_undo.borrow_mut().take() else {
return;
@@ -1055,7 +1059,7 @@ pub(crate) const HOLD_DELAY_MS: u64 = 450;
/// press that armed it has *already* selected the cell under the finger, so
/// what firing adds is the mode: from here taps toggle rather than open, and
/// the header's buttons appear to act on what has been gathered.
fn arm_hold(window: &AppWindow, ctl: &Rc<CollectionsController>) {
fn arm_hold(window: &AppWindow, ctl: &Rc<CollectionsController>, row: i32) {
let timer = slint::Timer::default();
let weak = window.as_weak();
let ctl_cb = ctl.clone();
@@ -1065,8 +1069,22 @@ fn arm_hold(window: &AppWindow, ctl: &Rc<CollectionsController>) {
std::time::Duration::from_millis(HOLD_DELAY_MS),
move || {
let Some(w) = weak.upgrade() else { return };
ctl_cb.select_mode.set(true);
w.set_library_select_mode(true);
// TRACES: FR-CAT-7
// The photograph is in the user's hand: the grid draws a ring
// opening around it and stops scrolling underneath it, so the drag
// that may follow cannot be lost to a flick. See `held-row` in
// `library.slint`.
//
// This half happens whether or not selection mode was already on —
// it is the half a *drag* needs, and a drag out of a selection of
// forty starts in a mode that is already on.
w.set_library_held_row(row);
if !ctl_cb.select_mode.get() {
ctl_cb.select_mode.set(true);
w.set_library_select_mode(true);
}
// The release that follows this hold must not also open the image:
// the user asked for a selection and would land in develop instead.
@@ -1531,17 +1549,20 @@ pub fn wire<S, R, P, C>(
let offset = w.get_library_offset().max(0) as usize;
select_row(&w, &ctl, &ids, offset, row as usize, ctrl_held, shift_held);
// TRACES: FR-UI-2 | FR-UI-4
// TRACES: FR-UI-2 | FR-UI-4 | FR-CAT-7
// And start counting, in case this press is a hold. The press has
// already selected this one cell; what the hold adds is the *mode*,
// so the taps that follow go on selecting instead of opening the
// next photograph the user touches.
// next photograph the user touches — and the *pick-up*, which is
// what makes the drag reliable.
//
// Not started when the mode is already on: it is on, and a second
// hold would have nothing to do but suppress the tap that ends it.
if !ctl.select_mode.get() {
arm_hold(&w, &ctl);
}
// Armed even when the mode is already on, which it did not used to
// be: there was nothing left for the hold to switch on, so it was
// skipped. But the mode being on is exactly the state a
// multi-image drag starts from, and skipping the hold left that
// drag with no pick-up and no cue — the one gesture that most
// needed both. See `arm_hold`.
arm_hold(&w, &ctl, row);
});
}
@@ -2121,6 +2142,10 @@ pub fn wire<S, R, P, C>(
let landed = ctl.dropped_on.borrow_mut().take();
let to_trash = ctl.trash_requested.borrow_mut().take();
ctl.dragging.borrow_mut().clear();
// The grid clears this itself on the cancel that starts a drag;
// this is for the endings that reach no cell — a drop, or a drag
// abandoned over nothing.
w.set_library_held_row(-1);
*ctl.hover_id.borrow_mut() = None;
*ctl.spring_timer.borrow_mut() = None;
+7
View File
@@ -71,6 +71,13 @@ pub const GESTURES: &[Gesture] = &[
pointer: "Press Done in the header",
keys: "Escape",
},
Gesture {
title: "Pick a photograph up to drag it",
section: "Library grid",
touch: "Press and hold it until a ring opens around it, then drag",
pointer: "Drag it",
keys: "",
},
Gesture {
title: "Select a range",
section: "Library grid",
+8
View File
@@ -543,6 +543,13 @@ export component AppWindow inherits Window {
/// that turns it on. The long press does the same thing without it.
in property <bool> library-select-mode: false;
callback library-toggle-select-mode();
/// TRACES: FR-CAT-7 | FR-UI-4
/// The row a press has held long enough to pick up, or `-1`.
///
/// Set by the same hold that turns on selection mode and cleared by the
/// grid when the press ends — `in-out` because both ends write it. See
/// `held-row` in `library.slint` for what it draws and what it stops.
in-out property <int> library-held-row: -1;
/// A press on a cell ended, so the long-press timer can be cancelled.
callback library-cell-press-ended();
/// TRACES: FR-UI-2 | FR-UI-4
@@ -1579,6 +1586,7 @@ in property <bool> panel-visible: true;
cell-press-ended() => { root.library-cell-press-ended(); }
select-mode: root.library-select-mode;
toggle-select-mode() => { root.library-toggle-select-mode(); }
held-row <=> root.library-held-row;
// The sidebar's rows, not a second model: the sheet files
// into the same tree the sidebar draws.
collections: root.collection-rows;
+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;
}
}
}