//! Sidebar-facing wiring: tree navigation, creating and renaming //! collections, removing images from the one on screen, the row's own //! hold-drag, its context menu, and the membership sheet. //! //! Split from [`super::wiring_grid`] because that half is about the grid //! and this half is about the tree; [`super::wire`] calls into both. use std::cell::RefCell; use std::rc::Rc; use dr_catalog::collections::{self as coll}; use dr_catalog::Catalog; use dr_types::{CollectionId, ImageId}; use slint::{ComponentHandle, Model as _}; use crate::{AppWindow, Collections, Library}; use super::controller::{ decide_delete, decide_release, CollectionsController, DeleteStep, Release, }; use super::drag::HOLD_DELAY_MS; use super::rename_menu::{ apply_rename, close_rename, close_row_menu, create_child, open_row_menu, Rename, }; use super::tree_sync::{ direct_holdings, refresh_membership, refresh_tree, sync_badges, sync_reorderable, }; /// Tree navigation. pub(super) fn wire_tree_navigation( window: &AppWindow, ctl: &Rc, catalog: &Rc>>, on_scope_changed: &Rc, ) { { let weak = window.as_weak(); let ctl = ctl.clone(); let reload = on_scope_changed.clone(); window.on_collection_select(move |id| { let Some(w) = weak.upgrade() else { return }; // -1 is the trash. It is not a collection, so it clears `scope` // rather than setting it — see `viewing_trash`. ctl.viewing_trash.set(id == -1); *ctl.scope.borrow_mut() = if id <= 0 { None } else { Some(CollectionId(id as u64)) }; // A selection the user cannot see is one they will act on by // accident, and the new scope shows different images. ctl.clear_selection(); let label = if id == -1 { "Trash".to_string() } else if id == 0 { String::new() } else { let rows = w.global::().get_collection_rows(); (0..rows.row_count()) .filter_map(|i| rows.row_data(i)) .find(|r| r.id == id) .map(|r| r.name.to_string()) .unwrap_or_default() }; w.set_collection_selected(id); w.global::() .set_collection_scope_label(label.into()); sync_reorderable(&w); reload(); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); let catalog = catalog.clone(); window .global::() .on_collection_toggle(move |id| { let Some(w) = weak.upgrade() else { return }; let id = CollectionId(id as u64); { let mut collapsed = ctl.collapsed.borrow_mut(); if !collapsed.remove(&id) { collapsed.insert(id); } } let borrow = catalog.borrow(); if let Some(cat) = borrow.as_ref() { refresh_tree(&w, &ctl, cat); } }); } } /// Create. pub(super) fn wire_create( window: &AppWindow, ctl: &Rc, catalog: &Rc>>, ) { { let weak = window.as_weak(); let ctl = ctl.clone(); let catalog = catalog.clone(); window.global::().on_collection_new(move || { let Some(w) = weak.upgrade() else { return }; let borrow = catalog.borrow(); let Some(cat) = borrow.as_ref() else { w.global::() .set_collection_error("Open a library first.".into()); return; }; // Created inside whatever is selected, which is how a hierarchy // gets built without leaving the header: select the parent, press // +. The row menu aims the same act at a row instead. create_child(&w, &ctl, cat, *ctl.scope.borrow()); }); } } /// Rename. /// /// Inline in the row, opened by a double-click or `F2`. The gesture is worth /// the field rather than a dialog: renaming is how a hierarchy gets tidied, /// and it is done in runs of several — a modal per collection would make /// that a chore. pub(super) fn wire_rename( window: &AppWindow, ctl: &Rc, catalog: &Rc>>, ) { { let weak = window.as_weak(); let ctl = ctl.clone(); window .global::() .on_collection_rename_start(move |id| { let Some(w) = weak.upgrade() else { return }; // `F2` arrives with whatever the sidebar has selected, which may be // "All photographs" (0) or the trash (-1). Neither has a name to // change, so the key does nothing rather than opening a field on a // row that is not a collection. if id <= 0 { return; } let id = CollectionId(id as u64); // A saved filter is renameable like any other — its *membership* is // computed, its name is not — so there is no kind check here. *ctl.renaming.borrow_mut() = Some(id); w.global::() .set_collection_renaming(id.0 as i32); w.global::() .set_collection_error(slint::SharedString::new()); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); let catalog = catalog.clone(); window .global::() .on_collection_rename_commit(move |id, name| { let Some(w) = weak.upgrade() else { return }; let id = CollectionId(id as u64); // The field reports a commit when it loses focus as well as on // Enter, so a second one can arrive for a rename already closed — // Enter commits, and the focus the field then gives up commits // again. Ignored rather than reapplied: the second would bump the // revision for no change and beat a real edit on another device. if *ctl.renaming.borrow() != Some(id) { return; } let borrow = catalog.borrow(); let Some(cat) = borrow.as_ref() else { return }; match apply_rename(cat.connection(), id, name.as_str()) { Ok(Rename::Applied(name)) => { close_rename(&w, &ctl); w.global::() .set_collection_error(slint::SharedString::new()); refresh_tree(&w, &ctl, cat); // The header names the collection being shown, so a rename // of the current scope has to reach it too. if *ctl.scope.borrow() == Some(id) { w.global::() .set_collection_scope_label(name.as_str().into()); } log::info!("renamed collection {} to {name}", id.0); } Ok(Rename::Unchanged) => close_rename(&w, &ctl), Err(e) => { // The field stays open on what the user typed. Closing it // would drop their text and leave the old name showing, // with only a line of red to explain where it went. w.global::() .set_collection_error(format!("renaming: {e}").into()); } } }); } { let weak = window.as_weak(); let ctl = ctl.clone(); window .global::() .on_collection_rename_cancel(move || { let Some(w) = weak.upgrade() else { return }; close_rename(&w, &ctl); w.global::() .set_collection_error(slint::SharedString::new()); }); } } /// Remove from the collection being shown. pub(super) fn wire_remove( window: &AppWindow, ctl: &Rc, catalog: &Rc>>, visible_ids: &Rc Vec>, on_scope_changed: &Rc, ) { { 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_remove_from_collection(move || { let Some(w) = weak.upgrade() else { return }; let Some(scope) = *ctl.scope.borrow() else { // Unscoped, there is no collection to remove from. The button // is hidden in that state; this guards the callback anyway. return; }; let chosen = ctl.selected(); if chosen.is_empty() { return; } let borrow = catalog.borrow(); let Some(cat) = borrow.as_ref() else { return }; match coll::remove_images(cat.connection(), scope, &chosen) { Ok(n) => { w.global::() .set_collection_error(slint::SharedString::new()); // Named explicitly as a membership change: the images are // still in the library, and a user who reads this as a // delete will not trust the feature again. w.global::().set_library_status( format!("Removed {n} from this collection; still in the library") .into(), ); ctl.clear_selection(); refresh_tree(&w, &ctl, cat); sync_badges(&w, cat, &visible()); reload(); } Err(e) => w .global::() .set_collection_error(format!("removing: {e}").into()), } }); } } /// The tree row's own hold-drag: lift a row, drag it onto another to /// reparent, or onto the root to un-nest it. pub(super) fn wire_row_drag( window: &AppWindow, ctl: &Rc, catalog: &Rc>>, ) { // TRACES: FR-CAT-7 | FR-UI-2 | FR-UI-3 | FR-UI-4 // The hold on a sidebar row: it arms the drag that rearranges the tree, // and it opens the row menu. Which one the user gets is decided on release // by whether they moved. // // Both from one gesture because there is only one to spend. A finger has // no right button, and this tree lives in a Flickable that claims any drag // beginning inside it — so without a hold, a touch drag on a row is a // scroll, and arranging the tree is a pointer-only feature. The row lifts // the moment the timer fires, which is what tells the user the next // movement will carry the collection rather than scroll past it. { let weak = window.as_weak(); let ctl = ctl.clone(); window .global::() .on_collection_row_press(move |id, down| { let Some(w) = weak.upgrade() else { return }; let id = CollectionId(id as u64); if !down { // Let go. A drag that began has already been dealt with by the // drop; a hold that did not move is a request for the menu. *ctl.row_hold_timer.borrow_mut() = None; let held = ctl.lifted.borrow_mut().take(); let dragged = ctl.drag_began.replace(false); w.global::().set_collection_lifted(0); if let Release::OpenMenu(held) = decide_release(held, dragged) { w.global::() .invoke_collection_menu(held.0 as i32); } return; } // A drag of this row, if one follows, carries this collection. ctl.note_row_press(id); ctl.drag_began.set(false); let timer = slint::Timer::default(); let weak = w.as_weak(); let ctl_cb = ctl.clone(); timer.start( slint::TimerMode::SingleShot, std::time::Duration::from_millis(HOLD_DELAY_MS), move || { let Some(w) = weak.upgrade() else { return }; // Picked up, not yet acted on. The menu waits for the // release, because opening it here would put a sheet over // the tree the user may be about to drag this row across. *ctl_cb.lifted.borrow_mut() = Some(id); w.global::().set_collection_lifted(id.0 as i32); }, ); *ctl.row_hold_timer.borrow_mut() = Some(timer); }); } // TRACES: FR-CAT-7 | FR-UI-4 // A row's drag crossed the threshold, or ended. // // This is what turns a hold into a rearrangement: once it has fired, the // release that follows opens no menu. It also decides whether // "All photographs" will accept the drop, which only makes sense for a // collection that has a parent to be taken out of. { let weak = window.as_weak(); let ctl = ctl.clone(); window .global::() .on_collection_drag_active(move |id, active| { let Some(w) = weak.upgrade() else { return }; let id = CollectionId(id as u64); if !active { w.global::() .set_collection_root_drop_allowed(false); w.global::().set_collection_lifted(0); *ctl.lifted.borrow_mut() = None; return; } // The hold may not have fired — a pointer drag needs no hold — so // the pick-up is recorded here as well as there, and the row lifts // either way. ctl.drag_began.set(true); ctl.note_row_press(id); *ctl.lifted.borrow_mut() = Some(id); w.global::().set_collection_lifted(id.0 as i32); // The hold has been spent on a drag; nothing may fire behind it. *ctl.row_hold_timer.borrow_mut() = None; // Only a nested collection has a top level to be returned to, and // only a collection drag — never photographs — means anything // there at all. let carrying_images = !ctl.dragging.borrow().is_empty(); let nested = ctl .row_ids .borrow() .iter() .position(|&c| c == id) .and_then(|row| ctl.row_nested.borrow().get(row).copied()) .unwrap_or(false); w.global::() .set_collection_root_drop_allowed(nested && !carrying_images); }); } // TRACES: FR-CAT-7 // Dropped on "All photographs": out to the top level. // // The gestural inverse of dropping one row onto another. `can-drop` has // already refused everything but a nested collection, so reaching here // means the drop is one — but the payload is taken rather than read, for // the reason the row's own drop gives: a remembered press must be spent by // the drop it belongs to, or a later empty drop moves a collection nobody // touched. { let weak = window.as_weak(); let ctl = ctl.clone(); let catalog = catalog.clone(); window .global::() .on_collection_dropped_on_root(move || { let Some(w) = weak.upgrade() else { return }; let source = ctl.dragging_collection.borrow_mut().take(); let Some(source) = source else { return }; // Photographs cannot land here. Guarded as well as refused in // `can-drop`, so a payload that somehow arrives does nothing // rather than reparenting whatever row was last pressed. if !ctl.dragging.borrow().is_empty() { return; } let borrow = catalog.borrow(); let Some(cat) = borrow.as_ref() else { return }; let moved = coll::set_parent(cat.connection(), source, None); match moved { Ok(()) => { w.global::() .set_collection_error(slint::SharedString::new()); w.global::() .set_library_status("Moved to the top level".into()); refresh_tree(&w, &ctl, cat); log::info!("promoted collection {} by drag", source.0); } Err(e) => w .global::() .set_collection_error(format!("moving collection: {e}").into()), } }); } } /// The row menu. /// /// Right-click, or a long press on touch. This gesture used to *delete an /// empty collection outright* and refuse with an error message otherwise, /// which was wrong in both directions at once: the destructive half fired /// with no confirmation and nothing on screen said it would, and the /// refusing half meant a collection holding anything could not be deleted /// at all — the user emptied it by hand and then did the same thing. pub(super) fn wire_row_menu( window: &AppWindow, ctl: &Rc, catalog: &Rc>>, on_scope_changed: &Rc, visible_ids: &Rc Vec>, ) { { let weak = window.as_weak(); let ctl = ctl.clone(); let catalog = catalog.clone(); window .global::() .on_collection_menu(move |id| { let Some(w) = weak.upgrade() else { return }; let borrow = catalog.borrow(); let Some(cat) = borrow.as_ref() else { return }; open_row_menu(&w, &ctl, cat, CollectionId(id as u64)); }); } // Rename, from the menu into the row's own inline field. // // The menu closes first and the field opens second, and the order is // load-bearing for the same reason it is in `collection-new`: the field is // focused by an `init` on a row, and a row torn down by a model rebuild // takes the focus with it. Here the tear-down is the sheet's, and a field // opened behind a sheet that is still up is a field the user cannot see // they are typing into. { let weak = window.as_weak(); let ctl = ctl.clone(); window .global::() .on_collection_menu_rename(move || { let Some(w) = weak.upgrade() else { return }; let Some(id) = *ctl.menu_for.borrow() else { return; }; close_row_menu(&w, &ctl); *ctl.renaming.borrow_mut() = Some(id); w.global::() .set_collection_renaming(id.0 as i32); }); } // A new collection nested inside this one. // // The `+` in the header already creates inside whatever the grid is scoped // to, which builds a hierarchy only if you first go and look at the parent. // This is the same act aimed at a row, so a tree can be built from the tree. { let weak = window.as_weak(); let ctl = ctl.clone(); let catalog = catalog.clone(); window .global::() .on_collection_menu_new_child(move || { let Some(w) = weak.upgrade() else { return }; let Some(parent) = *ctl.menu_for.borrow() else { return; }; let borrow = catalog.borrow(); let Some(cat) = borrow.as_ref() else { return }; close_row_menu(&w, &ctl); create_child(&w, &ctl, cat, Some(parent)); }); } // TRACES: FR-CAT-7 // Un-nesting: back out to the top level. // // Nesting has had a gesture since collections landed — drag a row onto // another row — and its inverse had none, in either direction: "All // photographs" ignores drops on purpose (an image is already in the // library), and no menu existed to ask. So a collection dragged into // another was in there permanently, and the only way out was to delete it // and build it again. { let weak = window.as_weak(); let ctl = ctl.clone(); let catalog = catalog.clone(); window .global::() .on_collection_menu_promote(move || { let Some(w) = weak.upgrade() else { return }; let Some(id) = *ctl.menu_for.borrow() else { return; }; let borrow = catalog.borrow(); let Some(cat) = borrow.as_ref() else { return }; match coll::set_parent(cat.connection(), id, None) { Ok(()) => { w.global::() .set_collection_error(slint::SharedString::new()); w.global::() .set_library_status("Moved to the top level".into()); close_row_menu(&w, &ctl); refresh_tree(&w, &ctl, cat); log::info!("promoted collection {} to the top level", id.0); } // Left open on failure, showing the collection it failed on: // closing would leave a red line referring to a row the user // can no longer tell was the one they aimed at. Err(e) => w .global::() .set_collection_error(format!("moving collection: {e}").into()), } }); } // TRACES: FR-NC-6a // The offline question, handed to the handler the tray on the row already // uses. Routed through the window's own callback rather than reaching into // `library_ui`'s prompt directly: that module owns the transfer state the // prompt reads, and a second entry point into it is a second place for the // two to disagree about what is downloading. { let weak = window.as_weak(); let ctl = ctl.clone(); window .global::() .on_collection_menu_offline(move || { let Some(w) = weak.upgrade() else { return }; let Some(id) = *ctl.menu_for.borrow() else { return; }; close_row_menu(&w, &ctl); w.invoke_collection_offline_menu(id.0 as i32); }); } // TRACES: FR-CAT-7 // Delete, in one press or two — see [`decide_delete`]. { let weak = window.as_weak(); let ctl = ctl.clone(); let catalog = catalog.clone(); let reload = on_scope_changed.clone(); let visible = visible_ids.clone(); window .global::() .on_collection_menu_delete(move || { let Some(w) = weak.upgrade() else { return }; let Some(id) = *ctl.menu_for.borrow() else { return; }; let borrow = catalog.borrow(); let Some(cat) = borrow.as_ref() else { return }; let (holds, children) = direct_holdings(cat.connection(), id).unwrap_or((0, 0)); if decide_delete(holds, children, ctl.menu_confirming.get()) == DeleteStep::Confirm { // Asked once. The sheet swaps its other actions for the // warning, so the second press cannot be a mis-aimed first one. ctl.menu_confirming.set(true); w.global::() .set_collection_menu_confirming(true); w.global::() .set_collection_menu_delete_label("Delete anyway".into()); return; } // Hoisted out of the `match` rather than called in the scrutinee: // the borrow `cat` comes from lives as long as the match does, and // the arm below has to release it before rereading the grid. let deleted = coll::delete(cat.connection(), id); match deleted { Ok(()) => { w.global::() .set_collection_error(slint::SharedString::new()); close_row_menu(&w, &ctl); // The grid was showing what no longer exists. let was_scope = *ctl.scope.borrow() == Some(id); if was_scope { *ctl.scope.borrow_mut() = None; w.set_collection_selected(0); w.global::() .set_collection_scope_label(slint::SharedString::new()); } refresh_tree(&w, &ctl, cat); // Named as a membership change, as the removal button is: // a user who reads this as a delete of their photographs // will not trust the feature again. w.global::().set_library_status(if holds > 0 { format!( "Deleted the collection; its {holds} photograph(s) stay in the library" ) .into() } else { slint::SharedString::from("Deleted the collection") }); log::info!("deleted collection {}", id.0); // The badge on every visible cell just lost a collection, // and a scope that has gone needs the grid rereading. let cells = visible(); sync_badges(&w, cat, &cells); drop(borrow); if was_scope { reload(); } } Err(e) => w .global::() .set_collection_error(format!("deleting: {e}").into()), } }); } // Dismiss. A pending confirmation is backed out of one step rather than // closing the whole menu: Escape from "are you sure" means "no", and // taking the sheet away with it would leave the user unsure whether the // key had cancelled the delete or performed it. { let weak = window.as_weak(); let ctl = ctl.clone(); window .global::() .on_collection_menu_dismiss(move || { let Some(w) = weak.upgrade() else { return }; if ctl.menu_confirming.get() { ctl.menu_confirming.set(false); w.global::() .set_collection_menu_confirming(false); w.global::() .set_collection_menu_delete_label("Delete".into()); return; } close_row_menu(&w, &ctl); }); } } /// Where the selection is filed. pub(super) fn wire_membership( window: &AppWindow, ctl: &Rc, catalog: &Rc>>, visible_ids: &Rc Vec>, on_scope_changed: &Rc, ) { { let weak = window.as_weak(); let ctl = ctl.clone(); let catalog = catalog.clone(); window .global::() .on_library_open_membership(move || { let Some(w) = weak.upgrade() else { return }; let borrow = catalog.borrow(); let Some(cat) = borrow.as_ref() else { return }; refresh_membership(&w, &ctl, cat); w.global::().set_membership_open(true); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); let catalog = catalog.clone(); let visible = visible_ids.clone(); let reload = on_scope_changed.clone(); window .global::() .on_membership_remove(move |id| { let Some(w) = weak.upgrade() else { return }; let target = CollectionId(id as u64); let chosen = ctl.selected(); if chosen.is_empty() { return; } let borrow = catalog.borrow(); let Some(cat) = borrow.as_ref() else { return }; // Hoisted for the same reason the delete above is: the arm // releases the catalog borrow before it rereads the grid. let removed = coll::remove_images(cat.connection(), target, &chosen); match removed { Ok(n) => { w.global::() .set_collection_error(slint::SharedString::new()); // The same wording the scoped button uses, and for the same // reason: this is a membership change, not a delete. w.global::().set_library_status( format!("Removed {n} from that collection; still in the library") .into(), ); // The sheet stays open. Taking photographs out of several // collections is one job, and a sheet that closed after // each would have to be reopened — with the selection // still live — to finish it. refresh_membership(&w, &ctl, cat); refresh_tree(&w, &ctl, cat); let cells = visible(); sync_badges(&w, cat, &cells); // Only the collection on screen changes what the grid // holds. Removing from another one leaves the grid right, // and rereading it would scroll the user's place away. let showing = *ctl.scope.borrow() == Some(target); drop(borrow); if showing { reload(); } } Err(e) => w .global::() .set_collection_error(format!("removing: {e}").into()), } }); } { let weak = window.as_weak(); window .global::() .on_membership_dismiss(move || { let Some(w) = weak.upgrade() else { return }; w.global::().set_membership_open(false); }); } }