Files
DarkRoom/ui/dr-ui/src/collections_ui.rs
T
dtourolle 2fba685e16 Make a pinch zoom the grid and nothing else
Two faults left over from making the gesture reach the grid at all.

**It still opened photographs.** Checking the finger id stops the
synthetic release Slint emits when the *second* finger lands, but not the
other end of the gesture: lifting one finger of two leaves the other one
down, and Slint replays that survivor as a fresh `Pressed` on whatever is
under it — which is how it hands the pointer back to ordinary handling.
Under it is a cell. So the cell was selected, and lifting that last finger
was a complete, well-formed click on the same finger that pressed. No
part of the event stream distinguishes it from a real tap, so the grid now
remembers that a pinch just happened: a latch raised when the gesture
starts and lowered a beat after it ends, during which cells take neither
presses nor clicks.

The press that *opens* a pinch is undone rather than suppressed — it has
already happened by the time a second finger makes it a pinch. Undoing it
has to be exact, or a cancel that restored the selection but left the
anchor moved would make the next shift-click select a run from a cell
nobody pointed at, so capture and restore are a tested pair.

**And it was not smooth.** Two reasons. The pinch was thresholded into ±1
steps of 25%, so the grid lurched and then sat still; it now takes the
ratio since the last update and tracks the fingers, with the drawn cell
still landing on whole column counts because the columns divide the
width. And `zoom-cells` was the one geometry change still reloading
inline — a full catalog re-read, 360-row model rebuild and thumbnail
batch per step, on the thread drawing the frame. It goes through the same
settle timer as the rest now.
2026-08-21 22:03:46 +02:00

