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",