AppWindow carried the grid's loaded window of cells, the keyboard cursor, drag and drop, the held-row long-press state, columns and cell size, the scroll and viewport bookkeeping, the photo roll's pick and centre-request, and the local-only/reorder/collection-filing gestures that act on a selection, as properties and callbacks on the root component. That state now lives in the `Library` global declared in library.slint, next to the structs (LibraryCell, TimelineBar, KeywordRow, PersonChip) it and the grid's other components already share; Rust reaches it through window.global::<Library>() instead of window.set_/get_/on_/invoke_ on the root, the same change collections.slint's `Collections` global made for the sidebar. library-visible stays on AppWindow: it is computed from active-page and active-view, the shell's own routing state, which a global cannot read. Everything else still prefixed library- — the timeline, the filter bar, ratings and flags, keywording, and the routes and status lines — stays on the window for now and moves in the commits that follow.
765 lines
29 KiB
Rust
765 lines
29 KiB
Rust
//! 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<ImageId>,
|
|
anchor: Option<usize>,
|
|
cursor: Option<usize>,
|
|
}
|
|
|
|
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<ImageId>,
|
|
anchor: Option<usize>,
|
|
cursor: Option<usize>,
|
|
) -> 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<ImageId>,
|
|
anchor: &mut Option<usize>,
|
|
) -> Option<usize> {
|
|
*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<ImageId>,
|
|
anchor: &mut Option<usize>,
|
|
ids: &[ImageId],
|
|
offset: usize,
|
|
row: usize,
|
|
ctrl: bool,
|
|
shift: bool,
|
|
span: &dyn Fn(usize, usize) -> Vec<ImageId>,
|
|
) -> 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<CollectionsController>,
|
|
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<CollectionsController>, 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<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 — 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::<Library>().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<ImageId>,
|
|
anchor: &mut Option<usize>,
|
|
ids: &[ImageId],
|
|
offset: usize,
|
|
row: usize,
|
|
ctrl: bool,
|
|
shift: bool,
|
|
span: &dyn Fn(usize, usize) -> Vec<ImageId>,
|
|
) {
|
|
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<ImageId> + '_ {
|
|
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<ImageId> {
|
|
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<_>>(), 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<_>>(), 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<_>>(),
|
|
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<ImageId>, anchor: &mut Option<usize>, 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<_>>(),
|
|
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<_>>(), vec![ImageId(4)]);
|
|
}
|
|
}
|