Merge: collections_ui split into a module directory

This commit is contained in:
2026-09-20 20:30:12 +02:00
11 changed files with 4809 additions and 4602 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+567
View File
@@ -0,0 +1,567 @@
//! Selection and tree state (`CollectionsController`), and the pure
//! decisions a drop, a delete, or a hold-release comes down to.
//!
//! `decide_drop`, `decide_delete` and `decide_release` are kept apart from
//! the callbacks that call them for the reason the crate-level doc gives:
//! the gesture cannot be driven from a test, but the decision can.
use std::cell::RefCell;
use std::collections::BTreeSet;
use std::rc::Rc;
use dr_types::{CollectionId, ImageId};
use super::press::PressUndo;
/// TRACES: FR-CAT-5
/// How a run of grid ordinals is turned into the ids it names.
///
/// See [`CollectionsController::span_source`] for why this is supplied rather
/// than reached for.
type SpanIds = Rc<dyn Fn(usize, usize) -> Vec<ImageId>>;
/// Selection, drag, and tree state for the running window.
///
/// Everything is `RefCell` because Slint callbacks are `Fn`, not `FnMut`, and
/// they all run on the one event-loop thread — the same shape
/// [`crate::library_ui::LibraryController`] uses.
#[derive(Default)]
pub struct CollectionsController {
/// Selected images, by catalog id. A `BTreeSet` rather than a `Vec` so
/// membership tests are cheap during a rubber-band and the order a drop
/// applies in is stable across runs.
pub(super) 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.
pub(super) anchor: RefCell<Option<usize>>,
/// TRACES: FR-UI-2 | FR-UI-4
/// TRACES: FR-CAT-7 | FR-UI-4
/// A photograph the most recent press asked to take *out* of the
/// selection, held until the release says the press was a tap.
///
/// See [`Press::Deferred`] for why removal cannot happen on the press:
/// this is the whole of what stops a drag of forty photographs carrying
/// one. Overwritten by the next press and dropped by the drag that
/// consumes it, so at most one is ever pending.
pub(super) pending_toggle: std::cell::Cell<Option<ImageId>>,
/// TRACES: FR-UI-4
/// What the selection was immediately before the most recent press, so a
/// gesture that turns out not to have been a press can put it back.
///
/// A press has to act immediately — the drag that may follow reads the
/// selection to build its payload, so deciding on release is too late.
/// That is right for a drag and wrong for a pinch, which begins as an
/// ordinary one-finger press and only becomes a pinch when the second
/// finger lands. By then a cell has been selected that the user never
/// meant to touch; they were reaching for the grid with two fingers.
///
/// Cheap to keep: a few hundred ids at most, cloned once per press.
pub(super) press_undo: RefCell<Option<PressUndo>>,
/// Where the keyboard is, as an image ordinal.
///
/// Distinct from the anchor, and it has to be: shift+arrow grows a range
/// *from* the anchor *to* the cursor, so the two are the two ends and
/// cannot be one field. `None` until the user has taken hold of the grid,
/// so the first arrow press starts from what is on screen rather than
/// jumping to the top of the library.
pub(super) 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.
pub(super) 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.
pub(super) 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.
pub(super) 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.
pub(super) row_ids: RefCell<Vec<CollectionId>>,
/// Which rows are saved filters, so a drop onto one is refused *before* the
/// release rather than after.
pub(super) row_smart: RefCell<Vec<bool>>,
/// Which rows have children, so the spring knows there is anything to open.
pub(super) 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.
pub(super) collapsed: RefCell<std::collections::HashSet<CollectionId>>,
/// Which collection scopes the grid. `None` is the whole library.
pub(super) 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.
pub(super) 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".
pub(super) viewing_trash: std::cell::Cell<bool>,
/// The live drag: what it carries. Empty means no drag.
pub(super) 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.
pub(super) dragging_collection: RefCell<Option<CollectionId>>,
/// TRACES: FR-UI-3 | FR-UI-4
/// The collection a hold has picked up, and the timer that picks it up.
///
/// The tree is inside a Flickable, which claims any drag beginning inside
/// it — so with a finger, a drag on a row is a scroll unless something says
/// otherwise first. The hold is that something. It arms the drag *and*
/// opens the row menu, and which of the two the user gets is decided by
/// whether they then moved: the same fork the grid already uses to tell a
/// hold-to-select from a drag-to-file.
pub(super) lifted: RefCell<Option<CollectionId>>,
/// Whether the lifted row's drag actually began. Reset by the press that
/// arms the next one, so a release can tell a rearrangement from a menu.
pub(super) drag_began: std::cell::Cell<bool>,
/// The hold timer for a sidebar row. One, replaced per press, so a press
/// that became a scroll leaves nothing queued to fire over the list the
/// user is now scrolling.
pub(super) row_hold_timer: RefCell<Option<slint::Timer>>,
/// Whether each visible row has a parent, in `row_ids` order — what decides
/// whether "All photographs" lights up as a drop target. Kept beside the
/// rows it indexes rather than queried per drag: `refresh_tree` already has
/// the parent in hand, and a query would answer for a tree that may have
/// been rebuilt since.
pub(super) row_nested: RefCell<Vec<bool>>,
/// The collection the row menu is open on.
///
/// Held here as well as in the window's title property because the two say
/// different things: the property is what the sheet *draws*, and this is
/// what every action *acts on*. A rename committed from the menu changes
/// the first and must not change the second.
pub(super) menu_for: RefCell<Option<CollectionId>>,
/// Whether the menu's delete has been asked once and is waiting to be
/// confirmed.
///
/// A `Cell` rather than a window property alone so the decision is made in
/// Rust: the sheet must not be able to reach the destructive branch by
/// flipping a bit of its own.
pub(super) menu_confirming: std::cell::Cell<bool>,
/// The collection a drag is currently over, by id.
///
/// Only the spring needs this — the *drop* is hit-tested by Slint and
/// arrives with its own id, so nothing here has to remember where the
/// pointer was.
pub(super) 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.
pub(super) 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.
pub(super) 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.
pub(super) 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.
pub(super) 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.
pub(super) dropped_on: RefCell<Option<CollectionId>>,
/// TRACES: FR-CAT-5
/// How a run of grid ordinals is turned into ids.
///
/// Supplied by [`crate::library_ui`] at wiring time, because the catalog,
/// the scope and the rating filter — everything the run depends on — belong
/// to the grid's controller, not to this one. Held as a closure rather than
/// reached for through a handle so the selection rules stay testable with
/// no library open: the tests pass a run that reads a plain slice.
///
/// `None` before wiring, which the caller reads as "nothing to ask" and
/// falls back to the loaded window.
pub(super) span_source: RefCell<Option<SpanIds>>,
/// Where the trash workers report what they are doing.
///
/// Shared with [`crate::library_ui`]: a delete and a scan are two jobs in
/// one list, and the user asking what the application is busy with does not
/// care which module started them.
pub(super) activity: Rc<crate::activity::ActivityLog>,
}
/// What a release over a collection row means.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Drop {
/// File these photographs in the target.
FileImages(Vec<ImageId>),
/// Move this collection under the target.
Reparent(CollectionId),
/// Nothing to do — an empty drag, or a row dropped on itself.
Nothing,
}
/// Decide what a drop on `target` should do.
///
/// Separated from the callback because the gesture cannot be driven from a
/// test — Slint owns it — while this decision is where it can actually go
/// wrong. The order matters: an image drag always fills `carried`, so
/// photographs can never be mistaken for a rearrangement, and only an empty
/// payload consults the row remembered from the press.
pub fn decide_drop(
carried: &[ImageId],
pressed_row: Option<CollectionId>,
target: CollectionId,
) -> Drop {
if !carried.is_empty() {
return Drop::FileImages(carried.to_vec());
}
match pressed_row {
// A collection cannot go inside itself. Caught here as well as in the
// catalog so the common case is a no-op rather than an error message.
Some(source) if source != target => Drop::Reparent(source),
_ => Drop::Nothing,
}
}
/// TRACES: FR-CAT-7
/// What the row menu's Delete should do on this press.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeleteStep {
/// Delete it now. Nothing is lost that the user cannot see is nothing.
Now,
/// Say what goes, and wait to be asked again.
Confirm,
}
/// TRACES: FR-CAT-7
/// Whether deleting this collection needs to be confirmed first.
///
/// The gesture this replaced refused outright whenever the collection held
/// anything, which made a full collection undeletable without emptying it by
/// hand — child by child, since a parent counts its descendants. Refusing is
/// not a safety property; it is the absence of one, because the user goes and
/// does the same thing the long way round.
///
/// So: an empty collection goes on the first press, because there is nothing
/// to warn about and a dialogue asking "delete this empty thing?" is the kind
/// that teaches people to dismiss dialogues. Anything else is asked once.
///
/// Split from the handler because this is the whole of the safety rule, and a
/// handler needs a window to run.
pub fn decide_delete(holds: usize, children: usize, confirmed: bool) -> DeleteStep {
if confirmed || (holds == 0 && children == 0) {
DeleteStep::Now
} else {
DeleteStep::Confirm
}
}
/// TRACES: FR-CAT-7
/// The line under the menu's title: what this collection directly holds.
///
/// Direct members and direct children, not the sidebar's deep count, because
/// this line sits above a Delete — and delete drops *this* collection's member
/// rows while promoting its children rather than taking them. A number here
/// that counted descendants' photographs would be describing something the
/// button below it does not do.
pub fn menu_detail(holds: usize, children: usize) -> String {
let photos = match holds {
0 => "no photographs".to_string(),
1 => "1 photograph".to_string(),
n => format!("{n} photographs"),
};
match children {
0 => photos,
1 => format!("{photos} · 1 collection inside"),
n => format!("{photos} · {n} collections inside"),
}
}
/// TRACES: FR-CAT-7
/// What deleting this collection actually does, in full.
///
/// Every clause here is one a user has got wrong about a collections feature
/// before: that deleting a collection deletes the photographs (it does not —
/// membership is a join table), and that deleting a parent takes the
/// collections nested in it (it does not — [`dr_catalog::collections::delete`]
/// promotes them, because losing a subtree because its container was tidied
/// away is not recoverable).
pub fn delete_warning(name: &str, holds: usize, children: usize) -> String {
let mut out = format!("Deleting “{name}” ");
match (holds, children) {
(0, _) => out.push_str("removes it from the sidebar."),
(1, _) => out.push_str("takes 1 photograph out of it."),
(n, _) => out.push_str(&format!("takes {n} photographs out of it.")),
}
if holds > 0 {
out.push_str(" They stay in your library and in every other collection they are in.");
}
match children {
0 => {}
1 => {
out.push_str(" The collection inside it moves up one level rather than going with it.")
}
n => out.push_str(&format!(
" The {n} collections inside it move up one level rather than going with it."
)),
}
out
}
/// TRACES: FR-UI-3 | FR-UI-4
/// What letting go of a held collection row means.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Release {
/// The hold fired and nothing moved: the user is asking what can be done
/// to this collection.
OpenMenu(CollectionId),
/// Either the hold never fired — an ordinary tap, which selects — or it
/// did and the row was then dragged, in which case the drop has already
/// done the work.
Nothing,
}
/// TRACES: FR-UI-3 | FR-UI-4
/// Decide what a release does, from what the press turned into.
///
/// The whole of the fork that lets one gesture mean two things, and the reason
/// it is worth a test: get it wrong in the direction of `OpenMenu` and every
/// rearrangement ends with a sheet over the tree the user just tidied; get it
/// wrong the other way and the menu is unreachable with a finger.
pub fn decide_release(held: Option<CollectionId>, dragged: bool) -> Release {
match held {
Some(id) if !dragged => Release::OpenMenu(id),
_ => Release::Nothing,
}
}
impl CollectionsController {
pub fn new(activity: Rc<crate::activity::ActivityLog>) -> Rc<Self> {
Rc::new(Self {
activity,
..Default::default()
})
}
/// Remember which row was pressed, so a drag that follows knows what it is
/// carrying.
///
/// Recorded on the press rather than at the drag's start because Slint
/// builds the payload through a `pure` binding, which must not have side
/// effects — and the drop callback is handed only the *target's* id, so the
/// source has to be remembered somewhere.
pub fn note_row_press(&self, id: CollectionId) {
*self.dragging_collection.borrow_mut() = Some(id);
}
/// Which collection the grid is scoped to, for [`crate::library_ui`] to
/// build its query from.
pub fn scope(&self) -> Option<CollectionId> {
*self.scope.borrow()
}
/// Whether the grid should be showing the trash.
pub fn viewing_trash(&self) -> bool {
self.viewing_trash.get()
}
/// Whether the gesture in progress began with ctrl or shift held.
///
/// A modified click selects and nothing more — opening the image as well
/// would eject the user from the grid they are selecting in.
pub fn press_was_modified(&self) -> bool {
self.modified_press.get()
}
/// Selected image ids, in a stable order.
pub fn selected(&self) -> Vec<ImageId> {
self.selection.borrow().iter().copied().collect()
}
/// TRACES: FR-CAT-5
/// The ids of grid rows `first..=last`, in the order the grid shows them.
///
/// Empty when nothing has been wired in — see [`Self::span_source`].
pub(super) fn span(&self, first: usize, last: usize) -> Vec<ImageId> {
let source = self.span_source.borrow().clone();
source.map(|f| f(first, last)).unwrap_or_default()
}
/// Drop the selection — after a scrub, or when the scope changes.
///
/// Selection is by id and survives a window change, but a selection the
/// user cannot see is a selection they will act on by accident. Clearing on
/// a deliberate navigation is the safer of the two behaviours.
pub fn clear_selection(&self) {
self.selection.borrow_mut().clear();
*self.anchor.borrow_mut() = None;
self.cursor.set(None);
}
/// Where the keyboard cursor is, as an image ordinal.
pub fn cursor(&self) -> Option<usize> {
self.cursor.get()
}
pub fn set_cursor(&self, at: Option<usize>) {
self.cursor.set(at);
}
}
#[cfg(test)]
mod tests {
use crate::collections_ui::test_support::*;
use super::*;
#[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 an_empty_collection_is_deleted_without_being_asked_about() {
// There is nothing to warn about, and a dialogue asking "delete this
// empty thing?" is the kind that teaches people to dismiss dialogues
// without reading them — including the one that mattered.
assert_eq!(decide_delete(0, 0, false), DeleteStep::Now);
}
#[test]
fn a_collection_holding_anything_is_asked_about_once() {
assert_eq!(decide_delete(12, 0, false), DeleteStep::Confirm);
assert_eq!(decide_delete(0, 1, false), DeleteStep::Confirm);
// And once asked, it goes.
assert_eq!(decide_delete(12, 3, true), DeleteStep::Now);
}
#[test]
fn a_collection_holding_only_children_still_asks() {
// The old gesture refused on `deep_count > 0`, which counted
// descendants' photographs — so an empty parent of empty children was
// deletable in one press and an empty parent of a *full* child was not
// deletable at all. Both are now the same question, asked once.
assert_eq!(decide_delete(0, 2, false), DeleteStep::Confirm);
}
#[test]
fn the_menu_line_counts_what_delete_acts_on() {
assert_eq!(menu_detail(0, 0), "no photographs");
assert_eq!(menu_detail(1, 0), "1 photograph");
assert_eq!(menu_detail(12, 1), "12 photographs · 1 collection inside");
assert_eq!(menu_detail(0, 3), "no photographs · 3 collections inside");
}
#[test]
fn the_warning_says_the_photographs_survive() {
// The single most likely misreading of "Delete" here. If this clause
// ever goes, a user deletes a collection expecting to lose the
// pictures — and either panics, or does not do it at all.
let w = delete_warning("Iceland", 12, 0);
assert!(w.contains("12 photographs"), "{w}");
assert!(w.contains("stay in your library"), "{w}");
}
#[test]
fn the_warning_says_nested_collections_are_promoted_not_taken() {
// `dr_catalog::collections::delete` promotes children to the deleted
// collection's parent rather than cascading. A warning that did not
// say so would describe a data loss that does not happen.
let w = delete_warning("Trips", 0, 2);
assert!(w.contains("2 collections inside it move up"), "{w}");
// And nothing about photographs surviving, because none were lost.
assert!(!w.contains("stay in your library"), "{w}");
}
#[test]
fn a_hold_that_did_not_move_opens_the_menu() {
assert_eq!(
decide_release(Some(CollectionId(4)), false),
Release::OpenMenu(CollectionId(4))
);
}
#[test]
fn a_hold_that_became_a_drag_opens_nothing() {
// The drop has already done the work. A menu here would put a sheet
// over the tree the user has just finished rearranging — and over the
// row they would have to look at to see whether it worked.
assert_eq!(
decide_release(Some(CollectionId(4)), true),
Release::Nothing
);
}
#[test]
fn an_ordinary_tap_opens_nothing() {
// Nothing was ever picked up, so this is the press that selects a
// collection and scopes the grid. A menu on every tap would make the
// sidebar unusable.
assert_eq!(decide_release(None, false), Release::Nothing);
}
}
+495
View File
@@ -0,0 +1,495 @@
//! The drag: the bitmap under the cursor, the press-to-selection-mode
//! hold, and the spring that opens a collapsed collection a drag is
//! dwelling over.
use std::cell::RefCell;
use std::rc::Rc;
use dr_catalog::collections::{self as coll};
use dr_catalog::Catalog;
use dr_types::CollectionId;
use slint::ComponentHandle;
use crate::AppWindow;
use super::controller::CollectionsController;
use super::tree_sync::refresh_tree;
/// 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.
pub(super) 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,
};
}
}
}
/// 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.
pub(super) fn arm_hold(window: &AppWindow, ctl: &Rc<CollectionsController>, row: i32) {
let timer = slint::Timer::default();
let weak = window.as_weak();
let ctl_cb = ctl.clone();
timer.start(
slint::TimerMode::SingleShot,
std::time::Duration::from_millis(HOLD_DELAY_MS),
move || {
let Some(w) = weak.upgrade() else { return };
// TRACES: FR-CAT-7
// The photograph is in the user's hand: the grid draws a ring
// opening around it and stops scrolling underneath it, so the drag
// that may follow cannot be lost to a flick. See `held-row` in
// `library.slint`.
//
// This half happens whether or not selection mode was already on —
// it is the half a *drag* needs, and a drag out of a selection of
// forty starts in a mode that is already on.
w.set_library_held_row(row);
if !ctl_cb.select_mode.get() {
ctl_cb.select_mode.set(true);
w.set_library_select_mode(true);
}
// The release that follows this hold must not also open the image:
// the user asked for a selection and would land in develop instead.
// Reusing `modified_press` rather than adding a second flag — the
// click already consults it, and it means exactly this: "the press
// was a selection gesture".
ctl_cb.modified_press.set(true);
},
);
*ctl.hold_timer.borrow_mut() = Some(timer);
}
/// Whether hovering this row should schedule a spring expansion.
///
/// Pure so the rule is testable: only a *collapsed parent* has anything to
/// open. A leaf would flash a pointless rebuild, and one already expanded is
/// where the user can already see the children.
fn should_spring(
row: Option<usize>,
row_ids: &[CollectionId],
row_has_children: &[bool],
collapsed: &std::collections::HashSet<CollectionId>,
) -> Option<CollectionId> {
let row = row?;
let &id = row_ids.get(row)?;
let has_children = row_has_children.get(row).copied().unwrap_or(false);
(has_children && collapsed.contains(&id)).then_some(id)
}
/// Start (or restart) the dwell timer that opens a collapsed collection.
///
/// Called on every hover change during a drag. Restarting on each change is
/// what makes the dwell a dwell: moving to another row cancels the pending
/// expansion instead of queueing a second one.
pub(super) 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.
pub(super) 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);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// 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"
);
}
}
+74
View File
@@ -0,0 +1,74 @@
//! 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.
//!
//! # Split into one module per area (docs/dev/code-health.md CH-1)
//!
//! `controller` holds the running state and the pure drop/delete/release
//! decisions; `press` the click-and-release selection policy; `tree_sync`
//! rebuilding the sidebar and pushing catalog state into the grid; `drag` the
//! cursor bitmap and the spring; `trash` the soft delete; `wiring_grid` and
//! `wiring_tree` the callbacks, split in two because together they were the
//! largest single piece; and `rename_menu` creating, naming and the row's
//! context menu. `mod.rs` re-exports what the rest of the crate calls by
//! `collections_ui::` path, so nothing outside this module needs to change.
mod controller;
mod drag;
mod press;
mod rename_menu;
mod trash;
mod tree_sync;
mod wiring_grid;
mod wiring_tree;
pub use controller::CollectionsController;
pub use press::{cancel_press, commit_press, select_row};
pub use tree_sync::{refresh_tree, sync_badges, sync_selection};
pub use wiring_grid::wire;
/// Test-only helpers shared by more than one of this module's submodules.
///
/// `ids` was defined once in the pre-split file and called from tests all
/// over it — both the pure `decide_drop` tests in `controller` and the press
/// tests in `press`. Splitting the tests with the code they exercise left it
/// needed in both, so it lives here once instead of being copied.
#[cfg(test)]
pub(super) mod test_support {
use dr_types::ImageId;
pub(super) fn ids(n: u64) -> Vec<ImageId> {
(1..=n).map(ImageId).collect()
}
}
+763
View File
@@ -0,0 +1,763 @@
//! A press, and the release that follows it: what selects, what waits for
//! the tap that confirms it, and how a gesture that turns out not to have
//! been a press is undone.
use std::collections::BTreeSet;
use std::rc::Rc;
use dr_types::ImageId;
use crate::AppWindow;
use super::controller::CollectionsController;
use super::tree_sync::sync_selection;
/// The selection as it stood before a press, for [`cancel_press`].
pub(super) struct PressUndo {
selection: BTreeSet<ImageId>,
anchor: Option<usize>,
cursor: Option<usize>,
}
impl PressUndo {
/// Everything [`select_row`] is about to change.
///
/// The three are the whole of what a press touches, which is the property
/// the restore depends on and the reason it is worth a test of its own: a
/// press that grew a fourth piece of state would leave that piece behind
/// after a cancel, silently.
fn capture(
selection: &BTreeSet<ImageId>,
anchor: Option<usize>,
cursor: Option<usize>,
) -> Self {
Self {
selection: selection.clone(),
anchor,
cursor,
}
}
/// Put it all back. Returns the one the caller holds in a `Cell`.
fn restore(
self,
selection: &mut BTreeSet<ImageId>,
anchor: &mut Option<usize>,
) -> Option<usize> {
*selection = self.selection;
*anchor = self.anchor;
self.cursor
}
}
/// TRACES: FR-CAT-7 | FR-UI-4
/// What a press did, and what is left for the release to do.
///
/// Only one press has anything left over, and it is the one every multi-image
/// drag depends on — see [`Press::Deferred`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[must_use]
pub enum Press {
/// The selection is already what this press means. Nothing to finish.
Applied,
/// **Taking a photograph out of the selection waits for the release.**
///
/// Putting one *in* has to happen on the press: the drag that may follow
/// reads the selection to decide what it carries, and by the time the
/// finger lifts it is over the sidebar. Taking one out is the opposite —
/// nothing between the press and the release needs the image gone, and one
/// thing very much needs it to stay.
///
/// A plain press has said so since selection was written: pressing an
/// already-selected cell leaves the selection alone. Ctrl did not, and on a
/// tablet *every* press is a ctrl-press — that is what selection mode is.
/// So grabbing one of forty selected photographs deselected it on the way
/// down; the drag that followed found the cell under the finger no longer
/// in the selection, took that to mean an unselected image was being
/// dragged, and carried it alone. Forty photographs became one, and the
/// only clue was the grabbed cell's ring blinking out.
///
/// So the removal is handed back for the *click* to apply, and a click
/// fires only for a press that stayed put — never for one that became a
/// drag. See `tap-slop` in `library.slint`.
Deferred(ImageId),
}
/// Apply a press to the selection.
///
/// Split from the callback so the policy is testable without a window: this is
/// the part a user notices being wrong.
///
/// - **plain** — replace the selection with this one image
/// - **ctrl** — add this image to the selection, keeping the rest, and move the
/// anchor here; taking one *out* is [deferred](Press::Deferred) to the release
/// - **shift** — select the range from the anchor to here, *replacing* what was
/// selected; the anchor stays put, so an overshoot is corrected by
/// shift-clicking the right cell rather than starting again
/// - **ctrl+shift** — the same range, *added* to the selection, for picking up a
/// second run without losing the first
///
/// A press on an image that is *already* selected never removes it here —
/// plainly or with ctrl. That is what makes dragging a multi-selection possible
/// at all: the press that begins the drag would otherwise take the grabbed
/// photograph out from under it.
///
/// `ids` is the **loaded window**, `offset` where it starts in the library and
/// `row` a position within it; the anchor is kept as `offset + row`, an
/// ordinal that still names the same photograph after the window has moved.
///
/// `span` turns a run of ordinals into the ids it names — the catalog's job,
/// since the run is mostly not loaded. Passed in rather than reached for so
/// this stays a pure function of what it is given.
#[allow(clippy::too_many_arguments)]
pub fn apply_press(
selection: &mut BTreeSet<ImageId>,
anchor: &mut Option<usize>,
ids: &[ImageId],
offset: usize,
row: usize,
ctrl: bool,
shift: bool,
span: &dyn Fn(usize, usize) -> Vec<ImageId>,
) -> Press {
let Some(&id) = ids.get(row) else {
return Press::Applied;
};
let here = offset + row;
if shift {
let Some(from) = *anchor else {
// No anchor to extend from: behave like a plain click and become
// the anchor, so the *next* shift-click has a range to describe.
selection.clear();
selection.insert(id);
*anchor = Some(here);
return Press::Applied;
};
// The anchor deliberately does **not** move. Shift-clicking again
// re-describes the range from the same origin, so a user who overshoots
// corrects by shift-clicking the right cell rather than starting over.
// That also means the previous range must be cleared first — extending
// without clearing turns a correction into a union, and the user ends up
// dragging cells they thought they had deselected.
//
// Ctrl+shift is the exception: it *adds* a range to what is already
// selected, which is how a second run is picked up without losing the
// first.
if !ctrl {
selection.clear();
}
let (lo, hi) = if from <= here {
(from, here)
} else {
(here, from)
};
// The run is described in ordinals and resolved by the catalog, which
// is the only thing that knows what lies between them. Selection is by
// id, and an image outside the loaded window has no id here — so an
// earlier version truncated the run to what was on screen, and
// shift-clicking two ends of a morning selected the dozen cells that
// happened to be loaded. The user cannot see that they did not get
// what they asked for until the drop files a dozen photographs
// instead of two hundred.
let mut run = span(lo, hi);
if run.is_empty() {
// Nothing to ask — no library open, or a query that failed. The
// loaded window is a poorer answer than the catalog's and a far
// better one than selecting nothing.
//
// The slice is always in range and needs no guard: `ids.get(row)`
// succeeded, so the window is non-empty and `here` is inside it,
// and `here` is one of the two bounds.
let last = ids.len() - 1;
let lo_row = lo.saturating_sub(offset);
let hi_row = hi.saturating_sub(offset).min(last);
run = ids[lo_row..=hi_row].to_vec();
}
selection.extend(run);
return Press::Applied;
}
if ctrl {
*anchor = Some(here);
// Taking one out waits for the release — see [`Press::Deferred`].
if selection.contains(&id) {
return Press::Deferred(id);
}
selection.insert(id);
return Press::Applied;
}
// Plain press on something already selected: leave it. The drag that may
// follow carries the whole selection, and collapsing it here would make a
// multi-image drag impossible to start.
if selection.contains(&id) {
*anchor = Some(here);
return Press::Applied;
}
selection.clear();
selection.insert(id);
*anchor = Some(here);
Press::Applied
}
/// Apply a press and push the result into the grid — the whole of what a
/// click, or an arrow key, does to the selection.
///
/// Shared so the keyboard and the pointer cannot drift: they are the same
/// gesture reached two ways, and the moment one of them grew its own copy of
/// the selection policy, "click here, shift+down twice" would stop meaning
/// what "click here, shift-click there" means.
pub fn select_row(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
ids: &[ImageId],
offset: usize,
row: usize,
ctrl: bool,
shift: bool,
) {
// Before anything moves — see `press_undo`. A press that turns out to be
// the opening finger of a pinch is undone from here.
*ctl.press_undo.borrow_mut() = Some(PressUndo::capture(
&ctl.selection.borrow(),
*ctl.anchor.borrow(),
ctl.cursor(),
));
// Whatever the last press left pending is answered by this one: two
// presses without a click in between means the first never was a tap.
ctl.pending_toggle.set(None);
if let Press::Deferred(id) = apply_press(
&mut ctl.selection.borrow_mut(),
&mut ctl.anchor.borrow_mut(),
ids,
offset,
row,
ctrl,
shift,
&|first, last| ctl.span(first, last),
) {
ctl.pending_toggle.set(Some(id));
}
// The cursor follows the press, so an arrow key after a click continues
// from the cell that was clicked rather than from wherever the keyboard
// was last.
ctl.set_cursor(Some(offset + row));
sync_selection(window, ctl, ids);
}
/// TRACES: FR-CAT-7 | FR-UI-4
/// Finish a press that turned out to be a tap.
///
/// The other half of [`Press::Deferred`]: a ctrl-press on an already-selected
/// photograph leaves it in, because the drag that may follow has to be able to
/// carry it, and this takes it out again once the release has proved there was
/// no drag.
///
/// Called from the click, which Slint reports only for a press that stayed
/// within `tap-slop` of where it landed — so a press that became a drag never
/// reaches here, and a press taken over by the Flickable never reaches here
/// either. Both leave the pending removal to be dropped by the next press.
pub fn commit_press(window: &AppWindow, ctl: &Rc<CollectionsController>, ids: &[ImageId]) {
let Some(id) = ctl.pending_toggle.take() else {
return;
};
ctl.selection.borrow_mut().remove(&id);
sync_selection(window, ctl, ids);
}
/// TRACES: FR-UI-4
/// Put the selection back as it was before the most recent press.
///
/// For the gesture that begins as a press and turns out to be something else.
/// A pinch is the case that matters: it starts as one finger on a cell, which
/// selects it, and only becomes a pinch when the second finger lands — by
/// which point the user has selected a photograph they were not reaching for.
///
/// Idempotent, and a no-op when there is nothing to undo, so it is safe to
/// call on every gesture start rather than only on the ones that need it.
pub fn cancel_press(window: &AppWindow, ctl: &Rc<CollectionsController>, ids: &[ImageId]) {
// The press is being unmade, so what it left for the release to finish is
// unmade with it. Cleared before the early return: a press that changed
// nothing to undo can still have deferred a removal — and a finger that
// held long enough to pick a photograph up before the second one landed
// has to put it back down, or the ring stays open around a cell nobody is
// touching and the grid stays frozen with it.
ctl.pending_toggle.set(None);
window.set_library_held_row(-1);
let Some(undo) = ctl.press_undo.borrow_mut().take() else {
return;
};
let cursor = undo.restore(
&mut ctl.selection.borrow_mut(),
&mut ctl.anchor.borrow_mut(),
);
ctl.set_cursor(cursor);
sync_selection(window, ctl, ids);
}
#[cfg(test)]
mod tests {
use crate::collections_ui::test_support::*;
use super::*;
/// A press and the release that follows it — which is what a click is.
///
/// These tests are about what a *user* sees, and a user only ever presses
/// and lets go. Calling [`apply_press`] alone would drop the half of the
/// policy that waits for the release ([`Press::Deferred`]) and quietly
/// assert the wrong thing about ctrl.
#[allow(clippy::too_many_arguments)]
fn click(
selection: &mut BTreeSet<ImageId>,
anchor: &mut Option<usize>,
ids: &[ImageId],
offset: usize,
row: usize,
ctrl: bool,
shift: bool,
span: &dyn Fn(usize, usize) -> Vec<ImageId>,
) {
if let Press::Deferred(id) =
apply_press(selection, anchor, ids, offset, row, ctrl, shift, span)
{
selection.remove(&id);
}
}
/// The catalog's answer to "what is between these two ordinals".
///
/// Stands in for [`crate::library_ui::LibraryController::ids_in_span`],
/// which does the same thing with a `LIMIT`/`OFFSET`: the whole library is
/// available to the query whatever the grid happens to have loaded, and an
/// ordinal indexes it directly.
fn library(all: &[ImageId]) -> impl Fn(usize, usize) -> Vec<ImageId> + '_ {
move |first, last| {
all.iter()
.copied()
.skip(first)
.take((last + 1).saturating_sub(first))
.collect()
}
}
/// No catalog to ask — what a press sees before a library is open, and if
/// the query fails.
fn unloaded(_first: usize, _last: usize) -> Vec<ImageId> {
Vec::new()
}
#[test]
fn a_plain_press_replaces_the_selection() {
let all = ids(5);
let span = library(&all);
let mut sel = BTreeSet::new();
let mut anchor = None;
click(&mut sel, &mut anchor, &all, 0, 0, false, false, &span);
click(&mut sel, &mut anchor, &all, 0, 2, false, false, &span);
assert_eq!(sel.iter().copied().collect::<Vec<_>>(), vec![ImageId(3)]);
}
#[test]
fn ctrl_press_adds_and_then_removes() {
let all = ids(5);
let span = library(&all);
let mut sel = BTreeSet::new();
let mut anchor = None;
click(&mut sel, &mut anchor, &all, 0, 0, false, false, &span);
click(&mut sel, &mut anchor, &all, 0, 3, true, false, &span);
assert_eq!(sel.len(), 2);
// Toggling: a second ctrl-press on the same cell takes it out again.
click(&mut sel, &mut anchor, &all, 0, 3, true, false, &span);
assert_eq!(sel.iter().copied().collect::<Vec<_>>(), vec![ImageId(1)]);
}
#[test]
fn shift_press_extends_a_contiguous_range() {
let all = ids(10);
let span = library(&all);
let mut sel = BTreeSet::new();
let mut anchor = None;
click(&mut sel, &mut anchor, &all, 0, 2, false, false, &span);
click(&mut sel, &mut anchor, &all, 0, 6, false, true, &span);
assert_eq!(sel.len(), 5, "rows 2..=6 inclusive");
assert!(sel.contains(&ImageId(3)) && sel.contains(&ImageId(7)));
}
#[test]
fn shift_extends_backwards_too() {
let all = ids(10);
let span = library(&all);
let mut sel = BTreeSet::new();
let mut anchor = None;
click(&mut sel, &mut anchor, &all, 0, 6, false, false, &span);
click(&mut sel, &mut anchor, &all, 0, 2, false, true, &span);
assert_eq!(sel.len(), 5);
}
#[test]
fn a_second_shift_click_re_describes_the_range_rather_than_adding_to_it() {
// Overshooting and correcting is the common case. Extending without
// clearing would turn the correction into a union, and the user would
// drag cells they believed they had just deselected.
let all = ids(20);
let span = library(&all);
let mut sel = BTreeSet::new();
let mut anchor = None;
click(&mut sel, &mut anchor, &all, 0, 5, false, false, &span);
click(&mut sel, &mut anchor, &all, 0, 15, false, true, &span);
assert_eq!(sel.len(), 11, "rows 5..=15");
// Corrected to a shorter range from the same anchor.
click(&mut sel, &mut anchor, &all, 0, 8, false, true, &span);
assert_eq!(sel.len(), 4, "rows 5..=8, and nothing from the first range");
assert!(!sel.contains(&ImageId(16)), "row 15 is no longer selected");
}
#[test]
fn the_anchor_stays_put_across_shift_clicks() {
// If the anchor moved to each shift-click, a range could only ever be
// grown, never corrected inward.
let all = ids(20);
let span = library(&all);
let mut sel = BTreeSet::new();
let mut anchor = None;
click(&mut sel, &mut anchor, &all, 0, 10, false, false, &span);
click(&mut sel, &mut anchor, &all, 0, 14, false, true, &span);
click(&mut sel, &mut anchor, &all, 0, 12, false, true, &span);
assert_eq!(anchor, Some(10));
assert_eq!(sel.len(), 3, "rows 10..=12");
}
/// TRACES: FR-UI-2 | FR-UI-4
/// Two taps on one cell put it back where it started, and take nothing else.
///
/// This is the behaviour that replaced the double-tap range. That gesture
/// selected everything between the cell and wherever the selection began —
/// silently, with no visible state, from a thing a hand does by accident.
/// "Select to…" does the same job and says so first, so a double tap is now
/// two toggles and nothing more: the only outcome a user can predict from
/// what is on the screen.
#[test]
fn two_taps_on_one_cell_cancel_out_and_take_no_range() {
let all = ids(30);
let span = library(&all);
let mut sel = BTreeSet::new();
let mut anchor = None;
// Selecting began somewhere else, so a range gesture would have had an
// anchor to sweep from.
click(&mut sel, &mut anchor, &all, 0, 4, false, false, &span);
assert_eq!(sel.len(), 1);
tap(&mut sel, &mut anchor, &all, 11);
tap(&mut sel, &mut anchor, &all, 11);
assert_eq!(
sel.iter().copied().collect::<Vec<_>>(),
vec![ImageId(5)],
"a double tap took a run instead of toggling one cell twice"
);
}
/// One tap in selection mode: a press reported as ctrl-held, which is what
/// `library.slint` sends while the mode is on.
fn tap(sel: &mut BTreeSet<ImageId>, anchor: &mut Option<usize>, all: &[ImageId], row: usize) {
click(sel, anchor, all, 0, row, true, false, &library(all));
}
/// TRACES: FR-CAT-7 | FR-UI-4
/// The bug that made a forty-image drag file one photograph.
///
/// In selection mode every press arrives as a ctrl-press, so grabbing one
/// of the selected cells to drag them all used to *deselect* the cell being
/// grabbed. `drag-started` then saw a press on an image that was not in the
/// selection, concluded that was the gesture, and carried it alone.
#[test]
fn grabbing_a_selected_cell_leaves_the_whole_selection_to_drag() {
let all = ids(20);
let span = library(&all);
let mut sel = BTreeSet::new();
let mut anchor = None;
// Three photographs picked in selection mode, which reports ctrl.
for row in [2, 5, 9] {
click(&mut sel, &mut anchor, &all, 0, row, true, false, &span);
}
assert_eq!(sel.len(), 3);
// The press that begins the drag, on one of the three.
let outcome = apply_press(&mut sel, &mut anchor, &all, 0, 5, true, false, &span);
assert_eq!(
outcome,
Press::Deferred(ImageId(6)),
"the removal has to be handed back, not performed"
);
assert_eq!(
sel.len(),
3,
"the drag reads the selection next, and all three have to still be in it"
);
assert!(sel.contains(&ImageId(6)), "least of all the one being held");
}
/// And the other half: with no drag, the release still takes it out.
///
/// A tap in selection mode has to toggle, or there is no way to correct a
/// mis-tap short of leaving the mode.
#[test]
fn a_tap_on_a_selected_cell_still_takes_it_out() {
let all = ids(20);
let span = library(&all);
let mut sel = BTreeSet::new();
let mut anchor = None;
for row in [2, 5, 9] {
click(&mut sel, &mut anchor, &all, 0, row, true, false, &span);
}
click(&mut sel, &mut anchor, &all, 0, 5, true, false, &span);
assert_eq!(
sel.iter().copied().collect::<Vec<_>>(),
vec![ImageId(3), ImageId(10)],
"the tapped photograph is out and the other two stayed"
);
}
/// The anchor moves on the press whether or not the removal does.
///
/// "Select to…" measures from the anchor, and a run taken after tapping a
/// selected cell has to start where the user last touched — otherwise the
/// gesture sweeps from wherever the anchor happened to be left.
#[test]
fn a_deferred_press_still_moves_the_anchor() {
let all = ids(20);
let span = library(&all);
let mut sel = BTreeSet::new();
let mut anchor = None;
click(&mut sel, &mut anchor, &all, 0, 3, true, false, &span);
let _ = apply_press(&mut sel, &mut anchor, &all, 0, 3, true, false, &span);
assert_eq!(anchor, Some(3));
}
#[test]
fn ctrl_shift_adds_a_second_range_to_the_selection() {
// Picking up a second run without losing the first: the one case where
// a shift-click must not clear.
let all = ids(20);
let span = library(&all);
let mut sel = BTreeSet::new();
let mut anchor = None;
click(&mut sel, &mut anchor, &all, 0, 0, false, false, &span);
click(&mut sel, &mut anchor, &all, 0, 2, false, true, &span);
assert_eq!(sel.len(), 3);
// A new anchor by ctrl-click, then a ctrl+shift range from it.
click(&mut sel, &mut anchor, &all, 0, 10, true, false, &span);
click(&mut sel, &mut anchor, &all, 0, 12, true, true, &span);
assert_eq!(sel.len(), 6, "rows 0..=2 and 10..=12");
assert!(sel.contains(&ImageId(1)) && sel.contains(&ImageId(13)));
}
#[test]
fn a_plain_press_on_a_selected_cell_keeps_the_selection() {
// This is what makes a multi-image drag possible: the press that starts
// the drag must not collapse what it is about to carry.
let all = ids(5);
let span = library(&all);
let mut sel = BTreeSet::new();
let mut anchor = None;
click(&mut sel, &mut anchor, &all, 0, 0, false, false, &span);
click(&mut sel, &mut anchor, &all, 0, 1, true, false, &span);
click(&mut sel, &mut anchor, &all, 0, 2, true, false, &span);
assert_eq!(sel.len(), 3);
// Pressing one of the three to begin a drag.
click(&mut sel, &mut anchor, &all, 0, 1, false, false, &span);
assert_eq!(sel.len(), 3, "the selection survived the press");
}
#[test]
fn a_press_undone_leaves_the_selection_exactly_as_it_was() {
// A pinch opens as one finger on a cell, so by the time it is known to
// be a pinch a photograph has been selected that the user was only
// reaching past. Undoing it has to be *exact*: a cancel that restored
// the selection but left the anchor moved would make the next
// shift-click select a run from a cell nobody pointed at.
let all = ids(6);
let span = library(&all);
let mut sel = BTreeSet::new();
let mut anchor = None;
// A selection built the ordinary way, and the state it leaves behind.
click(&mut sel, &mut anchor, &all, 0, 1, false, false, &span);
click(&mut sel, &mut anchor, &all, 0, 3, true, false, &span);
let before = (sel.clone(), anchor);
// The finger that opens a pinch.
let undo = PressUndo::capture(&sel, anchor, Some(3));
click(&mut sel, &mut anchor, &all, 0, 5, false, false, &span);
assert_ne!(
(sel.clone(), anchor),
before,
"the press has to change something, or this proves nothing"
);
let cursor = undo.restore(&mut sel, &mut anchor);
assert_eq!((sel, anchor), before, "selection and anchor are back");
assert_eq!(cursor, Some(3), "and the keyboard cursor");
}
#[test]
fn a_press_past_the_end_of_the_window_is_ignored() {
// The grid is windowed and a stale row index can arrive after a scrub.
let all = ids(3);
let span = library(&all);
let mut sel = BTreeSet::new();
let mut anchor = None;
click(&mut sel, &mut anchor, &all, 0, 99, false, false, &span);
assert!(sel.is_empty());
}
#[test]
fn an_anchor_survives_the_window_moving_under_it() {
// The grid is a window over the catalog, and the window moves whenever
// the user scrolls. Held as a row, an anchor set before a scroll
// described a range from whatever photograph had since drifted into
// that row — so shift-clicking after scrolling selected a run the user
// never pointed at, silently and with no way to tell.
let all = ids(20);
let span = library(&all);
let first: Vec<_> = all[0..10].to_vec();
let later: Vec<_> = all[4..14].to_vec();
let mut sel = BTreeSet::new();
let mut anchor = None;
// Anchor on the sixth image, in a window starting at the beginning.
click(&mut sel, &mut anchor, &first, 0, 5, false, false, &span);
assert_eq!(anchor, Some(5), "the anchor is an ordinal, not a row");
// The user scrolls — the window now starts four images in — and
// shift-clicks the image at ordinal 10.
click(&mut sel, &mut anchor, &later, 4, 6, false, true, &span);
assert_eq!(sel.len(), 6, "ordinals 5..=10");
assert!(sel.contains(&ImageId(6)), "the anchored image is still in");
assert!(sel.contains(&ImageId(11)), "up to the one shift-clicked");
assert!(!sel.contains(&ImageId(5)), "and nothing before the anchor");
}
#[test]
fn a_range_reaching_outside_the_window_selects_the_whole_run() {
// The bug this exists for: the anchor is far above the loaded window,
// so all but four of the photographs in the range are off screen. They
// are still what the user asked for, and the catalog is what knows
// their ids — reading the range from the window selected the four that
// happened to be loaded and looked as though it had worked.
let all = ids(30);
let span = library(&all);
let window: Vec<_> = all[10..20].to_vec();
let mut sel = BTreeSet::new();
let mut anchor = Some(0);
click(&mut sel, &mut anchor, &window, 10, 3, false, true, &span);
assert_eq!(sel.len(), 14, "ordinals 0..=13, loaded or not");
assert!(
sel.contains(&ImageId(1)),
"the anchored image, never loaded"
);
assert!(sel.contains(&ImageId(14)), "up to the cell pressed");
assert!(!sel.contains(&ImageId(15)), "and no further");
}
#[test]
fn a_range_running_off_the_far_end_is_whole_too() {
// The mirror of the case above, with the anchor *ahead* of the press
// instead of behind it. Neither direction may stop at the window, and
// neither may wrap round to the other end of it.
let all = ids(30);
let span = library(&all);
let window: Vec<_> = all[0..5].to_vec();
let mut sel = BTreeSet::new();
let mut anchor = Some(25);
click(&mut sel, &mut anchor, &window, 0, 2, false, true, &span);
assert_eq!(sel.len(), 24, "ordinals 2..=25");
assert!(sel.contains(&ImageId(3)) && sel.contains(&ImageId(26)));
assert!(
!sel.contains(&ImageId(1)),
"nothing before the pressed cell"
);
}
#[test]
fn a_range_with_no_catalog_to_ask_falls_back_to_the_window() {
// Before a library is open, and if the query fails. What is loaded is
// a poorer answer than the catalog's and a far better one than a
// gesture that appears to do nothing.
let all = ids(30);
let window: Vec<_> = all[10..20].to_vec();
let mut sel = BTreeSet::new();
let mut anchor = Some(0);
click(
&mut sel,
&mut anchor,
&window,
10,
3,
false,
true,
&unloaded,
);
assert_eq!(sel.len(), 4, "ordinals 10..=13, the loaded part of 0..=13");
assert!(sel.contains(&ImageId(11)) && sel.contains(&ImageId(14)));
}
#[test]
fn shift_without_an_anchor_selects_just_the_one() {
let all = ids(5);
let span = library(&all);
let mut sel = BTreeSet::new();
let mut anchor = None;
click(&mut sel, &mut anchor, &all, 0, 3, false, true, &span);
assert_eq!(sel.iter().copied().collect::<Vec<_>>(), vec![ImageId(4)]);
}
}
+461
View File
@@ -0,0 +1,461 @@
//! Creating, renaming and naming collections, and the row context menu
//! that reaches most of it.
//!
//! `apply_rename` and `unique_name` are kept apart from the callbacks that
//! call them because they are the part a user notices being wrong, and the
//! part that can be tested without a window.
use std::rc::Rc;
use dr_catalog::collections::{self as coll, CollectionKind};
use dr_catalog::Catalog;
use dr_types::CollectionId;
use rusqlite::OptionalExtension as _;
use slint::Model as _;
use crate::AppWindow;
use super::controller::{delete_warning, menu_detail, CollectionsController};
use super::tree_sync::{direct_holdings, refresh_tree};
/// TRACES: FR-CAT-7
/// Open the row menu on `id`, or leave it shut if the collection is gone.
///
/// Everything the sheet draws is computed here, in one place, so the menu
/// cannot offer "Move to top level" on a collection already at the top, or a
/// delete warning about children it no longer has. Reopened from scratch after
/// every action for the same reason.
pub(super) fn open_row_menu(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
catalog: &Catalog,
id: CollectionId,
) {
let conn = catalog.connection();
let row: Option<(String, Option<i64>, i64)> = conn
.query_row(
"SELECT name, parent_id, kind FROM collections WHERE id = ?1 AND deleted = 0",
[id.0 as i64],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.optional()
.unwrap_or(None);
let Some((name, parent, kind)) = row else {
// Deleted underneath us — by a merge, or by the action that just ran.
close_row_menu(window, ctl);
return;
};
let (holds, children) = direct_holdings(conn, id).unwrap_or((0, 0));
*ctl.menu_for.borrow_mut() = Some(id);
ctl.menu_confirming.set(false);
window.set_collection_menu_confirming(false);
window.set_collection_menu_title(name.as_str().into());
window.set_collection_menu_detail(menu_detail(holds, children).into());
window.set_collection_menu_nested(parent.is_some());
window.set_collection_menu_smart(kind == CollectionKind::Smart as i64);
window.set_collection_menu_confirm_detail(delete_warning(&name, holds, children).into());
window.set_collection_menu_delete_label("Delete".into());
// TRACES: FR-NC-6a
// Read off the row the sidebar already drew rather than recomputed: the
// roll-up is one pass over the whole tree, and a menu that computed its own
// could disagree with the tray beside the name it is titled with.
let pinned = ctl
.row_ids
.borrow()
.iter()
.position(|&c| c == id)
.and_then(|row| window.get_collection_rows().row_data(row))
.is_some_and(|r| r.pinned);
window.set_collection_menu_pinned(pinned);
}
/// Shut the row menu, both halves.
///
/// The controller's copy is what the handlers act on and the window property is
/// what draws the sheet; clearing one without the other leaves either an
/// invisible menu that still answers, or a visible one that acts on nothing.
pub(super) fn close_row_menu(window: &AppWindow, ctl: &Rc<CollectionsController>) {
*ctl.menu_for.borrow_mut() = None;
ctl.menu_confirming.set(false);
window.set_collection_menu_title(slint::SharedString::new());
window.set_collection_menu_confirming(false);
}
/// 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.
pub(super) 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)]
pub(super) 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.
pub(super) fn apply_rename(
conn: &rusqlite::Connection,
id: CollectionId,
typed: &str,
) -> Result<Rename, dr_catalog::CatalogError> {
let name = typed.trim();
if name.is_empty() {
return Err(dr_catalog::CatalogError::BadName(
"a collection needs a name".into(),
));
}
// The current name, which also proves the collection is still there.
let current: String = conn.query_row(
"SELECT name FROM collections WHERE id = ?1 AND deleted = 0",
[id.0 as i64],
|r| r.get(0),
)?;
if current == name {
return Ok(Rename::Unchanged);
}
// Siblings, excluding this collection: a rename that only changes case is a
// real rename, and must not be refused as a clash with itself.
let clash: Option<i64> = conn
.query_row(
"SELECT 1 FROM collections
WHERE deleted = 0
AND id != ?1
AND name = ?2 COLLATE NOCASE
AND parent_id IS (SELECT parent_id FROM collections WHERE id = ?1)",
rusqlite::params![id.0 as i64, name],
|r| r.get(0),
)
.optional()?;
if clash.is_some() {
return Err(dr_catalog::CatalogError::BadName(format!(
"there is already a “{name}” here"
)));
}
coll::rename(conn, id, name)?;
Ok(Rename::Applied(name.to_string()))
}
/// TRACES: FR-CAT-7
/// Make a collection inside `parent` and open its name field.
///
/// Shared by the header's `+` and the row menu's "New collection inside",
/// which differ only in where the parent comes from — and must not differ in
/// anything else, or building a tree from the tree would behave unlike
/// building one from the header.
pub(super) fn create_child(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
catalog: &Catalog,
parent: Option<CollectionId>,
) {
let name = unique_name(catalog.connection(), parent);
match coll::create(catalog.connection(), &name, parent, CollectionKind::Manual) {
Ok(id) => {
window.set_collection_error(slint::SharedString::new());
// A new child is useless if its parent is collapsed.
if let Some(p) = parent {
ctl.collapsed.borrow_mut().remove(&p);
}
// Straight into the name field, with "New collection" selected.
// The name is a placeholder nobody wants to keep, so making the
// user find the rename gesture afterwards is asking them to finish
// a job we started.
//
// Opened *after* the rebuild, and the order is load-bearing:
// `refresh_tree` replaces the row model, which destroys and
// recreates every row. A field opened before it would be torn down
// along with the `init` that focuses it, leaving an edit box
// nothing had typed into. Setting the property afterwards puts the
// field on a row that already exists.
refresh_tree(window, ctl, catalog);
*ctl.renaming.borrow_mut() = Some(id);
window.set_collection_renaming(id.0 as i32);
log::info!("created collection {} ({name})", id.0);
}
Err(e) => window.set_collection_error(format!("creating collection: {e}").into()),
}
}
/// A name no sibling is already using.
///
/// Duplicate names are legal in the schema, and two identically-named
/// collections in one parent are indistinguishable in the sidebar — which is
/// how images end up in the wrong one.
pub(super) 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::*;
#[test]
fn new_collections_do_not_share_a_name_with_a_sibling() {
// Two identically-named collections in one parent are
// indistinguishable in the sidebar, which is how images land in the
// wrong one.
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
let first = unique_name(c, None);
coll::create(c, &first, None, CollectionKind::Manual).unwrap();
let second = unique_name(c, None);
coll::create(c, &second, None, CollectionKind::Manual).unwrap();
assert_ne!(first, second);
assert_eq!(first, "New collection");
assert_eq!(second, "New collection 2");
}
/// A catalog holding one top-level collection, and its id.
fn with_one(name: &str) -> (Catalog, CollectionId) {
let cat = Catalog::in_memory().unwrap();
let id = coll::create(cat.connection(), name, None, CollectionKind::Manual).unwrap();
(cat, id)
}
fn name_of(cat: &Catalog, id: CollectionId) -> String {
cat.connection()
.query_row(
"SELECT name FROM collections WHERE id = ?1",
[id.0 as i64],
|r| r.get(0),
)
.unwrap()
}
#[test]
fn a_rename_stores_the_new_name() {
let (cat, id) = with_one("Untitled");
let out = apply_rename(cat.connection(), id, "Iceland").unwrap();
assert_eq!(out, Rename::Applied("Iceland".into()));
assert_eq!(name_of(&cat, id), "Iceland");
}
#[test]
fn surrounding_whitespace_is_trimmed_rather_than_stored() {
// A trailing space is invisible in the sidebar and makes two
// collections look identical while sorting them apart.
let (cat, id) = with_one("Untitled");
assert_eq!(
apply_rename(cat.connection(), id, " Iceland ").unwrap(),
Rename::Applied("Iceland".into())
);
assert_eq!(name_of(&cat, id), "Iceland");
}
#[test]
fn a_blank_name_is_refused() {
// A nameless row cannot be read or aimed at, and the schema would take
// one happily.
let (cat, id) = with_one("Iceland");
assert!(matches!(
apply_rename(cat.connection(), id, " "),
Err(dr_catalog::CatalogError::BadName(_))
));
assert_eq!(name_of(&cat, id), "Iceland", "the old name stands");
}
#[test]
fn renaming_to_the_current_name_writes_nothing() {
// Every write bumps the revision, and a no-op rename would let an idle
// device beat one that did real work at the next merge.
let (cat, id) = with_one("Iceland");
let rev = |c: &Catalog| -> i64 {
c.connection()
.query_row(
"SELECT revision FROM collections WHERE id = ?1",
[id.0 as i64],
|r| r.get(0),
)
.unwrap()
};
let before = rev(&cat);
assert_eq!(
apply_rename(cat.connection(), id, "Iceland").unwrap(),
Rename::Unchanged
);
assert_eq!(rev(&cat), before);
}
#[test]
fn a_siblings_name_is_refused() {
// Two identically-named collections in one parent are
// indistinguishable in the sidebar, which is how images land in the
// wrong one.
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
coll::create(c, "Iceland", None, CollectionKind::Manual).unwrap();
let japan = coll::create(c, "Japan", None, CollectionKind::Manual).unwrap();
assert!(matches!(
apply_rename(c, japan, "Iceland"),
Err(dr_catalog::CatalogError::BadName(_))
));
assert_eq!(name_of(&cat, japan), "Japan");
}
#[test]
fn a_siblings_name_is_refused_in_a_different_case_too() {
// The sidebar sorts case-insensitively, so "iceland" beside "Iceland"
// is the same trap as an exact duplicate.
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
coll::create(c, "Iceland", None, CollectionKind::Manual).unwrap();
let japan = coll::create(c, "Japan", None, CollectionKind::Manual).unwrap();
assert!(matches!(
apply_rename(c, japan, "iceland"),
Err(dr_catalog::CatalogError::BadName(_))
));
}
#[test]
fn changing_only_the_case_of_a_name_is_allowed() {
// The clash check excludes the collection itself, or fixing the
// capitalisation of a name would be refused as a clash with itself.
let (cat, id) = with_one("iceland");
assert_eq!(
apply_rename(cat.connection(), id, "Iceland").unwrap(),
Rename::Applied("Iceland".into())
);
assert_eq!(name_of(&cat, id), "Iceland");
}
#[test]
fn a_name_used_under_a_different_parent_is_free() {
// Uniqueness is per parent: "Selects" inside two different trips is
// unambiguous and normal.
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
let trips = coll::create(c, "Trips", None, CollectionKind::Manual).unwrap();
coll::create(c, "Selects", Some(trips), CollectionKind::Manual).unwrap();
let top = coll::create(c, "Loose", None, CollectionKind::Manual).unwrap();
assert_eq!(
apply_rename(c, top, "Selects").unwrap(),
Rename::Applied("Selects".into())
);
}
#[test]
fn two_top_level_collections_still_clash_despite_a_null_parent() {
// `parent_id IS NULL` never matches with `=`, so a naive clash query
// would silently allow every duplicate at the top level — which is
// where most collections live.
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
coll::create(c, "Iceland", None, CollectionKind::Manual).unwrap();
let other = coll::create(c, "Japan", None, CollectionKind::Manual).unwrap();
assert!(
apply_rename(c, other, "Iceland").is_err(),
"two top-level collections must not share a name"
);
}
#[test]
fn renaming_a_deleted_collection_fails_rather_than_resurrecting_it() {
let (cat, id) = with_one("Gone");
coll::delete(cat.connection(), id).unwrap();
assert!(apply_rename(cat.connection(), id, "Back").is_err());
}
#[test]
fn a_saved_filter_can_be_renamed() {
// Its *membership* is computed; its name is not, and a smart
// collection the user cannot label is worse than no smart collection.
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
let s = coll::create(c, "Untitled", None, CollectionKind::Smart).unwrap();
assert_eq!(
apply_rename(c, s, "Five star").unwrap(),
Rename::Applied("Five star".into())
);
}
#[test]
fn the_same_name_is_free_again_under_a_different_parent() {
// Uniqueness is per parent, not global: "Selects" inside two different
// trips is unambiguous and normal.
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
let top = coll::create(c, "New collection", None, CollectionKind::Manual).unwrap();
assert_eq!(unique_name(c, Some(top)), "New collection");
}
}
+296
View File
@@ -0,0 +1,296 @@
//! Soft delete: moving images to the trash on the server, restoring them,
//! and the worker drain both share.
//!
//! A drop 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, a separate, deliberate action.
use std::cell::RefCell;
use std::rc::Rc;
use dr_catalog::Catalog;
use dr_types::ImageId;
use slint::ComponentHandle;
use crate::AppWindow;
use super::controller::CollectionsController;
use super::tree_sync::refresh_tree;
/// 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)]
pub(super) fn start_trash(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
session: &Rc<dyn Fn() -> Option<dr_sync::Connection>>,
images: &[ImageId],
reload: &Rc<dyn Fn()>,
) {
let Some(conn) = session() else {
window.set_collection_error("Open a library first.".into());
return;
};
let moves = {
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
match crate::trash::plan_trash(cat, &conn.account.root, images) {
Ok(m) => m,
Err(e) => {
window.set_collection_error(format!("planning delete: {e}").into());
return;
}
}
};
if moves.is_empty() {
return;
}
log::info!("moving {} image(s) to the trash", moves.len());
window.set_library_status(format!("Moving {} to the trash…", moves.len()).into());
// The images are leaving the grid; a selection pointing at them would
// survive as a set of ids the user can no longer see.
ctl.clear_selection();
let count = moves.len();
let rx = crate::trash::spawn_move(
conn.clone(),
moves,
crate::trash::Direction::ToTrash,
crate::library::catalog_path(&conn.account),
);
drain_trash(
window.as_weak(),
ctl.clone(),
catalog.clone(),
rx,
reload.clone(),
format!("Moving {count} photograph(s) to the trash"),
);
}
/// TRACES: FR-CAT-15
/// Put trashed images back where they came from.
///
/// The mirror of [`start_trash`], and separate from it rather than a `direction`
/// parameter on one function: the two differ in what they plan, what they report
/// and what they say when the plan comes back empty, and the shared part is the
/// three lines that spawn the worker.
///
/// An image whose origin was never recorded is skipped by
/// [`crate::trash::plan_restore`] rather than guessed at. That can make the plan
/// shorter than the selection, which is why an empty plan is reported here
/// instead of returning silently — the user pressed a button and is owed an
/// answer either way.
pub(super) fn start_restore(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
session: &Rc<dyn Fn() -> Option<dr_sync::Connection>>,
images: &[ImageId],
reload: &Rc<dyn Fn()>,
) {
let Some(conn) = session() else {
window.set_collection_error("Open a library first.".into());
return;
};
let moves = {
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
match crate::trash::plan_restore(cat, images) {
Ok(m) => m,
Err(e) => {
window.set_collection_error(format!("planning restore: {e}").into());
return;
}
}
};
if moves.is_empty() {
// Said out loud rather than passed over in silence: a button that does
// nothing visible reads as broken, and the reason here is specific.
window.set_collection_error(
"Nothing to restore — no record of where these came from.".into(),
);
return;
}
log::info!("restoring {} image(s) from the trash", moves.len());
window.set_library_status(format!("Restoring {}…", moves.len()).into());
// The images are leaving the trash view, so a selection pointing at them
// would survive as ids the user can no longer see.
ctl.clear_selection();
let count = moves.len();
let rx = crate::trash::spawn_move(
conn.clone(),
moves,
crate::trash::Direction::Restore,
crate::library::catalog_path(&conn.account),
);
drain_trash(
window.as_weak(),
ctl.clone(),
catalog.clone(),
rx,
reload.clone(),
format!("Restoring {count} photograph(s)"),
);
}
/// Drain a trash worker on the UI thread.
///
/// Same shape as the scan and thumbnail drains: an mpsc channel polled by a
/// Slint timer, so nothing blocks the event loop (NFR-P9).
pub(super) 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))
}
+501
View File
@@ -0,0 +1,501 @@
//! Rebuilding the sidebar from the catalog, and pushing catalog-derived
//! state into the grid: the selection flags, the lifted cells, the
//! per-cell collection badges, and the membership sheet's own rows.
//!
//! `offline_state` and `direct_holdings` live here rather than beside the
//! menu or the tray that reads them, because both are catalog reads over
//! the tree — the same shape as `refresh_tree` itself, just narrower.
use std::rc::Rc;
use dr_catalog::collections::{self as coll, CollectionKind};
use dr_catalog::Catalog;
use dr_types::{CollectionId, ImageId};
use slint::Model as _;
use crate::{AppWindow, CollectionRow};
use super::controller::CollectionsController;
/// Rebuild the sidebar from the catalog.
///
/// Called after every edit. The whole tree rather than a patch: a rename can
/// reorder siblings, a delete promotes children, and a drop changes counts on
/// every ancestor — diffing that against the model would be more code than the
/// query costs, and the tree is tens of rows, not thousands.
pub fn refresh_tree(window: &AppWindow, ctl: &Rc<CollectionsController>, catalog: &Catalog) {
let rows = match coll::tree(catalog.connection()) {
Ok(r) => r,
Err(e) => {
window.set_collection_error(format!("reading collections: {e}").into());
return;
}
};
let collapsed = ctl.collapsed.borrow();
// TRACES: FR-NC-6a | FR-NC-6c
// How much of each collection is already on the device, rolled up the tree
// — computed once for the whole sidebar rather than per row.
let held = offline_state(catalog, &rows);
// A row is hidden when any ancestor is collapsed. The tree arrives
// depth-first, so tracking the shallowest collapsed depth seen is enough —
// no ancestor lookup per row.
let mut hide_below: Option<usize> = None;
let mut ids = Vec::new();
let mut smart = Vec::new();
let mut has_kids = Vec::new();
let mut nested = Vec::new();
let mut out = Vec::new();
for row in &rows {
if let Some(depth) = hide_below {
if row.depth > depth {
continue;
}
hide_below = None;
}
let id = row.collection.id;
let expanded = !collapsed.contains(&id);
if row.has_children && !expanded {
hide_below = Some(row.depth);
}
// Deep counts are one query per row. That is fine at sidebar scale and
// wrong at grid scale, which is why the grid does not do this.
let deep =
coll::deep_count(catalog.connection(), id).unwrap_or(row.collection.direct_count);
let offline = held.get(&id).copied().unwrap_or_default();
ids.push(id);
smart.push(row.collection.kind == CollectionKind::Smart);
has_kids.push(row.has_children);
nested.push(row.collection.parent.is_some());
out.push(CollectionRow {
id: id.0 as i32,
name: row.collection.name.as_str().into(),
depth: row.depth as i32,
direct_count: row.collection.direct_count as i32,
deep_count: deep as i32,
has_children: row.has_children,
expanded,
smart: row.collection.kind == CollectionKind::Smart,
// An empty collection is never "kept": every one of its zero
// photographs being here is true and says nothing, and a full tray
// beside an empty collection is a lie about what a trip would cost
// to take.
pinned: offline.total > 0 && offline.pinned == offline.total,
partly_pinned: offline.pinned > 0 && offline.pinned < offline.total,
});
}
*ctl.row_ids.borrow_mut() = ids;
*ctl.row_smart.borrow_mut() = smart;
*ctl.row_has_children.borrow_mut() = has_kids;
*ctl.row_nested.borrow_mut() = nested;
window.set_collection_rows(slint::ModelRc::new(slint::VecModel::from(out)));
// The rows are what `sync_reorderable` reads, so it has to be told they
// changed: a collection that gains a child, or one that is deleted out from
// under the scope, changes whether the grid can be reordered without the
// scope itself moving.
sync_reorderable(window);
}
/// TRACES: FR-NC-6a | FR-NC-6c
/// How many of a collection's photographs are pinned, and how many there are.
///
/// Counted over *members*, not distinct images: an image filed in both a parent
/// and a child is counted in each, which is what makes the roll-up below a sum
/// rather than a set union. That inflates the totals of a tree that files the
/// same photograph twice, and it does not matter here — the only questions
/// asked of these numbers are "all of them?" and "none of them?", and both
/// survive the double count.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct OfflineCount {
total: usize,
pinned: usize,
}
/// The offline state of every collection in the tree, descendants included.
///
/// One query for the direct membership, then a single reverse pass to fold each
/// row into its parent. The alternative — `descendants` plus a count per row —
/// is two queries per collection, and the sidebar rebuilds after every drop,
/// rename and rating change.
///
/// The reverse pass is correct because `tree` emits parents before children:
/// walking it backwards means every child has already been folded in by the
/// time its parent is reached, however deep the nesting goes.
fn offline_state(
catalog: &Catalog,
rows: &[coll::TreeRow],
) -> std::collections::HashMap<CollectionId, OfflineCount> {
let mut counts: std::collections::HashMap<CollectionId, OfflineCount> =
std::collections::HashMap::new();
// `tier_actual`, not `pinned`: the question a user is asking of this icon is
// "will these open on the aeroplane", and a pin whose download has not run
// yet answers no (see the note on `tier_actual` in `dr_catalog::cache`).
let sql = "SELECT cm.collection_id,
count(*),
coalesce(sum(CASE WHEN ic.tier_actual >= ?1 THEN 1 ELSE 0 END), 0)
FROM collection_members cm
LEFT JOIN image_cache ic ON ic.image_id = cm.image_id
GROUP BY cm.collection_id";
match catalog.connection().prepare(sql).and_then(|mut stmt| {
let rows = stmt.query_map(rusqlite::params![dr_types::Tier::Original.stored()], |r| {
Ok((
CollectionId(r.get::<_, i64>(0)? as u64),
r.get::<_, i64>(1)? as usize,
r.get::<_, i64>(2)? as usize,
))
})?;
rows.collect::<Result<Vec<_>, _>>()
}) {
Ok(direct) => {
for (id, total, pinned) in direct {
counts.insert(id, OfflineCount { total, pinned });
}
}
Err(e) => {
// Not fatal: the tray then reads "nothing kept", which is the safe
// direction — it offers a download rather than claiming a trip is
// already on the device.
log::debug!("reading offline state: {e}");
return counts;
}
}
for row in rows.iter().rev() {
let Some(parent) = row.collection.parent else {
continue;
};
let child = counts.get(&row.collection.id).copied().unwrap_or_default();
if child.total == 0 {
continue;
}
let entry = counts.entry(parent).or_default();
entry.total += child.total;
entry.pinned += child.pinned;
}
counts
}
/// Mark which cells are currently lifted out by a drag.
///
/// Separate from [`sync_selection`] because the two differ: the selection
/// persists after the drag, the lift lasts only while it is in flight.
pub fn sync_lifted(window: &AppWindow, lifted: &[ImageId], ids: &[ImageId]) {
let model = window.get_library_cells();
for (row, id) in ids.iter().enumerate() {
let want = lifted.contains(id);
if let Some(mut cell) = model.row_data(row) {
if cell.lifted != want {
cell.lifted = want;
model.set_row_data(row, cell);
}
}
}
}
/// Push the current selection into the grid model's `selected` flags.
///
/// The model carries the flag per cell so Slint can style without a lookup;
/// this is what keeps the two in step after a scrub, a drop, or a click.
pub fn sync_selection(window: &AppWindow, ctl: &Rc<CollectionsController>, ids: &[ImageId]) {
let selection = ctl.selection.borrow();
let model = window.get_library_cells();
// The anchor is deliberately **not** pushed to the grid.
//
// It used to be, and it was drawn as a thin ring inside the cell. Two
// things were wrong with that. It was the clearest mark on the cell, so it
// read as *the* selection to anyone who had not written it; and an anchor
// outlives a deselection, so the ring sat around the last photograph
// touched while nothing was selected at all — indistinguishable from a cell
// that had stayed behind.
//
// The ordinal still lives in the controller and still decides where a range
// extends from. What is gone is the claim that the user needs to see it:
// the selection bar says "Tap the last photograph" while a range is armed,
// which answers the question the ring was there to answer.
for (row, id) in ids.iter().enumerate() {
let want = selection.contains(id);
if let Some(mut cell) = model.row_data(row) {
if cell.selected != want {
cell.selected = want;
model.set_row_data(row, cell);
}
}
}
window.set_library_selected_count(selection.len() as i32);
}
/// TRACES: FR-CAT-7
/// Whether what the grid is showing has an order the user can change.
///
/// A single manual collection: not the whole library, not a saved filter whose
/// membership is a rule, and not a set — a set draws its descendants' images
/// too, and two children's `position` columns are unrelated integers that would
/// interleave arbitrarily.
///
/// Read off the tree rows the sidebar already draws rather than asked of the
/// catalog. `smart` and `has_children` are the only two facts it needs and both
/// are in the model already, so this cannot disagree with what the user is
/// looking at, and a scope change costs no extra query.
pub(super) fn sync_reorderable(window: &AppWindow) {
let id = window.get_collection_selected();
let rows = window.get_collection_rows();
let manual = id > 0
&& (0..rows.row_count())
.filter_map(|i| rows.row_data(i))
.find(|r| r.id == id)
.is_some_and(|r| !r.smart && !r.has_children);
window.set_library_reorderable(manual);
}
/// Refresh the per-cell "in this many collections" badges.
///
/// One query for the whole window rather than one per cell: 120 cells is 120
/// round trips otherwise, on every drop.
pub fn sync_badges(window: &AppWindow, catalog: &Catalog, ids: &[ImageId]) {
if ids.is_empty() {
return;
}
let placeholders = std::iter::repeat_n("?", ids.len())
.collect::<Vec<_>>()
.join(",");
let sql = format!(
"SELECT m.image_id, count(*)
FROM collection_members m
JOIN collections c ON c.id = m.collection_id
WHERE m.image_id IN ({placeholders}) AND c.deleted = 0
GROUP BY m.image_id"
);
let params: Vec<rusqlite::types::Value> = ids
.iter()
.map(|i| rusqlite::types::Value::Integer(i.0 as i64))
.collect();
let mut counts = std::collections::HashMap::new();
if let Ok(mut stmt) = catalog.connection().prepare(&sql) {
if let Ok(rows) = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?))
}) {
for (id, n) in rows.flatten() {
counts.insert(id, n as i32);
}
}
}
let model = window.get_library_cells();
for (row, id) in ids.iter().enumerate() {
let want = counts.get(&(id.0 as i64)).copied().unwrap_or(0);
if let Some(mut cell) = model.row_data(row) {
if cell.collection_count != want {
cell.collection_count = want;
model.set_row_data(row, cell);
}
}
}
}
/// TRACES: FR-CAT-7
/// What a collection directly holds: its own member rows, and its own children.
///
/// One query each rather than reusing `deep_count`, which counts descendants'
/// photographs — see [`menu_detail`] for why the menu needs the direct numbers.
pub(super) fn direct_holdings(
conn: &rusqlite::Connection,
id: CollectionId,
) -> Result<(usize, usize), dr_catalog::CatalogError> {
let holds: i64 = conn.query_row(
"SELECT count(*) FROM collection_members WHERE collection_id = ?1",
[id.0 as i64],
|r| r.get(0),
)?;
let children: i64 = conn.query_row(
"SELECT count(*) FROM collections WHERE parent_id = ?1 AND deleted = 0",
[id.0 as i64],
|r| r.get(0),
)?;
Ok((holds as usize, children as usize))
}
/// TRACES: FR-CAT-7 | FR-UI-4
/// Rebuild the membership sheet from the selection as it stands.
///
/// Called on open and after every removal rather than patched: a removal can
/// take the last selected photograph out of a collection, and that row then has
/// to go — a patched model would leave a "Remove" that removes nothing.
pub(super) fn refresh_membership(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
catalog: &Catalog,
) {
let chosen = ctl.selected();
let total = chosen.len();
let rows = match coll::membership_of(catalog.connection(), &chosen) {
Ok(r) => r,
Err(e) => {
window.set_collection_error(format!("reading collections: {e}").into());
Vec::new()
}
};
let out: Vec<crate::MembershipRow> = rows
.into_iter()
.map(|m| crate::MembershipRow {
id: m.id.0 as i32,
name: m.name.as_str().into(),
holding: m.holding as i32,
// "1 of 1" is arithmetic nobody asked for; with one photograph
// selected the name already says everything true about it.
detail: if total <= 1 {
slint::SharedString::new()
} else {
format!("{} of {total}", m.holding).into()
},
})
.collect();
window.set_membership_rows(slint::ModelRc::new(slint::VecModel::from(out)));
}
#[cfg(test)]
mod tests {
use super::*;
/// 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);
}
}
+943
View File
@@ -0,0 +1,943 @@
//! Grid-facing wiring: selection, filing the selection into a collection,
//! the drag out of the grid, and the trash reached from the grid or the
//! sidebar row.
//!
//! [`wire`] is the module's single entry point, called once when the
//! window is built; it and everything it calls here that has no test of
//! its own are exercised through the running application (see
//! `identity_bench` and the manual recording rig in the developer docs).
use std::cell::RefCell;
use std::rc::Rc;
use dr_catalog::collections::{self as coll, CollectionKind};
use dr_catalog::Catalog;
use dr_types::{CollectionId, ImageId};
use slint::{ComponentHandle, Model as _};
use crate::library;
use crate::AppWindow;
use super::controller::{decide_drop, CollectionsController, Drop};
use super::drag::{arm_hold, arm_spring, collapse_spring_opened, compose_drag_image};
use super::press::select_row;
use super::rename_menu::unique_name;
use super::trash::{drain_trash, start_restore, start_trash};
use super::tree_sync::{refresh_tree, sync_badges, sync_lifted, sync_selection};
use super::wiring_tree::{
wire_create, wire_membership, wire_remove, wire_rename, wire_row_drag, wire_row_menu,
wire_tree_navigation,
};
/// Connect the sidebar and drag callbacks.
///
/// `on_scope_changed` reloads the grid — that lives in [`crate::library_ui`],
/// which owns the window and the thumbnail workers, so it is passed in rather
/// than reached for.
#[allow(clippy::too_many_arguments)]
pub fn wire<S, R, P, C>(
window: &AppWindow,
ctl: Rc<CollectionsController>,
catalog: Rc<RefCell<Option<Catalog>>>,
on_scope_changed: S,
visible_ids: R,
span_ids: P,
session: C,
) where
S: Fn() + 'static,
R: Fn() -> Vec<ImageId> + 'static,
P: Fn(usize, usize) -> Vec<ImageId> + 'static,
C: Fn() -> Option<dr_sync::Connection> + 'static,
{
// Coerced to trait objects here rather than at each use: each is shared by
// several of the functions below, and a generic parameter would make every
// one of them a separate instantiation for no gain.
let on_scope_changed: Rc<dyn Fn()> = Rc::new(on_scope_changed);
let visible_ids: Rc<dyn Fn() -> Vec<ImageId>> = Rc::new(visible_ids);
// A shift-click asks the catalog what lies between its two ends, and the
// catalog belongs to the grid's controller — see `span_source`.
*ctl.span_source.borrow_mut() = Some(Rc::new(span_ids));
let session: Rc<dyn Fn() -> Option<dr_sync::Connection>> = Rc::new(session);
wire_selection(window, &ctl, &catalog, &visible_ids, &on_scope_changed);
wire_selection_filing(window, &ctl, &catalog, &visible_ids, &on_scope_changed);
wire_drag(
window,
&ctl,
&catalog,
&visible_ids,
&on_scope_changed,
&session,
);
wire_trash(window, &ctl, &catalog, &session, &on_scope_changed);
wire_trash_from_grid(
window,
&ctl,
&catalog,
&session,
&on_scope_changed,
&visible_ids,
);
wire_tree_navigation(window, &ctl, &catalog, &on_scope_changed);
wire_create(window, &ctl, &catalog);
wire_rename(window, &ctl, &catalog);
wire_remove(window, &ctl, &catalog, &visible_ids, &on_scope_changed);
wire_row_drag(window, &ctl, &catalog);
wire_row_menu(window, &ctl, &catalog, &on_scope_changed, &visible_ids);
wire_membership(window, &ctl, &catalog, &visible_ids, &on_scope_changed);
}
/// Selection.
fn wire_selection(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
visible_ids: &Rc<dyn Fn() -> Vec<ImageId>>,
on_scope_changed: &Rc<dyn Fn()>,
) {
{
let weak = window.as_weak();
let ctl = ctl.clone();
let visible = visible_ids.clone();
window.on_library_cell_pressed(move |row, ctrl_held, shift_held| {
let Some(w) = weak.upgrade() else { return };
let ids = visible();
// Consulted by the click that follows: a modified press is building
// a selection and must not also navigate to develop.
ctl.modified_press.set(ctrl_held || shift_held);
// Where the window starts, so the press is recorded as the ordinal
// it is rather than as a row that stops meaning this photograph on
// the next scroll.
let offset = w.get_library_offset().max(0) as usize;
select_row(&w, &ctl, &ids, offset, row as usize, ctrl_held, shift_held);
// TRACES: FR-UI-2 | FR-UI-4 | FR-CAT-7
// And start counting, in case this press is a hold. The press has
// already selected this one cell; what the hold adds is the *mode*,
// so the taps that follow go on selecting instead of opening the
// next photograph the user touches — and the *pick-up*, which is
// what makes the drag reliable.
//
// Armed even when the mode is already on, which it did not used to
// be: there was nothing left for the hold to switch on, so it was
// skipped. But the mode being on is exactly the state a
// multi-image drag starts from, and skipping the hold left that
// drag with no pick-up and no cue — the one gesture that most
// needed both. See `arm_hold`.
arm_hold(&w, &ctl, row);
});
}
// TRACES: FR-UI-2 | FR-UI-4
// The press ended — lifted, or taken by the Flickable when the finger
// travelled. Either way the hold is off.
{
let ctl = ctl.clone();
window.on_library_cell_press_ended(move || {
*ctl.hold_timer.borrow_mut() = None;
});
}
// TRACES: FR-CAT-5
// Put the selection down without leaving select mode.
//
// Distinct from "Done", which does both: after picking forty photographs
// and filing them, the next forty start with the mode already on, and
// making the user re-enter it is asking them to undo a step they did not
// take. It is also the only way back from a mis-tap that is not tapping
// every one of them again.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let visible = visible_ids.clone();
window.on_library_clear_selection(move || {
let Some(w) = weak.upgrade() else { return };
ctl.clear_selection();
sync_selection(&w, &ctl, &visible());
});
}
// TRACES: FR-CAT-7
// Drag a photograph, or a whole selection of them, to a new place in the
// collection being shown.
//
// `collection_members.position` and `Sort::CollectionPosition` have been in
// the catalog since collections were, and until now nothing above it ever
// wrote or read them — the grid ordered by capture time whatever it was
// scoped to. `library::grid_order_for` reads them; this writes them.
//
// The membership is rewritten whole rather than patched. `set_order` sets
// the positions it is given and leaves the rest, so a partial write would
// interleave the moved run with rows whose positions nobody touched — and
// it is read unfiltered for the same reason: the user reorders what they
// can see, and what the filter is hiding keeps its place relative to it.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
let visible = visible_ids.clone();
let reload = on_scope_changed.clone();
window.on_library_reorder_to(move |row, after| {
let Some(w) = weak.upgrade() else { return };
// Both guards belong here rather than only in `library.slint`: the
// scope can change between the drag starting and the drop landing.
if !w.get_library_reorderable() {
return;
}
let Some(scope) = *ctl.scope.borrow() else {
return;
};
let moving = ctl.selected();
if moving.is_empty() {
return;
}
let ids = visible();
let Some(&target) = usize::try_from(row).ok().and_then(|r| ids.get(r)) else {
return;
};
// Dropped on one of its own. There is no gap between a run and
// itself to land in, and rewriting the whole membership to say so
// would be a revision bump for no change.
if moving.contains(&target) {
return;
}
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
let current = match library::read_member_order(cat, scope) {
Ok(current) => current,
Err(e) => {
w.set_collection_error(format!("reading the collection's order: {e}").into());
return;
}
};
let wanted = library::reordered(&current, &moving, target, after);
match coll::set_order(cat.connection(), scope, &wanted) {
Ok(()) => {
w.set_collection_error(slint::SharedString::new());
w.set_library_status(
format!(
"{} photograph{} moved",
moving.len(),
if moving.len() == 1 { "" } else { "s" }
)
.into(),
);
drop(borrow);
// The selection survives. It is what was just moved, and
// dropping it would make a second nudge — which is how a
// drag-to-reorder is usually corrected — start over.
reload();
sync_selection(&w, &ctl, &visible());
}
Err(e) => {
drop(borrow);
w.set_collection_error(format!("reordering: {e}").into());
}
}
});
}
// TRACES: FR-CAT-5 | FR-UI-4
// Everything the grid is showing.
//
// Asked of the catalog through `span`, not read off the loaded window, for
// the reason spelled out in `apply_press`: the window is a hundred cells
// over a library of thousands, and a "select all" that quietly meant
// "select the hundred that happen to be loaded" is a lie the user cannot
// see until the export runs. `library_total` is the count the same scope
// and filter produced, so the run is the whole of what the grid claims.
//
// Replaces rather than adds. "All" is a statement about the result, not an
// increment, and a user who wanted the rest kept would not have reached for
// this.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let visible = visible_ids.clone();
window.on_library_select_all(move || {
let Some(w) = weak.upgrade() else { return };
let ids = visible();
let total = w.get_library_total().max(0) as usize;
if total == 0 {
return;
}
// The loaded window is the fallback, not the answer — the same
// trade the shift-click path makes. An empty span means no library
// or a failed query, and selecting what is on screen beats
// selecting nothing.
let all = match ctl.span(0, total - 1) {
run if run.is_empty() => ids.clone(),
run => run,
};
{
let mut selection = ctl.selection.borrow_mut();
selection.clear();
selection.extend(all);
}
// The first frame becomes the anchor, so a "Select to…" straight
// afterwards describes a range from the top rather than from
// wherever the last individual tap left it — which, after taking
// everything, is not a place the user is still thinking about.
*ctl.anchor.borrow_mut() = Some(0);
sync_selection(&w, &ctl, &ids);
});
}
}
/// Selection, continued: turning it into a collection, toggling the
/// mode, and filing it into one that already exists.
fn wire_selection_filing(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
visible_ids: &Rc<dyn Fn() -> Vec<ImageId>>,
on_scope_changed: &Rc<dyn Fn()>,
) {
// TRACES: FR-CAT-5
// A collection holding exactly what is selected.
//
// This was four steps — make a collection, find it in the tree, select the
// photographs again because creating one changed the scope, then add them
// — and the selection is usually made *because* it is going somewhere. One
// press instead.
//
// Created at the top level rather than inside the current scope, unlike
// the tree's "+". A selection can be gathered from anywhere, including
// across collections, so filing it under whichever one happens to be open
// would put it somewhere its contents did not come from.
//
// The name arrives already chosen. It used to be a placeholder, with the
// sidebar's rename field opened straight afterwards to correct it — which
// on a tablet meant opening a field inside a panel that is instantiated but
// not drawn, so it took the on-screen keyboard and could never give it
// back. `library.slint`'s naming sheet asks first, and nothing reaches the
// catalog until it is answered.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
let visible = visible_ids.clone();
let reload = on_scope_changed.clone();
window.on_library_collection_from_selection(move |name| {
let Some(w) = weak.upgrade() else { return };
let images = ctl.selected();
if images.is_empty() {
return;
}
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else {
w.set_collection_error("Open a library first.".into());
return;
};
// The sheet refuses an empty name in two places, so this is
// belt-and-braces rather than a path the UI can reach — but a
// collection with no name at all is unfindable in the tree, and
// falling back to the placeholder is recoverable where an empty
// row is not.
let name = match name.trim() {
"" => unique_name(cat.connection(), None),
typed => typed.to_string(),
};
// The id is not carried out. It used to be, to open the rename
// field on the row it names; now the name is already right, and
// `refresh_tree` redraws the tree from the catalog either way.
let made = coll::create(cat.connection(), &name, None, CollectionKind::Manual)
.and_then(|id| coll::add_images(cat.connection(), id, &images));
match made {
Ok(added) => {
w.set_collection_error(slint::SharedString::new());
w.set_library_status(
format!(
"{added} photograph{} in {name}",
if added == 1 { "" } else { "s" }
)
.into(),
);
refresh_tree(&w, &ctl, cat);
drop(borrow);
// The selection has been filed; holding on to it invites
// the next press acting on photographs the user considers
// dealt with.
ctl.clear_selection();
sync_selection(&w, &ctl, &visible());
// No rename opened here. The name was chosen before the
// collection existed, so there is nothing left to correct —
// and opening the sidebar's field is what stranded the
// keyboard on a tablet.
reload();
}
Err(e) => {
drop(borrow);
w.set_collection_error(format!("making a collection: {e}").into());
}
}
});
}
// TRACES: FR-UI-2 | FR-UI-4
// The button half of selection mode. The long press is faster and this is
// the one that can be found — a gesture with no visible counterpart is a
// feature only its author knows about.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let visible = visible_ids.clone();
window.on_library_toggle_select_mode(move || {
let Some(w) = weak.upgrade() else { return };
let on = !ctl.select_mode.get();
ctl.select_mode.set(on);
w.set_library_select_mode(on);
*ctl.hold_timer.borrow_mut() = None;
// Leaving the mode drops the selection. "Done" reads as finishing
// with these photographs, and a selection that outlived the mode
// would still be acted on by the buttons in the header — which is
// how forty images get exported by a user who thought they had put
// them down.
if !on {
ctl.clear_selection();
sync_selection(&w, &ctl, &visible());
}
});
}
// TRACES: FR-CAT-7 | FR-UI-4
// File the selection in a collection without dragging it there.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
let visible = visible_ids.clone();
let reload = on_scope_changed.clone();
window.on_library_file_in_collection(move |id, moves| {
let Some(w) = weak.upgrade() else { return };
let target = CollectionId(id as u64);
let chosen = ctl.selected();
if chosen.is_empty() {
return;
}
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
// Moving is only a move when there is somewhere to move *from*.
// Unscoped, the sheet does not offer it; this guards the callback
// anyway, and degrades to filing rather than refusing.
let from = if moves { *ctl.scope.borrow() } else { None };
let result = match from {
Some(from) => coll::move_images(cat.connection(), from, target, &chosen),
None => coll::add_images(cat.connection(), target, &chosen),
};
match result {
Ok(filed) => {
w.set_collection_error(slint::SharedString::new());
// The honest count, as the drop reports it: claiming all
// twelve when nine were already there teaches the user to
// distrust every number the app shows them.
let msg = if from.is_some() {
format!("Moved {filed} of {}", chosen.len())
} else if filed == chosen.len() {
format!("Added {filed} to collection")
} else {
format!(
"Added {filed} of {} — the rest were already there",
chosen.len()
)
};
w.set_library_status(msg.into());
refresh_tree(&w, &ctl, cat);
sync_badges(&w, cat, &visible());
// A move changes what the collection on screen holds, so
// the grid has to be reread; filing elsewhere does not, but
// the badge count on every cell just changed and the reread
// is one query.
drop(borrow);
reload();
}
Err(e) => w.set_collection_error(format!("filing: {e}").into()),
}
});
}
}
/// Drag.
///
/// Slint owns the gesture (see the preamble). What is left here is the
/// payload — the image ids the drop will act on — and the spring.
fn wire_drag(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
visible_ids: &Rc<dyn Fn() -> Vec<ImageId>>,
on_scope_changed: &Rc<dyn Fn()>,
session: &Rc<dyn Fn() -> Option<dr_sync::Connection>>,
) {
// TRACES: FR-CAT-7
// **What arms the drag, not what it carries.**
//
// `DragArea` declines to start a drag while its `data` is empty, and it
// tests that on every pointer event that reaches it — the first one
// included, which arrives long before any drag. The binding in
// `library.slint` calls this callback, and a binding that calls a callback
// has nothing Slint can invalidate it on: it is evaluated once, when a
// finger first lands on that cell, and cached. `dragging` is empty at that
// moment and stays empty until `drag-started` fires.
//
// So this is answered at the wrong time, and always will be. That is
// harmless only because **`set_user_data` is called unconditionally**: an
// empty `Vec` is still user data, so the transfer is never `is_empty()` and
// the `DragArea` stays armed. Skipping the call for an empty selection —
// which looks like an obvious tidy-up — would disarm every cell a finger
// had ever touched outside a drag, and dragging would simply stop working
// with nothing to see.
//
// What the drop actually reads is `dragging`, set by `drag-started` and
// read back by `dropped-on`. `user_data` rather than plain text so that
// nothing outside the application can interpret it as a paste.
{
let ctl = ctl.clone();
window.on_library_drag_payload(move || {
let carried = ctl.dragging.borrow().clone();
let mut data = slint::DataTransfer::default();
data.set_user_data(Rc::new(carried));
data
});
}
// Dragging a collection row. The id travels in the payload for anything
// that cares to read it; the drop itself uses the remembered press, since
// it is handed only the target.
{
window.on_collection_drag_payload(move |id| {
let mut data = slint::DataTransfer::default();
data.set_user_data(Rc::new(CollectionId(id as u64)));
data
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
let visible = visible_ids.clone();
window.on_library_drag_started(move |row| {
let Some(w) = weak.upgrade() else { return };
let ids = visible();
// TRACES: FR-UI-4
// A drag is not a hold, and the timer must not outlive the press
// that armed it. Grabbing a cell and moving inside 450 ms left the
// hold armed underneath the drag, so it fired mid-gesture and put
// the grid into selection mode the user had not asked for — the
// drag finished into a mode that changed what every later tap
// meant. The pinch already cancels for exactly this reason.
*ctl.hold_timer.borrow_mut() = None;
// TRACES: FR-CAT-7
// And this press was not a tap, so it never gets to take anything
// out of the selection — see [`Press::Deferred`]. Dropped here as
// well as on the next press because the drag reads the selection
// one line below, and a removal still pending would be a
// photograph the user can see is selected and the drop would not
// carry.
ctl.pending_toggle.set(None);
// Dragging an *unselected* cell carries only that one, and makes it
// the selection — otherwise the images that travel are not the ones
// the user grabbed. Dragging a selected cell carries the whole
// selection, which is the multi-image gesture.
let carried: Vec<ImageId> = {
let mut selection = ctl.selection.borrow_mut();
match ids.get(row as usize) {
Some(id) if !selection.contains(id) => {
selection.clear();
selection.insert(*id);
vec![*id]
}
_ => selection.iter().copied().collect(),
}
};
// The bitmap under the cursor, built from the thumbnails already in
// the model — the drag carries what the user can see, and a cell
// whose preview has not landed yet contributes nothing rather than
// a placeholder.
let thumbs: Vec<slint::Image> = {
let model = w.get_library_cells();
carried
.iter()
.filter_map(|id| ids.iter().position(|v| v == id))
.filter_map(|row| model.row_data(row))
.filter(|c| c.has_thumb)
.map(|c| c.thumbnail)
.collect()
};
w.set_library_drag_image(compose_drag_image(&thumbs));
*ctl.dragging.borrow_mut() = carried.clone();
sync_selection(&w, &ctl, &ids);
// The lift-out: these cells fade and shrink in place, so the grid
// shows where the photographs came from while the cursor shows them
// in full colour.
sync_lifted(&w, &carried, &ids);
});
}
// A drag dwelling over a collapsed collection springs it open, so a nested
// child can be reached without putting the images down first.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog_for_spring = catalog.clone();
window.on_collection_drag_over(move |id, over| {
let Some(w) = weak.upgrade() else { return };
let id = CollectionId(id as u64);
if !over {
// Left this row. Cancel its pending expansion rather than
// letting it fire over whatever the pointer moved on to.
if *ctl.hover_id.borrow() == Some(id) {
*ctl.hover_id.borrow_mut() = None;
*ctl.spring_timer.borrow_mut() = None;
}
return;
}
*ctl.hover_id.borrow_mut() = Some(id);
let row = ctl.row_ids.borrow().iter().position(|&c| c == id);
arm_spring(&w, &ctl, &catalog_for_spring, row);
});
}
// A drop. Slint hit-tested the release and `can-drop` already refused a
// saved filter, so reaching here means this collection accepted.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
// No model refresh or reload here on purpose — see the note at the end
// of this handler. `drag-finished` owns those.
window.on_collection_dropped(move |id| {
let Some(w) = weak.upgrade() else { return };
let id = CollectionId(id as u64);
let carried = ctl.dragging.borrow().clone();
// Taken, not read: a remembered press must be spent by the drop it
// belongs to, or a later empty drop — a file dragged in from
// outside, say — would move a collection nobody touched.
let pressed = ctl.dragging_collection.borrow_mut().take();
let carried = match decide_drop(&carried, pressed, id) {
Drop::Nothing => return,
Drop::Reparent(source) => {
let result = {
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
coll::set_parent(cat.connection(), source, Some(id))
};
match result {
Ok(()) => {
w.set_collection_error(slint::SharedString::new());
w.set_library_status("Moved collection".into());
let borrow = catalog.borrow();
if let Some(cat) = borrow.as_ref() {
refresh_tree(&w, &ctl, cat);
}
}
// The catalog refuses a cycle rather than letting the
// tree walk spin. Saying so is better than a drop that
// silently does nothing.
Err(e) => w.set_collection_error(format!("moving collection: {e}").into()),
}
return;
}
Drop::FileImages(images) => images,
};
// Scoped so the borrow is released before the spring cleanup below,
// which needs the catalog itself.
let result = {
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
coll::add_images(cat.connection(), id, &carried)
};
match result {
Ok(added) => {
w.set_collection_error(slint::SharedString::new());
// "Added 3 of 12" is the honest report when nine were
// already there; claiming 12 would teach the user to
// distrust the count.
let msg = if added == carried.len() {
format!("Added {added} to collection")
} else {
format!(
"Added {added} of {} — the rest were already there",
carried.len()
)
};
w.set_library_status(msg.into());
}
Err(e) => w.set_collection_error(format!("adding to collection: {e}").into()),
}
// Recorded, not acted on. Every visible consequence — rebuilding
// the tree, refreshing the badges, rereading the grid — happens in
// `drag-finished`, because all three replace models that Slint is
// *currently walking* to deliver this very event. Tearing down a
// live `DropArea` from inside its own `dropped` handler is the same
// hazard `sync_rows` in lib.rs documents for the adjust panel.
*ctl.dropped_on.borrow_mut() = Some(id);
});
}
// The drag ended: dropped, or abandoned. This is where the consequences of
// a drop land, once Slint has finished with the elements involved.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
let visible = visible_ids.clone();
let reload = on_scope_changed.clone();
let session = session.clone();
window.on_library_drag_finished(move || {
let Some(w) = weak.upgrade() else { return };
let landed = ctl.dropped_on.borrow_mut().take();
let to_trash = ctl.trash_requested.borrow_mut().take();
ctl.dragging.borrow_mut().clear();
// The grid clears this itself on the cancel that starts a drag;
// this is for the endings that reach no cell — a drop, or a drag
// abandoned over nothing.
w.set_library_held_row(-1);
*ctl.hover_id.borrow_mut() = None;
*ctl.spring_timer.borrow_mut() = None;
// A soft delete, deferred out of the drop handler so the models it
// replaces are no longer being walked.
if let Some(images) = to_trash {
start_trash(&w, &ctl, &catalog, &session, &images, &reload);
}
// The cells settle back into the grid, and the cursor bitmap is
// released — it holds a copy of every thumbnail it composited.
sync_lifted(&w, &[], &visible());
w.set_library_drag_image(slint::Image::default());
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.
fn wire_trash(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
session: &Rc<dyn Fn() -> Option<dr_sync::Connection>>,
on_scope_changed: &Rc<dyn Fn()>,
) {
{
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.
fn wire_trash_from_grid(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
session: &Rc<dyn Fn() -> Option<dr_sync::Connection>>,
on_scope_changed: &Rc<dyn Fn()>,
visible_ids: &Rc<dyn Fn() -> Vec<ImageId>>,
) {
// The trash glyph on one cell. Acts on that photograph alone: the pointer
// named it, and a click that silently trashed an entire selection would be
// exactly the trap the strip's other targets are laid out to avoid.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
let session = session.clone();
let reload = on_scope_changed.clone();
let visible = visible_ids.clone();
window.on_library_cell_trashed(move |row| {
let Some(w) = weak.upgrade() else { return };
// Resolved through the visible ids rather than the row index alone:
// the grid is a window over the catalog, so a stale index from
// before a scroll would name a different photograph — and here that
// would move the wrong file.
let Some(&id) = visible().get(row as usize) else {
return;
};
start_trash(&w, &ctl, &catalog, &session, &[id], &reload);
});
}
// `Delete` on the selection — the bulk gesture.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
let session = session.clone();
let reload = on_scope_changed.clone();
window.on_library_trash_selection(move || {
let Some(w) = weak.upgrade() else { return };
let chosen = ctl.selected();
if chosen.is_empty() {
// A keystroke that does nothing reads as a broken key, so it
// says why rather than failing silently.
w.set_library_status("Select an image first".into());
return;
}
start_trash(&w, &ctl, &catalog, &session, &chosen, &reload);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
let session = session.clone();
let reload = on_scope_changed.clone();
window.on_trash_empty(move || {
let Some(w) = weak.upgrade() else { return };
let conn = match session() {
Some(c) => c,
None => return,
};
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
// Everything in the trash, with the path and stable id each delete
// needs. Read here rather than in the worker: the catalog is not
// `Send`, and the worker opens its own connection only to write back.
let listed = match dr_catalog::trash::list(cat.connection(), usize::MAX) {
Ok(l) => l,
Err(e) => {
w.set_collection_error(format!("reading trash: {e}").into());
return;
}
};
if listed.is_empty() {
return;
}
let paths: Vec<(ImageId, Option<u64>, String)> = listed
.iter()
.map(|t| (t.image_id, t.file_id, t.source_ref.clone()))
.collect();
let ids: Vec<ImageId> = listed.iter().map(|t| t.image_id).collect();
log::info!("emptying trash: {} image(s)", ids.len());
w.set_library_status(format!("Deleting {} image(s)…", ids.len()).into());
let count = ids.len();
let rx = crate::trash::spawn_purge(
conn.clone(),
ids,
paths,
crate::library::catalog_path(&conn.account),
crate::library::thumbs_dir(&conn.account),
);
drain_trash(
w.as_weak(),
ctl.clone(),
catalog.clone(),
rx,
reload.clone(),
format!("Deleting {count} photograph(s) permanently"),
);
});
}
}
+698
View File
@@ -0,0 +1,698 @@
//! Sidebar-facing wiring: tree navigation, creating and renaming
//! collections, removing images from the one on screen, the row's own
//! hold-drag, its context menu, and the membership sheet.
//!
//! Split from [`super::wiring_grid`] because that half is about the grid
//! and this half is about the tree; [`super::wire`] calls into both.
use std::cell::RefCell;
use std::rc::Rc;
use dr_catalog::collections::{self as coll};
use dr_catalog::Catalog;
use dr_types::{CollectionId, ImageId};
use slint::{ComponentHandle, Model as _};
use crate::AppWindow;
use super::controller::{
decide_delete, decide_release, CollectionsController, DeleteStep, Release,
};
use super::drag::HOLD_DELAY_MS;
use super::rename_menu::{
apply_rename, close_rename, close_row_menu, create_child, open_row_menu, Rename,
};
use super::tree_sync::{
direct_holdings, refresh_membership, refresh_tree, sync_badges, sync_reorderable,
};
/// Tree navigation.
pub(super) fn wire_tree_navigation(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
on_scope_changed: &Rc<dyn Fn()>,
) {
{
let weak = window.as_weak();
let ctl = ctl.clone();
let reload = on_scope_changed.clone();
window.on_collection_select(move |id| {
let Some(w) = weak.upgrade() else { return };
// -1 is the trash. It is not a collection, so it clears `scope`
// rather than setting it — see `viewing_trash`.
ctl.viewing_trash.set(id == -1);
*ctl.scope.borrow_mut() = if id <= 0 {
None
} else {
Some(CollectionId(id as u64))
};
// A selection the user cannot see is one they will act on by
// accident, and the new scope shows different images.
ctl.clear_selection();
let label = if id == -1 {
"Trash".to_string()
} else if id == 0 {
String::new()
} else {
let rows = w.get_collection_rows();
(0..rows.row_count())
.filter_map(|i| rows.row_data(i))
.find(|r| r.id == id)
.map(|r| r.name.to_string())
.unwrap_or_default()
};
w.set_collection_selected(id);
w.set_collection_scope_label(label.into());
sync_reorderable(&w);
reload();
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
window.on_collection_toggle(move |id| {
let Some(w) = weak.upgrade() else { return };
let id = CollectionId(id as u64);
{
let mut collapsed = ctl.collapsed.borrow_mut();
if !collapsed.remove(&id) {
collapsed.insert(id);
}
}
let borrow = catalog.borrow();
if let Some(cat) = borrow.as_ref() {
refresh_tree(&w, &ctl, cat);
}
});
}
}
/// Create.
pub(super) fn wire_create(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
) {
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
window.on_collection_new(move || {
let Some(w) = weak.upgrade() else { return };
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else {
w.set_collection_error("Open a library first.".into());
return;
};
// Created inside whatever is selected, which is how a hierarchy
// gets built without leaving the header: select the parent, press
// +. The row menu aims the same act at a row instead.
create_child(&w, &ctl, cat, *ctl.scope.borrow());
});
}
}
/// Rename.
///
/// Inline in the row, opened by a double-click or `F2`. The gesture is worth
/// the field rather than a dialog: renaming is how a hierarchy gets tidied,
/// and it is done in runs of several — a modal per collection would make
/// that a chore.
pub(super) fn wire_rename(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
) {
{
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.
pub(super) fn wire_remove(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
visible_ids: &Rc<dyn Fn() -> Vec<ImageId>>,
on_scope_changed: &Rc<dyn Fn()>,
) {
{
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()),
}
});
}
}
/// The tree row's own hold-drag: lift a row, drag it onto another to
/// reparent, or onto the root to un-nest it.
pub(super) fn wire_row_drag(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
) {
// TRACES: FR-CAT-7 | FR-UI-2 | FR-UI-3 | FR-UI-4
// The hold on a sidebar row: it arms the drag that rearranges the tree,
// and it opens the row menu. Which one the user gets is decided on release
// by whether they moved.
//
// Both from one gesture because there is only one to spend. A finger has
// no right button, and this tree lives in a Flickable that claims any drag
// beginning inside it — so without a hold, a touch drag on a row is a
// scroll, and arranging the tree is a pointer-only feature. The row lifts
// the moment the timer fires, which is what tells the user the next
// movement will carry the collection rather than scroll past it.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_collection_row_press(move |id, down| {
let Some(w) = weak.upgrade() else { return };
let id = CollectionId(id as u64);
if !down {
// Let go. A drag that began has already been dealt with by the
// drop; a hold that did not move is a request for the menu.
*ctl.row_hold_timer.borrow_mut() = None;
let held = ctl.lifted.borrow_mut().take();
let dragged = ctl.drag_began.replace(false);
w.set_collection_lifted(0);
if let Release::OpenMenu(held) = decide_release(held, dragged) {
w.invoke_collection_menu(held.0 as i32);
}
return;
}
// A drag of this row, if one follows, carries this collection.
ctl.note_row_press(id);
ctl.drag_began.set(false);
let timer = slint::Timer::default();
let weak = w.as_weak();
let ctl_cb = ctl.clone();
timer.start(
slint::TimerMode::SingleShot,
std::time::Duration::from_millis(HOLD_DELAY_MS),
move || {
let Some(w) = weak.upgrade() else { return };
// Picked up, not yet acted on. The menu waits for the
// release, because opening it here would put a sheet over
// the tree the user may be about to drag this row across.
*ctl_cb.lifted.borrow_mut() = Some(id);
w.set_collection_lifted(id.0 as i32);
},
);
*ctl.row_hold_timer.borrow_mut() = Some(timer);
});
}
// TRACES: FR-CAT-7 | FR-UI-4
// A row's drag crossed the threshold, or ended.
//
// This is what turns a hold into a rearrangement: once it has fired, the
// release that follows opens no menu. It also decides whether
// "All photographs" will accept the drop, which only makes sense for a
// collection that has a parent to be taken out of.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_collection_drag_active(move |id, active| {
let Some(w) = weak.upgrade() else { return };
let id = CollectionId(id as u64);
if !active {
w.set_collection_root_drop_allowed(false);
w.set_collection_lifted(0);
*ctl.lifted.borrow_mut() = None;
return;
}
// The hold may not have fired — a pointer drag needs no hold — so
// the pick-up is recorded here as well as there, and the row lifts
// either way.
ctl.drag_began.set(true);
ctl.note_row_press(id);
*ctl.lifted.borrow_mut() = Some(id);
w.set_collection_lifted(id.0 as i32);
// The hold has been spent on a drag; nothing may fire behind it.
*ctl.row_hold_timer.borrow_mut() = None;
// Only a nested collection has a top level to be returned to, and
// only a collection drag — never photographs — means anything
// there at all.
let carrying_images = !ctl.dragging.borrow().is_empty();
let nested = ctl
.row_ids
.borrow()
.iter()
.position(|&c| c == id)
.and_then(|row| ctl.row_nested.borrow().get(row).copied())
.unwrap_or(false);
w.set_collection_root_drop_allowed(nested && !carrying_images);
});
}
// TRACES: FR-CAT-7
// Dropped on "All photographs": out to the top level.
//
// The gestural inverse of dropping one row onto another. `can-drop` has
// already refused everything but a nested collection, so reaching here
// means the drop is one — but the payload is taken rather than read, for
// the reason the row's own drop gives: a remembered press must be spent by
// the drop it belongs to, or a later empty drop moves a collection nobody
// touched.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
window.on_collection_dropped_on_root(move || {
let Some(w) = weak.upgrade() else { return };
let source = ctl.dragging_collection.borrow_mut().take();
let Some(source) = source else { return };
// Photographs cannot land here. Guarded as well as refused in
// `can-drop`, so a payload that somehow arrives does nothing
// rather than reparenting whatever row was last pressed.
if !ctl.dragging.borrow().is_empty() {
return;
}
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
let moved = coll::set_parent(cat.connection(), source, None);
match moved {
Ok(()) => {
w.set_collection_error(slint::SharedString::new());
w.set_library_status("Moved to the top level".into());
refresh_tree(&w, &ctl, cat);
log::info!("promoted collection {} by drag", source.0);
}
Err(e) => w.set_collection_error(format!("moving collection: {e}").into()),
}
});
}
}
/// The row menu.
///
/// Right-click, or a long press on touch. This gesture used to *delete an
/// empty collection outright* and refuse with an error message otherwise,
/// which was wrong in both directions at once: the destructive half fired
/// with no confirmation and nothing on screen said it would, and the
/// refusing half meant a collection holding anything could not be deleted
/// at all — the user emptied it by hand and then did the same thing.
pub(super) fn wire_row_menu(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
on_scope_changed: &Rc<dyn Fn()>,
visible_ids: &Rc<dyn Fn() -> Vec<ImageId>>,
) {
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
window.on_collection_menu(move |id| {
let Some(w) = weak.upgrade() else { return };
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
open_row_menu(&w, &ctl, cat, CollectionId(id as u64));
});
}
// Rename, from the menu into the row's own inline field.
//
// The menu closes first and the field opens second, and the order is
// load-bearing for the same reason it is in `collection-new`: the field is
// focused by an `init` on a row, and a row torn down by a model rebuild
// takes the focus with it. Here the tear-down is the sheet's, and a field
// opened behind a sheet that is still up is a field the user cannot see
// they are typing into.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_collection_menu_rename(move || {
let Some(w) = weak.upgrade() else { return };
let Some(id) = *ctl.menu_for.borrow() else {
return;
};
close_row_menu(&w, &ctl);
*ctl.renaming.borrow_mut() = Some(id);
w.set_collection_renaming(id.0 as i32);
});
}
// A new collection nested inside this one.
//
// The `+` in the header already creates inside whatever the grid is scoped
// to, which builds a hierarchy only if you first go and look at the parent.
// This is the same act aimed at a row, so a tree can be built from the tree.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
window.on_collection_menu_new_child(move || {
let Some(w) = weak.upgrade() else { return };
let Some(parent) = *ctl.menu_for.borrow() else {
return;
};
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
close_row_menu(&w, &ctl);
create_child(&w, &ctl, cat, Some(parent));
});
}
// TRACES: FR-CAT-7
// Un-nesting: back out to the top level.
//
// Nesting has had a gesture since collections landed — drag a row onto
// another row — and its inverse had none, in either direction: "All
// photographs" ignores drops on purpose (an image is already in the
// library), and no menu existed to ask. So a collection dragged into
// another was in there permanently, and the only way out was to delete it
// and build it again.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
window.on_collection_menu_promote(move || {
let Some(w) = weak.upgrade() else { return };
let Some(id) = *ctl.menu_for.borrow() else {
return;
};
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
match coll::set_parent(cat.connection(), id, None) {
Ok(()) => {
w.set_collection_error(slint::SharedString::new());
w.set_library_status("Moved to the top level".into());
close_row_menu(&w, &ctl);
refresh_tree(&w, &ctl, cat);
log::info!("promoted collection {} to the top level", id.0);
}
// Left open on failure, showing the collection it failed on:
// closing would leave a red line referring to a row the user
// can no longer tell was the one they aimed at.
Err(e) => w.set_collection_error(format!("moving collection: {e}").into()),
}
});
}
// TRACES: FR-NC-6a
// The offline question, handed to the handler the tray on the row already
// uses. Routed through the window's own callback rather than reaching into
// `library_ui`'s prompt directly: that module owns the transfer state the
// prompt reads, and a second entry point into it is a second place for the
// two to disagree about what is downloading.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_collection_menu_offline(move || {
let Some(w) = weak.upgrade() else { return };
let Some(id) = *ctl.menu_for.borrow() else {
return;
};
close_row_menu(&w, &ctl);
w.invoke_collection_offline_menu(id.0 as i32);
});
}
// TRACES: FR-CAT-7
// Delete, in one press or two — see [`decide_delete`].
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
let reload = on_scope_changed.clone();
let visible = visible_ids.clone();
window.on_collection_menu_delete(move || {
let Some(w) = weak.upgrade() else { return };
let Some(id) = *ctl.menu_for.borrow() else {
return;
};
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
let (holds, children) = direct_holdings(cat.connection(), id).unwrap_or((0, 0));
if decide_delete(holds, children, ctl.menu_confirming.get()) == DeleteStep::Confirm {
// Asked once. The sheet swaps its other actions for the
// warning, so the second press cannot be a mis-aimed first one.
ctl.menu_confirming.set(true);
w.set_collection_menu_confirming(true);
w.set_collection_menu_delete_label("Delete anyway".into());
return;
}
// Hoisted out of the `match` rather than called in the scrutinee:
// the borrow `cat` comes from lives as long as the match does, and
// the arm below has to release it before rereading the grid.
let deleted = coll::delete(cat.connection(), id);
match deleted {
Ok(()) => {
w.set_collection_error(slint::SharedString::new());
close_row_menu(&w, &ctl);
// The grid was showing what no longer exists.
let was_scope = *ctl.scope.borrow() == Some(id);
if was_scope {
*ctl.scope.borrow_mut() = None;
w.set_collection_selected(0);
w.set_collection_scope_label(slint::SharedString::new());
}
refresh_tree(&w, &ctl, cat);
// Named as a membership change, as the removal button is:
// a user who reads this as a delete of their photographs
// will not trust the feature again.
w.set_library_status(if holds > 0 {
format!(
"Deleted the collection; its {holds} photograph(s) stay in the library"
)
.into()
} else {
slint::SharedString::from("Deleted the collection")
});
log::info!("deleted collection {}", id.0);
// The badge on every visible cell just lost a collection,
// and a scope that has gone needs the grid rereading.
let cells = visible();
sync_badges(&w, cat, &cells);
drop(borrow);
if was_scope {
reload();
}
}
Err(e) => w.set_collection_error(format!("deleting: {e}").into()),
}
});
}
// Dismiss. A pending confirmation is backed out of one step rather than
// closing the whole menu: Escape from "are you sure" means "no", and
// taking the sheet away with it would leave the user unsure whether the
// key had cancelled the delete or performed it.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_collection_menu_dismiss(move || {
let Some(w) = weak.upgrade() else { return };
if ctl.menu_confirming.get() {
ctl.menu_confirming.set(false);
w.set_collection_menu_confirming(false);
w.set_collection_menu_delete_label("Delete".into());
return;
}
close_row_menu(&w, &ctl);
});
}
}
/// Where the selection is filed.
pub(super) fn wire_membership(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
visible_ids: &Rc<dyn Fn() -> Vec<ImageId>>,
on_scope_changed: &Rc<dyn Fn()>,
) {
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
window.on_library_open_membership(move || {
let Some(w) = weak.upgrade() else { return };
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
refresh_membership(&w, &ctl, cat);
w.set_membership_open(true);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
let visible = visible_ids.clone();
let reload = on_scope_changed.clone();
window.on_membership_remove(move |id| {
let Some(w) = weak.upgrade() else { return };
let target = CollectionId(id as u64);
let chosen = ctl.selected();
if chosen.is_empty() {
return;
}
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
// Hoisted for the same reason the delete above is: the arm
// releases the catalog borrow before it rereads the grid.
let removed = coll::remove_images(cat.connection(), target, &chosen);
match removed {
Ok(n) => {
w.set_collection_error(slint::SharedString::new());
// The same wording the scoped button uses, and for the same
// reason: this is a membership change, not a delete.
w.set_library_status(
format!("Removed {n} from that collection; still in the library").into(),
);
// The sheet stays open. Taking photographs out of several
// collections is one job, and a sheet that closed after
// each would have to be reopened — with the selection
// still live — to finish it.
refresh_membership(&w, &ctl, cat);
refresh_tree(&w, &ctl, cat);
let cells = visible();
sync_badges(&w, cat, &cells);
// Only the collection on screen changes what the grid
// holds. Removing from another one leaves the grid right,
// and rereading it would scroll the user's place away.
let showing = *ctl.scope.borrow() == Some(target);
drop(borrow);
if showing {
reload();
}
}
Err(e) => w.set_collection_error(format!("removing: {e}").into()),
}
});
}
{
let weak = window.as_weak();
window.on_membership_dismiss(move || {
let Some(w) = weak.upgrade() else { return };
w.set_membership_open(false);
});
}
}