//! TRACES: FR-CAT-4 | FR-NC-3 | NFR-P9 //! Drives the library grid from scan and thumbnail workers. //! //! Owns the bridge between three background activities and one single-threaded //! event loop: //! //! - a **scan** worker walking the remote tree into the catalog //! - a **thumbnail** worker range-fetching previews for visible cells //! - the **grid model** Slint renders //! //! Nothing here blocks. Workers post through mpsc channels drained by Slint //! timers, which is the same shape [`crate::launch_ui`] uses for login. use std::cell::RefCell; use std::path::PathBuf; use std::rc::Rc; use std::sync::mpsc::Receiver; use dr_catalog::Catalog; use dr_sync_nextcloud::{AppCredentials, Session, SessionStore}; use dr_types::FormatFilter; use slint::{ComponentHandle, Model as _}; use crate::library::{self, ScanMessage, ThumbnailMessage}; use crate::{AppWindow, KeywordRow, LibraryCell, TimelineBar}; /// 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. #[derive(Clone, Copy, PartialEq, Eq)] struct LibraryFacts { scope: Option, 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>>, /// Remote paths for the rows currently in the model, parallel to it. paths: RefCell>, /// `oc:fileid` and file length per row, parallel to the model. file_ids: RefCell>>, sizes: RefCell>, /// Catalog row ids and whether each still needs its EXIF read. image_ids: RefCell>, needs_metadata: RefCell>, /// 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>>, /// 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>>, /// Where in the catalog the current window starts. Scrubbing moves this. offset: RefCell, /// 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, /// 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, /// 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: std::cell::Cell>, /// 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, /// 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>, scan_timer: RefCell>, thumb_timer: RefCell>, /// The whole-library sweep, which outlives any one grid window. sweep_timer: RefCell>, /// 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>, /// Pushing shards and the catalog to the server. sync_timer: RefCell>, /// Kept so a rescan can run without going back through the launch screen. session: RefCell>, /// 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>, /// 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, /// 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>>, /// 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, timeline_centre: RefCell>, /// 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, /// 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>, /// 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, /// 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>, /// 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, /// Drains the sidecar writer. Held so a second judgement replaces the /// timer rather than leaving two draining the same finished channel. sidecar_timer: RefCell>, /// 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, /// 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, /// 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>, /// 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>, pin_timer: RefCell>, /// 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>, /// 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>, /// 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, /// 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, /// 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, /// 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, /// 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, } impl LibraryController { pub fn new(activity: Rc) -> Rc { 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: std::cell::Cell::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) { 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 { 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 { 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 { let borrow = self.session.borrow(); let (_, session, _) = borrow.as_ref()?; library::catalog_path(&session.server, &session.user_id) .parent() .map(|p| p.join("sidecars")) } /// TRACES: FR-NC-6a /// Where cached originals live for the open library. /// /// Beside the catalog rather than under a system cache directory: the two /// are per-account and are discarded together, and a cached original whose /// catalog row is gone is unreachable anyway. pub fn cache_dir(&self) -> Option { let borrow = self.session.borrow(); let (_, session, _) = borrow.as_ref()?; library::catalog_path(&session.server, &session.user_id) .parent() .map(|p| p.join("originals")) } /// Open the originals cache for the current library. /// /// Used by pinning, which writes intent against the catalog directly /// rather than going through a fetch. pub fn cache(&self) -> Option { 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 { 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 { let borrow = self.session.borrow(); let (_, session, _) = borrow.as_ref()?; let catalog_path = library::catalog_path(&session.server, &session.user_id); let dir = catalog_path.parent()?.join("originals"); drop(borrow); Some(library::CacheContext { dir, catalog_path, image, // The user's ceiling, not the catalog's floor: read at each fetch // so a budget changed mid-session takes effect on the next one. budget: self.cache_budget.get(), // Gates the *write* only. Reading stays enabled either way: bytes // already on disk were paid for, and refusing to use them because // the user has since stopped adding new ones would re-download // images that are sitting right there — and would make a pinned // collection unopenable offline. store: self.keep_opened.get(), }) } /// Toggle the local-only filter, resetting the window. /// /// The offset is cleared for the same reason a scope change clears it: the /// filter changes which images exist as far as the grid is concerned, so a /// position counted against the old set names a different photograph. pub fn set_local_only(&self, on: bool) { self.local_only.set(on); *self.offset.borrow_mut() = 0; self.requested.borrow_mut().clear(); } /// The catalog handle, for [`crate::collections_ui`] to edit through. pub fn catalog(&self) -> Rc>> { self.catalog.clone() } /// Credentials and session for the open library. /// /// Needed by the trash, whose `MOVE` and `DELETE` go to the same account the /// scan and thumbnail workers use. `None` before a library is opened. pub fn session(&self) -> Option<(AppCredentials, Session)> { self.session .borrow() .as_ref() .map(|(c, s, _)| (c.clone(), s.clone())) } /// Catalog ids of the rows currently in the model, in model order. /// /// Selection is keyed on these rather than on row indices: the grid is a /// window over the catalog and a scrub replaces every row, so an index /// would silently come to mean a different photograph. pub fn visible_ids(&self) -> Vec { 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 { 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::>() .join(","); let sql = format!("SELECT id, source_ref FROM images WHERE id IN ({placeholders})"); let params: Vec = 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 = 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 { self.selected_image_paths(images) .into_iter() .map(|(id, path)| crate::export::Source::Library { path, cache: self.cache_context_for(id), }) .collect() } /// Credentials and account for the open library, if one is open. /// /// What a full-file fetch needs: the grid's paths are remote, so opening /// an image means downloading it, and that needs the same session the /// thumbnail workers use. pub fn credentials(&self) -> Option<(AppCredentials, String)> { self.session .borrow() .as_ref() .map(|(creds, session, _)| (creds.clone(), session.user_id.clone())) } /// Narrow the grid to a collection, or to the whole library with `None`. pub fn set_scope(&self, scope: Option) { *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) { 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, coll_ctl: Rc, store: &SessionStore, session: Session, ) { let creds = match store.credentials(&session) { Ok(c) => c, Err(e) => { window.set_library_error(format!("credentials: {e}").into()); window.set_show_library(true); return; } }; let filter = session.format_filter(); *ctl.session.borrow_mut() = Some((creds.clone(), session.clone(), filter.clone())); window.set_show_library(true); window.set_library_open(true); window.set_library_scanning(true); window.set_library_error(slint::SharedString::new()); window.set_library_status("Starting…".into()); // Always visible: two folders one letter apart are easy to confuse, and a // scan of the wrong one is indistinguishable from a broken scan. window.set_library_root_label( if session.root.is_empty() { format!("{} · whole account", session.user_id) } else { format!("{}/{}", session.user_id, session.root) } .into(), ); // An empty filter would walk the whole tree and match nothing, which looks // exactly like a broken scan. Say so instead. if filter.is_empty() { window.set_library_scanning(false); window.set_library_error("No formats selected — tick at least one.".into()); return; } let path = library::catalog_path(&session.server, &session.user_id); log::info!( "scanning {} for {} format(s) → {}", if session.root.is_empty() { "" } else { &session.root }, filter.iter().count(), path.display() ); // Before the scan, not after it: the grid can be filled from disk now and // the scan is only ever going to add to it. show_catalog_now(window, &ctl, &path, &coll_ctl); let rx = library::spawn_scan( creds, session.user_id.clone(), session.root.clone(), filter, path.clone(), ); drain_scan(window.as_weak(), ctl, coll_ctl, rx, path); } /// Drain scan progress on the UI thread. fn drain_scan( weak: slint::Weak, ctl: Rc, coll_ctl: Rc, rx: Receiver, catalog_path: std::path::PathBuf, ) { let timer = slint::Timer::default(); let ctl_cb = ctl.clone(); // Indeterminate for as long as it runs: a recursive walk discovers its own // extent, so the count it reports is what it has *found*, never a fraction // of what there is (FR-CAT-1). // // Named after the folder, because two accounts or two roots produce rows // that are otherwise identical. let title = match ctl.session.borrow().as_ref() { Some((_, session, _)) if !session.root.is_empty() => { format!("Scanning {}", session.root) } _ => "Scanning the library".to_string(), }; let job = ctl.activity.begin(crate::activity::Kind::Scan, title); timer.start( slint::TimerMode::Repeated, std::time::Duration::from_millis(120), move || { let Some(w) = weak.upgrade() else { return }; let ctl = &ctl_cb; loop { let msg = match rx.try_recv() { Ok(m) => m, Err(std::sync::mpsc::TryRecvError::Empty) => return, Err(std::sync::mpsc::TryRecvError::Disconnected) => { // A worker that died without sending must not leave // the screen on "Scanning…" forever. if w.get_library_scanning() { w.set_library_scanning(false); w.set_library_error("scan ended unexpectedly".into()); job.fail("ended unexpectedly"); } stop(&ctl.scan_timer); return; } }; match msg { ScanMessage::Progress { directories, pruned, images, } => { // Pruned folders are reported separately rather than // folded into the total: they are the ETag walk paying // off, and hiding them makes an incremental rescan look // identical to a full one. let status = if pruned > 0 { format!("{directories} folders · {pruned} unchanged · {images} images") } else { format!("{directories} folders · {images} images") }; job.detail(status.clone()); w.set_library_status(status.into()); } ScanMessage::Done { found, total, pruned, elapsed_ms, } => { log::info!( "scan complete: {total} images ({found} listed, \ {pruned} folders unchanged) in {elapsed_ms} ms" ); w.set_library_scanning(false); // A completed scan is the strongest possible evidence // the server is reachable, so it clears offline mode // without needing a probe of its own. if ctl .reachability .borrow_mut() .mark_reachable(std::time::Instant::now()) { log::info!("back online"); } refresh_offline(&w, ctl); // An incremental rescan lists almost nothing, so // reporting the listed count would read as "0 images" // on a library that is simply up to date. let secs = elapsed_ms as f64 / 1000.0; let status = if pruned > 0 && found == 0 { format!("up to date · {total} images · {secs:.1}s") } else if pruned > 0 { format!("{found} new or changed · {total} images · {secs:.1}s") } else { format!("{total} images · {secs:.1}s") }; job.finish(status.clone()); w.set_library_status(status.into()); // Usually already open — `show_catalog_now` opened it // before this scan started, so the grid has been // showing the previous run's catalog all along and the // rows this scan found are new rows in that same file. // Only a first run, where there was nothing to open, // reaches the second arm. let opened = if ctl.catalog.borrow().is_some() { Ok(()) } else { Catalog::open(&catalog_path).map(|cat| { *ctl.catalog.borrow_mut() = Some(cat); }) }; match opened { Ok(()) => { // The sidebar is built before the grid: the // grid's badges read collection membership, and // the tree is where the catalog handle first // becomes available to it. { let borrow = ctl.catalog.borrow(); if let Some(cat) = borrow.as_ref() { crate::collections_ui::refresh_tree(&w, &coll_ctl, cat); } } load_window(&w, ctl); // Everything the grid did not touch: the rest // of the library gets a thumbnail and a date, // so the timeline describes all of it rather // than the part that was scrolled past. start_sweep(&w, ctl); } Err(e) => w.set_library_error(format!("opening catalog: {e}").into()), } stop(&ctl.scan_timer); return; } ScanMessage::Failed { message, offline } => { log::warn!("scan failed: {message}"); w.set_library_scanning(false); // Recorded as a failure even where it is only the // connection: the grid's offline banner says the server // is unreachable, and this says which piece of work // stopped because of it. job.fail(message.clone()); if offline { // Not an error state. The catalog from the last // successful scan is still on disk and still // accurate for everything already indexed, so the // grid keeps working — it simply cannot learn about // anything added on the server since. ctl.reachability .borrow_mut() .mark_unreachable(message, std::time::Instant::now()); refresh_offline(&w, ctl); // Show whatever the catalog holds. Without this the // grid stays empty on a launch that began offline, // which is precisely the case offline mode exists // for. open_catalog_for_offline(&w, ctl, &catalog_path, &coll_ctl); } else { w.set_library_error(message.into()); } stop(&ctl.scan_timer); return; } } } }, ); *ctl.scan_timer.borrow_mut() = Some(timer); } /// Start a scan against the configured library. /// /// Shared by the rescan button and by the offline banner's retry, which are /// the same operation: a scan is the only request that both proves the server /// is reachable and brings the catalog up to date. Keeping them one function /// is what stops "retry" from quietly becoming a weaker probe than "rescan". fn start_rescan( window: &AppWindow, ctl: &Rc, coll_ctl: &Rc, ) { let Some((creds, session, filter)) = ctl.session.borrow().clone() else { return; }; window.set_library_scanning(true); window.set_library_error(slint::SharedString::new()); window.set_library_status("Rescanning…".into()); let path = library::catalog_path(&session.server, &session.user_id); let rx = library::spawn_scan( creds, session.user_id.clone(), session.root.clone(), filter, path.clone(), ); drain_scan(window.as_weak(), ctl.clone(), coll_ctl.clone(), rx, path); } /// TRACES: FR-NC-6a | FR-NC-6c /// What a collection would cost to take with you, and what it is holding now. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] struct OfflineSummary { /// Photographs in the collection and its children, deduplicated. total: usize, /// Of those, how many have their original on this device. held: usize, /// Disk those originals occupy — what releasing would give back. held_bytes: u64, /// What the rest would cost to fetch, from the sizes the scan recorded. /// Zero where nothing has been stat-ed yet, which reads as "unknown" /// rather than "free" in the label built from it. missing_bytes: u64, } impl OfflineSummary { fn missing(self) -> usize { self.total.saturating_sub(self.held) } } /// Read the offline summary for a set of images. /// /// One query with the ids inlined as placeholders — the same shape /// [`collection_images`] uses, and for the same reason: a collection is tens to /// thousands of rows, and a round trip per photograph to answer one dialogue is /// not a trade worth making. fn offline_summary(catalog: &Catalog, images: &[dr_types::ImageId]) -> OfflineSummary { if images.is_empty() { return OfflineSummary::default(); } let placeholders = std::iter::repeat_n("?", images.len()) .collect::>() .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 = 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, 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) { 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, coll_ctl: &Rc, ) { 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, coll_ctl: &Rc, ) { let Some(id) = ctl.offline_target.get() else { return; }; let Some(cache) = ctl.cache() else { window.set_library_error("No cache directory for this library.".into()); return; }; let released = { let borrow = ctl.catalog.borrow(); let Some(catalog) = borrow.as_ref() else { return; }; let ids = match dr_catalog::collections::descendants(catalog.connection(), id) { Ok(ids) => ids, Err(e) => { window.set_library_error(format!("resolving collection: {e}").into()); return; } }; let images = collection_images(catalog, &ids); match cache.release(catalog.connection(), &images) { Ok(r) => r, Err(e) => { window.set_library_error(format!("removing local copies: {e}").into()); return; } } }; let (count, freed) = released; log::info!( "released {count} image(s), freeing {}", crate::activity::describe_bytes(freed) ); window.set_library_error(slint::SharedString::new()); window.set_library_status( format!( "Removed local copies · {} freed", crate::activity::describe_bytes(freed) ) .as_str() .into(), ); if ctl.scope.borrow().as_ref() == Some(&id) { window.set_library_scope_pinned(false); } // A download that was still running for this collection has just had its // reason withdrawn; the worker checks `pending_pins` per file, so it stops // finding work rather than being killed. window.set_library_pin_total(0); window.set_library_pin_done(0); close_offline_prompt(window, ctl); refresh_local_count(window, ctl); refresh_collection_tree(window, ctl, coll_ctl); } /// Redraw the sidebar, so the trays reflect what was just kept or released. fn refresh_collection_tree( window: &AppWindow, ctl: &Rc, coll_ctl: &Rc, ) { 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 { if ids.is_empty() { return Vec::new(); } let placeholders = std::iter::repeat_n("?", ids.len()) .collect::>() .join(","); let sql = format!( "SELECT DISTINCT image_id FROM collection_members WHERE collection_id IN ({placeholders})" ); let params: Vec = 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) { let Some((creds, session, _)) = ctl.session.borrow().clone() else { return; }; let Some(cache_dir) = ctl.cache_dir() else { return; }; // Offline, there is nothing to download from. The pin is already recorded, // so it resumes on reconnect rather than being lost. if ctl.is_offline() { log::info!("offline: the pin is recorded and will download on reconnect"); return; } let rx = library::spawn_pin_fetch( creds, session.user_id.clone(), library::catalog_path(&session.server, &session.user_id), cache_dir, // Pinned originals are exempt from the budget, but a pin fetch also // stores passively when it finds an image already cached, so the worker // still needs the user's ceiling rather than the catalog's floor. ctl.cache_budget.get(), ); let timer = slint::Timer::default(); let weak = window.as_weak(); let ctl_cb = ctl.clone(); // The longest-running transfer the app does, and the one most likely to be // watched from another view — which is the whole reason the register // exists (FR-NC-6, FR-NC-6c). let job = ctl.activity.begin( crate::activity::Kind::Download, "Keeping photographs on this device", ); timer.start( slint::TimerMode::Repeated, std::time::Duration::from_millis(300), move || { let Some(w) = weak.upgrade() else { return }; loop { let msg = match rx.try_recv() { Ok(m) => m, Err(std::sync::mpsc::TryRecvError::Empty) => return, Err(std::sync::mpsc::TryRecvError::Disconnected) => { w.set_library_pin_total(0); job.fail("stopped without finishing"); stop(&ctl_cb.pin_timer); return; } }; match msg { library::PinMessage::Planned { total } => { w.set_library_pin_total(total as i32); w.set_library_pin_done(0); job.total(total); } library::PinMessage::Stored { done } => { w.set_library_pin_done(done as i32); job.progress(done, w.get_library_pin_total() as usize); // The "On this device" count grows as they land, so // the chip agrees with the progress line beside it. refresh_local_count(&w, &ctl_cb); } library::PinMessage::Done { stored, bytes } => { log::info!( "pin complete: {stored} original(s), {:.1} MB", bytes as f64 / 1_048_576.0 ); job.finish(format!( "{stored} photograph(s) · {}", crate::activity::describe_bytes(bytes) )); w.set_library_pin_total(0); w.set_library_pin_done(0); refresh_local_count(&w, &ctl_cb); stop(&ctl_cb.pin_timer); return; } library::PinMessage::Failed { message, offline } => { log::warn!("pin fetch stopped: {message}"); job.fail(message.clone()); w.set_library_pin_total(0); if offline { ctl_cb .reachability .borrow_mut() .mark_unreachable(message, std::time::Instant::now()); refresh_offline(&w, &ctl_cb); } else { w.set_library_error(format!("keeping offline: {message}").into()); } refresh_local_count(&w, &ctl_cb); stop(&ctl_cb.pin_timer); return; } } } }, ); *ctl.pin_timer.borrow_mut() = Some(timer); } /// Refresh the "On this device" count from the catalog. fn refresh_local_count(window: &AppWindow, ctl: &Rc) { 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::>() .join(","); let params: Vec = 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) { 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) { if ctl.outbox_timer.borrow().is_some() { return; } if !ctl.outbox_maybe_dirty.get() { return; } let Some(cache_dir) = ctl.sidecar_cache_dir() else { return; }; if crate::sidecar_cache::SidecarCache::open(cache_dir.clone()) .pending() .is_empty() { // Nothing there. Recorded so the next hundred repaints skip the walk; // a write that queues sets it again. ctl.outbox_maybe_dirty.set(false); return; } let Some((creds, session, _)) = ctl.session.borrow().clone() else { return; }; let rx = library::spawn_outbox_drain(creds, session.user_id.clone(), cache_dir); let job = ctl .activity .begin(crate::activity::Kind::Upload, "Uploading queued edits"); let timer = slint::Timer::default(); let weak = window.as_weak(); let ctl_cb = ctl.clone(); timer.start( slint::TimerMode::Repeated, std::time::Duration::from_millis(250), move || { let Some(w) = weak.upgrade() else { return }; match rx.try_recv() { Ok(library::SidecarMessage::Finished { written, queued, failed, last_error, }) => { if failed > 0 { log::warn!( "{failed} queued sidecar(s) still undelivered: {}", last_error.clone().unwrap_or_default() ); // Not `fail`: the edits are still safely queued, and // reporting this as a loss would be wrong. job.finish(format!("{written} uploaded · {queued} still queued")); } else if written > 0 { log::info!("{written} queued sidecar(s) uploaded"); job.finish(format!("{written} queued edit(s) uploaded")); w.set_library_status(format!("{written} queued edit(s) uploaded").into()); } else { job.finish_quietly(); } ctl_cb.outbox_maybe_dirty.set(queued > 0); stop(&ctl_cb.outbox_timer); } Err(std::sync::mpsc::TryRecvError::Empty) => {} Err(std::sync::mpsc::TryRecvError::Disconnected) => { job.finish_quietly(); stop(&ctl_cb.outbox_timer); } } }, ); *ctl.outbox_timer.borrow_mut() = Some(timer); } /// A coarse "how long ago", for the offline banner. /// /// Deliberately imprecise: the user wants to know whether this just happened /// or has been true for a while, and a live-counting seconds display would /// draw the eye to a number that changes without meaning anything. fn describe_duration(d: std::time::Duration) -> String { let secs = d.as_secs(); if secs < 60 { "just now".to_string() } else if secs < 3600 { format!("{}m ago", secs / 60) } else { format!("{}h ago", secs / 3600) } } /// TRACES: FR-CAT-9 /// Show the catalog after a scan that could not reach the server. /// /// The whole point of offline mode: a scan is how the grid normally gets its /// catalog handle, so a failed one previously left the library empty even /// though a complete catalog was sitting on disk from the last successful run. /// The images are all still there, their thumbnails are in the shards, and /// rating and collecting them are local writes. /// /// The sweep is deliberately **not** started — it exists to fetch headers over /// the network, so offline it would do nothing but fail once per image. fn open_catalog_for_offline( window: &AppWindow, ctl: &Rc, catalog_path: &std::path::Path, coll_ctl: &Rc, ) { 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) { // 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, catalog_path: &std::path::Path, coll_ctl: &Rc, ) { 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, } /// **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, ids: &[i64], classes: &[Option], ) -> std::collections::HashMap { 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, ids: impl Iterator, ) -> 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 { // 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) { let borrow = ctl.catalog.borrow(); let Some(catalog) = borrow.as_ref() else { return; }; // Everything below is scoped to the selected collection, if any: the total, // the window, and the fetches issued for it. Reading the whole library here // and filtering later would fetch thumbnails for images the user is not // looking at, which on a remote library is the cost FR-NC-3 exists to // avoid. let scope = *ctl.scope.borrow(); let filter = *ctl.filter.borrow(); // The trash lists what every other view excludes, so it takes its own // query rather than another predicate threaded through the scoped one. let trash = ctl.viewing_trash.get(); 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, trash, total, }; let describes_something_new = ctl.library_facts.get() != Some(facts); ctl.library_facts.set(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 = 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 = 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 = 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 = 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, 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 = 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, 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, images: &[dr_types::ImageId], rating: Option, flag: Option, ) { 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, flag: Option) -> 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, 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 { let placeholders = std::iter::repeat_n("?", images.len()) .collect::>() .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 = 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 { let placeholders = std::iter::repeat_n("?", images.len()) .collect::>() .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 = 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, writes: Vec, ) { if writes.is_empty() { return; } // TRACES: FR-CAT-9 // Offline is passed down rather than used to skip. // // It used to skip, and the reasoning was that a cull stays responsive // because the rating is safe in the catalog. That held for judgements and // not for edits: the catalog stores no parameters, so a pasted edit made // offline survived nowhere at all. Every write now commits to the local // sidecar cache first and the upload is best-effort, which keeps the // keystroke path off the network — the original concern — without the // write being conditional on it. let offline = ctl.is_offline(); let Some((creds, session, _)) = ctl.session.borrow().clone() else { return; }; let Some(cache_dir) = ctl.sidecar_cache_dir() else { return; }; let count = writes.len(); let rx = library::spawn_sidecar_writes(creds, session.user_id.clone(), writes, cache_dir, offline); let timer = slint::Timer::default(); let weak = window.as_weak(); let ctl_cb = ctl.clone(); // The writer reports once at the end, so there is no per-file progress to // show — but a cull that has just rated forty frames has forty uploads in // flight, and "is that saved yet" deserves an answer somewhere. let job = ctl.activity.begin( crate::activity::Kind::Upload, format!("Saving {count} judgement(s)"), ); timer.start( slint::TimerMode::Repeated, std::time::Duration::from_millis(200), move || { let Some(w) = weak.upgrade() else { return }; // One message is all this channel ever carries — the writer reports // `Finished` once and hangs up — so this drains a single item rather // than looping like the scan and thumbnail drains do. match rx.try_recv() { Ok(library::SidecarMessage::Finished { written, queued, failed, last_error, }) => { if failed == 0 && queued > 0 { // Recorded locally, waiting for the server. Said out // loud because the user has just made an edit with no // connection and deserves to know it is safe — the // old behaviour here was to drop it silently. log::debug!("{queued} sidecar(s) queued for upload"); ctl_cb.outbox_maybe_dirty.set(true); job.finish_quietly(); w.set_library_status( format!("{queued} edit(s) saved · will upload when back online").into(), ); stop(&ctl_cb.sidecar_timer); return; } if failed > 0 { // A failed upload left the edit in the outbox. ctl_cb.outbox_maybe_dirty.set(true); log::warn!( "{failed} sidecar write(s) failed: {}", last_error.clone().unwrap_or_default() ); job.fail(format!( "{written} saved · {failed} failed: {}", last_error.clone().unwrap_or_default() )); // Said plainly, because the consequence is specific: // the rating is safe in the catalog but will not // survive deleting it. w.set_library_status( format!( "{written} saved · {failed} could not be written \ to the library folder" ) .into(), ); } else { log::debug!("{written} sidecar(s) written"); // Quietly: a cull produces one of these every few // seconds and none of them is news. job.finish_quietly(); } stop(&ctl_cb.sidecar_timer); } Err(std::sync::mpsc::TryRecvError::Empty) => {} Err(std::sync::mpsc::TryRecvError::Disconnected) => { stop(&ctl_cb.sidecar_timer); } } }, ); *ctl.sidecar_timer.borrow_mut() = Some(timer); } /// Slint carries the flag as an integer, matching the catalog's encoding. fn flag_code(f: dr_types::FlagState) -> i32 { match f { dr_types::FlagState::Unflagged => 0, dr_types::FlagState::Pick => 1, dr_types::FlagState::Reject => 2, } } /// The flag an integer from Slint stands for. fn flag_from_code(v: i32) -> dr_types::FlagState { match v { 1 => dr_types::FlagState::Pick, 2 => dr_types::FlagState::Reject, _ => dr_types::FlagState::Unflagged, } } /// 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) { let Some((creds, session, _)) = ctl.session.borrow().clone() else { return; }; // The drawn cell size decides which class to ask for. Chosen once for the // batch rather than per row, and carried through to the drain so a cell it // fills can record what it is now showing. let cell_pixels = window.get_library_cell_size().max(1.0) as u32; let class = dr_thumbs::ThumbSize::for_cell(cell_pixels); let mut wanted: Vec = { 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( creds, session.user_id.clone(), wanted, library::thumbs_dir(&session.server, &session.user_id), library::catalog_path(&session.server, &session.user_id), ); drain_thumbnails(window.as_weak(), ctl.clone(), rx, requested, class); } /// TRACES: FR-CAT-9 | FR-DEV-6 /// Replace a photograph's cached thumbnail with one rendered from its edit. /// /// # Why the grid cannot be left alone /// /// A thumbnail comes from the file's embedded preview, which is the camera's /// idea of the photograph and knows nothing about what has been done to it /// since. So a frame could be cropped, turned upright, and pulled two stops /// back, and the grid would go on showing the original — the one view of a /// library where an edit is least visible is the one the photographer spends /// most of their time in. /// /// # Both classes, and why /// /// The store keys on the size class, so replacing only the one the grid /// happens to be drawing at leaves the other holding the unedited preview — /// and a zoom past the class boundary would show the edit undoing itself. /// Each class is rendered separately because they are different sizes; a /// downscale of the large one would be a second, worse resampler than the GPU /// already applied. /// /// # Silent on failure /// /// The edit is saved to the sidecar by the caller before this runs, so nothing /// here can lose work. A thumbnail that could not be re-rendered is a stale /// cell, which the next scroll past it corrects from the store — worth a log /// line and not worth an error in front of a photographer who has just /// finished an image. pub fn refresh_thumbnail( window: &AppWindow, ctl: &Rc, remote_path: &str, mut render: impl FnMut(u32) -> Result<(u32, u32, Vec), String>, ) { // Where this photograph sits in the loaded window. It is always in it: the // develop view is reached from a cell, and the guards on the scroll and // geometry handlers stop the window moving while it is open. let Some(row) = ctl.paths.borrow().iter().position(|p| p == remote_path) else { return; }; let Some(file_id) = ctl.file_ids.borrow().get(row).copied().flatten() else { // Nothing to key the store on. A photograph the scan recorded without // a server file id cannot have a cached thumbnail either, so there is // nothing here to correct. return; }; let Some((_, session, _)) = ctl.session.borrow().clone() else { return; }; let mut store = match dr_thumbs::ThumbStore::open(&library::thumbs_dir( &session.server, &session.user_id, )) { Ok(s) => s, Err(e) => { log::warn!("re-thumbnailing {remote_path}: opening the store: {e}"); return; } }; // What the grid is drawing at, so the cell can be corrected on screen // rather than only on disk. let drawn = dr_thumbs::ThumbSize::for_cell(window.get_library_cell_size().max(1.0) as u32); for class in [dr_thumbs::ThumbSize::Grid, dr_thumbs::ThumbSize::Large] { let (w, h, rgba) = match render(class.edge()) { Ok(r) => r, Err(e) => { log::warn!("re-thumbnailing {remote_path} at {class:?}: {e}"); continue; } }; match dr_thumbs::codec::encode_rgba(w, h, &rgba) { Ok(bytes) => { let thumb = dr_thumbs::Thumbnail { width: w, height: h, bytes, }; if let Err(e) = store.put(file_id, class, &thumb) { log::warn!("re-thumbnailing {remote_path} at {class:?}: {e}"); } } Err(e) => { log::warn!("re-thumbnailing {remote_path} at {class:?}: encoding: {e}"); continue; } } if class == drawn { // Straight into the model, so the edit is on the cell the moment // the grid comes back rather than after a scroll evicts and // refetches it. let model = window.get_library_cells(); if let Some(mut cell) = model.row_data(row) { cell.thumbnail = to_slint_image(w, h, &rgba); cell.has_thumb = true; cell.unavailable = false; model.set_row_data(row, cell); } record_class(ctl, row, class); } } } /// Note which size class a row's pixels came from. /// /// Silent about a row past the end: the model and this vector are rebuilt /// together by [`load_window`], and the generation check above already refuses /// anything addressed to a window that has since moved. fn record_class(ctl: &Rc, 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, ctl: Rc, rx: Receiver, 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, 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) { // 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((creds, session, _)) = ctl.session.borrow().clone() else { return; }; // Already running: a second pass would race the first over the same // scratch files. if ctl.sync_timer.borrow().is_some() && window.get_library_syncing() { return; } // TRACES: FR-CAT-9 // Nothing to push to and nothing to take. Attempting it would upload // shards into a timeout and light the "Syncing…" indicator over work that // cannot start; the shards are unchanged on disk and go out on the next // sync once the server is back. if ctl.is_offline() { log::debug!("offline: skipping derived sync"); return; } let catalog_path = library::catalog_path(&session.server, &session.user_id); let scratch = catalog_path .parent() .map(|p| p.join("scratch")) .unwrap_or_else(std::env::temp_dir); let _ = std::fs::create_dir_all(&scratch); // TRACES: FR-EXP-7 | FR-NC-10 // Drain the export outbox on the same pass, and before the shards. An // export the user was told had succeeded is waiting here, and it is the // one thing in this directory that exists nowhere else — a thumbnail // shard can be rebuilt from the originals, and the catalog is an index. // // Fire-and-forget rather than reported: it runs on its own thread and // clears entries as they land, so a partial run leaves the rest queued // for next time and nothing is lost by not watching it. It reports // through the log until an export has a place in the activity list. { let outbox = crate::export::outbox_dir(&session.server, &session.user_id); if crate::export::pending_count(&outbox) > 0 { let rx = crate::export::spawn_upload( creds.clone(), session.user_id.clone(), session.root.clone(), outbox, ); std::thread::spawn(move || { while let Ok(msg) = rx.recv() { match msg { crate::export::UploadMessage::Status(s) => log::info!("export: {s}"), crate::export::UploadMessage::Finished { uploaded, remaining, error, } => { log::info!("export: {uploaded} uploaded, {remaining} still queued"); if let Some(e) = error { log::warn!("export upload stopped: {e}"); } } } } }); } } window.set_library_syncing(true); let rx = crate::derived_sync::spawn_sync( creds, session.user_id.clone(), session.root.clone(), library::thumbs_dir(&session.server, &session.user_id), catalog_path, scratch, ); let timer = slint::Timer::default(); let weak = window.as_weak(); let ctl_cb = ctl.clone(); // The sync reports stages rather than counts, so it stays indeterminate and // says what stage it is in. let job = ctl .activity .begin(crate::activity::Kind::Sync, "Syncing with the server"); timer.start( slint::TimerMode::Repeated, std::time::Duration::from_millis(300), move || { let Some(w) = weak.upgrade() else { return }; loop { let msg = match rx.try_recv() { Ok(m) => m, Err(std::sync::mpsc::TryRecvError::Empty) => return, Err(std::sync::mpsc::TryRecvError::Disconnected) => { w.set_library_syncing(false); job.fail("stopped without finishing"); stop(&ctl_cb.sync_timer); return; } }; match msg { crate::derived_sync::SyncMessage::Status(s) => { job.detail(s.clone()); w.set_library_status(s.into()); } crate::derived_sync::SyncMessage::Finished(report) => { log::info!( "sync: {} shard(s) up, {} down ({} thumbnails), \ catalog {}{}", report.shards_uploaded, report.shards_downloaded, report.thumbnails_adopted, if report.catalog_uploaded { "pushed" } else { "not pushed" }, if report.collections_gained > 0 { format!(", {} collection(s) gained", report.collections_gained) } else { String::new() } ); w.set_library_syncing(false); let summary = format!( "{} shard(s) up, {} down", report.shards_uploaded, report.shards_downloaded ); job.finish(if report.did_anything() { summary.clone() } else { "nothing to exchange".to_string() }); if report.did_anything() { w.set_library_status(format!("synced · {summary}").into()); } // Adopted thumbnails and merged collections both change // what the grid should show. if report.thumbnails_adopted > 0 || report.collections_gained > 0 || report.members_gained > 0 { load_window(&w, &ctl_cb); } // TRACES: FR-CAT-7 // And the sidebar, which the grid reload does not // touch. Membership counts as a change: a sync that // files 127 photographs into a collection both devices // already had gains no *collection*, so keying this on // `collections_gained` alone left the tree showing no // number beside a collection that had just been filled. // // The scan already does this after it finishes; the // sync merges the same tables and did not. if report.collections_gained > 0 || report.members_gained > 0 { let coll = ctl_cb.coll_ctl.borrow().as_ref().and_then(|w| w.upgrade()); if let Some(coll) = coll { let borrow = ctl_cb.catalog(); let borrow = borrow.borrow(); if let Some(cat) = borrow.as_ref() { crate::collections_ui::refresh_tree(&w, &coll, cat); } } } stop(&ctl_cb.sync_timer); return; } crate::derived_sync::SyncMessage::Failed(e) => { log::warn!("sync failed: {e}"); job.fail(e.to_string()); w.set_library_syncing(false); // Not an error banner: a failed sync costs nothing — // everything is still local and the next pass retries. w.set_library_status(format!("sync failed: {e}").into()); stop(&ctl_cb.sync_timer); return; } } } }, ); *ctl.sync_timer.borrow_mut() = Some(timer); } /// Start the whole-library sweep and report its progress. /// /// The grid only ever fetches what is on screen, so without this the timeline /// describes the fraction of the library that happened to be scrolled past. /// This covers the rest. fn start_sweep(window: &AppWindow, ctl: &Rc) { let Some((creds, session, _)) = ctl.session.borrow().clone() else { return; }; // TRACES: FR-CAT-9 // The sweep is nothing but network reads — one header fetch per undated // image, across the whole library. Offline it would spend a timeout on // every one of them, running for hours to learn nothing, while the // progress bar implied work was happening. It resumes on reconnect, and // the images it has already dated stay dated. if ctl.is_offline() { log::debug!("offline: not starting the metadata sweep"); return; } let rx = library::spawn_sweep( creds, session.user_id.clone(), library::catalog_path(&session.server, &session.user_id), ); let timer = slint::Timer::default(); let weak = window.as_weak(); let ctl_cb = ctl.clone(); // Hours on a large library, and entirely invisible outside the grid until // now: the register is where a user who has gone to develop can still see // that indexing is running and how far it has got. let job = ctl .activity .begin(crate::activity::Kind::Index, "Indexing capture times"); timer.start( slint::TimerMode::Repeated, // Slower than the thumbnail drain: this runs for tens of minutes and // its progress does not need per-frame accuracy. std::time::Duration::from_millis(400), move || { let Some(w) = weak.upgrade() else { return }; loop { let msg = match rx.try_recv() { Ok(m) => m, Err(std::sync::mpsc::TryRecvError::Empty) => return, Err(std::sync::mpsc::TryRecvError::Disconnected) => { w.set_library_sweep_total(0); job.fail("stopped without finishing"); stop(&ctl_cb.sweep_timer); return; } }; match msg { library::SweepMessage::Total(n) => { w.set_library_sweep_total(n as i32); w.set_library_sweep_done(0); job.total(n); } library::SweepMessage::Progress { done, dated } => { w.set_library_sweep_done(done as i32); job.progress(done, w.get_library_sweep_total() as usize); // Rebuild as it goes: the histogram growing while the // sweep runs is the visible sign it is working. if dated > 0 { let borrow = ctl_cb.catalog.borrow(); if let Some(catalog) = borrow.as_ref() { refresh_timeline(&w, catalog, &ctl_cb); } } } library::SweepMessage::Finished { dated } => { log::info!("sweep finished: {dated} dated"); job.finish(format!("{dated} dated")); w.set_library_sweep_total(0); { let borrow = ctl_cb.catalog.borrow(); if let Some(catalog) = borrow.as_ref() { refresh_timeline(&w, catalog, &ctl_cb); } } // Now that indexing is complete, hand the result to the // server so a second device inherits it rather than // repeating hours of range fetches. start_derived_sync(&w, &ctl_cb); stop(&ctl_cb.sweep_timer); return; } } } }, ); *ctl.sweep_timer.borrow_mut() = Some(timer); } /// TRACES: FR-CAT-3 | FR-NC-3 | FR-NC-7 /// Thumbnail every photograph in the library, then hand the shards over. /// /// # Why it is asked for rather than assumed /// /// The grid's own fetching is demand-driven on purpose: a remote library is /// browsed over a connection that must not be saturated to show one screen /// (FR-NC-3). But that leaves the thumbnail store holding only what has been /// looked at, and the store is the one derived thing worth syncing — a second /// device that downloads the shards gets a full grid without touching a RAW. /// So the complete set is worth an hour of transfers *once*, on a machine /// plugged in, at a moment the user chose. That is this. /// /// The sync at the end is not a separate courtesy: a filled store that never /// leaves this device is most of the cost for none of the point. fn start_thumbnail_sweep(window: &AppWindow, ctl: &Rc) { let Some((creds, session, _)) = ctl.session.borrow().clone() else { return; }; // Already running. A second pass would re-fetch everything the first is // part way through — the work list is built from what the store lacks, and // the first pass has not filled it yet. if ctl.thumb_sweep_timer.borrow().is_some() && window.get_library_thumbnailing() { return; } // TRACES: FR-CAT-9 // Every image here is a range fetch. Offline the pass would spend a // timeout per photograph and store nothing, so it is refused rather than // started — and said so, because this one was pressed deliberately and a // button that silently does nothing is worse than one that declines. if ctl.is_offline() { log::debug!("offline: not starting the thumbnail pass"); window.set_library_status("Offline — thumbnailing needs the server.".into()); return; } window.set_library_thumbnailing(true); let rx = library::spawn_thumbnail_sweep( creds, session.user_id.clone(), library::catalog_path(&session.server, &session.user_id), library::thumbs_dir(&session.server, &session.user_id), ); let timer = slint::Timer::default(); let weak = window.as_weak(); let ctl_cb = ctl.clone(); let job = ctl.activity.begin( crate::activity::Kind::Thumbnails, "Thumbnailing the library", ); // The total arrives in the first message rather than up front — counting // it means asking the store about every image — and the progress messages // after it carry only cumulative counts, so it is kept here rather than // re-derived. let total = std::cell::Cell::new(0usize); timer.start( slint::TimerMode::Repeated, // As slow as the metadata sweep's drain, and for the same reason: this // runs for tens of minutes and a chunk lands every few seconds. std::time::Duration::from_millis(400), move || { let Some(w) = weak.upgrade() else { return }; loop { let msg = match rx.try_recv() { Ok(m) => m, Err(std::sync::mpsc::TryRecvError::Empty) => return, Err(std::sync::mpsc::TryRecvError::Disconnected) => { w.set_library_thumbnailing(false); job.fail("stopped without finishing"); stop(&ctl_cb.thumb_sweep_timer); return; } }; match msg { library::ThumbSweepMessage::Total(n) => { total.set(n); job.total(n); w.set_library_status(format!("Thumbnailing {n} photograph(s)…").into()); } library::ThumbSweepMessage::Progress { done, stored } => { job.progress(done, total.get()); // Dates arrive on the same headers, so the histogram // grows as this runs — the visible sign it is working // on a page that is not the grid. if stored > 0 { let borrow = ctl_cb.catalog.borrow(); if let Some(catalog) = borrow.as_ref() { refresh_timeline(&w, catalog, &ctl_cb); } } } library::ThumbSweepMessage::Finished { stored, failed, offline, } => { log::info!( "thumbnail pass finished: {stored} stored, {failed} without a preview" ); w.set_library_thumbnailing(false); stop(&ctl_cb.thumb_sweep_timer); let summary = if offline { format!("{stored} stored · stopped, server unreachable") } else if failed > 0 { format!("{stored} stored · {failed} with no usable preview") } else { format!("{stored} stored") }; w.set_library_status(summary.clone().into()); // Finished rather than failed even when the server // dropped: the pass is resumable and what it did build // is stored, and a red row would invite the user to // redo work that is already done. job.finish(summary); { let borrow = ctl_cb.catalog.borrow(); if let Some(catalog) = borrow.as_ref() { refresh_timeline(&w, catalog, &ctl_cb); } } // Cells that showed a placeholder have pixels now, and // the grid asks the store once per photograph — so // without this the library the pass just thumbnailed // stays blank until something else reloads the window. if w.get_show_library() { ctl_cb.requested.borrow_mut().clear(); schedule_reload(&w, &ctl_cb); } // The point of the pass: the shards go to the server so // every other device inherits them. Skipped when the // connection is already gone — the sync would only // discover the same thing more slowly. if !offline { start_derived_sync(&w, &ctl_cb); } return; } } } }, ); *ctl.thumb_sweep_timer.borrow_mut() = Some(timer); } /// Rebuild the timeline histogram from the catalog. /// /// Bucket size follows the span of the library, the way darktable's does: /// a decade of photographs buckets by year, a single trip by day. Picking it /// from the data rather than fixing it means the histogram is informative at /// both scales instead of one flat bar or ten thousand slivers. fn refresh_timeline(window: &AppWindow, catalog: &Catalog, ctl: &Rc) { // Scoped to whatever the grid is showing. A collection's histogram drawn // over the whole library's span said almost nothing: every bar for a // fortnight in Arosa landed in one column of a fifteen-year axis. let scope = *ctl.scope.borrow(); let filter = *ctl.filter.borrow(); // Passed whole, date range included, and both queries below lift that // range themselves — see `span_scoped` and `timeline_uniform`. Every other // term still applies, because a histogram of the five-star frames is a // fair question; the range does not, because the axis is *how a range is // chosen* and drawing through it would empty every bin outside the band // and leave nothing to widen back into. let span = match library::span_scoped(catalog, scope, &filter) { Some(s) => s, None => { // No dated images yet. An empty histogram is honest — EXIF is read // as thumbnails load, so this populates as the user browses. window.set_library_timeline(slint::ModelRc::new(slint::VecModel::from(vec![]))); window.set_library_timeline_label(slint::SharedString::new()); return; } }; // A range is drawn *over* this span as a band, not substituted for it — // see the band below. The scale a narrow range deserves is reached by // zooming the axis instead, which is a gesture a finger has (pinch) and // does not disturb the range while it is being adjusted. // // Zoom narrows the span around wherever the view sits rather than around // the library's midpoint, so zooming in keeps what you were looking at. let zoom = *ctl.timeline_zoom.borrow(); let (from, to) = zoomed_span(span, zoom, *ctl.timeline_centre.borrow()); // A fixed number of equal bins, re-cut on every zoom (FR-CAT-6). The // count is the user's — see `LibrarySettings::timeline_bars` for why the // calendar units it replaced made zooming in draw a coarser picture. let bins = ctl.timeline_bars.get().max(1); // Calendar units survive as a *label* vocabulary: what a bin is called // depends on how long it is, not on how the counting was done. let granularity = dr_catalog::Granularity::for_bucket((to - from).max(1) / bins as i64); let buckets = match library::timeline_uniform(catalog, scope, &filter, from, to, bins) { Ok(b) => b, Err(e) => { log::debug!("timeline: {e}"); return; } }; // Normalise against the tallest bar. Counts vary by orders of magnitude // between a quiet month and a wedding, so a linear scale against the total // would render most buckets invisible. let peak = buckets.iter().map(|b| b.count).max().unwrap_or(1).max(1); let mut previous: Option<(i64, i64)> = None; let bars: Vec = 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) { 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) -> 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, catalog: &Catalog, ordinal: usize) -> Option { { 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 { 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, 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) { 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::::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, coll: &Rc, 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, coll: &Rc, 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, 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( window: &AppWindow, ctl: Rc, coll_ctl: Rc, on_open_image: F, on_leave_develop: Rc, ) 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| { let Some(w) = weak.upgrade() else { return }; let person = dr_catalog::faces::PersonId(id.max(0) as u64); // The chip needs a name, and an unnamed group has none — so it // borrows the rail's own wording rather than inventing a second // way to describe the same thing. let label = { let borrow = ctl.catalog.borrow(); borrow .as_ref() .and_then(|cat| dr_catalog::faces::people(cat.connection()).ok()) .and_then(|people| people.into_iter().find(|p| p.id == person)) .map(|p| { if p.name.trim().is_empty() { format!("Unnamed ({} faces)", p.confirmed_faces + p.suggested_faces) } else { p.name } }) .unwrap_or_else(|| "Person".to_string()) }; ctl.filter.borrow_mut().person = Some(person.0); w.set_library_filter_person_name(label.into()); // 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 || { let Some(w) = weak.upgrade() else { return }; ctl.filter.borrow_mut().person = None; w.set_library_filter_person_name(Default::default()); refilter(&w, &ctl); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); window.on_library_filter_flag_changed(move |f| { let Some(w) = weak.upgrade() else { return }; { let mut filter = ctl.filter.borrow_mut(); filter.flag = match f { 1 => Some(dr_types::FlagState::Pick), 2 => Some(dr_types::FlagState::Reject), _ => None, }; if f > 0 { filter.unjudged = false; } } w.set_library_filter_flag(f); w.set_library_filter_unjudged(ctl.filter.borrow().unjudged); refilter(&w, &ctl); }); } // Turn the range on, over the period the histogram is showing. // // The range is taken from the timeline rather than typed into two date // fields: finding the fortnight is what the histogram is *for*, and having // found it the user should not have to read the dates off the axis and key // them back in. // // A starting point rather than the answer. What this puts on the axis is a // band with two draggable ends, and dragging them is how the range is // actually stated — on a phone it is the only way that does not need a // keyboard. Seeding it to the whole visible span means the first drag // narrows from what the user is already looking at. // // Toggling off clears both ends rather than remembering them — a range you // cannot see the extent of is a filter that looks like an empty library. { let weak = window.as_weak(); let ctl = ctl.clone(); window.on_library_toggle_date_range(move || { let Some(w) = weak.upgrade() else { return }; // Whether the *controls* are showing, which is not the same as // whether a range is set — and reading it from the filter was why // pressing this did nothing. The fields are how a range gets set, // so requiring one before they appear is a door locked from the // inside. let on = !w.get_library_range_active(); if on { // Seeded from the span the axis is drawn over, which is what // the user is looking at when they ask for "this range". // // Only a seed. If there is no catalog yet, or nothing in it // carries a capture date — dates are read from EXIF as // thumbnails load, so a freshly opened library has none — the // fields simply open empty and wait to be typed into. This // used to `return` in both cases, which meant the button // silently did nothing on exactly the libraries where a person // is most likely to be looking for a date. let seeded = { let borrow = ctl.catalog.borrow(); borrow .as_ref() .and_then(|catalog| catalog_span(catalog, &ctl)) .map(|span| { zoomed_span( span, *ctl.timeline_zoom.borrow(), *ctl.timeline_centre.borrow(), ) }) }; if let Some(span) = seeded { // Through the same days-inclusive conversion a drag uses, // so a seeded range and a dragged one are the same kind of // thing — and so `show_range` reads this one back out as // the two days it covers. let (from, to) = dragged_range(span, 0.0, 1.0); let mut f = ctl.filter.borrow_mut(); f.captured_from = Some(from); f.captured_to = Some(to); } } else { let mut f = ctl.filter.borrow_mut(); f.captured_from = None; f.captured_to = None; } w.set_library_range_active(on); show_range(&w, &ctl); refilter(&w, &ctl); }); } // TRACES: FR-CAT-6 | FR-UI-2 // The ends of the range, dragged along the axis they were chosen from. // // This is the control the two typed fields were standing in for. On a // phone they could not do the job: `YYYY-MM-DD` keyed into a 108px field // behind a soft keyboard, to name a day already drawn on the axis a thumb // away. Nor was the chip a way round them — its span comes from the // timeline's zoom and pan, and those are a wheel and a middle button, // neither of which a touch screen has. So on Android the date range was a // filter that could be turned on and not aimed. // // The span is recomputed here rather than remembered from the last draw. // It is the same expression `refresh_timeline` uses, over the same // date-lifted extent, so a handle dropped at a fraction of the track names // the instant that fraction was drawn at. { let weak = window.as_weak(); let ctl = ctl.clone(); window.on_library_timeline_range_changed(move |a, b| { let Some(w) = weak.upgrade() else { return }; let borrow = ctl.catalog.borrow(); let Some(catalog) = borrow.as_ref() else { return; }; let Some(full) = catalog_span(catalog, &ctl) else { return; }; let span = zoomed_span( full, *ctl.timeline_zoom.borrow(), *ctl.timeline_centre.borrow(), ); drop(borrow); let (from, to) = dragged_range(span, a, b); { let mut f = ctl.filter.borrow_mut(); f.captured_from = Some(from); f.captured_to = Some(to); } // A dragged range cannot be mistyped, so any standing complaint // about the fields is now about a range that no longer exists. w.set_library_range_invalid(false); w.set_library_range_active(true); show_range(&w, &ctl); // Reloads the window, and `load_window` redraws the timeline — // which is what puts the band back under the finger that dropped // it, at the whole days it was snapped to. refilter(&w, &ctl); }); } // TRACES: FR-CAT-6 // The ends of the range, typed. // // The chip alone used to be the whole control and it read its span from // the timeline's zoom, which is zero until someone zooms — so "limit to // range" set the range to the entire library and appeared to do nothing. // The span is shown now, and these two fields are how it is corrected. { let weak = window.as_weak(); let ctl = ctl.clone(); window.on_library_range_edited(move |from, to| { let Some(w) = weak.upgrade() else { return }; // Both ends, or neither. A range with one end parsed and the other // silently dropped is a filter nobody asked for, and the grid // going empty is a poor way to find out a date was mistyped. let (Some(from), Some(to)) = ( dr_types::parse_date(from.as_str()), dr_types::parse_date(to.as_str()), ) else { w.set_library_range_invalid(true); return; }; // Typed the other way round is a slip, not an error: the user has // said which two days they mean and there is exactly one range // between them. let (from, to) = if from <= to { (from, to) } else { (to, from) }; { let mut f = ctl.filter.borrow_mut(); f.captured_from = Some(from); // Inclusive of the last day. "To the 5th" means the whole of // the 5th — `parse_date` returns its midnight, and a range // ending there would exclude every photograph taken on the day // the user named. The same rule `dragged_range` applies. f.captured_to = Some(to + 86_400 - 1); } w.set_library_range_invalid(false); w.set_library_range_active(true); show_range(&w, &ctl); refilter(&w, &ctl); }); } // TRACES: FR-CAT-9 // "On this device" — the images openable without a server. Composes with // the rating terms rather than replacing them: "five-star frames I can // actually edit on this train" is one filter, not a mode. { let weak = window.as_weak(); let ctl = ctl.clone(); window.on_library_toggle_local_only(move || { let Some(w) = weak.upgrade() else { return }; let on = !ctl.local_only(); ctl.set_local_only(on); ctl.filter.borrow_mut().local_only = on; w.set_library_local_only(on); refilter(&w, &ctl); }); } // TRACES: FR-NC-6a // The header's way in to the offline question, for the collection the grid // is scoped to. It opens the same prompt the sidebar's tray and the long // press open, rather than pinning outright: three affordances that did two // different things — one asking, two acting — is how a user comes to avoid // all three. { let weak = window.as_weak(); let ctl = ctl.clone(); window.on_library_toggle_pin_scope(move || { let Some(w) = weak.upgrade() else { return }; let Some(scope) = *ctl.scope.borrow() else { return; }; open_offline_prompt(&w, &ctl, scope); }); } // TRACES: FR-NC-6a | FR-UI-4 // The sidebar's way in: the tray on a row, tapped. { let weak = window.as_weak(); let ctl = ctl.clone(); window.on_collection_offline_menu(move |id| { let Some(w) = weak.upgrade() else { return }; *ctl.row_hold_timer.borrow_mut() = None; open_offline_prompt(&w, &ctl, dr_types::CollectionId(id as u64)); }); } // TRACES: FR-NC-6a | FR-UI-2 | FR-UI-4 // And the touch way in: hold the collection's name. // // The release that ends the hold still reaches the row's `clicked` and // scopes the grid to that collection. Left deliberately: the user is now // looking at the photographs they are being asked about, which is context // rather than a side effect — and suppressing it would mean a second // "ignore the next click" flag threaded through the sidebar for no gain. { let weak = window.as_weak(); let ctl = ctl.clone(); let coll_for_press = coll_ctl.clone(); window.on_collection_row_press(move |id, down| { let Some(w) = weak.upgrade() else { return }; // A drag of this row, if one follows, carries this collection. if down { coll_for_press.note_row_press(dr_types::CollectionId(id as u64)); } if !down { *ctl.row_hold_timer.borrow_mut() = None; return; } let timer = slint::Timer::default(); let weak = w.as_weak(); let ctl_cb = ctl.clone(); timer.start( slint::TimerMode::SingleShot, std::time::Duration::from_millis(crate::collections_ui::HOLD_DELAY_MS), move || { let Some(w) = weak.upgrade() else { return }; open_offline_prompt(&w, &ctl_cb, dr_types::CollectionId(id as u64)); }, ); *ctl.row_hold_timer.borrow_mut() = Some(timer); }); } // The three answers. { let weak = window.as_weak(); let ctl = ctl.clone(); let coll = coll_ctl.clone(); window.on_offline_prompt_keep(move || { let Some(w) = weak.upgrade() else { return }; keep_collection_offline(&w, &ctl, &coll); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); let coll = coll_ctl.clone(); window.on_offline_prompt_release(move || { let Some(w) = weak.upgrade() else { return }; release_collection_offline(&w, &ctl, &coll); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); window.on_offline_prompt_dismiss(move || { let Some(w) = weak.upgrade() else { return }; close_offline_prompt(&w, &ctl); }); } // TRACES: FR-CAT-9 // Retry now, rather than waiting out the backoff. A user who has just // reconnected their wifi knows something the backoff does not. { let weak = window.as_weak(); let ctl = ctl.clone(); let coll_ctl = coll_ctl.clone(); window.on_library_retry_connection(move || { let Some(w) = weak.upgrade() else { return }; log::info!("retrying the connection at the user's request"); // A rescan is the probe: it is the same request the scan worker // makes, so a success proves reachability and repopulates the // catalog in one pass rather than proving it twice. start_rescan(&w, &ctl, &coll_ctl); }); } } /// Reload the grid after the filter changed. /// /// The offset is reset because it is an ordinal into the filtered set: keeping /// it would land the user in the middle of a narrowed library with no sense of /// how they got there, or past its end entirely. fn refilter(window: &AppWindow, ctl: &Rc) { *ctl.offset.borrow_mut() = 0; ctl.requested.borrow_mut().clear(); load_window(window, ctl); } fn stop(slot: &RefCell>) { 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 { 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 = (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 { let rows: Vec = 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 = (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 { 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, images: &[dr_types::ImageId]) -> Vec { 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) { let f = ctl.filter.borrow(); let text = |t: Option| -> 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))); }