3273 lines
128 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};
/// The selection as it stood before a press, for [`cancel_press`].
struct PressUndo {
selection: BTreeSet<ImageId>,
anchor: Option<usize>,
previous_anchor: Option<usize>,
cursor: Option<usize>,
}
impl PressUndo {
/// Everything [`press_remembering_anchor`] is about to change.
///
/// The four are the whole of what a press touches, which is the property
/// the restore depends on and the reason it is worth a test of its own: an
/// press that grew a fifth piece of state would leave that piece behind
/// after a cancel, silently.
fn capture(
selection: &BTreeSet<ImageId>,
anchor: Option<usize>,
previous_anchor: Option<usize>,
cursor: Option<usize>,
) -> Self {
Self {
selection: selection.clone(),
anchor,
previous_anchor,
cursor,
}
}
/// Put it all back. Returns the two the caller holds in `Cell`s.
fn restore(
self,
selection: &mut BTreeSet<ImageId>,
anchor: &mut Option<usize>,
) -> (Option<usize>, Option<usize>) {
*selection = self.selection;
*anchor = self.anchor;
(self.previous_anchor, self.cursor)
}
}
/// 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.
///
/// An **image ordinal in the library**, not a row of the loaded window.
/// The grid is a window over the catalog, so row 7 names a different
/// photograph after every scroll: held as a row, an anchor set before a
/// scroll described a range from wherever that row had since drifted to.
/// Selection is by id for the same reason (see the preamble); this is the
/// same argument applied to the one index that has to survive a move.
anchor: RefCell<Option<usize>>,
/// TRACES: FR-UI-2 | FR-UI-4
/// Where the anchor was *before* the press that moved it.
///
/// The touch equivalent of shift-click needs this. A double tap is two
/// presses, and both of them move the anchor onto the cell being tapped —
/// so by the time the double tap is reported, "the range from the anchor to
/// here" describes a single cell. This remembers the cell the user actually
/// started from, which is the one they mean.
///
/// Updated only when a press *moves* the anchor, so the second tap of a
/// double tap — which lands on the cell that is already the anchor — leaves
/// it pointing where the first tap left it.
previous_anchor: std::cell::Cell<Option<usize>>,
/// TRACES: FR-UI-4
/// What the selection was immediately before the most recent press, so a
/// gesture that turns out not to have been a press can put it back.
///
/// A press has to act immediately — the drag that may follow reads the
/// selection to build its payload, so deciding on release is too late.
/// That is right for a drag and wrong for a pinch, which begins as an
/// ordinary one-finger press and only becomes a pinch when the second
/// finger lands. By then a cell has been selected that the user never
/// meant to touch; they were reaching for the grid with two fingers.
///
/// Cheap to keep: a few hundred ids at most, cloned once per press.
press_undo: RefCell<Option<PressUndo>>,
/// Where the keyboard is, as an image ordinal.
///
/// Distinct from the anchor, and it has to be: shift+arrow grows a range
/// *from* the anchor *to* the cursor, so the two are the two ends and
/// cannot be one field. `None` until the user has taken hold of the grid,
/// so the first arrow press starts from what is on screen rather than
/// jumping to the top of the library.
cursor: std::cell::Cell<Option<usize>>,
/// Whether the press that began the current gesture carried ctrl or shift.
///
/// A modified click is a *selection* gesture and must not also open the
/// image: building a selection would otherwise throw the user into the
/// develop view on the second ctrl-click. Slint does not report modifiers on
/// `clicked`, so the press records them and the click consults this.
modified_press: std::cell::Cell<bool>,
/// TRACES: FR-UI-2 | FR-UI-4
/// Whether a tap in the grid selects rather than opens.
///
/// Touch has no ctrl and no shift, so without a mode there is no way to
/// select a second photograph: the first tap would open the first one. In
/// this mode a plain press is reported as a ctrl-press and goes through the
/// same [`apply_press`] as everything else — a separate touch policy would
/// be a second copy of these rules to keep in step.
select_mode: std::cell::Cell<bool>,
/// The timer that turns a held cell into a selection.
///
/// Here rather than in `.slint` because Slint has no long-press gesture and
/// a hand-rolled one would need a `Timer` element per visible cell — a
/// hundred timers to answer a question about one finger. Held so that
/// dropping it cancels: a press that ends, or is taken by the Flickable
/// when the finger travels, must not arrive as a selection a moment later.
hold_timer: RefCell<Option<slint::Timer>>,
/// Collection ids parallel to the sidebar's rows, so a hovered row index
/// resolves to an id without another query.
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 being dragged, when the drag is a tree rearrangement
/// rather than a filing of photographs.
///
/// Set when the drag starts and read by the drop, the same way `dragging`
/// works — the drop callback carries only the target's id, so what is being
/// dropped has to be remembered rather than inspected.
dragging_collection: RefCell<Option<CollectionId>>,
/// 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>>,
/// Where the trash workers report what they are doing.
///
/// Shared with [`crate::library_ui`]: a delete and a scan are two jobs in
/// one list, and the user asking what the application is busy with does not
/// care which module started them.
activity: Rc<crate::activity::ActivityLog>,
}
/// What a release over a collection row means.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Drop {
/// File these photographs in the target.
FileImages(Vec<ImageId>),
/// Move this collection under the target.
Reparent(CollectionId),
/// Nothing to do — an empty drag, or a row dropped on itself.
Nothing,
}
/// Decide what a drop on `target` should do.
///
/// Separated from the callback because the gesture cannot be driven from a
/// test — Slint owns it — while this decision is where it can actually go
/// wrong. The order matters: an image drag always fills `carried`, so
/// photographs can never be mistaken for a rearrangement, and only an empty
/// payload consults the row remembered from the press.
pub fn decide_drop(
carried: &[ImageId],
pressed_row: Option<CollectionId>,
target: CollectionId,
) -> Drop {
if !carried.is_empty() {
return Drop::FileImages(carried.to_vec());
}
match pressed_row {
// A collection cannot go inside itself. Caught here as well as in the
// catalog so the common case is a no-op rather than an error message.
Some(source) if source != target => Drop::Reparent(source),
_ => Drop::Nothing,
}
}
impl CollectionsController {
pub fn new(activity: Rc<crate::activity::ActivityLog>) -> Rc<Self> {
Rc::new(Self {
activity,
..Default::default()
})
}
/// Remember which row was pressed, so a drag that follows knows what it is
/// carrying.
///
/// Recorded on the press rather than at the drag's start because Slint
/// builds the payload through a `pure` binding, which must not have side
/// effects — and the drop callback is handed only the *target's* id, so the
/// source has to be remembered somewhere.
pub fn note_row_press(&self, id: CollectionId) {
*self.dragging_collection.borrow_mut() = Some(id);
}
/// Which collection the grid is scoped to, for [`crate::library_ui`] to
/// build its query from.
pub fn scope(&self) -> Option<CollectionId> {
*self.scope.borrow()
}
/// Whether the grid should be showing the trash.
pub fn viewing_trash(&self) -> bool {
self.viewing_trash.get()
}
/// Whether the gesture in progress began with ctrl or shift held.
///
/// A modified click selects and nothing more — opening the image as well
/// would eject the user from the grid they are selecting in.
pub fn press_was_modified(&self) -> bool {
self.modified_press.get()
}
/// Selected image ids, in a stable order.
pub fn selected(&self) -> Vec<ImageId> {
self.selection.borrow().iter().copied().collect()
}
/// Drop the selection — after a scrub, or when the scope changes.
///
/// Selection is by id and survives a window change, but a selection the
/// user cannot see is a selection they will act on by accident. Clearing on
/// a deliberate navigation is the safer of the two behaviours.
pub fn clear_selection(&self) {
self.selection.borrow_mut().clear();
*self.anchor.borrow_mut() = None;
self.cursor.set(None);
}
/// Where the keyboard cursor is, as an image ordinal.
pub fn cursor(&self) -> Option<usize> {
self.cursor.get()
}
pub fn set_cursor(&self, at: Option<usize>) {
self.cursor.set(at);
}
}
/// Apply a press to the selection.
///
/// Split from the callback so the policy is testable without a window: this is
/// the part a user notices being wrong.
///
/// - **plain** — replace the selection with this one image
/// - **ctrl** — toggle this image, keeping the rest, and move the anchor here
/// - **shift** — select the range from the anchor to here, *replacing* what was
/// selected; the anchor stays put, so an overshoot is corrected by
/// shift-clicking the right cell rather than starting again
/// - **ctrl+shift** — the same range, *added* to the selection, for picking up a
/// second run without losing the first
///
/// A plain press on an image that is *already* selected leaves the selection
/// alone. That is what makes dragging a multi-selection possible at all — the
/// press that begins the drag would otherwise collapse the selection to one.
///
/// `ids` is the **loaded window**, `offset` where it starts in the library and
/// `row` a position within it; the anchor is kept as `offset + row`, an
/// ordinal that still names the same photograph after the window has moved.
pub fn apply_press(
selection: &mut BTreeSet<ImageId>,
anchor: &mut Option<usize>,
ids: &[ImageId],
offset: usize,
row: usize,
ctrl: bool,
shift: bool,
) {
let Some(&id) = ids.get(row) else { return };
let here = offset + row;
if shift {
let Some(from) = *anchor else {
// No anchor to extend from: behave like a plain click and become
// the anchor, so the *next* shift-click has a range to describe.
selection.clear();
selection.insert(id);
*anchor = Some(here);
return;
};
// The anchor deliberately does **not** move. Shift-clicking again
// re-describes the range from the same origin, so a user who overshoots
// corrects by shift-clicking the right cell rather than starting over.
// That also means the previous range must be cleared first — extending
// without clearing turns a correction into a union, and the user ends up
// dragging cells they thought they had deselected.
//
// Ctrl+shift is the exception: it *adds* a range to what is already
// selected, which is how a second run is picked up without losing the
// first.
if !ctrl {
selection.clear();
}
let (lo, hi) = if from <= here {
(from, here)
} else {
(here, from)
};
// The range is described in ordinals and applied to the window: only
// ids that are loaded can be selected, because selection is by id and
// an image the catalog has not been asked for has no id here. A range
// longer than the loaded window is therefore truncated to it rather
// than silently selecting the wrong photographs — which is what
// holding the anchor as a window row used to do.
//
// The slice is always in range and needs no guard: `ids.get(row)`
// succeeded, so the window is non-empty and `here` is inside it, and
// `here` is one of the two bounds.
let last = ids.len() - 1;
let lo_row = lo.saturating_sub(offset);
let hi_row = hi.saturating_sub(offset).min(last);
for id in &ids[lo_row..=hi_row] {
selection.insert(*id);
}
return;
}
if ctrl {
if !selection.remove(&id) {
selection.insert(id);
}
*anchor = Some(here);
return;
}
// Plain press on something already selected: leave it. The drag that may
// follow carries the whole selection, and collapsing it here would make a
// multi-image drag impossible to start.
if selection.contains(&id) {
*anchor = Some(here);
return;
}
selection.clear();
selection.insert(id);
*anchor = Some(here);
}
/// TRACES: FR-UI-2 | FR-UI-4
/// Apply a press, and remember where the anchor was before it moved.
///
/// The bookkeeping a double tap depends on, split out from the callback so the
/// touch sequence — hold one cell, double-tap another, get the run between —
/// can be tested without a window. See [`CollectionsController::previous_anchor`]
/// for why the *previous* anchor is the one a double tap means.
#[allow(clippy::too_many_arguments)]
pub fn press_remembering_anchor(
selection: &mut BTreeSet<ImageId>,
anchor: &mut Option<usize>,
previous: &mut Option<usize>,
ids: &[ImageId],
offset: usize,
row: usize,
ctrl: bool,
shift: bool,
) {
let before = *anchor;
apply_press(selection, anchor, ids, offset, row, ctrl, shift);
// Only a press that *moved* the anchor updates this. The second tap of a
// double tap lands on the cell the first tap made the anchor, so it changes
// nothing and the origin survives to be extended from.
if *anchor != before {
*previous = before;
}
}
/// TRACES: FR-UI-2 | FR-UI-4
/// Extend the selection to `row` — the touch form of a shift-click.
///
/// Added to the selection rather than replacing it: this is reached only from
/// selection mode, which the user entered deliberately, and a gesture that
/// silently discarded the run they gathered a moment ago would make gathering
/// two runs impossible.
pub fn extend_to_row(
selection: &mut BTreeSet<ImageId>,
anchor: &mut Option<usize>,
previous: Option<usize>,
ids: &[ImageId],
offset: usize,
row: usize,
) {
// Falling back to the current anchor makes a double tap with no history
// select just that cell, which is what a double tap already did.
*anchor = previous.or(*anchor);
apply_press(selection, anchor, ids, offset, row, true, true);
}
/// Apply a press and push the result into the grid — the whole of what a
/// click, or an arrow key, does to the selection.
///
/// Shared so the keyboard and the pointer cannot drift: they are the same
/// gesture reached two ways, and the moment one of them grew its own copy of
/// the selection policy, "click here, shift+down twice" would stop meaning
/// what "click here, shift-click there" means.
pub fn select_row(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
ids: &[ImageId],
offset: usize,
row: usize,
ctrl: bool,
shift: bool,
) {
// Before anything moves — see `press_undo`. A press that turns out to be
// the opening finger of a pinch is undone from here.
*ctl.press_undo.borrow_mut() = Some(PressUndo::capture(
&ctl.selection.borrow(),
*ctl.anchor.borrow(),
ctl.previous_anchor.get(),
ctl.cursor(),
));
let mut previous = ctl.previous_anchor.get();
press_remembering_anchor(
&mut ctl.selection.borrow_mut(),
&mut ctl.anchor.borrow_mut(),
&mut previous,
ids,
offset,
row,
ctrl,
shift,
);
ctl.previous_anchor.set(previous);
// The cursor follows the press, so an arrow key after a click continues
// from the cell that was clicked rather than from wherever the keyboard
// was last.
ctl.set_cursor(Some(offset + row));
sync_selection(window, ctl, ids);
}
/// TRACES: FR-UI-4
/// Put the selection back as it was before the most recent press.
///
/// For the gesture that begins as a press and turns out to be something else.
/// A pinch is the case that matters: it starts as one finger on a cell, which
/// selects it, and only becomes a pinch when the second finger lands — by
/// which point the user has selected a photograph they were not reaching for.
///
/// Idempotent, and a no-op when there is nothing to undo, so it is safe to
/// call on every gesture start rather than only on the ones that need it.
pub fn cancel_press(window: &AppWindow, ctl: &Rc<CollectionsController>, ids: &[ImageId]) {
let Some(undo) = ctl.press_undo.borrow_mut().take() else {
return;
};
let (previous_anchor, cursor) =
undo.restore(&mut ctl.selection.borrow_mut(), &mut ctl.anchor.borrow_mut());
ctl.previous_anchor.set(previous_anchor);
ctl.set_cursor(cursor);
sync_selection(window, ctl, ids);
}
/// Rebuild the sidebar from the catalog.
///
/// Called after every edit. The whole tree rather than a patch: a rename can
/// reorder siblings, a delete promotes children, and a drop changes counts on
/// every ancestor — diffing that against the model would be more code than the
/// query costs, and the tree is tens of rows, not thousands.
pub fn refresh_tree(window: &AppWindow, ctl: &Rc<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();
// TRACES: FR-NC-6a | FR-NC-6c
// How much of each collection is already on the device, rolled up the tree
// — computed once for the whole sidebar rather than per row.
let held = offline_state(catalog, &rows);
// A row is hidden when any ancestor is collapsed. The tree arrives
// depth-first, so tracking the shallowest collapsed depth seen is enough —
// no ancestor lookup per row.
let mut hide_below: Option<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);
let offline = held.get(&id).copied().unwrap_or_default();
ids.push(id);
smart.push(row.collection.kind == CollectionKind::Smart);
has_kids.push(row.has_children);
out.push(CollectionRow {
id: id.0 as i32,
name: row.collection.name.as_str().into(),
depth: row.depth as i32,
direct_count: row.collection.direct_count as i32,
deep_count: deep as i32,
has_children: row.has_children,
expanded,
smart: row.collection.kind == CollectionKind::Smart,
// An empty collection is never "kept": every one of its zero
// photographs being here is true and says nothing, and a full tray
// beside an empty collection is a lie about what a trip would cost
// to take.
pinned: offline.total > 0 && offline.pinned == offline.total,
partly_pinned: offline.pinned > 0 && offline.pinned < offline.total,
});
}
*ctl.row_ids.borrow_mut() = ids;
*ctl.row_smart.borrow_mut() = smart;
*ctl.row_has_children.borrow_mut() = has_kids;
window.set_collection_rows(slint::ModelRc::new(slint::VecModel::from(out)));
}
/// TRACES: FR-NC-6a | FR-NC-6c
/// How many of a collection's photographs are pinned, and how many there are.
///
/// Counted over *members*, not distinct images: an image filed in both a parent
/// and a child is counted in each, which is what makes the roll-up below a sum
/// rather than a set union. That inflates the totals of a tree that files the
/// same photograph twice, and it does not matter here — the only questions
/// asked of these numbers are "all of them?" and "none of them?", and both
/// survive the double count.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct OfflineCount {
total: usize,
pinned: usize,
}
/// The offline state of every collection in the tree, descendants included.
///
/// One query for the direct membership, then a single reverse pass to fold each
/// row into its parent. The alternative — `descendants` plus a count per row —
/// is two queries per collection, and the sidebar rebuilds after every drop,
/// rename and rating change.
///
/// The reverse pass is correct because `tree` emits parents before children:
/// walking it backwards means every child has already been folded in by the
/// time its parent is reached, however deep the nesting goes.
fn offline_state(
catalog: &Catalog,
rows: &[coll::TreeRow],
) -> std::collections::HashMap<CollectionId, OfflineCount> {
let mut counts: std::collections::HashMap<CollectionId, OfflineCount> =
std::collections::HashMap::new();
// `tier_actual`, not `pinned`: the question a user is asking of this icon is
// "will these open on the aeroplane", and a pin whose download has not run
// yet answers no (see the note on `tier_actual` in `dr_catalog::cache`).
let sql = "SELECT cm.collection_id,
count(*),
coalesce(sum(CASE WHEN ic.tier_actual >= ?1 THEN 1 ELSE 0 END), 0)
FROM collection_members cm
LEFT JOIN image_cache ic ON ic.image_id = cm.image_id
GROUP BY cm.collection_id";
match catalog.connection().prepare(sql).and_then(|mut stmt| {
let rows = stmt.query_map(rusqlite::params![dr_types::Tier::Original.stored()], |r| {
Ok((
CollectionId(r.get::<_, i64>(0)? as u64),
r.get::<_, i64>(1)? as usize,
r.get::<_, i64>(2)? as usize,
))
})?;
rows.collect::<Result<Vec<_>, _>>()
}) {
Ok(direct) => {
for (id, total, pinned) in direct {
counts.insert(id, OfflineCount { total, pinned });
}
}
Err(e) => {
// Not fatal: the tray then reads "nothing kept", which is the safe
// direction — it offers a download rather than claiming a trip is
// already on the device.
log::debug!("reading offline state: {e}");
return counts;
}
}
for row in rows.iter().rev() {
let Some(parent) = row.collection.parent else {
continue;
};
let child = counts.get(&row.collection.id).copied().unwrap_or_default();
if child.total == 0 {
continue;
}
let entry = counts.entry(parent).or_default();
entry.total += child.total;
entry.pinned += child.pinned;
}
counts
}
/// Build the bitmap that travels under the cursor.
///
/// One image is drawn as itself. Several are **fanned**, back to front with the
/// topmost last, so the cursor carries a visibly thicker stack the more is being
/// dragged — the count is legible from the shape rather than needing a number.
///
/// Composited here rather than in Slint because `DragArea.drag-image` takes a
/// single bitmap, and Slint cannot render a pile of thumbnails into one.
///
/// Only the top few are drawn. A forty-image drag would otherwise be forty
/// composites for a stack whose lower layers are hidden by the ones above.
fn compose_drag_image(thumbs: &[slint::Image]) -> slint::Image {
/// Layers drawn, at most. Past this the stack looks no thicker.
const MAX_LAYERS: usize = 4;
/// Pixel step between layers, in the composite's own space.
const FAN: u32 = 10;
/// Long edge of the composed bitmap.
const EDGE: u32 = 160;
let layers: Vec<&slint::Image> = thumbs.iter().rev().take(MAX_LAYERS).collect();
let Some(top) = layers.first() else {
return slint::Image::default();
};
// The whole composite is the top image's box plus room for the fan.
let offset = FAN * (layers.len().saturating_sub(1)) as u32;
let size = top.size();
if size.width == 0 || size.height == 0 {
return slint::Image::default();
}
// Scale the top thumbnail so its long edge is EDGE, then add the fan.
let scale = EDGE as f32 / size.width.max(size.height) as f32;
let tw = ((size.width as f32 * scale) as u32).max(1);
let th = ((size.height as f32 * scale) as u32).max(1);
let mut canvas = slint::SharedPixelBuffer::<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;
/// TRACES: FR-UI-2 | FR-UI-4
/// How long a cell must be held before the grid enters selection mode.
///
/// The platform convention, and the reason to match it rather than pick: every
/// gallery on the device this exists for opens a selection on a hold of about
/// this length, so a user who has never read a word about DarkRoom already
/// knows the gesture. Shorter and a slow tap becomes a selection; longer and
/// the hand lets go first, having concluded nothing was going to happen.
pub(crate) const HOLD_DELAY_MS: u64 = 450;
/// TRACES: FR-UI-2 | FR-UI-4
/// Start the timer that turns a held cell into a selection.
///
/// Restarted per press, and cancelled by the release — see `hold_timer`. The
/// press that armed it has *already* selected the cell under the finger, so
/// what firing adds is the mode: from here taps toggle rather than open, and
/// the header's buttons appear to act on what has been gathered.
fn arm_hold(window: &AppWindow, ctl: &Rc<CollectionsController>) {
let timer = slint::Timer::default();
let weak = window.as_weak();
let ctl_cb = ctl.clone();
timer.start(
slint::TimerMode::SingleShot,
std::time::Duration::from_millis(HOLD_DELAY_MS),
move || {
let Some(w) = weak.upgrade() else { return };
ctl_cb.select_mode.set(true);
w.set_library_select_mode(true);
// The release that follows this hold must not also open the image:
// the user asked for a selection and would land in develop instead.
// Reusing `modified_press` rather than adding a second flag — the
// click already consults it, and it means exactly this: "the press
// was a selection gesture".
ctl_cb.modified_press.set(true);
},
);
*ctl.hold_timer.borrow_mut() = Some(timer);
}
/// Whether hovering this row should schedule a spring expansion.
///
/// Pure so the rule is testable: only a *collapsed parent* has anything to
/// open. A leaf would flash a pointless rebuild, and one already expanded is
/// where the user can already see the children.
fn should_spring(
row: Option<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 count = moves.len();
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(),
format!("Moving {count} photograph(s) to the trash"),
);
}
/// TRACES: FR-CAT-15
/// Put trashed images back where they came from.
///
/// The mirror of [`start_trash`], and separate from it rather than a `direction`
/// parameter on one function: the two differ in what they plan, what they report
/// and what they say when the plan comes back empty, and the shared part is the
/// three lines that spawn the worker.
///
/// An image whose origin was never recorded is skipped by
/// [`crate::trash::plan_restore`] rather than guessed at. That can make the plan
/// shorter than the selection, which is why an empty plan is reported here
/// instead of returning silently — the user pressed a button and is owed an
/// answer either way.
fn start_restore(
window: &AppWindow,
ctl: &Rc<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 count = moves.len();
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(),
format!("Restoring {count} photograph(s)"),
);
}
/// Drain a trash worker on the UI thread.
///
/// Same shape as the scan and thumbnail drains: an mpsc channel polled by a
/// Slint timer, so nothing blocks the event loop (NFR-P9).
fn drain_trash(
weak: slint::Weak<AppWindow>,
ctl: Rc<CollectionsController>,
catalog: Rc<RefCell<Option<Catalog>>>,
rx: std::sync::mpsc::Receiver<crate::trash::TrashMessage>,
reload: Rc<dyn Fn()>,
// What the register calls this operation. Passed in rather than derived
// here: the three callers move files to the trash, back out of it, and
// delete them outright, and "Deleting 40 photographs" is the one word of
// the three that must not appear over a restore.
title: String,
) {
use crate::trash::TrashMessage;
let timer = slint::Timer::default();
let ctl_cb = ctl.clone();
// A server-side MOVE per file, so it is a transfer in the sense that
// matters: it takes as long as the connection is slow, and it can fail
// halfway with the library in two states at once.
let job = ctl.activity.begin(crate::activity::Kind::Trash, title);
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(120),
move || {
let Some(w) = weak.upgrade() else { return };
loop {
let msg = match rx.try_recv() {
Ok(m) => m,
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
// A worker that died without reporting must not leave the
// status line mid-sentence.
job.fail("stopped without finishing");
stop_trash(&ctl_cb);
return;
}
};
match msg {
TrashMessage::Progress {
done,
total,
failed,
} => {
let status = if failed > 0 {
format!("{done} / {total} · {failed} failed")
} else {
format!("{done} / {total}")
};
job.progress(done, total);
if failed > 0 {
job.detail(format!("{failed} failed"));
}
w.set_library_status(status.into());
}
TrashMessage::Done { moved, failed } => {
// Reported honestly, including the partial case: "38 of
// 40" is the truth when two files could not be moved,
// and claiming 40 would hide a real problem.
let status = if failed.is_empty() {
format!("{moved} image(s) done")
} else {
format!("{moved} done · {} failed", failed.len())
};
// A partial failure is a failure in the register: the
// library is now in two states at once, which is
// exactly the thing worth keeping on the list.
if failed.is_empty() {
job.finish(status.clone());
} else {
job.fail(status.clone());
}
w.set_library_status(status.into());
if let Some(first) = failed.first() {
w.set_collection_error(first.as_str().into());
}
let borrow = catalog.borrow();
if let Some(cat) = borrow.as_ref() {
refresh_trash(&w, cat);
refresh_tree(&w, &ctl_cb, cat);
}
drop(borrow);
// The grid changed: images left the library, or came
// back into it.
reload();
stop_trash(&ctl_cb);
return;
}
}
}
},
);
*ctl.trash_timer.borrow_mut() = Some(timer);
}
fn stop_trash(ctl: &Rc<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);
// Where the window starts, so the press is recorded as the ordinal
// it is rather than as a row that stops meaning this photograph on
// the next scroll.
let offset = w.get_library_offset().max(0) as usize;
select_row(&w, &ctl, &ids, offset, row as usize, ctrl_held, shift_held);
// TRACES: FR-UI-2 | FR-UI-4
// And start counting, in case this press is a hold. The press has
// already selected this one cell; what the hold adds is the *mode*,
// so the taps that follow go on selecting instead of opening the
// next photograph the user touches.
//
// Not started when the mode is already on: it is on, and a second
// hold would have nothing to do but suppress the tap that ends it.
if !ctl.select_mode.get() {
arm_hold(&w, &ctl);
}
});
}
// TRACES: FR-UI-2 | FR-UI-4
// A double tap in selection mode: take everything between the cell the
// selection started from and this one.
//
// This is shift-click, reached by the one gesture touch has left. Hold to
// start selecting, double-tap the far end, and a run of forty photographs
// is three touches — then the whole selection drags onto a collection as
// one, which is the thing this sequence exists to make possible.
//
// The range is *added*, not replaced, so a second run can be picked up
// without losing the first — ctrl+shift's behaviour, and the right one
// here: a mode the user entered deliberately should accumulate rather than
// throw away what they have already gathered.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let visible = visible_ids.clone();
window.on_library_cell_double_clicked(move |row| {
let Some(w) = weak.upgrade() else { return };
// Outside selection mode a double tap is two ordinary clicks, and
// the first has already opened the image. Nothing to do.
if !ctl.select_mode.get() {
return;
}
// The hold that would have fired mid-double-tap.
*ctl.hold_timer.borrow_mut() = None;
let ids = visible();
let offset = w.get_library_offset().max(0) as usize;
// Extended from the cell the user started at, not from the one the
// two taps just moved the anchor onto.
extend_to_row(
&mut ctl.selection.borrow_mut(),
&mut ctl.anchor.borrow_mut(),
ctl.previous_anchor.get(),
&ids,
offset,
row as usize,
);
ctl.set_cursor(Some(offset + row as usize));
sync_selection(&w, &ctl, &ids);
});
}
// TRACES: FR-UI-2 | FR-UI-4
// The press ended — lifted, or taken by the Flickable when the finger
// travelled. Either way the hold is off.
{
let ctl = ctl.clone();
window.on_library_cell_press_ended(move || {
*ctl.hold_timer.borrow_mut() = None;
});
}
// TRACES: FR-UI-2 | FR-UI-4
// The button half of selection mode. The long press is faster and this is
// the one that can be found — a gesture with no visible counterpart is a
// feature only its author knows about.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let visible = visible_ids.clone();
window.on_library_toggle_select_mode(move || {
let Some(w) = weak.upgrade() else { return };
let on = !ctl.select_mode.get();
ctl.select_mode.set(on);
w.set_library_select_mode(on);
*ctl.hold_timer.borrow_mut() = None;
// Leaving the mode drops the selection. "Done" reads as finishing
// with these photographs, and a selection that outlived the mode
// would still be acted on by the buttons in the header — which is
// how forty images get exported by a user who thought they had put
// them down.
if !on {
ctl.clear_selection();
sync_selection(&w, &ctl, &visible());
}
});
}
// TRACES: FR-CAT-7 | FR-UI-4
// File the selection in a collection without dragging it there.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
let visible = visible_ids.clone();
let reload = on_scope_changed.clone();
window.on_library_file_in_collection(move |id, moves| {
let Some(w) = weak.upgrade() else { return };
let target = CollectionId(id as u64);
let chosen = ctl.selected();
if chosen.is_empty() {
return;
}
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
// Moving is only a move when there is somewhere to move *from*.
// Unscoped, the sheet does not offer it; this guards the callback
// anyway, and degrades to filing rather than refusing.
let from = if moves { *ctl.scope.borrow() } else { None };
let result = match from {
Some(from) => coll::move_images(cat.connection(), from, target, &chosen),
None => coll::add_images(cat.connection(), target, &chosen),
};
match result {
Ok(filed) => {
w.set_collection_error(slint::SharedString::new());
// The honest count, as the drop reports it: claiming all
// twelve when nine were already there teaches the user to
// distrust every number the app shows them.
let msg = if from.is_some() {
format!("Moved {filed} of {}", chosen.len())
} else if filed == chosen.len() {
format!("Added {filed} to collection")
} else {
format!(
"Added {filed} of {} — the rest were already there",
chosen.len()
)
};
w.set_library_status(msg.into());
refresh_tree(&w, &ctl, cat);
sync_badges(&w, cat, &visible());
// A move changes what the collection on screen holds, so
// the grid has to be reread; filing elsewhere does not, but
// the badge count on every cell just changed and the reread
// is one query.
drop(borrow);
reload();
}
Err(e) => w.set_collection_error(format!("filing: {e}").into()),
}
});
}
// --- drag --------------------------------------------------------------
//
// Slint owns the gesture (see the preamble). What is left here is the
// payload — the image ids the drop will act on — and the spring.
// The payload is built when the drag starts, so it is the selection as it
// stands at that moment rather than whatever it becomes mid-flight.
{
let ctl = ctl.clone();
window.on_library_drag_payload(move || {
let carried = ctl.dragging.borrow().clone();
let mut data = slint::DataTransfer::default();
// `user_data` rather than plain text: these are catalog ids for our
// own drop handler, not something another application should be
// able to interpret as a paste.
data.set_user_data(Rc::new(carried));
data
});
}
// Dragging a collection row. The id travels in the payload for anything
// that cares to read it; the drop itself uses the remembered press, since
// it is handed only the target.
{
window.on_collection_drag_payload(move |id| {
let mut data = slint::DataTransfer::default();
data.set_user_data(Rc::new(CollectionId(id as u64)));
data
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
let visible = visible_ids.clone();
window.on_library_drag_started(move |row| {
let Some(w) = weak.upgrade() else { return };
let ids = visible();
// Dragging an *unselected* cell carries only that one, and makes it
// the selection — otherwise the images that travel are not the ones
// the user grabbed. Dragging a selected cell carries the whole
// selection, which is the multi-image gesture.
let carried: Vec<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();
// Taken, not read: a remembered press must be spent by the drop it
// belongs to, or a later empty drop — a file dragged in from
// outside, say — would move a collection nobody touched.
let pressed = ctl.dragging_collection.borrow_mut().take();
let carried = match decide_drop(&carried, pressed, id) {
Drop::Nothing => return,
Drop::Reparent(source) => {
let result = {
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
coll::set_parent(cat.connection(), source, Some(id))
};
match result {
Ok(()) => {
w.set_collection_error(slint::SharedString::new());
w.set_library_status("Moved collection".into());
let borrow = catalog.borrow();
if let Some(cat) = borrow.as_ref() {
refresh_tree(&w, &ctl, cat);
}
}
// The catalog refuses a cycle rather than letting the
// tree walk spin. Saying so is better than a drop that
// silently does nothing.
Err(e) => w.set_collection_error(format!("moving collection: {e}").into()),
}
return;
}
Drop::FileImages(images) => images,
};
// Scoped so the borrow is released before the spring cleanup below,
// which needs the catalog itself.
let result = {
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
coll::add_images(cat.connection(), id, &carried)
};
match result {
Ok(added) => {
w.set_collection_error(slint::SharedString::new());
// "Added 3 of 12" is the honest report when nine were
// already there; claiming 12 would teach the user to
// distrust the count.
let msg = if added == carried.len() {
format!("Added {added} to collection")
} else {
format!(
"Added {added} of {} — the rest were already there",
carried.len()
)
};
w.set_library_status(msg.into());
}
Err(e) => w.set_collection_error(format!("adding to collection: {e}").into()),
}
// Recorded, not acted on. Every visible consequence — rebuilding
// the tree, refreshing the badges, rereading the grid — happens in
// `drag-finished`, because all three replace models that Slint is
// *currently walking* to deliver this very event. Tearing down a
// live `DropArea` from inside its own `dropped` handler is the same
// hazard `sync_rows` in lib.rs documents for the adjust panel.
*ctl.dropped_on.borrow_mut() = Some(id);
});
}
// The drag ended: dropped, or abandoned. This is where the consequences of
// a drop land, once Slint has finished with the elements involved.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
let visible = visible_ids.clone();
let reload = on_scope_changed.clone();
let session = session.clone();
window.on_library_drag_finished(move || {
let Some(w) = weak.upgrade() else { return };
let landed = ctl.dropped_on.borrow_mut().take();
let to_trash = ctl.trash_requested.borrow_mut().take();
ctl.dragging.borrow_mut().clear();
*ctl.hover_id.borrow_mut() = None;
*ctl.spring_timer.borrow_mut() = None;
// A soft delete, deferred out of the drop handler so the models it
// replaces are no longer being walked.
if let Some(images) = to_trash {
start_trash(&w, &ctl, &catalog, &session, &images, &reload);
}
// The cells settle back into the grid, and the cursor bitmap is
// released — it holds a copy of every thumbnail it composited.
sync_lifted(&w, &[], &visible());
w.set_library_drag_image(slint::Image::default());
if landed.is_some() {
let borrow = catalog.borrow();
if let Some(cat) = borrow.as_ref() {
// Counts changed on the target and every ancestor, and the
// dropped images now carry one more collection badge.
refresh_tree(&w, &ctl, cat);
sync_badges(&w, cat, &visible());
}
}
// Put back whatever the spring opened on the way. The collection
// that received the images — and its ancestors — stay open, since
// that is where the user is now working; on an abandoned drag
// `landed` is `None` and everything closes.
collapse_spring_opened(&w, &ctl, &catalog, landed);
// A drop into the collection currently being shown changes what that
// collection holds, so the grid has to be reread.
if landed.is_some() && landed == *ctl.scope.borrow() {
reload();
}
});
}
// --- trash -------------------------------------------------------------
//
// TRACES: FR-CAT-15
// A drop here is a *soft delete*: the file moves to a trash folder on the
// server and the catalog records where it came from. Nothing is destroyed
// until the user empties it, which is a separate, deliberate action.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let session = session.clone();
window.on_trash_dropped(move || {
let Some(w) = weak.upgrade() else { return };
let carried = ctl.dragging.borrow().clone();
if carried.is_empty() {
return;
}
// Recorded like a collection drop, and acted on in `drag-finished`
// for the same reason: the work replaces Slint models that are
// still being walked to deliver this event.
*ctl.trash_requested.borrow_mut() = Some(carried);
let _ = session;
w.set_collection_error(slint::SharedString::new());
});
}
// Restore. Only reachable while the trash is being looked at, and it acts
// on the selection rather than on everything — the trash is where a user
// goes to recover *one* mistake, not usually to undo the lot.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
let session = session.clone();
let reload = on_scope_changed.clone();
window.on_trash_restore(move || {
let Some(w) = weak.upgrade() else { return };
let chosen = ctl.selected();
if chosen.is_empty() {
return;
}
start_restore(&w, &ctl, &catalog, &session, &chosen, &reload);
});
}
// --- trash from the grid ----------------------------------------------
//
// Until now the only route to the trash was dragging onto the sidebar row.
// These two are the direct gestures: the trash target on a cell's rating
// strip, and the `Delete` key.
//
// Both land here rather than in `library_ui` because everything the
// operation needs — the selection, the session closure, `start_trash` and
// its drain — already lives in this module. Reaching them from the grid
// side would mean either duplicating the worker plumbing or moving it, and
// trash is one feature whichever component happens to trigger it.
// The trash glyph on one cell. Acts on that photograph alone: the pointer
// named it, and a click that silently trashed an entire selection would be
// exactly the trap the strip's other targets are laid out to avoid.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
let session = session.clone();
let reload = on_scope_changed.clone();
let visible = visible_ids.clone();
window.on_library_cell_trashed(move |row| {
let Some(w) = weak.upgrade() else { return };
// Resolved through the visible ids rather than the row index alone:
// the grid is a window over the catalog, so a stale index from
// before a scroll would name a different photograph — and here that
// would move the wrong file.
let Some(&id) = visible().get(row as usize) else {
return;
};
start_trash(&w, &ctl, &catalog, &session, &[id], &reload);
});
}
// `Delete` on the selection — the bulk gesture.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
let session = session.clone();
let reload = on_scope_changed.clone();
window.on_library_trash_selection(move || {
let Some(w) = weak.upgrade() else { return };
let chosen = ctl.selected();
if chosen.is_empty() {
// A keystroke that does nothing reads as a broken key, so it
// says why rather than failing silently.
w.set_library_status("Select an image first".into());
return;
}
start_trash(&w, &ctl, &catalog, &session, &chosen, &reload);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
let session = session.clone();
let reload = on_scope_changed.clone();
window.on_trash_empty(move || {
let Some(w) = weak.upgrade() else { return };
let (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 count = ids.len();
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(),
format!("Deleting {count} photograph(s) permanently"),
);
});
}
// --- tree navigation ---------------------------------------------------
{
let weak = window.as_weak();
let ctl = ctl.clone();
let reload = on_scope_changed.clone();
window.on_collection_select(move |id| {
let Some(w) = weak.upgrade() else { return };
// -1 is the trash. It is not a collection, so it clears `scope`
// rather than setting it — see `viewing_trash`.
ctl.viewing_trash.set(id == -1);
*ctl.scope.borrow_mut() = if id <= 0 {
None
} else {
Some(CollectionId(id as u64))
};
// A selection the user cannot see is one they will act on by
// accident, and the new scope shows different images.
ctl.clear_selection();
let label = if id == -1 {
"Trash".to_string()
} else if id == 0 {
String::new()
} else {
let rows = w.get_collection_rows();
(0..rows.row_count())
.filter_map(|i| rows.row_data(i))
.find(|r| r.id == id)
.map(|r| r.name.to_string())
.unwrap_or_default()
};
w.set_collection_selected(id);
w.set_collection_scope_label(label.into());
reload();
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
window.on_collection_toggle(move |id| {
let Some(w) = weak.upgrade() else { return };
let id = CollectionId(id as u64);
{
let mut collapsed = ctl.collapsed.borrow_mut();
if !collapsed.remove(&id) {
collapsed.insert(id);
}
}
let borrow = catalog.borrow();
if let Some(cat) = borrow.as_ref() {
refresh_tree(&w, &ctl, cat);
}
});
}
// --- create ------------------------------------------------------------
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
window.on_collection_new(move || {
let Some(w) = weak.upgrade() else { return };
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else {
w.set_collection_error("Open a library first.".into());
return;
};
// Created inside whatever is selected, which is how a hierarchy
// gets built without a separate "new child" command: select the
// parent, press +.
let parent = *ctl.scope.borrow();
let name = unique_name(cat.connection(), parent);
match coll::create(cat.connection(), &name, parent, CollectionKind::Manual) {
Ok(id) => {
w.set_collection_error(slint::SharedString::new());
// A new child is useless if its parent is collapsed.
if let Some(p) = parent {
ctl.collapsed.borrow_mut().remove(&p);
}
// Straight into the name field, with "New collection"
// selected. The name is a placeholder nobody wants to
// keep, so making the user find the rename gesture
// afterwards is asking them to finish a job we started.
//
// Opened *after* the rebuild, and the order is load-bearing:
// `refresh_tree` replaces the row model, which destroys and
// recreates every row. A field opened before it would be
// torn down along with the `init` that focuses it, leaving
// an edit box nothing had typed into. Setting the property
// afterwards puts the field on a row that already exists.
refresh_tree(&w, &ctl, cat);
*ctl.renaming.borrow_mut() = Some(id);
w.set_collection_renaming(id.0 as i32);
log::info!("created collection {} ({name})", id.0);
}
Err(e) => w.set_collection_error(format!("creating collection: {e}").into()),
}
});
}
// --- rename ------------------------------------------------------------
//
// Inline in the row, opened by a double-click or `F2`. The gesture is worth
// the field rather than a dialog: renaming is how a hierarchy gets tidied,
// and it is done in runs of several — a modal per collection would make
// that a chore.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_collection_rename_start(move |id| {
let Some(w) = weak.upgrade() else { return };
// `F2` arrives with whatever the sidebar has selected, which may be
// "All photographs" (0) or the trash (-1). Neither has a name to
// change, so the key does nothing rather than opening a field on a
// row that is not a collection.
if id <= 0 {
return;
}
let id = CollectionId(id as u64);
// A saved filter is renameable like any other — its *membership* is
// computed, its name is not — so there is no kind check here.
*ctl.renaming.borrow_mut() = Some(id);
w.set_collection_renaming(id.0 as i32);
w.set_collection_error(slint::SharedString::new());
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
window.on_collection_rename_commit(move |id, name| {
let Some(w) = weak.upgrade() else { return };
let id = CollectionId(id as u64);
// The field reports a commit when it loses focus as well as on
// Enter, so a second one can arrive for a rename already closed —
// Enter commits, and the focus the field then gives up commits
// again. Ignored rather than reapplied: the second would bump the
// revision for no change and beat a real edit on another device.
if *ctl.renaming.borrow() != Some(id) {
return;
}
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
match apply_rename(cat.connection(), id, name.as_str()) {
Ok(Rename::Applied(name)) => {
close_rename(&w, &ctl);
w.set_collection_error(slint::SharedString::new());
refresh_tree(&w, &ctl, cat);
// The header names the collection being shown, so a rename
// of the current scope has to reach it too.
if *ctl.scope.borrow() == Some(id) {
w.set_collection_scope_label(name.as_str().into());
}
log::info!("renamed collection {} to {name}", id.0);
}
Ok(Rename::Unchanged) => close_rename(&w, &ctl),
Err(e) => {
// The field stays open on what the user typed. Closing it
// would drop their text and leave the old name showing,
// with only a line of red to explain where it went.
w.set_collection_error(format!("renaming: {e}").into());
}
}
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_collection_rename_cancel(move || {
let Some(w) = weak.upgrade() else { return };
close_rename(&w, &ctl);
w.set_collection_error(slint::SharedString::new());
});
}
// --- remove from the collection being shown ---------------------------
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
let visible = visible_ids.clone();
let reload = on_scope_changed.clone();
window.on_library_remove_from_collection(move || {
let Some(w) = weak.upgrade() else { return };
let Some(scope) = *ctl.scope.borrow() else {
// Unscoped, there is no collection to remove from. The button
// is hidden in that state; this guards the callback anyway.
return;
};
let chosen = ctl.selected();
if chosen.is_empty() {
return;
}
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
match coll::remove_images(cat.connection(), scope, &chosen) {
Ok(n) => {
w.set_collection_error(slint::SharedString::new());
// Named explicitly as a membership change: the images are
// still in the library, and a user who reads this as a
// delete will not trust the feature again.
w.set_library_status(
format!("Removed {n} from this collection; still in the library").into(),
);
ctl.clear_selection();
refresh_tree(&w, &ctl, cat);
sync_badges(&w, cat, &visible());
reload();
}
Err(e) => w.set_collection_error(format!("removing: {e}").into()),
}
});
}
// Right-click. A real context menu needs a popup with keyboard handling and
// a rename field; until that exists the gesture deletes an *empty*
// collection, which is the one destructive action safe without a
// confirmation dialog, and says why when it declines.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
let reload = on_scope_changed.clone();
window.on_collection_menu(move |id| {
let Some(w) = weak.upgrade() else { return };
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
let id = CollectionId(id as u64);
let holds = coll::deep_count(cat.connection(), id).unwrap_or(1);
if holds > 0 {
w.set_collection_error(
format!("{holds} photograph(s) in there — empty it first.").into(),
);
return;
}
match coll::delete(cat.connection(), id) {
Ok(()) => {
w.set_collection_error(slint::SharedString::new());
if *ctl.scope.borrow() == Some(id) {
*ctl.scope.borrow_mut() = None;
w.set_collection_selected(0);
w.set_collection_scope_label(slint::SharedString::new());
reload();
}
refresh_tree(&w, &ctl, cat);
}
Err(e) => w.set_collection_error(format!("deleting: {e}").into()),
}
});
}
}
/// Close the rename field, whatever the outcome.
///
/// Both halves together, always: the controller's copy is what a stray second
/// commit is tested against, and the window property is what draws the field.
/// Clearing one without the other either leaves a field open that nothing will
/// close, or closes one that Rust still believes is open.
fn close_rename(window: &AppWindow, ctl: &Rc<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.contains(&candidate) {
return candidate;
}
}
base.into()
}
#[cfg(test)]
mod tests {
use super::*;
fn ids(n: u64) -> Vec<ImageId> {
(1..=n).map(ImageId).collect()
}
#[test]
fn dragging_a_collection_onto_another_moves_it() {
// The gesture the tree rearrangement exists for: no images carried, a
// row remembered from the press, dropped somewhere else.
assert_eq!(
decide_drop(&[], Some(CollectionId(7)), CollectionId(9)),
Drop::Reparent(CollectionId(7))
);
}
#[test]
fn photographs_are_filed_even_when_a_row_was_pressed_first() {
// Clicking a collection and then dragging photographs into another must
// file them, not move the collection that happens to be remembered.
// This is the ordering the whole discrimination rests on.
assert_eq!(
decide_drop(&ids(3), Some(CollectionId(7)), CollectionId(9)),
Drop::FileImages(ids(3))
);
}
#[test]
fn a_collection_dropped_on_itself_does_nothing() {
// The catalog would refuse it as a cycle; catching it here makes the
// commonest slip a no-op rather than an error the user has to read.
assert_eq!(
decide_drop(&[], Some(CollectionId(7)), CollectionId(7)),
Drop::Nothing
);
}
#[test]
fn an_empty_drag_with_nothing_remembered_moves_nothing() {
// A file dragged in from another application lands here with no images
// and no pressed row. It must not disturb the tree.
assert_eq!(decide_drop(&[], None, CollectionId(9)), Drop::Nothing);
}
#[test]
fn a_plain_press_replaces_the_selection() {
let all = ids(5);
let mut sel = BTreeSet::new();
let mut anchor = None;
apply_press(&mut sel, &mut anchor, &all, 0, 0, false, false);
apply_press(&mut sel, &mut anchor, &all, 0, 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, 0, false, false);
apply_press(&mut sel, &mut anchor, &all, 0, 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, 0, 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, 0, 2, false, false);
apply_press(&mut sel, &mut anchor, &all, 0, 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, 0, 6, false, false);
apply_press(&mut sel, &mut anchor, &all, 0, 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, 0, 5, false, false);
apply_press(&mut sel, &mut anchor, &all, 0, 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, 0, 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, 0, 10, false, false);
apply_press(&mut sel, &mut anchor, &all, 0, 14, false, true);
apply_press(&mut sel, &mut anchor, &all, 0, 12, false, true);
assert_eq!(anchor, Some(10));
assert_eq!(sel.len(), 3, "rows 10..=12");
}
/// One tap in selection mode: a press reported as ctrl-held, which is what
/// `library.slint` sends while the mode is on.
fn tap(
sel: &mut BTreeSet<ImageId>,
anchor: &mut Option<usize>,
previous: &mut Option<usize>,
all: &[ImageId],
row: usize,
) {
press_remembering_anchor(sel, anchor, previous, all, 0, row, true, false);
}
#[test]
fn a_double_tap_takes_the_run_between_it_and_where_selecting_began() {
// The whole touch gesture, in the order a finger performs it: hold one
// photograph to start selecting, then double-tap the far end of the run.
// Both taps of that double tap land on the same cell — the first turns
// it on, the second turns it off — and the double tap that follows has
// to select the range anyway.
let all = ids(20);
let mut sel = BTreeSet::new();
let mut anchor = None;
let mut previous = None;
// The long press: an ordinary plain press, which is what the cell got
// before the hold timer fired.
press_remembering_anchor(
&mut sel,
&mut anchor,
&mut previous,
&all,
0,
4,
false,
false,
);
tap(&mut sel, &mut anchor, &mut previous, &all, 11);
tap(&mut sel, &mut anchor, &mut previous, &all, 11);
assert!(
!sel.contains(&ImageId(12)),
"the two taps cancelled out, which is what makes the double tap's \
job to select the range rather than to add one cell"
);
extend_to_row(&mut sel, &mut anchor, previous, &all, 0, 11);
assert_eq!(sel.len(), 8, "rows 4..=11");
assert!(sel.contains(&ImageId(5)) && sel.contains(&ImageId(12)));
}
#[test]
fn a_double_tap_keeps_a_run_gathered_earlier() {
// Two runs, which is why the extension unions rather than replaces: a
// user in selection mode is gathering, and the second gesture must not
// throw away the first.
let all = ids(30);
let mut sel = BTreeSet::new();
let mut anchor = None;
let mut previous = None;
press_remembering_anchor(
&mut sel,
&mut anchor,
&mut previous,
&all,
0,
0,
false,
false,
);
tap(&mut sel, &mut anchor, &mut previous, &all, 3);
tap(&mut sel, &mut anchor, &mut previous, &all, 3);
extend_to_row(&mut sel, &mut anchor, previous, &all, 0, 3);
assert_eq!(sel.len(), 4, "rows 0..=3");
// A second run, begun with a plain tap somewhere else.
tap(&mut sel, &mut anchor, &mut previous, &all, 20);
tap(&mut sel, &mut anchor, &mut previous, &all, 25);
tap(&mut sel, &mut anchor, &mut previous, &all, 25);
extend_to_row(&mut sel, &mut anchor, previous, &all, 0, 25);
assert_eq!(sel.len(), 10, "rows 0..=3 and 20..=25");
assert!(sel.contains(&ImageId(1)) && sel.contains(&ImageId(26)));
}
#[test]
fn a_double_tap_with_nothing_to_extend_from_selects_only_that_cell() {
// The degenerate case: selection mode entered from the header's button
// rather than by holding a cell, so nothing has anchored yet.
let all = ids(10);
let mut sel = BTreeSet::new();
let mut anchor = None;
let previous = None;
extend_to_row(&mut sel, &mut anchor, previous, &all, 0, 6);
assert_eq!(sel.iter().copied().collect::<Vec<_>>(), vec![ImageId(7)]);
}
#[test]
fn ctrl_shift_adds_a_second_range_to_the_selection() {
// Picking up a second run without losing the first: the one case where
// a shift-click must not clear.
let all = ids(20);
let mut sel = BTreeSet::new();
let mut anchor = None;
apply_press(&mut sel, &mut anchor, &all, 0, 0, false, false);
apply_press(&mut sel, &mut anchor, &all, 0, 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, 0, 10, true, false);
apply_press(&mut sel, &mut anchor, &all, 0, 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, 0, false, false);
apply_press(&mut sel, &mut anchor, &all, 0, 1, true, false);
apply_press(&mut sel, &mut anchor, &all, 0, 2, true, false);
assert_eq!(sel.len(), 3);
// Pressing one of the three to begin a drag.
apply_press(&mut sel, &mut anchor, &all, 0, 1, false, false);
assert_eq!(sel.len(), 3, "the selection survived the press");
}
#[test]
fn a_press_undone_leaves_the_selection_exactly_as_it_was() {
// A pinch opens as one finger on a cell, so by the time it is known to
// be a pinch a photograph has been selected that the user was only
// reaching past. Undoing it has to be *exact*: a cancel that restored
// the selection but left the anchor moved would make the next
// shift-click select a run from a cell nobody pointed at.
let all = ids(6);
let mut sel = BTreeSet::new();
let mut anchor = None;
// A selection built the ordinary way, and the state it leaves behind.
apply_press(&mut sel, &mut anchor, &all, 0, 1, false, false);
apply_press(&mut sel, &mut anchor, &all, 0, 3, true, false);
let before = (sel.clone(), anchor);
// The finger that opens a pinch.
let undo = PressUndo::capture(&sel, anchor, Some(1), Some(3));
apply_press(&mut sel, &mut anchor, &all, 0, 5, false, false);
assert_ne!(
(sel.clone(), anchor),
before,
"the press has to change something, or this proves nothing"
);
let (previous_anchor, cursor) = undo.restore(&mut sel, &mut anchor);
assert_eq!((sel, anchor), before, "selection and anchor are back");
assert_eq!(previous_anchor, Some(1), "and so is the shift-click origin");
assert_eq!(cursor, Some(3), "and the keyboard cursor");
}
#[test]
fn a_press_past_the_end_of_the_window_is_ignored() {
// The grid is windowed and a stale row index can arrive after a scrub.
let all = ids(3);
let mut sel = BTreeSet::new();
let mut anchor = None;
apply_press(&mut sel, &mut anchor, &all, 0, 99, false, false);
assert!(sel.is_empty());
}
#[test]
fn an_anchor_survives_the_window_moving_under_it() {
// The grid is a window over the catalog, and the window moves whenever
// the user scrolls. Held as a row, an anchor set before a scroll
// described a range from whatever photograph had since drifted into
// that row — so shift-clicking after scrolling selected a run the user
// never pointed at, silently and with no way to tell.
let all = ids(20);
let first: Vec<_> = all[0..10].to_vec();
let later: Vec<_> = all[4..14].to_vec();
let mut sel = BTreeSet::new();
let mut anchor = None;
// Anchor on the sixth image, in a window starting at the beginning.
apply_press(&mut sel, &mut anchor, &first, 0, 5, false, false);
assert_eq!(anchor, Some(5), "the anchor is an ordinal, not a row");
// The user scrolls — the window now starts four images in — and
// shift-clicks the image at ordinal 10.
apply_press(&mut sel, &mut anchor, &later, 4, 6, false, true);
assert_eq!(sel.len(), 6, "ordinals 5..=10");
assert!(sel.contains(&ImageId(6)), "the anchored image is still in");
assert!(sel.contains(&ImageId(11)), "up to the one shift-clicked");
assert!(!sel.contains(&ImageId(5)), "and nothing before the anchor");
}
#[test]
fn a_range_reaching_outside_the_window_selects_what_is_loaded() {
// Selection is by id, so a range can only cover images the window
// holds. Truncating is the honest answer; the alternative — indexing
// from the window's start as though it were the library's — selects
// the wrong photographs and looks like it worked.
let all = ids(30);
let window: Vec<_> = all[10..20].to_vec();
let mut sel = BTreeSet::new();
let mut anchor = Some(0);
apply_press(&mut sel, &mut anchor, &window, 10, 3, false, true);
assert_eq!(sel.len(), 4, "ordinals 10..=13, the loaded part of 0..=13");
assert!(sel.contains(&ImageId(11)) && sel.contains(&ImageId(14)));
}
#[test]
fn a_range_running_off_the_far_end_stops_at_the_window() {
// The mirror of the case above, with the anchor *ahead* of the press
// instead of behind it. Both directions truncate to what is loaded,
// and neither may wrap round to the other end of the window.
let all = ids(30);
let window: Vec<_> = all[0..5].to_vec();
let mut sel = BTreeSet::new();
let mut anchor = Some(25);
apply_press(&mut sel, &mut anchor, &window, 0, 2, false, true);
assert_eq!(sel.len(), 3, "ordinals 2..=4, the loaded part of 2..=25");
assert!(sel.contains(&ImageId(3)) && sel.contains(&ImageId(5)));
assert!(
!sel.contains(&ImageId(1)),
"nothing before the pressed cell"
);
}
#[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, 0, 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.
// Asserted in a const item rather than at runtime: the condition is
// constant either way, and clippy is right that a runtime assert on it
// is theatre. This form fails the build instead of a test run, which is
// strictly earlier, and keeps the bound where a reader of the constant
// will look for it.
const _: () = assert!(
SPRING_DELAY_MS >= 300,
"a pointer crossing a parent must not open it"
);
const _: () = assert!(
SPRING_DELAY_MS <= 900,
"and a deliberate dwell must not feel like a hang"
);
}
#[test]
fn new_collections_do_not_share_a_name_with_a_sibling() {
// Two identically-named collections in one parent are
// indistinguishable in the sidebar, which is how images land in the
// wrong one.
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
let first = unique_name(c, None);
coll::create(c, &first, None, CollectionKind::Manual).unwrap();
let second = unique_name(c, None);
coll::create(c, &second, None, CollectionKind::Manual).unwrap();
assert_ne!(first, second);
assert_eq!(first, "New collection");
assert_eq!(second, "New collection 2");
}
/// TRACES: FR-NC-6a | FR-NC-6c
/// A catalog with `n` images, for the offline roll-up tests.
fn catalog_with_images(n: usize) -> (Catalog, Vec<ImageId>) {
let cat = Catalog::in_memory().unwrap();
cat.connection()
.execute(
"INSERT INTO roots (id, kind, label) VALUES (1, 'remote', 'test')",
[],
)
.unwrap();
let mut ids = Vec::new();
for i in 0..n {
cat.connection()
.execute(
"INSERT INTO images (root_id, source_ref, added_at)
VALUES (1, ?1, 0)",
rusqlite::params![format!("Photos/img{i:03}.CR2")],
)
.unwrap();
ids.push(ImageId(cat.connection().last_insert_rowid() as u64));
}
(cat, ids)
}
/// Record an image as held on this device, as a finished download would.
fn mark_held(cat: &Catalog, image: ImageId) {
cat.connection()
.execute(
"INSERT INTO image_cache (image_id, tier_actual, tier_desired, bytes, pinned)
VALUES (?1, ?2, ?2, 100, 1)",
rusqlite::params![image.0 as i64, dr_types::Tier::Original.stored()],
)
.unwrap();
}
#[test]
fn a_parent_reads_as_kept_only_when_its_children_are_too() {
// The tray on a parent answers for the subtree, because that is what
// keeping a parent downloads. A parent that showed a full tray over a
// child with nothing downloaded would be a promise the aeroplane
// breaks.
let (cat, images) = catalog_with_images(4);
let c = cat.connection();
let parent = coll::create(c, "Trip", None, CollectionKind::Manual).unwrap();
let child = coll::create(c, "Day one", Some(parent), CollectionKind::Manual).unwrap();
coll::add_images(c, parent, &images[0..2]).unwrap();
coll::add_images(c, child, &images[2..4]).unwrap();
// Everything the parent holds directly, and nothing of the child's.
mark_held(&cat, images[0]);
mark_held(&cat, images[1]);
let rows = coll::tree(c).unwrap();
let state = offline_state(&cat, &rows);
assert_eq!(
state[&parent],
OfflineCount {
total: 4,
pinned: 2
}
);
assert_eq!(
state[&child],
OfflineCount {
total: 2,
pinned: 0
}
);
mark_held(&cat, images[2]);
mark_held(&cat, images[3]);
let state = offline_state(&cat, &coll::tree(c).unwrap());
assert_eq!(
state[&parent],
OfflineCount {
total: 4,
pinned: 4
}
);
}
#[test]
fn a_grandchild_rolls_all_the_way_up() {
// The reverse pass has to survive depth: a photograph three levels down
// is still part of what the top-level collection would download.
let (cat, images) = catalog_with_images(1);
let c = cat.connection();
let top = coll::create(c, "2024", None, CollectionKind::Manual).unwrap();
let mid = coll::create(c, "Corsica", Some(top), CollectionKind::Manual).unwrap();
let leaf = coll::create(c, "Bonifacio", Some(mid), CollectionKind::Manual).unwrap();
coll::add_images(c, leaf, &images).unwrap();
let state = offline_state(&cat, &coll::tree(c).unwrap());
assert_eq!(
state[&top],
OfflineCount {
total: 1,
pinned: 0
}
);
assert_eq!(
state[&mid],
OfflineCount {
total: 1,
pinned: 0
}
);
}
#[test]
fn an_empty_collection_has_nothing_to_keep() {
// Guards the reading that would put a full tray beside an empty
// collection: every one of its zero photographs is here, which is true
// and useless. `refresh_tree` requires a non-zero total before drawing
// one, and this is the fact it relies on.
let (cat, _) = catalog_with_images(0);
let c = cat.connection();
let empty = coll::create(c, "Nothing yet", None, CollectionKind::Manual).unwrap();
let state = offline_state(&cat, &coll::tree(c).unwrap());
assert_eq!(state.get(&empty).copied().unwrap_or_default().total, 0);
}
/// A catalog holding one top-level collection, and its id.
fn with_one(name: &str) -> (Catalog, CollectionId) {
let cat = Catalog::in_memory().unwrap();
let id = coll::create(cat.connection(), name, None, CollectionKind::Manual).unwrap();
(cat, id)
}
fn name_of(cat: &Catalog, id: CollectionId) -> String {
cat.connection()
.query_row(
"SELECT name FROM collections WHERE id = ?1",
[id.0 as i64],
|r| r.get(0),
)
.unwrap()
}
#[test]
fn a_rename_stores_the_new_name() {
let (cat, id) = with_one("Untitled");
let out = apply_rename(cat.connection(), id, "Iceland").unwrap();
assert_eq!(out, Rename::Applied("Iceland".into()));
assert_eq!(name_of(&cat, id), "Iceland");
}
#[test]
fn surrounding_whitespace_is_trimmed_rather_than_stored() {
// A trailing space is invisible in the sidebar and makes two
// collections look identical while sorting them apart.
let (cat, id) = with_one("Untitled");
assert_eq!(
apply_rename(cat.connection(), id, " Iceland ").unwrap(),
Rename::Applied("Iceland".into())
);
assert_eq!(name_of(&cat, id), "Iceland");
}
#[test]
fn a_blank_name_is_refused() {
// A nameless row cannot be read or aimed at, and the schema would take
// one happily.
let (cat, id) = with_one("Iceland");
assert!(matches!(
apply_rename(cat.connection(), id, " "),
Err(dr_catalog::CatalogError::BadName(_))
));
assert_eq!(name_of(&cat, id), "Iceland", "the old name stands");
}
#[test]
fn renaming_to_the_current_name_writes_nothing() {
// Every write bumps the revision, and a no-op rename would let an idle
// device beat one that did real work at the next merge.
let (cat, id) = with_one("Iceland");
let rev = |c: &Catalog| -> i64 {
c.connection()
.query_row(
"SELECT revision FROM collections WHERE id = ?1",
[id.0 as i64],
|r| r.get(0),
)
.unwrap()
};
let before = rev(&cat);
assert_eq!(
apply_rename(cat.connection(), id, "Iceland").unwrap(),
Rename::Unchanged
);
assert_eq!(rev(&cat), before);
}
#[test]
fn a_siblings_name_is_refused() {
// Two identically-named collections in one parent are
// indistinguishable in the sidebar, which is how images land in the
// wrong one.
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
coll::create(c, "Iceland", None, CollectionKind::Manual).unwrap();
let japan = coll::create(c, "Japan", None, CollectionKind::Manual).unwrap();
assert!(matches!(
apply_rename(c, japan, "Iceland"),
Err(dr_catalog::CatalogError::BadName(_))
));
assert_eq!(name_of(&cat, japan), "Japan");
}
#[test]
fn a_siblings_name_is_refused_in_a_different_case_too() {
// The sidebar sorts case-insensitively, so "iceland" beside "Iceland"
// is the same trap as an exact duplicate.
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
coll::create(c, "Iceland", None, CollectionKind::Manual).unwrap();
let japan = coll::create(c, "Japan", None, CollectionKind::Manual).unwrap();
assert!(matches!(
apply_rename(c, japan, "iceland"),
Err(dr_catalog::CatalogError::BadName(_))
));
}
#[test]
fn changing_only_the_case_of_a_name_is_allowed() {
// The clash check excludes the collection itself, or fixing the
// capitalisation of a name would be refused as a clash with itself.
let (cat, id) = with_one("iceland");
assert_eq!(
apply_rename(cat.connection(), id, "Iceland").unwrap(),
Rename::Applied("Iceland".into())
);
assert_eq!(name_of(&cat, id), "Iceland");
}
#[test]
fn a_name_used_under_a_different_parent_is_free() {
// Uniqueness is per parent: "Selects" inside two different trips is
// unambiguous and normal.
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
let trips = coll::create(c, "Trips", None, CollectionKind::Manual).unwrap();
coll::create(c, "Selects", Some(trips), CollectionKind::Manual).unwrap();
let top = coll::create(c, "Loose", None, CollectionKind::Manual).unwrap();
assert_eq!(
apply_rename(c, top, "Selects").unwrap(),
Rename::Applied("Selects".into())
);
}
#[test]
fn two_top_level_collections_still_clash_despite_a_null_parent() {
// `parent_id IS NULL` never matches with `=`, so a naive clash query
// would silently allow every duplicate at the top level — which is
// where most collections live.
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
coll::create(c, "Iceland", None, CollectionKind::Manual).unwrap();
let other = coll::create(c, "Japan", None, CollectionKind::Manual).unwrap();
assert!(
apply_rename(c, other, "Iceland").is_err(),
"two top-level collections must not share a name"
);
}
#[test]
fn renaming_a_deleted_collection_fails_rather_than_resurrecting_it() {
let (cat, id) = with_one("Gone");
coll::delete(cat.connection(), id).unwrap();
assert!(apply_rename(cat.connection(), id, "Back").is_err());
}
#[test]
fn a_saved_filter_can_be_renamed() {
// Its *membership* is computed; its name is not, and a smart
// collection the user cannot label is worse than no smart collection.
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
let s = coll::create(c, "Untitled", None, CollectionKind::Smart).unwrap();
assert_eq!(
apply_rename(c, s, "Five star").unwrap(),
Rename::Applied("Five star".into())
);
}
#[test]
fn the_same_name_is_free_again_under_a_different_parent() {
// Uniqueness is per parent, not global: "Selects" inside two different
// trips is unambiguous and normal.
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
let top = coll::create(c, "New collection", None, CollectionKind::Manual).unwrap();
assert_eq!(unique_name(c, Some(top)), "New collection");
}
}