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