The passes that need every photograph's bytes — thumbnails, face indexing — now borrow each one and release it at the end. On a placeholder library that is the difference between peak disk being the working set and being the whole library. Including on cancellation, which was nearly missed: the face sweep returns mid-loop when the user presses Stop, and without releasing there the disk is spent and nothing is delivered for it. `materialise` now answers whether *it* fetched the content. The pool used to work that out by listing a file's parent directory — one listing per file across a library — when the backend already had to `stat` it to decide whether to ask. One syscall instead of a directory walk, and it removes the bug class the tests found earlier: a file at the library root has no `parent()`, so every one of them read as already-downloaded. **Pinning is the retention control**, and it drives the model the catalog already had rather than a second one. `tier_desired` is what the user asked to keep hydrated, `pending_pins` is the resumable work list, and a pinned collection is never dehydrated for the same reason it was never evicted. It was in fact *broken* here before: `get` on a stub failed, and the pin worker logged "one unreadable file must not abandon the whole pin" and silently did nothing. Pinned originals on such a library are recorded with `path = NULL` (`Cache::record_in_place`) rather than copied under `originals/`. Two reasons, and the second is the important one. A copy would hold every pinned photograph twice, with the budget able to evict the half that was not costing the disk. And `release` deletes the file a row names — so a row that names none cannot delete anything, which puts the one catastrophic operation out of reach by construction rather than by remembering not to call it. Deleting a materialised file inside a synced tree removes the photograph from the server and every other device. Handing disk back is `spawn_dehydrate`, which asks the client. Two gaps written down rather than papered over (docs/storage.md §7): a hydrating pass cannot yet quote its cost, because a stub reports no size; and the two sweeps hold separate pools, so a library indexed for both fetches twice.
6546 lines
273 KiB
Rust
6546 lines
273 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::{Account, AccountStore, Connection};
|
||
use dr_types::FormatFilter;
|
||
use slint::{ComponentHandle, Model as _};
|
||
|
||
use crate::library::{self, ScanMessage, ThumbnailMessage};
|
||
use crate::{AppWindow, KeywordRow, LibraryCell, PersonChip, TimelineBar};
|
||
|
||
/// A screenful before the grid has reported its geometry.
|
||
///
|
||
/// Only used for the very first load; the grid replaces it with what it
|
||
/// actually shows as soon as it has laid out.
|
||
const INITIAL_VIEWPORT_CELLS: usize = 20;
|
||
|
||
/// How many screenfuls the loaded window spans.
|
||
///
|
||
/// One of them is on screen, so this buys `SCREENFULS - 1` screenfuls of
|
||
/// loaded-but-undrawn cells to scroll into, split unevenly by
|
||
/// [`window_start`]: a quarter of the window above the view, the rest below,
|
||
/// because a grid is read downward.
|
||
///
|
||
/// Owned here rather than in the grid, alongside [`window_move`]'s rule for
|
||
/// how close the view may come to an edge before the window follows. Split
|
||
/// across the two — the grid loading three screenfuls, Rust holding on until
|
||
/// the view was three quarters of the way through them — the two numbers
|
||
/// disagreed by a quarter of a screenful, and that quarter was rows on screen
|
||
/// that no loaded cell covered. Which is the bottom row of the grid going
|
||
/// blank.
|
||
const SCREENFULS: usize = 4;
|
||
|
||
/// Never load fewer than this, whatever the viewport reports.
|
||
///
|
||
/// A window collapsed to a sliver would otherwise load one or two cells and
|
||
/// re-query on every scroll tick.
|
||
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;
|
||
|
||
/// TRACES: NFR-P5
|
||
/// What the grid's whole-library readouts are an answer about.
|
||
///
|
||
/// The timeline's bars, the filter chips' counts, the "on this device" count
|
||
/// and whether the scoped collection is pinned all describe the *library*, not
|
||
/// the window over it — and [`load_window`] recomputed every one of them each
|
||
/// time the window moved, which is several times per screenful of scrolling.
|
||
/// Together they are a `MIN`/`MAX`, a `GROUP BY`, two counts and, under a
|
||
/// collection, three more queries: about 8 ms of SQLite on the thread that is
|
||
/// trying to draw the frame, for four answers that scrolling cannot change.
|
||
///
|
||
/// So they are recomputed when this changes and not otherwise. `total` is in
|
||
/// here as the change detector as much as anything else: it is already read on
|
||
/// every load for the scrollbar, and a scan landing, a delete or a restore all
|
||
/// move it. What it cannot see — a rating edited under an unchanged count — is
|
||
/// covered because the paths that do that refresh the chips themselves.
|
||
/// Not `Copy` since `RatingFilter` stopped being — it holds a set of people.
|
||
#[derive(Clone, PartialEq, Eq)]
|
||
struct LibraryFacts {
|
||
scope: Option<dr_types::CollectionId>,
|
||
filter: library::RatingFilter,
|
||
trash: bool,
|
||
total: usize,
|
||
}
|
||
|
||
/// Library state for the running window.
|
||
pub struct LibraryController {
|
||
/// Shared with [`crate::collections_ui`], which edits collections against
|
||
/// the same connection. `Rc` rather than a second `Catalog::open`: two
|
||
/// handles on one SQLite file would each hold their own WAL view, so a
|
||
/// collection edited through one would not be visible through the other
|
||
/// until it committed and the reader reopened.
|
||
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>>,
|
||
/// When each row in the model was taken, parallel to it.
|
||
///
|
||
/// Kept so the timeline marker can be moved from the window the grid has
|
||
/// already read rather than from a query per scroll event — see
|
||
/// [`capture_time_at`].
|
||
captured_at: RefCell<Vec<Option<i64>>>,
|
||
/// The size class of the pixels each row is currently showing, `None` for a
|
||
/// row still waiting.
|
||
///
|
||
/// Parallel to the model, and the reason [`load_window`] can carry a
|
||
/// thumbnail across a reload without re-deciding whether it is sharp
|
||
/// enough: a cell holding the 256px class while the grid has since been
|
||
/// zoomed past that must keep showing what it has *and* still ask for the
|
||
/// large one. Recording the class the pixels came from is what tells those
|
||
/// two states apart — without it a carried thumbnail either blocks the
|
||
/// sharper fetch forever or is re-fetched on every scroll.
|
||
thumb_class: RefCell<Vec<Option<dr_thumbs::ThumbSize>>>,
|
||
/// Where in the catalog the current window starts. Scrubbing moves this.
|
||
offset: RefCell<usize>,
|
||
/// The first visible ordinal, kept so returning from the develop view lands
|
||
/// where the user left rather than at the top.
|
||
///
|
||
/// Recorded when the grid is left, not read back when it is re-entered:
|
||
/// `show-library` gates an `if` in the markup, so the whole grid subtree is
|
||
/// destroyed and rebuilt, and the rebuilt `Flickable` reports a scroll to
|
||
/// row 0 before anything can restore the old position. Capturing at the
|
||
/// moment of departure is the only value the reset cannot overwrite.
|
||
///
|
||
/// Separate from `offset`, which is the *loaded window*'s start and sits a
|
||
/// quarter-window above the view. Restoring that as a viewport position
|
||
/// would land the user consistently short of where they were.
|
||
resume_at: std::cell::Cell<usize>,
|
||
/// How many cells to load, derived from what the viewport can show.
|
||
///
|
||
/// A fixed count is wrong in both directions: too small on a maximised 4K
|
||
/// window, wasteful on a narrow one. The grid measures itself and reports
|
||
/// its screenful; this is [`SCREENFULS`] of them.
|
||
window: RefCell<usize>,
|
||
/// What the whole-library readouts on screen were last computed for, so a
|
||
/// window that merely moved does not recompute them. See [`LibraryFacts`].
|
||
library_facts: RefCell<Option<LibraryFacts>>,
|
||
/// How many cells the viewport shows at once, as the grid last reported.
|
||
///
|
||
/// Kept beside `window` rather than divided back out of it, because
|
||
/// `MIN_WINDOW` can hold the window above what the screenful implies — and
|
||
/// a screenful guessed too small is exactly the mistake that leaves the
|
||
/// bottom row of the grid outside the loaded window.
|
||
viewport_cells: std::cell::Cell<usize>,
|
||
/// Which rows already have a thumbnail fetch issued, so scrolling back
|
||
/// does not refetch what is already on screen.
|
||
/// Which images have a fetch in flight or already served, keyed on
|
||
/// `(image_id, size class)`.
|
||
///
|
||
/// **Identity, not row index.** The grid is a window over the catalog, so
|
||
/// row 7 means a different photograph after every scroll — a set of
|
||
/// indices had to be cleared on each window move, which made every visible
|
||
/// cell look unrequested and re-issued the whole screenful on every
|
||
/// scroll.
|
||
///
|
||
/// The size class is part of the key because the two resolutions are
|
||
/// fetched independently: holding the 256px one says nothing about whether
|
||
/// the large one has been asked for.
|
||
requested: RefCell<std::collections::HashSet<(i64, dr_thumbs::ThumbSize)>>,
|
||
scan_timer: RefCell<Option<slint::Timer>>,
|
||
thumb_timer: RefCell<Option<slint::Timer>>,
|
||
/// The whole-library sweep, which outlives any one grid window.
|
||
sweep_timer: RefCell<Option<slint::Timer>>,
|
||
/// TRACES: FR-CAT-3
|
||
/// Drains the whole-library thumbnail pass. Held for the same reason as
|
||
/// the others, and read as well as written: it is what stops a second
|
||
/// press of the button starting a pass alongside the first.
|
||
thumb_sweep_timer: RefCell<Option<slint::Timer>>,
|
||
/// Pushing shards and the catalog to the server.
|
||
sync_timer: RefCell<Option<slint::Timer>>,
|
||
/// Kept so a rescan can run without going back through the launch screen.
|
||
///
|
||
/// A [`Connection`] rather than credentials beside an account: it is what
|
||
/// every worker needs, it is what `remote::connect` takes, and holding the
|
||
/// two halves separately is how they came to be threaded through fifteen
|
||
/// signatures in the wrong order.
|
||
session: RefCell<Option<(Connection, FormatFilter)>>,
|
||
/// Which collection narrows the grid, owned by [`crate::collections_ui`]
|
||
/// and read here. Shared rather than passed per call because a rescan, a
|
||
/// scrub and a drop all reload the window and must all honour it.
|
||
scope: RefCell<Option<dr_types::CollectionId>>,
|
||
/// TRACES: FR-CAT-15
|
||
/// Whether the grid is listing the trash rather than the library.
|
||
///
|
||
/// Separate from `scope` rather than a sentinel id in it, for the reason
|
||
/// [`crate::collections_ui::CollectionsController`] gives: the trash is not
|
||
/// a collection, and its query *inverts* the predicate every other view
|
||
/// applies. Folding that into a type meaning "a collection" would put the
|
||
/// inversion where nothing reading `scope` expects it.
|
||
///
|
||
/// A `Cell` because it is a `bool` read inside callbacks that already hold
|
||
/// other borrows.
|
||
viewing_trash: std::cell::Cell<bool>,
|
||
/// The selection's owner, so a rebuilt window can restore the `selected`
|
||
/// flags it just cleared.
|
||
///
|
||
/// `Weak` because the two controllers outlive each other only through the
|
||
/// window, and an `Rc` both ways would leak both. Set once at wiring; a
|
||
/// `None` here means the grid simply draws nothing selected, which is the
|
||
/// old behaviour rather than a crash.
|
||
coll_ctl: RefCell<Option<std::rc::Weak<crate::collections_ui::CollectionsController>>>,
|
||
/// Timeline view state: how far zoomed in, and around what instant.
|
||
///
|
||
/// Zoom is a level rather than a span so the axis halves and doubles in
|
||
/// even steps; the centre is what keeps the thing you were looking at in
|
||
/// view as you zoom.
|
||
timeline_zoom: RefCell<i32>,
|
||
timeline_centre: RefCell<Option<i64>>,
|
||
/// Pinch ratio accumulated since the last zoom step was taken.
|
||
///
|
||
/// A pinch is continuous and zoom levels are discrete, so the ratio is
|
||
/// held until it reaches a doubling. Without it a slow spread would either
|
||
/// do nothing or, if each update were rounded, leap several levels.
|
||
pinch_accum: RefCell<f32>,
|
||
/// The instant the grid is showing. `None` until the user has moved the
|
||
/// timeline, which is what leaves the marker resting at the middle.
|
||
current_bucket: RefCell<Option<i64>>,
|
||
/// What the rating filter bar is narrowed to.
|
||
///
|
||
/// Held here beside `scope` and for the same reason: a scrub, a rescan and
|
||
/// a drop all reload the window, and every one of them must honour it or
|
||
/// the filter silently lapses.
|
||
filter: RefCell<library::RatingFilter>,
|
||
/// TRACES: FR-CAT-9
|
||
/// Drains the outbox uploader. Held so that a repaint while one is
|
||
/// already running does not start a second against the same channel —
|
||
/// this handle *is* the "a drain is in flight" state.
|
||
outbox_timer: RefCell<Option<slint::Timer>>,
|
||
/// Whether the outbox might hold something, so the common case costs a
|
||
/// boolean rather than a directory walk.
|
||
///
|
||
/// `refresh_offline` runs on scan progress as well as on a genuine
|
||
/// connectivity change, so the drain is *asked* far more often than there
|
||
/// is anything to send. Starts `true` so the first ask after launch does
|
||
/// walk — edits queued in a previous session are exactly the ones that
|
||
/// need sending, and nothing in memory knows about them.
|
||
outbox_maybe_dirty: std::cell::Cell<bool>,
|
||
/// Drains the sidecar writer. Held so a second judgement replaces the
|
||
/// timer rather than leaving two draining the same finished channel.
|
||
sidecar_timer: RefCell<Option<slint::Timer>>,
|
||
/// Which window load the model belongs to, bumped by [`load_window`].
|
||
///
|
||
/// A thumbnail worker addresses cells by *row index into the window that
|
||
/// asked for it*. A reload replaces the model, so once the generation has
|
||
/// moved on every row still in flight names a different photograph —
|
||
/// applying it paints thumbnails onto unrelated cells, or marks a cell
|
||
/// "no preview" for a fetch never attempted against it. Worse, a stale
|
||
/// drain reaching `Disconnected` would call `stop` on `thumb_timer`, which
|
||
/// by then holds the *current* batch's timer: the new worker then fetched
|
||
/// into a channel nobody drained and the grid stayed black until a scroll
|
||
/// forced another load.
|
||
///
|
||
/// A `Cell` rather than a `RefCell`: it is read inside a timer callback
|
||
/// that already holds borrows of other fields, and a `u64` needs no borrow
|
||
/// tracking.
|
||
generation: std::cell::Cell<u64>,
|
||
/// TRACES: FR-CAT-9
|
||
/// Whether the server is reachable, inferred from what the workers saw.
|
||
///
|
||
/// Lives on the controller rather than in a global because it is scoped to
|
||
/// one open library: signing into a different account starts a fresh
|
||
/// judgement, and carrying the old one over would report a server down
|
||
/// that was never contacted.
|
||
reachability: RefCell<dr_sync::Reachability>,
|
||
/// TRACES: FR-NC-6a
|
||
/// Drains the pin downloader. Held so a second pin replaces the timer
|
||
/// rather than leaving two draining the same finished channel.
|
||
/// Coalesces the reloads a run of geometry changes would otherwise each
|
||
/// demand — see [`schedule_reload`].
|
||
geometry_timer: RefCell<Option<slint::Timer>>,
|
||
/// The ordinal the view was on when the current run of geometry changes
|
||
/// began, so the settle returns to the photograph the user was looking at
|
||
/// rather than to wherever the re-flow left the viewport.
|
||
pending_anchor: std::cell::Cell<Option<usize>>,
|
||
pin_timer: RefCell<Option<slint::Timer>>,
|
||
/// TRACES: FR-NC-6a
|
||
/// Which collection the offline question is being asked about.
|
||
///
|
||
/// Held rather than passed through the window because the prompt's three
|
||
/// answers arrive as three separate callbacks, and a dialogue that read its
|
||
/// subject back out of a string property would act on whatever the sidebar
|
||
/// had been rebuilt to say since.
|
||
offline_target: std::cell::Cell<Option<dr_types::CollectionId>>,
|
||
/// TRACES: FR-NC-6a | FR-UI-2
|
||
/// The timer that turns a held sidebar row into that question. Dropped on
|
||
/// release, so a tap — or a press the Flickable takes for a scroll — is not
|
||
/// a dialogue a moment later.
|
||
row_hold_timer: RefCell<Option<slint::Timer>>,
|
||
/// Narrow the grid to images whose original is stored locally.
|
||
///
|
||
/// A `Cell` beside `filter` rather than a field inside it: the rating
|
||
/// filter compiles to a SQL predicate over `versions`, while this one is a
|
||
/// predicate over the cache, and folding two different joins into one type
|
||
/// would put the cache schema inside a rating concept.
|
||
local_only: std::cell::Cell<bool>,
|
||
/// TRACES: FR-NC-6a
|
||
/// The ceiling on passively cached originals, from the settings page.
|
||
///
|
||
/// Held here rather than read from `SettingsStore` at each use because the
|
||
/// workers that need it run on threads with no config access — the same
|
||
/// reason [`library::CacheContext`] takes it as a field. A `Cell` because
|
||
/// the settings page can move it while a library is open, and the next
|
||
/// fetch must use the new figure rather than one captured at open.
|
||
cache_budget: std::cell::Cell<dr_catalog::Budget>,
|
||
/// TRACES: FR-NC-6a
|
||
/// Whether opening an image in develop keeps its original on disk.
|
||
///
|
||
/// Held beside the budget and for the same reason. Distinct from a zero
|
||
/// budget: this switches the passive population off entirely, while a
|
||
/// small budget still keeps a working set. A metered or small-disk device
|
||
/// wants the first.
|
||
keep_opened: std::cell::Cell<bool>,
|
||
/// TRACES: FR-CAT-6
|
||
/// How many bars the capture-time axis is cut into, from the settings
|
||
/// page.
|
||
///
|
||
/// Held here beside the cache budget and for the same reason: the axis is
|
||
/// redrawn from a dozen callbacks that have no config access, and reading
|
||
/// a JSON file on every scroll to answer "how many bars" would be the
|
||
/// wrong shape of question. A `Cell` because the page can change it while
|
||
/// a library is open.
|
||
timeline_bars: std::cell::Cell<u32>,
|
||
/// Where every worker this module starts reports what it is doing.
|
||
///
|
||
/// Held on the controller rather than passed to each function because the
|
||
/// jobs are started from a dozen callbacks — a scroll, a rescan, a pin, a
|
||
/// reconnect — and threading a second argument through all of them would
|
||
/// say nothing except that they all report progress.
|
||
activity: Rc<crate::activity::ActivityLog>,
|
||
}
|
||
|
||
impl LibraryController {
|
||
pub fn new(activity: Rc<crate::activity::ActivityLog>) -> Rc<Self> {
|
||
Rc::new(Self {
|
||
activity,
|
||
catalog: Rc::new(RefCell::new(None)),
|
||
paths: RefCell::new(Vec::new()),
|
||
file_ids: RefCell::new(Vec::new()),
|
||
sizes: RefCell::new(Vec::new()),
|
||
image_ids: RefCell::new(Vec::new()),
|
||
needs_metadata: RefCell::new(Vec::new()),
|
||
captured_at: RefCell::new(Vec::new()),
|
||
thumb_class: RefCell::new(Vec::new()),
|
||
offset: RefCell::new(0),
|
||
resume_at: std::cell::Cell::new(0),
|
||
window: RefCell::new((INITIAL_VIEWPORT_CELLS * SCREENFULS).max(MIN_WINDOW)),
|
||
viewport_cells: std::cell::Cell::new(INITIAL_VIEWPORT_CELLS),
|
||
library_facts: RefCell::new(None),
|
||
requested: RefCell::new(Default::default()),
|
||
scan_timer: RefCell::new(None),
|
||
thumb_timer: RefCell::new(None),
|
||
sweep_timer: RefCell::new(None),
|
||
thumb_sweep_timer: RefCell::new(None),
|
||
sync_timer: RefCell::new(None),
|
||
session: RefCell::new(None),
|
||
scope: RefCell::new(None),
|
||
viewing_trash: std::cell::Cell::new(false),
|
||
coll_ctl: RefCell::new(None),
|
||
timeline_zoom: RefCell::new(0),
|
||
timeline_centre: RefCell::new(None),
|
||
pinch_accum: RefCell::new(1.0),
|
||
current_bucket: RefCell::new(None),
|
||
filter: RefCell::new(library::RatingFilter::default()),
|
||
sidecar_timer: RefCell::new(None),
|
||
generation: std::cell::Cell::new(0),
|
||
reachability: RefCell::new(dr_sync::Reachability::new()),
|
||
outbox_timer: RefCell::new(None),
|
||
outbox_maybe_dirty: std::cell::Cell::new(true),
|
||
geometry_timer: RefCell::new(None),
|
||
pending_anchor: std::cell::Cell::new(None),
|
||
pin_timer: RefCell::new(None),
|
||
offline_target: std::cell::Cell::new(None),
|
||
row_hold_timer: RefCell::new(None),
|
||
local_only: std::cell::Cell::new(false),
|
||
// The catalog's own floor until the settings page reports what the
|
||
// user has stored, which it does at startup before any fetch.
|
||
cache_budget: std::cell::Cell::new(dr_catalog::Budget::default()),
|
||
keep_opened: std::cell::Cell::new(
|
||
dr_types::CacheSettings::default().keep_opened_originals,
|
||
),
|
||
timeline_bars: std::cell::Cell::new(dr_types::LibrarySettings::default().timeline_bars),
|
||
})
|
||
}
|
||
|
||
/// TRACES: FR-CAT-6
|
||
/// How many bars the capture-time axis is cut into.
|
||
///
|
||
/// Answers whether it changed, so the caller can leave the grid alone when
|
||
/// it did not: this is set on every settings edit, and reloading the
|
||
/// window because a user changed their export folder would be a visible
|
||
/// stutter for nothing.
|
||
pub fn set_timeline_bars(&self, bars: u32) -> bool {
|
||
let bars = bars.max(1);
|
||
self.timeline_bars.replace(bars) != bars
|
||
}
|
||
|
||
/// TRACES: FR-NC-6a
|
||
/// Whether a develop open should keep the original it downloads.
|
||
///
|
||
/// Turning it off leaves what is already cached alone: those bytes are
|
||
/// paid for, and deleting them would make the switch destructive when it
|
||
/// only means "stop adding to this".
|
||
pub fn set_keep_opened_originals(&self, keep: bool) {
|
||
self.keep_opened.set(keep);
|
||
}
|
||
|
||
/// TRACES: FR-NC-6a
|
||
/// Set the ceiling on passively cached originals, and apply it now.
|
||
///
|
||
/// Applied immediately rather than only to later downloads: a user who has
|
||
/// just lowered the limit expects the space back, and a budget that took
|
||
/// effect only on the next fetch would leave the cache over its stated
|
||
/// ceiling for as long as they browsed nothing new.
|
||
pub fn set_cache_budget(&self, bytes: Option<u64>) {
|
||
let budget = match bytes {
|
||
Some(n) => dr_catalog::Budget::bytes(n),
|
||
None => dr_catalog::Budget::unlimited(),
|
||
};
|
||
self.cache_budget.set(budget);
|
||
|
||
// Best-effort: no library open means no cache to trim, and a failure
|
||
// here must not stop the setting from being stored — the ceiling still
|
||
// applies to every fetch from now on.
|
||
let Some(dir) = self.cache_dir() else { return };
|
||
let borrow = self.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
match dr_catalog::Cache::open(&dir, budget)
|
||
.and_then(|cache| cache.enforce(catalog.connection()))
|
||
{
|
||
Ok(0) => {}
|
||
Ok(n) => log::info!("cache budget changed: evicted {n} original(s)"),
|
||
Err(e) => log::warn!("applying the new cache budget: {e}"),
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-CAT-9
|
||
/// Whether the app currently believes the server is unreachable.
|
||
pub fn is_offline(&self) -> bool {
|
||
self.reachability.borrow().is_offline()
|
||
}
|
||
|
||
/// Whether the grid is narrowed to locally-stored originals.
|
||
pub fn local_only(&self) -> bool {
|
||
self.local_only.get()
|
||
}
|
||
|
||
/// TRACES: FR-NC-6a
|
||
/// The catalog id for a remote path currently in the grid.
|
||
///
|
||
/// The cache is keyed on `ImageId` because that is what survives a
|
||
/// server-side rename (FR-NC-5), while the develop callback carries only a
|
||
/// path. `paths` and `image_ids` are parallel to the model, so this is the
|
||
/// join between the two.
|
||
pub fn image_id_for_path(&self, path: &str) -> Option<dr_types::ImageId> {
|
||
let index = self.paths.borrow().iter().position(|p| p == path)?;
|
||
self.image_ids
|
||
.borrow()
|
||
.get(index)
|
||
.map(|id| dr_types::ImageId(*id as u64))
|
||
}
|
||
|
||
/// TRACES: FR-NC-8 | FR-DEV-6
|
||
/// The default version's uuid for an image, by its remote path.
|
||
///
|
||
/// Taken from the catalog rather than generated, and rather than read off
|
||
/// whatever the sidecar happens to contain. The uuid is the identity a
|
||
/// cross-device merge keys on (FR-NC-8): a develop session that invented
|
||
/// one would write a *second* version beside the one the cull is stored
|
||
/// in, and the photograph would arrive on the other device holding two
|
||
/// edits that never merge.
|
||
///
|
||
/// Creates the version if the image has none, by the same route a rating
|
||
/// does — see `dr_catalog::rating::default_version_id` for why an image
|
||
/// can legitimately arrive without one.
|
||
pub fn version_uuid_for_path(&self, path: &str) -> Option<String> {
|
||
let image = self.image_id_for_path(path)?;
|
||
let borrow = self.catalog.borrow();
|
||
let catalog = borrow.as_ref()?;
|
||
let conn = catalog.connection();
|
||
|
||
let id = match dr_catalog::rating::default_version_id(conn, image) {
|
||
Ok(id) => id,
|
||
Err(e) => {
|
||
log::debug!("no version for {path}: {e}");
|
||
return None;
|
||
}
|
||
};
|
||
conn.query_row("SELECT uuid FROM versions WHERE id = ?1", [id], |r| {
|
||
r.get(0)
|
||
})
|
||
.ok()
|
||
}
|
||
|
||
/// TRACES: FR-CAT-9 | FR-NC-10
|
||
/// Where cached sidecars and the upload outbox live for the open library.
|
||
///
|
||
/// Beside the catalog and the originals cache, for the same reason those
|
||
/// two sit together: all three are per-account and are discarded together.
|
||
/// A separate directory rather than a subfolder of `originals` because the
|
||
/// two have opposite lifetimes — originals are evicted under a budget
|
||
/// (FR-NC-6a), and a queued edit must never be.
|
||
pub fn sidecar_cache_dir(&self) -> Option<PathBuf> {
|
||
let borrow = self.session.borrow();
|
||
let (conn, _) = borrow.as_ref()?;
|
||
library::catalog_path(&conn.account)
|
||
.parent()
|
||
.map(|p| p.join("sidecars"))
|
||
}
|
||
|
||
/// TRACES: FR-NC-6a
|
||
/// Where cached originals live for the open library.
|
||
///
|
||
/// Beside the catalog rather than under a system cache directory: the two
|
||
/// are per-account and are discarded together, and a cached original whose
|
||
/// catalog row is gone is unreachable anyway.
|
||
pub fn cache_dir(&self) -> Option<PathBuf> {
|
||
let borrow = self.session.borrow();
|
||
let (conn, _) = borrow.as_ref()?;
|
||
library::catalog_path(&conn.account)
|
||
.parent()
|
||
.map(|p| p.join("originals"))
|
||
}
|
||
|
||
/// Open the originals cache for the current library.
|
||
///
|
||
/// Used by pinning, which writes intent against the catalog directly
|
||
/// rather than going through a fetch.
|
||
pub fn cache(&self) -> Option<dr_catalog::Cache> {
|
||
let dir = self.cache_dir()?;
|
||
match dr_catalog::Cache::open(&dir, self.cache_budget.get()) {
|
||
Ok(c) => Some(c),
|
||
Err(e) => {
|
||
// Not fatal: without a cache every open is a download, which
|
||
// is exactly the behaviour that existed before this.
|
||
log::warn!("originals cache unavailable: {e}");
|
||
None
|
||
}
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-NC-6a
|
||
/// Everything a full fetch needs to read and write the cache.
|
||
///
|
||
/// Assembled here because the develop callback holds only a path, while
|
||
/// the cache is keyed on `ImageId` and lives beside a catalog whose
|
||
/// location comes from the session. `None` where any part is missing — a
|
||
/// grid row that has scrolled away, or no library open — and the fetch
|
||
/// then simply goes to the network.
|
||
pub fn cache_context(&self, path: &str) -> Option<library::CacheContext> {
|
||
self.cache_context_for(self.image_id_for_path(path)?)
|
||
}
|
||
|
||
/// TRACES: FR-EXP-7 | FR-NC-6a
|
||
/// The same, for an image named by id rather than by path.
|
||
///
|
||
/// A batch export needs this one: its selection is by catalog id and may
|
||
/// include photographs that have scrolled out of the loaded window, where
|
||
/// [`Self::image_id_for_path`] has nothing to match against. Going through
|
||
/// the path would quietly hand those images no cache at all, and a batch of
|
||
/// three hundred would re-download every one of them.
|
||
pub fn cache_context_for(&self, image: dr_types::ImageId) -> Option<library::CacheContext> {
|
||
let borrow = self.session.borrow();
|
||
let (conn, _) = borrow.as_ref()?;
|
||
let catalog_path = library::catalog_path(&conn.account);
|
||
let dir = catalog_path.parent()?.join("originals");
|
||
drop(borrow);
|
||
|
||
Some(library::CacheContext {
|
||
dir,
|
||
catalog_path,
|
||
image,
|
||
// The user's ceiling, not the catalog's floor: read at each fetch
|
||
// so a budget changed mid-session takes effect on the next one.
|
||
budget: self.cache_budget.get(),
|
||
// Gates the *write* only. Reading stays enabled either way: bytes
|
||
// already on disk were paid for, and refusing to use them because
|
||
// the user has since stopped adding new ones would re-download
|
||
// images that are sitting right there — and would make a pinned
|
||
// collection unopenable offline.
|
||
store: self.keep_opened.get(),
|
||
})
|
||
}
|
||
|
||
/// Toggle the local-only filter, resetting the window.
|
||
///
|
||
/// The offset is cleared for the same reason a scope change clears it: the
|
||
/// filter changes which images exist as far as the grid is concerned, so a
|
||
/// position counted against the old set names a different photograph.
|
||
pub fn set_local_only(&self, on: bool) {
|
||
self.local_only.set(on);
|
||
*self.offset.borrow_mut() = 0;
|
||
self.requested.borrow_mut().clear();
|
||
}
|
||
|
||
/// The catalog handle, for [`crate::collections_ui`] to edit through.
|
||
pub fn catalog(&self) -> Rc<RefCell<Option<Catalog>>> {
|
||
self.catalog.clone()
|
||
}
|
||
|
||
/// The open library's connection.
|
||
///
|
||
/// Needed by the trash, whose move and delete go to the same account the
|
||
/// scan and thumbnail workers use. `None` before a library is opened.
|
||
pub fn session(&self) -> Option<Connection> {
|
||
self.session.borrow().as_ref().map(|(c, _)| c.clone())
|
||
}
|
||
|
||
/// Catalog ids of the rows currently in the model, in model order.
|
||
///
|
||
/// Selection is keyed on these rather than on row indices: the grid is a
|
||
/// window over the catalog and a scrub replaces every row, so an index
|
||
/// would silently come to mean a different photograph.
|
||
pub fn visible_ids(&self) -> Vec<dr_types::ImageId> {
|
||
self.image_ids
|
||
.borrow()
|
||
.iter()
|
||
.map(|id| dr_types::ImageId(*id as u64))
|
||
.collect()
|
||
}
|
||
|
||
/// TRACES: FR-CAT-5
|
||
/// Catalog ids of grid rows `first..=last`, loaded or not.
|
||
///
|
||
/// The counterpart to [`Self::visible_ids`], and the reason both exist: a
|
||
/// shift-click names a run by its two ends, and everything between them is
|
||
/// usually off screen. Answered from the catalog, through the same scope,
|
||
/// filter and ordering this controller reads its window with — so the run
|
||
/// is the run the user can see themselves selecting, continued past the
|
||
/// edge of what has been loaded.
|
||
///
|
||
/// Empty before a library is open, and empty if the query fails: a
|
||
/// selection gesture is not worth an error dialog, and the caller keeps
|
||
/// what was already selected.
|
||
pub fn ids_in_span(&self, first: usize, last: usize) -> Vec<dr_types::ImageId> {
|
||
let borrow = self.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return Vec::new();
|
||
};
|
||
library::read_ids_span(
|
||
catalog,
|
||
*self.scope.borrow(),
|
||
&self.filter.borrow(),
|
||
self.viewing_trash.get(),
|
||
first,
|
||
last,
|
||
)
|
||
.unwrap_or_else(|e| {
|
||
log::warn!("reading the selected range: {e}");
|
||
Vec::new()
|
||
})
|
||
}
|
||
|
||
/// TRACES: FR-EXP-7
|
||
/// Remote paths for a set of selected images, in the order they were given.
|
||
///
|
||
/// Answered from the catalog rather than from `paths`, which is only the
|
||
/// loaded window. Selection is by id precisely so that it survives a scrub
|
||
/// (see [`crate::collections_ui`]), so a selection made before scrolling
|
||
/// routinely names photographs no row currently holds — and an export that
|
||
/// silently dropped those would be worse than one that refused.
|
||
///
|
||
/// An id the catalog has never heard of is skipped rather than reported: it
|
||
/// can only mean the image was deleted between the selection and the click,
|
||
/// and there is nothing to export and nothing to fix.
|
||
///
|
||
/// Each path comes back beside the id it belongs to, because that skipping
|
||
/// means the ids that come out are not the ids that went in — and the caller
|
||
/// still has to find each photograph's cache, which is keyed on the id.
|
||
pub fn selected_image_paths(
|
||
&self,
|
||
images: &[dr_types::ImageId],
|
||
) -> Vec<(dr_types::ImageId, String)> {
|
||
if images.is_empty() {
|
||
return Vec::new();
|
||
}
|
||
|
||
let borrow = self.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return Vec::new();
|
||
};
|
||
|
||
let placeholders = std::iter::repeat_n("?", images.len())
|
||
.collect::<Vec<_>>()
|
||
.join(",");
|
||
let sql = format!("SELECT id, source_ref FROM images WHERE id IN ({placeholders})");
|
||
let params: Vec<rusqlite::types::Value> = images
|
||
.iter()
|
||
.map(|i| rusqlite::types::Value::Integer(i.0 as i64))
|
||
.collect();
|
||
|
||
let Ok(mut stmt) = catalog.connection().prepare(&sql) else {
|
||
return Vec::new();
|
||
};
|
||
let Ok(rows) = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
||
Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?))
|
||
}) else {
|
||
return Vec::new();
|
||
};
|
||
|
||
// `IN` returns rows in whatever order suits SQLite, and the export's
|
||
// `{seq}` token counts through the batch — so the answer is put back
|
||
// into the order the caller asked in rather than the one it arrived in.
|
||
let found: std::collections::HashMap<i64, String> = rows.flatten().collect();
|
||
images
|
||
.iter()
|
||
.filter_map(|id| found.get(&(id.0 as i64)).map(|path| (*id, path.clone())))
|
||
.collect()
|
||
}
|
||
|
||
/// TRACES: FR-EXP-7
|
||
/// The selection, addressed the way an export worker can fetch it.
|
||
///
|
||
/// Assembled here because a worker thread can reach neither the catalog nor
|
||
/// the session, and both are needed to say where a cached original lives.
|
||
pub fn export_sources(&self, images: &[dr_types::ImageId]) -> Vec<crate::export::Source> {
|
||
self.selected_image_paths(images)
|
||
.into_iter()
|
||
.map(|(id, path)| crate::export::Source::Library {
|
||
path,
|
||
cache: self.cache_context_for(id),
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// The open library's connection, for a full-file fetch.
|
||
///
|
||
/// The grid's paths are remote, so opening an image means fetching it, and
|
||
/// that goes through the same account the thumbnail workers use.
|
||
pub fn credentials(&self) -> Option<Connection> {
|
||
self.session.borrow().as_ref().map(|(c, _)| c.clone())
|
||
}
|
||
|
||
/// Narrow the grid to a collection, or to the whole library with `None`.
|
||
pub fn set_scope(&self, scope: Option<dr_types::CollectionId>) {
|
||
*self.scope.borrow_mut() = scope;
|
||
// Selecting a collection is leaving the trash. Without this, picking a
|
||
// collection while the trash was open would keep listing trashed images
|
||
// under that collection's name.
|
||
self.viewing_trash.set(false);
|
||
// A new scope is a different set of images, so the old window's
|
||
// position and its issued fetches mean nothing.
|
||
*self.offset.borrow_mut() = 0;
|
||
self.requested.borrow_mut().clear();
|
||
}
|
||
|
||
/// TRACES: FR-CAT-15
|
||
/// Show the trash instead of the library.
|
||
///
|
||
/// Clears `scope` as well: the trash is not inside a collection, and leaving
|
||
/// a stale scope set would narrow it to one on the way back out.
|
||
pub fn set_viewing_trash(&self, viewing: bool) {
|
||
self.viewing_trash.set(viewing);
|
||
if viewing {
|
||
*self.scope.borrow_mut() = None;
|
||
}
|
||
*self.offset.borrow_mut() = 0;
|
||
self.requested.borrow_mut().clear();
|
||
}
|
||
}
|
||
|
||
/// 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: &AccountStore,
|
||
account: Account,
|
||
) {
|
||
// The credential is fetched only where the connector wants one; a folder
|
||
// library has none, and asking the keyring for it would fail the one
|
||
// backend that needs nothing.
|
||
let conn = match store.connection(&account, crate::remote::needs_secret(&account)) {
|
||
Ok(c) => c,
|
||
Err(e) => {
|
||
window.set_library_error(format!("credentials: {e}").into());
|
||
window.set_show_library(true);
|
||
return;
|
||
}
|
||
};
|
||
|
||
let filter = account.format_filter();
|
||
*ctl.session.borrow_mut() = Some((conn.clone(), filter.clone()));
|
||
|
||
window.set_show_library(true);
|
||
window.set_library_open(true);
|
||
window.set_library_scanning(true);
|
||
window.set_library_error(slint::SharedString::new());
|
||
window.set_library_status("Starting…".into());
|
||
// Always visible: two folders one letter apart are easy to confuse, and a
|
||
// scan of the wrong one is indistinguishable from a broken scan.
|
||
window.set_library_root_label(
|
||
if conn.account.root.is_empty() {
|
||
format!("{} · whole account", conn.account.user_id)
|
||
} else {
|
||
format!("{}/{}", conn.account.user_id, conn.account.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(&conn.account);
|
||
log::info!(
|
||
"scanning {} for {} format(s) → {}",
|
||
if conn.account.root.is_empty() {
|
||
"<account root>"
|
||
} else {
|
||
&conn.account.root
|
||
},
|
||
filter.iter().count(),
|
||
path.display()
|
||
);
|
||
|
||
// Before the scan, not after it: the grid can be filled from disk now and
|
||
// the scan is only ever going to add to it.
|
||
show_catalog_now(window, &ctl, &path, &coll_ctl);
|
||
|
||
let rx = library::spawn_scan(
|
||
conn.clone(),
|
||
conn.account.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((c, _)) if !c.account.root.is_empty() => {
|
||
format!("Scanning {}", c.account.root)
|
||
}
|
||
_ => "Scanning the library".to_string(),
|
||
};
|
||
let job = ctl.activity.begin(crate::activity::Kind::Scan, title);
|
||
|
||
timer.start(
|
||
slint::TimerMode::Repeated,
|
||
std::time::Duration::from_millis(120),
|
||
move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let ctl = &ctl_cb;
|
||
|
||
loop {
|
||
let msg = match rx.try_recv() {
|
||
Ok(m) => m,
|
||
Err(std::sync::mpsc::TryRecvError::Empty) => return,
|
||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||
// A worker that died without sending must not leave
|
||
// the screen on "Scanning…" forever.
|
||
if w.get_library_scanning() {
|
||
w.set_library_scanning(false);
|
||
w.set_library_error("scan ended unexpectedly".into());
|
||
job.fail("ended unexpectedly");
|
||
}
|
||
stop(&ctl.scan_timer);
|
||
return;
|
||
}
|
||
};
|
||
|
||
match msg {
|
||
ScanMessage::Progress {
|
||
directories,
|
||
pruned,
|
||
images,
|
||
} => {
|
||
// Pruned folders are reported separately rather than
|
||
// folded into the total: they are the ETag walk paying
|
||
// off, and hiding them makes an incremental rescan look
|
||
// identical to a full one.
|
||
let status = if pruned > 0 {
|
||
format!("{directories} folders · {pruned} unchanged · {images} images")
|
||
} else {
|
||
format!("{directories} folders · {images} images")
|
||
};
|
||
job.detail(status.clone());
|
||
w.set_library_status(status.into());
|
||
}
|
||
ScanMessage::Done {
|
||
found,
|
||
total,
|
||
pruned,
|
||
elapsed_ms,
|
||
} => {
|
||
log::info!(
|
||
"scan complete: {total} images ({found} listed, \
|
||
{pruned} folders unchanged) in {elapsed_ms} ms"
|
||
);
|
||
w.set_library_scanning(false);
|
||
|
||
// A completed scan is the strongest possible evidence
|
||
// the server is reachable, so it clears offline mode
|
||
// without needing a probe of its own.
|
||
if ctl
|
||
.reachability
|
||
.borrow_mut()
|
||
.mark_reachable(std::time::Instant::now())
|
||
{
|
||
log::info!("back online");
|
||
}
|
||
refresh_offline(&w, ctl);
|
||
|
||
// An incremental rescan lists almost nothing, so
|
||
// reporting the listed count would read as "0 images"
|
||
// on a library that is simply up to date.
|
||
let secs = elapsed_ms as f64 / 1000.0;
|
||
let status = if pruned > 0 && found == 0 {
|
||
format!("up to date · {total} images · {secs:.1}s")
|
||
} else if pruned > 0 {
|
||
format!("{found} new or changed · {total} images · {secs:.1}s")
|
||
} else {
|
||
format!("{total} images · {secs:.1}s")
|
||
};
|
||
job.finish(status.clone());
|
||
w.set_library_status(status.into());
|
||
|
||
// Usually already open — `show_catalog_now` opened it
|
||
// before this scan started, so the grid has been
|
||
// showing the previous run's catalog all along and the
|
||
// rows this scan found are new rows in that same file.
|
||
// Only a first run, where there was nothing to open,
|
||
// reaches the second arm.
|
||
let opened = if ctl.catalog.borrow().is_some() {
|
||
Ok(())
|
||
} else {
|
||
Catalog::open(&catalog_path).map(|cat| {
|
||
*ctl.catalog.borrow_mut() = Some(cat);
|
||
})
|
||
};
|
||
|
||
match opened {
|
||
Ok(()) => {
|
||
// The sidebar is built before the grid: the
|
||
// grid's badges read collection membership, and
|
||
// the tree is where the catalog handle first
|
||
// becomes available to it.
|
||
{
|
||
let borrow = ctl.catalog.borrow();
|
||
if let Some(cat) = borrow.as_ref() {
|
||
crate::collections_ui::refresh_tree(&w, &coll_ctl, cat);
|
||
}
|
||
}
|
||
load_window(&w, ctl);
|
||
// Everything the grid did not touch: the rest
|
||
// of the library gets a thumbnail and a date,
|
||
// so the timeline describes all of it rather
|
||
// than the part that was scrolled past.
|
||
start_sweep(&w, ctl);
|
||
}
|
||
Err(e) => w.set_library_error(format!("opening catalog: {e}").into()),
|
||
}
|
||
stop(&ctl.scan_timer);
|
||
return;
|
||
}
|
||
ScanMessage::Failed { message, offline } => {
|
||
log::warn!("scan failed: {message}");
|
||
w.set_library_scanning(false);
|
||
// Recorded as a failure even where it is only the
|
||
// connection: the grid's offline banner says the server
|
||
// is unreachable, and this says which piece of work
|
||
// stopped because of it.
|
||
job.fail(message.clone());
|
||
|
||
if offline {
|
||
// Not an error state. The catalog from the last
|
||
// successful scan is still on disk and still
|
||
// accurate for everything already indexed, so the
|
||
// grid keeps working — it simply cannot learn about
|
||
// anything added on the server since.
|
||
ctl.reachability
|
||
.borrow_mut()
|
||
.mark_unreachable(message, std::time::Instant::now());
|
||
refresh_offline(&w, ctl);
|
||
// Show whatever the catalog holds. Without this the
|
||
// grid stays empty on a launch that began offline,
|
||
// which is precisely the case offline mode exists
|
||
// for.
|
||
open_catalog_for_offline(&w, ctl, &catalog_path, &coll_ctl);
|
||
} else {
|
||
w.set_library_error(message.into());
|
||
}
|
||
stop(&ctl.scan_timer);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
},
|
||
);
|
||
|
||
*ctl.scan_timer.borrow_mut() = Some(timer);
|
||
}
|
||
|
||
/// Start a scan against the configured library.
|
||
///
|
||
/// Shared by the rescan button and by the offline banner's retry, which are
|
||
/// the same operation: a scan is the only request that both proves the server
|
||
/// is reachable and brings the catalog up to date. Keeping them one function
|
||
/// is what stops "retry" from quietly becoming a weaker probe than "rescan".
|
||
fn start_rescan(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
||
) {
|
||
let Some((conn, 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(&conn.account);
|
||
let rx = library::spawn_scan(
|
||
conn.clone(),
|
||
conn.account.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);
|
||
let outcome = match cache.release(catalog.connection(), &images) {
|
||
Ok(r) => r,
|
||
Err(e) => {
|
||
window.set_library_error(format!("removing local copies: {e}").into());
|
||
return;
|
||
}
|
||
};
|
||
|
||
// TRACES: FR-NC-6c
|
||
// On a placeholder library the bookkeeping above owns no files, so it
|
||
// freed nothing — the originals are materialised in the library folder
|
||
// and only the sync client may take them back. Asking it to is what
|
||
// makes unpinning actually return the disk, and it must be a
|
||
// dehydration rather than a delete: removing a file inside a synced
|
||
// tree propagates to the server (ARCH §9.0a).
|
||
//
|
||
// Fire and forget: it is per-file work over a socket, the user has
|
||
// already been told the pin is withdrawn, and a client that refuses
|
||
// leaves the content where it is at no cost but disk.
|
||
if let Some(conn) = ctl.session() {
|
||
let path = library::catalog_path(&conn.account);
|
||
std::mem::drop(library::spawn_dehydrate(conn, path, images));
|
||
}
|
||
outcome
|
||
};
|
||
|
||
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((conn, _)) = 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(
|
||
conn.clone(),
|
||
library::catalog_path(&conn.account),
|
||
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((conn, _)) = ctl.session.borrow().clone() else {
|
||
return;
|
||
};
|
||
|
||
let rx = library::spawn_outbox_drain(conn.clone(), cache_dir);
|
||
let job = ctl
|
||
.activity
|
||
.begin(crate::activity::Kind::Upload, "Uploading queued edits");
|
||
|
||
let timer = slint::Timer::default();
|
||
let weak = window.as_weak();
|
||
let ctl_cb = ctl.clone();
|
||
timer.start(
|
||
slint::TimerMode::Repeated,
|
||
std::time::Duration::from_millis(250),
|
||
move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
match rx.try_recv() {
|
||
Ok(library::SidecarMessage::Finished {
|
||
written,
|
||
queued,
|
||
failed,
|
||
last_error,
|
||
}) => {
|
||
if failed > 0 {
|
||
log::warn!(
|
||
"{failed} queued sidecar(s) still undelivered: {}",
|
||
last_error.clone().unwrap_or_default()
|
||
);
|
||
// Not `fail`: the edits are still safely queued, and
|
||
// reporting this as a loss would be wrong.
|
||
job.finish(format!("{written} uploaded · {queued} still queued"));
|
||
} else if written > 0 {
|
||
log::info!("{written} queued sidecar(s) uploaded");
|
||
job.finish(format!("{written} queued edit(s) uploaded"));
|
||
w.set_library_status(format!("{written} queued edit(s) uploaded").into());
|
||
} else {
|
||
job.finish_quietly();
|
||
}
|
||
ctl_cb.outbox_maybe_dirty.set(queued > 0);
|
||
stop(&ctl_cb.outbox_timer);
|
||
}
|
||
Err(std::sync::mpsc::TryRecvError::Empty) => {}
|
||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||
job.finish_quietly();
|
||
stop(&ctl_cb.outbox_timer);
|
||
}
|
||
}
|
||
},
|
||
);
|
||
*ctl.outbox_timer.borrow_mut() = Some(timer);
|
||
}
|
||
|
||
/// A coarse "how long ago", for the offline banner.
|
||
///
|
||
/// Deliberately imprecise: the user wants to know whether this just happened
|
||
/// or has been true for a while, and a live-counting seconds display would
|
||
/// draw the eye to a number that changes without meaning anything.
|
||
fn describe_duration(d: std::time::Duration) -> String {
|
||
let secs = d.as_secs();
|
||
if secs < 60 {
|
||
"just now".to_string()
|
||
} else if secs < 3600 {
|
||
format!("{}m ago", secs / 60)
|
||
} else {
|
||
format!("{}h ago", secs / 3600)
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-CAT-9
|
||
/// Show the catalog after a scan that could not reach the server.
|
||
///
|
||
/// The whole point of offline mode: a scan is how the grid normally gets its
|
||
/// catalog handle, so a failed one previously left the library empty even
|
||
/// though a complete catalog was sitting on disk from the last successful run.
|
||
/// The images are all still there, their thumbnails are in the shards, and
|
||
/// rating and collecting them are local writes.
|
||
///
|
||
/// The sweep is deliberately **not** started — it exists to fetch headers over
|
||
/// the network, so offline it would do nothing but fail once per image.
|
||
fn open_catalog_for_offline(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
catalog_path: &std::path::Path,
|
||
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
||
) {
|
||
if ctl.catalog.borrow().is_some() {
|
||
// Already open — a rescan that failed, rather than a launch that
|
||
// began offline. The grid is showing the catalog already.
|
||
load_window(window, ctl);
|
||
return;
|
||
}
|
||
|
||
match Catalog::open(catalog_path) {
|
||
Ok(cat) => {
|
||
crate::collections_ui::refresh_tree(window, coll_ctl, &cat);
|
||
*ctl.catalog.borrow_mut() = Some(cat);
|
||
load_window(window, ctl);
|
||
}
|
||
Err(e) => {
|
||
// No catalog and no server. This is the one genuinely empty case:
|
||
// a first run that never reached the server has nothing indexed.
|
||
log::warn!("offline with no local catalog: {e}");
|
||
window.set_library_error(
|
||
"Offline, and this library has not been scanned on this device yet.".into(),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// How long a run of geometry changes has to stop for before the window is
|
||
/// reloaded against it.
|
||
///
|
||
/// Long enough to swallow a whole gesture's worth of steps, short enough that a
|
||
/// single deliberate step still feels immediate.
|
||
const GEOMETRY_SETTLE: std::time::Duration = std::time::Duration::from_millis(140);
|
||
|
||
/// Reload the window once the grid's geometry has stopped changing.
|
||
///
|
||
/// # Why this is deferred when a scroll is not
|
||
///
|
||
/// A pinch is not one zoom step, it is a stream of them, and every step
|
||
/// changes both the column count and the capacity — two reports. Each report
|
||
/// used to re-query the catalog, rebuild all 360 rows of the model, re-read the
|
||
/// badges and ratings for every one of them and spawn a thumbnail batch,
|
||
/// synchronously, on the thread that is trying to draw the frame. Twice per
|
||
/// step. That is why zooming juddered while scrolling the same grid is smooth:
|
||
/// a scroll reloads a few times per screenful, a zoom reloaded twice a frame.
|
||
///
|
||
/// None of it is urgent, because none of it is about *which* photographs are on
|
||
/// screen. A column change moves the cells and a zoom resizes them, but the
|
||
/// window holds the same images either way — the model already has them, and
|
||
/// the cells re-flow from `columns` and `cell-size` without Rust being involved
|
||
/// at all. What the reload actually recomputes is which cells begin a row, so
|
||
/// the month headings land in the right places, and which thumbnail size class
|
||
/// to ask for now. Both can wait for the gesture to finish.
|
||
///
|
||
/// # The anchor
|
||
///
|
||
/// Captured on the *first* report of a run rather than read when the timer
|
||
/// fires. As the grid re-flows, the viewport keeps its pixel offset while the
|
||
/// rows move underneath it, so the view drifts and reports its drift — reading
|
||
/// the anchor at the end would faithfully return to wherever it had wandered
|
||
/// to. Taking it at the start returns to the photograph the user was actually
|
||
/// looking at when they began the gesture.
|
||
fn schedule_reload(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
// Only the first report of a run sets it; the rest of the run reuses it.
|
||
if ctl.pending_anchor.get().is_none() {
|
||
ctl.pending_anchor.set(Some(ctl.resume_at.get()));
|
||
}
|
||
|
||
let timer = slint::Timer::default();
|
||
let weak = window.as_weak();
|
||
let ctl_cb = ctl.clone();
|
||
timer.start(slint::TimerMode::SingleShot, GEOMETRY_SETTLE, move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let anchor = ctl_cb.pending_anchor.take().unwrap_or(0);
|
||
if !w.get_show_library() {
|
||
return;
|
||
}
|
||
|
||
// The window re-centred on the anchor, and the viewport sent back to
|
||
// it: cells are drawn at their absolute place in the library, so a
|
||
// change to `columns` moves every one of them and a viewport left
|
||
// where it was would be pointing at rows the window no longer covers.
|
||
let window_size = *ctl_cb.window.borrow();
|
||
let total = w.get_library_total().max(0) as usize;
|
||
*ctl_cb.offset.borrow_mut() = window_start(anchor, window_size, total);
|
||
load_window(&w, &ctl_cb);
|
||
w.set_library_scroll_to(anchor as i32);
|
||
w.set_library_scroll_token(w.get_library_scroll_token() + 1);
|
||
});
|
||
|
||
// Replacing the slot drops the previous timer, which is what makes this
|
||
// coalesce: only the last report of a run lives long enough to fire.
|
||
*ctl.geometry_timer.borrow_mut() = Some(timer);
|
||
}
|
||
|
||
/// Show what the catalog already holds, without waiting for the scan.
|
||
///
|
||
/// # Why a launch should not be a scan
|
||
///
|
||
/// A launch does not have to discover the library. The catalog from the last
|
||
/// run is on disk, complete, with its thumbnails in the shards beside it —
|
||
/// which is precisely the state [`open_catalog_for_offline`] already relies on
|
||
/// when the server cannot be reached. Every launch that *could* reach the
|
||
/// server threw that away and sat on "Scanning…" over an empty grid for as long
|
||
/// as a recursive WebDAV walk of the whole tree takes. On a real library, and
|
||
/// especially on a phone's connection, that walk is the entire startup time,
|
||
/// spent hiding a grid that was ready before it began.
|
||
///
|
||
/// The scan still runs and still replaces this the moment it lands. What it no
|
||
/// longer does is gate the first paint on the network.
|
||
///
|
||
/// Silent when there is no catalog yet: that is a genuine first run, the empty
|
||
/// state already says "Scanning…", and an error here would contradict a scan
|
||
/// that is working perfectly. `Catalog::open` creates the file in that case, so
|
||
/// what the grid reads is an empty catalog rather than a failure.
|
||
fn show_catalog_now(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
catalog_path: &std::path::Path,
|
||
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
||
) {
|
||
if ctl.catalog.borrow().is_some() {
|
||
return;
|
||
}
|
||
|
||
let cat = match Catalog::open(catalog_path) {
|
||
Ok(cat) => cat,
|
||
Err(e) => {
|
||
// Not surfaced: the scan is the thing that has to work, and it is
|
||
// still running. If it fails too, it reports for both of them.
|
||
log::info!("no catalog to show before the scan: {e}");
|
||
return;
|
||
}
|
||
};
|
||
|
||
// The sidebar before the grid, because the grid's badges read collection
|
||
// membership — the same order the scan's completion uses.
|
||
crate::collections_ui::refresh_tree(window, coll_ctl, &cat);
|
||
*ctl.catalog.borrow_mut() = Some(cat);
|
||
load_window(window, ctl);
|
||
}
|
||
|
||
/// What one cell of the outgoing model is worth keeping.
|
||
#[derive(Clone)]
|
||
struct Held {
|
||
thumbnail: slint::Image,
|
||
has_thumb: bool,
|
||
/// A completed fetch that found no preview. Worth carrying for the same
|
||
/// reason the pixels are: it is an answer about the file, and re-asking it
|
||
/// on every scroll is a fetch that will fail again.
|
||
unavailable: bool,
|
||
/// Which size class the pixels came from, so the reload can tell a cell
|
||
/// that is showing what it should from one that is showing the small class
|
||
/// while the grid has since been zoomed past it.
|
||
class: Option<dr_thumbs::ThumbSize>,
|
||
}
|
||
|
||
/// **What the outgoing model is still holding, keyed on the photograph.**
|
||
///
|
||
/// A reload replaces every row, and a scroll reloads once the view has
|
||
/// travelled a quarter of the loaded window — so three quarters of the cells
|
||
/// being rebuilt are the *same* photographs the user is looking at right now.
|
||
/// Rebuilding them empty blanked the whole grid to `Theme.ground` and refilled
|
||
/// it a beat later, once a worker had re-read and re-decoded every one of them
|
||
/// from the thumbnail store. That is the black flash that punctuated every
|
||
/// screenful of scrolling, and the one visible on a column change, a zoom step,
|
||
/// a filter and a return from develop.
|
||
///
|
||
/// Keyed on `image_id` rather than on the row, because the row is exactly what
|
||
/// a reload changes. Cheap: the values are `slint::Image` handles, so this is a
|
||
/// refcount per cell and no pixels move.
|
||
fn hold_thumbnails(
|
||
previous: &slint::ModelRc<LibraryCell>,
|
||
ids: &[i64],
|
||
classes: &[Option<dr_thumbs::ThumbSize>],
|
||
) -> std::collections::HashMap<i64, Held> {
|
||
ids.iter()
|
||
.enumerate()
|
||
.filter_map(|(row, id)| {
|
||
let cell = previous.row_data(row)?;
|
||
// Nothing to carry: a row still waiting is equally blank either
|
||
// way, and holding a default image would claim otherwise.
|
||
if !cell.has_thumb && !cell.unavailable {
|
||
return None;
|
||
}
|
||
Some((
|
||
*id,
|
||
Held {
|
||
thumbnail: cell.thumbnail,
|
||
has_thumb: cell.has_thumb,
|
||
unavailable: cell.unavailable,
|
||
class: classes.get(row).copied().flatten(),
|
||
},
|
||
))
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// The fetches a reloaded window does **not** have to make.
|
||
///
|
||
/// The set used to be cleared on every load, which said "every cell here is
|
||
/// still to be fetched" — true when every row came back empty, and false now
|
||
/// that the overlap keeps its pixels. Re-requesting them re-read and re-decoded
|
||
/// three quarters of the window from the store on every scroll.
|
||
///
|
||
/// Rebuilt rather than merely kept, and that is the half that is easy to get
|
||
/// wrong: an image served into a window the user has since scrolled away from
|
||
/// is no longer on screen, and a set that remembered it would leave that cell
|
||
/// permanently blank when they scrolled back. Only what the new model actually
|
||
/// holds counts as served — and only at the class it holds it at, so zooming
|
||
/// past the grid class still asks for the large one.
|
||
fn already_served(
|
||
held: &std::collections::HashMap<i64, Held>,
|
||
ids: impl Iterator<Item = i64>,
|
||
) -> std::collections::HashSet<(i64, dr_thumbs::ThumbSize)> {
|
||
ids.filter_map(|id| Some((id, held.get(&id)?.class?)))
|
||
.collect()
|
||
}
|
||
|
||
/// Where the loaded window starts for a view whose first visible cell is
|
||
/// `anchor`.
|
||
///
|
||
/// A quarter of the window sits above the view, so scrolling back has loaded
|
||
/// rows to move into, and the remaining three quarters below, because a grid
|
||
/// is read downward. Clamped to the last position where the window is still
|
||
/// full — otherwise a scrub to the very end loads a handful of cells and the
|
||
/// rest of the window addresses images that do not exist.
|
||
fn window_start(anchor: usize, window_size: usize, total: usize) -> usize {
|
||
let max_offset = total.saturating_sub(window_size.min(total));
|
||
anchor.saturating_sub(window_size / 4).min(max_offset)
|
||
}
|
||
|
||
/// Where the loaded window should move to for a view at `first_visible`, or
|
||
/// `None` to leave it where it is.
|
||
///
|
||
/// # Why this is a rule and not four lines in the scroll handler
|
||
///
|
||
/// It decides how often the grid re-reads the catalog while a finger is on it,
|
||
/// and both of its ways of being wrong are invisible in the code and obvious
|
||
/// on a tablet: too eager and every scroll stutters, too lazy and the view
|
||
/// runs off the end of the loaded rows into blank ones.
|
||
///
|
||
/// # The rule
|
||
///
|
||
/// The window is placed by [`window_start`], and it moves once the view comes
|
||
/// within half a screenful of an edge of what is loaded — below the *bottom*
|
||
/// of the view going down, above its top going up.
|
||
///
|
||
/// # Measured against the view, not against a fraction of the window
|
||
///
|
||
/// Both halves of that used to be a quarter of `window_size`, and both were
|
||
/// wrong, in opposite directions and for the same reason: a quarter of a
|
||
/// three-screenful window is three quarters of a screenful, which is not a
|
||
/// quantity either edge of the view cares about.
|
||
///
|
||
/// - Downward it was too lax. The test asked where the *first* visible cell
|
||
/// was, so it kept the window still until `first_visible` was three quarters
|
||
/// of the way through it — by which point the bottom of the view had
|
||
/// travelled a quarter of a screenful past the last loaded cell. Those rows
|
||
/// are not in the model, so nothing is drawn for them: the last row of the
|
||
/// grid, blank, at a scroll position the user can sit at indefinitely.
|
||
/// - Upward it was too eager, and exactly so. The window is placed a quarter
|
||
/// of itself behind the view, and the old margin then declared the view too
|
||
/// close to the top at precisely that distance — so the first row scrolled
|
||
/// upward after any move re-read the catalog, rebuilt every cell, and
|
||
/// re-queried the badges and ratings, and so did the row after it.
|
||
///
|
||
/// # And it never moves to where it already is
|
||
///
|
||
/// That is the case the margin test alone gets wrong: at either end of a scope
|
||
/// the window is *pinned* — the first screenful cannot be placed further back
|
||
/// than zero, and the last cannot start past `max_offset` — so the margin is
|
||
/// unsatisfiable there and every row crossed in the first or last quarter of a
|
||
/// window re-read the catalog, rebuilt the model and issued a thumbnail batch
|
||
/// to arrive at the offset it already had. On a library of twenty-odd thousand
|
||
/// that is a stutter at the top and the bottom of every collection, which is
|
||
/// exactly where a cull begins and ends.
|
||
fn window_move(
|
||
first_visible: usize,
|
||
current: usize,
|
||
window_size: usize,
|
||
on_screen: usize,
|
||
total: usize,
|
||
) -> Option<usize> {
|
||
// The same placement `load_window` applies, repeated here so this compares
|
||
// against where the window would come to rest rather than where it was
|
||
// asked to go.
|
||
let desired = window_start(first_visible, window_size, total);
|
||
if desired == current {
|
||
return None;
|
||
}
|
||
|
||
// Half a screenful of loaded cells beyond each edge of the view. Enough
|
||
// that a flick has somewhere to land before the reload it triggers has
|
||
// finished, small enough that the window still moves only about once per
|
||
// screenful of travel.
|
||
let slack = (on_screen / 2).max(1);
|
||
let above = first_visible >= current + slack;
|
||
let below = first_visible + on_screen + slack <= current + window_size;
|
||
if above && below {
|
||
return None;
|
||
}
|
||
|
||
Some(desired)
|
||
}
|
||
|
||
/// Fill the model from the catalog and start fetching thumbnails.
|
||
///
|
||
/// Reads the window starting at the controller's current offset, which the
|
||
/// scrubber moves. Without a movable offset the grid could only ever show the
|
||
/// first 120 of 23,971 images.
|
||
fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
let borrow = ctl.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
|
||
// Everything below is scoped to the selected collection, if any: the total,
|
||
// the window, and the fetches issued for it. Reading the whole library here
|
||
// and filtering later would fetch thumbnails for images the user is not
|
||
// looking at, which on a remote library is the cost FR-NC-3 exists to
|
||
// avoid.
|
||
let scope = *ctl.scope.borrow();
|
||
let filter = ctl.filter.borrow().clone();
|
||
// 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();
|
||
|
||
let total = if trash {
|
||
library::total_trashed(catalog).unwrap_or(0)
|
||
} else {
|
||
library::total_images_scoped(catalog, scope, &filter).unwrap_or(0)
|
||
};
|
||
|
||
// What the whole-library readouts below describe. See [`LibraryFacts`].
|
||
let facts = LibraryFacts {
|
||
scope,
|
||
filter: filter.clone(),
|
||
trash,
|
||
total,
|
||
};
|
||
let describes_something_new = ctl.library_facts.borrow().as_ref() != Some(&facts);
|
||
*ctl.library_facts.borrow_mut() = Some(facts);
|
||
|
||
// 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.
|
||
//
|
||
// Three queries deep — descendants, their images, and whether every one of
|
||
// them is pinned — and the answer cannot change by scrolling.
|
||
if describes_something_new {
|
||
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,
|
||
});
|
||
}
|
||
// Read before it is overwritten: the property still holds what the library
|
||
// was last time this ran, and a library that has become shorter is the
|
||
// signal that something was deleted out from under the view.
|
||
let was = window.get_library_total().max(0) as usize;
|
||
window.set_library_total(total as i32);
|
||
let shrank = total < was;
|
||
|
||
// Clamp so a scrub to the very end still fills the window rather than
|
||
// showing a handful of cells.
|
||
let window_size = *ctl.window.borrow();
|
||
let offset = (*ctl.offset.borrow()).min(total.saturating_sub(window_size.min(total)));
|
||
*ctl.offset.borrow_mut() = offset;
|
||
window.set_library_offset(offset as i32);
|
||
|
||
// The axis the scrubber draws. A `MIN`/`MAX` and a `GROUP BY` over the
|
||
// whole scope — 5.7 ms on 24,000 images — and the bars do not change as the
|
||
// grid scrolls, only the marker on them does. The marker is moved by the
|
||
// scroll handler on every event, without coming through here.
|
||
//
|
||
// Every other thing that *does* change the bars — a zoom, a pan, a scrub,
|
||
// dates landing from the thumbnail worker — calls `refresh_timeline`
|
||
// itself, so this being skipped cannot leave a stale axis on screen.
|
||
if describes_something_new {
|
||
refresh_timeline(window, catalog, ctl);
|
||
}
|
||
|
||
let cells = if trash {
|
||
library::read_trashed_cells(catalog, offset, window_size)
|
||
} else {
|
||
library::read_cells_scoped(catalog, scope, &filter, offset, window_size)
|
||
};
|
||
let cells = match cells {
|
||
Ok(c) => c,
|
||
Err(e) => {
|
||
window.set_library_error(format!("reading catalog: {e}").into());
|
||
return;
|
||
}
|
||
};
|
||
|
||
// What the window currently spans, in the photographer's own terms.
|
||
let span = cells
|
||
.iter()
|
||
.filter_map(|c| c.captured_at)
|
||
.fold(None::<(i64, i64)>, |acc, t| {
|
||
Some(match acc {
|
||
None => (t, t),
|
||
Some((lo, hi)) => (lo.min(t), hi.max(t)),
|
||
})
|
||
});
|
||
window.set_library_window_label(
|
||
match span {
|
||
Some((lo, hi)) => format!("{} – {}", format_date(lo), format_date(hi)),
|
||
// Nothing here has a date yet: EXIF is read as thumbnails load, so
|
||
// this fills in rather than being an error.
|
||
None => "dates not yet read".to_string(),
|
||
}
|
||
.into(),
|
||
);
|
||
|
||
// Month headings. The grid is ordered by capture time, so without these a
|
||
// wall of thumbnails gives no sense of *when* you are looking — the
|
||
// sidebar says it, but only if you consult it.
|
||
//
|
||
// Marked on the cell that both begins a month and begins a row: a heading
|
||
// stranded mid-row would label the cells to its left, which belong to the
|
||
// previous month.
|
||
let columns = window.get_library_columns().max(1) as usize;
|
||
let mut previous_month: Option<(i64, i64)> = None;
|
||
let headings: Vec<String> = cells
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(i, c)| {
|
||
let Some(t) = c.captured_at else {
|
||
return String::new();
|
||
};
|
||
let (y, m, _, _) = civil_from_unix(t);
|
||
let is_new = previous_month != Some((y, m));
|
||
previous_month = Some((y, m));
|
||
|
||
// A heading is drawn above its row, so it can only sit on a cell
|
||
// that begins one — a heading stranded mid-row would appear to
|
||
// label the cells to its left, which belong to the month before.
|
||
//
|
||
// The first cell of the window always carries one, whichever
|
||
// column it lands in: a scrolled window would otherwise show no
|
||
// date at all until the next month began.
|
||
let begins_row = (i + offset) % columns == 0;
|
||
if i == 0 || (is_new && begins_row) {
|
||
format!("{} {y}", month_name(m))
|
||
} else {
|
||
String::new()
|
||
}
|
||
})
|
||
.collect();
|
||
|
||
// What the outgoing model is still holding — see [`hold_thumbnails`].
|
||
let held = {
|
||
let previous = window.get_library_cells();
|
||
let ids = ctl.image_ids.borrow();
|
||
let classes = ctl.thumb_class.borrow();
|
||
hold_thumbnails(&previous, &ids, &classes)
|
||
};
|
||
|
||
let rows: Vec<LibraryCell> = cells
|
||
.iter()
|
||
.zip(headings)
|
||
.map(|(c, heading)| {
|
||
let carried = held.get(&c.image_id);
|
||
LibraryCell {
|
||
period_heading: heading.into(),
|
||
// A freshly loaded window has no drag in flight.
|
||
lifted: false,
|
||
name: without_extension(&c.name).into(),
|
||
thumbnail: carried.map(|h| h.thumbnail.clone()).unwrap_or_default(),
|
||
has_thumb: carried.is_some_and(|h| h.has_thumb),
|
||
unavailable: carried.is_some_and(|h| h.unavailable),
|
||
// All three 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,
|
||
anchor: 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.captured_at.borrow_mut() = cells.iter().map(|c| c.captured_at).collect();
|
||
*ctl.thumb_class.borrow_mut() = cells
|
||
.iter()
|
||
.map(|c| held.get(&c.image_id).and_then(|h| h.class))
|
||
.collect();
|
||
*ctl.requested.borrow_mut() = already_served(&held, cells.iter().map(|c| c.image_id));
|
||
|
||
// The model is about to be replaced, so every thumbnail still in flight
|
||
// addresses a window that no longer exists. Bumping here — before the swap
|
||
// — is what lets `drain_thumbnails` recognise itself as stale. The rows
|
||
// those fetches would have filled are absent from `requested` above, so the
|
||
// batch started below asks for them again.
|
||
ctl.generation.set(ctl.generation.get().wrapping_add(1));
|
||
window.set_library_cells(slint::ModelRc::new(slint::VecModel::from(rows)));
|
||
|
||
// The model is fresh, so the "in this many collections" badges are all zero
|
||
// until refilled. One query for the whole window, not one per cell.
|
||
let ids: Vec<dr_types::ImageId> = cells
|
||
.iter()
|
||
.map(|c| dr_types::ImageId(c.image_id as u64))
|
||
.collect();
|
||
crate::collections_ui::sync_badges(window, catalog, &ids);
|
||
sync_ratings(window, catalog, &ids);
|
||
// The rebuilt cells all carry `selected: false`, but the selection itself
|
||
// is a set of image ids and survives untouched. Without this the ticks
|
||
// vanished on every scroll — the selection was still there and still acted
|
||
// on, which is worse than losing it, because the user cannot see what the
|
||
// buttons are about to do.
|
||
if let Some(coll) = ctl.coll_ctl.borrow().as_ref().and_then(|w| w.upgrade()) {
|
||
crate::collections_ui::sync_selection(window, &coll, &ids);
|
||
}
|
||
// The filter chips' counts describe the whole library, not this window —
|
||
// which is exactly why they are not recomputed for a window that moved.
|
||
// A judgement changes them and calls this itself (see `apply_judgement`),
|
||
// so a cull still watches its own chips move.
|
||
if describes_something_new {
|
||
refresh_rating_counts(window, catalog);
|
||
}
|
||
|
||
// The library got shorter while the view was looking at it — a delete.
|
||
//
|
||
// Two things have already moved by this point and neither touches the
|
||
// viewport: the scrollable height shrank, and `offset` was re-clamped so
|
||
// the loaded window still fills. So the view is left pointing either past
|
||
// the end of the content (a blank grid) or at a different part of the
|
||
// library (an apparent jump to nowhere). Re-anchoring here puts it back on
|
||
// the photographs the user was actually looking at.
|
||
//
|
||
// Only on a shrink. Doing it on every load would fight a scrub, which sets
|
||
// exactly this property to go somewhere the user asked for.
|
||
if shrank {
|
||
restore_position(window, ctl);
|
||
}
|
||
|
||
if total == 0 {
|
||
return;
|
||
}
|
||
request_thumbnails(window, ctl);
|
||
}
|
||
|
||
/// Push each visible image's stars and flag into the grid model.
|
||
///
|
||
/// One query for the window, mirroring `collections_ui::sync_badges` — 120
|
||
/// cells is 120 round trips otherwise, on every scroll and after every
|
||
/// keystroke.
|
||
pub fn sync_ratings(window: &AppWindow, catalog: &Catalog, ids: &[dr_types::ImageId]) {
|
||
if ids.is_empty() {
|
||
return;
|
||
}
|
||
|
||
let found = match dr_catalog::rating::judgements(catalog.connection(), ids) {
|
||
Ok(j) => j,
|
||
Err(e) => {
|
||
// The grid is still usable without stars, so this is logged rather
|
||
// than surfaced — a failure here must not blank the library.
|
||
log::debug!("reading ratings: {e}");
|
||
return;
|
||
}
|
||
};
|
||
|
||
let model = window.get_library_cells();
|
||
for (row, id) in ids.iter().enumerate() {
|
||
// Absent means unrated, which is a real state rather than missing data.
|
||
let j = found.get(id).copied().unwrap_or_default();
|
||
let (rating, flag) = (j.rating as i32, flag_code(j.flag));
|
||
|
||
if let Some(mut cell) = model.row_data(row) {
|
||
if cell.rating != rating || cell.flag != flag {
|
||
cell.rating = rating;
|
||
cell.flag = flag;
|
||
model.set_row_data(row, cell);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Refresh the filter chips' per-star counts.
|
||
///
|
||
/// Whole-library figures, deliberately: they say what narrowing to a filter
|
||
/// would show, so computing them over the current window would make each chip
|
||
/// describe the view it is meant to change.
|
||
fn refresh_rating_counts(window: &AppWindow, catalog: &Catalog) {
|
||
let counts = dr_catalog::rating::rating_histogram(catalog.connection()).unwrap_or_default();
|
||
let as_i32: Vec<i32> = counts.iter().map(|n| *n as i32).collect();
|
||
window.set_library_rating_counts(slint::ModelRc::new(slint::VecModel::from(as_i32)));
|
||
|
||
// TRACES: FR-CAT-9
|
||
// How many originals are actually here, for the "On this device" chip.
|
||
// Shown for the same reason the star counts are: a filter that silently
|
||
// empties the grid reads as broken, and this one will legitimately be zero
|
||
// on a library nothing has been downloaded from yet.
|
||
window.set_library_local_count(library::local_original_count(catalog).unwrap_or(0) as i32);
|
||
}
|
||
|
||
// --- keywords (FR-CAT-5, FR-CAT-6) ---------------------------------------
|
||
//
|
||
// `dr_catalog::keywords` owns the data rules — the vocabulary, the many-to-many
|
||
// join, what a rename does to the assignments. This part owns the *interaction*:
|
||
// which photographs the sheet is acting on, and keeping what it draws honest
|
||
// about what actually landed.
|
||
|
||
/// Redraw the keywording sheet against whatever is selected now.
|
||
///
|
||
/// Called when the sheet opens and after every assignment, rather than on every
|
||
/// selection change: the selection moves on each arrow key and the sheet is shut
|
||
/// for almost all of them, so computing coverage over a forty-image selection
|
||
/// on each one would be work nobody is looking at.
|
||
///
|
||
/// Re-read from the catalog rather than patched in place after a write. A word
|
||
/// applied to a selection that partly already had it moves from "3 of 12" to
|
||
/// "12 of 12", and a model updated by hand would have to reproduce the rule
|
||
/// that decides that — which is exactly the rule the catalog has just applied.
|
||
fn refresh_keywords(window: &AppWindow, ctl: &Rc<LibraryController>, images: &[dr_types::ImageId]) {
|
||
let borrow = ctl.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
|
||
let rows = match dr_catalog::keywords::for_images(catalog.connection(), images) {
|
||
Ok(rows) => rows,
|
||
Err(e) => {
|
||
// The grid is entirely usable without the sheet, so this is logged
|
||
// rather than surfaced: a keyword read that failed must not put an
|
||
// error banner over a library the user is browsing.
|
||
log::debug!("reading keywords: {e}");
|
||
return;
|
||
}
|
||
};
|
||
|
||
let model: Vec<KeywordRow> = rows
|
||
.into_iter()
|
||
.map(|row| KeywordRow {
|
||
id: row.keyword.id.0 as i32,
|
||
name: row.keyword.name.into(),
|
||
coverage: match row.coverage {
|
||
dr_catalog::Coverage::None => 0,
|
||
dr_catalog::Coverage::Some => 1,
|
||
dr_catalog::Coverage::All => 2,
|
||
},
|
||
selected_count: row.selected_count as i32,
|
||
image_count: row.keyword.image_count as i32,
|
||
})
|
||
.collect();
|
||
|
||
window.set_library_keywords(slint::ModelRc::new(slint::VecModel::from(model)));
|
||
}
|
||
|
||
/// Put a keyword on the selection, or take it off.
|
||
///
|
||
/// # Why this does not write a sidecar
|
||
///
|
||
/// Every other judgement in this file — a star, a flag — is written to the
|
||
/// catalog and then queued to the image's sidecar, because the sidecar is what
|
||
/// makes it survive a catalog rebuild (ARCH §6.12). A keyword has no place in
|
||
/// the sidecar format yet: `dr_pipeline::sidecar::Version` carries `rating` and
|
||
/// `flag` and nothing else that is not an edit-graph parameter.
|
||
///
|
||
/// So a keyword is, for now, catalog state that reaches the user's other
|
||
/// devices through the *catalog* merge ([`dr_catalog::merge`]) rather than
|
||
/// through the sidecar. That is a real limitation and not a silent one: a
|
||
/// deleted catalog loses keywords where it would keep ratings, until the
|
||
/// sidecar gains a `dc:subject` field (FR-CAT-13) and this grows the same
|
||
/// queued write the stars have.
|
||
fn apply_keyword(window: &AppWindow, ctl: &Rc<LibraryController>, word: &str, assigning: bool) {
|
||
let Some(coll) = ctl.coll_ctl.borrow().as_ref().and_then(|c| c.upgrade()) else {
|
||
return;
|
||
};
|
||
let images = coll.selected();
|
||
|
||
// The word as it will be *stored*, resolved before anything is written.
|
||
// The status line below quotes it back, and quoting what was typed would
|
||
// report a leading space the catalog is about to drop — leaving the user to
|
||
// wonder whether it mattered.
|
||
//
|
||
// This is also where a blank keyword is caught, which is why it happens
|
||
// before the selection check: "you typed nothing" is a better answer than
|
||
// "select an image first" to someone who pressed return on an empty field.
|
||
let word = match dr_catalog::keywords::normalise(word) {
|
||
Ok(word) => word,
|
||
Err(e) => {
|
||
// `BadName` carries text written to be read by the user rather than
|
||
// by a developer, so it is shown as it is.
|
||
window.set_library_error(format!("{e}").into());
|
||
return;
|
||
}
|
||
};
|
||
|
||
// Assigning with nothing selected still means something — it puts the word
|
||
// in the vocabulary, ready for the photographs it was typed for — so only
|
||
// the removal half needs a selection to act on.
|
||
if images.is_empty() && !assigning {
|
||
window.set_library_status("Select an image first".into());
|
||
return;
|
||
}
|
||
|
||
let outcome = {
|
||
let borrow = ctl.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
let conn = catalog.connection();
|
||
if assigning {
|
||
dr_catalog::keywords::assign(conn, &images, &word)
|
||
} else {
|
||
dr_catalog::keywords::unassign(conn, &images, &word)
|
||
}
|
||
};
|
||
|
||
let n = match outcome {
|
||
Ok(n) => n,
|
||
Err(e) => {
|
||
window.set_library_error(format!("{e}").into());
|
||
return;
|
||
}
|
||
};
|
||
|
||
window.set_library_error(slint::SharedString::new());
|
||
window.set_library_status(keyword_summary(&word, n, images.len(), assigning).into());
|
||
refresh_keywords(window, ctl, &images);
|
||
|
||
// A filtered grid may no longer hold what was just keyworded — taking
|
||
// "puffin" off an image while showing only puffins means it belongs
|
||
// elsewhere now. The same reasoning as a rating that falls below the star
|
||
// filter.
|
||
if !ctl.filter.borrow().is_unfiltered() {
|
||
load_window(window, ctl);
|
||
}
|
||
}
|
||
|
||
/// What the status line says about a keyword that just landed.
|
||
///
|
||
/// The honest count, not the requested one: "added to 3 of 12" is what
|
||
/// happened when nine of them already carried the word, and a message that
|
||
/// claimed twelve would be teaching the user that the counts are decorative.
|
||
fn keyword_summary(word: &str, changed: usize, selected: usize, assigning: bool) -> String {
|
||
if selected == 0 {
|
||
return format!("Added “{word}” to the keyword list");
|
||
}
|
||
let verb = if assigning { "Added" } else { "Removed" };
|
||
let preposition = if assigning { "to" } else { "from" };
|
||
if changed == 0 {
|
||
return if assigning {
|
||
format!("Every selected photograph already had “{word}”")
|
||
} else {
|
||
format!("None of the selected photographs had “{word}”")
|
||
};
|
||
}
|
||
if changed == selected {
|
||
let what = if selected == 1 {
|
||
"1 photograph".to_string()
|
||
} else {
|
||
format!("{selected} photographs")
|
||
};
|
||
return format!("{verb} “{word}” {preposition} {what}");
|
||
}
|
||
format!("{verb} “{word}” {preposition} {changed} of {selected}")
|
||
}
|
||
|
||
/// Apply a judgement to a set of images: catalog first, then sidecars.
|
||
///
|
||
/// # Order matters
|
||
///
|
||
/// The catalog is written **synchronously and first**, so the star appears
|
||
/// immediately and survives a restart even if the network is down. The sidecar
|
||
/// write is queued behind it on a worker thread — it is what makes the
|
||
/// judgement survive a *catalog rebuild* (ARCH §6.12), which is a slower and
|
||
/// rarer concern than the user seeing their keystroke take effect.
|
||
///
|
||
/// Doing it the other way round would mean a cull that stalls on every
|
||
/// keypress waiting for a round trip, on a workflow whose entire premise is
|
||
/// speed (FR-CULL-1).
|
||
fn apply_judgement(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
images: &[dr_types::ImageId],
|
||
rating: Option<u8>,
|
||
flag: Option<dr_types::FlagState>,
|
||
) {
|
||
if images.is_empty() {
|
||
// Nothing selected. Said out loud rather than ignored: a keystroke
|
||
// that silently does nothing reads as a broken key.
|
||
window.set_library_status("Select an image first".into());
|
||
return;
|
||
}
|
||
|
||
let writes = {
|
||
let borrow = ctl.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
let conn = catalog.connection();
|
||
|
||
let wrote = match (rating, flag) {
|
||
(Some(r), _) => dr_catalog::rating::set_rating_many(conn, images, r),
|
||
(_, Some(f)) => dr_catalog::rating::set_flag_many(conn, images, f),
|
||
// Neither axis named: nothing to do, and not an error.
|
||
(None, None) => return,
|
||
};
|
||
|
||
if let Err(e) = wrote {
|
||
window.set_library_error(format!("recording rating: {e}").into());
|
||
return;
|
||
}
|
||
|
||
// Report what happened, in the user's terms rather than as a count of
|
||
// rows. A bulk judgement on a selection is easy to trigger by accident
|
||
// and the status line is the only confirmation of its extent.
|
||
window.set_library_status(judgement_summary(images.len(), rating, flag).into());
|
||
|
||
// Refresh the grid and the chips from what actually landed, rather
|
||
// than assuming the write took: a clamped or coalesced value must show
|
||
// as what is stored.
|
||
let visible = ctl.visible_ids();
|
||
sync_ratings(window, catalog, &visible);
|
||
refresh_rating_counts(window, catalog);
|
||
|
||
collect_sidecar_writes(catalog, images)
|
||
};
|
||
|
||
// A filtered grid may no longer contain what was just judged — rating an
|
||
// image 2 while showing "★4+" means it belongs elsewhere now. Reloading
|
||
// keeps the cells and the header count honest.
|
||
if !ctl.filter.borrow().is_unfiltered() {
|
||
load_window(window, ctl);
|
||
}
|
||
|
||
start_sidecar_writes(window, ctl, writes);
|
||
}
|
||
|
||
/// What the status line says about a judgement that just landed.
|
||
fn judgement_summary(n: usize, rating: Option<u8>, flag: Option<dr_types::FlagState>) -> String {
|
||
let what = match (rating, flag) {
|
||
(Some(0), _) => "unrated".to_string(),
|
||
(Some(r), _) => format!("{r} star{}", if r == 1 { "" } else { "s" }),
|
||
(_, Some(dr_types::FlagState::Pick)) => "picked".to_string(),
|
||
(_, Some(dr_types::FlagState::Reject)) => "rejected".to_string(),
|
||
(_, Some(dr_types::FlagState::Unflagged)) => "unflagged".to_string(),
|
||
(None, None) => return String::new(),
|
||
};
|
||
if n == 1 {
|
||
what
|
||
} else {
|
||
format!("{n} images · {what}")
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-DEV-6
|
||
/// Apply copied develop settings to a selection of images.
|
||
///
|
||
/// # Why this goes straight to the sidecars
|
||
///
|
||
/// The sidecar is the authoritative store for an edit (ARCH §6.12) and the
|
||
/// catalog holds no parameters at all — only a `graph_hash` — so there is
|
||
/// nothing here for the catalog to record. Nor is any image opened: applying
|
||
/// to forty frames by loading forty develop sessions would mean forty RAW
|
||
/// downloads and forty demosaics to move some numbers between two maps, which
|
||
/// is not a thing to ask of a phone. See [`crate::presets`].
|
||
///
|
||
/// # What the user sees
|
||
///
|
||
/// Nothing in the grid changes — a thumbnail is rendered from the server's
|
||
/// preview and does not reflect an edit — so the status line is the only
|
||
/// confirmation, exactly as it is for a bulk judgement. The applied settings
|
||
/// appear when a target is next opened in develop, which is what reads the
|
||
/// sidecar back.
|
||
pub fn paste_settings_to_selection(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
images: &[dr_types::ImageId],
|
||
preset: &dr_pipeline::Preset,
|
||
scope: dr_pipeline::Scope,
|
||
) {
|
||
if images.is_empty() {
|
||
// Said out loud rather than ignored, matching what a judgement
|
||
// keystroke does with an empty selection.
|
||
window.set_library_status("Select an image first".into());
|
||
return;
|
||
}
|
||
|
||
let writes = {
|
||
let borrow = ctl.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
// A never-judged image may have no version row yet, and the query
|
||
// below joins on one. Ratings create them as a side effect; a paste
|
||
// is the first write path that can reach an image which has never
|
||
// been rated, so it has to ask for them itself.
|
||
if let Err(e) = dr_catalog::rating::ensure_default_versions(catalog.connection()) {
|
||
log::debug!("ensuring versions before a paste: {e}");
|
||
}
|
||
collect_settings_writes(catalog, images, preset, scope)
|
||
};
|
||
|
||
if writes.is_empty() {
|
||
window.set_library_error("Could not find those images in the catalog.".into());
|
||
return;
|
||
}
|
||
|
||
let count = writes.len();
|
||
window.set_library_status(
|
||
format!(
|
||
"Applied settings to {count} image{}.",
|
||
if count == 1 { "" } else { "s" }
|
||
)
|
||
.into(),
|
||
);
|
||
start_sidecar_writes(window, ctl, writes);
|
||
}
|
||
|
||
/// Gather one settings write per image, addressed by remote path and version.
|
||
///
|
||
/// The uuid comes from the catalog for the same reason a judgement's does: it
|
||
/// is the identity a cross-device merge keys on, and a generated one would
|
||
/// write a second version beside the one the image already has (FR-NC-8).
|
||
fn collect_settings_writes(
|
||
catalog: &Catalog,
|
||
images: &[dr_types::ImageId],
|
||
preset: &dr_pipeline::Preset,
|
||
scope: dr_pipeline::Scope,
|
||
) -> Vec<library::SidecarWrite> {
|
||
let placeholders = std::iter::repeat_n("?", images.len())
|
||
.collect::<Vec<_>>()
|
||
.join(",");
|
||
let sql = format!(
|
||
"SELECT i.source_ref, v.uuid
|
||
FROM images i
|
||
JOIN versions v ON v.image_id = i.id AND v.is_default = 1
|
||
WHERE i.id IN ({placeholders})"
|
||
);
|
||
let params: Vec<rusqlite::types::Value> = images
|
||
.iter()
|
||
.map(|i| rusqlite::types::Value::Integer(i.0 as i64))
|
||
.collect();
|
||
|
||
let Ok(mut stmt) = catalog.connection().prepare(&sql) else {
|
||
return Vec::new();
|
||
};
|
||
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
||
Ok(library::SidecarWrite {
|
||
image_path: r.get(0)?,
|
||
version_uuid: r.get(1)?,
|
||
amendment: library::Amendment::Settings {
|
||
preset: preset.clone(),
|
||
scope,
|
||
// A paste carries no masks, and must not: a mask is drawn
|
||
// against one photograph and describes nothing on another.
|
||
// The target keeps whatever local adjustments it already had.
|
||
masks: None,
|
||
// A paste carries no film either, and for a plainer reason
|
||
// than the masks: a preset is a parameter map, and a stock is
|
||
// not a parameter. Pasting one would be pasting a choice the
|
||
// clipboard never captured.
|
||
film: None,
|
||
},
|
||
})
|
||
});
|
||
|
||
match rows {
|
||
Ok(rows) => rows.flatten().collect(),
|
||
Err(e) => {
|
||
log::debug!("collecting settings writes: {e}");
|
||
Vec::new()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Gather what the sidecar writer needs for each judged image.
|
||
///
|
||
/// The version uuid comes from the catalog rather than being generated here:
|
||
/// it is the identity a cross-device merge keys on, so the sidecar and the
|
||
/// catalog must name the same version or a sync would treat one photograph's
|
||
/// judgement as two (FR-NC-8).
|
||
fn collect_sidecar_writes(
|
||
catalog: &Catalog,
|
||
images: &[dr_types::ImageId],
|
||
) -> Vec<library::SidecarWrite> {
|
||
let placeholders = std::iter::repeat_n("?", images.len())
|
||
.collect::<Vec<_>>()
|
||
.join(",");
|
||
let sql = format!(
|
||
"SELECT i.source_ref, v.uuid, v.rating, v.flag
|
||
FROM images i
|
||
JOIN versions v ON v.image_id = i.id AND v.is_default = 1
|
||
WHERE i.id IN ({placeholders})"
|
||
);
|
||
let params: Vec<rusqlite::types::Value> = images
|
||
.iter()
|
||
.map(|i| rusqlite::types::Value::Integer(i.0 as i64))
|
||
.collect();
|
||
|
||
let Ok(mut stmt) = catalog.connection().prepare(&sql) else {
|
||
return Vec::new();
|
||
};
|
||
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
||
Ok(library::SidecarWrite {
|
||
image_path: r.get(0)?,
|
||
version_uuid: r.get(1)?,
|
||
amendment: library::Amendment::Judgement {
|
||
rating: r.get::<_, i64>(2)? as u8,
|
||
flag: r.get::<_, i64>(3)? as u8,
|
||
},
|
||
})
|
||
});
|
||
|
||
match rows {
|
||
Ok(rows) => rows.flatten().collect(),
|
||
Err(e) => {
|
||
log::debug!("collecting sidecar writes: {e}");
|
||
Vec::new()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Push judgements out to sidecars on a worker, reporting once at the end.
|
||
pub(crate) fn start_sidecar_writes(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
writes: Vec<library::SidecarWrite>,
|
||
) {
|
||
if writes.is_empty() {
|
||
return;
|
||
}
|
||
|
||
// TRACES: FR-CAT-9
|
||
// Offline is passed down rather than used to skip.
|
||
//
|
||
// It used to skip, and the reasoning was that a cull stays responsive
|
||
// because the rating is safe in the catalog. That held for judgements and
|
||
// not for edits: the catalog stores no parameters, so a pasted edit made
|
||
// offline survived nowhere at all. Every write now commits to the local
|
||
// sidecar cache first and the upload is best-effort, which keeps the
|
||
// keystroke path off the network — the original concern — without the
|
||
// write being conditional on it.
|
||
let offline = ctl.is_offline();
|
||
|
||
let Some((conn, _)) = 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(conn.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,
|
||
}
|
||
}
|
||
|
||
/// Where a row of the loaded window sits in the fetch queue.
|
||
///
|
||
/// On screen first, top to bottom; then the rows below the view, nearest
|
||
/// first; then the rows above it, nearest first.
|
||
///
|
||
/// # Why the queue has an order at all
|
||
///
|
||
/// The batch is fetched one image at a time, two round trips each, and it is
|
||
/// abandoned wholesale the moment the window moves — so whatever is at the
|
||
/// back of it on a remote library is not slow to appear, it never appears.
|
||
/// Issued in model order, the back of the queue was the bottom of the screen
|
||
/// and everything below it, and the *front* was the quarter-window of cells
|
||
/// above the view that nobody was looking at. A scroll then abandoned the
|
||
/// batch and the new one started over, again from above the view: on a
|
||
/// library still filling its store, the last rows of the grid could be
|
||
/// starved for as long as the scrolling continued.
|
||
///
|
||
/// Below before above because that is where the view is going. Scrolling back
|
||
/// over cells already fetched is served from the store, and from `requested`
|
||
/// without a fetch at all.
|
||
fn fetch_rank(row: usize, first_on_screen: usize, on_screen: usize) -> (u8, usize) {
|
||
let past = first_on_screen + on_screen;
|
||
if row >= first_on_screen && row < past {
|
||
(0, row - first_on_screen)
|
||
} else if row >= past {
|
||
(1, row - past)
|
||
} else {
|
||
(2, first_on_screen - row)
|
||
}
|
||
}
|
||
|
||
/// Fetch thumbnails for rows in the model that do not have one yet.
|
||
fn request_thumbnails(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
let Some((conn, _)) = ctl.session.borrow().clone() else {
|
||
return;
|
||
};
|
||
|
||
// The drawn cell size decides which class to ask for. Chosen once for the
|
||
// batch rather than per row, and carried through to the drain so a cell it
|
||
// fills can record what it is now showing.
|
||
let cell_pixels = window.get_library_cell_size().max(1.0) as u32;
|
||
let class = dr_thumbs::ThumbSize::for_cell(cell_pixels);
|
||
|
||
let mut wanted: Vec<library::ThumbnailRequest> = {
|
||
let paths = ctl.paths.borrow();
|
||
let file_ids = ctl.file_ids.borrow();
|
||
let sizes = ctl.sizes.borrow();
|
||
let image_ids = ctl.image_ids.borrow();
|
||
let needs_md = ctl.needs_metadata.borrow();
|
||
let mut requested = ctl.requested.borrow_mut();
|
||
paths
|
||
.iter()
|
||
.enumerate()
|
||
.filter_map(|(i, p)| {
|
||
let image_id = *image_ids.get(i)?;
|
||
// A zoomed grid asks for detail a 256px thumbnail cannot give,
|
||
// and a wall of small cells does not pay for it.
|
||
let thumb_size = class;
|
||
// Keyed on the photograph, so scrolling back over a cell that
|
||
// has already been served does not ask for it again.
|
||
if !requested.insert((image_id, thumb_size)) {
|
||
return None;
|
||
}
|
||
Some(library::ThumbnailRequest {
|
||
row: i,
|
||
path: p.clone(),
|
||
file_id: file_ids.get(i).copied().flatten(),
|
||
size: sizes.get(i).copied().unwrap_or(0),
|
||
image_id,
|
||
needs_metadata: needs_md.get(i).copied().unwrap_or(false),
|
||
thumb_size,
|
||
// The grid is filling a cell of a known size.
|
||
full_resolution: false,
|
||
})
|
||
})
|
||
.collect()
|
||
};
|
||
|
||
if wanted.is_empty() {
|
||
return;
|
||
}
|
||
|
||
// What is on screen, first — see [`fetch_rank`]. The rows keep addressing
|
||
// the model they were built against; only the order they are asked for in
|
||
// changes.
|
||
{
|
||
let first_on_screen = ctl.resume_at.get().saturating_sub(*ctl.offset.borrow());
|
||
let on_screen = ctl.viewport_cells.get().max(1);
|
||
wanted.sort_by_key(|r| fetch_rank(r.row, first_on_screen, on_screen));
|
||
}
|
||
|
||
let requested = wanted.len();
|
||
let rx = library::spawn_thumbnails(
|
||
conn.clone(),
|
||
wanted,
|
||
library::thumbs_dir(&conn.account),
|
||
library::catalog_path(&conn.account),
|
||
);
|
||
drain_thumbnails(window.as_weak(), ctl.clone(), rx, requested, class);
|
||
}
|
||
|
||
/// TRACES: FR-CAT-9 | FR-DEV-6
|
||
/// Replace a photograph's cached thumbnail with one rendered from its edit.
|
||
///
|
||
/// # Why the grid cannot be left alone
|
||
///
|
||
/// A thumbnail comes from the file's embedded preview, which is the camera's
|
||
/// idea of the photograph and knows nothing about what has been done to it
|
||
/// since. So a frame could be cropped, turned upright, and pulled two stops
|
||
/// back, and the grid would go on showing the original — the one view of a
|
||
/// library where an edit is least visible is the one the photographer spends
|
||
/// most of their time in.
|
||
///
|
||
/// # Both classes, and why
|
||
///
|
||
/// The store keys on the size class, so replacing only the one the grid
|
||
/// happens to be drawing at leaves the other holding the unedited preview —
|
||
/// and a zoom past the class boundary would show the edit undoing itself.
|
||
/// Each class is rendered separately because they are different sizes; a
|
||
/// downscale of the large one would be a second, worse resampler than the GPU
|
||
/// already applied.
|
||
///
|
||
/// # Silent on failure
|
||
///
|
||
/// The edit is saved to the sidecar by the caller before this runs, so nothing
|
||
/// here can lose work. A thumbnail that could not be re-rendered is a stale
|
||
/// cell, which the next scroll past it corrects from the store — worth a log
|
||
/// line and not worth an error in front of a photographer who has just
|
||
/// finished an image.
|
||
pub fn refresh_thumbnail(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
remote_path: &str,
|
||
mut render: impl FnMut(u32) -> Result<(u32, u32, Vec<u8>), String>,
|
||
) {
|
||
// Where this photograph sits in the loaded window. It is always in it: the
|
||
// develop view is reached from a cell, and the guards on the scroll and
|
||
// geometry handlers stop the window moving while it is open.
|
||
let Some(row) = ctl.paths.borrow().iter().position(|p| p == remote_path) else {
|
||
return;
|
||
};
|
||
let Some(file_id) = ctl.file_ids.borrow().get(row).copied().flatten() else {
|
||
// Nothing to key the store on. A photograph the scan recorded without
|
||
// a server file id cannot have a cached thumbnail either, so there is
|
||
// nothing here to correct.
|
||
return;
|
||
};
|
||
let Some((conn, _)) = ctl.session.borrow().clone() else {
|
||
return;
|
||
};
|
||
|
||
let mut store = match dr_thumbs::ThumbStore::open(&library::thumbs_dir(&conn.account)) {
|
||
Ok(s) => s,
|
||
Err(e) => {
|
||
log::warn!("re-thumbnailing {remote_path}: opening the store: {e}");
|
||
return;
|
||
}
|
||
};
|
||
|
||
// What the grid is drawing at, so the cell can be corrected on screen
|
||
// rather than only on disk.
|
||
let drawn = dr_thumbs::ThumbSize::for_cell(window.get_library_cell_size().max(1.0) as u32);
|
||
|
||
for class in [dr_thumbs::ThumbSize::Grid, dr_thumbs::ThumbSize::Large] {
|
||
let (w, h, rgba) = match render(class.edge()) {
|
||
Ok(r) => r,
|
||
Err(e) => {
|
||
log::warn!("re-thumbnailing {remote_path} at {class:?}: {e}");
|
||
continue;
|
||
}
|
||
};
|
||
|
||
match dr_thumbs::codec::encode_rgba(w, h, &rgba) {
|
||
Ok(bytes) => {
|
||
let thumb = dr_thumbs::Thumbnail {
|
||
width: w,
|
||
height: h,
|
||
bytes,
|
||
};
|
||
if let Err(e) = store.put(file_id, class, &thumb) {
|
||
log::warn!("re-thumbnailing {remote_path} at {class:?}: {e}");
|
||
}
|
||
}
|
||
Err(e) => {
|
||
log::warn!("re-thumbnailing {remote_path} at {class:?}: encoding: {e}");
|
||
continue;
|
||
}
|
||
}
|
||
|
||
if class == drawn {
|
||
// Straight into the model, so the edit is on the cell the moment
|
||
// the grid comes back rather than after a scroll evicts and
|
||
// refetches it.
|
||
let model = window.get_library_cells();
|
||
if let Some(mut cell) = model.row_data(row) {
|
||
cell.thumbnail = to_slint_image(w, h, &rgba);
|
||
cell.has_thumb = true;
|
||
cell.unavailable = false;
|
||
model.set_row_data(row, cell);
|
||
}
|
||
record_class(ctl, row, class);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Note which size class a row's pixels came from.
|
||
///
|
||
/// Silent about a row past the end: the model and this vector are rebuilt
|
||
/// together by [`load_window`], and the generation check above already refuses
|
||
/// anything addressed to a window that has since moved.
|
||
fn record_class(ctl: &Rc<LibraryController>, row: usize, class: dr_thumbs::ThumbSize) {
|
||
if let Some(slot) = ctl.thumb_class.borrow_mut().get_mut(row) {
|
||
*slot = Some(class);
|
||
}
|
||
}
|
||
|
||
/// Apply thumbnails to the model as they arrive.
|
||
fn drain_thumbnails(
|
||
weak: slint::Weak<AppWindow>,
|
||
ctl: Rc<LibraryController>,
|
||
rx: Receiver<ThumbnailMessage>,
|
||
requested: usize,
|
||
class: dr_thumbs::ThumbSize,
|
||
) {
|
||
let timer = slint::Timer::default();
|
||
let ctl_cb = ctl.clone();
|
||
|
||
// One row per batch, measured against the cells this window asked for.
|
||
// Starting a new batch does not extend the last one: a scroll abandons
|
||
// whatever the previous window wanted, and a denominator carried across
|
||
// both would describe neither.
|
||
let job = ctl
|
||
.activity
|
||
.begin(crate::activity::Kind::Thumbnails, "Loading thumbnails");
|
||
job.total(requested);
|
||
// Which window this batch was requested for. Captured at spawn, compared on
|
||
// every tick.
|
||
let mine = ctl.generation.get();
|
||
|
||
timer.start(
|
||
slint::TimerMode::Repeated,
|
||
std::time::Duration::from_millis(100),
|
||
move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
|
||
// A reload replaced the model under this worker. Two things must
|
||
// not happen now, and both did:
|
||
//
|
||
// - Applying a row. `t.row` indexes the window that asked for it,
|
||
// so after a reload it names a different photograph — thumbnails
|
||
// landed on unrelated cells, and `Unavailable` marked cells
|
||
// "no preview" for a fetch never attempted against them.
|
||
// - Calling `stop`. `thumb_timer` holds the *current* batch's timer
|
||
// by now, so a stale drain reaching `Disconnected` killed the
|
||
// live drain instead of itself. The new worker then fetched into
|
||
// a channel nobody read, and the grid stayed black until a scroll
|
||
// forced yet another load — which is the flicker being chased.
|
||
//
|
||
// Returning without stopping is deliberate: this timer is no longer
|
||
// reachable through the controller, so it is dropped with its
|
||
// receiver when the slot is overwritten, and the worker exits on
|
||
// its next failed send.
|
||
if ctl_cb.generation.get() != mine {
|
||
return;
|
||
}
|
||
|
||
let model = w.get_library_cells();
|
||
|
||
loop {
|
||
let msg = match rx.try_recv() {
|
||
Ok(m) => m,
|
||
Err(std::sync::mpsc::TryRecvError::Empty) => return,
|
||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||
// The worker finished or died. Either way nothing more
|
||
// is coming, so the bar must not sit part-filled
|
||
// forever.
|
||
//
|
||
// Quietly: a scroll starts one of these every second,
|
||
// and a history of them would bury anything worth
|
||
// reading.
|
||
job.finish_quietly();
|
||
stop(&ctl_cb.thumb_timer);
|
||
return;
|
||
}
|
||
};
|
||
|
||
match msg {
|
||
// Bookkeeping, not an outcome — reports the split between
|
||
// store and network without advancing the bar.
|
||
ThumbnailMessage::Plan {
|
||
cached,
|
||
fetching,
|
||
dating,
|
||
} => {
|
||
// Date reads produce no cell, so they are counted into
|
||
// the bar's denominator or it finishes while work is
|
||
// still running.
|
||
job.add_total(dating);
|
||
|
||
let mut parts = Vec::new();
|
||
if cached > 0 {
|
||
parts.push(format!("{cached} cached"));
|
||
}
|
||
if fetching > 0 {
|
||
parts.push(format!("fetching {fetching}"));
|
||
}
|
||
if dating > 0 {
|
||
parts.push(format!("reading {dating} dates"));
|
||
}
|
||
if !parts.is_empty() {
|
||
let status = parts.join(" · ");
|
||
job.detail(status.clone());
|
||
w.set_library_status(status.into());
|
||
}
|
||
}
|
||
// A header-only date read. Advances the bar; draws nothing.
|
||
ThumbnailMessage::DateProgress => {
|
||
job.advance();
|
||
}
|
||
// Dates landed, so the histogram can now be built. This is
|
||
// what makes the timeline appear on a library whose
|
||
// thumbnails were all cached.
|
||
ThumbnailMessage::DatesRecorded(n) => {
|
||
log::info!("timeline: {n} new dates");
|
||
let borrow = ctl_cb.catalog.borrow();
|
||
if let Some(catalog) = borrow.as_ref() {
|
||
refresh_timeline(&w, catalog, &ctl_cb);
|
||
}
|
||
}
|
||
// Every real outcome advances the bar. Counting only
|
||
// successes would stall it on a library where some files
|
||
// carry no embedded preview.
|
||
ThumbnailMessage::Ready(t) => {
|
||
job.advance();
|
||
// Bytes arrived *from the server*, so it is reachable.
|
||
// This is what clears the banner when a connection
|
||
// returns while the user is simply scrolling, without
|
||
// waiting for a probe or a manual retry.
|
||
//
|
||
// Store hits are excluded deliberately: they are read
|
||
// from local disk and say nothing about the network. A
|
||
// window that is mostly cached delivers a run of them
|
||
// before the first request is even attempted, so
|
||
// counting them declared "back online" against a server
|
||
// that was plainly down.
|
||
if !t.from_cache
|
||
&& ctl_cb
|
||
.reachability
|
||
.borrow_mut()
|
||
.mark_reachable(std::time::Instant::now())
|
||
{
|
||
log::info!("back online");
|
||
refresh_offline(&w, &ctl_cb);
|
||
// The sweep was refused while offline, so nothing
|
||
// else would ever restart it — the grid would stay
|
||
// partially dated until the next launch.
|
||
start_sweep(&w, &ctl_cb);
|
||
}
|
||
if let Some(mut row) = model.row_data(t.row) {
|
||
row.thumbnail = to_slint_image(t.width, t.height, &t.rgba);
|
||
row.has_thumb = true;
|
||
model.set_row_data(t.row, row);
|
||
// What this cell is now showing, so the next reload
|
||
// can carry it over and know not to ask again.
|
||
record_class(&ctl_cb, t.row, class);
|
||
}
|
||
}
|
||
ThumbnailMessage::Unavailable { row, reason } => {
|
||
job.advance();
|
||
log::debug!("thumbnail {row}: {reason}");
|
||
if let Some(mut r) = model.row_data(row) {
|
||
r.unavailable = true;
|
||
model.set_row_data(row, r);
|
||
// A verdict is worth carrying too: "no preview" is
|
||
// an answer about the file, and re-asking it on
|
||
// every scroll is a fetch that will fail again.
|
||
record_class(&ctl_cb, row, class);
|
||
}
|
||
}
|
||
// TRACES: FR-CAT-9
|
||
ThumbnailMessage::Offline { reason } => {
|
||
log::info!("thumbnails stopped: {reason}");
|
||
// The batch is over, so the bar must not be left
|
||
// showing a partial fetch that will never finish — it
|
||
// would sweep for ever.
|
||
job.fail(reason.clone());
|
||
ctl_cb
|
||
.reachability
|
||
.borrow_mut()
|
||
.mark_unreachable(reason, std::time::Instant::now());
|
||
refresh_offline(&w, &ctl_cb);
|
||
|
||
// Cells left without pixels stay placeholders rather
|
||
// than being marked unavailable: the images are fine,
|
||
// and a reconnect should fill them in. Marking them
|
||
// would persist a verdict about the *file* from an
|
||
// event about the *connection*.
|
||
stop(&ctl_cb.thumb_timer);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
},
|
||
);
|
||
|
||
*ctl.thumb_timer.borrow_mut() = Some(timer);
|
||
}
|
||
|
||
/// Move the timeline's zoom by whole levels.
|
||
///
|
||
/// Shared by the wheel and the pinch so the two cannot drift apart in how they
|
||
/// clamp, or in where they choose to centre.
|
||
fn apply_zoom(window: &AppWindow, ctl: &Rc<LibraryController>, delta: i32) {
|
||
let borrow = ctl.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
|
||
// Bounded: past ~2^12 the window is minutes wide and every bucket is
|
||
// empty, which reads as a broken axis rather than a deep zoom.
|
||
let next = (*ctl.timeline_zoom.borrow() + delta).clamp(0, 12);
|
||
if next == *ctl.timeline_zoom.borrow() {
|
||
return;
|
||
}
|
||
*ctl.timeline_zoom.borrow_mut() = next;
|
||
|
||
// Zooming fully out forgets the centre, so the axis returns to describing
|
||
// the whole library rather than a remembered position.
|
||
if next == 0 {
|
||
*ctl.timeline_centre.borrow_mut() = None;
|
||
} else if ctl.timeline_centre.borrow().is_none() {
|
||
// First zoom centres on wherever the grid is, else the middle.
|
||
*ctl.timeline_centre.borrow_mut() = *ctl.current_bucket.borrow();
|
||
}
|
||
refresh_timeline(window, catalog, ctl);
|
||
}
|
||
|
||
/// Push shards and the catalog to the server, and take what it has.
|
||
///
|
||
/// Fired after the sweep completes, when there is a finished index worth
|
||
/// sharing, and from the Sync button for an explicit exchange.
|
||
fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
// An escape hatch for running the app against a *copied* library without
|
||
// touching the account's real server.
|
||
//
|
||
// Added after doing exactly that by accident: a local test launch
|
||
// completed its thumbnail sweep, which fires this, which pushed a test
|
||
// catalog over the live one. Redirecting `XDG_DATA_HOME` isolates the
|
||
// catalog and the thumbnails but not the server, and nothing said so.
|
||
//
|
||
// The guard belongs here rather than at the call sites: the sweep firing a
|
||
// sync is correct — the point of building thumbnails is sharing them — and
|
||
// a flag checked in three places would eventually be missed in a fourth.
|
||
if std::env::var_os("DARKROOM_NO_SYNC").is_some() {
|
||
log::info!("derived sync suppressed by DARKROOM_NO_SYNC");
|
||
return;
|
||
}
|
||
|
||
let Some((conn, _)) = 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(&conn.account);
|
||
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(&conn.account);
|
||
if crate::export::pending_count(&outbox) > 0 {
|
||
let rx = crate::export::spawn_upload(conn.clone(), conn.account.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(
|
||
conn.clone(),
|
||
conn.account.root.clone(),
|
||
library::thumbs_dir(&conn.account),
|
||
catalog_path,
|
||
scratch,
|
||
);
|
||
|
||
let timer = slint::Timer::default();
|
||
let weak = window.as_weak();
|
||
let ctl_cb = ctl.clone();
|
||
|
||
// The sync reports stages rather than counts, so it stays indeterminate and
|
||
// says what stage it is in.
|
||
let job = ctl
|
||
.activity
|
||
.begin(crate::activity::Kind::Sync, "Syncing with the server");
|
||
|
||
timer.start(
|
||
slint::TimerMode::Repeated,
|
||
std::time::Duration::from_millis(300),
|
||
move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
loop {
|
||
let msg = match rx.try_recv() {
|
||
Ok(m) => m,
|
||
Err(std::sync::mpsc::TryRecvError::Empty) => return,
|
||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||
w.set_library_syncing(false);
|
||
job.fail("stopped without finishing");
|
||
stop(&ctl_cb.sync_timer);
|
||
return;
|
||
}
|
||
};
|
||
|
||
match msg {
|
||
crate::derived_sync::SyncMessage::Status(s) => {
|
||
job.detail(s.clone());
|
||
w.set_library_status(s.into());
|
||
}
|
||
crate::derived_sync::SyncMessage::Finished(report) => {
|
||
log::info!(
|
||
"sync: {} shard(s) up, {} down ({} thumbnails), \
|
||
catalog {}{}",
|
||
report.shards_uploaded,
|
||
report.shards_downloaded,
|
||
report.thumbnails_adopted,
|
||
if report.catalog_uploaded {
|
||
"pushed"
|
||
} else {
|
||
"not pushed"
|
||
},
|
||
if report.collections_gained > 0 {
|
||
format!(", {} collection(s) gained", report.collections_gained)
|
||
} else {
|
||
String::new()
|
||
}
|
||
);
|
||
w.set_library_syncing(false);
|
||
let summary = format!(
|
||
"{} shard(s) up, {} down",
|
||
report.shards_uploaded, report.shards_downloaded
|
||
);
|
||
job.finish(if report.did_anything() {
|
||
summary.clone()
|
||
} else {
|
||
"nothing to exchange".to_string()
|
||
});
|
||
if report.did_anything() {
|
||
w.set_library_status(format!("synced · {summary}").into());
|
||
}
|
||
// Adopted thumbnails and merged collections both change
|
||
// what the grid should show.
|
||
if report.thumbnails_adopted > 0
|
||
|| report.collections_gained > 0
|
||
|| report.members_gained > 0
|
||
{
|
||
load_window(&w, &ctl_cb);
|
||
}
|
||
// TRACES: FR-CAT-7
|
||
// And the sidebar, which the grid reload does not
|
||
// touch. Membership counts as a change: a sync that
|
||
// files 127 photographs into a collection both devices
|
||
// already had gains no *collection*, so keying this on
|
||
// `collections_gained` alone left the tree showing no
|
||
// number beside a collection that had just been filled.
|
||
//
|
||
// The scan already does this after it finishes; the
|
||
// sync merges the same tables and did not.
|
||
if report.collections_gained > 0 || report.members_gained > 0 {
|
||
let coll = ctl_cb.coll_ctl.borrow().as_ref().and_then(|w| w.upgrade());
|
||
if let Some(coll) = coll {
|
||
let borrow = ctl_cb.catalog();
|
||
let borrow = borrow.borrow();
|
||
if let Some(cat) = borrow.as_ref() {
|
||
crate::collections_ui::refresh_tree(&w, &coll, cat);
|
||
}
|
||
}
|
||
}
|
||
stop(&ctl_cb.sync_timer);
|
||
return;
|
||
}
|
||
crate::derived_sync::SyncMessage::Failed(e) => {
|
||
log::warn!("sync failed: {e}");
|
||
job.fail(e.to_string());
|
||
w.set_library_syncing(false);
|
||
// Not an error banner: a failed sync costs nothing —
|
||
// everything is still local and the next pass retries.
|
||
w.set_library_status(format!("sync failed: {e}").into());
|
||
stop(&ctl_cb.sync_timer);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
},
|
||
);
|
||
|
||
*ctl.sync_timer.borrow_mut() = Some(timer);
|
||
}
|
||
|
||
/// Start the whole-library sweep and report its progress.
|
||
///
|
||
/// The grid only ever fetches what is on screen, so without this the timeline
|
||
/// describes the fraction of the library that happened to be scrolled past.
|
||
/// This covers the rest.
|
||
fn start_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
let Some((conn, _)) = 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(conn.clone(), library::catalog_path(&conn.account));
|
||
|
||
let timer = slint::Timer::default();
|
||
let weak = window.as_weak();
|
||
let ctl_cb = ctl.clone();
|
||
|
||
// Hours on a large library, and entirely invisible outside the grid until
|
||
// now: the register is where a user who has gone to develop can still see
|
||
// that indexing is running and how far it has got.
|
||
let job = ctl
|
||
.activity
|
||
.begin(crate::activity::Kind::Index, "Indexing capture times");
|
||
|
||
timer.start(
|
||
slint::TimerMode::Repeated,
|
||
// Slower than the thumbnail drain: this runs for tens of minutes and
|
||
// its progress does not need per-frame accuracy.
|
||
std::time::Duration::from_millis(400),
|
||
move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
|
||
loop {
|
||
let msg = match rx.try_recv() {
|
||
Ok(m) => m,
|
||
Err(std::sync::mpsc::TryRecvError::Empty) => return,
|
||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||
w.set_library_sweep_total(0);
|
||
job.fail("stopped without finishing");
|
||
stop(&ctl_cb.sweep_timer);
|
||
return;
|
||
}
|
||
};
|
||
|
||
match msg {
|
||
library::SweepMessage::Total(n) => {
|
||
w.set_library_sweep_total(n as i32);
|
||
w.set_library_sweep_done(0);
|
||
job.total(n);
|
||
}
|
||
library::SweepMessage::Progress { done, dated } => {
|
||
w.set_library_sweep_done(done as i32);
|
||
job.progress(done, w.get_library_sweep_total() as usize);
|
||
// Rebuild as it goes: the histogram growing while the
|
||
// sweep runs is the visible sign it is working.
|
||
if dated > 0 {
|
||
let borrow = ctl_cb.catalog.borrow();
|
||
if let Some(catalog) = borrow.as_ref() {
|
||
refresh_timeline(&w, catalog, &ctl_cb);
|
||
}
|
||
}
|
||
}
|
||
library::SweepMessage::Finished { dated } => {
|
||
log::info!("sweep finished: {dated} dated");
|
||
job.finish(format!("{dated} dated"));
|
||
w.set_library_sweep_total(0);
|
||
{
|
||
let borrow = ctl_cb.catalog.borrow();
|
||
if let Some(catalog) = borrow.as_ref() {
|
||
refresh_timeline(&w, catalog, &ctl_cb);
|
||
}
|
||
}
|
||
// Now that indexing is complete, hand the result to the
|
||
// server so a second device inherits it rather than
|
||
// repeating hours of range fetches.
|
||
start_derived_sync(&w, &ctl_cb);
|
||
stop(&ctl_cb.sweep_timer);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
},
|
||
);
|
||
|
||
*ctl.sweep_timer.borrow_mut() = Some(timer);
|
||
}
|
||
|
||
/// TRACES: FR-CAT-3 | FR-NC-3 | FR-NC-7
|
||
/// Thumbnail every photograph in the library, then hand the shards over.
|
||
///
|
||
/// # Why it is asked for rather than assumed
|
||
///
|
||
/// The grid's own fetching is demand-driven on purpose: a remote library is
|
||
/// browsed over a connection that must not be saturated to show one screen
|
||
/// (FR-NC-3). But that leaves the thumbnail store holding only what has been
|
||
/// looked at, and the store is the one derived thing worth syncing — a second
|
||
/// device that downloads the shards gets a full grid without touching a RAW.
|
||
/// So the complete set is worth an hour of transfers *once*, on a machine
|
||
/// plugged in, at a moment the user chose. That is this.
|
||
///
|
||
/// The sync at the end is not a separate courtesy: a filled store that never
|
||
/// leaves this device is most of the cost for none of the point.
|
||
fn start_thumbnail_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
let Some((conn, _)) = ctl.session.borrow().clone() else {
|
||
return;
|
||
};
|
||
|
||
// Already running. A second pass would re-fetch everything the first is
|
||
// part way through — the work list is built from what the store lacks, and
|
||
// the first pass has not filled it yet.
|
||
if ctl.thumb_sweep_timer.borrow().is_some() && window.get_library_thumbnailing() {
|
||
return;
|
||
}
|
||
|
||
// TRACES: FR-CAT-9
|
||
// Every image here is a range fetch. Offline the pass would spend a
|
||
// timeout per photograph and store nothing, so it is refused rather than
|
||
// started — and said so, because this one was pressed deliberately and a
|
||
// button that silently does nothing is worse than one that declines.
|
||
if ctl.is_offline() {
|
||
log::debug!("offline: not starting the thumbnail pass");
|
||
window.set_library_status("Offline — thumbnailing needs the server.".into());
|
||
return;
|
||
}
|
||
|
||
window.set_library_thumbnailing(true);
|
||
let rx = library::spawn_thumbnail_sweep(
|
||
conn.clone(),
|
||
library::catalog_path(&conn.account),
|
||
library::thumbs_dir(&conn.account),
|
||
);
|
||
|
||
let timer = slint::Timer::default();
|
||
let weak = window.as_weak();
|
||
let ctl_cb = ctl.clone();
|
||
|
||
let job = ctl.activity.begin(
|
||
crate::activity::Kind::Thumbnails,
|
||
"Thumbnailing the library",
|
||
);
|
||
|
||
// The total arrives in the first message rather than up front — counting
|
||
// it means asking the store about every image — and the progress messages
|
||
// after it carry only cumulative counts, so it is kept here rather than
|
||
// re-derived.
|
||
let total = std::cell::Cell::new(0usize);
|
||
|
||
timer.start(
|
||
slint::TimerMode::Repeated,
|
||
// As slow as the metadata sweep's drain, and for the same reason: this
|
||
// runs for tens of minutes and a chunk lands every few seconds.
|
||
std::time::Duration::from_millis(400),
|
||
move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
|
||
loop {
|
||
let msg = match rx.try_recv() {
|
||
Ok(m) => m,
|
||
Err(std::sync::mpsc::TryRecvError::Empty) => return,
|
||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||
w.set_library_thumbnailing(false);
|
||
job.fail("stopped without finishing");
|
||
stop(&ctl_cb.thumb_sweep_timer);
|
||
return;
|
||
}
|
||
};
|
||
|
||
match msg {
|
||
library::ThumbSweepMessage::Total(n) => {
|
||
total.set(n);
|
||
job.total(n);
|
||
w.set_library_status(format!("Thumbnailing {n} photograph(s)…").into());
|
||
}
|
||
library::ThumbSweepMessage::Progress { done, stored } => {
|
||
job.progress(done, total.get());
|
||
// Dates arrive on the same headers, so the histogram
|
||
// grows as this runs — the visible sign it is working
|
||
// on a page that is not the grid.
|
||
if stored > 0 {
|
||
let borrow = ctl_cb.catalog.borrow();
|
||
if let Some(catalog) = borrow.as_ref() {
|
||
refresh_timeline(&w, catalog, &ctl_cb);
|
||
}
|
||
}
|
||
}
|
||
library::ThumbSweepMessage::Finished {
|
||
stored,
|
||
failed,
|
||
offline,
|
||
} => {
|
||
log::info!(
|
||
"thumbnail pass finished: {stored} stored, {failed} without a preview"
|
||
);
|
||
w.set_library_thumbnailing(false);
|
||
stop(&ctl_cb.thumb_sweep_timer);
|
||
|
||
let summary = if offline {
|
||
format!("{stored} stored · stopped, server unreachable")
|
||
} else if failed > 0 {
|
||
format!("{stored} stored · {failed} with no usable preview")
|
||
} else {
|
||
format!("{stored} stored")
|
||
};
|
||
w.set_library_status(summary.clone().into());
|
||
// Finished rather than failed even when the server
|
||
// dropped: the pass is resumable and what it did build
|
||
// is stored, and a red row would invite the user to
|
||
// redo work that is already done.
|
||
job.finish(summary);
|
||
|
||
{
|
||
let borrow = ctl_cb.catalog.borrow();
|
||
if let Some(catalog) = borrow.as_ref() {
|
||
refresh_timeline(&w, catalog, &ctl_cb);
|
||
}
|
||
}
|
||
// Cells that showed a placeholder have pixels now, and
|
||
// the grid asks the store once per photograph — so
|
||
// without this the library the pass just thumbnailed
|
||
// stays blank until something else reloads the window.
|
||
if w.get_show_library() {
|
||
ctl_cb.requested.borrow_mut().clear();
|
||
schedule_reload(&w, &ctl_cb);
|
||
}
|
||
|
||
// The point of the pass: the shards go to the server so
|
||
// every other device inherits them. Skipped when the
|
||
// connection is already gone — the sync would only
|
||
// discover the same thing more slowly.
|
||
if !offline {
|
||
start_derived_sync(&w, &ctl_cb);
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
},
|
||
);
|
||
|
||
*ctl.thumb_sweep_timer.borrow_mut() = Some(timer);
|
||
}
|
||
|
||
/// Rebuild the timeline histogram from the catalog.
|
||
///
|
||
/// Bucket size follows the span of the library, the way darktable's does:
|
||
/// a decade of photographs buckets by year, a single trip by day. Picking it
|
||
/// from the data rather than fixing it means the histogram is informative at
|
||
/// both scales instead of one flat bar or ten thousand slivers.
|
||
fn refresh_timeline(window: &AppWindow, catalog: &Catalog, ctl: &Rc<LibraryController>) {
|
||
// Scoped to whatever the grid is showing. A collection's histogram drawn
|
||
// over the whole library's span said almost nothing: every bar for a
|
||
// fortnight in Arosa landed in one column of a fifteen-year axis.
|
||
let scope = *ctl.scope.borrow();
|
||
let filter = ctl.filter.borrow().clone();
|
||
// Passed whole, date range included, and both queries below lift that
|
||
// range themselves — see `span_scoped` and `timeline_uniform`. Every other
|
||
// term still applies, because a histogram of the five-star frames is a
|
||
// fair question; the range does not, because the axis is *how a range is
|
||
// chosen* and drawing through it would empty every bin outside the band
|
||
// and leave nothing to widen back into.
|
||
let span = match library::span_scoped(catalog, scope, &filter) {
|
||
Some(s) => s,
|
||
None => {
|
||
// No dated images yet. An empty histogram is honest — EXIF is read
|
||
// as thumbnails load, so this populates as the user browses.
|
||
window.set_library_timeline(slint::ModelRc::new(slint::VecModel::from(vec![])));
|
||
window.set_library_timeline_label(slint::SharedString::new());
|
||
return;
|
||
}
|
||
};
|
||
|
||
// A range is drawn *over* this span as a band, not substituted for it —
|
||
// see the band below. The scale a narrow range deserves is reached by
|
||
// zooming the axis instead, which is a gesture a finger has (pinch) and
|
||
// does not disturb the range while it is being adjusted.
|
||
//
|
||
// Zoom narrows the span around wherever the view sits rather than around
|
||
// the library's midpoint, so zooming in keeps what you were looking at.
|
||
let zoom = *ctl.timeline_zoom.borrow();
|
||
let (from, to) = zoomed_span(span, zoom, *ctl.timeline_centre.borrow());
|
||
|
||
// A fixed number of equal bins, re-cut on every zoom (FR-CAT-6). The
|
||
// count is the user's — see `LibrarySettings::timeline_bars` for why the
|
||
// calendar units it replaced made zooming in draw a coarser picture.
|
||
let bins = ctl.timeline_bars.get().max(1);
|
||
// Calendar units survive as a *label* vocabulary: what a bin is called
|
||
// depends on how long it is, not on how the counting was done.
|
||
let granularity = dr_catalog::Granularity::for_bucket((to - from).max(1) / bins as i64);
|
||
let buckets = match library::timeline_uniform(catalog, scope, &filter, from, to, bins) {
|
||
Ok(b) => b,
|
||
Err(e) => {
|
||
log::debug!("timeline: {e}");
|
||
return;
|
||
}
|
||
};
|
||
|
||
// Normalise against the tallest bar. Counts vary by orders of magnitude
|
||
// between a quiet month and a wedding, so a linear scale against the total
|
||
// would render most buckets invisible.
|
||
let peak = buckets.iter().map(|b| b.count).max().unwrap_or(1).max(1);
|
||
let mut previous: Option<(i64, i64)> = None;
|
||
|
||
let bars: Vec<TimelineBar> = buckets
|
||
.iter()
|
||
.map(|b| {
|
||
let (y, m, _, _) = civil_from_unix(b.start);
|
||
// Label only where a period begins, so a month-bucketed axis reads
|
||
// "2024 … Mar … Apr" rather than repeating the year on every bar.
|
||
let period = match previous {
|
||
None => format!("{y}"),
|
||
Some((py, _)) if py != y => format!("{y}"),
|
||
Some((_, pm)) if pm != m && granularity_labels_months(granularity) => {
|
||
month_abbrev(m).to_string()
|
||
}
|
||
_ => String::new(),
|
||
};
|
||
previous = Some((y, m));
|
||
|
||
TimelineBar {
|
||
// Square root rather than linear: it keeps a 3-image day
|
||
// visible beside a 400-image one without a log scale's
|
||
// misleading flatness.
|
||
//
|
||
// A bin holding nothing draws nothing. The floor exists so a
|
||
// quiet day is not rounded away, and empty bins are common now
|
||
// that every one of them is emitted — a sliver on each would
|
||
// draw a library that has photographs in months it does not.
|
||
height: if b.count == 0 {
|
||
0.0
|
||
} else {
|
||
((b.count as f32 / peak as f32).sqrt()).clamp(0.02, 1.0)
|
||
},
|
||
start: b.start as i32,
|
||
count: b.count as i32,
|
||
label: format_bucket(b.start, granularity).into(),
|
||
period_label: period.into(),
|
||
}
|
||
})
|
||
.collect();
|
||
|
||
// Where the grid currently sits, as a fraction of the visible span.
|
||
//
|
||
// This is the exact inverse of what a click produces: the widget maps a y
|
||
// to a fraction of its track and `on_library_scrub_fraction` interpolates
|
||
// `from + (to - from) * f`, so the marker must invert that same expression
|
||
// or it lands somewhere other than the pointer.
|
||
//
|
||
// The earlier version sent a *bar index* instead. Bars occupy one equal
|
||
// slot each regardless of how much time they cover, and `rposition` snaps
|
||
// to the bucket's start edge, so the marker sat at the top of whichever
|
||
// slot contained the instant — near enough on a dense uniform axis, plainly
|
||
// wrong on a sparse one, and never under the click.
|
||
let current = *ctl.current_bucket.borrow();
|
||
let fraction = current.map(|t| fraction_at((from, to), t)).unwrap_or(-1.0);
|
||
|
||
// The chosen range in those same coordinates, for the band. Both ends or
|
||
// neither: half a band is a line the user cannot tell from a handle.
|
||
let (band_from, band_to) = match (filter.captured_from, filter.captured_to) {
|
||
(Some(a), Some(b)) => (fraction_at((from, to), a), fraction_at((from, to), b)),
|
||
_ => (-1.0, -1.0),
|
||
};
|
||
|
||
// Which bar to light: the *bin* holding the grid's instant, not the
|
||
// instant itself. A bar's start is its bin's left edge — one of a fixed
|
||
// set of positions — so comparing it against an arbitrary capture time
|
||
// would light nothing, where before every bar started on a photograph.
|
||
//
|
||
// Taken from the bars themselves rather than recomputed, because the edges
|
||
// are integer division and a second spelling of `from + i * span / bins`
|
||
// is a second chance to be a second out.
|
||
let lit = current
|
||
.and_then(|t| {
|
||
let i = bin_of((from, to), t, bins);
|
||
buckets.get(i).map(|b| b.start)
|
||
})
|
||
.unwrap_or(0);
|
||
window.set_library_current_bucket(lit as i32);
|
||
window.set_library_current_fraction(fraction);
|
||
window.set_library_range_from_fraction(band_from);
|
||
window.set_library_range_to_fraction(band_to);
|
||
window.set_library_timeline_anchored(current.is_some());
|
||
window
|
||
.set_library_timeline_label(format!("{} – {}", format_date(from), format_date(to)).into());
|
||
window.set_library_timeline(slint::ModelRc::new(slint::VecModel::from(bars)));
|
||
}
|
||
|
||
/// Whether this bucket size is fine enough for month labels to mean anything.
|
||
///
|
||
/// A year-bucketed axis labelled by month would put twelve labels on one bar.
|
||
/// Month-sized bins are excluded too, now that the axis has a fixed number of
|
||
/// them: a bin of about a month changes month at nearly every bar, so the rule
|
||
/// below would write a label on all sixty-four of them and the right-hand
|
||
/// strip would be a column of overlapping text. Below a month the labels are
|
||
/// occasional again, which is what makes them readable.
|
||
fn granularity_labels_months(g: dr_catalog::Granularity) -> bool {
|
||
matches!(
|
||
g,
|
||
dr_catalog::Granularity::Day | dr_catalog::Granularity::Hour
|
||
)
|
||
}
|
||
|
||
/// Full month name, for the grid's headings.
|
||
fn month_name(m: i64) -> &'static str {
|
||
const NAMES: [&str; 12] = [
|
||
"January",
|
||
"February",
|
||
"March",
|
||
"April",
|
||
"May",
|
||
"June",
|
||
"July",
|
||
"August",
|
||
"September",
|
||
"October",
|
||
"November",
|
||
"December",
|
||
];
|
||
NAMES[((m - 1).clamp(0, 11)) as usize]
|
||
}
|
||
|
||
fn month_abbrev(m: i64) -> &'static str {
|
||
const NAMES: [&str; 12] = [
|
||
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||
];
|
||
NAMES[((m - 1).clamp(0, 11)) as usize]
|
||
}
|
||
|
||
/// The instant a fraction along a span names.
|
||
///
|
||
/// One home for `from + (to - from) * f`, because three things now depend on
|
||
/// it agreeing with itself: a scrub, the marker drawn where that scrub landed,
|
||
/// and the ends of the date range dragged along the same axis. A second copy
|
||
/// is a second chance for a click and what it produces to disagree.
|
||
fn instant_at(span: (i64, i64), f: f32) -> i64 {
|
||
span.0 + ((span.1 - span.0) as f64 * f.clamp(0.0, 1.0) as f64) as i64
|
||
}
|
||
|
||
/// Where an instant sits along a span, 0..1. The exact inverse of
|
||
/// [`instant_at`], clamped so an instant off the end of the axis is drawn at
|
||
/// the end it went past rather than off the widget.
|
||
fn fraction_at(span: (i64, i64), t: i64) -> f32 {
|
||
((t - span.0) as f64 / (span.1 - span.0).max(1) as f64).clamp(0.0, 1.0) as f32
|
||
}
|
||
|
||
/// Which of `bins` equal bins across `span` holds `t`.
|
||
///
|
||
/// The same arithmetic `library::timeline_uniform` groups by, in the same
|
||
/// order — integer division on the offset from the start — so the bar this
|
||
/// names is the bar that counted the photograph. Clamped, because the grid's
|
||
/// instant can sit outside a zoomed axis.
|
||
fn bin_of(span: (i64, i64), t: i64, bins: u32) -> usize {
|
||
let width = (span.1 - span.0).max(1);
|
||
let i = (t - span.0).clamp(0, width) * bins as i64 / width;
|
||
i.clamp(0, bins as i64 - 1) as usize
|
||
}
|
||
|
||
/// Midnight UTC at or before `t`.
|
||
///
|
||
/// `rem_euclid` rather than `%`: a negative timestamp — a scanned archive
|
||
/// holds photographs from before 1970 — would otherwise truncate *towards*
|
||
/// zero and name the following midnight, putting the day off by one.
|
||
fn day_start(t: i64) -> i64 {
|
||
t - t.rem_euclid(86_400)
|
||
}
|
||
|
||
/// The range a drag of the band's two ends names, as it is stored.
|
||
///
|
||
/// Whole days at both ends, which does three things. It is what the typed
|
||
/// fields mean, so a dragged range reads back out of them as the two days it
|
||
/// covers rather than as two instants nobody chose. It gives the band a floor:
|
||
/// two handles dragged onto each other name one day, not an empty grid. And it
|
||
/// makes the gesture reproducible — the same day is the same range however
|
||
/// close the finger came to a bucket edge.
|
||
///
|
||
/// Ends dragged past each other are put back in order rather than refused: the
|
||
/// user has described the same two edges either way round.
|
||
fn dragged_range(span: (i64, i64), from: f32, to: f32) -> (i64, i64) {
|
||
let (from, to) = if from <= to { (from, to) } else { (to, from) };
|
||
let start = day_start(instant_at(span, from));
|
||
// Inclusive of the closing day. `parse_date` gives midnight, and a range
|
||
// ending there would exclude every photograph taken on the day named.
|
||
let end = day_start(instant_at(span, to)) + 86_400 - 1;
|
||
(start, end)
|
||
}
|
||
|
||
/// Narrow a span by a zoom level, centred on `centre`.
|
||
///
|
||
/// Each step halves or doubles the visible duration. Clamped to the library's
|
||
/// own extent so zooming out cannot wander past the first or last photograph.
|
||
fn zoomed_span(full: (i64, i64), zoom: i32, centre: Option<i64>) -> (i64, i64) {
|
||
let (lo, hi) = full;
|
||
if zoom <= 0 {
|
||
return (lo, hi);
|
||
}
|
||
let duration = (hi - lo).max(1);
|
||
// 2^zoom, saturating: a very deep zoom must not shift the duration to zero.
|
||
let factor = 1i64 << zoom.min(20);
|
||
let window = (duration / factor).max(3600);
|
||
|
||
let mid = centre.unwrap_or(lo + duration / 2);
|
||
let half = window / 2;
|
||
let (mut from, mut to) = (mid - half, mid + half);
|
||
|
||
// Slide rather than shrink at the ends, so the window keeps its size.
|
||
if from < lo {
|
||
to += lo - from;
|
||
from = lo;
|
||
}
|
||
if to > hi {
|
||
from -= to - hi;
|
||
to = hi;
|
||
}
|
||
(from.max(lo), to.min(hi))
|
||
}
|
||
|
||
/// Earliest and latest capture time in the catalog.
|
||
/// The timeline's extent, over exactly the images the grid is showing.
|
||
///
|
||
/// Scrubbing and zooming must measure the same span the histogram is drawn
|
||
/// over. When this read the whole library while the bars were scoped to a
|
||
/// collection, a scrub landed at an instant the collection did not contain and
|
||
/// the view jumped somewhere the user had not asked for.
|
||
fn catalog_span(catalog: &Catalog, ctl: &Rc<LibraryController>) -> Option<(i64, i64)> {
|
||
// `span_scoped` reports the extent with the date range lifted, which is
|
||
// what every caller here wants: they measure the axis that is on screen,
|
||
// and that axis spans the library rather than the chosen range. Measured
|
||
// through the range, narrowing it would move the ground under the very
|
||
// handles doing the narrowing — each drag re-scaling the axis, so the next
|
||
// one meant something else.
|
||
library::span_scoped(catalog, *ctl.scope.borrow(), &ctl.filter.borrow())
|
||
}
|
||
|
||
/// TRACES: NFR-P5
|
||
/// Capture time of the image at row `ordinal` in the grid's own ordering.
|
||
///
|
||
/// `None` for an ordinal that lands among the undated tail, which sorts last
|
||
/// and has no place on a capture-time axis.
|
||
///
|
||
/// # Answered from the loaded window, not from the catalog
|
||
///
|
||
/// This moves the timeline marker, so it is called on **every scroll event** —
|
||
/// every row the view crosses, by design, or the marker would advance in jerks
|
||
/// while the photographs beside it moved smoothly.
|
||
///
|
||
/// It used to be a query, and `LIMIT 1 OFFSET n` is not a seek: SQLite reaches
|
||
/// row `n` by producing and discarding the `n` before it. So its cost grew with
|
||
/// how far down the library the user had scrolled — 0.02 ms near the top, 0.7 ms
|
||
/// at twenty thousand — and it was paid per row crossed, on the thread drawing
|
||
/// the frame. A flick down a long library therefore got *choppier the further
|
||
/// it went*, which is a strange enough symptom to be worth naming.
|
||
///
|
||
/// The window the grid has already read holds this answer. `window_move` keeps
|
||
/// the whole view inside that window, so the lookup is a vector index at the
|
||
/// position the ordinal has in it.
|
||
///
|
||
/// # The fallback, and why it is also more correct than what it replaces
|
||
///
|
||
/// Outside the loaded window — briefly, after a scrub or a keyboard jump, before
|
||
/// the load lands — it falls back to the query. That query counts in a
|
||
/// *dated-only* ordering while `ordinal` is a grid row, so the two disagree
|
||
/// wherever undated images sit in between; it is kept because it is close
|
||
/// enough for a marker that is about to be corrected, and because being wrong
|
||
/// there is what it always did. The window path has no such disagreement: it
|
||
/// reads the very cell the row belongs to.
|
||
fn capture_time_at(ctl: &Rc<LibraryController>, catalog: &Catalog, ordinal: usize) -> Option<i64> {
|
||
{
|
||
let offset = *ctl.offset.borrow();
|
||
let loaded = ctl.captured_at.borrow();
|
||
if ordinal >= offset {
|
||
if let Some(when) = loaded.get(ordinal - offset) {
|
||
return *when;
|
||
}
|
||
}
|
||
}
|
||
capture_time_from_catalog(catalog, ordinal)
|
||
}
|
||
|
||
/// The fallback of [`capture_time_at`], for a row outside the loaded window.
|
||
fn capture_time_from_catalog(catalog: &Catalog, ordinal: usize) -> Option<i64> {
|
||
catalog
|
||
.connection()
|
||
.query_row(
|
||
"SELECT captured_at FROM images
|
||
WHERE shadowed_by IS NULL AND captured_at IS NOT NULL
|
||
ORDER BY captured_at
|
||
LIMIT 1 OFFSET ?1",
|
||
[ordinal as i64],
|
||
|r| r.get::<_, i64>(0),
|
||
)
|
||
.ok()
|
||
}
|
||
|
||
/// Jump the grid to the first image at or after `when`.
|
||
///
|
||
/// This is the scrub: the window moves, the library does not narrow. Every
|
||
/// image stays reachable, which is why this is a position rather than a
|
||
/// filter.
|
||
fn scrub_to(window: &AppWindow, ctl: &Rc<LibraryController>, when: i64) {
|
||
let position = {
|
||
let borrow = ctl.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
// How many rows precede this instant **in the grid's own ordering**.
|
||
// That ordinal is the scroll offset, so any disagreement with the
|
||
// grid's query lands the view somewhere else entirely.
|
||
//
|
||
// The earlier version counted only dated images. With 2,400 of 19,841
|
||
// dated, a click near the end of the axis produced an ordinal of ~2,400
|
||
// against a grid of 19,841 rows — the view landed near the top however
|
||
// far down the axis the pointer went.
|
||
//
|
||
// Undated images sort last (`captured_at IS NULL` first in the ORDER
|
||
// BY), so they never precede a dated one and the predicate below stays
|
||
// a simple `<`. Shadowed rows are excluded here exactly as the grid
|
||
// excludes them.
|
||
catalog
|
||
.connection()
|
||
.query_row(
|
||
"SELECT count(*) FROM images
|
||
WHERE shadowed_by IS NULL
|
||
AND captured_at IS NOT NULL
|
||
AND captured_at < ?1",
|
||
[when],
|
||
|r| r.get::<_, i64>(0),
|
||
)
|
||
.unwrap_or(0) as usize
|
||
};
|
||
|
||
// Record where the grid now sits, which anchors the timeline marker. Until
|
||
// the first scrub this stays `None` and the marker rests at the middle
|
||
// rather than implying a choice the user has not made.
|
||
*ctl.current_bucket.borrow_mut() = Some(when);
|
||
*ctl.offset.borrow_mut() = position;
|
||
// Move the viewport as well as the window. Cells are drawn at their
|
||
// absolute place in the library, so loading rows around image 15,000 while
|
||
// the viewport sits at row 0 shows an empty grid until the user scrolls.
|
||
window.set_library_scroll_to(position as i32);
|
||
window.set_library_scroll_token(window.get_library_scroll_token() + 1);
|
||
load_window(window, ctl);
|
||
}
|
||
|
||
/// TRACES: FR-CAT-7
|
||
/// Put the view back where the photographer was after the library changed
|
||
/// under it.
|
||
///
|
||
/// Deleting is the case this exists for. The grid draws each cell at its
|
||
/// absolute place in the library — `(i + offset) / columns` — and a delete does
|
||
/// two things at once: the library gets shorter, and `offset` is re-clamped
|
||
/// downward so the loaded window still fills. Neither touches `viewport-y`, so
|
||
/// the view ends up pointing at a different part of the library, or past the
|
||
/// end of it entirely. That is the "goes blank and jumps somewhere random".
|
||
///
|
||
/// Anchored on the *ordinal the view was showing*, clamped into what is left.
|
||
/// Not on the deleted image's own position, which no longer exists, and not on
|
||
/// the top of the library, which would throw away the scroll position on every
|
||
/// delete — after removing one frame from a wall of twenty thousand, the next
|
||
/// one you want is the one that just moved into its place.
|
||
pub fn restore_position(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
let total = window.get_library_total().max(0) as usize;
|
||
if total == 0 {
|
||
return;
|
||
}
|
||
// The first *visible* ordinal, not the loaded window's start.
|
||
//
|
||
// Anchoring on `offset` was the first attempt and it still jumped, because
|
||
// the two are deliberately different: the window begins about a quarter of
|
||
// a screen above the view so scrolling up has rows ready, and by this point
|
||
// in `load_window` it has also been re-clamped against the new, smaller
|
||
// total. Seeking to it therefore moved the view to somewhere the user was
|
||
// not — a smaller jump than before, but the same fault.
|
||
//
|
||
// `resume_at` is what the grid last reported as its first visible image,
|
||
// which is the thing the photographer is actually looking at.
|
||
let anchor = ctl.resume_at.get().min(total - 1);
|
||
window.set_library_scroll_to(anchor as i32);
|
||
window.set_library_scroll_token(window.get_library_scroll_token() + 1);
|
||
}
|
||
|
||
/// Format a bucket start for the histogram's hover label.
|
||
fn format_bucket(t: i64, g: dr_catalog::Granularity) -> String {
|
||
let (y, m, d, h) = civil_from_unix(t);
|
||
match g {
|
||
dr_catalog::Granularity::Year => format!("{y}"),
|
||
dr_catalog::Granularity::Month => format!("{y}-{m:02}"),
|
||
dr_catalog::Granularity::Day => format!("{y}-{m:02}-{d:02}"),
|
||
dr_catalog::Granularity::Hour => format!("{y}-{m:02}-{d:02} {h:02}:00"),
|
||
}
|
||
}
|
||
|
||
/// A capture instant as `YYYY-MM-DD`.
|
||
///
|
||
/// Shared with the exporter, which resolves the `{date}` token from the same
|
||
/// reading so a filename and the timeline cannot disagree about what day a
|
||
/// photograph was taken.
|
||
pub fn format_date(t: i64) -> String {
|
||
dr_types::format_date(t)
|
||
}
|
||
|
||
/// Unix seconds to a civil date, in the tuple shape this module reads it in.
|
||
///
|
||
/// The algorithm moved to `dr_types::time` when import folder templates
|
||
/// (FR-CAT-10) became a third caller. Three readings of the same instant that
|
||
/// could drift apart is one too many: an image filed under a date the timeline
|
||
/// does not show it on is a file the user cannot find.
|
||
fn civil_from_unix(t: i64) -> (i64, i64, i64, i64) {
|
||
let c = dr_types::civil_from_unix(t);
|
||
(c.year, c.month, c.day, c.hour)
|
||
}
|
||
|
||
/// Copy decoded RGBA into a Slint image.
|
||
///
|
||
/// This is a CPU copy, which is acceptable here and not in the develop path:
|
||
/// a 256px thumbnail is 256 KB and happens once per image, where the canvas
|
||
/// would pay per frame (ARCH §6.1).
|
||
fn to_slint_image(width: u32, height: u32, rgba: &[u8]) -> slint::Image {
|
||
let mut buf = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::new(width, height);
|
||
let expected = (width as usize) * (height as usize) * 4;
|
||
let src = &rgba[..expected.min(rgba.len())];
|
||
buf.make_mut_bytes()[..src.len()].copy_from_slice(src);
|
||
slint::Image::from_rgba8(buf)
|
||
}
|
||
|
||
/// Walk the keyboard cursor through the library — the arrow keys.
|
||
///
|
||
/// **The cursor is a library ordinal, not a row of the loaded window.** That is
|
||
/// what lets it walk past the window's edge: the window is a few screenfuls
|
||
/// around wherever the user is looking, and a cursor held as a row would stop
|
||
/// at its end or, worse, keep counting into cells belonging to different
|
||
/// photographs. Moving out of the window reloads it around the new position,
|
||
/// which is the same thing scrolling does.
|
||
///
|
||
/// The grid supplies the step, because how far "down" is depends on how many
|
||
/// columns the window happens to be showing, and only the grid knows that. It
|
||
/// does not clamp: `Home` and `End` arrive as a step longer than the library
|
||
/// and are clamped here, where the total is known.
|
||
fn move_cursor(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
coll: &Rc<crate::collections_ui::CollectionsController>,
|
||
delta: i32,
|
||
extend: bool,
|
||
) {
|
||
let total = window.get_library_total().max(0) as usize;
|
||
if total == 0 {
|
||
return;
|
||
}
|
||
|
||
let Some(from) = coll.cursor() else {
|
||
// The first press takes hold of the grid rather than moving in it. A
|
||
// key that jumped to image zero would lose wherever the user had
|
||
// scrolled to, and one that started from a cell off the top of the
|
||
// screen would appear to do nothing but scroll.
|
||
//
|
||
// The first *visible* ordinal, not the window's start: the loaded
|
||
// window deliberately begins a quarter of a screen above the view, so
|
||
// its first cell is one the user cannot see.
|
||
let at = ctl.resume_at.get().min(total - 1);
|
||
place_cursor(window, ctl, coll, at, false);
|
||
return;
|
||
};
|
||
|
||
// Saturating in `isize`, so a `Home` expressed as minus the library's
|
||
// length does not wrap round to the end.
|
||
let next = (from as isize)
|
||
.saturating_add(delta as isize)
|
||
.clamp(0, total as isize - 1) as usize;
|
||
if next == from {
|
||
// Already at the end being pressed toward. Nothing to move, and
|
||
// reloading the window would be a visible jerk for no movement.
|
||
return;
|
||
}
|
||
place_cursor(window, ctl, coll, next, extend);
|
||
}
|
||
|
||
/// Put the cursor on one image, bringing the window with it.
|
||
fn place_cursor(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
coll: &Rc<crate::collections_ui::CollectionsController>,
|
||
at: usize,
|
||
extend: bool,
|
||
) {
|
||
// Bring the window to the cursor if it has walked out of it. Centred a
|
||
// quarter in, exactly as a scroll does it, so continuing in the same
|
||
// direction has loaded cells to move into rather than another reload on
|
||
// the very next press.
|
||
let loaded = window.get_library_cells().row_count();
|
||
let offset = *ctl.offset.borrow();
|
||
if at < offset || at >= offset + loaded {
|
||
let size = *ctl.window.borrow();
|
||
let total = window.get_library_total().max(0) as usize;
|
||
*ctl.offset.borrow_mut() = window_start(at, size, total);
|
||
load_window(window, ctl);
|
||
}
|
||
|
||
// Re-read: `load_window` clamps the offset against the library's end, so
|
||
// the window may not start where it was asked to.
|
||
let offset = *ctl.offset.borrow();
|
||
let ids = ctl.visible_ids();
|
||
let Some(row) = at.checked_sub(offset).filter(|r| *r < ids.len()) else {
|
||
// The window could not be brought to the cursor — an empty or
|
||
// shrinking library. Leaving the cursor where it was is better than
|
||
// pointing it at nothing.
|
||
return;
|
||
};
|
||
|
||
if !extend {
|
||
// An arrow key collapses the selection onto the cursor. A *click* on
|
||
// an already-selected cell deliberately leaves the selection alone —
|
||
// that exception exists so a multi-image drag can start from one of
|
||
// its members — and there is no drag behind a keystroke.
|
||
coll.clear_selection();
|
||
}
|
||
|
||
crate::collections_ui::select_row(window, coll, &ids, offset, row, false, extend);
|
||
window.set_library_cursor(at as i32);
|
||
}
|
||
|
||
/// Say where in the library the photograph now open sits.
|
||
///
|
||
/// **After `on_open_image`, never before.** The generic open path resets the
|
||
/// readout to "1 of 1" on its way in, because until the photo roll existed the
|
||
/// grid really did hand develop a single path with nothing to walk to. It has
|
||
/// a window of them now, so the honest answer is the ordinal in the library —
|
||
/// not the row in the loaded window, which is an artefact of how much has been
|
||
/// paged in and would jump about as the window moves.
|
||
fn report_position(window: &AppWindow, ctl: &Rc<LibraryController>, row: usize) {
|
||
let offset = *ctl.offset.borrow();
|
||
window.set_index((offset + row) as i32);
|
||
window.set_total(window.get_library_total());
|
||
}
|
||
|
||
/// Connect the grid's callbacks.
|
||
pub fn wire<F>(
|
||
window: &AppWindow,
|
||
ctl: Rc<LibraryController>,
|
||
coll_ctl: Rc<crate::collections_ui::CollectionsController>,
|
||
on_open_image: F,
|
||
on_leave_develop: Rc<dyn Fn()>,
|
||
) where
|
||
F: Fn(String) + 'static,
|
||
{
|
||
// So a rebuilt window can put the selection ticks back. Weak, or the two
|
||
// controllers would hold each other alive for the life of the process.
|
||
*ctl.coll_ctl.borrow_mut() = Some(Rc::downgrade(&coll_ctl));
|
||
|
||
// TRACES: FR-UI-3 | FR-UI-4
|
||
// Whether the rating strip waits to be hovered or stands open.
|
||
//
|
||
// On Android touch is not evidence to be gathered, it is the platform.
|
||
// The grid latches it from the first finger it sees as well, which is what
|
||
// covers a touchscreen on the desktop — but that latch needs a press to
|
||
// reach a cell, and a quick flick never delivers one because the Flickable
|
||
// claims the gesture before the delay it would forward after. Seeding it
|
||
// here means the stars are on screen before the first touch rather than
|
||
// after it, which is the whole point of showing them.
|
||
window.set_library_touched(cfg!(target_os = "android"));
|
||
|
||
// Shared rather than moved: a click and `Return` both open an image, and
|
||
// they are two callbacks.
|
||
let on_open_image = Rc::new(on_open_image);
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
let coll_for_click = coll_ctl.clone();
|
||
let on_open_image = on_open_image.clone();
|
||
window.on_library_cell_clicked(move |i| {
|
||
// A ctrl- or shift-click is a selection gesture. Opening the image
|
||
// too would throw the user out of the grid mid-selection.
|
||
if coll_for_click.press_was_modified() {
|
||
return;
|
||
}
|
||
let path = ctl.paths.borrow().get(i as usize).cloned();
|
||
if let Some(path) = path {
|
||
// Leave the grid for the develop view. The status bar's
|
||
// "‹ Library" button comes back here.
|
||
if let Some(w) = weak.upgrade() {
|
||
w.set_show_library(false);
|
||
// Which cell the develop view is now showing, so the photo
|
||
// roll opens marking it rather than marking nothing.
|
||
w.set_library_roll_current(i);
|
||
}
|
||
on_open_image(path);
|
||
if let Some(w) = weak.upgrade() {
|
||
report_position(&w, &ctl, i as usize);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// TRACES: FR-UI-4
|
||
// A photograph chosen from the photo roll.
|
||
//
|
||
// The same row-to-path lookup a cell click does, without the selection
|
||
// rules: the roll is a way of moving between photographs, not of building
|
||
// a set, so there is no modified press to honour and no reason to leave
|
||
// develop. `open_from_library` persists the outgoing edit before it loads
|
||
// the next one, which is what makes this safe to fire repeatedly.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
let on_open_image = on_open_image.clone();
|
||
window.on_library_roll_pick(move |i| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let Some(path) = ctl.paths.borrow().get(i as usize).cloned() else {
|
||
return;
|
||
};
|
||
w.set_library_roll_current(i);
|
||
on_open_image(path);
|
||
report_position(&w, &ctl, i as usize);
|
||
});
|
||
}
|
||
|
||
// --- the keyboard cursor (FR-CULL-4) -----------------------------------
|
||
//
|
||
// Walking the grid with the arrows, and opening with `Return`. Together
|
||
// with the judgement keys already bound in the grid, this is what makes a
|
||
// culling pass a keyboard job: move, rate, move, open the doubtful one,
|
||
// come back. A cull is thousands of decisions, and reaching for the mouse
|
||
// between each of them is the difference between an hour and an evening.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
let coll = coll_ctl.clone();
|
||
window.on_library_move_cursor(move |delta, extend| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
move_cursor(&w, &ctl, &coll, delta, extend);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
let coll = coll_ctl.clone();
|
||
let on_open = on_open_image.clone();
|
||
window.on_library_open_cursor(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
// The cursor is a library ordinal and `paths` is the loaded
|
||
// window, so the row is the difference. A cursor outside the
|
||
// window cannot happen — moving it loads the window around it —
|
||
// but a shrinking library could leave one behind, and opening the
|
||
// wrong photograph is worse than opening none.
|
||
let Some(cursor) = coll.cursor() else { return };
|
||
let offset = *ctl.offset.borrow();
|
||
let row = cursor.checked_sub(offset);
|
||
let path = row.and_then(|row| ctl.paths.borrow().get(row).cloned());
|
||
if let Some(path) = path {
|
||
w.set_show_library(false);
|
||
// As on a click: the roll marks what is open.
|
||
w.set_library_roll_current(row.unwrap_or(0) as i32);
|
||
on_open(path);
|
||
report_position(&w, &ctl, row.unwrap_or(0));
|
||
}
|
||
});
|
||
}
|
||
|
||
// Ctrl+wheel or pinch over the grid resizes the cells.
|
||
//
|
||
// Geometric steps rather than fixed pixels: the same gesture should feel
|
||
// the same at 90px and at 400px, and a linear step is imperceptible at one
|
||
// end and violent at the other.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_zoom_cells(move |delta| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
|
||
let current = w.get_library_cell_size();
|
||
let next = if delta > 0 {
|
||
current * 1.25
|
||
} else {
|
||
current / 1.25
|
||
}
|
||
.clamp(MIN_CELL_SIZE, MAX_CELL_SIZE);
|
||
|
||
if (next - current).abs() < 0.5 {
|
||
return;
|
||
}
|
||
w.set_library_cell_size(next);
|
||
|
||
// No need to forget anything on a class change: the class is part
|
||
// of the request key, so cells that now want the large resolution
|
||
// simply miss and ask for it, while the 256px ones they already
|
||
// hold stay served.
|
||
//
|
||
// Deferred, like every other geometry change. This one was still
|
||
// reloading inline — a full catalog re-read and model rebuild per
|
||
// step, which is what a wheel spun through six steps paid six
|
||
// times over.
|
||
schedule_reload(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
// TRACES: FR-UI-4
|
||
// A pinch, which is continuous where the wheel is stepped.
|
||
//
|
||
// Given the ratio since the last update rather than a direction, so the
|
||
// grid tracks the fingers instead of jumping a fixed 25% per threshold
|
||
// crossing. What the user is setting is the size class — how big they want
|
||
// a thumbnail to be — and the drawn cell follows from it by dividing the
|
||
// width, so the visible result still lands on whole column counts. Feeding
|
||
// a continuous value in is what decides *when* it crosses.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_pinch_cells(move |ratio| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
if !(ratio.is_finite() && ratio > 0.0) {
|
||
return;
|
||
}
|
||
|
||
let current = w.get_library_cell_size();
|
||
let next = (current * ratio).clamp(MIN_CELL_SIZE, MAX_CELL_SIZE);
|
||
if (next - current).abs() < 0.5 {
|
||
return;
|
||
}
|
||
w.set_library_cell_size(next);
|
||
schedule_reload(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
// TRACES: FR-UI-4
|
||
// A pinch has begun, so the press that started it was not a press.
|
||
//
|
||
// A pinch opens as one finger on a cell — which selects it — and only
|
||
// becomes a pinch when the second lands. Without this the user is left
|
||
// holding a selection they never made, on a photograph they were only
|
||
// reaching past.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
let coll = coll_ctl.clone();
|
||
window.on_library_pinch_started(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
crate::collections_ui::cancel_press(&w, &coll, &ctl.visible_ids());
|
||
});
|
||
}
|
||
|
||
// Explicit sync, for when the user wants the exchange now rather than
|
||
// after the next sweep.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_sync_now(move || {
|
||
if let Some(w) = weak.upgrade() {
|
||
start_derived_sync(&w, &ctl);
|
||
}
|
||
});
|
||
}
|
||
|
||
// TRACES: FR-CAT-3 | FR-NC-3
|
||
// Thumbnail the whole library, from the settings page. It ends in a sync
|
||
// of its own, so this is the long version of the button above.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_thumbnail_all(move || {
|
||
if let Some(w) = weak.upgrade() {
|
||
start_thumbnail_sweep(&w, &ctl);
|
||
}
|
||
});
|
||
}
|
||
|
||
// A column-count change moves which cells begin a row, and month headings
|
||
// sit on row-leading cells.
|
||
//
|
||
// Guarded like the scroll below, and for the same reason: a grid being
|
||
// taken down reports its geometry collapsing on the way out, and reloading
|
||
// the window against that is work done for a page nobody is looking at.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_columns_changed(move || {
|
||
if let Some(w) = weak.upgrade() {
|
||
if !w.get_show_library() {
|
||
return;
|
||
}
|
||
|
||
// Deferred, and re-anchored when it lands — see
|
||
// [`schedule_reload`]. A column change moves every cell in the
|
||
// grid, because a cell is drawn at its absolute place in the
|
||
// library and the row that resolves to is `index / columns`.
|
||
// The viewport does not move with them, so without the
|
||
// re-anchor the view is left pointing at rows the loaded window
|
||
// no longer covers and the grid draws nothing at all — the
|
||
// "gallery randomly goes blank until I scroll" report, whose
|
||
// triggers are a resize, the sidebar opening, a zoom step, or
|
||
// turning the tablet over.
|
||
schedule_reload(&w, &ctl);
|
||
}
|
||
});
|
||
}
|
||
|
||
// The viewport changed size, so the window it can usefully hold changed
|
||
// with it. Reloading only on growth would leave a maximised-then-restored
|
||
// window over-fetching, so both directions are honoured.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_viewport_cells(move |on_screen| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
if !w.get_show_library() {
|
||
return;
|
||
}
|
||
let on_screen = (on_screen.max(0) as usize).max(1);
|
||
let window_size = (on_screen * SCREENFULS).max(MIN_WINDOW);
|
||
if window_size == *ctl.window.borrow() && on_screen == ctl.viewport_cells.get() {
|
||
return;
|
||
}
|
||
ctl.viewport_cells.set(on_screen);
|
||
*ctl.window.borrow_mut() = window_size;
|
||
// Coalesced with the column change that almost always accompanies
|
||
// it: resizing the cells alters both, and reloading once per report
|
||
// meant two full rebuilds per zoom step.
|
||
schedule_reload(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
// Scrolling moves the loaded window through the library.
|
||
//
|
||
// The whole catalog is reachable because the Flickable's viewport is sized
|
||
// to it; this keeps the 120 loaded rows centred on wherever the view is.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_scrolled(move |first_visible| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let first_visible = first_visible.max(0) as usize;
|
||
|
||
// **A report from a grid that is not on screen is not a scroll.**
|
||
//
|
||
// `show-library` gates an `if`, so opening an image tears the whole
|
||
// subtree down — and a Flickable being destroyed passes its viewport
|
||
// through zero on the way out, which arrives here indistinguishable
|
||
// from the user having flung the grid to the top. Everything below
|
||
// then ran on the way *into* develop: the loaded window was reset to
|
||
// offset zero, the model was rebuilt against the first rows of the
|
||
// library, and a thumbnail batch was issued for photographs nobody
|
||
// had asked to see. Those rebuilds landed while the grid was still
|
||
// being taken apart, which is what flashed the library over the
|
||
// develop view for the first few frames after a click — and on a
|
||
// remote library it also spent a burst of requests on the top of the
|
||
// catalog every single time an image was opened.
|
||
//
|
||
// The guard used to cover only `resume_at`, for a narrower version
|
||
// of the same reason. It belongs over the whole handler.
|
||
if !w.get_show_library() {
|
||
return;
|
||
}
|
||
|
||
// Remember where the view is, so leaving for the develop view and
|
||
// coming back returns here. Latched on every event rather than read
|
||
// at departure: by the time the grid is hidden its scroll position
|
||
// is only in the Flickable, which is about to be destroyed.
|
||
ctl.resume_at.set(first_visible);
|
||
|
||
// Move the timeline marker with the view. Scrolling the grid is a
|
||
// way of moving through time just as scrubbing is, and a marker
|
||
// that only ever moved on a scrub sat still while the photographs
|
||
// beside it advanced by months — the axis said "when you are" and
|
||
// was wrong the moment the user touched the wheel.
|
||
//
|
||
// This runs before the reload guard below, which fires only a few
|
||
// times per screenful; the marker has to follow every event or it
|
||
// would advance in visible jerks.
|
||
//
|
||
// Only the marker is moved, not the whole histogram: rebuilding
|
||
// the bars means a `GROUP BY strftime` aggregate over the library,
|
||
// far too much for every event of a flick. The bars do not change
|
||
// as the grid scrolls anyway — only where the marker sits on them.
|
||
//
|
||
// Re-running the scrub would be wrong for a second reason: it sets
|
||
// `scroll-to`, which would drive the grid from its own scroll.
|
||
{
|
||
let borrow = ctl.catalog.borrow();
|
||
if let Some(catalog) = borrow.as_ref() {
|
||
if let Some(when) = capture_time_at(&ctl, catalog, first_visible) {
|
||
*ctl.current_bucket.borrow_mut() = Some(when);
|
||
|
||
let zoom = *ctl.timeline_zoom.borrow();
|
||
let centre = *ctl.timeline_centre.borrow();
|
||
if let Some(full) = catalog_span(catalog, &ctl) {
|
||
let (from, to) = zoomed_span(full, zoom, centre);
|
||
w.set_library_current_bucket(when as i32);
|
||
w.set_library_current_fraction(
|
||
((when - from) as f64 / (to - from).max(1) as f64).clamp(0.0, 1.0)
|
||
as f32,
|
||
);
|
||
w.set_library_timeline_anchored(true);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Centre the window on the view, so scrolling either way has
|
||
// loaded rows ahead of it rather than only below.
|
||
let Some(offset) = window_move(
|
||
first_visible,
|
||
*ctl.offset.borrow(),
|
||
*ctl.window.borrow(),
|
||
ctl.viewport_cells.get(),
|
||
w.get_library_total().max(0) as usize,
|
||
) else {
|
||
return;
|
||
};
|
||
|
||
*ctl.offset.borrow_mut() = offset;
|
||
load_window(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
// Panning the timeline slides the visible span without changing its width.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_timeline_pan(move |fraction| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let borrow = ctl.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
let Some(full) = catalog_span(catalog, &ctl) else {
|
||
return;
|
||
};
|
||
|
||
// A fraction of the *visible* span, so dragging half the axis
|
||
// moves half a span's worth of time whatever the zoom — and a
|
||
// small movement produces a small shift rather than nothing.
|
||
let zoom = *ctl.timeline_zoom.borrow();
|
||
let (from, to) = zoomed_span(full, zoom, *ctl.timeline_centre.borrow());
|
||
let shift = ((to - from) as f64 * fraction as f64) as i64;
|
||
if shift == 0 {
|
||
return;
|
||
}
|
||
|
||
let centre = ctl.timeline_centre.borrow().unwrap_or((from + to) / 2) + shift;
|
||
*ctl.timeline_centre.borrow_mut() = Some(centre.clamp(full.0, full.1));
|
||
refresh_timeline(&w, catalog, &ctl);
|
||
});
|
||
}
|
||
|
||
// The wheel zooms the axis: a sidebar is a scale, not a list.
|
||
//
|
||
// Shares `apply_zoom` with the pinch handler so the two cannot drift apart
|
||
// in how they clamp or where they centre.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_timeline_zoom(move |delta| {
|
||
if let Some(w) = weak.upgrade() {
|
||
apply_zoom(&w, &ctl, delta);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Dragging the histogram moves the grid through time.
|
||
//
|
||
// The fraction is interpolated across the visible span rather than snapped
|
||
// to a bucket edge, so a slow drag advances continuously instead of sitting
|
||
// still and then jumping a whole month.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_scrub_fraction(move |f| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let borrow = ctl.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
let Some(full) = catalog_span(catalog, &ctl) else {
|
||
return;
|
||
};
|
||
|
||
let (from, to) = zoomed_span(
|
||
full,
|
||
*ctl.timeline_zoom.borrow(),
|
||
*ctl.timeline_centre.borrow(),
|
||
);
|
||
let when = instant_at((from, to), f);
|
||
drop(borrow);
|
||
scrub_to(&w, &ctl, when);
|
||
});
|
||
}
|
||
|
||
// Pinch, for tablet: no wheel there, so this is the only way to reach the
|
||
// axis's zoom with a finger.
|
||
//
|
||
// A continuous ratio against discrete zoom levels, so the accumulated
|
||
// ratio is held and a level is taken each time it passes a doubling. That
|
||
// keeps a slow spread from either doing nothing or leaping several levels.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_timeline_pinch(move |ratio| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
if !(0.01..=100.0).contains(&ratio) {
|
||
return;
|
||
}
|
||
|
||
let mut accum = ctl.pinch_accum.borrow_mut();
|
||
*accum *= ratio;
|
||
|
||
// Whole doublings out of the accumulated ratio, remainder carried.
|
||
//
|
||
// `log2` rather than repeated halving: one update can carry a
|
||
// large ratio — a fast spread, or a trackpad reporting coarsely —
|
||
// and stepping once per update would turn an 8× pinch into a
|
||
// single level instead of three.
|
||
let steps = accum.log2().trunc() as i32;
|
||
if steps == 0 {
|
||
return;
|
||
}
|
||
*accum /= (2.0f32).powi(steps);
|
||
drop(accum);
|
||
|
||
apply_zoom(&w, &ctl, steps);
|
||
});
|
||
}
|
||
|
||
// Grid → launch screen. The route that was missing: once past the launch
|
||
// screen there was no way back to it, so a library pointed at the wrong
|
||
// folder could not be changed without clearing stored state by hand.
|
||
{
|
||
let weak = window.as_weak();
|
||
window.on_library_change(move || {
|
||
if let Some(w) = weak.upgrade() {
|
||
w.set_show_launch(true);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Develop → grid.
|
||
//
|
||
// Returns to where the user left rather than to the top. `show-library`
|
||
// gates an `if` in the markup, so the grid is rebuilt from nothing and its
|
||
// Flickable starts at row 0; the position has to be replayed explicitly.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_back_to_library(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
|
||
// TRACES: FR-CAT-8
|
||
// The edit is persisted on the way out rather than on every
|
||
// slider move: a save is a network round-trip, and one per drag
|
||
// frame would put an upload inside the gesture NFR-P5 governs.
|
||
// This is the moment the image stops being the open one, so it is
|
||
// the last moment its edit can be written.
|
||
on_leave_develop();
|
||
|
||
let resume = ctl.resume_at.get();
|
||
if resume > 0 {
|
||
// Through the same channel a scrub uses, and for the same
|
||
// reason: the viewport and the loaded window both have to move,
|
||
// or the cells are drawn thousands of rows from where the view
|
||
// sits.
|
||
//
|
||
// Centred like `on_library_scrolled` does it, so scrolling up
|
||
// from the restored position has loaded rows above it.
|
||
let window_size = *ctl.window.borrow();
|
||
let total = w.get_library_total().max(0) as usize;
|
||
*ctl.offset.borrow_mut() = window_start(resume, window_size, total);
|
||
load_window(&w, &ctl);
|
||
|
||
// Before the grid is shown, not after: the markup gates it on
|
||
// an `if`, and the rebuilt Flickable reads `scroll-to` in its
|
||
// `init`. Setting these afterwards would leave that init to run
|
||
// against the previous position.
|
||
w.set_library_scroll_to(resume as i32);
|
||
w.set_library_scroll_token(w.get_library_scroll_token() + 1);
|
||
}
|
||
|
||
w.set_show_library(true);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
let coll_ctl = coll_ctl.clone();
|
||
window.on_library_rescan(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
start_rescan(&w, &ctl, &coll_ctl);
|
||
});
|
||
}
|
||
|
||
// --- ratings and flags (FR-CAT-5, FR-CULL-4) --------------------------
|
||
|
||
// Clicking a star rates *that cell*, not the selection. The pointer names
|
||
// one photograph unambiguously, and a click that silently rated forty
|
||
// others would be a trap — the keyboard is the bulk gesture.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_cell_rated(move |row, stars| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let id = ctl
|
||
.image_ids
|
||
.borrow()
|
||
.get(row as usize)
|
||
.map(|id| dr_types::ImageId(*id as u64));
|
||
let Some(id) = id else { return };
|
||
|
||
apply_judgement(&w, &ctl, &[id], Some(stars.clamp(0, 5) as u8), None);
|
||
});
|
||
}
|
||
|
||
// A rating or flag key. Applies to the whole selection, which is what
|
||
// makes judging a run of frames one keystroke rather than forty.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
let coll_for_keys = coll_ctl.clone();
|
||
window.on_library_judged(move |rating, flag| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let chosen = coll_for_keys.selected();
|
||
|
||
// Exactly one axis is meant per keystroke; the other arrives as
|
||
// -1 so a star press cannot disturb a flag or the reverse.
|
||
if rating >= 0 {
|
||
apply_judgement(&w, &ctl, &chosen, Some(rating.clamp(0, 5) as u8), None);
|
||
} else if flag >= 0 {
|
||
apply_judgement(&w, &ctl, &chosen, None, Some(flag_from_code(flag)));
|
||
}
|
||
});
|
||
}
|
||
|
||
// --- keywords (FR-CAT-5, FR-CAT-6) ------------------------------------
|
||
//
|
||
// Three callbacks and no state of their own: the sheet's open/shut is local
|
||
// to the `.slint` file, and what a keyword applies to is the grid selection
|
||
// the collections controller already owns. A second copy of either here is
|
||
// a second thing that can disagree with the first.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
let coll_for_keywords = coll_ctl.clone();
|
||
window.on_library_keywords_opened(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
refresh_keywords(&w, &ctl, &coll_for_keywords.selected());
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_assign_keyword(move |word| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
apply_keyword(&w, &ctl, word.as_str(), true);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_unassign_keyword(move |word| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
apply_keyword(&w, &ctl, word.as_str(), false);
|
||
});
|
||
}
|
||
|
||
// --- the filter bar ---------------------------------------------------
|
||
//
|
||
// Each of these narrows what the grid *queries*, so all three reset the
|
||
// scroll offset: the window's position was an ordinal into a different
|
||
// set of images and means nothing once the set changes.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_filter_min_rating_changed(move |n| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
ctl.filter.borrow_mut().min_rating = n.clamp(0, 5) as u8;
|
||
// Stars and "unrated" are contradictory terms — asking for four
|
||
// stars *and* nothing judged matches nothing at all, which reads
|
||
// as a broken filter rather than an impossible question.
|
||
if n > 0 {
|
||
ctl.filter.borrow_mut().unjudged = false;
|
||
}
|
||
w.set_library_filter_min_rating(n.clamp(0, 5));
|
||
w.set_library_filter_unjudged(ctl.filter.borrow().unjudged);
|
||
refilter(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_filter_unjudged_changed(move |on| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
{
|
||
let mut f = ctl.filter.borrow_mut();
|
||
f.unjudged = on;
|
||
// See above: the two cannot both hold.
|
||
if on {
|
||
f.min_rating = 0;
|
||
f.flag = None;
|
||
}
|
||
}
|
||
w.set_library_filter_unjudged(on);
|
||
if on {
|
||
w.set_library_filter_min_rating(0);
|
||
w.set_library_filter_flag(0);
|
||
}
|
||
refilter(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_identity_show_photos(move |id, add| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let person = dr_catalog::faces::PersonId(id.max(0) as u64);
|
||
|
||
{
|
||
let mut f = ctl.filter.borrow_mut();
|
||
if !add {
|
||
f.people.clear();
|
||
}
|
||
// Adding the same person twice is a no-op rather than a
|
||
// duplicate chip — and, under `All`, rather than a filter that
|
||
// silently asks for them twice and matches the same pictures.
|
||
if !f.people.contains(&person.0) {
|
||
f.people.push(person.0);
|
||
}
|
||
}
|
||
push_people_chips(&w, &ctl);
|
||
|
||
// Leaving the Identity screen for the grid is the whole point of
|
||
// the button: the answer to "who is this" is a set of photographs,
|
||
// and they are shown where photographs are shown.
|
||
w.set_show_identity(false);
|
||
w.set_show_library(true);
|
||
refilter(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_filter_person_cleared(move |id| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
ctl.filter
|
||
.borrow_mut()
|
||
.people
|
||
.retain(|p| *p != id.max(0) as u64);
|
||
push_people_chips(&w, &ctl);
|
||
refilter(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_filter_people_mode_toggled(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
{
|
||
let mut f = ctl.filter.borrow_mut();
|
||
f.people_mode = match f.people_mode {
|
||
library::PeopleMode::Any => library::PeopleMode::All,
|
||
library::PeopleMode::All => library::PeopleMode::Any,
|
||
};
|
||
}
|
||
push_people_chips(&w, &ctl);
|
||
refilter(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_filter_flag_changed(move |f| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
{
|
||
let mut filter = ctl.filter.borrow_mut();
|
||
filter.flag = match f {
|
||
1 => Some(dr_types::FlagState::Pick),
|
||
2 => Some(dr_types::FlagState::Reject),
|
||
_ => None,
|
||
};
|
||
if f > 0 {
|
||
filter.unjudged = false;
|
||
}
|
||
}
|
||
w.set_library_filter_flag(f);
|
||
w.set_library_filter_unjudged(ctl.filter.borrow().unjudged);
|
||
refilter(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
// Turn the range on, over the period the histogram is showing.
|
||
//
|
||
// The range is taken from the timeline rather than typed into two date
|
||
// fields: finding the fortnight is what the histogram is *for*, and having
|
||
// found it the user should not have to read the dates off the axis and key
|
||
// them back in.
|
||
//
|
||
// A starting point rather than the answer. What this puts on the axis is a
|
||
// band with two draggable ends, and dragging them is how the range is
|
||
// actually stated — on a phone it is the only way that does not need a
|
||
// keyboard. Seeding it to the whole visible span means the first drag
|
||
// narrows from what the user is already looking at.
|
||
//
|
||
// Toggling off clears both ends rather than remembering them — a range you
|
||
// cannot see the extent of is a filter that looks like an empty library.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_toggle_date_range(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
// Whether the *controls* are showing, which is not the same as
|
||
// whether a range is set — and reading it from the filter was why
|
||
// pressing this did nothing. The fields are how a range gets set,
|
||
// so requiring one before they appear is a door locked from the
|
||
// inside.
|
||
let on = !w.get_library_range_active();
|
||
if on {
|
||
// Seeded from the span the axis is drawn over, which is what
|
||
// the user is looking at when they ask for "this range".
|
||
//
|
||
// Only a seed. If there is no catalog yet, or nothing in it
|
||
// carries a capture date — dates are read from EXIF as
|
||
// thumbnails load, so a freshly opened library has none — the
|
||
// fields simply open empty and wait to be typed into. This
|
||
// used to `return` in both cases, which meant the button
|
||
// silently did nothing on exactly the libraries where a person
|
||
// is most likely to be looking for a date.
|
||
let seeded = {
|
||
let borrow = ctl.catalog.borrow();
|
||
borrow
|
||
.as_ref()
|
||
.and_then(|catalog| catalog_span(catalog, &ctl))
|
||
.map(|span| {
|
||
zoomed_span(
|
||
span,
|
||
*ctl.timeline_zoom.borrow(),
|
||
*ctl.timeline_centre.borrow(),
|
||
)
|
||
})
|
||
};
|
||
if let Some(span) = seeded {
|
||
// Through the same days-inclusive conversion a drag uses,
|
||
// so a seeded range and a dragged one are the same kind of
|
||
// thing — and so `show_range` reads this one back out as
|
||
// the two days it covers.
|
||
let (from, to) = dragged_range(span, 0.0, 1.0);
|
||
let mut f = ctl.filter.borrow_mut();
|
||
f.captured_from = Some(from);
|
||
f.captured_to = Some(to);
|
||
}
|
||
} else {
|
||
let mut f = ctl.filter.borrow_mut();
|
||
f.captured_from = None;
|
||
f.captured_to = None;
|
||
}
|
||
w.set_library_range_active(on);
|
||
show_range(&w, &ctl);
|
||
refilter(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
// TRACES: FR-CAT-6 | FR-UI-2
|
||
// The ends of the range, dragged along the axis they were chosen from.
|
||
//
|
||
// This is the control the two typed fields were standing in for. On a
|
||
// phone they could not do the job: `YYYY-MM-DD` keyed into a 108px field
|
||
// behind a soft keyboard, to name a day already drawn on the axis a thumb
|
||
// away. Nor was the chip a way round them — its span comes from the
|
||
// timeline's zoom and pan, and those are a wheel and a middle button,
|
||
// neither of which a touch screen has. So on Android the date range was a
|
||
// filter that could be turned on and not aimed.
|
||
//
|
||
// The span is recomputed here rather than remembered from the last draw.
|
||
// It is the same expression `refresh_timeline` uses, over the same
|
||
// date-lifted extent, so a handle dropped at a fraction of the track names
|
||
// the instant that fraction was drawn at.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_timeline_range_changed(move |a, b| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let borrow = ctl.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
let Some(full) = catalog_span(catalog, &ctl) else {
|
||
return;
|
||
};
|
||
let span = zoomed_span(
|
||
full,
|
||
*ctl.timeline_zoom.borrow(),
|
||
*ctl.timeline_centre.borrow(),
|
||
);
|
||
drop(borrow);
|
||
|
||
let (from, to) = dragged_range(span, a, b);
|
||
{
|
||
let mut f = ctl.filter.borrow_mut();
|
||
f.captured_from = Some(from);
|
||
f.captured_to = Some(to);
|
||
}
|
||
|
||
// A dragged range cannot be mistyped, so any standing complaint
|
||
// about the fields is now about a range that no longer exists.
|
||
w.set_library_range_invalid(false);
|
||
w.set_library_range_active(true);
|
||
show_range(&w, &ctl);
|
||
// Reloads the window, and `load_window` redraws the timeline —
|
||
// which is what puts the band back under the finger that dropped
|
||
// it, at the whole days it was snapped to.
|
||
refilter(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
// TRACES: FR-CAT-6
|
||
// The ends of the range, typed.
|
||
//
|
||
// The chip alone used to be the whole control and it read its span from
|
||
// the timeline's zoom, which is zero until someone zooms — so "limit to
|
||
// range" set the range to the entire library and appeared to do nothing.
|
||
// The span is shown now, and these two fields are how it is corrected.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_range_edited(move |from, to| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
|
||
// Both ends, or neither. A range with one end parsed and the other
|
||
// silently dropped is a filter nobody asked for, and the grid
|
||
// going empty is a poor way to find out a date was mistyped.
|
||
let (Some(from), Some(to)) = (
|
||
dr_types::parse_date(from.as_str()),
|
||
dr_types::parse_date(to.as_str()),
|
||
) else {
|
||
w.set_library_range_invalid(true);
|
||
return;
|
||
};
|
||
|
||
// Typed the other way round is a slip, not an error: the user has
|
||
// said which two days they mean and there is exactly one range
|
||
// between them.
|
||
let (from, to) = if from <= to { (from, to) } else { (to, from) };
|
||
|
||
{
|
||
let mut f = ctl.filter.borrow_mut();
|
||
f.captured_from = Some(from);
|
||
// Inclusive of the last day. "To the 5th" means the whole of
|
||
// the 5th — `parse_date` returns its midnight, and a range
|
||
// ending there would exclude every photograph taken on the day
|
||
// the user named. The same rule `dragged_range` applies.
|
||
f.captured_to = Some(to + 86_400 - 1);
|
||
}
|
||
w.set_library_range_invalid(false);
|
||
w.set_library_range_active(true);
|
||
show_range(&w, &ctl);
|
||
refilter(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
// TRACES: FR-CAT-9
|
||
// "On this device" — the images openable without a server. Composes with
|
||
// the rating terms rather than replacing them: "five-star frames I can
|
||
// actually edit on this train" is one filter, not a mode.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_toggle_local_only(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let on = !ctl.local_only();
|
||
ctl.set_local_only(on);
|
||
ctl.filter.borrow_mut().local_only = on;
|
||
w.set_library_local_only(on);
|
||
refilter(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
// TRACES: FR-NC-6a
|
||
// The header's way in to the offline question, for the collection the grid
|
||
// is scoped to. It opens the same prompt the sidebar's tray and the long
|
||
// press open, rather than pinning outright: three affordances that did two
|
||
// different things — one asking, two acting — is how a user comes to avoid
|
||
// all three.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_toggle_pin_scope(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let Some(scope) = *ctl.scope.borrow() else {
|
||
return;
|
||
};
|
||
open_offline_prompt(&w, &ctl, scope);
|
||
});
|
||
}
|
||
|
||
// TRACES: FR-NC-6a | FR-UI-4
|
||
// The sidebar's way in: the tray on a row, tapped.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_collection_offline_menu(move |id| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
*ctl.row_hold_timer.borrow_mut() = None;
|
||
open_offline_prompt(&w, &ctl, dr_types::CollectionId(id as u64));
|
||
});
|
||
}
|
||
|
||
// TRACES: FR-NC-6a | FR-UI-2 | FR-UI-4
|
||
// And the touch way in: hold the collection's name.
|
||
//
|
||
// The release that ends the hold still reaches the row's `clicked` and
|
||
// scopes the grid to that collection. Left deliberately: the user is now
|
||
// looking at the photographs they are being asked about, which is context
|
||
// rather than a side effect — and suppressing it would mean a second
|
||
// "ignore the next click" flag threaded through the sidebar for no gain.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
let coll_for_press = coll_ctl.clone();
|
||
window.on_collection_row_press(move |id, down| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
// A drag of this row, if one follows, carries this collection.
|
||
if down {
|
||
coll_for_press.note_row_press(dr_types::CollectionId(id as u64));
|
||
}
|
||
if !down {
|
||
*ctl.row_hold_timer.borrow_mut() = None;
|
||
return;
|
||
}
|
||
|
||
let timer = slint::Timer::default();
|
||
let weak = w.as_weak();
|
||
let ctl_cb = ctl.clone();
|
||
timer.start(
|
||
slint::TimerMode::SingleShot,
|
||
std::time::Duration::from_millis(crate::collections_ui::HOLD_DELAY_MS),
|
||
move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
open_offline_prompt(&w, &ctl_cb, dr_types::CollectionId(id as u64));
|
||
},
|
||
);
|
||
*ctl.row_hold_timer.borrow_mut() = Some(timer);
|
||
});
|
||
}
|
||
|
||
// The three answers.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
let coll = coll_ctl.clone();
|
||
window.on_offline_prompt_keep(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
keep_collection_offline(&w, &ctl, &coll);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
let coll = coll_ctl.clone();
|
||
window.on_offline_prompt_release(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
release_collection_offline(&w, &ctl, &coll);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_offline_prompt_dismiss(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
close_offline_prompt(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
// TRACES: FR-CAT-9
|
||
// Retry now, rather than waiting out the backoff. A user who has just
|
||
// reconnected their wifi knows something the backoff does not.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
let coll_ctl = coll_ctl.clone();
|
||
window.on_library_retry_connection(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
log::info!("retrying the connection at the user's request");
|
||
// A rescan is the probe: it is the same request the scan worker
|
||
// makes, so a success proves reachability and repopulates the
|
||
// catalog in one pass rather than proving it twice.
|
||
start_rescan(&w, &ctl, &coll_ctl);
|
||
});
|
||
}
|
||
}
|
||
|
||
/// Reload the grid after the filter changed.
|
||
///
|
||
/// The offset is reset because it is an ordinal into the filtered set: keeping
|
||
/// it would land the user in the middle of a narrowed library with no sense of
|
||
/// how they got there, or past its end entirely.
|
||
/// Put the identity filter's people on the filter bar.
|
||
///
|
||
/// One chip each, so a selection of three is three things to look at and three
|
||
/// things to remove — a single chip saying "3 people" would be a filter the
|
||
/// user can only clear wholesale.
|
||
///
|
||
/// The label is the People screen's own wording, unnamed groups included, so
|
||
/// the same group does not read one way on one screen and another way on the
|
||
/// next.
|
||
fn push_people_chips(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
let ids = ctl.filter.borrow().people.clone();
|
||
if ids.is_empty() {
|
||
window
|
||
.set_library_filter_people(slint::ModelRc::new(slint::VecModel::from(
|
||
Vec::<PersonChip>::new(),
|
||
)));
|
||
return;
|
||
}
|
||
|
||
let borrow = ctl.catalog.borrow();
|
||
let people = borrow
|
||
.as_ref()
|
||
.and_then(|cat| dr_catalog::faces::people(cat.connection()).ok())
|
||
.unwrap_or_default();
|
||
|
||
let chips: Vec<PersonChip> = ids
|
||
.iter()
|
||
.map(|id| {
|
||
let name = people
|
||
.iter()
|
||
.find(|p| p.id.0 == *id)
|
||
.map(|p| {
|
||
if p.name.trim().is_empty() {
|
||
format!("Unnamed ({} faces)", p.confirmed_faces + p.suggested_faces)
|
||
} else {
|
||
p.name.clone()
|
||
}
|
||
})
|
||
// A person deleted from under the filter still needs a chip,
|
||
// or there would be no way to take them back out of it.
|
||
.unwrap_or_else(|| "Person".to_string());
|
||
PersonChip {
|
||
id: *id as i32,
|
||
name: name.into(),
|
||
}
|
||
})
|
||
.collect();
|
||
|
||
window.set_library_filter_people(slint::ModelRc::new(slint::VecModel::from(chips)));
|
||
window.set_library_filter_people_all(matches!(
|
||
ctl.filter.borrow().people_mode,
|
||
library::PeopleMode::All
|
||
));
|
||
}
|
||
|
||
fn refilter(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
*ctl.offset.borrow_mut() = 0;
|
||
ctl.requested.borrow_mut().clear();
|
||
load_window(window, ctl);
|
||
}
|
||
|
||
fn stop(slot: &RefCell<Option<slint::Timer>>) {
|
||
if let Some(t) = slot.borrow().as_ref() {
|
||
t.stop();
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn a_scrub_fraction_interpolates_within_the_span() {
|
||
// The point of going fractional: a slow drag must advance
|
||
// continuously rather than sitting still until it crosses a bucket
|
||
// edge and then jumping a whole month.
|
||
let span = (0, 1_000);
|
||
assert_eq!(instant_at(span, 0.0), 0);
|
||
assert_eq!(instant_at(span, 0.5), 500);
|
||
assert_eq!(instant_at(span, 1.0), 1_000);
|
||
// Distinct fractions inside one bucket must give distinct instants.
|
||
assert_ne!(instant_at(span, 0.10), instant_at(span, 0.11));
|
||
}
|
||
|
||
#[test]
|
||
fn the_marker_lands_on_the_fraction_that_was_clicked() {
|
||
// The bug this guards: the marker was placed from a *bar index* while
|
||
// the click was a fraction of the track, so it never appeared under
|
||
// the pointer. Marker and click must be inverses.
|
||
let span = (1_000, 9_000);
|
||
for f in [0.0_f32, 0.1, 0.25, 0.5, 0.75, 1.0] {
|
||
let round_trip = fraction_at(span, instant_at(span, f));
|
||
assert!(
|
||
(round_trip - f).abs() < 1e-3,
|
||
"clicked {f}, marker drawn at {round_trip}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn an_instant_outside_the_visible_span_pins_the_marker_to_an_end() {
|
||
// Zooming in leaves the grid's instant outside the axis. The marker
|
||
// belongs at the edge it went past, not off the widget.
|
||
let span = (1_000, 2_000);
|
||
assert_eq!(fraction_at(span, 0), 0.0);
|
||
assert_eq!(fraction_at(span, 5_000), 1.0);
|
||
}
|
||
|
||
#[test]
|
||
fn a_scrub_fraction_outside_the_axis_is_clamped() {
|
||
// A drag that leaves the widget still reports a position; it must land
|
||
// at an end rather than off the timeline.
|
||
let span = (100, 200);
|
||
assert_eq!(instant_at(span, -3.0), 100);
|
||
assert_eq!(instant_at(span, 9.9), 200);
|
||
}
|
||
|
||
/// A fortnight in March 2024, in whole days, as the axis would span it.
|
||
const MARCH: (i64, i64) = (1_709_251_200, 1_710_460_800);
|
||
|
||
#[test]
|
||
fn a_dragged_range_covers_whole_days_at_both_ends() {
|
||
// What makes a dragged range the same kind of thing as a typed one:
|
||
// `show_range` reads the closing day back out of the stored second, so
|
||
// an end left mid-afternoon would be shown as the day before.
|
||
let (from, to) = dragged_range(MARCH, 0.13, 0.61);
|
||
assert_eq!(day_start(from), from, "opens at a midnight");
|
||
assert_eq!(
|
||
day_start(to + 1),
|
||
to + 1,
|
||
"closes at the last second of a day"
|
||
);
|
||
assert_eq!(dr_types::format_date(from), "2024-03-02");
|
||
assert_eq!(dr_types::format_date(to - 86_400 + 1), "2024-03-09");
|
||
}
|
||
|
||
#[test]
|
||
fn ends_dragged_past_each_other_name_the_same_range() {
|
||
// Pulling the early end below the late one is how a range that was
|
||
// narrowed too far gets corrected; refusing it would make starting
|
||
// again the easier move.
|
||
assert_eq!(
|
||
dragged_range(MARCH, 0.6, 0.2),
|
||
dragged_range(MARCH, 0.2, 0.6)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn both_ends_dragged_together_name_one_day() {
|
||
// The floor the day snapping buys. Two handles on the same pixel is a
|
||
// gesture that happens; an empty grid is not what it means.
|
||
let (from, to) = dragged_range(MARCH, 0.5, 0.5);
|
||
assert_eq!(to - from, 86_399);
|
||
}
|
||
|
||
#[test]
|
||
fn the_band_is_drawn_where_the_handle_was_dropped() {
|
||
// The band is placed by `fraction_at` and the range comes from
|
||
// `instant_at`. If those two ever disagreed the band would settle
|
||
// somewhere other than where the finger let go, which reads as the
|
||
// drag having been ignored.
|
||
for f in [0.0_f32, 0.25, 0.5, 0.75, 1.0] {
|
||
let (from, _) = dragged_range(MARCH, f, f);
|
||
let drawn = fraction_at(MARCH, from);
|
||
// Within a day of the span, which is the snapping and nothing else.
|
||
let day = 86_400.0 / (MARCH.1 - MARCH.0) as f32;
|
||
assert!((drawn - f).abs() <= day, "dropped at {f}, drawn at {drawn}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn every_instant_on_the_axis_lands_in_a_bin() {
|
||
// The bar under a date and the bar that counted it must be the same
|
||
// bar: `bin_of` is the UI's copy of the division the SQL groups by.
|
||
let span = (1_000, 9_000);
|
||
assert_eq!(bin_of(span, 1_000, 8), 0);
|
||
assert_eq!(bin_of(span, 1_999, 8), 0);
|
||
assert_eq!(bin_of(span, 2_000, 8), 1);
|
||
// The last instant belongs to the last bin rather than to one past it.
|
||
assert_eq!(bin_of(span, 9_000, 8), 7);
|
||
}
|
||
|
||
#[test]
|
||
fn an_instant_off_the_zoomed_axis_lands_at_the_end_it_went_past() {
|
||
// Zooming in leaves the grid's position outside the span. The lit bar
|
||
// belongs at the edge, not at an index that does not exist.
|
||
let span = (1_000, 9_000);
|
||
assert_eq!(bin_of(span, -50_000, 8), 0);
|
||
assert_eq!(bin_of(span, 50_000, 8), 7);
|
||
// And a degenerate axis still answers with a bin.
|
||
assert_eq!(bin_of((5, 5), 5, 32), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn a_day_before_1970_starts_at_its_own_midnight() {
|
||
// `%` truncates towards zero, which on a negative timestamp names the
|
||
// *following* midnight — a scanned archive of negatives would have
|
||
// every range off by a day at the early end.
|
||
assert_eq!(day_start(-1), -86_400);
|
||
assert_eq!(day_start(-86_400), -86_400);
|
||
assert_eq!(day_start(-86_401), -172_800);
|
||
}
|
||
|
||
/// One pinch update, as the handler applies it: fold the ratio in, take
|
||
/// out whole doublings, carry the remainder.
|
||
fn pinch_step(accum: &mut f32, ratio: f32) -> i32 {
|
||
*accum *= ratio;
|
||
let steps = accum.log2().trunc() as i32;
|
||
if steps != 0 {
|
||
*accum /= (2.0f32).powi(steps);
|
||
}
|
||
steps
|
||
}
|
||
|
||
#[test]
|
||
fn a_pinch_accumulates_until_it_reaches_a_doubling() {
|
||
// A continuous gesture against discrete levels. Small spreads must
|
||
// accumulate rather than each being rounded to a step.
|
||
let mut accum = 1.0f32;
|
||
let mut steps = 0;
|
||
for _ in 0..12 {
|
||
steps += pinch_step(&mut accum, 1.1);
|
||
}
|
||
// 1.1^12 is 3.138x, which is log2 = 1.65 — one whole doubling, with
|
||
// the rest carried rather than discarded or rounded up.
|
||
assert_eq!(steps, 1);
|
||
assert!((accum - 1.569).abs() < 0.01, "remainder carried: {accum}");
|
||
}
|
||
|
||
#[test]
|
||
fn one_large_pinch_yields_every_level_it_crossed() {
|
||
// The bug this replaced: stepping at most once per update turned an
|
||
// 8x spread — three doublings — into a single zoom level, so a fast
|
||
// gesture lost most of its travel.
|
||
let mut accum = 1.0f32;
|
||
assert_eq!(pinch_step(&mut accum, 8.0), 3);
|
||
assert!((accum - 1.0).abs() < 0.001);
|
||
|
||
let mut accum = 1.0f32;
|
||
assert_eq!(pinch_step(&mut accum, 0.125), -3);
|
||
}
|
||
|
||
#[test]
|
||
fn pinching_in_and_back_out_returns_to_where_it_started() {
|
||
let mut accum = 1.0f32;
|
||
let steps: i32 = [2.0f32, 2.0, 0.5, 0.5]
|
||
.iter()
|
||
.map(|r| pinch_step(&mut accum, *r))
|
||
.sum();
|
||
assert_eq!(steps, 0);
|
||
assert!((accum - 1.0).abs() < 0.001, "no drift: {accum}");
|
||
}
|
||
|
||
#[test]
|
||
fn a_pinch_below_a_doubling_takes_no_step() {
|
||
// Otherwise the axis would flicker between levels on the smallest
|
||
// finger movement.
|
||
let mut accum = 1.0f32;
|
||
assert_eq!(pinch_step(&mut accum, 1.4), 0);
|
||
assert_eq!(pinch_step(&mut accum, 0.72), 0, "back roughly to 1.0");
|
||
}
|
||
|
||
/// One zoom step, as the handler applies it.
|
||
fn zoom_cell(current: f32, delta: i32) -> f32 {
|
||
let next = if delta > 0 {
|
||
current * 1.25
|
||
} else {
|
||
current / 1.25
|
||
};
|
||
next.clamp(MIN_CELL_SIZE, MAX_CELL_SIZE)
|
||
}
|
||
|
||
#[test]
|
||
fn cell_zoom_steps_geometrically_and_reverses() {
|
||
// Geometric so the gesture feels the same at either end; a fixed pixel
|
||
// step is imperceptible at 400px and violent at 90px.
|
||
let a = zoom_cell(180.0, 1);
|
||
assert!((a - 225.0).abs() < 0.01);
|
||
assert!(
|
||
(zoom_cell(a, -1) - 180.0).abs() < 0.01,
|
||
"in then out returns"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn cell_zoom_stays_within_its_bounds() {
|
||
let mut size = 180.0;
|
||
for _ in 0..40 {
|
||
size = zoom_cell(size, 1);
|
||
}
|
||
assert_eq!(size, MAX_CELL_SIZE);
|
||
|
||
for _ in 0..40 {
|
||
size = zoom_cell(size, -1);
|
||
}
|
||
assert_eq!(size, MIN_CELL_SIZE);
|
||
}
|
||
|
||
#[test]
|
||
fn zooming_past_the_grid_class_asks_for_the_large_one() {
|
||
use dr_thumbs::ThumbSize;
|
||
// The point of the second class: past 256px a grid thumbnail is being
|
||
// upscaled, and the softness shows.
|
||
assert_eq!(ThumbSize::for_cell(180), ThumbSize::Grid);
|
||
assert_eq!(
|
||
ThumbSize::for_cell(zoom_cell(225.0, 1) as u32),
|
||
ThumbSize::Large
|
||
);
|
||
// And zooming back down does not keep paying for it.
|
||
assert_eq!(
|
||
ThumbSize::for_cell(zoom_cell(281.0, -1) as u32),
|
||
ThumbSize::Grid
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_request_key_survives_the_window_moving() {
|
||
use dr_thumbs::ThumbSize;
|
||
use std::collections::HashSet;
|
||
|
||
// Keyed on the photograph, not its position. The grid is a window over
|
||
// the catalog, so row 7 is a different image after every scroll — a
|
||
// set of row indices had to be cleared on each move, and every visible
|
||
// cell then looked unrequested and was re-issued.
|
||
let mut requested: HashSet<(i64, ThumbSize)> = HashSet::new();
|
||
|
||
// A screenful at rows 0..3, holding images 100..103.
|
||
for id in 100..103 {
|
||
assert!(requested.insert((id, ThumbSize::Grid)), "first sight");
|
||
}
|
||
|
||
// Scrolled: the same photographs now occupy different rows.
|
||
for id in 100..103 {
|
||
assert!(
|
||
!requested.insert((id, ThumbSize::Grid)),
|
||
"image {id} must not be requested twice"
|
||
);
|
||
}
|
||
|
||
// A genuinely new photograph still is.
|
||
assert!(requested.insert((200, ThumbSize::Grid)));
|
||
}
|
||
|
||
#[test]
|
||
fn the_two_size_classes_are_requested_independently() {
|
||
use dr_thumbs::ThumbSize;
|
||
use std::collections::HashSet;
|
||
|
||
// Holding the 256px version says nothing about the large one, so
|
||
// zooming past the boundary must still ask.
|
||
let mut requested: HashSet<(i64, ThumbSize)> = HashSet::new();
|
||
assert!(requested.insert((1, ThumbSize::Grid)));
|
||
assert!(
|
||
requested.insert((1, ThumbSize::Large)),
|
||
"the large class is a separate request"
|
||
);
|
||
assert!(!requested.insert((1, ThumbSize::Grid)));
|
||
}
|
||
|
||
#[test]
|
||
fn zoom_zero_is_the_whole_library() {
|
||
let full = (1_000, 2_000);
|
||
assert_eq!(zoomed_span(full, 0, None), full);
|
||
// A remembered centre must not narrow the span at zoom 0.
|
||
assert_eq!(zoomed_span(full, 0, Some(1_500)), full);
|
||
}
|
||
|
||
#[test]
|
||
fn each_zoom_step_halves_the_span() {
|
||
let day = 86_400;
|
||
let full = (0, 64 * day);
|
||
let (a, b) = zoomed_span(full, 1, Some(32 * day));
|
||
assert_eq!(b - a, 32 * day);
|
||
let (a, b) = zoomed_span(full, 2, Some(32 * day));
|
||
assert_eq!(b - a, 16 * day);
|
||
}
|
||
|
||
#[test]
|
||
fn zooming_centres_on_the_given_instant() {
|
||
let day = 86_400;
|
||
let full = (0, 100 * day);
|
||
let (a, b) = zoomed_span(full, 1, Some(60 * day));
|
||
assert_eq!((a + b) / 2, 60 * day, "centred where asked");
|
||
}
|
||
|
||
#[test]
|
||
fn a_window_at_the_edge_slides_rather_than_shrinking() {
|
||
// Clamping both ends would silently halve the span near the start of
|
||
// the library, so the axis would show less detail there than in the
|
||
// middle for the same zoom level.
|
||
let day = 86_400;
|
||
let full = (0, 100 * day);
|
||
let (a, b) = zoomed_span(full, 1, Some(0));
|
||
assert_eq!(a, 0, "cannot start before the library does");
|
||
assert_eq!(b - a, 50 * day, "keeps its width");
|
||
|
||
let (a, b) = zoomed_span(full, 1, Some(100 * day));
|
||
assert_eq!(b, 100 * day);
|
||
assert_eq!(b - a, 50 * day);
|
||
}
|
||
|
||
#[test]
|
||
fn a_deep_zoom_does_not_collapse_to_nothing() {
|
||
// A zero-width span would make every bucket empty, which reads as a
|
||
// broken axis rather than a deep zoom.
|
||
let full = (0, 86_400);
|
||
let (a, b) = zoomed_span(full, 12, Some(43_200));
|
||
assert!(b > a, "span stays positive");
|
||
}
|
||
|
||
#[test]
|
||
fn a_single_instant_library_does_not_divide_by_zero() {
|
||
let full = (1_700_000_000, 1_700_000_000);
|
||
let (a, b) = zoomed_span(full, 5, None);
|
||
assert!(a <= b);
|
||
}
|
||
|
||
#[test]
|
||
fn month_names_cover_the_year_and_clamp() {
|
||
assert_eq!(month_name(1), "January");
|
||
assert_eq!(month_name(12), "December");
|
||
assert_eq!(month_abbrev(3), "Mar");
|
||
// A corrupt month must not panic the grid.
|
||
assert_eq!(month_name(0), "January");
|
||
assert_eq!(month_name(99), "December");
|
||
}
|
||
|
||
#[test]
|
||
fn civil_dates_round_trip_at_boundaries() {
|
||
// Era-based date maths is silently wrong at year and leap boundaries
|
||
// if the algorithm is transcribed slightly off, and the symptom is an
|
||
// image landing in the wrong histogram bucket.
|
||
assert_eq!(civil_from_unix(0), (1970, 1, 1, 0));
|
||
assert_eq!(civil_from_unix(1_786_285_800), (2026, 8, 9, 14));
|
||
// Leap day.
|
||
assert_eq!(civil_from_unix(1_709_164_800), (2024, 2, 29, 0));
|
||
// Last second of a year, and the first of the next.
|
||
assert_eq!(civil_from_unix(1_735_689_599), (2024, 12, 31, 23));
|
||
assert_eq!(civil_from_unix(1_735_689_600), (2025, 1, 1, 0));
|
||
}
|
||
|
||
#[test]
|
||
fn dates_before_the_epoch_do_not_wrap() {
|
||
// Scanned film carries capture dates well before 1970; a negative
|
||
// timestamp must floor rather than truncate toward zero.
|
||
let (y, _, _, _) = civil_from_unix(-1);
|
||
assert_eq!(y, 1969);
|
||
}
|
||
|
||
/// A bar is named after how long it is.
|
||
///
|
||
/// The axis no longer buckets by calendar unit — it cuts the visible span
|
||
/// into a fixed number of equal bins — so the unit survives only as a
|
||
/// vocabulary for *labelling* one. What this guards is that zooming in
|
||
/// never produces a coarser label: the complaint it came from was a
|
||
/// fortnight drawn as one column of a year-per-bar axis.
|
||
#[test]
|
||
fn a_bin_is_labelled_at_its_own_scale() {
|
||
use dr_catalog::Granularity;
|
||
const DAY: i64 = 86_400;
|
||
|
||
// Fifteen years across 32 bars is nearly six months a bar, which on a
|
||
// ratio measure is nearer a year than a month — so the bars are
|
||
// labelled by year, which is what a fifteen-year axis wants anyway.
|
||
assert_eq!(
|
||
Granularity::for_bucket(15 * 365 * DAY / 32),
|
||
Granularity::Year
|
||
);
|
||
|
||
// A fortnight across the same 32 bars is about ten hours a bar, which
|
||
// is nearer a day than an hour and is labelled as a date.
|
||
assert_eq!(Granularity::for_bucket(14 * DAY / 32), Granularity::Day);
|
||
|
||
// Zoom on until a bar is an hour or two, and it is named by the hour.
|
||
assert_eq!(Granularity::for_bucket(2 * 3600), Granularity::Hour);
|
||
|
||
// The property rather than the thresholds: a shorter bin never gets a
|
||
// coarser label than a longer one.
|
||
let mut previous = i64::MAX;
|
||
for days in [3650, 730, 180, 60, 14, 3, 1] {
|
||
let g = Granularity::for_bucket(days * DAY / 32);
|
||
let bucket = match g {
|
||
Granularity::Year => 365 * DAY,
|
||
Granularity::Month => 30 * DAY,
|
||
Granularity::Day => DAY,
|
||
Granularity::Hour => 3600,
|
||
};
|
||
assert!(
|
||
bucket <= previous,
|
||
"{days} days chose a coarser bin label than the span above it"
|
||
);
|
||
previous = bucket;
|
||
}
|
||
}
|
||
|
||
/// Deleting must not move the view somewhere the user did not ask to be.
|
||
///
|
||
/// The grid draws each cell at its absolute place in the library, and a
|
||
/// delete shortens the library *and* re-clamps the loaded window's offset.
|
||
/// Neither touches the viewport, so before this the grid went blank — the
|
||
/// view left pointing past the end of the content — or appeared to jump,
|
||
/// the same scroll position now addressing different photographs.
|
||
///
|
||
/// The anchor is the last *visible* ordinal the grid reported, clamped into
|
||
/// what is left — not the loaded window's `offset`, which sits about a
|
||
/// quarter of a screen above the view and is itself re-clamped by the
|
||
/// delete. Anchoring on that was the first attempt and still jumped.
|
||
#[test]
|
||
fn a_shorter_library_anchors_the_view_instead_of_losing_it() {
|
||
// The rule `restore_position` applies, stated where it can be checked.
|
||
let anchor = |offset: usize, total: usize| -> Option<usize> {
|
||
if total == 0 {
|
||
None
|
||
} else {
|
||
Some(offset.min(total - 1))
|
||
}
|
||
};
|
||
|
||
// The everyday case: one frame removed from the middle of a wall of
|
||
// twenty thousand. The view does not move — the photograph that slid
|
||
// into the gap is the one you want next.
|
||
assert_eq!(anchor(12_000, 19_999), Some(12_000));
|
||
|
||
// Near the end, which is where the blank came from: the view was
|
||
// showing ordinals past what now exists, so it lands on the last.
|
||
assert_eq!(anchor(19_990, 5), Some(4));
|
||
|
||
// Emptied entirely. Seeking into an empty library would be the same
|
||
// fault in the other direction, so there is nothing to do.
|
||
assert_eq!(anchor(500, 0), None);
|
||
|
||
// The boundary: an offset equal to the new total is one past the end.
|
||
assert_eq!(anchor(10, 10), Some(9));
|
||
}
|
||
|
||
#[test]
|
||
fn bucket_labels_match_their_granularity() {
|
||
use dr_catalog::Granularity;
|
||
let t = 1_786_285_800; // 2026-08-09 14:30 UTC
|
||
assert_eq!(format_bucket(t, Granularity::Year), "2026");
|
||
assert_eq!(format_bucket(t, Granularity::Month), "2026-08");
|
||
assert_eq!(format_bucket(t, Granularity::Day), "2026-08-09");
|
||
assert_eq!(format_bucket(t, Granularity::Hour), "2026-08-09 14:00");
|
||
}
|
||
|
||
#[test]
|
||
fn rgba_shorter_than_declared_does_not_panic() {
|
||
// A truncated decode must degrade to a partial image, not abort the
|
||
// grid. Decoders handle untrusted input (NFR-SEC-1).
|
||
let img = to_slint_image(4, 4, &[0u8; 8]);
|
||
assert_eq!(img.size().width, 4);
|
||
}
|
||
|
||
#[test]
|
||
fn rgba_longer_than_declared_is_truncated() {
|
||
let img = to_slint_image(2, 2, &[255u8; 1024]);
|
||
assert_eq!(img.size().width, 2);
|
||
assert_eq!(img.size().height, 2);
|
||
}
|
||
|
||
// --- moving the loaded window ------------------------------------------
|
||
//
|
||
// A screenful of 120 cells, four of them loaded, out of 24,000.
|
||
|
||
const ON_SCREEN: usize = 120;
|
||
const W: usize = ON_SCREEN * SCREENFULS;
|
||
const TOTAL: usize = 24_000;
|
||
|
||
/// Where the window lands for a view at `first_visible`, as the scroll
|
||
/// handler would leave it.
|
||
fn settle(first_visible: usize) -> usize {
|
||
window_start(first_visible, W, TOTAL)
|
||
}
|
||
|
||
#[test]
|
||
fn a_view_well_inside_the_loaded_window_does_not_move_it() {
|
||
// The whole point of loading screenfuls either side: scrolling within
|
||
// them must not touch the catalog.
|
||
let at = settle(5_000);
|
||
assert_eq!(window_move(5_000, at, W, ON_SCREEN, TOTAL), None);
|
||
assert_eq!(window_move(5_100, at, W, ON_SCREEN, TOTAL), None);
|
||
}
|
||
|
||
#[test]
|
||
fn the_whole_view_stays_inside_the_loaded_window() {
|
||
// The bug this rule exists for: the test used to ask only where the
|
||
// *first* visible cell was, so the window sat still while the bottom
|
||
// of the view hung a quarter of a screenful past the last loaded cell
|
||
// — rows the model does not hold and the grid therefore draws blank.
|
||
let mut at = settle(0);
|
||
for first_visible in (0..TOTAL - ON_SCREEN).step_by(10) {
|
||
if let Some(moved) = window_move(first_visible, at, W, ON_SCREEN, TOTAL) {
|
||
at = moved;
|
||
}
|
||
assert!(
|
||
first_visible >= at && first_visible + ON_SCREEN <= at + W,
|
||
"view {first_visible}..{} is not covered by the window {at}..{}",
|
||
first_visible + ON_SCREEN,
|
||
at + W
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn scrolling_back_a_row_does_not_reload() {
|
||
// The mirror fault, and the more expensive one: the window is placed a
|
||
// quarter of itself behind the view, and the old margin declared the
|
||
// view too close to the top at exactly that distance. So every single
|
||
// row scrolled upward re-read the catalog and rebuilt every cell.
|
||
let at = settle(5_200);
|
||
for row in 1..=4 {
|
||
let first_visible = 5_200 - row * 10;
|
||
assert_eq!(
|
||
window_move(first_visible, at, W, ON_SCREEN, TOTAL),
|
||
None,
|
||
"row {first_visible} reloaded the window it was already inside"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_view_reaching_the_edge_of_the_loaded_window_moves_it() {
|
||
// Far enough down that scrolling on would run into rows nobody has
|
||
// read.
|
||
let at = settle(5_000);
|
||
let far = at + W - ON_SCREEN;
|
||
let moved = window_move(far, at, W, ON_SCREEN, TOTAL).expect("the window follows the view");
|
||
assert_eq!(moved, far - W / 4, "placed a quarter behind the view");
|
||
}
|
||
|
||
#[test]
|
||
fn the_window_moves_about_once_a_screenful() {
|
||
// Too eager is a stutter under the finger, so the rule is checked from
|
||
// both sides: the view must cross most of a screenful between reloads.
|
||
let mut at = settle(0);
|
||
let mut last = 0;
|
||
let mut gaps = Vec::new();
|
||
for first_visible in (0..12_000).step_by(10) {
|
||
if let Some(moved) = window_move(first_visible, at, W, ON_SCREEN, TOTAL) {
|
||
at = moved;
|
||
gaps.push(first_visible - last);
|
||
last = first_visible;
|
||
}
|
||
}
|
||
assert!(
|
||
gaps.iter().skip(1).all(|g| *g >= ON_SCREEN),
|
||
"reloaded after less than a screenful of travel: {gaps:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn the_top_of_the_library_is_not_reloaded_on_every_row() {
|
||
// `first_visible` cannot be placed further back than zero, so the
|
||
// margin test can never be satisfied here. Before this rule every one
|
||
// of these re-read the catalog to arrive at the offset it already had,
|
||
// which is the stutter at the top of every scope.
|
||
for first_visible in [0, 6, 30, 89] {
|
||
assert_eq!(
|
||
window_move(first_visible, 0, W, ON_SCREEN, TOTAL),
|
||
None,
|
||
"row {first_visible} asked for a move to offset 0, which is where it is"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_end_of_the_library_is_not_reloaded_on_every_row() {
|
||
// The mirror of the above, and the worse of the two: the window is
|
||
// clamped to `max_offset` while the view keeps travelling past it.
|
||
let pinned = TOTAL - W;
|
||
for first_visible in [TOTAL - W / 2, TOTAL - 30, TOTAL - 1] {
|
||
assert_eq!(
|
||
window_move(first_visible, pinned, W, ON_SCREEN, TOTAL),
|
||
None,
|
||
"row {first_visible} asked for a move to the offset it already had"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_window_never_starts_past_the_last_full_screenful() {
|
||
// Otherwise a scrub to the very end loads a handful of cells and the
|
||
// rest of the window addresses images that do not exist.
|
||
let moved =
|
||
window_move(TOTAL - 1, 0, W, ON_SCREEN, TOTAL).expect("a scrub to the end moves");
|
||
assert_eq!(moved, TOTAL - W);
|
||
}
|
||
|
||
#[test]
|
||
fn a_library_smaller_than_the_window_stays_at_the_beginning() {
|
||
// `max_offset` is zero, so there is one valid position and the view
|
||
// must never ask for another.
|
||
assert_eq!(window_move(0, 0, W, ON_SCREEN, 40), None);
|
||
assert_eq!(window_move(39, 0, W, ON_SCREEN, 40), None);
|
||
}
|
||
|
||
// --- which cells are fetched first -------------------------------------
|
||
|
||
#[test]
|
||
fn the_visible_cells_are_fetched_before_anything_else() {
|
||
// The window holds a quarter of itself above the view, and in model
|
||
// order those cells — which nobody is looking at — were fetched first,
|
||
// ahead of the whole screen.
|
||
let window: Vec<usize> = (0..W).collect();
|
||
let first_on_screen = W / 4;
|
||
let mut order = window.clone();
|
||
order.sort_by_key(|row| fetch_rank(*row, first_on_screen, ON_SCREEN));
|
||
|
||
assert_eq!(
|
||
&order[..ON_SCREEN],
|
||
&window[first_on_screen..first_on_screen + ON_SCREEN],
|
||
"the screen is not fetched first, in reading order"
|
||
);
|
||
// The bottom row of the grid — the one reported missing — comes before
|
||
// every offscreen cell rather than after all of them.
|
||
let bottom = first_on_screen + ON_SCREEN - 1;
|
||
assert!(
|
||
order.iter().position(|r| *r == bottom).unwrap() < ON_SCREEN,
|
||
"the bottom row of the grid is still behind offscreen cells"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn offscreen_cells_are_fetched_nearest_first_below_before_above() {
|
||
let first_on_screen = W / 4;
|
||
let past = first_on_screen + ON_SCREEN;
|
||
// The row just below the view beats the row just above it, and both
|
||
// beat rows further out on their own side.
|
||
assert!(
|
||
fetch_rank(past, first_on_screen, ON_SCREEN)
|
||
< fetch_rank(first_on_screen - 1, first_on_screen, ON_SCREEN)
|
||
);
|
||
assert!(
|
||
fetch_rank(past, first_on_screen, ON_SCREEN)
|
||
< fetch_rank(past + 1, first_on_screen, ON_SCREEN)
|
||
);
|
||
assert!(
|
||
fetch_rank(first_on_screen - 1, first_on_screen, ON_SCREEN)
|
||
< fetch_rank(0, first_on_screen, ON_SCREEN)
|
||
);
|
||
}
|
||
|
||
// --- carrying thumbnails across a reload -------------------------------
|
||
//
|
||
// The black flash: `load_window` rebuilds every row, and until these it
|
||
// rebuilt them empty — so the three quarters of the grid the user was
|
||
// looking at went to `Theme.ground` and back on every scroll.
|
||
|
||
/// A model of cells, `has_thumb` set for the ids named.
|
||
fn model_of(ids: &[i64], with_pixels: &[i64]) -> slint::ModelRc<LibraryCell> {
|
||
let rows: Vec<LibraryCell> = ids
|
||
.iter()
|
||
.map(|id| LibraryCell {
|
||
has_thumb: with_pixels.contains(id),
|
||
thumbnail: if with_pixels.contains(id) {
|
||
to_slint_image(2, 2, &[255u8; 16])
|
||
} else {
|
||
slint::Image::default()
|
||
},
|
||
..Default::default()
|
||
})
|
||
.collect();
|
||
slint::ModelRc::new(slint::VecModel::from(rows))
|
||
}
|
||
|
||
#[test]
|
||
fn a_reload_keeps_the_pixels_of_photographs_that_are_still_in_the_window() {
|
||
let ids = [10i64, 11, 12];
|
||
let previous = model_of(&ids, &[10, 12]);
|
||
let classes = vec![Some(dr_thumbs::ThumbSize::Grid); 3];
|
||
|
||
let held = hold_thumbnails(&previous, &ids, &classes);
|
||
|
||
assert!(held.contains_key(&10), "a drawn cell is carried");
|
||
assert!(held.contains_key(&12));
|
||
assert!(
|
||
!held.contains_key(&11),
|
||
"a cell still waiting has nothing to carry"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_no_preview_verdict_is_carried_too() {
|
||
// Otherwise every scroll re-asks a question the server has already
|
||
// answered, and the cell blinks from "no preview" back to "…".
|
||
let ids = [7i64];
|
||
let rows = vec![LibraryCell {
|
||
unavailable: true,
|
||
..Default::default()
|
||
}];
|
||
let previous = slint::ModelRc::new(slint::VecModel::from(rows));
|
||
|
||
let held = hold_thumbnails(&previous, &ids, &[Some(dr_thumbs::ThumbSize::Grid)]);
|
||
assert!(held[&7].unavailable);
|
||
assert!(!held[&7].has_thumb);
|
||
}
|
||
|
||
#[test]
|
||
fn a_carried_cell_is_not_fetched_again_but_a_newly_scrolled_in_one_is() {
|
||
// The scroll this describes: the window moved down by one image, so
|
||
// 11 and 12 are still on screen and 13 has just arrived.
|
||
let ids = [10i64, 11, 12];
|
||
let previous = model_of(&ids, &[10, 11, 12]);
|
||
let held = hold_thumbnails(&previous, &ids, &[Some(dr_thumbs::ThumbSize::Grid); 3]);
|
||
|
||
let served = already_served(&held, [11i64, 12, 13].into_iter());
|
||
|
||
assert!(served.contains(&(11, dr_thumbs::ThumbSize::Grid)));
|
||
assert!(served.contains(&(12, dr_thumbs::ThumbSize::Grid)));
|
||
assert!(
|
||
!served.contains(&(13, dr_thumbs::ThumbSize::Grid)),
|
||
"a photograph the window has just reached must still be fetched"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_photograph_scrolled_out_of_the_window_is_fetched_again_on_return() {
|
||
// The trap in keeping the set rather than rebuilding it: image 10 was
|
||
// served once, but the model that held its pixels is long gone, so the
|
||
// cell would sit blank for ever if it still counted as served.
|
||
let held = hold_thumbnails(
|
||
&model_of(&[10], &[10]),
|
||
&[10],
|
||
&[Some(dr_thumbs::ThumbSize::Grid)],
|
||
);
|
||
|
||
let served = already_served(&held, [40i64, 41].into_iter());
|
||
assert!(served.is_empty(), "nothing in this window is already drawn");
|
||
}
|
||
|
||
#[test]
|
||
fn a_carried_thumbnail_does_not_satisfy_a_zoom_past_its_class() {
|
||
// Zooming past 256px reloads the window. The cell keeps showing the
|
||
// small thumbnail — no flash — but the large one must still be asked
|
||
// for, or the grid would stay soft until something else forced a fetch.
|
||
let held = hold_thumbnails(
|
||
&model_of(&[5], &[5]),
|
||
&[5],
|
||
&[Some(dr_thumbs::ThumbSize::Grid)],
|
||
);
|
||
let mut served = already_served(&held, [5i64].into_iter());
|
||
|
||
assert!(
|
||
served.insert((5, dr_thumbs::ThumbSize::Large)),
|
||
"the large class is still unserved"
|
||
);
|
||
assert!(
|
||
!served.insert((5, dr_thumbs::ThumbSize::Grid)),
|
||
"and the small one is not asked for twice"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_cell_whose_class_was_never_recorded_is_fetched_again() {
|
||
// Pixels with no class are pixels from before this bookkeeping existed
|
||
// — or from a row the drain never reached. Showing them is right;
|
||
// claiming they were served is not, because nothing knows at what size.
|
||
let held = hold_thumbnails(&model_of(&[5], &[5]), &[5], &[None]);
|
||
assert!(held.contains_key(&5), "still drawn");
|
||
assert!(already_served(&held, [5i64].into_iter()).is_empty());
|
||
}
|
||
|
||
/// A thumbnail drain must be able to tell that the window it was started
|
||
/// for has been replaced.
|
||
///
|
||
/// This is the whole of the black-grid fix, reduced to the comparison the
|
||
/// timer callback makes. `row` is an index into the window that requested
|
||
/// the fetch, so a drain that keeps writing after a reload paints
|
||
/// thumbnails onto unrelated photographs — and, worse, its `stop` lands on
|
||
/// the *current* batch's timer and leaves the new fetches undrained.
|
||
#[test]
|
||
fn a_reload_makes_an_in_flight_thumbnail_batch_stale() {
|
||
let ctl = LibraryController::new(crate::activity::ActivityLog::new());
|
||
|
||
// What `drain_thumbnails` captures when the batch is spawned.
|
||
let mine = ctl.generation.get();
|
||
assert_eq!(ctl.generation.get(), mine, "its own batch is live");
|
||
|
||
// What `load_window` does just before swapping the model.
|
||
ctl.generation.set(ctl.generation.get().wrapping_add(1));
|
||
|
||
assert_ne!(
|
||
ctl.generation.get(),
|
||
mine,
|
||
"the batch must recognise itself as stale once the model is replaced"
|
||
);
|
||
}
|
||
|
||
/// Each load is distinct, so two reloads cannot alias back to a live batch.
|
||
#[test]
|
||
fn every_window_load_takes_a_fresh_generation() {
|
||
let ctl = LibraryController::new(crate::activity::ActivityLog::new());
|
||
let seen: Vec<u64> = (0..4)
|
||
.map(|_| {
|
||
let g = ctl.generation.get();
|
||
ctl.generation.set(g.wrapping_add(1));
|
||
g
|
||
})
|
||
.collect();
|
||
|
||
assert_eq!(seen, vec![0, 1, 2, 3]);
|
||
}
|
||
|
||
/// A controller holding a catalog of four named images.
|
||
fn with_catalog() -> Rc<LibraryController> {
|
||
let ctl = LibraryController::new(crate::activity::ActivityLog::new());
|
||
let catalog = Catalog::in_memory().expect("in-memory catalog");
|
||
let c = catalog.connection();
|
||
c.execute(
|
||
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')",
|
||
[],
|
||
)
|
||
.expect("root");
|
||
for (id, name) in [(1i64, "a.CR2"), (2, "b.CR2"), (3, "c.CR2"), (4, "d.CR2")] {
|
||
c.execute(
|
||
"INSERT INTO images(id, root_id, source_ref, added_at) VALUES (?1, 1, ?2, 0)",
|
||
rusqlite::params![id, name],
|
||
)
|
||
.expect("image");
|
||
}
|
||
*ctl.catalog.borrow_mut() = Some(catalog);
|
||
ctl
|
||
}
|
||
|
||
/// Just the names, for assertions that are about order rather than ids.
|
||
fn named(ctl: &Rc<LibraryController>, images: &[dr_types::ImageId]) -> Vec<String> {
|
||
ctl.selected_image_paths(images)
|
||
.into_iter()
|
||
.map(|(_, path)| path)
|
||
.collect()
|
||
}
|
||
|
||
/// TRACES: FR-EXP-7
|
||
#[test]
|
||
fn a_selection_resolves_to_paths_in_the_order_it_was_given() {
|
||
// `IN (…)` returns rows in whatever order suits SQLite, and an export's
|
||
// `{seq}` token counts through the batch — so a lookup that handed back
|
||
// the database's order would number the photographs in an order the
|
||
// user never saw.
|
||
let ctl = with_catalog();
|
||
let paths = named(&ctl, &[dr_types::ImageId(3), dr_types::ImageId(1)]);
|
||
assert_eq!(paths, vec!["c.CR2", "a.CR2"]);
|
||
}
|
||
|
||
// --- what the status line says about a keyword (FR-CAT-5) -------------
|
||
//
|
||
// Split out from the callback for the same reason `decide_drop` is: the
|
||
// sheet cannot be driven from a test, and this is the part that can
|
||
// actually mislead someone.
|
||
|
||
/// TRACES: FR-CAT-5
|
||
#[test]
|
||
fn a_partly_applied_keyword_reports_the_honest_count() {
|
||
// Nine of the twelve already had it. Claiming twelve is how a user
|
||
// learns that the counts are decorative.
|
||
assert_eq!(
|
||
keyword_summary("puffin", 3, 12, true),
|
||
"Added “puffin” to 3 of 12"
|
||
);
|
||
}
|
||
|
||
/// TRACES: FR-CAT-5
|
||
#[test]
|
||
fn a_keyword_that_changed_nothing_says_so_rather_than_claiming_success() {
|
||
assert_eq!(
|
||
keyword_summary("puffin", 0, 12, true),
|
||
"Every selected photograph already had “puffin”"
|
||
);
|
||
assert_eq!(
|
||
keyword_summary("puffin", 0, 12, false),
|
||
"None of the selected photographs had “puffin”"
|
||
);
|
||
}
|
||
|
||
/// TRACES: FR-CAT-5
|
||
#[test]
|
||
fn one_photograph_is_singular() {
|
||
// "Added to 1 photographs" is the kind of small wrongness that makes
|
||
// the rest of the interface look unfinished.
|
||
assert_eq!(
|
||
keyword_summary("puffin", 1, 1, true),
|
||
"Added “puffin” to 1 photograph"
|
||
);
|
||
assert_eq!(
|
||
keyword_summary("puffin", 2, 2, true),
|
||
"Added “puffin” to 2 photographs"
|
||
);
|
||
}
|
||
|
||
/// TRACES: FR-CAT-5
|
||
#[test]
|
||
fn removing_a_keyword_reads_as_removal() {
|
||
assert_eq!(
|
||
keyword_summary("blurry", 4, 4, false),
|
||
"Removed “blurry” from 4 photographs"
|
||
);
|
||
}
|
||
|
||
/// TRACES: FR-CAT-5
|
||
#[test]
|
||
fn typing_a_word_with_nothing_selected_says_what_it_did_do() {
|
||
// It builds the vocabulary, which is a legitimate thing to do ahead of
|
||
// a shoot — so it must not report itself as having keyworded nothing.
|
||
assert_eq!(
|
||
keyword_summary("puffin", 0, 0, true),
|
||
"Added “puffin” to the keyword list"
|
||
);
|
||
}
|
||
|
||
/// TRACES: FR-EXP-7
|
||
#[test]
|
||
fn a_selection_outside_the_loaded_window_still_resolves() {
|
||
// The property the catalog lookup exists for. Selection is by id and
|
||
// survives a scrub, so a selection made before scrolling routinely
|
||
// names photographs no row holds — and `paths`, the loaded window, is
|
||
// empty here precisely to prove nothing is being read from it.
|
||
let ctl = with_catalog();
|
||
assert!(ctl.paths.borrow().is_empty());
|
||
assert_eq!(named(&ctl, &[dr_types::ImageId(4)]), ["d.CR2"]);
|
||
}
|
||
|
||
/// TRACES: FR-EXP-7
|
||
#[test]
|
||
fn an_image_that_vanished_under_the_selection_is_skipped() {
|
||
// Deleted between the selection and the click. There is nothing to
|
||
// export and nothing to fix, so it drops out rather than becoming a
|
||
// failure row the user can do nothing about.
|
||
let ctl = with_catalog();
|
||
let paths = named(
|
||
&ctl,
|
||
&[
|
||
dr_types::ImageId(1),
|
||
dr_types::ImageId(99),
|
||
dr_types::ImageId(2),
|
||
],
|
||
);
|
||
assert_eq!(paths, vec!["a.CR2", "b.CR2"]);
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-UI-2
|
||
/// A filename as a caption: without the part that says how it is stored.
|
||
///
|
||
/// A grid cell is about four words wide, and `.CR2` spends one of them saying
|
||
/// something the photographer already knows — every frame in a RAW library
|
||
/// ends the same way, so the extension distinguishes nothing while taking
|
||
/// room from the part that does. The name is elided under pressure, and it is
|
||
/// the *end* that goes, so an extension can push the digits that identify a
|
||
/// frame off the visible part of its own label.
|
||
///
|
||
/// Display only. `LibraryCell::name` keeps the true filename and
|
||
/// `remote_path` the full path, because both are used to find the file again
|
||
/// and a stem is not a filename.
|
||
///
|
||
/// **The case this is wrong for**, worth knowing before it is reported: a
|
||
/// library holding `IMG_1234.CR2` beside `IMG_1234.JPG` shows two cells
|
||
/// captioned `IMG_1234`. They are still two rows with two thumbnails and two
|
||
/// entries in the info panel, and RAW+JPEG pairs are usually shot to be one
|
||
/// photograph anyway — but the caption alone no longer separates them.
|
||
fn without_extension(name: &str) -> &str {
|
||
match name.rsplit_once('.') {
|
||
// The guard is for a leading dot: `.hidden` splits to an empty stem,
|
||
// and a dotfile's name starts with that dot rather than ending with an
|
||
// extension. A *trailing* dot needs no guard — `odd.` splits to `odd`,
|
||
// which is the better caption anyway.
|
||
Some((stem, _)) if !stem.is_empty() => stem,
|
||
_ => name,
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod display_name_tests {
|
||
use super::without_extension;
|
||
|
||
#[test]
|
||
fn an_extension_is_dropped_and_nothing_else_is() {
|
||
assert_eq!(without_extension("IMG_1234.CR2"), "IMG_1234");
|
||
assert_eq!(without_extension("IMG_1234.jpeg"), "IMG_1234");
|
||
// Only the last one: a name can contain dots and they are part of it.
|
||
assert_eq!(without_extension("2026.08.23-a.dng"), "2026.08.23-a");
|
||
// Nothing to drop.
|
||
assert_eq!(without_extension("IMG_1234"), "IMG_1234");
|
||
// A dotfile is not an extensionless name with an extension.
|
||
assert_eq!(without_extension(".hidden"), ".hidden");
|
||
// A trailing dot is an empty extension, and dropping it is right.
|
||
assert_eq!(without_extension("odd."), "odd");
|
||
assert_eq!(without_extension(""), "");
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-CAT-6
|
||
/// Show the range the filter is actually using.
|
||
///
|
||
/// Pushed back rather than left as the user typed it, because the filter may
|
||
/// have adjusted it — ends given backwards are swapped, and the closing day is
|
||
/// extended to include itself. A field showing something other than what is
|
||
/// being filtered on is worse than one showing nothing.
|
||
fn show_range(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
let f = ctl.filter.borrow();
|
||
let text = |t: Option<i64>| -> slint::SharedString {
|
||
t.map(dr_types::format_date).unwrap_or_default().into()
|
||
};
|
||
window.set_library_range_from(text(f.captured_from));
|
||
// The stored end is the last second of the closing day; naming that day is
|
||
// what the user typed and what they should read back.
|
||
window.set_library_range_to(text(f.captured_to.map(|t| t - 86_400 + 1)));
|
||
}
|