🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Build and test / Desktop (Linux) (push) Failing after 57m33s
Build and test / Layer separation (push) Successful in 36s
Traceability / Requirement traces (push) Failing after 29s
Build and test / Android (aarch64) (push) Failing after 9m28s
The library could be narrowed by rating, flag and availability, but not by when a photograph was taken — so finding a fortnight meant scrolling to it and holding position. The range rides on `RatingFilter` for the reason `local_only` already does: every query path threads that one struct, so the count in the header cannot claim a total the grid does not draw. Undated images are excluded whenever either end is set — they cannot be inside or outside a span, and drawing them made the range look as though it had not applied. Taken from the timeline rather than typed into two date fields. Finding the period 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. The histogram keeps drawing the full extent while the range is on, or there would be nowhere to widen back out from. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4282 lines
172 KiB
Rust
4282 lines
172 KiB
Rust
//! TRACES: FR-CAT-4 | FR-NC-3 | NFR-P9
|
||
//! Drives the library grid from scan and thumbnail workers.
|
||
//!
|
||
//! Owns the bridge between three background activities and one single-threaded
|
||
//! event loop:
|
||
//!
|
||
//! - a **scan** worker walking the remote tree into the catalog
|
||
//! - a **thumbnail** worker range-fetching previews for visible cells
|
||
//! - the **grid model** Slint renders
|
||
//!
|
||
//! Nothing here blocks. Workers post through mpsc channels drained by Slint
|
||
//! timers, which is the same shape [`crate::launch_ui`] uses for login.
|
||
|
||
use std::cell::RefCell;
|
||
use std::path::PathBuf;
|
||
use std::rc::Rc;
|
||
use std::sync::mpsc::Receiver;
|
||
|
||
use dr_catalog::Catalog;
|
||
use dr_sync_nextcloud::{AppCredentials, Session, SessionStore};
|
||
use dr_types::FormatFilter;
|
||
use slint::{ComponentHandle, Model as _};
|
||
|
||
use crate::library::{self, ScanMessage, ThumbnailMessage};
|
||
use crate::{AppWindow, LibraryCell, TimelineBar};
|
||
|
||
/// Window size before the grid has reported its geometry.
|
||
///
|
||
/// Only used for the very first load; the grid replaces it with its real
|
||
/// capacity as soon as it has laid out.
|
||
const INITIAL_WINDOW: usize = 60;
|
||
|
||
/// Never load fewer than this, whatever the viewport reports.
|
||
///
|
||
/// A window collapsed to a sliver would otherwise load one or two cells and
|
||
/// re-query on every scroll tick.
|
||
const MIN_WINDOW: usize = 24;
|
||
|
||
/// Cell-size bounds for the grid's zoom.
|
||
///
|
||
/// The lower end is where a thumbnail stops being recognisable; the upper is
|
||
/// where one screen holds so few that the grid stops being a grid. Past 256px
|
||
/// the large thumbnail class is fetched, so the top of this range is sharp
|
||
/// rather than upscaled.
|
||
const MIN_CELL_SIZE: f32 = 90.0;
|
||
const MAX_CELL_SIZE: f32 = 420.0;
|
||
|
||
/// Library state for the running window.
|
||
pub struct LibraryController {
|
||
/// Shared with [`crate::collections_ui`], which edits collections against
|
||
/// the same connection. `Rc` rather than a second `Catalog::open`: two
|
||
/// handles on one SQLite file would each hold their own WAL view, so a
|
||
/// collection edited through one would not be visible through the other
|
||
/// until it committed and the reader reopened.
|
||
catalog: Rc<RefCell<Option<Catalog>>>,
|
||
/// Remote paths for the rows currently in the model, parallel to it.
|
||
paths: RefCell<Vec<String>>,
|
||
/// `oc:fileid` and file length per row, parallel to the model.
|
||
file_ids: RefCell<Vec<Option<u64>>>,
|
||
sizes: RefCell<Vec<u64>>,
|
||
/// Catalog row ids and whether each still needs its EXIF read.
|
||
image_ids: RefCell<Vec<i64>>,
|
||
needs_metadata: RefCell<Vec<bool>>,
|
||
/// Where in the catalog the current window starts. Scrubbing moves this.
|
||
offset: RefCell<usize>,
|
||
/// The first visible ordinal, kept so returning from the develop view lands
|
||
/// where the user left rather than at the top.
|
||
///
|
||
/// Recorded when the grid is left, not read back when it is re-entered:
|
||
/// `show-library` gates an `if` in the markup, so the whole grid subtree is
|
||
/// destroyed and rebuilt, and the rebuilt `Flickable` reports a scroll to
|
||
/// row 0 before anything can restore the old position. Capturing at the
|
||
/// moment of departure is the only value the reset cannot overwrite.
|
||
///
|
||
/// Separate from `offset`, which is the *loaded window*'s start and sits a
|
||
/// quarter-window above the view. Restoring that as a viewport position
|
||
/// would land the user consistently short of where they were.
|
||
resume_at: std::cell::Cell<usize>,
|
||
/// How many cells to load, derived from what the viewport can show.
|
||
///
|
||
/// A fixed count is wrong in both directions: too small on a maximised 4K
|
||
/// window, wasteful on a narrow one. The grid measures itself and reports
|
||
/// its capacity, including a screenful of margin either side.
|
||
window: RefCell<usize>,
|
||
/// Which rows already have a thumbnail fetch issued, so scrolling back
|
||
/// does not refetch what is already on screen.
|
||
/// Which images have a fetch in flight or already served, keyed on
|
||
/// `(image_id, size class)`.
|
||
///
|
||
/// **Identity, not row index.** The grid is a window over the catalog, so
|
||
/// row 7 means a different photograph after every scroll — a set of
|
||
/// indices had to be cleared on each window move, which made every visible
|
||
/// cell look unrequested and re-issued the whole screenful on every
|
||
/// scroll.
|
||
///
|
||
/// The size class is part of the key because the two resolutions are
|
||
/// fetched independently: holding the 256px one says nothing about whether
|
||
/// the large one has been asked for.
|
||
requested: RefCell<std::collections::HashSet<(i64, dr_thumbs::ThumbSize)>>,
|
||
scan_timer: RefCell<Option<slint::Timer>>,
|
||
thumb_timer: RefCell<Option<slint::Timer>>,
|
||
/// The whole-library sweep, which outlives any one grid window.
|
||
sweep_timer: RefCell<Option<slint::Timer>>,
|
||
/// Pushing shards and the catalog to the server.
|
||
sync_timer: RefCell<Option<slint::Timer>>,
|
||
/// Kept so a rescan can run without going back through the launch screen.
|
||
session: RefCell<Option<(AppCredentials, Session, FormatFilter)>>,
|
||
/// Which collection narrows the grid, owned by [`crate::collections_ui`]
|
||
/// and read here. Shared rather than passed per call because a rescan, a
|
||
/// scrub and a drop all reload the window and must all honour it.
|
||
scope: RefCell<Option<dr_types::CollectionId>>,
|
||
/// TRACES: FR-CAT-15
|
||
/// Whether the grid is listing the trash rather than the library.
|
||
///
|
||
/// Separate from `scope` rather than a sentinel id in it, for the reason
|
||
/// [`crate::collections_ui::CollectionsController`] gives: the trash is not
|
||
/// a collection, and its query *inverts* the predicate every other view
|
||
/// applies. Folding that into a type meaning "a collection" would put the
|
||
/// inversion where nothing reading `scope` expects it.
|
||
///
|
||
/// A `Cell` because it is a `bool` read inside callbacks that already hold
|
||
/// other borrows.
|
||
viewing_trash: std::cell::Cell<bool>,
|
||
/// The selection's owner, so a rebuilt window can restore the `selected`
|
||
/// flags it just cleared.
|
||
///
|
||
/// `Weak` because the two controllers outlive each other only through the
|
||
/// window, and an `Rc` both ways would leak both. Set once at wiring; a
|
||
/// `None` here means the grid simply draws nothing selected, which is the
|
||
/// old behaviour rather than a crash.
|
||
coll_ctl: RefCell<Option<std::rc::Weak<crate::collections_ui::CollectionsController>>>,
|
||
/// Timeline view state: how far zoomed in, and around what instant.
|
||
///
|
||
/// Zoom is a level rather than a span so the axis halves and doubles in
|
||
/// even steps; the centre is what keeps the thing you were looking at in
|
||
/// view as you zoom.
|
||
timeline_zoom: RefCell<i32>,
|
||
timeline_centre: RefCell<Option<i64>>,
|
||
/// Pinch ratio accumulated since the last zoom step was taken.
|
||
///
|
||
/// A pinch is continuous and zoom levels are discrete, so the ratio is
|
||
/// held until it reaches a doubling. Without it a slow spread would either
|
||
/// do nothing or, if each update were rounded, leap several levels.
|
||
pinch_accum: RefCell<f32>,
|
||
/// The instant the grid is showing. `None` until the user has moved the
|
||
/// timeline, which is what leaves the marker resting at the middle.
|
||
current_bucket: RefCell<Option<i64>>,
|
||
/// What the rating filter bar is narrowed to.
|
||
///
|
||
/// Held here beside `scope` and for the same reason: a scrub, a rescan and
|
||
/// a drop all reload the window, and every one of them must honour it or
|
||
/// the filter silently lapses.
|
||
filter: RefCell<library::RatingFilter>,
|
||
/// TRACES: FR-CAT-9
|
||
/// Drains the outbox uploader. Held so that a repaint while one is
|
||
/// already running does not start a second against the same channel —
|
||
/// this handle *is* the "a drain is in flight" state.
|
||
outbox_timer: RefCell<Option<slint::Timer>>,
|
||
/// Whether the outbox might hold something, so the common case costs a
|
||
/// boolean rather than a directory walk.
|
||
///
|
||
/// `refresh_offline` runs on scan progress as well as on a genuine
|
||
/// connectivity change, so the drain is *asked* far more often than there
|
||
/// is anything to send. Starts `true` so the first ask after launch does
|
||
/// walk — edits queued in a previous session are exactly the ones that
|
||
/// need sending, and nothing in memory knows about them.
|
||
outbox_maybe_dirty: std::cell::Cell<bool>,
|
||
/// Drains the sidecar writer. Held so a second judgement replaces the
|
||
/// timer rather than leaving two draining the same finished channel.
|
||
sidecar_timer: RefCell<Option<slint::Timer>>,
|
||
/// Which window load the model belongs to, bumped by [`load_window`].
|
||
///
|
||
/// A thumbnail worker addresses cells by *row index into the window that
|
||
/// asked for it*. A reload replaces the model, so once the generation has
|
||
/// moved on every row still in flight names a different photograph —
|
||
/// applying it paints thumbnails onto unrelated cells, or marks a cell
|
||
/// "no preview" for a fetch never attempted against it. Worse, a stale
|
||
/// drain reaching `Disconnected` would call `stop` on `thumb_timer`, which
|
||
/// by then holds the *current* batch's timer: the new worker then fetched
|
||
/// into a channel nobody drained and the grid stayed black until a scroll
|
||
/// forced another load.
|
||
///
|
||
/// A `Cell` rather than a `RefCell`: it is read inside a timer callback
|
||
/// that already holds borrows of other fields, and a `u64` needs no borrow
|
||
/// tracking.
|
||
generation: std::cell::Cell<u64>,
|
||
/// TRACES: FR-CAT-9
|
||
/// Whether the server is reachable, inferred from what the workers saw.
|
||
///
|
||
/// Lives on the controller rather than in a global because it is scoped to
|
||
/// one open library: signing into a different account starts a fresh
|
||
/// judgement, and carrying the old one over would report a server down
|
||
/// that was never contacted.
|
||
reachability: RefCell<dr_sync::Reachability>,
|
||
/// TRACES: FR-NC-6a
|
||
/// Drains the pin downloader. Held so a second pin replaces the timer
|
||
/// rather than leaving two draining the same finished channel.
|
||
pin_timer: RefCell<Option<slint::Timer>>,
|
||
/// TRACES: FR-NC-6a
|
||
/// Which collection the offline question is being asked about.
|
||
///
|
||
/// Held rather than passed through the window because the prompt's three
|
||
/// answers arrive as three separate callbacks, and a dialogue that read its
|
||
/// subject back out of a string property would act on whatever the sidebar
|
||
/// had been rebuilt to say since.
|
||
offline_target: std::cell::Cell<Option<dr_types::CollectionId>>,
|
||
/// TRACES: FR-NC-6a | FR-UI-2
|
||
/// The timer that turns a held sidebar row into that question. Dropped on
|
||
/// release, so a tap — or a press the Flickable takes for a scroll — is not
|
||
/// a dialogue a moment later.
|
||
row_hold_timer: RefCell<Option<slint::Timer>>,
|
||
/// Narrow the grid to images whose original is stored locally.
|
||
///
|
||
/// A `Cell` beside `filter` rather than a field inside it: the rating
|
||
/// filter compiles to a SQL predicate over `versions`, while this one is a
|
||
/// predicate over the cache, and folding two different joins into one type
|
||
/// would put the cache schema inside a rating concept.
|
||
local_only: std::cell::Cell<bool>,
|
||
/// TRACES: FR-NC-6a
|
||
/// The ceiling on passively cached originals, from the settings page.
|
||
///
|
||
/// Held here rather than read from `SettingsStore` at each use because the
|
||
/// workers that need it run on threads with no config access — the same
|
||
/// reason [`library::CacheContext`] takes it as a field. A `Cell` because
|
||
/// the settings page can move it while a library is open, and the next
|
||
/// fetch must use the new figure rather than one captured at open.
|
||
cache_budget: std::cell::Cell<dr_catalog::Budget>,
|
||
/// TRACES: FR-NC-6a
|
||
/// Whether opening an image in develop keeps its original on disk.
|
||
///
|
||
/// Held beside the budget and for the same reason. Distinct from a zero
|
||
/// budget: this switches the passive population off entirely, while a
|
||
/// small budget still keeps a working set. A metered or small-disk device
|
||
/// wants the first.
|
||
keep_opened: std::cell::Cell<bool>,
|
||
/// Where every worker this module starts reports what it is doing.
|
||
///
|
||
/// Held on the controller rather than passed to each function because the
|
||
/// jobs are started from a dozen callbacks — a scroll, a rescan, a pin, a
|
||
/// reconnect — and threading a second argument through all of them would
|
||
/// say nothing except that they all report progress.
|
||
activity: Rc<crate::activity::ActivityLog>,
|
||
}
|
||
|
||
impl LibraryController {
|
||
pub fn new(activity: Rc<crate::activity::ActivityLog>) -> Rc<Self> {
|
||
Rc::new(Self {
|
||
activity,
|
||
catalog: Rc::new(RefCell::new(None)),
|
||
paths: RefCell::new(Vec::new()),
|
||
file_ids: RefCell::new(Vec::new()),
|
||
sizes: RefCell::new(Vec::new()),
|
||
image_ids: RefCell::new(Vec::new()),
|
||
needs_metadata: RefCell::new(Vec::new()),
|
||
offset: RefCell::new(0),
|
||
resume_at: std::cell::Cell::new(0),
|
||
window: RefCell::new(INITIAL_WINDOW),
|
||
requested: RefCell::new(Default::default()),
|
||
scan_timer: RefCell::new(None),
|
||
thumb_timer: RefCell::new(None),
|
||
sweep_timer: RefCell::new(None),
|
||
sync_timer: RefCell::new(None),
|
||
session: RefCell::new(None),
|
||
scope: RefCell::new(None),
|
||
viewing_trash: std::cell::Cell::new(false),
|
||
coll_ctl: RefCell::new(None),
|
||
timeline_zoom: RefCell::new(0),
|
||
timeline_centre: RefCell::new(None),
|
||
pinch_accum: RefCell::new(1.0),
|
||
current_bucket: RefCell::new(None),
|
||
filter: RefCell::new(library::RatingFilter::default()),
|
||
sidecar_timer: RefCell::new(None),
|
||
generation: std::cell::Cell::new(0),
|
||
reachability: RefCell::new(dr_sync::Reachability::new()),
|
||
outbox_timer: RefCell::new(None),
|
||
outbox_maybe_dirty: std::cell::Cell::new(true),
|
||
pin_timer: RefCell::new(None),
|
||
offline_target: std::cell::Cell::new(None),
|
||
row_hold_timer: RefCell::new(None),
|
||
local_only: std::cell::Cell::new(false),
|
||
// The catalog's own floor until the settings page reports what the
|
||
// user has stored, which it does at startup before any fetch.
|
||
cache_budget: std::cell::Cell::new(dr_catalog::Budget::default()),
|
||
keep_opened: std::cell::Cell::new(
|
||
dr_types::CacheSettings::default().keep_opened_originals,
|
||
),
|
||
})
|
||
}
|
||
|
||
/// TRACES: FR-NC-6a
|
||
/// Whether a develop open should keep the original it downloads.
|
||
///
|
||
/// Turning it off leaves what is already cached alone: those bytes are
|
||
/// paid for, and deleting them would make the switch destructive when it
|
||
/// only means "stop adding to this".
|
||
pub fn set_keep_opened_originals(&self, keep: bool) {
|
||
self.keep_opened.set(keep);
|
||
}
|
||
|
||
/// TRACES: FR-NC-6a
|
||
/// Set the ceiling on passively cached originals, and apply it now.
|
||
///
|
||
/// Applied immediately rather than only to later downloads: a user who has
|
||
/// just lowered the limit expects the space back, and a budget that took
|
||
/// effect only on the next fetch would leave the cache over its stated
|
||
/// ceiling for as long as they browsed nothing new.
|
||
pub fn set_cache_budget(&self, bytes: Option<u64>) {
|
||
let budget = match bytes {
|
||
Some(n) => dr_catalog::Budget::bytes(n),
|
||
None => dr_catalog::Budget::unlimited(),
|
||
};
|
||
self.cache_budget.set(budget);
|
||
|
||
// Best-effort: no library open means no cache to trim, and a failure
|
||
// here must not stop the setting from being stored — the ceiling still
|
||
// applies to every fetch from now on.
|
||
let Some(dir) = self.cache_dir() else { return };
|
||
let borrow = self.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
match dr_catalog::Cache::open(&dir, budget)
|
||
.and_then(|cache| cache.enforce(catalog.connection()))
|
||
{
|
||
Ok(0) => {}
|
||
Ok(n) => log::info!("cache budget changed: evicted {n} original(s)"),
|
||
Err(e) => log::warn!("applying the new cache budget: {e}"),
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-CAT-9
|
||
/// Whether the app currently believes the server is unreachable.
|
||
pub fn is_offline(&self) -> bool {
|
||
self.reachability.borrow().is_offline()
|
||
}
|
||
|
||
/// Whether the grid is narrowed to locally-stored originals.
|
||
pub fn local_only(&self) -> bool {
|
||
self.local_only.get()
|
||
}
|
||
|
||
/// TRACES: FR-NC-6a
|
||
/// The catalog id for a remote path currently in the grid.
|
||
///
|
||
/// The cache is keyed on `ImageId` because that is what survives a
|
||
/// server-side rename (FR-NC-5), while the develop callback carries only a
|
||
/// path. `paths` and `image_ids` are parallel to the model, so this is the
|
||
/// join between the two.
|
||
pub fn image_id_for_path(&self, path: &str) -> Option<dr_types::ImageId> {
|
||
let index = self.paths.borrow().iter().position(|p| p == path)?;
|
||
self.image_ids
|
||
.borrow()
|
||
.get(index)
|
||
.map(|id| dr_types::ImageId(*id as u64))
|
||
}
|
||
|
||
/// TRACES: FR-NC-8 | FR-DEV-6
|
||
/// The default version's uuid for an image, by its remote path.
|
||
///
|
||
/// Taken from the catalog rather than generated, and rather than read off
|
||
/// whatever the sidecar happens to contain. The uuid is the identity a
|
||
/// cross-device merge keys on (FR-NC-8): a develop session that invented
|
||
/// one would write a *second* version beside the one the cull is stored
|
||
/// in, and the photograph would arrive on the other device holding two
|
||
/// edits that never merge.
|
||
///
|
||
/// Creates the version if the image has none, by the same route a rating
|
||
/// does — see `dr_catalog::rating::default_version_id` for why an image
|
||
/// can legitimately arrive without one.
|
||
pub fn version_uuid_for_path(&self, path: &str) -> Option<String> {
|
||
let image = self.image_id_for_path(path)?;
|
||
let borrow = self.catalog.borrow();
|
||
let catalog = borrow.as_ref()?;
|
||
let conn = catalog.connection();
|
||
|
||
let id = match dr_catalog::rating::default_version_id(conn, image) {
|
||
Ok(id) => id,
|
||
Err(e) => {
|
||
log::debug!("no version for {path}: {e}");
|
||
return None;
|
||
}
|
||
};
|
||
conn.query_row("SELECT uuid FROM versions WHERE id = ?1", [id], |r| {
|
||
r.get(0)
|
||
})
|
||
.ok()
|
||
}
|
||
|
||
/// TRACES: FR-CAT-9 | FR-NC-10
|
||
/// Where cached sidecars and the upload outbox live for the open library.
|
||
///
|
||
/// Beside the catalog and the originals cache, for the same reason those
|
||
/// two sit together: all three are per-account and are discarded together.
|
||
/// A separate directory rather than a subfolder of `originals` because the
|
||
/// two have opposite lifetimes — originals are evicted under a budget
|
||
/// (FR-NC-6a), and a queued edit must never be.
|
||
pub fn sidecar_cache_dir(&self) -> Option<PathBuf> {
|
||
let borrow = self.session.borrow();
|
||
let (_, session, _) = borrow.as_ref()?;
|
||
library::catalog_path(&session.server, &session.user_id)
|
||
.parent()
|
||
.map(|p| p.join("sidecars"))
|
||
}
|
||
|
||
/// TRACES: FR-NC-6a
|
||
/// Where cached originals live for the open library.
|
||
///
|
||
/// Beside the catalog rather than under a system cache directory: the two
|
||
/// are per-account and are discarded together, and a cached original whose
|
||
/// catalog row is gone is unreachable anyway.
|
||
pub fn cache_dir(&self) -> Option<PathBuf> {
|
||
let borrow = self.session.borrow();
|
||
let (_, session, _) = borrow.as_ref()?;
|
||
library::catalog_path(&session.server, &session.user_id)
|
||
.parent()
|
||
.map(|p| p.join("originals"))
|
||
}
|
||
|
||
/// Open the originals cache for the current library.
|
||
///
|
||
/// Used by pinning, which writes intent against the catalog directly
|
||
/// rather than going through a fetch.
|
||
pub fn cache(&self) -> Option<dr_catalog::Cache> {
|
||
let dir = self.cache_dir()?;
|
||
match dr_catalog::Cache::open(&dir, self.cache_budget.get()) {
|
||
Ok(c) => Some(c),
|
||
Err(e) => {
|
||
// Not fatal: without a cache every open is a download, which
|
||
// is exactly the behaviour that existed before this.
|
||
log::warn!("originals cache unavailable: {e}");
|
||
None
|
||
}
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-NC-6a
|
||
/// Everything a full fetch needs to read and write the cache.
|
||
///
|
||
/// Assembled here because the develop callback holds only a path, while
|
||
/// the cache is keyed on `ImageId` and lives beside a catalog whose
|
||
/// location comes from the session. `None` where any part is missing — a
|
||
/// grid row that has scrolled away, or no library open — and the fetch
|
||
/// then simply goes to the network.
|
||
pub fn cache_context(&self, path: &str) -> Option<library::CacheContext> {
|
||
self.cache_context_for(self.image_id_for_path(path)?)
|
||
}
|
||
|
||
/// TRACES: FR-EXP-7 | FR-NC-6a
|
||
/// The same, for an image named by id rather than by path.
|
||
///
|
||
/// A batch export needs this one: its selection is by catalog id and may
|
||
/// include photographs that have scrolled out of the loaded window, where
|
||
/// [`Self::image_id_for_path`] has nothing to match against. Going through
|
||
/// the path would quietly hand those images no cache at all, and a batch of
|
||
/// three hundred would re-download every one of them.
|
||
pub fn cache_context_for(&self, image: dr_types::ImageId) -> Option<library::CacheContext> {
|
||
let borrow = self.session.borrow();
|
||
let (_, session, _) = borrow.as_ref()?;
|
||
let catalog_path = library::catalog_path(&session.server, &session.user_id);
|
||
let dir = catalog_path.parent()?.join("originals");
|
||
drop(borrow);
|
||
|
||
Some(library::CacheContext {
|
||
dir,
|
||
catalog_path,
|
||
image,
|
||
// The user's ceiling, not the catalog's floor: read at each fetch
|
||
// so a budget changed mid-session takes effect on the next one.
|
||
budget: self.cache_budget.get(),
|
||
// Gates the *write* only. Reading stays enabled either way: bytes
|
||
// already on disk were paid for, and refusing to use them because
|
||
// the user has since stopped adding new ones would re-download
|
||
// images that are sitting right there — and would make a pinned
|
||
// collection unopenable offline.
|
||
store: self.keep_opened.get(),
|
||
})
|
||
}
|
||
|
||
/// Toggle the local-only filter, resetting the window.
|
||
///
|
||
/// The offset is cleared for the same reason a scope change clears it: the
|
||
/// filter changes which images exist as far as the grid is concerned, so a
|
||
/// position counted against the old set names a different photograph.
|
||
pub fn set_local_only(&self, on: bool) {
|
||
self.local_only.set(on);
|
||
*self.offset.borrow_mut() = 0;
|
||
self.requested.borrow_mut().clear();
|
||
}
|
||
|
||
/// The catalog handle, for [`crate::collections_ui`] to edit through.
|
||
pub fn catalog(&self) -> Rc<RefCell<Option<Catalog>>> {
|
||
self.catalog.clone()
|
||
}
|
||
|
||
/// Credentials and session for the open library.
|
||
///
|
||
/// Needed by the trash, whose `MOVE` and `DELETE` go to the same account the
|
||
/// scan and thumbnail workers use. `None` before a library is opened.
|
||
pub fn session(&self) -> Option<(AppCredentials, Session)> {
|
||
self.session
|
||
.borrow()
|
||
.as_ref()
|
||
.map(|(c, s, _)| (c.clone(), s.clone()))
|
||
}
|
||
|
||
/// Catalog ids of the rows currently in the model, in model order.
|
||
///
|
||
/// Selection is keyed on these rather than on row indices: the grid is a
|
||
/// window over the catalog and a scrub replaces every row, so an index
|
||
/// would silently come to mean a different photograph.
|
||
pub fn visible_ids(&self) -> Vec<dr_types::ImageId> {
|
||
self.image_ids
|
||
.borrow()
|
||
.iter()
|
||
.map(|id| dr_types::ImageId(*id as u64))
|
||
.collect()
|
||
}
|
||
|
||
/// TRACES: FR-EXP-7
|
||
/// Remote paths for a set of selected images, in the order they were given.
|
||
///
|
||
/// Answered from the catalog rather than from `paths`, which is only the
|
||
/// loaded window. Selection is by id precisely so that it survives a scrub
|
||
/// (see [`crate::collections_ui`]), so a selection made before scrolling
|
||
/// routinely names photographs no row currently holds — and an export that
|
||
/// silently dropped those would be worse than one that refused.
|
||
///
|
||
/// An id the catalog has never heard of is skipped rather than reported: it
|
||
/// can only mean the image was deleted between the selection and the click,
|
||
/// and there is nothing to export and nothing to fix.
|
||
///
|
||
/// Each path comes back beside the id it belongs to, because that skipping
|
||
/// means the ids that come out are not the ids that went in — and the caller
|
||
/// still has to find each photograph's cache, which is keyed on the id.
|
||
pub fn selected_image_paths(
|
||
&self,
|
||
images: &[dr_types::ImageId],
|
||
) -> Vec<(dr_types::ImageId, String)> {
|
||
if images.is_empty() {
|
||
return Vec::new();
|
||
}
|
||
|
||
let borrow = self.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return Vec::new();
|
||
};
|
||
|
||
let placeholders = std::iter::repeat_n("?", images.len())
|
||
.collect::<Vec<_>>()
|
||
.join(",");
|
||
let sql = format!("SELECT id, source_ref FROM images WHERE id IN ({placeholders})");
|
||
let params: Vec<rusqlite::types::Value> = images
|
||
.iter()
|
||
.map(|i| rusqlite::types::Value::Integer(i.0 as i64))
|
||
.collect();
|
||
|
||
let Ok(mut stmt) = catalog.connection().prepare(&sql) else {
|
||
return Vec::new();
|
||
};
|
||
let Ok(rows) = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
||
Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?))
|
||
}) else {
|
||
return Vec::new();
|
||
};
|
||
|
||
// `IN` returns rows in whatever order suits SQLite, and the export's
|
||
// `{seq}` token counts through the batch — so the answer is put back
|
||
// into the order the caller asked in rather than the one it arrived in.
|
||
let found: std::collections::HashMap<i64, String> = rows.flatten().collect();
|
||
images
|
||
.iter()
|
||
.filter_map(|id| found.get(&(id.0 as i64)).map(|path| (*id, path.clone())))
|
||
.collect()
|
||
}
|
||
|
||
/// TRACES: FR-EXP-7
|
||
/// The selection, addressed the way an export worker can fetch it.
|
||
///
|
||
/// Assembled here because a worker thread can reach neither the catalog nor
|
||
/// the session, and both are needed to say where a cached original lives.
|
||
pub fn export_sources(&self, images: &[dr_types::ImageId]) -> Vec<crate::export::Source> {
|
||
self.selected_image_paths(images)
|
||
.into_iter()
|
||
.map(|(id, path)| crate::export::Source::Library {
|
||
path,
|
||
cache: self.cache_context_for(id),
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Credentials and account for the open library, if one is open.
|
||
///
|
||
/// What a full-file fetch needs: the grid's paths are remote, so opening
|
||
/// an image means downloading it, and that needs the same session the
|
||
/// thumbnail workers use.
|
||
pub fn credentials(&self) -> Option<(AppCredentials, String)> {
|
||
self.session
|
||
.borrow()
|
||
.as_ref()
|
||
.map(|(creds, session, _)| (creds.clone(), session.user_id.clone()))
|
||
}
|
||
|
||
/// Narrow the grid to a collection, or to the whole library with `None`.
|
||
pub fn set_scope(&self, scope: Option<dr_types::CollectionId>) {
|
||
*self.scope.borrow_mut() = scope;
|
||
// Selecting a collection is leaving the trash. Without this, picking a
|
||
// collection while the trash was open would keep listing trashed images
|
||
// under that collection's name.
|
||
self.viewing_trash.set(false);
|
||
// A new scope is a different set of images, so the old window's
|
||
// position and its issued fetches mean nothing.
|
||
*self.offset.borrow_mut() = 0;
|
||
self.requested.borrow_mut().clear();
|
||
}
|
||
|
||
/// TRACES: FR-CAT-15
|
||
/// Show the trash instead of the library.
|
||
///
|
||
/// Clears `scope` as well: the trash is not inside a collection, and leaving
|
||
/// a stale scope set would narrow it to one on the way back out.
|
||
pub fn set_viewing_trash(&self, viewing: bool) {
|
||
self.viewing_trash.set(viewing);
|
||
if viewing {
|
||
*self.scope.borrow_mut() = None;
|
||
}
|
||
*self.offset.borrow_mut() = 0;
|
||
self.requested.borrow_mut().clear();
|
||
}
|
||
}
|
||
|
||
/// Reload the grid for the current scope and offset.
|
||
///
|
||
/// The reload entry point for everything outside this module — a scope change,
|
||
/// or a drop that altered the collection being shown.
|
||
pub fn reload(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
load_window(window, ctl);
|
||
}
|
||
|
||
/// Open a library: show the grid, start a scan, then fill in thumbnails.
|
||
///
|
||
/// Called from the launch screen's "Open library" button — the callback that
|
||
/// until now only logged its intent.
|
||
pub fn open(
|
||
window: &AppWindow,
|
||
ctl: Rc<LibraryController>,
|
||
coll_ctl: Rc<crate::collections_ui::CollectionsController>,
|
||
store: &SessionStore,
|
||
session: Session,
|
||
) {
|
||
let creds = match store.credentials(&session) {
|
||
Ok(c) => c,
|
||
Err(e) => {
|
||
window.set_library_error(format!("credentials: {e}").into());
|
||
window.set_show_library(true);
|
||
return;
|
||
}
|
||
};
|
||
|
||
let filter = session.format_filter();
|
||
*ctl.session.borrow_mut() = Some((creds.clone(), session.clone(), filter.clone()));
|
||
|
||
window.set_show_library(true);
|
||
window.set_library_scanning(true);
|
||
window.set_library_error(slint::SharedString::new());
|
||
window.set_library_status("Starting…".into());
|
||
// Always visible: two folders one letter apart are easy to confuse, and a
|
||
// scan of the wrong one is indistinguishable from a broken scan.
|
||
window.set_library_root_label(
|
||
if session.root.is_empty() {
|
||
format!("{} · whole account", session.user_id)
|
||
} else {
|
||
format!("{}/{}", session.user_id, session.root)
|
||
}
|
||
.into(),
|
||
);
|
||
|
||
// An empty filter would walk the whole tree and match nothing, which looks
|
||
// exactly like a broken scan. Say so instead.
|
||
if filter.is_empty() {
|
||
window.set_library_scanning(false);
|
||
window.set_library_error("No formats selected — tick at least one.".into());
|
||
return;
|
||
}
|
||
|
||
let path = library::catalog_path(&session.server, &session.user_id);
|
||
log::info!(
|
||
"scanning {} for {} format(s) → {}",
|
||
if session.root.is_empty() {
|
||
"<account root>"
|
||
} else {
|
||
&session.root
|
||
},
|
||
filter.iter().count(),
|
||
path.display()
|
||
);
|
||
|
||
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());
|
||
|
||
match Catalog::open(&catalog_path) {
|
||
Ok(cat) => {
|
||
// 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.
|
||
crate::collections_ui::refresh_tree(&w, &coll_ctl, &cat);
|
||
*ctl.catalog.borrow_mut() = Some(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(),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Fill the model from the catalog and start fetching thumbnails.
|
||
///
|
||
/// Reads the window starting at the controller's current offset, which the
|
||
/// scrubber moves. Without a movable offset the grid could only ever show the
|
||
/// first 120 of 23,971 images.
|
||
fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
let borrow = ctl.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
|
||
// Everything below is scoped to the selected collection, if any: the total,
|
||
// the window, and the fetches issued for it. Reading the whole library here
|
||
// and filtering later would fetch thumbnails for images the user is not
|
||
// looking at, which on a remote library is the cost FR-NC-3 exists to
|
||
// avoid.
|
||
let scope = *ctl.scope.borrow();
|
||
let filter = *ctl.filter.borrow();
|
||
// The trash lists what every other view excludes, so it takes its own
|
||
// query rather than another predicate threaded through the scoped one.
|
||
let trash = ctl.viewing_trash.get();
|
||
|
||
// TRACES: FR-NC-6a
|
||
// Whether the newly scoped collection is already pinned. Read here rather
|
||
// than remembered, because a pin outlives the session that made it — on
|
||
// reopening the library the button has to show what the catalog says, not
|
||
// what this run happens to have done.
|
||
window.set_library_scope_pinned(match scope {
|
||
Some(id) => dr_catalog::collections::descendants(catalog.connection(), id)
|
||
.map(|ids| collection_images(catalog, &ids))
|
||
.map(|images| scope_is_pinned(catalog, &images))
|
||
.unwrap_or(false),
|
||
None => false,
|
||
});
|
||
|
||
let total = if trash {
|
||
library::total_trashed(catalog).unwrap_or(0)
|
||
} else {
|
||
library::total_images_scoped(catalog, scope, &filter).unwrap_or(0)
|
||
};
|
||
window.set_library_total(total as i32);
|
||
|
||
// Clamp so a scrub to the very end still fills the window rather than
|
||
// showing a handful of cells.
|
||
let window_size = *ctl.window.borrow();
|
||
let offset = (*ctl.offset.borrow()).min(total.saturating_sub(window_size.min(total)));
|
||
*ctl.offset.borrow_mut() = offset;
|
||
window.set_library_offset(offset as i32);
|
||
|
||
// The date range this window covers, so the scrubber can label itself.
|
||
refresh_timeline(window, catalog, ctl);
|
||
|
||
let cells = if trash {
|
||
library::read_trashed_cells(catalog, offset, window_size)
|
||
} else {
|
||
library::read_cells_scoped(catalog, scope, &filter, offset, window_size)
|
||
};
|
||
let cells = match cells {
|
||
Ok(c) => c,
|
||
Err(e) => {
|
||
window.set_library_error(format!("reading catalog: {e}").into());
|
||
return;
|
||
}
|
||
};
|
||
|
||
// What the window currently spans, in the photographer's own terms.
|
||
let span = cells
|
||
.iter()
|
||
.filter_map(|c| c.captured_at)
|
||
.fold(None::<(i64, i64)>, |acc, t| {
|
||
Some(match acc {
|
||
None => (t, t),
|
||
Some((lo, hi)) => (lo.min(t), hi.max(t)),
|
||
})
|
||
});
|
||
window.set_library_window_label(
|
||
match span {
|
||
Some((lo, hi)) => format!("{} – {}", format_date(lo), format_date(hi)),
|
||
// Nothing here has a date yet: EXIF is read as thumbnails load, so
|
||
// this fills in rather than being an error.
|
||
None => "dates not yet read".to_string(),
|
||
}
|
||
.into(),
|
||
);
|
||
|
||
// Month headings. The grid is ordered by capture time, so without these a
|
||
// wall of thumbnails gives no sense of *when* you are looking — the
|
||
// sidebar says it, but only if you consult it.
|
||
//
|
||
// Marked on the cell that both begins a month and begins a row: a heading
|
||
// stranded mid-row would label the cells to its left, which belong to the
|
||
// previous month.
|
||
let columns = window.get_library_columns().max(1) as usize;
|
||
let mut previous_month: Option<(i64, i64)> = None;
|
||
let headings: Vec<String> = cells
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(i, c)| {
|
||
let Some(t) = c.captured_at else {
|
||
return String::new();
|
||
};
|
||
let (y, m, _, _) = civil_from_unix(t);
|
||
let is_new = previous_month != Some((y, m));
|
||
previous_month = Some((y, m));
|
||
|
||
// A heading is drawn above its row, so it can only sit on a cell
|
||
// that begins one — a heading stranded mid-row would appear to
|
||
// label the cells to its left, which belong to the month before.
|
||
//
|
||
// The first cell of the window always carries one, whichever
|
||
// column it lands in: a scrolled window would otherwise show no
|
||
// date at all until the next month began.
|
||
let begins_row = (i + offset) % columns == 0;
|
||
if i == 0 || (is_new && begins_row) {
|
||
format!("{} {y}", month_name(m))
|
||
} else {
|
||
String::new()
|
||
}
|
||
})
|
||
.collect();
|
||
|
||
let rows: Vec<LibraryCell> = cells
|
||
.iter()
|
||
.zip(headings)
|
||
.map(|(c, heading)| LibraryCell {
|
||
period_heading: heading.into(),
|
||
// A freshly loaded window has no drag in flight.
|
||
lifted: false,
|
||
name: c.name.as_str().into(),
|
||
thumbnail: slint::Image::default(),
|
||
has_thumb: false,
|
||
unavailable: false,
|
||
// 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.requested.borrow_mut().clear();
|
||
// 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,
|
||
// and beside the `requested` clear that already admits the old fetches no
|
||
// longer apply — is what lets `drain_thumbnails` recognise itself as stale.
|
||
ctl.generation.set(ctl.generation.get().wrapping_add(1));
|
||
window.set_library_cells(slint::ModelRc::new(slint::VecModel::from(rows)));
|
||
|
||
// The model is fresh, so the "in this many collections" badges are all zero
|
||
// until refilled. One query for the whole window, not one per cell.
|
||
let ids: Vec<dr_types::ImageId> = cells
|
||
.iter()
|
||
.map(|c| dr_types::ImageId(c.image_id as u64))
|
||
.collect();
|
||
crate::collections_ui::sync_badges(window, catalog, &ids);
|
||
sync_ratings(window, catalog, &ids);
|
||
// The rebuilt cells all carry `selected: false`, but the selection itself
|
||
// is a set of image ids and survives untouched. Without this the ticks
|
||
// vanished on every scroll — the selection was still there and still acted
|
||
// on, which is worse than losing it, because the user cannot see what the
|
||
// buttons are about to do.
|
||
if let Some(coll) = ctl.coll_ctl.borrow().as_ref().and_then(|w| w.upgrade()) {
|
||
crate::collections_ui::sync_selection(window, &coll, &ids);
|
||
}
|
||
// The filter chips' counts describe the whole library, not this window, so
|
||
// they are refreshed here rather than per cell.
|
||
refresh_rating_counts(window, catalog);
|
||
|
||
if total == 0 {
|
||
return;
|
||
}
|
||
request_thumbnails(window, ctl);
|
||
}
|
||
|
||
/// Push each visible image's stars and flag into the grid model.
|
||
///
|
||
/// One query for the window, mirroring `collections_ui::sync_badges` — 120
|
||
/// cells is 120 round trips otherwise, on every scroll and after every
|
||
/// keystroke.
|
||
pub fn sync_ratings(window: &AppWindow, catalog: &Catalog, ids: &[dr_types::ImageId]) {
|
||
if ids.is_empty() {
|
||
return;
|
||
}
|
||
|
||
let found = match dr_catalog::rating::judgements(catalog.connection(), ids) {
|
||
Ok(j) => j,
|
||
Err(e) => {
|
||
// The grid is still usable without stars, so this is logged rather
|
||
// than surfaced — a failure here must not blank the library.
|
||
log::debug!("reading ratings: {e}");
|
||
return;
|
||
}
|
||
};
|
||
|
||
let model = window.get_library_cells();
|
||
for (row, id) in ids.iter().enumerate() {
|
||
// Absent means unrated, which is a real state rather than missing data.
|
||
let j = found.get(id).copied().unwrap_or_default();
|
||
let (rating, flag) = (j.rating as i32, flag_code(j.flag));
|
||
|
||
if let Some(mut cell) = model.row_data(row) {
|
||
if cell.rating != rating || cell.flag != flag {
|
||
cell.rating = rating;
|
||
cell.flag = flag;
|
||
model.set_row_data(row, cell);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Refresh the filter chips' per-star counts.
|
||
///
|
||
/// Whole-library figures, deliberately: they say what narrowing to a filter
|
||
/// would show, so computing them over the current window would make each chip
|
||
/// describe the view it is meant to change.
|
||
fn refresh_rating_counts(window: &AppWindow, catalog: &Catalog) {
|
||
let counts = dr_catalog::rating::rating_histogram(catalog.connection()).unwrap_or_default();
|
||
let as_i32: Vec<i32> = counts.iter().map(|n| *n as i32).collect();
|
||
window.set_library_rating_counts(slint::ModelRc::new(slint::VecModel::from(as_i32)));
|
||
|
||
// TRACES: FR-CAT-9
|
||
// How many originals are actually here, for the "On this device" chip.
|
||
// Shown for the same reason the star counts are: a filter that silently
|
||
// empties the grid reads as broken, and this one will legitimately be zero
|
||
// on a library nothing has been downloaded from yet.
|
||
window.set_library_local_count(library::local_original_count(catalog).unwrap_or(0) as i32);
|
||
}
|
||
|
||
/// Apply a judgement to a set of images: catalog first, then sidecars.
|
||
///
|
||
/// # Order matters
|
||
///
|
||
/// The catalog is written **synchronously and first**, so the star appears
|
||
/// immediately and survives a restart even if the network is down. The sidecar
|
||
/// write is queued behind it on a worker thread — it is what makes the
|
||
/// judgement survive a *catalog rebuild* (ARCH §6.12), which is a slower and
|
||
/// rarer concern than the user seeing their keystroke take effect.
|
||
///
|
||
/// Doing it the other way round would mean a cull that stalls on every
|
||
/// keypress waiting for a round trip, on a workflow whose entire premise is
|
||
/// speed (FR-CULL-1).
|
||
fn apply_judgement(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
images: &[dr_types::ImageId],
|
||
rating: Option<u8>,
|
||
flag: Option<dr_types::FlagState>,
|
||
) {
|
||
if images.is_empty() {
|
||
// Nothing selected. Said out loud rather than ignored: a keystroke
|
||
// that silently does nothing reads as a broken key.
|
||
window.set_library_status("Select an image first".into());
|
||
return;
|
||
}
|
||
|
||
let writes = {
|
||
let borrow = ctl.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
let conn = catalog.connection();
|
||
|
||
let wrote = match (rating, flag) {
|
||
(Some(r), _) => dr_catalog::rating::set_rating_many(conn, images, r),
|
||
(_, Some(f)) => dr_catalog::rating::set_flag_many(conn, images, f),
|
||
// Neither axis named: nothing to do, and not an error.
|
||
(None, None) => return,
|
||
};
|
||
|
||
if let Err(e) = wrote {
|
||
window.set_library_error(format!("recording rating: {e}").into());
|
||
return;
|
||
}
|
||
|
||
// Report what happened, in the user's terms rather than as a count of
|
||
// rows. A bulk judgement on a selection is easy to trigger by accident
|
||
// and the status line is the only confirmation of its extent.
|
||
window.set_library_status(judgement_summary(images.len(), rating, flag).into());
|
||
|
||
// Refresh the grid and the chips from what actually landed, rather
|
||
// than assuming the write took: a clamped or coalesced value must show
|
||
// as what is stored.
|
||
let visible = ctl.visible_ids();
|
||
sync_ratings(window, catalog, &visible);
|
||
refresh_rating_counts(window, catalog);
|
||
|
||
collect_sidecar_writes(catalog, images)
|
||
};
|
||
|
||
// A filtered grid may no longer contain what was just judged — rating an
|
||
// image 2 while showing "★4+" means it belongs elsewhere now. Reloading
|
||
// keeps the cells and the header count honest.
|
||
if !ctl.filter.borrow().is_unfiltered() {
|
||
load_window(window, ctl);
|
||
}
|
||
|
||
start_sidecar_writes(window, ctl, writes);
|
||
}
|
||
|
||
/// What the status line says about a judgement that just landed.
|
||
fn judgement_summary(n: usize, rating: Option<u8>, flag: Option<dr_types::FlagState>) -> String {
|
||
let what = match (rating, flag) {
|
||
(Some(0), _) => "unrated".to_string(),
|
||
(Some(r), _) => format!("{r} star{}", if r == 1 { "" } else { "s" }),
|
||
(_, Some(dr_types::FlagState::Pick)) => "picked".to_string(),
|
||
(_, Some(dr_types::FlagState::Reject)) => "rejected".to_string(),
|
||
(_, Some(dr_types::FlagState::Unflagged)) => "unflagged".to_string(),
|
||
(None, None) => return String::new(),
|
||
};
|
||
if n == 1 {
|
||
what
|
||
} else {
|
||
format!("{n} images · {what}")
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-DEV-6
|
||
/// Apply copied develop settings to a selection of images.
|
||
///
|
||
/// # Why this goes straight to the sidecars
|
||
///
|
||
/// The sidecar is the authoritative store for an edit (ARCH §6.12) and the
|
||
/// catalog holds no parameters at all — only a `graph_hash` — so there is
|
||
/// nothing here for the catalog to record. Nor is any image opened: applying
|
||
/// to forty frames by loading forty develop sessions would mean forty RAW
|
||
/// downloads and forty demosaics to move some numbers between two maps, which
|
||
/// is not a thing to ask of a phone. See [`crate::presets`].
|
||
///
|
||
/// # What the user sees
|
||
///
|
||
/// Nothing in the grid changes — a thumbnail is rendered from the server's
|
||
/// preview and does not reflect an edit — so the status line is the only
|
||
/// confirmation, exactly as it is for a bulk judgement. The applied settings
|
||
/// appear when a target is next opened in develop, which is what reads the
|
||
/// sidecar back.
|
||
pub fn paste_settings_to_selection(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
images: &[dr_types::ImageId],
|
||
preset: &dr_pipeline::Preset,
|
||
scope: dr_pipeline::Scope,
|
||
) {
|
||
if images.is_empty() {
|
||
// Said out loud rather than ignored, matching what a judgement
|
||
// keystroke does with an empty selection.
|
||
window.set_library_status("Select an image first".into());
|
||
return;
|
||
}
|
||
|
||
let writes = {
|
||
let borrow = ctl.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
// A never-judged image may have no version row yet, and the query
|
||
// below joins on one. Ratings create them as a side effect; a paste
|
||
// is the first write path that can reach an image which has never
|
||
// been rated, so it has to ask for them itself.
|
||
if let Err(e) = dr_catalog::rating::ensure_default_versions(catalog.connection()) {
|
||
log::debug!("ensuring versions before a paste: {e}");
|
||
}
|
||
collect_settings_writes(catalog, images, preset, scope)
|
||
};
|
||
|
||
if writes.is_empty() {
|
||
window.set_library_error("Could not find those images in the catalog.".into());
|
||
return;
|
||
}
|
||
|
||
let count = writes.len();
|
||
window.set_library_status(
|
||
format!(
|
||
"Applied settings to {count} image{}.",
|
||
if count == 1 { "" } else { "s" }
|
||
)
|
||
.into(),
|
||
);
|
||
start_sidecar_writes(window, ctl, writes);
|
||
}
|
||
|
||
/// Gather one settings write per image, addressed by remote path and version.
|
||
///
|
||
/// The uuid comes from the catalog for the same reason a judgement's does: it
|
||
/// is the identity a cross-device merge keys on, and a generated one would
|
||
/// write a second version beside the one the image already has (FR-NC-8).
|
||
fn collect_settings_writes(
|
||
catalog: &Catalog,
|
||
images: &[dr_types::ImageId],
|
||
preset: &dr_pipeline::Preset,
|
||
scope: dr_pipeline::Scope,
|
||
) -> Vec<library::SidecarWrite> {
|
||
let placeholders = std::iter::repeat_n("?", images.len())
|
||
.collect::<Vec<_>>()
|
||
.join(",");
|
||
let sql = format!(
|
||
"SELECT i.source_ref, v.uuid
|
||
FROM images i
|
||
JOIN versions v ON v.image_id = i.id AND v.is_default = 1
|
||
WHERE i.id IN ({placeholders})"
|
||
);
|
||
let params: Vec<rusqlite::types::Value> = images
|
||
.iter()
|
||
.map(|i| rusqlite::types::Value::Integer(i.0 as i64))
|
||
.collect();
|
||
|
||
let Ok(mut stmt) = catalog.connection().prepare(&sql) else {
|
||
return Vec::new();
|
||
};
|
||
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
||
Ok(library::SidecarWrite {
|
||
image_path: r.get(0)?,
|
||
version_uuid: r.get(1)?,
|
||
amendment: library::Amendment::Settings {
|
||
preset: preset.clone(),
|
||
scope,
|
||
},
|
||
})
|
||
});
|
||
|
||
match rows {
|
||
Ok(rows) => rows.flatten().collect(),
|
||
Err(e) => {
|
||
log::debug!("collecting settings writes: {e}");
|
||
Vec::new()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Gather what the sidecar writer needs for each judged image.
|
||
///
|
||
/// The version uuid comes from the catalog rather than being generated here:
|
||
/// it is the identity a cross-device merge keys on, so the sidecar and the
|
||
/// catalog must name the same version or a sync would treat one photograph's
|
||
/// judgement as two (FR-NC-8).
|
||
fn collect_sidecar_writes(
|
||
catalog: &Catalog,
|
||
images: &[dr_types::ImageId],
|
||
) -> Vec<library::SidecarWrite> {
|
||
let placeholders = std::iter::repeat_n("?", images.len())
|
||
.collect::<Vec<_>>()
|
||
.join(",");
|
||
let sql = format!(
|
||
"SELECT i.source_ref, v.uuid, v.rating, v.flag
|
||
FROM images i
|
||
JOIN versions v ON v.image_id = i.id AND v.is_default = 1
|
||
WHERE i.id IN ({placeholders})"
|
||
);
|
||
let params: Vec<rusqlite::types::Value> = images
|
||
.iter()
|
||
.map(|i| rusqlite::types::Value::Integer(i.0 as i64))
|
||
.collect();
|
||
|
||
let Ok(mut stmt) = catalog.connection().prepare(&sql) else {
|
||
return Vec::new();
|
||
};
|
||
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
||
Ok(library::SidecarWrite {
|
||
image_path: r.get(0)?,
|
||
version_uuid: r.get(1)?,
|
||
amendment: library::Amendment::Judgement {
|
||
rating: r.get::<_, i64>(2)? as u8,
|
||
flag: r.get::<_, i64>(3)? as u8,
|
||
},
|
||
})
|
||
});
|
||
|
||
match rows {
|
||
Ok(rows) => rows.flatten().collect(),
|
||
Err(e) => {
|
||
log::debug!("collecting sidecar writes: {e}");
|
||
Vec::new()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Push judgements out to sidecars on a worker, reporting once at the end.
|
||
pub(crate) fn start_sidecar_writes(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
writes: Vec<library::SidecarWrite>,
|
||
) {
|
||
if writes.is_empty() {
|
||
return;
|
||
}
|
||
|
||
// TRACES: FR-CAT-9
|
||
// Offline is passed down rather than used to skip.
|
||
//
|
||
// It used to skip, and the reasoning was that a cull stays responsive
|
||
// because the rating is safe in the catalog. That held for judgements and
|
||
// not for edits: the catalog stores no parameters, so a pasted edit made
|
||
// offline survived nowhere at all. Every write now commits to the local
|
||
// sidecar cache first and the upload is best-effort, which keeps the
|
||
// keystroke path off the network — the original concern — without the
|
||
// write being conditional on it.
|
||
let offline = ctl.is_offline();
|
||
|
||
let Some((creds, session, _)) = ctl.session.borrow().clone() else {
|
||
return;
|
||
};
|
||
let Some(cache_dir) = ctl.sidecar_cache_dir() else {
|
||
return;
|
||
};
|
||
|
||
let count = writes.len();
|
||
let rx =
|
||
library::spawn_sidecar_writes(creds, session.user_id.clone(), writes, cache_dir, offline);
|
||
|
||
let timer = slint::Timer::default();
|
||
let weak = window.as_weak();
|
||
let ctl_cb = ctl.clone();
|
||
|
||
// The writer reports once at the end, so there is no per-file progress to
|
||
// show — but a cull that has just rated forty frames has forty uploads in
|
||
// flight, and "is that saved yet" deserves an answer somewhere.
|
||
let job = ctl.activity.begin(
|
||
crate::activity::Kind::Upload,
|
||
format!("Saving {count} judgement(s)"),
|
||
);
|
||
|
||
timer.start(
|
||
slint::TimerMode::Repeated,
|
||
std::time::Duration::from_millis(200),
|
||
move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
// One message is all this channel ever carries — the writer reports
|
||
// `Finished` once and hangs up — so this drains a single item rather
|
||
// than looping like the scan and thumbnail drains do.
|
||
match rx.try_recv() {
|
||
Ok(library::SidecarMessage::Finished {
|
||
written,
|
||
queued,
|
||
failed,
|
||
last_error,
|
||
}) => {
|
||
if failed == 0 && queued > 0 {
|
||
// Recorded locally, waiting for the server. Said out
|
||
// loud because the user has just made an edit with no
|
||
// connection and deserves to know it is safe — the
|
||
// old behaviour here was to drop it silently.
|
||
log::debug!("{queued} sidecar(s) queued for upload");
|
||
ctl_cb.outbox_maybe_dirty.set(true);
|
||
job.finish_quietly();
|
||
w.set_library_status(
|
||
format!("{queued} edit(s) saved · will upload when back online").into(),
|
||
);
|
||
stop(&ctl_cb.sidecar_timer);
|
||
return;
|
||
}
|
||
if failed > 0 {
|
||
// A failed upload left the edit in the outbox.
|
||
ctl_cb.outbox_maybe_dirty.set(true);
|
||
log::warn!(
|
||
"{failed} sidecar write(s) failed: {}",
|
||
last_error.clone().unwrap_or_default()
|
||
);
|
||
job.fail(format!(
|
||
"{written} saved · {failed} failed: {}",
|
||
last_error.clone().unwrap_or_default()
|
||
));
|
||
// Said plainly, because the consequence is specific:
|
||
// the rating is safe in the catalog but will not
|
||
// survive deleting it.
|
||
w.set_library_status(
|
||
format!(
|
||
"{written} saved · {failed} could not be written \
|
||
to the library folder"
|
||
)
|
||
.into(),
|
||
);
|
||
} else {
|
||
log::debug!("{written} sidecar(s) written");
|
||
// Quietly: a cull produces one of these every few
|
||
// seconds and none of them is news.
|
||
job.finish_quietly();
|
||
}
|
||
stop(&ctl_cb.sidecar_timer);
|
||
}
|
||
Err(std::sync::mpsc::TryRecvError::Empty) => {}
|
||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||
stop(&ctl_cb.sidecar_timer);
|
||
}
|
||
}
|
||
},
|
||
);
|
||
|
||
*ctl.sidecar_timer.borrow_mut() = Some(timer);
|
||
}
|
||
|
||
/// Slint carries the flag as an integer, matching the catalog's encoding.
|
||
fn flag_code(f: dr_types::FlagState) -> i32 {
|
||
match f {
|
||
dr_types::FlagState::Unflagged => 0,
|
||
dr_types::FlagState::Pick => 1,
|
||
dr_types::FlagState::Reject => 2,
|
||
}
|
||
}
|
||
|
||
/// The flag an integer from Slint stands for.
|
||
fn flag_from_code(v: i32) -> dr_types::FlagState {
|
||
match v {
|
||
1 => dr_types::FlagState::Pick,
|
||
2 => dr_types::FlagState::Reject,
|
||
_ => dr_types::FlagState::Unflagged,
|
||
}
|
||
}
|
||
|
||
/// Fetch thumbnails for rows in the model that do not have one yet.
|
||
fn request_thumbnails(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
let Some((creds, session, _)) = ctl.session.borrow().clone() else {
|
||
return;
|
||
};
|
||
|
||
let wanted: Vec<library::ThumbnailRequest> = {
|
||
// The drawn cell size decides which class to ask for.
|
||
let cell_pixels = window.get_library_cell_size().max(1.0) as u32;
|
||
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)?;
|
||
// Chosen from how large the cell is actually drawn, so 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 = dr_thumbs::ThumbSize::for_cell(cell_pixels);
|
||
// 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);
|
||
}
|
||
|
||
/// Apply thumbnails to the model as they arrive.
|
||
fn drain_thumbnails(
|
||
weak: slint::Weak<AppWindow>,
|
||
ctl: Rc<LibraryController>,
|
||
rx: Receiver<ThumbnailMessage>,
|
||
requested: usize,
|
||
) {
|
||
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);
|
||
}
|
||
}
|
||
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);
|
||
}
|
||
}
|
||
// TRACES: FR-CAT-9
|
||
ThumbnailMessage::Offline { reason } => {
|
||
log::info!("thumbnails stopped: {reason}");
|
||
// The batch is over, so the bar must not be left
|
||
// showing a partial fetch that will never finish — it
|
||
// would sweep for ever.
|
||
job.fail(reason.clone());
|
||
ctl_cb
|
||
.reachability
|
||
.borrow_mut()
|
||
.mark_unreachable(reason, std::time::Instant::now());
|
||
refresh_offline(&w, &ctl_cb);
|
||
|
||
// Cells left without pixels stay placeholders rather
|
||
// than being marked unavailable: the images are fine,
|
||
// and a reconnect should fill them in. Marking them
|
||
// would persist a verdict about the *file* from an
|
||
// event about the *connection*.
|
||
stop(&ctl_cb.thumb_timer);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
},
|
||
);
|
||
|
||
*ctl.thumb_timer.borrow_mut() = Some(timer);
|
||
}
|
||
|
||
/// Move the timeline's zoom by whole levels.
|
||
///
|
||
/// Shared by the wheel and the pinch so the two cannot drift apart in how they
|
||
/// clamp, or in where they choose to centre.
|
||
fn apply_zoom(window: &AppWindow, ctl: &Rc<LibraryController>, delta: i32) {
|
||
let borrow = ctl.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
|
||
// Bounded: past ~2^12 the window is minutes wide and every bucket is
|
||
// empty, which reads as a broken axis rather than a deep zoom.
|
||
let next = (*ctl.timeline_zoom.borrow() + delta).clamp(0, 12);
|
||
if next == *ctl.timeline_zoom.borrow() {
|
||
return;
|
||
}
|
||
*ctl.timeline_zoom.borrow_mut() = next;
|
||
|
||
// Zooming fully out forgets the centre, so the axis returns to describing
|
||
// the whole library rather than a remembered position.
|
||
if next == 0 {
|
||
*ctl.timeline_centre.borrow_mut() = None;
|
||
} else if ctl.timeline_centre.borrow().is_none() {
|
||
// First zoom centres on wherever the grid is, else the middle.
|
||
*ctl.timeline_centre.borrow_mut() = *ctl.current_bucket.borrow();
|
||
}
|
||
refresh_timeline(window, catalog, ctl);
|
||
}
|
||
|
||
/// Push shards and the catalog to the server, and take what it has.
|
||
///
|
||
/// Fired after the sweep completes, when there is a finished index worth
|
||
/// sharing, and from the Sync button for an explicit exchange.
|
||
fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
let Some((creds, session, _)) = ctl.session.borrow().clone() else {
|
||
return;
|
||
};
|
||
// Already running: a second pass would race the first over the same
|
||
// scratch files.
|
||
if ctl.sync_timer.borrow().is_some() && window.get_library_syncing() {
|
||
return;
|
||
}
|
||
|
||
// TRACES: FR-CAT-9
|
||
// Nothing to push to and nothing to take. Attempting it would upload
|
||
// shards into a timeout and light the "Syncing…" indicator over work that
|
||
// cannot start; the shards are unchanged on disk and go out on the next
|
||
// sync once the server is back.
|
||
if ctl.is_offline() {
|
||
log::debug!("offline: skipping derived sync");
|
||
return;
|
||
}
|
||
|
||
let catalog_path = library::catalog_path(&session.server, &session.user_id);
|
||
let scratch = catalog_path
|
||
.parent()
|
||
.map(|p| p.join("scratch"))
|
||
.unwrap_or_else(std::env::temp_dir);
|
||
let _ = std::fs::create_dir_all(&scratch);
|
||
|
||
// TRACES: FR-EXP-7 | FR-NC-10
|
||
// Drain the export outbox on the same pass, and before the shards. An
|
||
// export the user was told had succeeded is waiting here, and it is the
|
||
// one thing in this directory that exists nowhere else — a thumbnail
|
||
// shard can be rebuilt from the originals, and the catalog is an index.
|
||
//
|
||
// Fire-and-forget rather than reported: it runs on its own thread and
|
||
// clears entries as they land, so a partial run leaves the rest queued
|
||
// for next time and nothing is lost by not watching it. It reports
|
||
// through the log until an export has a place in the activity list.
|
||
{
|
||
let outbox = crate::export::outbox_dir(&session.server, &session.user_id);
|
||
if crate::export::pending_count(&outbox) > 0 {
|
||
let rx = crate::export::spawn_upload(
|
||
creds.clone(),
|
||
session.user_id.clone(),
|
||
session.root.clone(),
|
||
outbox,
|
||
);
|
||
std::thread::spawn(move || {
|
||
while let Ok(msg) = rx.recv() {
|
||
match msg {
|
||
crate::export::UploadMessage::Status(s) => log::info!("export: {s}"),
|
||
crate::export::UploadMessage::Finished {
|
||
uploaded,
|
||
remaining,
|
||
error,
|
||
} => {
|
||
log::info!("export: {uploaded} uploaded, {remaining} still queued");
|
||
if let Some(e) = error {
|
||
log::warn!("export upload stopped: {e}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
window.set_library_syncing(true);
|
||
let rx = crate::derived_sync::spawn_sync(
|
||
creds,
|
||
session.user_id.clone(),
|
||
session.root.clone(),
|
||
library::thumbs_dir(&session.server, &session.user_id),
|
||
catalog_path,
|
||
scratch,
|
||
);
|
||
|
||
let timer = slint::Timer::default();
|
||
let weak = window.as_weak();
|
||
let ctl_cb = ctl.clone();
|
||
|
||
// The sync reports stages rather than counts, so it stays indeterminate and
|
||
// says what stage it is in.
|
||
let job = ctl
|
||
.activity
|
||
.begin(crate::activity::Kind::Sync, "Syncing with the server");
|
||
|
||
timer.start(
|
||
slint::TimerMode::Repeated,
|
||
std::time::Duration::from_millis(300),
|
||
move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
loop {
|
||
let msg = match rx.try_recv() {
|
||
Ok(m) => m,
|
||
Err(std::sync::mpsc::TryRecvError::Empty) => return,
|
||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||
w.set_library_syncing(false);
|
||
job.fail("stopped without finishing");
|
||
stop(&ctl_cb.sync_timer);
|
||
return;
|
||
}
|
||
};
|
||
|
||
match msg {
|
||
crate::derived_sync::SyncMessage::Status(s) => {
|
||
job.detail(s.clone());
|
||
w.set_library_status(s.into());
|
||
}
|
||
crate::derived_sync::SyncMessage::Finished(report) => {
|
||
log::info!(
|
||
"sync: {} shard(s) up, {} down ({} thumbnails), \
|
||
catalog {}{}",
|
||
report.shards_uploaded,
|
||
report.shards_downloaded,
|
||
report.thumbnails_adopted,
|
||
if report.catalog_uploaded {
|
||
"pushed"
|
||
} else {
|
||
"not pushed"
|
||
},
|
||
if report.collections_gained > 0 {
|
||
format!(", {} collection(s) gained", report.collections_gained)
|
||
} else {
|
||
String::new()
|
||
}
|
||
);
|
||
w.set_library_syncing(false);
|
||
let summary = format!(
|
||
"{} shard(s) up, {} down",
|
||
report.shards_uploaded, report.shards_downloaded
|
||
);
|
||
job.finish(if report.did_anything() {
|
||
summary.clone()
|
||
} else {
|
||
"nothing to exchange".to_string()
|
||
});
|
||
if report.did_anything() {
|
||
w.set_library_status(format!("synced · {summary}").into());
|
||
}
|
||
// Adopted thumbnails and merged collections both change
|
||
// what the grid should show.
|
||
if report.thumbnails_adopted > 0 || report.collections_gained > 0 {
|
||
load_window(&w, &ctl_cb);
|
||
}
|
||
stop(&ctl_cb.sync_timer);
|
||
return;
|
||
}
|
||
crate::derived_sync::SyncMessage::Failed(e) => {
|
||
log::warn!("sync failed: {e}");
|
||
job.fail(e.to_string());
|
||
w.set_library_syncing(false);
|
||
// Not an error banner: a failed sync costs nothing —
|
||
// everything is still local and the next pass retries.
|
||
w.set_library_status(format!("sync failed: {e}").into());
|
||
stop(&ctl_cb.sync_timer);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
},
|
||
);
|
||
|
||
*ctl.sync_timer.borrow_mut() = Some(timer);
|
||
}
|
||
|
||
/// Start the whole-library sweep and report its progress.
|
||
///
|
||
/// The grid only ever fetches what is on screen, so without this the timeline
|
||
/// describes the fraction of the library that happened to be scrolled past.
|
||
/// This covers the rest.
|
||
fn start_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
let Some((creds, session, _)) = ctl.session.borrow().clone() else {
|
||
return;
|
||
};
|
||
|
||
// TRACES: FR-CAT-9
|
||
// The sweep is nothing but network reads — one header fetch per undated
|
||
// image, across the whole library. Offline it would spend a timeout on
|
||
// every one of them, running for hours to learn nothing, while the
|
||
// progress bar implied work was happening. It resumes on reconnect, and
|
||
// the images it has already dated stay dated.
|
||
if ctl.is_offline() {
|
||
log::debug!("offline: not starting the metadata sweep");
|
||
return;
|
||
}
|
||
|
||
let rx = library::spawn_sweep(
|
||
creds,
|
||
session.user_id.clone(),
|
||
library::catalog_path(&session.server, &session.user_id),
|
||
);
|
||
|
||
let timer = slint::Timer::default();
|
||
let weak = window.as_weak();
|
||
let ctl_cb = ctl.clone();
|
||
|
||
// Hours on a large library, and entirely invisible outside the grid until
|
||
// now: the register is where a user who has gone to develop can still see
|
||
// that indexing is running and how far it has got.
|
||
let job = ctl
|
||
.activity
|
||
.begin(crate::activity::Kind::Index, "Indexing capture times");
|
||
|
||
timer.start(
|
||
slint::TimerMode::Repeated,
|
||
// Slower than the thumbnail drain: this runs for tens of minutes and
|
||
// its progress does not need per-frame accuracy.
|
||
std::time::Duration::from_millis(400),
|
||
move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
|
||
loop {
|
||
let msg = match rx.try_recv() {
|
||
Ok(m) => m,
|
||
Err(std::sync::mpsc::TryRecvError::Empty) => return,
|
||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||
w.set_library_sweep_total(0);
|
||
job.fail("stopped without finishing");
|
||
stop(&ctl_cb.sweep_timer);
|
||
return;
|
||
}
|
||
};
|
||
|
||
match msg {
|
||
library::SweepMessage::Total(n) => {
|
||
w.set_library_sweep_total(n as i32);
|
||
w.set_library_sweep_done(0);
|
||
job.total(n);
|
||
}
|
||
library::SweepMessage::Progress { done, dated } => {
|
||
w.set_library_sweep_done(done as i32);
|
||
job.progress(done, w.get_library_sweep_total() as usize);
|
||
// Rebuild as it goes: the histogram growing while the
|
||
// sweep runs is the visible sign it is working.
|
||
if dated > 0 {
|
||
let borrow = ctl_cb.catalog.borrow();
|
||
if let Some(catalog) = borrow.as_ref() {
|
||
refresh_timeline(&w, catalog, &ctl_cb);
|
||
}
|
||
}
|
||
}
|
||
library::SweepMessage::Finished { dated } => {
|
||
log::info!("sweep finished: {dated} dated");
|
||
job.finish(format!("{dated} dated"));
|
||
w.set_library_sweep_total(0);
|
||
{
|
||
let borrow = ctl_cb.catalog.borrow();
|
||
if let Some(catalog) = borrow.as_ref() {
|
||
refresh_timeline(&w, catalog, &ctl_cb);
|
||
}
|
||
}
|
||
// Now that indexing is complete, hand the result to the
|
||
// server so a second device inherits it rather than
|
||
// repeating hours of range fetches.
|
||
start_derived_sync(&w, &ctl_cb);
|
||
stop(&ctl_cb.sweep_timer);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
},
|
||
);
|
||
|
||
*ctl.sweep_timer.borrow_mut() = Some(timer);
|
||
}
|
||
|
||
/// Rebuild the timeline histogram from the catalog.
|
||
///
|
||
/// Bucket size follows the span of the library, the way darktable's does:
|
||
/// a decade of photographs buckets by year, a single trip by day. Picking it
|
||
/// from the data rather than fixing it means the histogram is informative at
|
||
/// both scales instead of one flat bar or ten thousand slivers.
|
||
fn refresh_timeline(window: &AppWindow, catalog: &Catalog, ctl: &Rc<LibraryController>) {
|
||
// Scoped to whatever the grid is showing. A collection's histogram drawn
|
||
// over the whole library's span said almost nothing: every bar for a
|
||
// fortnight in Arosa landed in one column of a fifteen-year axis.
|
||
let scope = *ctl.scope.borrow();
|
||
let filter = *ctl.filter.borrow();
|
||
let span = match library::span_scoped(catalog, scope, &filter) {
|
||
Some(s) => s,
|
||
None => {
|
||
// No dated images yet. An empty histogram is honest — EXIF is read
|
||
// as thumbnails load, so this populates as the user browses.
|
||
window.set_library_timeline(slint::ModelRc::new(slint::VecModel::from(vec![])));
|
||
window.set_library_timeline_label(slint::SharedString::new());
|
||
return;
|
||
}
|
||
};
|
||
|
||
// Zoom narrows the span around wherever the view sits rather than around
|
||
// the library's midpoint, so zooming in keeps what you were looking at.
|
||
let zoom = *ctl.timeline_zoom.borrow();
|
||
let (from, to) = zoomed_span(span, zoom, *ctl.timeline_centre.borrow());
|
||
|
||
let granularity = dr_catalog::Granularity::for_span(to - from);
|
||
let buckets = match library::timeline_scoped(catalog, scope, &filter, granularity, from, to) {
|
||
Ok(b) => b,
|
||
Err(e) => {
|
||
log::debug!("timeline: {e}");
|
||
return;
|
||
}
|
||
};
|
||
|
||
// Normalise against the tallest bar. Counts vary by orders of magnitude
|
||
// between a quiet month and a wedding, so a linear scale against the total
|
||
// would render most buckets invisible.
|
||
let peak = buckets.iter().map(|b| b.count).max().unwrap_or(1).max(1);
|
||
let mut previous: Option<(i64, i64)> = None;
|
||
|
||
let bars: Vec<TimelineBar> = buckets
|
||
.iter()
|
||
.map(|b| {
|
||
let (y, m, _, _) = civil_from_unix(b.start);
|
||
// Label only where a period begins, so a month-bucketed axis reads
|
||
// "2024 … Mar … Apr" rather than repeating the year on every bar.
|
||
let period = match previous {
|
||
None => format!("{y}"),
|
||
Some((py, _)) if py != y => format!("{y}"),
|
||
Some((_, pm)) if pm != m && granularity_labels_months(granularity) => {
|
||
month_abbrev(m).to_string()
|
||
}
|
||
_ => String::new(),
|
||
};
|
||
previous = Some((y, m));
|
||
|
||
TimelineBar {
|
||
// Square root rather than linear: it keeps a 3-image day
|
||
// visible beside a 400-image one without a log scale's
|
||
// misleading flatness.
|
||
height: ((b.count as f32 / peak as f32).sqrt()).clamp(0.02, 1.0),
|
||
start: b.start as i32,
|
||
count: b.count as i32,
|
||
label: format_bucket(b.start, granularity).into(),
|
||
period_label: period.into(),
|
||
}
|
||
})
|
||
.collect();
|
||
|
||
// Where the grid currently sits, as a fraction of the visible span.
|
||
//
|
||
// This is the exact inverse of what a click produces: the widget maps a y
|
||
// to a fraction of its track and `on_library_scrub_fraction` interpolates
|
||
// `from + (to - from) * f`, so the marker must invert that same expression
|
||
// or it lands somewhere other than the pointer.
|
||
//
|
||
// The earlier version sent a *bar index* instead. Bars occupy one equal
|
||
// slot each regardless of how much time they cover, and `rposition` snaps
|
||
// to the bucket's start edge, so the marker sat at the top of whichever
|
||
// slot contained the instant — near enough on a dense uniform axis, plainly
|
||
// wrong on a sparse one, and never under the click.
|
||
let current = *ctl.current_bucket.borrow();
|
||
let fraction = current
|
||
.map(|t| ((t - from) as f64 / (to - from).max(1) as f64).clamp(0.0, 1.0) as f32)
|
||
.unwrap_or(-1.0);
|
||
|
||
window.set_library_current_bucket(current.unwrap_or(0) as i32);
|
||
window.set_library_current_fraction(fraction);
|
||
window.set_library_timeline_anchored(current.is_some());
|
||
window
|
||
.set_library_timeline_label(format!("{} – {}", format_date(from), format_date(to)).into());
|
||
window.set_library_timeline(slint::ModelRc::new(slint::VecModel::from(bars)));
|
||
}
|
||
|
||
/// Whether this bucket size is fine enough for month labels to mean anything.
|
||
///
|
||
/// A year-bucketed axis labelled by month would put twelve labels on one bar.
|
||
fn granularity_labels_months(g: dr_catalog::Granularity) -> bool {
|
||
!matches!(g, dr_catalog::Granularity::Year)
|
||
}
|
||
|
||
/// Full month name, for the grid's headings.
|
||
fn month_name(m: i64) -> &'static str {
|
||
const NAMES: [&str; 12] = [
|
||
"January",
|
||
"February",
|
||
"March",
|
||
"April",
|
||
"May",
|
||
"June",
|
||
"July",
|
||
"August",
|
||
"September",
|
||
"October",
|
||
"November",
|
||
"December",
|
||
];
|
||
NAMES[((m - 1).clamp(0, 11)) as usize]
|
||
}
|
||
|
||
fn month_abbrev(m: i64) -> &'static str {
|
||
const NAMES: [&str; 12] = [
|
||
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||
];
|
||
NAMES[((m - 1).clamp(0, 11)) as usize]
|
||
}
|
||
|
||
/// Narrow a span by a zoom level, centred on `centre`.
|
||
///
|
||
/// Each step halves or doubles the visible duration. Clamped to the library's
|
||
/// own extent so zooming out cannot wander past the first or last photograph.
|
||
fn zoomed_span(full: (i64, i64), zoom: i32, centre: Option<i64>) -> (i64, i64) {
|
||
let (lo, hi) = full;
|
||
if zoom <= 0 {
|
||
return (lo, hi);
|
||
}
|
||
let duration = (hi - lo).max(1);
|
||
// 2^zoom, saturating: a very deep zoom must not shift the duration to zero.
|
||
let factor = 1i64 << zoom.min(20);
|
||
let window = (duration / factor).max(3600);
|
||
|
||
let mid = centre.unwrap_or(lo + duration / 2);
|
||
let half = window / 2;
|
||
let (mut from, mut to) = (mid - half, mid + half);
|
||
|
||
// Slide rather than shrink at the ends, so the window keeps its size.
|
||
if from < lo {
|
||
to += lo - from;
|
||
from = lo;
|
||
}
|
||
if to > hi {
|
||
from -= to - hi;
|
||
to = hi;
|
||
}
|
||
(from.max(lo), to.min(hi))
|
||
}
|
||
|
||
/// Earliest and latest capture time in the catalog.
|
||
/// The timeline's extent, over exactly the images the grid is showing.
|
||
///
|
||
/// Scrubbing and zooming must measure the same span the histogram is drawn
|
||
/// over. When this read the whole library while the bars were scoped to a
|
||
/// collection, a scrub landed at an instant the collection did not contain and
|
||
/// the view jumped somewhere the user had not asked for.
|
||
fn catalog_span(catalog: &Catalog, ctl: &Rc<LibraryController>) -> Option<(i64, i64)> {
|
||
library::span_scoped(catalog, *ctl.scope.borrow(), &ctl.filter.borrow())
|
||
}
|
||
|
||
/// Capture time of the image at row `ordinal` in the grid's own ordering.
|
||
///
|
||
/// The inverse of the count in [`scrub_to`], and it must stay the inverse: the
|
||
/// same `shadowed_by IS NULL` exclusion and the same ordering, or scrolling
|
||
/// would report an instant the scrub would never produce for that row.
|
||
///
|
||
/// `None` for an ordinal that lands among the undated tail, which sorts last
|
||
/// and has no place on a capture-time axis.
|
||
///
|
||
/// Called on every scroll event, so it has to stay cheap: the `images_captured`
|
||
/// index makes it a seek along already-ordered rows rather than a sort.
|
||
fn capture_time_at(catalog: &Catalog, ordinal: usize) -> Option<i64> {
|
||
catalog
|
||
.connection()
|
||
.query_row(
|
||
"SELECT captured_at FROM images
|
||
WHERE shadowed_by IS NULL AND captured_at IS NOT NULL
|
||
ORDER BY captured_at
|
||
LIMIT 1 OFFSET ?1",
|
||
[ordinal as i64],
|
||
|r| r.get::<_, i64>(0),
|
||
)
|
||
.ok()
|
||
}
|
||
|
||
/// Jump the grid to the first image at or after `when`.
|
||
///
|
||
/// This is the scrub: the window moves, the library does not narrow. Every
|
||
/// image stays reachable, which is why this is a position rather than a
|
||
/// filter.
|
||
fn scrub_to(window: &AppWindow, ctl: &Rc<LibraryController>, when: i64) {
|
||
let position = {
|
||
let borrow = ctl.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
// How many rows precede this instant **in the grid's own ordering**.
|
||
// That ordinal is the scroll offset, so any disagreement with the
|
||
// grid's query lands the view somewhere else entirely.
|
||
//
|
||
// The earlier version counted only dated images. With 2,400 of 19,841
|
||
// dated, a click near the end of the axis produced an ordinal of ~2,400
|
||
// against a grid of 19,841 rows — the view landed near the top however
|
||
// far down the axis the pointer went.
|
||
//
|
||
// Undated images sort last (`captured_at IS NULL` first in the ORDER
|
||
// BY), so they never precede a dated one and the predicate below stays
|
||
// a simple `<`. Shadowed rows are excluded here exactly as the grid
|
||
// excludes them.
|
||
catalog
|
||
.connection()
|
||
.query_row(
|
||
"SELECT count(*) FROM images
|
||
WHERE shadowed_by IS NULL
|
||
AND captured_at IS NOT NULL
|
||
AND captured_at < ?1",
|
||
[when],
|
||
|r| r.get::<_, i64>(0),
|
||
)
|
||
.unwrap_or(0) as usize
|
||
};
|
||
|
||
// Record where the grid now sits, which anchors the timeline marker. Until
|
||
// the first scrub this stays `None` and the marker rests at the middle
|
||
// rather than implying a choice the user has not made.
|
||
*ctl.current_bucket.borrow_mut() = Some(when);
|
||
*ctl.offset.borrow_mut() = position;
|
||
// Move the viewport as well as the window. Cells are drawn at their
|
||
// absolute place in the library, so loading rows around image 15,000 while
|
||
// the viewport sits at row 0 shows an empty grid until the user scrolls.
|
||
window.set_library_scroll_to(position as i32);
|
||
window.set_library_scroll_token(window.get_library_scroll_token() + 1);
|
||
load_window(window, ctl);
|
||
}
|
||
|
||
/// Format a bucket start for the histogram's hover label.
|
||
fn format_bucket(t: i64, g: dr_catalog::Granularity) -> String {
|
||
let (y, m, d, h) = civil_from_unix(t);
|
||
match g {
|
||
dr_catalog::Granularity::Year => format!("{y}"),
|
||
dr_catalog::Granularity::Month => format!("{y}-{m:02}"),
|
||
dr_catalog::Granularity::Day => format!("{y}-{m:02}-{d:02}"),
|
||
dr_catalog::Granularity::Hour => format!("{y}-{m:02}-{d:02} {h:02}:00"),
|
||
}
|
||
}
|
||
|
||
/// A capture instant as `YYYY-MM-DD`.
|
||
///
|
||
/// Shared with the exporter, which resolves the `{date}` token from the same
|
||
/// reading so a filename and the timeline cannot disagree about what day a
|
||
/// photograph was taken.
|
||
pub fn format_date(t: i64) -> String {
|
||
let (y, m, d, _) = civil_from_unix(t);
|
||
format!("{y}-{m:02}-{d:02}")
|
||
}
|
||
|
||
/// Unix seconds to a civil date, via the usual era-based algorithm.
|
||
///
|
||
/// Hand-rolled rather than pulling in chrono for four fields — the same
|
||
/// reasoning as the connector's HTTP date parsing.
|
||
fn civil_from_unix(t: i64) -> (i64, i64, i64, i64) {
|
||
let days = t.div_euclid(86_400);
|
||
let secs = t.rem_euclid(86_400);
|
||
let z = days + 719_468;
|
||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||
let doe = z - era * 146_097;
|
||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
|
||
let y = yoe + era * 400;
|
||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||
let mp = (5 * doy + 2) / 153;
|
||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||
(if m <= 2 { y + 1 } else { y }, m, d, secs / 3600)
|
||
}
|
||
|
||
/// Copy decoded RGBA into a Slint image.
|
||
///
|
||
/// This is a CPU copy, which is acceptable here and not in the develop path:
|
||
/// a 256px thumbnail is 256 KB and happens once per image, where the canvas
|
||
/// would pay per frame (ARCH §6.1).
|
||
fn to_slint_image(width: u32, height: u32, rgba: &[u8]) -> slint::Image {
|
||
let mut buf = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::new(width, height);
|
||
let expected = (width as usize) * (height as usize) * 4;
|
||
let src = &rgba[..expected.min(rgba.len())];
|
||
buf.make_mut_bytes()[..src.len()].copy_from_slice(src);
|
||
slint::Image::from_rgba8(buf)
|
||
}
|
||
|
||
/// Walk the keyboard cursor through the library — the arrow keys.
|
||
///
|
||
/// **The cursor is a library ordinal, not a row of the loaded window.** That is
|
||
/// what lets it walk past the window's edge: the window is a few screenfuls
|
||
/// around wherever the user is looking, and a cursor held as a row would stop
|
||
/// at its end or, worse, keep counting into cells belonging to different
|
||
/// photographs. Moving out of the window reloads it around the new position,
|
||
/// which is the same thing scrolling does.
|
||
///
|
||
/// The grid supplies the step, because how far "down" is depends on how many
|
||
/// columns the window happens to be showing, and only the grid knows that. It
|
||
/// does not clamp: `Home` and `End` arrive as a step longer than the library
|
||
/// and are clamped here, where the total is known.
|
||
fn move_cursor(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
coll: &Rc<crate::collections_ui::CollectionsController>,
|
||
delta: i32,
|
||
extend: bool,
|
||
) {
|
||
let total = window.get_library_total().max(0) as usize;
|
||
if total == 0 {
|
||
return;
|
||
}
|
||
|
||
let Some(from) = coll.cursor() else {
|
||
// The first press takes hold of the grid rather than moving in it. A
|
||
// key that jumped to image zero would lose wherever the user had
|
||
// scrolled to, and one that started from a cell off the top of the
|
||
// screen would appear to do nothing but scroll.
|
||
//
|
||
// The first *visible* ordinal, not the window's start: the loaded
|
||
// window deliberately begins a quarter of a screen above the view, so
|
||
// its first cell is one the user cannot see.
|
||
let at = ctl.resume_at.get().min(total - 1);
|
||
place_cursor(window, ctl, coll, at, false);
|
||
return;
|
||
};
|
||
|
||
// Saturating in `isize`, so a `Home` expressed as minus the library's
|
||
// length does not wrap round to the end.
|
||
let next = (from as isize)
|
||
.saturating_add(delta as isize)
|
||
.clamp(0, total as isize - 1) as usize;
|
||
if next == from {
|
||
// Already at the end being pressed toward. Nothing to move, and
|
||
// reloading the window would be a visible jerk for no movement.
|
||
return;
|
||
}
|
||
place_cursor(window, ctl, coll, next, extend);
|
||
}
|
||
|
||
/// Put the cursor on one image, bringing the window with it.
|
||
fn place_cursor(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
coll: &Rc<crate::collections_ui::CollectionsController>,
|
||
at: usize,
|
||
extend: bool,
|
||
) {
|
||
// Bring the window to the cursor if it has walked out of it. Centred a
|
||
// quarter in, exactly as a scroll does it, so continuing in the same
|
||
// direction has loaded cells to move into rather than another reload on
|
||
// the very next press.
|
||
let loaded = window.get_library_cells().row_count();
|
||
let offset = *ctl.offset.borrow();
|
||
if at < offset || at >= offset + loaded {
|
||
let size = *ctl.window.borrow();
|
||
*ctl.offset.borrow_mut() = at.saturating_sub(size / 4);
|
||
load_window(window, ctl);
|
||
}
|
||
|
||
// Re-read: `load_window` clamps the offset against the library's end, so
|
||
// the window may not start where it was asked to.
|
||
let offset = *ctl.offset.borrow();
|
||
let ids = ctl.visible_ids();
|
||
let Some(row) = at.checked_sub(offset).filter(|r| *r < ids.len()) else {
|
||
// The window could not be brought to the cursor — an empty or
|
||
// shrinking library. Leaving the cursor where it was is better than
|
||
// pointing it at nothing.
|
||
return;
|
||
};
|
||
|
||
if !extend {
|
||
// An arrow key collapses the selection onto the cursor. A *click* on
|
||
// an already-selected cell deliberately leaves the selection alone —
|
||
// that exception exists so a multi-image drag can start from one of
|
||
// its members — and there is no drag behind a keystroke.
|
||
coll.clear_selection();
|
||
}
|
||
|
||
crate::collections_ui::select_row(window, coll, &ids, offset, row, false, extend);
|
||
window.set_library_cursor(at as i32);
|
||
}
|
||
|
||
/// 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));
|
||
|
||
// 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);
|
||
}
|
||
on_open_image(path);
|
||
}
|
||
});
|
||
}
|
||
|
||
// --- 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 path = cursor
|
||
.checked_sub(offset)
|
||
.and_then(|row| ctl.paths.borrow().get(row).cloned());
|
||
if let Some(path) = path {
|
||
w.set_show_library(false);
|
||
on_open(path);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 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.
|
||
load_window(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
// Explicit sync, for when the user wants the exchange now rather than
|
||
// after the next sweep.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_sync_now(move || {
|
||
if let Some(w) = weak.upgrade() {
|
||
start_derived_sync(&w, &ctl);
|
||
}
|
||
});
|
||
}
|
||
|
||
// A column-count change moves which cells begin a row, and month headings
|
||
// sit on row-leading cells.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_columns_changed(move || {
|
||
if let Some(w) = weak.upgrade() {
|
||
load_window(&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 };
|
||
let capacity = (capacity.max(0) as usize).max(MIN_WINDOW);
|
||
if capacity == *ctl.window.borrow() {
|
||
return;
|
||
}
|
||
*ctl.window.borrow_mut() = capacity;
|
||
load_window(&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;
|
||
|
||
// 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.
|
||
//
|
||
// Only while the grid is the visible page. Building and tearing
|
||
// down the subtree moves the viewport through 0, and accepting that
|
||
// would erase the position on the way out — the first return would
|
||
// work and every one after it would land at the top.
|
||
if w.get_show_library() {
|
||
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 window_size = *ctl.window.borrow();
|
||
let desired = first_visible.saturating_sub(window_size / 4);
|
||
let current = *ctl.offset.borrow();
|
||
|
||
// Reload only once the view nears an edge of what is loaded.
|
||
// Reacting to every scroll event would re-query and re-fetch
|
||
// continuously during a drag; this fires a few times per screenful.
|
||
let margin = window_size / 4;
|
||
let inside =
|
||
first_visible >= current + margin && first_visible + margin < current + window_size;
|
||
if inside {
|
||
return;
|
||
}
|
||
|
||
*ctl.offset.borrow_mut() = desired;
|
||
load_window(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
// Panning the timeline slides the visible span without changing its width.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_timeline_pan(move |fraction| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let borrow = ctl.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
let Some(full) = catalog_span(catalog, &ctl) else {
|
||
return;
|
||
};
|
||
|
||
// A fraction of the *visible* span, so dragging half the axis
|
||
// moves half a span's worth of time whatever the zoom — and a
|
||
// small movement produces a small shift rather than nothing.
|
||
let zoom = *ctl.timeline_zoom.borrow();
|
||
let (from, to) = zoomed_span(full, zoom, *ctl.timeline_centre.borrow());
|
||
let shift = ((to - from) as f64 * fraction as f64) as i64;
|
||
if shift == 0 {
|
||
return;
|
||
}
|
||
|
||
let centre = ctl.timeline_centre.borrow().unwrap_or((from + to) / 2) + shift;
|
||
*ctl.timeline_centre.borrow_mut() = Some(centre.clamp(full.0, full.1));
|
||
refresh_timeline(&w, catalog, &ctl);
|
||
});
|
||
}
|
||
|
||
// The wheel zooms the axis: a sidebar is a scale, not a list.
|
||
//
|
||
// Shares `apply_zoom` with the pinch handler so the two cannot drift apart
|
||
// in how they clamp or where they centre.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_timeline_zoom(move |delta| {
|
||
if let Some(w) = weak.upgrade() {
|
||
apply_zoom(&w, &ctl, delta);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Dragging the histogram moves the grid through time.
|
||
//
|
||
// The fraction is interpolated across the visible span rather than snapped
|
||
// to a bucket edge, so a slow drag advances continuously instead of sitting
|
||
// still and then jumping a whole month.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_scrub_fraction(move |f| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let borrow = ctl.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
let Some(full) = catalog_span(catalog, &ctl) else {
|
||
return;
|
||
};
|
||
|
||
let (from, to) = zoomed_span(
|
||
full,
|
||
*ctl.timeline_zoom.borrow(),
|
||
*ctl.timeline_centre.borrow(),
|
||
);
|
||
let when = from + ((to - from) as f64 * f.clamp(0.0, 1.0) as f64) as i64;
|
||
drop(borrow);
|
||
scrub_to(&w, &ctl, when);
|
||
});
|
||
}
|
||
|
||
// Pinch, for tablet: no wheel there, so this is the only way to reach the
|
||
// axis's zoom with a finger.
|
||
//
|
||
// A continuous ratio against discrete zoom levels, so the accumulated
|
||
// ratio is held and a level is taken each time it passes a doubling. That
|
||
// keeps a slow spread from either doing nothing or leaping several levels.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_timeline_pinch(move |ratio| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
if !(0.01..=100.0).contains(&ratio) {
|
||
return;
|
||
}
|
||
|
||
let mut accum = ctl.pinch_accum.borrow_mut();
|
||
*accum *= ratio;
|
||
|
||
// Whole doublings out of the accumulated ratio, remainder carried.
|
||
//
|
||
// `log2` rather than repeated halving: one update can carry a
|
||
// large ratio — a fast spread, or a trackpad reporting coarsely —
|
||
// and stepping once per update would turn an 8× pinch into a
|
||
// single level instead of three.
|
||
let steps = accum.log2().trunc() as i32;
|
||
if steps == 0 {
|
||
return;
|
||
}
|
||
*accum /= (2.0f32).powi(steps);
|
||
drop(accum);
|
||
|
||
apply_zoom(&w, &ctl, steps);
|
||
});
|
||
}
|
||
|
||
// Grid → launch screen. The route that was missing: once past the launch
|
||
// screen there was no way back to it, so a library pointed at the wrong
|
||
// folder could not be changed without clearing stored state by hand.
|
||
{
|
||
let weak = window.as_weak();
|
||
window.on_library_change(move || {
|
||
if let Some(w) = weak.upgrade() {
|
||
w.set_show_launch(true);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Develop → grid.
|
||
//
|
||
// Returns to where the user left rather than to the top. `show-library`
|
||
// gates an `if` in the markup, so the grid is rebuilt from nothing and its
|
||
// Flickable starts at row 0; the position has to be replayed explicitly.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_back_to_library(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
|
||
// TRACES: FR-CAT-8
|
||
// The edit is persisted on the way out rather than on every
|
||
// slider move: a save is a network round-trip, and one per drag
|
||
// frame would put an upload inside the gesture NFR-P5 governs.
|
||
// This is the moment the image stops being the open one, so it is
|
||
// the last moment its edit can be written.
|
||
on_leave_develop();
|
||
|
||
let resume = ctl.resume_at.get();
|
||
if resume > 0 {
|
||
// Through the same channel a scrub uses, and for the same
|
||
// reason: the viewport and the loaded window both have to move,
|
||
// or the cells are drawn thousands of rows from where the view
|
||
// sits.
|
||
//
|
||
// Centred like `on_library_scrolled` does it, so scrolling up
|
||
// from the restored position has loaded rows above it.
|
||
let window_size = *ctl.window.borrow();
|
||
*ctl.offset.borrow_mut() = resume.saturating_sub(window_size / 4);
|
||
load_window(&w, &ctl);
|
||
|
||
// Before the grid is shown, not after: the markup gates it on
|
||
// an `if`, and the rebuilt Flickable reads `scroll-to` in its
|
||
// `init`. Setting these afterwards would leave that init to run
|
||
// against the previous position.
|
||
w.set_library_scroll_to(resume as i32);
|
||
w.set_library_scroll_token(w.get_library_scroll_token() + 1);
|
||
}
|
||
|
||
w.set_show_library(true);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
let coll_ctl = coll_ctl.clone();
|
||
window.on_library_rescan(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
start_rescan(&w, &ctl, &coll_ctl);
|
||
});
|
||
}
|
||
|
||
// --- ratings and flags (FR-CAT-5, FR-CULL-4) --------------------------
|
||
|
||
// Clicking a star rates *that cell*, not the selection. The pointer names
|
||
// one photograph unambiguously, and a click that silently rated forty
|
||
// others would be a trap — the keyboard is the bulk gesture.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_cell_rated(move |row, stars| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let id = ctl
|
||
.image_ids
|
||
.borrow()
|
||
.get(row as usize)
|
||
.map(|id| dr_types::ImageId(*id as u64));
|
||
let Some(id) = id else { return };
|
||
|
||
apply_judgement(&w, &ctl, &[id], Some(stars.clamp(0, 5) as u8), None);
|
||
});
|
||
}
|
||
|
||
// A rating or flag key. Applies to the whole selection, which is what
|
||
// makes judging a run of frames one keystroke rather than forty.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
let coll_for_keys = coll_ctl.clone();
|
||
window.on_library_judged(move |rating, flag| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let chosen = coll_for_keys.selected();
|
||
|
||
// Exactly one axis is meant per keystroke; the other arrives as
|
||
// -1 so a star press cannot disturb a flag or the reverse.
|
||
if rating >= 0 {
|
||
apply_judgement(&w, &ctl, &chosen, Some(rating.clamp(0, 5) as u8), None);
|
||
} else if flag >= 0 {
|
||
apply_judgement(&w, &ctl, &chosen, None, Some(flag_from_code(flag)));
|
||
}
|
||
});
|
||
}
|
||
|
||
// --- the filter bar ---------------------------------------------------
|
||
//
|
||
// Each of these narrows what the grid *queries*, so all three reset the
|
||
// scroll offset: the window's position was an ordinal into a different
|
||
// set of images and means nothing once the set changes.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_filter_min_rating_changed(move |n| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
ctl.filter.borrow_mut().min_rating = n.clamp(0, 5) as u8;
|
||
// Stars and "unrated" are contradictory terms — asking for four
|
||
// stars *and* nothing judged matches nothing at all, which reads
|
||
// as a broken filter rather than an impossible question.
|
||
if n > 0 {
|
||
ctl.filter.borrow_mut().unjudged = false;
|
||
}
|
||
w.set_library_filter_min_rating(n.clamp(0, 5));
|
||
w.set_library_filter_unjudged(ctl.filter.borrow().unjudged);
|
||
refilter(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_filter_unjudged_changed(move |on| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
{
|
||
let mut f = ctl.filter.borrow_mut();
|
||
f.unjudged = on;
|
||
// See above: the two cannot both hold.
|
||
if on {
|
||
f.min_rating = 0;
|
||
f.flag = None;
|
||
}
|
||
}
|
||
w.set_library_filter_unjudged(on);
|
||
if on {
|
||
w.set_library_filter_min_rating(0);
|
||
w.set_library_filter_flag(0);
|
||
}
|
||
refilter(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_filter_flag_changed(move |f| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
{
|
||
let mut filter = ctl.filter.borrow_mut();
|
||
filter.flag = match f {
|
||
1 => Some(dr_types::FlagState::Pick),
|
||
2 => Some(dr_types::FlagState::Reject),
|
||
_ => None,
|
||
};
|
||
if f > 0 {
|
||
filter.unjudged = false;
|
||
}
|
||
}
|
||
w.set_library_filter_flag(f);
|
||
w.set_library_filter_unjudged(ctl.filter.borrow().unjudged);
|
||
refilter(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
// Narrow the grid to the period the histogram is showing.
|
||
//
|
||
// The range is taken from the timeline rather than typed into two date
|
||
// fields: finding the fortnight is what the histogram is *for*, and having
|
||
// found it the user should not have to read the dates off the axis and key
|
||
// them back in. Zoom, then say "only that".
|
||
//
|
||
// Toggling off clears both ends rather than remembering them — a range you
|
||
// cannot see the extent of is a filter that looks like an empty library.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_toggle_date_range(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let on = !ctl.filter.borrow().has_date_range();
|
||
if on {
|
||
// The span the axis is currently drawn over, which is what the
|
||
// user is looking at when they ask for "this range".
|
||
let borrow = ctl.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
let Some(span) = catalog_span(catalog, &ctl) else {
|
||
return;
|
||
};
|
||
let (from, to) = zoomed_span(
|
||
span,
|
||
*ctl.timeline_zoom.borrow(),
|
||
*ctl.timeline_centre.borrow(),
|
||
);
|
||
drop(borrow);
|
||
let mut f = ctl.filter.borrow_mut();
|
||
f.captured_from = Some(from);
|
||
f.captured_to = Some(to);
|
||
} else {
|
||
let mut f = ctl.filter.borrow_mut();
|
||
f.captured_from = None;
|
||
f.captured_to = None;
|
||
}
|
||
w.set_library_range_active(on);
|
||
refilter(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
// TRACES: FR-CAT-9
|
||
// "On this device" — the images openable without a server. Composes with
|
||
// the rating terms rather than replacing them: "five-star frames I can
|
||
// actually edit on this train" is one filter, not a mode.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_toggle_local_only(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let on = !ctl.local_only();
|
||
ctl.set_local_only(on);
|
||
ctl.filter.borrow_mut().local_only = on;
|
||
w.set_library_local_only(on);
|
||
refilter(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
// TRACES: FR-NC-6a
|
||
// The header's way in to the offline question, for the collection the grid
|
||
// is scoped to. It opens the same prompt the sidebar's tray and the long
|
||
// press open, rather than pinning outright: three affordances that did two
|
||
// different things — one asking, two acting — is how a user comes to avoid
|
||
// all three.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_toggle_pin_scope(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let Some(scope) = *ctl.scope.borrow() else {
|
||
return;
|
||
};
|
||
open_offline_prompt(&w, &ctl, scope);
|
||
});
|
||
}
|
||
|
||
// TRACES: FR-NC-6a | FR-UI-4
|
||
// The sidebar's way in: the tray on a row, tapped.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_collection_offline_menu(move |id| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
*ctl.row_hold_timer.borrow_mut() = None;
|
||
open_offline_prompt(&w, &ctl, dr_types::CollectionId(id as u64));
|
||
});
|
||
}
|
||
|
||
// TRACES: FR-NC-6a | FR-UI-2 | FR-UI-4
|
||
// And the touch way in: hold the collection's name.
|
||
//
|
||
// The release that ends the hold still reaches the row's `clicked` and
|
||
// scopes the grid to that collection. Left deliberately: the user is now
|
||
// looking at the photographs they are being asked about, which is context
|
||
// rather than a side effect — and suppressing it would mean a second
|
||
// "ignore the next click" flag threaded through the sidebar for no gain.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_collection_row_press(move |id, down| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
if !down {
|
||
*ctl.row_hold_timer.borrow_mut() = None;
|
||
return;
|
||
}
|
||
|
||
let timer = slint::Timer::default();
|
||
let weak = w.as_weak();
|
||
let ctl_cb = ctl.clone();
|
||
timer.start(
|
||
slint::TimerMode::SingleShot,
|
||
std::time::Duration::from_millis(crate::collections_ui::HOLD_DELAY_MS),
|
||
move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
open_offline_prompt(&w, &ctl_cb, dr_types::CollectionId(id as u64));
|
||
},
|
||
);
|
||
*ctl.row_hold_timer.borrow_mut() = Some(timer);
|
||
});
|
||
}
|
||
|
||
// The three answers.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
let coll = coll_ctl.clone();
|
||
window.on_offline_prompt_keep(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
keep_collection_offline(&w, &ctl, &coll);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
let coll = coll_ctl.clone();
|
||
window.on_offline_prompt_release(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
release_collection_offline(&w, &ctl, &coll);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_offline_prompt_dismiss(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
close_offline_prompt(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
// TRACES: FR-CAT-9
|
||
// Retry now, rather than waiting out the backoff. A user who has just
|
||
// reconnected their wifi knows something the backoff does not.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
let coll_ctl = coll_ctl.clone();
|
||
window.on_library_retry_connection(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
log::info!("retrying the connection at the user's request");
|
||
// A rescan is the probe: it is the same request the scan worker
|
||
// makes, so a success proves reachability and repopulates the
|
||
// catalog in one pass rather than proving it twice.
|
||
start_rescan(&w, &ctl, &coll_ctl);
|
||
});
|
||
}
|
||
}
|
||
|
||
/// Reload the grid after the filter changed.
|
||
///
|
||
/// The offset is reset because it is an ordinal into the filtered set: keeping
|
||
/// it would land the user in the middle of a narrowed library with no sense of
|
||
/// how they got there, or past its end entirely.
|
||
fn refilter(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
*ctl.offset.borrow_mut() = 0;
|
||
ctl.requested.borrow_mut().clear();
|
||
load_window(window, ctl);
|
||
}
|
||
|
||
fn stop(slot: &RefCell<Option<slint::Timer>>) {
|
||
if let Some(t) = slot.borrow().as_ref() {
|
||
t.stop();
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// The instant a scrub fraction names within a span, as the handler
|
||
/// computes it.
|
||
fn instant_at(span: (i64, i64), f: f32) -> i64 {
|
||
span.0 + ((span.1 - span.0) as f64 * f.clamp(0.0, 1.0) as f64) as i64
|
||
}
|
||
|
||
#[test]
|
||
fn a_scrub_fraction_interpolates_within_the_span() {
|
||
// The point of going fractional: a slow drag must advance
|
||
// continuously rather than sitting still until it crosses a bucket
|
||
// edge and then jumping a whole month.
|
||
let span = (0, 1_000);
|
||
assert_eq!(instant_at(span, 0.0), 0);
|
||
assert_eq!(instant_at(span, 0.5), 500);
|
||
assert_eq!(instant_at(span, 1.0), 1_000);
|
||
// Distinct fractions inside one bucket must give distinct instants.
|
||
assert_ne!(instant_at(span, 0.10), instant_at(span, 0.11));
|
||
}
|
||
|
||
/// Where the marker is drawn for an instant, as `refresh_timeline`
|
||
/// computes it.
|
||
fn marker_at(span: (i64, i64), t: i64) -> f32 {
|
||
((t - span.0) as f64 / (span.1 - span.0).max(1) as f64).clamp(0.0, 1.0) as f32
|
||
}
|
||
|
||
#[test]
|
||
fn the_marker_lands_on_the_fraction_that_was_clicked() {
|
||
// The bug this guards: the marker was placed from a *bar index* while
|
||
// the click was a fraction of the track, so it never appeared under
|
||
// the pointer. Marker and click must be inverses.
|
||
let span = (1_000, 9_000);
|
||
for f in [0.0_f32, 0.1, 0.25, 0.5, 0.75, 1.0] {
|
||
let round_trip = marker_at(span, instant_at(span, f));
|
||
assert!(
|
||
(round_trip - f).abs() < 1e-3,
|
||
"clicked {f}, marker drawn at {round_trip}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn an_instant_outside_the_visible_span_pins_the_marker_to_an_end() {
|
||
// Zooming in leaves the grid's instant outside the axis. The marker
|
||
// belongs at the edge it went past, not off the widget.
|
||
let span = (1_000, 2_000);
|
||
assert_eq!(marker_at(span, 0), 0.0);
|
||
assert_eq!(marker_at(span, 5_000), 1.0);
|
||
}
|
||
|
||
#[test]
|
||
fn a_scrub_fraction_outside_the_axis_is_clamped() {
|
||
// A drag that leaves the widget still reports a position; it must land
|
||
// at an end rather than off the timeline.
|
||
let span = (100, 200);
|
||
assert_eq!(instant_at(span, -3.0), 100);
|
||
assert_eq!(instant_at(span, 9.9), 200);
|
||
}
|
||
|
||
/// One pinch update, as the handler applies it: fold the ratio in, take
|
||
/// out whole doublings, carry the remainder.
|
||
fn pinch_step(accum: &mut f32, ratio: f32) -> i32 {
|
||
*accum *= ratio;
|
||
let steps = accum.log2().trunc() as i32;
|
||
if steps != 0 {
|
||
*accum /= (2.0f32).powi(steps);
|
||
}
|
||
steps
|
||
}
|
||
|
||
#[test]
|
||
fn a_pinch_accumulates_until_it_reaches_a_doubling() {
|
||
// A continuous gesture against discrete levels. Small spreads must
|
||
// accumulate rather than each being rounded to a step.
|
||
let mut accum = 1.0f32;
|
||
let mut steps = 0;
|
||
for _ in 0..12 {
|
||
steps += pinch_step(&mut accum, 1.1);
|
||
}
|
||
// 1.1^12 is 3.138x, which is log2 = 1.65 — one whole doubling, with
|
||
// the rest carried rather than discarded or rounded up.
|
||
assert_eq!(steps, 1);
|
||
assert!((accum - 1.569).abs() < 0.01, "remainder carried: {accum}");
|
||
}
|
||
|
||
#[test]
|
||
fn one_large_pinch_yields_every_level_it_crossed() {
|
||
// The bug this replaced: stepping at most once per update turned an
|
||
// 8x spread — three doublings — into a single zoom level, so a fast
|
||
// gesture lost most of its travel.
|
||
let mut accum = 1.0f32;
|
||
assert_eq!(pinch_step(&mut accum, 8.0), 3);
|
||
assert!((accum - 1.0).abs() < 0.001);
|
||
|
||
let mut accum = 1.0f32;
|
||
assert_eq!(pinch_step(&mut accum, 0.125), -3);
|
||
}
|
||
|
||
#[test]
|
||
fn pinching_in_and_back_out_returns_to_where_it_started() {
|
||
let mut accum = 1.0f32;
|
||
let steps: i32 = [2.0f32, 2.0, 0.5, 0.5]
|
||
.iter()
|
||
.map(|r| pinch_step(&mut accum, *r))
|
||
.sum();
|
||
assert_eq!(steps, 0);
|
||
assert!((accum - 1.0).abs() < 0.001, "no drift: {accum}");
|
||
}
|
||
|
||
#[test]
|
||
fn a_pinch_below_a_doubling_takes_no_step() {
|
||
// Otherwise the axis would flicker between levels on the smallest
|
||
// finger movement.
|
||
let mut accum = 1.0f32;
|
||
assert_eq!(pinch_step(&mut accum, 1.4), 0);
|
||
assert_eq!(pinch_step(&mut accum, 0.72), 0, "back roughly to 1.0");
|
||
}
|
||
|
||
/// One zoom step, as the handler applies it.
|
||
fn zoom_cell(current: f32, delta: i32) -> f32 {
|
||
let next = if delta > 0 {
|
||
current * 1.25
|
||
} else {
|
||
current / 1.25
|
||
};
|
||
next.clamp(MIN_CELL_SIZE, MAX_CELL_SIZE)
|
||
}
|
||
|
||
#[test]
|
||
fn cell_zoom_steps_geometrically_and_reverses() {
|
||
// Geometric so the gesture feels the same at either end; a fixed pixel
|
||
// step is imperceptible at 400px and violent at 90px.
|
||
let a = zoom_cell(180.0, 1);
|
||
assert!((a - 225.0).abs() < 0.01);
|
||
assert!(
|
||
(zoom_cell(a, -1) - 180.0).abs() < 0.01,
|
||
"in then out returns"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn cell_zoom_stays_within_its_bounds() {
|
||
let mut size = 180.0;
|
||
for _ in 0..40 {
|
||
size = zoom_cell(size, 1);
|
||
}
|
||
assert_eq!(size, MAX_CELL_SIZE);
|
||
|
||
for _ in 0..40 {
|
||
size = zoom_cell(size, -1);
|
||
}
|
||
assert_eq!(size, MIN_CELL_SIZE);
|
||
}
|
||
|
||
#[test]
|
||
fn zooming_past_the_grid_class_asks_for_the_large_one() {
|
||
use dr_thumbs::ThumbSize;
|
||
// The point of the second class: past 256px a grid thumbnail is being
|
||
// upscaled, and the softness shows.
|
||
assert_eq!(ThumbSize::for_cell(180), ThumbSize::Grid);
|
||
assert_eq!(
|
||
ThumbSize::for_cell(zoom_cell(225.0, 1) as u32),
|
||
ThumbSize::Large
|
||
);
|
||
// And zooming back down does not keep paying for it.
|
||
assert_eq!(
|
||
ThumbSize::for_cell(zoom_cell(281.0, -1) as u32),
|
||
ThumbSize::Grid
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_request_key_survives_the_window_moving() {
|
||
use dr_thumbs::ThumbSize;
|
||
use std::collections::HashSet;
|
||
|
||
// Keyed on the photograph, not its position. The grid is a window over
|
||
// the catalog, so row 7 is a different image after every scroll — a
|
||
// set of row indices had to be cleared on each move, and every visible
|
||
// cell then looked unrequested and was re-issued.
|
||
let mut requested: HashSet<(i64, ThumbSize)> = HashSet::new();
|
||
|
||
// A screenful at rows 0..3, holding images 100..103.
|
||
for id in 100..103 {
|
||
assert!(requested.insert((id, ThumbSize::Grid)), "first sight");
|
||
}
|
||
|
||
// Scrolled: the same photographs now occupy different rows.
|
||
for id in 100..103 {
|
||
assert!(
|
||
!requested.insert((id, ThumbSize::Grid)),
|
||
"image {id} must not be requested twice"
|
||
);
|
||
}
|
||
|
||
// A genuinely new photograph still is.
|
||
assert!(requested.insert((200, ThumbSize::Grid)));
|
||
}
|
||
|
||
#[test]
|
||
fn the_two_size_classes_are_requested_independently() {
|
||
use dr_thumbs::ThumbSize;
|
||
use std::collections::HashSet;
|
||
|
||
// Holding the 256px version says nothing about the large one, so
|
||
// zooming past the boundary must still ask.
|
||
let mut requested: HashSet<(i64, ThumbSize)> = HashSet::new();
|
||
assert!(requested.insert((1, ThumbSize::Grid)));
|
||
assert!(
|
||
requested.insert((1, ThumbSize::Large)),
|
||
"the large class is a separate request"
|
||
);
|
||
assert!(!requested.insert((1, ThumbSize::Grid)));
|
||
}
|
||
|
||
#[test]
|
||
fn zoom_zero_is_the_whole_library() {
|
||
let full = (1_000, 2_000);
|
||
assert_eq!(zoomed_span(full, 0, None), full);
|
||
// A remembered centre must not narrow the span at zoom 0.
|
||
assert_eq!(zoomed_span(full, 0, Some(1_500)), full);
|
||
}
|
||
|
||
#[test]
|
||
fn each_zoom_step_halves_the_span() {
|
||
let day = 86_400;
|
||
let full = (0, 64 * day);
|
||
let (a, b) = zoomed_span(full, 1, Some(32 * day));
|
||
assert_eq!(b - a, 32 * day);
|
||
let (a, b) = zoomed_span(full, 2, Some(32 * day));
|
||
assert_eq!(b - a, 16 * day);
|
||
}
|
||
|
||
#[test]
|
||
fn zooming_centres_on_the_given_instant() {
|
||
let day = 86_400;
|
||
let full = (0, 100 * day);
|
||
let (a, b) = zoomed_span(full, 1, Some(60 * day));
|
||
assert_eq!((a + b) / 2, 60 * day, "centred where asked");
|
||
}
|
||
|
||
#[test]
|
||
fn a_window_at_the_edge_slides_rather_than_shrinking() {
|
||
// Clamping both ends would silently halve the span near the start of
|
||
// the library, so the axis would show less detail there than in the
|
||
// middle for the same zoom level.
|
||
let day = 86_400;
|
||
let full = (0, 100 * day);
|
||
let (a, b) = zoomed_span(full, 1, Some(0));
|
||
assert_eq!(a, 0, "cannot start before the library does");
|
||
assert_eq!(b - a, 50 * day, "keeps its width");
|
||
|
||
let (a, b) = zoomed_span(full, 1, Some(100 * day));
|
||
assert_eq!(b, 100 * day);
|
||
assert_eq!(b - a, 50 * day);
|
||
}
|
||
|
||
#[test]
|
||
fn a_deep_zoom_does_not_collapse_to_nothing() {
|
||
// A zero-width span would make every bucket empty, which reads as a
|
||
// broken axis rather than a deep zoom.
|
||
let full = (0, 86_400);
|
||
let (a, b) = zoomed_span(full, 12, Some(43_200));
|
||
assert!(b > a, "span stays positive");
|
||
}
|
||
|
||
#[test]
|
||
fn a_single_instant_library_does_not_divide_by_zero() {
|
||
let full = (1_700_000_000, 1_700_000_000);
|
||
let (a, b) = zoomed_span(full, 5, None);
|
||
assert!(a <= b);
|
||
}
|
||
|
||
#[test]
|
||
fn month_names_cover_the_year_and_clamp() {
|
||
assert_eq!(month_name(1), "January");
|
||
assert_eq!(month_name(12), "December");
|
||
assert_eq!(month_abbrev(3), "Mar");
|
||
// A corrupt month must not panic the grid.
|
||
assert_eq!(month_name(0), "January");
|
||
assert_eq!(month_name(99), "December");
|
||
}
|
||
|
||
#[test]
|
||
fn civil_dates_round_trip_at_boundaries() {
|
||
// Era-based date maths is silently wrong at year and leap boundaries
|
||
// if the algorithm is transcribed slightly off, and the symptom is an
|
||
// image landing in the wrong histogram bucket.
|
||
assert_eq!(civil_from_unix(0), (1970, 1, 1, 0));
|
||
assert_eq!(civil_from_unix(1_786_285_800), (2026, 8, 9, 14));
|
||
// Leap day.
|
||
assert_eq!(civil_from_unix(1_709_164_800), (2024, 2, 29, 0));
|
||
// Last second of a year, and the first of the next.
|
||
assert_eq!(civil_from_unix(1_735_689_599), (2024, 12, 31, 23));
|
||
assert_eq!(civil_from_unix(1_735_689_600), (2025, 1, 1, 0));
|
||
}
|
||
|
||
#[test]
|
||
fn dates_before_the_epoch_do_not_wrap() {
|
||
// Scanned film carries capture dates well before 1970; a negative
|
||
// timestamp must floor rather than truncate toward zero.
|
||
let (y, _, _, _) = civil_from_unix(-1);
|
||
assert_eq!(y, 1969);
|
||
}
|
||
|
||
#[test]
|
||
fn bucket_labels_match_their_granularity() {
|
||
use dr_catalog::Granularity;
|
||
let t = 1_786_285_800; // 2026-08-09 14:30 UTC
|
||
assert_eq!(format_bucket(t, Granularity::Year), "2026");
|
||
assert_eq!(format_bucket(t, Granularity::Month), "2026-08");
|
||
assert_eq!(format_bucket(t, Granularity::Day), "2026-08-09");
|
||
assert_eq!(format_bucket(t, Granularity::Hour), "2026-08-09 14:00");
|
||
}
|
||
|
||
#[test]
|
||
fn rgba_shorter_than_declared_does_not_panic() {
|
||
// A truncated decode must degrade to a partial image, not abort the
|
||
// grid. Decoders handle untrusted input (NFR-SEC-1).
|
||
let img = to_slint_image(4, 4, &[0u8; 8]);
|
||
assert_eq!(img.size().width, 4);
|
||
}
|
||
|
||
#[test]
|
||
fn rgba_longer_than_declared_is_truncated() {
|
||
let img = to_slint_image(2, 2, &[255u8; 1024]);
|
||
assert_eq!(img.size().width, 2);
|
||
assert_eq!(img.size().height, 2);
|
||
}
|
||
|
||
/// A thumbnail drain must be able to tell that the window it was started
|
||
/// for has been replaced.
|
||
///
|
||
/// This is the whole of the black-grid fix, reduced to the comparison the
|
||
/// timer callback makes. `row` is an index into the window that requested
|
||
/// the fetch, so a drain that keeps writing after a reload paints
|
||
/// thumbnails onto unrelated photographs — and, worse, its `stop` lands on
|
||
/// the *current* batch's timer and leaves the new fetches undrained.
|
||
#[test]
|
||
fn a_reload_makes_an_in_flight_thumbnail_batch_stale() {
|
||
let ctl = LibraryController::new(crate::activity::ActivityLog::new());
|
||
|
||
// What `drain_thumbnails` captures when the batch is spawned.
|
||
let mine = ctl.generation.get();
|
||
assert_eq!(ctl.generation.get(), mine, "its own batch is live");
|
||
|
||
// What `load_window` does just before swapping the model.
|
||
ctl.generation.set(ctl.generation.get().wrapping_add(1));
|
||
|
||
assert_ne!(
|
||
ctl.generation.get(),
|
||
mine,
|
||
"the batch must recognise itself as stale once the model is replaced"
|
||
);
|
||
}
|
||
|
||
/// Each load is distinct, so two reloads cannot alias back to a live batch.
|
||
#[test]
|
||
fn every_window_load_takes_a_fresh_generation() {
|
||
let ctl = LibraryController::new(crate::activity::ActivityLog::new());
|
||
let seen: Vec<u64> = (0..4)
|
||
.map(|_| {
|
||
let g = ctl.generation.get();
|
||
ctl.generation.set(g.wrapping_add(1));
|
||
g
|
||
})
|
||
.collect();
|
||
|
||
assert_eq!(seen, vec![0, 1, 2, 3]);
|
||
}
|
||
|
||
/// A controller holding a catalog of four named images.
|
||
fn with_catalog() -> Rc<LibraryController> {
|
||
let ctl = LibraryController::new(crate::activity::ActivityLog::new());
|
||
let catalog = Catalog::in_memory().expect("in-memory catalog");
|
||
let c = catalog.connection();
|
||
c.execute(
|
||
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')",
|
||
[],
|
||
)
|
||
.expect("root");
|
||
for (id, name) in [(1i64, "a.CR2"), (2, "b.CR2"), (3, "c.CR2"), (4, "d.CR2")] {
|
||
c.execute(
|
||
"INSERT INTO images(id, root_id, source_ref, added_at) VALUES (?1, 1, ?2, 0)",
|
||
rusqlite::params![id, name],
|
||
)
|
||
.expect("image");
|
||
}
|
||
*ctl.catalog.borrow_mut() = Some(catalog);
|
||
ctl
|
||
}
|
||
|
||
/// Just the names, for assertions that are about order rather than ids.
|
||
fn named(ctl: &Rc<LibraryController>, images: &[dr_types::ImageId]) -> Vec<String> {
|
||
ctl.selected_image_paths(images)
|
||
.into_iter()
|
||
.map(|(_, path)| path)
|
||
.collect()
|
||
}
|
||
|
||
/// TRACES: FR-EXP-7
|
||
#[test]
|
||
fn a_selection_resolves_to_paths_in_the_order_it_was_given() {
|
||
// `IN (…)` returns rows in whatever order suits SQLite, and an export's
|
||
// `{seq}` token counts through the batch — so a lookup that handed back
|
||
// the database's order would number the photographs in an order the
|
||
// user never saw.
|
||
let ctl = with_catalog();
|
||
let paths = named(&ctl, &[dr_types::ImageId(3), dr_types::ImageId(1)]);
|
||
assert_eq!(paths, vec!["c.CR2", "a.CR2"]);
|
||
}
|
||
|
||
/// TRACES: FR-EXP-7
|
||
#[test]
|
||
fn a_selection_outside_the_loaded_window_still_resolves() {
|
||
// The property the catalog lookup exists for. Selection is by id and
|
||
// survives a scrub, so a selection made before scrolling routinely
|
||
// names photographs no row holds — and `paths`, the loaded window, is
|
||
// empty here precisely to prove nothing is being read from it.
|
||
let ctl = with_catalog();
|
||
assert!(ctl.paths.borrow().is_empty());
|
||
assert_eq!(named(&ctl, &[dr_types::ImageId(4)]), ["d.CR2"]);
|
||
}
|
||
|
||
/// TRACES: FR-EXP-7
|
||
#[test]
|
||
fn an_image_that_vanished_under_the_selection_is_skipped() {
|
||
// Deleted between the selection and the click. There is nothing to
|
||
// export and nothing to fix, so it drops out rather than becoming a
|
||
// failure row the user can do nothing about.
|
||
let ctl = with_catalog();
|
||
let paths = named(
|
||
&ctl,
|
||
&[
|
||
dr_types::ImageId(1),
|
||
dr_types::ImageId(99),
|
||
dr_types::ImageId(2),
|
||
],
|
||
);
|
||
assert_eq!(paths, vec!["a.CR2", "b.CR2"]);
|
||
}
|
||
}
|