Split collections_ui.rs into a module directory by area of behaviour
collections_ui.rs had grown to 4,591 lines covering the sidebar controller, the click/drag selection policy, tree refresh, the drag gesture, the trash worker, twelve wiring functions, and the row's rename/create/context menu, all in one file. Split into collections_ui/ with one module per area, the way develop/ and library/ were already split on this branch: - controller.rs: CollectionsController and the pure drop/delete/release decisions (decide_drop, decide_delete, decide_release, menu_detail, delete_warning) that a test can drive without a window. - press.rs: PressUndo and the click-and-release selection policy (apply_press, select_row, commit_press, cancel_press). - tree_sync.rs: rebuilding the sidebar from the catalog and pushing catalog-derived state into the grid (refresh_tree, offline_state, sync_lifted/sync_selection/sync_reorderable/sync_badges, refresh_membership, direct_holdings). - drag.rs: the cursor bitmap (compose_drag_image, blit_scaled) and the hold/spring timers (arm_hold, arm_spring, should_spring, collapse_spring_opened) plus their delay constants. - trash.rs: the soft delete (start_trash, start_restore, drain_trash, stop_trash, refresh_trash, format_bytes). - wiring_grid.rs / wiring_tree.rs: the wire() entry point and its twelve wire_* functions, split in two because together they were the largest single piece (grid-facing selection/drag/trash vs. sidebar-facing navigation/create/rename/row-drag/menu/membership). - rename_menu.rs: creating, naming and renaming collections, and the row's context menu (apply_rename, create_child, unique_name, open_row_menu, close_row_menu, close_rename). mod.rs carries the module's own top-level doc comment, the `pub use` re-exports for the eight items the rest of the crate reaches by `collections_ui::` path (CollectionsController, wire, refresh_tree, sync_badges, sync_selection, select_row, commit_press, cancel_press), and a shared `test_support` for the one fixture (`ids`) more than one file's tests needed. Every item that only crossed a boundary within this module, not out of it, was narrowed to `pub(super)` rather than kept at the crate-wide `pub` a single file gave it for free. Extracted with a brace-aware pass that kept each item's own leading doc comment and attributes attached to it, and tests moved with the code they exercise; every TRACES/GESTURE comment lands on the same code it did before. No file outside the new directory changed — lib.rs's `mod collections_ui;` resolves to the directory automatically, and every outside caller's `collections_ui::` path still resolves through mod.rs's re-exports.
This commit is contained in:
@@ -0,0 +1,763 @@
|
||||
//! 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 crate::AppWindow;
|
||||
|
||||
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.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)]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user