From 2fba685e168fd7827c55d619b8f47834745db807 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Fri, 21 Aug 2026 22:03:46 +0200 Subject: [PATCH] Make a pinch zoom the grid and nothing else MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ui/dr-ui/src/collections_ui.rs | 116 +++++++++++++++++++++++++++++++++ ui/dr-ui/src/library_ui.rs | 52 ++++++++++++++- ui/dr-ui/ui/app.slint | 6 ++ ui/dr-ui/ui/library.slint | 87 +++++++++++++++++++------ 4 files changed, 241 insertions(+), 20 deletions(-) diff --git a/ui/dr-ui/src/collections_ui.rs b/ui/dr-ui/src/collections_ui.rs index 50d8226..d1036e7 100644 --- a/ui/dr-ui/src/collections_ui.rs +++ b/ui/dr-ui/src/collections_ui.rs @@ -45,6 +45,47 @@ use slint::{ComponentHandle, Model as _}; use crate::{AppWindow, CollectionRow}; +/// The selection as it stood before a press, for [`cancel_press`]. +struct PressUndo { + selection: BTreeSet, + anchor: Option, + previous_anchor: Option, + cursor: Option, +} + +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, + anchor: Option, + previous_anchor: Option, + cursor: Option, + ) -> 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, + anchor: &mut Option, + ) -> (Option, Option) { + *selection = self.selection; + *anchor = self.anchor; + (self.previous_anchor, self.cursor) + } +} + /// Selection, drag, and tree state for the running window. /// /// 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 /// it pointing where the first tap left it. previous_anchor: std::cell::Cell>, + /// 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>, /// Where the keyboard is, as an image ordinal. /// /// Distinct from the anchor, and it has to be: shift+arrow grows a range @@ -452,6 +506,15 @@ pub fn select_row( ctrl: 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(); press_remembering_anchor( &mut ctl.selection.borrow_mut(), @@ -471,6 +534,27 @@ pub fn select_row( 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, 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. /// /// 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"); } + #[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] 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. diff --git a/ui/dr-ui/src/library_ui.rs b/ui/dr-ui/src/library_ui.rs index f59dc1b..8b29283 100644 --- a/ui/dr-ui/src/library_ui.rs +++ b/ui/dr-ui/src/library_ui.rs @@ -3607,7 +3607,57 @@ pub fn wire( // of the request key, so cells that now want the large resolution // simply miss and ask for it, while the 256px ones they already // 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()); }); } diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index 07d97ba..d2fb338 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -508,6 +508,10 @@ export component AppWindow inherits Window { in property library-syncing: false; in-out property library-cell-size: 180px; 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 library-scroll-to: 0; in property library-scroll-token: 0; /// Which row of the loaded window is the photograph currently open in @@ -1130,6 +1134,8 @@ in property panel-visible: true; syncing: root.library-syncing; requested-cell-size: root.library-cell-size; 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-token: root.library-scroll-token; sync-now() => { root.library-sync-now(); } diff --git a/ui/dr-ui/ui/library.slint b/ui/dr-ui/ui/library.slint index bfe06c0..83ae0e6 100644 --- a/ui/dr-ui/ui/library.slint +++ b/ui/dr-ui/ui/library.slint @@ -860,6 +860,11 @@ export component LibraryGrid inherits Rectangle { /// Ctrl+wheel or pinch over the grid: resize the cells. A signed step, /// not a size, so Rust owns the bounds. 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 sync-now(); /// 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); } property 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 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 // scrolling has loaded rows to move into rather than blank ones. // @@ -1884,21 +1913,34 @@ export component LibraryGrid inherits Rectangle { property last-scale: 1.0; - started => { self.last-scale = 1.0; } - updated => { - // A quarter-step either way is enough to act on: cell - // size is continuous, unlike the timeline's discrete - // zoom levels. - if (self.scale / max(0.01, self.last-scale) > 1.15) { - root.zoom-cells(1); - self.last-scale = self.scale; - } else if (self.scale / max(0.01, self.last-scale) < 0.87) { - root.zoom-cells(-1); - self.last-scale = self.scale; - } + started => { + self.last-scale = 1.0; + root.pinching = true; + settle.running = false; + // The finger that opened this gesture landed on a cell + // and selected it. It was reaching for the grid, not + // for that photograph. + root.pinch-started(); + } + // Continuous, not stepped. Thresholding this into ±1 zoom + // 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 @@ -2160,11 +2202,17 @@ export component LibraryGrid inherits Rectangle { if (ev.kind == PointerEventKind.down) { self.down-finger = ev.touch-finger-id; self.click-pending = false; - root.cell-pressed( - i, - ev.modifiers.control || root.select-mode, - ev.modifiers.shift, - ); + // Not while the grid is being pinched, nor in + // the moment after: this is the finger left + // over from the gesture, handed back as a new + // press. See `pinching`. + if (!root.pinching) { + root.cell-pressed( + i, + ev.modifiers.control || root.select-mode, + ev.modifiers.shift, + ); + } } if (ev.kind == PointerEventKind.up) { // A *plain* click opens the image; a modified @@ -2176,6 +2224,7 @@ export component LibraryGrid inherits Rectangle { // is only honoured when the press was // unmodified. if (self.click-pending + && !root.pinching && ev.touch-finger-id == self.down-finger) { root.cell-clicked(i); }