Files

999 lines
43 KiB
Rust

//! 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, Collections, Library};
use super::controller::{decide_drop, CollectionsController, Drop};
use super::drag::{
arm_hold, arm_spring, collapse_spring_opened, compose_drag_image, drag_image_via_file,
forget_drag_image_file,
};
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
.global::<Library>()
.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.global::<Library>().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
.global::<Library>()
.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
.global::<Library>()
.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
.global::<Library>()
.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.global::<Library>().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.global::<Collections>().set_collection_error(
format!("reading the collection's order: {e}").into(),
);
return;
}
};
let wanted = library::reordered(&current, &moving, target, after);
match coll::set_order(cat.connection(), scope, &wanted) {
Ok(()) => {
w.global::<Collections>()
.set_collection_error(slint::SharedString::new());
w.global::<Library>().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.global::<Collections>()
.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.global::<Library>().on_library_select_all(move || {
let Some(w) = weak.upgrade() else { return };
let ids = visible();
let total = w.global::<Library>().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
.global::<Library>()
.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.global::<Collections>()
.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.global::<Collections>()
.set_collection_error(slint::SharedString::new());
w.global::<Library>().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.global::<Collections>()
.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
.global::<Library>()
.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.global::<Library>().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
.global::<Library>()
.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.global::<Collections>()
.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.global::<Library>().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
.global::<Collections>()
.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.global::<Library>().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
.global::<Collections>()
.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
.global::<Library>()
.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.global::<Library>().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.global::<Library>()
.set_library_drag_image(drag_image_via_file(compose_drag_image(&thumbs)));
*ctl.dragging.borrow_mut() = carried.clone();
sync_selection(&w, &ctl, &ids);
// The lift-out: these cells fade and shrink in place, so the grid
// shows where the photographs came from while the cursor shows them
// in full colour.
sync_lifted(&w, &carried, &ids);
});
}
// A drag dwelling over a collapsed collection springs it open, so a nested
// child can be reached without putting the images down first.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog_for_spring = catalog.clone();
window
.global::<Collections>()
.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
.global::<Collections>()
.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.global::<Collections>()
.set_collection_error(slint::SharedString::new());
w.global::<Library>()
.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
.global::<Collections>()
.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.global::<Collections>()
.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.global::<Library>().set_library_status(msg.into());
}
Err(e) => w
.global::<Collections>()
.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
.global::<Library>()
.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.global::<Library>().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.global::<Library>()
.set_library_drag_image(slint::Image::default());
forget_drag_image_file();
if landed.is_some() {
let borrow = catalog.borrow();
if let Some(cat) = borrow.as_ref() {
// Counts changed on the target and every ancestor, and the
// dropped images now carry one more collection badge.
refresh_tree(&w, &ctl, cat);
sync_badges(&w, cat, &visible());
}
}
// Put back whatever the spring opened on the way. The collection
// that received the images — and its ancestors — stay open, since
// that is where the user is now working; on an abandoned drag
// `landed` is `None` and everything closes.
collapse_spring_opened(&w, &ctl, &catalog, landed);
// A drop into the collection currently being shown changes what that
// collection holds, so the grid has to be reread.
if landed.is_some() && landed == *ctl.scope.borrow() {
reload();
}
});
}
}
/// Trash.
///
/// TRACES: FR-CAT-15
/// A drop here is a *soft delete*: the file moves to a trash folder on the
/// server and the catalog records where it came from. Nothing is destroyed
/// until the user empties it, which is a separate, deliberate action.
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.global::<Collections>().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.global::<Collections>()
.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.global::<Collections>().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
.global::<Library>()
.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
.global::<Library>()
.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.global::<Library>()
.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.global::<Collections>().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.global::<Collections>()
.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.global::<Library>()
.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"),
);
});
}
}