Split library_ui.rs into a module directory by area of behaviour

controller holds LibraryController and the window-sizing constants every
other module reads and writes through pub(super) fields, the same shape
collections_ui and develop already use. open is the launch-to-scan cycle and
the worker that checks the catalog file before either touches it. offline is
what of a collection is on this device and the prompt that offers to change
it. window fills the grid model from the catalog and drains the thumbnail
fetch, which is the piece the catalog-reads-are-proportional-to-what-changed
rule (docs/catalog.md §1) bears on most directly. sync is the background
passes that reach beyond the loaded window: the metadata sweep, the
whole-library thumbnail pass, and the exchange with the server.
ratings_keywords applies a judgement or a keyword to a selection and queues
the sidecar and XMP writes behind it. timeline is the capture-time sidebar
and the photographer's place together, kept in one file because a restored
place ends by moving the timeline marker and a scrub is a restore of one
instant, so most calls between the two would otherwise cross a module
boundary. grid wires the grid's own callbacks — the keyboard cursor,
cell-size zoom, the routes into and out of develop — and filter_bar wires
the rating, people, date and offline-scope filters, calling back into
whichever of the above owns the work a filter change triggers.

Extracted by item rather than by line range, so every doc comment and
TRACES/GESTURE annotation stayed attached to the code it describes; the
sorted set of TRACES/GESTURE lines in the new directory is identical to the
original file's. Tests moved with the code they exercise, including the
handful of fixtures — settle, model_of, with_catalog, zoom_cell, pinch_step
— that only one target module needed and so were not worth sharing through
a test_support module the way the other splits use one. Items that crossed
a new module boundary were widened from private to pub(super), narrower
than the whole-file access the original gave them; a few items already
pub(crate) for recovery_ui or presets stayed there rather than being
narrowed, since nothing needed them tightened further.

