Files
DarkRoom/ui/dr-ui/src/library_ui.rs
T
dtourolle caf61d41a5 Re-thumbnail a photograph from its own edit
A thumbnail comes from the file's embedded preview, which is the camera's
idea of the photograph and knows nothing about what has been done to it
since. So a frame could be cropped, turned upright and pulled two stops
back, and the grid would go on showing the original — making the library,
where a photographer spends most of their time, the one view in which an
edit is invisible.

The render is the framed output, not the sensor: `output_size` is what a
crop, a quarter turn, a flip and a straighten all act on, so a thumbnail
taken from the raw frame would be the right pixels in the wrong shape and
still the wrong way up. It is the same path an export takes, at a size
the store wants rather than at full resolution, and always sRGB — this is
a JPEG in a shard that syncs between devices and is drawn as a cell, not
a file anyone is finishing.

Both size classes are replaced. The store keys on the class, so
refreshing only the one the grid happens to be drawing leaves the other
holding the unedited preview, and a zoom across the boundary would show
the edit undoing itself. Each is rendered rather than downscaled from the
larger, which would be a second and worse resampler than the GPU has
already applied.

It runs on the way out of develop, after the sidecar write is queued and
never instead of it — the edit is what must not be lost, and a render
that failed must not take the save down with it. Two cases are worth the
work: an edit made in this sitting, which `can-undo` records even when it
ends back at neutral, and an image opened with an edit already in its
sidecar and left untouched, whose cached thumbnail has never shown that
edit at all. A neutral image nobody touched fails both and costs nothing.

Not covered: a batch paste onto a selection, which deliberately never
opens a session — there is no rendered frame to take a thumbnail from,
and downloading forty RAWs to make forty is exactly what that path exists
to avoid.
2026-08-21 22:48:10 +02:00

