999 lines
43 KiB
Rust
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(¤t, &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"),
|
|
);
|
|
});
|
|
}
|
|
}
|