//! 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 Vec>; /// 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>, /// 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>, /// 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>, /// 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>, /// 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>, /// 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, /// 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, /// 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>, /// 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>, /// Which rows are saved filters, so a drop onto one is refused *before* the /// release rather than after. pub(super) row_smart: RefCell>, /// Which rows have children, so the spring knows there is anything to open. pub(super) row_has_children: RefCell>, /// 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>, /// Which collection scopes the grid. `None` is the whole library. pub(super) scope: RefCell>, /// 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>, /// 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, /// TRACES: FR-EXP-10 /// The albums section below the tree, refreshed whenever the tree is: /// every open, scan, sync and merge that can change collections can /// change albums too. `None` until `lib.rs` wires it. pub(crate) albums: RefCell>>, /// The live drag: what it carries. Empty means no drag. pub(super) dragging: RefCell>, /// 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>, /// 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>, /// 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, /// 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>, /// 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>, /// 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>, /// 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, /// 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>, /// 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>, /// 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>, /// 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>>, /// 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>, /// 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>, /// 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>, /// 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, } /// What a release over a collection row means. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Drop { /// File these photographs in the target. FileImages(Vec), /// 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, 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, dragged: bool) -> Release { match held { Some(id) if !dragged => Release::OpenMenu(id), _ => Release::Nothing, } } impl CollectionsController { pub fn new(activity: Rc) -> Rc { 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 { *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 { 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 { 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. /// TRACES: FR-EXP-10 /// An album was chosen in the sidebar: nothing of this panel scopes the /// grid any more. The selection goes too, as it does on any scope change — /// a selection the user cannot see is one they will act on by accident. pub(crate) fn leave_for_album(&self) { self.viewing_trash.set(false); *self.scope.borrow_mut() = None; self.clear_selection(); } 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 { self.cursor.get() } pub fn set_cursor(&self, at: Option) { 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); } }