Files
DarkRoom/ui/dr-ui/src/collections_ui.rs
T
dtourolle 6507593715 Hand the drag ghost to the renderer through a file, so it draws
The bitmap under the cursor was a solid red rectangle. Slint's drag
overlay uploads the image as a texture, draws it and drops the texture in
one call; with the wgpu FemtoVG renderer the drop is immediate and the
draw is deferred to the flush, so the frame binds femtovg's placeholder —
which is red. An image with a cache key survives in the texture cache
until after the flush, and only a path gives one. So the composite goes
to the data directory's scratch as a PNG and comes back through
load_from_path; one file per drag, removed when the drag ends. A
workaround for Slint 1.17.1, written up as one beside the code.
2026-09-20 15:58:44 +02:00

4565 lines
183 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::library;
use crate::{AppWindow, CollectionRow};
/// The selection as it stood before a press, for [`cancel_press`].
struct PressUndo {
selection: BTreeSet<ImageId>,
anchor: Option<usize>,
cursor: Option<usize>,
}
impl PressUndo {
/// Everything [`select_row`] is about to change.
///
/// The three 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: a
/// press that grew a fourth piece of state would leave that piece behind
/// after a cancel, silently.
fn capture(
selection: &BTreeSet<ImageId>,
anchor: Option<usize>,
cursor: Option<usize>,
) -> Self {
Self {
selection: selection.clone(),
anchor,
cursor,
}
}
/// Put it all back. Returns the one the caller holds in a `Cell`.
fn restore(
self,
selection: &mut BTreeSet<ImageId>,
anchor: &mut Option<usize>,
) -> Option<usize> {
*selection = self.selection;
*anchor = self.anchor;
self.cursor
}
}
/// TRACES: FR-CAT-5
/// How a run of grid ordinals is turned into the ids it names.
///
/// See [`CollectionsController::span_source`] for why this is supplied rather
/// than reached for.
type SpanIds = Rc<dyn Fn(usize, usize) -> Vec<ImageId>>;
/// 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
/// TRACES: FR-CAT-7 | FR-UI-4
/// A photograph the most recent press asked to take *out* of the
/// selection, held until the release says the press was a tap.
///
/// See [`Press::Deferred`] for why removal cannot happen on the press:
/// this is the whole of what stops a drag of forty photographs carrying
/// one. Overwritten by the next press and dropped by the drag that
/// consumes it, so at most one is ever pending.
pending_toggle: std::cell::Cell<Option<ImageId>>,
/// 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>>,
/// TRACES: FR-UI-3 | FR-UI-4
/// The collection a hold has picked up, and the timer that picks it up.
///
/// The tree is inside a Flickable, which claims any drag beginning inside
/// it — so with a finger, a drag on a row is a scroll unless something says
/// otherwise first. The hold is that something. It arms the drag *and*
/// opens the row menu, and which of the two the user gets is decided by
/// whether they then moved: the same fork the grid already uses to tell a
/// hold-to-select from a drag-to-file.
lifted: RefCell<Option<CollectionId>>,
/// Whether the lifted row's drag actually began. Reset by the press that
/// arms the next one, so a release can tell a rearrangement from a menu.
drag_began: std::cell::Cell<bool>,
/// The hold timer for a sidebar row. One, replaced per press, so a press
/// that became a scroll leaves nothing queued to fire over the list the
/// user is now scrolling.
row_hold_timer: RefCell<Option<slint::Timer>>,
/// Whether each visible row has a parent, in `row_ids` order — what decides
/// whether "All photographs" lights up as a drop target. Kept beside the
/// rows it indexes rather than queried per drag: `refresh_tree` already has
/// the parent in hand, and a query would answer for a tree that may have
/// been rebuilt since.
row_nested: RefCell<Vec<bool>>,
/// The collection the row menu is open on.
///
/// Held here as well as in the window's title property because the two say
/// different things: the property is what the sheet *draws*, and this is
/// what every action *acts on*. A rename committed from the menu changes
/// the first and must not change the second.
menu_for: RefCell<Option<CollectionId>>,
/// Whether the menu's delete has been asked once and is waiting to be
/// confirmed.
///
/// A `Cell` rather than a window property alone so the decision is made in
/// Rust: the sheet must not be able to reach the destructive branch by
/// flipping a bit of its own.
menu_confirming: std::cell::Cell<bool>,
/// 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>>,
/// TRACES: FR-CAT-5
/// How a run of grid ordinals is turned into ids.
///
/// Supplied by [`crate::library_ui`] at wiring time, because the catalog,
/// the scope and the rating filter — everything the run depends on — belong
/// to the grid's controller, not to this one. Held as a closure rather than
/// reached for through a handle so the selection rules stay testable with
/// no library open: the tests pass a run that reads a plain slice.
///
/// `None` before wiring, which the caller reads as "nothing to ask" and
/// falls back to the loaded window.
span_source: RefCell<Option<SpanIds>>,
/// 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,
}
}
/// TRACES: FR-CAT-7
/// What the row menu's Delete should do on this press.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeleteStep {
/// Delete it now. Nothing is lost that the user cannot see is nothing.
Now,
/// Say what goes, and wait to be asked again.
Confirm,
}
/// TRACES: FR-CAT-7
/// Whether deleting this collection needs to be confirmed first.
///
/// The gesture this replaced refused outright whenever the collection held
/// anything, which made a full collection undeletable without emptying it by
/// hand — child by child, since a parent counts its descendants. Refusing is
/// not a safety property; it is the absence of one, because the user goes and
/// does the same thing the long way round.
///
/// So: an empty collection goes on the first press, because there is nothing
/// to warn about and a dialogue asking "delete this empty thing?" is the kind
/// that teaches people to dismiss dialogues. Anything else is asked once.
///
/// Split from the handler because this is the whole of the safety rule, and a
/// handler needs a window to run.
pub fn decide_delete(holds: usize, children: usize, confirmed: bool) -> DeleteStep {
if confirmed || (holds == 0 && children == 0) {
DeleteStep::Now
} else {
DeleteStep::Confirm
}
}
/// TRACES: FR-CAT-7
/// The line under the menu's title: what this collection directly holds.
///
/// Direct members and direct children, not the sidebar's deep count, because
/// this line sits above a Delete — and delete drops *this* collection's member
/// rows while promoting its children rather than taking them. A number here
/// that counted descendants' photographs would be describing something the
/// button below it does not do.
pub fn menu_detail(holds: usize, children: usize) -> String {
let photos = match holds {
0 => "no photographs".to_string(),
1 => "1 photograph".to_string(),
n => format!("{n} photographs"),
};
match children {
0 => photos,
1 => format!("{photos} · 1 collection inside"),
n => format!("{photos} · {n} collections inside"),
}
}
/// TRACES: FR-CAT-7
/// What deleting this collection actually does, in full.
///
/// Every clause here is one a user has got wrong about a collections feature
/// before: that deleting a collection deletes the photographs (it does not —
/// membership is a join table), and that deleting a parent takes the
/// collections nested in it (it does not — [`dr_catalog::collections::delete`]
/// promotes them, because losing a subtree because its container was tidied
/// away is not recoverable).
pub fn delete_warning(name: &str, holds: usize, children: usize) -> String {
let mut out = format!("Deleting “{name}” ");
match (holds, children) {
(0, _) => out.push_str("removes it from the sidebar."),
(1, _) => out.push_str("takes 1 photograph out of it."),
(n, _) => out.push_str(&format!("takes {n} photographs out of it.")),
}
if holds > 0 {
out.push_str(" They stay in your library and in every other collection they are in.");
}
match children {
0 => {}
1 => {
out.push_str(" The collection inside it moves up one level rather than going with it.")
}
n => out.push_str(&format!(
" The {n} collections inside it move up one level rather than going with it."
)),
}
out
}
/// TRACES: FR-UI-3 | FR-UI-4
/// What letting go of a held collection row means.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Release {
/// The hold fired and nothing moved: the user is asking what can be done
/// to this collection.
OpenMenu(CollectionId),
/// Either the hold never fired — an ordinary tap, which selects — or it
/// did and the row was then dragged, in which case the drop has already
/// done the work.
Nothing,
}
/// TRACES: FR-UI-3 | FR-UI-4
/// Decide what a release does, from what the press turned into.
///
/// The whole of the fork that lets one gesture mean two things, and the reason
/// it is worth a test: get it wrong in the direction of `OpenMenu` and every
/// rearrangement ends with a sheet over the tree the user just tidied; get it
/// wrong the other way and the menu is unreachable with a finger.
pub fn decide_release(held: Option<CollectionId>, dragged: bool) -> Release {
match held {
Some(id) if !dragged => Release::OpenMenu(id),
_ => Release::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()
}
/// TRACES: FR-CAT-5
/// The ids of grid rows `first..=last`, in the order the grid shows them.
///
/// Empty when nothing has been wired in — see [`Self::span_source`].
fn span(&self, first: usize, last: usize) -> Vec<ImageId> {
let source = self.span_source.borrow().clone();
source.map(|f| f(first, last)).unwrap_or_default()
}
/// Drop the selection — after a scrub, or when the scope changes.
///
/// Selection is by id and survives a window change, but a selection the
/// user cannot see is a selection they will act on by accident. Clearing on
/// a deliberate navigation is the safer of the two behaviours.
pub fn clear_selection(&self) {
self.selection.borrow_mut().clear();
*self.anchor.borrow_mut() = None;
self.cursor.set(None);
}
/// Where the keyboard cursor is, as an image ordinal.
pub fn cursor(&self) -> Option<usize> {
self.cursor.get()
}
pub fn set_cursor(&self, at: Option<usize>) {
self.cursor.set(at);
}
}
/// TRACES: FR-CAT-7 | FR-UI-4
/// What a press did, and what is left for the release to do.
///
/// Only one press has anything left over, and it is the one every multi-image
/// drag depends on — see [`Press::Deferred`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[must_use]
pub enum Press {
/// The selection is already what this press means. Nothing to finish.
Applied,
/// **Taking a photograph out of the selection waits for the release.**
///
/// Putting one *in* has to happen on the press: the drag that may follow
/// reads the selection to decide what it carries, and by the time the
/// finger lifts it is over the sidebar. Taking one out is the opposite —
/// nothing between the press and the release needs the image gone, and one
/// thing very much needs it to stay.
///
/// A plain press has said so since selection was written: pressing an
/// already-selected cell leaves the selection alone. Ctrl did not, and on a
/// tablet *every* press is a ctrl-press — that is what selection mode is.
/// So grabbing one of forty selected photographs deselected it on the way
/// down; the drag that followed found the cell under the finger no longer
/// in the selection, took that to mean an unselected image was being
/// dragged, and carried it alone. Forty photographs became one, and the
/// only clue was the grabbed cell's ring blinking out.
///
/// So the removal is handed back for the *click* to apply, and a click
/// fires only for a press that stayed put — never for one that became a
/// drag. See `tap-slop` in `library.slint`.
Deferred(ImageId),
}
/// 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** — add this image to the selection, keeping the rest, and move the
/// anchor here; taking one *out* is [deferred](Press::Deferred) to the release
/// - **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 press on an image that is *already* selected never removes it here —
/// plainly or with ctrl. That is what makes dragging a multi-selection possible
/// at all: the press that begins the drag would otherwise take the grabbed
/// photograph out from under it.
///
/// `ids` is the **loaded window**, `offset` where it starts in the library and
/// `row` a position within it; the anchor is kept as `offset + row`, an
/// ordinal that still names the same photograph after the window has moved.
///
/// `span` turns a run of ordinals into the ids it names — the catalog's job,
/// since the run is mostly not loaded. Passed in rather than reached for so
/// this stays a pure function of what it is given.
#[allow(clippy::too_many_arguments)]
pub fn apply_press(
selection: &mut BTreeSet<ImageId>,
anchor: &mut Option<usize>,
ids: &[ImageId],
offset: usize,
row: usize,
ctrl: bool,
shift: bool,
span: &dyn Fn(usize, usize) -> Vec<ImageId>,
) -> Press {
let Some(&id) = ids.get(row) else {
return Press::Applied;
};
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 Press::Applied;
};
// The anchor deliberately does **not** move. Shift-clicking again
// re-describes the range from the same origin, so a user who overshoots
// corrects by shift-clicking the right cell rather than starting over.
// That also means the previous range must be cleared first — extending
// without clearing turns a correction into a union, and the user ends up
// dragging cells they thought they had deselected.
//
// Ctrl+shift is the exception: it *adds* a range to what is already
// selected, which is how a second run is picked up without losing the
// first.
if !ctrl {
selection.clear();
}
let (lo, hi) = if from <= here {
(from, here)
} else {
(here, from)
};
// The run is described in ordinals and resolved by the catalog, which
// is the only thing that knows what lies between them. Selection is by
// id, and an image outside the loaded window has no id here — so an
// earlier version truncated the run to what was on screen, and
// shift-clicking two ends of a morning selected the dozen cells that
// happened to be loaded. The user cannot see that they did not get
// what they asked for until the drop files a dozen photographs
// instead of two hundred.
let mut run = span(lo, hi);
if run.is_empty() {
// Nothing to ask — no library open, or a query that failed. The
// loaded window is a poorer answer than the catalog's and a far
// better one than selecting nothing.
//
// The slice is always in range and needs no guard: `ids.get(row)`
// succeeded, so the window is non-empty and `here` is inside it,
// and `here` is one of the two bounds.
let last = ids.len() - 1;
let lo_row = lo.saturating_sub(offset);
let hi_row = hi.saturating_sub(offset).min(last);
run = ids[lo_row..=hi_row].to_vec();
}
selection.extend(run);
return Press::Applied;
}
if ctrl {
*anchor = Some(here);
// Taking one out waits for the release — see [`Press::Deferred`].
if selection.contains(&id) {
return Press::Deferred(id);
}
selection.insert(id);
return Press::Applied;
}
// 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 Press::Applied;
}
selection.clear();
selection.insert(id);
*anchor = Some(here);
Press::Applied
}
/// 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.cursor(),
));
// Whatever the last press left pending is answered by this one: two
// presses without a click in between means the first never was a tap.
ctl.pending_toggle.set(None);
if let Press::Deferred(id) = apply_press(
&mut ctl.selection.borrow_mut(),
&mut ctl.anchor.borrow_mut(),
ids,
offset,
row,
ctrl,
shift,
&|first, last| ctl.span(first, last),
) {
ctl.pending_toggle.set(Some(id));
}
// 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-CAT-7 | FR-UI-4
/// Finish a press that turned out to be a tap.
///
/// The other half of [`Press::Deferred`]: a ctrl-press on an already-selected
/// photograph leaves it in, because the drag that may follow has to be able to
/// carry it, and this takes it out again once the release has proved there was
/// no drag.
///
/// Called from the click, which Slint reports only for a press that stayed
/// within `tap-slop` of where it landed — so a press that became a drag never
/// reaches here, and a press taken over by the Flickable never reaches here
/// either. Both leave the pending removal to be dropped by the next press.
pub fn commit_press(window: &AppWindow, ctl: &Rc<CollectionsController>, ids: &[ImageId]) {
let Some(id) = ctl.pending_toggle.take() else {
return;
};
ctl.selection.borrow_mut().remove(&id);
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]) {
// The press is being unmade, so what it left for the release to finish is
// unmade with it. Cleared before the early return: a press that changed
// nothing to undo can still have deferred a removal — and a finger that
// held long enough to pick a photograph up before the second one landed
// has to put it back down, or the ring stays open around a cell nobody is
// touching and the grid stays frozen with it.
ctl.pending_toggle.set(None);
window.set_library_held_row(-1);
let Some(undo) = ctl.press_undo.borrow_mut().take() else {
return;
};
let cursor = undo.restore(
&mut ctl.selection.borrow_mut(),
&mut ctl.anchor.borrow_mut(),
);
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 nested = 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);
nested.push(row.collection.parent.is_some());
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;
*ctl.row_nested.borrow_mut() = nested;
window.set_collection_rows(slint::ModelRc::new(slint::VecModel::from(out)));
// The rows are what `sync_reorderable` reads, so it has to be told they
// changed: a collection that gains a child, or one that is deleted out from
// under the scope, changes whether the grid can be reordered without the
// scope itself moving.
sync_reorderable(window);
}
/// 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)
}
/// Hand the composite to the renderer by way of a file.
///
/// **A workaround for a renderer fault, and it should read as one.** The
/// ghost under the cursor is drawn by Slint's own drag overlay, which
/// uploads the image as a texture, draws it, and drops the texture in the
/// same call. With the wgpu FemtoVG renderer that drop is immediate and
/// the draw is deferred to the frame's flush, so by the time the frame is
/// rendered the texture is gone and the renderer binds its placeholder
/// instead — a solid red rectangle the size of the ghost. An image with a
/// cache key is kept in the renderer's texture cache until after the
/// flush; an image built from pixels has none, and only a path gives one.
/// So the composite goes to disk as a PNG and comes back through
/// `load_from_path`. Slint 1.17.1, `draw_image_direct` in the FemtoVG
/// item renderer; the GL FemtoVG renderer is not affected.
///
/// One file per drag, named uniquely: the core caches decoded images by
/// path, so reusing a name would show the previous drag's ghost. The file
/// is removed when the drag ends, or when the next one begins.
///
/// If anything on the way fails the composite is handed over as it is,
/// which on the affected renderer draws the placeholder — no worse than
/// before, and a log line says why.
fn drag_image_via_file(composite: slint::Image) -> slint::Image {
let Some(buffer) = composite.to_rgba8() else {
log::warn!("drag ghost: the composite has no pixels to write");
return composite;
};
log::debug!("drag ghost: {}×{}", buffer.width(), buffer.height());
if buffer.width() == 0 || buffer.height() == 0 {
return composite;
}
match write_drag_image(&buffer) {
Ok(path) => match slint::Image::load_from_path(&path) {
Ok(image) => {
forget_drag_image_file();
*DRAG_IMAGE_FILE.lock().unwrap() = Some(path);
image
}
Err(_) => {
log::warn!("drag ghost: {} did not load back", path.display());
let _ = std::fs::remove_file(&path);
composite
}
},
Err(e) => {
log::warn!("drag ghost: {e}");
composite
}
}
}
/// The file the current drag's ghost is loaded from, if any.
static DRAG_IMAGE_FILE: std::sync::Mutex<Option<std::path::PathBuf>> = std::sync::Mutex::new(None);
fn write_drag_image(
buffer: &slint::SharedPixelBuffer<slint::Rgba8Pixel>,
) -> std::io::Result<std::path::PathBuf> {
use std::sync::atomic::{AtomicU64, Ordering};
static SERIAL: AtomicU64 = AtomicU64::new(0);
let dir = crate::library::scratch_dir();
std::fs::create_dir_all(&dir)?;
let path = dir.join(format!(
"drag-{}-{}.png",
std::process::id(),
SERIAL.fetch_add(1, Ordering::Relaxed)
));
let file = std::fs::File::create(&path)?;
let mut encoder = png::Encoder::new(
std::io::BufWriter::new(file),
buffer.width(),
buffer.height(),
);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
// Fastest: this is a 160px bitmap written once per drag and read once.
encoder.set_compression(png::Compression::Fastest);
let mut writer = encoder.write_header().map_err(std::io::Error::other)?;
writer
.write_image_data(buffer.as_bytes())
.map_err(std::io::Error::other)?;
writer.finish().map_err(std::io::Error::other)?;
Ok(path)
}
fn forget_drag_image_file() {
if let Some(path) = DRAG_IMAGE_FILE.lock().unwrap().take() {
let _ = std::fs::remove_file(path);
}
}
/// 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();
// The anchor is deliberately **not** pushed to the grid.
//
// It used to be, and it was drawn as a thin ring inside the cell. Two
// things were wrong with that. It was the clearest mark on the cell, so it
// read as *the* selection to anyone who had not written it; and an anchor
// outlives a deselection, so the ring sat around the last photograph
// touched while nothing was selected at all — indistinguishable from a cell
// that had stayed behind.
//
// The ordinal still lives in the controller and still decides where a range
// extends from. What is gone is the claim that the user needs to see it:
// the selection bar says "Tap the last photograph" while a range is armed,
// which answers the question the ring was there to answer.
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);
}
/// TRACES: FR-CAT-7
/// Whether what the grid is showing has an order the user can change.
///
/// A single manual collection: not the whole library, not a saved filter whose
/// membership is a rule, and not a set — a set draws its descendants' images
/// too, and two children's `position` columns are unrelated integers that would
/// interleave arbitrarily.
///
/// Read off the tree rows the sidebar already draws rather than asked of the
/// catalog. `smart` and `has_children` are the only two facts it needs and both
/// are in the model already, so this cannot disagree with what the user is
/// looking at, and a scope change costs no extra query.
fn sync_reorderable(window: &AppWindow) {
let id = window.get_collection_selected();
let rows = window.get_collection_rows();
let manual = id > 0
&& (0..rows.row_count())
.filter_map(|i| rows.row_data(i))
.find(|r| r.id == id)
.is_some_and(|r| !r.smart && !r.has_children);
window.set_library_reorderable(manual);
}
/// 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>, row: i32) {
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 };
// TRACES: FR-CAT-7
// The photograph is in the user's hand: the grid draws a ring
// opening around it and stops scrolling underneath it, so the drag
// that may follow cannot be lost to a flick. See `held-row` in
// `library.slint`.
//
// This half happens whether or not selection mode was already on —
// it is the half a *drag* needs, and a drag out of a selection of
// forty starts in a mode that is already on.
w.set_library_held_row(row);
if !ctl_cb.select_mode.get() {
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::Connection>>,
images: &[ImageId],
reload: &Rc<dyn Fn()>,
) {
let Some(conn) = session() else {
window.set_collection_error("Open a library first.".into());
return;
};
let moves = {
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
match crate::trash::plan_trash(cat, &conn.account.root, images) {
Ok(m) => m,
Err(e) => {
window.set_collection_error(format!("planning delete: {e}").into());
return;
}
}
};
if moves.is_empty() {
return;
}
log::info!("moving {} image(s) to the trash", moves.len());
window.set_library_status(format!("Moving {} to the trash…", moves.len()).into());
// The images are leaving the grid; a selection pointing at them would
// survive as a set of ids the user can no longer see.
ctl.clear_selection();
let count = moves.len();
let rx = crate::trash::spawn_move(
conn.clone(),
moves,
crate::trash::Direction::ToTrash,
crate::library::catalog_path(&conn.account),
);
drain_trash(
window.as_weak(),
ctl.clone(),
catalog.clone(),
rx,
reload.clone(),
format!("Moving {count} photograph(s) to the trash"),
);
}
/// TRACES: FR-CAT-15
/// Put trashed images back where they came from.
///
/// The mirror of [`start_trash`], and separate from it rather than a `direction`
/// parameter on one function: the two differ in what they plan, what they report
/// and what they say when the plan comes back empty, and the shared part is the
/// three lines that spawn the worker.
///
/// An image whose origin was never recorded is skipped by
/// [`crate::trash::plan_restore`] rather than guessed at. That can make the plan
/// shorter than the selection, which is why an empty plan is reported here
/// instead of returning silently — the user pressed a button and is owed an
/// answer either way.
fn start_restore(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
session: &Rc<dyn Fn() -> Option<dr_sync::Connection>>,
images: &[ImageId],
reload: &Rc<dyn Fn()>,
) {
let Some(conn) = session() else {
window.set_collection_error("Open a library first.".into());
return;
};
let moves = {
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
match crate::trash::plan_restore(cat, images) {
Ok(m) => m,
Err(e) => {
window.set_collection_error(format!("planning restore: {e}").into());
return;
}
}
};
if moves.is_empty() {
// Said out loud rather than passed over in silence: a button that does
// nothing visible reads as broken, and the reason here is specific.
window.set_collection_error(
"Nothing to restore — no record of where these came from.".into(),
);
return;
}
log::info!("restoring {} image(s) from the trash", moves.len());
window.set_library_status(format!("Restoring {}…", moves.len()).into());
// The images are leaving the trash view, so a selection pointing at them
// would survive as ids the user can no longer see.
ctl.clear_selection();
let count = moves.len();
let rx = crate::trash::spawn_move(
conn.clone(),
moves,
crate::trash::Direction::Restore,
crate::library::catalog_path(&conn.account),
);
drain_trash(
window.as_weak(),
ctl.clone(),
catalog.clone(),
rx,
reload.clone(),
format!("Restoring {count} photograph(s)"),
);
}
/// Drain a trash worker on the UI thread.
///
/// Same shape as the scan and thumbnail drains: an mpsc channel polled by a
/// Slint timer, so nothing blocks the event loop (NFR-P9).
fn drain_trash(
weak: slint::Weak<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.
#[allow(clippy::too_many_arguments)]
pub fn wire<S, R, P, C>(
window: &AppWindow,
ctl: Rc<CollectionsController>,
catalog: Rc<RefCell<Option<Catalog>>>,
on_scope_changed: S,
visible_ids: R,
span_ids: P,
session: C,
) where
S: Fn() + 'static,
R: Fn() -> Vec<ImageId> + 'static,
P: Fn(usize, usize) -> Vec<ImageId> + 'static,
C: Fn() -> Option<dr_sync::Connection> + '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);
// A shift-click asks the catalog what lies between its two ends, and the
// catalog belongs to the grid's controller — see `span_source`.
*ctl.span_source.borrow_mut() = Some(Rc::new(span_ids));
let session: Rc<dyn Fn() -> Option<dr_sync::Connection>> = 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 | FR-CAT-7
// 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 — and the *pick-up*, which is
// what makes the drag reliable.
//
// Armed even when the mode is already on, which it did not used to
// be: there was nothing left for the hold to switch on, so it was
// skipped. But the mode being on is exactly the state a
// multi-image drag starts from, and skipping the hold left that
// drag with no pick-up and no cue — the one gesture that most
// needed both. See `arm_hold`.
arm_hold(&w, &ctl, row);
});
}
// TRACES: FR-UI-2 | FR-UI-4
// The press ended — lifted, or taken by the Flickable when the finger
// travelled. Either way the hold is off.
{
let ctl = ctl.clone();
window.on_library_cell_press_ended(move || {
*ctl.hold_timer.borrow_mut() = None;
});
}
// TRACES: FR-CAT-5
// Put the selection down without leaving select mode.
//
// Distinct from "Done", which does both: after picking forty photographs
// and filing them, the next forty start with the mode already on, and
// making the user re-enter it is asking them to undo a step they did not
// take. It is also the only way back from a mis-tap that is not tapping
// every one of them again.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let visible = visible_ids.clone();
window.on_library_clear_selection(move || {
let Some(w) = weak.upgrade() else { return };
ctl.clear_selection();
sync_selection(&w, &ctl, &visible());
});
}
// TRACES: FR-CAT-7
// Drag a photograph, or a whole selection of them, to a new place in the
// collection being shown.
//
// `collection_members.position` and `Sort::CollectionPosition` have been in
// the catalog since collections were, and until now nothing above it ever
// wrote or read them — the grid ordered by capture time whatever it was
// scoped to. `library::grid_order_for` reads them; this writes them.
//
// The membership is rewritten whole rather than patched. `set_order` sets
// the positions it is given and leaves the rest, so a partial write would
// interleave the moved run with rows whose positions nobody touched — and
// it is read unfiltered for the same reason: the user reorders what they
// can see, and what the filter is hiding keeps its place relative to it.
{
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_reorder_to(move |row, after| {
let Some(w) = weak.upgrade() else { return };
// Both guards belong here rather than only in `library.slint`: the
// scope can change between the drag starting and the drop landing.
if !w.get_library_reorderable() {
return;
}
let Some(scope) = *ctl.scope.borrow() else {
return;
};
let moving = ctl.selected();
if moving.is_empty() {
return;
}
let ids = visible();
let Some(&target) = usize::try_from(row).ok().and_then(|r| ids.get(r)) else {
return;
};
// Dropped on one of its own. There is no gap between a run and
// itself to land in, and rewriting the whole membership to say so
// would be a revision bump for no change.
if moving.contains(&target) {
return;
}
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
let current = match library::read_member_order(cat, scope) {
Ok(current) => current,
Err(e) => {
w.set_collection_error(format!("reading the collection's order: {e}").into());
return;
}
};
let wanted = library::reordered(&current, &moving, target, after);
match coll::set_order(cat.connection(), scope, &wanted) {
Ok(()) => {
w.set_collection_error(slint::SharedString::new());
w.set_library_status(
format!(
"{} photograph{} moved",
moving.len(),
if moving.len() == 1 { "" } else { "s" }
)
.into(),
);
drop(borrow);
// The selection survives. It is what was just moved, and
// dropping it would make a second nudge — which is how a
// drag-to-reorder is usually corrected — start over.
reload();
sync_selection(&w, &ctl, &visible());
}
Err(e) => {
drop(borrow);
w.set_collection_error(format!("reordering: {e}").into());
}
}
});
}
// TRACES: FR-CAT-5 | FR-UI-4
// Everything the grid is showing.
//
// Asked of the catalog through `span`, not read off the loaded window, for
// the reason spelled out in `apply_press`: the window is a hundred cells
// over a library of thousands, and a "select all" that quietly meant
// "select the hundred that happen to be loaded" is a lie the user cannot
// see until the export runs. `library_total` is the count the same scope
// and filter produced, so the run is the whole of what the grid claims.
//
// Replaces rather than adds. "All" is a statement about the result, not an
// increment, and a user who wanted the rest kept would not have reached for
// this.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let visible = visible_ids.clone();
window.on_library_select_all(move || {
let Some(w) = weak.upgrade() else { return };
let ids = visible();
let total = w.get_library_total().max(0) as usize;
if total == 0 {
return;
}
// The loaded window is the fallback, not the answer — the same
// trade the shift-click path makes. An empty span means no library
// or a failed query, and selecting what is on screen beats
// selecting nothing.
let all = match ctl.span(0, total - 1) {
run if run.is_empty() => ids.clone(),
run => run,
};
{
let mut selection = ctl.selection.borrow_mut();
selection.clear();
selection.extend(all);
}
// The first frame becomes the anchor, so a "Select to…" straight
// afterwards describes a range from the top rather than from
// wherever the last individual tap left it — which, after taking
// everything, is not a place the user is still thinking about.
*ctl.anchor.borrow_mut() = Some(0);
sync_selection(&w, &ctl, &ids);
});
}
// TRACES: FR-CAT-5
// A collection holding exactly what is selected.
//
// This was four steps — make a collection, find it in the tree, select the
// photographs again because creating one changed the scope, then add them
// — and the selection is usually made *because* it is going somewhere. One
// press instead.
//
// Created at the top level rather than inside the current scope, unlike
// the tree's "+". A selection can be gathered from anywhere, including
// across collections, so filing it under whichever one happens to be open
// would put it somewhere its contents did not come from.
//
// The name arrives already chosen. It used to be a placeholder, with the
// sidebar's rename field opened straight afterwards to correct it — which
// on a tablet meant opening a field inside a panel that is instantiated but
// not drawn, so it took the on-screen keyboard and could never give it
// back. `library.slint`'s naming sheet asks first, and nothing reaches the
// catalog until it is answered.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
let visible = visible_ids.clone();
let reload = on_scope_changed.clone();
window.on_library_collection_from_selection(move |name| {
let Some(w) = weak.upgrade() else { return };
let images = ctl.selected();
if images.is_empty() {
return;
}
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else {
w.set_collection_error("Open a library first.".into());
return;
};
// The sheet refuses an empty name in two places, so this is
// belt-and-braces rather than a path the UI can reach — but a
// collection with no name at all is unfindable in the tree, and
// falling back to the placeholder is recoverable where an empty
// row is not.
let name = match name.trim() {
"" => unique_name(cat.connection(), None),
typed => typed.to_string(),
};
// The id is not carried out. It used to be, to open the rename
// field on the row it names; now the name is already right, and
// `refresh_tree` redraws the tree from the catalog either way.
let made = coll::create(cat.connection(), &name, None, CollectionKind::Manual)
.and_then(|id| coll::add_images(cat.connection(), id, &images));
match made {
Ok(added) => {
w.set_collection_error(slint::SharedString::new());
w.set_library_status(
format!(
"{added} photograph{} in {name}",
if added == 1 { "" } else { "s" }
)
.into(),
);
refresh_tree(&w, &ctl, cat);
drop(borrow);
// The selection has been filed; holding on to it invites
// the next press acting on photographs the user considers
// dealt with.
ctl.clear_selection();
sync_selection(&w, &ctl, &visible());
// No rename opened here. The name was chosen before the
// collection existed, so there is nothing left to correct —
// and opening the sidebar's field is what stranded the
// keyboard on a tablet.
reload();
}
Err(e) => {
drop(borrow);
w.set_collection_error(format!("making a collection: {e}").into());
}
}
});
}
// TRACES: FR-UI-2 | FR-UI-4
// The button half of selection mode. The long press is faster and this is
// the one that can be found — a gesture with no visible counterpart is a
// feature only its author knows about.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let visible = visible_ids.clone();
window.on_library_toggle_select_mode(move || {
let Some(w) = weak.upgrade() else { return };
let on = !ctl.select_mode.get();
ctl.select_mode.set(on);
w.set_library_select_mode(on);
*ctl.hold_timer.borrow_mut() = None;
// Leaving the mode drops the selection. "Done" reads as finishing
// with these photographs, and a selection that outlived the mode
// would still be acted on by the buttons in the header — which is
// how forty images get exported by a user who thought they had put
// them down.
if !on {
ctl.clear_selection();
sync_selection(&w, &ctl, &visible());
}
});
}
// TRACES: FR-CAT-7 | FR-UI-4
// File the selection in a collection without dragging it there.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
let visible = visible_ids.clone();
let reload = on_scope_changed.clone();
window.on_library_file_in_collection(move |id, moves| {
let Some(w) = weak.upgrade() else { return };
let target = CollectionId(id as u64);
let chosen = ctl.selected();
if chosen.is_empty() {
return;
}
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
// Moving is only a move when there is somewhere to move *from*.
// Unscoped, the sheet does not offer it; this guards the callback
// anyway, and degrades to filing rather than refusing.
let from = if moves { *ctl.scope.borrow() } else { None };
let result = match from {
Some(from) => coll::move_images(cat.connection(), from, target, &chosen),
None => coll::add_images(cat.connection(), target, &chosen),
};
match result {
Ok(filed) => {
w.set_collection_error(slint::SharedString::new());
// The honest count, as the drop reports it: claiming all
// twelve when nine were already there teaches the user to
// distrust every number the app shows them.
let msg = if from.is_some() {
format!("Moved {filed} of {}", chosen.len())
} else if filed == chosen.len() {
format!("Added {filed} to collection")
} else {
format!(
"Added {filed} of {} — the rest were already there",
chosen.len()
)
};
w.set_library_status(msg.into());
refresh_tree(&w, &ctl, cat);
sync_badges(&w, cat, &visible());
// A move changes what the collection on screen holds, so
// the grid has to be reread; filing elsewhere does not, but
// the badge count on every cell just changed and the reread
// is one query.
drop(borrow);
reload();
}
Err(e) => w.set_collection_error(format!("filing: {e}").into()),
}
});
}
// --- drag --------------------------------------------------------------
//
// Slint owns the gesture (see the preamble). What is left here is the
// payload — the image ids the drop will act on — and the spring.
// TRACES: FR-CAT-7
// **What arms the drag, not what it carries.**
//
// `DragArea` declines to start a drag while its `data` is empty, and it
// tests that on every pointer event that reaches it — the first one
// included, which arrives long before any drag. The binding in
// `library.slint` calls this callback, and a binding that calls a callback
// has nothing Slint can invalidate it on: it is evaluated once, when a
// finger first lands on that cell, and cached. `dragging` is empty at that
// moment and stays empty until `drag-started` fires.
//
// So this is answered at the wrong time, and always will be. That is
// harmless only because **`set_user_data` is called unconditionally**: an
// empty `Vec` is still user data, so the transfer is never `is_empty()` and
// the `DragArea` stays armed. Skipping the call for an empty selection —
// which looks like an obvious tidy-up — would disarm every cell a finger
// had ever touched outside a drag, and dragging would simply stop working
// with nothing to see.
//
// What the drop actually reads is `dragging`, set by `drag-started` and
// read back by `dropped-on`. `user_data` rather than plain text so that
// nothing outside the application can interpret it as a paste.
{
let ctl = ctl.clone();
window.on_library_drag_payload(move || {
let carried = ctl.dragging.borrow().clone();
let mut data = slint::DataTransfer::default();
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();
// TRACES: FR-UI-4
// A drag is not a hold, and the timer must not outlive the press
// that armed it. Grabbing a cell and moving inside 450 ms left the
// hold armed underneath the drag, so it fired mid-gesture and put
// the grid into selection mode the user had not asked for — the
// drag finished into a mode that changed what every later tap
// meant. The pinch already cancels for exactly this reason.
*ctl.hold_timer.borrow_mut() = None;
// TRACES: FR-CAT-7
// And this press was not a tap, so it never gets to take anything
// out of the selection — see [`Press::Deferred`]. Dropped here as
// well as on the next press because the drag reads the selection
// one line below, and a removal still pending would be a
// photograph the user can see is selected and the drop would not
// carry.
ctl.pending_toggle.set(None);
// 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(drag_image_via_file(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();
// The grid clears this itself on the cancel that starts a drag;
// this is for the endings that reach no cell — a drop, or a drag
// abandoned over nothing.
w.set_library_held_row(-1);
*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());
forget_drag_image_file();
if landed.is_some() {
let borrow = catalog.borrow();
if let Some(cat) = borrow.as_ref() {
// Counts changed on the target and every ancestor, and the
// dropped images now carry one more collection badge.
refresh_tree(&w, &ctl, cat);
sync_badges(&w, cat, &visible());
}
}
// Put back whatever the spring opened on the way. The collection
// that received the images — and its ancestors — stay open, since
// that is where the user is now working; on an abandoned drag
// `landed` is `None` and everything closes.
collapse_spring_opened(&w, &ctl, &catalog, landed);
// A drop into the collection currently being shown changes what that
// collection holds, so the grid has to be reread.
if landed.is_some() && landed == *ctl.scope.borrow() {
reload();
}
});
}
// --- trash -------------------------------------------------------------
//
// TRACES: FR-CAT-15
// A drop here is a *soft delete*: the file moves to a trash folder on the
// server and the catalog records where it came from. Nothing is destroyed
// until the user empties it, which is a separate, deliberate action.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let session = session.clone();
window.on_trash_dropped(move || {
let Some(w) = weak.upgrade() else { return };
let carried = ctl.dragging.borrow().clone();
if carried.is_empty() {
return;
}
// Recorded like a collection drop, and acted on in `drag-finished`
// for the same reason: the work replaces Slint models that are
// still being walked to deliver this event.
*ctl.trash_requested.borrow_mut() = Some(carried);
let _ = session;
w.set_collection_error(slint::SharedString::new());
});
}
// Restore. Only reachable while the trash is being looked at, and it acts
// on the selection rather than on everything — the trash is where a user
// goes to recover *one* mistake, not usually to undo the lot.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
let session = session.clone();
let reload = on_scope_changed.clone();
window.on_trash_restore(move || {
let Some(w) = weak.upgrade() else { return };
let chosen = ctl.selected();
if chosen.is_empty() {
return;
}
start_restore(&w, &ctl, &catalog, &session, &chosen, &reload);
});
}
// --- trash from the grid ----------------------------------------------
//
// Until now the only route to the trash was dragging onto the sidebar row.
// These two are the direct gestures: the trash target on a cell's rating
// strip, and the `Delete` key.
//
// Both land here rather than in `library_ui` because everything the
// operation needs — the selection, the session closure, `start_trash` and
// its drain — already lives in this module. Reaching them from the grid
// side would mean either duplicating the worker plumbing or moving it, and
// trash is one feature whichever component happens to trigger it.
// The trash glyph on one cell. Acts on that photograph alone: the pointer
// named it, and a click that silently trashed an entire selection would be
// exactly the trap the strip's other targets are laid out to avoid.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
let session = session.clone();
let reload = on_scope_changed.clone();
let visible = visible_ids.clone();
window.on_library_cell_trashed(move |row| {
let Some(w) = weak.upgrade() else { return };
// Resolved through the visible ids rather than the row index alone:
// the grid is a window over the catalog, so a stale index from
// before a scroll would name a different photograph — and here that
// would move the wrong file.
let Some(&id) = visible().get(row as usize) else {
return;
};
start_trash(&w, &ctl, &catalog, &session, &[id], &reload);
});
}
// `Delete` on the selection — the bulk gesture.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
let session = session.clone();
let reload = on_scope_changed.clone();
window.on_library_trash_selection(move || {
let Some(w) = weak.upgrade() else { return };
let chosen = ctl.selected();
if chosen.is_empty() {
// A keystroke that does nothing reads as a broken key, so it
// says why rather than failing silently.
w.set_library_status("Select an image first".into());
return;
}
start_trash(&w, &ctl, &catalog, &session, &chosen, &reload);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
let session = session.clone();
let reload = on_scope_changed.clone();
window.on_trash_empty(move || {
let Some(w) = weak.upgrade() else { return };
let conn = match session() {
Some(c) => c,
None => return,
};
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
// Everything in the trash, with the path and stable id each delete
// needs. Read here rather than in the worker: the catalog is not
// `Send`, and the worker opens its own connection only to write back.
let listed = match dr_catalog::trash::list(cat.connection(), usize::MAX) {
Ok(l) => l,
Err(e) => {
w.set_collection_error(format!("reading trash: {e}").into());
return;
}
};
if listed.is_empty() {
return;
}
let paths: Vec<(ImageId, Option<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(
conn.clone(),
ids,
paths,
crate::library::catalog_path(&conn.account),
crate::library::thumbs_dir(&conn.account),
);
drain_trash(
w.as_weak(),
ctl.clone(),
catalog.clone(),
rx,
reload.clone(),
format!("Deleting {count} photograph(s) permanently"),
);
});
}
// --- tree navigation ---------------------------------------------------
{
let weak = window.as_weak();
let ctl = ctl.clone();
let reload = on_scope_changed.clone();
window.on_collection_select(move |id| {
let Some(w) = weak.upgrade() else { return };
// -1 is the trash. It is not a collection, so it clears `scope`
// rather than setting it — see `viewing_trash`.
ctl.viewing_trash.set(id == -1);
*ctl.scope.borrow_mut() = if id <= 0 {
None
} else {
Some(CollectionId(id as u64))
};
// A selection the user cannot see is one they will act on by
// accident, and the new scope shows different images.
ctl.clear_selection();
let label = if id == -1 {
"Trash".to_string()
} else if id == 0 {
String::new()
} else {
let rows = w.get_collection_rows();
(0..rows.row_count())
.filter_map(|i| rows.row_data(i))
.find(|r| r.id == id)
.map(|r| r.name.to_string())
.unwrap_or_default()
};
w.set_collection_selected(id);
w.set_collection_scope_label(label.into());
sync_reorderable(&w);
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 leaving the header: select the parent, press
// +. The row menu aims the same act at a row instead.
create_child(&w, &ctl, cat, *ctl.scope.borrow());
});
}
// --- 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()),
}
});
}
// TRACES: FR-CAT-7 | FR-UI-2 | FR-UI-3 | FR-UI-4
// The hold on a sidebar row: it arms the drag that rearranges the tree,
// and it opens the row menu. Which one the user gets is decided on release
// by whether they moved.
//
// Both from one gesture because there is only one to spend. A finger has
// no right button, and this tree lives in a Flickable that claims any drag
// beginning inside it — so without a hold, a touch drag on a row is a
// scroll, and arranging the tree is a pointer-only feature. The row lifts
// the moment the timer fires, which is what tells the user the next
// movement will carry the collection rather than scroll past it.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_collection_row_press(move |id, down| {
let Some(w) = weak.upgrade() else { return };
let id = CollectionId(id as u64);
if !down {
// Let go. A drag that began has already been dealt with by the
// drop; a hold that did not move is a request for the menu.
*ctl.row_hold_timer.borrow_mut() = None;
let held = ctl.lifted.borrow_mut().take();
let dragged = ctl.drag_began.replace(false);
w.set_collection_lifted(0);
if let Release::OpenMenu(held) = decide_release(held, dragged) {
w.invoke_collection_menu(held.0 as i32);
}
return;
}
// A drag of this row, if one follows, carries this collection.
ctl.note_row_press(id);
ctl.drag_began.set(false);
let timer = slint::Timer::default();
let weak = w.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 };
// Picked up, not yet acted on. The menu waits for the
// release, because opening it here would put a sheet over
// the tree the user may be about to drag this row across.
*ctl_cb.lifted.borrow_mut() = Some(id);
w.set_collection_lifted(id.0 as i32);
},
);
*ctl.row_hold_timer.borrow_mut() = Some(timer);
});
}
// TRACES: FR-CAT-7 | FR-UI-4
// A row's drag crossed the threshold, or ended.
//
// This is what turns a hold into a rearrangement: once it has fired, the
// release that follows opens no menu. It also decides whether
// "All photographs" will accept the drop, which only makes sense for a
// collection that has a parent to be taken out of.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_collection_drag_active(move |id, active| {
let Some(w) = weak.upgrade() else { return };
let id = CollectionId(id as u64);
if !active {
w.set_collection_root_drop_allowed(false);
w.set_collection_lifted(0);
*ctl.lifted.borrow_mut() = None;
return;
}
// The hold may not have fired — a pointer drag needs no hold — so
// the pick-up is recorded here as well as there, and the row lifts
// either way.
ctl.drag_began.set(true);
ctl.note_row_press(id);
*ctl.lifted.borrow_mut() = Some(id);
w.set_collection_lifted(id.0 as i32);
// The hold has been spent on a drag; nothing may fire behind it.
*ctl.row_hold_timer.borrow_mut() = None;
// Only a nested collection has a top level to be returned to, and
// only a collection drag — never photographs — means anything
// there at all.
let carrying_images = !ctl.dragging.borrow().is_empty();
let nested = ctl
.row_ids
.borrow()
.iter()
.position(|&c| c == id)
.and_then(|row| ctl.row_nested.borrow().get(row).copied())
.unwrap_or(false);
w.set_collection_root_drop_allowed(nested && !carrying_images);
});
}
// TRACES: FR-CAT-7
// Dropped on "All photographs": out to the top level.
//
// The gestural inverse of dropping one row onto another. `can-drop` has
// already refused everything but a nested collection, so reaching here
// means the drop is one — but the payload is taken rather than read, for
// the reason the row's own drop gives: a remembered press must be spent by
// the drop it belongs to, or a later empty drop moves a collection nobody
// touched.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
window.on_collection_dropped_on_root(move || {
let Some(w) = weak.upgrade() else { return };
let source = ctl.dragging_collection.borrow_mut().take();
let Some(source) = source else { return };
// Photographs cannot land here. Guarded as well as refused in
// `can-drop`, so a payload that somehow arrives does nothing
// rather than reparenting whatever row was last pressed.
if !ctl.dragging.borrow().is_empty() {
return;
}
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
let moved = coll::set_parent(cat.connection(), source, None);
match moved {
Ok(()) => {
w.set_collection_error(slint::SharedString::new());
w.set_library_status("Moved to the top level".into());
refresh_tree(&w, &ctl, cat);
log::info!("promoted collection {} by drag", source.0);
}
Err(e) => w.set_collection_error(format!("moving collection: {e}").into()),
}
});
}
// --- the row menu ------------------------------------------------------
//
// Right-click, or a long press on touch. This gesture used to *delete an
// empty collection outright* and refuse with an error message otherwise,
// which was wrong in both directions at once: the destructive half fired
// with no confirmation and nothing on screen said it would, and the
// refusing half meant a collection holding anything could not be deleted
// at all — the user emptied it by hand and then did the same thing.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.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 };
open_row_menu(&w, &ctl, cat, CollectionId(id as u64));
});
}
// Rename, from the menu into the row's own inline field.
//
// The menu closes first and the field opens second, and the order is
// load-bearing for the same reason it is in `collection-new`: the field is
// focused by an `init` on a row, and a row torn down by a model rebuild
// takes the focus with it. Here the tear-down is the sheet's, and a field
// opened behind a sheet that is still up is a field the user cannot see
// they are typing into.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_collection_menu_rename(move || {
let Some(w) = weak.upgrade() else { return };
let Some(id) = *ctl.menu_for.borrow() else {
return;
};
close_row_menu(&w, &ctl);
*ctl.renaming.borrow_mut() = Some(id);
w.set_collection_renaming(id.0 as i32);
});
}
// A new collection nested inside this one.
//
// The `+` in the header already creates inside whatever the grid is scoped
// to, which builds a hierarchy only if you first go and look at the parent.
// This is the same act aimed at a row, so a tree can be built from the tree.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
window.on_collection_menu_new_child(move || {
let Some(w) = weak.upgrade() else { return };
let Some(parent) = *ctl.menu_for.borrow() else {
return;
};
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
close_row_menu(&w, &ctl);
create_child(&w, &ctl, cat, Some(parent));
});
}
// TRACES: FR-CAT-7
// Un-nesting: back out to the top level.
//
// Nesting has had a gesture since collections landed — drag a row onto
// another row — and its inverse had none, in either direction: "All
// photographs" ignores drops on purpose (an image is already in the
// library), and no menu existed to ask. So a collection dragged into
// another was in there permanently, and the only way out was to delete it
// and build it again.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
window.on_collection_menu_promote(move || {
let Some(w) = weak.upgrade() else { return };
let Some(id) = *ctl.menu_for.borrow() else {
return;
};
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
match coll::set_parent(cat.connection(), id, None) {
Ok(()) => {
w.set_collection_error(slint::SharedString::new());
w.set_library_status("Moved to the top level".into());
close_row_menu(&w, &ctl);
refresh_tree(&w, &ctl, cat);
log::info!("promoted collection {} to the top level", id.0);
}
// Left open on failure, showing the collection it failed on:
// closing would leave a red line referring to a row the user
// can no longer tell was the one they aimed at.
Err(e) => w.set_collection_error(format!("moving collection: {e}").into()),
}
});
}
// TRACES: FR-NC-6a
// The offline question, handed to the handler the tray on the row already
// uses. Routed through the window's own callback rather than reaching into
// `library_ui`'s prompt directly: that module owns the transfer state the
// prompt reads, and a second entry point into it is a second place for the
// two to disagree about what is downloading.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_collection_menu_offline(move || {
let Some(w) = weak.upgrade() else { return };
let Some(id) = *ctl.menu_for.borrow() else {
return;
};
close_row_menu(&w, &ctl);
w.invoke_collection_offline_menu(id.0 as i32);
});
}
// TRACES: FR-CAT-7
// Delete, in one press or two — see [`decide_delete`].
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
let reload = on_scope_changed.clone();
let visible = visible_ids.clone();
window.on_collection_menu_delete(move || {
let Some(w) = weak.upgrade() else { return };
let Some(id) = *ctl.menu_for.borrow() else {
return;
};
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
let (holds, children) = direct_holdings(cat.connection(), id).unwrap_or((0, 0));
if decide_delete(holds, children, ctl.menu_confirming.get()) == DeleteStep::Confirm {
// Asked once. The sheet swaps its other actions for the
// warning, so the second press cannot be a mis-aimed first one.
ctl.menu_confirming.set(true);
w.set_collection_menu_confirming(true);
w.set_collection_menu_delete_label("Delete anyway".into());
return;
}
// Hoisted out of the `match` rather than called in the scrutinee:
// the borrow `cat` comes from lives as long as the match does, and
// the arm below has to release it before rereading the grid.
let deleted = coll::delete(cat.connection(), id);
match deleted {
Ok(()) => {
w.set_collection_error(slint::SharedString::new());
close_row_menu(&w, &ctl);
// The grid was showing what no longer exists.
let was_scope = *ctl.scope.borrow() == Some(id);
if was_scope {
*ctl.scope.borrow_mut() = None;
w.set_collection_selected(0);
w.set_collection_scope_label(slint::SharedString::new());
}
refresh_tree(&w, &ctl, cat);
// Named as a membership change, as the removal button is:
// a user who reads this as a delete of their photographs
// will not trust the feature again.
w.set_library_status(if holds > 0 {
format!(
"Deleted the collection; its {holds} photograph(s) stay in the library"
)
.into()
} else {
slint::SharedString::from("Deleted the collection")
});
log::info!("deleted collection {}", id.0);
// The badge on every visible cell just lost a collection,
// and a scope that has gone needs the grid rereading.
let cells = visible();
sync_badges(&w, cat, &cells);
drop(borrow);
if was_scope {
reload();
}
}
Err(e) => w.set_collection_error(format!("deleting: {e}").into()),
}
});
}
// Dismiss. A pending confirmation is backed out of one step rather than
// closing the whole menu: Escape from "are you sure" means "no", and
// taking the sheet away with it would leave the user unsure whether the
// key had cancelled the delete or performed it.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_collection_menu_dismiss(move || {
let Some(w) = weak.upgrade() else { return };
if ctl.menu_confirming.get() {
ctl.menu_confirming.set(false);
w.set_collection_menu_confirming(false);
w.set_collection_menu_delete_label("Delete".into());
return;
}
close_row_menu(&w, &ctl);
});
}
// --- where the selection is filed --------------------------------------
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
window.on_library_open_membership(move || {
let Some(w) = weak.upgrade() else { return };
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
refresh_membership(&w, &ctl, cat);
w.set_membership_open(true);
});
}
{
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_membership_remove(move |id| {
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 };
// Hoisted for the same reason the delete above is: the arm
// releases the catalog borrow before it rereads the grid.
let removed = coll::remove_images(cat.connection(), target, &chosen);
match removed {
Ok(n) => {
w.set_collection_error(slint::SharedString::new());
// The same wording the scoped button uses, and for the same
// reason: this is a membership change, not a delete.
w.set_library_status(
format!("Removed {n} from that collection; still in the library").into(),
);
// The sheet stays open. Taking photographs out of several
// collections is one job, and a sheet that closed after
// each would have to be reopened — with the selection
// still live — to finish it.
refresh_membership(&w, &ctl, cat);
refresh_tree(&w, &ctl, cat);
let cells = visible();
sync_badges(&w, cat, &cells);
// Only the collection on screen changes what the grid
// holds. Removing from another one leaves the grid right,
// and rereading it would scroll the user's place away.
let showing = *ctl.scope.borrow() == Some(target);
drop(borrow);
if showing {
reload();
}
}
Err(e) => w.set_collection_error(format!("removing: {e}").into()),
}
});
}
{
let weak = window.as_weak();
window.on_membership_dismiss(move || {
let Some(w) = weak.upgrade() else { return };
w.set_membership_open(false);
});
}
}
/// TRACES: FR-CAT-7
/// What a collection directly holds: its own member rows, and its own children.
///
/// One query each rather than reusing `deep_count`, which counts descendants'
/// photographs — see [`menu_detail`] for why the menu needs the direct numbers.
fn direct_holdings(
conn: &rusqlite::Connection,
id: CollectionId,
) -> Result<(usize, usize), dr_catalog::CatalogError> {
let holds: i64 = conn.query_row(
"SELECT count(*) FROM collection_members WHERE collection_id = ?1",
[id.0 as i64],
|r| r.get(0),
)?;
let children: i64 = conn.query_row(
"SELECT count(*) FROM collections WHERE parent_id = ?1 AND deleted = 0",
[id.0 as i64],
|r| r.get(0),
)?;
Ok((holds as usize, children as usize))
}
/// TRACES: FR-CAT-7
/// Open the row menu on `id`, or leave it shut if the collection is gone.
///
/// Everything the sheet draws is computed here, in one place, so the menu
/// cannot offer "Move to top level" on a collection already at the top, or a
/// delete warning about children it no longer has. Reopened from scratch after
/// every action for the same reason.
fn open_row_menu(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
catalog: &Catalog,
id: CollectionId,
) {
let conn = catalog.connection();
let row: Option<(String, Option<i64>, i64)> = conn
.query_row(
"SELECT name, parent_id, kind FROM collections WHERE id = ?1 AND deleted = 0",
[id.0 as i64],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.optional()
.unwrap_or(None);
let Some((name, parent, kind)) = row else {
// Deleted underneath us — by a merge, or by the action that just ran.
close_row_menu(window, ctl);
return;
};
let (holds, children) = direct_holdings(conn, id).unwrap_or((0, 0));
*ctl.menu_for.borrow_mut() = Some(id);
ctl.menu_confirming.set(false);
window.set_collection_menu_confirming(false);
window.set_collection_menu_title(name.as_str().into());
window.set_collection_menu_detail(menu_detail(holds, children).into());
window.set_collection_menu_nested(parent.is_some());
window.set_collection_menu_smart(kind == CollectionKind::Smart as i64);
window.set_collection_menu_confirm_detail(delete_warning(&name, holds, children).into());
window.set_collection_menu_delete_label("Delete".into());
// TRACES: FR-NC-6a
// Read off the row the sidebar already drew rather than recomputed: the
// roll-up is one pass over the whole tree, and a menu that computed its own
// could disagree with the tray beside the name it is titled with.
let pinned = ctl
.row_ids
.borrow()
.iter()
.position(|&c| c == id)
.and_then(|row| window.get_collection_rows().row_data(row))
.is_some_and(|r| r.pinned);
window.set_collection_menu_pinned(pinned);
}
/// Shut the row menu, both halves.
///
/// The controller's copy is what the handlers act on and the window property is
/// what draws the sheet; clearing one without the other leaves either an
/// invisible menu that still answers, or a visible one that acts on nothing.
fn close_row_menu(window: &AppWindow, ctl: &Rc<CollectionsController>) {
*ctl.menu_for.borrow_mut() = None;
ctl.menu_confirming.set(false);
window.set_collection_menu_title(slint::SharedString::new());
window.set_collection_menu_confirming(false);
}
/// TRACES: FR-CAT-7 | FR-UI-4
/// Rebuild the membership sheet from the selection as it stands.
///
/// Called on open and after every removal rather than patched: a removal can
/// take the last selected photograph out of a collection, and that row then has
/// to go — a patched model would leave a "Remove" that removes nothing.
fn refresh_membership(window: &AppWindow, ctl: &Rc<CollectionsController>, catalog: &Catalog) {
let chosen = ctl.selected();
let total = chosen.len();
let rows = match coll::membership_of(catalog.connection(), &chosen) {
Ok(r) => r,
Err(e) => {
window.set_collection_error(format!("reading collections: {e}").into());
Vec::new()
}
};
let out: Vec<crate::MembershipRow> = rows
.into_iter()
.map(|m| crate::MembershipRow {
id: m.id.0 as i32,
name: m.name.as_str().into(),
holding: m.holding as i32,
// "1 of 1" is arithmetic nobody asked for; with one photograph
// selected the name already says everything true about it.
detail: if total <= 1 {
slint::SharedString::new()
} else {
format!("{} of {total}", m.holding).into()
},
})
.collect();
window.set_membership_rows(slint::ModelRc::new(slint::VecModel::from(out)));
}
/// 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()))
}
/// TRACES: FR-CAT-7
/// Make a collection inside `parent` and open its name field.
///
/// Shared by the header's `+` and the row menu's "New collection inside",
/// which differ only in where the parent comes from — and must not differ in
/// anything else, or building a tree from the tree would behave unlike
/// building one from the header.
fn create_child(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
catalog: &Catalog,
parent: Option<CollectionId>,
) {
let name = unique_name(catalog.connection(), parent);
match coll::create(catalog.connection(), &name, parent, CollectionKind::Manual) {
Ok(id) => {
window.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(window, ctl, catalog);
*ctl.renaming.borrow_mut() = Some(id);
window.set_collection_renaming(id.0 as i32);
log::info!("created collection {} ({name})", id.0);
}
Err(e) => window.set_collection_error(format!("creating collection: {e}").into()),
}
}
/// 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 {
/// A press and the release that follows it — which is what a click is.
///
/// These tests are about what a *user* sees, and a user only ever presses
/// and lets go. Calling [`apply_press`] alone would drop the half of the
/// policy that waits for the release ([`Press::Deferred`]) and quietly
/// assert the wrong thing about ctrl.
#[allow(clippy::too_many_arguments)]
fn click(
selection: &mut BTreeSet<ImageId>,
anchor: &mut Option<usize>,
ids: &[ImageId],
offset: usize,
row: usize,
ctrl: bool,
shift: bool,
span: &dyn Fn(usize, usize) -> Vec<ImageId>,
) {
if let Press::Deferred(id) =
apply_press(selection, anchor, ids, offset, row, ctrl, shift, span)
{
selection.remove(&id);
}
}
use super::*;
fn ids(n: u64) -> Vec<ImageId> {
(1..=n).map(ImageId).collect()
}
/// The catalog's answer to "what is between these two ordinals".
///
/// Stands in for [`crate::library_ui::LibraryController::ids_in_span`],
/// which does the same thing with a `LIMIT`/`OFFSET`: the whole library is
/// available to the query whatever the grid happens to have loaded, and an
/// ordinal indexes it directly.
fn library(all: &[ImageId]) -> impl Fn(usize, usize) -> Vec<ImageId> + '_ {
move |first, last| {
all.iter()
.copied()
.skip(first)
.take((last + 1).saturating_sub(first))
.collect()
}
}
/// No catalog to ask — what a press sees before a library is open, and if
/// the query fails.
fn unloaded(_first: usize, _last: usize) -> Vec<ImageId> {
Vec::new()
}
#[test]
fn dragging_a_collection_onto_another_moves_it() {
// The gesture the tree rearrangement exists for: no images carried, a
// row remembered from the press, dropped somewhere else.
assert_eq!(
decide_drop(&[], Some(CollectionId(7)), CollectionId(9)),
Drop::Reparent(CollectionId(7))
);
}
#[test]
fn photographs_are_filed_even_when_a_row_was_pressed_first() {
// Clicking a collection and then dragging photographs into another must
// file them, not move the collection that happens to be remembered.
// This is the ordering the whole discrimination rests on.
assert_eq!(
decide_drop(&ids(3), Some(CollectionId(7)), CollectionId(9)),
Drop::FileImages(ids(3))
);
}
#[test]
fn a_collection_dropped_on_itself_does_nothing() {
// The catalog would refuse it as a cycle; catching it here makes the
// commonest slip a no-op rather than an error the user has to read.
assert_eq!(
decide_drop(&[], Some(CollectionId(7)), CollectionId(7)),
Drop::Nothing
);
}
#[test]
fn an_empty_drag_with_nothing_remembered_moves_nothing() {
// A file dragged in from another application lands here with no images
// and no pressed row. It must not disturb the tree.
assert_eq!(decide_drop(&[], None, CollectionId(9)), Drop::Nothing);
}
#[test]
fn a_plain_press_replaces_the_selection() {
let all = ids(5);
let span = library(&all);
let mut sel = BTreeSet::new();
let mut anchor = None;
click(&mut sel, &mut anchor, &all, 0, 0, false, false, &span);
click(&mut sel, &mut anchor, &all, 0, 2, false, false, &span);
assert_eq!(sel.iter().copied().collect::<Vec<_>>(), vec![ImageId(3)]);
}
#[test]
fn ctrl_press_adds_and_then_removes() {
let all = ids(5);
let span = library(&all);
let mut sel = BTreeSet::new();
let mut anchor = None;
click(&mut sel, &mut anchor, &all, 0, 0, false, false, &span);
click(&mut sel, &mut anchor, &all, 0, 3, true, false, &span);
assert_eq!(sel.len(), 2);
// Toggling: a second ctrl-press on the same cell takes it out again.
click(&mut sel, &mut anchor, &all, 0, 3, true, false, &span);
assert_eq!(sel.iter().copied().collect::<Vec<_>>(), vec![ImageId(1)]);
}
#[test]
fn shift_press_extends_a_contiguous_range() {
let all = ids(10);
let span = library(&all);
let mut sel = BTreeSet::new();
let mut anchor = None;
click(&mut sel, &mut anchor, &all, 0, 2, false, false, &span);
click(&mut sel, &mut anchor, &all, 0, 6, false, true, &span);
assert_eq!(sel.len(), 5, "rows 2..=6 inclusive");
assert!(sel.contains(&ImageId(3)) && sel.contains(&ImageId(7)));
}
#[test]
fn shift_extends_backwards_too() {
let all = ids(10);
let span = library(&all);
let mut sel = BTreeSet::new();
let mut anchor = None;
click(&mut sel, &mut anchor, &all, 0, 6, false, false, &span);
click(&mut sel, &mut anchor, &all, 0, 2, false, true, &span);
assert_eq!(sel.len(), 5);
}
#[test]
fn a_second_shift_click_re_describes_the_range_rather_than_adding_to_it() {
// Overshooting and correcting is the common case. Extending without
// clearing would turn the correction into a union, and the user would
// drag cells they believed they had just deselected.
let all = ids(20);
let span = library(&all);
let mut sel = BTreeSet::new();
let mut anchor = None;
click(&mut sel, &mut anchor, &all, 0, 5, false, false, &span);
click(&mut sel, &mut anchor, &all, 0, 15, false, true, &span);
assert_eq!(sel.len(), 11, "rows 5..=15");
// Corrected to a shorter range from the same anchor.
click(&mut sel, &mut anchor, &all, 0, 8, false, true, &span);
assert_eq!(sel.len(), 4, "rows 5..=8, and nothing from the first range");
assert!(!sel.contains(&ImageId(16)), "row 15 is no longer selected");
}
#[test]
fn the_anchor_stays_put_across_shift_clicks() {
// If the anchor moved to each shift-click, a range could only ever be
// grown, never corrected inward.
let all = ids(20);
let span = library(&all);
let mut sel = BTreeSet::new();
let mut anchor = None;
click(&mut sel, &mut anchor, &all, 0, 10, false, false, &span);
click(&mut sel, &mut anchor, &all, 0, 14, false, true, &span);
click(&mut sel, &mut anchor, &all, 0, 12, false, true, &span);
assert_eq!(anchor, Some(10));
assert_eq!(sel.len(), 3, "rows 10..=12");
}
/// TRACES: FR-UI-2 | FR-UI-4
/// Two taps on one cell put it back where it started, and take nothing else.
///
/// This is the behaviour that replaced the double-tap range. That gesture
/// selected everything between the cell and wherever the selection began —
/// silently, with no visible state, from a thing a hand does by accident.
/// "Select to…" does the same job and says so first, so a double tap is now
/// two toggles and nothing more: the only outcome a user can predict from
/// what is on the screen.
#[test]
fn two_taps_on_one_cell_cancel_out_and_take_no_range() {
let all = ids(30);
let span = library(&all);
let mut sel = BTreeSet::new();
let mut anchor = None;
// Selecting began somewhere else, so a range gesture would have had an
// anchor to sweep from.
click(&mut sel, &mut anchor, &all, 0, 4, false, false, &span);
assert_eq!(sel.len(), 1);
tap(&mut sel, &mut anchor, &all, 11);
tap(&mut sel, &mut anchor, &all, 11);
assert_eq!(
sel.iter().copied().collect::<Vec<_>>(),
vec![ImageId(5)],
"a double tap took a run instead of toggling one cell twice"
);
}
/// 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>, all: &[ImageId], row: usize) {
click(sel, anchor, all, 0, row, true, false, &library(all));
}
/// TRACES: FR-CAT-7 | FR-UI-4
/// The bug that made a forty-image drag file one photograph.
///
/// In selection mode every press arrives as a ctrl-press, so grabbing one
/// of the selected cells to drag them all used to *deselect* the cell being
/// grabbed. `drag-started` then saw a press on an image that was not in the
/// selection, concluded that was the gesture, and carried it alone.
#[test]
fn grabbing_a_selected_cell_leaves_the_whole_selection_to_drag() {
let all = ids(20);
let span = library(&all);
let mut sel = BTreeSet::new();
let mut anchor = None;
// Three photographs picked in selection mode, which reports ctrl.
for row in [2, 5, 9] {
click(&mut sel, &mut anchor, &all, 0, row, true, false, &span);
}
assert_eq!(sel.len(), 3);
// The press that begins the drag, on one of the three.
let outcome = apply_press(&mut sel, &mut anchor, &all, 0, 5, true, false, &span);
assert_eq!(
outcome,
Press::Deferred(ImageId(6)),
"the removal has to be handed back, not performed"
);
assert_eq!(
sel.len(),
3,
"the drag reads the selection next, and all three have to still be in it"
);
assert!(sel.contains(&ImageId(6)), "least of all the one being held");
}
/// And the other half: with no drag, the release still takes it out.
///
/// A tap in selection mode has to toggle, or there is no way to correct a
/// mis-tap short of leaving the mode.
#[test]
fn a_tap_on_a_selected_cell_still_takes_it_out() {
let all = ids(20);
let span = library(&all);
let mut sel = BTreeSet::new();
let mut anchor = None;
for row in [2, 5, 9] {
click(&mut sel, &mut anchor, &all, 0, row, true, false, &span);
}
click(&mut sel, &mut anchor, &all, 0, 5, true, false, &span);
assert_eq!(
sel.iter().copied().collect::<Vec<_>>(),
vec![ImageId(3), ImageId(10)],
"the tapped photograph is out and the other two stayed"
);
}
/// The anchor moves on the press whether or not the removal does.
///
/// "Select to…" measures from the anchor, and a run taken after tapping a
/// selected cell has to start where the user last touched — otherwise the
/// gesture sweeps from wherever the anchor happened to be left.
#[test]
fn a_deferred_press_still_moves_the_anchor() {
let all = ids(20);
let span = library(&all);
let mut sel = BTreeSet::new();
let mut anchor = None;
click(&mut sel, &mut anchor, &all, 0, 3, true, false, &span);
let _ = apply_press(&mut sel, &mut anchor, &all, 0, 3, true, false, &span);
assert_eq!(anchor, Some(3));
}
#[test]
fn ctrl_shift_adds_a_second_range_to_the_selection() {
// Picking up a second run without losing the first: the one case where
// a shift-click must not clear.
let all = ids(20);
let span = library(&all);
let mut sel = BTreeSet::new();
let mut anchor = None;
click(&mut sel, &mut anchor, &all, 0, 0, false, false, &span);
click(&mut sel, &mut anchor, &all, 0, 2, false, true, &span);
assert_eq!(sel.len(), 3);
// A new anchor by ctrl-click, then a ctrl+shift range from it.
click(&mut sel, &mut anchor, &all, 0, 10, true, false, &span);
click(&mut sel, &mut anchor, &all, 0, 12, true, true, &span);
assert_eq!(sel.len(), 6, "rows 0..=2 and 10..=12");
assert!(sel.contains(&ImageId(1)) && sel.contains(&ImageId(13)));
}
#[test]
fn a_plain_press_on_a_selected_cell_keeps_the_selection() {
// This is what makes a multi-image drag possible: the press that starts
// the drag must not collapse what it is about to carry.
let all = ids(5);
let span = library(&all);
let mut sel = BTreeSet::new();
let mut anchor = None;
click(&mut sel, &mut anchor, &all, 0, 0, false, false, &span);
click(&mut sel, &mut anchor, &all, 0, 1, true, false, &span);
click(&mut sel, &mut anchor, &all, 0, 2, true, false, &span);
assert_eq!(sel.len(), 3);
// Pressing one of the three to begin a drag.
click(&mut sel, &mut anchor, &all, 0, 1, false, false, &span);
assert_eq!(sel.len(), 3, "the selection survived the press");
}
#[test]
fn a_press_undone_leaves_the_selection_exactly_as_it_was() {
// A pinch opens as one finger on a cell, so by the time it is known to
// be a pinch a photograph has been selected that the user was only
// reaching past. Undoing it has to be *exact*: a cancel that restored
// the selection but left the anchor moved would make the next
// shift-click select a run from a cell nobody pointed at.
let all = ids(6);
let span = library(&all);
let mut sel = BTreeSet::new();
let mut anchor = None;
// A selection built the ordinary way, and the state it leaves behind.
click(&mut sel, &mut anchor, &all, 0, 1, false, false, &span);
click(&mut sel, &mut anchor, &all, 0, 3, true, false, &span);
let before = (sel.clone(), anchor);
// The finger that opens a pinch.
let undo = PressUndo::capture(&sel, anchor, Some(3));
click(&mut sel, &mut anchor, &all, 0, 5, false, false, &span);
assert_ne!(
(sel.clone(), anchor),
before,
"the press has to change something, or this proves nothing"
);
let cursor = undo.restore(&mut sel, &mut anchor);
assert_eq!((sel, anchor), before, "selection and anchor are back");
assert_eq!(cursor, Some(3), "and the keyboard cursor");
}
#[test]
fn a_press_past_the_end_of_the_window_is_ignored() {
// The grid is windowed and a stale row index can arrive after a scrub.
let all = ids(3);
let span = library(&all);
let mut sel = BTreeSet::new();
let mut anchor = None;
click(&mut sel, &mut anchor, &all, 0, 99, false, false, &span);
assert!(sel.is_empty());
}
#[test]
fn an_anchor_survives_the_window_moving_under_it() {
// The grid is a window over the catalog, and the window moves whenever
// the user scrolls. Held as a row, an anchor set before a scroll
// described a range from whatever photograph had since drifted into
// that row — so shift-clicking after scrolling selected a run the user
// never pointed at, silently and with no way to tell.
let all = ids(20);
let span = library(&all);
let first: Vec<_> = all[0..10].to_vec();
let later: Vec<_> = all[4..14].to_vec();
let mut sel = BTreeSet::new();
let mut anchor = None;
// Anchor on the sixth image, in a window starting at the beginning.
click(&mut sel, &mut anchor, &first, 0, 5, false, false, &span);
assert_eq!(anchor, Some(5), "the anchor is an ordinal, not a row");
// The user scrolls — the window now starts four images in — and
// shift-clicks the image at ordinal 10.
click(&mut sel, &mut anchor, &later, 4, 6, false, true, &span);
assert_eq!(sel.len(), 6, "ordinals 5..=10");
assert!(sel.contains(&ImageId(6)), "the anchored image is still in");
assert!(sel.contains(&ImageId(11)), "up to the one shift-clicked");
assert!(!sel.contains(&ImageId(5)), "and nothing before the anchor");
}
#[test]
fn a_range_reaching_outside_the_window_selects_the_whole_run() {
// The bug this exists for: the anchor is far above the loaded window,
// so all but four of the photographs in the range are off screen. They
// are still what the user asked for, and the catalog is what knows
// their ids — reading the range from the window selected the four that
// happened to be loaded and looked as though it had worked.
let all = ids(30);
let span = library(&all);
let window: Vec<_> = all[10..20].to_vec();
let mut sel = BTreeSet::new();
let mut anchor = Some(0);
click(&mut sel, &mut anchor, &window, 10, 3, false, true, &span);
assert_eq!(sel.len(), 14, "ordinals 0..=13, loaded or not");
assert!(
sel.contains(&ImageId(1)),
"the anchored image, never loaded"
);
assert!(sel.contains(&ImageId(14)), "up to the cell pressed");
assert!(!sel.contains(&ImageId(15)), "and no further");
}
#[test]
fn a_range_running_off_the_far_end_is_whole_too() {
// The mirror of the case above, with the anchor *ahead* of the press
// instead of behind it. Neither direction may stop at the window, and
// neither may wrap round to the other end of it.
let all = ids(30);
let span = library(&all);
let window: Vec<_> = all[0..5].to_vec();
let mut sel = BTreeSet::new();
let mut anchor = Some(25);
click(&mut sel, &mut anchor, &window, 0, 2, false, true, &span);
assert_eq!(sel.len(), 24, "ordinals 2..=25");
assert!(sel.contains(&ImageId(3)) && sel.contains(&ImageId(26)));
assert!(
!sel.contains(&ImageId(1)),
"nothing before the pressed cell"
);
}
#[test]
fn a_range_with_no_catalog_to_ask_falls_back_to_the_window() {
// Before a library is open, and if the query fails. What is loaded is
// a poorer answer than the catalog's and a far better one than a
// gesture that appears to do nothing.
let all = ids(30);
let window: Vec<_> = all[10..20].to_vec();
let mut sel = BTreeSet::new();
let mut anchor = Some(0);
click(
&mut sel,
&mut anchor,
&window,
10,
3,
false,
true,
&unloaded,
);
assert_eq!(sel.len(), 4, "ordinals 10..=13, the loaded part of 0..=13");
assert!(sel.contains(&ImageId(11)) && sel.contains(&ImageId(14)));
}
#[test]
fn shift_without_an_anchor_selects_just_the_one() {
let all = ids(5);
let span = library(&all);
let mut sel = BTreeSet::new();
let mut anchor = None;
click(&mut sel, &mut anchor, &all, 0, 3, false, true, &span);
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");
}
#[test]
fn an_empty_collection_is_deleted_without_being_asked_about() {
// There is nothing to warn about, and a dialogue asking "delete this
// empty thing?" is the kind that teaches people to dismiss dialogues
// without reading them — including the one that mattered.
assert_eq!(decide_delete(0, 0, false), DeleteStep::Now);
}
#[test]
fn a_collection_holding_anything_is_asked_about_once() {
assert_eq!(decide_delete(12, 0, false), DeleteStep::Confirm);
assert_eq!(decide_delete(0, 1, false), DeleteStep::Confirm);
// And once asked, it goes.
assert_eq!(decide_delete(12, 3, true), DeleteStep::Now);
}
#[test]
fn a_collection_holding_only_children_still_asks() {
// The old gesture refused on `deep_count > 0`, which counted
// descendants' photographs — so an empty parent of empty children was
// deletable in one press and an empty parent of a *full* child was not
// deletable at all. Both are now the same question, asked once.
assert_eq!(decide_delete(0, 2, false), DeleteStep::Confirm);
}
#[test]
fn the_menu_line_counts_what_delete_acts_on() {
assert_eq!(menu_detail(0, 0), "no photographs");
assert_eq!(menu_detail(1, 0), "1 photograph");
assert_eq!(menu_detail(12, 1), "12 photographs · 1 collection inside");
assert_eq!(menu_detail(0, 3), "no photographs · 3 collections inside");
}
#[test]
fn the_warning_says_the_photographs_survive() {
// The single most likely misreading of "Delete" here. If this clause
// ever goes, a user deletes a collection expecting to lose the
// pictures — and either panics, or does not do it at all.
let w = delete_warning("Iceland", 12, 0);
assert!(w.contains("12 photographs"), "{w}");
assert!(w.contains("stay in your library"), "{w}");
}
#[test]
fn the_warning_says_nested_collections_are_promoted_not_taken() {
// `dr_catalog::collections::delete` promotes children to the deleted
// collection's parent rather than cascading. A warning that did not
// say so would describe a data loss that does not happen.
let w = delete_warning("Trips", 0, 2);
assert!(w.contains("2 collections inside it move up"), "{w}");
// And nothing about photographs surviving, because none were lost.
assert!(!w.contains("stay in your library"), "{w}");
}
#[test]
fn a_hold_that_did_not_move_opens_the_menu() {
assert_eq!(
decide_release(Some(CollectionId(4)), false),
Release::OpenMenu(CollectionId(4))
);
}
#[test]
fn a_hold_that_became_a_drag_opens_nothing() {
// The drop has already done the work. A menu here would put a sheet
// over the tree the user has just finished rearranging — and over the
// row they would have to look at to see whether it worked.
assert_eq!(
decide_release(Some(CollectionId(4)), true),
Release::Nothing
);
}
#[test]
fn an_ordinary_tap_opens_nothing() {
// Nothing was ever picked up, so this is the press that selects a
// collection and scopes the grid. A menu on every tap would make the
// sidebar unusable.
assert_eq!(decide_release(None, false), Release::Nothing);
}
}