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.
568 lines
25 KiB
Rust
568 lines
25 KiB
Rust
//! Selection and tree state (`CollectionsController`), and the pure
|
|
//! decisions a drop, a delete, or a hold-release comes down to.
|
|
//!
|
|
//! `decide_drop`, `decide_delete` and `decide_release` are kept apart from
|
|
//! the callbacks that call them for the reason the crate-level doc gives:
|
|
//! the gesture cannot be driven from a test, but the decision can.
|
|
|
|
use std::cell::RefCell;
|
|
use std::collections::BTreeSet;
|
|
use std::rc::Rc;
|
|
|
|
use dr_types::{CollectionId, ImageId};
|
|
|
|
use super::press::PressUndo;
|
|
|
|
/// TRACES: FR-CAT-5
|
|
/// How a run of grid ordinals is turned into the ids it names.
|
|
///
|
|
/// See [`CollectionsController::span_source`] for why this is supplied rather
|
|
/// than reached for.
|
|
type SpanIds = Rc<dyn Fn(usize, usize) -> Vec<ImageId>>;
|
|
|
|
/// Selection, drag, and tree state for the running window.
|
|
///
|
|
/// Everything is `RefCell` because Slint callbacks are `Fn`, not `FnMut`, and
|
|
/// they all run on the one event-loop thread — the same shape
|
|
/// [`crate::library_ui::LibraryController`] uses.
|
|
#[derive(Default)]
|
|
pub struct CollectionsController {
|
|
/// Selected images, by catalog id. A `BTreeSet` rather than a `Vec` so
|
|
/// membership tests are cheap during a rubber-band and the order a drop
|
|
/// applies in is stable across runs.
|
|
pub(super) selection: RefCell<BTreeSet<ImageId>>,
|
|
/// Where a shift-click extends from. The last cell *clicked*, not the last
|
|
/// added — extending from the far end of a previous range is not what the
|
|
/// gesture means anywhere else.
|
|
///
|
|
/// An **image ordinal in the library**, not a row of the loaded window.
|
|
/// The grid is a window over the catalog, so row 7 names a different
|
|
/// photograph after every scroll: held as a row, an anchor set before a
|
|
/// scroll described a range from wherever that row had since drifted to.
|
|
/// Selection is by id for the same reason (see the preamble); this is the
|
|
/// same argument applied to the one index that has to survive a move.
|
|
pub(super) anchor: RefCell<Option<usize>>,
|
|
/// TRACES: FR-UI-2 | FR-UI-4
|
|
/// TRACES: FR-CAT-7 | FR-UI-4
|
|
/// A photograph the most recent press asked to take *out* of the
|
|
/// selection, held until the release says the press was a tap.
|
|
///
|
|
/// See [`Press::Deferred`] for why removal cannot happen on the press:
|
|
/// this is the whole of what stops a drag of forty photographs carrying
|
|
/// one. Overwritten by the next press and dropped by the drag that
|
|
/// consumes it, so at most one is ever pending.
|
|
pub(super) pending_toggle: std::cell::Cell<Option<ImageId>>,
|
|
/// 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.
|
|
pub(super) 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
|
|
/// *from* the anchor *to* the cursor, so the two are the two ends and
|
|
/// cannot be one field. `None` until the user has taken hold of the grid,
|
|
/// so the first arrow press starts from what is on screen rather than
|
|
/// jumping to the top of the library.
|
|
pub(super) cursor: std::cell::Cell<Option<usize>>,
|
|
/// Whether the press that began the current gesture carried ctrl or shift.
|
|
///
|
|
/// A modified click is a *selection* gesture and must not also open the
|
|
/// image: building a selection would otherwise throw the user into the
|
|
/// develop view on the second ctrl-click. Slint does not report modifiers on
|
|
/// `clicked`, so the press records them and the click consults this.
|
|
pub(super) modified_press: std::cell::Cell<bool>,
|
|
/// TRACES: FR-UI-2 | FR-UI-4
|
|
/// Whether a tap in the grid selects rather than opens.
|
|
///
|
|
/// Touch has no ctrl and no shift, so without a mode there is no way to
|
|
/// select a second photograph: the first tap would open the first one. In
|
|
/// this mode a plain press is reported as a ctrl-press and goes through the
|
|
/// same [`apply_press`] as everything else — a separate touch policy would
|
|
/// be a second copy of these rules to keep in step.
|
|
pub(super) select_mode: std::cell::Cell<bool>,
|
|
/// The timer that turns a held cell into a selection.
|
|
///
|
|
/// Here rather than in `.slint` because Slint has no long-press gesture and
|
|
/// a hand-rolled one would need a `Timer` element per visible cell — a
|
|
/// hundred timers to answer a question about one finger. Held so that
|
|
/// dropping it cancels: a press that ends, or is taken by the Flickable
|
|
/// when the finger travels, must not arrive as a selection a moment later.
|
|
pub(super) hold_timer: RefCell<Option<slint::Timer>>,
|
|
/// Collection ids parallel to the sidebar's rows, so a hovered row index
|
|
/// resolves to an id without another query.
|
|
pub(super) row_ids: RefCell<Vec<CollectionId>>,
|
|
/// Which rows are saved filters, so a drop onto one is refused *before* the
|
|
/// release rather than after.
|
|
pub(super) row_smart: RefCell<Vec<bool>>,
|
|
/// Which rows have children, so the spring knows there is anything to open.
|
|
pub(super) row_has_children: RefCell<Vec<bool>>,
|
|
/// Collapsed collections, by id. Collapse is a view preference and
|
|
/// deliberately not persisted to the catalog — it is not something to sync
|
|
/// between devices.
|
|
pub(super) collapsed: RefCell<std::collections::HashSet<CollectionId>>,
|
|
/// Which collection scopes the grid. `None` is the whole library.
|
|
pub(super) scope: RefCell<Option<CollectionId>>,
|
|
/// The collection whose name is being edited in the sidebar, if any.
|
|
///
|
|
/// Held here rather than in Slint because a rename can also be *started*
|
|
/// from Rust — creating a collection opens its field — and because a
|
|
/// commit that the catalog refuses has to leave the field open on the name
|
|
/// the user typed rather than silently closing over a rejected edit.
|
|
pub(super) renaming: RefCell<Option<CollectionId>>,
|
|
/// Whether the grid is showing the trash rather than the library.
|
|
///
|
|
/// Separate from `scope` because the trash is not a collection: its contents
|
|
/// come from `trashed_at`, not from membership, and every other query in the
|
|
/// library *excludes* exactly what this view exists to show. Folding it into
|
|
/// `scope` as a sentinel id would put that inversion inside a type that
|
|
/// means "a collection".
|
|
pub(super) viewing_trash: std::cell::Cell<bool>,
|
|
/// The live drag: what it carries. Empty means no drag.
|
|
pub(super) dragging: RefCell<Vec<ImageId>>,
|
|
/// The collection being dragged, when the drag is a tree rearrangement
|
|
/// rather than a filing of photographs.
|
|
///
|
|
/// Set when the drag starts and read by the drop, the same way `dragging`
|
|
/// works — the drop callback carries only the target's id, so what is being
|
|
/// dropped has to be remembered rather than inspected.
|
|
pub(super) dragging_collection: RefCell<Option<CollectionId>>,
|
|
/// TRACES: FR-UI-3 | FR-UI-4
|
|
/// The collection a hold has picked up, and the timer that picks it up.
|
|
///
|
|
/// The tree is inside a Flickable, which claims any drag beginning inside
|
|
/// it — so with a finger, a drag on a row is a scroll unless something says
|
|
/// otherwise first. The hold is that something. It arms the drag *and*
|
|
/// opens the row menu, and which of the two the user gets is decided by
|
|
/// whether they then moved: the same fork the grid already uses to tell a
|
|
/// hold-to-select from a drag-to-file.
|
|
pub(super) lifted: RefCell<Option<CollectionId>>,
|
|
/// Whether the lifted row's drag actually began. Reset by the press that
|
|
/// arms the next one, so a release can tell a rearrangement from a menu.
|
|
pub(super) drag_began: std::cell::Cell<bool>,
|
|
/// The hold timer for a sidebar row. One, replaced per press, so a press
|
|
/// that became a scroll leaves nothing queued to fire over the list the
|
|
/// user is now scrolling.
|
|
pub(super) row_hold_timer: RefCell<Option<slint::Timer>>,
|
|
/// Whether each visible row has a parent, in `row_ids` order — what decides
|
|
/// whether "All photographs" lights up as a drop target. Kept beside the
|
|
/// rows it indexes rather than queried per drag: `refresh_tree` already has
|
|
/// the parent in hand, and a query would answer for a tree that may have
|
|
/// been rebuilt since.
|
|
pub(super) row_nested: RefCell<Vec<bool>>,
|
|
/// The collection the row menu is open on.
|
|
///
|
|
/// Held here as well as in the window's title property because the two say
|
|
/// different things: the property is what the sheet *draws*, and this is
|
|
/// what every action *acts on*. A rename committed from the menu changes
|
|
/// the first and must not change the second.
|
|
pub(super) menu_for: RefCell<Option<CollectionId>>,
|
|
/// Whether the menu's delete has been asked once and is waiting to be
|
|
/// confirmed.
|
|
///
|
|
/// A `Cell` rather than a window property alone so the decision is made in
|
|
/// Rust: the sheet must not be able to reach the destructive branch by
|
|
/// flipping a bit of its own.
|
|
pub(super) menu_confirming: std::cell::Cell<bool>,
|
|
/// The collection a drag is currently over, by id.
|
|
///
|
|
/// Only the spring needs this — the *drop* is hit-tested by Slint and
|
|
/// arrives with its own id, so nothing here has to remember where the
|
|
/// pointer was.
|
|
pub(super) hover_id: RefCell<Option<CollectionId>>,
|
|
/// Spring-loaded expansion: the timer that opens a collapsed parent the
|
|
/// pointer has been dwelling on mid-drag.
|
|
///
|
|
/// One timer, restarted per row, so moving on cancels the pending
|
|
/// expansion rather than leaving a queue of them to fire later.
|
|
pub(super) spring_timer: RefCell<Option<slint::Timer>>,
|
|
/// Collections the spring opened during *this* drag, so they can be closed
|
|
/// again if the drag ends elsewhere. Without this, dragging across a deep
|
|
/// tree leaves every parent it passed over hanging open.
|
|
pub(super) spring_opened: RefCell<Vec<CollectionId>>,
|
|
/// Images dropped on the trash, recorded by `dropped-on-trash` and acted on
|
|
/// in `drag-finished` — the same deferral, for the same reason.
|
|
pub(super) trash_requested: RefCell<Option<Vec<ImageId>>>,
|
|
/// Drains a trash worker. Held so a second operation replaces the first
|
|
/// rather than two timers fighting over the same model.
|
|
pub(super) trash_timer: RefCell<Option<slint::Timer>>,
|
|
/// Which collection a drop landed on, recorded by `dropped` and acted on in
|
|
/// `drag-finished`.
|
|
///
|
|
/// Deferred because every consequence of a drop replaces a Slint model —
|
|
/// the tree, the grid cells — and doing that from inside the `dropped`
|
|
/// handler destroys the elements Slint is still using to deliver the event.
|
|
pub(super) dropped_on: RefCell<Option<CollectionId>>,
|
|
/// TRACES: FR-CAT-5
|
|
/// How a run of grid ordinals is turned into ids.
|
|
///
|
|
/// Supplied by [`crate::library_ui`] at wiring time, because the catalog,
|
|
/// the scope and the rating filter — everything the run depends on — belong
|
|
/// to the grid's controller, not to this one. Held as a closure rather than
|
|
/// reached for through a handle so the selection rules stay testable with
|
|
/// no library open: the tests pass a run that reads a plain slice.
|
|
///
|
|
/// `None` before wiring, which the caller reads as "nothing to ask" and
|
|
/// falls back to the loaded window.
|
|
pub(super) span_source: RefCell<Option<SpanIds>>,
|
|
/// Where the trash workers report what they are doing.
|
|
///
|
|
/// Shared with [`crate::library_ui`]: a delete and a scan are two jobs in
|
|
/// one list, and the user asking what the application is busy with does not
|
|
/// care which module started them.
|
|
pub(super) activity: Rc<crate::activity::ActivityLog>,
|
|
}
|
|
|
|
/// What a release over a collection row means.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum Drop {
|
|
/// File these photographs in the target.
|
|
FileImages(Vec<ImageId>),
|
|
/// Move this collection under the target.
|
|
Reparent(CollectionId),
|
|
/// Nothing to do — an empty drag, or a row dropped on itself.
|
|
Nothing,
|
|
}
|
|
|
|
/// Decide what a drop on `target` should do.
|
|
///
|
|
/// Separated from the callback because the gesture cannot be driven from a
|
|
/// test — Slint owns it — while this decision is where it can actually go
|
|
/// wrong. The order matters: an image drag always fills `carried`, so
|
|
/// photographs can never be mistaken for a rearrangement, and only an empty
|
|
/// payload consults the row remembered from the press.
|
|
pub fn decide_drop(
|
|
carried: &[ImageId],
|
|
pressed_row: Option<CollectionId>,
|
|
target: CollectionId,
|
|
) -> Drop {
|
|
if !carried.is_empty() {
|
|
return Drop::FileImages(carried.to_vec());
|
|
}
|
|
match pressed_row {
|
|
// A collection cannot go inside itself. Caught here as well as in the
|
|
// catalog so the common case is a no-op rather than an error message.
|
|
Some(source) if source != target => Drop::Reparent(source),
|
|
_ => Drop::Nothing,
|
|
}
|
|
}
|
|
|
|
/// TRACES: FR-CAT-7
|
|
/// What the row menu's Delete should do on this press.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum DeleteStep {
|
|
/// Delete it now. Nothing is lost that the user cannot see is nothing.
|
|
Now,
|
|
/// Say what goes, and wait to be asked again.
|
|
Confirm,
|
|
}
|
|
|
|
/// TRACES: FR-CAT-7
|
|
/// Whether deleting this collection needs to be confirmed first.
|
|
///
|
|
/// The gesture this replaced refused outright whenever the collection held
|
|
/// anything, which made a full collection undeletable without emptying it by
|
|
/// hand — child by child, since a parent counts its descendants. Refusing is
|
|
/// not a safety property; it is the absence of one, because the user goes and
|
|
/// does the same thing the long way round.
|
|
///
|
|
/// So: an empty collection goes on the first press, because there is nothing
|
|
/// to warn about and a dialogue asking "delete this empty thing?" is the kind
|
|
/// that teaches people to dismiss dialogues. Anything else is asked once.
|
|
///
|
|
/// Split from the handler because this is the whole of the safety rule, and a
|
|
/// handler needs a window to run.
|
|
pub fn decide_delete(holds: usize, children: usize, confirmed: bool) -> DeleteStep {
|
|
if confirmed || (holds == 0 && children == 0) {
|
|
DeleteStep::Now
|
|
} else {
|
|
DeleteStep::Confirm
|
|
}
|
|
}
|
|
|
|
/// TRACES: FR-CAT-7
|
|
/// The line under the menu's title: what this collection directly holds.
|
|
///
|
|
/// Direct members and direct children, not the sidebar's deep count, because
|
|
/// this line sits above a Delete — and delete drops *this* collection's member
|
|
/// rows while promoting its children rather than taking them. A number here
|
|
/// that counted descendants' photographs would be describing something the
|
|
/// button below it does not do.
|
|
pub fn menu_detail(holds: usize, children: usize) -> String {
|
|
let photos = match holds {
|
|
0 => "no photographs".to_string(),
|
|
1 => "1 photograph".to_string(),
|
|
n => format!("{n} photographs"),
|
|
};
|
|
match children {
|
|
0 => photos,
|
|
1 => format!("{photos} · 1 collection inside"),
|
|
n => format!("{photos} · {n} collections inside"),
|
|
}
|
|
}
|
|
|
|
/// TRACES: FR-CAT-7
|
|
/// What deleting this collection actually does, in full.
|
|
///
|
|
/// Every clause here is one a user has got wrong about a collections feature
|
|
/// before: that deleting a collection deletes the photographs (it does not —
|
|
/// membership is a join table), and that deleting a parent takes the
|
|
/// collections nested in it (it does not — [`dr_catalog::collections::delete`]
|
|
/// promotes them, because losing a subtree because its container was tidied
|
|
/// away is not recoverable).
|
|
pub fn delete_warning(name: &str, holds: usize, children: usize) -> String {
|
|
let mut out = format!("Deleting “{name}” ");
|
|
match (holds, children) {
|
|
(0, _) => out.push_str("removes it from the sidebar."),
|
|
(1, _) => out.push_str("takes 1 photograph out of it."),
|
|
(n, _) => out.push_str(&format!("takes {n} photographs out of it.")),
|
|
}
|
|
if holds > 0 {
|
|
out.push_str(" They stay in your library and in every other collection they are in.");
|
|
}
|
|
match children {
|
|
0 => {}
|
|
1 => {
|
|
out.push_str(" The collection inside it moves up one level rather than going with it.")
|
|
}
|
|
n => out.push_str(&format!(
|
|
" The {n} collections inside it move up one level rather than going with it."
|
|
)),
|
|
}
|
|
out
|
|
}
|
|
|
|
/// TRACES: FR-UI-3 | FR-UI-4
|
|
/// What letting go of a held collection row means.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Release {
|
|
/// The hold fired and nothing moved: the user is asking what can be done
|
|
/// to this collection.
|
|
OpenMenu(CollectionId),
|
|
/// Either the hold never fired — an ordinary tap, which selects — or it
|
|
/// did and the row was then dragged, in which case the drop has already
|
|
/// done the work.
|
|
Nothing,
|
|
}
|
|
|
|
/// TRACES: FR-UI-3 | FR-UI-4
|
|
/// Decide what a release does, from what the press turned into.
|
|
///
|
|
/// The whole of the fork that lets one gesture mean two things, and the reason
|
|
/// it is worth a test: get it wrong in the direction of `OpenMenu` and every
|
|
/// rearrangement ends with a sheet over the tree the user just tidied; get it
|
|
/// wrong the other way and the menu is unreachable with a finger.
|
|
pub fn decide_release(held: Option<CollectionId>, dragged: bool) -> Release {
|
|
match held {
|
|
Some(id) if !dragged => Release::OpenMenu(id),
|
|
_ => Release::Nothing,
|
|
}
|
|
}
|
|
|
|
impl CollectionsController {
|
|
pub fn new(activity: Rc<crate::activity::ActivityLog>) -> Rc<Self> {
|
|
Rc::new(Self {
|
|
activity,
|
|
..Default::default()
|
|
})
|
|
}
|
|
|
|
/// Remember which row was pressed, so a drag that follows knows what it is
|
|
/// carrying.
|
|
///
|
|
/// Recorded on the press rather than at the drag's start because Slint
|
|
/// builds the payload through a `pure` binding, which must not have side
|
|
/// effects — and the drop callback is handed only the *target's* id, so the
|
|
/// source has to be remembered somewhere.
|
|
pub fn note_row_press(&self, id: CollectionId) {
|
|
*self.dragging_collection.borrow_mut() = Some(id);
|
|
}
|
|
|
|
/// Which collection the grid is scoped to, for [`crate::library_ui`] to
|
|
/// build its query from.
|
|
pub fn scope(&self) -> Option<CollectionId> {
|
|
*self.scope.borrow()
|
|
}
|
|
|
|
/// Whether the grid should be showing the trash.
|
|
pub fn viewing_trash(&self) -> bool {
|
|
self.viewing_trash.get()
|
|
}
|
|
|
|
/// Whether the gesture in progress began with ctrl or shift held.
|
|
///
|
|
/// A modified click selects and nothing more — opening the image as well
|
|
/// would eject the user from the grid they are selecting in.
|
|
pub fn press_was_modified(&self) -> bool {
|
|
self.modified_press.get()
|
|
}
|
|
|
|
/// Selected image ids, in a stable order.
|
|
pub fn selected(&self) -> Vec<ImageId> {
|
|
self.selection.borrow().iter().copied().collect()
|
|
}
|
|
|
|
/// TRACES: FR-CAT-5
|
|
/// The ids of grid rows `first..=last`, in the order the grid shows them.
|
|
///
|
|
/// Empty when nothing has been wired in — see [`Self::span_source`].
|
|
pub(super) fn span(&self, first: usize, last: usize) -> Vec<ImageId> {
|
|
let source = self.span_source.borrow().clone();
|
|
source.map(|f| f(first, last)).unwrap_or_default()
|
|
}
|
|
|
|
/// Drop the selection — after a scrub, or when the scope changes.
|
|
///
|
|
/// Selection is by id and survives a window change, but a selection the
|
|
/// user cannot see is a selection they will act on by accident. Clearing on
|
|
/// a deliberate navigation is the safer of the two behaviours.
|
|
pub fn clear_selection(&self) {
|
|
self.selection.borrow_mut().clear();
|
|
*self.anchor.borrow_mut() = None;
|
|
self.cursor.set(None);
|
|
}
|
|
|
|
/// Where the keyboard cursor is, as an image ordinal.
|
|
pub fn cursor(&self) -> Option<usize> {
|
|
self.cursor.get()
|
|
}
|
|
|
|
pub fn set_cursor(&self, at: Option<usize>) {
|
|
self.cursor.set(at);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use crate::collections_ui::test_support::*;
|
|
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn dragging_a_collection_onto_another_moves_it() {
|
|
// The gesture the tree rearrangement exists for: no images carried, a
|
|
// row remembered from the press, dropped somewhere else.
|
|
assert_eq!(
|
|
decide_drop(&[], Some(CollectionId(7)), CollectionId(9)),
|
|
Drop::Reparent(CollectionId(7))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn photographs_are_filed_even_when_a_row_was_pressed_first() {
|
|
// Clicking a collection and then dragging photographs into another must
|
|
// file them, not move the collection that happens to be remembered.
|
|
// This is the ordering the whole discrimination rests on.
|
|
assert_eq!(
|
|
decide_drop(&ids(3), Some(CollectionId(7)), CollectionId(9)),
|
|
Drop::FileImages(ids(3))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_collection_dropped_on_itself_does_nothing() {
|
|
// The catalog would refuse it as a cycle; catching it here makes the
|
|
// commonest slip a no-op rather than an error the user has to read.
|
|
assert_eq!(
|
|
decide_drop(&[], Some(CollectionId(7)), CollectionId(7)),
|
|
Drop::Nothing
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_drag_with_nothing_remembered_moves_nothing() {
|
|
// A file dragged in from another application lands here with no images
|
|
// and no pressed row. It must not disturb the tree.
|
|
assert_eq!(decide_drop(&[], None, CollectionId(9)), Drop::Nothing);
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_collection_is_deleted_without_being_asked_about() {
|
|
// There is nothing to warn about, and a dialogue asking "delete this
|
|
// empty thing?" is the kind that teaches people to dismiss dialogues
|
|
// without reading them — including the one that mattered.
|
|
assert_eq!(decide_delete(0, 0, false), DeleteStep::Now);
|
|
}
|
|
|
|
#[test]
|
|
fn a_collection_holding_anything_is_asked_about_once() {
|
|
assert_eq!(decide_delete(12, 0, false), DeleteStep::Confirm);
|
|
assert_eq!(decide_delete(0, 1, false), DeleteStep::Confirm);
|
|
// And once asked, it goes.
|
|
assert_eq!(decide_delete(12, 3, true), DeleteStep::Now);
|
|
}
|
|
|
|
#[test]
|
|
fn a_collection_holding_only_children_still_asks() {
|
|
// The old gesture refused on `deep_count > 0`, which counted
|
|
// descendants' photographs — so an empty parent of empty children was
|
|
// deletable in one press and an empty parent of a *full* child was not
|
|
// deletable at all. Both are now the same question, asked once.
|
|
assert_eq!(decide_delete(0, 2, false), DeleteStep::Confirm);
|
|
}
|
|
|
|
#[test]
|
|
fn the_menu_line_counts_what_delete_acts_on() {
|
|
assert_eq!(menu_detail(0, 0), "no photographs");
|
|
assert_eq!(menu_detail(1, 0), "1 photograph");
|
|
assert_eq!(menu_detail(12, 1), "12 photographs · 1 collection inside");
|
|
assert_eq!(menu_detail(0, 3), "no photographs · 3 collections inside");
|
|
}
|
|
|
|
#[test]
|
|
fn the_warning_says_the_photographs_survive() {
|
|
// The single most likely misreading of "Delete" here. If this clause
|
|
// ever goes, a user deletes a collection expecting to lose the
|
|
// pictures — and either panics, or does not do it at all.
|
|
let w = delete_warning("Iceland", 12, 0);
|
|
assert!(w.contains("12 photographs"), "{w}");
|
|
assert!(w.contains("stay in your library"), "{w}");
|
|
}
|
|
|
|
#[test]
|
|
fn the_warning_says_nested_collections_are_promoted_not_taken() {
|
|
// `dr_catalog::collections::delete` promotes children to the deleted
|
|
// collection's parent rather than cascading. A warning that did not
|
|
// say so would describe a data loss that does not happen.
|
|
let w = delete_warning("Trips", 0, 2);
|
|
assert!(w.contains("2 collections inside it move up"), "{w}");
|
|
// And nothing about photographs surviving, because none were lost.
|
|
assert!(!w.contains("stay in your library"), "{w}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_hold_that_did_not_move_opens_the_menu() {
|
|
assert_eq!(
|
|
decide_release(Some(CollectionId(4)), false),
|
|
Release::OpenMenu(CollectionId(4))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_hold_that_became_a_drag_opens_nothing() {
|
|
// The drop has already done the work. A menu here would put a sheet
|
|
// over the tree the user has just finished rearranging — and over the
|
|
// row they would have to look at to see whether it worked.
|
|
assert_eq!(
|
|
decide_release(Some(CollectionId(4)), true),
|
|
Release::Nothing
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_ordinary_tap_opens_nothing() {
|
|
// Nothing was ever picked up, so this is the press that selects a
|
|
// collection and scopes the grid. A menu on every tap would make the
|
|
// sidebar unusable.
|
|
assert_eq!(decide_release(None, false), Release::Nothing);
|
|
}
|
|
}
|