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.
This commit is contained in:
2026-09-20 21:07:25 +02:00
parent 14ed1dc410
commit f6ff5eabd9
9 changed files with 816 additions and 643 deletions
+48 -17
View File
@@ -11,9 +11,9 @@ use dr_catalog::collections::{self as coll, CollectionKind};
use dr_catalog::Catalog;
use dr_types::CollectionId;
use rusqlite::OptionalExtension as _;
use slint::Model as _;
use slint::{ComponentHandle as _, Model as _};
use crate::AppWindow;
use crate::{AppWindow, Collections};
use super::controller::{delete_warning, menu_detail, CollectionsController};
use super::tree_sync::{direct_holdings, refresh_tree};
@@ -50,13 +50,27 @@ pub(super) fn open_row_menu(
*ctl.menu_for.borrow_mut() = Some(id);
ctl.menu_confirming.set(false);
window.set_collection_menu_confirming(false);
window.set_collection_menu_title(name.as_str().into());
window.set_collection_menu_detail(menu_detail(holds, children).into());
window.set_collection_menu_nested(parent.is_some());
window.set_collection_menu_smart(kind == CollectionKind::Smart as i64);
window.set_collection_menu_confirm_detail(delete_warning(&name, holds, children).into());
window.set_collection_menu_delete_label("Delete".into());
window
.global::<Collections>()
.set_collection_menu_confirming(false);
window
.global::<Collections>()
.set_collection_menu_title(name.as_str().into());
window
.global::<Collections>()
.set_collection_menu_detail(menu_detail(holds, children).into());
window
.global::<Collections>()
.set_collection_menu_nested(parent.is_some());
window
.global::<Collections>()
.set_collection_menu_smart(kind == CollectionKind::Smart as i64);
window
.global::<Collections>()
.set_collection_menu_confirm_detail(delete_warning(&name, holds, children).into());
window
.global::<Collections>()
.set_collection_menu_delete_label("Delete".into());
// TRACES: FR-NC-6a
// Read off the row the sidebar already drew rather than recomputed: the
@@ -67,9 +81,16 @@ pub(super) fn open_row_menu(
.borrow()
.iter()
.position(|&c| c == id)
.and_then(|row| window.get_collection_rows().row_data(row))
.and_then(|row| {
window
.global::<Collections>()
.get_collection_rows()
.row_data(row)
})
.is_some_and(|r| r.pinned);
window.set_collection_menu_pinned(pinned);
window
.global::<Collections>()
.set_collection_menu_pinned(pinned);
}
/// Shut the row menu, both halves.
@@ -80,8 +101,12 @@ pub(super) fn open_row_menu(
pub(super) fn close_row_menu(window: &AppWindow, ctl: &Rc<CollectionsController>) {
*ctl.menu_for.borrow_mut() = None;
ctl.menu_confirming.set(false);
window.set_collection_menu_title(slint::SharedString::new());
window.set_collection_menu_confirming(false);
window
.global::<Collections>()
.set_collection_menu_title(slint::SharedString::new());
window
.global::<Collections>()
.set_collection_menu_confirming(false);
}
/// Close the rename field, whatever the outcome.
@@ -92,7 +117,7 @@ pub(super) fn close_row_menu(window: &AppWindow, ctl: &Rc<CollectionsController>
/// close, or closes one that Rust still believes is open.
pub(super) fn close_rename(window: &AppWindow, ctl: &Rc<CollectionsController>) {
*ctl.renaming.borrow_mut() = None;
window.set_collection_renaming(0);
window.global::<Collections>().set_collection_renaming(0);
}
/// What a committed rename did.
@@ -185,7 +210,9 @@ pub(super) fn create_child(
match coll::create(catalog.connection(), &name, parent, CollectionKind::Manual) {
Ok(id) => {
window.set_collection_error(slint::SharedString::new());
window
.global::<Collections>()
.set_collection_error(slint::SharedString::new());
// A new child is useless if its parent is collapsed.
if let Some(p) = parent {
ctl.collapsed.borrow_mut().remove(&p);
@@ -203,10 +230,14 @@ pub(super) fn create_child(
// field on a row that already exists.
refresh_tree(window, ctl, catalog);
*ctl.renaming.borrow_mut() = Some(id);
window.set_collection_renaming(id.0 as i32);
window
.global::<Collections>()
.set_collection_renaming(id.0 as i32);
log::info!("created collection {} ({name})", id.0);
}
Err(e) => window.set_collection_error(format!("creating collection: {e}").into()),
Err(e) => window
.global::<Collections>()
.set_collection_error(format!("creating collection: {e}").into()),
}
}
+18 -9
View File
@@ -12,7 +12,7 @@ use dr_catalog::Catalog;
use dr_types::ImageId;
use slint::ComponentHandle;
use crate::AppWindow;
use crate::{AppWindow, Collections};
use super::controller::CollectionsController;
use super::tree_sync::refresh_tree;
@@ -31,7 +31,9 @@ pub(super) fn start_trash(
reload: &Rc<dyn Fn()>,
) {
let Some(conn) = session() else {
window.set_collection_error("Open a library first.".into());
window
.global::<Collections>()
.set_collection_error("Open a library first.".into());
return;
};
@@ -41,7 +43,9 @@ pub(super) fn start_trash(
match crate::trash::plan_trash(cat, &conn.account.root, images) {
Ok(m) => m,
Err(e) => {
window.set_collection_error(format!("planning delete: {e}").into());
window
.global::<Collections>()
.set_collection_error(format!("planning delete: {e}").into());
return;
}
}
@@ -97,7 +101,9 @@ pub(super) fn start_restore(
reload: &Rc<dyn Fn()>,
) {
let Some(conn) = session() else {
window.set_collection_error("Open a library first.".into());
window
.global::<Collections>()
.set_collection_error("Open a library first.".into());
return;
};
@@ -107,7 +113,9 @@ pub(super) fn start_restore(
match crate::trash::plan_restore(cat, images) {
Ok(m) => m,
Err(e) => {
window.set_collection_error(format!("planning restore: {e}").into());
window
.global::<Collections>()
.set_collection_error(format!("planning restore: {e}").into());
return;
}
}
@@ -116,7 +124,7 @@ pub(super) fn start_restore(
if moves.is_empty() {
// Said out loud rather than passed over in silence: a button that does
// nothing visible reads as broken, and the reason here is specific.
window.set_collection_error(
window.global::<Collections>().set_collection_error(
"Nothing to restore — no record of where these came from.".into(),
);
return;
@@ -227,7 +235,8 @@ pub(super) fn drain_trash(
}
w.set_library_status(status.into());
if let Some(first) = failed.first() {
w.set_collection_error(first.as_str().into());
w.global::<Collections>()
.set_collection_error(first.as_str().into());
}
let borrow = catalog.borrow();
@@ -264,8 +273,8 @@ fn stop_trash(ctl: &Rc<CollectionsController>) {
/// it.
pub fn refresh_trash(window: &AppWindow, catalog: &Catalog) {
let (n, bytes) = dr_catalog::trash::summary(catalog.connection()).unwrap_or((0, 0));
window.set_trash_count(n as i32);
window.set_trash_label(if n == 0 {
window.global::<Collections>().set_trash_count(n as i32);
window.global::<Collections>().set_trash_label(if n == 0 {
slint::SharedString::new()
} else {
format!("{n} · {}", format_bytes(bytes)).into()
+15 -7
View File
@@ -11,9 +11,9 @@ use std::rc::Rc;
use dr_catalog::collections::{self as coll, CollectionKind};
use dr_catalog::Catalog;
use dr_types::{CollectionId, ImageId};
use slint::Model as _;
use slint::{ComponentHandle as _, Model as _};
use crate::{AppWindow, CollectionRow};
use crate::{AppWindow, CollectionRow, Collections};
use super::controller::CollectionsController;
@@ -27,7 +27,9 @@ pub fn refresh_tree(window: &AppWindow, ctl: &Rc<CollectionsController>, catalog
let rows = match coll::tree(catalog.connection()) {
Ok(r) => r,
Err(e) => {
window.set_collection_error(format!("reading collections: {e}").into());
window
.global::<Collections>()
.set_collection_error(format!("reading collections: {e}").into());
return;
}
};
@@ -95,7 +97,9 @@ pub fn refresh_tree(window: &AppWindow, ctl: &Rc<CollectionsController>, catalog
*ctl.row_smart.borrow_mut() = smart;
*ctl.row_has_children.borrow_mut() = has_kids;
*ctl.row_nested.borrow_mut() = nested;
window.set_collection_rows(slint::ModelRc::new(slint::VecModel::from(out)));
window
.global::<Collections>()
.set_collection_rows(slint::ModelRc::new(slint::VecModel::from(out)));
// The rows are what `sync_reorderable` reads, so it has to be told they
// changed: a collection that gains a child, or one that is deleted out from
@@ -251,7 +255,7 @@ pub fn sync_selection(window: &AppWindow, ctl: &Rc<CollectionsController>, ids:
/// looking at, and a scope change costs no extra query.
pub(super) fn sync_reorderable(window: &AppWindow) {
let id = window.get_collection_selected();
let rows = window.get_collection_rows();
let rows = window.global::<Collections>().get_collection_rows();
let manual = id > 0
&& (0..rows.row_count())
.filter_map(|i| rows.row_data(i))
@@ -346,7 +350,9 @@ pub(super) fn refresh_membership(
let rows = match coll::membership_of(catalog.connection(), &chosen) {
Ok(r) => r,
Err(e) => {
window.set_collection_error(format!("reading collections: {e}").into());
window
.global::<Collections>()
.set_collection_error(format!("reading collections: {e}").into());
Vec::new()
}
};
@@ -367,7 +373,9 @@ pub(super) fn refresh_membership(
})
.collect();
window.set_membership_rows(slint::ModelRc::new(slint::VecModel::from(out)));
window
.global::<Collections>()
.set_membership_rows(slint::ModelRc::new(slint::VecModel::from(out)));
}
#[cfg(test)]
+122 -98
View File
@@ -16,7 +16,7 @@ use dr_types::{CollectionId, ImageId};
use slint::{ComponentHandle, Model as _};
use crate::library;
use crate::AppWindow;
use crate::{AppWindow, Collections};
use super::controller::{decide_drop, CollectionsController, Drop};
use super::drag::{arm_hold, arm_spring, collapse_spring_opened, compose_drag_image};
@@ -213,7 +213,9 @@ fn wire_selection(
let current = match library::read_member_order(cat, scope) {
Ok(current) => current,
Err(e) => {
w.set_collection_error(format!("reading the collection's order: {e}").into());
w.global::<Collections>().set_collection_error(
format!("reading the collection's order: {e}").into(),
);
return;
}
};
@@ -221,7 +223,8 @@ fn wire_selection(
match coll::set_order(cat.connection(), scope, &wanted) {
Ok(()) => {
w.set_collection_error(slint::SharedString::new());
w.global::<Collections>()
.set_collection_error(slint::SharedString::new());
w.set_library_status(
format!(
"{} photograph{} moved",
@@ -240,7 +243,8 @@ fn wire_selection(
}
Err(e) => {
drop(borrow);
w.set_collection_error(format!("reordering: {e}").into());
w.global::<Collections>()
.set_collection_error(format!("reordering: {e}").into());
}
}
});
@@ -339,7 +343,8 @@ fn wire_selection_filing(
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else {
w.set_collection_error("Open a library first.".into());
w.global::<Collections>()
.set_collection_error("Open a library first.".into());
return;
};
@@ -361,7 +366,8 @@ fn wire_selection_filing(
match made {
Ok(added) => {
w.set_collection_error(slint::SharedString::new());
w.global::<Collections>()
.set_collection_error(slint::SharedString::new());
w.set_library_status(
format!(
"{added} photograph{} in {name}",
@@ -386,7 +392,8 @@ fn wire_selection_filing(
}
Err(e) => {
drop(borrow);
w.set_collection_error(format!("making a collection: {e}").into());
w.global::<Collections>()
.set_collection_error(format!("making a collection: {e}").into());
}
}
});
@@ -449,7 +456,8 @@ fn wire_selection_filing(
match result {
Ok(filed) => {
w.set_collection_error(slint::SharedString::new());
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.
@@ -474,7 +482,9 @@ fn wire_selection_filing(
drop(borrow);
reload();
}
Err(e) => w.set_collection_error(format!("filing: {e}").into()),
Err(e) => w
.global::<Collections>()
.set_collection_error(format!("filing: {e}").into()),
}
});
}
@@ -528,11 +538,13 @@ fn wire_drag(
// that cares to read it; the drop itself uses the remembered press, since
// it is handed only the target.
{
window.on_collection_drag_payload(move |id| {
let mut data = slint::DataTransfer::default();
data.set_user_data(Rc::new(CollectionId(id as u64)));
data
});
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
});
}
{
@@ -608,24 +620,26 @@ fn wire_drag(
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog_for_spring = catalog.clone();
window.on_collection_drag_over(move |id, over| {
let Some(w) = weak.upgrade() else { return };
let id = CollectionId(id as u64);
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;
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;
}
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);
});
*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
@@ -636,77 +650,85 @@ fn wire_drag(
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.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();
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.set_collection_error(slint::SharedString::new());
w.set_library_status("Moved collection".into());
let carried = match decide_drop(&carried, pressed, id) {
Drop::Nothing => return,
Drop::Reparent(source) => {
let result = {
let borrow = catalog.borrow();
if let Some(cat) = borrow.as_ref() {
refresh_tree(&w, &ctl, cat);
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.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()),
}
// 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.set_collection_error(format!("moving collection: {e}").into()),
return;
}
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.set_library_status(msg.into());
}
Err(e) => w
.global::<Collections>()
.set_collection_error(format!("adding to collection: {e}").into()),
}
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.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.set_library_status(msg.into());
}
Err(e) => w.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);
});
// 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
@@ -784,7 +806,7 @@ fn wire_trash(
let weak = window.as_weak();
let ctl = ctl.clone();
let session = session.clone();
window.on_trash_dropped(move || {
window.global::<Collections>().on_trash_dropped(move || {
let Some(w) = weak.upgrade() else { return };
let carried = ctl.dragging.borrow().clone();
if carried.is_empty() {
@@ -795,7 +817,8 @@ fn wire_trash(
// still being walked to deliver this event.
*ctl.trash_requested.borrow_mut() = Some(carried);
let _ = session;
w.set_collection_error(slint::SharedString::new());
w.global::<Collections>()
.set_collection_error(slint::SharedString::new());
});
}
@@ -808,7 +831,7 @@ fn wire_trash(
let catalog = catalog.clone();
let session = session.clone();
let reload = on_scope_changed.clone();
window.on_trash_restore(move || {
window.global::<Collections>().on_trash_restore(move || {
let Some(w) = weak.upgrade() else { return };
let chosen = ctl.selected();
if chosen.is_empty() {
@@ -887,7 +910,7 @@ fn wire_trash_from_grid(
let catalog = catalog.clone();
let session = session.clone();
let reload = on_scope_changed.clone();
window.on_trash_empty(move || {
window.global::<Collections>().on_trash_empty(move || {
let Some(w) = weak.upgrade() else { return };
let conn = match session() {
@@ -904,7 +927,8 @@ fn wire_trash_from_grid(
let listed = match dr_catalog::trash::list(cat.connection(), usize::MAX) {
Ok(l) => l,
Err(e) => {
w.set_collection_error(format!("reading trash: {e}").into());
w.global::<Collections>()
.set_collection_error(format!("reading trash: {e}").into());
return;
}
};
+387 -321
View File
@@ -13,7 +13,7 @@ use dr_catalog::Catalog;
use dr_types::{CollectionId, ImageId};
use slint::{ComponentHandle, Model as _};
use crate::AppWindow;
use crate::{AppWindow, Collections};
use super::controller::{
decide_delete, decide_release, CollectionsController, DeleteStep, Release,
@@ -58,7 +58,7 @@ pub(super) fn wire_tree_navigation(
} else if id == 0 {
String::new()
} else {
let rows = w.get_collection_rows();
let rows = w.global::<Collections>().get_collection_rows();
(0..rows.row_count())
.filter_map(|i| rows.row_data(i))
.find(|r| r.id == id)
@@ -66,7 +66,8 @@ pub(super) fn wire_tree_navigation(
.unwrap_or_default()
};
w.set_collection_selected(id);
w.set_collection_scope_label(label.into());
w.global::<Collections>()
.set_collection_scope_label(label.into());
sync_reorderable(&w);
reload();
});
@@ -76,20 +77,22 @@ pub(super) fn wire_tree_navigation(
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
window.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);
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);
}
});
let borrow = catalog.borrow();
if let Some(cat) = borrow.as_ref() {
refresh_tree(&w, &ctl, cat);
}
});
}
}
@@ -103,11 +106,12 @@ pub(super) fn wire_create(
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
window.on_collection_new(move || {
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.set_collection_error("Open a library first.".into());
w.global::<Collections>()
.set_collection_error("Open a library first.".into());
return;
};
@@ -133,76 +137,88 @@ pub(super) fn wire_rename(
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.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);
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.set_collection_renaming(id.0 as i32);
w.set_collection_error(slint::SharedString::new());
});
// 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.on_collection_rename_commit(move |id, name| {
let Some(w) = weak.upgrade() else { return };
let id = CollectionId(id as u64);
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;
}
// 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 };
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.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.set_collection_scope_label(name.as_str().into());
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());
}
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.set_collection_error(format!("renaming: {e}").into());
}
}
});
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_collection_rename_cancel(move || {
let Some(w) = weak.upgrade() else { return };
close_rename(&w, &ctl);
w.set_collection_error(slint::SharedString::new());
});
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());
});
}
}
@@ -237,7 +253,8 @@ pub(super) fn wire_remove(
match coll::remove_images(cat.connection(), scope, &chosen) {
Ok(n) => {
w.set_collection_error(slint::SharedString::new());
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.
@@ -249,7 +266,9 @@ pub(super) fn wire_remove(
sync_badges(&w, cat, &visible());
reload();
}
Err(e) => w.set_collection_error(format!("removing: {e}").into()),
Err(e) => w
.global::<Collections>()
.set_collection_error(format!("removing: {e}").into()),
}
});
}
@@ -276,44 +295,47 @@ pub(super) fn wire_row_drag(
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_collection_row_press(move |id, down| {
let Some(w) = weak.upgrade() else { return };
let id = CollectionId(id as u64);
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.set_collection_lifted(0);
if let Release::OpenMenu(held) = decide_release(held, dragged) {
w.invoke_collection_menu(held.0 as i32);
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;
}
return;
}
// A drag of this row, if one follows, carries this collection.
ctl.note_row_press(id);
ctl.drag_began.set(false);
// 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.set_collection_lifted(id.0 as i32);
},
);
*ctl.row_hold_timer.borrow_mut() = Some(timer);
});
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
@@ -326,40 +348,44 @@ pub(super) fn wire_row_drag(
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_collection_drag_active(move |id, active| {
let Some(w) = weak.upgrade() else { return };
let id = CollectionId(id as u64);
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.set_collection_root_drop_allowed(false);
w.set_collection_lifted(0);
*ctl.lifted.borrow_mut() = None;
return;
}
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.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;
// 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.set_collection_root_drop_allowed(nested && !carrying_images);
});
// 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
@@ -375,30 +401,35 @@ pub(super) fn wire_row_drag(
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
window.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.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);
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;
}
Err(e) => w.set_collection_error(format!("moving collection: {e}").into()),
}
});
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()),
}
});
}
}
@@ -421,12 +452,14 @@ pub(super) fn wire_row_menu(
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
window.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));
});
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.
@@ -440,15 +473,18 @@ pub(super) fn wire_row_menu(
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.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.set_collection_renaming(id.0 as i32);
});
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.
@@ -460,16 +496,18 @@ pub(super) fn wire_row_menu(
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
window.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));
});
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
@@ -485,28 +523,33 @@ pub(super) fn wire_row_menu(
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
window.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 };
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.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);
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()),
}
// 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.set_collection_error(format!("moving collection: {e}").into()),
}
});
});
}
// TRACES: FR-NC-6a
@@ -518,14 +561,16 @@ pub(super) fn wire_row_menu(
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.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);
});
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
@@ -536,66 +581,75 @@ pub(super) fn wire_row_menu(
let catalog = catalog.clone();
let reload = on_scope_changed.clone();
let visible = visible_ids.clone();
window.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 };
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.set_collection_menu_confirming(true);
w.set_collection_menu_delete_label("Delete anyway".into());
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.set_collection_error(slint::SharedString::new());
close_row_menu(&w, &ctl);
// 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.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!(
// 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);
.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();
// 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()),
}
Err(e) => w.set_collection_error(format!("deleting: {e}").into()),
}
});
});
}
// Dismiss. A pending confirmation is backed out of one step rather than
@@ -605,16 +659,20 @@ pub(super) fn wire_row_menu(
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_collection_menu_dismiss(move || {
let Some(w) = weak.upgrade() else { return };
if ctl.menu_confirming.get() {
ctl.menu_confirming.set(false);
w.set_collection_menu_confirming(false);
w.set_collection_menu_delete_label("Delete".into());
return;
}
close_row_menu(&w, &ctl);
});
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);
});
}
}
@@ -635,7 +693,7 @@ pub(super) fn wire_membership(
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
refresh_membership(&w, &ctl, cat);
w.set_membership_open(true);
w.global::<Collections>().set_membership_open(true);
});
}
@@ -645,54 +703,62 @@ pub(super) fn wire_membership(
let catalog = catalog.clone();
let visible = visible_ids.clone();
let reload = on_scope_changed.clone();
window.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.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();
}
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;
}
Err(e) => w.set_collection_error(format!("removing: {e}").into()),
}
});
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.on_membership_dismiss(move || {
let Some(w) = weak.upgrade() else { return };
w.set_membership_open(false);
});
window
.global::<Collections>()
.on_membership_dismiss(move || {
let Some(w) = weak.upgrade() else { return };
w.global::<Collections>().set_membership_open(false);
});
}
}
+62 -145
View File
@@ -10,7 +10,7 @@ import { IdentityScreen, IdentityPerson, IdentityFace } from "identity.slint";
import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll, KeywordRow, PersonChip } from "library.slint";
import { GestureRow } from "gestures.slint";
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
import { CollectionsPanel, CollectionRow, OfflinePrompt, CollectionMenu,
import { CollectionsPanel, CollectionRow, Collections, OfflinePrompt, CollectionMenu,
MembershipSheet, MembershipRow } from "collections.slint";
import { HistogramPanel, HistogramView, Levels } from "histogram.slint";
import { RecoveryPrompt } from "recovery.slint";
@@ -31,6 +31,7 @@ export { ViewMode, GradientHandle, HandleRole, SpotHandle, SpotRole }
// exports appears in the generated API. `session.slint` says why the develop
// panels take their wiring this way and why there is one global per family.
export { Develop, Capture, Levels, Peaking, Adjustments, Framing, Transfer, Masking, Repair, Steps }
export { Collections }
/// Which top-level screen is on display. Exactly one at a time — replacing
/// the `show-launch` / `show-library` / `show-identity` booleans, with
@@ -539,75 +540,25 @@ export component AppWindow inherits Window {
// --- collections (FR-CAT-7) ---
//
// The sidebar's tree, flattened in Rust: Slint cannot instantiate a
// component recursively, so depth arrives as an integer per row.
in-out property <[CollectionRow]> collection-rows;
// State that belongs to the sidebar itself — the tree, the row menu,
// drag and drop between rows, the trash row — has moved to the
// `Collections` global in `collections.slint`. What is left here is
// shared with the library grid, whose Rust wiring still reaches it on
// the window.
/// Which collection scopes the grid. 0 is the whole library.
in property <int> collection-selected: 0;
in property <string> collection-scope-label: "";
in property <string> collection-error: "";
/// Whether the sidebar is shown at all. Closed by default in the compact
/// layout, where 232px of the window is most of the photograph (FR-UI-1),
/// and openable from the grid header either way — see `panel-visible` for
/// how a default and a user's answer to it are kept apart.
/// how a default and a user's answer to it are kept apart. Owned by
/// `run()`'s panel-layout wiring rather than the sidebar's own global,
/// because it seeds from the saved layout before the sidebar exists.
in property <bool> collections-visible: true;
/// Which collection is being renamed in the sidebar, by id. 0 is none.
in property <int> collection-renaming: 0;
callback collection-select(int);
callback collection-toggle(int);
callback collection-new();
callback collection-menu(int);
// --- the row menu the gesture above opens (FR-CAT-7) -------------------
//
// Rust owns every one of these. What the menu offers depends on counts it
// has to query — whether the collection is nested, what deleting would
// take — and the confirmation step is a policy decision, not a local bit
// the sheet can flip for itself.
/// The collection the menu is about. Empty closes it.
in property <string> collection-menu-title;
/// "12 photographs · 2 collections inside".
in property <string> collection-menu-detail;
/// Whether it has a parent, and so somewhere to be promoted out to.
in property <bool> collection-menu-nested: false;
/// Whether it is a saved filter, which cannot hold a nested collection.
in property <bool> collection-menu-smart: false;
/// Whether a delete has been asked once and awaits confirmation.
in property <bool> collection-menu-confirming: false;
/// What the delete would take with it, and what survives it.
in property <string> collection-menu-confirm-detail;
in property <string> collection-menu-delete-label;
/// TRACES: FR-NC-6a
/// Whether the collection the menu is about is kept on this device.
in property <bool> collection-menu-pinned: false;
callback collection-menu-rename();
callback collection-menu-new-child();
/// Move it back to the top level. The inverse of the drag that nests one,
/// which until now had no gesture at all.
callback collection-menu-promote();
/// TRACES: FR-NC-6a
/// Hand the offline question over to the handler the tray on the row
/// already uses, rather than a second route to the same prompt.
callback collection-menu-offline();
callback collection-menu-delete();
callback collection-menu-dismiss();
/// TRACES: FR-NC-6a
/// Ask about a collection's local copies: the tray on a row, or a long
/// press anywhere on it.
callback collection-offline-menu(int);
/// A row is held or released — the long press is timed in Rust.
callback collection-row-press(int, bool);
// --- picking a collection up (FR-UI-3, FR-UI-4) ------------------------
/// Which collection the hold has picked up, by id. 0 is none.
in property <int> collection-lifted: 0;
/// A row's drag crossed the threshold, or ended.
callback collection-drag-active(int, bool);
/// Whether "All photographs" would take the drag currently in flight.
in property <bool> collection-root-drop-allowed: false;
/// A collection was dropped on "All photographs": out to the top level.
callback collection-dropped-on-root();
/// TRACES: FR-CAT-7 | FR-UI-4
/// File the grid's selection in a collection without dragging it there:
/// the target's id, and whether to take the images out of the collection
@@ -646,38 +597,6 @@ export component AppWindow inherits Window {
in-out property <int> library-held-row: -1;
/// A press on a cell ended, so the long-press timer can be cancelled.
callback library-cell-press-ended();
/// TRACES: FR-UI-2 | FR-UI-4
/// Two taps on a cell: in selection mode, the far end of a range.
/// Renaming started on a row, by id — a double-click, or `F2`.
callback collection-rename-start(int);
/// A rename was committed: the collection's id and the new name.
callback collection-rename-commit(int, string);
/// Renaming was abandoned with Escape.
callback collection-rename-cancel();
/// Row index and collection id under the pointer. The id is what the
/// drop highlight follows: a spring expansion rebuilds the row model
/// mid-drag, and an index would then point at a different collection.
/// A drag is dwelling over a collection, or has left it: id, and whether
/// it is over. Drives the spring-loaded expansion, timed in Rust.
callback collection-drag-over(int, bool);
/// Images were dropped on a collection, by id. Slint hit-tests the release,
/// so this is the collection actually under the pointer.
callback collection-dropped(int);
/// Payload for dragging a collection onto another to reparent it.
pure callback collection-drag-payload(int) -> data-transfer;
// --- trash (FR-CAT-15) ---
//
// Not a collection: a drop here moves the file into a trash folder on the
// server, where a collection drop only adds a reference.
in property <int> trash-count: 0;
in property <string> trash-label: "";
/// A soft delete: move the dragged images to the trash folder.
callback trash-dropped();
/// A hard delete: permanently remove everything in the trash.
callback trash-empty();
/// Put the selected trashed images back where they came from.
callback trash-restore();
// --- drag and drop ---
//
@@ -703,14 +622,12 @@ export component AppWindow inherits Window {
// --- where the selection is filed (FR-CAT-7) ---------------------------
//
// `library-remove-from-collection` can only ever mean the collection the
// grid is scoped to — it is the only one that button can name. This sheet
// is the other half: which collections the selection is actually in, and
// the way out of any of them without navigating there first.
in property <bool> membership-open: false;
in property <[MembershipRow]> membership-rows;
// grid is scoped to — it is the only one that button can name. Opens the
// membership sheet, whose state (`Collections.membership-open` and
// `.membership-rows`) is on the sidebar's own global — only the library
// side knows the selection has changed enough to be worth recomputing
// the sheet against.
callback library-open-membership();
callback membership-remove(int);
callback membership-dismiss();
// The keyboard cursor: where the arrows are in the library, as an image
// ordinal. Rust owns it — clamping it needs the library's length, and
@@ -1287,12 +1204,12 @@ in property <bool> panel-visible: true;
// menu's own confirmation is a step Back must be able to take
// one at a time, which is why the dismiss handlers in Rust
// back out of a pending delete rather than closing outright.
if (root.collection-menu-title != "") {
root.collection-menu-dismiss();
if (Collections.collection-menu-title != "") {
Collections.collection-menu-dismiss();
return accept;
}
if (root.membership-open) {
root.membership-dismiss();
if (Collections.membership-open) {
Collections.membership-dismiss();
return accept;
}
if (root.offline-prompt-title != "") {
@@ -1641,10 +1558,10 @@ in property <bool> panel-visible: true;
CollectionsPanel {
width: 100%;
height: 100%;
rows: root.collection-rows;
rows: Collections.collection-rows;
selected-id: root.collection-selected;
total-images: root.library-total;
error: root.collection-error;
error: Collections.collection-error;
// TRACES: FR-NC-6a
// The two actions that were in the library header and
@@ -1657,32 +1574,32 @@ in property <bool> panel-visible: true;
change-library() => { root.library-change(); }
select(id) => { root.collection-select(id); }
toggle(id) => { root.collection-toggle(id); }
new-collection() => { root.collection-new(); }
row-menu(id) => { root.collection-menu(id); }
toggle(id) => { Collections.collection-toggle(id); }
new-collection() => { Collections.collection-new(); }
row-menu(id) => { Collections.collection-menu(id); }
offline-menu(id) => { root.collection-offline-menu(id); }
row-press(id, down) => { root.collection-row-press(id, down); }
lifted-id: root.collection-lifted;
drag-active(id, on) => { root.collection-drag-active(id, on); }
root-drop-allowed: root.collection-root-drop-allowed;
dropped-on-root() => { root.collection-dropped-on-root(); }
row-press(id, down) => { Collections.collection-row-press(id, down); }
lifted-id: Collections.collection-lifted;
drag-active(id, on) => { Collections.collection-drag-active(id, on); }
root-drop-allowed: Collections.collection-root-drop-allowed;
dropped-on-root() => { Collections.collection-dropped-on-root(); }
renaming-id: root.collection-renaming;
rename-start(id) => { root.collection-rename-start(id); }
renaming-id: Collections.collection-renaming;
rename-start(id) => { Collections.collection-rename-start(id); }
rename-commit(id, name) => {
root.collection-rename-commit(id, name);
Collections.collection-rename-commit(id, name);
}
rename-cancel() => { root.collection-rename-cancel(); }
trash-count: root.trash-count;
trash-label: root.trash-label;
rename-cancel() => { Collections.collection-rename-cancel(); }
trash-count: Collections.trash-count;
trash-label: Collections.trash-label;
selected-count: root.library-selected-count;
dropped-on(id) => { root.collection-dropped(id); }
collection-drag-payload(id) => { return root.collection-drag-payload(id); }
dropped-on-trash() => { root.trash-dropped(); }
empty-trash() => { root.trash-empty(); }
restore-selected() => { root.trash-restore(); }
drag-over(id, over) => { root.collection-drag-over(id, over); }
dropped-on(id) => { Collections.collection-dropped(id); }
collection-drag-payload(id) => { return Collections.collection-drag-payload(id); }
dropped-on-trash() => { Collections.trash-dropped(); }
empty-trash() => { Collections.trash-empty(); }
restore-selected() => { Collections.trash-restore(); }
drag-over(id, over) => { Collections.collection-drag-over(id, over); }
}
}
@@ -1737,7 +1654,7 @@ in property <bool> panel-visible: true;
window-label: root.library-window-label;
offset: root.library-offset;
selected-count: root.library-selected-count;
scope-label: root.collection-scope-label;
scope-label: Collections.collection-scope-label;
settings-armed: Transfer.armed;
paste-settings-to-selection => {
root.paste-settings-to-selection();
@@ -1804,7 +1721,7 @@ in property <bool> panel-visible: true;
held-row <=> root.library-held-row;
// The sidebar's rows, not a second model: the sheet files
// into the same tree the sidebar draws.
collections: root.collection-rows;
collections: Collections.collection-rows;
file-in-collection(id, moves) => {
root.library-file-in-collection(id, moves);
}
@@ -1856,7 +1773,7 @@ in property <bool> panel-visible: true;
// trash, neither of which has a name to change — Rust drops
// those rather than the binding trying to reason about it.
rename-scope() => {
root.collection-rename-start(root.collection-selected);
Collections.collection-rename-start(root.collection-selected);
}
filter-min-rating-changed(n) => {
root.library-filter-min-rating-changed(n);
@@ -3261,30 +3178,30 @@ in property <bool> panel-visible: true;
MembershipSheet {
width: 100%;
height: 100%;
open: root.membership-open;
open: Collections.membership-open;
selected-count: root.library-selected-count;
rows: root.membership-rows;
remove(id) => { root.membership-remove(id); }
dismiss() => { root.membership-dismiss(); }
rows: Collections.membership-rows;
remove(id) => { Collections.membership-remove(id); }
dismiss() => { Collections.membership-dismiss(); }
}
CollectionMenu {
width: 100%;
height: 100%;
title: root.collection-menu-title;
detail: root.collection-menu-detail;
nested: root.collection-menu-nested;
smart: root.collection-menu-smart;
confirming: root.collection-menu-confirming;
confirm-detail: root.collection-menu-confirm-detail;
delete-label: root.collection-menu-delete-label;
pinned: root.collection-menu-pinned;
rename() => { root.collection-menu-rename(); }
new-child() => { root.collection-menu-new-child(); }
promote() => { root.collection-menu-promote(); }
offline() => { root.collection-menu-offline(); }
delete() => { root.collection-menu-delete(); }
dismiss() => { root.collection-menu-dismiss(); }
title: Collections.collection-menu-title;
detail: Collections.collection-menu-detail;
nested: Collections.collection-menu-nested;
smart: Collections.collection-menu-smart;
confirming: Collections.collection-menu-confirming;
confirm-detail: Collections.collection-menu-confirm-detail;
delete-label: Collections.collection-menu-delete-label;
pinned: Collections.collection-menu-pinned;
rename() => { Collections.collection-menu-rename(); }
new-child() => { Collections.collection-menu-new-child(); }
promote() => { Collections.collection-menu-promote(); }
offline() => { Collections.collection-menu-offline(); }
delete() => { Collections.collection-menu-delete(); }
dismiss() => { Collections.collection-menu-dismiss(); }
}
// Last, and therefore over everything including the settings page and
+118
View File
@@ -1179,6 +1179,124 @@ export struct MembershipRow {
detail: string,
}
// TRACES: FR-CAT-7 | FR-CAT-15
// The collections sidebar's own state: the tree, the row menu, renaming,
// drag and drop between rows, the trash row, and the membership sheet this
// file also declares.
//
// A global rather than properties and callbacks on the window root — see
// `session.slint` for why the develop panels stopped taking their wiring
// through the window root; the same reasoning applies here. What the sidebar
// shares with the library grid (a photograph dragged out of a cell, the
// selection a "file in collection" gesture acts on) stays on the window,
// because Rust on the library side still reaches it there.
export global Collections {
// --- collections (FR-CAT-7) ---
//
// The sidebar's tree, flattened in Rust: Slint cannot instantiate a
// component recursively, so depth arrives as an integer per row.
in-out property <[CollectionRow]> collection-rows;
in property <string> collection-scope-label: "";
in property <string> collection-error: "";
/// Which collection is being renamed in the sidebar, by id. 0 is none.
in property <int> collection-renaming: 0;
callback collection-toggle(int);
callback collection-new();
callback collection-menu(int);
// --- the row menu the gesture above opens (FR-CAT-7) -------------------
//
// Rust owns every one of these. What the menu offers depends on counts it
// has to query — whether the collection is nested, what deleting would
// take — and the confirmation step is a policy decision, not a local bit
// the sheet can flip for itself.
/// The collection the menu is about. Empty closes it.
in property <string> collection-menu-title;
/// "12 photographs · 2 collections inside".
in property <string> collection-menu-detail;
/// Whether it has a parent, and so somewhere to be promoted out to.
in property <bool> collection-menu-nested: false;
/// Whether it is a saved filter, which cannot hold a nested collection.
in property <bool> collection-menu-smart: false;
/// Whether a delete has been asked once and awaits confirmation.
in property <bool> collection-menu-confirming: false;
/// What the delete would take with it, and what survives it.
in property <string> collection-menu-confirm-detail;
in property <string> collection-menu-delete-label;
/// TRACES: FR-NC-6a
/// Whether the collection the menu is about is kept on this device.
in property <bool> collection-menu-pinned: false;
callback collection-menu-rename();
callback collection-menu-new-child();
/// Move it back to the top level. The inverse of the drag that nests one,
/// which until now had no gesture at all.
callback collection-menu-promote();
/// TRACES: FR-NC-6a
/// Hand the offline question over to the handler the tray on the row
/// already uses, rather than a second route to the same prompt.
callback collection-menu-offline();
callback collection-menu-delete();
callback collection-menu-dismiss();
/// A row is held or released — the long press is timed in Rust.
callback collection-row-press(int, bool);
// --- picking a collection up (FR-UI-3, FR-UI-4) ------------------------
/// Which collection the hold has picked up, by id. 0 is none.
in property <int> collection-lifted: 0;
/// A row's drag crossed the threshold, or ended.
callback collection-drag-active(int, bool);
/// Whether "All photographs" would take the drag currently in flight.
in property <bool> collection-root-drop-allowed: false;
/// A collection was dropped on "All photographs": out to the top level.
callback collection-dropped-on-root();
/// TRACES: FR-UI-2 | FR-UI-4
/// Renaming started on a row, by id — a double-click, or `F2`.
callback collection-rename-start(int);
/// A rename was committed: the collection's id and the new name.
callback collection-rename-commit(int, string);
/// Renaming was abandoned with Escape.
callback collection-rename-cancel();
/// Row index and collection id under the pointer. The id is what the
/// drop highlight follows: a spring expansion rebuilds the row model
/// mid-drag, and an index would then point at a different collection.
/// A drag is dwelling over a collection, or has left it: id, and whether
/// it is over. Drives the spring-loaded expansion, timed in Rust.
callback collection-drag-over(int, bool);
/// Images were dropped on a collection, by id. Slint hit-tests the release,
/// so this is the collection actually under the pointer.
callback collection-dropped(int);
/// Payload for dragging a collection onto another to reparent it.
pure callback collection-drag-payload(int) -> data-transfer;
// --- trash (FR-CAT-15) ---
//
// Not a collection: a drop here moves the file into a trash folder on the
// server, where a collection drop only adds a reference.
in property <int> trash-count: 0;
in property <string> trash-label: "";
/// A soft delete: move the dragged images to the trash folder.
callback trash-dropped();
/// A hard delete: permanently remove everything in the trash.
callback trash-empty();
/// Put the selected trashed images back where they came from.
callback trash-restore();
// --- where the selection is filed (FR-CAT-7) ---------------------------
//
// The library's "Remove from collection" can only ever mean the
// collection the grid is scoped to — it is the only one that button can
// name. This sheet is the other half: which collections the selection is
// actually in, and the way out of any of them without navigating there
// first. Opened from the library grid via the window's own
// `library-open-membership`, because only the library side knows the
// selection has changed enough to be worth recomputing against.
in property <bool> membership-open: false;
in property <[MembershipRow]> membership-rows;
callback membership-remove(int);
callback membership-dismiss();
}
// Where the selection is filed, and the way out of any of it.
//
// The library's "Remove from collection" button takes the selection out of the