//! 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( window: &AppWindow, ctl: Rc, catalog: Rc>>, on_scope_changed: S, visible_ids: R, span_ids: P, session: C, ) where S: Fn() + 'static, R: Fn() -> Vec + 'static, P: Fn(usize, usize) -> Vec + 'static, C: Fn() -> Option + '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 = Rc::new(on_scope_changed); let visible_ids: Rc Vec> = 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 Option> = 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, catalog: &Rc>>, visible_ids: &Rc Vec>, on_scope_changed: &Rc, ) { { let weak = window.as_weak(); let ctl = ctl.clone(); let visible = visible_ids.clone(); window .global::() .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::().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::() .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::() .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::() .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::().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::().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::() .set_collection_error(slint::SharedString::new()); w.global::().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::() .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::().on_library_select_all(move || { let Some(w) = weak.upgrade() else { return }; let ids = visible(); let total = w.global::().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, catalog: &Rc>>, visible_ids: &Rc Vec>, on_scope_changed: &Rc, ) { // 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::() .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::() .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::() .set_collection_error(slint::SharedString::new()); w.global::().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::() .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::() .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::().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::() .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::() .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::().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::() .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, catalog: &Rc>>, visible_ids: &Rc Vec>, on_scope_changed: &Rc, session: &Rc Option>, ) { // 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::().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::() .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::() .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 = { 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 = { let model = w.global::().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::() .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::() .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::() .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::() .set_collection_error(slint::SharedString::new()); w.global::() .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::() .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::() .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::().set_library_status(msg.into()); } Err(e) => w .global::() .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::() .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::().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::() .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, catalog: &Rc>>, session: &Rc Option>, on_scope_changed: &Rc, ) { { let weak = window.as_weak(); let ctl = ctl.clone(); let session = session.clone(); window.global::().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::() .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::().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, catalog: &Rc>>, session: &Rc Option>, on_scope_changed: &Rc, visible_ids: &Rc Vec>, ) { // 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::() .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::() .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::() .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::().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::() .set_collection_error(format!("reading trash: {e}").into()); return; } }; if listed.is_empty() { return; } let paths: Vec<(ImageId, Option, String)> = listed .iter() .map(|t| (t.image_id, t.file_id, t.source_ref.clone())) .collect(); let ids: Vec = listed.iter().map(|t| t.image_id).collect(); log::info!("emptying trash: {} image(s)", ids.len()); w.global::() .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"), ); }); } }