Files
DarkRoom/ui/dr-ui/src/collections_ui.rs
T
dtourolle 8ad5c86ff9 Add the library, collections, and trash views; theme from style.yaml
The UI gains the views the catalog work was building toward: a windowed
library grid with ratings and flags, the collection tree with drag-to-add,
and trash with restore. derived_sync pushes thumbnail shards and the catalog
snapshot to the server's derived folder.

Tokens now have one source of truth. build.rs reads style.yaml and generates
theme.slint into OUT_DIR, which answers every existing
`import { Theme } from "theme.slint"` unchanged, because Slint resolves
imports against the importing file's directory first and the include paths
after. Generating into OUT_DIR rather than beside the hand-written Slint is
the point: a generated file sitting in ui/ looks exactly like the files
around it that are meant to be edited, and an edit to it would survive until
the next touch of style.yaml — a bug that hides for weeks. build.rs fails
loudly if a stale ui/theme.slint exists, which would otherwise shadow the
generated one silently and make every palette change vanish with no error.

The palette moves to near-neutral dark with achromatic signalling, so the
accent means "modified" or "active" rather than "heading". Shared components
land in widgets.slint: a token that binds several values into one concept is
a component, not a row in a YAML file.

Adds an optional live-style feature that makes the tokens in-out so they can
be written at startup — a feature rather than the default because it stops
the properties being constant-folded.

serde_norway is the YAML crate: serde_yaml and serde_yml are both deprecated,
and its mappings preserve insertion order, which is what lets the generated
Slint keep the token ordering the author chose.

Assisted-by: LLM
2026-08-09 21:11:38 +02:00

