Files
DarkRoom/ui/dr-ui/src/library_ui.rs
T
dtourolleandClaude Opus 5 cd64166b15
Build and test / Desktop (Linux) (push) Failing after 2m21s
Build and test / Layer separation (push) Successful in 27s
Traceability / Requirement traces (push) Successful in 27s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 3s
Build and test / Android (aarch64) (push) Failing after 4s
Keep the view on the photographs after some of them are deleted
Deleting made the grid go blank and jump somewhere arbitrary. Two causes, both
of them the viewport being left behind by everything else that moved.

The Flickable is sized to the *whole* library so its scrollbar is a real
address into twenty thousand images. Delete some and that content gets shorter,
which leaves a view near the end scrolled past what now exists — cells sitting
above a viewport looking at empty space. Slint does not pull a Flickable back
on its own. (The clamp for that went in with the previous commit.)

The jump is the other half. Cells are drawn at their absolute place in the
library, `(i + offset) / columns`, and a delete re-clamps `offset` downward so
the loaded window still fills. Nothing touches `viewport-y`, so the same scroll
position now addresses different photographs and the grid appears to leap
somewhere unrelated.

`restore_position` re-anchors on the ordinal the view was showing, clamped into
what is left. Not on the deleted image's own position, which no longer exists,
and not on the top of the library, which would throw the scroll position away
on every delete — after removing one frame from a wall of twenty thousand, the
one you want next is the one that just moved into its place.

Only on a shrink, and the shrink is detected by reading `library-total` before
overwriting it. Re-anchoring on every load would fight a scrub, which sets
exactly this property to go where the user asked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 19:03:15 +02:00

