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:
+18
-9
@@ -5,7 +5,7 @@
|
||||
|
||||
Every entry here is extracted from the comment beside the code that implements it, so this file cannot describe a gesture the application does not have. Add one by writing a `GESTURE:` block next to the implementation; there is nowhere else to write it.
|
||||
|
||||
15 gestures, in 2 places.
|
||||
16 gestures, in 2 places.
|
||||
|
||||
## People
|
||||
|
||||
@@ -73,6 +73,15 @@ While selecting, a tap never opens. That is the whole point of the mode: one mea
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:1291`</sub>
|
||||
|
||||
### Pick a photograph up to drag it
|
||||
|
||||
- **Touch** — Press and hold it until a ring opens around it, then drag
|
||||
- **Pointer** — Drag it
|
||||
|
||||
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.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:1321`</sub>
|
||||
|
||||
### Select a range
|
||||
|
||||
- **Touch** — While selecting, press "Select to…", then tap the last photograph of the run
|
||||
@@ -80,7 +89,7 @@ While selecting, a tap never opens. That is the whole point of the mode: one mea
|
||||
|
||||
This replaced a double tap, which had no visible state and could take forty photographs by accident. The run is resolved by the catalog rather than by what is on screen, so the grid can scroll between the two taps — the ranges that hurt on a tablet are longer than a screenful, which is exactly where a finger sweep runs out.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:1352`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:1386`</sub>
|
||||
|
||||
### Find photographs with two people in them
|
||||
|
||||
@@ -89,7 +98,7 @@ This replaced a double tap, which had no visible state and could take forty phot
|
||||
|
||||
"Any of them" is a union and "all of them" is an intersection. The tray is where both terms and the choice between them live, because a filter belongs on the filter bar.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:2107`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:2141`</sub>
|
||||
|
||||
### Resize the thumbnails
|
||||
|
||||
@@ -98,7 +107,7 @@ This replaced a double tap, which had no visible state and could take forty phot
|
||||
|
||||
There is no wheel on a tablet, so without the pinch the cell size could only be changed by a control a finger cannot reach.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:2733`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:2783`</sub>
|
||||
|
||||
### File photographs in a collection
|
||||
|
||||
@@ -107,7 +116,7 @@ There is no wheel on a tablet, so without the pinch the cell size could only be
|
||||
|
||||
The selection is what the drag carries, which is why selecting several is worth the mode: forty photographs file in one gesture.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:2892`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:2942`</sub>
|
||||
|
||||
### Open a photograph
|
||||
|
||||
@@ -116,7 +125,7 @@ The selection is what the drag carries, which is why selecting several is worth
|
||||
|
||||
A tap opens; a tap that *moved* does not. Travel is what separates a deliberate tap from a hand brushing past, and it is the only thing that does: the two are the same length. An earlier version required the finger to dwell 120 ms instead, and that rejected ordinary taps — a real tap is often quicker than a brush.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:3160`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:3210`</sub>
|
||||
|
||||
### Rate a photograph without opening it
|
||||
|
||||
@@ -126,7 +135,7 @@ A tap opens; a tap that *moved* does not. Travel is what separates a deliberate
|
||||
|
||||
A star has to take the press without it also reaching the cell, or every rating throws the user into develop.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:3272`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:3330`</sub>
|
||||
|
||||
### Drop the selection but keep selecting
|
||||
|
||||
@@ -135,7 +144,7 @@ A star has to take the press without it also reaching the cell, or every rating
|
||||
|
||||
Distinct from Done, which leaves the mode entirely. Clearing keeps it, so the next selection can start straight away.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:3835`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:3938`</sub>
|
||||
|
||||
### Select everything the grid is showing
|
||||
|
||||
@@ -144,4 +153,4 @@ Distinct from Done, which leaves the mode entirely. Clearing keeps it, so the ne
|
||||
|
||||
A scoped grid of two hundred frames is two hundred taps otherwise, and "all of them, except those three" is a far more common shape than the taps it took to say it.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:3852`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:3955`</sub>
|
||||
|
||||
+19
-19
File diff suppressed because one or more lines are too long
@@ -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;
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user