Files
DarkRoom/ui/dr-ui/src/library_ui/controller.rs
T
dtourolle 4bec01eaf1 Say a photograph is downloading, and how far, instead of failing
The develop view reported a remote original on its way through the
error message, so it read "Could not load image" over "Downloading…".
It did so on every step along the roll, including a cached frame that
was ready within a tick, so each step flashed the error.

Waiting is now its own state. On the step, the grid's thumbnail of the
photograph stands in at once. Only when a transfer is really on the
wire does it dim under "Not on this device yet", with a line like
"Downloading — 12.4 of 38.0 MB" and a progress bar.

The bytes come from a new RemoteBackend::get_reporting. The Nextcloud
backend overrides it to read the body chunk by chunk; the default
reports once at the end. Progress is kept in the in-flight registry by
path, because a step usually lands on a frame the prefetcher is already
fetching. The catalog's file length stands in when the server sends no
Content-Length.
2026-09-26 11:02:11 -04:00

1092 lines
51 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>,
/// The photograph open in develop, by catalog id, when it was opened from
/// this library.
///
/// **An id, not a row of the loaded window.** The roll marks a row and the
/// keyboard steps from one, but a row only names a photograph until the
/// window is next re-read — a step past its edge, a background sync, a
/// judgement that drops the frame out of a filter. Each reload finds this
/// id again in the new window (see `window::follow_open`) and puts the
/// roll's mark, and the ordinal `index` reports, back on it.
pub(super) roll_open: std::cell::Cell<Option<i64>>,
/// The open photograph was not in the window last read, although the
/// ordinal it held still was: it has left the grid — a judgement under a
/// filter, most often — and the photograph after it has moved up into its
/// place. A step forward is then onto that ordinal, not past it, or the
/// frame that closed the gap would be skipped.
pub(super) roll_left: std::cell::Cell<bool>,
/// 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),
roll_open: std::cell::Cell::new(None),
roll_left: std::cell::Cell::new(false),
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
/// What the grid already holds for `path`: the thumbnail its cell is
/// drawing, if it has one yet, and the file's length as the scan recorded
/// it.
///
/// For the develop view while the original comes down. The thumbnail is
/// the one already decoded for the grid — no store read, no decode — and
/// the length gives the progress bar a denominator when the server sends
/// none of its own.
pub fn preview_for_path(
&self,
window: &crate::AppWindow,
path: &str,
) -> (Option<slint::Image>, Option<u64>) {
use slint::{ComponentHandle as _, Model as _};
let Some(row) = self.paths.borrow().iter().position(|p| p == path) else {
return (None, None);
};
let size = self.sizes.borrow().get(row).copied().filter(|&s| s > 0);
let thumbnail = window
.global::<crate::Library>()
.get_library_cells()
.row_data(row)
.filter(|c| c.has_thumb)
.map(|c| c.thumbnail);
(thumbnail, size)
}
/// 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"]);
}
}