Files
DarkRoom/ui/dr-ui/src/collections_ui/rename_menu.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

493 lines
18 KiB
Rust

//! Creating, renaming and naming collections, and the row context menu
//! that reaches most of it.
//!
//! `apply_rename` and `unique_name` are kept apart from the callbacks that
//! call them because they are the part a user notices being wrong, and the
//! part that can be tested without a window.
use std::rc::Rc;
use dr_catalog::collections::{self as coll, CollectionKind};
use dr_catalog::Catalog;
use dr_types::CollectionId;
use rusqlite::OptionalExtension as _;
use slint::{ComponentHandle as _, Model as _};
use crate::{AppWindow, Collections};
use super::controller::{delete_warning, menu_detail, CollectionsController};
use super::tree_sync::{direct_holdings, refresh_tree};
/// TRACES: FR-CAT-7
/// Open the row menu on `id`, or leave it shut if the collection is gone.
///
/// Everything the sheet draws is computed here, in one place, so the menu
/// cannot offer "Move to top level" on a collection already at the top, or a
/// delete warning about children it no longer has. Reopened from scratch after
/// every action for the same reason.
pub(super) fn open_row_menu(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
catalog: &Catalog,
id: CollectionId,
) {
let conn = catalog.connection();
let row: Option<(String, Option<i64>, i64)> = conn
.query_row(
"SELECT name, parent_id, kind FROM collections WHERE id = ?1 AND deleted = 0",
[id.0 as i64],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.optional()
.unwrap_or(None);
let Some((name, parent, kind)) = row else {
// Deleted underneath us — by a merge, or by the action that just ran.
close_row_menu(window, ctl);
return;
};
let (holds, children) = direct_holdings(conn, id).unwrap_or((0, 0));
*ctl.menu_for.borrow_mut() = Some(id);
ctl.menu_confirming.set(false);
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
// roll-up is one pass over the whole tree, and a menu that computed its own
// could disagree with the tray beside the name it is titled with.
let pinned = ctl
.row_ids
.borrow()
.iter()
.position(|&c| c == id)
.and_then(|row| {
window
.global::<Collections>()
.get_collection_rows()
.row_data(row)
})
.is_some_and(|r| r.pinned);
window
.global::<Collections>()
.set_collection_menu_pinned(pinned);
}
/// Shut the row menu, both halves.
///
/// The controller's copy is what the handlers act on and the window property is
/// what draws the sheet; clearing one without the other leaves either an
/// invisible menu that still answers, or a visible one that acts on nothing.
pub(super) fn close_row_menu(window: &AppWindow, ctl: &Rc<CollectionsController>) {
*ctl.menu_for.borrow_mut() = None;
ctl.menu_confirming.set(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.
///
/// Both halves together, always: the controller's copy is what a stray second
/// commit is tested against, and the window property is what draws the field.
/// Clearing one without the other either leaves a field open that nothing will
/// 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.global::<Collections>().set_collection_renaming(0);
}
/// What a committed rename did.
#[derive(Debug, PartialEq, Eq)]
pub(super) enum Rename {
/// Written, with the name as stored — trimmed.
Applied(String),
/// The name was the one it already had, so nothing was written.
///
/// Distinguished from `Applied` because every write bumps the revision, and
/// a rename to the same name would let a device that changed nothing win a
/// merge against one that did real work.
Unchanged,
}
/// Validate a typed name and store it.
///
/// Split out so the rules are testable without a window — they are the part a
/// user runs into:
///
/// - **blank is refused.** A nameless row is unclickable and unfindable, and
/// the schema is happy to store one.
/// - **a sibling's name is refused.** Two identically-named collections in one
/// parent are indistinguishable in the sidebar, which is how images end up in
/// the wrong one. The same reasoning as [`unique_name`], enforced here rather
/// than silently suffixing: the user typed a specific name and quietly
/// storing a different one is worse than saying no.
///
/// Case-insensitive against siblings, because the sidebar sorts that way and
/// "Iceland" beside "iceland" is the same trap as an exact duplicate.
pub(super) fn apply_rename(
conn: &rusqlite::Connection,
id: CollectionId,
typed: &str,
) -> Result<Rename, dr_catalog::CatalogError> {
let name = typed.trim();
if name.is_empty() {
return Err(dr_catalog::CatalogError::BadName(
"a collection needs a name".into(),
));
}
// The current name, which also proves the collection is still there.
let current: String = conn.query_row(
"SELECT name FROM collections WHERE id = ?1 AND deleted = 0",
[id.0 as i64],
|r| r.get(0),
)?;
if current == name {
return Ok(Rename::Unchanged);
}
// Siblings, excluding this collection: a rename that only changes case is a
// real rename, and must not be refused as a clash with itself.
let clash: Option<i64> = conn
.query_row(
"SELECT 1 FROM collections
WHERE deleted = 0
AND id != ?1
AND name = ?2 COLLATE NOCASE
AND parent_id IS (SELECT parent_id FROM collections WHERE id = ?1)",
rusqlite::params![id.0 as i64, name],
|r| r.get(0),
)
.optional()?;
if clash.is_some() {
return Err(dr_catalog::CatalogError::BadName(format!(
"there is already a “{name}” here"
)));
}
coll::rename(conn, id, name)?;
Ok(Rename::Applied(name.to_string()))
}
/// TRACES: FR-CAT-7
/// Make a collection inside `parent` and open its name field.
///
/// Shared by the header's `+` and the row menu's "New collection inside",
/// which differ only in where the parent comes from — and must not differ in
/// anything else, or building a tree from the tree would behave unlike
/// building one from the header.
pub(super) fn create_child(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
catalog: &Catalog,
parent: Option<CollectionId>,
) {
let name = unique_name(catalog.connection(), parent);
match coll::create(catalog.connection(), &name, parent, CollectionKind::Manual) {
Ok(id) => {
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);
}
// Straight into the name field, with "New collection" selected.
// The name is a placeholder nobody wants to keep, so making the
// user find the rename gesture afterwards is asking them to finish
// a job we started.
//
// Opened *after* the rebuild, and the order is load-bearing:
// `refresh_tree` replaces the row model, which destroys and
// recreates every row. A field opened before it would be torn down
// along with the `init` that focuses it, leaving an edit box
// nothing had typed into. Setting the property afterwards puts the
// field on a row that already exists.
refresh_tree(window, ctl, catalog);
*ctl.renaming.borrow_mut() = Some(id);
window
.global::<Collections>()
.set_collection_renaming(id.0 as i32);
log::info!("created collection {} ({name})", id.0);
}
Err(e) => window
.global::<Collections>()
.set_collection_error(format!("creating collection: {e}").into()),
}
}
/// A name no sibling is already using.
///
/// Duplicate names are legal in the schema, and two identically-named
/// collections in one parent are indistinguishable in the sidebar — which is
/// how images end up in the wrong one.
pub(super) fn unique_name(conn: &rusqlite::Connection, parent: Option<CollectionId>) -> String {
let taken: Vec<String> = {
let sql = match parent {
Some(_) => "SELECT name FROM collections WHERE deleted = 0 AND parent_id = ?1",
None => "SELECT name FROM collections WHERE deleted = 0 AND parent_id IS NULL",
};
let Ok(mut stmt) = conn.prepare(sql) else {
return "New collection".into();
};
// Collected inside each arm: the two `query_map` calls bind different
// parameter types, so their iterators are different types and cannot
// be the arms of one `match`.
match parent {
Some(p) => stmt
.query_map([p.0 as i64], |r| r.get::<_, String>(0))
.map(|rows| rows.flatten().collect())
.unwrap_or_default(),
None => stmt
.query_map([], |r| r.get::<_, String>(0))
.map(|rows| rows.flatten().collect())
.unwrap_or_default(),
}
};
let base = "New collection";
if !taken.iter().any(|t| t == base) {
return base.into();
}
for n in 2..1000 {
let candidate = format!("{base} {n}");
if !taken.contains(&candidate) {
return candidate;
}
}
base.into()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_collections_do_not_share_a_name_with_a_sibling() {
// Two identically-named collections in one parent are
// indistinguishable in the sidebar, which is how images land in the
// wrong one.
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
let first = unique_name(c, None);
coll::create(c, &first, None, CollectionKind::Manual).unwrap();
let second = unique_name(c, None);
coll::create(c, &second, None, CollectionKind::Manual).unwrap();
assert_ne!(first, second);
assert_eq!(first, "New collection");
assert_eq!(second, "New collection 2");
}
/// A catalog holding one top-level collection, and its id.
fn with_one(name: &str) -> (Catalog, CollectionId) {
let cat = Catalog::in_memory().unwrap();
let id = coll::create(cat.connection(), name, None, CollectionKind::Manual).unwrap();
(cat, id)
}
fn name_of(cat: &Catalog, id: CollectionId) -> String {
cat.connection()
.query_row(
"SELECT name FROM collections WHERE id = ?1",
[id.0 as i64],
|r| r.get(0),
)
.unwrap()
}
#[test]
fn a_rename_stores_the_new_name() {
let (cat, id) = with_one("Untitled");
let out = apply_rename(cat.connection(), id, "Iceland").unwrap();
assert_eq!(out, Rename::Applied("Iceland".into()));
assert_eq!(name_of(&cat, id), "Iceland");
}
#[test]
fn surrounding_whitespace_is_trimmed_rather_than_stored() {
// A trailing space is invisible in the sidebar and makes two
// collections look identical while sorting them apart.
let (cat, id) = with_one("Untitled");
assert_eq!(
apply_rename(cat.connection(), id, " Iceland ").unwrap(),
Rename::Applied("Iceland".into())
);
assert_eq!(name_of(&cat, id), "Iceland");
}
#[test]
fn a_blank_name_is_refused() {
// A nameless row cannot be read or aimed at, and the schema would take
// one happily.
let (cat, id) = with_one("Iceland");
assert!(matches!(
apply_rename(cat.connection(), id, " "),
Err(dr_catalog::CatalogError::BadName(_))
));
assert_eq!(name_of(&cat, id), "Iceland", "the old name stands");
}
#[test]
fn renaming_to_the_current_name_writes_nothing() {
// Every write bumps the revision, and a no-op rename would let an idle
// device beat one that did real work at the next merge.
let (cat, id) = with_one("Iceland");
let rev = |c: &Catalog| -> i64 {
c.connection()
.query_row(
"SELECT revision FROM collections WHERE id = ?1",
[id.0 as i64],
|r| r.get(0),
)
.unwrap()
};
let before = rev(&cat);
assert_eq!(
apply_rename(cat.connection(), id, "Iceland").unwrap(),
Rename::Unchanged
);
assert_eq!(rev(&cat), before);
}
#[test]
fn a_siblings_name_is_refused() {
// Two identically-named collections in one parent are
// indistinguishable in the sidebar, which is how images land in the
// wrong one.
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
coll::create(c, "Iceland", None, CollectionKind::Manual).unwrap();
let japan = coll::create(c, "Japan", None, CollectionKind::Manual).unwrap();
assert!(matches!(
apply_rename(c, japan, "Iceland"),
Err(dr_catalog::CatalogError::BadName(_))
));
assert_eq!(name_of(&cat, japan), "Japan");
}
#[test]
fn a_siblings_name_is_refused_in_a_different_case_too() {
// The sidebar sorts case-insensitively, so "iceland" beside "Iceland"
// is the same trap as an exact duplicate.
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
coll::create(c, "Iceland", None, CollectionKind::Manual).unwrap();
let japan = coll::create(c, "Japan", None, CollectionKind::Manual).unwrap();
assert!(matches!(
apply_rename(c, japan, "iceland"),
Err(dr_catalog::CatalogError::BadName(_))
));
}
#[test]
fn changing_only_the_case_of_a_name_is_allowed() {
// The clash check excludes the collection itself, or fixing the
// capitalisation of a name would be refused as a clash with itself.
let (cat, id) = with_one("iceland");
assert_eq!(
apply_rename(cat.connection(), id, "Iceland").unwrap(),
Rename::Applied("Iceland".into())
);
assert_eq!(name_of(&cat, id), "Iceland");
}
#[test]
fn a_name_used_under_a_different_parent_is_free() {
// Uniqueness is per parent: "Selects" inside two different trips is
// unambiguous and normal.
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
let trips = coll::create(c, "Trips", None, CollectionKind::Manual).unwrap();
coll::create(c, "Selects", Some(trips), CollectionKind::Manual).unwrap();
let top = coll::create(c, "Loose", None, CollectionKind::Manual).unwrap();
assert_eq!(
apply_rename(c, top, "Selects").unwrap(),
Rename::Applied("Selects".into())
);
}
#[test]
fn two_top_level_collections_still_clash_despite_a_null_parent() {
// `parent_id IS NULL` never matches with `=`, so a naive clash query
// would silently allow every duplicate at the top level — which is
// where most collections live.
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
coll::create(c, "Iceland", None, CollectionKind::Manual).unwrap();
let other = coll::create(c, "Japan", None, CollectionKind::Manual).unwrap();
assert!(
apply_rename(c, other, "Iceland").is_err(),
"two top-level collections must not share a name"
);
}
#[test]
fn renaming_a_deleted_collection_fails_rather_than_resurrecting_it() {
let (cat, id) = with_one("Gone");
coll::delete(cat.connection(), id).unwrap();
assert!(apply_rename(cat.connection(), id, "Back").is_err());
}
#[test]
fn a_saved_filter_can_be_renamed() {
// Its *membership* is computed; its name is not, and a smart
// collection the user cannot label is worse than no smart collection.
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
let s = coll::create(c, "Untitled", None, CollectionKind::Smart).unwrap();
assert_eq!(
apply_rename(c, s, "Five star").unwrap(),
Rename::Applied("Five star".into())
);
}
#[test]
fn the_same_name_is_free_again_under_a_different_parent() {
// Uniqueness is per parent, not global: "Selects" inside two different
// trips is unambiguous and normal.
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
let top = coll::create(c, "New collection", None, CollectionKind::Manual).unwrap();
assert_eq!(unique_name(c, Some(top)), "New collection");
}
}