//! 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}; /// 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. anchor: RefCell>, /// 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, /// 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 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>, } impl CollectionsController { pub fn new() -> Rc { Rc::new(Self::default()) } /// 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() } /// 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; } } /// 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. pub fn apply_press( selection: &mut BTreeSet, anchor: &mut Option, ids: &[ImageId], row: usize, ctrl: bool, shift: bool, ) { let Some(&id) = ids.get(row) else { return }; 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(row); 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 <= row { (from, row) } else { (row, from) }; let hi = hi.min(ids.len().saturating_sub(1)); for id in &ids[lo..=hi] { selection.insert(*id); } return; } if ctrl { if !selection.remove(&id) { selection.insert(id); } *anchor = Some(row); 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(row); return; } selection.clear(); selection.insert(id); *anchor = Some(row); } /// 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(); // 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); 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, }); } *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))); } /// 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(); for (row, id) in ids.iter().enumerate() { let want = selection.contains(id); if let Some(mut cell) = model.row_data(row) { if cell.selected != want { cell.selected = want; 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; /// 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< dyn Fn() -> Option<( dr_sync_nextcloud::AppCredentials, dr_sync_nextcloud::Session, )>, >, images: &[ImageId], reload: &Rc, ) { let Some((creds, sess)) = 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, &sess.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 rx = crate::trash::spawn_move( creds, sess.user_id.clone(), moves, crate::trash::Direction::ToTrash, crate::library::catalog_path(&sess.server, &sess.user_id), ); drain_trash( window.as_weak(), ctl.clone(), catalog.clone(), rx, reload.clone(), ); } /// 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< dyn Fn() -> Option<( dr_sync_nextcloud::AppCredentials, dr_sync_nextcloud::Session, )>, >, images: &[ImageId], reload: &Rc, ) { let Some((creds, sess)) = 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 rx = crate::trash::spawn_move( creds, sess.user_id.clone(), moves, crate::trash::Direction::Restore, crate::library::catalog_path(&sess.server, &sess.user_id), ); drain_trash( window.as_weak(), ctl.clone(), catalog.clone(), rx, reload.clone(), ); } /// 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, ) { use crate::trash::TrashMessage; let timer = slint::Timer::default(); let ctl_cb = ctl.clone(); 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. 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}") }; 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()) }; 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. pub fn wire( window: &AppWindow, ctl: Rc, catalog: Rc>>, on_scope_changed: S, visible_ids: R, session: C, ) where S: Fn() + 'static, R: Fn() -> Vec + 'static, C: Fn() -> Option<( dr_sync_nextcloud::AppCredentials, dr_sync_nextcloud::Session, )> + '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); let session: Rc< dyn Fn() -> Option<( dr_sync_nextcloud::AppCredentials, dr_sync_nextcloud::Session, )>, > = 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); apply_press( &mut ctl.selection.borrow_mut(), &mut ctl.anchor.borrow_mut(), &ids, row as usize, ctrl_held, shift_held, ); sync_selection(&w, &ctl, &ids); }); } // --- 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 }); } { 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(); if carried.is_empty() { return; } // 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 (creds, sess) = match session() { Some(s) => s, 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 rx = crate::trash::spawn_purge( creds, sess.user_id.clone(), ids, paths, crate::library::catalog_path(&sess.server, &sess.user_id), crate::library::thumbs_dir(&sess.server, &sess.user_id), ); drain_trash( w.as_weak(), ctl.clone(), catalog.clone(), rx, reload.clone(), ); }); } // --- 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() } #[test] fn a_plain_press_replaces_the_selection() { let all = ids(5); let mut sel = BTreeSet::new(); let mut anchor = None; apply_press(&mut sel, &mut anchor, &all, 0, false, false); apply_press(&mut sel, &mut anchor, &all, 2, false, false); assert_eq!(sel.iter().copied().collect::>(), vec![ImageId(3)]); } #[test] fn ctrl_press_adds_and_then_removes() { let all = ids(5); let mut sel = BTreeSet::new(); let mut anchor = None; apply_press(&mut sel, &mut anchor, &all, 0, false, false); apply_press(&mut sel, &mut anchor, &all, 3, true, false); 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, 3, true, false); assert_eq!(sel.iter().copied().collect::>(), vec![ImageId(1)]); } #[test] fn shift_press_extends_a_contiguous_range() { let all = ids(10); let mut sel = BTreeSet::new(); let mut anchor = None; apply_press(&mut sel, &mut anchor, &all, 2, false, false); apply_press(&mut sel, &mut anchor, &all, 6, false, true); 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 mut sel = BTreeSet::new(); let mut anchor = None; apply_press(&mut sel, &mut anchor, &all, 6, false, false); apply_press(&mut sel, &mut anchor, &all, 2, false, true); 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 mut sel = BTreeSet::new(); let mut anchor = None; apply_press(&mut sel, &mut anchor, &all, 5, false, false); apply_press(&mut sel, &mut anchor, &all, 15, false, true); assert_eq!(sel.len(), 11, "rows 5..=15"); // Corrected to a shorter range from the same anchor. apply_press(&mut sel, &mut anchor, &all, 8, false, true); 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 mut sel = BTreeSet::new(); let mut anchor = None; apply_press(&mut sel, &mut anchor, &all, 10, false, false); apply_press(&mut sel, &mut anchor, &all, 14, false, true); apply_press(&mut sel, &mut anchor, &all, 12, false, true); assert_eq!(anchor, Some(10)); assert_eq!(sel.len(), 3, "rows 10..=12"); } #[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 mut sel = BTreeSet::new(); let mut anchor = None; apply_press(&mut sel, &mut anchor, &all, 0, false, false); apply_press(&mut sel, &mut anchor, &all, 2, false, true); 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, 10, true, false); apply_press(&mut sel, &mut anchor, &all, 12, true, true); 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 mut sel = BTreeSet::new(); let mut anchor = None; apply_press(&mut sel, &mut anchor, &all, 0, false, false); apply_press(&mut sel, &mut anchor, &all, 1, true, false); apply_press(&mut sel, &mut anchor, &all, 2, true, false); assert_eq!(sel.len(), 3); // Pressing one of the three to begin a drag. apply_press(&mut sel, &mut anchor, &all, 1, false, false); assert_eq!(sel.len(), 3, "the selection survived the press"); } #[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 mut sel = BTreeSet::new(); let mut anchor = None; apply_press(&mut sel, &mut anchor, &all, 99, false, false); assert!(sel.is_empty()); } #[test] fn shift_without_an_anchor_selects_just_the_one() { let all = ids(5); let mut sel = BTreeSet::new(); let mut anchor = None; apply_press(&mut sel, &mut anchor, &all, 3, false, true); 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"); } /// 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"); } }