//! TRACES: FR-CAT-7 | FR-UI-5 | NFR-P9 //! The collections sidebar, grid selection, and the drag between them. //! //! `dr_catalog::collections` owns the data rules — hierarchy, membership, //! revisions, cycles. This module owns the *interaction*: what is selected, //! what a drag is carrying, and where a release lands. //! //! # What this module does and does not own //! //! Slint's `DragArea`/`DropArea` own the *gesture* — pointer capture, the //! threshold separating a click from a drag, arbitration against the grid's //! Flickable, the image under the cursor, and hit-testing the release. So the //! drop arrives already addressed to a collection, and nothing here tracks //! pointer positions or guesses a target. //! //! What is left here is what Slint cannot know: //! //! - **the payload** — which images the drag carries, built from the selection //! at the moment the drag starts; //! - **the spring** — a dwell timer that opens a collapsed collection so a //! nested child can be reached mid-drag, and closes again what the drag only //! passed over. //! //! An earlier version hand-rolled the whole gesture on `TouchArea` and did not //! work, for a reason worth keeping: an interactive `Flickable` claims any drag //! starting inside it for scrolling and cancels the child TouchArea's press, so //! the drag could never leave the grid. //! //! # Selection //! //! Selection is by **catalog image id**, never by row index. The grid is a //! window over the catalog (FR-CAT-4) and scrubbing replaces every row, so an //! index-based selection would silently come to mean forty different //! photographs after a scrub. Ids survive that; they also survive a rescan. use std::cell::RefCell; use std::collections::BTreeSet; use std::rc::Rc; use dr_catalog::collections::{self as coll, CollectionKind}; use dr_catalog::Catalog; use dr_types::{CollectionId, ImageId}; use rusqlite::OptionalExtension as _; use slint::{ComponentHandle, Model as _}; use crate::{AppWindow, CollectionRow}; /// The selection as it stood before a press, for [`cancel_press`]. struct PressUndo { selection: BTreeSet, anchor: Option, previous_anchor: Option, cursor: Option, } impl PressUndo { /// Everything [`press_remembering_anchor`] is about to change. /// /// The four 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: an /// press that grew a fifth piece of state would leave that piece behind /// after a cancel, silently. fn capture( selection: &BTreeSet, anchor: Option, previous_anchor: Option, cursor: Option, ) -> Self { Self { selection: selection.clone(), anchor, previous_anchor, cursor, } } /// Put it all back. Returns the two the caller holds in `Cell`s. fn restore( self, selection: &mut BTreeSet, anchor: &mut Option, ) -> (Option, Option) { *selection = self.selection; *anchor = self.anchor; (self.previous_anchor, self.cursor) } } /// 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. 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. anchor: RefCell>, /// TRACES: FR-UI-2 | FR-UI-4 /// Where the anchor was *before* the press that moved it. /// /// The touch equivalent of shift-click needs this. A double tap is two /// presses, and both of them move the anchor onto the cell being tapped — /// so by the time the double tap is reported, "the range from the anchor to /// here" describes a single cell. This remembers the cell the user actually /// started from, which is the one they mean. /// /// Updated only when a press *moves* the anchor, so the second tap of a /// double tap — which lands on the cell that is already the anchor — leaves /// it pointing where the first tap left it. previous_anchor: 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. 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. 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. 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. 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. hold_timer: RefCell>, /// Collection ids parallel to the sidebar's rows, so a hovered row index /// resolves to an id without another query. row_ids: RefCell>, /// Which rows are saved filters, so a drop onto one is refused *before* the /// release rather than after. row_smart: RefCell>, /// Which rows have children, so the spring knows there is anything to open. 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. collapsed: RefCell>, /// Which collection scopes the grid. `None` is the whole library. 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. 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". viewing_trash: std::cell::Cell, /// The live drag: what it carries. Empty means no drag. 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. dragging_collection: RefCell>, /// 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. 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. 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. 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. trash_requested: RefCell>>, /// Drains a trash worker. Held so a second operation replaces the first /// rather than two timers fighting over the same model. 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. 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. 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. 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, } } 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`]. 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. 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); } } /// 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** — toggle this image, keeping the rest, and move the anchor here /// - **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 plain press on an image that is *already* selected leaves the selection /// alone. That is what makes dragging a multi-selection possible at all — the /// press that begins the drag would otherwise collapse the selection to one. /// /// `ids` is the **loaded window**, `offset` where it starts in the library and /// `row` a position within it; the anchor is kept as `offset + row`, an /// ordinal that still names the same photograph after the window has moved. /// /// `span` turns a run of ordinals into the ids it names — the catalog's job, /// since the run is mostly not loaded. Passed in rather than reached for so /// this stays a pure function of what it is given. #[allow(clippy::too_many_arguments)] pub fn apply_press( selection: &mut BTreeSet, anchor: &mut Option, ids: &[ImageId], offset: usize, row: usize, ctrl: bool, shift: bool, span: &dyn Fn(usize, usize) -> Vec, ) { let Some(&id) = ids.get(row) else { return }; 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; }; // 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; } if ctrl { if !selection.remove(&id) { selection.insert(id); } *anchor = Some(here); return; } // 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; } selection.clear(); selection.insert(id); *anchor = Some(here); } /// TRACES: FR-UI-2 | FR-UI-4 /// Apply a press, and remember where the anchor was before it moved. /// /// The bookkeeping a double tap depends on, split out from the callback so the /// touch sequence — hold one cell, double-tap another, get the run between — /// can be tested without a window. See [`CollectionsController::previous_anchor`] /// for why the *previous* anchor is the one a double tap means. #[allow(clippy::too_many_arguments)] pub fn press_remembering_anchor( selection: &mut BTreeSet, anchor: &mut Option, previous: &mut Option, ids: &[ImageId], offset: usize, row: usize, ctrl: bool, shift: bool, span: &dyn Fn(usize, usize) -> Vec, ) { let before = *anchor; apply_press(selection, anchor, ids, offset, row, ctrl, shift, span); // Only a press that *moved* the anchor updates this. The second tap of a // double tap lands on the cell the first tap made the anchor, so it changes // nothing and the origin survives to be extended from. if *anchor != before { *previous = before; } } /// TRACES: FR-UI-2 | FR-UI-4 /// Extend the selection to `row` — the touch form of a shift-click. /// /// Added to the selection rather than replacing it: this is reached only from /// selection mode, which the user entered deliberately, and a gesture that /// silently discarded the run they gathered a moment ago would make gathering /// two runs impossible. #[allow(clippy::too_many_arguments)] pub fn extend_to_row( selection: &mut BTreeSet, anchor: &mut Option, previous: Option, ids: &[ImageId], offset: usize, row: usize, span: &dyn Fn(usize, usize) -> Vec, ) { // Falling back to the current anchor makes a double tap with no history // select just that cell, which is what a double tap already did. *anchor = previous.or(*anchor); apply_press(selection, anchor, ids, offset, row, true, true, span); } /// Apply a press and push the result into the grid — the whole of what a /// click, or an arrow key, does to the selection. /// /// Shared so the keyboard and the pointer cannot drift: they are the same /// gesture reached two ways, and the moment one of them grew its own copy of /// the selection policy, "click here, shift+down twice" would stop meaning /// what "click here, shift-click there" means. pub fn select_row( window: &AppWindow, ctl: &Rc, ids: &[ImageId], offset: usize, row: usize, ctrl: bool, shift: bool, ) { // Before anything moves — see `press_undo`. A press that turns out to be // the opening finger of a pinch is undone from here. *ctl.press_undo.borrow_mut() = Some(PressUndo::capture( &ctl.selection.borrow(), *ctl.anchor.borrow(), ctl.previous_anchor.get(), ctl.cursor(), )); let mut previous = ctl.previous_anchor.get(); press_remembering_anchor( &mut ctl.selection.borrow_mut(), &mut ctl.anchor.borrow_mut(), &mut previous, ids, offset, row, ctrl, shift, &|first, last| ctl.span(first, last), ); ctl.previous_anchor.set(previous); // 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-UI-4 /// Put the selection back as it was before the most recent press. /// /// For the gesture that begins as a press and turns out to be something else. /// A pinch is the case that matters: it starts as one finger on a cell, which /// selects it, and only becomes a pinch when the second finger lands — by /// which point the user has selected a photograph they were not reaching for. /// /// Idempotent, and a no-op when there is nothing to undo, so it is safe to /// call on every gesture start rather than only on the ones that need it. pub fn cancel_press(window: &AppWindow, ctl: &Rc, ids: &[ImageId]) { let Some(undo) = ctl.press_undo.borrow_mut().take() else { return; }; let (previous_anchor, cursor) = undo.restore( &mut ctl.selection.borrow_mut(), &mut ctl.anchor.borrow_mut(), ); ctl.previous_anchor.set(previous_anchor); ctl.set_cursor(cursor); sync_selection(window, ctl, ids); } /// Rebuild the sidebar from the catalog. /// /// Called after every edit. The whole tree rather than a patch: a rename can /// reorder siblings, a delete promotes children, and a drop changes counts on /// every ancestor — diffing that against the model would be more code than the /// query costs, and the tree is tens of rows, not thousands. pub fn refresh_tree(window: &AppWindow, ctl: &Rc, catalog: &Catalog) { let rows = match coll::tree(catalog.connection()) { Ok(r) => r, Err(e) => { window.set_collection_error(format!("reading collections: {e}").into()); return; } }; let collapsed = ctl.collapsed.borrow(); // TRACES: FR-NC-6a | FR-NC-6c // How much of each collection is already on the device, rolled up the tree // — computed once for the whole sidebar rather than per row. let held = offline_state(catalog, &rows); // A row is hidden when any ancestor is collapsed. The tree arrives // depth-first, so tracking the shallowest collapsed depth seen is enough — // no ancestor lookup per row. let mut hide_below: Option = None; let mut ids = Vec::new(); let mut smart = Vec::new(); let mut has_kids = Vec::new(); let mut out = Vec::new(); for row in &rows { if let Some(depth) = hide_below { if row.depth > depth { continue; } hide_below = None; } let id = row.collection.id; let expanded = !collapsed.contains(&id); if row.has_children && !expanded { hide_below = Some(row.depth); } // Deep counts are one query per row. That is fine at sidebar scale and // wrong at grid scale, which is why the grid does not do this. let deep = coll::deep_count(catalog.connection(), id).unwrap_or(row.collection.direct_count); let offline = held.get(&id).copied().unwrap_or_default(); ids.push(id); smart.push(row.collection.kind == CollectionKind::Smart); has_kids.push(row.has_children); out.push(CollectionRow { id: id.0 as i32, name: row.collection.name.as_str().into(), depth: row.depth as i32, direct_count: row.collection.direct_count as i32, deep_count: deep as i32, has_children: row.has_children, expanded, smart: row.collection.kind == CollectionKind::Smart, // An empty collection is never "kept": every one of its zero // photographs being here is true and says nothing, and a full tray // beside an empty collection is a lie about what a trip would cost // to take. pinned: offline.total > 0 && offline.pinned == offline.total, partly_pinned: offline.pinned > 0 && offline.pinned < offline.total, }); } *ctl.row_ids.borrow_mut() = ids; *ctl.row_smart.borrow_mut() = smart; *ctl.row_has_children.borrow_mut() = has_kids; window.set_collection_rows(slint::ModelRc::new(slint::VecModel::from(out))); } /// TRACES: FR-NC-6a | FR-NC-6c /// How many of a collection's photographs are pinned, and how many there are. /// /// Counted over *members*, not distinct images: an image filed in both a parent /// and a child is counted in each, which is what makes the roll-up below a sum /// rather than a set union. That inflates the totals of a tree that files the /// same photograph twice, and it does not matter here — the only questions /// asked of these numbers are "all of them?" and "none of them?", and both /// survive the double count. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] struct OfflineCount { total: usize, pinned: usize, } /// The offline state of every collection in the tree, descendants included. /// /// One query for the direct membership, then a single reverse pass to fold each /// row into its parent. The alternative — `descendants` plus a count per row — /// is two queries per collection, and the sidebar rebuilds after every drop, /// rename and rating change. /// /// The reverse pass is correct because `tree` emits parents before children: /// walking it backwards means every child has already been folded in by the /// time its parent is reached, however deep the nesting goes. fn offline_state( catalog: &Catalog, rows: &[coll::TreeRow], ) -> std::collections::HashMap { let mut counts: std::collections::HashMap = std::collections::HashMap::new(); // `tier_actual`, not `pinned`: the question a user is asking of this icon is // "will these open on the aeroplane", and a pin whose download has not run // yet answers no (see the note on `tier_actual` in `dr_catalog::cache`). let sql = "SELECT cm.collection_id, count(*), coalesce(sum(CASE WHEN ic.tier_actual >= ?1 THEN 1 ELSE 0 END), 0) FROM collection_members cm LEFT JOIN image_cache ic ON ic.image_id = cm.image_id GROUP BY cm.collection_id"; match catalog.connection().prepare(sql).and_then(|mut stmt| { let rows = stmt.query_map(rusqlite::params![dr_types::Tier::Original.stored()], |r| { Ok(( CollectionId(r.get::<_, i64>(0)? as u64), r.get::<_, i64>(1)? as usize, r.get::<_, i64>(2)? as usize, )) })?; rows.collect::, _>>() }) { Ok(direct) => { for (id, total, pinned) in direct { counts.insert(id, OfflineCount { total, pinned }); } } Err(e) => { // Not fatal: the tray then reads "nothing kept", which is the safe // direction — it offers a download rather than claiming a trip is // already on the device. log::debug!("reading offline state: {e}"); return counts; } } for row in rows.iter().rev() { let Some(parent) = row.collection.parent else { continue; }; let child = counts.get(&row.collection.id).copied().unwrap_or_default(); if child.total == 0 { continue; } let entry = counts.entry(parent).or_default(); entry.total += child.total; entry.pinned += child.pinned; } counts } /// Build the bitmap that travels under the cursor. /// /// One image is drawn as itself. Several are **fanned**, back to front with the /// topmost last, so the cursor carries a visibly thicker stack the more is being /// dragged — the count is legible from the shape rather than needing a number. /// /// Composited here rather than in Slint because `DragArea.drag-image` takes a /// single bitmap, and Slint cannot render a pile of thumbnails into one. /// /// Only the top few are drawn. A forty-image drag would otherwise be forty /// composites for a stack whose lower layers are hidden by the ones above. fn compose_drag_image(thumbs: &[slint::Image]) -> slint::Image { /// Layers drawn, at most. Past this the stack looks no thicker. const MAX_LAYERS: usize = 4; /// Pixel step between layers, in the composite's own space. const FAN: u32 = 10; /// Long edge of the composed bitmap. const EDGE: u32 = 160; let layers: Vec<&slint::Image> = thumbs.iter().rev().take(MAX_LAYERS).collect(); let Some(top) = layers.first() else { return slint::Image::default(); }; // The whole composite is the top image's box plus room for the fan. let offset = FAN * (layers.len().saturating_sub(1)) as u32; let size = top.size(); if size.width == 0 || size.height == 0 { return slint::Image::default(); } // Scale the top thumbnail so its long edge is EDGE, then add the fan. let scale = EDGE as f32 / size.width.max(size.height) as f32; let tw = ((size.width as f32 * scale) as u32).max(1); let th = ((size.height as f32 * scale) as u32).max(1); let mut canvas = slint::SharedPixelBuffer::::new(tw + offset, th + offset); let cw = canvas.width(); let stride = cw as usize; let pixels = canvas.make_mut_slice(); // Back to front: `layers` is already reversed, so the last drawn is the // image the user grabbed and it lands on top. for (n, layer) in layers.iter().enumerate().rev() { // The furthest-back layer sits at the largest offset, so the stack fans // down and right from the top image at (0, 0). let dx = FAN * n as u32; let dy = FAN * n as u32; // Each layer is fitted to the *top* image's box rather than stretched to // it: a portrait frame behind a landscape one would otherwise be visibly // distorted, and the stack stops reading as a pile of photographs. let s = layer.size(); let (lw, lh) = if s.width == 0 || s.height == 0 { (tw, th) } else { let fit = (tw as f32 / s.width as f32).min(th as f32 / s.height as f32); ( ((s.width as f32 * fit) as u32).max(1), ((s.height as f32 * fit) as u32).max(1), ) }; // Centred in the slot, so a narrower frame is not pinned to one edge. let cx = dx + (tw - lw.min(tw)) / 2; let cy = dy + (th - lh.min(th)) / 2; blit_scaled(layer, pixels, stride, cx, cy, lw, lh, n > 0); } slint::Image::from_rgba8_premultiplied(canvas) } /// Draw one thumbnail into the composite, scaled to `tw`×`th` at `dx`,`dy`. /// /// Nearest-neighbour: this is a transient 160px cursor bitmap, and a filtered /// resample would cost more than it could visibly buy. `dim` darkens the layers /// beneath the top one so the stack reads as depth rather than as a smear. /// /// The buffer is premultiplied, so the alpha applied here is baked into the /// colour channels as well. #[allow(clippy::too_many_arguments)] fn blit_scaled( src: &slint::Image, dst: &mut [slint::Rgba8Pixel], stride: usize, dx: u32, dy: u32, tw: u32, th: u32, dim: bool, ) { let Some(buf) = src.to_rgba8() else { return }; let (sw, sh) = (buf.width(), buf.height()); if sw == 0 || sh == 0 { return; } let src_px = buf.as_slice(); for y in 0..th { let sy = (y * sh / th).min(sh - 1); for x in 0..tw { let sx = (x * sw / tw).min(sw - 1); let s = src_px[(sy * sw + sx) as usize]; // Clipped per pixel on both axes. A row-major index alone would let // an overhanging right edge wrap onto the next line, which draws as // a smear rather than as an out-of-bounds panic. let (px, py) = (dx + x, dy + y); if px as usize >= stride { continue; } let out = py as usize * stride + px as usize; if out >= dst.len() { continue; } // Layers below the top are darkened, not made transparent: the // composite sits over whatever is on screen, and translucency there // would show the desktop through the stack. let f = if dim { 0.55 } else { 1.0 }; dst[out] = slint::Rgba8Pixel { r: (s.r as f32 * f) as u8, g: (s.g as f32 * f) as u8, b: (s.b as f32 * f) as u8, a: s.a, }; } } } /// Mark which cells are currently lifted out by a drag. /// /// Separate from [`sync_selection`] because the two differ: the selection /// persists after the drag, the lift lasts only while it is in flight. pub fn sync_lifted(window: &AppWindow, lifted: &[ImageId], ids: &[ImageId]) { let model = window.get_library_cells(); for (row, id) in ids.iter().enumerate() { let want = lifted.contains(id); if let Some(mut cell) = model.row_data(row) { if cell.lifted != want { cell.lifted = want; model.set_row_data(row, cell); } } } } /// Push the current selection into the grid model's `selected` flags. /// /// The model carries the flag per cell so Slint can style without a lookup; /// this is what keeps the two in step after a scrub, a drop, or a click. pub fn sync_selection(window: &AppWindow, ctl: &Rc, ids: &[ImageId]) { let selection = ctl.selection.borrow(); let model = window.get_library_cells(); // The anchor is an ordinal and the model is a window, so it marks a row // only while the photograph it names is on screen. Off screen it marks // nothing, which is the honest answer: the range still extends from there. let offset = window.get_library_offset().max(0) as usize; let anchor_row = ctl.anchor.borrow().and_then(|at| at.checked_sub(offset)); for (row, id) in ids.iter().enumerate() { let want = selection.contains(id); let anchored = anchor_row == Some(row); if let Some(mut cell) = model.row_data(row) { if cell.selected != want || cell.anchor != anchored { cell.selected = want; cell.anchor = anchored; model.set_row_data(row, cell); } } } window.set_library_selected_count(selection.len() as i32); } /// Refresh the per-cell "in this many collections" badges. /// /// One query for the whole window rather than one per cell: 120 cells is 120 /// round trips otherwise, on every drop. pub fn sync_badges(window: &AppWindow, catalog: &Catalog, ids: &[ImageId]) { if ids.is_empty() { return; } let placeholders = std::iter::repeat_n("?", ids.len()) .collect::>() .join(","); let sql = format!( "SELECT m.image_id, count(*) FROM collection_members m JOIN collections c ON c.id = m.collection_id WHERE m.image_id IN ({placeholders}) AND c.deleted = 0 GROUP BY m.image_id" ); let params: Vec = ids .iter() .map(|i| rusqlite::types::Value::Integer(i.0 as i64)) .collect(); let mut counts = std::collections::HashMap::new(); if let Ok(mut stmt) = catalog.connection().prepare(&sql) { if let Ok(rows) = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| { Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)) }) { for (id, n) in rows.flatten() { counts.insert(id, n as i32); } } } let model = window.get_library_cells(); for (row, id) in ids.iter().enumerate() { let want = counts.get(&(id.0 as i64)).copied().unwrap_or(0); if let Some(mut cell) = model.row_data(row) { if cell.collection_count != want { cell.collection_count = want; model.set_row_data(row, cell); } } } } /// How long the pointer must dwell on a collapsed parent before it springs /// open, mid-drag. /// /// Long enough that crossing a parent on the way somewhere else does not open /// it — a tree that flaps open under every passing pointer is worse than one /// that never opens. Short enough to feel like a response rather than a wait; /// this is the range file managers have settled on for the same gesture. const SPRING_DELAY_MS: u64 = 500; /// TRACES: FR-UI-2 | FR-UI-4 /// How long a cell must be held before the grid enters selection mode. /// /// The platform convention, and the reason to match it rather than pick: every /// gallery on the device this exists for opens a selection on a hold of about /// this length, so a user who has never read a word about DarkRoom already /// knows the gesture. Shorter and a slow tap becomes a selection; longer and /// the hand lets go first, having concluded nothing was going to happen. pub(crate) const HOLD_DELAY_MS: u64 = 450; /// TRACES: FR-UI-2 | FR-UI-4 /// Start the timer that turns a held cell into a selection. /// /// Restarted per press, and cancelled by the release — see `hold_timer`. The /// press that armed it has *already* selected the cell under the finger, so /// what firing adds is the mode: from here taps toggle rather than open, and /// the header's buttons appear to act on what has been gathered. fn arm_hold(window: &AppWindow, ctl: &Rc) { let timer = slint::Timer::default(); let weak = window.as_weak(); let ctl_cb = ctl.clone(); timer.start( slint::TimerMode::SingleShot, std::time::Duration::from_millis(HOLD_DELAY_MS), move || { let Some(w) = weak.upgrade() else { return }; ctl_cb.select_mode.set(true); w.set_library_select_mode(true); // The release that follows this hold must not also open the image: // the user asked for a selection and would land in develop instead. // Reusing `modified_press` rather than adding a second flag — the // click already consults it, and it means exactly this: "the press // was a selection gesture". ctl_cb.modified_press.set(true); }, ); *ctl.hold_timer.borrow_mut() = Some(timer); } /// Whether hovering this row should schedule a spring expansion. /// /// Pure so the rule is testable: only a *collapsed parent* has anything to /// open. A leaf would flash a pointless rebuild, and one already expanded is /// where the user can already see the children. fn should_spring( row: Option, row_ids: &[CollectionId], row_has_children: &[bool], collapsed: &std::collections::HashSet, ) -> Option { let row = row?; let &id = row_ids.get(row)?; let has_children = row_has_children.get(row).copied().unwrap_or(false); (has_children && collapsed.contains(&id)).then_some(id) } /// Start (or restart) the dwell timer that opens a collapsed collection. /// /// Called on every hover change during a drag. Restarting on each change is /// what makes the dwell a dwell: moving to another row cancels the pending /// expansion instead of queueing a second one. fn arm_spring( window: &AppWindow, ctl: &Rc, catalog: &Rc>>, row: Option, ) { // Dropping the old timer cancels it. Anything already scheduled for the row // the pointer has just left must not fire. *ctl.spring_timer.borrow_mut() = None; let Some(row) = row else { return }; // Only a collapsed parent has anything to spring. A leaf, or one already // open, is left alone rather than being pointlessly "expanded". let target = should_spring( Some(row), &ctl.row_ids.borrow(), &ctl.row_has_children.borrow(), &ctl.collapsed.borrow(), ); let Some(id) = target else { return }; let timer = slint::Timer::default(); let weak = window.as_weak(); let ctl_cb = ctl.clone(); let catalog = catalog.clone(); timer.start( slint::TimerMode::SingleShot, std::time::Duration::from_millis(SPRING_DELAY_MS), move || { let Some(w) = weak.upgrade() else { return }; // The drag may have ended, or moved on, during the dwell. // Expanding then would rearrange the sidebar for no reason the user // can connect to what they did. `dragging` being non-empty *is* the // "a drag is live" test — Slint owns the gesture now, so there is no // window flag to consult. if ctl_cb.dragging.borrow().is_empty() || *ctl_cb.hover_id.borrow() != Some(id) { return; } ctl_cb.collapsed.borrow_mut().remove(&id); // Remembered so it can be closed again if the drag ends elsewhere. ctl_cb.spring_opened.borrow_mut().push(id); let borrow = catalog.borrow(); if let Some(cat) = borrow.as_ref() { // The rebuild inserts the children below this row. The pointer // is still over this same collection, and its own `DropArea` // re-establishes the highlight — there is no index to re-point, // which is the second thing the native drag API removed. refresh_tree(&w, &ctl_cb, cat); } }, ); *ctl.spring_timer.borrow_mut() = Some(timer); } /// Close whatever the spring opened during a drag that did not land in it. /// /// A collection the user dropped into stays open — they are working in it. One /// merely passed over is put back, so a drag across a deep tree does not leave /// it unfolded. fn collapse_spring_opened( window: &AppWindow, ctl: &Rc, catalog: &Rc>>, keep: Option, ) { *ctl.spring_timer.borrow_mut() = None; let opened = std::mem::take(&mut *ctl.spring_opened.borrow_mut()); if opened.is_empty() { return; } { let mut collapsed = ctl.collapsed.borrow_mut(); for id in opened { // The collection dropped into stays open, and so does every // ancestor of it — closing a parent would hide the very row that // just received the images. let keep_this = keep.is_some_and(|k| { k == id || catalog .borrow() .as_ref() .and_then(|cat| coll::descendants(cat.connection(), id).ok()) .is_some_and(|d| d.contains(&k)) }); if !keep_this { collapsed.insert(id); } } } let borrow = catalog.borrow(); if let Some(cat) = borrow.as_ref() { refresh_tree(window, ctl, cat); } } /// Begin a soft delete: plan the moves, then hand them to a worker. /// /// The plan is built here because it reads the catalog, which is not `Send`; the /// worker gets paths and ids and needs no catalog to do its half. #[allow(clippy::too_many_arguments)] fn start_trash( window: &AppWindow, ctl: &Rc, catalog: &Rc>>, session: &Rc Option>, images: &[ImageId], reload: &Rc, ) { let Some(conn) = session() else { window.set_collection_error("Open a library first.".into()); return; }; let moves = { let borrow = catalog.borrow(); let Some(cat) = borrow.as_ref() else { return }; match crate::trash::plan_trash(cat, &conn.account.root, images) { Ok(m) => m, Err(e) => { window.set_collection_error(format!("planning delete: {e}").into()); return; } } }; if moves.is_empty() { return; } log::info!("moving {} image(s) to the trash", moves.len()); window.set_library_status(format!("Moving {} to the trash…", moves.len()).into()); // The images are leaving the grid; a selection pointing at them would // survive as a set of ids the user can no longer see. ctl.clear_selection(); let count = moves.len(); let rx = crate::trash::spawn_move( conn.clone(), moves, crate::trash::Direction::ToTrash, crate::library::catalog_path(&conn.account), ); drain_trash( window.as_weak(), ctl.clone(), catalog.clone(), rx, reload.clone(), format!("Moving {count} photograph(s) to the trash"), ); } /// TRACES: FR-CAT-15 /// Put trashed images back where they came from. /// /// The mirror of [`start_trash`], and separate from it rather than a `direction` /// parameter on one function: the two differ in what they plan, what they report /// and what they say when the plan comes back empty, and the shared part is the /// three lines that spawn the worker. /// /// An image whose origin was never recorded is skipped by /// [`crate::trash::plan_restore`] rather than guessed at. That can make the plan /// shorter than the selection, which is why an empty plan is reported here /// instead of returning silently — the user pressed a button and is owed an /// answer either way. fn start_restore( window: &AppWindow, ctl: &Rc, catalog: &Rc>>, session: &Rc Option>, images: &[ImageId], reload: &Rc, ) { let Some(conn) = session() else { window.set_collection_error("Open a library first.".into()); return; }; let moves = { let borrow = catalog.borrow(); let Some(cat) = borrow.as_ref() else { return }; match crate::trash::plan_restore(cat, images) { Ok(m) => m, Err(e) => { window.set_collection_error(format!("planning restore: {e}").into()); return; } } }; if moves.is_empty() { // Said out loud rather than passed over in silence: a button that does // nothing visible reads as broken, and the reason here is specific. window.set_collection_error( "Nothing to restore — no record of where these came from.".into(), ); return; } log::info!("restoring {} image(s) from the trash", moves.len()); window.set_library_status(format!("Restoring {}…", moves.len()).into()); // The images are leaving the trash view, so a selection pointing at them // would survive as ids the user can no longer see. ctl.clear_selection(); let count = moves.len(); let rx = crate::trash::spawn_move( conn.clone(), moves, crate::trash::Direction::Restore, crate::library::catalog_path(&conn.account), ); drain_trash( window.as_weak(), ctl.clone(), catalog.clone(), rx, reload.clone(), format!("Restoring {count} photograph(s)"), ); } /// Drain a trash worker on the UI thread. /// /// Same shape as the scan and thumbnail drains: an mpsc channel polled by a /// Slint timer, so nothing blocks the event loop (NFR-P9). fn drain_trash( weak: slint::Weak, ctl: Rc, catalog: Rc>>, rx: std::sync::mpsc::Receiver, reload: Rc, // What the register calls this operation. Passed in rather than derived // here: the three callers move files to the trash, back out of it, and // delete them outright, and "Deleting 40 photographs" is the one word of // the three that must not appear over a restore. title: String, ) { use crate::trash::TrashMessage; let timer = slint::Timer::default(); let ctl_cb = ctl.clone(); // A server-side MOVE per file, so it is a transfer in the sense that // matters: it takes as long as the connection is slow, and it can fail // halfway with the library in two states at once. let job = ctl.activity.begin(crate::activity::Kind::Trash, title); timer.start( slint::TimerMode::Repeated, std::time::Duration::from_millis(120), move || { let Some(w) = weak.upgrade() else { return }; loop { let msg = match rx.try_recv() { Ok(m) => m, Err(std::sync::mpsc::TryRecvError::Empty) => return, Err(std::sync::mpsc::TryRecvError::Disconnected) => { // A worker that died without reporting must not leave the // status line mid-sentence. job.fail("stopped without finishing"); stop_trash(&ctl_cb); return; } }; match msg { TrashMessage::Progress { done, total, failed, } => { let status = if failed > 0 { format!("{done} / {total} · {failed} failed") } else { format!("{done} / {total}") }; job.progress(done, total); if failed > 0 { job.detail(format!("{failed} failed")); } w.set_library_status(status.into()); } TrashMessage::Done { moved, failed } => { // Reported honestly, including the partial case: "38 of // 40" is the truth when two files could not be moved, // and claiming 40 would hide a real problem. let status = if failed.is_empty() { format!("{moved} image(s) done") } else { format!("{moved} done · {} failed", failed.len()) }; // A partial failure is a failure in the register: the // library is now in two states at once, which is // exactly the thing worth keeping on the list. if failed.is_empty() { job.finish(status.clone()); } else { job.fail(status.clone()); } w.set_library_status(status.into()); if let Some(first) = failed.first() { w.set_collection_error(first.as_str().into()); } let borrow = catalog.borrow(); if let Some(cat) = borrow.as_ref() { refresh_trash(&w, cat); refresh_tree(&w, &ctl_cb, cat); } drop(borrow); // The grid changed: images left the library, or came // back into it. reload(); stop_trash(&ctl_cb); return; } } } }, ); *ctl.trash_timer.borrow_mut() = Some(timer); } fn stop_trash(ctl: &Rc) { if let Some(t) = ctl.trash_timer.borrow().as_ref() { t.stop(); } } /// Refresh the sidebar's trash count and size. /// /// The size is formatted here rather than in Slint, which has no byte-size /// formatting — and the number is what tells the user whether emptying is worth /// it. pub fn refresh_trash(window: &AppWindow, catalog: &Catalog) { let (n, bytes) = dr_catalog::trash::summary(catalog.connection()).unwrap_or((0, 0)); window.set_trash_count(n as i32); window.set_trash_label(if n == 0 { slint::SharedString::new() } else { format!("{n} · {}", format_bytes(bytes)).into() }); } /// Bytes as a human-readable size. /// /// Binary units, one decimal place past a kilobyte: a RAW library is measured in /// gigabytes and "3.4 GB" is the figure a photographer reasons about, where /// 3_650_722_201 is not. fn format_bytes(bytes: u64) -> String { const KB: f64 = 1024.0; let b = bytes as f64; if bytes < 1024 { return format!("{bytes} B"); } for (limit, unit) in [ (KB * KB, "kB"), (KB * KB * KB, "MB"), (KB * KB * KB * KB, "GB"), ] { if b < limit { return format!("{:.1} {unit}", b / (limit / KB)); } } format!("{:.1} TB", b / (KB * KB * KB * KB)) } /// Connect the sidebar and drag callbacks. /// /// `on_scope_changed` reloads the grid — that lives in [`crate::library_ui`], /// which owns the window and the thumbnail workers, so it is passed in rather /// than reached for. #[allow(clippy::too_many_arguments)] pub fn wire( window: &AppWindow, ctl: Rc, catalog: Rc>>, on_scope_changed: S, visible_ids: R, span_ids: P, session: C, ) where S: Fn() + 'static, R: Fn() -> Vec + 'static, P: Fn(usize, usize) -> Vec + 'static, C: Fn() -> Option + 'static, { // Coerced to trait objects here rather than at each use: `start_trash` and // `drain_trash` are shared by three callbacks, and a generic parameter would // make each of them a separate instantiation for no gain. let on_scope_changed: Rc = Rc::new(on_scope_changed); let visible_ids = Rc::new(visible_ids); // A shift-click asks the catalog what lies between its two ends, and the // catalog belongs to the grid's controller — see `span_source`. *ctl.span_source.borrow_mut() = Some(Rc::new(span_ids)); let session: Rc Option> = Rc::new(session); // --- selection --------------------------------------------------------- { let weak = window.as_weak(); let ctl = ctl.clone(); let visible = visible_ids.clone(); window.on_library_cell_pressed(move |row, ctrl_held, shift_held| { let Some(w) = weak.upgrade() else { return }; let ids = visible(); // Consulted by the click that follows: a modified press is building // a selection and must not also navigate to develop. ctl.modified_press.set(ctrl_held || shift_held); // Where the window starts, so the press is recorded as the ordinal // it is rather than as a row that stops meaning this photograph on // the next scroll. let offset = w.get_library_offset().max(0) as usize; select_row(&w, &ctl, &ids, offset, row as usize, ctrl_held, shift_held); // TRACES: FR-UI-2 | FR-UI-4 // And start counting, in case this press is a hold. The press has // already selected this one cell; what the hold adds is the *mode*, // so the taps that follow go on selecting instead of opening the // next photograph the user touches. // // Not started when the mode is already on: it is on, and a second // hold would have nothing to do but suppress the tap that ends it. if !ctl.select_mode.get() { arm_hold(&w, &ctl); } }); } // TRACES: FR-UI-2 | FR-UI-4 // A double tap in selection mode: take everything between the cell the // selection started from and this one. // // This is shift-click, reached by the one gesture touch has left. Hold to // start selecting, double-tap the far end, and a run of forty photographs // is three touches — then the whole selection drags onto a collection as // one, which is the thing this sequence exists to make possible. // // The range is *added*, not replaced, so a second run can be picked up // without losing the first — ctrl+shift's behaviour, and the right one // here: a mode the user entered deliberately should accumulate rather than // throw away what they have already gathered. { let weak = window.as_weak(); let ctl = ctl.clone(); let visible = visible_ids.clone(); window.on_library_cell_double_clicked(move |row| { let Some(w) = weak.upgrade() else { return }; // Outside selection mode a double tap is two ordinary clicks, and // the first has already opened the image. Nothing to do. if !ctl.select_mode.get() { return; } // The hold that would have fired mid-double-tap. *ctl.hold_timer.borrow_mut() = None; let ids = visible(); let offset = w.get_library_offset().max(0) as usize; // Extended from the cell the user started at, not from the one the // two taps just moved the anchor onto. extend_to_row( &mut ctl.selection.borrow_mut(), &mut ctl.anchor.borrow_mut(), ctl.previous_anchor.get(), &ids, offset, row as usize, &|first, last| ctl.span(first, last), ); ctl.set_cursor(Some(offset + row as usize)); sync_selection(&w, &ctl, &ids); }); } // TRACES: FR-UI-2 | FR-UI-4 // The press ended — lifted, or taken by the Flickable when the finger // travelled. Either way the hold is off. { let ctl = ctl.clone(); window.on_library_cell_press_ended(move || { *ctl.hold_timer.borrow_mut() = None; }); } // TRACES: FR-CAT-5 // Put the selection down without leaving select mode. // // Distinct from "Done", which does both: after picking forty photographs // and filing them, the next forty start with the mode already on, and // making the user re-enter it is asking them to undo a step they did not // take. It is also the only way back from a mis-tap that is not tapping // every one of them again. { let weak = window.as_weak(); let ctl = ctl.clone(); let visible = visible_ids.clone(); window.on_library_clear_selection(move || { let Some(w) = weak.upgrade() else { return }; ctl.clear_selection(); sync_selection(&w, &ctl, &visible()); }); } // TRACES: FR-CAT-5 // A collection holding exactly what is selected. // // This was four steps — make a collection, find it in the tree, select the // photographs again because creating one changed the scope, then add them // — and the selection is usually made *because* it is going somewhere. One // press instead. // // Created at the top level rather than inside the current scope, unlike // the tree's "+". A selection can be gathered from anywhere, including // across collections, so filing it under whichever one happens to be open // would put it somewhere its contents did not come from. { let weak = window.as_weak(); let ctl = ctl.clone(); let catalog = catalog.clone(); let visible = visible_ids.clone(); let reload = on_scope_changed.clone(); window.on_library_collection_from_selection(move || { let Some(w) = weak.upgrade() else { return }; let images = ctl.selected(); if images.is_empty() { return; } let borrow = catalog.borrow(); let Some(cat) = borrow.as_ref() else { w.set_collection_error("Open a library first.".into()); return; }; let name = unique_name(cat.connection(), None); let made = coll::create(cat.connection(), &name, None, CollectionKind::Manual) .and_then(|id| coll::add_images(cat.connection(), id, &images).map(|n| (id, n))); match made { Ok((id, added)) => { w.set_collection_error(slint::SharedString::new()); w.set_library_status( format!( "{added} photograph{} in {name}", if added == 1 { "" } else { "s" } ) .into(), ); refresh_tree(&w, &ctl, cat); drop(borrow); // The selection has been filed; holding on to it invites // the next press acting on photographs the user considers // dealt with. ctl.clear_selection(); sync_selection(&w, &ctl, &visible()); // Into the name field, for the same reason `collection_new` // does it: "New collection" is a placeholder nobody wants // to keep, and making them find the rename afterwards is // asking them to finish a job we started. w.set_collection_renaming(id.0 as i32); reload(); } Err(e) => { drop(borrow); w.set_collection_error(format!("making a collection: {e}").into()); } } }); } // TRACES: FR-UI-2 | FR-UI-4 // The button half of selection mode. The long press is faster and this is // the one that can be found — a gesture with no visible counterpart is a // feature only its author knows about. { let weak = window.as_weak(); let ctl = ctl.clone(); let visible = visible_ids.clone(); window.on_library_toggle_select_mode(move || { let Some(w) = weak.upgrade() else { return }; let on = !ctl.select_mode.get(); ctl.select_mode.set(on); w.set_library_select_mode(on); *ctl.hold_timer.borrow_mut() = None; // Leaving the mode drops the selection. "Done" reads as finishing // with these photographs, and a selection that outlived the mode // would still be acted on by the buttons in the header — which is // how forty images get exported by a user who thought they had put // them down. if !on { ctl.clear_selection(); sync_selection(&w, &ctl, &visible()); } }); } // TRACES: FR-CAT-7 | FR-UI-4 // File the selection in a collection without dragging it there. { let weak = window.as_weak(); let ctl = ctl.clone(); let catalog = catalog.clone(); let visible = visible_ids.clone(); let reload = on_scope_changed.clone(); window.on_library_file_in_collection(move |id, moves| { let Some(w) = weak.upgrade() else { return }; let target = CollectionId(id as u64); let chosen = ctl.selected(); if chosen.is_empty() { return; } let borrow = catalog.borrow(); let Some(cat) = borrow.as_ref() else { return }; // Moving is only a move when there is somewhere to move *from*. // Unscoped, the sheet does not offer it; this guards the callback // anyway, and degrades to filing rather than refusing. let from = if moves { *ctl.scope.borrow() } else { None }; let result = match from { Some(from) => coll::move_images(cat.connection(), from, target, &chosen), None => coll::add_images(cat.connection(), target, &chosen), }; match result { Ok(filed) => { w.set_collection_error(slint::SharedString::new()); // The honest count, as the drop reports it: claiming all // twelve when nine were already there teaches the user to // distrust every number the app shows them. let msg = if from.is_some() { format!("Moved {filed} of {}", chosen.len()) } else if filed == chosen.len() { format!("Added {filed} to collection") } else { format!( "Added {filed} of {} — the rest were already there", chosen.len() ) }; w.set_library_status(msg.into()); refresh_tree(&w, &ctl, cat); sync_badges(&w, cat, &visible()); // A move changes what the collection on screen holds, so // the grid has to be reread; filing elsewhere does not, but // the badge count on every cell just changed and the reread // is one query. drop(borrow); reload(); } Err(e) => w.set_collection_error(format!("filing: {e}").into()), } }); } // --- drag -------------------------------------------------------------- // // Slint owns the gesture (see the preamble). What is left here is the // payload — the image ids the drop will act on — and the spring. // The payload is built when the drag starts, so it is the selection as it // stands at that moment rather than whatever it becomes mid-flight. { let ctl = ctl.clone(); window.on_library_drag_payload(move || { let carried = ctl.dragging.borrow().clone(); let mut data = slint::DataTransfer::default(); // `user_data` rather than plain text: these are catalog ids for our // own drop handler, not something another application should be // able to interpret as a paste. data.set_user_data(Rc::new(carried)); data }); } // Dragging a collection row. The id travels in the payload for anything // that cares to read it; the drop itself uses the remembered press, since // it is handed only the target. { window.on_collection_drag_payload(move |id| { let mut data = slint::DataTransfer::default(); data.set_user_data(Rc::new(CollectionId(id as u64))); data }); } { let weak = window.as_weak(); let ctl = ctl.clone(); let visible = visible_ids.clone(); window.on_library_drag_started(move |row| { let Some(w) = weak.upgrade() else { return }; let ids = visible(); // Dragging an *unselected* cell carries only that one, and makes it // the selection — otherwise the images that travel are not the ones // the user grabbed. Dragging a selected cell carries the whole // selection, which is the multi-image gesture. let carried: Vec = { let mut selection = ctl.selection.borrow_mut(); match ids.get(row as usize) { Some(id) if !selection.contains(id) => { selection.clear(); selection.insert(*id); vec![*id] } _ => selection.iter().copied().collect(), } }; // The bitmap under the cursor, built from the thumbnails already in // the model — the drag carries what the user can see, and a cell // whose preview has not landed yet contributes nothing rather than // a placeholder. let thumbs: Vec = { let model = w.get_library_cells(); carried .iter() .filter_map(|id| ids.iter().position(|v| v == id)) .filter_map(|row| model.row_data(row)) .filter(|c| c.has_thumb) .map(|c| c.thumbnail) .collect() }; w.set_library_drag_image(compose_drag_image(&thumbs)); *ctl.dragging.borrow_mut() = carried.clone(); sync_selection(&w, &ctl, &ids); // The lift-out: these cells fade and shrink in place, so the grid // shows where the photographs came from while the cursor shows them // in full colour. sync_lifted(&w, &carried, &ids); }); } // A drag dwelling over a collapsed collection springs it open, so a nested // child can be reached without putting the images down first. { let weak = window.as_weak(); let ctl = ctl.clone(); let catalog_for_spring = catalog.clone(); window.on_collection_drag_over(move |id, over| { let Some(w) = weak.upgrade() else { return }; let id = CollectionId(id as u64); if !over { // Left this row. Cancel its pending expansion rather than // letting it fire over whatever the pointer moved on to. if *ctl.hover_id.borrow() == Some(id) { *ctl.hover_id.borrow_mut() = None; *ctl.spring_timer.borrow_mut() = None; } return; } *ctl.hover_id.borrow_mut() = Some(id); let row = ctl.row_ids.borrow().iter().position(|&c| c == id); arm_spring(&w, &ctl, &catalog_for_spring, row); }); } // A drop. Slint hit-tested the release and `can-drop` already refused a // saved filter, so reaching here means this collection accepted. { let weak = window.as_weak(); let ctl = ctl.clone(); let catalog = catalog.clone(); // No model refresh or reload here on purpose — see the note at the end // of this handler. `drag-finished` owns those. window.on_collection_dropped(move |id| { let Some(w) = weak.upgrade() else { return }; let id = CollectionId(id as u64); let carried = ctl.dragging.borrow().clone(); // Taken, not read: a remembered press must be spent by the drop it // belongs to, or a later empty drop — a file dragged in from // outside, say — would move a collection nobody touched. let pressed = ctl.dragging_collection.borrow_mut().take(); let carried = match decide_drop(&carried, pressed, id) { Drop::Nothing => return, Drop::Reparent(source) => { let result = { let borrow = catalog.borrow(); let Some(cat) = borrow.as_ref() else { return }; coll::set_parent(cat.connection(), source, Some(id)) }; match result { Ok(()) => { w.set_collection_error(slint::SharedString::new()); w.set_library_status("Moved collection".into()); let borrow = catalog.borrow(); if let Some(cat) = borrow.as_ref() { refresh_tree(&w, &ctl, cat); } } // The catalog refuses a cycle rather than letting the // tree walk spin. Saying so is better than a drop that // silently does nothing. Err(e) => w.set_collection_error(format!("moving collection: {e}").into()), } return; } Drop::FileImages(images) => images, }; // Scoped so the borrow is released before the spring cleanup below, // which needs the catalog itself. let result = { let borrow = catalog.borrow(); let Some(cat) = borrow.as_ref() else { return }; coll::add_images(cat.connection(), id, &carried) }; match result { Ok(added) => { w.set_collection_error(slint::SharedString::new()); // "Added 3 of 12" is the honest report when nine were // already there; claiming 12 would teach the user to // distrust the count. let msg = if added == carried.len() { format!("Added {added} to collection") } else { format!( "Added {added} of {} — the rest were already there", carried.len() ) }; w.set_library_status(msg.into()); } Err(e) => w.set_collection_error(format!("adding to collection: {e}").into()), } // Recorded, not acted on. Every visible consequence — rebuilding // the tree, refreshing the badges, rereading the grid — happens in // `drag-finished`, because all three replace models that Slint is // *currently walking* to deliver this very event. Tearing down a // live `DropArea` from inside its own `dropped` handler is the same // hazard `sync_rows` in lib.rs documents for the adjust panel. *ctl.dropped_on.borrow_mut() = Some(id); }); } // The drag ended: dropped, or abandoned. This is where the consequences of // a drop land, once Slint has finished with the elements involved. { let weak = window.as_weak(); let ctl = ctl.clone(); let catalog = catalog.clone(); let visible = visible_ids.clone(); let reload = on_scope_changed.clone(); let session = session.clone(); window.on_library_drag_finished(move || { let Some(w) = weak.upgrade() else { return }; let landed = ctl.dropped_on.borrow_mut().take(); let to_trash = ctl.trash_requested.borrow_mut().take(); ctl.dragging.borrow_mut().clear(); *ctl.hover_id.borrow_mut() = None; *ctl.spring_timer.borrow_mut() = None; // A soft delete, deferred out of the drop handler so the models it // replaces are no longer being walked. if let Some(images) = to_trash { start_trash(&w, &ctl, &catalog, &session, &images, &reload); } // The cells settle back into the grid, and the cursor bitmap is // released — it holds a copy of every thumbnail it composited. sync_lifted(&w, &[], &visible()); w.set_library_drag_image(slint::Image::default()); if landed.is_some() { let borrow = catalog.borrow(); if let Some(cat) = borrow.as_ref() { // Counts changed on the target and every ancestor, and the // dropped images now carry one more collection badge. refresh_tree(&w, &ctl, cat); sync_badges(&w, cat, &visible()); } } // Put back whatever the spring opened on the way. The collection // that received the images — and its ancestors — stay open, since // that is where the user is now working; on an abandoned drag // `landed` is `None` and everything closes. collapse_spring_opened(&w, &ctl, &catalog, landed); // A drop into the collection currently being shown changes what that // collection holds, so the grid has to be reread. if landed.is_some() && landed == *ctl.scope.borrow() { reload(); } }); } // --- trash ------------------------------------------------------------- // // TRACES: FR-CAT-15 // A drop here is a *soft delete*: the file moves to a trash folder on the // server and the catalog records where it came from. Nothing is destroyed // until the user empties it, which is a separate, deliberate action. { let weak = window.as_weak(); let ctl = ctl.clone(); let session = session.clone(); window.on_trash_dropped(move || { let Some(w) = weak.upgrade() else { return }; let carried = ctl.dragging.borrow().clone(); if carried.is_empty() { return; } // Recorded like a collection drop, and acted on in `drag-finished` // for the same reason: the work replaces Slint models that are // still being walked to deliver this event. *ctl.trash_requested.borrow_mut() = Some(carried); let _ = session; w.set_collection_error(slint::SharedString::new()); }); } // Restore. Only reachable while the trash is being looked at, and it acts // on the selection rather than on everything — the trash is where a user // goes to recover *one* mistake, not usually to undo the lot. { let weak = window.as_weak(); let ctl = ctl.clone(); let catalog = catalog.clone(); let session = session.clone(); let reload = on_scope_changed.clone(); window.on_trash_restore(move || { let Some(w) = weak.upgrade() else { return }; let chosen = ctl.selected(); if chosen.is_empty() { return; } start_restore(&w, &ctl, &catalog, &session, &chosen, &reload); }); } // --- trash from the grid ---------------------------------------------- // // Until now the only route to the trash was dragging onto the sidebar row. // These two are the direct gestures: the trash target on a cell's rating // strip, and the `Delete` key. // // Both land here rather than in `library_ui` because everything the // operation needs — the selection, the session closure, `start_trash` and // its drain — already lives in this module. Reaching them from the grid // side would mean either duplicating the worker plumbing or moving it, and // trash is one feature whichever component happens to trigger it. // The trash glyph on one cell. Acts on that photograph alone: the pointer // named it, and a click that silently trashed an entire selection would be // exactly the trap the strip's other targets are laid out to avoid. { let weak = window.as_weak(); let ctl = ctl.clone(); let catalog = catalog.clone(); let session = session.clone(); let reload = on_scope_changed.clone(); let visible = visible_ids.clone(); window.on_library_cell_trashed(move |row| { let Some(w) = weak.upgrade() else { return }; // Resolved through the visible ids rather than the row index alone: // the grid is a window over the catalog, so a stale index from // before a scroll would name a different photograph — and here that // would move the wrong file. let Some(&id) = visible().get(row as usize) else { return; }; start_trash(&w, &ctl, &catalog, &session, &[id], &reload); }); } // `Delete` on the selection — the bulk gesture. { let weak = window.as_weak(); let ctl = ctl.clone(); let catalog = catalog.clone(); let session = session.clone(); let reload = on_scope_changed.clone(); window.on_library_trash_selection(move || { let Some(w) = weak.upgrade() else { return }; let chosen = ctl.selected(); if chosen.is_empty() { // A keystroke that does nothing reads as a broken key, so it // says why rather than failing silently. w.set_library_status("Select an image first".into()); return; } start_trash(&w, &ctl, &catalog, &session, &chosen, &reload); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); let catalog = catalog.clone(); let session = session.clone(); let reload = on_scope_changed.clone(); window.on_trash_empty(move || { let Some(w) = weak.upgrade() else { return }; let conn = match session() { Some(c) => c, None => return, }; let borrow = catalog.borrow(); let Some(cat) = borrow.as_ref() else { return }; // Everything in the trash, with the path and stable id each delete // needs. Read here rather than in the worker: the catalog is not // `Send`, and the worker opens its own connection only to write back. let listed = match dr_catalog::trash::list(cat.connection(), usize::MAX) { Ok(l) => l, Err(e) => { w.set_collection_error(format!("reading trash: {e}").into()); return; } }; if listed.is_empty() { return; } let paths: Vec<(ImageId, Option, String)> = listed .iter() .map(|t| (t.image_id, t.file_id, t.source_ref.clone())) .collect(); let ids: Vec = listed.iter().map(|t| t.image_id).collect(); log::info!("emptying trash: {} image(s)", ids.len()); w.set_library_status(format!("Deleting {} image(s)…", ids.len()).into()); let count = ids.len(); let rx = crate::trash::spawn_purge( conn.clone(), ids, paths, crate::library::catalog_path(&conn.account), crate::library::thumbs_dir(&conn.account), ); drain_trash( w.as_weak(), ctl.clone(), catalog.clone(), rx, reload.clone(), format!("Deleting {count} photograph(s) permanently"), ); }); } // --- tree navigation --------------------------------------------------- { let weak = window.as_weak(); let ctl = ctl.clone(); let reload = on_scope_changed.clone(); window.on_collection_select(move |id| { let Some(w) = weak.upgrade() else { return }; // -1 is the trash. It is not a collection, so it clears `scope` // rather than setting it — see `viewing_trash`. ctl.viewing_trash.set(id == -1); *ctl.scope.borrow_mut() = if id <= 0 { None } else { Some(CollectionId(id as u64)) }; // A selection the user cannot see is one they will act on by // accident, and the new scope shows different images. ctl.clear_selection(); let label = if id == -1 { "Trash".to_string() } else if id == 0 { String::new() } else { let rows = w.get_collection_rows(); (0..rows.row_count()) .filter_map(|i| rows.row_data(i)) .find(|r| r.id == id) .map(|r| r.name.to_string()) .unwrap_or_default() }; w.set_collection_selected(id); w.set_collection_scope_label(label.into()); reload(); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); let catalog = catalog.clone(); window.on_collection_toggle(move |id| { let Some(w) = weak.upgrade() else { return }; let id = CollectionId(id as u64); { let mut collapsed = ctl.collapsed.borrow_mut(); if !collapsed.remove(&id) { collapsed.insert(id); } } let borrow = catalog.borrow(); if let Some(cat) = borrow.as_ref() { refresh_tree(&w, &ctl, cat); } }); } // --- create ------------------------------------------------------------ { let weak = window.as_weak(); let ctl = ctl.clone(); let catalog = catalog.clone(); window.on_collection_new(move || { let Some(w) = weak.upgrade() else { return }; let borrow = catalog.borrow(); let Some(cat) = borrow.as_ref() else { w.set_collection_error("Open a library first.".into()); return; }; // Created inside whatever is selected, which is how a hierarchy // gets built without a separate "new child" command: select the // parent, press +. let parent = *ctl.scope.borrow(); let name = unique_name(cat.connection(), parent); match coll::create(cat.connection(), &name, parent, CollectionKind::Manual) { Ok(id) => { w.set_collection_error(slint::SharedString::new()); // A new child is useless if its parent is collapsed. if let Some(p) = parent { ctl.collapsed.borrow_mut().remove(&p); } // Straight into the name field, with "New collection" // selected. The name is a placeholder nobody wants to // keep, so making the user find the rename gesture // afterwards is asking them to finish a job we started. // // Opened *after* the rebuild, and the order is load-bearing: // `refresh_tree` replaces the row model, which destroys and // recreates every row. A field opened before it would be // torn down along with the `init` that focuses it, leaving // an edit box nothing had typed into. Setting the property // afterwards puts the field on a row that already exists. refresh_tree(&w, &ctl, cat); *ctl.renaming.borrow_mut() = Some(id); w.set_collection_renaming(id.0 as i32); log::info!("created collection {} ({name})", id.0); } Err(e) => w.set_collection_error(format!("creating collection: {e}").into()), } }); } // --- rename ------------------------------------------------------------ // // Inline in the row, opened by a double-click or `F2`. The gesture is worth // the field rather than a dialog: renaming is how a hierarchy gets tidied, // and it is done in runs of several — a modal per collection would make // that a chore. { let weak = window.as_weak(); let ctl = ctl.clone(); window.on_collection_rename_start(move |id| { let Some(w) = weak.upgrade() else { return }; // `F2` arrives with whatever the sidebar has selected, which may be // "All photographs" (0) or the trash (-1). Neither has a name to // change, so the key does nothing rather than opening a field on a // row that is not a collection. if id <= 0 { return; } let id = CollectionId(id as u64); // A saved filter is renameable like any other — its *membership* is // computed, its name is not — so there is no kind check here. *ctl.renaming.borrow_mut() = Some(id); w.set_collection_renaming(id.0 as i32); w.set_collection_error(slint::SharedString::new()); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); let catalog = catalog.clone(); window.on_collection_rename_commit(move |id, name| { let Some(w) = weak.upgrade() else { return }; let id = CollectionId(id as u64); // The field reports a commit when it loses focus as well as on // Enter, so a second one can arrive for a rename already closed — // Enter commits, and the focus the field then gives up commits // again. Ignored rather than reapplied: the second would bump the // revision for no change and beat a real edit on another device. if *ctl.renaming.borrow() != Some(id) { return; } let borrow = catalog.borrow(); let Some(cat) = borrow.as_ref() else { return }; match apply_rename(cat.connection(), id, name.as_str()) { Ok(Rename::Applied(name)) => { close_rename(&w, &ctl); w.set_collection_error(slint::SharedString::new()); refresh_tree(&w, &ctl, cat); // The header names the collection being shown, so a rename // of the current scope has to reach it too. if *ctl.scope.borrow() == Some(id) { w.set_collection_scope_label(name.as_str().into()); } log::info!("renamed collection {} to {name}", id.0); } Ok(Rename::Unchanged) => close_rename(&w, &ctl), Err(e) => { // The field stays open on what the user typed. Closing it // would drop their text and leave the old name showing, // with only a line of red to explain where it went. w.set_collection_error(format!("renaming: {e}").into()); } } }); } { let weak = window.as_weak(); let ctl = ctl.clone(); window.on_collection_rename_cancel(move || { let Some(w) = weak.upgrade() else { return }; close_rename(&w, &ctl); w.set_collection_error(slint::SharedString::new()); }); } // --- remove from the collection being shown --------------------------- { let weak = window.as_weak(); let ctl = ctl.clone(); let catalog = catalog.clone(); let visible = visible_ids.clone(); let reload = on_scope_changed.clone(); window.on_library_remove_from_collection(move || { let Some(w) = weak.upgrade() else { return }; let Some(scope) = *ctl.scope.borrow() else { // Unscoped, there is no collection to remove from. The button // is hidden in that state; this guards the callback anyway. return; }; let chosen = ctl.selected(); if chosen.is_empty() { return; } let borrow = catalog.borrow(); let Some(cat) = borrow.as_ref() else { return }; match coll::remove_images(cat.connection(), scope, &chosen) { Ok(n) => { w.set_collection_error(slint::SharedString::new()); // Named explicitly as a membership change: the images are // still in the library, and a user who reads this as a // delete will not trust the feature again. w.set_library_status( format!("Removed {n} from this collection; still in the library").into(), ); ctl.clear_selection(); refresh_tree(&w, &ctl, cat); sync_badges(&w, cat, &visible()); reload(); } Err(e) => w.set_collection_error(format!("removing: {e}").into()), } }); } // Right-click. A real context menu needs a popup with keyboard handling and // a rename field; until that exists the gesture deletes an *empty* // collection, which is the one destructive action safe without a // confirmation dialog, and says why when it declines. { let weak = window.as_weak(); let ctl = ctl.clone(); let catalog = catalog.clone(); let reload = on_scope_changed.clone(); window.on_collection_menu(move |id| { let Some(w) = weak.upgrade() else { return }; let borrow = catalog.borrow(); let Some(cat) = borrow.as_ref() else { return }; let id = CollectionId(id as u64); let holds = coll::deep_count(cat.connection(), id).unwrap_or(1); if holds > 0 { w.set_collection_error( format!("{holds} photograph(s) in there — empty it first.").into(), ); return; } match coll::delete(cat.connection(), id) { Ok(()) => { w.set_collection_error(slint::SharedString::new()); if *ctl.scope.borrow() == Some(id) { *ctl.scope.borrow_mut() = None; w.set_collection_selected(0); w.set_collection_scope_label(slint::SharedString::new()); reload(); } refresh_tree(&w, &ctl, cat); } Err(e) => w.set_collection_error(format!("deleting: {e}").into()), } }); } } /// Close the rename field, whatever the outcome. /// /// Both halves together, always: the controller's copy is what a stray second /// commit is tested against, and the window property is what draws the field. /// Clearing one without the other either leaves a field open that nothing will /// close, or closes one that Rust still believes is open. fn close_rename(window: &AppWindow, ctl: &Rc) { *ctl.renaming.borrow_mut() = None; window.set_collection_renaming(0); } /// What a committed rename did. #[derive(Debug, PartialEq, Eq)] enum Rename { /// Written, with the name as stored — trimmed. Applied(String), /// The name was the one it already had, so nothing was written. /// /// Distinguished from `Applied` because every write bumps the revision, and /// a rename to the same name would let a device that changed nothing win a /// merge against one that did real work. Unchanged, } /// Validate a typed name and store it. /// /// Split out so the rules are testable without a window — they are the part a /// user runs into: /// /// - **blank is refused.** A nameless row is unclickable and unfindable, and /// the schema is happy to store one. /// - **a sibling's name is refused.** Two identically-named collections in one /// parent are indistinguishable in the sidebar, which is how images end up in /// the wrong one. The same reasoning as [`unique_name`], enforced here rather /// than silently suffixing: the user typed a specific name and quietly /// storing a different one is worse than saying no. /// /// Case-insensitive against siblings, because the sidebar sorts that way and /// "Iceland" beside "iceland" is the same trap as an exact duplicate. fn apply_rename( conn: &rusqlite::Connection, id: CollectionId, typed: &str, ) -> Result { let name = typed.trim(); if name.is_empty() { return Err(dr_catalog::CatalogError::BadName( "a collection needs a name".into(), )); } // The current name, which also proves the collection is still there. let current: String = conn.query_row( "SELECT name FROM collections WHERE id = ?1 AND deleted = 0", [id.0 as i64], |r| r.get(0), )?; if current == name { return Ok(Rename::Unchanged); } // Siblings, excluding this collection: a rename that only changes case is a // real rename, and must not be refused as a clash with itself. let clash: Option = conn .query_row( "SELECT 1 FROM collections WHERE deleted = 0 AND id != ?1 AND name = ?2 COLLATE NOCASE AND parent_id IS (SELECT parent_id FROM collections WHERE id = ?1)", rusqlite::params![id.0 as i64, name], |r| r.get(0), ) .optional()?; if clash.is_some() { return Err(dr_catalog::CatalogError::BadName(format!( "there is already a “{name}” here" ))); } coll::rename(conn, id, name)?; Ok(Rename::Applied(name.to_string())) } /// A name no sibling is already using. /// /// Duplicate names are legal in the schema, and two identically-named /// collections in one parent are indistinguishable in the sidebar — which is /// how images end up in the wrong one. fn unique_name(conn: &rusqlite::Connection, parent: Option) -> String { let taken: Vec = { let sql = match parent { Some(_) => "SELECT name FROM collections WHERE deleted = 0 AND parent_id = ?1", None => "SELECT name FROM collections WHERE deleted = 0 AND parent_id IS NULL", }; let Ok(mut stmt) = conn.prepare(sql) else { return "New collection".into(); }; // Collected inside each arm: the two `query_map` calls bind different // parameter types, so their iterators are different types and cannot // be the arms of one `match`. match parent { Some(p) => stmt .query_map([p.0 as i64], |r| r.get::<_, String>(0)) .map(|rows| rows.flatten().collect()) .unwrap_or_default(), None => stmt .query_map([], |r| r.get::<_, String>(0)) .map(|rows| rows.flatten().collect()) .unwrap_or_default(), } }; let base = "New collection"; if !taken.iter().any(|t| t == base) { return base.into(); } for n in 2..1000 { let candidate = format!("{base} {n}"); if !taken.contains(&candidate) { return candidate; } } base.into() } #[cfg(test)] mod tests { use super::*; fn ids(n: u64) -> Vec { (1..=n).map(ImageId).collect() } /// The catalog's answer to "what is between these two ordinals". /// /// Stands in for [`crate::library_ui::LibraryController::ids_in_span`], /// which does the same thing with a `LIMIT`/`OFFSET`: the whole library is /// available to the query whatever the grid happens to have loaded, and an /// ordinal indexes it directly. fn library(all: &[ImageId]) -> impl Fn(usize, usize) -> Vec + '_ { move |first, last| { all.iter() .copied() .skip(first) .take((last + 1).saturating_sub(first)) .collect() } } /// No catalog to ask — what a press sees before a library is open, and if /// the query fails. fn unloaded(_first: usize, _last: usize) -> Vec { Vec::new() } #[test] fn 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 a_plain_press_replaces_the_selection() { let all = ids(5); let span = library(&all); let mut sel = BTreeSet::new(); let mut anchor = None; apply_press(&mut sel, &mut anchor, &all, 0, 0, false, false, &span); apply_press(&mut sel, &mut anchor, &all, 0, 2, false, false, &span); assert_eq!(sel.iter().copied().collect::>(), vec![ImageId(3)]); } #[test] fn ctrl_press_adds_and_then_removes() { let all = ids(5); let span = library(&all); let mut sel = BTreeSet::new(); let mut anchor = None; apply_press(&mut sel, &mut anchor, &all, 0, 0, false, false, &span); apply_press(&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. apply_press(&mut sel, &mut anchor, &all, 0, 3, true, false, &span); assert_eq!(sel.iter().copied().collect::>(), vec![ImageId(1)]); } #[test] fn shift_press_extends_a_contiguous_range() { let all = ids(10); let span = library(&all); let mut sel = BTreeSet::new(); let mut anchor = None; apply_press(&mut sel, &mut anchor, &all, 0, 2, false, false, &span); apply_press(&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; apply_press(&mut sel, &mut anchor, &all, 0, 6, false, false, &span); apply_press(&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; apply_press(&mut sel, &mut anchor, &all, 0, 5, false, false, &span); apply_press(&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. apply_press(&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; apply_press(&mut sel, &mut anchor, &all, 0, 10, false, false, &span); apply_press(&mut sel, &mut anchor, &all, 0, 14, false, true, &span); apply_press(&mut sel, &mut anchor, &all, 0, 12, false, true, &span); assert_eq!(anchor, Some(10)); assert_eq!(sel.len(), 3, "rows 10..=12"); } /// One tap in selection mode: a press reported as ctrl-held, which is what /// `library.slint` sends while the mode is on. fn tap( sel: &mut BTreeSet, anchor: &mut Option, previous: &mut Option, all: &[ImageId], row: usize, ) { press_remembering_anchor( sel, anchor, previous, all, 0, row, true, false, &library(all), ); } #[test] fn a_double_tap_takes_the_run_between_it_and_where_selecting_began() { // The whole touch gesture, in the order a finger performs it: hold one // photograph to start selecting, then double-tap the far end of the run. // Both taps of that double tap land on the same cell — the first turns // it on, the second turns it off — and the double tap that follows has // to select the range anyway. let all = ids(20); let span = library(&all); let mut sel = BTreeSet::new(); let mut anchor = None; let mut previous = None; // The long press: an ordinary plain press, which is what the cell got // before the hold timer fired. press_remembering_anchor( &mut sel, &mut anchor, &mut previous, &all, 0, 4, false, false, &span, ); tap(&mut sel, &mut anchor, &mut previous, &all, 11); tap(&mut sel, &mut anchor, &mut previous, &all, 11); assert!( !sel.contains(&ImageId(12)), "the two taps cancelled out, which is what makes the double tap's \ job to select the range rather than to add one cell" ); extend_to_row(&mut sel, &mut anchor, previous, &all, 0, 11, &span); assert_eq!(sel.len(), 8, "rows 4..=11"); assert!(sel.contains(&ImageId(5)) && sel.contains(&ImageId(12))); } #[test] fn a_double_tap_keeps_a_run_gathered_earlier() { // Two runs, which is why the extension unions rather than replaces: a // user in selection mode is gathering, and the second gesture must not // throw away the first. let all = ids(30); let span = library(&all); let mut sel = BTreeSet::new(); let mut anchor = None; let mut previous = None; press_remembering_anchor( &mut sel, &mut anchor, &mut previous, &all, 0, 0, false, false, &span, ); tap(&mut sel, &mut anchor, &mut previous, &all, 3); tap(&mut sel, &mut anchor, &mut previous, &all, 3); extend_to_row(&mut sel, &mut anchor, previous, &all, 0, 3, &span); assert_eq!(sel.len(), 4, "rows 0..=3"); // A second run, begun with a plain tap somewhere else. tap(&mut sel, &mut anchor, &mut previous, &all, 20); tap(&mut sel, &mut anchor, &mut previous, &all, 25); tap(&mut sel, &mut anchor, &mut previous, &all, 25); extend_to_row(&mut sel, &mut anchor, previous, &all, 0, 25, &span); assert_eq!(sel.len(), 10, "rows 0..=3 and 20..=25"); assert!(sel.contains(&ImageId(1)) && sel.contains(&ImageId(26))); } #[test] fn a_double_tap_with_nothing_to_extend_from_selects_only_that_cell() { // The degenerate case: selection mode entered from the header's button // rather than by holding a cell, so nothing has anchored yet. let all = ids(10); let span = library(&all); let mut sel = BTreeSet::new(); let mut anchor = None; let previous = None; extend_to_row(&mut sel, &mut anchor, previous, &all, 0, 6, &span); assert_eq!(sel.iter().copied().collect::>(), vec![ImageId(7)]); } #[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; apply_press(&mut sel, &mut anchor, &all, 0, 0, false, false, &span); apply_press(&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. apply_press(&mut sel, &mut anchor, &all, 0, 10, true, false, &span); apply_press(&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; apply_press(&mut sel, &mut anchor, &all, 0, 0, false, false, &span); apply_press(&mut sel, &mut anchor, &all, 0, 1, true, false, &span); apply_press(&mut sel, &mut anchor, &all, 0, 2, true, false, &span); assert_eq!(sel.len(), 3); // Pressing one of the three to begin a drag. apply_press(&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. apply_press(&mut sel, &mut anchor, &all, 0, 1, false, false, &span); apply_press(&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(1), Some(3)); apply_press(&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 (previous_anchor, cursor) = undo.restore(&mut sel, &mut anchor); assert_eq!((sel, anchor), before, "selection and anchor are back"); assert_eq!(previous_anchor, Some(1), "and so is the shift-click origin"); 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; apply_press(&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. apply_press(&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. apply_press(&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); apply_press(&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); apply_press(&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); apply_press( &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; apply_press(&mut sel, &mut anchor, &all, 0, 3, false, true, &span); assert_eq!(sel.iter().copied().collect::>(), vec![ImageId(4)]); } /// A solid test thumbnail. fn thumb(w: u32, h: u32) -> slint::Image { let mut buf = slint::SharedPixelBuffer::::new(w, h); for p in buf.make_mut_slice() { *p = slint::Rgba8Pixel { r: 200, g: 120, b: 60, a: 255, }; } slint::Image::from_rgba8(buf) } #[test] fn one_dragged_image_composites_to_a_single_frame() { let img = compose_drag_image(&[thumb(64, 64)]); let size = img.size(); // No fan for one image: the bitmap is just the thumbnail's own box. assert_eq!(size.width, size.height, "a square thumbnail stays square"); assert!(size.width > 0); } #[test] fn a_stack_is_wider_than_a_single_image() { // The fan is what makes the count legible from the shape rather than // needing a number drawn on it. let one = compose_drag_image(&[thumb(64, 64)]); let many = compose_drag_image(&[thumb(64, 64), thumb(64, 64), thumb(64, 64)]); assert!( many.size().width > one.size().width, "three images fan wider than one" ); assert!(many.size().height > one.size().height); } #[test] fn the_stack_stops_growing_past_the_layer_cap() { // A forty-image drag must not composite forty thumbnails for a pile // whose lower layers are hidden anyway. let five: Vec = (0..5).map(|_| thumb(64, 64)).collect(); let forty: Vec = (0..40).map(|_| thumb(64, 64)).collect(); assert_eq!( compose_drag_image(&five).size().width, compose_drag_image(&forty).size().width, "past the cap the stack looks no thicker" ); } #[test] fn an_empty_drag_composites_to_nothing() { // Every cell in the selection may still be waiting for its preview. assert_eq!(compose_drag_image(&[]).size().width, 0); } #[test] fn a_portrait_thumbnail_keeps_its_proportions() { // Fitted, not stretched: a distorted frame stops the stack reading as // photographs. let img = compose_drag_image(&[thumb(60, 120)]); let size = img.size(); assert!( size.height > size.width, "a tall thumbnail composites tall, {}x{}", size.width, size.height ); } #[test] fn mixed_orientations_do_not_panic_or_wrap() { // The layers below the top are fitted into its box and clipped. Getting // that wrong draws as a smear across the next row, or panics. let img = compose_drag_image(&[thumb(120, 60), thumb(60, 120), thumb(90, 90)]); assert!(img.size().width > 0 && img.size().height > 0); } #[test] fn a_zero_sized_thumbnail_is_not_composited() { // A decode that produced nothing must not become a zero-divide. assert_eq!(compose_drag_image(&[thumb(0, 0)]).size().width, 0); } /// The spring's inputs: rows, which have children, and which are collapsed. fn spring_fixture() -> ( Vec, Vec, std::collections::HashSet, ) { let ids = vec![CollectionId(1), CollectionId(2), CollectionId(3)]; // 1 is a collapsed parent, 2 an expanded parent, 3 a leaf. let has_children = vec![true, true, false]; let collapsed = [CollectionId(1)].into_iter().collect(); (ids, has_children, collapsed) } #[test] fn hovering_a_collapsed_parent_springs_it_open() { // The point of the gesture: reaching a child of something closed. let (ids, kids, collapsed) = spring_fixture(); assert_eq!( should_spring(Some(0), &ids, &kids, &collapsed), Some(CollectionId(1)) ); } #[test] fn hovering_an_already_open_parent_springs_nothing() { // Its children are already reachable; rebuilding the tree would move // rows under the pointer for no gain. let (ids, kids, collapsed) = spring_fixture(); assert_eq!(should_spring(Some(1), &ids, &kids, &collapsed), None); } #[test] fn hovering_a_leaf_springs_nothing() { // A collection with no children has nothing to open, and flashing a // rebuild would just shift the row the user is aiming at. let (ids, kids, collapsed) = spring_fixture(); assert_eq!(should_spring(Some(2), &ids, &kids, &collapsed), None); } #[test] fn hovering_nothing_springs_nothing() { let (ids, kids, collapsed) = spring_fixture(); assert_eq!(should_spring(None, &ids, &kids, &collapsed), None); } #[test] fn a_stale_row_index_springs_nothing() { // The hover can outlive the row model it referred to. let (ids, kids, collapsed) = spring_fixture(); assert_eq!(should_spring(Some(99), &ids, &kids, &collapsed), None); } #[test] fn the_spring_dwell_is_long_enough_not_to_trigger_in_passing() { // A tree that flaps open under every passing pointer is worse than one // that never opens. This pins the intent rather than the number: a // reflex-speed value here would be a regression, not a tuning choice. // Asserted in a const item rather than at runtime: the condition is // constant either way, and clippy is right that a runtime assert on it // is theatre. This form fails the build instead of a test run, which is // strictly earlier, and keeps the bound where a reader of the constant // will look for it. const _: () = assert!( SPRING_DELAY_MS >= 300, "a pointer crossing a parent must not open it" ); const _: () = assert!( SPRING_DELAY_MS <= 900, "and a deliberate dwell must not feel like a hang" ); } #[test] fn new_collections_do_not_share_a_name_with_a_sibling() { // Two identically-named collections in one parent are // indistinguishable in the sidebar, which is how images land in the // wrong one. let cat = Catalog::in_memory().unwrap(); let c = cat.connection(); let first = unique_name(c, None); coll::create(c, &first, None, CollectionKind::Manual).unwrap(); let second = unique_name(c, None); coll::create(c, &second, None, CollectionKind::Manual).unwrap(); assert_ne!(first, second); assert_eq!(first, "New collection"); assert_eq!(second, "New collection 2"); } /// TRACES: FR-NC-6a | FR-NC-6c /// A catalog with `n` images, for the offline roll-up tests. fn catalog_with_images(n: usize) -> (Catalog, Vec) { let cat = Catalog::in_memory().unwrap(); cat.connection() .execute( "INSERT INTO roots (id, kind, label) VALUES (1, 'remote', 'test')", [], ) .unwrap(); let mut ids = Vec::new(); for i in 0..n { cat.connection() .execute( "INSERT INTO images (root_id, source_ref, added_at) VALUES (1, ?1, 0)", rusqlite::params![format!("Photos/img{i:03}.CR2")], ) .unwrap(); ids.push(ImageId(cat.connection().last_insert_rowid() as u64)); } (cat, ids) } /// Record an image as held on this device, as a finished download would. fn mark_held(cat: &Catalog, image: ImageId) { cat.connection() .execute( "INSERT INTO image_cache (image_id, tier_actual, tier_desired, bytes, pinned) VALUES (?1, ?2, ?2, 100, 1)", rusqlite::params![image.0 as i64, dr_types::Tier::Original.stored()], ) .unwrap(); } #[test] fn a_parent_reads_as_kept_only_when_its_children_are_too() { // The tray on a parent answers for the subtree, because that is what // keeping a parent downloads. A parent that showed a full tray over a // child with nothing downloaded would be a promise the aeroplane // breaks. let (cat, images) = catalog_with_images(4); let c = cat.connection(); let parent = coll::create(c, "Trip", None, CollectionKind::Manual).unwrap(); let child = coll::create(c, "Day one", Some(parent), CollectionKind::Manual).unwrap(); coll::add_images(c, parent, &images[0..2]).unwrap(); coll::add_images(c, child, &images[2..4]).unwrap(); // Everything the parent holds directly, and nothing of the child's. mark_held(&cat, images[0]); mark_held(&cat, images[1]); let rows = coll::tree(c).unwrap(); let state = offline_state(&cat, &rows); assert_eq!( state[&parent], OfflineCount { total: 4, pinned: 2 } ); assert_eq!( state[&child], OfflineCount { total: 2, pinned: 0 } ); mark_held(&cat, images[2]); mark_held(&cat, images[3]); let state = offline_state(&cat, &coll::tree(c).unwrap()); assert_eq!( state[&parent], OfflineCount { total: 4, pinned: 4 } ); } #[test] fn a_grandchild_rolls_all_the_way_up() { // The reverse pass has to survive depth: a photograph three levels down // is still part of what the top-level collection would download. let (cat, images) = catalog_with_images(1); let c = cat.connection(); let top = coll::create(c, "2024", None, CollectionKind::Manual).unwrap(); let mid = coll::create(c, "Corsica", Some(top), CollectionKind::Manual).unwrap(); let leaf = coll::create(c, "Bonifacio", Some(mid), CollectionKind::Manual).unwrap(); coll::add_images(c, leaf, &images).unwrap(); let state = offline_state(&cat, &coll::tree(c).unwrap()); assert_eq!( state[&top], OfflineCount { total: 1, pinned: 0 } ); assert_eq!( state[&mid], OfflineCount { total: 1, pinned: 0 } ); } #[test] fn an_empty_collection_has_nothing_to_keep() { // Guards the reading that would put a full tray beside an empty // collection: every one of its zero photographs is here, which is true // and useless. `refresh_tree` requires a non-zero total before drawing // one, and this is the fact it relies on. let (cat, _) = catalog_with_images(0); let c = cat.connection(); let empty = coll::create(c, "Nothing yet", None, CollectionKind::Manual).unwrap(); let state = offline_state(&cat, &coll::tree(c).unwrap()); assert_eq!(state.get(&empty).copied().unwrap_or_default().total, 0); } /// A catalog holding one top-level collection, and its id. fn with_one(name: &str) -> (Catalog, CollectionId) { let cat = Catalog::in_memory().unwrap(); let id = coll::create(cat.connection(), name, None, CollectionKind::Manual).unwrap(); (cat, id) } fn name_of(cat: &Catalog, id: CollectionId) -> String { cat.connection() .query_row( "SELECT name FROM collections WHERE id = ?1", [id.0 as i64], |r| r.get(0), ) .unwrap() } #[test] fn a_rename_stores_the_new_name() { let (cat, id) = with_one("Untitled"); let out = apply_rename(cat.connection(), id, "Iceland").unwrap(); assert_eq!(out, Rename::Applied("Iceland".into())); assert_eq!(name_of(&cat, id), "Iceland"); } #[test] fn surrounding_whitespace_is_trimmed_rather_than_stored() { // A trailing space is invisible in the sidebar and makes two // collections look identical while sorting them apart. let (cat, id) = with_one("Untitled"); assert_eq!( apply_rename(cat.connection(), id, " Iceland ").unwrap(), Rename::Applied("Iceland".into()) ); assert_eq!(name_of(&cat, id), "Iceland"); } #[test] fn a_blank_name_is_refused() { // A nameless row cannot be read or aimed at, and the schema would take // one happily. let (cat, id) = with_one("Iceland"); assert!(matches!( apply_rename(cat.connection(), id, " "), Err(dr_catalog::CatalogError::BadName(_)) )); assert_eq!(name_of(&cat, id), "Iceland", "the old name stands"); } #[test] fn renaming_to_the_current_name_writes_nothing() { // Every write bumps the revision, and a no-op rename would let an idle // device beat one that did real work at the next merge. let (cat, id) = with_one("Iceland"); let rev = |c: &Catalog| -> i64 { c.connection() .query_row( "SELECT revision FROM collections WHERE id = ?1", [id.0 as i64], |r| r.get(0), ) .unwrap() }; let before = rev(&cat); assert_eq!( apply_rename(cat.connection(), id, "Iceland").unwrap(), Rename::Unchanged ); assert_eq!(rev(&cat), before); } #[test] fn a_siblings_name_is_refused() { // Two identically-named collections in one parent are // indistinguishable in the sidebar, which is how images land in the // wrong one. let cat = Catalog::in_memory().unwrap(); let c = cat.connection(); coll::create(c, "Iceland", None, CollectionKind::Manual).unwrap(); let japan = coll::create(c, "Japan", None, CollectionKind::Manual).unwrap(); assert!(matches!( apply_rename(c, japan, "Iceland"), Err(dr_catalog::CatalogError::BadName(_)) )); assert_eq!(name_of(&cat, japan), "Japan"); } #[test] fn a_siblings_name_is_refused_in_a_different_case_too() { // The sidebar sorts case-insensitively, so "iceland" beside "Iceland" // is the same trap as an exact duplicate. let cat = Catalog::in_memory().unwrap(); let c = cat.connection(); coll::create(c, "Iceland", None, CollectionKind::Manual).unwrap(); let japan = coll::create(c, "Japan", None, CollectionKind::Manual).unwrap(); assert!(matches!( apply_rename(c, japan, "iceland"), Err(dr_catalog::CatalogError::BadName(_)) )); } #[test] fn changing_only_the_case_of_a_name_is_allowed() { // The clash check excludes the collection itself, or fixing the // capitalisation of a name would be refused as a clash with itself. let (cat, id) = with_one("iceland"); assert_eq!( apply_rename(cat.connection(), id, "Iceland").unwrap(), Rename::Applied("Iceland".into()) ); assert_eq!(name_of(&cat, id), "Iceland"); } #[test] fn a_name_used_under_a_different_parent_is_free() { // Uniqueness is per parent: "Selects" inside two different trips is // unambiguous and normal. let cat = Catalog::in_memory().unwrap(); let c = cat.connection(); let trips = coll::create(c, "Trips", None, CollectionKind::Manual).unwrap(); coll::create(c, "Selects", Some(trips), CollectionKind::Manual).unwrap(); let top = coll::create(c, "Loose", None, CollectionKind::Manual).unwrap(); assert_eq!( apply_rename(c, top, "Selects").unwrap(), Rename::Applied("Selects".into()) ); } #[test] fn two_top_level_collections_still_clash_despite_a_null_parent() { // `parent_id IS NULL` never matches with `=`, so a naive clash query // would silently allow every duplicate at the top level — which is // where most collections live. let cat = Catalog::in_memory().unwrap(); let c = cat.connection(); coll::create(c, "Iceland", None, CollectionKind::Manual).unwrap(); let other = coll::create(c, "Japan", None, CollectionKind::Manual).unwrap(); assert!( apply_rename(c, other, "Iceland").is_err(), "two top-level collections must not share a name" ); } #[test] fn renaming_a_deleted_collection_fails_rather_than_resurrecting_it() { let (cat, id) = with_one("Gone"); coll::delete(cat.connection(), id).unwrap(); assert!(apply_rename(cat.connection(), id, "Back").is_err()); } #[test] fn a_saved_filter_can_be_renamed() { // Its *membership* is computed; its name is not, and a smart // collection the user cannot label is worse than no smart collection. let cat = Catalog::in_memory().unwrap(); let c = cat.connection(); let s = coll::create(c, "Untitled", None, CollectionKind::Smart).unwrap(); assert_eq!( apply_rename(c, s, "Five star").unwrap(), Rename::Applied("Five star".into()) ); } #[test] fn the_same_name_is_free_again_under_a_different_parent() { // Uniqueness is per parent, not global: "Selects" inside two different // trips is unambiguous and normal. let cat = Catalog::in_memory().unwrap(); let c = cat.connection(); let top = coll::create(c, "New collection", None, CollectionKind::Manual).unwrap(); assert_eq!(unique_name(c, Some(top)), "New collection"); } }