Split collections_ui.rs into a module directory by area of behaviour
collections_ui.rs had grown to 4,591 lines covering the sidebar controller, the click/drag selection policy, tree refresh, the drag gesture, the trash worker, twelve wiring functions, and the row's rename/create/context menu, all in one file. Split into collections_ui/ with one module per area, the way develop/ and library/ were already split on this branch: - controller.rs: CollectionsController and the pure drop/delete/release decisions (decide_drop, decide_delete, decide_release, menu_detail, delete_warning) that a test can drive without a window. - press.rs: PressUndo and the click-and-release selection policy (apply_press, select_row, commit_press, cancel_press). - tree_sync.rs: rebuilding the sidebar from the catalog and pushing catalog-derived state into the grid (refresh_tree, offline_state, sync_lifted/sync_selection/sync_reorderable/sync_badges, refresh_membership, direct_holdings). - drag.rs: the cursor bitmap (compose_drag_image, blit_scaled) and the hold/spring timers (arm_hold, arm_spring, should_spring, collapse_spring_opened) plus their delay constants. - trash.rs: the soft delete (start_trash, start_restore, drain_trash, stop_trash, refresh_trash, format_bytes). - wiring_grid.rs / wiring_tree.rs: the wire() entry point and its twelve wire_* functions, split in two because together they were the largest single piece (grid-facing selection/drag/trash vs. sidebar-facing navigation/create/rename/row-drag/menu/membership). - rename_menu.rs: creating, naming and renaming collections, and the row's context menu (apply_rename, create_child, unique_name, open_row_menu, close_row_menu, close_rename). mod.rs carries the module's own top-level doc comment, the `pub use` re-exports for the eight items the rest of the crate reaches by `collections_ui::` path (CollectionsController, wire, refresh_tree, sync_badges, sync_selection, select_row, commit_press, cancel_press), and a shared `test_support` for the one fixture (`ids`) more than one file's tests needed. Every item that only crossed a boundary within this module, not out of it, was narrowed to `pub(super)` rather than kept at the crate-wide `pub` a single file gave it for free. Extracted with a brace-aware pass that kept each item's own leading doc comment and attributes attached to it, and tests moved with the code they exercise; every TRACES/GESTURE comment lands on the same code it did before. No file outside the new directory changed — lib.rs's `mod collections_ui;` resolves to the directory automatically, and every outside caller's `collections_ui::` path still resolves through mod.rs's re-exports.
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
//! Soft delete: moving images to the trash on the server, restoring them,
|
||||
//! and the worker drain both share.
|
||||
//!
|
||||
//! A drop is a *soft delete*: the file moves to a trash folder on the
|
||||
//! server and the catalog records where it came from. Nothing is
|
||||
//! destroyed until the user empties it, a separate, deliberate action.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use dr_catalog::Catalog;
|
||||
use dr_types::ImageId;
|
||||
use slint::ComponentHandle;
|
||||
|
||||
use crate::AppWindow;
|
||||
|
||||
use super::controller::CollectionsController;
|
||||
use super::tree_sync::refresh_tree;
|
||||
|
||||
/// Begin a soft delete: plan the moves, then hand them to a worker.
|
||||
///
|
||||
/// The plan is built here because it reads the catalog, which is not `Send`; the
|
||||
/// worker gets paths and ids and needs no catalog to do its half.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn start_trash(
|
||||
window: &AppWindow,
|
||||
ctl: &Rc<CollectionsController>,
|
||||
catalog: &Rc<RefCell<Option<Catalog>>>,
|
||||
session: &Rc<dyn Fn() -> Option<dr_sync::Connection>>,
|
||||
images: &[ImageId],
|
||||
reload: &Rc<dyn Fn()>,
|
||||
) {
|
||||
let Some(conn) = session() else {
|
||||
window.set_collection_error("Open a library first.".into());
|
||||
return;
|
||||
};
|
||||
|
||||
let moves = {
|
||||
let borrow = catalog.borrow();
|
||||
let Some(cat) = borrow.as_ref() else { return };
|
||||
match crate::trash::plan_trash(cat, &conn.account.root, images) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
window.set_collection_error(format!("planning delete: {e}").into());
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if moves.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
log::info!("moving {} image(s) to the trash", moves.len());
|
||||
window.set_library_status(format!("Moving {} to the trash…", moves.len()).into());
|
||||
// The images are leaving the grid; a selection pointing at them would
|
||||
// survive as a set of ids the user can no longer see.
|
||||
ctl.clear_selection();
|
||||
|
||||
let count = moves.len();
|
||||
let rx = crate::trash::spawn_move(
|
||||
conn.clone(),
|
||||
moves,
|
||||
crate::trash::Direction::ToTrash,
|
||||
crate::library::catalog_path(&conn.account),
|
||||
);
|
||||
|
||||
drain_trash(
|
||||
window.as_weak(),
|
||||
ctl.clone(),
|
||||
catalog.clone(),
|
||||
rx,
|
||||
reload.clone(),
|
||||
format!("Moving {count} photograph(s) to the trash"),
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-15
|
||||
/// Put trashed images back where they came from.
|
||||
///
|
||||
/// The mirror of [`start_trash`], and separate from it rather than a `direction`
|
||||
/// parameter on one function: the two differ in what they plan, what they report
|
||||
/// and what they say when the plan comes back empty, and the shared part is the
|
||||
/// three lines that spawn the worker.
|
||||
///
|
||||
/// An image whose origin was never recorded is skipped by
|
||||
/// [`crate::trash::plan_restore`] rather than guessed at. That can make the plan
|
||||
/// shorter than the selection, which is why an empty plan is reported here
|
||||
/// instead of returning silently — the user pressed a button and is owed an
|
||||
/// answer either way.
|
||||
pub(super) fn start_restore(
|
||||
window: &AppWindow,
|
||||
ctl: &Rc<CollectionsController>,
|
||||
catalog: &Rc<RefCell<Option<Catalog>>>,
|
||||
session: &Rc<dyn Fn() -> Option<dr_sync::Connection>>,
|
||||
images: &[ImageId],
|
||||
reload: &Rc<dyn Fn()>,
|
||||
) {
|
||||
let Some(conn) = session() else {
|
||||
window.set_collection_error("Open a library first.".into());
|
||||
return;
|
||||
};
|
||||
|
||||
let moves = {
|
||||
let borrow = catalog.borrow();
|
||||
let Some(cat) = borrow.as_ref() else { return };
|
||||
match crate::trash::plan_restore(cat, images) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
window.set_collection_error(format!("planning restore: {e}").into());
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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(
|
||||
"Nothing to restore — no record of where these came from.".into(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
log::info!("restoring {} image(s) from the trash", moves.len());
|
||||
window.set_library_status(format!("Restoring {}…", moves.len()).into());
|
||||
// The images are leaving the trash view, so a selection pointing at them
|
||||
// would survive as ids the user can no longer see.
|
||||
ctl.clear_selection();
|
||||
|
||||
let count = moves.len();
|
||||
let rx = crate::trash::spawn_move(
|
||||
conn.clone(),
|
||||
moves,
|
||||
crate::trash::Direction::Restore,
|
||||
crate::library::catalog_path(&conn.account),
|
||||
);
|
||||
|
||||
drain_trash(
|
||||
window.as_weak(),
|
||||
ctl.clone(),
|
||||
catalog.clone(),
|
||||
rx,
|
||||
reload.clone(),
|
||||
format!("Restoring {count} photograph(s)"),
|
||||
);
|
||||
}
|
||||
|
||||
/// Drain a trash worker on the UI thread.
|
||||
///
|
||||
/// Same shape as the scan and thumbnail drains: an mpsc channel polled by a
|
||||
/// Slint timer, so nothing blocks the event loop (NFR-P9).
|
||||
pub(super) fn drain_trash(
|
||||
weak: slint::Weak<AppWindow>,
|
||||
ctl: Rc<CollectionsController>,
|
||||
catalog: Rc<RefCell<Option<Catalog>>>,
|
||||
rx: std::sync::mpsc::Receiver<crate::trash::TrashMessage>,
|
||||
reload: Rc<dyn Fn()>,
|
||||
// What the register calls this operation. Passed in rather than derived
|
||||
// here: the three callers move files to the trash, back out of it, and
|
||||
// delete them outright, and "Deleting 40 photographs" is the one word of
|
||||
// the three that must not appear over a restore.
|
||||
title: String,
|
||||
) {
|
||||
use crate::trash::TrashMessage;
|
||||
|
||||
let timer = slint::Timer::default();
|
||||
let ctl_cb = ctl.clone();
|
||||
|
||||
// A server-side MOVE per file, so it is a transfer in the sense that
|
||||
// matters: it takes as long as the connection is slow, and it can fail
|
||||
// halfway with the library in two states at once.
|
||||
let job = ctl.activity.begin(crate::activity::Kind::Trash, title);
|
||||
|
||||
timer.start(
|
||||
slint::TimerMode::Repeated,
|
||||
std::time::Duration::from_millis(120),
|
||||
move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
|
||||
loop {
|
||||
let msg = match rx.try_recv() {
|
||||
Ok(m) => m,
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => return,
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||||
// A worker that died without reporting must not leave the
|
||||
// status line mid-sentence.
|
||||
job.fail("stopped without finishing");
|
||||
stop_trash(&ctl_cb);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match msg {
|
||||
TrashMessage::Progress {
|
||||
done,
|
||||
total,
|
||||
failed,
|
||||
} => {
|
||||
let status = if failed > 0 {
|
||||
format!("{done} / {total} · {failed} failed")
|
||||
} else {
|
||||
format!("{done} / {total}")
|
||||
};
|
||||
job.progress(done, total);
|
||||
if failed > 0 {
|
||||
job.detail(format!("{failed} failed"));
|
||||
}
|
||||
w.set_library_status(status.into());
|
||||
}
|
||||
TrashMessage::Done { moved, failed } => {
|
||||
// Reported honestly, including the partial case: "38 of
|
||||
// 40" is the truth when two files could not be moved,
|
||||
// and claiming 40 would hide a real problem.
|
||||
let status = if failed.is_empty() {
|
||||
format!("{moved} image(s) done")
|
||||
} else {
|
||||
format!("{moved} done · {} failed", failed.len())
|
||||
};
|
||||
// A partial failure is a failure in the register: the
|
||||
// library is now in two states at once, which is
|
||||
// exactly the thing worth keeping on the list.
|
||||
if failed.is_empty() {
|
||||
job.finish(status.clone());
|
||||
} else {
|
||||
job.fail(status.clone());
|
||||
}
|
||||
w.set_library_status(status.into());
|
||||
if let Some(first) = failed.first() {
|
||||
w.set_collection_error(first.as_str().into());
|
||||
}
|
||||
|
||||
let borrow = catalog.borrow();
|
||||
if let Some(cat) = borrow.as_ref() {
|
||||
refresh_trash(&w, cat);
|
||||
refresh_tree(&w, &ctl_cb, cat);
|
||||
}
|
||||
drop(borrow);
|
||||
|
||||
// The grid changed: images left the library, or came
|
||||
// back into it.
|
||||
reload();
|
||||
stop_trash(&ctl_cb);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
*ctl.trash_timer.borrow_mut() = Some(timer);
|
||||
}
|
||||
|
||||
fn stop_trash(ctl: &Rc<CollectionsController>) {
|
||||
if let Some(t) = ctl.trash_timer.borrow().as_ref() {
|
||||
t.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh the sidebar's trash count and size.
|
||||
///
|
||||
/// The size is formatted here rather than in Slint, which has no byte-size
|
||||
/// formatting — and the number is what tells the user whether emptying is worth
|
||||
/// 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 {
|
||||
slint::SharedString::new()
|
||||
} else {
|
||||
format!("{n} · {}", format_bytes(bytes)).into()
|
||||
});
|
||||
}
|
||||
|
||||
/// Bytes as a human-readable size.
|
||||
///
|
||||
/// Binary units, one decimal place past a kilobyte: a RAW library is measured in
|
||||
/// gigabytes and "3.4 GB" is the figure a photographer reasons about, where
|
||||
/// 3_650_722_201 is not.
|
||||
fn format_bytes(bytes: u64) -> String {
|
||||
const KB: f64 = 1024.0;
|
||||
let b = bytes as f64;
|
||||
if bytes < 1024 {
|
||||
return format!("{bytes} B");
|
||||
}
|
||||
for (limit, unit) in [
|
||||
(KB * KB, "kB"),
|
||||
(KB * KB * KB, "MB"),
|
||||
(KB * KB * KB * KB, "GB"),
|
||||
] {
|
||||
if b < limit {
|
||||
return format!("{:.1} {unit}", b / (limit / KB));
|
||||
}
|
||||
}
|
||||
format!("{:.1} TB", b / (KB * KB * KB * KB))
|
||||
}
|
||||
Reference in New Issue
Block a user