5050 lines
206 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! TRACES: FR-CAT-4 | FR-NC-3 | NFR-P9
//! Drives the library grid from scan and thumbnail workers.
//!
//! Owns the bridge between three background activities and one single-threaded
//! event loop:
//!
//! - a **scan** worker walking the remote tree into the catalog
//! - a **thumbnail** worker range-fetching previews for visible cells
//! - the **grid model** Slint renders
//!
//! Nothing here blocks. Workers post through mpsc channels drained by Slint
//! timers, which is the same shape [`crate::launch_ui`] uses for login.
use std::cell::RefCell;
use std::path::PathBuf;
use std::rc::Rc;
use std::sync::mpsc::Receiver;
use dr_catalog::Catalog;
use dr_sync_nextcloud::{AppCredentials, Session, SessionStore};
use dr_types::FormatFilter;
use slint::{ComponentHandle, Model as _};
use crate::library::{self, ScanMessage, ThumbnailMessage};
use crate::{AppWindow, LibraryCell, TimelineBar};
/// Window size before the grid has reported its geometry.
///
/// Only used for the very first load; the grid replaces it with its real
/// capacity as soon as it has laid out.
const INITIAL_WINDOW: usize = 60;
/// 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.
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.
const MIN_CELL_SIZE: f32 = 90.0;
const MAX_CELL_SIZE: f32 = 420.0;
/// 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.
catalog: Rc<RefCell<Option<Catalog>>>,
/// Remote paths for the rows currently in the model, parallel to it.
paths: RefCell<Vec<String>>,
/// `oc:fileid` and file length per row, parallel to the model.
file_ids: RefCell<Vec<Option<u64>>>,
sizes: RefCell<Vec<u64>>,
/// Catalog row ids and whether each still needs its EXIF read.
image_ids: RefCell<Vec<i64>>,
needs_metadata: RefCell<Vec<bool>>,
/// 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.
thumb_class: RefCell<Vec<Option<dr_thumbs::ThumbSize>>>,
/// Where in the catalog the current window starts. Scrubbing moves this.
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.
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 capacity, including a screenful of margin either side.
window: RefCell<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.
requested: RefCell<std::collections::HashSet<(i64, dr_thumbs::ThumbSize)>>,
scan_timer: RefCell<Option<slint::Timer>>,
thumb_timer: RefCell<Option<slint::Timer>>,
/// The whole-library sweep, which outlives any one grid window.
sweep_timer: RefCell<Option<slint::Timer>>,
/// Pushing shards and the catalog to the server.
sync_timer: RefCell<Option<slint::Timer>>,
/// Kept so a rescan can run without going back through the launch screen.
session: RefCell<Option<(AppCredentials, Session, 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.
scope: RefCell<Option<dr_types::CollectionId>>,
/// 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.
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.
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.
timeline_zoom: RefCell<i32>,
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.
pinch_accum: RefCell<f32>,
/// The instant the grid is showing. `None` until the user has moved the
/// timeline, which is what leaves the marker resting at the middle.
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.
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.
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.
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.
sidecar_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.
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.
reachability: RefCell<dr_sync::Reachability>,
/// 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`].
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.
pending_anchor: std::cell::Cell<Option<usize>>,
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.
offline_target: std::cell::Cell<Option<dr_types::CollectionId>>,
/// TRACES: FR-NC-6a | FR-UI-2
/// The timer that turns a held sidebar row into that question. Dropped on
/// release, so a tap — or a press the Flickable takes for a scroll — is not
/// a dialogue a moment later.
row_hold_timer: RefCell<Option<slint::Timer>>,
/// 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.
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.
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.
keep_opened: std::cell::Cell<bool>,
/// 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.
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()),
thumb_class: RefCell::new(Vec::new()),
offset: RefCell::new(0),
resume_at: std::cell::Cell::new(0),
window: RefCell::new(INITIAL_WINDOW),
requested: RefCell::new(Default::default()),
scan_timer: RefCell::new(None),
thumb_timer: RefCell::new(None),
sweep_timer: RefCell::new(None),
sync_timer: RefCell::new(None),
session: RefCell::new(None),
scope: RefCell::new(None),
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),
generation: std::cell::Cell::new(0),
reachability: RefCell::new(dr_sync::Reachability::new()),
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),
row_hold_timer: RefCell::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,
),
})
}
/// 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
/// 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
/// Whether the app currently believes the server is unreachable.
pub fn is_offline(&self) -> bool {
self.reachability.borrow().is_offline()
}
/// 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-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 (_, session, _) = borrow.as_ref()?;
library::catalog_path(&session.server, &session.user_id)
.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 (_, session, _) = borrow.as_ref()?;
library::catalog_path(&session.server, &session.user_id)
.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 (_, session, _) = borrow.as_ref()?;
let catalog_path = library::catalog_path(&session.server, &session.user_id);
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()
}
/// Credentials and session for the open library.
///
/// 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<(AppCredentials, Session)> {
self.session
.borrow()
.as_ref()
.map(|(c, s, _)| (c.clone(), s.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-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()
}
/// Credentials and account for the open library, if one is open.
///
/// What a full-file fetch needs: the grid's paths are remote, so opening
/// an image means downloading it, and that needs the same session the
/// thumbnail workers use.
pub fn credentials(&self) -> Option<(AppCredentials, String)> {
self.session
.borrow()
.as_ref()
.map(|(creds, session, _)| (creds.clone(), session.user_id.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();
}
}
/// Reload the grid for the current scope and offset.
///
/// The reload entry point for everything outside this module — a scope change,
/// or a drop that altered the collection being shown.
pub fn reload(window: &AppWindow, ctl: &Rc<LibraryController>) {
load_window(window, ctl);
}
/// Open a library: show the grid, start a scan, then fill in thumbnails.
///
/// Called from the launch screen's "Open library" button — the callback that
/// until now only logged its intent.
pub fn open(
window: &AppWindow,
ctl: Rc<LibraryController>,
coll_ctl: Rc<crate::collections_ui::CollectionsController>,
store: &SessionStore,
session: Session,
) {
let creds = match store.credentials(&session) {
Ok(c) => c,
Err(e) => {
window.set_library_error(format!("credentials: {e}").into());
window.set_show_library(true);
return;
}
};
let filter = session.format_filter();
*ctl.session.borrow_mut() = Some((creds.clone(), session.clone(), filter.clone()));
window.set_show_library(true);
window.set_library_scanning(true);
window.set_library_error(slint::SharedString::new());
window.set_library_status("Starting…".into());
// Always visible: two folders one letter apart are easy to confuse, and a
// scan of the wrong one is indistinguishable from a broken scan.
window.set_library_root_label(
if session.root.is_empty() {
format!("{} · whole account", session.user_id)
} else {
format!("{}/{}", session.user_id, session.root)
}
.into(),
);
// An empty filter would walk the whole tree and match nothing, which looks
// exactly like a broken scan. Say so instead.
if filter.is_empty() {
window.set_library_scanning(false);
window.set_library_error("No formats selected — tick at least one.".into());
return;
}
let path = library::catalog_path(&session.server, &session.user_id);
log::info!(
"scanning {} for {} format(s) → {}",
if session.root.is_empty() {
"<account root>"
} else {
&session.root
},
filter.iter().count(),
path.display()
);
// Before the scan, not after it: the grid can be filled from disk now and
// the scan is only ever going to add to it.
show_catalog_now(window, &ctl, &path, &coll_ctl);
let rx = library::spawn_scan(
creds,
session.user_id.clone(),
session.root.clone(),
filter,
path.clone(),
);
drain_scan(window.as_weak(), ctl, coll_ctl, rx, path);
}
/// Drain scan progress on the UI thread.
fn drain_scan(
weak: slint::Weak<AppWindow>,
ctl: Rc<LibraryController>,
coll_ctl: Rc<crate::collections_ui::CollectionsController>,
rx: Receiver<ScanMessage>,
catalog_path: std::path::PathBuf,
) {
let timer = slint::Timer::default();
let ctl_cb = ctl.clone();
// Indeterminate for as long as it runs: a recursive walk discovers its own
// extent, so the count it reports is what it has *found*, never a fraction
// of what there is (FR-CAT-1).
//
// Named after the folder, because two accounts or two roots produce rows
// that are otherwise identical.
let title = match ctl.session.borrow().as_ref() {
Some((_, session, _)) if !session.root.is_empty() => {
format!("Scanning {}", session.root)
}
_ => "Scanning the library".to_string(),
};
let job = ctl.activity.begin(crate::activity::Kind::Scan, title);
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(120),
move || {
let Some(w) = weak.upgrade() else { return };
let ctl = &ctl_cb;
loop {
let msg = match rx.try_recv() {
Ok(m) => m,
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
// A worker that died without sending must not leave
// the screen on "Scanning…" forever.
if w.get_library_scanning() {
w.set_library_scanning(false);
w.set_library_error("scan ended unexpectedly".into());
job.fail("ended unexpectedly");
}
stop(&ctl.scan_timer);
return;
}
};
match msg {
ScanMessage::Progress {
directories,
pruned,
images,
} => {
// Pruned folders are reported separately rather than
// folded into the total: they are the ETag walk paying
// off, and hiding them makes an incremental rescan look
// identical to a full one.
let status = if pruned > 0 {
format!("{directories} folders · {pruned} unchanged · {images} images")
} else {
format!("{directories} folders · {images} images")
};
job.detail(status.clone());
w.set_library_status(status.into());
}
ScanMessage::Done {
found,
total,
pruned,
elapsed_ms,
} => {
log::info!(
"scan complete: {total} images ({found} listed, \
{pruned} folders unchanged) in {elapsed_ms} ms"
);
w.set_library_scanning(false);
// A completed scan is the strongest possible evidence
// the server is reachable, so it clears offline mode
// without needing a probe of its own.
if ctl
.reachability
.borrow_mut()
.mark_reachable(std::time::Instant::now())
{
log::info!("back online");
}
refresh_offline(&w, ctl);
// An incremental rescan lists almost nothing, so
// reporting the listed count would read as "0 images"
// on a library that is simply up to date.
let secs = elapsed_ms as f64 / 1000.0;
let status = if pruned > 0 && found == 0 {
format!("up to date · {total} images · {secs:.1}s")
} else if pruned > 0 {
format!("{found} new or changed · {total} images · {secs:.1}s")
} else {
format!("{total} images · {secs:.1}s")
};
job.finish(status.clone());
w.set_library_status(status.into());
// Usually already open — `show_catalog_now` opened it
// before this scan started, so the grid has been
// showing the previous run's catalog all along and the
// rows this scan found are new rows in that same file.
// Only a first run, where there was nothing to open,
// reaches the second arm.
let opened = if ctl.catalog.borrow().is_some() {
Ok(())
} else {
Catalog::open(&catalog_path).map(|cat| {
*ctl.catalog.borrow_mut() = Some(cat);
})
};
match opened {
Ok(()) => {
// The sidebar is built before the grid: the
// grid's badges read collection membership, and
// the tree is where the catalog handle first
// becomes available to it.
{
let borrow = ctl.catalog.borrow();
if let Some(cat) = borrow.as_ref() {
crate::collections_ui::refresh_tree(&w, &coll_ctl, cat);
}
}
load_window(&w, ctl);
// Everything the grid did not touch: the rest
// of the library gets a thumbnail and a date,
// so the timeline describes all of it rather
// than the part that was scrolled past.
start_sweep(&w, ctl);
}
Err(e) => w.set_library_error(format!("opening catalog: {e}").into()),
}
stop(&ctl.scan_timer);
return;
}
ScanMessage::Failed { message, offline } => {
log::warn!("scan failed: {message}");
w.set_library_scanning(false);
// Recorded as a failure even where it is only the
// connection: the grid's offline banner says the server
// is unreachable, and this says which piece of work
// stopped because of it.
job.fail(message.clone());
if offline {
// Not an error state. The catalog from the last
// successful scan is still on disk and still
// accurate for everything already indexed, so the
// grid keeps working — it simply cannot learn about
// anything added on the server since.
ctl.reachability
.borrow_mut()
.mark_unreachable(message, std::time::Instant::now());
refresh_offline(&w, ctl);
// Show whatever the catalog holds. Without this the
// grid stays empty on a launch that began offline,
// which is precisely the case offline mode exists
// for.
open_catalog_for_offline(&w, ctl, &catalog_path, &coll_ctl);
} else {
w.set_library_error(message.into());
}
stop(&ctl.scan_timer);
return;
}
}
}
},
);
*ctl.scan_timer.borrow_mut() = Some(timer);
}
/// Start a scan against the configured library.
///
/// Shared by the rescan button and by the offline banner's retry, which are
/// the same operation: a scan is the only request that both proves the server
/// is reachable and brings the catalog up to date. Keeping them one function
/// is what stops "retry" from quietly becoming a weaker probe than "rescan".
fn start_rescan(
window: &AppWindow,
ctl: &Rc<LibraryController>,
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
) {
let Some((creds, session, filter)) = ctl.session.borrow().clone() else {
return;
};
window.set_library_scanning(true);
window.set_library_error(slint::SharedString::new());
window.set_library_status("Rescanning…".into());
let path = library::catalog_path(&session.server, &session.user_id);
let rx = library::spawn_scan(
creds,
session.user_id.clone(),
session.root.clone(),
filter,
path.clone(),
);
drain_scan(window.as_weak(), ctl.clone(), coll_ctl.clone(), rx, path);
}
/// TRACES: FR-NC-6a | FR-NC-6c
/// What a collection would cost to take with you, and what it is holding now.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct OfflineSummary {
/// Photographs in the collection and its children, deduplicated.
total: usize,
/// Of those, how many have their original on this device.
held: usize,
/// Disk those originals occupy — what releasing would give back.
held_bytes: u64,
/// What the rest would cost to fetch, from the sizes the scan recorded.
/// Zero where nothing has been stat-ed yet, which reads as "unknown"
/// rather than "free" in the label built from it.
missing_bytes: u64,
}
impl OfflineSummary {
fn missing(self) -> usize {
self.total.saturating_sub(self.held)
}
}
/// Read the offline summary for a set of images.
///
/// One query with the ids inlined as placeholders — the same shape
/// [`collection_images`] uses, and for the same reason: a collection is tens to
/// thousands of rows, and a round trip per photograph to answer one dialogue is
/// not a trade worth making.
fn offline_summary(catalog: &Catalog, images: &[dr_types::ImageId]) -> OfflineSummary {
if images.is_empty() {
return OfflineSummary::default();
}
let placeholders = std::iter::repeat_n("?", images.len())
.collect::<Vec<_>>()
.join(",");
// `tier_actual`, never `tier_desired`: the question is what can be opened
// on the aeroplane, and a pin whose download has not run yet answers no.
let sql = format!(
"SELECT count(*),
coalesce(sum(CASE WHEN c.tier_actual >= ?1 THEN 1 ELSE 0 END), 0),
coalesce(sum(CASE WHEN c.tier_actual >= ?1 THEN c.bytes ELSE 0 END), 0),
coalesce(sum(CASE WHEN c.tier_actual >= ?1 THEN 0
ELSE coalesce(i.file_size, 0) END), 0)
FROM images i
LEFT JOIN image_cache c ON c.image_id = i.id
WHERE i.id IN ({placeholders})"
);
let mut params: Vec<rusqlite::types::Value> = vec![rusqlite::types::Value::Integer(
dr_types::Tier::Original.stored(),
)];
params.extend(
images
.iter()
.map(|i| rusqlite::types::Value::Integer(i.0 as i64)),
);
catalog
.connection()
.query_row(&sql, rusqlite::params_from_iter(params.iter()), |r| {
Ok(OfflineSummary {
total: r.get::<_, i64>(0)? as usize,
held: r.get::<_, i64>(1)? as usize,
held_bytes: r.get::<_, i64>(2)? as u64,
missing_bytes: r.get::<_, i64>(3)? as u64,
})
})
.unwrap_or_else(|e| {
log::debug!("reading offline summary: {e}");
OfflineSummary::default()
})
}
/// TRACES: FR-NC-6a | FR-NC-6c
/// Ask what should happen to a collection's local copies.
///
/// Both answers are expensive — one commits the device to a download of
/// gigabytes, the other deletes gigabytes it already holds — so this is a
/// question rather than a toggle, and the counts and sizes go in the buttons
/// where they are read *before* the tap rather than in a second dialogue after
/// it (FR-NC-6c: an operation requiring absent data says so, with the size,
/// before starting).
fn open_offline_prompt(
window: &AppWindow,
ctl: &Rc<LibraryController>,
id: dr_types::CollectionId,
) {
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
// Descendants, matching what the grid shows when scoped to this row:
// keeping a parent whose children hold the photographs must keep the
// photographs, or the answer would appear to do nothing.
let ids = match dr_catalog::collections::descendants(catalog.connection(), id) {
Ok(ids) => ids,
Err(e) => {
window.set_library_error(format!("resolving collection: {e}").into());
return;
}
};
let images = collection_images(catalog, &ids);
let summary = offline_summary(catalog, &images);
let name = dr_catalog::collections::tree(catalog.connection())
.ok()
.and_then(|rows| {
rows.into_iter()
.find(|r| r.collection.id == id)
.map(|r| r.collection.name)
})
.unwrap_or_else(|| "This collection".to_string());
ctl.offline_target.set(Some(id));
window.set_offline_prompt_title(name.as_str().into());
window.set_offline_prompt_detail(
if summary.total == 0 {
"Nothing in here yet. Put some photographs in it first.".to_string()
} else if summary.held == summary.total {
format!(
"All {} on this device · {}",
summary.total,
crate::activity::describe_bytes(summary.held_bytes)
)
} else {
format!(
"{} photographs · {} already on this device",
summary.total, summary.held
)
}
.as_str()
.into(),
);
window.set_offline_prompt_keep_label(
// The size is named where the scan has recorded one. Where it has not,
// the label says what it will do and not what it will cost, which is
// honest — a "0 B" download would be a lie about a gigabyte.
if summary.missing_bytes > 0 {
format!(
"Download {} · {}",
summary.missing(),
crate::activity::describe_bytes(summary.missing_bytes)
)
} else if summary.missing() > 0 {
format!("Download {}", summary.missing())
} else {
"Everything is already here".to_string()
}
.as_str()
.into(),
);
window.set_offline_prompt_release_label(
format!(
"Remove {} local copies · frees {}",
summary.held,
crate::activity::describe_bytes(summary.held_bytes)
)
.as_str()
.into(),
);
window.set_offline_prompt_can_keep(summary.missing() > 0);
window.set_offline_prompt_can_release(summary.held > 0);
window.set_offline_prompt_busy(window.get_library_pin_total() > 0);
}
/// Close the offline question without answering it.
fn close_offline_prompt(window: &AppWindow, ctl: &Rc<LibraryController>) {
ctl.offline_target.set(None);
// The title is what the prompt's visibility is bound to: one fact, so a
// dialogue cannot be up with nothing written on it.
window.set_offline_prompt_title(slint::SharedString::new());
}
/// TRACES: FR-NC-6a
/// Keep a collection on this device: record the pin, then start the transfer.
///
/// Two separate things, and keeping them separate is what makes the answer feel
/// immediate — recording the intent is a local catalog write that completes at
/// once, and downloading the bytes may take a very long time. The pin also
/// survives the app being closed halfway through, which is what makes the
/// transfer resumable rather than something to start again.
fn keep_collection_offline(
window: &AppWindow,
ctl: &Rc<LibraryController>,
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
) {
let Some(id) = ctl.offline_target.get() else {
return;
};
let Some(cache) = ctl.cache() else {
window.set_library_error("No cache directory for this library.".into());
return;
};
let images = {
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
let ids = match dr_catalog::collections::descendants(catalog.connection(), id) {
Ok(ids) => ids,
Err(e) => {
window.set_library_error(format!("resolving collection: {e}").into());
return;
}
};
let images = collection_images(catalog, &ids);
if images.is_empty() {
window.set_library_error("Nothing in that collection to keep offline.".into());
return;
}
if let Err(e) = cache.pin(catalog.connection(), &images) {
window.set_library_error(format!("pinning: {e}").into());
return;
}
images
};
log::info!("pinned {} image(s) for offline use", images.len());
window.set_library_error(slint::SharedString::new());
if ctl.scope.borrow().as_ref() == Some(&id) {
window.set_library_scope_pinned(true);
}
close_offline_prompt(window, ctl);
start_pin_fetch(window, ctl);
refresh_collection_tree(window, ctl, coll_ctl);
}
/// TRACES: FR-NC-6a | FR-NC-6b
/// Give the disk back: release the pin *and* delete the originals it held.
///
/// Deliberately destructive, where unpinning alone is not. "Remove the local
/// copies" is asked by someone whose device is full, and answering it by
/// withdrawing a promise and leaving the gigabytes for a future eviction to
/// notice is not an answer. Nothing is lost that cannot be fetched again: the
/// originals are on the server, and the ratings, the edit graph and the
/// thumbnails are all untouched — they are authoritative and small.
fn release_collection_offline(
window: &AppWindow,
ctl: &Rc<LibraryController>,
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
) {
let Some(id) = ctl.offline_target.get() else {
return;
};
let Some(cache) = ctl.cache() else {
window.set_library_error("No cache directory for this library.".into());
return;
};
let released = {
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
let ids = match dr_catalog::collections::descendants(catalog.connection(), id) {
Ok(ids) => ids,
Err(e) => {
window.set_library_error(format!("resolving collection: {e}").into());
return;
}
};
let images = collection_images(catalog, &ids);
match cache.release(catalog.connection(), &images) {
Ok(r) => r,
Err(e) => {
window.set_library_error(format!("removing local copies: {e}").into());
return;
}
}
};
let (count, freed) = released;
log::info!(
"released {count} image(s), freeing {}",
crate::activity::describe_bytes(freed)
);
window.set_library_error(slint::SharedString::new());
window.set_library_status(
format!(
"Removed local copies · {} freed",
crate::activity::describe_bytes(freed)
)
.as_str()
.into(),
);
if ctl.scope.borrow().as_ref() == Some(&id) {
window.set_library_scope_pinned(false);
}
// A download that was still running for this collection has just had its
// reason withdrawn; the worker checks `pending_pins` per file, so it stops
// finding work rather than being killed.
window.set_library_pin_total(0);
window.set_library_pin_done(0);
close_offline_prompt(window, ctl);
refresh_local_count(window, ctl);
refresh_collection_tree(window, ctl, coll_ctl);
}
/// Redraw the sidebar, so the trays reflect what was just kept or released.
fn refresh_collection_tree(
window: &AppWindow,
ctl: &Rc<LibraryController>,
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
) {
let borrow = ctl.catalog.borrow();
if let Some(catalog) = borrow.as_ref() {
crate::collections_ui::refresh_tree(window, coll_ctl, catalog);
}
}
/// Every image in the given collections, deduplicated.
///
/// An image in both a parent and a child is one photograph and must be pinned
/// once, exactly as the grid draws it once.
fn collection_images(catalog: &Catalog, ids: &[dr_types::CollectionId]) -> Vec<dr_types::ImageId> {
if ids.is_empty() {
return Vec::new();
}
let placeholders = std::iter::repeat_n("?", ids.len())
.collect::<Vec<_>>()
.join(",");
let sql = format!(
"SELECT DISTINCT image_id FROM collection_members
WHERE collection_id IN ({placeholders})"
);
let params: Vec<rusqlite::types::Value> = ids
.iter()
.map(|c| rusqlite::types::Value::Integer(c.0 as i64))
.collect();
let Ok(mut stmt) = catalog.connection().prepare(&sql) else {
return Vec::new();
};
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok(dr_types::ImageId(r.get::<_, i64>(0)? as u64))
});
match rows {
Ok(rows) => rows.flatten().collect(),
Err(e) => {
log::debug!("listing collection images: {e}");
Vec::new()
}
}
}
/// TRACES: FR-NC-6a
/// Download whatever the pins still want, reporting progress.
fn start_pin_fetch(window: &AppWindow, ctl: &Rc<LibraryController>) {
let Some((creds, session, _)) = ctl.session.borrow().clone() else {
return;
};
let Some(cache_dir) = ctl.cache_dir() else {
return;
};
// Offline, there is nothing to download from. The pin is already recorded,
// so it resumes on reconnect rather than being lost.
if ctl.is_offline() {
log::info!("offline: the pin is recorded and will download on reconnect");
return;
}
let rx = library::spawn_pin_fetch(
creds,
session.user_id.clone(),
library::catalog_path(&session.server, &session.user_id),
cache_dir,
// Pinned originals are exempt from the budget, but a pin fetch also
// stores passively when it finds an image already cached, so the worker
// still needs the user's ceiling rather than the catalog's floor.
ctl.cache_budget.get(),
);
let timer = slint::Timer::default();
let weak = window.as_weak();
let ctl_cb = ctl.clone();
// The longest-running transfer the app does, and the one most likely to be
// watched from another view — which is the whole reason the register
// exists (FR-NC-6, FR-NC-6c).
let job = ctl.activity.begin(
crate::activity::Kind::Download,
"Keeping photographs on this device",
);
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(300),
move || {
let Some(w) = weak.upgrade() else { return };
loop {
let msg = match rx.try_recv() {
Ok(m) => m,
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
w.set_library_pin_total(0);
job.fail("stopped without finishing");
stop(&ctl_cb.pin_timer);
return;
}
};
match msg {
library::PinMessage::Planned { total } => {
w.set_library_pin_total(total as i32);
w.set_library_pin_done(0);
job.total(total);
}
library::PinMessage::Stored { done } => {
w.set_library_pin_done(done as i32);
job.progress(done, w.get_library_pin_total() as usize);
// The "On this device" count grows as they land, so
// the chip agrees with the progress line beside it.
refresh_local_count(&w, &ctl_cb);
}
library::PinMessage::Done { stored, bytes } => {
log::info!(
"pin complete: {stored} original(s), {:.1} MB",
bytes as f64 / 1_048_576.0
);
job.finish(format!(
"{stored} photograph(s) · {}",
crate::activity::describe_bytes(bytes)
));
w.set_library_pin_total(0);
w.set_library_pin_done(0);
refresh_local_count(&w, &ctl_cb);
stop(&ctl_cb.pin_timer);
return;
}
library::PinMessage::Failed { message, offline } => {
log::warn!("pin fetch stopped: {message}");
job.fail(message.clone());
w.set_library_pin_total(0);
if offline {
ctl_cb
.reachability
.borrow_mut()
.mark_unreachable(message, std::time::Instant::now());
refresh_offline(&w, &ctl_cb);
} else {
w.set_library_error(format!("keeping offline: {message}").into());
}
refresh_local_count(&w, &ctl_cb);
stop(&ctl_cb.pin_timer);
return;
}
}
}
},
);
*ctl.pin_timer.borrow_mut() = Some(timer);
}
/// Refresh the "On this device" count from the catalog.
fn refresh_local_count(window: &AppWindow, ctl: &Rc<LibraryController>) {
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
window.set_library_local_count(library::local_original_count(catalog).unwrap_or(0) as i32);
}
/// TRACES: FR-NC-6a
/// Whether every image in the scoped collection is pinned.
///
/// Read from the catalog rather than remembered, because a pin outlives the
/// session that made it: reopening the library must show the button already
/// active, or the user would pin the same collection twice.
fn scope_is_pinned(catalog: &Catalog, images: &[dr_types::ImageId]) -> bool {
if images.is_empty() {
return false;
}
let placeholders = std::iter::repeat_n("?", images.len())
.collect::<Vec<_>>()
.join(",");
let params: Vec<rusqlite::types::Value> = images
.iter()
.map(|i| rusqlite::types::Value::Integer(i.0 as i64))
.collect();
let pinned: i64 = catalog
.connection()
.query_row(
&format!(
"SELECT count(*) FROM image_cache
WHERE pinned = 1 AND image_id IN ({placeholders})"
),
rusqlite::params_from_iter(params.iter()),
|r| r.get(0),
)
.unwrap_or(0);
pinned as usize == images.len()
}
/// TRACES: FR-CAT-9
/// Paint the connectivity state into the window.
///
/// Called wherever reachability may have moved, rather than by the state
/// itself: `Reachability` is in `dr-sync` and knows nothing about a window,
/// which is what keeps it testable without a display server.
fn refresh_offline(window: &AppWindow, ctl: &Rc<LibraryController>) {
let reach = ctl.reachability.borrow();
let offline = reach.is_offline();
window.set_library_offline(offline);
window.set_library_offline_reason(reach.reason().unwrap_or_default().into());
window.set_library_offline_since(
reach
.offline_for(std::time::Instant::now())
.map(describe_duration)
.unwrap_or_default()
.into(),
);
// A stale scan error under an offline banner reports one problem twice.
if offline {
window.set_library_error("".into());
}
drop(reach);
// TRACES: FR-CAT-9
// Back online: send whatever the outbox is still holding.
//
// Hung off the one function that paints connectivity rather than off each
// of the seven places that move it — a drain that had to be remembered at
// every call site is a drain that will be forgotten at one of them, and
// the symptom is an edit that stays queued until the app is restarted.
//
// `start_outbox_drain` is a no-op when the outbox is empty and while one
// is already running, so calling it on every repaint costs a directory
// walk that finds nothing.
if !offline {
start_outbox_drain(window, ctl);
}
}
/// TRACES: FR-CAT-9 | FR-NC-10
/// Upload the sidecars queued while this device had no connection.
///
/// Guarded on the timer rather than on a flag: the timer *is* the "a drain is
/// running" state, and a second one started beside it would drain the same
/// channel twice.
fn start_outbox_drain(window: &AppWindow, ctl: &Rc<LibraryController>) {
if ctl.outbox_timer.borrow().is_some() {
return;
}
if !ctl.outbox_maybe_dirty.get() {
return;
}
let Some(cache_dir) = ctl.sidecar_cache_dir() else {
return;
};
if crate::sidecar_cache::SidecarCache::open(cache_dir.clone())
.pending()
.is_empty()
{
// Nothing there. Recorded so the next hundred repaints skip the walk;
// a write that queues sets it again.
ctl.outbox_maybe_dirty.set(false);
return;
}
let Some((creds, session, _)) = ctl.session.borrow().clone() else {
return;
};
let rx = library::spawn_outbox_drain(creds, session.user_id.clone(), cache_dir);
let job = ctl
.activity
.begin(crate::activity::Kind::Upload, "Uploading queued edits");
let timer = slint::Timer::default();
let weak = window.as_weak();
let ctl_cb = ctl.clone();
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(250),
move || {
let Some(w) = weak.upgrade() else { return };
match rx.try_recv() {
Ok(library::SidecarMessage::Finished {
written,
queued,
failed,
last_error,
}) => {
if failed > 0 {
log::warn!(
"{failed} queued sidecar(s) still undelivered: {}",
last_error.clone().unwrap_or_default()
);
// Not `fail`: the edits are still safely queued, and
// reporting this as a loss would be wrong.
job.finish(format!("{written} uploaded · {queued} still queued"));
} else if written > 0 {
log::info!("{written} queued sidecar(s) uploaded");
job.finish(format!("{written} queued edit(s) uploaded"));
w.set_library_status(format!("{written} queued edit(s) uploaded").into());
} else {
job.finish_quietly();
}
ctl_cb.outbox_maybe_dirty.set(queued > 0);
stop(&ctl_cb.outbox_timer);
}
Err(std::sync::mpsc::TryRecvError::Empty) => {}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
job.finish_quietly();
stop(&ctl_cb.outbox_timer);
}
}
},
);
*ctl.outbox_timer.borrow_mut() = Some(timer);
}
/// A coarse "how long ago", for the offline banner.
///
/// Deliberately imprecise: the user wants to know whether this just happened
/// or has been true for a while, and a live-counting seconds display would
/// draw the eye to a number that changes without meaning anything.
fn describe_duration(d: std::time::Duration) -> String {
let secs = d.as_secs();
if secs < 60 {
"just now".to_string()
} else if secs < 3600 {
format!("{}m ago", secs / 60)
} else {
format!("{}h ago", secs / 3600)
}
}
/// TRACES: FR-CAT-9
/// Show the catalog after a scan that could not reach the server.
///
/// The whole point of offline mode: a scan is how the grid normally gets its
/// catalog handle, so a failed one previously left the library empty even
/// though a complete catalog was sitting on disk from the last successful run.
/// The images are all still there, their thumbnails are in the shards, and
/// rating and collecting them are local writes.
///
/// The sweep is deliberately **not** started — it exists to fetch headers over
/// the network, so offline it would do nothing but fail once per image.
fn open_catalog_for_offline(
window: &AppWindow,
ctl: &Rc<LibraryController>,
catalog_path: &std::path::Path,
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
) {
if ctl.catalog.borrow().is_some() {
// Already open — a rescan that failed, rather than a launch that
// began offline. The grid is showing the catalog already.
load_window(window, ctl);
return;
}
match Catalog::open(catalog_path) {
Ok(cat) => {
crate::collections_ui::refresh_tree(window, coll_ctl, &cat);
*ctl.catalog.borrow_mut() = Some(cat);
load_window(window, ctl);
}
Err(e) => {
// No catalog and no server. This is the one genuinely empty case:
// a first run that never reached the server has nothing indexed.
log::warn!("offline with no local catalog: {e}");
window.set_library_error(
"Offline, and this library has not been scanned on this device yet.".into(),
);
}
}
}
/// How long a run of geometry changes has to stop for before the window is
/// reloaded against it.
///
/// Long enough to swallow a whole gesture's worth of steps, short enough that a
/// single deliberate step still feels immediate.
const GEOMETRY_SETTLE: std::time::Duration = std::time::Duration::from_millis(140);
/// Reload the window once the grid's geometry has stopped changing.
///
/// # Why this is deferred when a scroll is not
///
/// A pinch is not one zoom step, it is a stream of them, and every step
/// changes both the column count and the capacity — two reports. Each report
/// used to re-query the catalog, rebuild all 360 rows of the model, re-read the
/// badges and ratings for every one of them and spawn a thumbnail batch,
/// synchronously, on the thread that is trying to draw the frame. Twice per
/// step. That is why zooming juddered while scrolling the same grid is smooth:
/// a scroll reloads a few times per screenful, a zoom reloaded twice a frame.
///
/// None of it is urgent, because none of it is about *which* photographs are on
/// screen. A column change moves the cells and a zoom resizes them, but the
/// window holds the same images either way — the model already has them, and
/// the cells re-flow from `columns` and `cell-size` without Rust being involved
/// at all. What the reload actually recomputes is which cells begin a row, so
/// the month headings land in the right places, and which thumbnail size class
/// to ask for now. Both can wait for the gesture to finish.
///
/// # The anchor
///
/// Captured on the *first* report of a run rather than read when the timer
/// fires. As the grid re-flows, the viewport keeps its pixel offset while the
/// rows move underneath it, so the view drifts and reports its drift — reading
/// the anchor at the end would faithfully return to wherever it had wandered
/// to. Taking it at the start returns to the photograph the user was actually
/// looking at when they began the gesture.
fn schedule_reload(window: &AppWindow, ctl: &Rc<LibraryController>) {
// Only the first report of a run sets it; the rest of the run reuses it.
if ctl.pending_anchor.get().is_none() {
ctl.pending_anchor.set(Some(ctl.resume_at.get()));
}
let timer = slint::Timer::default();
let weak = window.as_weak();
let ctl_cb = ctl.clone();
timer.start(slint::TimerMode::SingleShot, GEOMETRY_SETTLE, move || {
let Some(w) = weak.upgrade() else { return };
let anchor = ctl_cb.pending_anchor.take().unwrap_or(0);
if !w.get_show_library() {
return;
}
// The window re-centred on the anchor, and the viewport sent back to
// it: cells are drawn at their absolute place in the library, so a
// change to `columns` moves every one of them and a viewport left
// where it was would be pointing at rows the window no longer covers.
let window_size = *ctl_cb.window.borrow();
*ctl_cb.offset.borrow_mut() = anchor.saturating_sub(window_size / 4);
load_window(&w, &ctl_cb);
w.set_library_scroll_to(anchor as i32);
w.set_library_scroll_token(w.get_library_scroll_token() + 1);
});
// Replacing the slot drops the previous timer, which is what makes this
// coalesce: only the last report of a run lives long enough to fire.
*ctl.geometry_timer.borrow_mut() = Some(timer);
}
/// Show what the catalog already holds, without waiting for the scan.
///
/// # Why a launch should not be a scan
///
/// A launch does not have to discover the library. The catalog from the last
/// run is on disk, complete, with its thumbnails in the shards beside it —
/// which is precisely the state [`open_catalog_for_offline`] already relies on
/// when the server cannot be reached. Every launch that *could* reach the
/// server threw that away and sat on "Scanning…" over an empty grid for as long
/// as a recursive WebDAV walk of the whole tree takes. On a real library, and
/// especially on a phone's connection, that walk is the entire startup time,
/// spent hiding a grid that was ready before it began.
///
/// The scan still runs and still replaces this the moment it lands. What it no
/// longer does is gate the first paint on the network.
///
/// Silent when there is no catalog yet: that is a genuine first run, the empty
/// state already says "Scanning…", and an error here would contradict a scan
/// that is working perfectly. `Catalog::open` creates the file in that case, so
/// what the grid reads is an empty catalog rather than a failure.
fn show_catalog_now(
window: &AppWindow,
ctl: &Rc<LibraryController>,
catalog_path: &std::path::Path,
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
) {
if ctl.catalog.borrow().is_some() {
return;
}
let cat = match Catalog::open(catalog_path) {
Ok(cat) => cat,
Err(e) => {
// Not surfaced: the scan is the thing that has to work, and it is
// still running. If it fails too, it reports for both of them.
log::info!("no catalog to show before the scan: {e}");
return;
}
};
// The sidebar before the grid, because the grid's badges read collection
// membership — the same order the scan's completion uses.
crate::collections_ui::refresh_tree(window, coll_ctl, &cat);
*ctl.catalog.borrow_mut() = Some(cat);
load_window(window, ctl);
}
/// What one cell of the outgoing model is worth keeping.
#[derive(Clone)]
struct Held {
thumbnail: slint::Image,
has_thumb: bool,
/// A completed fetch that found no preview. Worth carrying for the same
/// reason the pixels are: it is an answer about the file, and re-asking it
/// on every scroll is a fetch that will fail again.
unavailable: bool,
/// Which size class the pixels came from, so the reload can tell a cell
/// that is showing what it should from one that is showing the small class
/// while the grid has since been zoomed past it.
class: Option<dr_thumbs::ThumbSize>,
}
/// **What the outgoing model is still holding, keyed on the photograph.**
///
/// A reload replaces every row, and a scroll reloads once the view has
/// travelled a quarter of the loaded window — so three quarters of the cells
/// being rebuilt are the *same* photographs the user is looking at right now.
/// Rebuilding them empty blanked the whole grid to `Theme.ground` and refilled
/// it a beat later, once a worker had re-read and re-decoded every one of them
/// from the thumbnail store. That is the black flash that punctuated every
/// screenful of scrolling, and the one visible on a column change, a zoom step,
/// a filter and a return from develop.
///
/// Keyed on `image_id` rather than on the row, because the row is exactly what
/// a reload changes. Cheap: the values are `slint::Image` handles, so this is a
/// refcount per cell and no pixels move.
fn hold_thumbnails(
previous: &slint::ModelRc<LibraryCell>,
ids: &[i64],
classes: &[Option<dr_thumbs::ThumbSize>],
) -> std::collections::HashMap<i64, Held> {
ids.iter()
.enumerate()
.filter_map(|(row, id)| {
let cell = previous.row_data(row)?;
// Nothing to carry: a row still waiting is equally blank either
// way, and holding a default image would claim otherwise.
if !cell.has_thumb && !cell.unavailable {
return None;
}
Some((
*id,
Held {
thumbnail: cell.thumbnail,
has_thumb: cell.has_thumb,
unavailable: cell.unavailable,
class: classes.get(row).copied().flatten(),
},
))
})
.collect()
}
/// The fetches a reloaded window does **not** have to make.
///
/// The set used to be cleared on every load, which said "every cell here is
/// still to be fetched" — true when every row came back empty, and false now
/// that the overlap keeps its pixels. Re-requesting them re-read and re-decoded
/// three quarters of the window from the store on every scroll.
///
/// Rebuilt rather than merely kept, and that is the half that is easy to get
/// wrong: an image served into a window the user has since scrolled away from
/// is no longer on screen, and a set that remembered it would leave that cell
/// permanently blank when they scrolled back. Only what the new model actually
/// holds counts as served — and only at the class it holds it at, so zooming
/// past the grid class still asks for the large one.
fn already_served(
held: &std::collections::HashMap<i64, Held>,
ids: impl Iterator<Item = i64>,
) -> std::collections::HashSet<(i64, dr_thumbs::ThumbSize)> {
ids.filter_map(|id| Some((id, held.get(&id)?.class?)))
.collect()
}
/// Where the loaded window should move to for a view at `first_visible`, or
/// `None` to leave it where it is.
///
/// # Why this is a rule and not four lines in the scroll handler
///
/// It decides how often the grid re-reads the catalog while a finger is on it,
/// and both of its ways of being wrong are invisible in the code and obvious
/// on a tablet: too eager and every scroll stutters, too lazy and the view
/// runs off the end of the loaded rows into blank ones.
///
/// # The rule
///
/// The window is centred on the view — a quarter of it behind, so scrolling
/// back has loaded rows to move into — and clamped to the last position where
/// it is still full. It moves only once the view comes within a quarter-window
/// of an edge of what is loaded, so a drag reloads a few times per screenful
/// rather than on every row.
///
/// And it never moves to where it already is. That is the case the margin test
/// alone gets wrong: at either end of a scope the window is *pinned* — the
/// first screenful cannot be centred further back than zero, and the last
/// cannot start past `max_offset` — so the margin is unsatisfiable there and
/// every row crossed in the first or last quarter of a window re-read the
/// catalog, rebuilt the model and issued a thumbnail batch to arrive at the
/// offset it already had. On a library of twenty-odd thousand that is a
/// stutter at the top and the bottom of every collection, which is exactly
/// where a cull begins and ends.
fn window_move(
first_visible: usize,
current: usize,
window_size: usize,
total: usize,
) -> Option<usize> {
// The same clamp `load_window` applies, repeated here so this compares
// against where the window would come to rest rather than where it was
// asked to go.
let max_offset = total.saturating_sub(window_size.min(total));
let desired = first_visible
.saturating_sub(window_size / 4)
.min(max_offset);
if desired == current {
return None;
}
let margin = window_size / 4;
let inside =
first_visible >= current + margin && first_visible + margin < current + window_size;
if inside {
return None;
}
Some(desired)
}
/// Fill the model from the catalog and start fetching thumbnails.
///
/// Reads the window starting at the controller's current offset, which the
/// scrubber moves. Without a movable offset the grid could only ever show the
/// first 120 of 23,971 images.
fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
// Everything below is scoped to the selected collection, if any: the total,
// the window, and the fetches issued for it. Reading the whole library here
// and filtering later would fetch thumbnails for images the user is not
// looking at, which on a remote library is the cost FR-NC-3 exists to
// avoid.
let scope = *ctl.scope.borrow();
let filter = *ctl.filter.borrow();
// The trash lists what every other view excludes, so it takes its own
// query rather than another predicate threaded through the scoped one.
let trash = ctl.viewing_trash.get();
// TRACES: FR-NC-6a
// Whether the newly scoped collection is already pinned. Read here rather
// than remembered, because a pin outlives the session that made it — on
// reopening the library the button has to show what the catalog says, not
// what this run happens to have done.
window.set_library_scope_pinned(match scope {
Some(id) => dr_catalog::collections::descendants(catalog.connection(), id)
.map(|ids| collection_images(catalog, &ids))
.map(|images| scope_is_pinned(catalog, &images))
.unwrap_or(false),
None => false,
});
let total = if trash {
library::total_trashed(catalog).unwrap_or(0)
} else {
library::total_images_scoped(catalog, scope, &filter).unwrap_or(0)
};
window.set_library_total(total as i32);
// Clamp so a scrub to the very end still fills the window rather than
// showing a handful of cells.
let window_size = *ctl.window.borrow();
let offset = (*ctl.offset.borrow()).min(total.saturating_sub(window_size.min(total)));
*ctl.offset.borrow_mut() = offset;
window.set_library_offset(offset as i32);
// The date range this window covers, so the scrubber can label itself.
refresh_timeline(window, catalog, ctl);
let cells = if trash {
library::read_trashed_cells(catalog, offset, window_size)
} else {
library::read_cells_scoped(catalog, scope, &filter, offset, window_size)
};
let cells = match cells {
Ok(c) => c,
Err(e) => {
window.set_library_error(format!("reading catalog: {e}").into());
return;
}
};
// What the window currently spans, in the photographer's own terms.
let span = cells
.iter()
.filter_map(|c| c.captured_at)
.fold(None::<(i64, i64)>, |acc, t| {
Some(match acc {
None => (t, t),
Some((lo, hi)) => (lo.min(t), hi.max(t)),
})
});
window.set_library_window_label(
match span {
Some((lo, hi)) => format!("{} – {}", format_date(lo), format_date(hi)),
// Nothing here has a date yet: EXIF is read as thumbnails load, so
// this fills in rather than being an error.
None => "dates not yet read".to_string(),
}
.into(),
);
// Month headings. The grid is ordered by capture time, so without these a
// wall of thumbnails gives no sense of *when* you are looking — the
// sidebar says it, but only if you consult it.
//
// Marked on the cell that both begins a month and begins a row: a heading
// stranded mid-row would label the cells to its left, which belong to the
// previous month.
let columns = window.get_library_columns().max(1) as usize;
let mut previous_month: Option<(i64, i64)> = None;
let headings: Vec<String> = cells
.iter()
.enumerate()
.map(|(i, c)| {
let Some(t) = c.captured_at else {
return String::new();
};
let (y, m, _, _) = civil_from_unix(t);
let is_new = previous_month != Some((y, m));
previous_month = Some((y, m));
// A heading is drawn above its row, so it can only sit on a cell
// that begins one — a heading stranded mid-row would appear to
// label the cells to its left, which belong to the month before.
//
// The first cell of the window always carries one, whichever
// column it lands in: a scrolled window would otherwise show no
// date at all until the next month began.
let begins_row = (i + offset) % columns == 0;
if i == 0 || (is_new && begins_row) {
format!("{} {y}", month_name(m))
} else {
String::new()
}
})
.collect();
// What the outgoing model is still holding — see [`hold_thumbnails`].
let held = {
let previous = window.get_library_cells();
let ids = ctl.image_ids.borrow();
let classes = ctl.thumb_class.borrow();
hold_thumbnails(&previous, &ids, &classes)
};
let rows: Vec<LibraryCell> = cells
.iter()
.zip(headings)
.map(|(c, heading)| {
let carried = held.get(&c.image_id);
LibraryCell {
period_heading: heading.into(),
// A freshly loaded window has no drag in flight.
lifted: false,
name: c.name.as_str().into(),
thumbnail: carried.map(|h| h.thumbnail.clone()).unwrap_or_default(),
has_thumb: carried.is_some_and(|h| h.has_thumb),
unavailable: carried.is_some_and(|h| h.unavailable),
// Both are filled straight after by `collections_ui`, which owns
// the selection and queries the badge counts for the whole window
// in one statement rather than one per cell.
selected: false,
collection_count: 0,
// Likewise filled by `sync_ratings` below — one query for the
// window, not one per cell.
rating: 0,
flag: 0,
}
})
.collect();
*ctl.paths.borrow_mut() = cells.iter().map(|c| c.remote_path.clone()).collect();
*ctl.file_ids.borrow_mut() = cells.iter().map(|c| c.file_id).collect();
*ctl.sizes.borrow_mut() = cells.iter().map(|c| c.size).collect();
*ctl.image_ids.borrow_mut() = cells.iter().map(|c| c.image_id).collect();
*ctl.needs_metadata.borrow_mut() = cells.iter().map(|c| c.metadata_state < 2).collect();
*ctl.thumb_class.borrow_mut() = cells
.iter()
.map(|c| held.get(&c.image_id).and_then(|h| h.class))
.collect();
*ctl.requested.borrow_mut() = already_served(&held, cells.iter().map(|c| c.image_id));
// The model is about to be replaced, so every thumbnail still in flight
// addresses a window that no longer exists. Bumping here — before the swap
// — is what lets `drain_thumbnails` recognise itself as stale. The rows
// those fetches would have filled are absent from `requested` above, so the
// batch started below asks for them again.
ctl.generation.set(ctl.generation.get().wrapping_add(1));
window.set_library_cells(slint::ModelRc::new(slint::VecModel::from(rows)));
// The model is fresh, so the "in this many collections" badges are all zero
// until refilled. One query for the whole window, not one per cell.
let ids: Vec<dr_types::ImageId> = cells
.iter()
.map(|c| dr_types::ImageId(c.image_id as u64))
.collect();
crate::collections_ui::sync_badges(window, catalog, &ids);
sync_ratings(window, catalog, &ids);
// The rebuilt cells all carry `selected: false`, but the selection itself
// is a set of image ids and survives untouched. Without this the ticks
// vanished on every scroll — the selection was still there and still acted
// on, which is worse than losing it, because the user cannot see what the
// buttons are about to do.
if let Some(coll) = ctl.coll_ctl.borrow().as_ref().and_then(|w| w.upgrade()) {
crate::collections_ui::sync_selection(window, &coll, &ids);
}
// The filter chips' counts describe the whole library, not this window, so
// they are refreshed here rather than per cell.
refresh_rating_counts(window, catalog);
if total == 0 {
return;
}
request_thumbnails(window, ctl);
}
/// Push each visible image's stars and flag into the grid model.
///
/// One query for the window, mirroring `collections_ui::sync_badges` — 120
/// cells is 120 round trips otherwise, on every scroll and after every
/// keystroke.
pub fn sync_ratings(window: &AppWindow, catalog: &Catalog, ids: &[dr_types::ImageId]) {
if ids.is_empty() {
return;
}
let found = match dr_catalog::rating::judgements(catalog.connection(), ids) {
Ok(j) => j,
Err(e) => {
// The grid is still usable without stars, so this is logged rather
// than surfaced — a failure here must not blank the library.
log::debug!("reading ratings: {e}");
return;
}
};
let model = window.get_library_cells();
for (row, id) in ids.iter().enumerate() {
// Absent means unrated, which is a real state rather than missing data.
let j = found.get(id).copied().unwrap_or_default();
let (rating, flag) = (j.rating as i32, flag_code(j.flag));
if let Some(mut cell) = model.row_data(row) {
if cell.rating != rating || cell.flag != flag {
cell.rating = rating;
cell.flag = flag;
model.set_row_data(row, cell);
}
}
}
}
/// Refresh the filter chips' per-star counts.
///
/// Whole-library figures, deliberately: they say what narrowing to a filter
/// would show, so computing them over the current window would make each chip
/// describe the view it is meant to change.
fn refresh_rating_counts(window: &AppWindow, catalog: &Catalog) {
let counts = dr_catalog::rating::rating_histogram(catalog.connection()).unwrap_or_default();
let as_i32: Vec<i32> = counts.iter().map(|n| *n as i32).collect();
window.set_library_rating_counts(slint::ModelRc::new(slint::VecModel::from(as_i32)));
// TRACES: FR-CAT-9
// How many originals are actually here, for the "On this device" chip.
// Shown for the same reason the star counts are: a filter that silently
// empties the grid reads as broken, and this one will legitimately be zero
// on a library nothing has been downloaded from yet.
window.set_library_local_count(library::local_original_count(catalog).unwrap_or(0) as i32);
}
/// Apply a judgement to a set of images: catalog first, then sidecars.
///
/// # Order matters
///
/// The catalog is written **synchronously and first**, so the star appears
/// immediately and survives a restart even if the network is down. The sidecar
/// write is queued behind it on a worker thread — it is what makes the
/// judgement survive a *catalog rebuild* (ARCH §6.12), which is a slower and
/// rarer concern than the user seeing their keystroke take effect.
///
/// Doing it the other way round would mean a cull that stalls on every
/// keypress waiting for a round trip, on a workflow whose entire premise is
/// speed (FR-CULL-1).
fn apply_judgement(
window: &AppWindow,
ctl: &Rc<LibraryController>,
images: &[dr_types::ImageId],
rating: Option<u8>,
flag: Option<dr_types::FlagState>,
) {
if images.is_empty() {
// Nothing selected. Said out loud rather than ignored: a keystroke
// that silently does nothing reads as a broken key.
window.set_library_status("Select an image first".into());
return;
}
let writes = {
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
let conn = catalog.connection();
let wrote = match (rating, flag) {
(Some(r), _) => dr_catalog::rating::set_rating_many(conn, images, r),
(_, Some(f)) => dr_catalog::rating::set_flag_many(conn, images, f),
// Neither axis named: nothing to do, and not an error.
(None, None) => return,
};
if let Err(e) = wrote {
window.set_library_error(format!("recording rating: {e}").into());
return;
}
// Report what happened, in the user's terms rather than as a count of
// rows. A bulk judgement on a selection is easy to trigger by accident
// and the status line is the only confirmation of its extent.
window.set_library_status(judgement_summary(images.len(), rating, flag).into());
// Refresh the grid and the chips from what actually landed, rather
// than assuming the write took: a clamped or coalesced value must show
// as what is stored.
let visible = ctl.visible_ids();
sync_ratings(window, catalog, &visible);
refresh_rating_counts(window, catalog);
collect_sidecar_writes(catalog, images)
};
// A filtered grid may no longer contain what was just judged — rating an
// image 2 while showing "★4+" means it belongs elsewhere now. Reloading
// keeps the cells and the header count honest.
if !ctl.filter.borrow().is_unfiltered() {
load_window(window, ctl);
}
start_sidecar_writes(window, ctl, writes);
}
/// What the status line says about a judgement that just landed.
fn judgement_summary(n: usize, rating: Option<u8>, flag: Option<dr_types::FlagState>) -> String {
let what = match (rating, flag) {
(Some(0), _) => "unrated".to_string(),
(Some(r), _) => format!("{r} star{}", if r == 1 { "" } else { "s" }),
(_, Some(dr_types::FlagState::Pick)) => "picked".to_string(),
(_, Some(dr_types::FlagState::Reject)) => "rejected".to_string(),
(_, Some(dr_types::FlagState::Unflagged)) => "unflagged".to_string(),
(None, None) => return String::new(),
};
if n == 1 {
what
} else {
format!("{n} images · {what}")
}
}
/// TRACES: FR-DEV-6
/// Apply copied develop settings to a selection of images.
///
/// # Why this goes straight to the sidecars
///
/// The sidecar is the authoritative store for an edit (ARCH §6.12) and the
/// catalog holds no parameters at all — only a `graph_hash` — so there is
/// nothing here for the catalog to record. Nor is any image opened: applying
/// to forty frames by loading forty develop sessions would mean forty RAW
/// downloads and forty demosaics to move some numbers between two maps, which
/// is not a thing to ask of a phone. See [`crate::presets`].
///
/// # What the user sees
///
/// Nothing in the grid changes — a thumbnail is rendered from the server's
/// preview and does not reflect an edit — so the status line is the only
/// confirmation, exactly as it is for a bulk judgement. The applied settings
/// appear when a target is next opened in develop, which is what reads the
/// sidecar back.
pub fn paste_settings_to_selection(
window: &AppWindow,
ctl: &Rc<LibraryController>,
images: &[dr_types::ImageId],
preset: &dr_pipeline::Preset,
scope: dr_pipeline::Scope,
) {
if images.is_empty() {
// Said out loud rather than ignored, matching what a judgement
// keystroke does with an empty selection.
window.set_library_status("Select an image first".into());
return;
}
let writes = {
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
// A never-judged image may have no version row yet, and the query
// below joins on one. Ratings create them as a side effect; a paste
// is the first write path that can reach an image which has never
// been rated, so it has to ask for them itself.
if let Err(e) = dr_catalog::rating::ensure_default_versions(catalog.connection()) {
log::debug!("ensuring versions before a paste: {e}");
}
collect_settings_writes(catalog, images, preset, scope)
};
if writes.is_empty() {
window.set_library_error("Could not find those images in the catalog.".into());
return;
}
let count = writes.len();
window.set_library_status(
format!(
"Applied settings to {count} image{}.",
if count == 1 { "" } else { "s" }
)
.into(),
);
start_sidecar_writes(window, ctl, writes);
}
/// Gather one settings write per image, addressed by remote path and version.
///
/// The uuid comes from the catalog for the same reason a judgement's does: it
/// is the identity a cross-device merge keys on, and a generated one would
/// write a second version beside the one the image already has (FR-NC-8).
fn collect_settings_writes(
catalog: &Catalog,
images: &[dr_types::ImageId],
preset: &dr_pipeline::Preset,
scope: dr_pipeline::Scope,
) -> Vec<library::SidecarWrite> {
let placeholders = std::iter::repeat_n("?", images.len())
.collect::<Vec<_>>()
.join(",");
let sql = format!(
"SELECT i.source_ref, v.uuid
FROM images i
JOIN versions v ON v.image_id = i.id AND v.is_default = 1
WHERE i.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 rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok(library::SidecarWrite {
image_path: r.get(0)?,
version_uuid: r.get(1)?,
amendment: library::Amendment::Settings {
preset: preset.clone(),
scope,
},
})
});
match rows {
Ok(rows) => rows.flatten().collect(),
Err(e) => {
log::debug!("collecting settings writes: {e}");
Vec::new()
}
}
}
/// Gather what the sidecar writer needs for each judged image.
///
/// The version uuid comes from the catalog rather than being generated here:
/// it is the identity a cross-device merge keys on, so the sidecar and the
/// catalog must name the same version or a sync would treat one photograph's
/// judgement as two (FR-NC-8).
fn collect_sidecar_writes(
catalog: &Catalog,
images: &[dr_types::ImageId],
) -> Vec<library::SidecarWrite> {
let placeholders = std::iter::repeat_n("?", images.len())
.collect::<Vec<_>>()
.join(",");
let sql = format!(
"SELECT i.source_ref, v.uuid, v.rating, v.flag
FROM images i
JOIN versions v ON v.image_id = i.id AND v.is_default = 1
WHERE i.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 rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok(library::SidecarWrite {
image_path: r.get(0)?,
version_uuid: r.get(1)?,
amendment: library::Amendment::Judgement {
rating: r.get::<_, i64>(2)? as u8,
flag: r.get::<_, i64>(3)? as u8,
},
})
});
match rows {
Ok(rows) => rows.flatten().collect(),
Err(e) => {
log::debug!("collecting sidecar writes: {e}");
Vec::new()
}
}
}
/// Push judgements out to sidecars on a worker, reporting once at the end.
pub(crate) fn start_sidecar_writes(
window: &AppWindow,
ctl: &Rc<LibraryController>,
writes: Vec<library::SidecarWrite>,
) {
if writes.is_empty() {
return;
}
// TRACES: FR-CAT-9
// Offline is passed down rather than used to skip.
//
// It used to skip, and the reasoning was that a cull stays responsive
// because the rating is safe in the catalog. That held for judgements and
// not for edits: the catalog stores no parameters, so a pasted edit made
// offline survived nowhere at all. Every write now commits to the local
// sidecar cache first and the upload is best-effort, which keeps the
// keystroke path off the network — the original concern — without the
// write being conditional on it.
let offline = ctl.is_offline();
let Some((creds, session, _)) = ctl.session.borrow().clone() else {
return;
};
let Some(cache_dir) = ctl.sidecar_cache_dir() else {
return;
};
let count = writes.len();
let rx =
library::spawn_sidecar_writes(creds, session.user_id.clone(), writes, cache_dir, offline);
let timer = slint::Timer::default();
let weak = window.as_weak();
let ctl_cb = ctl.clone();
// The writer reports once at the end, so there is no per-file progress to
// show — but a cull that has just rated forty frames has forty uploads in
// flight, and "is that saved yet" deserves an answer somewhere.
let job = ctl.activity.begin(
crate::activity::Kind::Upload,
format!("Saving {count} judgement(s)"),
);
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(200),
move || {
let Some(w) = weak.upgrade() else { return };
// One message is all this channel ever carries — the writer reports
// `Finished` once and hangs up — so this drains a single item rather
// than looping like the scan and thumbnail drains do.
match rx.try_recv() {
Ok(library::SidecarMessage::Finished {
written,
queued,
failed,
last_error,
}) => {
if failed == 0 && queued > 0 {
// Recorded locally, waiting for the server. Said out
// loud because the user has just made an edit with no
// connection and deserves to know it is safe — the
// old behaviour here was to drop it silently.
log::debug!("{queued} sidecar(s) queued for upload");
ctl_cb.outbox_maybe_dirty.set(true);
job.finish_quietly();
w.set_library_status(
format!("{queued} edit(s) saved · will upload when back online").into(),
);
stop(&ctl_cb.sidecar_timer);
return;
}
if failed > 0 {
// A failed upload left the edit in the outbox.
ctl_cb.outbox_maybe_dirty.set(true);
log::warn!(
"{failed} sidecar write(s) failed: {}",
last_error.clone().unwrap_or_default()
);
job.fail(format!(
"{written} saved · {failed} failed: {}",
last_error.clone().unwrap_or_default()
));
// Said plainly, because the consequence is specific:
// the rating is safe in the catalog but will not
// survive deleting it.
w.set_library_status(
format!(
"{written} saved · {failed} could not be written \
to the library folder"
)
.into(),
);
} else {
log::debug!("{written} sidecar(s) written");
// Quietly: a cull produces one of these every few
// seconds and none of them is news.
job.finish_quietly();
}
stop(&ctl_cb.sidecar_timer);
}
Err(std::sync::mpsc::TryRecvError::Empty) => {}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
stop(&ctl_cb.sidecar_timer);
}
}
},
);
*ctl.sidecar_timer.borrow_mut() = Some(timer);
}
/// Slint carries the flag as an integer, matching the catalog's encoding.
fn flag_code(f: dr_types::FlagState) -> i32 {
match f {
dr_types::FlagState::Unflagged => 0,
dr_types::FlagState::Pick => 1,
dr_types::FlagState::Reject => 2,
}
}
/// The flag an integer from Slint stands for.
fn flag_from_code(v: i32) -> dr_types::FlagState {
match v {
1 => dr_types::FlagState::Pick,
2 => dr_types::FlagState::Reject,
_ => dr_types::FlagState::Unflagged,
}
}
/// Fetch thumbnails for rows in the model that do not have one yet.
fn request_thumbnails(window: &AppWindow, ctl: &Rc<LibraryController>) {
let Some((creds, session, _)) = ctl.session.borrow().clone() else {
return;
};
// The drawn cell size decides which class to ask for. Chosen once for the
// batch rather than per row, and carried through to the drain so a cell it
// fills can record what it is now showing.
let cell_pixels = window.get_library_cell_size().max(1.0) as u32;
let class = dr_thumbs::ThumbSize::for_cell(cell_pixels);
let wanted: Vec<library::ThumbnailRequest> = {
let paths = ctl.paths.borrow();
let file_ids = ctl.file_ids.borrow();
let sizes = ctl.sizes.borrow();
let image_ids = ctl.image_ids.borrow();
let needs_md = ctl.needs_metadata.borrow();
let mut requested = ctl.requested.borrow_mut();
paths
.iter()
.enumerate()
.filter_map(|(i, p)| {
let image_id = *image_ids.get(i)?;
// A zoomed grid asks for detail a 256px thumbnail cannot give,
// and a wall of small cells does not pay for it.
let thumb_size = class;
// Keyed on the photograph, so scrolling back over a cell that
// has already been served does not ask for it again.
if !requested.insert((image_id, thumb_size)) {
return None;
}
Some(library::ThumbnailRequest {
row: i,
path: p.clone(),
file_id: file_ids.get(i).copied().flatten(),
size: sizes.get(i).copied().unwrap_or(0),
image_id,
needs_metadata: needs_md.get(i).copied().unwrap_or(false),
thumb_size,
})
})
.collect()
};
if wanted.is_empty() {
return;
}
let requested = wanted.len();
let rx = library::spawn_thumbnails(
creds,
session.user_id.clone(),
wanted,
library::thumbs_dir(&session.server, &session.user_id),
library::catalog_path(&session.server, &session.user_id),
);
drain_thumbnails(window.as_weak(), ctl.clone(), rx, requested, class);
}
/// TRACES: FR-CAT-9 | FR-DEV-6
/// Replace a photograph's cached thumbnail with one rendered from its edit.
///
/// # Why the grid cannot be left alone
///
/// A thumbnail comes from the file's embedded preview, which is the camera's
/// idea of the photograph and knows nothing about what has been done to it
/// since. So a frame could be cropped, turned upright, and pulled two stops
/// back, and the grid would go on showing the original — the one view of a
/// library where an edit is least visible is the one the photographer spends
/// most of their time in.
///
/// # Both classes, and why
///
/// The store keys on the size class, so replacing only the one the grid
/// happens to be drawing at leaves the other holding the unedited preview —
/// and a zoom past the class boundary would show the edit undoing itself.
/// Each class is rendered separately because they are different sizes; a
/// downscale of the large one would be a second, worse resampler than the GPU
/// already applied.
///
/// # Silent on failure
///
/// The edit is saved to the sidecar by the caller before this runs, so nothing
/// here can lose work. A thumbnail that could not be re-rendered is a stale
/// cell, which the next scroll past it corrects from the store — worth a log
/// line and not worth an error in front of a photographer who has just
/// finished an image.
pub fn refresh_thumbnail(
window: &AppWindow,
ctl: &Rc<LibraryController>,
remote_path: &str,
mut render: impl FnMut(u32) -> Result<(u32, u32, Vec<u8>), String>,
) {
// Where this photograph sits in the loaded window. It is always in it: the
// develop view is reached from a cell, and the guards on the scroll and
// geometry handlers stop the window moving while it is open.
let Some(row) = ctl.paths.borrow().iter().position(|p| p == remote_path) else {
return;
};
let Some(file_id) = ctl.file_ids.borrow().get(row).copied().flatten() else {
// Nothing to key the store on. A photograph the scan recorded without
// a server file id cannot have a cached thumbnail either, so there is
// nothing here to correct.
return;
};
let Some((_, session, _)) = ctl.session.borrow().clone() else {
return;
};
let mut store = match dr_thumbs::ThumbStore::open(&library::thumbs_dir(
&session.server,
&session.user_id,
)) {
Ok(s) => s,
Err(e) => {
log::warn!("re-thumbnailing {remote_path}: opening the store: {e}");
return;
}
};
// What the grid is drawing at, so the cell can be corrected on screen
// rather than only on disk.
let drawn = dr_thumbs::ThumbSize::for_cell(window.get_library_cell_size().max(1.0) as u32);
for class in [dr_thumbs::ThumbSize::Grid, dr_thumbs::ThumbSize::Large] {
let (w, h, rgba) = match render(class.edge()) {
Ok(r) => r,
Err(e) => {
log::warn!("re-thumbnailing {remote_path} at {class:?}: {e}");
continue;
}
};
match dr_thumbs::codec::encode_rgba(w, h, &rgba) {
Ok(bytes) => {
let thumb = dr_thumbs::Thumbnail {
width: w,
height: h,
bytes,
};
if let Err(e) = store.put(file_id, class, &thumb) {
log::warn!("re-thumbnailing {remote_path} at {class:?}: {e}");
}
}
Err(e) => {
log::warn!("re-thumbnailing {remote_path} at {class:?}: encoding: {e}");
continue;
}
}
if class == drawn {
// Straight into the model, so the edit is on the cell the moment
// the grid comes back rather than after a scroll evicts and
// refetches it.
let model = window.get_library_cells();
if let Some(mut cell) = model.row_data(row) {
cell.thumbnail = to_slint_image(w, h, &rgba);
cell.has_thumb = true;
cell.unavailable = false;
model.set_row_data(row, cell);
}
record_class(ctl, row, class);
}
}
}
/// Note which size class a row's pixels came from.
///
/// Silent about a row past the end: the model and this vector are rebuilt
/// together by [`load_window`], and the generation check above already refuses
/// anything addressed to a window that has since moved.
fn record_class(ctl: &Rc<LibraryController>, row: usize, class: dr_thumbs::ThumbSize) {
if let Some(slot) = ctl.thumb_class.borrow_mut().get_mut(row) {
*slot = Some(class);
}
}
/// Apply thumbnails to the model as they arrive.
fn drain_thumbnails(
weak: slint::Weak<AppWindow>,
ctl: Rc<LibraryController>,
rx: Receiver<ThumbnailMessage>,
requested: usize,
class: dr_thumbs::ThumbSize,
) {
let timer = slint::Timer::default();
let ctl_cb = ctl.clone();
// One row per batch, measured against the cells this window asked for.
// Starting a new batch does not extend the last one: a scroll abandons
// whatever the previous window wanted, and a denominator carried across
// both would describe neither.
let job = ctl
.activity
.begin(crate::activity::Kind::Thumbnails, "Loading thumbnails");
job.total(requested);
// Which window this batch was requested for. Captured at spawn, compared on
// every tick.
let mine = ctl.generation.get();
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(100),
move || {
let Some(w) = weak.upgrade() else { return };
// A reload replaced the model under this worker. Two things must
// not happen now, and both did:
//
// - Applying a row. `t.row` indexes the window that asked for it,
// so after a reload it names a different photograph — thumbnails
// landed on unrelated cells, and `Unavailable` marked cells
// "no preview" for a fetch never attempted against them.
// - Calling `stop`. `thumb_timer` holds the *current* batch's timer
// by now, so a stale drain reaching `Disconnected` killed the
// live drain instead of itself. The new worker then fetched into
// a channel nobody read, and the grid stayed black until a scroll
// forced yet another load — which is the flicker being chased.
//
// Returning without stopping is deliberate: this timer is no longer
// reachable through the controller, so it is dropped with its
// receiver when the slot is overwritten, and the worker exits on
// its next failed send.
if ctl_cb.generation.get() != mine {
return;
}
let model = w.get_library_cells();
loop {
let msg = match rx.try_recv() {
Ok(m) => m,
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
// The worker finished or died. Either way nothing more
// is coming, so the bar must not sit part-filled
// forever.
//
// Quietly: a scroll starts one of these every second,
// and a history of them would bury anything worth
// reading.
job.finish_quietly();
stop(&ctl_cb.thumb_timer);
return;
}
};
match msg {
// Bookkeeping, not an outcome — reports the split between
// store and network without advancing the bar.
ThumbnailMessage::Plan {
cached,
fetching,
dating,
} => {
// Date reads produce no cell, so they are counted into
// the bar's denominator or it finishes while work is
// still running.
job.add_total(dating);
let mut parts = Vec::new();
if cached > 0 {
parts.push(format!("{cached} cached"));
}
if fetching > 0 {
parts.push(format!("fetching {fetching}"));
}
if dating > 0 {
parts.push(format!("reading {dating} dates"));
}
if !parts.is_empty() {
let status = parts.join(" · ");
job.detail(status.clone());
w.set_library_status(status.into());
}
}
// A header-only date read. Advances the bar; draws nothing.
ThumbnailMessage::DateProgress => {
job.advance();
}
// Dates landed, so the histogram can now be built. This is
// what makes the timeline appear on a library whose
// thumbnails were all cached.
ThumbnailMessage::DatesRecorded(n) => {
log::info!("timeline: {n} new dates");
let borrow = ctl_cb.catalog.borrow();
if let Some(catalog) = borrow.as_ref() {
refresh_timeline(&w, catalog, &ctl_cb);
}
}
// Every real outcome advances the bar. Counting only
// successes would stall it on a library where some files
// carry no embedded preview.
ThumbnailMessage::Ready(t) => {
job.advance();
// Bytes arrived *from the server*, so it is reachable.
// This is what clears the banner when a connection
// returns while the user is simply scrolling, without
// waiting for a probe or a manual retry.
//
// Store hits are excluded deliberately: they are read
// from local disk and say nothing about the network. A
// window that is mostly cached delivers a run of them
// before the first request is even attempted, so
// counting them declared "back online" against a server
// that was plainly down.
if !t.from_cache
&& ctl_cb
.reachability
.borrow_mut()
.mark_reachable(std::time::Instant::now())
{
log::info!("back online");
refresh_offline(&w, &ctl_cb);
// The sweep was refused while offline, so nothing
// else would ever restart it — the grid would stay
// partially dated until the next launch.
start_sweep(&w, &ctl_cb);
}
if let Some(mut row) = model.row_data(t.row) {
row.thumbnail = to_slint_image(t.width, t.height, &t.rgba);
row.has_thumb = true;
model.set_row_data(t.row, row);
// What this cell is now showing, so the next reload
// can carry it over and know not to ask again.
record_class(&ctl_cb, t.row, class);
}
}
ThumbnailMessage::Unavailable { row, reason } => {
job.advance();
log::debug!("thumbnail {row}: {reason}");
if let Some(mut r) = model.row_data(row) {
r.unavailable = true;
model.set_row_data(row, r);
// A verdict is worth carrying too: "no preview" is
// an answer about the file, and re-asking it on
// every scroll is a fetch that will fail again.
record_class(&ctl_cb, row, class);
}
}
// TRACES: FR-CAT-9
ThumbnailMessage::Offline { reason } => {
log::info!("thumbnails stopped: {reason}");
// The batch is over, so the bar must not be left
// showing a partial fetch that will never finish — it
// would sweep for ever.
job.fail(reason.clone());
ctl_cb
.reachability
.borrow_mut()
.mark_unreachable(reason, std::time::Instant::now());
refresh_offline(&w, &ctl_cb);
// Cells left without pixels stay placeholders rather
// than being marked unavailable: the images are fine,
// and a reconnect should fill them in. Marking them
// would persist a verdict about the *file* from an
// event about the *connection*.
stop(&ctl_cb.thumb_timer);
return;
}
}
}
},
);
*ctl.thumb_timer.borrow_mut() = Some(timer);
}
/// Move the timeline's zoom by whole levels.
///
/// Shared by the wheel and the pinch so the two cannot drift apart in how they
/// clamp, or in where they choose to centre.
fn apply_zoom(window: &AppWindow, ctl: &Rc<LibraryController>, delta: i32) {
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
// Bounded: past ~2^12 the window is minutes wide and every bucket is
// empty, which reads as a broken axis rather than a deep zoom.
let next = (*ctl.timeline_zoom.borrow() + delta).clamp(0, 12);
if next == *ctl.timeline_zoom.borrow() {
return;
}
*ctl.timeline_zoom.borrow_mut() = next;
// Zooming fully out forgets the centre, so the axis returns to describing
// the whole library rather than a remembered position.
if next == 0 {
*ctl.timeline_centre.borrow_mut() = None;
} else if ctl.timeline_centre.borrow().is_none() {
// First zoom centres on wherever the grid is, else the middle.
*ctl.timeline_centre.borrow_mut() = *ctl.current_bucket.borrow();
}
refresh_timeline(window, catalog, ctl);
}
/// Push shards and the catalog to the server, and take what it has.
///
/// Fired after the sweep completes, when there is a finished index worth
/// sharing, and from the Sync button for an explicit exchange.
fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>) {
let Some((creds, session, _)) = ctl.session.borrow().clone() else {
return;
};
// Already running: a second pass would race the first over the same
// scratch files.
if ctl.sync_timer.borrow().is_some() && window.get_library_syncing() {
return;
}
// TRACES: FR-CAT-9
// Nothing to push to and nothing to take. Attempting it would upload
// shards into a timeout and light the "Syncing…" indicator over work that
// cannot start; the shards are unchanged on disk and go out on the next
// sync once the server is back.
if ctl.is_offline() {
log::debug!("offline: skipping derived sync");
return;
}
let catalog_path = library::catalog_path(&session.server, &session.user_id);
let scratch = catalog_path
.parent()
.map(|p| p.join("scratch"))
.unwrap_or_else(std::env::temp_dir);
let _ = std::fs::create_dir_all(&scratch);
// TRACES: FR-EXP-7 | FR-NC-10
// Drain the export outbox on the same pass, and before the shards. An
// export the user was told had succeeded is waiting here, and it is the
// one thing in this directory that exists nowhere else — a thumbnail
// shard can be rebuilt from the originals, and the catalog is an index.
//
// Fire-and-forget rather than reported: it runs on its own thread and
// clears entries as they land, so a partial run leaves the rest queued
// for next time and nothing is lost by not watching it. It reports
// through the log until an export has a place in the activity list.
{
let outbox = crate::export::outbox_dir(&session.server, &session.user_id);
if crate::export::pending_count(&outbox) > 0 {
let rx = crate::export::spawn_upload(
creds.clone(),
session.user_id.clone(),
session.root.clone(),
outbox,
);
std::thread::spawn(move || {
while let Ok(msg) = rx.recv() {
match msg {
crate::export::UploadMessage::Status(s) => log::info!("export: {s}"),
crate::export::UploadMessage::Finished {
uploaded,
remaining,
error,
} => {
log::info!("export: {uploaded} uploaded, {remaining} still queued");
if let Some(e) = error {
log::warn!("export upload stopped: {e}");
}
}
}
}
});
}
}
window.set_library_syncing(true);
let rx = crate::derived_sync::spawn_sync(
creds,
session.user_id.clone(),
session.root.clone(),
library::thumbs_dir(&session.server, &session.user_id),
catalog_path,
scratch,
);
let timer = slint::Timer::default();
let weak = window.as_weak();
let ctl_cb = ctl.clone();
// The sync reports stages rather than counts, so it stays indeterminate and
// says what stage it is in.
let job = ctl
.activity
.begin(crate::activity::Kind::Sync, "Syncing with the server");
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(300),
move || {
let Some(w) = weak.upgrade() else { return };
loop {
let msg = match rx.try_recv() {
Ok(m) => m,
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
w.set_library_syncing(false);
job.fail("stopped without finishing");
stop(&ctl_cb.sync_timer);
return;
}
};
match msg {
crate::derived_sync::SyncMessage::Status(s) => {
job.detail(s.clone());
w.set_library_status(s.into());
}
crate::derived_sync::SyncMessage::Finished(report) => {
log::info!(
"sync: {} shard(s) up, {} down ({} thumbnails), \
catalog {}{}",
report.shards_uploaded,
report.shards_downloaded,
report.thumbnails_adopted,
if report.catalog_uploaded {
"pushed"
} else {
"not pushed"
},
if report.collections_gained > 0 {
format!(", {} collection(s) gained", report.collections_gained)
} else {
String::new()
}
);
w.set_library_syncing(false);
let summary = format!(
"{} shard(s) up, {} down",
report.shards_uploaded, report.shards_downloaded
);
job.finish(if report.did_anything() {
summary.clone()
} else {
"nothing to exchange".to_string()
});
if report.did_anything() {
w.set_library_status(format!("synced · {summary}").into());
}
// Adopted thumbnails and merged collections both change
// what the grid should show.
if report.thumbnails_adopted > 0 || report.collections_gained > 0 {
load_window(&w, &ctl_cb);
}
stop(&ctl_cb.sync_timer);
return;
}
crate::derived_sync::SyncMessage::Failed(e) => {
log::warn!("sync failed: {e}");
job.fail(e.to_string());
w.set_library_syncing(false);
// Not an error banner: a failed sync costs nothing —
// everything is still local and the next pass retries.
w.set_library_status(format!("sync failed: {e}").into());
stop(&ctl_cb.sync_timer);
return;
}
}
}
},
);
*ctl.sync_timer.borrow_mut() = Some(timer);
}
/// Start the whole-library sweep and report its progress.
///
/// The grid only ever fetches what is on screen, so without this the timeline
/// describes the fraction of the library that happened to be scrolled past.
/// This covers the rest.
fn start_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
let Some((creds, session, _)) = ctl.session.borrow().clone() else {
return;
};
// TRACES: FR-CAT-9
// The sweep is nothing but network reads — one header fetch per undated
// image, across the whole library. Offline it would spend a timeout on
// every one of them, running for hours to learn nothing, while the
// progress bar implied work was happening. It resumes on reconnect, and
// the images it has already dated stay dated.
if ctl.is_offline() {
log::debug!("offline: not starting the metadata sweep");
return;
}
let rx = library::spawn_sweep(
creds,
session.user_id.clone(),
library::catalog_path(&session.server, &session.user_id),
);
let timer = slint::Timer::default();
let weak = window.as_weak();
let ctl_cb = ctl.clone();
// Hours on a large library, and entirely invisible outside the grid until
// now: the register is where a user who has gone to develop can still see
// that indexing is running and how far it has got.
let job = ctl
.activity
.begin(crate::activity::Kind::Index, "Indexing capture times");
timer.start(
slint::TimerMode::Repeated,
// Slower than the thumbnail drain: this runs for tens of minutes and
// its progress does not need per-frame accuracy.
std::time::Duration::from_millis(400),
move || {
let Some(w) = weak.upgrade() else { return };
loop {
let msg = match rx.try_recv() {
Ok(m) => m,
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
w.set_library_sweep_total(0);
job.fail("stopped without finishing");
stop(&ctl_cb.sweep_timer);
return;
}
};
match msg {
library::SweepMessage::Total(n) => {
w.set_library_sweep_total(n as i32);
w.set_library_sweep_done(0);
job.total(n);
}
library::SweepMessage::Progress { done, dated } => {
w.set_library_sweep_done(done as i32);
job.progress(done, w.get_library_sweep_total() as usize);
// Rebuild as it goes: the histogram growing while the
// sweep runs is the visible sign it is working.
if dated > 0 {
let borrow = ctl_cb.catalog.borrow();
if let Some(catalog) = borrow.as_ref() {
refresh_timeline(&w, catalog, &ctl_cb);
}
}
}
library::SweepMessage::Finished { dated } => {
log::info!("sweep finished: {dated} dated");
job.finish(format!("{dated} dated"));
w.set_library_sweep_total(0);
{
let borrow = ctl_cb.catalog.borrow();
if let Some(catalog) = borrow.as_ref() {
refresh_timeline(&w, catalog, &ctl_cb);
}
}
// Now that indexing is complete, hand the result to the
// server so a second device inherits it rather than
// repeating hours of range fetches.
start_derived_sync(&w, &ctl_cb);
stop(&ctl_cb.sweep_timer);
return;
}
}
}
},
);
*ctl.sweep_timer.borrow_mut() = Some(timer);
}
/// Rebuild the timeline histogram from the catalog.
///
/// Bucket size follows the span of the library, the way darktable's does:
/// a decade of photographs buckets by year, a single trip by day. Picking it
/// from the data rather than fixing it means the histogram is informative at
/// both scales instead of one flat bar or ten thousand slivers.
fn refresh_timeline(window: &AppWindow, catalog: &Catalog, ctl: &Rc<LibraryController>) {
// Scoped to whatever the grid is showing. A collection's histogram drawn
// over the whole library's span said almost nothing: every bar for a
// fortnight in Arosa landed in one column of a fifteen-year axis.
let scope = *ctl.scope.borrow();
let filter = *ctl.filter.borrow();
let span = match library::span_scoped(catalog, scope, &filter) {
Some(s) => s,
None => {
// No dated images yet. An empty histogram is honest — EXIF is read
// as thumbnails load, so this populates as the user browses.
window.set_library_timeline(slint::ModelRc::new(slint::VecModel::from(vec![])));
window.set_library_timeline_label(slint::SharedString::new());
return;
}
};
// Zoom narrows the span around wherever the view sits rather than around
// the library's midpoint, so zooming in keeps what you were looking at.
let zoom = *ctl.timeline_zoom.borrow();
let (from, to) = zoomed_span(span, zoom, *ctl.timeline_centre.borrow());
let granularity = dr_catalog::Granularity::for_span(to - from);
let buckets = match library::timeline_scoped(catalog, scope, &filter, granularity, from, to) {
Ok(b) => b,
Err(e) => {
log::debug!("timeline: {e}");
return;
}
};
// Normalise against the tallest bar. Counts vary by orders of magnitude
// between a quiet month and a wedding, so a linear scale against the total
// would render most buckets invisible.
let peak = buckets.iter().map(|b| b.count).max().unwrap_or(1).max(1);
let mut previous: Option<(i64, i64)> = None;
let bars: Vec<TimelineBar> = buckets
.iter()
.map(|b| {
let (y, m, _, _) = civil_from_unix(b.start);
// Label only where a period begins, so a month-bucketed axis reads
// "2024 … Mar … Apr" rather than repeating the year on every bar.
let period = match previous {
None => format!("{y}"),
Some((py, _)) if py != y => format!("{y}"),
Some((_, pm)) if pm != m && granularity_labels_months(granularity) => {
month_abbrev(m).to_string()
}
_ => String::new(),
};
previous = Some((y, m));
TimelineBar {
// Square root rather than linear: it keeps a 3-image day
// visible beside a 400-image one without a log scale's
// misleading flatness.
height: ((b.count as f32 / peak as f32).sqrt()).clamp(0.02, 1.0),
start: b.start as i32,
count: b.count as i32,
label: format_bucket(b.start, granularity).into(),
period_label: period.into(),
}
})
.collect();
// Where the grid currently sits, as a fraction of the visible span.
//
// This is the exact inverse of what a click produces: the widget maps a y
// to a fraction of its track and `on_library_scrub_fraction` interpolates
// `from + (to - from) * f`, so the marker must invert that same expression
// or it lands somewhere other than the pointer.
//
// The earlier version sent a *bar index* instead. Bars occupy one equal
// slot each regardless of how much time they cover, and `rposition` snaps
// to the bucket's start edge, so the marker sat at the top of whichever
// slot contained the instant — near enough on a dense uniform axis, plainly
// wrong on a sparse one, and never under the click.
let current = *ctl.current_bucket.borrow();
let fraction = current
.map(|t| ((t - from) as f64 / (to - from).max(1) as f64).clamp(0.0, 1.0) as f32)
.unwrap_or(-1.0);
window.set_library_current_bucket(current.unwrap_or(0) as i32);
window.set_library_current_fraction(fraction);
window.set_library_timeline_anchored(current.is_some());
window
.set_library_timeline_label(format!("{} – {}", format_date(from), format_date(to)).into());
window.set_library_timeline(slint::ModelRc::new(slint::VecModel::from(bars)));
}
/// Whether this bucket size is fine enough for month labels to mean anything.
///
/// A year-bucketed axis labelled by month would put twelve labels on one bar.
fn granularity_labels_months(g: dr_catalog::Granularity) -> bool {
!matches!(g, dr_catalog::Granularity::Year)
}
/// Full month name, for the grid's headings.
fn month_name(m: i64) -> &'static str {
const NAMES: [&str; 12] = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
NAMES[((m - 1).clamp(0, 11)) as usize]
}
fn month_abbrev(m: i64) -> &'static str {
const NAMES: [&str; 12] = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
NAMES[((m - 1).clamp(0, 11)) as usize]
}
/// Narrow a span by a zoom level, centred on `centre`.
///
/// Each step halves or doubles the visible duration. Clamped to the library's
/// own extent so zooming out cannot wander past the first or last photograph.
fn zoomed_span(full: (i64, i64), zoom: i32, centre: Option<i64>) -> (i64, i64) {
let (lo, hi) = full;
if zoom <= 0 {
return (lo, hi);
}
let duration = (hi - lo).max(1);
// 2^zoom, saturating: a very deep zoom must not shift the duration to zero.
let factor = 1i64 << zoom.min(20);
let window = (duration / factor).max(3600);
let mid = centre.unwrap_or(lo + duration / 2);
let half = window / 2;
let (mut from, mut to) = (mid - half, mid + half);
// Slide rather than shrink at the ends, so the window keeps its size.
if from < lo {
to += lo - from;
from = lo;
}
if to > hi {
from -= to - hi;
to = hi;
}
(from.max(lo), to.min(hi))
}
/// Earliest and latest capture time in the catalog.
/// The timeline's extent, over exactly the images the grid is showing.
///
/// Scrubbing and zooming must measure the same span the histogram is drawn
/// over. When this read the whole library while the bars were scoped to a
/// collection, a scrub landed at an instant the collection did not contain and
/// the view jumped somewhere the user had not asked for.
fn catalog_span(catalog: &Catalog, ctl: &Rc<LibraryController>) -> Option<(i64, i64)> {
library::span_scoped(catalog, *ctl.scope.borrow(), &ctl.filter.borrow())
}
/// Capture time of the image at row `ordinal` in the grid's own ordering.
///
/// The inverse of the count in [`scrub_to`], and it must stay the inverse: the
/// same `shadowed_by IS NULL` exclusion and the same ordering, or scrolling
/// would report an instant the scrub would never produce for that row.
///
/// `None` for an ordinal that lands among the undated tail, which sorts last
/// and has no place on a capture-time axis.
///
/// Called on every scroll event, so it has to stay cheap: the `images_captured`
/// index makes it a seek along already-ordered rows rather than a sort.
fn capture_time_at(catalog: &Catalog, ordinal: usize) -> Option<i64> {
catalog
.connection()
.query_row(
"SELECT captured_at FROM images
WHERE shadowed_by IS NULL AND captured_at IS NOT NULL
ORDER BY captured_at
LIMIT 1 OFFSET ?1",
[ordinal as i64],
|r| r.get::<_, i64>(0),
)
.ok()
}
/// Jump the grid to the first image at or after `when`.
///
/// This is the scrub: the window moves, the library does not narrow. Every
/// image stays reachable, which is why this is a position rather than a
/// filter.
fn scrub_to(window: &AppWindow, ctl: &Rc<LibraryController>, when: i64) {
let position = {
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
// How many rows precede this instant **in the grid's own ordering**.
// That ordinal is the scroll offset, so any disagreement with the
// grid's query lands the view somewhere else entirely.
//
// The earlier version counted only dated images. With 2,400 of 19,841
// dated, a click near the end of the axis produced an ordinal of ~2,400
// against a grid of 19,841 rows — the view landed near the top however
// far down the axis the pointer went.
//
// Undated images sort last (`captured_at IS NULL` first in the ORDER
// BY), so they never precede a dated one and the predicate below stays
// a simple `<`. Shadowed rows are excluded here exactly as the grid
// excludes them.
catalog
.connection()
.query_row(
"SELECT count(*) FROM images
WHERE shadowed_by IS NULL
AND captured_at IS NOT NULL
AND captured_at < ?1",
[when],
|r| r.get::<_, i64>(0),
)
.unwrap_or(0) as usize
};
// Record where the grid now sits, which anchors the timeline marker. Until
// the first scrub this stays `None` and the marker rests at the middle
// rather than implying a choice the user has not made.
*ctl.current_bucket.borrow_mut() = Some(when);
*ctl.offset.borrow_mut() = position;
// Move the viewport as well as the window. Cells are drawn at their
// absolute place in the library, so loading rows around image 15,000 while
// the viewport sits at row 0 shows an empty grid until the user scrolls.
window.set_library_scroll_to(position as i32);
window.set_library_scroll_token(window.get_library_scroll_token() + 1);
load_window(window, ctl);
}
/// Format a bucket start for the histogram's hover label.
fn format_bucket(t: i64, g: dr_catalog::Granularity) -> String {
let (y, m, d, h) = civil_from_unix(t);
match g {
dr_catalog::Granularity::Year => format!("{y}"),
dr_catalog::Granularity::Month => format!("{y}-{m:02}"),
dr_catalog::Granularity::Day => format!("{y}-{m:02}-{d:02}"),
dr_catalog::Granularity::Hour => format!("{y}-{m:02}-{d:02} {h:02}:00"),
}
}
/// A capture instant as `YYYY-MM-DD`.
///
/// Shared with the exporter, which resolves the `{date}` token from the same
/// reading so a filename and the timeline cannot disagree about what day a
/// photograph was taken.
pub fn format_date(t: i64) -> String {
let (y, m, d, _) = civil_from_unix(t);
format!("{y}-{m:02}-{d:02}")
}
/// Unix seconds to a civil date, via the usual era-based algorithm.
///
/// Hand-rolled rather than pulling in chrono for four fields — the same
/// reasoning as the connector's HTTP date parsing.
fn civil_from_unix(t: i64) -> (i64, i64, i64, i64) {
let days = t.div_euclid(86_400);
let secs = t.rem_euclid(86_400);
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
(if m <= 2 { y + 1 } else { y }, m, d, secs / 3600)
}
/// Copy decoded RGBA into a Slint image.
///
/// This is a CPU copy, which is acceptable here and not in the develop path:
/// a 256px thumbnail is 256 KB and happens once per image, where the canvas
/// would pay per frame (ARCH §6.1).
fn to_slint_image(width: u32, height: u32, rgba: &[u8]) -> slint::Image {
let mut buf = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::new(width, height);
let expected = (width as usize) * (height as usize) * 4;
let src = &rgba[..expected.min(rgba.len())];
buf.make_mut_bytes()[..src.len()].copy_from_slice(src);
slint::Image::from_rgba8(buf)
}
/// Walk the keyboard cursor through the library — the arrow keys.
///
/// **The cursor is a library ordinal, not a row of the loaded window.** That is
/// what lets it walk past the window's edge: the window is a few screenfuls
/// around wherever the user is looking, and a cursor held as a row would stop
/// at its end or, worse, keep counting into cells belonging to different
/// photographs. Moving out of the window reloads it around the new position,
/// which is the same thing scrolling does.
///
/// The grid supplies the step, because how far "down" is depends on how many
/// columns the window happens to be showing, and only the grid knows that. It
/// does not clamp: `Home` and `End` arrive as a step longer than the library
/// and are clamped here, where the total is known.
fn move_cursor(
window: &AppWindow,
ctl: &Rc<LibraryController>,
coll: &Rc<crate::collections_ui::CollectionsController>,
delta: i32,
extend: bool,
) {
let total = window.get_library_total().max(0) as usize;
if total == 0 {
return;
}
let Some(from) = coll.cursor() else {
// The first press takes hold of the grid rather than moving in it. A
// key that jumped to image zero would lose wherever the user had
// scrolled to, and one that started from a cell off the top of the
// screen would appear to do nothing but scroll.
//
// The first *visible* ordinal, not the window's start: the loaded
// window deliberately begins a quarter of a screen above the view, so
// its first cell is one the user cannot see.
let at = ctl.resume_at.get().min(total - 1);
place_cursor(window, ctl, coll, at, false);
return;
};
// Saturating in `isize`, so a `Home` expressed as minus the library's
// length does not wrap round to the end.
let next = (from as isize)
.saturating_add(delta as isize)
.clamp(0, total as isize - 1) as usize;
if next == from {
// Already at the end being pressed toward. Nothing to move, and
// reloading the window would be a visible jerk for no movement.
return;
}
place_cursor(window, ctl, coll, next, extend);
}
/// Put the cursor on one image, bringing the window with it.
fn place_cursor(
window: &AppWindow,
ctl: &Rc<LibraryController>,
coll: &Rc<crate::collections_ui::CollectionsController>,
at: usize,
extend: bool,
) {
// Bring the window to the cursor if it has walked out of it. Centred a
// quarter in, exactly as a scroll does it, so continuing in the same
// direction has loaded cells to move into rather than another reload on
// the very next press.
let loaded = window.get_library_cells().row_count();
let offset = *ctl.offset.borrow();
if at < offset || at >= offset + loaded {
let size = *ctl.window.borrow();
*ctl.offset.borrow_mut() = at.saturating_sub(size / 4);
load_window(window, ctl);
}
// Re-read: `load_window` clamps the offset against the library's end, so
// the window may not start where it was asked to.
let offset = *ctl.offset.borrow();
let ids = ctl.visible_ids();
let Some(row) = at.checked_sub(offset).filter(|r| *r < ids.len()) else {
// The window could not be brought to the cursor — an empty or
// shrinking library. Leaving the cursor where it was is better than
// pointing it at nothing.
return;
};
if !extend {
// An arrow key collapses the selection onto the cursor. A *click* on
// an already-selected cell deliberately leaves the selection alone —
// that exception exists so a multi-image drag can start from one of
// its members — and there is no drag behind a keystroke.
coll.clear_selection();
}
crate::collections_ui::select_row(window, coll, &ids, offset, row, false, extend);
window.set_library_cursor(at as i32);
}
/// Say where in the library the photograph now open sits.
///
/// **After `on_open_image`, never before.** The generic open path resets the
/// readout to "1 of 1" on its way in, because until the photo roll existed the
/// grid really did hand develop a single path with nothing to walk to. It has
/// a window of them now, so the honest answer is the ordinal in the library —
/// not the row in the loaded window, which is an artefact of how much has been
/// paged in and would jump about as the window moves.
fn report_position(window: &AppWindow, ctl: &Rc<LibraryController>, row: usize) {
let offset = *ctl.offset.borrow();
window.set_index((offset + row) as i32);
window.set_total(window.get_library_total());
}
/// Connect the grid's callbacks.
pub fn wire<F>(
window: &AppWindow,
ctl: Rc<LibraryController>,
coll_ctl: Rc<crate::collections_ui::CollectionsController>,
on_open_image: F,
on_leave_develop: Rc<dyn Fn()>,
) where
F: Fn(String) + 'static,
{
// So a rebuilt window can put the selection ticks back. Weak, or the two
// controllers would hold each other alive for the life of the process.
*ctl.coll_ctl.borrow_mut() = Some(Rc::downgrade(&coll_ctl));
// TRACES: FR-UI-3 | FR-UI-4
// Whether the rating strip waits to be hovered or stands open.
//
// On Android touch is not evidence to be gathered, it is the platform.
// The grid latches it from the first finger it sees as well, which is what
// covers a touchscreen on the desktop — but that latch needs a press to
// reach a cell, and a quick flick never delivers one because the Flickable
// claims the gesture before the delay it would forward after. Seeding it
// here means the stars are on screen before the first touch rather than
// after it, which is the whole point of showing them.
window.set_library_touched(cfg!(target_os = "android"));
// Shared rather than moved: a click and `Return` both open an image, and
// they are two callbacks.
let on_open_image = Rc::new(on_open_image);
{
let weak = window.as_weak();
let ctl = ctl.clone();
let coll_for_click = coll_ctl.clone();
let on_open_image = on_open_image.clone();
window.on_library_cell_clicked(move |i| {
// A ctrl- or shift-click is a selection gesture. Opening the image
// too would throw the user out of the grid mid-selection.
if coll_for_click.press_was_modified() {
return;
}
let path = ctl.paths.borrow().get(i as usize).cloned();
if let Some(path) = path {
// Leave the grid for the develop view. The status bar's
// "‹ Library" button comes back here.
if let Some(w) = weak.upgrade() {
w.set_show_library(false);
// Which cell the develop view is now showing, so the photo
// roll opens marking it rather than marking nothing.
w.set_library_roll_current(i);
}
on_open_image(path);
if let Some(w) = weak.upgrade() {
report_position(&w, &ctl, i as usize);
}
}
});
}
// TRACES: FR-UI-4
// A photograph chosen from the photo roll.
//
// The same row-to-path lookup a cell click does, without the selection
// rules: the roll is a way of moving between photographs, not of building
// a set, so there is no modified press to honour and no reason to leave
// develop. `open_from_library` persists the outgoing edit before it loads
// the next one, which is what makes this safe to fire repeatedly.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let on_open_image = on_open_image.clone();
window.on_library_roll_pick(move |i| {
let Some(w) = weak.upgrade() else { return };
let Some(path) = ctl.paths.borrow().get(i as usize).cloned() else {
return;
};
w.set_library_roll_current(i);
on_open_image(path);
report_position(&w, &ctl, i as usize);
});
}
// --- the keyboard cursor (FR-CULL-4) -----------------------------------
//
// Walking the grid with the arrows, and opening with `Return`. Together
// with the judgement keys already bound in the grid, this is what makes a
// culling pass a keyboard job: move, rate, move, open the doubtful one,
// come back. A cull is thousands of decisions, and reaching for the mouse
// between each of them is the difference between an hour and an evening.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let coll = coll_ctl.clone();
window.on_library_move_cursor(move |delta, extend| {
let Some(w) = weak.upgrade() else { return };
move_cursor(&w, &ctl, &coll, delta, extend);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
let coll = coll_ctl.clone();
let on_open = on_open_image.clone();
window.on_library_open_cursor(move || {
let Some(w) = weak.upgrade() else { return };
// The cursor is a library ordinal and `paths` is the loaded
// window, so the row is the difference. A cursor outside the
// window cannot happen — moving it loads the window around it —
// but a shrinking library could leave one behind, and opening the
// wrong photograph is worse than opening none.
let Some(cursor) = coll.cursor() else { return };
let offset = *ctl.offset.borrow();
let row = cursor.checked_sub(offset);
let path = row.and_then(|row| ctl.paths.borrow().get(row).cloned());
if let Some(path) = path {
w.set_show_library(false);
// As on a click: the roll marks what is open.
w.set_library_roll_current(row.unwrap_or(0) as i32);
on_open(path);
report_position(&w, &ctl, row.unwrap_or(0));
}
});
}
// Ctrl+wheel or pinch over the grid resizes the cells.
//
// Geometric steps rather than fixed pixels: the same gesture should feel
// the same at 90px and at 400px, and a linear step is imperceptible at one
// end and violent at the other.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_zoom_cells(move |delta| {
let Some(w) = weak.upgrade() else { return };
let current = w.get_library_cell_size();
let next = if delta > 0 {
current * 1.25
} else {
current / 1.25
}
.clamp(MIN_CELL_SIZE, MAX_CELL_SIZE);
if (next - current).abs() < 0.5 {
return;
}
w.set_library_cell_size(next);
// No need to forget anything on a class change: the class is part
// of the request key, so cells that now want the large resolution
// simply miss and ask for it, while the 256px ones they already
// hold stay served.
//
// Deferred, like every other geometry change. This one was still
// reloading inline — a full catalog re-read and model rebuild per
// step, which is what a wheel spun through six steps paid six
// times over.
schedule_reload(&w, &ctl);
});
}
// TRACES: FR-UI-4
// A pinch, which is continuous where the wheel is stepped.
//
// Given the ratio since the last update rather than a direction, so the
// grid tracks the fingers instead of jumping a fixed 25% per threshold
// crossing. What the user is setting is the size class — how big they want
// a thumbnail to be — and the drawn cell follows from it by dividing the
// width, so the visible result still lands on whole column counts. Feeding
// a continuous value in is what decides *when* it crosses.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_pinch_cells(move |ratio| {
let Some(w) = weak.upgrade() else { return };
if !(ratio.is_finite() && ratio > 0.0) {
return;
}
let current = w.get_library_cell_size();
let next = (current * ratio).clamp(MIN_CELL_SIZE, MAX_CELL_SIZE);
if (next - current).abs() < 0.5 {
return;
}
w.set_library_cell_size(next);
schedule_reload(&w, &ctl);
});
}
// TRACES: FR-UI-4
// A pinch has begun, so the press that started it was not a press.
//
// A pinch opens as one finger on a cell — which selects it — and only
// becomes a pinch when the second lands. Without this the user is left
// holding a selection they never made, on a photograph they were only
// reaching past.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let coll = coll_ctl.clone();
window.on_library_pinch_started(move || {
let Some(w) = weak.upgrade() else { return };
crate::collections_ui::cancel_press(&w, &coll, &ctl.visible_ids());
});
}
// Explicit sync, for when the user wants the exchange now rather than
// after the next sweep.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_sync_now(move || {
if let Some(w) = weak.upgrade() {
start_derived_sync(&w, &ctl);
}
});
}
// A column-count change moves which cells begin a row, and month headings
// sit on row-leading cells.
//
// Guarded like the scroll below, and for the same reason: a grid being
// taken down reports its geometry collapsing on the way out, and reloading
// the window against that is work done for a page nobody is looking at.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_columns_changed(move || {
if let Some(w) = weak.upgrade() {
if !w.get_show_library() {
return;
}
// Deferred, and re-anchored when it lands — see
// [`schedule_reload`]. A column change moves every cell in the
// grid, because a cell is drawn at its absolute place in the
// library and the row that resolves to is `index / columns`.
// The viewport does not move with them, so without the
// re-anchor the view is left pointing at rows the loaded window
// no longer covers and the grid draws nothing at all — the
// "gallery randomly goes blank until I scroll" report, whose
// triggers are a resize, the sidebar opening, a zoom step, or
// turning the tablet over.
schedule_reload(&w, &ctl);
}
});
}
// The viewport changed size, so the window it can usefully hold changed
// with it. Reloading only on growth would leave a maximised-then-restored
// window over-fetching, so both directions are honoured.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_capacity(move |capacity| {
let Some(w) = weak.upgrade() else { return };
if !w.get_show_library() {
return;
}
let capacity = (capacity.max(0) as usize).max(MIN_WINDOW);
if capacity == *ctl.window.borrow() {
return;
}
*ctl.window.borrow_mut() = capacity;
// Coalesced with the column change that almost always accompanies
// it: resizing the cells alters both, and reloading once per report
// meant two full rebuilds per zoom step.
schedule_reload(&w, &ctl);
});
}
// Scrolling moves the loaded window through the library.
//
// The whole catalog is reachable because the Flickable's viewport is sized
// to it; this keeps the 120 loaded rows centred on wherever the view is.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_scrolled(move |first_visible| {
let Some(w) = weak.upgrade() else { return };
let first_visible = first_visible.max(0) as usize;
// **A report from a grid that is not on screen is not a scroll.**
//
// `show-library` gates an `if`, so opening an image tears the whole
// subtree down — and a Flickable being destroyed passes its viewport
// through zero on the way out, which arrives here indistinguishable
// from the user having flung the grid to the top. Everything below
// then ran on the way *into* develop: the loaded window was reset to
// offset zero, the model was rebuilt against the first rows of the
// library, and a thumbnail batch was issued for photographs nobody
// had asked to see. Those rebuilds landed while the grid was still
// being taken apart, which is what flashed the library over the
// develop view for the first few frames after a click — and on a
// remote library it also spent a burst of requests on the top of the
// catalog every single time an image was opened.
//
// The guard used to cover only `resume_at`, for a narrower version
// of the same reason. It belongs over the whole handler.
if !w.get_show_library() {
return;
}
// Remember where the view is, so leaving for the develop view and
// coming back returns here. Latched on every event rather than read
// at departure: by the time the grid is hidden its scroll position
// is only in the Flickable, which is about to be destroyed.
ctl.resume_at.set(first_visible);
// Move the timeline marker with the view. Scrolling the grid is a
// way of moving through time just as scrubbing is, and a marker
// that only ever moved on a scrub sat still while the photographs
// beside it advanced by months — the axis said "when you are" and
// was wrong the moment the user touched the wheel.
//
// This runs before the reload guard below, which fires only a few
// times per screenful; the marker has to follow every event or it
// would advance in visible jerks.
//
// Only the marker is moved, not the whole histogram: rebuilding
// the bars means a `GROUP BY strftime` aggregate over the library,
// far too much for every event of a flick. The bars do not change
// as the grid scrolls anyway — only where the marker sits on them.
//
// Re-running the scrub would be wrong for a second reason: it sets
// `scroll-to`, which would drive the grid from its own scroll.
{
let borrow = ctl.catalog.borrow();
if let Some(catalog) = borrow.as_ref() {
if let Some(when) = capture_time_at(catalog, first_visible) {
*ctl.current_bucket.borrow_mut() = Some(when);
let zoom = *ctl.timeline_zoom.borrow();
let centre = *ctl.timeline_centre.borrow();
if let Some(full) = catalog_span(catalog, &ctl) {
let (from, to) = zoomed_span(full, zoom, centre);
w.set_library_current_bucket(when as i32);
w.set_library_current_fraction(
((when - from) as f64 / (to - from).max(1) as f64).clamp(0.0, 1.0)
as f32,
);
w.set_library_timeline_anchored(true);
}
}
}
}
// Centre the window on the view, so scrolling either way has
// loaded rows ahead of it rather than only below.
let Some(offset) = window_move(
first_visible,
*ctl.offset.borrow(),
*ctl.window.borrow(),
w.get_library_total().max(0) as usize,
) else {
return;
};
*ctl.offset.borrow_mut() = offset;
load_window(&w, &ctl);
});
}
// Panning the timeline slides the visible span without changing its width.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_timeline_pan(move |fraction| {
let Some(w) = weak.upgrade() else { return };
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
let Some(full) = catalog_span(catalog, &ctl) else {
return;
};
// A fraction of the *visible* span, so dragging half the axis
// moves half a span's worth of time whatever the zoom — and a
// small movement produces a small shift rather than nothing.
let zoom = *ctl.timeline_zoom.borrow();
let (from, to) = zoomed_span(full, zoom, *ctl.timeline_centre.borrow());
let shift = ((to - from) as f64 * fraction as f64) as i64;
if shift == 0 {
return;
}
let centre = ctl.timeline_centre.borrow().unwrap_or((from + to) / 2) + shift;
*ctl.timeline_centre.borrow_mut() = Some(centre.clamp(full.0, full.1));
refresh_timeline(&w, catalog, &ctl);
});
}
// The wheel zooms the axis: a sidebar is a scale, not a list.
//
// Shares `apply_zoom` with the pinch handler so the two cannot drift apart
// in how they clamp or where they centre.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_timeline_zoom(move |delta| {
if let Some(w) = weak.upgrade() {
apply_zoom(&w, &ctl, delta);
}
});
}
// Dragging the histogram moves the grid through time.
//
// The fraction is interpolated across the visible span rather than snapped
// to a bucket edge, so a slow drag advances continuously instead of sitting
// still and then jumping a whole month.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_scrub_fraction(move |f| {
let Some(w) = weak.upgrade() else { return };
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
let Some(full) = catalog_span(catalog, &ctl) else {
return;
};
let (from, to) = zoomed_span(
full,
*ctl.timeline_zoom.borrow(),
*ctl.timeline_centre.borrow(),
);
let when = from + ((to - from) as f64 * f.clamp(0.0, 1.0) as f64) as i64;
drop(borrow);
scrub_to(&w, &ctl, when);
});
}
// Pinch, for tablet: no wheel there, so this is the only way to reach the
// axis's zoom with a finger.
//
// A continuous ratio against discrete zoom levels, so the accumulated
// ratio is held and a level is taken each time it passes a doubling. That
// keeps a slow spread from either doing nothing or leaping several levels.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_timeline_pinch(move |ratio| {
let Some(w) = weak.upgrade() else { return };
if !(0.01..=100.0).contains(&ratio) {
return;
}
let mut accum = ctl.pinch_accum.borrow_mut();
*accum *= ratio;
// Whole doublings out of the accumulated ratio, remainder carried.
//
// `log2` rather than repeated halving: one update can carry a
// large ratio — a fast spread, or a trackpad reporting coarsely —
// and stepping once per update would turn an 8× pinch into a
// single level instead of three.
let steps = accum.log2().trunc() as i32;
if steps == 0 {
return;
}
*accum /= (2.0f32).powi(steps);
drop(accum);
apply_zoom(&w, &ctl, steps);
});
}
// Grid → launch screen. The route that was missing: once past the launch
// screen there was no way back to it, so a library pointed at the wrong
// folder could not be changed without clearing stored state by hand.
{
let weak = window.as_weak();
window.on_library_change(move || {
if let Some(w) = weak.upgrade() {
w.set_show_launch(true);
}
});
}
// Develop → grid.
//
// Returns to where the user left rather than to the top. `show-library`
// gates an `if` in the markup, so the grid is rebuilt from nothing and its
// Flickable starts at row 0; the position has to be replayed explicitly.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_back_to_library(move || {
let Some(w) = weak.upgrade() else { return };
// TRACES: FR-CAT-8
// The edit is persisted on the way out rather than on every
// slider move: a save is a network round-trip, and one per drag
// frame would put an upload inside the gesture NFR-P5 governs.
// This is the moment the image stops being the open one, so it is
// the last moment its edit can be written.
on_leave_develop();
let resume = ctl.resume_at.get();
if resume > 0 {
// Through the same channel a scrub uses, and for the same
// reason: the viewport and the loaded window both have to move,
// or the cells are drawn thousands of rows from where the view
// sits.
//
// Centred like `on_library_scrolled` does it, so scrolling up
// from the restored position has loaded rows above it.
let window_size = *ctl.window.borrow();
*ctl.offset.borrow_mut() = resume.saturating_sub(window_size / 4);
load_window(&w, &ctl);
// Before the grid is shown, not after: the markup gates it on
// an `if`, and the rebuilt Flickable reads `scroll-to` in its
// `init`. Setting these afterwards would leave that init to run
// against the previous position.
w.set_library_scroll_to(resume as i32);
w.set_library_scroll_token(w.get_library_scroll_token() + 1);
}
w.set_show_library(true);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
let coll_ctl = coll_ctl.clone();
window.on_library_rescan(move || {
let Some(w) = weak.upgrade() else { return };
start_rescan(&w, &ctl, &coll_ctl);
});
}
// --- ratings and flags (FR-CAT-5, FR-CULL-4) --------------------------
// Clicking a star rates *that cell*, not the selection. The pointer names
// one photograph unambiguously, and a click that silently rated forty
// others would be a trap — the keyboard is the bulk gesture.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_cell_rated(move |row, stars| {
let Some(w) = weak.upgrade() else { return };
let id = ctl
.image_ids
.borrow()
.get(row as usize)
.map(|id| dr_types::ImageId(*id as u64));
let Some(id) = id else { return };
apply_judgement(&w, &ctl, &[id], Some(stars.clamp(0, 5) as u8), None);
});
}
// A rating or flag key. Applies to the whole selection, which is what
// makes judging a run of frames one keystroke rather than forty.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let coll_for_keys = coll_ctl.clone();
window.on_library_judged(move |rating, flag| {
let Some(w) = weak.upgrade() else { return };
let chosen = coll_for_keys.selected();
// Exactly one axis is meant per keystroke; the other arrives as
// -1 so a star press cannot disturb a flag or the reverse.
if rating >= 0 {
apply_judgement(&w, &ctl, &chosen, Some(rating.clamp(0, 5) as u8), None);
} else if flag >= 0 {
apply_judgement(&w, &ctl, &chosen, None, Some(flag_from_code(flag)));
}
});
}
// --- the filter bar ---------------------------------------------------
//
// Each of these narrows what the grid *queries*, so all three reset the
// scroll offset: the window's position was an ordinal into a different
// set of images and means nothing once the set changes.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_filter_min_rating_changed(move |n| {
let Some(w) = weak.upgrade() else { return };
ctl.filter.borrow_mut().min_rating = n.clamp(0, 5) as u8;
// Stars and "unrated" are contradictory terms — asking for four
// stars *and* nothing judged matches nothing at all, which reads
// as a broken filter rather than an impossible question.
if n > 0 {
ctl.filter.borrow_mut().unjudged = false;
}
w.set_library_filter_min_rating(n.clamp(0, 5));
w.set_library_filter_unjudged(ctl.filter.borrow().unjudged);
refilter(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_filter_unjudged_changed(move |on| {
let Some(w) = weak.upgrade() else { return };
{
let mut f = ctl.filter.borrow_mut();
f.unjudged = on;
// See above: the two cannot both hold.
if on {
f.min_rating = 0;
f.flag = None;
}
}
w.set_library_filter_unjudged(on);
if on {
w.set_library_filter_min_rating(0);
w.set_library_filter_flag(0);
}
refilter(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_filter_flag_changed(move |f| {
let Some(w) = weak.upgrade() else { return };
{
let mut filter = ctl.filter.borrow_mut();
filter.flag = match f {
1 => Some(dr_types::FlagState::Pick),
2 => Some(dr_types::FlagState::Reject),
_ => None,
};
if f > 0 {
filter.unjudged = false;
}
}
w.set_library_filter_flag(f);
w.set_library_filter_unjudged(ctl.filter.borrow().unjudged);
refilter(&w, &ctl);
});
}
// Narrow the grid to the period the histogram is showing.
//
// The range is taken from the timeline rather than typed into two date
// fields: finding the fortnight is what the histogram is *for*, and having
// found it the user should not have to read the dates off the axis and key
// them back in. Zoom, then say "only that".
//
// Toggling off clears both ends rather than remembering them — a range you
// cannot see the extent of is a filter that looks like an empty library.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_toggle_date_range(move || {
let Some(w) = weak.upgrade() else { return };
let on = !ctl.filter.borrow().has_date_range();
if on {
// The span the axis is currently drawn over, which is what the
// user is looking at when they ask for "this range".
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
let Some(span) = catalog_span(catalog, &ctl) else {
return;
};
let (from, to) = zoomed_span(
span,
*ctl.timeline_zoom.borrow(),
*ctl.timeline_centre.borrow(),
);
drop(borrow);
let mut f = ctl.filter.borrow_mut();
f.captured_from = Some(from);
f.captured_to = Some(to);
} else {
let mut f = ctl.filter.borrow_mut();
f.captured_from = None;
f.captured_to = None;
}
w.set_library_range_active(on);
refilter(&w, &ctl);
});
}
// TRACES: FR-CAT-9
// "On this device" — the images openable without a server. Composes with
// the rating terms rather than replacing them: "five-star frames I can
// actually edit on this train" is one filter, not a mode.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_toggle_local_only(move || {
let Some(w) = weak.upgrade() else { return };
let on = !ctl.local_only();
ctl.set_local_only(on);
ctl.filter.borrow_mut().local_only = on;
w.set_library_local_only(on);
refilter(&w, &ctl);
});
}
// TRACES: FR-NC-6a
// The header's way in to the offline question, for the collection the grid
// is scoped to. It opens the same prompt the sidebar's tray and the long
// press open, rather than pinning outright: three affordances that did two
// different things — one asking, two acting — is how a user comes to avoid
// all three.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_toggle_pin_scope(move || {
let Some(w) = weak.upgrade() else { return };
let Some(scope) = *ctl.scope.borrow() else {
return;
};
open_offline_prompt(&w, &ctl, scope);
});
}
// TRACES: FR-NC-6a | FR-UI-4
// The sidebar's way in: the tray on a row, tapped.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_collection_offline_menu(move |id| {
let Some(w) = weak.upgrade() else { return };
*ctl.row_hold_timer.borrow_mut() = None;
open_offline_prompt(&w, &ctl, dr_types::CollectionId(id as u64));
});
}
// TRACES: FR-NC-6a | FR-UI-2 | FR-UI-4
// And the touch way in: hold the collection's name.
//
// The release that ends the hold still reaches the row's `clicked` and
// scopes the grid to that collection. Left deliberately: the user is now
// looking at the photographs they are being asked about, which is context
// rather than a side effect — and suppressing it would mean a second
// "ignore the next click" flag threaded through the sidebar for no gain.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let coll_for_press = coll_ctl.clone();
window.on_collection_row_press(move |id, down| {
let Some(w) = weak.upgrade() else { return };
// A drag of this row, if one follows, carries this collection.
if down {
coll_for_press.note_row_press(dr_types::CollectionId(id as u64));
}
if !down {
*ctl.row_hold_timer.borrow_mut() = None;
return;
}
let timer = slint::Timer::default();
let weak = w.as_weak();
let ctl_cb = ctl.clone();
timer.start(
slint::TimerMode::SingleShot,
std::time::Duration::from_millis(crate::collections_ui::HOLD_DELAY_MS),
move || {
let Some(w) = weak.upgrade() else { return };
open_offline_prompt(&w, &ctl_cb, dr_types::CollectionId(id as u64));
},
);
*ctl.row_hold_timer.borrow_mut() = Some(timer);
});
}
// The three answers.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let coll = coll_ctl.clone();
window.on_offline_prompt_keep(move || {
let Some(w) = weak.upgrade() else { return };
keep_collection_offline(&w, &ctl, &coll);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
let coll = coll_ctl.clone();
window.on_offline_prompt_release(move || {
let Some(w) = weak.upgrade() else { return };
release_collection_offline(&w, &ctl, &coll);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_offline_prompt_dismiss(move || {
let Some(w) = weak.upgrade() else { return };
close_offline_prompt(&w, &ctl);
});
}
// TRACES: FR-CAT-9
// Retry now, rather than waiting out the backoff. A user who has just
// reconnected their wifi knows something the backoff does not.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let coll_ctl = coll_ctl.clone();
window.on_library_retry_connection(move || {
let Some(w) = weak.upgrade() else { return };
log::info!("retrying the connection at the user's request");
// A rescan is the probe: it is the same request the scan worker
// makes, so a success proves reachability and repopulates the
// catalog in one pass rather than proving it twice.
start_rescan(&w, &ctl, &coll_ctl);
});
}
}
/// Reload the grid after the filter changed.
///
/// The offset is reset because it is an ordinal into the filtered set: keeping
/// it would land the user in the middle of a narrowed library with no sense of
/// how they got there, or past its end entirely.
fn refilter(window: &AppWindow, ctl: &Rc<LibraryController>) {
*ctl.offset.borrow_mut() = 0;
ctl.requested.borrow_mut().clear();
load_window(window, ctl);
}
fn stop(slot: &RefCell<Option<slint::Timer>>) {
if let Some(t) = slot.borrow().as_ref() {
t.stop();
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The instant a scrub fraction names within a span, as the handler
/// computes it.
fn instant_at(span: (i64, i64), f: f32) -> i64 {
span.0 + ((span.1 - span.0) as f64 * f.clamp(0.0, 1.0) as f64) as i64
}
#[test]
fn a_scrub_fraction_interpolates_within_the_span() {
// The point of going fractional: a slow drag must advance
// continuously rather than sitting still until it crosses a bucket
// edge and then jumping a whole month.
let span = (0, 1_000);
assert_eq!(instant_at(span, 0.0), 0);
assert_eq!(instant_at(span, 0.5), 500);
assert_eq!(instant_at(span, 1.0), 1_000);
// Distinct fractions inside one bucket must give distinct instants.
assert_ne!(instant_at(span, 0.10), instant_at(span, 0.11));
}
/// Where the marker is drawn for an instant, as `refresh_timeline`
/// computes it.
fn marker_at(span: (i64, i64), t: i64) -> f32 {
((t - span.0) as f64 / (span.1 - span.0).max(1) as f64).clamp(0.0, 1.0) as f32
}
#[test]
fn the_marker_lands_on_the_fraction_that_was_clicked() {
// The bug this guards: the marker was placed from a *bar index* while
// the click was a fraction of the track, so it never appeared under
// the pointer. Marker and click must be inverses.
let span = (1_000, 9_000);
for f in [0.0_f32, 0.1, 0.25, 0.5, 0.75, 1.0] {
let round_trip = marker_at(span, instant_at(span, f));
assert!(
(round_trip - f).abs() < 1e-3,
"clicked {f}, marker drawn at {round_trip}"
);
}
}
#[test]
fn an_instant_outside_the_visible_span_pins_the_marker_to_an_end() {
// Zooming in leaves the grid's instant outside the axis. The marker
// belongs at the edge it went past, not off the widget.
let span = (1_000, 2_000);
assert_eq!(marker_at(span, 0), 0.0);
assert_eq!(marker_at(span, 5_000), 1.0);
}
#[test]
fn a_scrub_fraction_outside_the_axis_is_clamped() {
// A drag that leaves the widget still reports a position; it must land
// at an end rather than off the timeline.
let span = (100, 200);
assert_eq!(instant_at(span, -3.0), 100);
assert_eq!(instant_at(span, 9.9), 200);
}
/// One pinch update, as the handler applies it: fold the ratio in, take
/// out whole doublings, carry the remainder.
fn pinch_step(accum: &mut f32, ratio: f32) -> i32 {
*accum *= ratio;
let steps = accum.log2().trunc() as i32;
if steps != 0 {
*accum /= (2.0f32).powi(steps);
}
steps
}
#[test]
fn a_pinch_accumulates_until_it_reaches_a_doubling() {
// A continuous gesture against discrete levels. Small spreads must
// accumulate rather than each being rounded to a step.
let mut accum = 1.0f32;
let mut steps = 0;
for _ in 0..12 {
steps += pinch_step(&mut accum, 1.1);
}
// 1.1^12 is 3.138x, which is log2 = 1.65 — one whole doubling, with
// the rest carried rather than discarded or rounded up.
assert_eq!(steps, 1);
assert!((accum - 1.569).abs() < 0.01, "remainder carried: {accum}");
}
#[test]
fn one_large_pinch_yields_every_level_it_crossed() {
// The bug this replaced: stepping at most once per update turned an
// 8x spread — three doublings — into a single zoom level, so a fast
// gesture lost most of its travel.
let mut accum = 1.0f32;
assert_eq!(pinch_step(&mut accum, 8.0), 3);
assert!((accum - 1.0).abs() < 0.001);
let mut accum = 1.0f32;
assert_eq!(pinch_step(&mut accum, 0.125), -3);
}
#[test]
fn pinching_in_and_back_out_returns_to_where_it_started() {
let mut accum = 1.0f32;
let steps: i32 = [2.0f32, 2.0, 0.5, 0.5]
.iter()
.map(|r| pinch_step(&mut accum, *r))
.sum();
assert_eq!(steps, 0);
assert!((accum - 1.0).abs() < 0.001, "no drift: {accum}");
}
#[test]
fn a_pinch_below_a_doubling_takes_no_step() {
// Otherwise the axis would flicker between levels on the smallest
// finger movement.
let mut accum = 1.0f32;
assert_eq!(pinch_step(&mut accum, 1.4), 0);
assert_eq!(pinch_step(&mut accum, 0.72), 0, "back roughly to 1.0");
}
/// One zoom step, as the handler applies it.
fn zoom_cell(current: f32, delta: i32) -> f32 {
let next = if delta > 0 {
current * 1.25
} else {
current / 1.25
};
next.clamp(MIN_CELL_SIZE, MAX_CELL_SIZE)
}
#[test]
fn cell_zoom_steps_geometrically_and_reverses() {
// Geometric so the gesture feels the same at either end; a fixed pixel
// step is imperceptible at 400px and violent at 90px.
let a = zoom_cell(180.0, 1);
assert!((a - 225.0).abs() < 0.01);
assert!(
(zoom_cell(a, -1) - 180.0).abs() < 0.01,
"in then out returns"
);
}
#[test]
fn cell_zoom_stays_within_its_bounds() {
let mut size = 180.0;
for _ in 0..40 {
size = zoom_cell(size, 1);
}
assert_eq!(size, MAX_CELL_SIZE);
for _ in 0..40 {
size = zoom_cell(size, -1);
}
assert_eq!(size, MIN_CELL_SIZE);
}
#[test]
fn zooming_past_the_grid_class_asks_for_the_large_one() {
use dr_thumbs::ThumbSize;
// The point of the second class: past 256px a grid thumbnail is being
// upscaled, and the softness shows.
assert_eq!(ThumbSize::for_cell(180), ThumbSize::Grid);
assert_eq!(
ThumbSize::for_cell(zoom_cell(225.0, 1) as u32),
ThumbSize::Large
);
// And zooming back down does not keep paying for it.
assert_eq!(
ThumbSize::for_cell(zoom_cell(281.0, -1) as u32),
ThumbSize::Grid
);
}
#[test]
fn a_request_key_survives_the_window_moving() {
use dr_thumbs::ThumbSize;
use std::collections::HashSet;
// Keyed on the photograph, not its position. The grid is a window over
// the catalog, so row 7 is a different image after every scroll — a
// set of row indices had to be cleared on each move, and every visible
// cell then looked unrequested and was re-issued.
let mut requested: HashSet<(i64, ThumbSize)> = HashSet::new();
// A screenful at rows 0..3, holding images 100..103.
for id in 100..103 {
assert!(requested.insert((id, ThumbSize::Grid)), "first sight");
}
// Scrolled: the same photographs now occupy different rows.
for id in 100..103 {
assert!(
!requested.insert((id, ThumbSize::Grid)),
"image {id} must not be requested twice"
);
}
// A genuinely new photograph still is.
assert!(requested.insert((200, ThumbSize::Grid)));
}
#[test]
fn the_two_size_classes_are_requested_independently() {
use dr_thumbs::ThumbSize;
use std::collections::HashSet;
// Holding the 256px version says nothing about the large one, so
// zooming past the boundary must still ask.
let mut requested: HashSet<(i64, ThumbSize)> = HashSet::new();
assert!(requested.insert((1, ThumbSize::Grid)));
assert!(
requested.insert((1, ThumbSize::Large)),
"the large class is a separate request"
);
assert!(!requested.insert((1, ThumbSize::Grid)));
}
#[test]
fn zoom_zero_is_the_whole_library() {
let full = (1_000, 2_000);
assert_eq!(zoomed_span(full, 0, None), full);
// A remembered centre must not narrow the span at zoom 0.
assert_eq!(zoomed_span(full, 0, Some(1_500)), full);
}
#[test]
fn each_zoom_step_halves_the_span() {
let day = 86_400;
let full = (0, 64 * day);
let (a, b) = zoomed_span(full, 1, Some(32 * day));
assert_eq!(b - a, 32 * day);
let (a, b) = zoomed_span(full, 2, Some(32 * day));
assert_eq!(b - a, 16 * day);
}
#[test]
fn zooming_centres_on_the_given_instant() {
let day = 86_400;
let full = (0, 100 * day);
let (a, b) = zoomed_span(full, 1, Some(60 * day));
assert_eq!((a + b) / 2, 60 * day, "centred where asked");
}
#[test]
fn a_window_at_the_edge_slides_rather_than_shrinking() {
// Clamping both ends would silently halve the span near the start of
// the library, so the axis would show less detail there than in the
// middle for the same zoom level.
let day = 86_400;
let full = (0, 100 * day);
let (a, b) = zoomed_span(full, 1, Some(0));
assert_eq!(a, 0, "cannot start before the library does");
assert_eq!(b - a, 50 * day, "keeps its width");
let (a, b) = zoomed_span(full, 1, Some(100 * day));
assert_eq!(b, 100 * day);
assert_eq!(b - a, 50 * day);
}
#[test]
fn a_deep_zoom_does_not_collapse_to_nothing() {
// A zero-width span would make every bucket empty, which reads as a
// broken axis rather than a deep zoom.
let full = (0, 86_400);
let (a, b) = zoomed_span(full, 12, Some(43_200));
assert!(b > a, "span stays positive");
}
#[test]
fn a_single_instant_library_does_not_divide_by_zero() {
let full = (1_700_000_000, 1_700_000_000);
let (a, b) = zoomed_span(full, 5, None);
assert!(a <= b);
}
#[test]
fn month_names_cover_the_year_and_clamp() {
assert_eq!(month_name(1), "January");
assert_eq!(month_name(12), "December");
assert_eq!(month_abbrev(3), "Mar");
// A corrupt month must not panic the grid.
assert_eq!(month_name(0), "January");
assert_eq!(month_name(99), "December");
}
#[test]
fn civil_dates_round_trip_at_boundaries() {
// Era-based date maths is silently wrong at year and leap boundaries
// if the algorithm is transcribed slightly off, and the symptom is an
// image landing in the wrong histogram bucket.
assert_eq!(civil_from_unix(0), (1970, 1, 1, 0));
assert_eq!(civil_from_unix(1_786_285_800), (2026, 8, 9, 14));
// Leap day.
assert_eq!(civil_from_unix(1_709_164_800), (2024, 2, 29, 0));
// Last second of a year, and the first of the next.
assert_eq!(civil_from_unix(1_735_689_599), (2024, 12, 31, 23));
assert_eq!(civil_from_unix(1_735_689_600), (2025, 1, 1, 0));
}
#[test]
fn dates_before_the_epoch_do_not_wrap() {
// Scanned film carries capture dates well before 1970; a negative
// timestamp must floor rather than truncate toward zero.
let (y, _, _, _) = civil_from_unix(-1);
assert_eq!(y, 1969);
}
#[test]
fn bucket_labels_match_their_granularity() {
use dr_catalog::Granularity;
let t = 1_786_285_800; // 2026-08-09 14:30 UTC
assert_eq!(format_bucket(t, Granularity::Year), "2026");
assert_eq!(format_bucket(t, Granularity::Month), "2026-08");
assert_eq!(format_bucket(t, Granularity::Day), "2026-08-09");
assert_eq!(format_bucket(t, Granularity::Hour), "2026-08-09 14:00");
}
#[test]
fn rgba_shorter_than_declared_does_not_panic() {
// A truncated decode must degrade to a partial image, not abort the
// grid. Decoders handle untrusted input (NFR-SEC-1).
let img = to_slint_image(4, 4, &[0u8; 8]);
assert_eq!(img.size().width, 4);
}
#[test]
fn rgba_longer_than_declared_is_truncated() {
let img = to_slint_image(2, 2, &[255u8; 1024]);
assert_eq!(img.size().width, 2);
assert_eq!(img.size().height, 2);
}
// --- moving the loaded window ------------------------------------------
//
// 360 images loaded out of 24,000, centred on the view a quarter back.
const W: usize = 360;
const TOTAL: usize = 24_000;
#[test]
fn a_view_well_inside_the_loaded_window_does_not_move_it() {
// The whole point of loading a screenful either side: scrolling within
// it must not touch the catalog.
assert_eq!(window_move(5_000, 4_910, W, TOTAL), None);
assert_eq!(window_move(5_100, 4_910, W, TOTAL), None);
}
#[test]
fn a_view_reaching_the_edge_of_the_loaded_window_moves_it() {
// Close enough to the bottom of what is loaded that scrolling on would
// run into rows nobody has read.
let moved = window_move(5_200, 4_910, W, TOTAL).expect("the window follows the view");
assert_eq!(moved, 5_200 - W / 4, "centred a quarter behind the view");
}
#[test]
fn the_top_of_the_library_is_not_reloaded_on_every_row() {
// `first_visible` cannot be centred further back than zero, so the
// margin test can never be satisfied here. Before this rule every one
// of these re-read the catalog to arrive at the offset it already had,
// which is the stutter at the top of every scope.
for first_visible in [0, 6, 30, 89] {
assert_eq!(
window_move(first_visible, 0, W, TOTAL),
None,
"row {first_visible} asked for a move to offset 0, which is where it is"
);
}
}
#[test]
fn the_end_of_the_library_is_not_reloaded_on_every_row() {
// The mirror of the above, and the worse of the two: the window is
// clamped to `max_offset` while the view keeps travelling past it.
let pinned = TOTAL - W;
for first_visible in [TOTAL - W / 2, TOTAL - 30, TOTAL - 1] {
assert_eq!(
window_move(first_visible, pinned, W, TOTAL),
None,
"row {first_visible} asked for a move to the offset it already had"
);
}
}
#[test]
fn the_window_never_starts_past_the_last_full_screenful() {
// Otherwise a scrub to the very end loads a handful of cells and the
// rest of the window addresses images that do not exist.
let moved = window_move(TOTAL - 1, 0, W, TOTAL).expect("a scrub to the end moves");
assert_eq!(moved, TOTAL - W);
}
#[test]
fn a_library_smaller_than_the_window_stays_at_the_beginning() {
// `max_offset` is zero, so there is one valid position and the view
// must never ask for another.
assert_eq!(window_move(0, 0, W, 40), None);
assert_eq!(window_move(39, 0, W, 40), None);
}
// --- carrying thumbnails across a reload -------------------------------
//
// The black flash: `load_window` rebuilds every row, and until these it
// rebuilt them empty — so the three quarters of the grid the user was
// looking at went to `Theme.ground` and back on every scroll.
/// A model of cells, `has_thumb` set for the ids named.
fn model_of(ids: &[i64], with_pixels: &[i64]) -> slint::ModelRc<LibraryCell> {
let rows: Vec<LibraryCell> = ids
.iter()
.map(|id| LibraryCell {
has_thumb: with_pixels.contains(id),
thumbnail: if with_pixels.contains(id) {
to_slint_image(2, 2, &[255u8; 16])
} else {
slint::Image::default()
},
..Default::default()
})
.collect();
slint::ModelRc::new(slint::VecModel::from(rows))
}
#[test]
fn a_reload_keeps_the_pixels_of_photographs_that_are_still_in_the_window() {
let ids = [10i64, 11, 12];
let previous = model_of(&ids, &[10, 12]);
let classes = vec![Some(dr_thumbs::ThumbSize::Grid); 3];
let held = hold_thumbnails(&previous, &ids, &classes);
assert!(held.contains_key(&10), "a drawn cell is carried");
assert!(held.contains_key(&12));
assert!(
!held.contains_key(&11),
"a cell still waiting has nothing to carry"
);
}
#[test]
fn a_no_preview_verdict_is_carried_too() {
// Otherwise every scroll re-asks a question the server has already
// answered, and the cell blinks from "no preview" back to "…".
let ids = [7i64];
let rows = vec![LibraryCell {
unavailable: true,
..Default::default()
}];
let previous = slint::ModelRc::new(slint::VecModel::from(rows));
let held = hold_thumbnails(&previous, &ids, &[Some(dr_thumbs::ThumbSize::Grid)]);
assert!(held[&7].unavailable);
assert!(!held[&7].has_thumb);
}
#[test]
fn a_carried_cell_is_not_fetched_again_but_a_newly_scrolled_in_one_is() {
// The scroll this describes: the window moved down by one image, so
// 11 and 12 are still on screen and 13 has just arrived.
let ids = [10i64, 11, 12];
let previous = model_of(&ids, &[10, 11, 12]);
let held = hold_thumbnails(&previous, &ids, &[Some(dr_thumbs::ThumbSize::Grid); 3]);
let served = already_served(&held, [11i64, 12, 13].into_iter());
assert!(served.contains(&(11, dr_thumbs::ThumbSize::Grid)));
assert!(served.contains(&(12, dr_thumbs::ThumbSize::Grid)));
assert!(
!served.contains(&(13, dr_thumbs::ThumbSize::Grid)),
"a photograph the window has just reached must still be fetched"
);
}
#[test]
fn a_photograph_scrolled_out_of_the_window_is_fetched_again_on_return() {
// The trap in keeping the set rather than rebuilding it: image 10 was
// served once, but the model that held its pixels is long gone, so the
// cell would sit blank for ever if it still counted as served.
let held = hold_thumbnails(
&model_of(&[10], &[10]),
&[10],
&[Some(dr_thumbs::ThumbSize::Grid)],
);
let served = already_served(&held, [40i64, 41].into_iter());
assert!(served.is_empty(), "nothing in this window is already drawn");
}
#[test]
fn a_carried_thumbnail_does_not_satisfy_a_zoom_past_its_class() {
// Zooming past 256px reloads the window. The cell keeps showing the
// small thumbnail — no flash — but the large one must still be asked
// for, or the grid would stay soft until something else forced a fetch.
let held = hold_thumbnails(
&model_of(&[5], &[5]),
&[5],
&[Some(dr_thumbs::ThumbSize::Grid)],
);
let mut served = already_served(&held, [5i64].into_iter());
assert!(
served.insert((5, dr_thumbs::ThumbSize::Large)),
"the large class is still unserved"
);
assert!(
!served.insert((5, dr_thumbs::ThumbSize::Grid)),
"and the small one is not asked for twice"
);
}
#[test]
fn a_cell_whose_class_was_never_recorded_is_fetched_again() {
// Pixels with no class are pixels from before this bookkeeping existed
// — or from a row the drain never reached. Showing them is right;
// claiming they were served is not, because nothing knows at what size.
let held = hold_thumbnails(&model_of(&[5], &[5]), &[5], &[None]);
assert!(held.contains_key(&5), "still drawn");
assert!(already_served(&held, [5i64].into_iter()).is_empty());
}
/// A thumbnail drain must be able to tell that the window it was started
/// for has been replaced.
///
/// This is the whole of the black-grid fix, reduced to the comparison the
/// timer callback makes. `row` is an index into the window that requested
/// the fetch, so a drain that keeps writing after a reload paints
/// thumbnails onto unrelated photographs — and, worse, its `stop` lands on
/// the *current* batch's timer and leaves the new fetches undrained.
#[test]
fn a_reload_makes_an_in_flight_thumbnail_batch_stale() {
let ctl = LibraryController::new(crate::activity::ActivityLog::new());
// What `drain_thumbnails` captures when the batch is spawned.
let mine = ctl.generation.get();
assert_eq!(ctl.generation.get(), mine, "its own batch is live");
// What `load_window` does just before swapping the model.
ctl.generation.set(ctl.generation.get().wrapping_add(1));
assert_ne!(
ctl.generation.get(),
mine,
"the batch must recognise itself as stale once the model is replaced"
);
}
/// Each load is distinct, so two reloads cannot alias back to a live batch.
#[test]
fn every_window_load_takes_a_fresh_generation() {
let ctl = LibraryController::new(crate::activity::ActivityLog::new());
let seen: Vec<u64> = (0..4)
.map(|_| {
let g = ctl.generation.get();
ctl.generation.set(g.wrapping_add(1));
g
})
.collect();
assert_eq!(seen, vec![0, 1, 2, 3]);
}
/// 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"]);
}
}