Files
DarkRoom/ui/dr-ui/src/collections_ui/wiring_tree.rs
T
dtourolle f6ff5eabd9 Give the collections sidebar its own Slint global
AppWindow carried the sidebar's tree, its row menu, renaming, drag and
drop between rows, the trash row, and the membership sheet as ~40
properties and callbacks on the root component, in the pattern CH-1
describes and the develop screen's globals (Adjustments, Framing, Steps,
...) already replaced. Collections.* in collections.slint now holds that
state, declared next to the MembershipRow struct it and the membership
sheet both use; Rust reaches it through window.global::<Collections>()
instead of window.set_/get_/on_/invoke_ on the root.

collection-selected, collection-select, and collection-offline-menu stay
on AppWindow: library_ui.rs invokes collection-select directly and
registers collection-offline-menu's handler, and lib.rs reads
collection-selected for back-navigation, so moving them would have meant
editing library_ui.rs, which another change on this branch is splitting
into a module directory. collections-visible stays too — it is
lib.rs's panel-layout state, seeded from the saved layout before the
sidebar exists, and collections_ui never touches it. Everything prefixed
library- (the grid's drag, selection and keyword state that the sidebar's
Rust also wires for cross-feature gestures like filing a selection into
a collection) stays on the window as well, since it belongs to the
library screen, not the sidebar.
2026-09-20 21:07:25 +02:00

765 lines
32 KiB
Rust