6046 lines
250 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, KeywordRow, 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>>,
/// TRACES: FR-CAT-3
/// Drains the whole-library thumbnail pass. Held for the same reason as
/// the others, and read as well as written: it is what stops a second
/// press of the button starting a pass alongside the first.
thumb_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>,
/// TRACES: FR-CAT-6
/// How many bars the capture-time axis is cut into, from the settings
/// page.
///
/// Held here beside the cache budget and for the same reason: the axis is
/// redrawn from a dozen callbacks that have no config access, and reading
/// a JSON file on every scroll to answer "how many bars" would be the
/// wrong shape of question. A `Cell` because the page can change it while
/// a library is open.
timeline_bars: std::cell::Cell<u32>,
/// 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),
thumb_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,
),
timeline_bars: std::cell::Cell::new(dr_types::LibrarySettings::default().timeline_bars),
})
}
/// TRACES: FR-CAT-6
/// How many bars the capture-time axis is cut into.
///
/// Answers whether it changed, so the caller can leave the grid alone when
/// it did not: this is set on every settings edit, and reloading the
/// window because a user changed their export folder would be a visible
/// stutter for nothing.
pub fn set_timeline_bars(&self, bars: u32) -> bool {
let bars = bars.max(1);
self.timeline_bars.replace(bars) != bars
}
/// TRACES: FR-NC-6a
/// Whether a develop open should keep the original it downloads.
///
/// Turning it off leaves what is already cached alone: those bytes are
/// paid for, and deleting them would make the switch destructive when it
/// only means "stop adding to this".
pub fn set_keep_opened_originals(&self, keep: bool) {
self.keep_opened.set(keep);
}
/// TRACES: FR-NC-6a
/// 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_open(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)
};
// Read before it is overwritten: the property still holds what the library
// was last time this ran, and a library that has become shorter is the
// signal that something was deleted out from under the view.
let was = window.get_library_total().max(0) as usize;
window.set_library_total(total as i32);
let shrank = total < was;
// 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: without_extension(&c.name).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);
// The library got shorter while the view was looking at it — a delete.
//
// Two things have already moved by this point and neither touches the
// viewport: the scrollable height shrank, and `offset` was re-clamped so
// the loaded window still fills. So the view is left pointing either past
// the end of the content (a blank grid) or at a different part of the
// library (an apparent jump to nowhere). Re-anchoring here puts it back on
// the photographs the user was actually looking at.
//
// Only on a shrink. Doing it on every load would fight a scrub, which sets
// exactly this property to go somewhere the user asked for.
if shrank {
restore_position(window, ctl);
}
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);
}
// --- keywords (FR-CAT-5, FR-CAT-6) ---------------------------------------
//
// `dr_catalog::keywords` owns the data rules — the vocabulary, the many-to-many
// join, what a rename does to the assignments. This part owns the *interaction*:
// which photographs the sheet is acting on, and keeping what it draws honest
// about what actually landed.
/// Redraw the keywording sheet against whatever is selected now.
///
/// Called when the sheet opens and after every assignment, rather than on every
/// selection change: the selection moves on each arrow key and the sheet is shut
/// for almost all of them, so computing coverage over a forty-image selection
/// on each one would be work nobody is looking at.
///
/// Re-read from the catalog rather than patched in place after a write. A word
/// applied to a selection that partly already had it moves from "3 of 12" to
/// "12 of 12", and a model updated by hand would have to reproduce the rule
/// that decides that — which is exactly the rule the catalog has just applied.
fn refresh_keywords(window: &AppWindow, ctl: &Rc<LibraryController>, images: &[dr_types::ImageId]) {
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
let rows = match dr_catalog::keywords::for_images(catalog.connection(), images) {
Ok(rows) => rows,
Err(e) => {
// The grid is entirely usable without the sheet, so this is logged
// rather than surfaced: a keyword read that failed must not put an
// error banner over a library the user is browsing.
log::debug!("reading keywords: {e}");
return;
}
};
let model: Vec<KeywordRow> = rows
.into_iter()
.map(|row| KeywordRow {
id: row.keyword.id.0 as i32,
name: row.keyword.name.into(),
coverage: match row.coverage {
dr_catalog::Coverage::None => 0,
dr_catalog::Coverage::Some => 1,
dr_catalog::Coverage::All => 2,
},
selected_count: row.selected_count as i32,
image_count: row.keyword.image_count as i32,
})
.collect();
window.set_library_keywords(slint::ModelRc::new(slint::VecModel::from(model)));
}
/// Put a keyword on the selection, or take it off.
///
/// # Why this does not write a sidecar
///
/// Every other judgement in this file — a star, a flag — is written to the
/// catalog and then queued to the image's sidecar, because the sidecar is what
/// makes it survive a catalog rebuild (ARCH §6.12). A keyword has no place in
/// the sidecar format yet: `dr_pipeline::sidecar::Version` carries `rating` and
/// `flag` and nothing else that is not an edit-graph parameter.
///
/// So a keyword is, for now, catalog state that reaches the user's other
/// devices through the *catalog* merge ([`dr_catalog::merge`]) rather than
/// through the sidecar. That is a real limitation and not a silent one: a
/// deleted catalog loses keywords where it would keep ratings, until the
/// sidecar gains a `dc:subject` field (FR-CAT-13) and this grows the same
/// queued write the stars have.
fn apply_keyword(window: &AppWindow, ctl: &Rc<LibraryController>, word: &str, assigning: bool) {
let Some(coll) = ctl.coll_ctl.borrow().as_ref().and_then(|c| c.upgrade()) else {
return;
};
let images = coll.selected();
// The word as it will be *stored*, resolved before anything is written.
// The status line below quotes it back, and quoting what was typed would
// report a leading space the catalog is about to drop — leaving the user to
// wonder whether it mattered.
//
// This is also where a blank keyword is caught, which is why it happens
// before the selection check: "you typed nothing" is a better answer than
// "select an image first" to someone who pressed return on an empty field.
let word = match dr_catalog::keywords::normalise(word) {
Ok(word) => word,
Err(e) => {
// `BadName` carries text written to be read by the user rather than
// by a developer, so it is shown as it is.
window.set_library_error(format!("{e}").into());
return;
}
};
// Assigning with nothing selected still means something — it puts the word
// in the vocabulary, ready for the photographs it was typed for — so only
// the removal half needs a selection to act on.
if images.is_empty() && !assigning {
window.set_library_status("Select an image first".into());
return;
}
let outcome = {
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
let conn = catalog.connection();
if assigning {
dr_catalog::keywords::assign(conn, &images, &word)
} else {
dr_catalog::keywords::unassign(conn, &images, &word)
}
};
let n = match outcome {
Ok(n) => n,
Err(e) => {
window.set_library_error(format!("{e}").into());
return;
}
};
window.set_library_error(slint::SharedString::new());
window.set_library_status(keyword_summary(&word, n, images.len(), assigning).into());
refresh_keywords(window, ctl, &images);
// A filtered grid may no longer hold what was just keyworded — taking
// "puffin" off an image while showing only puffins means it belongs
// elsewhere now. The same reasoning as a rating that falls below the star
// filter.
if !ctl.filter.borrow().is_unfiltered() {
load_window(window, ctl);
}
}
/// What the status line says about a keyword that just landed.
///
/// The honest count, not the requested one: "added to 3 of 12" is what
/// happened when nine of them already carried the word, and a message that
/// claimed twelve would be teaching the user that the counts are decorative.
fn keyword_summary(word: &str, changed: usize, selected: usize, assigning: bool) -> String {
if selected == 0 {
return format!("Added “{word}” to the keyword list");
}
let verb = if assigning { "Added" } else { "Removed" };
let preposition = if assigning { "to" } else { "from" };
if changed == 0 {
return if assigning {
format!("Every selected photograph already had “{word}”")
} else {
format!("None of the selected photographs had “{word}”")
};
}
if changed == selected {
let what = if selected == 1 {
"1 photograph".to_string()
} else {
format!("{selected} photographs")
};
return format!("{verb} “{word}” {preposition} {what}");
}
format!("{verb} “{word}” {preposition} {changed} of {selected}")
}
/// 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,
// A paste carries no masks, and must not: a mask is drawn
// against one photograph and describes nothing on another.
// The target keeps whatever local adjustments it already had.
masks: None,
},
})
});
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
|| report.members_gained > 0
{
load_window(&w, &ctl_cb);
}
// TRACES: FR-CAT-7
// And the sidebar, which the grid reload does not
// touch. Membership counts as a change: a sync that
// files 127 photographs into a collection both devices
// already had gains no *collection*, so keying this on
// `collections_gained` alone left the tree showing no
// number beside a collection that had just been filled.
//
// The scan already does this after it finishes; the
// sync merges the same tables and did not.
if report.collections_gained > 0 || report.members_gained > 0 {
let coll = ctl_cb.coll_ctl.borrow().as_ref().and_then(|w| w.upgrade());
if let Some(coll) = coll {
let borrow = ctl_cb.catalog();
let borrow = borrow.borrow();
if let Some(cat) = borrow.as_ref() {
crate::collections_ui::refresh_tree(&w, &coll, cat);
}
}
}
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);
}
/// TRACES: FR-CAT-3 | FR-NC-3 | FR-NC-7
/// Thumbnail every photograph in the library, then hand the shards over.
///
/// # Why it is asked for rather than assumed
///
/// The grid's own fetching is demand-driven on purpose: a remote library is
/// browsed over a connection that must not be saturated to show one screen
/// (FR-NC-3). But that leaves the thumbnail store holding only what has been
/// looked at, and the store is the one derived thing worth syncing — a second
/// device that downloads the shards gets a full grid without touching a RAW.
/// So the complete set is worth an hour of transfers *once*, on a machine
/// plugged in, at a moment the user chose. That is this.
///
/// The sync at the end is not a separate courtesy: a filled store that never
/// leaves this device is most of the cost for none of the point.
fn start_thumbnail_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
let Some((creds, session, _)) = ctl.session.borrow().clone() else {
return;
};
// Already running. A second pass would re-fetch everything the first is
// part way through — the work list is built from what the store lacks, and
// the first pass has not filled it yet.
if ctl.thumb_sweep_timer.borrow().is_some() && window.get_library_thumbnailing() {
return;
}
// TRACES: FR-CAT-9
// Every image here is a range fetch. Offline the pass would spend a
// timeout per photograph and store nothing, so it is refused rather than
// started — and said so, because this one was pressed deliberately and a
// button that silently does nothing is worse than one that declines.
if ctl.is_offline() {
log::debug!("offline: not starting the thumbnail pass");
window.set_library_status("Offline — thumbnailing needs the server.".into());
return;
}
window.set_library_thumbnailing(true);
let rx = library::spawn_thumbnail_sweep(
creds,
session.user_id.clone(),
library::catalog_path(&session.server, &session.user_id),
library::thumbs_dir(&session.server, &session.user_id),
);
let timer = slint::Timer::default();
let weak = window.as_weak();
let ctl_cb = ctl.clone();
let job = ctl.activity.begin(
crate::activity::Kind::Thumbnails,
"Thumbnailing the library",
);
// The total arrives in the first message rather than up front — counting
// it means asking the store about every image — and the progress messages
// after it carry only cumulative counts, so it is kept here rather than
// re-derived.
let total = std::cell::Cell::new(0usize);
timer.start(
slint::TimerMode::Repeated,
// As slow as the metadata sweep's drain, and for the same reason: this
// runs for tens of minutes and a chunk lands every few seconds.
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_thumbnailing(false);
job.fail("stopped without finishing");
stop(&ctl_cb.thumb_sweep_timer);
return;
}
};
match msg {
library::ThumbSweepMessage::Total(n) => {
total.set(n);
job.total(n);
w.set_library_status(format!("Thumbnailing {n} photograph(s)…").into());
}
library::ThumbSweepMessage::Progress { done, stored } => {
job.progress(done, total.get());
// Dates arrive on the same headers, so the histogram
// grows as this runs — the visible sign it is working
// on a page that is not the grid.
if stored > 0 {
let borrow = ctl_cb.catalog.borrow();
if let Some(catalog) = borrow.as_ref() {
refresh_timeline(&w, catalog, &ctl_cb);
}
}
}
library::ThumbSweepMessage::Finished {
stored,
failed,
offline,
} => {
log::info!(
"thumbnail pass finished: {stored} stored, {failed} without a preview"
);
w.set_library_thumbnailing(false);
stop(&ctl_cb.thumb_sweep_timer);
let summary = if offline {
format!("{stored} stored · stopped, server unreachable")
} else if failed > 0 {
format!("{stored} stored · {failed} with no usable preview")
} else {
format!("{stored} stored")
};
w.set_library_status(summary.clone().into());
// Finished rather than failed even when the server
// dropped: the pass is resumable and what it did build
// is stored, and a red row would invite the user to
// redo work that is already done.
job.finish(summary);
{
let borrow = ctl_cb.catalog.borrow();
if let Some(catalog) = borrow.as_ref() {
refresh_timeline(&w, catalog, &ctl_cb);
}
}
// Cells that showed a placeholder have pixels now, and
// the grid asks the store once per photograph — so
// without this the library the pass just thumbnailed
// stays blank until something else reloads the window.
if w.get_show_library() {
ctl_cb.requested.borrow_mut().clear();
schedule_reload(&w, &ctl_cb);
}
// The point of the pass: the shards go to the server so
// every other device inherits them. Skipped when the
// connection is already gone — the sync would only
// discover the same thing more slowly.
if !offline {
start_derived_sync(&w, &ctl_cb);
}
return;
}
}
}
},
);
*ctl.thumb_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();
// Passed whole, date range included, and both queries below lift that
// range themselves — see `span_scoped` and `timeline_uniform`. Every other
// term still applies, because a histogram of the five-star frames is a
// fair question; the range does not, because the axis is *how a range is
// chosen* and drawing through it would empty every bin outside the band
// and leave nothing to widen back into.
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;
}
};
// A range is drawn *over* this span as a band, not substituted for it —
// see the band below. The scale a narrow range deserves is reached by
// zooming the axis instead, which is a gesture a finger has (pinch) and
// does not disturb the range while it is being adjusted.
//
// 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());
// A fixed number of equal bins, re-cut on every zoom (FR-CAT-6). The
// count is the user's — see `LibrarySettings::timeline_bars` for why the
// calendar units it replaced made zooming in draw a coarser picture.
let bins = ctl.timeline_bars.get().max(1);
// Calendar units survive as a *label* vocabulary: what a bin is called
// depends on how long it is, not on how the counting was done.
let granularity = dr_catalog::Granularity::for_bucket((to - from).max(1) / bins as i64);
let buckets = match library::timeline_uniform(catalog, scope, &filter, from, to, bins) {
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.
//
// A bin holding nothing draws nothing. The floor exists so a
// quiet day is not rounded away, and empty bins are common now
// that every one of them is emitted — a sliver on each would
// draw a library that has photographs in months it does not.
height: if b.count == 0 {
0.0
} else {
((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| fraction_at((from, to), t)).unwrap_or(-1.0);
// The chosen range in those same coordinates, for the band. Both ends or
// neither: half a band is a line the user cannot tell from a handle.
let (band_from, band_to) = match (filter.captured_from, filter.captured_to) {
(Some(a), Some(b)) => (fraction_at((from, to), a), fraction_at((from, to), b)),
_ => (-1.0, -1.0),
};
// Which bar to light: the *bin* holding the grid's instant, not the
// instant itself. A bar's start is its bin's left edge — one of a fixed
// set of positions — so comparing it against an arbitrary capture time
// would light nothing, where before every bar started on a photograph.
//
// Taken from the bars themselves rather than recomputed, because the edges
// are integer division and a second spelling of `from + i * span / bins`
// is a second chance to be a second out.
let lit = current
.and_then(|t| {
let i = bin_of((from, to), t, bins);
buckets.get(i).map(|b| b.start)
})
.unwrap_or(0);
window.set_library_current_bucket(lit as i32);
window.set_library_current_fraction(fraction);
window.set_library_range_from_fraction(band_from);
window.set_library_range_to_fraction(band_to);
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.
/// Month-sized bins are excluded too, now that the axis has a fixed number of
/// them: a bin of about a month changes month at nearly every bar, so the rule
/// below would write a label on all sixty-four of them and the right-hand
/// strip would be a column of overlapping text. Below a month the labels are
/// occasional again, which is what makes them readable.
fn granularity_labels_months(g: dr_catalog::Granularity) -> bool {
matches!(
g,
dr_catalog::Granularity::Day | dr_catalog::Granularity::Hour
)
}
/// 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]
}
/// The instant a fraction along a span names.
///
/// One home for `from + (to - from) * f`, because three things now depend on
/// it agreeing with itself: a scrub, the marker drawn where that scrub landed,
/// and the ends of the date range dragged along the same axis. A second copy
/// is a second chance for a click and what it produces to disagree.
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
}
/// Where an instant sits along a span, 0..1. The exact inverse of
/// [`instant_at`], clamped so an instant off the end of the axis is drawn at
/// the end it went past rather than off the widget.
fn fraction_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
}
/// Which of `bins` equal bins across `span` holds `t`.
///
/// The same arithmetic `library::timeline_uniform` groups by, in the same
/// order — integer division on the offset from the start — so the bar this
/// names is the bar that counted the photograph. Clamped, because the grid's
/// instant can sit outside a zoomed axis.
fn bin_of(span: (i64, i64), t: i64, bins: u32) -> usize {
let width = (span.1 - span.0).max(1);
let i = (t - span.0).clamp(0, width) * bins as i64 / width;
i.clamp(0, bins as i64 - 1) as usize
}
/// Midnight UTC at or before `t`.
///
/// `rem_euclid` rather than `%`: a negative timestamp — a scanned archive
/// holds photographs from before 1970 — would otherwise truncate *towards*
/// zero and name the following midnight, putting the day off by one.
fn day_start(t: i64) -> i64 {
t - t.rem_euclid(86_400)
}
/// The range a drag of the band's two ends names, as it is stored.
///
/// Whole days at both ends, which does three things. It is what the typed
/// fields mean, so a dragged range reads back out of them as the two days it
/// covers rather than as two instants nobody chose. It gives the band a floor:
/// two handles dragged onto each other name one day, not an empty grid. And it
/// makes the gesture reproducible — the same day is the same range however
/// close the finger came to a bucket edge.
///
/// Ends dragged past each other are put back in order rather than refused: the
/// user has described the same two edges either way round.
fn dragged_range(span: (i64, i64), from: f32, to: f32) -> (i64, i64) {
let (from, to) = if from <= to { (from, to) } else { (to, from) };
let start = day_start(instant_at(span, from));
// Inclusive of the closing day. `parse_date` gives midnight, and a range
// ending there would exclude every photograph taken on the day named.
let end = day_start(instant_at(span, to)) + 86_400 - 1;
(start, end)
}
/// 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)> {
// `span_scoped` reports the extent with the date range lifted, which is
// what every caller here wants: they measure the axis that is on screen,
// and that axis spans the library rather than the chosen range. Measured
// through the range, narrowing it would move the ground under the very
// handles doing the narrowing — each drag re-scaling the axis, so the next
// one meant something else.
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);
}
/// TRACES: FR-CAT-7
/// Put the view back where the photographer was after the library changed
/// under it.
///
/// Deleting is the case this exists for. The grid draws each cell at its
/// absolute place in the library — `(i + offset) / columns` — and a delete does
/// two things at once: the library gets shorter, and `offset` is re-clamped
/// downward so the loaded window still fills. Neither touches `viewport-y`, so
/// the view ends up pointing at a different part of the library, or past the
/// end of it entirely. That is the "goes blank and jumps somewhere random".
///
/// Anchored on the *ordinal the view was showing*, clamped into what is left.
/// Not on the deleted image's own position, which no longer exists, and not on
/// the top of the library, which would throw away the scroll position on every
/// delete — after removing one frame from a wall of twenty thousand, the next
/// one you want is the one that just moved into its place.
pub fn restore_position(window: &AppWindow, ctl: &Rc<LibraryController>) {
let total = window.get_library_total().max(0) as usize;
if total == 0 {
return;
}
let anchor = (*ctl.offset.borrow()).min(total - 1);
window.set_library_scroll_to(anchor as i32);
window.set_library_scroll_token(window.get_library_scroll_token() + 1);
}
/// 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 {
dr_types::format_date(t)
}
/// Unix seconds to a civil date, in the tuple shape this module reads it in.
///
/// The algorithm moved to `dr_types::time` when import folder templates
/// (FR-CAT-10) became a third caller. Three readings of the same instant that
/// could drift apart is one too many: an image filed under a date the timeline
/// does not show it on is a file the user cannot find.
fn civil_from_unix(t: i64) -> (i64, i64, i64, i64) {
let c = dr_types::civil_from_unix(t);
(c.year, c.month, c.day, c.hour)
}
/// 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);
}
});
}
// TRACES: FR-CAT-3 | FR-NC-3
// Thumbnail the whole library, from the settings page. It ends in a sync
// of its own, so this is the long version of the button above.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_thumbnail_all(move || {
if let Some(w) = weak.upgrade() {
start_thumbnail_sweep(&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 = instant_at((from, to), f);
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)));
}
});
}
// --- keywords (FR-CAT-5, FR-CAT-6) ------------------------------------
//
// Three callbacks and no state of their own: the sheet's open/shut is local
// to the `.slint` file, and what a keyword applies to is the grid selection
// the collections controller already owns. A second copy of either here is
// a second thing that can disagree with the first.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let coll_for_keywords = coll_ctl.clone();
window.on_library_keywords_opened(move || {
let Some(w) = weak.upgrade() else { return };
refresh_keywords(&w, &ctl, &coll_for_keywords.selected());
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_assign_keyword(move |word| {
let Some(w) = weak.upgrade() else { return };
apply_keyword(&w, &ctl, word.as_str(), true);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_unassign_keyword(move |word| {
let Some(w) = weak.upgrade() else { return };
apply_keyword(&w, &ctl, word.as_str(), false);
});
}
// --- 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);
});
}
// Turn the range on, over 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.
//
// A starting point rather than the answer. What this puts on the axis is a
// band with two draggable ends, and dragging them is how the range is
// actually stated — on a phone it is the only way that does not need a
// keyboard. Seeding it to the whole visible span means the first drag
// narrows from what the user is already looking at.
//
// 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 };
// Whether the *controls* are showing, which is not the same as
// whether a range is set — and reading it from the filter was why
// pressing this did nothing. The fields are how a range gets set,
// so requiring one before they appear is a door locked from the
// inside.
let on = !w.get_library_range_active();
if on {
// Seeded from the span the axis is drawn over, which is what
// the user is looking at when they ask for "this range".
//
// Only a seed. If there is no catalog yet, or nothing in it
// carries a capture date — dates are read from EXIF as
// thumbnails load, so a freshly opened library has none — the
// fields simply open empty and wait to be typed into. This
// used to `return` in both cases, which meant the button
// silently did nothing on exactly the libraries where a person
// is most likely to be looking for a date.
let seeded = {
let borrow = ctl.catalog.borrow();
borrow
.as_ref()
.and_then(|catalog| catalog_span(catalog, &ctl))
.map(|span| {
zoomed_span(
span,
*ctl.timeline_zoom.borrow(),
*ctl.timeline_centre.borrow(),
)
})
};
if let Some(span) = seeded {
// Through the same days-inclusive conversion a drag uses,
// so a seeded range and a dragged one are the same kind of
// thing — and so `show_range` reads this one back out as
// the two days it covers.
let (from, to) = dragged_range(span, 0.0, 1.0);
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);
show_range(&w, &ctl);
refilter(&w, &ctl);
});
}
// TRACES: FR-CAT-6 | FR-UI-2
// The ends of the range, dragged along the axis they were chosen from.
//
// This is the control the two typed fields were standing in for. On a
// phone they could not do the job: `YYYY-MM-DD` keyed into a 108px field
// behind a soft keyboard, to name a day already drawn on the axis a thumb
// away. Nor was the chip a way round them — its span comes from the
// timeline's zoom and pan, and those are a wheel and a middle button,
// neither of which a touch screen has. So on Android the date range was a
// filter that could be turned on and not aimed.
//
// The span is recomputed here rather than remembered from the last draw.
// It is the same expression `refresh_timeline` uses, over the same
// date-lifted extent, so a handle dropped at a fraction of the track names
// the instant that fraction was drawn at.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_timeline_range_changed(move |a, b| {
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 span = zoomed_span(
full,
*ctl.timeline_zoom.borrow(),
*ctl.timeline_centre.borrow(),
);
drop(borrow);
let (from, to) = dragged_range(span, a, b);
{
let mut f = ctl.filter.borrow_mut();
f.captured_from = Some(from);
f.captured_to = Some(to);
}
// A dragged range cannot be mistyped, so any standing complaint
// about the fields is now about a range that no longer exists.
w.set_library_range_invalid(false);
w.set_library_range_active(true);
show_range(&w, &ctl);
// Reloads the window, and `load_window` redraws the timeline —
// which is what puts the band back under the finger that dropped
// it, at the whole days it was snapped to.
refilter(&w, &ctl);
});
}
// TRACES: FR-CAT-6
// The ends of the range, typed.
//
// The chip alone used to be the whole control and it read its span from
// the timeline's zoom, which is zero until someone zooms — so "limit to
// range" set the range to the entire library and appeared to do nothing.
// The span is shown now, and these two fields are how it is corrected.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_range_edited(move |from, to| {
let Some(w) = weak.upgrade() else { return };
// Both ends, or neither. A range with one end parsed and the other
// silently dropped is a filter nobody asked for, and the grid
// going empty is a poor way to find out a date was mistyped.
let (Some(from), Some(to)) = (
dr_types::parse_date(from.as_str()),
dr_types::parse_date(to.as_str()),
) else {
w.set_library_range_invalid(true);
return;
};
// Typed the other way round is a slip, not an error: the user has
// said which two days they mean and there is exactly one range
// between them.
let (from, to) = if from <= to { (from, to) } else { (to, from) };
{
let mut f = ctl.filter.borrow_mut();
f.captured_from = Some(from);
// Inclusive of the last day. "To the 5th" means the whole of
// the 5th — `parse_date` returns its midnight, and a range
// ending there would exclude every photograph taken on the day
// the user named. The same rule `dragged_range` applies.
f.captured_to = Some(to + 86_400 - 1);
}
w.set_library_range_invalid(false);
w.set_library_range_active(true);
show_range(&w, &ctl);
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::*;
#[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));
}
#[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 = fraction_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!(fraction_at(span, 0), 0.0);
assert_eq!(fraction_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);
}
/// A fortnight in March 2024, in whole days, as the axis would span it.
const MARCH: (i64, i64) = (1_709_251_200, 1_710_460_800);
#[test]
fn a_dragged_range_covers_whole_days_at_both_ends() {
// What makes a dragged range the same kind of thing as a typed one:
// `show_range` reads the closing day back out of the stored second, so
// an end left mid-afternoon would be shown as the day before.
let (from, to) = dragged_range(MARCH, 0.13, 0.61);
assert_eq!(day_start(from), from, "opens at a midnight");
assert_eq!(
day_start(to + 1),
to + 1,
"closes at the last second of a day"
);
assert_eq!(dr_types::format_date(from), "2024-03-02");
assert_eq!(dr_types::format_date(to - 86_400 + 1), "2024-03-09");
}
#[test]
fn ends_dragged_past_each_other_name_the_same_range() {
// Pulling the early end below the late one is how a range that was
// narrowed too far gets corrected; refusing it would make starting
// again the easier move.
assert_eq!(
dragged_range(MARCH, 0.6, 0.2),
dragged_range(MARCH, 0.2, 0.6)
);
}
#[test]
fn both_ends_dragged_together_name_one_day() {
// The floor the day snapping buys. Two handles on the same pixel is a
// gesture that happens; an empty grid is not what it means.
let (from, to) = dragged_range(MARCH, 0.5, 0.5);
assert_eq!(to - from, 86_399);
}
#[test]
fn the_band_is_drawn_where_the_handle_was_dropped() {
// The band is placed by `fraction_at` and the range comes from
// `instant_at`. If those two ever disagreed the band would settle
// somewhere other than where the finger let go, which reads as the
// drag having been ignored.
for f in [0.0_f32, 0.25, 0.5, 0.75, 1.0] {
let (from, _) = dragged_range(MARCH, f, f);
let drawn = fraction_at(MARCH, from);
// Within a day of the span, which is the snapping and nothing else.
let day = 86_400.0 / (MARCH.1 - MARCH.0) as f32;
assert!((drawn - f).abs() <= day, "dropped at {f}, drawn at {drawn}");
}
}
#[test]
fn every_instant_on_the_axis_lands_in_a_bin() {
// The bar under a date and the bar that counted it must be the same
// bar: `bin_of` is the UI's copy of the division the SQL groups by.
let span = (1_000, 9_000);
assert_eq!(bin_of(span, 1_000, 8), 0);
assert_eq!(bin_of(span, 1_999, 8), 0);
assert_eq!(bin_of(span, 2_000, 8), 1);
// The last instant belongs to the last bin rather than to one past it.
assert_eq!(bin_of(span, 9_000, 8), 7);
}
#[test]
fn an_instant_off_the_zoomed_axis_lands_at_the_end_it_went_past() {
// Zooming in leaves the grid's position outside the span. The lit bar
// belongs at the edge, not at an index that does not exist.
let span = (1_000, 9_000);
assert_eq!(bin_of(span, -50_000, 8), 0);
assert_eq!(bin_of(span, 50_000, 8), 7);
// And a degenerate axis still answers with a bin.
assert_eq!(bin_of((5, 5), 5, 32), 0);
}
#[test]
fn a_day_before_1970_starts_at_its_own_midnight() {
// `%` truncates towards zero, which on a negative timestamp names the
// *following* midnight — a scanned archive of negatives would have
// every range off by a day at the early end.
assert_eq!(day_start(-1), -86_400);
assert_eq!(day_start(-86_400), -86_400);
assert_eq!(day_start(-86_401), -172_800);
}
/// 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);
}
/// A bar is named after how long it is.
///
/// The axis no longer buckets by calendar unit — it cuts the visible span
/// into a fixed number of equal bins — so the unit survives only as a
/// vocabulary for *labelling* one. What this guards is that zooming in
/// never produces a coarser label: the complaint it came from was a
/// fortnight drawn as one column of a year-per-bar axis.
#[test]
fn a_bin_is_labelled_at_its_own_scale() {
use dr_catalog::Granularity;
const DAY: i64 = 86_400;
// Fifteen years across 32 bars is nearly six months a bar, which on a
// ratio measure is nearer a year than a month — so the bars are
// labelled by year, which is what a fifteen-year axis wants anyway.
assert_eq!(
Granularity::for_bucket(15 * 365 * DAY / 32),
Granularity::Year
);
// A fortnight across the same 32 bars is about ten hours a bar, which
// is nearer a day than an hour and is labelled as a date.
assert_eq!(Granularity::for_bucket(14 * DAY / 32), Granularity::Day);
// Zoom on until a bar is an hour or two, and it is named by the hour.
assert_eq!(Granularity::for_bucket(2 * 3600), Granularity::Hour);
// The property rather than the thresholds: a shorter bin never gets a
// coarser label than a longer one.
let mut previous = i64::MAX;
for days in [3650, 730, 180, 60, 14, 3, 1] {
let g = Granularity::for_bucket(days * DAY / 32);
let bucket = match g {
Granularity::Year => 365 * DAY,
Granularity::Month => 30 * DAY,
Granularity::Day => DAY,
Granularity::Hour => 3600,
};
assert!(
bucket <= previous,
"{days} days chose a coarser bin label than the span above it"
);
previous = bucket;
}
}
/// Deleting must not move the view somewhere the user did not ask to be.
///
/// The grid draws each cell at its absolute place in the library, and a
/// delete shortens the library *and* re-clamps the loaded window's offset.
/// Neither touches the viewport, so before this the grid went blank — the
/// view left pointing past the end of the content — or appeared to jump,
/// the same scroll position now addressing different photographs.
///
/// The anchor is the offset clamped into what is left: the ordinal the view
/// was showing, or the last one there is if it was near the end.
#[test]
fn a_shorter_library_anchors_the_view_instead_of_losing_it() {
// The rule `restore_position` applies, stated where it can be checked.
let anchor = |offset: usize, total: usize| -> Option<usize> {
if total == 0 {
None
} else {
Some(offset.min(total - 1))
}
};
// The everyday case: one frame removed from the middle of a wall of
// twenty thousand. The view does not move — the photograph that slid
// into the gap is the one you want next.
assert_eq!(anchor(12_000, 19_999), Some(12_000));
// Near the end, which is where the blank came from: the view was
// showing ordinals past what now exists, so it lands on the last.
assert_eq!(anchor(19_990, 5), Some(4));
// Emptied entirely. Seeking into an empty library would be the same
// fault in the other direction, so there is nothing to do.
assert_eq!(anchor(500, 0), None);
// The boundary: an offset equal to the new total is one past the end.
assert_eq!(anchor(10, 10), Some(9));
}
#[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"]);
}
// --- what the status line says about a keyword (FR-CAT-5) -------------
//
// Split out from the callback for the same reason `decide_drop` is: the
// sheet cannot be driven from a test, and this is the part that can
// actually mislead someone.
/// TRACES: FR-CAT-5
#[test]
fn a_partly_applied_keyword_reports_the_honest_count() {
// Nine of the twelve already had it. Claiming twelve is how a user
// learns that the counts are decorative.
assert_eq!(
keyword_summary("puffin", 3, 12, true),
"Added “puffin” to 3 of 12"
);
}
/// TRACES: FR-CAT-5
#[test]
fn a_keyword_that_changed_nothing_says_so_rather_than_claiming_success() {
assert_eq!(
keyword_summary("puffin", 0, 12, true),
"Every selected photograph already had “puffin”"
);
assert_eq!(
keyword_summary("puffin", 0, 12, false),
"None of the selected photographs had “puffin”"
);
}
/// TRACES: FR-CAT-5
#[test]
fn one_photograph_is_singular() {
// "Added to 1 photographs" is the kind of small wrongness that makes
// the rest of the interface look unfinished.
assert_eq!(
keyword_summary("puffin", 1, 1, true),
"Added “puffin” to 1 photograph"
);
assert_eq!(
keyword_summary("puffin", 2, 2, true),
"Added “puffin” to 2 photographs"
);
}
/// TRACES: FR-CAT-5
#[test]
fn removing_a_keyword_reads_as_removal() {
assert_eq!(
keyword_summary("blurry", 4, 4, false),
"Removed “blurry” from 4 photographs"
);
}
/// TRACES: FR-CAT-5
#[test]
fn typing_a_word_with_nothing_selected_says_what_it_did_do() {
// It builds the vocabulary, which is a legitimate thing to do ahead of
// a shoot — so it must not report itself as having keyworded nothing.
assert_eq!(
keyword_summary("puffin", 0, 0, true),
"Added “puffin” to the keyword list"
);
}
/// 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"]);
}
}
/// TRACES: FR-UI-2
/// A filename as a caption: without the part that says how it is stored.
///
/// A grid cell is about four words wide, and `.CR2` spends one of them saying
/// something the photographer already knows — every frame in a RAW library
/// ends the same way, so the extension distinguishes nothing while taking
/// room from the part that does. The name is elided under pressure, and it is
/// the *end* that goes, so an extension can push the digits that identify a
/// frame off the visible part of its own label.
///
/// Display only. `LibraryCell::name` keeps the true filename and
/// `remote_path` the full path, because both are used to find the file again
/// and a stem is not a filename.
///
/// **The case this is wrong for**, worth knowing before it is reported: a
/// library holding `IMG_1234.CR2` beside `IMG_1234.JPG` shows two cells
/// captioned `IMG_1234`. They are still two rows with two thumbnails and two
/// entries in the info panel, and RAW+JPEG pairs are usually shot to be one
/// photograph anyway — but the caption alone no longer separates them.
fn without_extension(name: &str) -> &str {
match name.rsplit_once('.') {
// The guard is for a leading dot: `.hidden` splits to an empty stem,
// and a dotfile's name starts with that dot rather than ending with an
// extension. A *trailing* dot needs no guard — `odd.` splits to `odd`,
// which is the better caption anyway.
Some((stem, _)) if !stem.is_empty() => stem,
_ => name,
}
}
#[cfg(test)]
mod display_name_tests {
use super::without_extension;
#[test]
fn an_extension_is_dropped_and_nothing_else_is() {
assert_eq!(without_extension("IMG_1234.CR2"), "IMG_1234");
assert_eq!(without_extension("IMG_1234.jpeg"), "IMG_1234");
// Only the last one: a name can contain dots and they are part of it.
assert_eq!(without_extension("2026.08.23-a.dng"), "2026.08.23-a");
// Nothing to drop.
assert_eq!(without_extension("IMG_1234"), "IMG_1234");
// A dotfile is not an extensionless name with an extension.
assert_eq!(without_extension(".hidden"), ".hidden");
// A trailing dot is an empty extension, and dropping it is right.
assert_eq!(without_extension("odd."), "odd");
assert_eq!(without_extension(""), "");
}
}
/// TRACES: FR-CAT-6
/// Show the range the filter is actually using.
///
/// Pushed back rather than left as the user typed it, because the filter may
/// have adjusted it — ends given backwards are swapped, and the closing day is
/// extended to include itself. A field showing something other than what is
/// being filtered on is worse than one showing nothing.
fn show_range(window: &AppWindow, ctl: &Rc<LibraryController>) {
let f = ctl.filter.borrow();
let text = |t: Option<i64>| -> slint::SharedString {
t.map(dr_types::format_date).unwrap_or_default().into()
};
window.set_library_range_from(text(f.captured_from));
// The stored end is the last second of the closing day; naming that day is
// what the user typed and what they should read back.
window.set_library_range_to(text(f.captured_to.map(|t| t - 86_400 + 1)));
}