Files
DarkRoom/ui/dr-ui/src/library_ui/controller.rs
T
dtourolle cc73ea3153 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.
2026-09-20 21:10:02 +02:00

1046 lines
49 KiB
Rust

//! Library state for the running window, and the window-sizing constants
//! that decide how much of the catalog is loaded.
//!
//! `LibraryController` is the piece every other module in this directory
//! borrows from, so its fields are `pub(super)` rather than reached through
//! accessor methods: the area of behaviour that touches one field — loading,
//! the timeline, ratings, keywords — owns its own file instead of all of it
//! living beside the fields it reads. See `docs/dev/code-health.md` CH-1.
use std::cell::RefCell;
use std::path::PathBuf;
use std::rc::Rc;
use dr_catalog::Catalog;
use dr_sync::Connection;
use dr_types::FormatFilter;
use crate::library;
/// A screenful before the grid has reported its geometry.
///
/// Only used for the very first load; the grid replaces it with what it
/// actually shows as soon as it has laid out.
const INITIAL_VIEWPORT_CELLS: usize = 20;
/// How many screenfuls the loaded window spans.
///
/// One of them is on screen, so this buys `SCREENFULS - 1` screenfuls of
/// loaded-but-undrawn cells to scroll into, split unevenly by
/// [`window_start`]: a quarter of the window above the view, the rest below,
/// because a grid is read downward.
///
/// Owned here rather than in the grid, alongside [`window_move`]'s rule for
/// how close the view may come to an edge before the window follows. Split
/// across the two — the grid loading three screenfuls, Rust holding on until
/// the view was three quarters of the way through them — the two numbers
/// disagreed by a quarter of a screenful, and that quarter was rows on screen
/// that no loaded cell covered. Which is the bottom row of the grid going
/// blank.
pub(super) const SCREENFULS: usize = 4;
/// Never load fewer than this, whatever the viewport reports.
///
/// A window collapsed to a sliver would otherwise load one or two cells and
/// re-query on every scroll tick.
pub(super) const MIN_WINDOW: usize = 24;
/// Cell-size bounds for the grid's zoom.
///
/// The lower end is where a thumbnail stops being recognisable; the upper is
/// where one screen holds so few that the grid stops being a grid. Past 256px
/// the large thumbnail class is fetched, so the top of this range is sharp
/// rather than upscaled.
pub(super) const MIN_CELL_SIZE: f32 = 90.0;
pub(super) const MAX_CELL_SIZE: f32 = 420.0;
/// TRACES: NFR-P5
/// What the grid's whole-library readouts are an answer about.
///
/// The timeline's bars, the filter chips' counts, the "on this device" count
/// and whether the scoped collection is pinned all describe the *library*, not
/// the window over it — and [`load_window`] recomputed every one of them each
/// time the window moved, which is several times per screenful of scrolling.
/// Together they are a `MIN`/`MAX`, a `GROUP BY`, two counts and, under a
/// collection, three more queries: about 8 ms of SQLite on the thread that is
/// trying to draw the frame, for four answers that scrolling cannot change.
///
/// So they are recomputed when this changes and not otherwise. `total` is in
/// here as the change detector as much as anything else: it is already read on
/// every load for the scrollbar, and a scan landing, a delete or a restore all
/// move it. What it cannot see — a rating edited under an unchanged count — is
/// covered because the paths that do that refresh the chips themselves.
/// Not `Copy` since `RatingFilter` stopped being — it holds a set of people.
#[derive(Clone, PartialEq, Eq)]
pub(super) struct LibraryFacts {
pub(super) scope: Option<dr_types::CollectionId>,
pub(super) filter: library::RatingFilter,
pub(super) trash: bool,
pub(super) total: usize,
}
/// Library state for the running window.
pub struct LibraryController {
/// Shared with [`crate::collections_ui`], which edits collections against
/// the same connection. `Rc` rather than a second `Catalog::open`: two
/// handles on one SQLite file would each hold their own WAL view, so a
/// collection edited through one would not be visible through the other
/// until it committed and the reader reopened.
pub(super) catalog: Rc<RefCell<Option<Catalog>>>,
/// Remote paths for the rows currently in the model, parallel to it.
pub(super) paths: RefCell<Vec<String>>,
/// `oc:fileid` and file length per row, parallel to the model.
pub(super) file_ids: RefCell<Vec<Option<u64>>>,
pub(super) sizes: RefCell<Vec<u64>>,
/// Catalog row ids and whether each still needs its EXIF read.
pub(super) image_ids: RefCell<Vec<i64>>,
pub(super) needs_metadata: RefCell<Vec<bool>>,
/// When each row in the model was taken, parallel to it.
///
/// Kept so the timeline marker can be moved from the window the grid has
/// already read rather than from a query per scroll event — see
/// [`capture_time_at`].
pub(super) captured_at: RefCell<Vec<Option<i64>>>,
/// The size class of the pixels each row is currently showing, `None` for a
/// row still waiting.
///
/// Parallel to the model, and the reason [`load_window`] can carry a
/// thumbnail across a reload without re-deciding whether it is sharp
/// enough: a cell holding the 256px class while the grid has since been
/// zoomed past that must keep showing what it has *and* still ask for the
/// large one. Recording the class the pixels came from is what tells those
/// two states apart — without it a carried thumbnail either blocks the
/// sharper fetch forever or is re-fetched on every scroll.
pub(super) thumb_class: RefCell<Vec<Option<dr_thumbs::ThumbSize>>>,
/// Where in the catalog the current window starts. Scrubbing moves this.
pub(super) offset: RefCell<usize>,
/// The first visible ordinal, kept so returning from the develop view lands
/// where the user left rather than at the top.
///
/// Recorded when the grid is left, not read back when it is re-entered:
/// `show-library` gates an `if` in the markup, so the whole grid subtree is
/// destroyed and rebuilt, and the rebuilt `Flickable` reports a scroll to
/// row 0 before anything can restore the old position. Capturing at the
/// moment of departure is the only value the reset cannot overwrite.
///
/// Separate from `offset`, which is the *loaded window*'s start and sits a
/// quarter-window above the view. Restoring that as a viewport position
/// would land the user consistently short of where they were.
pub(super) resume_at: std::cell::Cell<usize>,
/// How many cells to load, derived from what the viewport can show.
///
/// A fixed count is wrong in both directions: too small on a maximised 4K
/// window, wasteful on a narrow one. The grid measures itself and reports
/// its screenful; this is [`SCREENFULS`] of them.
pub(super) window: RefCell<usize>,
/// What the whole-library readouts on screen were last computed for, so a
/// window that merely moved does not recompute them. See [`LibraryFacts`].
pub(super) library_facts: RefCell<Option<LibraryFacts>>,
/// How many cells the viewport shows at once, as the grid last reported.
///
/// Kept beside `window` rather than divided back out of it, because
/// `MIN_WINDOW` can hold the window above what the screenful implies — and
/// a screenful guessed too small is exactly the mistake that leaves the
/// bottom row of the grid outside the loaded window.
pub(super) viewport_cells: std::cell::Cell<usize>,
/// Which rows already have a thumbnail fetch issued, so scrolling back
/// does not refetch what is already on screen.
/// Which images have a fetch in flight or already served, keyed on
/// `(image_id, size class)`.
///
/// **Identity, not row index.** The grid is a window over the catalog, so
/// row 7 means a different photograph after every scroll — a set of
/// indices had to be cleared on each window move, which made every visible
/// cell look unrequested and re-issued the whole screenful on every
/// scroll.
///
/// The size class is part of the key because the two resolutions are
/// fetched independently: holding the 256px one says nothing about whether
/// the large one has been asked for.
pub(super) requested: RefCell<std::collections::HashSet<(i64, dr_thumbs::ThumbSize)>>,
/// Drains the worker that opens and verifies the catalog — see
/// [`spawn_catalog_open`]. Held for the same reason every other timer here
/// is: a `slint::Timer` stops when it is dropped, so a local one would
/// never fire.
pub(super) catalog_timer: RefCell<Option<slint::Timer>>,
pub(super) scan_timer: RefCell<Option<slint::Timer>>,
pub(super) thumb_timer: RefCell<Option<slint::Timer>>,
/// The whole-library sweep, which outlives any one grid window.
pub(super) sweep_timer: RefCell<Option<slint::Timer>>,
/// TRACES: FR-CAT-3
/// Drains the whole-library thumbnail pass. Held for the same reason as
/// the others, and read as well as written: it is what stops a second
/// press of the button starting a pass alongside the first.
pub(super) thumb_sweep_timer: RefCell<Option<slint::Timer>>,
/// Pushing shards and the catalog to the server.
pub(super) sync_timer: RefCell<Option<slint::Timer>>,
/// Kept so a rescan can run without going back through the launch screen.
///
/// A [`Connection`] rather than credentials beside an account: it is what
/// every worker needs, it is what `remote::connect` takes, and holding the
/// two halves separately is how they came to be threaded through fifteen
/// signatures in the wrong order.
pub(super) session: RefCell<Option<(Connection, FormatFilter)>>,
/// Which collection narrows the grid, owned by [`crate::collections_ui`]
/// and read here. Shared rather than passed per call because a rescan, a
/// scrub and a drop all reload the window and must all honour it.
pub(super) scope: RefCell<Option<dr_types::CollectionId>>,
/// TRACES: FR-UI-8
/// How to open a photograph in develop.
///
/// Held so a restored place that was left in develop can put the user back
/// there. The closure is the grid's own — `wire` is handed it and stashes a
/// clone — rather than a second route into the develop view, which would be
/// a second place for "persist the outgoing edit first" to be forgotten.
pub(super) open_image: RefCell<Option<OpenImage>>,
/// Where this library's position is written, once one is open.
///
/// `None` before a library has been opened, and on the local-files path,
/// which has no library to have a position in.
pub(super) place_store: RefCell<Option<crate::place::PlaceStore>>,
/// What this device calls itself in a place it writes.
///
/// [`dr_thumbs::ThumbStore::client_id`], so the place and the thumbnail
/// shards a device publishes carry the same name — which is what makes
/// `.darkroom-derived/` readable by a human wondering which machine put
/// what there. Informational only: see [`crate::place`] on why the
/// timestamp, not the device, decides an exchange.
pub(super) place_device: RefCell<String>,
/// Coalesces the writes a flick would otherwise make one of per event.
pub(super) place_timer: RefCell<Option<slint::Timer>>,
/// Drains the launch-time fetch of the server's place. Held for the reason
/// every other timer here is: a `slint::Timer` stops when it is dropped.
pub(super) place_fetch_timer: RefCell<Option<slint::Timer>>,
/// Whether a place is being applied right now.
///
/// A restore moves the scope, the filter and the viewport, and every one of
/// those is something [`note_place`] otherwise treats as the photographer
/// having moved. Without this a restore would close its own handover latch
/// and, worse, write the record back out under a fresh timestamp — so the
/// device that had just *adopted* a place from the tablet would claim to be
/// the newer of the two.
pub(super) applying_place: std::cell::Cell<bool>,
/// Whether the photographer has moved since this library was opened.
///
/// The latch that decides whether a place arriving from another device may
/// still be applied. A handover is only welcome before the user has started
/// working: a grid that jumped somewhere else *while being scrolled*, because
/// a network round-trip finally landed, would be worse than never handing
/// over at all.
pub(super) place_untouched: std::cell::Cell<bool>,
/// TRACES: FR-CAT-15
/// Whether the grid is listing the trash rather than the library.
///
/// Separate from `scope` rather than a sentinel id in it, for the reason
/// [`crate::collections_ui::CollectionsController`] gives: the trash is not
/// a collection, and its query *inverts* the predicate every other view
/// applies. Folding that into a type meaning "a collection" would put the
/// inversion where nothing reading `scope` expects it.
///
/// A `Cell` because it is a `bool` read inside callbacks that already hold
/// other borrows.
pub(super) viewing_trash: std::cell::Cell<bool>,
/// The selection's owner, so a rebuilt window can restore the `selected`
/// flags it just cleared.
///
/// `Weak` because the two controllers outlive each other only through the
/// window, and an `Rc` both ways would leak both. Set once at wiring; a
/// `None` here means the grid simply draws nothing selected, which is the
/// old behaviour rather than a crash.
pub(super) coll_ctl:
RefCell<Option<std::rc::Weak<crate::collections_ui::CollectionsController>>>,
/// Timeline view state: how far zoomed in, and around what instant.
///
/// Zoom is a level rather than a span so the axis halves and doubles in
/// even steps; the centre is what keeps the thing you were looking at in
/// view as you zoom.
pub(super) timeline_zoom: RefCell<i32>,
pub(super) timeline_centre: RefCell<Option<i64>>,
/// Pinch ratio accumulated since the last zoom step was taken.
///
/// A pinch is continuous and zoom levels are discrete, so the ratio is
/// held until it reaches a doubling. Without it a slow spread would either
/// do nothing or, if each update were rounded, leap several levels.
pub(super) pinch_accum: RefCell<f32>,
/// The instant the grid is showing.
///
/// `None` only before the first axis has been built: `refresh_timeline`
/// seeds it from wherever the view sits, so the marker reports a position
/// from the first frame rather than resting greyed at mid-track until the
/// user happens to scroll. A scroll or a scrub then sets it directly.
pub(super) current_bucket: RefCell<Option<i64>>,
/// What the rating filter bar is narrowed to.
///
/// Held here beside `scope` and for the same reason: a scrub, a rescan and
/// a drop all reload the window, and every one of them must honour it or
/// the filter silently lapses.
pub(super) filter: RefCell<library::RatingFilter>,
/// TRACES: FR-CAT-9
/// Drains the outbox uploader. Held so that a repaint while one is
/// already running does not start a second against the same channel —
/// this handle *is* the "a drain is in flight" state.
pub(super) outbox_timer: RefCell<Option<slint::Timer>>,
/// Whether the outbox might hold something, so the common case costs a
/// boolean rather than a directory walk.
///
/// `refresh_offline` runs on scan progress as well as on a genuine
/// connectivity change, so the drain is *asked* far more often than there
/// is anything to send. Starts `true` so the first ask after launch does
/// walk — edits queued in a previous session are exactly the ones that
/// need sending, and nothing in memory knows about them.
pub(super) outbox_maybe_dirty: std::cell::Cell<bool>,
/// Drains the sidecar writer. Held so a second judgement replaces the
/// timer rather than leaving two draining the same finished channel.
pub(super) sidecar_timer: RefCell<Option<slint::Timer>>,
/// TRACES: FR-CAT-13
/// The same, for a batch of XMP writes or a reload.
pub(super) xmp_timer: RefCell<Option<slint::Timer>>,
/// Which window load the model belongs to, bumped by [`load_window`].
///
/// A thumbnail worker addresses cells by *row index into the window that
/// asked for it*. A reload replaces the model, so once the generation has
/// moved on every row still in flight names a different photograph —
/// applying it paints thumbnails onto unrelated cells, or marks a cell
/// "no preview" for a fetch never attempted against it. Worse, a stale
/// drain reaching `Disconnected` would call `stop` on `thumb_timer`, which
/// by then holds the *current* batch's timer: the new worker then fetched
/// into a channel nobody drained and the grid stayed black until a scroll
/// forced another load.
///
/// A `Cell` rather than a `RefCell`: it is read inside a timer callback
/// that already holds borrows of other fields, and a `u64` needs no borrow
/// tracking.
pub(super) generation: std::cell::Cell<u64>,
/// TRACES: FR-CAT-9
/// Whether the server is reachable, inferred from what the workers saw.
///
/// Lives on the controller rather than in a global because it is scoped to
/// one open library: signing into a different account starts a fresh
/// judgement, and carrying the old one over would report a server down
/// that was never contacted.
pub(super) reachability: RefCell<dr_sync::Reachability>,
/// TRACES: FR-PLAT-AND-2 | FR-CAT-9
/// Why the library folder itself could not be opened, if it could not.
///
/// Beside [`Self::reachability`] rather than inside it, because
/// `dr_sync::Reachability` models *the server*, and it is deliberately
/// unmoved by a refusal — a forbidden file must not report the network as
/// down (see its own tests). A revoked tree grant is a refusal, so folding
/// it in would either break that rule or need an exception carved through
/// it.
///
/// Set only by a scan that failed at the root, and cleared only by one
/// that succeeded. Both states drive the same banner as being offline
/// does, because what the user can do is the same — carry on with what is
/// stored on the device — but the sentence under it is different, and so
/// is what will end it.
pub(super) root_lost: RefCell<Option<String>>,
/// TRACES: FR-NC-6a
/// Drains the pin downloader. Held so a second pin replaces the timer
/// rather than leaving two draining the same finished channel.
/// Coalesces the reloads a run of geometry changes would otherwise each
/// demand — see [`schedule_reload`].
pub(super) geometry_timer: RefCell<Option<slint::Timer>>,
/// The ordinal the view was on when the current run of geometry changes
/// began, so the settle returns to the photograph the user was looking at
/// rather than to wherever the re-flow left the viewport.
pub(super) pending_anchor: std::cell::Cell<Option<usize>>,
pub(super) pin_timer: RefCell<Option<slint::Timer>>,
/// TRACES: FR-NC-6a
/// Which collection the offline question is being asked about.
///
/// Held rather than passed through the window because the prompt's three
/// answers arrive as three separate callbacks, and a dialogue that read its
/// subject back out of a string property would act on whatever the sidebar
/// had been rebuilt to say since.
pub(super) offline_target: std::cell::Cell<Option<dr_types::CollectionId>>,
/// Narrow the grid to images whose original is stored locally.
///
/// A `Cell` beside `filter` rather than a field inside it: the rating
/// filter compiles to a SQL predicate over `versions`, while this one is a
/// predicate over the cache, and folding two different joins into one type
/// would put the cache schema inside a rating concept.
pub(super) local_only: std::cell::Cell<bool>,
/// TRACES: FR-NC-6a
/// The ceiling on passively cached originals, from the settings page.
///
/// Held here rather than read from `SettingsStore` at each use because the
/// workers that need it run on threads with no config access — the same
/// reason [`library::CacheContext`] takes it as a field. A `Cell` because
/// the settings page can move it while a library is open, and the next
/// fetch must use the new figure rather than one captured at open.
pub(super) cache_budget: std::cell::Cell<dr_catalog::Budget>,
/// TRACES: FR-NC-6a
/// Whether opening an image in develop keeps its original on disk.
///
/// Held beside the budget and for the same reason. Distinct from a zero
/// budget: this switches the passive population off entirely, while a
/// small budget still keeps a working set. A metered or small-disk device
/// wants the first.
pub(super) keep_opened: std::cell::Cell<bool>,
/// TRACES: FR-NC-6a | FR-UI-4
/// How many photographs each side of the open one are fetched ahead.
/// Mirrors `CacheSettings::fetch_ahead`, held beside the flag that
/// gates it.
pub(super) fetch_ahead: std::cell::Cell<u32>,
/// TRACES: FR-CAT-13 | NFR-R4
/// Whether judgements and keywords also go to the `.xmp` beside the
/// original. Mirrors `LibrarySettings::write_xmp_sidecars`.
pub(super) write_xmp: std::cell::Cell<bool>,
/// TRACES: FR-CAT-6
/// How many bars the capture-time axis is cut into, from the settings
/// page.
///
/// Held here beside the cache budget and for the same reason: the axis is
/// redrawn from a dozen callbacks that have no config access, and reading
/// a JSON file on every scroll to answer "how many bars" would be the
/// wrong shape of question. A `Cell` because the page can change it while
/// a library is open.
pub(super) timeline_bars: std::cell::Cell<u32>,
/// TRACES: FR-CULL-8
/// Which face pipeline this device indexes with, from the settings page.
///
/// Held here for the same reason as the two above: the derived sync runs
/// from a sweep's completion and from the Sync button, neither of which
/// has the settings in reach, and the shards it exports and adopts are
/// keyed on this id. A `RefCell` of the id rather than the enum so that
/// nothing here has to know how a detector becomes a model id.
pub(super) face_model_id: RefCell<String>,
/// Where every worker this module starts reports what it is doing.
///
/// Held on the controller rather than passed to each function because the
/// jobs are started from a dozen callbacks — a scroll, a rescan, a pin, a
/// reconnect — and threading a second argument through all of them would
/// say nothing except that they all report progress.
pub(super) activity: Rc<crate::activity::ActivityLog>,
}
impl LibraryController {
pub fn new(activity: Rc<crate::activity::ActivityLog>) -> Rc<Self> {
Rc::new(Self {
activity,
catalog: Rc::new(RefCell::new(None)),
paths: RefCell::new(Vec::new()),
file_ids: RefCell::new(Vec::new()),
sizes: RefCell::new(Vec::new()),
image_ids: RefCell::new(Vec::new()),
needs_metadata: RefCell::new(Vec::new()),
captured_at: RefCell::new(Vec::new()),
thumb_class: RefCell::new(Vec::new()),
offset: RefCell::new(0),
resume_at: std::cell::Cell::new(0),
window: RefCell::new((INITIAL_VIEWPORT_CELLS * SCREENFULS).max(MIN_WINDOW)),
viewport_cells: std::cell::Cell::new(INITIAL_VIEWPORT_CELLS),
library_facts: RefCell::new(None),
requested: RefCell::new(Default::default()),
catalog_timer: RefCell::new(None),
scan_timer: RefCell::new(None),
thumb_timer: RefCell::new(None),
sweep_timer: RefCell::new(None),
thumb_sweep_timer: RefCell::new(None),
sync_timer: RefCell::new(None),
session: RefCell::new(None),
scope: RefCell::new(None),
open_image: RefCell::new(None),
place_store: RefCell::new(None),
place_device: RefCell::new(String::new()),
place_timer: RefCell::new(None),
place_fetch_timer: RefCell::new(None),
place_untouched: std::cell::Cell::new(true),
applying_place: std::cell::Cell::new(false),
viewing_trash: std::cell::Cell::new(false),
coll_ctl: RefCell::new(None),
timeline_zoom: RefCell::new(0),
timeline_centre: RefCell::new(None),
pinch_accum: RefCell::new(1.0),
current_bucket: RefCell::new(None),
filter: RefCell::new(library::RatingFilter::default()),
sidecar_timer: RefCell::new(None),
xmp_timer: RefCell::new(None),
generation: std::cell::Cell::new(0),
reachability: RefCell::new(dr_sync::Reachability::new()),
root_lost: RefCell::new(None),
outbox_timer: RefCell::new(None),
outbox_maybe_dirty: std::cell::Cell::new(true),
geometry_timer: RefCell::new(None),
pending_anchor: std::cell::Cell::new(None),
pin_timer: RefCell::new(None),
offline_target: std::cell::Cell::new(None),
local_only: std::cell::Cell::new(false),
// The catalog's own floor until the settings page reports what the
// user has stored, which it does at startup before any fetch.
cache_budget: std::cell::Cell::new(dr_catalog::Budget::default()),
keep_opened: std::cell::Cell::new(
dr_types::CacheSettings::default().keep_opened_originals,
),
fetch_ahead: std::cell::Cell::new(dr_types::CacheSettings::default().fetch_ahead),
write_xmp: std::cell::Cell::new(
dr_types::LibrarySettings::default().write_xmp_sidecars,
),
timeline_bars: std::cell::Cell::new(dr_types::LibrarySettings::default().timeline_bars),
face_model_id: RefCell::new(
crate::inference::model_id(dr_types::FaceDetector::default()).to_string(),
),
})
}
/// TRACES: FR-CULL-8
/// Which face pipeline the next sync exports and adopts under.
///
/// Set on every settings edit like the bars above. A sync already running
/// keeps the id it started with, which is right: its shards are half
/// written under that one.
pub fn set_face_model_id(&self, id: &str) {
if *self.face_model_id.borrow() != id {
*self.face_model_id.borrow_mut() = id.to_string();
}
}
/// TRACES: FR-CAT-6
/// How many bars the capture-time axis is cut into.
///
/// Answers whether it changed, so the caller can leave the grid alone when
/// it did not: this is set on every settings edit, and reloading the
/// window because a user changed their export folder would be a visible
/// stutter for nothing.
pub fn set_timeline_bars(&self, bars: u32) -> bool {
let bars = bars.max(1);
self.timeline_bars.replace(bars) != bars
}
/// TRACES: FR-NC-6a
/// Whether a develop open should keep the original it downloads.
///
/// Turning it off leaves what is already cached alone: those bytes are
/// paid for, and deleting them would make the switch destructive when it
/// only means "stop adding to this".
pub fn set_keep_opened_originals(&self, keep: bool) {
self.keep_opened.set(keep);
}
/// TRACES: FR-NC-6a | FR-UI-4
/// How far along the roll, each side, the next open fetches ahead.
pub fn set_fetch_ahead(&self, depth: u32) {
self.fetch_ahead.set(depth);
}
pub fn fetch_ahead(&self) -> u32 {
self.fetch_ahead.get()
}
/// TRACES: FR-CAT-13 | NFR-R4
pub fn set_write_xmp_sidecars(&self, on: bool) {
self.write_xmp.set(on);
}
/// TRACES: FR-NC-6a
/// Set the ceiling on passively cached originals, and apply it now.
///
/// Applied immediately rather than only to later downloads: a user who has
/// just lowered the limit expects the space back, and a budget that took
/// effect only on the next fetch would leave the cache over its stated
/// ceiling for as long as they browsed nothing new.
pub fn set_cache_budget(&self, bytes: Option<u64>) {
let budget = match bytes {
Some(n) => dr_catalog::Budget::bytes(n),
None => dr_catalog::Budget::unlimited(),
};
self.cache_budget.set(budget);
// Best-effort: no library open means no cache to trim, and a failure
// here must not stop the setting from being stored — the ceiling still
// applies to every fetch from now on.
let Some(dir) = self.cache_dir() else { return };
let borrow = self.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
match dr_catalog::Cache::open(&dir, budget)
.and_then(|cache| cache.enforce(catalog.connection()))
{
Ok(0) => {}
Ok(n) => log::info!("cache budget changed: evicted {n} original(s)"),
Err(e) => log::warn!("applying the new cache budget: {e}"),
}
}
/// TRACES: FR-CAT-9 | FR-PLAT-AND-2
/// Whether the library cannot be reached, for either of the two reasons.
///
/// One answer rather than two because every caller asks it for the same
/// purpose: to decide whether starting a transfer is worth attempting.
/// A revoked grant fails that question exactly as a dead network does, and
/// a sync started against it would spend its retries proving it.
pub fn is_offline(&self) -> bool {
self.reachability.borrow().is_offline() || self.root_lost.borrow().is_some()
}
/// Whether the grid is narrowed to locally-stored originals.
pub fn local_only(&self) -> bool {
self.local_only.get()
}
/// TRACES: FR-NC-6a
/// The catalog id for a remote path currently in the grid.
///
/// The cache is keyed on `ImageId` because that is what survives a
/// server-side rename (FR-NC-5), while the develop callback carries only a
/// path. `paths` and `image_ids` are parallel to the model, so this is the
/// join between the two.
pub fn image_id_for_path(&self, path: &str) -> Option<dr_types::ImageId> {
let index = self.paths.borrow().iter().position(|p| p == path)?;
self.image_ids
.borrow()
.get(index)
.map(|id| dr_types::ImageId(*id as u64))
}
/// TRACES: FR-NC-6a | FR-UI-4
/// The photographs within `depth` of `path` on the roll, closest first
/// and working outwards: next, previous, next-but-one, previous-but-one…
///
/// That order is the point. The prefetcher serves the list one at a time
/// and drops the rest the moment the user moves, so whatever depth is
/// set, the frame most likely to be stepped to is the first thing
/// fetched — and the step most likely is one forward, which is why next
/// leads previous at every distance.
///
/// In the roll's order, which is the loaded window's — the same rows a
/// roll pick names — so what gets fetched ahead is exactly what a step
/// lands on, filter and scope included. The window's edges cut the list
/// short; a photograph not in the window has no neighbours, and a
/// prefetch of nothing is the right answer for it.
pub fn neighbours_of(&self, path: &str, depth: u32) -> Vec<String> {
let paths = self.paths.borrow();
let Some(at) = paths.iter().position(|p| p == path) else {
return Vec::new();
};
(1..=depth as usize)
.flat_map(|d| [at.checked_add(d), at.checked_sub(d)])
.filter_map(|i| paths.get(i?).cloned())
.collect()
}
/// TRACES: FR-NC-8 | FR-DEV-6
/// The default version's uuid for an image, by its remote path.
///
/// Taken from the catalog rather than generated, and rather than read off
/// whatever the sidecar happens to contain. The uuid is the identity a
/// cross-device merge keys on (FR-NC-8): a develop session that invented
/// one would write a *second* version beside the one the cull is stored
/// in, and the photograph would arrive on the other device holding two
/// edits that never merge.
///
/// Creates the version if the image has none, by the same route a rating
/// does — see `dr_catalog::rating::default_version_id` for why an image
/// can legitimately arrive without one.
pub fn version_uuid_for_path(&self, path: &str) -> Option<String> {
let image = self.image_id_for_path(path)?;
let borrow = self.catalog.borrow();
let catalog = borrow.as_ref()?;
let conn = catalog.connection();
let id = match dr_catalog::rating::default_version_id(conn, image) {
Ok(id) => id,
Err(e) => {
log::debug!("no version for {path}: {e}");
return None;
}
};
conn.query_row("SELECT uuid FROM versions WHERE id = ?1", [id], |r| {
r.get(0)
})
.ok()
}
/// TRACES: FR-CAT-9 | FR-NC-10
/// Where cached sidecars and the upload outbox live for the open library.
///
/// Beside the catalog and the originals cache, for the same reason those
/// two sit together: all three are per-account and are discarded together.
/// A separate directory rather than a subfolder of `originals` because the
/// two have opposite lifetimes — originals are evicted under a budget
/// (FR-NC-6a), and a queued edit must never be.
pub fn sidecar_cache_dir(&self) -> Option<PathBuf> {
let borrow = self.session.borrow();
let (conn, _) = borrow.as_ref()?;
library::catalog_path(&conn.account)
.parent()
.map(|p| p.join("sidecars"))
}
/// TRACES: FR-NC-6a
/// Where cached originals live for the open library.
///
/// Beside the catalog rather than under a system cache directory: the two
/// are per-account and are discarded together, and a cached original whose
/// catalog row is gone is unreachable anyway.
pub fn cache_dir(&self) -> Option<PathBuf> {
let borrow = self.session.borrow();
let (conn, _) = borrow.as_ref()?;
library::catalog_path(&conn.account)
.parent()
.map(|p| p.join("originals"))
}
/// Open the originals cache for the current library.
///
/// Used by pinning, which writes intent against the catalog directly
/// rather than going through a fetch.
pub fn cache(&self) -> Option<dr_catalog::Cache> {
let dir = self.cache_dir()?;
match dr_catalog::Cache::open(&dir, self.cache_budget.get()) {
Ok(c) => Some(c),
Err(e) => {
// Not fatal: without a cache every open is a download, which
// is exactly the behaviour that existed before this.
log::warn!("originals cache unavailable: {e}");
None
}
}
}
/// TRACES: FR-NC-6a
/// Everything a full fetch needs to read and write the cache.
///
/// Assembled here because the develop callback holds only a path, while
/// the cache is keyed on `ImageId` and lives beside a catalog whose
/// location comes from the session. `None` where any part is missing — a
/// grid row that has scrolled away, or no library open — and the fetch
/// then simply goes to the network.
pub fn cache_context(&self, path: &str) -> Option<library::CacheContext> {
self.cache_context_for(self.image_id_for_path(path)?)
}
/// TRACES: FR-EXP-7 | FR-NC-6a
/// The same, for an image named by id rather than by path.
///
/// A batch export needs this one: its selection is by catalog id and may
/// include photographs that have scrolled out of the loaded window, where
/// [`Self::image_id_for_path`] has nothing to match against. Going through
/// the path would quietly hand those images no cache at all, and a batch of
/// three hundred would re-download every one of them.
pub fn cache_context_for(&self, image: dr_types::ImageId) -> Option<library::CacheContext> {
let borrow = self.session.borrow();
let (conn, _) = borrow.as_ref()?;
let catalog_path = library::catalog_path(&conn.account);
let dir = catalog_path.parent()?.join("originals");
drop(borrow);
Some(library::CacheContext {
dir,
catalog_path,
image,
// The user's ceiling, not the catalog's floor: read at each fetch
// so a budget changed mid-session takes effect on the next one.
budget: self.cache_budget.get(),
// Gates the *write* only. Reading stays enabled either way: bytes
// already on disk were paid for, and refusing to use them because
// the user has since stopped adding new ones would re-download
// images that are sitting right there — and would make a pinned
// collection unopenable offline.
store: self.keep_opened.get(),
})
}
/// Toggle the local-only filter, resetting the window.
///
/// The offset is cleared for the same reason a scope change clears it: the
/// filter changes which images exist as far as the grid is concerned, so a
/// position counted against the old set names a different photograph.
pub fn set_local_only(&self, on: bool) {
self.local_only.set(on);
*self.offset.borrow_mut() = 0;
self.requested.borrow_mut().clear();
}
/// The catalog handle, for [`crate::collections_ui`] to edit through.
pub fn catalog(&self) -> Rc<RefCell<Option<Catalog>>> {
self.catalog.clone()
}
/// The open library's connection.
///
/// Needed by the trash, whose move and delete go to the same account the
/// scan and thumbnail workers use. `None` before a library is opened.
pub fn session(&self) -> Option<Connection> {
self.session.borrow().as_ref().map(|(c, _)| c.clone())
}
/// Catalog ids of the rows currently in the model, in model order.
///
/// Selection is keyed on these rather than on row indices: the grid is a
/// window over the catalog and a scrub replaces every row, so an index
/// would silently come to mean a different photograph.
pub fn visible_ids(&self) -> Vec<dr_types::ImageId> {
self.image_ids
.borrow()
.iter()
.map(|id| dr_types::ImageId(*id as u64))
.collect()
}
/// TRACES: FR-CAT-5
/// Catalog ids of grid rows `first..=last`, loaded or not.
///
/// The counterpart to [`Self::visible_ids`], and the reason both exist: a
/// shift-click names a run by its two ends, and everything between them is
/// usually off screen. Answered from the catalog, through the same scope,
/// filter and ordering this controller reads its window with — so the run
/// is the run the user can see themselves selecting, continued past the
/// edge of what has been loaded.
///
/// Empty before a library is open, and empty if the query fails: a
/// selection gesture is not worth an error dialog, and the caller keeps
/// what was already selected.
pub fn ids_in_span(&self, first: usize, last: usize) -> Vec<dr_types::ImageId> {
let borrow = self.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return Vec::new();
};
library::read_ids_span(
catalog,
*self.scope.borrow(),
&self.filter.borrow(),
self.viewing_trash.get(),
first,
last,
)
.unwrap_or_else(|e| {
log::warn!("reading the selected range: {e}");
Vec::new()
})
}
/// TRACES: FR-EXP-7
/// Remote paths for a set of selected images, in the order they were given.
///
/// Answered from the catalog rather than from `paths`, which is only the
/// loaded window. Selection is by id precisely so that it survives a scrub
/// (see [`crate::collections_ui`]), so a selection made before scrolling
/// routinely names photographs no row currently holds — and an export that
/// silently dropped those would be worse than one that refused.
///
/// An id the catalog has never heard of is skipped rather than reported: it
/// can only mean the image was deleted between the selection and the click,
/// and there is nothing to export and nothing to fix.
///
/// Each path comes back beside the id it belongs to, because that skipping
/// means the ids that come out are not the ids that went in — and the caller
/// still has to find each photograph's cache, which is keyed on the id.
pub fn selected_image_paths(
&self,
images: &[dr_types::ImageId],
) -> Vec<(dr_types::ImageId, String)> {
if images.is_empty() {
return Vec::new();
}
let borrow = self.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return Vec::new();
};
let placeholders = std::iter::repeat_n("?", images.len())
.collect::<Vec<_>>()
.join(",");
let sql = format!("SELECT id, source_ref FROM images WHERE id IN ({placeholders})");
let params: Vec<rusqlite::types::Value> = images
.iter()
.map(|i| rusqlite::types::Value::Integer(i.0 as i64))
.collect();
let Ok(mut stmt) = catalog.connection().prepare(&sql) else {
return Vec::new();
};
let Ok(rows) = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?))
}) else {
return Vec::new();
};
// `IN` returns rows in whatever order suits SQLite, and the export's
// `{seq}` token counts through the batch — so the answer is put back
// into the order the caller asked in rather than the one it arrived in.
let found: std::collections::HashMap<i64, String> = rows.flatten().collect();
images
.iter()
.filter_map(|id| found.get(&(id.0 as i64)).map(|path| (*id, path.clone())))
.collect()
}
/// TRACES: FR-EXP-7
/// The selection, addressed the way an export worker can fetch it.
///
/// Assembled here because a worker thread can reach neither the catalog nor
/// the session, and both are needed to say where a cached original lives.
pub fn export_sources(&self, images: &[dr_types::ImageId]) -> Vec<crate::export::Source> {
self.selected_image_paths(images)
.into_iter()
.map(|(id, path)| crate::export::Source::Library {
path,
cache: self.cache_context_for(id),
})
.collect()
}
/// The open library's connection, for a full-file fetch.
///
/// The grid's paths are remote, so opening an image means fetching it, and
/// that goes through the same account the thumbnail workers use.
pub fn credentials(&self) -> Option<Connection> {
self.session.borrow().as_ref().map(|(c, _)| c.clone())
}
/// Narrow the grid to a collection, or to the whole library with `None`.
pub fn set_scope(&self, scope: Option<dr_types::CollectionId>) {
*self.scope.borrow_mut() = scope;
// Selecting a collection is leaving the trash. Without this, picking a
// collection while the trash was open would keep listing trashed images
// under that collection's name.
self.viewing_trash.set(false);
// A new scope is a different set of images, so the old window's
// position and its issued fetches mean nothing.
*self.offset.borrow_mut() = 0;
self.requested.borrow_mut().clear();
}
/// TRACES: FR-CAT-15
/// Show the trash instead of the library.
///
/// Clears `scope` as well: the trash is not inside a collection, and leaving
/// a stale scope set would narrow it to one on the way back out.
pub fn set_viewing_trash(&self, viewing: bool) {
self.viewing_trash.set(viewing);
if viewing {
*self.scope.borrow_mut() = None;
}
*self.offset.borrow_mut() = 0;
self.requested.borrow_mut().clear();
}
}
/// TRACES: FR-UI-8
/// The grid's own route into the develop view, as something that can be held.
///
/// Named because it is threaded through the controller as well as passed to
/// `wire`, and a `RefCell<Option<Rc<dyn Fn(String)>>>` written out twice is a
/// signature nobody reads.
pub(super) type OpenImage = Rc<dyn Fn(String)>;
pub(super) fn stop(slot: &RefCell<Option<slint::Timer>>) {
if let Some(t) = slot.borrow().as_ref() {
t.stop();
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The prefetch order is the walking order: closest first, next before
/// previous at every distance, and the window's edges cut it short.
#[test]
fn neighbours_are_listed_closest_first_working_outwards() {
let ctl = LibraryController::new(crate::activity::ActivityLog::new());
*ctl.paths.borrow_mut() = ["a", "b", "c", "d", "e", "f"].map(String::from).to_vec();
assert_eq!(ctl.neighbours_of("c", 2), vec!["d", "b", "e", "a"]);
assert_eq!(
ctl.neighbours_of("c", 10),
vec!["d", "b", "e", "a", "f"],
"past an edge the other side keeps going"
);
assert_eq!(
ctl.neighbours_of("a", 1),
vec!["b"],
"nothing before the first"
);
assert_eq!(
ctl.neighbours_of("f", 1),
vec!["e"],
"nothing after the last"
);
assert!(
ctl.neighbours_of("c", 0).is_empty(),
"zero fetches nothing ahead"
);
assert!(
ctl.neighbours_of("z", 3).is_empty(),
"a photograph outside the window has no neighbours to fetch"
);
}
/// A controller holding a catalog of four named images.
fn with_catalog() -> Rc<LibraryController> {
let ctl = LibraryController::new(crate::activity::ActivityLog::new());
let catalog = Catalog::in_memory().expect("in-memory catalog");
let c = catalog.connection();
c.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')",
[],
)
.expect("root");
for (id, name) in [(1i64, "a.CR2"), (2, "b.CR2"), (3, "c.CR2"), (4, "d.CR2")] {
c.execute(
"INSERT INTO images(id, root_id, source_ref, added_at) VALUES (?1, 1, ?2, 0)",
rusqlite::params![id, name],
)
.expect("image");
}
*ctl.catalog.borrow_mut() = Some(catalog);
ctl
}
/// Just the names, for assertions that are about order rather than ids.
fn named(ctl: &Rc<LibraryController>, images: &[dr_types::ImageId]) -> Vec<String> {
ctl.selected_image_paths(images)
.into_iter()
.map(|(_, path)| path)
.collect()
}
/// TRACES: FR-EXP-7
#[test]
fn a_selection_resolves_to_paths_in_the_order_it_was_given() {
// `IN (…)` returns rows in whatever order suits SQLite, and an export's
// `{seq}` token counts through the batch — so a lookup that handed back
// the database's order would number the photographs in an order the
// user never saw.
let ctl = with_catalog();
let paths = named(&ctl, &[dr_types::ImageId(3), dr_types::ImageId(1)]);
assert_eq!(paths, vec!["c.CR2", "a.CR2"]);
}
/// TRACES: FR-EXP-7
#[test]
fn a_selection_outside_the_loaded_window_still_resolves() {
// The property the catalog lookup exists for. Selection is by id and
// survives a scrub, so a selection made before scrolling routinely
// names photographs no row holds — and `paths`, the loaded window, is
// empty here precisely to prove nothing is being read from it.
let ctl = with_catalog();
assert!(ctl.paths.borrow().is_empty());
assert_eq!(named(&ctl, &[dr_types::ImageId(4)]), ["d.CR2"]);
}
/// TRACES: FR-EXP-7
#[test]
fn an_image_that_vanished_under_the_selection_is_skipped() {
// Deleted between the selection and the click. There is nothing to
// export and nothing to fix, so it drops out rather than becoming a
// failure row the user can do nothing about.
let ctl = with_catalog();
let paths = named(
&ctl,
&[
dr_types::ImageId(1),
dr_types::ImageId(99),
dr_types::ImageId(2),
],
);
assert_eq!(paths, vec!["a.CR2", "b.CR2"]);
}
}