Make a pinch zoom the grid and nothing else
Two faults left over from making the gesture reach the grid at all. **It still opened photographs.** Checking the finger id stops the synthetic release Slint emits when the *second* finger lands, but not the other end of the gesture: lifting one finger of two leaves the other one down, and Slint replays that survivor as a fresh `Pressed` on whatever is under it — which is how it hands the pointer back to ordinary handling. Under it is a cell. So the cell was selected, and lifting that last finger was a complete, well-formed click on the same finger that pressed. No part of the event stream distinguishes it from a real tap, so the grid now remembers that a pinch just happened: a latch raised when the gesture starts and lowered a beat after it ends, during which cells take neither presses nor clicks. The press that *opens* a pinch is undone rather than suppressed — it has already happened by the time a second finger makes it a pinch. Undoing it has to be exact, or a cancel that restored the selection but left the anchor moved would make the next shift-click select a run from a cell nobody pointed at, so capture and restore are a tested pair. **And it was not smooth.** Two reasons. The pinch was thresholded into ±1 steps of 25%, so the grid lurched and then sat still; it now takes the ratio since the last update and tracks the fingers, with the drawn cell still landing on whole column counts because the columns divide the width. And `zoom-cells` was the one geometry change still reloading inline — a full catalog re-read, 360-row model rebuild and thumbnail batch per step, on the thread drawing the frame. It goes through the same settle timer as the rest now.
This commit is contained in:
@@ -45,6 +45,47 @@ use slint::{ComponentHandle, Model as _};
|
|||||||
|
|
||||||
use crate::{AppWindow, CollectionRow};
|
use crate::{AppWindow, CollectionRow};
|
||||||
|
|
||||||
|
/// The selection as it stood before a press, for [`cancel_press`].
|
||||||
|
struct PressUndo {
|
||||||
|
selection: BTreeSet<ImageId>,
|
||||||
|
anchor: Option<usize>,
|
||||||
|
previous_anchor: Option<usize>,
|
||||||
|
cursor: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PressUndo {
|
||||||
|
/// Everything [`press_remembering_anchor`] is about to change.
|
||||||
|
///
|
||||||
|
/// The four are the whole of what a press touches, which is the property
|
||||||
|
/// the restore depends on and the reason it is worth a test of its own: an
|
||||||
|
/// press that grew a fifth piece of state would leave that piece behind
|
||||||
|
/// after a cancel, silently.
|
||||||
|
fn capture(
|
||||||
|
selection: &BTreeSet<ImageId>,
|
||||||
|
anchor: Option<usize>,
|
||||||
|
previous_anchor: Option<usize>,
|
||||||
|
cursor: Option<usize>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
selection: selection.clone(),
|
||||||
|
anchor,
|
||||||
|
previous_anchor,
|
||||||
|
cursor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Put it all back. Returns the two the caller holds in `Cell`s.
|
||||||
|
fn restore(
|
||||||
|
self,
|
||||||
|
selection: &mut BTreeSet<ImageId>,
|
||||||
|
anchor: &mut Option<usize>,
|
||||||
|
) -> (Option<usize>, Option<usize>) {
|
||||||
|
*selection = self.selection;
|
||||||
|
*anchor = self.anchor;
|
||||||
|
(self.previous_anchor, self.cursor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Selection, drag, and tree state for the running window.
|
/// Selection, drag, and tree state for the running window.
|
||||||
///
|
///
|
||||||
/// Everything is `RefCell` because Slint callbacks are `Fn`, not `FnMut`, and
|
/// Everything is `RefCell` because Slint callbacks are `Fn`, not `FnMut`, and
|
||||||
@@ -80,6 +121,19 @@ pub struct CollectionsController {
|
|||||||
/// double tap — which lands on the cell that is already the anchor — leaves
|
/// double tap — which lands on the cell that is already the anchor — leaves
|
||||||
/// it pointing where the first tap left it.
|
/// it pointing where the first tap left it.
|
||||||
previous_anchor: std::cell::Cell<Option<usize>>,
|
previous_anchor: std::cell::Cell<Option<usize>>,
|
||||||
|
/// TRACES: FR-UI-4
|
||||||
|
/// What the selection was immediately before the most recent press, so a
|
||||||
|
/// gesture that turns out not to have been a press can put it back.
|
||||||
|
///
|
||||||
|
/// A press has to act immediately — the drag that may follow reads the
|
||||||
|
/// selection to build its payload, so deciding on release is too late.
|
||||||
|
/// That is right for a drag and wrong for a pinch, which begins as an
|
||||||
|
/// ordinary one-finger press and only becomes a pinch when the second
|
||||||
|
/// finger lands. By then a cell has been selected that the user never
|
||||||
|
/// meant to touch; they were reaching for the grid with two fingers.
|
||||||
|
///
|
||||||
|
/// Cheap to keep: a few hundred ids at most, cloned once per press.
|
||||||
|
press_undo: RefCell<Option<PressUndo>>,
|
||||||
/// Where the keyboard is, as an image ordinal.
|
/// Where the keyboard is, as an image ordinal.
|
||||||
///
|
///
|
||||||
/// Distinct from the anchor, and it has to be: shift+arrow grows a range
|
/// Distinct from the anchor, and it has to be: shift+arrow grows a range
|
||||||
@@ -452,6 +506,15 @@ pub fn select_row(
|
|||||||
ctrl: bool,
|
ctrl: bool,
|
||||||
shift: bool,
|
shift: bool,
|
||||||
) {
|
) {
|
||||||
|
// Before anything moves — see `press_undo`. A press that turns out to be
|
||||||
|
// the opening finger of a pinch is undone from here.
|
||||||
|
*ctl.press_undo.borrow_mut() = Some(PressUndo::capture(
|
||||||
|
&ctl.selection.borrow(),
|
||||||
|
*ctl.anchor.borrow(),
|
||||||
|
ctl.previous_anchor.get(),
|
||||||
|
ctl.cursor(),
|
||||||
|
));
|
||||||
|
|
||||||
let mut previous = ctl.previous_anchor.get();
|
let mut previous = ctl.previous_anchor.get();
|
||||||
press_remembering_anchor(
|
press_remembering_anchor(
|
||||||
&mut ctl.selection.borrow_mut(),
|
&mut ctl.selection.borrow_mut(),
|
||||||
@@ -471,6 +534,27 @@ pub fn select_row(
|
|||||||
sync_selection(window, ctl, ids);
|
sync_selection(window, ctl, ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-UI-4
|
||||||
|
/// Put the selection back as it was before the most recent press.
|
||||||
|
///
|
||||||
|
/// For the gesture that begins as a press and turns out to be something else.
|
||||||
|
/// A pinch is the case that matters: it starts as one finger on a cell, which
|
||||||
|
/// selects it, and only becomes a pinch when the second finger lands — by
|
||||||
|
/// which point the user has selected a photograph they were not reaching for.
|
||||||
|
///
|
||||||
|
/// Idempotent, and a no-op when there is nothing to undo, so it is safe to
|
||||||
|
/// call on every gesture start rather than only on the ones that need it.
|
||||||
|
pub fn cancel_press(window: &AppWindow, ctl: &Rc<CollectionsController>, ids: &[ImageId]) {
|
||||||
|
let Some(undo) = ctl.press_undo.borrow_mut().take() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (previous_anchor, cursor) =
|
||||||
|
undo.restore(&mut ctl.selection.borrow_mut(), &mut ctl.anchor.borrow_mut());
|
||||||
|
ctl.previous_anchor.set(previous_anchor);
|
||||||
|
ctl.set_cursor(cursor);
|
||||||
|
sync_selection(window, ctl, ids);
|
||||||
|
}
|
||||||
|
|
||||||
/// Rebuild the sidebar from the catalog.
|
/// Rebuild the sidebar from the catalog.
|
||||||
///
|
///
|
||||||
/// Called after every edit. The whole tree rather than a patch: a rename can
|
/// Called after every edit. The whole tree rather than a patch: a rename can
|
||||||
@@ -2581,6 +2665,38 @@ mod tests {
|
|||||||
assert_eq!(sel.len(), 3, "the selection survived the press");
|
assert_eq!(sel.len(), 3, "the selection survived the press");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_press_undone_leaves_the_selection_exactly_as_it_was() {
|
||||||
|
// A pinch opens as one finger on a cell, so by the time it is known to
|
||||||
|
// be a pinch a photograph has been selected that the user was only
|
||||||
|
// reaching past. Undoing it has to be *exact*: a cancel that restored
|
||||||
|
// the selection but left the anchor moved would make the next
|
||||||
|
// shift-click select a run from a cell nobody pointed at.
|
||||||
|
let all = ids(6);
|
||||||
|
let mut sel = BTreeSet::new();
|
||||||
|
let mut anchor = None;
|
||||||
|
|
||||||
|
// A selection built the ordinary way, and the state it leaves behind.
|
||||||
|
apply_press(&mut sel, &mut anchor, &all, 0, 1, false, false);
|
||||||
|
apply_press(&mut sel, &mut anchor, &all, 0, 3, true, false);
|
||||||
|
let before = (sel.clone(), anchor);
|
||||||
|
|
||||||
|
// The finger that opens a pinch.
|
||||||
|
let undo = PressUndo::capture(&sel, anchor, Some(1), Some(3));
|
||||||
|
apply_press(&mut sel, &mut anchor, &all, 0, 5, false, false);
|
||||||
|
assert_ne!(
|
||||||
|
(sel.clone(), anchor),
|
||||||
|
before,
|
||||||
|
"the press has to change something, or this proves nothing"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (previous_anchor, cursor) = undo.restore(&mut sel, &mut anchor);
|
||||||
|
|
||||||
|
assert_eq!((sel, anchor), before, "selection and anchor are back");
|
||||||
|
assert_eq!(previous_anchor, Some(1), "and so is the shift-click origin");
|
||||||
|
assert_eq!(cursor, Some(3), "and the keyboard cursor");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_press_past_the_end_of_the_window_is_ignored() {
|
fn a_press_past_the_end_of_the_window_is_ignored() {
|
||||||
// The grid is windowed and a stale row index can arrive after a scrub.
|
// The grid is windowed and a stale row index can arrive after a scrub.
|
||||||
|
|||||||
@@ -3607,7 +3607,57 @@ pub fn wire<F>(
|
|||||||
// of the request key, so cells that now want the large resolution
|
// of the request key, so cells that now want the large resolution
|
||||||
// simply miss and ask for it, while the 256px ones they already
|
// simply miss and ask for it, while the 256px ones they already
|
||||||
// hold stay served.
|
// hold stay served.
|
||||||
load_window(&w, &ctl);
|
//
|
||||||
|
// Deferred, like every other geometry change. This one was still
|
||||||
|
// reloading inline — a full catalog re-read and model rebuild per
|
||||||
|
// step, which is what a wheel spun through six steps paid six
|
||||||
|
// times over.
|
||||||
|
schedule_reload(&w, &ctl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-UI-4
|
||||||
|
// A pinch, which is continuous where the wheel is stepped.
|
||||||
|
//
|
||||||
|
// Given the ratio since the last update rather than a direction, so the
|
||||||
|
// grid tracks the fingers instead of jumping a fixed 25% per threshold
|
||||||
|
// crossing. What the user is setting is the size class — how big they want
|
||||||
|
// a thumbnail to be — and the drawn cell follows from it by dividing the
|
||||||
|
// width, so the visible result still lands on whole column counts. Feeding
|
||||||
|
// a continuous value in is what decides *when* it crosses.
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let ctl = ctl.clone();
|
||||||
|
window.on_library_pinch_cells(move |ratio| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
if !(ratio.is_finite() && ratio > 0.0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = w.get_library_cell_size();
|
||||||
|
let next = (current * ratio).clamp(MIN_CELL_SIZE, MAX_CELL_SIZE);
|
||||||
|
if (next - current).abs() < 0.5 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
w.set_library_cell_size(next);
|
||||||
|
schedule_reload(&w, &ctl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-UI-4
|
||||||
|
// A pinch has begun, so the press that started it was not a press.
|
||||||
|
//
|
||||||
|
// A pinch opens as one finger on a cell — which selects it — and only
|
||||||
|
// becomes a pinch when the second lands. Without this the user is left
|
||||||
|
// holding a selection they never made, on a photograph they were only
|
||||||
|
// reaching past.
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let ctl = ctl.clone();
|
||||||
|
let coll = coll_ctl.clone();
|
||||||
|
window.on_library_pinch_started(move || {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
crate::collections_ui::cancel_press(&w, &coll, &ctl.visible_ids());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -508,6 +508,10 @@ export component AppWindow inherits Window {
|
|||||||
in property <bool> library-syncing: false;
|
in property <bool> library-syncing: false;
|
||||||
in-out property <length> library-cell-size: 180px;
|
in-out property <length> library-cell-size: 180px;
|
||||||
callback library-zoom-cells(int);
|
callback library-zoom-cells(int);
|
||||||
|
/// A pinch step on the grid: the ratio since the last update.
|
||||||
|
callback library-pinch-cells(float);
|
||||||
|
/// A pinch on the grid has begun, so the press that opened it was not one.
|
||||||
|
callback library-pinch-started();
|
||||||
in property <int> library-scroll-to: 0;
|
in property <int> library-scroll-to: 0;
|
||||||
in property <int> library-scroll-token: 0;
|
in property <int> library-scroll-token: 0;
|
||||||
/// Which row of the loaded window is the photograph currently open in
|
/// Which row of the loaded window is the photograph currently open in
|
||||||
@@ -1130,6 +1134,8 @@ in property <bool> panel-visible: true;
|
|||||||
syncing: root.library-syncing;
|
syncing: root.library-syncing;
|
||||||
requested-cell-size: root.library-cell-size;
|
requested-cell-size: root.library-cell-size;
|
||||||
zoom-cells(d) => { root.library-zoom-cells(d); }
|
zoom-cells(d) => { root.library-zoom-cells(d); }
|
||||||
|
pinch-cells(r) => { root.library-pinch-cells(r); }
|
||||||
|
pinch-started() => { root.library-pinch-started(); }
|
||||||
scroll-to: root.library-scroll-to;
|
scroll-to: root.library-scroll-to;
|
||||||
scroll-token: root.library-scroll-token;
|
scroll-token: root.library-scroll-token;
|
||||||
sync-now() => { root.library-sync-now(); }
|
sync-now() => { root.library-sync-now(); }
|
||||||
|
|||||||
+68
-19
@@ -860,6 +860,11 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
/// Ctrl+wheel or pinch over the grid: resize the cells. A signed step,
|
/// Ctrl+wheel or pinch over the grid: resize the cells. A signed step,
|
||||||
/// not a size, so Rust owns the bounds.
|
/// not a size, so Rust owns the bounds.
|
||||||
callback zoom-cells(int);
|
callback zoom-cells(int);
|
||||||
|
/// A pinch step: the ratio since the last update, above 1 spreading. The
|
||||||
|
/// continuous counterpart of `zoom-cells`, which the wheel steps.
|
||||||
|
callback pinch-cells(float);
|
||||||
|
/// A pinch has begun, so the press that opened it was not a press.
|
||||||
|
callback pinch-started();
|
||||||
callback columns-changed(int);
|
callback columns-changed(int);
|
||||||
callback sync-now();
|
callback sync-now();
|
||||||
/// The grid scrolled: the first visible image's ordinal in the library.
|
/// The grid scrolled: the first visible image's ordinal in the library.
|
||||||
@@ -1167,6 +1172,30 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
changed columns => { root.columns-changed(root.columns); }
|
changed columns => { root.columns-changed(root.columns); }
|
||||||
property <int> row-count: ceil(root.cells.length / max(1, columns));
|
property <int> row-count: ceil(root.cells.length / max(1, columns));
|
||||||
|
|
||||||
|
// --- while a pinch is happening, and just after -----------------------
|
||||||
|
//
|
||||||
|
// **A pinch does not end cleanly.** Lifting one finger of two leaves the
|
||||||
|
// other one down, and Slint replays that survivor as a *fresh* `Pressed`
|
||||||
|
// on whatever is under it — that is how it hands the pointer back to
|
||||||
|
// ordinary handling. Under it is a cell. So the cell was selected, and
|
||||||
|
// then lifting that last finger was a complete, well-formed click and the
|
||||||
|
// photograph opened. Checking the finger id, which is what stops the
|
||||||
|
// *second* finger's synthetic release from opening anything, cannot help
|
||||||
|
// here: this press and this release are genuinely the same finger.
|
||||||
|
//
|
||||||
|
// Nothing in the event stream distinguishes that survivor from a real tap,
|
||||||
|
// so the grid has to remember that a pinch just happened. The latch is
|
||||||
|
// raised when the gesture starts and lowered a beat after it ends.
|
||||||
|
property <bool> pinching: false;
|
||||||
|
settle := Timer {
|
||||||
|
interval: 350ms;
|
||||||
|
running: false;
|
||||||
|
triggered => {
|
||||||
|
root.pinching = false;
|
||||||
|
self.running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// How many cells the viewport holds, plus a screenful either side so
|
// How many cells the viewport holds, plus a screenful either side so
|
||||||
// scrolling has loaded rows to move into rather than blank ones.
|
// scrolling has loaded rows to move into rather than blank ones.
|
||||||
//
|
//
|
||||||
@@ -1884,21 +1913,34 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
|
|
||||||
property <float> last-scale: 1.0;
|
property <float> last-scale: 1.0;
|
||||||
|
|
||||||
started => { self.last-scale = 1.0; }
|
started => {
|
||||||
updated => {
|
self.last-scale = 1.0;
|
||||||
// A quarter-step either way is enough to act on: cell
|
root.pinching = true;
|
||||||
// size is continuous, unlike the timeline's discrete
|
settle.running = false;
|
||||||
// zoom levels.
|
// The finger that opened this gesture landed on a cell
|
||||||
if (self.scale / max(0.01, self.last-scale) > 1.15) {
|
// and selected it. It was reaching for the grid, not
|
||||||
root.zoom-cells(1);
|
// for that photograph.
|
||||||
self.last-scale = self.scale;
|
root.pinch-started();
|
||||||
} else if (self.scale / max(0.01, self.last-scale) < 0.87) {
|
}
|
||||||
root.zoom-cells(-1);
|
// Continuous, not stepped. Thresholding this into ±1 zoom
|
||||||
self.last-scale = self.scale;
|
// steps meant the grid lurched 25% at a time and sat still
|
||||||
}
|
// in between, which is the whole of "pinching is not
|
||||||
|
// smooth". The ratio since the last update is what tracks
|
||||||
|
// the fingers; where the drawn cell lands is still a whole
|
||||||
|
// number of columns, because the columns divide the width.
|
||||||
|
updated => {
|
||||||
|
root.pinch-cells(self.scale / max(0.01, self.last-scale));
|
||||||
|
self.last-scale = self.scale;
|
||||||
|
}
|
||||||
|
// The latch outlives the gesture — see `pinching`.
|
||||||
|
ended => {
|
||||||
|
self.last-scale = 1.0;
|
||||||
|
settle.running = true;
|
||||||
|
}
|
||||||
|
cancelled => {
|
||||||
|
self.last-scale = 1.0;
|
||||||
|
settle.running = true;
|
||||||
}
|
}
|
||||||
ended => { self.last-scale = 1.0; }
|
|
||||||
cancelled => { self.last-scale = 1.0; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Follow a requested position. Without this a scrub moves the
|
// Follow a requested position. Without this a scrub moves the
|
||||||
@@ -2160,11 +2202,17 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
if (ev.kind == PointerEventKind.down) {
|
if (ev.kind == PointerEventKind.down) {
|
||||||
self.down-finger = ev.touch-finger-id;
|
self.down-finger = ev.touch-finger-id;
|
||||||
self.click-pending = false;
|
self.click-pending = false;
|
||||||
root.cell-pressed(
|
// Not while the grid is being pinched, nor in
|
||||||
i,
|
// the moment after: this is the finger left
|
||||||
ev.modifiers.control || root.select-mode,
|
// over from the gesture, handed back as a new
|
||||||
ev.modifiers.shift,
|
// press. See `pinching`.
|
||||||
);
|
if (!root.pinching) {
|
||||||
|
root.cell-pressed(
|
||||||
|
i,
|
||||||
|
ev.modifiers.control || root.select-mode,
|
||||||
|
ev.modifiers.shift,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (ev.kind == PointerEventKind.up) {
|
if (ev.kind == PointerEventKind.up) {
|
||||||
// A *plain* click opens the image; a modified
|
// A *plain* click opens the image; a modified
|
||||||
@@ -2176,6 +2224,7 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
// is only honoured when the press was
|
// is only honoured when the press was
|
||||||
// unmodified.
|
// unmodified.
|
||||||
if (self.click-pending
|
if (self.click-pending
|
||||||
|
&& !root.pinching
|
||||||
&& ev.touch-finger-id == self.down-finger) {
|
&& ev.touch-finger-id == self.down-finger) {
|
||||||
root.cell-clicked(i);
|
root.cell-clicked(i);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user