2216 lines
85 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<BTreeSet<ImageId>>,
/// Where a shift-click extends from. The last cell *clicked*, not the last
/// added — extending from the far end of a previous range is not what the
/// gesture means anywhere else.
anchor: RefCell<Option<usize>>,
/// Whether the press that began the current gesture carried ctrl or shift.
///
/// A modified click is a *selection* gesture and must not also open the
/// image: building a selection would otherwise throw the user into the
/// develop view on the second ctrl-click. Slint does not report modifiers on
/// `clicked`, so the press records them and the click consults this.
modified_press: std::cell::Cell<bool>,
/// Collection ids parallel to the sidebar's rows, so a hovered row index
/// resolves to an id without another query.
row_ids: RefCell<Vec<CollectionId>>,
/// Which rows are saved filters, so a drop onto one is refused *before* the
/// release rather than after.
row_smart: RefCell<Vec<bool>>,
/// Which rows have children, so the spring knows there is anything to open.
row_has_children: RefCell<Vec<bool>>,
/// Collapsed collections, by id. Collapse is a view preference and
/// deliberately not persisted to the catalog — it is not something to sync
/// between devices.
collapsed: RefCell<std::collections::HashSet<CollectionId>>,
/// Which collection scopes the grid. `None` is the whole library.
scope: RefCell<Option<CollectionId>>,
/// The collection whose name is being edited in the sidebar, if any.
///
/// Held here rather than in Slint because a rename can also be *started*
/// from Rust — creating a collection opens its field — and because a
/// commit that the catalog refuses has to leave the field open on the name
/// the user typed rather than silently closing over a rejected edit.
renaming: RefCell<Option<CollectionId>>,
/// Whether the grid is showing the trash rather than the library.
///
/// Separate from `scope` because the trash is not a collection: its contents
/// come from `trashed_at`, not from membership, and every other query in the
/// library *excludes* exactly what this view exists to show. Folding it into
/// `scope` as a sentinel id would put that inversion inside a type that
/// means "a collection".
viewing_trash: std::cell::Cell<bool>,
/// The live drag: what it carries. Empty means no drag.
dragging: RefCell<Vec<ImageId>>,
/// 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<Option<CollectionId>>,
/// Spring-loaded expansion: the timer that opens a collapsed parent the
/// pointer has been dwelling on mid-drag.
///
/// One timer, restarted per row, so moving on cancels the pending
/// expansion rather than leaving a queue of them to fire later.
spring_timer: RefCell<Option<slint::Timer>>,
/// Collections the spring opened during *this* drag, so they can be closed
/// again if the drag ends elsewhere. Without this, dragging across a deep
/// tree leaves every parent it passed over hanging open.
spring_opened: RefCell<Vec<CollectionId>>,
/// Images dropped on the trash, recorded by `dropped-on-trash` and acted on
/// in `drag-finished` — the same deferral, for the same reason.
trash_requested: RefCell<Option<Vec<ImageId>>>,
/// Drains a trash worker. Held so a second operation replaces the first
/// rather than two timers fighting over the same model.
trash_timer: RefCell<Option<slint::Timer>>,
/// Which collection a drop landed on, recorded by `dropped` and acted on in
/// `drag-finished`.
///
/// Deferred because every consequence of a drop replaces a Slint model —
/// the tree, the grid cells — and doing that from inside the `dropped`
/// handler destroys the elements Slint is still using to deliver the event.
dropped_on: RefCell<Option<CollectionId>>,
}
impl CollectionsController {
pub fn new() -> Rc<Self> {
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<CollectionId> {
*self.scope.borrow()
}
/// Whether the grid should be showing the trash.
pub fn viewing_trash(&self) -> bool {
self.viewing_trash.get()
}
/// Whether the gesture in progress began with ctrl or shift held.
///
/// A modified click selects and nothing more — opening the image as well
/// would eject the user from the grid they are selecting in.
pub fn press_was_modified(&self) -> bool {
self.modified_press.get()
}
/// Selected image ids, in a stable order.
pub fn selected(&self) -> Vec<ImageId> {
self.selection.borrow().iter().copied().collect()
}
/// 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<ImageId>,
anchor: &mut Option<usize>,
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<CollectionsController>, 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<usize> = 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::<slint::Rgba8Pixel>::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<CollectionsController>, 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::<Vec<_>>()
.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<rusqlite::types::Value> = 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<usize>,
row_ids: &[CollectionId],
row_has_children: &[bool],
collapsed: &std::collections::HashSet<CollectionId>,
) -> Option<CollectionId> {
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<CollectionsController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
row: Option<usize>,
) {
// 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<CollectionsController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
keep: Option<CollectionId>,
) {
*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<CollectionsController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
session: &Rc<dyn Fn() -> Option<(dr_sync_nextcloud::AppCredentials, dr_sync_nextcloud::Session)>>,
images: &[ImageId],
reload: &Rc<dyn Fn()>,
) {
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<CollectionsController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
session: &Rc<dyn Fn() -> Option<(dr_sync_nextcloud::AppCredentials, dr_sync_nextcloud::Session)>>,
images: &[ImageId],
reload: &Rc<dyn Fn()>,
) {
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<AppWindow>,
ctl: Rc<CollectionsController>,
catalog: Rc<RefCell<Option<Catalog>>>,
rx: std::sync::mpsc::Receiver<crate::trash::TrashMessage>,
reload: Rc<dyn Fn()>,
) {
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<CollectionsController>) {
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<S, R, C>(
window: &AppWindow,
ctl: Rc<CollectionsController>,
catalog: Rc<RefCell<Option<Catalog>>>,
on_scope_changed: S,
visible_ids: R,
session: C,
) where
S: Fn() + 'static,
R: Fn() -> Vec<ImageId> + '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<dyn Fn()> = 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<ImageId> = {
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<slint::Image> = {
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<u64>, String)> = listed
.iter()
.map(|t| (t.image_id, t.file_id, t.source_ref.clone()))
.collect();
let ids: Vec<ImageId> = 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<CollectionsController>) {
*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<Rename, dr_catalog::CatalogError> {
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<i64> = 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<CollectionId>) -> String {
let taken: Vec<String> = {
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.iter().any(|t| *t == candidate) {
return candidate;
}
}
base.into()
}
#[cfg(test)]
mod tests {
use super::*;
fn ids(n: u64) -> Vec<ImageId> {
(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<_>>(), 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<_>>(), 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<_>>(), vec![ImageId(4)]);
}
/// A solid test thumbnail.
fn thumb(w: u32, h: u32) -> slint::Image {
let mut buf = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::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<slint::Image> = (0..5).map(|_| thumb(64, 64)).collect();
let forty: Vec<slint::Image> = (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<CollectionId>, Vec<bool>, std::collections::HashSet<CollectionId>) {
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.
assert!(
SPRING_DELAY_MS >= 300,
"a pointer crossing a parent must not open it"
);
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");
}
}