//! A press, and the release that follows it: what selects, what waits for //! the tap that confirms it, and how a gesture that turns out not to have //! been a press is undone. use std::collections::BTreeSet; use std::rc::Rc; use dr_types::ImageId; use slint::ComponentHandle as _; use crate::{AppWindow, Library}; use super::controller::CollectionsController; use super::tree_sync::sync_selection; /// The selection as it stood before a press, for [`cancel_press`]. pub(super) struct PressUndo { selection: BTreeSet, anchor: Option, cursor: Option, } impl PressUndo { /// Everything [`select_row`] is about to change. /// /// The three 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: a /// press that grew a fourth piece of state would leave that piece behind /// after a cancel, silently. fn capture( selection: &BTreeSet, anchor: Option, cursor: Option, ) -> Self { Self { selection: selection.clone(), anchor, cursor, } } /// Put it all back. Returns the one the caller holds in a `Cell`. fn restore( self, selection: &mut BTreeSet, anchor: &mut Option, ) -> Option { *selection = self.selection; *anchor = self.anchor; self.cursor } } /// TRACES: FR-CAT-7 | FR-UI-4 /// What a press did, and what is left for the release to do. /// /// Only one press has anything left over, and it is the one every multi-image /// drag depends on — see [`Press::Deferred`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[must_use] pub enum Press { /// The selection is already what this press means. Nothing to finish. Applied, /// **Taking a photograph out of the selection waits for the release.** /// /// Putting one *in* has to happen on the press: the drag that may follow /// reads the selection to decide what it carries, and by the time the /// finger lifts it is over the sidebar. Taking one out is the opposite — /// nothing between the press and the release needs the image gone, and one /// thing very much needs it to stay. /// /// A plain press has said so since selection was written: pressing an /// already-selected cell leaves the selection alone. Ctrl did not, and on a /// tablet *every* press is a ctrl-press — that is what selection mode is. /// So grabbing one of forty selected photographs deselected it on the way /// down; the drag that followed found the cell under the finger no longer /// in the selection, took that to mean an unselected image was being /// dragged, and carried it alone. Forty photographs became one, and the /// only clue was the grabbed cell's ring blinking out. /// /// So the removal is handed back for the *click* to apply, and a click /// fires only for a press that stayed put — never for one that became a /// drag. See `tap-slop` in `library.slint`. Deferred(ImageId), } /// Apply a press to the selection. /// /// Split from the callback so the policy is testable without a window: this is /// the part a user notices being wrong. /// /// - **plain** — replace the selection with this one image /// - **ctrl** — add this image to the selection, keeping the rest, and move the /// anchor here; taking one *out* is [deferred](Press::Deferred) to the release /// - **shift** — select the range from the anchor to here, *replacing* what was /// selected; the anchor stays put, so an overshoot is corrected by /// shift-clicking the right cell rather than starting again /// - **ctrl+shift** — the same range, *added* to the selection, for picking up a /// second run without losing the first /// /// A press on an image that is *already* selected never removes it here — /// plainly or with ctrl. That is what makes dragging a multi-selection possible /// at all: the press that begins the drag would otherwise take the grabbed /// photograph out from under it. /// /// `ids` is the **loaded window**, `offset` where it starts in the library and /// `row` a position within it; the anchor is kept as `offset + row`, an /// ordinal that still names the same photograph after the window has moved. /// /// `span` turns a run of ordinals into the ids it names — the catalog's job, /// since the run is mostly not loaded. Passed in rather than reached for so /// this stays a pure function of what it is given. #[allow(clippy::too_many_arguments)] pub fn apply_press( selection: &mut BTreeSet, anchor: &mut Option, ids: &[ImageId], offset: usize, row: usize, ctrl: bool, shift: bool, span: &dyn Fn(usize, usize) -> Vec, ) -> Press { let Some(&id) = ids.get(row) else { return Press::Applied; }; let here = offset + row; if shift { let Some(from) = *anchor else { // No anchor to extend from: behave like a plain click and become // the anchor, so the *next* shift-click has a range to describe. selection.clear(); selection.insert(id); *anchor = Some(here); return Press::Applied; }; // The anchor deliberately does **not** move. Shift-clicking again // re-describes the range from the same origin, so a user who overshoots // corrects by shift-clicking the right cell rather than starting over. // That also means the previous range must be cleared first — extending // without clearing turns a correction into a union, and the user ends up // dragging cells they thought they had deselected. // // Ctrl+shift is the exception: it *adds* a range to what is already // selected, which is how a second run is picked up without losing the // first. if !ctrl { selection.clear(); } let (lo, hi) = if from <= here { (from, here) } else { (here, from) }; // The run is described in ordinals and resolved by the catalog, which // is the only thing that knows what lies between them. Selection is by // id, and an image outside the loaded window has no id here — so an // earlier version truncated the run to what was on screen, and // shift-clicking two ends of a morning selected the dozen cells that // happened to be loaded. The user cannot see that they did not get // what they asked for until the drop files a dozen photographs // instead of two hundred. let mut run = span(lo, hi); if run.is_empty() { // Nothing to ask — no library open, or a query that failed. The // loaded window is a poorer answer than the catalog's and a far // better one than selecting nothing. // // The slice is always in range and needs no guard: `ids.get(row)` // succeeded, so the window is non-empty and `here` is inside it, // and `here` is one of the two bounds. let last = ids.len() - 1; let lo_row = lo.saturating_sub(offset); let hi_row = hi.saturating_sub(offset).min(last); run = ids[lo_row..=hi_row].to_vec(); } selection.extend(run); return Press::Applied; } if ctrl { *anchor = Some(here); // Taking one out waits for the release — see [`Press::Deferred`]. if selection.contains(&id) { return Press::Deferred(id); } selection.insert(id); return Press::Applied; } // Plain press on something already selected: leave it. The drag that may // follow carries the whole selection, and collapsing it here would make a // multi-image drag impossible to start. if selection.contains(&id) { *anchor = Some(here); return Press::Applied; } selection.clear(); selection.insert(id); *anchor = Some(here); Press::Applied } /// Apply a press and push the result into the grid — the whole of what a /// click, or an arrow key, does to the selection. /// /// Shared so the keyboard and the pointer cannot drift: they are the same /// gesture reached two ways, and the moment one of them grew its own copy of /// the selection policy, "click here, shift+down twice" would stop meaning /// what "click here, shift-click there" means. pub fn select_row( window: &AppWindow, ctl: &Rc, ids: &[ImageId], offset: usize, row: usize, 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.cursor(), )); // Whatever the last press left pending is answered by this one: two // presses without a click in between means the first never was a tap. ctl.pending_toggle.set(None); if let Press::Deferred(id) = apply_press( &mut ctl.selection.borrow_mut(), &mut ctl.anchor.borrow_mut(), ids, offset, row, ctrl, shift, &|first, last| ctl.span(first, last), ) { ctl.pending_toggle.set(Some(id)); } // The cursor follows the press, so an arrow key after a click continues // from the cell that was clicked rather than from wherever the keyboard // was last. ctl.set_cursor(Some(offset + row)); sync_selection(window, ctl, ids); } /// TRACES: FR-CAT-7 | FR-UI-4 /// Finish a press that turned out to be a tap. /// /// The other half of [`Press::Deferred`]: a ctrl-press on an already-selected /// photograph leaves it in, because the drag that may follow has to be able to /// carry it, and this takes it out again once the release has proved there was /// no drag. /// /// Called from the click, which Slint reports only for a press that stayed /// within `tap-slop` of where it landed — so a press that became a drag never /// reaches here, and a press taken over by the Flickable never reaches here /// either. Both leave the pending removal to be dropped by the next press. pub fn commit_press(window: &AppWindow, ctl: &Rc, ids: &[ImageId]) { let Some(id) = ctl.pending_toggle.take() else { return; }; ctl.selection.borrow_mut().remove(&id); 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]) { // 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 — 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.global::().set_library_held_row(-1); let Some(undo) = ctl.press_undo.borrow_mut().take() else { return; }; let cursor = undo.restore( &mut ctl.selection.borrow_mut(), &mut ctl.anchor.borrow_mut(), ); ctl.set_cursor(cursor); sync_selection(window, ctl, ids); } #[cfg(test)] mod tests { use crate::collections_ui::test_support::*; use super::*; /// A press and the release that follows it — which is what a click is. /// /// These tests are about what a *user* sees, and a user only ever presses /// and lets go. Calling [`apply_press`] alone would drop the half of the /// policy that waits for the release ([`Press::Deferred`]) and quietly /// assert the wrong thing about ctrl. #[allow(clippy::too_many_arguments)] fn click( selection: &mut BTreeSet, anchor: &mut Option, ids: &[ImageId], offset: usize, row: usize, ctrl: bool, shift: bool, span: &dyn Fn(usize, usize) -> Vec, ) { if let Press::Deferred(id) = apply_press(selection, anchor, ids, offset, row, ctrl, shift, span) { selection.remove(&id); } } /// The catalog's answer to "what is between these two ordinals". /// /// Stands in for [`crate::library_ui::LibraryController::ids_in_span`], /// which does the same thing with a `LIMIT`/`OFFSET`: the whole library is /// available to the query whatever the grid happens to have loaded, and an /// ordinal indexes it directly. fn library(all: &[ImageId]) -> impl Fn(usize, usize) -> Vec + '_ { move |first, last| { all.iter() .copied() .skip(first) .take((last + 1).saturating_sub(first)) .collect() } } /// No catalog to ask — what a press sees before a library is open, and if /// the query fails. fn unloaded(_first: usize, _last: usize) -> Vec { Vec::new() } #[test] fn a_plain_press_replaces_the_selection() { let all = ids(5); let span = library(&all); let mut sel = BTreeSet::new(); let mut anchor = None; click(&mut sel, &mut anchor, &all, 0, 0, false, false, &span); click(&mut sel, &mut anchor, &all, 0, 2, false, false, &span); assert_eq!(sel.iter().copied().collect::>(), vec![ImageId(3)]); } #[test] fn ctrl_press_adds_and_then_removes() { let all = ids(5); let span = library(&all); let mut sel = BTreeSet::new(); let mut anchor = None; click(&mut sel, &mut anchor, &all, 0, 0, false, false, &span); click(&mut sel, &mut anchor, &all, 0, 3, true, false, &span); assert_eq!(sel.len(), 2); // Toggling: a second ctrl-press on the same cell takes it out again. click(&mut sel, &mut anchor, &all, 0, 3, true, false, &span); assert_eq!(sel.iter().copied().collect::>(), vec![ImageId(1)]); } #[test] fn shift_press_extends_a_contiguous_range() { let all = ids(10); let span = library(&all); let mut sel = BTreeSet::new(); let mut anchor = None; click(&mut sel, &mut anchor, &all, 0, 2, false, false, &span); click(&mut sel, &mut anchor, &all, 0, 6, false, true, &span); assert_eq!(sel.len(), 5, "rows 2..=6 inclusive"); assert!(sel.contains(&ImageId(3)) && sel.contains(&ImageId(7))); } #[test] fn shift_extends_backwards_too() { let all = ids(10); let span = library(&all); let mut sel = BTreeSet::new(); let mut anchor = None; click(&mut sel, &mut anchor, &all, 0, 6, false, false, &span); click(&mut sel, &mut anchor, &all, 0, 2, false, true, &span); assert_eq!(sel.len(), 5); } #[test] fn a_second_shift_click_re_describes_the_range_rather_than_adding_to_it() { // Overshooting and correcting is the common case. Extending without // clearing would turn the correction into a union, and the user would // drag cells they believed they had just deselected. let all = ids(20); let span = library(&all); let mut sel = BTreeSet::new(); let mut anchor = None; click(&mut sel, &mut anchor, &all, 0, 5, false, false, &span); click(&mut sel, &mut anchor, &all, 0, 15, false, true, &span); assert_eq!(sel.len(), 11, "rows 5..=15"); // Corrected to a shorter range from the same anchor. click(&mut sel, &mut anchor, &all, 0, 8, false, true, &span); assert_eq!(sel.len(), 4, "rows 5..=8, and nothing from the first range"); assert!(!sel.contains(&ImageId(16)), "row 15 is no longer selected"); } #[test] fn the_anchor_stays_put_across_shift_clicks() { // If the anchor moved to each shift-click, a range could only ever be // grown, never corrected inward. let all = ids(20); let span = library(&all); let mut sel = BTreeSet::new(); let mut anchor = None; click(&mut sel, &mut anchor, &all, 0, 10, false, false, &span); click(&mut sel, &mut anchor, &all, 0, 14, false, true, &span); click(&mut sel, &mut anchor, &all, 0, 12, false, true, &span); assert_eq!(anchor, Some(10)); assert_eq!(sel.len(), 3, "rows 10..=12"); } /// TRACES: FR-UI-2 | FR-UI-4 /// Two taps on one cell put it back where it started, and take nothing else. /// /// This is the behaviour that replaced the double-tap range. That gesture /// selected everything between the cell and wherever the selection began — /// silently, with no visible state, from a thing a hand does by accident. /// "Select to…" does the same job and says so first, so a double tap is now /// two toggles and nothing more: the only outcome a user can predict from /// what is on the screen. #[test] fn two_taps_on_one_cell_cancel_out_and_take_no_range() { let all = ids(30); let span = library(&all); let mut sel = BTreeSet::new(); let mut anchor = None; // Selecting began somewhere else, so a range gesture would have had an // anchor to sweep from. click(&mut sel, &mut anchor, &all, 0, 4, false, false, &span); assert_eq!(sel.len(), 1); tap(&mut sel, &mut anchor, &all, 11); tap(&mut sel, &mut anchor, &all, 11); assert_eq!( sel.iter().copied().collect::>(), vec![ImageId(5)], "a double tap took a run instead of toggling one cell twice" ); } /// One tap in selection mode: a press reported as ctrl-held, which is what /// `library.slint` sends while the mode is on. fn tap(sel: &mut BTreeSet, anchor: &mut Option, all: &[ImageId], row: usize) { click(sel, anchor, all, 0, row, true, false, &library(all)); } /// TRACES: FR-CAT-7 | FR-UI-4 /// The bug that made a forty-image drag file one photograph. /// /// In selection mode every press arrives as a ctrl-press, so grabbing one /// of the selected cells to drag them all used to *deselect* the cell being /// grabbed. `drag-started` then saw a press on an image that was not in the /// selection, concluded that was the gesture, and carried it alone. #[test] fn grabbing_a_selected_cell_leaves_the_whole_selection_to_drag() { let all = ids(20); let span = library(&all); let mut sel = BTreeSet::new(); let mut anchor = None; // Three photographs picked in selection mode, which reports ctrl. for row in [2, 5, 9] { click(&mut sel, &mut anchor, &all, 0, row, true, false, &span); } assert_eq!(sel.len(), 3); // The press that begins the drag, on one of the three. let outcome = apply_press(&mut sel, &mut anchor, &all, 0, 5, true, false, &span); assert_eq!( outcome, Press::Deferred(ImageId(6)), "the removal has to be handed back, not performed" ); assert_eq!( sel.len(), 3, "the drag reads the selection next, and all three have to still be in it" ); assert!(sel.contains(&ImageId(6)), "least of all the one being held"); } /// And the other half: with no drag, the release still takes it out. /// /// A tap in selection mode has to toggle, or there is no way to correct a /// mis-tap short of leaving the mode. #[test] fn a_tap_on_a_selected_cell_still_takes_it_out() { let all = ids(20); let span = library(&all); let mut sel = BTreeSet::new(); let mut anchor = None; for row in [2, 5, 9] { click(&mut sel, &mut anchor, &all, 0, row, true, false, &span); } click(&mut sel, &mut anchor, &all, 0, 5, true, false, &span); assert_eq!( sel.iter().copied().collect::>(), vec![ImageId(3), ImageId(10)], "the tapped photograph is out and the other two stayed" ); } /// The anchor moves on the press whether or not the removal does. /// /// "Select to…" measures from the anchor, and a run taken after tapping a /// selected cell has to start where the user last touched — otherwise the /// gesture sweeps from wherever the anchor happened to be left. #[test] fn a_deferred_press_still_moves_the_anchor() { let all = ids(20); let span = library(&all); let mut sel = BTreeSet::new(); let mut anchor = None; click(&mut sel, &mut anchor, &all, 0, 3, true, false, &span); let _ = apply_press(&mut sel, &mut anchor, &all, 0, 3, true, false, &span); assert_eq!(anchor, Some(3)); } #[test] fn ctrl_shift_adds_a_second_range_to_the_selection() { // Picking up a second run without losing the first: the one case where // a shift-click must not clear. let all = ids(20); let span = library(&all); let mut sel = BTreeSet::new(); let mut anchor = None; click(&mut sel, &mut anchor, &all, 0, 0, false, false, &span); click(&mut sel, &mut anchor, &all, 0, 2, false, true, &span); assert_eq!(sel.len(), 3); // A new anchor by ctrl-click, then a ctrl+shift range from it. click(&mut sel, &mut anchor, &all, 0, 10, true, false, &span); click(&mut sel, &mut anchor, &all, 0, 12, true, true, &span); assert_eq!(sel.len(), 6, "rows 0..=2 and 10..=12"); assert!(sel.contains(&ImageId(1)) && sel.contains(&ImageId(13))); } #[test] fn a_plain_press_on_a_selected_cell_keeps_the_selection() { // This is what makes a multi-image drag possible: the press that starts // the drag must not collapse what it is about to carry. let all = ids(5); let span = library(&all); let mut sel = BTreeSet::new(); let mut anchor = None; click(&mut sel, &mut anchor, &all, 0, 0, false, false, &span); click(&mut sel, &mut anchor, &all, 0, 1, true, false, &span); click(&mut sel, &mut anchor, &all, 0, 2, true, false, &span); assert_eq!(sel.len(), 3); // Pressing one of the three to begin a drag. click(&mut sel, &mut anchor, &all, 0, 1, false, false, &span); 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 span = library(&all); let mut sel = BTreeSet::new(); let mut anchor = None; // A selection built the ordinary way, and the state it leaves behind. click(&mut sel, &mut anchor, &all, 0, 1, false, false, &span); click(&mut sel, &mut anchor, &all, 0, 3, true, false, &span); let before = (sel.clone(), anchor); // The finger that opens a pinch. let undo = PressUndo::capture(&sel, anchor, Some(3)); click(&mut sel, &mut anchor, &all, 0, 5, false, false, &span); assert_ne!( (sel.clone(), anchor), before, "the press has to change something, or this proves nothing" ); let cursor = undo.restore(&mut sel, &mut anchor); assert_eq!((sel, anchor), before, "selection and anchor are back"); 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. let all = ids(3); let span = library(&all); let mut sel = BTreeSet::new(); let mut anchor = None; click(&mut sel, &mut anchor, &all, 0, 99, false, false, &span); assert!(sel.is_empty()); } #[test] fn an_anchor_survives_the_window_moving_under_it() { // The grid is a window over the catalog, and the window moves whenever // the user scrolls. Held as a row, an anchor set before a scroll // described a range from whatever photograph had since drifted into // that row — so shift-clicking after scrolling selected a run the user // never pointed at, silently and with no way to tell. let all = ids(20); let span = library(&all); let first: Vec<_> = all[0..10].to_vec(); let later: Vec<_> = all[4..14].to_vec(); let mut sel = BTreeSet::new(); let mut anchor = None; // Anchor on the sixth image, in a window starting at the beginning. click(&mut sel, &mut anchor, &first, 0, 5, false, false, &span); assert_eq!(anchor, Some(5), "the anchor is an ordinal, not a row"); // The user scrolls — the window now starts four images in — and // shift-clicks the image at ordinal 10. click(&mut sel, &mut anchor, &later, 4, 6, false, true, &span); assert_eq!(sel.len(), 6, "ordinals 5..=10"); assert!(sel.contains(&ImageId(6)), "the anchored image is still in"); assert!(sel.contains(&ImageId(11)), "up to the one shift-clicked"); assert!(!sel.contains(&ImageId(5)), "and nothing before the anchor"); } #[test] fn a_range_reaching_outside_the_window_selects_the_whole_run() { // The bug this exists for: the anchor is far above the loaded window, // so all but four of the photographs in the range are off screen. They // are still what the user asked for, and the catalog is what knows // their ids — reading the range from the window selected the four that // happened to be loaded and looked as though it had worked. let all = ids(30); let span = library(&all); let window: Vec<_> = all[10..20].to_vec(); let mut sel = BTreeSet::new(); let mut anchor = Some(0); click(&mut sel, &mut anchor, &window, 10, 3, false, true, &span); assert_eq!(sel.len(), 14, "ordinals 0..=13, loaded or not"); assert!( sel.contains(&ImageId(1)), "the anchored image, never loaded" ); assert!(sel.contains(&ImageId(14)), "up to the cell pressed"); assert!(!sel.contains(&ImageId(15)), "and no further"); } #[test] fn a_range_running_off_the_far_end_is_whole_too() { // The mirror of the case above, with the anchor *ahead* of the press // instead of behind it. Neither direction may stop at the window, and // neither may wrap round to the other end of it. let all = ids(30); let span = library(&all); let window: Vec<_> = all[0..5].to_vec(); let mut sel = BTreeSet::new(); let mut anchor = Some(25); click(&mut sel, &mut anchor, &window, 0, 2, false, true, &span); assert_eq!(sel.len(), 24, "ordinals 2..=25"); assert!(sel.contains(&ImageId(3)) && sel.contains(&ImageId(26))); assert!( !sel.contains(&ImageId(1)), "nothing before the pressed cell" ); } #[test] fn a_range_with_no_catalog_to_ask_falls_back_to_the_window() { // Before a library is open, and if the query fails. What is loaded is // a poorer answer than the catalog's and a far better one than a // gesture that appears to do nothing. let all = ids(30); let window: Vec<_> = all[10..20].to_vec(); let mut sel = BTreeSet::new(); let mut anchor = Some(0); click( &mut sel, &mut anchor, &window, 10, 3, false, true, &unloaded, ); assert_eq!(sel.len(), 4, "ordinals 10..=13, the loaded part of 0..=13"); assert!(sel.contains(&ImageId(11)) && sel.contains(&ImageId(14))); } #[test] fn shift_without_an_anchor_selects_just_the_one() { let all = ids(5); let span = library(&all); let mut sel = BTreeSet::new(); let mut anchor = None; click(&mut sel, &mut anchor, &all, 0, 3, false, true, &span); assert_eq!(sel.iter().copied().collect::>(), vec![ImageId(4)]); } }