mod.rs re-exports the same surface library_ui:: callers used before, so
lib.rs and every other caller needed no change.
This commit is contained in:
2026-09-20 21:10:02 +02:00
parent 14ed1dc410
commit cc73ea3153
12 changed files with 8676 additions and 8412 deletions
+690
View File
@@ -0,0 +1,690 @@
//! Keeping a collection's originals on this device: the offline prompt, the
//! pin fetch, and the connectivity banner every worker in this directory
//! paints through [`refresh_offline`].
//!
//! Split out because it is one coherent user question — "what of this is on
//! my device, and what would keeping or releasing it cost" — with its own
//! catalog queries, distinct from loading a window over the grid or scanning
//! the tree. See `docs/dev/code-health.md` CH-1.
use std::rc::Rc;
use dr_catalog::Catalog;
use slint::ComponentHandle;
use crate::library;
use crate::AppWindow;
use super::controller::{stop, LibraryController};
/// TRACES: FR-NC-6a | FR-NC-6c
/// What a collection would cost to take with you, and what it is holding now.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct OfflineSummary {
/// Photographs in the collection and its children, deduplicated.
total: usize,
/// Of those, how many have their original on this device.
held: usize,
/// Disk those originals occupy — what releasing would give back.
held_bytes: u64,
/// What the rest would cost to fetch, from the sizes the scan recorded.
/// Zero where nothing has been stat-ed yet, which reads as "unknown"
/// rather than "free" in the label built from it.
missing_bytes: u64,
}
impl OfflineSummary {
fn missing(self) -> usize {
self.total.saturating_sub(self.held)
}
}
/// Read the offline summary for a set of images.
///
/// One query with the ids inlined as placeholders — the same shape
/// [`collection_images`] uses, and for the same reason: a collection is tens to
/// thousands of rows, and a round trip per photograph to answer one dialogue is
/// not a trade worth making.
fn offline_summary(catalog: &Catalog, images: &[dr_types::ImageId]) -> OfflineSummary {
if images.is_empty() {
return OfflineSummary::default();
}
let placeholders = std::iter::repeat_n("?", images.len())
.collect::<Vec<_>>()
.join(",");
// `tier_actual`, never `tier_desired`: the question is what can be opened
// on the aeroplane, and a pin whose download has not run yet answers no.
let sql = format!(
"SELECT count(*),
coalesce(sum(CASE WHEN c.tier_actual >= ?1 THEN 1 ELSE 0 END), 0),
coalesce(sum(CASE WHEN c.tier_actual >= ?1 THEN c.bytes ELSE 0 END), 0),
coalesce(sum(CASE WHEN c.tier_actual >= ?1 THEN 0
ELSE coalesce(i.file_size, 0) END), 0)
FROM images i
LEFT JOIN image_cache c ON c.image_id = i.id
WHERE i.id IN ({placeholders})"
);
let mut params: Vec<rusqlite::types::Value> = vec![rusqlite::types::Value::Integer(
dr_types::Tier::Original.stored(),
)];
params.extend(
images
.iter()
.map(|i| rusqlite::types::Value::Integer(i.0 as i64)),
);
catalog
.connection()
.query_row(&sql, rusqlite::params_from_iter(params.iter()), |r| {
Ok(OfflineSummary {
total: r.get::<_, i64>(0)? as usize,
held: r.get::<_, i64>(1)? as usize,
held_bytes: r.get::<_, i64>(2)? as u64,
missing_bytes: r.get::<_, i64>(3)? as u64,
})
})
.unwrap_or_else(|e| {
log::debug!("reading offline summary: {e}");
OfflineSummary::default()
})
}
/// TRACES: FR-NC-6a | FR-NC-6c
/// Ask what should happen to a collection's local copies.
///
/// Both answers are expensive — one commits the device to a download of
/// gigabytes, the other deletes gigabytes it already holds — so this is a
/// question rather than a toggle, and the counts and sizes go in the buttons
/// where they are read *before* the tap rather than in a second dialogue after
/// it (FR-NC-6c: an operation requiring absent data says so, with the size,
/// before starting).
pub(super) fn open_offline_prompt(
window: &AppWindow,
ctl: &Rc<LibraryController>,
id: dr_types::CollectionId,
) {
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
// Descendants, matching what the grid shows when scoped to this row:
// keeping a parent whose children hold the photographs must keep the
// photographs, or the answer would appear to do nothing.
let ids = match dr_catalog::collections::descendants(catalog.connection(), id) {
Ok(ids) => ids,
Err(e) => {
window.set_library_error(format!("resolving collection: {e}").into());
return;
}
};
let images = collection_images(catalog, &ids);
let summary = offline_summary(catalog, &images);
let name = dr_catalog::collections::tree(catalog.connection())
.ok()
.and_then(|rows| {
rows.into_iter()
.find(|r| r.collection.id == id)
.map(|r| r.collection.name)
})
.unwrap_or_else(|| "This collection".to_string());
ctl.offline_target.set(Some(id));
window.set_offline_prompt_title(name.as_str().into());
window.set_offline_prompt_detail(
if summary.total == 0 {
"Nothing in here yet. Put some photographs in it first.".to_string()
} else if summary.held == summary.total {
format!(
"All {} on this device · {}",
summary.total,
crate::activity::describe_bytes(summary.held_bytes)
)
} else {
format!(
"{} photographs · {} already on this device",
summary.total, summary.held
)
}
.as_str()
.into(),
);
window.set_offline_prompt_keep_label(
// The size is named where the scan has recorded one. Where it has not,
// the label says what it will do and not what it will cost, which is
// honest — a "0 B" download would be a lie about a gigabyte.
if summary.missing_bytes > 0 {
format!(
"Download {} · {}",
summary.missing(),
crate::activity::describe_bytes(summary.missing_bytes)
)
} else if summary.missing() > 0 {
format!("Download {}", summary.missing())
} else {
"Everything is already here".to_string()
}
.as_str()
.into(),
);
window.set_offline_prompt_release_label(
format!(
"Remove {} local copies · frees {}",
summary.held,
crate::activity::describe_bytes(summary.held_bytes)
)
.as_str()
.into(),
);
window.set_offline_prompt_can_keep(summary.missing() > 0);
window.set_offline_prompt_can_release(summary.held > 0);
window.set_offline_prompt_busy(window.get_library_pin_total() > 0);
}
/// Close the offline question without answering it.
pub(super) fn close_offline_prompt(window: &AppWindow, ctl: &Rc<LibraryController>) {
ctl.offline_target.set(None);
// The title is what the prompt's visibility is bound to: one fact, so a
// dialogue cannot be up with nothing written on it.
window.set_offline_prompt_title(slint::SharedString::new());
}
/// TRACES: FR-NC-6a
/// Keep a collection on this device: record the pin, then start the transfer.
///
/// Two separate things, and keeping them separate is what makes the answer feel
/// immediate — recording the intent is a local catalog write that completes at
/// once, and downloading the bytes may take a very long time. The pin also
/// survives the app being closed halfway through, which is what makes the
/// transfer resumable rather than something to start again.
pub(super) fn keep_collection_offline(
window: &AppWindow,
ctl: &Rc<LibraryController>,
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
) {
let Some(id) = ctl.offline_target.get() else {
return;
};
let Some(cache) = ctl.cache() else {
window.set_library_error("No cache directory for this library.".into());
return;
};
let images = {
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
let ids = match dr_catalog::collections::descendants(catalog.connection(), id) {
Ok(ids) => ids,
Err(e) => {
window.set_library_error(format!("resolving collection: {e}").into());
return;
}
};
let images = collection_images(catalog, &ids);
if images.is_empty() {
window.set_library_error("Nothing in that collection to keep offline.".into());
return;
}
if let Err(e) = cache.pin(catalog.connection(), &images) {
window.set_library_error(format!("pinning: {e}").into());
return;
}
images
};
log::info!("pinned {} image(s) for offline use", images.len());
window.set_library_error(slint::SharedString::new());
if ctl.scope.borrow().as_ref() == Some(&id) {
window.set_library_scope_pinned(true);
}
close_offline_prompt(window, ctl);
start_pin_fetch(window, ctl);
refresh_collection_tree(window, ctl, coll_ctl);
}
/// TRACES: FR-NC-6a | FR-NC-6b
/// Give the disk back: release the pin *and* delete the originals it held.
///
/// Deliberately destructive, where unpinning alone is not. "Remove the local
/// copies" is asked by someone whose device is full, and answering it by
/// withdrawing a promise and leaving the gigabytes for a future eviction to
/// notice is not an answer. Nothing is lost that cannot be fetched again: the
/// originals are on the server, and the ratings, the edit graph and the
/// thumbnails are all untouched — they are authoritative and small.
pub(super) fn release_collection_offline(
window: &AppWindow,
ctl: &Rc<LibraryController>,
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
) {
let Some(id) = ctl.offline_target.get() else {
return;
};
let Some(cache) = ctl.cache() else {
window.set_library_error("No cache directory for this library.".into());
return;
};
let released = {
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
let ids = match dr_catalog::collections::descendants(catalog.connection(), id) {
Ok(ids) => ids,
Err(e) => {
window.set_library_error(format!("resolving collection: {e}").into());
return;
}
};
let images = collection_images(catalog, &ids);
let outcome = match cache.release(catalog.connection(), &images) {
Ok(r) => r,
Err(e) => {
window.set_library_error(format!("removing local copies: {e}").into());
return;
}
};
// TRACES: FR-NC-6c
// On a placeholder library the bookkeeping above owns no files, so it
// freed nothing — the originals are materialised in the library folder
// and only the sync client may take them back. Asking it to is what
// makes unpinning actually return the disk, and it must be a
// dehydration rather than a delete: removing a file inside a synced
// tree propagates to the server (ARCH §9.0a).
//
// Fire and forget: it is per-file work over a socket, the user has
// already been told the pin is withdrawn, and a client that refuses
// leaves the content where it is at no cost but disk.
if let Some(conn) = ctl.session() {
let path = library::catalog_path(&conn.account);
std::mem::drop(library::spawn_dehydrate(conn, path, images));
}
outcome
};
let (count, freed) = released;
log::info!(
"released {count} image(s), freeing {}",
crate::activity::describe_bytes(freed)
);
window.set_library_error(slint::SharedString::new());
window.set_library_status(
format!(
"Removed local copies · {} freed",
crate::activity::describe_bytes(freed)
)
.as_str()
.into(),
);
if ctl.scope.borrow().as_ref() == Some(&id) {
window.set_library_scope_pinned(false);
}
// A download that was still running for this collection has just had its
// reason withdrawn; the worker checks `pending_pins` per file, so it stops
// finding work rather than being killed.
window.set_library_pin_total(0);
window.set_library_pin_done(0);
close_offline_prompt(window, ctl);
refresh_local_count(window, ctl);
refresh_collection_tree(window, ctl, coll_ctl);
}
/// Redraw the sidebar, so the trays reflect what was just kept or released.
fn refresh_collection_tree(
window: &AppWindow,
ctl: &Rc<LibraryController>,
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
) {
let borrow = ctl.catalog.borrow();
if let Some(catalog) = borrow.as_ref() {
crate::collections_ui::refresh_tree(window, coll_ctl, catalog);
}
}
/// Every image in the given collections, deduplicated.
///
/// An image in both a parent and a child is one photograph and must be pinned
/// once, exactly as the grid draws it once.
pub(super) fn collection_images(
catalog: &Catalog,
ids: &[dr_types::CollectionId],
) -> Vec<dr_types::ImageId> {
if ids.is_empty() {
return Vec::new();
}
let placeholders = std::iter::repeat_n("?", ids.len())
.collect::<Vec<_>>()
.join(",");
let sql = format!(
"SELECT DISTINCT image_id FROM collection_members
WHERE collection_id IN ({placeholders})"
);
let params: Vec<rusqlite::types::Value> = ids
.iter()
.map(|c| rusqlite::types::Value::Integer(c.0 as i64))
.collect();
let Ok(mut stmt) = catalog.connection().prepare(&sql) else {
return Vec::new();
};
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok(dr_types::ImageId(r.get::<_, i64>(0)? as u64))
});
match rows {
Ok(rows) => rows.flatten().collect(),
Err(e) => {
log::debug!("listing collection images: {e}");
Vec::new()
}
}
}
/// TRACES: FR-NC-6a
/// Download whatever the pins still want, reporting progress.
fn start_pin_fetch(window: &AppWindow, ctl: &Rc<LibraryController>) {
let Some((conn, _)) = ctl.session.borrow().clone() else {
return;
};
let Some(cache_dir) = ctl.cache_dir() else {
return;
};
// Offline, there is nothing to download from. The pin is already recorded,
// so it resumes on reconnect rather than being lost.
if ctl.is_offline() {
log::info!("offline: the pin is recorded and will download on reconnect");
return;
}
let rx = library::spawn_pin_fetch(
conn.clone(),
library::catalog_path(&conn.account),
cache_dir,
// Pinned originals are exempt from the budget, but a pin fetch also
// stores passively when it finds an image already cached, so the worker
// still needs the user's ceiling rather than the catalog's floor.
ctl.cache_budget.get(),
);
let timer = slint::Timer::default();
let weak = window.as_weak();
let ctl_cb = ctl.clone();
// The longest-running transfer the app does, and the one most likely to be
// watched from another view — which is the whole reason the register
// exists (FR-NC-6, FR-NC-6c).
let job = ctl.activity.begin(
crate::activity::Kind::Download,
"Keeping photographs on this device",
);
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(300),
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) => {
w.set_library_pin_total(0);
job.fail("stopped without finishing");
stop(&ctl_cb.pin_timer);
return;
}
};
match msg {
library::PinMessage::Planned { total } => {
w.set_library_pin_total(total as i32);
w.set_library_pin_done(0);
job.total(total);
}
library::PinMessage::Stored { done } => {
w.set_library_pin_done(done as i32);
job.progress(done, w.get_library_pin_total() as usize);
// The "On this device" count grows as they land, so
// the chip agrees with the progress line beside it.
refresh_local_count(&w, &ctl_cb);
}
library::PinMessage::Done { stored, bytes } => {
log::info!(
"pin complete: {stored} original(s), {:.1} MB",
bytes as f64 / 1_048_576.0
);
job.finish(format!(
"{stored} photograph(s) · {}",
crate::activity::describe_bytes(bytes)
));
w.set_library_pin_total(0);
w.set_library_pin_done(0);
refresh_local_count(&w, &ctl_cb);
stop(&ctl_cb.pin_timer);
return;
}
library::PinMessage::Failed { message, offline } => {
log::warn!("pin fetch stopped: {message}");
job.fail(message.clone());
w.set_library_pin_total(0);
if offline {
ctl_cb
.reachability
.borrow_mut()
.mark_unreachable(message, std::time::Instant::now());
refresh_offline(&w, &ctl_cb);
} else {
w.set_library_error(format!("keeping offline: {message}").into());
}
refresh_local_count(&w, &ctl_cb);
stop(&ctl_cb.pin_timer);
return;
}
}
}
},
);
*ctl.pin_timer.borrow_mut() = Some(timer);
}
/// Refresh the "On this device" count from the catalog.
fn refresh_local_count(window: &AppWindow, ctl: &Rc<LibraryController>) {
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
window.set_library_local_count(library::local_original_count(catalog).unwrap_or(0) as i32);
}
/// TRACES: FR-NC-6a
/// Whether every image in the scoped collection is pinned.
///
/// Read from the catalog rather than remembered, because a pin outlives the
/// session that made it: reopening the library must show the button already
/// active, or the user would pin the same collection twice.
pub(super) fn scope_is_pinned(catalog: &Catalog, images: &[dr_types::ImageId]) -> bool {
if images.is_empty() {
return false;
}
let placeholders = std::iter::repeat_n("?", images.len())
.collect::<Vec<_>>()
.join(",");
let params: Vec<rusqlite::types::Value> = images
.iter()
.map(|i| rusqlite::types::Value::Integer(i.0 as i64))
.collect();
let pinned: i64 = catalog
.connection()
.query_row(
&format!(
"SELECT count(*) FROM image_cache
WHERE pinned = 1 AND image_id IN ({placeholders})"
),
rusqlite::params_from_iter(params.iter()),
|r| r.get(0),
)
.unwrap_or(0);
pinned as usize == images.len()
}
/// TRACES: FR-CAT-9
/// Paint the connectivity state into the window.
///
/// Called wherever reachability may have moved, rather than by the state
/// itself: `Reachability` is in `dr-sync` and knows nothing about a window,
/// which is what keeps it testable without a display server.
pub(super) fn refresh_offline(window: &AppWindow, ctl: &Rc<LibraryController>) {
let reach = ctl.reachability.borrow();
// TRACES: FR-PLAT-AND-2
// A lost root wins over a dead network, and does so even when both are
// true — which is the ordinary case, since the scan that discovered the
// grant was gone was also the last request the app made. Reported the
// other way round the user is told to wait for a connection that is
// working, and the thing that would actually fix it is never mentioned.
let lost = ctl.root_lost.borrow();
let offline = reach.is_offline() || lost.is_some();
window.set_library_offline(offline);
window.set_library_offline_reason(match lost.as_deref() {
Some(why) => why.into(),
None => reach.reason().unwrap_or_default().into(),
});
window.set_library_offline_since(
// A duration is what a network outage has and a revoked permission
// does not: "for 4 minutes" invites waiting, and waiting is precisely
// what will not help here.
if lost.is_some() {
slint::SharedString::default()
} else {
reach
.offline_for(std::time::Instant::now())
.map(describe_duration)
.unwrap_or_default()
.into()
},
);
drop(lost);
// A stale scan error under an offline banner reports one problem twice.
if offline {
window.set_library_error("".into());
}
drop(reach);
// TRACES: FR-CAT-9
// Back online: send whatever the outbox is still holding.
//
// Hung off the one function that paints connectivity rather than off each
// of the seven places that move it — a drain that had to be remembered at
// every call site is a drain that will be forgotten at one of them, and
// the symptom is an edit that stays queued until the app is restarted.
//
// `start_outbox_drain` is a no-op when the outbox is empty and while one
// is already running, so calling it on every repaint costs a directory
// walk that finds nothing.
if !offline {
start_outbox_drain(window, ctl);
}
}
/// TRACES: FR-CAT-9 | FR-NC-10
/// Upload the sidecars queued while this device had no connection.
///
/// Guarded on the timer rather than on a flag: the timer *is* the "a drain is
/// running" state, and a second one started beside it would drain the same
/// channel twice.
fn start_outbox_drain(window: &AppWindow, ctl: &Rc<LibraryController>) {
if ctl.outbox_timer.borrow().is_some() {
return;
}
if !ctl.outbox_maybe_dirty.get() {
return;
}
let Some(cache_dir) = ctl.sidecar_cache_dir() else {
return;
};
if crate::sidecar_cache::SidecarCache::open(cache_dir.clone())
.pending()
.is_empty()
{
// Nothing there. Recorded so the next hundred repaints skip the walk;
// a write that queues sets it again.
ctl.outbox_maybe_dirty.set(false);
return;
}
let Some((conn, _)) = ctl.session.borrow().clone() else {
return;
};
let rx = library::spawn_outbox_drain(conn.clone(), cache_dir);
let job = ctl
.activity
.begin(crate::activity::Kind::Upload, "Uploading queued edits");
let timer = slint::Timer::default();
let weak = window.as_weak();
let ctl_cb = ctl.clone();
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(250),
move || {
let Some(w) = weak.upgrade() else { return };
match rx.try_recv() {
Ok(library::SidecarMessage::Finished {
written,
queued,
failed,
last_error,
}) => {
if failed > 0 {
log::warn!(
"{failed} queued sidecar(s) still undelivered: {}",
last_error.clone().unwrap_or_default()
);
// Not `fail`: the edits are still safely queued, and
// reporting this as a loss would be wrong.
job.finish(format!("{written} uploaded · {queued} still queued"));
} else if written > 0 {
log::info!("{written} queued sidecar(s) uploaded");
job.finish(format!("{written} queued edit(s) uploaded"));
w.set_library_status(format!("{written} queued edit(s) uploaded").into());
} else {
job.finish_quietly();
}
ctl_cb.outbox_maybe_dirty.set(queued > 0);
stop(&ctl_cb.outbox_timer);
}
Err(std::sync::mpsc::TryRecvError::Empty) => {}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
job.finish_quietly();
stop(&ctl_cb.outbox_timer);
}
}
},
);
*ctl.outbox_timer.borrow_mut() = Some(timer);
}
/// A coarse "how long ago", for the offline banner.
///
/// Deliberately imprecise: the user wants to know whether this just happened
/// or has been true for a while, and a live-counting seconds display would
/// draw the eye to a number that changes without meaning anything.
fn describe_duration(d: std::time::Duration) -> String {
let secs = d.as_secs();
if secs < 60 {
"just now".to_string()
} else if secs < 3600 {
format!("{}m ago", secs / 60)
} else {
format!("{}h ago", secs / 3600)
}
}