Merge: collections_ui split into a module directory
This commit is contained in:
+11
-11
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)]);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(¤t, &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"),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user