//! 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};
use super::controller::{
decide_delete, decide_release, CollectionsController, DeleteStep, Release,
};
use super::drag::HOLD_DELAY_MS;
use super::rename_menu::{
apply_rename, close_rename, close_row_menu, create_child, open_row_menu, Rename,
};
use super::tree_sync::{
direct_holdings, refresh_membership, refresh_tree, sync_badges, sync_reorderable,
};
/// Tree navigation.
pub(super) fn wire_tree_navigation(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
on_scope_changed: &Rc<dyn Fn()>,
) {
{
let weak = window.as_weak();
let ctl = ctl.clone();
let reload = on_scope_changed.clone();
window.on_collection_select(move |id| {
let Some(w) = weak.upgrade() else { return };
// -1 is the trash. It is not a collection, so it clears `scope`
// rather than setting it — see `viewing_trash`.
ctl.viewing_trash.set(id == -1);
*ctl.scope.borrow_mut() = if id <= 0 {
None
} else {
Some(CollectionId(id as u64))
};
// A selection the user cannot see is one they will act on by
// accident, and the new scope shows different images.
ctl.clear_selection();
let label = if id == -1 {
"Trash".to_string()
} else if id == 0 {
String::new()
} else {
let rows = w.global::<Collections>().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::<Collections>()
.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::<Collections>()
.on_collection_toggle(move |id| {
let Some(w) = weak.upgrade() else { return };
let id = CollectionId(id as u64);
{
let mut collapsed = ctl.collapsed.borrow_mut();
if !collapsed.remove(&id) {
collapsed.insert(id);
}
}
let borrow = catalog.borrow();
if let Some(cat) = borrow.as_ref() {
refresh_tree(&w, &ctl, cat);
}
});
}
}
/// Create.
pub(super) fn wire_create(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
) {
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
window.global::<Collections>().on_collection_new(move || {
let Some(w) = weak.upgrade() else { return };
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else {
w.global::<Collections>()
.set_collection_error("Open a library first.".into());
return;
};
// Created inside whatever is selected, which is how a hierarchy
// gets built without leaving the header: select the parent, press
// +. The row menu aims the same act at a row instead.
create_child(&w, &ctl, cat, *ctl.scope.borrow());
});
}
}
/// Rename.
///
/// Inline in the row, opened by a double-click or `F2`. The gesture is worth
/// the field rather than a dialog: renaming is how a hierarchy gets tidied,
/// and it is done in runs of several — a modal per collection would make
/// that a chore.
pub(super) fn wire_rename(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
) {
{
let weak = window.as_weak();
let ctl = ctl.clone();
window
.global::<Collections>()
.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::<Collections>()
.set_collection_renaming(id.0 as i32);
w.global::<Collections>()
.set_collection_error(slint::SharedString::new());
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
window
.global::<Collections>()
.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::<Collections>()
.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::<Collections>()
.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::<Collections>()
.set_collection_error(format!("renaming: {e}").into());
}
}
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window
.global::<Collections>()
.on_collection_rename_cancel(move || {
let Some(w) = weak.upgrade() else { return };
close_rename(&w, &ctl);
w.global::<Collections>()
.set_collection_error(slint::SharedString::new());
});
}
}
/// Remove from the collection being shown.
pub(super) fn wire_remove(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
visible_ids: &Rc<dyn Fn() -> Vec<ImageId>>,
on_scope_changed: &Rc<dyn Fn()>,
) {
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
let visible = visible_ids.clone();
let reload = on_scope_changed.clone();
window.on_library_remove_from_collection(move || {
let Some(w) = weak.upgrade() else { return };
let Some(scope) = *ctl.scope.borrow() else {
// Unscoped, there is no collection to remove from. The button
// is hidden in that state; this guards the callback anyway.
return;
};
let chosen = ctl.selected();
if chosen.is_empty() {
return;
}
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
match coll::remove_images(cat.connection(), scope, &chosen) {
Ok(n) => {
w.global::<Collections>()
.set_collection_error(slint::SharedString::new());
// Named explicitly as a membership change: the images are
// still in the library, and a user who reads this as a
// delete will not trust the feature again.
w.set_library_status(
format!("Removed {n} from this collection; still in the library").into(),
);
ctl.clear_selection();
refresh_tree(&w, &ctl, cat);
sync_badges(&w, cat, &visible());
reload();
}
Err(e) => w
.global::<Collections>()
.set_collection_error(format!("removing: {e}").into()),
}
});
}
}
/// The tree row's own hold-drag: lift a row, drag it onto another to
/// reparent, or onto the root to un-nest it.
pub(super) fn wire_row_drag(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
) {
// TRACES: FR-CAT-7 | FR-UI-2 | FR-UI-3 | FR-UI-4
// The hold on a sidebar row: it arms the drag that rearranges the tree,
// and it opens the row menu. Which one the user gets is decided on release
// by whether they moved.
//
// Both from one gesture because there is only one to spend. A finger has
// no right button, and this tree lives in a Flickable that claims any drag
// beginning inside it — so without a hold, a touch drag on a row is a
// scroll, and arranging the tree is a pointer-only feature. The row lifts
// the moment the timer fires, which is what tells the user the next
// movement will carry the collection rather than scroll past it.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window
.global::<Collections>()
.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::<Collections>().set_collection_lifted(0);
if let Release::OpenMenu(held) = decide_release(held, dragged) {
w.global::<Collections>()
.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::<Collections>().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::<Collections>()
.on_collection_drag_active(move |id, active| {
let Some(w) = weak.upgrade() else { return };
let id = CollectionId(id as u64);
if !active {
w.global::<Collections>()
.set_collection_root_drop_allowed(false);
w.global::<Collections>().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::<Collections>().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::<Collections>()
.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::<Collections>()
.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::<Collections>()
.set_collection_error(slint::SharedString::new());
w.set_library_status("Moved to the top level".into());
refresh_tree(&w, &ctl, cat);
log::info!("promoted collection {} by drag", source.0);
}
Err(e) => w
.global::<Collections>()
.set_collection_error(format!("moving collection: {e}").into()),
}
});
}
}
/// The row menu.
///
/// Right-click, or a long press on touch. This gesture used to *delete an
/// empty collection outright* and refuse with an error message otherwise,
/// which was wrong in both directions at once: the destructive half fired
/// with no confirmation and nothing on screen said it would, and the
/// refusing half meant a collection holding anything could not be deleted
/// at all — the user emptied it by hand and then did the same thing.
pub(super) fn wire_row_menu(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
on_scope_changed: &Rc<dyn Fn()>,
visible_ids: &Rc<dyn Fn() -> Vec<ImageId>>,
) {
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
window
.global::<Collections>()
.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::<Collections>()
.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::<Collections>()
.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::<Collections>()
.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::<Collections>()
.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::<Collections>()
.set_collection_error(slint::SharedString::new());
w.set_library_status("Moved to the top level".into());
close_row_menu(&w, &ctl);
refresh_tree(&w, &ctl, cat);
log::info!("promoted collection {} to the top level", id.0);
}
// Left open on failure, showing the collection it failed on:
// closing would leave a red line referring to a row the user
// can no longer tell was the one they aimed at.
Err(e) => w
.global::<Collections>()
.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::<Collections>()
.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::<Collections>()
.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::<Collections>()
.set_collection_menu_confirming(true);
w.global::<Collections>()
.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::<Collections>()
.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::<Collections>()
.set_collection_scope_label(slint::SharedString::new());
}
refresh_tree(&w, &ctl, cat);
// Named as a membership change, as the removal button is:
// a user who reads this as a delete of their photographs
// will not trust the feature again.
w.set_library_status(if holds > 0 {
format!(
"Deleted the collection; its {holds} photograph(s) stay in the library"
)
.into()
} else {
slint::SharedString::from("Deleted the collection")
});
log::info!("deleted collection {}", id.0);
// The badge on every visible cell just lost a collection,
// and a scope that has gone needs the grid rereading.
let cells = visible();
sync_badges(&w, cat, &cells);
drop(borrow);
if was_scope {
reload();
}
}
Err(e) => w
.global::<Collections>()
.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::<Collections>()
.on_collection_menu_dismiss(move || {
let Some(w) = weak.upgrade() else { return };
if ctl.menu_confirming.get() {
ctl.menu_confirming.set(false);
w.global::<Collections>()
.set_collection_menu_confirming(false);
w.global::<Collections>()
.set_collection_menu_delete_label("Delete".into());
return;
}
close_row_menu(&w, &ctl);
});
}
}
/// Where the selection is filed.
pub(super) fn wire_membership(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
visible_ids: &Rc<dyn Fn() -> Vec<ImageId>>,
on_scope_changed: &Rc<dyn Fn()>,
) {
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
window.on_library_open_membership(move || {
let Some(w) = weak.upgrade() else { return };
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
refresh_membership(&w, &ctl, cat);
w.global::<Collections>().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::<Collections>()
.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::<Collections>()
.set_collection_error(slint::SharedString::new());
// The same wording the scoped button uses, and for the same
// reason: this is a membership change, not a delete.
w.set_library_status(
format!("Removed {n} from that collection; still in the library")
.into(),
);
// The sheet stays open. Taking photographs out of several
// collections is one job, and a sheet that closed after
// each would have to be reopened — with the selection
// still live — to finish it.
refresh_membership(&w, &ctl, cat);
refresh_tree(&w, &ctl, cat);
let cells = visible();
sync_badges(&w, cat, &cells);
// Only the collection on screen changes what the grid
// holds. Removing from another one leaves the grid right,
// and rereading it would scroll the user's place away.
let showing = *ctl.scope.borrow() == Some(target);
drop(borrow);
if showing {
reload();
}
}
Err(e) => w
.global::<Collections>()
.set_collection_error(format!("removing: {e}").into()),
}
});
}
{
let weak = window.as_weak();
window
.global::<Collections>()
.on_membership_dismiss(move || {
let Some(w) = weak.upgrade() else { return };
w.global::<Collections>().set_membership_open(false);
});
}
}