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:
2026-08-21 22:03:46 +02:00
parent 6ae0af3f72
commit 2fba685e16
4 changed files with 241 additions and 20 deletions
+116
View File
@@ -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<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.
///
/// 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<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.
///
/// 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<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.
///
/// 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.
+51 -1
View File
@@ -3607,7 +3607,57 @@ pub fn wire<F>(
// 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());
});
}