An "Eyes open" chip beside the people chips, offered only while someone is chosen and dropped when the last person goes, so no term narrows the grid with nothing on the bar to say so. It compiles the rule in dr_face::eyes into the person's face subquery — Anna, eyes open, whoever else is blinking beside her — and drops a frame only on a closed eye that could be read: sunglasses, eyes too small or soft to read, and faces never read all pass, so an old library shows everything under the chip until the measuring pass has run. A test drives the same readings through the SQL and through the rule and requires them to agree. The People screen badges a face "Eyes closed", "Sunglasses" or "Eyes unclear" so the reason a frame is or is not in the grid can be read off the face; the sweep loads the three models when they are beside the pair and reads eyes on the indexing and measuring passes from the native render; the coverage line counts unread faces as work to measure so an already-indexed library keeps its Index button. The term travels with the place.
8232 lines
347 KiB
Rust
8232 lines
347 KiB
Rust
//! TRACES: FR-CAT-4 | FR-NC-3 | NFR-P9
|
||
//! Drives the library grid from scan and thumbnail workers.
|
||
//!
|
||
//! Owns the bridge between three background activities and one single-threaded
|
||
//! event loop:
|
||
//!
|
||
//! - a **scan** worker walking the remote tree into the catalog
|
||
//! - a **thumbnail** worker range-fetching previews for visible cells
|
||
//! - the **grid model** Slint renders
|
||
//!
|
||
//! Nothing here blocks. Workers post through mpsc channels drained by Slint
|
||
//! timers, which is the same shape [`crate::launch_ui`] uses for login.
|
||
|
||
use std::cell::RefCell;
|
||
use std::path::PathBuf;
|
||
use std::rc::Rc;
|
||
use std::sync::mpsc::Receiver;
|
||
|
||
use dr_catalog::Catalog;
|
||
use dr_sync::{Account, AccountStore, Connection};
|
||
use dr_types::FormatFilter;
|
||
use slint::{ComponentHandle, Model as _};
|
||
|
||
use crate::library::{self, ScanMessage, ThumbnailMessage};
|
||
use crate::{AppWindow, GestureRow, KeywordRow, LibraryCell, PersonChip, TimelineBar};
|
||
|
||
/// A screenful before the grid has reported its geometry.
|
||
///
|
||
/// Only used for the very first load; the grid replaces it with what it
|
||
/// actually shows as soon as it has laid out.
|
||
const INITIAL_VIEWPORT_CELLS: usize = 20;
|
||
|
||
/// How many screenfuls the loaded window spans.
|
||
///
|
||
/// One of them is on screen, so this buys `SCREENFULS - 1` screenfuls of
|
||
/// loaded-but-undrawn cells to scroll into, split unevenly by
|
||
/// [`window_start`]: a quarter of the window above the view, the rest below,
|
||
/// because a grid is read downward.
|
||
///
|
||
/// Owned here rather than in the grid, alongside [`window_move`]'s rule for
|
||
/// how close the view may come to an edge before the window follows. Split
|
||
/// across the two — the grid loading three screenfuls, Rust holding on until
|
||
/// the view was three quarters of the way through them — the two numbers
|
||
/// disagreed by a quarter of a screenful, and that quarter was rows on screen
|
||
/// that no loaded cell covered. Which is the bottom row of the grid going
|
||
/// blank.
|
||
const SCREENFULS: usize = 4;
|
||
|
||
/// Never load fewer than this, whatever the viewport reports.
|
||
///
|
||
/// A window collapsed to a sliver would otherwise load one or two cells and
|
||
/// re-query on every scroll tick.
|
||
const MIN_WINDOW: usize = 24;
|
||
|
||
/// Cell-size bounds for the grid's zoom.
|
||
///
|
||
/// The lower end is where a thumbnail stops being recognisable; the upper is
|
||
/// where one screen holds so few that the grid stops being a grid. Past 256px
|
||
/// the large thumbnail class is fetched, so the top of this range is sharp
|
||
/// rather than upscaled.
|
||
const MIN_CELL_SIZE: f32 = 90.0;
|
||
const MAX_CELL_SIZE: f32 = 420.0;
|
||
|
||
/// TRACES: NFR-P5
|
||
/// What the grid's whole-library readouts are an answer about.
|
||
///
|
||
/// The timeline's bars, the filter chips' counts, the "on this device" count
|
||
/// and whether the scoped collection is pinned all describe the *library*, not
|
||
/// the window over it — and [`load_window`] recomputed every one of them each
|
||
/// time the window moved, which is several times per screenful of scrolling.
|
||
/// Together they are a `MIN`/`MAX`, a `GROUP BY`, two counts and, under a
|
||
/// collection, three more queries: about 8 ms of SQLite on the thread that is
|
||
/// trying to draw the frame, for four answers that scrolling cannot change.
|
||
///
|
||
/// So they are recomputed when this changes and not otherwise. `total` is in
|
||
/// here as the change detector as much as anything else: it is already read on
|
||
/// every load for the scrollbar, and a scan landing, a delete or a restore all
|
||
/// move it. What it cannot see — a rating edited under an unchanged count — is
|
||
/// covered because the paths that do that refresh the chips themselves.
|
||
/// Not `Copy` since `RatingFilter` stopped being — it holds a set of people.
|
||
#[derive(Clone, PartialEq, Eq)]
|
||
struct LibraryFacts {
|
||
scope: Option<dr_types::CollectionId>,
|
||
filter: library::RatingFilter,
|
||
trash: bool,
|
||
total: usize,
|
||
}
|
||
|
||
/// Library state for the running window.
|
||
pub struct LibraryController {
|
||
/// Shared with [`crate::collections_ui`], which edits collections against
|
||
/// the same connection. `Rc` rather than a second `Catalog::open`: two
|
||
/// handles on one SQLite file would each hold their own WAL view, so a
|
||
/// collection edited through one would not be visible through the other
|
||
/// until it committed and the reader reopened.
|
||
catalog: Rc<RefCell<Option<Catalog>>>,
|
||
/// Remote paths for the rows currently in the model, parallel to it.
|
||
paths: RefCell<Vec<String>>,
|
||
/// `oc:fileid` and file length per row, parallel to the model.
|
||
file_ids: RefCell<Vec<Option<u64>>>,
|
||
sizes: RefCell<Vec<u64>>,
|
||
/// Catalog row ids and whether each still needs its EXIF read.
|
||
image_ids: RefCell<Vec<i64>>,
|
||
needs_metadata: RefCell<Vec<bool>>,
|
||
/// When each row in the model was taken, parallel to it.
|
||
///
|
||
/// Kept so the timeline marker can be moved from the window the grid has
|
||
/// already read rather than from a query per scroll event — see
|
||
/// [`capture_time_at`].
|
||
captured_at: RefCell<Vec<Option<i64>>>,
|
||
/// The size class of the pixels each row is currently showing, `None` for a
|
||
/// row still waiting.
|
||
///
|
||
/// Parallel to the model, and the reason [`load_window`] can carry a
|
||
/// thumbnail across a reload without re-deciding whether it is sharp
|
||
/// enough: a cell holding the 256px class while the grid has since been
|
||
/// zoomed past that must keep showing what it has *and* still ask for the
|
||
/// large one. Recording the class the pixels came from is what tells those
|
||
/// two states apart — without it a carried thumbnail either blocks the
|
||
/// sharper fetch forever or is re-fetched on every scroll.
|
||
thumb_class: RefCell<Vec<Option<dr_thumbs::ThumbSize>>>,
|
||
/// Where in the catalog the current window starts. Scrubbing moves this.
|
||
offset: RefCell<usize>,
|
||
/// The first visible ordinal, kept so returning from the develop view lands
|
||
/// where the user left rather than at the top.
|
||
///
|
||
/// Recorded when the grid is left, not read back when it is re-entered:
|
||
/// `show-library` gates an `if` in the markup, so the whole grid subtree is
|
||
/// destroyed and rebuilt, and the rebuilt `Flickable` reports a scroll to
|
||
/// row 0 before anything can restore the old position. Capturing at the
|
||
/// moment of departure is the only value the reset cannot overwrite.
|
||
///
|
||
/// Separate from `offset`, which is the *loaded window*'s start and sits a
|
||
/// quarter-window above the view. Restoring that as a viewport position
|
||
/// would land the user consistently short of where they were.
|
||
resume_at: std::cell::Cell<usize>,
|
||
/// How many cells to load, derived from what the viewport can show.
|
||
///
|
||
/// A fixed count is wrong in both directions: too small on a maximised 4K
|
||
/// window, wasteful on a narrow one. The grid measures itself and reports
|
||
/// its screenful; this is [`SCREENFULS`] of them.
|
||
window: RefCell<usize>,
|
||
/// What the whole-library readouts on screen were last computed for, so a
|
||
/// window that merely moved does not recompute them. See [`LibraryFacts`].
|
||
library_facts: RefCell<Option<LibraryFacts>>,
|
||
/// How many cells the viewport shows at once, as the grid last reported.
|
||
///
|
||
/// Kept beside `window` rather than divided back out of it, because
|
||
/// `MIN_WINDOW` can hold the window above what the screenful implies — and
|
||
/// a screenful guessed too small is exactly the mistake that leaves the
|
||
/// bottom row of the grid outside the loaded window.
|
||
viewport_cells: std::cell::Cell<usize>,
|
||
/// Which rows already have a thumbnail fetch issued, so scrolling back
|
||
/// does not refetch what is already on screen.
|
||
/// Which images have a fetch in flight or already served, keyed on
|
||
/// `(image_id, size class)`.
|
||
///
|
||
/// **Identity, not row index.** The grid is a window over the catalog, so
|
||
/// row 7 means a different photograph after every scroll — a set of
|
||
/// indices had to be cleared on each window move, which made every visible
|
||
/// cell look unrequested and re-issued the whole screenful on every
|
||
/// scroll.
|
||
///
|
||
/// The size class is part of the key because the two resolutions are
|
||
/// fetched independently: holding the 256px one says nothing about whether
|
||
/// the large one has been asked for.
|
||
requested: RefCell<std::collections::HashSet<(i64, dr_thumbs::ThumbSize)>>,
|
||
/// Drains the worker that opens and verifies the catalog — see
|
||
/// [`spawn_catalog_open`]. Held for the same reason every other timer here
|
||
/// is: a `slint::Timer` stops when it is dropped, so a local one would
|
||
/// never fire.
|
||
catalog_timer: RefCell<Option<slint::Timer>>,
|
||
scan_timer: RefCell<Option<slint::Timer>>,
|
||
thumb_timer: RefCell<Option<slint::Timer>>,
|
||
/// The whole-library sweep, which outlives any one grid window.
|
||
sweep_timer: RefCell<Option<slint::Timer>>,
|
||
/// TRACES: FR-CAT-3
|
||
/// Drains the whole-library thumbnail pass. Held for the same reason as
|
||
/// the others, and read as well as written: it is what stops a second
|
||
/// press of the button starting a pass alongside the first.
|
||
thumb_sweep_timer: RefCell<Option<slint::Timer>>,
|
||
/// Pushing shards and the catalog to the server.
|
||
sync_timer: RefCell<Option<slint::Timer>>,
|
||
/// Kept so a rescan can run without going back through the launch screen.
|
||
///
|
||
/// A [`Connection`] rather than credentials beside an account: it is what
|
||
/// every worker needs, it is what `remote::connect` takes, and holding the
|
||
/// two halves separately is how they came to be threaded through fifteen
|
||
/// signatures in the wrong order.
|
||
session: RefCell<Option<(Connection, FormatFilter)>>,
|
||
/// Which collection narrows the grid, owned by [`crate::collections_ui`]
|
||
/// and read here. Shared rather than passed per call because a rescan, a
|
||
/// scrub and a drop all reload the window and must all honour it.
|
||
scope: RefCell<Option<dr_types::CollectionId>>,
|
||
/// TRACES: FR-UI-8
|
||
/// How to open a photograph in develop.
|
||
///
|
||
/// Held so a restored place that was left in develop can put the user back
|
||
/// there. The closure is the grid's own — `wire` is handed it and stashes a
|
||
/// clone — rather than a second route into the develop view, which would be
|
||
/// a second place for "persist the outgoing edit first" to be forgotten.
|
||
open_image: RefCell<Option<OpenImage>>,
|
||
/// Where this library's position is written, once one is open.
|
||
///
|
||
/// `None` before a library has been opened, and on the local-files path,
|
||
/// which has no library to have a position in.
|
||
place_store: RefCell<Option<crate::place::PlaceStore>>,
|
||
/// What this device calls itself in a place it writes.
|
||
///
|
||
/// [`dr_thumbs::ThumbStore::client_id`], so the place and the thumbnail
|
||
/// shards a device publishes carry the same name — which is what makes
|
||
/// `.darkroom-derived/` readable by a human wondering which machine put
|
||
/// what there. Informational only: see [`crate::place`] on why the
|
||
/// timestamp, not the device, decides an exchange.
|
||
place_device: RefCell<String>,
|
||
/// Coalesces the writes a flick would otherwise make one of per event.
|
||
place_timer: RefCell<Option<slint::Timer>>,
|
||
/// Drains the launch-time fetch of the server's place. Held for the reason
|
||
/// every other timer here is: a `slint::Timer` stops when it is dropped.
|
||
place_fetch_timer: RefCell<Option<slint::Timer>>,
|
||
/// Whether a place is being applied right now.
|
||
///
|
||
/// A restore moves the scope, the filter and the viewport, and every one of
|
||
/// those is something [`note_place`] otherwise treats as the photographer
|
||
/// having moved. Without this a restore would close its own handover latch
|
||
/// and, worse, write the record back out under a fresh timestamp — so the
|
||
/// device that had just *adopted* a place from the tablet would claim to be
|
||
/// the newer of the two.
|
||
applying_place: std::cell::Cell<bool>,
|
||
/// Whether the photographer has moved since this library was opened.
|
||
///
|
||
/// The latch that decides whether a place arriving from another device may
|
||
/// still be applied. A handover is only welcome before the user has started
|
||
/// working: a grid that jumped somewhere else *while being scrolled*, because
|
||
/// a network round-trip finally landed, would be worse than never handing
|
||
/// over at all.
|
||
place_untouched: std::cell::Cell<bool>,
|
||
/// TRACES: FR-CAT-15
|
||
/// Whether the grid is listing the trash rather than the library.
|
||
///
|
||
/// Separate from `scope` rather than a sentinel id in it, for the reason
|
||
/// [`crate::collections_ui::CollectionsController`] gives: the trash is not
|
||
/// a collection, and its query *inverts* the predicate every other view
|
||
/// applies. Folding that into a type meaning "a collection" would put the
|
||
/// inversion where nothing reading `scope` expects it.
|
||
///
|
||
/// A `Cell` because it is a `bool` read inside callbacks that already hold
|
||
/// other borrows.
|
||
viewing_trash: std::cell::Cell<bool>,
|
||
/// The selection's owner, so a rebuilt window can restore the `selected`
|
||
/// flags it just cleared.
|
||
///
|
||
/// `Weak` because the two controllers outlive each other only through the
|
||
/// window, and an `Rc` both ways would leak both. Set once at wiring; a
|
||
/// `None` here means the grid simply draws nothing selected, which is the
|
||
/// old behaviour rather than a crash.
|
||
coll_ctl: RefCell<Option<std::rc::Weak<crate::collections_ui::CollectionsController>>>,
|
||
/// Timeline view state: how far zoomed in, and around what instant.
|
||
///
|
||
/// Zoom is a level rather than a span so the axis halves and doubles in
|
||
/// even steps; the centre is what keeps the thing you were looking at in
|
||
/// view as you zoom.
|
||
timeline_zoom: RefCell<i32>,
|
||
timeline_centre: RefCell<Option<i64>>,
|
||
/// Pinch ratio accumulated since the last zoom step was taken.
|
||
///
|
||
/// A pinch is continuous and zoom levels are discrete, so the ratio is
|
||
/// held until it reaches a doubling. Without it a slow spread would either
|
||
/// do nothing or, if each update were rounded, leap several levels.
|
||
pinch_accum: RefCell<f32>,
|
||
/// The instant the grid is showing.
|
||
///
|
||
/// `None` only before the first axis has been built: `refresh_timeline`
|
||
/// seeds it from wherever the view sits, so the marker reports a position
|
||
/// from the first frame rather than resting greyed at mid-track until the
|
||
/// user happens to scroll. A scroll or a scrub then sets it directly.
|
||
current_bucket: RefCell<Option<i64>>,
|
||
/// What the rating filter bar is narrowed to.
|
||
///
|
||
/// Held here beside `scope` and for the same reason: a scrub, a rescan and
|
||
/// a drop all reload the window, and every one of them must honour it or
|
||
/// the filter silently lapses.
|
||
filter: RefCell<library::RatingFilter>,
|
||
/// TRACES: FR-CAT-9
|
||
/// Drains the outbox uploader. Held so that a repaint while one is
|
||
/// already running does not start a second against the same channel —
|
||
/// this handle *is* the "a drain is in flight" state.
|
||
outbox_timer: RefCell<Option<slint::Timer>>,
|
||
/// Whether the outbox might hold something, so the common case costs a
|
||
/// boolean rather than a directory walk.
|
||
///
|
||
/// `refresh_offline` runs on scan progress as well as on a genuine
|
||
/// connectivity change, so the drain is *asked* far more often than there
|
||
/// is anything to send. Starts `true` so the first ask after launch does
|
||
/// walk — edits queued in a previous session are exactly the ones that
|
||
/// need sending, and nothing in memory knows about them.
|
||
outbox_maybe_dirty: std::cell::Cell<bool>,
|
||
/// Drains the sidecar writer. Held so a second judgement replaces the
|
||
/// timer rather than leaving two draining the same finished channel.
|
||
sidecar_timer: RefCell<Option<slint::Timer>>,
|
||
/// TRACES: FR-CAT-13
|
||
/// The same, for a batch of XMP writes or a reload.
|
||
xmp_timer: RefCell<Option<slint::Timer>>,
|
||
/// Which window load the model belongs to, bumped by [`load_window`].
|
||
///
|
||
/// A thumbnail worker addresses cells by *row index into the window that
|
||
/// asked for it*. A reload replaces the model, so once the generation has
|
||
/// moved on every row still in flight names a different photograph —
|
||
/// applying it paints thumbnails onto unrelated cells, or marks a cell
|
||
/// "no preview" for a fetch never attempted against it. Worse, a stale
|
||
/// drain reaching `Disconnected` would call `stop` on `thumb_timer`, which
|
||
/// by then holds the *current* batch's timer: the new worker then fetched
|
||
/// into a channel nobody drained and the grid stayed black until a scroll
|
||
/// forced another load.
|
||
///
|
||
/// A `Cell` rather than a `RefCell`: it is read inside a timer callback
|
||
/// that already holds borrows of other fields, and a `u64` needs no borrow
|
||
/// tracking.
|
||
generation: std::cell::Cell<u64>,
|
||
/// TRACES: FR-CAT-9
|
||
/// Whether the server is reachable, inferred from what the workers saw.
|
||
///
|
||
/// Lives on the controller rather than in a global because it is scoped to
|
||
/// one open library: signing into a different account starts a fresh
|
||
/// judgement, and carrying the old one over would report a server down
|
||
/// that was never contacted.
|
||
reachability: RefCell<dr_sync::Reachability>,
|
||
/// TRACES: FR-PLAT-AND-2 | FR-CAT-9
|
||
/// Why the library folder itself could not be opened, if it could not.
|
||
///
|
||
/// Beside [`Self::reachability`] rather than inside it, because
|
||
/// `dr_sync::Reachability` models *the server*, and it is deliberately
|
||
/// unmoved by a refusal — a forbidden file must not report the network as
|
||
/// down (see its own tests). A revoked tree grant is a refusal, so folding
|
||
/// it in would either break that rule or need an exception carved through
|
||
/// it.
|
||
///
|
||
/// Set only by a scan that failed at the root, and cleared only by one
|
||
/// that succeeded. Both states drive the same banner as being offline
|
||
/// does, because what the user can do is the same — carry on with what is
|
||
/// stored on the device — but the sentence under it is different, and so
|
||
/// is what will end it.
|
||
root_lost: RefCell<Option<String>>,
|
||
/// TRACES: FR-NC-6a
|
||
/// Drains the pin downloader. Held so a second pin replaces the timer
|
||
/// rather than leaving two draining the same finished channel.
|
||
/// Coalesces the reloads a run of geometry changes would otherwise each
|
||
/// demand — see [`schedule_reload`].
|
||
geometry_timer: RefCell<Option<slint::Timer>>,
|
||
/// The ordinal the view was on when the current run of geometry changes
|
||
/// began, so the settle returns to the photograph the user was looking at
|
||
/// rather than to wherever the re-flow left the viewport.
|
||
pending_anchor: std::cell::Cell<Option<usize>>,
|
||
pin_timer: RefCell<Option<slint::Timer>>,
|
||
/// TRACES: FR-NC-6a
|
||
/// Which collection the offline question is being asked about.
|
||
///
|
||
/// Held rather than passed through the window because the prompt's three
|
||
/// answers arrive as three separate callbacks, and a dialogue that read its
|
||
/// subject back out of a string property would act on whatever the sidebar
|
||
/// had been rebuilt to say since.
|
||
offline_target: std::cell::Cell<Option<dr_types::CollectionId>>,
|
||
/// Narrow the grid to images whose original is stored locally.
|
||
///
|
||
/// A `Cell` beside `filter` rather than a field inside it: the rating
|
||
/// filter compiles to a SQL predicate over `versions`, while this one is a
|
||
/// predicate over the cache, and folding two different joins into one type
|
||
/// would put the cache schema inside a rating concept.
|
||
local_only: std::cell::Cell<bool>,
|
||
/// TRACES: FR-NC-6a
|
||
/// The ceiling on passively cached originals, from the settings page.
|
||
///
|
||
/// Held here rather than read from `SettingsStore` at each use because the
|
||
/// workers that need it run on threads with no config access — the same
|
||
/// reason [`library::CacheContext`] takes it as a field. A `Cell` because
|
||
/// the settings page can move it while a library is open, and the next
|
||
/// fetch must use the new figure rather than one captured at open.
|
||
cache_budget: std::cell::Cell<dr_catalog::Budget>,
|
||
/// TRACES: FR-NC-6a
|
||
/// Whether opening an image in develop keeps its original on disk.
|
||
///
|
||
/// Held beside the budget and for the same reason. Distinct from a zero
|
||
/// budget: this switches the passive population off entirely, while a
|
||
/// small budget still keeps a working set. A metered or small-disk device
|
||
/// wants the first.
|
||
keep_opened: std::cell::Cell<bool>,
|
||
/// TRACES: FR-NC-6a | FR-UI-4
|
||
/// How many photographs each side of the open one are fetched ahead.
|
||
/// Mirrors `CacheSettings::fetch_ahead`, held beside the flag that
|
||
/// gates it.
|
||
fetch_ahead: std::cell::Cell<u32>,
|
||
/// TRACES: FR-CAT-13 | NFR-R4
|
||
/// Whether judgements and keywords also go to the `.xmp` beside the
|
||
/// original. Mirrors `LibrarySettings::write_xmp_sidecars`.
|
||
write_xmp: std::cell::Cell<bool>,
|
||
/// TRACES: FR-CAT-6
|
||
/// How many bars the capture-time axis is cut into, from the settings
|
||
/// page.
|
||
///
|
||
/// Held here beside the cache budget and for the same reason: the axis is
|
||
/// redrawn from a dozen callbacks that have no config access, and reading
|
||
/// a JSON file on every scroll to answer "how many bars" would be the
|
||
/// wrong shape of question. A `Cell` because the page can change it while
|
||
/// a library is open.
|
||
timeline_bars: std::cell::Cell<u32>,
|
||
/// TRACES: FR-CULL-8
|
||
/// Which face pipeline this device indexes with, from the settings page.
|
||
///
|
||
/// Held here for the same reason as the two above: the derived sync runs
|
||
/// from a sweep's completion and from the Sync button, neither of which
|
||
/// has the settings in reach, and the shards it exports and adopts are
|
||
/// keyed on this id. A `RefCell` of the id rather than the enum so that
|
||
/// nothing here has to know how a detector becomes a model id.
|
||
face_model_id: RefCell<String>,
|
||
/// Where every worker this module starts reports what it is doing.
|
||
///
|
||
/// Held on the controller rather than passed to each function because the
|
||
/// jobs are started from a dozen callbacks — a scroll, a rescan, a pin, a
|
||
/// reconnect — and threading a second argument through all of them would
|
||
/// say nothing except that they all report progress.
|
||
activity: Rc<crate::activity::ActivityLog>,
|
||
}
|
||
|
||
impl LibraryController {
|
||
pub fn new(activity: Rc<crate::activity::ActivityLog>) -> Rc<Self> {
|
||
Rc::new(Self {
|
||
activity,
|
||
catalog: Rc::new(RefCell::new(None)),
|
||
paths: RefCell::new(Vec::new()),
|
||
file_ids: RefCell::new(Vec::new()),
|
||
sizes: RefCell::new(Vec::new()),
|
||
image_ids: RefCell::new(Vec::new()),
|
||
needs_metadata: RefCell::new(Vec::new()),
|
||
captured_at: RefCell::new(Vec::new()),
|
||
thumb_class: RefCell::new(Vec::new()),
|
||
offset: RefCell::new(0),
|
||
resume_at: std::cell::Cell::new(0),
|
||
window: RefCell::new((INITIAL_VIEWPORT_CELLS * SCREENFULS).max(MIN_WINDOW)),
|
||
viewport_cells: std::cell::Cell::new(INITIAL_VIEWPORT_CELLS),
|
||
library_facts: RefCell::new(None),
|
||
requested: RefCell::new(Default::default()),
|
||
catalog_timer: RefCell::new(None),
|
||
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),
|
||
open_image: RefCell::new(None),
|
||
place_store: RefCell::new(None),
|
||
place_device: RefCell::new(String::new()),
|
||
place_timer: RefCell::new(None),
|
||
place_fetch_timer: RefCell::new(None),
|
||
place_untouched: std::cell::Cell::new(true),
|
||
applying_place: std::cell::Cell::new(false),
|
||
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),
|
||
xmp_timer: RefCell::new(None),
|
||
generation: std::cell::Cell::new(0),
|
||
reachability: RefCell::new(dr_sync::Reachability::new()),
|
||
root_lost: RefCell::new(None),
|
||
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),
|
||
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,
|
||
),
|
||
fetch_ahead: std::cell::Cell::new(dr_types::CacheSettings::default().fetch_ahead),
|
||
write_xmp: std::cell::Cell::new(
|
||
dr_types::LibrarySettings::default().write_xmp_sidecars,
|
||
),
|
||
timeline_bars: std::cell::Cell::new(dr_types::LibrarySettings::default().timeline_bars),
|
||
face_model_id: RefCell::new(dr_types::FaceDetector::default().model_id().to_string()),
|
||
})
|
||
}
|
||
|
||
/// TRACES: FR-CULL-8
|
||
/// Which face pipeline the next sync exports and adopts under.
|
||
///
|
||
/// Set on every settings edit like the bars above. A sync already running
|
||
/// keeps the id it started with, which is right: its shards are half
|
||
/// written under that one.
|
||
pub fn set_face_model_id(&self, id: &str) {
|
||
if *self.face_model_id.borrow() != id {
|
||
*self.face_model_id.borrow_mut() = id.to_string();
|
||
}
|
||
}
|
||
|
||
/// 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 | FR-UI-4
|
||
/// How far along the roll, each side, the next open fetches ahead.
|
||
pub fn set_fetch_ahead(&self, depth: u32) {
|
||
self.fetch_ahead.set(depth);
|
||
}
|
||
|
||
pub fn fetch_ahead(&self) -> u32 {
|
||
self.fetch_ahead.get()
|
||
}
|
||
|
||
/// TRACES: FR-CAT-13 | NFR-R4
|
||
pub fn set_write_xmp_sidecars(&self, on: bool) {
|
||
self.write_xmp.set(on);
|
||
}
|
||
|
||
/// TRACES: FR-NC-6a
|
||
/// Set the ceiling on passively cached originals, and apply it now.
|
||
///
|
||
/// Applied immediately rather than only to later downloads: a user who has
|
||
/// just lowered the limit expects the space back, and a budget that took
|
||
/// effect only on the next fetch would leave the cache over its stated
|
||
/// ceiling for as long as they browsed nothing new.
|
||
pub fn set_cache_budget(&self, bytes: Option<u64>) {
|
||
let budget = match bytes {
|
||
Some(n) => dr_catalog::Budget::bytes(n),
|
||
None => dr_catalog::Budget::unlimited(),
|
||
};
|
||
self.cache_budget.set(budget);
|
||
|
||
// Best-effort: no library open means no cache to trim, and a failure
|
||
// here must not stop the setting from being stored — the ceiling still
|
||
// applies to every fetch from now on.
|
||
let Some(dir) = self.cache_dir() else { return };
|
||
let borrow = self.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
match dr_catalog::Cache::open(&dir, budget)
|
||
.and_then(|cache| cache.enforce(catalog.connection()))
|
||
{
|
||
Ok(0) => {}
|
||
Ok(n) => log::info!("cache budget changed: evicted {n} original(s)"),
|
||
Err(e) => log::warn!("applying the new cache budget: {e}"),
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-CAT-9 | FR-PLAT-AND-2
|
||
/// Whether the library cannot be reached, for either of the two reasons.
|
||
///
|
||
/// One answer rather than two because every caller asks it for the same
|
||
/// purpose: to decide whether starting a transfer is worth attempting.
|
||
/// A revoked grant fails that question exactly as a dead network does, and
|
||
/// a sync started against it would spend its retries proving it.
|
||
pub fn is_offline(&self) -> bool {
|
||
self.reachability.borrow().is_offline() || self.root_lost.borrow().is_some()
|
||
}
|
||
|
||
/// Whether the grid is narrowed to locally-stored originals.
|
||
pub fn local_only(&self) -> bool {
|
||
self.local_only.get()
|
||
}
|
||
|
||
/// TRACES: FR-NC-6a
|
||
/// The catalog id for a remote path currently in the grid.
|
||
///
|
||
/// The cache is keyed on `ImageId` because that is what survives a
|
||
/// server-side rename (FR-NC-5), while the develop callback carries only a
|
||
/// path. `paths` and `image_ids` are parallel to the model, so this is the
|
||
/// join between the two.
|
||
pub fn image_id_for_path(&self, path: &str) -> Option<dr_types::ImageId> {
|
||
let index = self.paths.borrow().iter().position(|p| p == path)?;
|
||
self.image_ids
|
||
.borrow()
|
||
.get(index)
|
||
.map(|id| dr_types::ImageId(*id as u64))
|
||
}
|
||
|
||
/// TRACES: FR-NC-6a | FR-UI-4
|
||
/// The photographs within `depth` of `path` on the roll, closest first
|
||
/// and working outwards: next, previous, next-but-one, previous-but-one…
|
||
///
|
||
/// That order is the point. The prefetcher serves the list one at a time
|
||
/// and drops the rest the moment the user moves, so whatever depth is
|
||
/// set, the frame most likely to be stepped to is the first thing
|
||
/// fetched — and the step most likely is one forward, which is why next
|
||
/// leads previous at every distance.
|
||
///
|
||
/// In the roll's order, which is the loaded window's — the same rows a
|
||
/// roll pick names — so what gets fetched ahead is exactly what a step
|
||
/// lands on, filter and scope included. The window's edges cut the list
|
||
/// short; a photograph not in the window has no neighbours, and a
|
||
/// prefetch of nothing is the right answer for it.
|
||
pub fn neighbours_of(&self, path: &str, depth: u32) -> Vec<String> {
|
||
let paths = self.paths.borrow();
|
||
let Some(at) = paths.iter().position(|p| p == path) else {
|
||
return Vec::new();
|
||
};
|
||
(1..=depth as usize)
|
||
.flat_map(|d| [at.checked_add(d), at.checked_sub(d)])
|
||
.filter_map(|i| paths.get(i?).cloned())
|
||
.collect()
|
||
}
|
||
|
||
/// TRACES: FR-NC-8 | FR-DEV-6
|
||
/// The default version's uuid for an image, by its remote path.
|
||
///
|
||
/// Taken from the catalog rather than generated, and rather than read off
|
||
/// whatever the sidecar happens to contain. The uuid is the identity a
|
||
/// cross-device merge keys on (FR-NC-8): a develop session that invented
|
||
/// one would write a *second* version beside the one the cull is stored
|
||
/// in, and the photograph would arrive on the other device holding two
|
||
/// edits that never merge.
|
||
///
|
||
/// Creates the version if the image has none, by the same route a rating
|
||
/// does — see `dr_catalog::rating::default_version_id` for why an image
|
||
/// can legitimately arrive without one.
|
||
pub fn version_uuid_for_path(&self, path: &str) -> Option<String> {
|
||
let image = self.image_id_for_path(path)?;
|
||
let borrow = self.catalog.borrow();
|
||
let catalog = borrow.as_ref()?;
|
||
let conn = catalog.connection();
|
||
|
||
let id = match dr_catalog::rating::default_version_id(conn, image) {
|
||
Ok(id) => id,
|
||
Err(e) => {
|
||
log::debug!("no version for {path}: {e}");
|
||
return None;
|
||
}
|
||
};
|
||
conn.query_row("SELECT uuid FROM versions WHERE id = ?1", [id], |r| {
|
||
r.get(0)
|
||
})
|
||
.ok()
|
||
}
|
||
|
||
/// TRACES: FR-CAT-9 | FR-NC-10
|
||
/// Where cached sidecars and the upload outbox live for the open library.
|
||
///
|
||
/// Beside the catalog and the originals cache, for the same reason those
|
||
/// two sit together: all three are per-account and are discarded together.
|
||
/// A separate directory rather than a subfolder of `originals` because the
|
||
/// two have opposite lifetimes — originals are evicted under a budget
|
||
/// (FR-NC-6a), and a queued edit must never be.
|
||
pub fn sidecar_cache_dir(&self) -> Option<PathBuf> {
|
||
let borrow = self.session.borrow();
|
||
let (conn, _) = borrow.as_ref()?;
|
||
library::catalog_path(&conn.account)
|
||
.parent()
|
||
.map(|p| p.join("sidecars"))
|
||
}
|
||
|
||
/// TRACES: FR-NC-6a
|
||
/// Where cached originals live for the open library.
|
||
///
|
||
/// Beside the catalog rather than under a system cache directory: the two
|
||
/// are per-account and are discarded together, and a cached original whose
|
||
/// catalog row is gone is unreachable anyway.
|
||
pub fn cache_dir(&self) -> Option<PathBuf> {
|
||
let borrow = self.session.borrow();
|
||
let (conn, _) = borrow.as_ref()?;
|
||
library::catalog_path(&conn.account)
|
||
.parent()
|
||
.map(|p| p.join("originals"))
|
||
}
|
||
|
||
/// Open the originals cache for the current library.
|
||
///
|
||
/// Used by pinning, which writes intent against the catalog directly
|
||
/// rather than going through a fetch.
|
||
pub fn cache(&self) -> Option<dr_catalog::Cache> {
|
||
let dir = self.cache_dir()?;
|
||
match dr_catalog::Cache::open(&dir, self.cache_budget.get()) {
|
||
Ok(c) => Some(c),
|
||
Err(e) => {
|
||
// Not fatal: without a cache every open is a download, which
|
||
// is exactly the behaviour that existed before this.
|
||
log::warn!("originals cache unavailable: {e}");
|
||
None
|
||
}
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-NC-6a
|
||
/// Everything a full fetch needs to read and write the cache.
|
||
///
|
||
/// Assembled here because the develop callback holds only a path, while
|
||
/// the cache is keyed on `ImageId` and lives beside a catalog whose
|
||
/// location comes from the session. `None` where any part is missing — a
|
||
/// grid row that has scrolled away, or no library open — and the fetch
|
||
/// then simply goes to the network.
|
||
pub fn cache_context(&self, path: &str) -> Option<library::CacheContext> {
|
||
self.cache_context_for(self.image_id_for_path(path)?)
|
||
}
|
||
|
||
/// TRACES: FR-EXP-7 | FR-NC-6a
|
||
/// The same, for an image named by id rather than by path.
|
||
///
|
||
/// A batch export needs this one: its selection is by catalog id and may
|
||
/// include photographs that have scrolled out of the loaded window, where
|
||
/// [`Self::image_id_for_path`] has nothing to match against. Going through
|
||
/// the path would quietly hand those images no cache at all, and a batch of
|
||
/// three hundred would re-download every one of them.
|
||
pub fn cache_context_for(&self, image: dr_types::ImageId) -> Option<library::CacheContext> {
|
||
let borrow = self.session.borrow();
|
||
let (conn, _) = borrow.as_ref()?;
|
||
let catalog_path = library::catalog_path(&conn.account);
|
||
let dir = catalog_path.parent()?.join("originals");
|
||
drop(borrow);
|
||
|
||
Some(library::CacheContext {
|
||
dir,
|
||
catalog_path,
|
||
image,
|
||
// The user's ceiling, not the catalog's floor: read at each fetch
|
||
// so a budget changed mid-session takes effect on the next one.
|
||
budget: self.cache_budget.get(),
|
||
// Gates the *write* only. Reading stays enabled either way: bytes
|
||
// already on disk were paid for, and refusing to use them because
|
||
// the user has since stopped adding new ones would re-download
|
||
// images that are sitting right there — and would make a pinned
|
||
// collection unopenable offline.
|
||
store: self.keep_opened.get(),
|
||
})
|
||
}
|
||
|
||
/// Toggle the local-only filter, resetting the window.
|
||
///
|
||
/// The offset is cleared for the same reason a scope change clears it: the
|
||
/// filter changes which images exist as far as the grid is concerned, so a
|
||
/// position counted against the old set names a different photograph.
|
||
pub fn set_local_only(&self, on: bool) {
|
||
self.local_only.set(on);
|
||
*self.offset.borrow_mut() = 0;
|
||
self.requested.borrow_mut().clear();
|
||
}
|
||
|
||
/// The catalog handle, for [`crate::collections_ui`] to edit through.
|
||
pub fn catalog(&self) -> Rc<RefCell<Option<Catalog>>> {
|
||
self.catalog.clone()
|
||
}
|
||
|
||
/// The open library's connection.
|
||
///
|
||
/// Needed by the trash, whose move and delete go to the same account the
|
||
/// scan and thumbnail workers use. `None` before a library is opened.
|
||
pub fn session(&self) -> Option<Connection> {
|
||
self.session.borrow().as_ref().map(|(c, _)| c.clone())
|
||
}
|
||
|
||
/// Catalog ids of the rows currently in the model, in model order.
|
||
///
|
||
/// Selection is keyed on these rather than on row indices: the grid is a
|
||
/// window over the catalog and a scrub replaces every row, so an index
|
||
/// would silently come to mean a different photograph.
|
||
pub fn visible_ids(&self) -> Vec<dr_types::ImageId> {
|
||
self.image_ids
|
||
.borrow()
|
||
.iter()
|
||
.map(|id| dr_types::ImageId(*id as u64))
|
||
.collect()
|
||
}
|
||
|
||
/// TRACES: FR-CAT-5
|
||
/// Catalog ids of grid rows `first..=last`, loaded or not.
|
||
///
|
||
/// The counterpart to [`Self::visible_ids`], and the reason both exist: a
|
||
/// shift-click names a run by its two ends, and everything between them is
|
||
/// usually off screen. Answered from the catalog, through the same scope,
|
||
/// filter and ordering this controller reads its window with — so the run
|
||
/// is the run the user can see themselves selecting, continued past the
|
||
/// edge of what has been loaded.
|
||
///
|
||
/// Empty before a library is open, and empty if the query fails: a
|
||
/// selection gesture is not worth an error dialog, and the caller keeps
|
||
/// what was already selected.
|
||
pub fn ids_in_span(&self, first: usize, last: usize) -> Vec<dr_types::ImageId> {
|
||
let borrow = self.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return Vec::new();
|
||
};
|
||
library::read_ids_span(
|
||
catalog,
|
||
*self.scope.borrow(),
|
||
&self.filter.borrow(),
|
||
self.viewing_trash.get(),
|
||
first,
|
||
last,
|
||
)
|
||
.unwrap_or_else(|e| {
|
||
log::warn!("reading the selected range: {e}");
|
||
Vec::new()
|
||
})
|
||
}
|
||
|
||
/// TRACES: FR-EXP-7
|
||
/// Remote paths for a set of selected images, in the order they were given.
|
||
///
|
||
/// Answered from the catalog rather than from `paths`, which is only the
|
||
/// loaded window. Selection is by id precisely so that it survives a scrub
|
||
/// (see [`crate::collections_ui`]), so a selection made before scrolling
|
||
/// routinely names photographs no row currently holds — and an export that
|
||
/// silently dropped those would be worse than one that refused.
|
||
///
|
||
/// An id the catalog has never heard of is skipped rather than reported: it
|
||
/// can only mean the image was deleted between the selection and the click,
|
||
/// and there is nothing to export and nothing to fix.
|
||
///
|
||
/// Each path comes back beside the id it belongs to, because that skipping
|
||
/// means the ids that come out are not the ids that went in — and the caller
|
||
/// still has to find each photograph's cache, which is keyed on the id.
|
||
pub fn selected_image_paths(
|
||
&self,
|
||
images: &[dr_types::ImageId],
|
||
) -> Vec<(dr_types::ImageId, String)> {
|
||
if images.is_empty() {
|
||
return Vec::new();
|
||
}
|
||
|
||
let borrow = self.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return Vec::new();
|
||
};
|
||
|
||
let placeholders = std::iter::repeat_n("?", images.len())
|
||
.collect::<Vec<_>>()
|
||
.join(",");
|
||
let sql = format!("SELECT id, source_ref FROM images WHERE id IN ({placeholders})");
|
||
let params: Vec<rusqlite::types::Value> = images
|
||
.iter()
|
||
.map(|i| rusqlite::types::Value::Integer(i.0 as i64))
|
||
.collect();
|
||
|
||
let Ok(mut stmt) = catalog.connection().prepare(&sql) else {
|
||
return Vec::new();
|
||
};
|
||
let Ok(rows) = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
||
Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?))
|
||
}) else {
|
||
return Vec::new();
|
||
};
|
||
|
||
// `IN` returns rows in whatever order suits SQLite, and the export's
|
||
// `{seq}` token counts through the batch — so the answer is put back
|
||
// into the order the caller asked in rather than the one it arrived in.
|
||
let found: std::collections::HashMap<i64, String> = rows.flatten().collect();
|
||
images
|
||
.iter()
|
||
.filter_map(|id| found.get(&(id.0 as i64)).map(|path| (*id, path.clone())))
|
||
.collect()
|
||
}
|
||
|
||
/// TRACES: FR-EXP-7
|
||
/// The selection, addressed the way an export worker can fetch it.
|
||
///
|
||
/// Assembled here because a worker thread can reach neither the catalog nor
|
||
/// the session, and both are needed to say where a cached original lives.
|
||
pub fn export_sources(&self, images: &[dr_types::ImageId]) -> Vec<crate::export::Source> {
|
||
self.selected_image_paths(images)
|
||
.into_iter()
|
||
.map(|(id, path)| crate::export::Source::Library {
|
||
path,
|
||
cache: self.cache_context_for(id),
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// The open library's connection, for a full-file fetch.
|
||
///
|
||
/// The grid's paths are remote, so opening an image means fetching it, and
|
||
/// that goes through the same account the thumbnail workers use.
|
||
pub fn credentials(&self) -> Option<Connection> {
|
||
self.session.borrow().as_ref().map(|(c, _)| c.clone())
|
||
}
|
||
|
||
/// Narrow the grid to a collection, or to the whole library with `None`.
|
||
pub fn set_scope(&self, scope: Option<dr_types::CollectionId>) {
|
||
*self.scope.borrow_mut() = scope;
|
||
// Selecting a collection is leaving the trash. Without this, picking a
|
||
// collection while the trash was open would keep listing trashed images
|
||
// under that collection's name.
|
||
self.viewing_trash.set(false);
|
||
// A new scope is a different set of images, so the old window's
|
||
// position and its issued fetches mean nothing.
|
||
*self.offset.borrow_mut() = 0;
|
||
self.requested.borrow_mut().clear();
|
||
}
|
||
|
||
/// TRACES: FR-CAT-15
|
||
/// Show the trash instead of the library.
|
||
///
|
||
/// Clears `scope` as well: the trash is not inside a collection, and leaving
|
||
/// a stale scope set would narrow it to one on the way back out.
|
||
pub fn set_viewing_trash(&self, viewing: bool) {
|
||
self.viewing_trash.set(viewing);
|
||
if viewing {
|
||
*self.scope.borrow_mut() = None;
|
||
}
|
||
*self.offset.borrow_mut() = 0;
|
||
self.requested.borrow_mut().clear();
|
||
}
|
||
}
|
||
|
||
/// Reload the grid for the current scope and offset.
|
||
///
|
||
/// The reload entry point for everything outside this module — a scope change,
|
||
/// or a drop that altered the collection being shown.
|
||
pub fn reload(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
load_window(window, ctl);
|
||
// TRACES: FR-UI-8
|
||
// The scope changed, or the collection under it did. Either way this is the
|
||
// entry point every such change comes through, which is why the record is
|
||
// taken here rather than at each of the callers.
|
||
note_place(window, ctl);
|
||
}
|
||
|
||
/// Open a library: show the grid, start a scan, then fill in thumbnails.
|
||
///
|
||
/// Called from the launch screen's "Open library" button — the callback that
|
||
/// until now only logged its intent.
|
||
pub fn open(
|
||
window: &AppWindow,
|
||
ctl: Rc<LibraryController>,
|
||
coll_ctl: Rc<crate::collections_ui::CollectionsController>,
|
||
store: &AccountStore,
|
||
account: Account,
|
||
) {
|
||
// The credential is fetched only where the connector wants one; a folder
|
||
// library has none, and asking the keyring for it would fail the one
|
||
// backend that needs nothing.
|
||
let conn = match store.connection(&account, crate::remote::needs_secret(&account)) {
|
||
Ok(c) => c,
|
||
Err(e) => {
|
||
window.set_library_error(format!("credentials: {e}").into());
|
||
window.set_show_library(true);
|
||
return;
|
||
}
|
||
};
|
||
|
||
let filter = account.format_filter();
|
||
*ctl.session.borrow_mut() = Some((conn.clone(), filter.clone()));
|
||
|
||
// TRACES: FR-UI-8
|
||
// Where this library's position is kept, and what this device signs it
|
||
// with. Before the catalog opens, because `adopt_catalog` is what reads the
|
||
// record back and it is the next thing to run.
|
||
*ctl.place_store.borrow_mut() = Some(crate::place::PlaceStore::open_at(library::place_path(
|
||
&conn.account,
|
||
)));
|
||
*ctl.place_device.borrow_mut() = device_id(&conn.account);
|
||
// A fresh library is one nobody has moved in yet, so a place arriving from
|
||
// another device may still be applied — see `place_untouched`.
|
||
ctl.place_untouched.set(true);
|
||
|
||
window.set_show_library(true);
|
||
window.set_library_open(true);
|
||
window.set_library_scanning(true);
|
||
window.set_library_error(slint::SharedString::new());
|
||
window.set_library_status("Starting…".into());
|
||
// Always visible: two folders one letter apart are easy to confuse, and a
|
||
// scan of the wrong one is indistinguishable from a broken scan.
|
||
window.set_library_root_label(
|
||
if conn.account.root.is_empty() {
|
||
format!("{} · whole account", conn.account.user_id)
|
||
} else {
|
||
format!("{}/{}", conn.account.user_id, conn.account.root)
|
||
}
|
||
.into(),
|
||
);
|
||
|
||
// An empty filter would walk the whole tree and match nothing, which looks
|
||
// exactly like a broken scan. Say so instead.
|
||
if filter.is_empty() {
|
||
window.set_library_scanning(false);
|
||
window.set_library_error("No formats selected — tick at least one.".into());
|
||
return;
|
||
}
|
||
|
||
let path = library::catalog_path(&conn.account);
|
||
|
||
// 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.
|
||
//
|
||
// And it is the gate on the scan, not merely a prelude to it — a damaged
|
||
// catalog has a question on screen, and a scan writing into it while that
|
||
// question is unanswered is how the last good copy gets destroyed. That
|
||
// gate is why the scan starts from the drain below rather than from here:
|
||
// the answer no longer arrives on the next line.
|
||
// TRACES: FR-UI-8
|
||
// Started beside the catalog open rather than after it, so the handover has
|
||
// a round trip's head start on the user deciding what to look at. It
|
||
// resolves nothing until the catalog is there — see `fetch_remote_place`.
|
||
fetch_remote_place(window, &ctl, &coll_ctl, &conn);
|
||
|
||
open_catalog_soon(window, ctl, coll_ctl, conn, filter, path);
|
||
}
|
||
|
||
/// Open the catalog on a worker, then show it and start the scan.
|
||
///
|
||
/// # Why this is not `show_catalog_now` on the spot
|
||
///
|
||
/// It was, and it was the largest blocking call on the launch path.
|
||
/// [`Catalog::open_verified`] runs `PRAGMA quick_check`, which reads **every
|
||
/// page** of the database, and then `Catalog::open`, which takes a full copy of
|
||
/// the file before a migration and rewrites its structure. On a 50,000-image
|
||
/// library that is tens of megabytes of I/O, and all of it happened before the
|
||
/// window had painted anything.
|
||
///
|
||
/// On Android that is not a stutter but an ANR. `dr_ui::run` is called from
|
||
/// `android_main` and does not reach `window.run()` — the first `poll_events`,
|
||
/// and so the first time anything drains the activity's input channel — until
|
||
/// every line above it has finished. Five seconds of that and the system offers
|
||
/// to kill the app.
|
||
///
|
||
/// [`crate::recovery_ui`] and `recovery`'s own module documentation both say the
|
||
/// check belongs "at startup, where a failure has a user in front of it who can
|
||
/// answer a question". That was always the intent; this is what makes it true,
|
||
/// because the question can only be asked once there is an interface to ask it
|
||
/// in.
|
||
///
|
||
/// # What the user sees meanwhile
|
||
///
|
||
/// `library-opening`, which the grid's empty state draws as "Opening the
|
||
/// library…" rather than as "Scanning…". They are different answers — one is
|
||
/// reading a file on this device, the other is walking a tree over the network
|
||
/// — and the grid already refuses to conflate the two states it had.
|
||
///
|
||
/// `library-scanning` stays true throughout, which is what keeps the gate above
|
||
/// from being merely advisory: the header hides Rescan while it is set, so
|
||
/// there is no button that could start a scan into a catalog this has not
|
||
/// finished checking.
|
||
fn open_catalog_soon(
|
||
window: &AppWindow,
|
||
ctl: Rc<LibraryController>,
|
||
coll_ctl: Rc<crate::collections_ui::CollectionsController>,
|
||
conn: Connection,
|
||
filter: FormatFilter,
|
||
path: PathBuf,
|
||
) {
|
||
// Already open: a library being re-opened within one session, which is the
|
||
// case `show_catalog_now` short-circuited. There is nothing to wait for and
|
||
// nothing to say, so the scan starts immediately as it always did.
|
||
if ctl.catalog.borrow().is_some() {
|
||
begin_scan(window, &ctl, &coll_ctl, conn, filter, path);
|
||
return;
|
||
}
|
||
|
||
window.set_library_opening(true);
|
||
window.set_library_status("Reading the catalog on this device…".into());
|
||
|
||
let rx = spawn_catalog_open(path.clone());
|
||
let weak = window.as_weak();
|
||
let held = ctl.clone();
|
||
let timer = slint::Timer::default();
|
||
timer.start(
|
||
slint::TimerMode::Repeated,
|
||
// Tighter than the scan's 120 ms: this one lands once, and everything
|
||
// the grid can show is waiting behind it.
|
||
std::time::Duration::from_millis(30),
|
||
move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let got = match rx.try_recv() {
|
||
Ok(got) => got,
|
||
Err(std::sync::mpsc::TryRecvError::Empty) => return,
|
||
// The worker died without answering — a panic inside SQLite,
|
||
// realistically. Treated as "no catalog to show", which is what
|
||
// the synchronous path did with any error it could not
|
||
// classify: the scan is the thing that has to work.
|
||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||
CatalogOpen::Failed("the catalog worker stopped without answering".into())
|
||
}
|
||
};
|
||
stop(&held.catalog_timer);
|
||
w.set_library_opening(false);
|
||
|
||
match got {
|
||
CatalogOpen::Opened(cat) => {
|
||
adopt_catalog(&w, &held, &coll_ctl, cat);
|
||
begin_scan(
|
||
&w,
|
||
&held,
|
||
&coll_ctl,
|
||
conn.clone(),
|
||
filter.clone(),
|
||
path.clone(),
|
||
);
|
||
}
|
||
CatalogOpen::Corrupt(detail) => {
|
||
// No scan, and this is the whole reason the scan waits for
|
||
// this answer: `Catalog::open` succeeds on a file whose
|
||
// header survived, so a scan would write ETags and image
|
||
// rows into damaged pages while the user is still reading
|
||
// the question — turning a file that had a backup into one
|
||
// where the backup is the only copy left.
|
||
//
|
||
// `offer` clears the scanning state itself, for exactly
|
||
// that reason, so nothing here does it twice.
|
||
crate::recovery_ui::offer(&w, &path, &detail);
|
||
}
|
||
CatalogOpen::Failed(why) => {
|
||
// Not surfaced: this is a first run more often than it is
|
||
// anything else, the empty state already says the scan is
|
||
// running, and an error here would contradict a scan that
|
||
// is working perfectly. If the scan fails too, it reports
|
||
// for both of them.
|
||
log::info!("no catalog to show before the scan: {why}");
|
||
begin_scan(
|
||
&w,
|
||
&held,
|
||
&coll_ctl,
|
||
conn.clone(),
|
||
filter.clone(),
|
||
path.clone(),
|
||
);
|
||
}
|
||
}
|
||
},
|
||
);
|
||
*ctl.catalog_timer.borrow_mut() = Some(timer);
|
||
}
|
||
|
||
/// What the catalog worker found.
|
||
enum CatalogOpen {
|
||
/// Checked, opened, migrated forward and backfilled.
|
||
Opened(Catalog),
|
||
/// `PRAGMA quick_check` failed. Carries SQLite's own words, because the
|
||
/// message the user is shown is the diagnosis rather than a paraphrase.
|
||
Corrupt(String),
|
||
/// Anything else: no catalog file yet, a permission, a locked database.
|
||
Failed(String),
|
||
}
|
||
|
||
/// Check, open and migrate the catalog on a thread of its own.
|
||
///
|
||
/// `Catalog` holds a `rusqlite::Connection`, which is `Send` and not `Sync` —
|
||
/// exactly the shape that can be built on a worker and handed over once, which
|
||
/// is what this does. Nothing else touches the file while it runs: this is the
|
||
/// only opener on the launch path, and the scan that opens its own connection
|
||
/// does not start until the answer has landed.
|
||
fn spawn_catalog_open(path: PathBuf) -> Receiver<CatalogOpen> {
|
||
let (tx, rx) = std::sync::mpsc::channel();
|
||
|
||
std::thread::spawn(move || {
|
||
let started = std::time::Instant::now();
|
||
let message = match Catalog::open_verified(&path) {
|
||
Ok(cat) => {
|
||
// The number this change exists for. Worth an `info` line on
|
||
// every launch: it is the one figure that says whether a slow
|
||
// start is the catalog or something else, and it is not
|
||
// measurable from anywhere else.
|
||
log::info!(
|
||
"catalog checked and opened in {} ms",
|
||
started.elapsed().as_millis()
|
||
);
|
||
CatalogOpen::Opened(cat)
|
||
}
|
||
Err(dr_catalog::CatalogError::Corrupt { detail }) => CatalogOpen::Corrupt(detail),
|
||
Err(e) => CatalogOpen::Failed(e.to_string()),
|
||
};
|
||
let _ = tx.send(message);
|
||
});
|
||
|
||
rx
|
||
}
|
||
|
||
/// Start the scan that brings the catalog up to date.
|
||
///
|
||
/// Split out of [`open`] because it no longer runs there — see
|
||
/// [`open_catalog_soon`] — and shared with the two paths that reach it once the
|
||
/// catalog has been answered for.
|
||
fn begin_scan(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
||
conn: Connection,
|
||
filter: FormatFilter,
|
||
path: PathBuf,
|
||
) {
|
||
log::info!(
|
||
"scanning {} for {} format(s) → {}",
|
||
if conn.account.root.is_empty() {
|
||
"<account root>"
|
||
} else {
|
||
&conn.account.root
|
||
},
|
||
filter.iter().count(),
|
||
path.display()
|
||
);
|
||
window.set_library_status("Starting…".into());
|
||
|
||
let rx = library::spawn_scan(
|
||
conn.clone(),
|
||
conn.account.root.clone(),
|
||
filter,
|
||
path.clone(),
|
||
);
|
||
|
||
drain_scan(window.as_weak(), ctl.clone(), coll_ctl.clone(), rx, path);
|
||
}
|
||
|
||
/// Drain scan progress on the UI thread.
|
||
fn drain_scan(
|
||
weak: slint::Weak<AppWindow>,
|
||
ctl: Rc<LibraryController>,
|
||
coll_ctl: Rc<crate::collections_ui::CollectionsController>,
|
||
rx: Receiver<ScanMessage>,
|
||
catalog_path: std::path::PathBuf,
|
||
) {
|
||
let timer = slint::Timer::default();
|
||
let ctl_cb = ctl.clone();
|
||
|
||
// Indeterminate for as long as it runs: a recursive walk discovers its own
|
||
// extent, so the count it reports is what it has *found*, never a fraction
|
||
// of what there is (FR-CAT-1).
|
||
//
|
||
// Named after the folder, because two accounts or two roots produce rows
|
||
// that are otherwise identical.
|
||
let title = match ctl.session.borrow().as_ref() {
|
||
Some((c, _)) if !c.account.root.is_empty() => {
|
||
format!("Scanning {}", c.account.root)
|
||
}
|
||
_ => "Scanning the library".to_string(),
|
||
};
|
||
let job = ctl.activity.begin(crate::activity::Kind::Scan, title);
|
||
|
||
timer.start(
|
||
slint::TimerMode::Repeated,
|
||
std::time::Duration::from_millis(120),
|
||
move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let ctl = &ctl_cb;
|
||
|
||
loop {
|
||
let msg = match rx.try_recv() {
|
||
Ok(m) => m,
|
||
Err(std::sync::mpsc::TryRecvError::Empty) => return,
|
||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||
// A worker that died without sending must not leave
|
||
// the screen on "Scanning…" forever.
|
||
if w.get_library_scanning() {
|
||
w.set_library_scanning(false);
|
||
w.set_library_error("scan ended unexpectedly".into());
|
||
job.fail("ended unexpectedly");
|
||
}
|
||
stop(&ctl.scan_timer);
|
||
return;
|
||
}
|
||
};
|
||
|
||
match msg {
|
||
ScanMessage::Progress {
|
||
directories,
|
||
pruned,
|
||
images,
|
||
} => {
|
||
// Pruned folders are reported separately rather than
|
||
// folded into the total: they are the ETag walk paying
|
||
// off, and hiding them makes an incremental rescan look
|
||
// identical to a full one.
|
||
let status = if pruned > 0 {
|
||
format!("{directories} folders · {pruned} unchanged · {images} images")
|
||
} else {
|
||
format!("{directories} folders · {images} images")
|
||
};
|
||
job.detail(status.clone());
|
||
w.set_library_status(status.into());
|
||
}
|
||
ScanMessage::Done {
|
||
found,
|
||
total,
|
||
pruned,
|
||
elapsed_ms,
|
||
judgements,
|
||
} => {
|
||
log::info!(
|
||
"scan complete: {total} images ({found} listed, \
|
||
{pruned} folders unchanged, {judgements} judgements \
|
||
taken in) 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");
|
||
}
|
||
// TRACES: FR-PLAT-AND-2
|
||
// And it is the only evidence that clears a lost root,
|
||
// for the same reason: the walk began by listing the
|
||
// root, so a scan that finished is a root that opened.
|
||
// The rows it marked offline are restored one at a
|
||
// time by `library::persist`, as each file is listed
|
||
// again — this only stops the banner claiming what is
|
||
// no longer true.
|
||
if ctl.root_lost.borrow_mut().take().is_some() {
|
||
log::info!("library folder is readable again");
|
||
}
|
||
refresh_offline(&w, ctl);
|
||
// TRACES: FR-CAT-13
|
||
// The pull may have found an `.xmp` that disagrees
|
||
// with the catalog; the settings page offers the
|
||
// reload, and this is what tells it how many.
|
||
refresh_xmp_conflicts(&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")
|
||
};
|
||
// TRACES: FR-CAT-8 | FR-NC-9
|
||
// Said out loud, because a grid that silently gains
|
||
// three hundred stars is indistinguishable from one
|
||
// that has gone wrong — and because for as long as
|
||
// this number was structurally zero, the photographer
|
||
// had no way to tell that a cull made on another
|
||
// device had failed to arrive.
|
||
let status = if judgements > 0 {
|
||
format!("{status} · {judgements} from other devices")
|
||
} else {
|
||
status
|
||
};
|
||
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,
|
||
lost_root,
|
||
} => {
|
||
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 lost_root {
|
||
// TRACES: FR-PLAT-AND-2 | FR-CAT-9
|
||
// The library folder itself could not be opened —
|
||
// a share withdrawn, an unplugged drive, and in
|
||
// time a revoked document-tree grant. The worker
|
||
// has already marked every row under this root
|
||
// offline and deleted none of them; this is the
|
||
// half the user sees.
|
||
//
|
||
// Tested first because it is also true that the
|
||
// library is unreachable, and the generic answer
|
||
// would be reached first and be less useful.
|
||
*ctl.root_lost.borrow_mut() = Some(message);
|
||
refresh_offline(&w, ctl);
|
||
// Same reason as the offline arm below: without
|
||
// this a launch that began with a revoked grant
|
||
// shows an empty grid, which is the one impression
|
||
// this whole path exists to avoid.
|
||
open_catalog_for_offline(&w, ctl, &catalog_path, &coll_ctl);
|
||
} else 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".
|
||
pub(crate) fn start_rescan(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
||
) {
|
||
let Some((conn, filter)) = ctl.session.borrow().clone() else {
|
||
return;
|
||
};
|
||
|
||
window.set_library_scanning(true);
|
||
window.set_library_error(slint::SharedString::new());
|
||
window.set_library_status("Rescanning…".into());
|
||
|
||
let path = library::catalog_path(&conn.account);
|
||
let rx = library::spawn_scan(
|
||
conn.clone(),
|
||
conn.account.root.clone(),
|
||
filter,
|
||
path.clone(),
|
||
);
|
||
drain_scan(window.as_weak(), ctl.clone(), coll_ctl.clone(), rx, path);
|
||
}
|
||
|
||
/// TRACES: FR-NC-6a | FR-NC-6c
|
||
/// What a collection would cost to take with you, and what it is holding now.
|
||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||
struct OfflineSummary {
|
||
/// Photographs in the collection and its children, deduplicated.
|
||
total: usize,
|
||
/// Of those, how many have their original on this device.
|
||
held: usize,
|
||
/// Disk those originals occupy — what releasing would give back.
|
||
held_bytes: u64,
|
||
/// What the rest would cost to fetch, from the sizes the scan recorded.
|
||
/// Zero where nothing has been stat-ed yet, which reads as "unknown"
|
||
/// rather than "free" in the label built from it.
|
||
missing_bytes: u64,
|
||
}
|
||
|
||
impl OfflineSummary {
|
||
fn missing(self) -> usize {
|
||
self.total.saturating_sub(self.held)
|
||
}
|
||
}
|
||
|
||
/// Read the offline summary for a set of images.
|
||
///
|
||
/// One query with the ids inlined as placeholders — the same shape
|
||
/// [`collection_images`] uses, and for the same reason: a collection is tens to
|
||
/// thousands of rows, and a round trip per photograph to answer one dialogue is
|
||
/// not a trade worth making.
|
||
fn offline_summary(catalog: &Catalog, images: &[dr_types::ImageId]) -> OfflineSummary {
|
||
if images.is_empty() {
|
||
return OfflineSummary::default();
|
||
}
|
||
let placeholders = std::iter::repeat_n("?", images.len())
|
||
.collect::<Vec<_>>()
|
||
.join(",");
|
||
// `tier_actual`, never `tier_desired`: the question is what can be opened
|
||
// on the aeroplane, and a pin whose download has not run yet answers no.
|
||
let sql = format!(
|
||
"SELECT count(*),
|
||
coalesce(sum(CASE WHEN c.tier_actual >= ?1 THEN 1 ELSE 0 END), 0),
|
||
coalesce(sum(CASE WHEN c.tier_actual >= ?1 THEN c.bytes ELSE 0 END), 0),
|
||
coalesce(sum(CASE WHEN c.tier_actual >= ?1 THEN 0
|
||
ELSE coalesce(i.file_size, 0) END), 0)
|
||
FROM images i
|
||
LEFT JOIN image_cache c ON c.image_id = i.id
|
||
WHERE i.id IN ({placeholders})"
|
||
);
|
||
|
||
let mut params: Vec<rusqlite::types::Value> = vec![rusqlite::types::Value::Integer(
|
||
dr_types::Tier::Original.stored(),
|
||
)];
|
||
params.extend(
|
||
images
|
||
.iter()
|
||
.map(|i| rusqlite::types::Value::Integer(i.0 as i64)),
|
||
);
|
||
|
||
catalog
|
||
.connection()
|
||
.query_row(&sql, rusqlite::params_from_iter(params.iter()), |r| {
|
||
Ok(OfflineSummary {
|
||
total: r.get::<_, i64>(0)? as usize,
|
||
held: r.get::<_, i64>(1)? as usize,
|
||
held_bytes: r.get::<_, i64>(2)? as u64,
|
||
missing_bytes: r.get::<_, i64>(3)? as u64,
|
||
})
|
||
})
|
||
.unwrap_or_else(|e| {
|
||
log::debug!("reading offline summary: {e}");
|
||
OfflineSummary::default()
|
||
})
|
||
}
|
||
|
||
/// TRACES: FR-NC-6a | FR-NC-6c
|
||
/// Ask what should happen to a collection's local copies.
|
||
///
|
||
/// Both answers are expensive — one commits the device to a download of
|
||
/// gigabytes, the other deletes gigabytes it already holds — so this is a
|
||
/// question rather than a toggle, and the counts and sizes go in the buttons
|
||
/// where they are read *before* the tap rather than in a second dialogue after
|
||
/// it (FR-NC-6c: an operation requiring absent data says so, with the size,
|
||
/// before starting).
|
||
fn open_offline_prompt(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
id: dr_types::CollectionId,
|
||
) {
|
||
let borrow = ctl.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
|
||
// Descendants, matching what the grid shows when scoped to this row:
|
||
// keeping a parent whose children hold the photographs must keep the
|
||
// photographs, or the answer would appear to do nothing.
|
||
let ids = match dr_catalog::collections::descendants(catalog.connection(), id) {
|
||
Ok(ids) => ids,
|
||
Err(e) => {
|
||
window.set_library_error(format!("resolving collection: {e}").into());
|
||
return;
|
||
}
|
||
};
|
||
let images = collection_images(catalog, &ids);
|
||
let summary = offline_summary(catalog, &images);
|
||
|
||
let name = dr_catalog::collections::tree(catalog.connection())
|
||
.ok()
|
||
.and_then(|rows| {
|
||
rows.into_iter()
|
||
.find(|r| r.collection.id == id)
|
||
.map(|r| r.collection.name)
|
||
})
|
||
.unwrap_or_else(|| "This collection".to_string());
|
||
|
||
ctl.offline_target.set(Some(id));
|
||
window.set_offline_prompt_title(name.as_str().into());
|
||
window.set_offline_prompt_detail(
|
||
if summary.total == 0 {
|
||
"Nothing in here yet. Put some photographs in it first.".to_string()
|
||
} else if summary.held == summary.total {
|
||
format!(
|
||
"All {} on this device · {}",
|
||
summary.total,
|
||
crate::activity::describe_bytes(summary.held_bytes)
|
||
)
|
||
} else {
|
||
format!(
|
||
"{} photographs · {} already on this device",
|
||
summary.total, summary.held
|
||
)
|
||
}
|
||
.as_str()
|
||
.into(),
|
||
);
|
||
window.set_offline_prompt_keep_label(
|
||
// The size is named where the scan has recorded one. Where it has not,
|
||
// the label says what it will do and not what it will cost, which is
|
||
// honest — a "0 B" download would be a lie about a gigabyte.
|
||
if summary.missing_bytes > 0 {
|
||
format!(
|
||
"Download {} · {}",
|
||
summary.missing(),
|
||
crate::activity::describe_bytes(summary.missing_bytes)
|
||
)
|
||
} else if summary.missing() > 0 {
|
||
format!("Download {}", summary.missing())
|
||
} else {
|
||
"Everything is already here".to_string()
|
||
}
|
||
.as_str()
|
||
.into(),
|
||
);
|
||
window.set_offline_prompt_release_label(
|
||
format!(
|
||
"Remove {} local copies · frees {}",
|
||
summary.held,
|
||
crate::activity::describe_bytes(summary.held_bytes)
|
||
)
|
||
.as_str()
|
||
.into(),
|
||
);
|
||
window.set_offline_prompt_can_keep(summary.missing() > 0);
|
||
window.set_offline_prompt_can_release(summary.held > 0);
|
||
window.set_offline_prompt_busy(window.get_library_pin_total() > 0);
|
||
}
|
||
|
||
/// Close the offline question without answering it.
|
||
fn close_offline_prompt(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
ctl.offline_target.set(None);
|
||
// The title is what the prompt's visibility is bound to: one fact, so a
|
||
// dialogue cannot be up with nothing written on it.
|
||
window.set_offline_prompt_title(slint::SharedString::new());
|
||
}
|
||
|
||
/// TRACES: FR-NC-6a
|
||
/// Keep a collection on this device: record the pin, then start the transfer.
|
||
///
|
||
/// Two separate things, and keeping them separate is what makes the answer feel
|
||
/// immediate — recording the intent is a local catalog write that completes at
|
||
/// once, and downloading the bytes may take a very long time. The pin also
|
||
/// survives the app being closed halfway through, which is what makes the
|
||
/// transfer resumable rather than something to start again.
|
||
fn keep_collection_offline(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
||
) {
|
||
let Some(id) = ctl.offline_target.get() else {
|
||
return;
|
||
};
|
||
let Some(cache) = ctl.cache() else {
|
||
window.set_library_error("No cache directory for this library.".into());
|
||
return;
|
||
};
|
||
|
||
let images = {
|
||
let borrow = ctl.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
let ids = match dr_catalog::collections::descendants(catalog.connection(), id) {
|
||
Ok(ids) => ids,
|
||
Err(e) => {
|
||
window.set_library_error(format!("resolving collection: {e}").into());
|
||
return;
|
||
}
|
||
};
|
||
let images = collection_images(catalog, &ids);
|
||
if images.is_empty() {
|
||
window.set_library_error("Nothing in that collection to keep offline.".into());
|
||
return;
|
||
}
|
||
if let Err(e) = cache.pin(catalog.connection(), &images) {
|
||
window.set_library_error(format!("pinning: {e}").into());
|
||
return;
|
||
}
|
||
images
|
||
};
|
||
|
||
log::info!("pinned {} image(s) for offline use", images.len());
|
||
window.set_library_error(slint::SharedString::new());
|
||
if ctl.scope.borrow().as_ref() == Some(&id) {
|
||
window.set_library_scope_pinned(true);
|
||
}
|
||
close_offline_prompt(window, ctl);
|
||
start_pin_fetch(window, ctl);
|
||
refresh_collection_tree(window, ctl, coll_ctl);
|
||
}
|
||
|
||
/// TRACES: FR-NC-6a | FR-NC-6b
|
||
/// Give the disk back: release the pin *and* delete the originals it held.
|
||
///
|
||
/// Deliberately destructive, where unpinning alone is not. "Remove the local
|
||
/// copies" is asked by someone whose device is full, and answering it by
|
||
/// withdrawing a promise and leaving the gigabytes for a future eviction to
|
||
/// notice is not an answer. Nothing is lost that cannot be fetched again: the
|
||
/// originals are on the server, and the ratings, the edit graph and the
|
||
/// thumbnails are all untouched — they are authoritative and small.
|
||
fn release_collection_offline(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
||
) {
|
||
let Some(id) = ctl.offline_target.get() else {
|
||
return;
|
||
};
|
||
let Some(cache) = ctl.cache() else {
|
||
window.set_library_error("No cache directory for this library.".into());
|
||
return;
|
||
};
|
||
|
||
let released = {
|
||
let borrow = ctl.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
let ids = match dr_catalog::collections::descendants(catalog.connection(), id) {
|
||
Ok(ids) => ids,
|
||
Err(e) => {
|
||
window.set_library_error(format!("resolving collection: {e}").into());
|
||
return;
|
||
}
|
||
};
|
||
let images = collection_images(catalog, &ids);
|
||
let outcome = match cache.release(catalog.connection(), &images) {
|
||
Ok(r) => r,
|
||
Err(e) => {
|
||
window.set_library_error(format!("removing local copies: {e}").into());
|
||
return;
|
||
}
|
||
};
|
||
|
||
// TRACES: FR-NC-6c
|
||
// On a placeholder library the bookkeeping above owns no files, so it
|
||
// freed nothing — the originals are materialised in the library folder
|
||
// and only the sync client may take them back. Asking it to is what
|
||
// makes unpinning actually return the disk, and it must be a
|
||
// dehydration rather than a delete: removing a file inside a synced
|
||
// tree propagates to the server (ARCH §9.0a).
|
||
//
|
||
// Fire and forget: it is per-file work over a socket, the user has
|
||
// already been told the pin is withdrawn, and a client that refuses
|
||
// leaves the content where it is at no cost but disk.
|
||
if let Some(conn) = ctl.session() {
|
||
let path = library::catalog_path(&conn.account);
|
||
std::mem::drop(library::spawn_dehydrate(conn, path, images));
|
||
}
|
||
outcome
|
||
};
|
||
|
||
let (count, freed) = released;
|
||
log::info!(
|
||
"released {count} image(s), freeing {}",
|
||
crate::activity::describe_bytes(freed)
|
||
);
|
||
window.set_library_error(slint::SharedString::new());
|
||
window.set_library_status(
|
||
format!(
|
||
"Removed local copies · {} freed",
|
||
crate::activity::describe_bytes(freed)
|
||
)
|
||
.as_str()
|
||
.into(),
|
||
);
|
||
if ctl.scope.borrow().as_ref() == Some(&id) {
|
||
window.set_library_scope_pinned(false);
|
||
}
|
||
// A download that was still running for this collection has just had its
|
||
// reason withdrawn; the worker checks `pending_pins` per file, so it stops
|
||
// finding work rather than being killed.
|
||
window.set_library_pin_total(0);
|
||
window.set_library_pin_done(0);
|
||
close_offline_prompt(window, ctl);
|
||
refresh_local_count(window, ctl);
|
||
refresh_collection_tree(window, ctl, coll_ctl);
|
||
}
|
||
|
||
/// Redraw the sidebar, so the trays reflect what was just kept or released.
|
||
fn refresh_collection_tree(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
||
) {
|
||
let borrow = ctl.catalog.borrow();
|
||
if let Some(catalog) = borrow.as_ref() {
|
||
crate::collections_ui::refresh_tree(window, coll_ctl, catalog);
|
||
}
|
||
}
|
||
|
||
/// Every image in the given collections, deduplicated.
|
||
///
|
||
/// An image in both a parent and a child is one photograph and must be pinned
|
||
/// once, exactly as the grid draws it once.
|
||
fn collection_images(catalog: &Catalog, ids: &[dr_types::CollectionId]) -> Vec<dr_types::ImageId> {
|
||
if ids.is_empty() {
|
||
return Vec::new();
|
||
}
|
||
let placeholders = std::iter::repeat_n("?", ids.len())
|
||
.collect::<Vec<_>>()
|
||
.join(",");
|
||
let sql = format!(
|
||
"SELECT DISTINCT image_id FROM collection_members
|
||
WHERE collection_id IN ({placeholders})"
|
||
);
|
||
let params: Vec<rusqlite::types::Value> = ids
|
||
.iter()
|
||
.map(|c| rusqlite::types::Value::Integer(c.0 as i64))
|
||
.collect();
|
||
|
||
let Ok(mut stmt) = catalog.connection().prepare(&sql) else {
|
||
return Vec::new();
|
||
};
|
||
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
||
Ok(dr_types::ImageId(r.get::<_, i64>(0)? as u64))
|
||
});
|
||
match rows {
|
||
Ok(rows) => rows.flatten().collect(),
|
||
Err(e) => {
|
||
log::debug!("listing collection images: {e}");
|
||
Vec::new()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-NC-6a
|
||
/// Download whatever the pins still want, reporting progress.
|
||
fn start_pin_fetch(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
let Some((conn, _)) = ctl.session.borrow().clone() else {
|
||
return;
|
||
};
|
||
let Some(cache_dir) = ctl.cache_dir() else {
|
||
return;
|
||
};
|
||
|
||
// Offline, there is nothing to download from. The pin is already recorded,
|
||
// so it resumes on reconnect rather than being lost.
|
||
if ctl.is_offline() {
|
||
log::info!("offline: the pin is recorded and will download on reconnect");
|
||
return;
|
||
}
|
||
|
||
let rx = library::spawn_pin_fetch(
|
||
conn.clone(),
|
||
library::catalog_path(&conn.account),
|
||
cache_dir,
|
||
// Pinned originals are exempt from the budget, but a pin fetch also
|
||
// stores passively when it finds an image already cached, so the worker
|
||
// still needs the user's ceiling rather than the catalog's floor.
|
||
ctl.cache_budget.get(),
|
||
);
|
||
|
||
let timer = slint::Timer::default();
|
||
let weak = window.as_weak();
|
||
let ctl_cb = ctl.clone();
|
||
|
||
// The longest-running transfer the app does, and the one most likely to be
|
||
// watched from another view — which is the whole reason the register
|
||
// exists (FR-NC-6, FR-NC-6c).
|
||
let job = ctl.activity.begin(
|
||
crate::activity::Kind::Download,
|
||
"Keeping photographs on this device",
|
||
);
|
||
|
||
timer.start(
|
||
slint::TimerMode::Repeated,
|
||
std::time::Duration::from_millis(300),
|
||
move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
loop {
|
||
let msg = match rx.try_recv() {
|
||
Ok(m) => m,
|
||
Err(std::sync::mpsc::TryRecvError::Empty) => return,
|
||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||
w.set_library_pin_total(0);
|
||
job.fail("stopped without finishing");
|
||
stop(&ctl_cb.pin_timer);
|
||
return;
|
||
}
|
||
};
|
||
|
||
match msg {
|
||
library::PinMessage::Planned { total } => {
|
||
w.set_library_pin_total(total as i32);
|
||
w.set_library_pin_done(0);
|
||
job.total(total);
|
||
}
|
||
library::PinMessage::Stored { done } => {
|
||
w.set_library_pin_done(done as i32);
|
||
job.progress(done, w.get_library_pin_total() as usize);
|
||
// The "On this device" count grows as they land, so
|
||
// the chip agrees with the progress line beside it.
|
||
refresh_local_count(&w, &ctl_cb);
|
||
}
|
||
library::PinMessage::Done { stored, bytes } => {
|
||
log::info!(
|
||
"pin complete: {stored} original(s), {:.1} MB",
|
||
bytes as f64 / 1_048_576.0
|
||
);
|
||
job.finish(format!(
|
||
"{stored} photograph(s) · {}",
|
||
crate::activity::describe_bytes(bytes)
|
||
));
|
||
w.set_library_pin_total(0);
|
||
w.set_library_pin_done(0);
|
||
refresh_local_count(&w, &ctl_cb);
|
||
stop(&ctl_cb.pin_timer);
|
||
return;
|
||
}
|
||
library::PinMessage::Failed { message, offline } => {
|
||
log::warn!("pin fetch stopped: {message}");
|
||
job.fail(message.clone());
|
||
w.set_library_pin_total(0);
|
||
if offline {
|
||
ctl_cb
|
||
.reachability
|
||
.borrow_mut()
|
||
.mark_unreachable(message, std::time::Instant::now());
|
||
refresh_offline(&w, &ctl_cb);
|
||
} else {
|
||
w.set_library_error(format!("keeping offline: {message}").into());
|
||
}
|
||
refresh_local_count(&w, &ctl_cb);
|
||
stop(&ctl_cb.pin_timer);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
},
|
||
);
|
||
|
||
*ctl.pin_timer.borrow_mut() = Some(timer);
|
||
}
|
||
|
||
/// Refresh the "On this device" count from the catalog.
|
||
fn refresh_local_count(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
let borrow = ctl.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
window.set_library_local_count(library::local_original_count(catalog).unwrap_or(0) as i32);
|
||
}
|
||
|
||
/// TRACES: FR-NC-6a
|
||
/// Whether every image in the scoped collection is pinned.
|
||
///
|
||
/// Read from the catalog rather than remembered, because a pin outlives the
|
||
/// session that made it: reopening the library must show the button already
|
||
/// active, or the user would pin the same collection twice.
|
||
fn scope_is_pinned(catalog: &Catalog, images: &[dr_types::ImageId]) -> bool {
|
||
if images.is_empty() {
|
||
return false;
|
||
}
|
||
let placeholders = std::iter::repeat_n("?", images.len())
|
||
.collect::<Vec<_>>()
|
||
.join(",");
|
||
let params: Vec<rusqlite::types::Value> = images
|
||
.iter()
|
||
.map(|i| rusqlite::types::Value::Integer(i.0 as i64))
|
||
.collect();
|
||
|
||
let pinned: i64 = catalog
|
||
.connection()
|
||
.query_row(
|
||
&format!(
|
||
"SELECT count(*) FROM image_cache
|
||
WHERE pinned = 1 AND image_id IN ({placeholders})"
|
||
),
|
||
rusqlite::params_from_iter(params.iter()),
|
||
|r| r.get(0),
|
||
)
|
||
.unwrap_or(0);
|
||
|
||
pinned as usize == images.len()
|
||
}
|
||
|
||
/// TRACES: FR-CAT-9
|
||
/// Paint the connectivity state into the window.
|
||
///
|
||
/// Called wherever reachability may have moved, rather than by the state
|
||
/// itself: `Reachability` is in `dr-sync` and knows nothing about a window,
|
||
/// which is what keeps it testable without a display server.
|
||
fn refresh_offline(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
let reach = ctl.reachability.borrow();
|
||
// TRACES: FR-PLAT-AND-2
|
||
// A lost root wins over a dead network, and does so even when both are
|
||
// true — which is the ordinary case, since the scan that discovered the
|
||
// grant was gone was also the last request the app made. Reported the
|
||
// other way round the user is told to wait for a connection that is
|
||
// working, and the thing that would actually fix it is never mentioned.
|
||
let lost = ctl.root_lost.borrow();
|
||
let offline = reach.is_offline() || lost.is_some();
|
||
|
||
window.set_library_offline(offline);
|
||
window.set_library_offline_reason(match lost.as_deref() {
|
||
Some(why) => why.into(),
|
||
None => reach.reason().unwrap_or_default().into(),
|
||
});
|
||
window.set_library_offline_since(
|
||
// A duration is what a network outage has and a revoked permission
|
||
// does not: "for 4 minutes" invites waiting, and waiting is precisely
|
||
// what will not help here.
|
||
if lost.is_some() {
|
||
slint::SharedString::default()
|
||
} else {
|
||
reach
|
||
.offline_for(std::time::Instant::now())
|
||
.map(describe_duration)
|
||
.unwrap_or_default()
|
||
.into()
|
||
},
|
||
);
|
||
drop(lost);
|
||
|
||
// A stale scan error under an offline banner reports one problem twice.
|
||
if offline {
|
||
window.set_library_error("".into());
|
||
}
|
||
drop(reach);
|
||
|
||
// TRACES: FR-CAT-9
|
||
// Back online: send whatever the outbox is still holding.
|
||
//
|
||
// Hung off the one function that paints connectivity rather than off each
|
||
// of the seven places that move it — a drain that had to be remembered at
|
||
// every call site is a drain that will be forgotten at one of them, and
|
||
// the symptom is an edit that stays queued until the app is restarted.
|
||
//
|
||
// `start_outbox_drain` is a no-op when the outbox is empty and while one
|
||
// is already running, so calling it on every repaint costs a directory
|
||
// walk that finds nothing.
|
||
if !offline {
|
||
start_outbox_drain(window, ctl);
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-CAT-9 | FR-NC-10
|
||
/// Upload the sidecars queued while this device had no connection.
|
||
///
|
||
/// Guarded on the timer rather than on a flag: the timer *is* the "a drain is
|
||
/// running" state, and a second one started beside it would drain the same
|
||
/// channel twice.
|
||
fn start_outbox_drain(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
if ctl.outbox_timer.borrow().is_some() {
|
||
return;
|
||
}
|
||
if !ctl.outbox_maybe_dirty.get() {
|
||
return;
|
||
}
|
||
let Some(cache_dir) = ctl.sidecar_cache_dir() else {
|
||
return;
|
||
};
|
||
if crate::sidecar_cache::SidecarCache::open(cache_dir.clone())
|
||
.pending()
|
||
.is_empty()
|
||
{
|
||
// Nothing there. Recorded so the next hundred repaints skip the walk;
|
||
// a write that queues sets it again.
|
||
ctl.outbox_maybe_dirty.set(false);
|
||
return;
|
||
}
|
||
let Some((conn, _)) = ctl.session.borrow().clone() else {
|
||
return;
|
||
};
|
||
|
||
let rx = library::spawn_outbox_drain(conn.clone(), cache_dir);
|
||
let job = ctl
|
||
.activity
|
||
.begin(crate::activity::Kind::Upload, "Uploading queued edits");
|
||
|
||
let timer = slint::Timer::default();
|
||
let weak = window.as_weak();
|
||
let ctl_cb = ctl.clone();
|
||
timer.start(
|
||
slint::TimerMode::Repeated,
|
||
std::time::Duration::from_millis(250),
|
||
move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
match rx.try_recv() {
|
||
Ok(library::SidecarMessage::Finished {
|
||
written,
|
||
queued,
|
||
failed,
|
||
last_error,
|
||
}) => {
|
||
if failed > 0 {
|
||
log::warn!(
|
||
"{failed} queued sidecar(s) still undelivered: {}",
|
||
last_error.clone().unwrap_or_default()
|
||
);
|
||
// Not `fail`: the edits are still safely queued, and
|
||
// reporting this as a loss would be wrong.
|
||
job.finish(format!("{written} uploaded · {queued} still queued"));
|
||
} else if written > 0 {
|
||
log::info!("{written} queued sidecar(s) uploaded");
|
||
job.finish(format!("{written} queued edit(s) uploaded"));
|
||
w.set_library_status(format!("{written} queued edit(s) uploaded").into());
|
||
} else {
|
||
job.finish_quietly();
|
||
}
|
||
ctl_cb.outbox_maybe_dirty.set(queued > 0);
|
||
stop(&ctl_cb.outbox_timer);
|
||
}
|
||
Err(std::sync::mpsc::TryRecvError::Empty) => {}
|
||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||
job.finish_quietly();
|
||
stop(&ctl_cb.outbox_timer);
|
||
}
|
||
}
|
||
},
|
||
);
|
||
*ctl.outbox_timer.borrow_mut() = Some(timer);
|
||
}
|
||
|
||
/// A coarse "how long ago", for the offline banner.
|
||
///
|
||
/// Deliberately imprecise: the user wants to know whether this just happened
|
||
/// or has been true for a while, and a live-counting seconds display would
|
||
/// draw the eye to a number that changes without meaning anything.
|
||
fn describe_duration(d: std::time::Duration) -> String {
|
||
let secs = d.as_secs();
|
||
if secs < 60 {
|
||
"just now".to_string()
|
||
} else if secs < 3600 {
|
||
format!("{}m ago", secs / 60)
|
||
} else {
|
||
format!("{}h ago", secs / 3600)
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-CAT-9
|
||
/// Show the catalog after a scan that could not reach the server.
|
||
///
|
||
/// The whole point of offline mode: a scan is how the grid normally gets its
|
||
/// catalog handle, so a failed one previously left the library empty even
|
||
/// though a complete catalog was sitting on disk from the last successful run.
|
||
/// The images are all still there, their thumbnails are in the shards, and
|
||
/// rating and collecting them are local writes.
|
||
///
|
||
/// The sweep is deliberately **not** started — it exists to fetch headers over
|
||
/// the network, so offline it would do nothing but fail once per image.
|
||
fn open_catalog_for_offline(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
catalog_path: &std::path::Path,
|
||
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
||
) {
|
||
if ctl.catalog.borrow().is_some() {
|
||
// Already open — a rescan that failed, rather than a launch that
|
||
// began offline. The grid is showing the catalog already.
|
||
load_window(window, ctl);
|
||
return;
|
||
}
|
||
|
||
match Catalog::open(catalog_path) {
|
||
Ok(cat) => {
|
||
crate::collections_ui::refresh_tree(window, coll_ctl, &cat);
|
||
*ctl.catalog.borrow_mut() = Some(cat);
|
||
load_window(window, ctl);
|
||
}
|
||
Err(e) => {
|
||
// No catalog and no server. This is the one genuinely empty case:
|
||
// a first run that never reached the server has nothing indexed.
|
||
log::warn!("offline with no local catalog: {e}");
|
||
window.set_library_error(
|
||
"Offline, and this library has not been scanned on this device yet.".into(),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-UI-8
|
||
/// The grid's own route into the develop view, as something that can be held.
|
||
///
|
||
/// Named because it is threaded through the controller as well as passed to
|
||
/// `wire`, and a `RefCell<Option<Rc<dyn Fn(String)>>>` written out twice is a
|
||
/// signature nobody reads.
|
||
type OpenImage = Rc<dyn Fn(String)>;
|
||
|
||
/// How long a run of geometry changes has to stop for before the window is
|
||
/// reloaded against it.
|
||
///
|
||
/// Long enough to swallow a whole gesture's worth of steps, short enough that a
|
||
/// single deliberate step still feels immediate.
|
||
const GEOMETRY_SETTLE: std::time::Duration = std::time::Duration::from_millis(140);
|
||
|
||
/// Reload the window once the grid's geometry has stopped changing.
|
||
///
|
||
/// # Why this is deferred when a scroll is not
|
||
///
|
||
/// A pinch is not one zoom step, it is a stream of them, and every step
|
||
/// changes both the column count and the capacity — two reports. Each report
|
||
/// used to re-query the catalog, rebuild all 360 rows of the model, re-read the
|
||
/// badges and ratings for every one of them and spawn a thumbnail batch,
|
||
/// synchronously, on the thread that is trying to draw the frame. Twice per
|
||
/// step. That is why zooming juddered while scrolling the same grid is smooth:
|
||
/// a scroll reloads a few times per screenful, a zoom reloaded twice a frame.
|
||
///
|
||
/// None of it is urgent, because none of it is about *which* photographs are on
|
||
/// screen. A column change moves the cells and a zoom resizes them, but the
|
||
/// window holds the same images either way — the model already has them, and
|
||
/// the cells re-flow from `columns` and `cell-size` without Rust being involved
|
||
/// at all. What the reload actually recomputes is which cells begin a row, so
|
||
/// the month headings land in the right places, and which thumbnail size class
|
||
/// to ask for now. Both can wait for the gesture to finish.
|
||
///
|
||
/// # The anchor
|
||
///
|
||
/// Captured on the *first* report of a run rather than read when the timer
|
||
/// fires. As the grid re-flows, the viewport keeps its pixel offset while the
|
||
/// rows move underneath it, so the view drifts and reports its drift — reading
|
||
/// the anchor at the end would faithfully return to wherever it had wandered
|
||
/// to. Taking it at the start returns to the photograph the user was actually
|
||
/// looking at when they began the gesture.
|
||
fn schedule_reload(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
// Only the first report of a run sets it; the rest of the run reuses it.
|
||
if ctl.pending_anchor.get().is_none() {
|
||
ctl.pending_anchor.set(Some(ctl.resume_at.get()));
|
||
}
|
||
|
||
let timer = slint::Timer::default();
|
||
let weak = window.as_weak();
|
||
let ctl_cb = ctl.clone();
|
||
timer.start(slint::TimerMode::SingleShot, GEOMETRY_SETTLE, move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let anchor = ctl_cb.pending_anchor.take().unwrap_or(0);
|
||
if !w.get_library_visible() {
|
||
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.
|
||
///
|
||
/// **Blocking, and no longer on the launch path.** [`open_catalog_soon`] is
|
||
/// what a launch goes through; this is what [`crate::recovery_ui`] goes through
|
||
/// once the user has answered the damage question, where the event loop is
|
||
/// already running, the file has just been replaced under a `forget_catalog`,
|
||
/// and there is a `recovery-busy` state on screen saying so. Same reasoning as
|
||
/// `recovery_ui::answer` gives for doing the file copy itself in place: nothing
|
||
/// else is on screen to block, and a worker would buy a spinner and cost the
|
||
/// guarantee that nothing else touches the file while it is being replaced.
|
||
///
|
||
/// # 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.
|
||
/// Returns whether it is safe to go on and scan.
|
||
///
|
||
/// `false` means the catalog is damaged and the recovery question is up. The
|
||
/// caller must not start a scan on that answer: `Catalog::open` succeeds on a
|
||
/// file whose header survived, so the scan would write ETags and image rows
|
||
/// into damaged pages while the user is still reading the question — turning a
|
||
/// file that had a backup into one where the backup is the only copy left.
|
||
pub(crate) fn show_catalog_now(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
catalog_path: &std::path::Path,
|
||
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
||
) -> bool {
|
||
if ctl.catalog.borrow().is_some() {
|
||
return true;
|
||
}
|
||
|
||
// Verified rather than plain: this is a moment where a full check is
|
||
// affordable and there is a user in front of it who can answer the
|
||
// question — they have just answered one. See `dr_catalog::recovery` for
|
||
// why it is not on every open.
|
||
let cat = match Catalog::open_verified(catalog_path) {
|
||
Ok(cat) => cat,
|
||
Err(dr_catalog::CatalogError::Corrupt { detail }) => {
|
||
crate::recovery_ui::offer(window, catalog_path, &detail);
|
||
return false;
|
||
}
|
||
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 true;
|
||
}
|
||
};
|
||
|
||
adopt_catalog(window, ctl, coll_ctl, cat);
|
||
true
|
||
}
|
||
|
||
/// Take an opened catalog into the interface.
|
||
///
|
||
/// The UI half of opening a catalog, separated from the opening itself so that
|
||
/// [`open_catalog_soon`] — which does the opening on a worker — and
|
||
/// [`show_catalog_now`] — which still does it in place, for
|
||
/// [`crate::recovery_ui`], where the event loop is already running and the file
|
||
/// has just been replaced — describe the grid the same way.
|
||
///
|
||
/// Everything here is UI-thread work by necessity: it fills the sidebar and the
|
||
/// grid model.
|
||
fn adopt_catalog(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
||
cat: Catalog,
|
||
) {
|
||
// Before anything else can see this catalog, and exactly once per open —
|
||
// the callers' early return on an already-open catalog is what makes it
|
||
// once. The job queue is durable, so a run that was killed mid-job left its
|
||
// row marked `Running` with nobody holding it; recovery hands those back to
|
||
// be resumed rather than lost, and drops jobs naming photographs that have
|
||
// since been deleted.
|
||
//
|
||
// Here rather than wherever a runner starts, because there is no owner
|
||
// column in `jobs`: a second recovery pass while a worker held a claim
|
||
// would take that claim away from it.
|
||
match dr_catalog::runner::recover(cat.connection()) {
|
||
Ok(r) if r.did_anything() => log::info!(
|
||
"job queue: {} interrupted job(s) resumed, \
|
||
{} for deleted photographs dropped",
|
||
r.reclaimed,
|
||
r.reaped
|
||
),
|
||
Ok(_) => {}
|
||
// Not surfaced. The queue is rebuildable like everything else in the
|
||
// catalog, and a grid that refuses to open because a background queue
|
||
// could not be tidied is the worse failure by some way.
|
||
Err(e) => log::warn!("job queue could not be recovered: {e}"),
|
||
}
|
||
|
||
// 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);
|
||
|
||
// TRACES: FR-UI-8
|
||
// Where the photographer was, before the first window is read rather than
|
||
// after. The scope and the filter both change what the grid's query
|
||
// returns, so restoring them afterwards would mean reading the top of the
|
||
// whole library, drawing it, and then reading again — a visible jump on
|
||
// every launch, and on a remote library a screenful of thumbnails fetched
|
||
// for photographs nobody asked to see.
|
||
//
|
||
// The sidebar is refreshed above rather than below for the same ordering
|
||
// reason: a scope is restored by naming a row of a tree that has to exist.
|
||
let stored = ctl.place_store.borrow().as_ref().and_then(|s| s.load());
|
||
match stored {
|
||
Some(place) => apply_place(window, ctl, coll_ctl, &place),
|
||
None => load_window(window, ctl),
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-UI-8
|
||
/// Ask the server where the photographer was, and use it if they have not
|
||
/// started working yet.
|
||
///
|
||
/// # The two halves of a restore
|
||
///
|
||
/// The local record is applied in [`adopt_catalog`], synchronously and before
|
||
/// the first window is read: it is on this disk, it costs nothing, and it works
|
||
/// with the network down. This is the other half — the record another device
|
||
/// left — and it cannot be applied there because it has not arrived yet.
|
||
///
|
||
/// # Why it may be ignored when it does arrive
|
||
///
|
||
/// A handover is welcome on the way in and unwelcome once the photographer has
|
||
/// started. A grid that jumped somewhere else mid-scroll, because a round trip
|
||
/// finally landed, would be worse than never handing over at all — the user did
|
||
/// not ask for it, cannot see why it happened, and has lost their place to a
|
||
/// feature whose entire purpose is keeping it.
|
||
///
|
||
/// So `place_untouched` gates it: any scroll, scrub, scope change, filter or
|
||
/// opened photograph closes the latch. The record is still adopted onto disk by
|
||
/// the exchange in [`crate::derived_sync::sync_place`], so nothing is lost —
|
||
/// it simply takes effect at the next launch instead of this one.
|
||
///
|
||
/// Silent throughout. A first launch against a library nobody has recorded a
|
||
/// place for is the ordinary case, and being offline is not a failure of
|
||
/// anything the user asked for.
|
||
fn fetch_remote_place(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
||
conn: &Connection,
|
||
) {
|
||
if std::env::var_os("DARKROOM_NO_SYNC").is_some() {
|
||
return;
|
||
}
|
||
|
||
let rx = crate::derived_sync::spawn_place_fetch(conn.clone(), conn.account.root.clone());
|
||
|
||
let timer = slint::Timer::default();
|
||
let weak = window.as_weak();
|
||
let held = ctl.clone();
|
||
let coll = coll_ctl.clone();
|
||
timer.start(
|
||
slint::TimerMode::Repeated,
|
||
std::time::Duration::from_millis(120),
|
||
move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let got = match rx.try_recv() {
|
||
Ok(got) => got,
|
||
Err(std::sync::mpsc::TryRecvError::Empty) => return,
|
||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||
stop(&held.place_fetch_timer);
|
||
return;
|
||
}
|
||
};
|
||
stop(&held.place_fetch_timer);
|
||
|
||
let theirs = match got {
|
||
Ok(Some(place)) => place,
|
||
Ok(None) => return,
|
||
Err(e) => {
|
||
log::debug!("no place from the server: {e}");
|
||
return;
|
||
}
|
||
};
|
||
|
||
// Newer than what this device has, or there is nothing to learn.
|
||
//
|
||
// The borrow is scoped rather than dropped by hand: `apply_place`
|
||
// below reaches back into the controller, and a `Ref` still held
|
||
// across it is the shape a `RefCell` panic takes.
|
||
let adopted = match held.place_store.borrow().as_ref() {
|
||
Some(store) => store.adopt(&theirs),
|
||
None => false,
|
||
};
|
||
if !adopted {
|
||
return;
|
||
}
|
||
|
||
// On disk either way; on screen only if nobody has moved.
|
||
if !held.place_untouched.get() {
|
||
log::info!("a newer place arrived, and will be used at the next launch");
|
||
return;
|
||
}
|
||
// And only once the catalog is open — the ordinal it resolves to is
|
||
// a query against it. A record that lands first is left on disk,
|
||
// where `adopt_catalog` reads it.
|
||
if held.catalog.borrow().is_none() {
|
||
return;
|
||
}
|
||
log::info!("picking up where another device left off");
|
||
apply_place(&w, &held, &coll, &theirs);
|
||
},
|
||
);
|
||
*ctl.place_fetch_timer.borrow_mut() = Some(timer);
|
||
}
|
||
|
||
/// TRACES: FR-UI-8
|
||
/// What this device signs a place with.
|
||
///
|
||
/// The thumbnail store's client id, which is already this device's name among
|
||
/// the clients sharing a library — it is what qualifies a shard's remote
|
||
/// filename. Reusing it means the place and the shards a machine publishes
|
||
/// carry one name in `.darkroom-derived/`, rather than two that have to be
|
||
/// correlated by hand.
|
||
///
|
||
/// Empty where the store will not open. The field is informational, so an
|
||
/// unsigned place is worth strictly more than no place at all.
|
||
fn device_id(account: &Account) -> String {
|
||
match dr_thumbs::ThumbStore::open(&library::thumbs_dir(account)) {
|
||
Ok(store) => store.client_id().to_string(),
|
||
Err(e) => {
|
||
log::debug!("no device id for the place record: {e}");
|
||
String::new()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Drop the open catalog, so the next `show_catalog_now` opens the file
|
||
/// again rather than returning early.
|
||
///
|
||
/// Only recovery needs this, and it needs it for a specific reason: the file
|
||
/// under that connection has been replaced. A handle to the catalog that was
|
||
/// there before is a handle to a file that no longer has a name, and every
|
||
/// read through it would return the damaged pages the recovery just moved out
|
||
/// of the way.
|
||
pub(crate) fn forget_catalog(ctl: &Rc<LibraryController>) {
|
||
*ctl.catalog.borrow_mut() = None;
|
||
}
|
||
|
||
/// What one cell of the outgoing model is worth keeping.
|
||
#[derive(Clone)]
|
||
struct Held {
|
||
thumbnail: slint::Image,
|
||
has_thumb: bool,
|
||
/// A completed fetch that found no preview. Worth carrying for the same
|
||
/// reason the pixels are: it is an answer about the file, and re-asking it
|
||
/// on every scroll is a fetch that will fail again.
|
||
unavailable: bool,
|
||
/// Which size class the pixels came from, so the reload can tell a cell
|
||
/// that is showing what it should from one that is showing the small class
|
||
/// while the grid has since been zoomed past it.
|
||
class: Option<dr_thumbs::ThumbSize>,
|
||
}
|
||
|
||
/// **What the outgoing model is still holding, keyed on the photograph.**
|
||
///
|
||
/// A reload replaces every row, and a scroll reloads once the view has
|
||
/// travelled a quarter of the loaded window — so three quarters of the cells
|
||
/// being rebuilt are the *same* photographs the user is looking at right now.
|
||
/// Rebuilding them empty blanked the whole grid to `Theme.ground` and refilled
|
||
/// it a beat later, once a worker had re-read and re-decoded every one of them
|
||
/// from the thumbnail store. That is the black flash that punctuated every
|
||
/// screenful of scrolling, and the one visible on a column change, a zoom step,
|
||
/// a filter and a return from develop.
|
||
///
|
||
/// Keyed on `image_id` rather than on the row, because the row is exactly what
|
||
/// a reload changes. Cheap: the values are `slint::Image` handles, so this is a
|
||
/// refcount per cell and no pixels move.
|
||
fn hold_thumbnails(
|
||
previous: &slint::ModelRc<LibraryCell>,
|
||
ids: &[i64],
|
||
classes: &[Option<dr_thumbs::ThumbSize>],
|
||
) -> std::collections::HashMap<i64, Held> {
|
||
ids.iter()
|
||
.enumerate()
|
||
.filter_map(|(row, id)| {
|
||
let cell = previous.row_data(row)?;
|
||
// Nothing to carry: a row still waiting is equally blank either
|
||
// way, and holding a default image would claim otherwise.
|
||
if !cell.has_thumb && !cell.unavailable {
|
||
return None;
|
||
}
|
||
Some((
|
||
*id,
|
||
Held {
|
||
thumbnail: cell.thumbnail,
|
||
has_thumb: cell.has_thumb,
|
||
unavailable: cell.unavailable,
|
||
class: classes.get(row).copied().flatten(),
|
||
},
|
||
))
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// The fetches a reloaded window does **not** have to make.
|
||
///
|
||
/// The set used to be cleared on every load, which said "every cell here is
|
||
/// still to be fetched" — true when every row came back empty, and false now
|
||
/// that the overlap keeps its pixels. Re-requesting them re-read and re-decoded
|
||
/// three quarters of the window from the store on every scroll.
|
||
///
|
||
/// Rebuilt rather than merely kept, and that is the half that is easy to get
|
||
/// wrong: an image served into a window the user has since scrolled away from
|
||
/// is no longer on screen, and a set that remembered it would leave that cell
|
||
/// permanently blank when they scrolled back. Only what the new model actually
|
||
/// holds counts as served — and only at the class it holds it at, so zooming
|
||
/// past the grid class still asks for the large one.
|
||
fn already_served(
|
||
held: &std::collections::HashMap<i64, Held>,
|
||
ids: impl Iterator<Item = i64>,
|
||
) -> std::collections::HashSet<(i64, dr_thumbs::ThumbSize)> {
|
||
ids.filter_map(|id| Some((id, held.get(&id)?.class?)))
|
||
.collect()
|
||
}
|
||
|
||
/// Where the loaded window starts for a view whose first visible cell is
|
||
/// `anchor`.
|
||
///
|
||
/// A quarter of the window sits above the view, so scrolling back has loaded
|
||
/// rows to move into, and the remaining three quarters below, because a grid
|
||
/// is read downward. Clamped to the last position where the window is still
|
||
/// full — otherwise a scrub to the very end loads a handful of cells and the
|
||
/// rest of the window addresses images that do not exist.
|
||
fn window_start(anchor: usize, window_size: usize, total: usize) -> usize {
|
||
let max_offset = total.saturating_sub(window_size.min(total));
|
||
anchor.saturating_sub(window_size / 4).min(max_offset)
|
||
}
|
||
|
||
/// Where the loaded window should move to for a view at `first_visible`, or
|
||
/// `None` to leave it where it is.
|
||
///
|
||
/// # Why this is a rule and not four lines in the scroll handler
|
||
///
|
||
/// It decides how often the grid re-reads the catalog while a finger is on it,
|
||
/// and both of its ways of being wrong are invisible in the code and obvious
|
||
/// on a tablet: too eager and every scroll stutters, too lazy and the view
|
||
/// runs off the end of the loaded rows into blank ones.
|
||
///
|
||
/// # The rule
|
||
///
|
||
/// The window is placed by [`window_start`], and it moves once the view comes
|
||
/// within half a screenful of an edge of what is loaded — below the *bottom*
|
||
/// of the view going down, above its top going up.
|
||
///
|
||
/// # Measured against the view, not against a fraction of the window
|
||
///
|
||
/// Both halves of that used to be a quarter of `window_size`, and both were
|
||
/// wrong, in opposite directions and for the same reason: a quarter of a
|
||
/// three-screenful window is three quarters of a screenful, which is not a
|
||
/// quantity either edge of the view cares about.
|
||
///
|
||
/// - Downward it was too lax. The test asked where the *first* visible cell
|
||
/// was, so it kept the window still until `first_visible` was three quarters
|
||
/// of the way through it — by which point the bottom of the view had
|
||
/// travelled a quarter of a screenful past the last loaded cell. Those rows
|
||
/// are not in the model, so nothing is drawn for them: the last row of the
|
||
/// grid, blank, at a scroll position the user can sit at indefinitely.
|
||
/// - Upward it was too eager, and exactly so. The window is placed a quarter
|
||
/// of itself behind the view, and the old margin then declared the view too
|
||
/// close to the top at precisely that distance — so the first row scrolled
|
||
/// upward after any move re-read the catalog, rebuilt every cell, and
|
||
/// re-queried the badges and ratings, and so did the row after it.
|
||
///
|
||
/// # And it never moves to where it already is
|
||
///
|
||
/// That is the case the margin test alone gets wrong: at either end of a scope
|
||
/// the window is *pinned* — the first screenful cannot be placed further back
|
||
/// than zero, and the last cannot start past `max_offset` — so the margin is
|
||
/// unsatisfiable there and every row crossed in the first or last quarter of a
|
||
/// window re-read the catalog, rebuilt the model and issued a thumbnail batch
|
||
/// to arrive at the offset it already had. On a library of twenty-odd thousand
|
||
/// that is a stutter at the top and the bottom of every collection, which is
|
||
/// exactly where a cull begins and ends.
|
||
fn window_move(
|
||
first_visible: usize,
|
||
current: usize,
|
||
window_size: usize,
|
||
on_screen: usize,
|
||
total: usize,
|
||
) -> Option<usize> {
|
||
// The same placement `load_window` applies, repeated here so this compares
|
||
// against where the window would come to rest rather than where it was
|
||
// asked to go.
|
||
let desired = window_start(first_visible, window_size, total);
|
||
if desired == current {
|
||
return None;
|
||
}
|
||
|
||
// Half a screenful of loaded cells beyond each edge of the view. Enough
|
||
// that a flick has somewhere to land before the reload it triggers has
|
||
// finished, small enough that the window still moves only about once per
|
||
// screenful of travel.
|
||
let slack = (on_screen / 2).max(1);
|
||
let above = first_visible >= current + slack;
|
||
let below = first_visible + on_screen + slack <= current + window_size;
|
||
if above && below {
|
||
return None;
|
||
}
|
||
|
||
Some(desired)
|
||
}
|
||
|
||
/// Fill the model from the catalog and start fetching thumbnails.
|
||
///
|
||
/// Reads the window starting at the controller's current offset, which the
|
||
/// scrubber moves. Without a movable offset the grid could only ever show the
|
||
/// first 120 of 23,971 images.
|
||
fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
let borrow = ctl.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
|
||
// Everything below is scoped to the selected collection, if any: the total,
|
||
// the window, and the fetches issued for it. Reading the whole library here
|
||
// and filtering later would fetch thumbnails for images the user is not
|
||
// looking at, which on a remote library is the cost FR-NC-3 exists to
|
||
// avoid.
|
||
let scope = *ctl.scope.borrow();
|
||
let filter = ctl.filter.borrow().clone();
|
||
// The trash lists what every other view excludes, so it takes its own
|
||
// query rather than another predicate threaded through the scoped one.
|
||
let trash = ctl.viewing_trash.get();
|
||
|
||
let total = if trash {
|
||
library::total_trashed(catalog).unwrap_or(0)
|
||
} else {
|
||
library::total_images_scoped(catalog, scope, &filter).unwrap_or(0)
|
||
};
|
||
|
||
// What the whole-library readouts below describe. See [`LibraryFacts`].
|
||
let facts = LibraryFacts {
|
||
scope,
|
||
filter: filter.clone(),
|
||
trash,
|
||
total,
|
||
};
|
||
let describes_something_new = ctl.library_facts.borrow().as_ref() != Some(&facts);
|
||
*ctl.library_facts.borrow_mut() = Some(facts);
|
||
|
||
// TRACES: FR-NC-6a
|
||
// Whether the newly scoped collection is already pinned. Read here rather
|
||
// than remembered, because a pin outlives the session that made it — on
|
||
// reopening the library the button has to show what the catalog says, not
|
||
// what this run happens to have done.
|
||
//
|
||
// Three queries deep — descendants, their images, and whether every one of
|
||
// them is pinned — and the answer cannot change by scrolling.
|
||
if describes_something_new {
|
||
window.set_library_scope_pinned(match scope {
|
||
Some(id) => dr_catalog::collections::descendants(catalog.connection(), id)
|
||
.map(|ids| collection_images(catalog, &ids))
|
||
.map(|images| scope_is_pinned(catalog, &images))
|
||
.unwrap_or(false),
|
||
None => false,
|
||
});
|
||
}
|
||
// Read before it is overwritten: the property still holds what the library
|
||
// was last time this ran, and a library that has become shorter is the
|
||
// signal that something was deleted out from under the view.
|
||
let was = window.get_library_total().max(0) as usize;
|
||
window.set_library_total(total as i32);
|
||
let shrank = total < was;
|
||
|
||
// Clamp so a scrub to the very end still fills the window rather than
|
||
// showing a handful of cells.
|
||
let window_size = *ctl.window.borrow();
|
||
let offset = (*ctl.offset.borrow()).min(total.saturating_sub(window_size.min(total)));
|
||
*ctl.offset.borrow_mut() = offset;
|
||
window.set_library_offset(offset as i32);
|
||
|
||
// The axis the scrubber draws. A `MIN`/`MAX` and a `GROUP BY` over the
|
||
// whole scope — 5.7 ms on 24,000 images — and the bars do not change as the
|
||
// grid scrolls, only the marker on them does. The marker is moved by the
|
||
// scroll handler on every event, without coming through here.
|
||
//
|
||
// Every other thing that *does* change the bars — a zoom, a pan, a scrub,
|
||
// dates landing from the thumbnail worker — calls `refresh_timeline`
|
||
// itself, so this being skipped cannot leave a stale axis on screen.
|
||
if describes_something_new {
|
||
refresh_timeline(window, catalog, ctl);
|
||
}
|
||
|
||
// TRACES: FR-CAT-7
|
||
// Whether this scope has an order a drag can change, which is what lets the
|
||
// grid draw an insertion caret and accept a drop.
|
||
//
|
||
// Answered here because this is the one place that runs on every re-read —
|
||
// a scope change, a reorder, a collection gaining a child that turns it
|
||
// into a set — so the caret cannot outlive the scope that justified it.
|
||
//
|
||
// The trash is never reorderable. It is a view of what was deleted, ordered
|
||
// by when, and it is not a collection at all.
|
||
window.set_library_reorderable(
|
||
!trash
|
||
&& scope.is_some_and(|c| {
|
||
dr_catalog::collections::orders_manually(catalog.connection(), c).unwrap_or(false)
|
||
}),
|
||
);
|
||
|
||
let cells = if trash {
|
||
library::read_trashed_cells(catalog, offset, window_size)
|
||
} else {
|
||
library::read_cells_scoped(catalog, scope, &filter, offset, window_size)
|
||
};
|
||
let cells = match cells {
|
||
Ok(c) => c,
|
||
Err(e) => {
|
||
window.set_library_error(format!("reading catalog: {e}").into());
|
||
return;
|
||
}
|
||
};
|
||
|
||
// What the window currently spans, in the photographer's own terms.
|
||
let span = cells
|
||
.iter()
|
||
.filter_map(|c| c.captured_at)
|
||
.fold(None::<(i64, i64)>, |acc, t| {
|
||
Some(match acc {
|
||
None => (t, t),
|
||
Some((lo, hi)) => (lo.min(t), hi.max(t)),
|
||
})
|
||
});
|
||
window.set_library_window_label(
|
||
match span {
|
||
Some((lo, hi)) => format!("{} – {}", format_date(lo), format_date(hi)),
|
||
// Nothing here has a date yet: EXIF is read as thumbnails load, so
|
||
// this fills in rather than being an error.
|
||
None => "dates not yet read".to_string(),
|
||
}
|
||
.into(),
|
||
);
|
||
|
||
// Month headings. The grid is ordered by capture time, so without these a
|
||
// wall of thumbnails gives no sense of *when* you are looking — the
|
||
// sidebar says it, but only if you consult it.
|
||
//
|
||
// Marked on the cell that both begins a month and begins a row: a heading
|
||
// stranded mid-row would label the cells to its left, which belong to the
|
||
// previous month.
|
||
let columns = window.get_library_columns().max(1) as usize;
|
||
let mut previous_month: Option<(i64, i64)> = None;
|
||
let headings: Vec<String> = cells
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(i, c)| {
|
||
let Some(t) = c.captured_at else {
|
||
return String::new();
|
||
};
|
||
let (y, m, _, _) = civil_from_unix(t);
|
||
let is_new = previous_month != Some((y, m));
|
||
previous_month = Some((y, m));
|
||
|
||
// A heading is drawn above its row, so it can only sit on a cell
|
||
// that begins one — a heading stranded mid-row would appear to
|
||
// label the cells to its left, which belong to the month before.
|
||
//
|
||
// The first cell of the window always carries one, whichever
|
||
// column it lands in: a scrolled window would otherwise show no
|
||
// date at all until the next month began.
|
||
let begins_row = (i + offset) % columns == 0;
|
||
if i == 0 || (is_new && begins_row) {
|
||
format!("{} {y}", month_name(m))
|
||
} else {
|
||
String::new()
|
||
}
|
||
})
|
||
.collect();
|
||
|
||
// What the outgoing model is still holding — see [`hold_thumbnails`].
|
||
let held = {
|
||
let previous = window.get_library_cells();
|
||
let ids = ctl.image_ids.borrow();
|
||
let classes = ctl.thumb_class.borrow();
|
||
hold_thumbnails(&previous, &ids, &classes)
|
||
};
|
||
|
||
let rows: Vec<LibraryCell> = cells
|
||
.iter()
|
||
.zip(headings)
|
||
.map(|(c, heading)| {
|
||
let carried = held.get(&c.image_id);
|
||
LibraryCell {
|
||
period_heading: heading.into(),
|
||
// A freshly loaded window has no drag in flight.
|
||
lifted: false,
|
||
name: without_extension(&c.name).into(),
|
||
thumbnail: carried.map(|h| h.thumbnail.clone()).unwrap_or_default(),
|
||
has_thumb: carried.is_some_and(|h| h.has_thumb),
|
||
unavailable: carried.is_some_and(|h| h.unavailable),
|
||
// All three are filled straight after by `collections_ui`,
|
||
// which owns the selection and queries the badge counts for the
|
||
// whole window in one statement rather than one per cell.
|
||
selected: false,
|
||
collection_count: 0,
|
||
// Likewise filled by `sync_ratings` below — one query for the
|
||
// window, not one per cell.
|
||
rating: 0,
|
||
flag: 0,
|
||
// And by `bursts::sync_badges`, in one more query for the
|
||
// window. Zero is "not in a burst", which is what almost every
|
||
// photograph in a library is.
|
||
burst_count: 0,
|
||
burst_expanded: false,
|
||
burst_representative: false,
|
||
}
|
||
})
|
||
.collect();
|
||
|
||
*ctl.paths.borrow_mut() = cells.iter().map(|c| c.remote_path.clone()).collect();
|
||
*ctl.file_ids.borrow_mut() = cells.iter().map(|c| c.file_id).collect();
|
||
*ctl.sizes.borrow_mut() = cells.iter().map(|c| c.size).collect();
|
||
*ctl.image_ids.borrow_mut() = cells.iter().map(|c| c.image_id).collect();
|
||
*ctl.needs_metadata.borrow_mut() = cells.iter().map(|c| c.metadata_state < 2).collect();
|
||
*ctl.captured_at.borrow_mut() = cells.iter().map(|c| c.captured_at).collect();
|
||
*ctl.thumb_class.borrow_mut() = cells
|
||
.iter()
|
||
.map(|c| held.get(&c.image_id).and_then(|h| h.class))
|
||
.collect();
|
||
*ctl.requested.borrow_mut() = already_served(&held, cells.iter().map(|c| c.image_id));
|
||
|
||
// The model is about to be replaced, so every thumbnail still in flight
|
||
// addresses a window that no longer exists. Bumping here — before the swap
|
||
// — is what lets `drain_thumbnails` recognise itself as stale. The rows
|
||
// those fetches would have filled are absent from `requested` above, so the
|
||
// batch started below asks for them again.
|
||
ctl.generation.set(ctl.generation.get().wrapping_add(1));
|
||
window.set_library_cells(slint::ModelRc::new(slint::VecModel::from(rows)));
|
||
|
||
// The model is fresh, so the "in this many collections" badges are all zero
|
||
// until refilled. One query for the whole window, not one per cell.
|
||
let ids: Vec<dr_types::ImageId> = cells
|
||
.iter()
|
||
.map(|c| dr_types::ImageId(c.image_id as u64))
|
||
.collect();
|
||
crate::collections_ui::sync_badges(window, catalog, &ids);
|
||
sync_ratings(window, catalog, &ids);
|
||
// How many frames each cell stands for, where it stands for several
|
||
// (FR-CULL-5).
|
||
crate::bursts::sync_badges(window, catalog, &ids);
|
||
// The rebuilt cells all carry `selected: false`, but the selection itself
|
||
// is a set of image ids and survives untouched. Without this the ticks
|
||
// vanished on every scroll — the selection was still there and still acted
|
||
// on, which is worse than losing it, because the user cannot see what the
|
||
// buttons are about to do.
|
||
if let Some(coll) = ctl.coll_ctl.borrow().as_ref().and_then(|w| w.upgrade()) {
|
||
crate::collections_ui::sync_selection(window, &coll, &ids);
|
||
}
|
||
// The filter chips' counts describe the whole library, not this window —
|
||
// which is exactly why they are not recomputed for a window that moved.
|
||
// A judgement changes them and calls this itself (see `apply_judgement`),
|
||
// so a cull still watches its own chips move.
|
||
if describes_something_new {
|
||
refresh_rating_counts(window, catalog);
|
||
}
|
||
|
||
// The library got shorter while the view was looking at it — a delete.
|
||
//
|
||
// Two things have already moved by this point and neither touches the
|
||
// viewport: the scrollable height shrank, and `offset` was re-clamped so
|
||
// the loaded window still fills. So the view is left pointing either past
|
||
// the end of the content (a blank grid) or at a different part of the
|
||
// library (an apparent jump to nowhere). Re-anchoring here puts it back on
|
||
// the photographs the user was actually looking at.
|
||
//
|
||
// Only on a shrink. Doing it on every load would fight a scrub, which sets
|
||
// exactly this property to go somewhere the user asked for.
|
||
if shrank {
|
||
restore_position(window, ctl);
|
||
}
|
||
|
||
if total == 0 {
|
||
return;
|
||
}
|
||
request_thumbnails(window, ctl);
|
||
}
|
||
|
||
/// Push each visible image's stars and flag into the grid model.
|
||
///
|
||
/// One query for the window, mirroring `collections_ui::sync_badges` — 120
|
||
/// cells is 120 round trips otherwise, on every scroll and after every
|
||
/// keystroke.
|
||
pub fn sync_ratings(window: &AppWindow, catalog: &Catalog, ids: &[dr_types::ImageId]) {
|
||
if ids.is_empty() {
|
||
return;
|
||
}
|
||
|
||
let found = match dr_catalog::rating::judgements(catalog.connection(), ids) {
|
||
Ok(j) => j,
|
||
Err(e) => {
|
||
// The grid is still usable without stars, so this is logged rather
|
||
// than surfaced — a failure here must not blank the library.
|
||
log::debug!("reading ratings: {e}");
|
||
return;
|
||
}
|
||
};
|
||
|
||
let model = window.get_library_cells();
|
||
for (row, id) in ids.iter().enumerate() {
|
||
// Absent means unrated, which is a real state rather than missing data.
|
||
let j = found.get(id).copied().unwrap_or_default();
|
||
let (rating, flag) = (j.rating as i32, flag_code(j.flag));
|
||
|
||
if let Some(mut cell) = model.row_data(row) {
|
||
if cell.rating != rating || cell.flag != flag {
|
||
cell.rating = rating;
|
||
cell.flag = flag;
|
||
model.set_row_data(row, cell);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Refresh the filter chips' per-star counts.
|
||
///
|
||
/// Whole-library figures, deliberately: they say what narrowing to a filter
|
||
/// would show, so computing them over the current window would make each chip
|
||
/// describe the view it is meant to change.
|
||
fn refresh_rating_counts(window: &AppWindow, catalog: &Catalog) {
|
||
let counts = dr_catalog::rating::rating_histogram(catalog.connection()).unwrap_or_default();
|
||
let as_i32: Vec<i32> = counts.iter().map(|n| *n as i32).collect();
|
||
window.set_library_rating_counts(slint::ModelRc::new(slint::VecModel::from(as_i32)));
|
||
|
||
// TRACES: FR-CAT-9
|
||
// How many originals are actually here, for the "On this device" chip.
|
||
// Shown for the same reason the star counts are: a filter that silently
|
||
// empties the grid reads as broken, and this one will legitimately be zero
|
||
// on a library nothing has been downloaded from yet.
|
||
window.set_library_local_count(library::local_original_count(catalog).unwrap_or(0) as i32);
|
||
}
|
||
|
||
// --- keywords (FR-CAT-5, FR-CAT-6) ---------------------------------------
|
||
//
|
||
// `dr_catalog::keywords` owns the data rules — the vocabulary, the many-to-many
|
||
// join, what a rename does to the assignments. This part owns the *interaction*:
|
||
// which photographs the sheet is acting on, and keeping what it draws honest
|
||
// about what actually landed.
|
||
|
||
/// Redraw the keywording sheet against whatever is selected now.
|
||
///
|
||
/// Called when the sheet opens and after every assignment, rather than on every
|
||
/// selection change: the selection moves on each arrow key and the sheet is shut
|
||
/// for almost all of them, so computing coverage over a forty-image selection
|
||
/// on each one would be work nobody is looking at.
|
||
///
|
||
/// Re-read from the catalog rather than patched in place after a write. A word
|
||
/// applied to a selection that partly already had it moves from "3 of 12" to
|
||
/// "12 of 12", and a model updated by hand would have to reproduce the rule
|
||
/// that decides that — which is exactly the rule the catalog has just applied.
|
||
fn refresh_keywords(window: &AppWindow, ctl: &Rc<LibraryController>, images: &[dr_types::ImageId]) {
|
||
let borrow = ctl.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
|
||
let rows = match dr_catalog::keywords::for_images(catalog.connection(), images) {
|
||
Ok(rows) => rows,
|
||
Err(e) => {
|
||
// The grid is entirely usable without the sheet, so this is logged
|
||
// rather than surfaced: a keyword read that failed must not put an
|
||
// error banner over a library the user is browsing.
|
||
log::debug!("reading keywords: {e}");
|
||
return;
|
||
}
|
||
};
|
||
|
||
let model: Vec<KeywordRow> = rows
|
||
.into_iter()
|
||
.map(|row| KeywordRow {
|
||
id: row.keyword.id.0 as i32,
|
||
name: row.keyword.name.into(),
|
||
coverage: match row.coverage {
|
||
dr_catalog::Coverage::None => 0,
|
||
dr_catalog::Coverage::Some => 1,
|
||
dr_catalog::Coverage::All => 2,
|
||
},
|
||
selected_count: row.selected_count as i32,
|
||
image_count: row.keyword.image_count as i32,
|
||
})
|
||
.collect();
|
||
|
||
window.set_library_keywords(slint::ModelRc::new(slint::VecModel::from(model)));
|
||
}
|
||
|
||
/// Put a keyword on the selection, or take it off.
|
||
///
|
||
/// # Why this does not write a sidecar
|
||
///
|
||
/// Every other judgement in this file — a star, a flag — is written to the
|
||
/// catalog and then queued to the image's sidecar, because the sidecar is what
|
||
/// makes it survive a catalog rebuild (ARCH §6.12). A keyword has no place in
|
||
/// the sidecar format yet: `dr_pipeline::sidecar::Version` carries `rating` and
|
||
/// `flag` and nothing else that is not an edit-graph parameter.
|
||
///
|
||
/// So a keyword is, for now, catalog state that reaches the user's other
|
||
/// devices through the *catalog* merge ([`dr_catalog::merge`]) rather than
|
||
/// through the sidecar. That is a real limitation and not a silent one: a
|
||
/// deleted catalog loses keywords where it would keep ratings, until the
|
||
/// sidecar gains a `dc:subject` field (FR-CAT-13) and this grows the same
|
||
/// queued write the stars have.
|
||
fn apply_keyword(window: &AppWindow, ctl: &Rc<LibraryController>, word: &str, assigning: bool) {
|
||
let Some(coll) = ctl.coll_ctl.borrow().as_ref().and_then(|c| c.upgrade()) else {
|
||
return;
|
||
};
|
||
let images = coll.selected();
|
||
|
||
// The word as it will be *stored*, resolved before anything is written.
|
||
// The status line below quotes it back, and quoting what was typed would
|
||
// report a leading space the catalog is about to drop — leaving the user to
|
||
// wonder whether it mattered.
|
||
//
|
||
// This is also where a blank keyword is caught, which is why it happens
|
||
// before the selection check: "you typed nothing" is a better answer than
|
||
// "select an image first" to someone who pressed return on an empty field.
|
||
let word = match dr_catalog::keywords::normalise(word) {
|
||
Ok(word) => word,
|
||
Err(e) => {
|
||
// `BadName` carries text written to be read by the user rather than
|
||
// by a developer, so it is shown as it is.
|
||
window.set_library_error(format!("{e}").into());
|
||
return;
|
||
}
|
||
};
|
||
|
||
// Assigning with nothing selected still means something — it puts the word
|
||
// in the vocabulary, ready for the photographs it was typed for — so only
|
||
// the removal half needs a selection to act on.
|
||
if images.is_empty() && !assigning {
|
||
window.set_library_status("Select an image first".into());
|
||
return;
|
||
}
|
||
|
||
let outcome = {
|
||
let borrow = ctl.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
let conn = catalog.connection();
|
||
if assigning {
|
||
dr_catalog::keywords::assign(conn, &images, &word)
|
||
} else {
|
||
dr_catalog::keywords::unassign(conn, &images, &word)
|
||
}
|
||
};
|
||
|
||
let n = match outcome {
|
||
Ok(n) => n,
|
||
Err(e) => {
|
||
window.set_library_error(format!("{e}").into());
|
||
return;
|
||
}
|
||
};
|
||
|
||
window.set_library_error(slint::SharedString::new());
|
||
window.set_library_status(keyword_summary(&word, n, images.len(), assigning).into());
|
||
refresh_keywords(window, ctl, &images);
|
||
start_xmp_writes(window, ctl, &images);
|
||
|
||
// A filtered grid may no longer hold what was just keyworded — taking
|
||
// "puffin" off an image while showing only puffins means it belongs
|
||
// elsewhere now. The same reasoning as a rating that falls below the star
|
||
// filter.
|
||
if !ctl.filter.borrow().is_unfiltered() {
|
||
load_window(window, ctl);
|
||
}
|
||
}
|
||
|
||
/// What the status line says about a keyword that just landed.
|
||
///
|
||
/// The honest count, not the requested one: "added to 3 of 12" is what
|
||
/// happened when nine of them already carried the word, and a message that
|
||
/// claimed twelve would be teaching the user that the counts are decorative.
|
||
fn keyword_summary(word: &str, changed: usize, selected: usize, assigning: bool) -> String {
|
||
if selected == 0 {
|
||
return format!("Added “{word}” to the keyword list");
|
||
}
|
||
let verb = if assigning { "Added" } else { "Removed" };
|
||
let preposition = if assigning { "to" } else { "from" };
|
||
if changed == 0 {
|
||
return if assigning {
|
||
format!("Every selected photograph already had “{word}”")
|
||
} else {
|
||
format!("None of the selected photographs had “{word}”")
|
||
};
|
||
}
|
||
if changed == selected {
|
||
let what = if selected == 1 {
|
||
"1 photograph".to_string()
|
||
} else {
|
||
format!("{selected} photographs")
|
||
};
|
||
return format!("{verb} “{word}” {preposition} {what}");
|
||
}
|
||
format!("{verb} “{word}” {preposition} {changed} of {selected}")
|
||
}
|
||
|
||
/// Apply a judgement to a set of images: catalog first, then sidecars.
|
||
///
|
||
/// # Order matters
|
||
///
|
||
/// The catalog is written **synchronously and first**, so the star appears
|
||
/// immediately and survives a restart even if the network is down. The sidecar
|
||
/// write is queued behind it on a worker thread — it is what makes the
|
||
/// judgement survive a *catalog rebuild* (ARCH §6.12), which is a slower and
|
||
/// rarer concern than the user seeing their keystroke take effect.
|
||
///
|
||
/// Doing it the other way round would mean a cull that stalls on every
|
||
/// keypress waiting for a round trip, on a workflow whose entire premise is
|
||
/// speed (FR-CULL-1).
|
||
fn apply_judgement(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
images: &[dr_types::ImageId],
|
||
rating: Option<u8>,
|
||
flag: Option<dr_types::FlagState>,
|
||
) {
|
||
if images.is_empty() {
|
||
// Nothing selected. Said out loud rather than ignored: a keystroke
|
||
// that silently does nothing reads as a broken key.
|
||
window.set_library_status("Select an image first".into());
|
||
return;
|
||
}
|
||
|
||
let writes = {
|
||
let borrow = ctl.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
let conn = catalog.connection();
|
||
|
||
let wrote = match (rating, flag) {
|
||
(Some(r), _) => dr_catalog::rating::set_rating_many(conn, images, r),
|
||
(_, Some(f)) => dr_catalog::rating::set_flag_many(conn, images, f),
|
||
// Neither axis named: nothing to do, and not an error.
|
||
(None, None) => return,
|
||
};
|
||
|
||
if let Err(e) = wrote {
|
||
window.set_library_error(format!("recording rating: {e}").into());
|
||
return;
|
||
}
|
||
|
||
// Report what happened, in the user's terms rather than as a count of
|
||
// rows. A bulk judgement on a selection is easy to trigger by accident
|
||
// and the status line is the only confirmation of its extent.
|
||
window.set_library_status(judgement_summary(images.len(), rating, flag).into());
|
||
|
||
// Refresh the grid and the chips from what actually landed, rather
|
||
// than assuming the write took: a clamped or coalesced value must show
|
||
// as what is stored.
|
||
let visible = ctl.visible_ids();
|
||
sync_ratings(window, catalog, &visible);
|
||
refresh_rating_counts(window, catalog);
|
||
|
||
collect_sidecar_writes(catalog, images)
|
||
};
|
||
|
||
// A filtered grid may no longer contain what was just judged — rating an
|
||
// image 2 while showing "★4+" means it belongs elsewhere now. Reloading
|
||
// keeps the cells and the header count honest.
|
||
if !ctl.filter.borrow().is_unfiltered() {
|
||
load_window(window, ctl);
|
||
}
|
||
|
||
start_sidecar_writes(window, ctl, writes);
|
||
start_xmp_writes(window, ctl, images);
|
||
}
|
||
|
||
/// What the status line says about a judgement that just landed.
|
||
fn judgement_summary(n: usize, rating: Option<u8>, flag: Option<dr_types::FlagState>) -> String {
|
||
let what = match (rating, flag) {
|
||
(Some(0), _) => "unrated".to_string(),
|
||
(Some(r), _) => format!("{r} star{}", if r == 1 { "" } else { "s" }),
|
||
(_, Some(dr_types::FlagState::Pick)) => "picked".to_string(),
|
||
(_, Some(dr_types::FlagState::Reject)) => "rejected".to_string(),
|
||
(_, Some(dr_types::FlagState::Unflagged)) => "unflagged".to_string(),
|
||
(None, None) => return String::new(),
|
||
};
|
||
if n == 1 {
|
||
what
|
||
} else {
|
||
format!("{n} images · {what}")
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-DEV-6
|
||
/// Apply copied develop settings to a selection of images.
|
||
///
|
||
/// # Why this goes straight to the sidecars
|
||
///
|
||
/// The sidecar is the authoritative store for an edit (ARCH §6.12) and the
|
||
/// catalog holds no parameters at all — only a `graph_hash` — so there is
|
||
/// nothing here for the catalog to record. Nor is any image opened: applying
|
||
/// to forty frames by loading forty develop sessions would mean forty RAW
|
||
/// downloads and forty demosaics to move some numbers between two maps, which
|
||
/// is not a thing to ask of a phone. See [`crate::presets`].
|
||
///
|
||
/// # What the user sees
|
||
///
|
||
/// Nothing in the grid changes — a thumbnail is rendered from the server's
|
||
/// preview and does not reflect an edit — so the status line is the only
|
||
/// confirmation, exactly as it is for a bulk judgement. The applied settings
|
||
/// appear when a target is next opened in develop, which is what reads the
|
||
/// sidecar back.
|
||
pub fn paste_settings_to_selection(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
images: &[dr_types::ImageId],
|
||
preset: &dr_pipeline::Preset,
|
||
scope: dr_pipeline::Scope,
|
||
) {
|
||
if images.is_empty() {
|
||
// Said out loud rather than ignored, matching what a judgement
|
||
// keystroke does with an empty selection.
|
||
window.set_library_status("Select an image first".into());
|
||
return;
|
||
}
|
||
|
||
let writes = {
|
||
let borrow = ctl.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
// A never-judged image may have no version row yet, and the query
|
||
// below joins on one. Ratings create them as a side effect; a paste
|
||
// is the first write path that can reach an image which has never
|
||
// been rated, so it has to ask for them itself.
|
||
if let Err(e) = dr_catalog::rating::ensure_default_versions(catalog.connection()) {
|
||
log::debug!("ensuring versions before a paste: {e}");
|
||
}
|
||
collect_settings_writes(catalog, images, preset, scope)
|
||
};
|
||
|
||
if writes.is_empty() {
|
||
window.set_library_error("Could not find those images in the catalog.".into());
|
||
return;
|
||
}
|
||
|
||
let count = writes.len();
|
||
window.set_library_status(
|
||
format!(
|
||
"Applied settings to {count} image{}.",
|
||
if count == 1 { "" } else { "s" }
|
||
)
|
||
.into(),
|
||
);
|
||
start_sidecar_writes(window, ctl, writes);
|
||
}
|
||
|
||
/// Gather one settings write per image, addressed by remote path and version.
|
||
///
|
||
/// The uuid comes from the catalog for the same reason a judgement's does: it
|
||
/// is the identity a cross-device merge keys on, and a generated one would
|
||
/// write a second version beside the one the image already has (FR-NC-8).
|
||
fn collect_settings_writes(
|
||
catalog: &Catalog,
|
||
images: &[dr_types::ImageId],
|
||
preset: &dr_pipeline::Preset,
|
||
scope: dr_pipeline::Scope,
|
||
) -> Vec<library::SidecarWrite> {
|
||
let placeholders = std::iter::repeat_n("?", images.len())
|
||
.collect::<Vec<_>>()
|
||
.join(",");
|
||
let sql = format!(
|
||
"SELECT i.source_ref, v.uuid
|
||
FROM images i
|
||
JOIN versions v ON v.image_id = i.id AND v.is_default = 1
|
||
WHERE i.id IN ({placeholders})"
|
||
);
|
||
let params: Vec<rusqlite::types::Value> = images
|
||
.iter()
|
||
.map(|i| rusqlite::types::Value::Integer(i.0 as i64))
|
||
.collect();
|
||
|
||
let Ok(mut stmt) = catalog.connection().prepare(&sql) else {
|
||
return Vec::new();
|
||
};
|
||
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
||
Ok(library::SidecarWrite {
|
||
image_path: r.get(0)?,
|
||
version_uuid: r.get(1)?,
|
||
amendment: library::Amendment::Settings {
|
||
preset: preset.clone(),
|
||
scope,
|
||
// A paste carries no masks, and must not: a mask is drawn
|
||
// against one photograph and describes nothing on another.
|
||
// The target keeps whatever local adjustments it already had.
|
||
masks: None,
|
||
// A paste carries no film either, and for a plainer reason
|
||
// than the masks: a preset is a parameter map, and a stock is
|
||
// not a parameter. Pasting one would be pasting a choice the
|
||
// clipboard never captured.
|
||
film: None,
|
||
// Nor snapshots: they are states of the photograph they were
|
||
// taken on, and mean nothing on another.
|
||
snapshots: None,
|
||
},
|
||
})
|
||
});
|
||
|
||
match rows {
|
||
Ok(rows) => rows.flatten().collect(),
|
||
Err(e) => {
|
||
log::debug!("collecting settings writes: {e}");
|
||
Vec::new()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Gather what the sidecar writer needs for each judged image.
|
||
///
|
||
/// The version uuid comes from the catalog rather than being generated here:
|
||
/// it is the identity a cross-device merge keys on, so the sidecar and the
|
||
/// catalog must name the same version or a sync would treat one photograph's
|
||
/// judgement as two (FR-NC-8).
|
||
fn collect_sidecar_writes(
|
||
catalog: &Catalog,
|
||
images: &[dr_types::ImageId],
|
||
) -> Vec<library::SidecarWrite> {
|
||
let placeholders = std::iter::repeat_n("?", images.len())
|
||
.collect::<Vec<_>>()
|
||
.join(",");
|
||
let sql = format!(
|
||
"SELECT i.source_ref, v.uuid, v.rating, v.flag
|
||
FROM images i
|
||
JOIN versions v ON v.image_id = i.id AND v.is_default = 1
|
||
WHERE i.id IN ({placeholders})"
|
||
);
|
||
let params: Vec<rusqlite::types::Value> = images
|
||
.iter()
|
||
.map(|i| rusqlite::types::Value::Integer(i.0 as i64))
|
||
.collect();
|
||
|
||
let Ok(mut stmt) = catalog.connection().prepare(&sql) else {
|
||
return Vec::new();
|
||
};
|
||
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
||
Ok(library::SidecarWrite {
|
||
image_path: r.get(0)?,
|
||
version_uuid: r.get(1)?,
|
||
amendment: library::Amendment::Judgement {
|
||
rating: r.get::<_, i64>(2)? as u8,
|
||
flag: r.get::<_, i64>(3)? as u8,
|
||
},
|
||
})
|
||
});
|
||
|
||
match rows {
|
||
Ok(rows) => rows.flatten().collect(),
|
||
Err(e) => {
|
||
log::debug!("collecting sidecar writes: {e}");
|
||
Vec::new()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Push judgements out to sidecars on a worker, reporting once at the end.
|
||
/// TRACES: FR-CAT-13 | NFR-R4
|
||
/// Write these images' ratings, labels and keywords to the `.xmp` beside
|
||
/// each, where the user has switched that on.
|
||
///
|
||
/// The record is read from the catalog *now*, whole, rather than carried
|
||
/// from the gesture: a rating and a keyword typed a second apart are two
|
||
/// writes of the same file, and the second must not carry a copy of the
|
||
/// first taken before it landed.
|
||
pub(crate) fn start_xmp_writes(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
images: &[dr_types::ImageId],
|
||
) {
|
||
if !ctl.write_xmp.get() || images.is_empty() || ctl.is_offline() {
|
||
return;
|
||
}
|
||
let Some((conn, _)) = ctl.session.borrow().clone() else {
|
||
return;
|
||
};
|
||
let writes: Vec<library::XmpWrite> = {
|
||
let borrow = ctl.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
let c = catalog.connection();
|
||
images
|
||
.iter()
|
||
.filter_map(|&image| {
|
||
let version = dr_catalog::rating::default_version_id(c, image).ok()?;
|
||
let image_path: String = c
|
||
.query_row(
|
||
"SELECT source_ref FROM images WHERE id = ?1",
|
||
[image.0 as i64],
|
||
|r| r.get(0),
|
||
)
|
||
.ok()?;
|
||
Some(library::XmpWrite {
|
||
image_path,
|
||
record: crate::xmp_sync::record_of(c, image, version),
|
||
})
|
||
})
|
||
.collect()
|
||
};
|
||
if writes.is_empty() {
|
||
return;
|
||
}
|
||
|
||
let count = writes.len();
|
||
let rx = library::spawn_xmp_writes(conn, writes);
|
||
let job = ctl.activity.begin(
|
||
crate::activity::Kind::Upload,
|
||
format!("Writing {count} XMP sidecar(s)"),
|
||
);
|
||
drain_xmp(window.as_weak(), ctl.clone(), rx, job, false);
|
||
}
|
||
|
||
/// TRACES: FR-CAT-13
|
||
/// The offered reload: take the sidecars' values for every photograph the
|
||
/// last pull found disagreeing with the catalog.
|
||
pub(crate) fn start_xmp_reload(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
let Some((conn, _)) = ctl.session.borrow().clone() else {
|
||
return;
|
||
};
|
||
let paths: Vec<String> = {
|
||
let borrow = ctl.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
let Some(root_id) = root_id_of(catalog, &conn.account.root) else {
|
||
return;
|
||
};
|
||
crate::xmp_sync::conflicts(catalog, root_id)
|
||
.into_iter()
|
||
.map(|c| c.path)
|
||
.collect()
|
||
};
|
||
if paths.is_empty() {
|
||
return;
|
||
}
|
||
let count = paths.len();
|
||
let rx = library::spawn_xmp_reload(
|
||
conn.clone(),
|
||
conn.account.root.clone(),
|
||
library::catalog_path(&conn.account),
|
||
paths,
|
||
);
|
||
let job = ctl.activity.begin(
|
||
crate::activity::Kind::Download,
|
||
format!("Reloading {count} XMP sidecar(s)"),
|
||
);
|
||
drain_xmp(window.as_weak(), ctl.clone(), rx, job, true);
|
||
}
|
||
|
||
/// Wait for a batch of XMP work to report, then say what it did.
|
||
fn drain_xmp(
|
||
weak: slint::Weak<AppWindow>,
|
||
ctl: Rc<LibraryController>,
|
||
rx: std::sync::mpsc::Receiver<library::XmpMessage>,
|
||
job: crate::activity::Activity,
|
||
reload: bool,
|
||
) {
|
||
let timer = slint::Timer::default();
|
||
let job = RefCell::new(Some(job));
|
||
let ctl_cb = ctl.clone();
|
||
timer.start(
|
||
slint::TimerMode::Repeated,
|
||
std::time::Duration::from_millis(200),
|
||
move || {
|
||
let ctl = &ctl_cb;
|
||
let message = match rx.try_recv() {
|
||
Ok(m) => m,
|
||
Err(std::sync::mpsc::TryRecvError::Empty) => return,
|
||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||
stop(&ctl.xmp_timer);
|
||
return;
|
||
}
|
||
};
|
||
stop(&ctl.xmp_timer);
|
||
let library::XmpMessage::Finished {
|
||
written,
|
||
failed,
|
||
last_error,
|
||
} = message;
|
||
let status = match (reload, failed, last_error) {
|
||
(false, 0, _) => format!("{written} XMP sidecar(s) written"),
|
||
(true, 0, _) => format!("{written} photograph(s) reloaded from XMP"),
|
||
(_, n, Some(e)) => format!("{n} XMP sidecar(s) failed: {e}"),
|
||
(_, n, None) => format!("{n} XMP sidecar(s) failed"),
|
||
};
|
||
if let Some(job) = job.borrow_mut().take() {
|
||
if failed > 0 {
|
||
job.fail(status.clone());
|
||
} else {
|
||
job.finish(status.clone());
|
||
}
|
||
}
|
||
if let Some(w) = weak.upgrade() {
|
||
w.set_library_status(status.into());
|
||
if reload {
|
||
// The grid draws what the reload changed, and the
|
||
// settings page stops offering what is settled.
|
||
let visible = ctl.visible_ids();
|
||
if let Some(catalog) = ctl.catalog.borrow().as_ref() {
|
||
sync_ratings(&w, catalog, &visible);
|
||
refresh_rating_counts(&w, catalog);
|
||
}
|
||
refresh_xmp_conflicts(&w, ctl);
|
||
}
|
||
}
|
||
},
|
||
);
|
||
*ctl.xmp_timer.borrow_mut() = Some(timer);
|
||
}
|
||
|
||
/// TRACES: FR-CAT-13
|
||
/// How many sidecars the last pull found disagreeing with the catalog, for
|
||
/// the settings page to offer the reload against.
|
||
pub(crate) fn refresh_xmp_conflicts(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
let count = (|| {
|
||
let (conn, _) = ctl.session.borrow().clone()?;
|
||
let borrow = ctl.catalog.borrow();
|
||
let catalog = borrow.as_ref()?;
|
||
let root_id = root_id_of(catalog, &conn.account.root)?;
|
||
Some(crate::xmp_sync::conflicts(catalog, root_id).len())
|
||
})()
|
||
.unwrap_or(0);
|
||
window.set_settings_xmp_conflicts(count as i32);
|
||
}
|
||
|
||
fn root_id_of(catalog: &Catalog, root: &str) -> Option<i64> {
|
||
catalog
|
||
.connection()
|
||
.query_row(
|
||
"SELECT id FROM roots WHERE label = ?1 AND kind = 'remote'",
|
||
[root],
|
||
|r| r.get(0),
|
||
)
|
||
.ok()
|
||
}
|
||
|
||
pub(crate) fn start_sidecar_writes(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
writes: Vec<library::SidecarWrite>,
|
||
) {
|
||
if writes.is_empty() {
|
||
return;
|
||
}
|
||
|
||
// TRACES: FR-CAT-9
|
||
// Offline is passed down rather than used to skip.
|
||
//
|
||
// It used to skip, and the reasoning was that a cull stays responsive
|
||
// because the rating is safe in the catalog. That held for judgements and
|
||
// not for edits: the catalog stores no parameters, so a pasted edit made
|
||
// offline survived nowhere at all. Every write now commits to the local
|
||
// sidecar cache first and the upload is best-effort, which keeps the
|
||
// keystroke path off the network — the original concern — without the
|
||
// write being conditional on it.
|
||
let offline = ctl.is_offline();
|
||
|
||
let Some((conn, _)) = ctl.session.borrow().clone() else {
|
||
return;
|
||
};
|
||
let Some(cache_dir) = ctl.sidecar_cache_dir() else {
|
||
return;
|
||
};
|
||
|
||
let count = writes.len();
|
||
let rx = library::spawn_sidecar_writes(conn.clone(), writes, cache_dir, offline);
|
||
|
||
let timer = slint::Timer::default();
|
||
let weak = window.as_weak();
|
||
let ctl_cb = ctl.clone();
|
||
|
||
// The writer reports once at the end, so there is no per-file progress to
|
||
// show — but a cull that has just rated forty frames has forty uploads in
|
||
// flight, and "is that saved yet" deserves an answer somewhere.
|
||
let job = ctl.activity.begin(
|
||
crate::activity::Kind::Upload,
|
||
format!("Saving {count} judgement(s)"),
|
||
);
|
||
|
||
timer.start(
|
||
slint::TimerMode::Repeated,
|
||
std::time::Duration::from_millis(200),
|
||
move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
// One message is all this channel ever carries — the writer reports
|
||
// `Finished` once and hangs up — so this drains a single item rather
|
||
// than looping like the scan and thumbnail drains do.
|
||
match rx.try_recv() {
|
||
Ok(library::SidecarMessage::Finished {
|
||
written,
|
||
queued,
|
||
failed,
|
||
last_error,
|
||
}) => {
|
||
if failed == 0 && queued > 0 {
|
||
// Recorded locally, waiting for the server. Said out
|
||
// loud because the user has just made an edit with no
|
||
// connection and deserves to know it is safe — the
|
||
// old behaviour here was to drop it silently.
|
||
log::debug!("{queued} sidecar(s) queued for upload");
|
||
ctl_cb.outbox_maybe_dirty.set(true);
|
||
job.finish_quietly();
|
||
w.set_library_status(
|
||
format!("{queued} edit(s) saved · will upload when back online").into(),
|
||
);
|
||
stop(&ctl_cb.sidecar_timer);
|
||
return;
|
||
}
|
||
if failed > 0 {
|
||
// A failed upload left the edit in the outbox.
|
||
ctl_cb.outbox_maybe_dirty.set(true);
|
||
log::warn!(
|
||
"{failed} sidecar write(s) failed: {}",
|
||
last_error.clone().unwrap_or_default()
|
||
);
|
||
job.fail(format!(
|
||
"{written} saved · {failed} failed: {}",
|
||
last_error.clone().unwrap_or_default()
|
||
));
|
||
// Said plainly, because the consequence is specific:
|
||
// the rating is safe in the catalog but will not
|
||
// survive deleting it.
|
||
w.set_library_status(
|
||
format!(
|
||
"{written} saved · {failed} could not be written \
|
||
to the library folder"
|
||
)
|
||
.into(),
|
||
);
|
||
} else {
|
||
log::debug!("{written} sidecar(s) written");
|
||
// Quietly: a cull produces one of these every few
|
||
// seconds and none of them is news.
|
||
job.finish_quietly();
|
||
}
|
||
stop(&ctl_cb.sidecar_timer);
|
||
}
|
||
Err(std::sync::mpsc::TryRecvError::Empty) => {}
|
||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||
stop(&ctl_cb.sidecar_timer);
|
||
}
|
||
}
|
||
},
|
||
);
|
||
|
||
*ctl.sidecar_timer.borrow_mut() = Some(timer);
|
||
}
|
||
|
||
/// Slint carries the flag as an integer, matching the catalog's encoding.
|
||
fn flag_code(f: dr_types::FlagState) -> i32 {
|
||
match f {
|
||
dr_types::FlagState::Unflagged => 0,
|
||
dr_types::FlagState::Pick => 1,
|
||
dr_types::FlagState::Reject => 2,
|
||
}
|
||
}
|
||
|
||
/// The flag an integer from Slint stands for.
|
||
fn flag_from_code(v: i32) -> dr_types::FlagState {
|
||
match v {
|
||
1 => dr_types::FlagState::Pick,
|
||
2 => dr_types::FlagState::Reject,
|
||
_ => dr_types::FlagState::Unflagged,
|
||
}
|
||
}
|
||
|
||
/// Where a row of the loaded window sits in the fetch queue.
|
||
///
|
||
/// On screen first, top to bottom; then the rows below the view, nearest
|
||
/// first; then the rows above it, nearest first.
|
||
///
|
||
/// # Why the queue has an order at all
|
||
///
|
||
/// The batch is fetched one image at a time, two round trips each, and it is
|
||
/// abandoned wholesale the moment the window moves — so whatever is at the
|
||
/// back of it on a remote library is not slow to appear, it never appears.
|
||
/// Issued in model order, the back of the queue was the bottom of the screen
|
||
/// and everything below it, and the *front* was the quarter-window of cells
|
||
/// above the view that nobody was looking at. A scroll then abandoned the
|
||
/// batch and the new one started over, again from above the view: on a
|
||
/// library still filling its store, the last rows of the grid could be
|
||
/// starved for as long as the scrolling continued.
|
||
///
|
||
/// Below before above because that is where the view is going. Scrolling back
|
||
/// over cells already fetched is served from the store, and from `requested`
|
||
/// without a fetch at all.
|
||
fn fetch_rank(row: usize, first_on_screen: usize, on_screen: usize) -> (u8, usize) {
|
||
let past = first_on_screen + on_screen;
|
||
if row >= first_on_screen && row < past {
|
||
(0, row - first_on_screen)
|
||
} else if row >= past {
|
||
(1, row - past)
|
||
} else {
|
||
(2, first_on_screen - row)
|
||
}
|
||
}
|
||
|
||
/// Fetch thumbnails for rows in the model that do not have one yet.
|
||
fn request_thumbnails(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
let Some((conn, _)) = ctl.session.borrow().clone() else {
|
||
return;
|
||
};
|
||
|
||
// The drawn cell size decides which class to ask for. Chosen once for the
|
||
// batch rather than per row, and carried through to the drain so a cell it
|
||
// fills can record what it is now showing.
|
||
let cell_pixels = window.get_library_cell_size().max(1.0) as u32;
|
||
let class = dr_thumbs::ThumbSize::for_cell(cell_pixels);
|
||
|
||
let mut wanted: Vec<library::ThumbnailRequest> = {
|
||
let paths = ctl.paths.borrow();
|
||
let file_ids = ctl.file_ids.borrow();
|
||
let sizes = ctl.sizes.borrow();
|
||
let image_ids = ctl.image_ids.borrow();
|
||
let needs_md = ctl.needs_metadata.borrow();
|
||
let mut requested = ctl.requested.borrow_mut();
|
||
paths
|
||
.iter()
|
||
.enumerate()
|
||
.filter_map(|(i, p)| {
|
||
let image_id = *image_ids.get(i)?;
|
||
// A zoomed grid asks for detail a 256px thumbnail cannot give,
|
||
// and a wall of small cells does not pay for it.
|
||
let thumb_size = class;
|
||
// Keyed on the photograph, so scrolling back over a cell that
|
||
// has already been served does not ask for it again.
|
||
if !requested.insert((image_id, thumb_size)) {
|
||
return None;
|
||
}
|
||
Some(library::ThumbnailRequest {
|
||
row: i,
|
||
path: p.clone(),
|
||
file_id: file_ids.get(i).copied().flatten(),
|
||
size: sizes.get(i).copied().unwrap_or(0),
|
||
image_id,
|
||
needs_metadata: needs_md.get(i).copied().unwrap_or(false),
|
||
thumb_size,
|
||
// The grid is filling a cell of a known size.
|
||
full_resolution: false,
|
||
})
|
||
})
|
||
.collect()
|
||
};
|
||
|
||
if wanted.is_empty() {
|
||
return;
|
||
}
|
||
|
||
// What is on screen, first — see [`fetch_rank`]. The rows keep addressing
|
||
// the model they were built against; only the order they are asked for in
|
||
// changes.
|
||
{
|
||
let first_on_screen = ctl.resume_at.get().saturating_sub(*ctl.offset.borrow());
|
||
let on_screen = ctl.viewport_cells.get().max(1);
|
||
wanted.sort_by_key(|r| fetch_rank(r.row, first_on_screen, on_screen));
|
||
}
|
||
|
||
let requested = wanted.len();
|
||
let rx = library::spawn_thumbnails(
|
||
conn.clone(),
|
||
wanted,
|
||
library::thumbs_dir(&conn.account),
|
||
library::catalog_path(&conn.account),
|
||
);
|
||
drain_thumbnails(window.as_weak(), ctl.clone(), rx, requested, class);
|
||
}
|
||
|
||
/// TRACES: FR-CAT-9 | FR-DEV-6
|
||
/// Replace a photograph's cached thumbnail with one rendered from its edit.
|
||
///
|
||
/// # Why the grid cannot be left alone
|
||
///
|
||
/// A thumbnail comes from the file's embedded preview, which is the camera's
|
||
/// idea of the photograph and knows nothing about what has been done to it
|
||
/// since. So a frame could be cropped, turned upright, and pulled two stops
|
||
/// back, and the grid would go on showing the original — the one view of a
|
||
/// library where an edit is least visible is the one the photographer spends
|
||
/// most of their time in.
|
||
///
|
||
/// # Both classes, and why
|
||
///
|
||
/// The store keys on the size class, so replacing only the one the grid
|
||
/// happens to be drawing at leaves the other holding the unedited preview —
|
||
/// and a zoom past the class boundary would show the edit undoing itself.
|
||
/// Each class is rendered separately because they are different sizes; a
|
||
/// downscale of the large one would be a second, worse resampler than the GPU
|
||
/// already applied.
|
||
///
|
||
/// # Silent on failure
|
||
///
|
||
/// The edit is saved to the sidecar by the caller before this runs, so nothing
|
||
/// here can lose work. A thumbnail that could not be re-rendered is a stale
|
||
/// cell, which the next scroll past it corrects from the store — worth a log
|
||
/// line and not worth an error in front of a photographer who has just
|
||
/// finished an image.
|
||
pub fn refresh_thumbnail(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
remote_path: &str,
|
||
mut render: impl FnMut(u32) -> Result<(u32, u32, Vec<u8>), String>,
|
||
) {
|
||
// Where this photograph sits in the loaded window. It is always in it: the
|
||
// develop view is reached from a cell, and the guards on the scroll and
|
||
// geometry handlers stop the window moving while it is open.
|
||
let Some(row) = ctl.paths.borrow().iter().position(|p| p == remote_path) else {
|
||
return;
|
||
};
|
||
let Some(file_id) = ctl.file_ids.borrow().get(row).copied().flatten() else {
|
||
// Nothing to key the store on. A photograph the scan recorded without
|
||
// a server file id cannot have a cached thumbnail either, so there is
|
||
// nothing here to correct.
|
||
return;
|
||
};
|
||
let Some((conn, _)) = ctl.session.borrow().clone() else {
|
||
return;
|
||
};
|
||
|
||
let mut store = match dr_thumbs::ThumbStore::open(&library::thumbs_dir(&conn.account)) {
|
||
Ok(s) => s,
|
||
Err(e) => {
|
||
log::warn!("re-thumbnailing {remote_path}: opening the store: {e}");
|
||
return;
|
||
}
|
||
};
|
||
|
||
// What the grid is drawing at, so the cell can be corrected on screen
|
||
// rather than only on disk.
|
||
let drawn = dr_thumbs::ThumbSize::for_cell(window.get_library_cell_size().max(1.0) as u32);
|
||
|
||
for class in [dr_thumbs::ThumbSize::Grid, dr_thumbs::ThumbSize::Large] {
|
||
let (w, h, rgba) = match render(class.edge()) {
|
||
Ok(r) => r,
|
||
Err(e) => {
|
||
log::warn!("re-thumbnailing {remote_path} at {class:?}: {e}");
|
||
continue;
|
||
}
|
||
};
|
||
|
||
match dr_thumbs::codec::encode_rgba(w, h, &rgba) {
|
||
Ok(bytes) => {
|
||
let thumb = dr_thumbs::Thumbnail {
|
||
width: w,
|
||
height: h,
|
||
bytes,
|
||
};
|
||
if let Err(e) = store.put(file_id, class, &thumb) {
|
||
log::warn!("re-thumbnailing {remote_path} at {class:?}: {e}");
|
||
}
|
||
}
|
||
Err(e) => {
|
||
log::warn!("re-thumbnailing {remote_path} at {class:?}: encoding: {e}");
|
||
continue;
|
||
}
|
||
}
|
||
|
||
if class == drawn {
|
||
// Straight into the model, so the edit is on the cell the moment
|
||
// the grid comes back rather than after a scroll evicts and
|
||
// refetches it.
|
||
let model = window.get_library_cells();
|
||
if let Some(mut cell) = model.row_data(row) {
|
||
cell.thumbnail = to_slint_image(w, h, &rgba);
|
||
cell.has_thumb = true;
|
||
cell.unavailable = false;
|
||
model.set_row_data(row, cell);
|
||
}
|
||
record_class(ctl, row, class);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Note which size class a row's pixels came from.
|
||
///
|
||
/// Silent about a row past the end: the model and this vector are rebuilt
|
||
/// together by [`load_window`], and the generation check above already refuses
|
||
/// anything addressed to a window that has since moved.
|
||
fn record_class(ctl: &Rc<LibraryController>, row: usize, class: dr_thumbs::ThumbSize) {
|
||
if let Some(slot) = ctl.thumb_class.borrow_mut().get_mut(row) {
|
||
*slot = Some(class);
|
||
}
|
||
}
|
||
|
||
/// Apply thumbnails to the model as they arrive.
|
||
fn drain_thumbnails(
|
||
weak: slint::Weak<AppWindow>,
|
||
ctl: Rc<LibraryController>,
|
||
rx: Receiver<ThumbnailMessage>,
|
||
requested: usize,
|
||
class: dr_thumbs::ThumbSize,
|
||
) {
|
||
let timer = slint::Timer::default();
|
||
let ctl_cb = ctl.clone();
|
||
|
||
// One row per batch, measured against the cells this window asked for.
|
||
// Starting a new batch does not extend the last one: a scroll abandons
|
||
// whatever the previous window wanted, and a denominator carried across
|
||
// both would describe neither.
|
||
let job = ctl
|
||
.activity
|
||
.begin(crate::activity::Kind::Thumbnails, "Loading thumbnails");
|
||
job.total(requested);
|
||
// Which window this batch was requested for. Captured at spawn, compared on
|
||
// every tick.
|
||
let mine = ctl.generation.get();
|
||
|
||
timer.start(
|
||
slint::TimerMode::Repeated,
|
||
std::time::Duration::from_millis(100),
|
||
move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
|
||
// A reload replaced the model under this worker. Two things must
|
||
// not happen now, and both did:
|
||
//
|
||
// - Applying a row. `t.row` indexes the window that asked for it,
|
||
// so after a reload it names a different photograph — thumbnails
|
||
// landed on unrelated cells, and `Unavailable` marked cells
|
||
// "no preview" for a fetch never attempted against them.
|
||
// - Calling `stop`. `thumb_timer` holds the *current* batch's timer
|
||
// by now, so a stale drain reaching `Disconnected` killed the
|
||
// live drain instead of itself. The new worker then fetched into
|
||
// a channel nobody read, and the grid stayed black until a scroll
|
||
// forced yet another load — which is the flicker being chased.
|
||
//
|
||
// Returning without stopping is deliberate: this timer is no longer
|
||
// reachable through the controller, so it is dropped with its
|
||
// receiver when the slot is overwritten, and the worker exits on
|
||
// its next failed send.
|
||
if ctl_cb.generation.get() != mine {
|
||
return;
|
||
}
|
||
|
||
let model = w.get_library_cells();
|
||
|
||
loop {
|
||
let msg = match rx.try_recv() {
|
||
Ok(m) => m,
|
||
Err(std::sync::mpsc::TryRecvError::Empty) => return,
|
||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||
// The worker finished or died. Either way nothing more
|
||
// is coming, so the bar must not sit part-filled
|
||
// forever.
|
||
//
|
||
// Quietly: a scroll starts one of these every second,
|
||
// and a history of them would bury anything worth
|
||
// reading.
|
||
job.finish_quietly();
|
||
stop(&ctl_cb.thumb_timer);
|
||
return;
|
||
}
|
||
};
|
||
|
||
match msg {
|
||
// Bookkeeping, not an outcome — reports the split between
|
||
// store and network without advancing the bar.
|
||
ThumbnailMessage::Plan {
|
||
cached,
|
||
fetching,
|
||
dating,
|
||
} => {
|
||
// Date reads produce no cell, so they are counted into
|
||
// the bar's denominator or it finishes while work is
|
||
// still running.
|
||
job.add_total(dating);
|
||
|
||
let mut parts = Vec::new();
|
||
if cached > 0 {
|
||
parts.push(format!("{cached} cached"));
|
||
}
|
||
if fetching > 0 {
|
||
parts.push(format!("fetching {fetching}"));
|
||
}
|
||
if dating > 0 {
|
||
parts.push(format!("reading {dating} dates"));
|
||
}
|
||
if !parts.is_empty() {
|
||
let status = parts.join(" · ");
|
||
job.detail(status.clone());
|
||
w.set_library_status(status.into());
|
||
}
|
||
}
|
||
// A header-only date read. Advances the bar; draws nothing.
|
||
ThumbnailMessage::DateProgress => {
|
||
job.advance();
|
||
}
|
||
// Dates landed, so the histogram can now be built. This is
|
||
// what makes the timeline appear on a library whose
|
||
// thumbnails were all cached.
|
||
ThumbnailMessage::DatesRecorded(n) => {
|
||
log::info!("timeline: {n} new dates");
|
||
let borrow = ctl_cb.catalog.borrow();
|
||
if let Some(catalog) = borrow.as_ref() {
|
||
refresh_timeline(&w, catalog, &ctl_cb);
|
||
}
|
||
}
|
||
// Every real outcome advances the bar. Counting only
|
||
// successes would stall it on a library where some files
|
||
// carry no embedded preview.
|
||
ThumbnailMessage::Ready(t) => {
|
||
job.advance();
|
||
// Bytes arrived *from the server*, so it is reachable.
|
||
// This is what clears the banner when a connection
|
||
// returns while the user is simply scrolling, without
|
||
// waiting for a probe or a manual retry.
|
||
//
|
||
// Store hits are excluded deliberately: they are read
|
||
// from local disk and say nothing about the network. A
|
||
// window that is mostly cached delivers a run of them
|
||
// before the first request is even attempted, so
|
||
// counting them declared "back online" against a server
|
||
// that was plainly down.
|
||
if !t.from_cache
|
||
&& ctl_cb
|
||
.reachability
|
||
.borrow_mut()
|
||
.mark_reachable(std::time::Instant::now())
|
||
{
|
||
log::info!("back online");
|
||
refresh_offline(&w, &ctl_cb);
|
||
// The sweep was refused while offline, so nothing
|
||
// else would ever restart it — the grid would stay
|
||
// partially dated until the next launch.
|
||
start_sweep(&w, &ctl_cb);
|
||
}
|
||
if let Some(mut row) = model.row_data(t.row) {
|
||
row.thumbnail = to_slint_image(t.width, t.height, &t.rgba);
|
||
row.has_thumb = true;
|
||
model.set_row_data(t.row, row);
|
||
// What this cell is now showing, so the next reload
|
||
// can carry it over and know not to ask again.
|
||
record_class(&ctl_cb, t.row, class);
|
||
}
|
||
}
|
||
ThumbnailMessage::Unavailable { row, reason } => {
|
||
job.advance();
|
||
log::debug!("thumbnail {row}: {reason}");
|
||
if let Some(mut r) = model.row_data(row) {
|
||
r.unavailable = true;
|
||
model.set_row_data(row, r);
|
||
// A verdict is worth carrying too: "no preview" is
|
||
// an answer about the file, and re-asking it on
|
||
// every scroll is a fetch that will fail again.
|
||
record_class(&ctl_cb, row, class);
|
||
}
|
||
}
|
||
// TRACES: FR-CAT-9
|
||
ThumbnailMessage::Offline { reason } => {
|
||
log::info!("thumbnails stopped: {reason}");
|
||
// The batch is over, so the bar must not be left
|
||
// showing a partial fetch that will never finish — it
|
||
// would sweep for ever.
|
||
job.fail(reason.clone());
|
||
ctl_cb
|
||
.reachability
|
||
.borrow_mut()
|
||
.mark_unreachable(reason, std::time::Instant::now());
|
||
refresh_offline(&w, &ctl_cb);
|
||
|
||
// Cells left without pixels stay placeholders rather
|
||
// than being marked unavailable: the images are fine,
|
||
// and a reconnect should fill them in. Marking them
|
||
// would persist a verdict about the *file* from an
|
||
// event about the *connection*.
|
||
stop(&ctl_cb.thumb_timer);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
},
|
||
);
|
||
|
||
*ctl.thumb_timer.borrow_mut() = Some(timer);
|
||
}
|
||
|
||
/// Move the timeline's zoom by whole levels.
|
||
///
|
||
/// Shared by the wheel and the pinch so the two cannot drift apart in how they
|
||
/// clamp, or in where they choose to centre.
|
||
fn apply_zoom(window: &AppWindow, ctl: &Rc<LibraryController>, delta: i32) {
|
||
let borrow = ctl.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
|
||
// Bounded: past ~2^12 the window is minutes wide and every bucket is
|
||
// empty, which reads as a broken axis rather than a deep zoom.
|
||
let next = (*ctl.timeline_zoom.borrow() + delta).clamp(0, 12);
|
||
if next == *ctl.timeline_zoom.borrow() {
|
||
return;
|
||
}
|
||
*ctl.timeline_zoom.borrow_mut() = next;
|
||
|
||
// Zooming fully out forgets the centre, so the axis returns to describing
|
||
// the whole library rather than a remembered position.
|
||
if next == 0 {
|
||
*ctl.timeline_centre.borrow_mut() = None;
|
||
} else if ctl.timeline_centre.borrow().is_none() {
|
||
// First zoom centres on wherever the grid is, else the middle.
|
||
*ctl.timeline_centre.borrow_mut() = *ctl.current_bucket.borrow();
|
||
}
|
||
refresh_timeline(window, catalog, ctl);
|
||
}
|
||
|
||
/// TRACES: NFR-R2
|
||
/// Take the day's catalog backup if one is due, off the UI thread.
|
||
///
|
||
/// Decided cheaply first — [`dr_catalog::recovery::backup_due`] reads a
|
||
/// directory listing, not the catalog — so the ordinary case of "backed up
|
||
/// this morning already" costs no thread and no connection.
|
||
fn spawn_scheduled_backup(ctl: &Rc<LibraryController>) {
|
||
let Some((conn, _)) = ctl.session.borrow().clone() else {
|
||
return;
|
||
};
|
||
let catalog_path = library::catalog_path(&conn.account);
|
||
if !dr_catalog::recovery::backup_due(&catalog_path) {
|
||
return;
|
||
}
|
||
std::thread::spawn(move || {
|
||
let catalog = match dr_catalog::Catalog::open(&catalog_path) {
|
||
Ok(c) => c,
|
||
Err(e) => {
|
||
log::warn!("scheduled backup: opening the catalog: {e}");
|
||
return;
|
||
}
|
||
};
|
||
if let Err(e) = dr_catalog::recovery::backup_if_due(catalog.connection(), &catalog_path) {
|
||
log::warn!("scheduled backup: {e}");
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Push shards and the catalog to the server, and take what it has.
|
||
///
|
||
/// Fired after the sweep completes, when there is a finished index worth
|
||
/// sharing, and from the Sync button for an explicit exchange.
|
||
fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
// An escape hatch for running the app against a *copied* library without
|
||
// touching the account's real server.
|
||
//
|
||
// Added after doing exactly that by accident: a local test launch
|
||
// completed its thumbnail sweep, which fires this, which pushed a test
|
||
// catalog over the live one. Redirecting `XDG_DATA_HOME` isolates the
|
||
// catalog and the thumbnails but not the server, and nothing said so.
|
||
//
|
||
// The guard belongs here rather than at the call sites: the sweep firing a
|
||
// sync is correct — the point of building thumbnails is sharing them — and
|
||
// a flag checked in three places would eventually be missed in a fourth.
|
||
if std::env::var_os("DARKROOM_NO_SYNC").is_some() {
|
||
log::info!("derived sync suppressed by DARKROOM_NO_SYNC");
|
||
return;
|
||
}
|
||
|
||
let Some((conn, _)) = ctl.session.borrow().clone() else {
|
||
return;
|
||
};
|
||
// Already running: a second pass would race the first over the same
|
||
// scratch files.
|
||
if ctl.sync_timer.borrow().is_some() && window.get_library_syncing() {
|
||
return;
|
||
}
|
||
|
||
// TRACES: FR-CAT-9
|
||
// Nothing to push to and nothing to take. Attempting it would upload
|
||
// shards into a timeout and light the "Syncing…" indicator over work that
|
||
// cannot start; the shards are unchanged on disk and go out on the next
|
||
// sync once the server is back.
|
||
if ctl.is_offline() {
|
||
log::debug!("offline: skipping derived sync");
|
||
return;
|
||
}
|
||
|
||
let catalog_path = library::catalog_path(&conn.account);
|
||
let scratch = catalog_path
|
||
.parent()
|
||
.map(|p| p.join("scratch"))
|
||
.unwrap_or_else(std::env::temp_dir);
|
||
let _ = std::fs::create_dir_all(&scratch);
|
||
|
||
// TRACES: FR-EXP-7 | FR-NC-10
|
||
// Drain the export outbox on the same pass, and before the shards. An
|
||
// export the user was told had succeeded is waiting here, and it is the
|
||
// one thing in this directory that exists nowhere else — a thumbnail
|
||
// shard can be rebuilt from the originals, and the catalog is an index.
|
||
//
|
||
// Fire-and-forget rather than reported: it runs on its own thread and
|
||
// clears entries as they land, so a partial run leaves the rest queued
|
||
// for next time and nothing is lost by not watching it. It reports
|
||
// through the log until an export has a place in the activity list.
|
||
{
|
||
let outbox = crate::export::outbox_dir(&conn.account);
|
||
if crate::export::pending_count(&outbox) > 0 {
|
||
let rx = crate::export::spawn_upload(conn.clone(), conn.account.root.clone(), outbox);
|
||
std::thread::spawn(move || {
|
||
while let Ok(msg) = rx.recv() {
|
||
match msg {
|
||
crate::export::UploadMessage::Status(s) => log::info!("export: {s}"),
|
||
crate::export::UploadMessage::Finished {
|
||
uploaded,
|
||
remaining,
|
||
error,
|
||
} => {
|
||
log::info!("export: {uploaded} uploaded, {remaining} still queued");
|
||
if let Some(e) = error {
|
||
log::warn!("export upload stopped: {e}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
window.set_library_syncing(true);
|
||
let rx = crate::derived_sync::spawn_sync(
|
||
conn.clone(),
|
||
conn.account.root.clone(),
|
||
library::thumbs_dir(&conn.account),
|
||
catalog_path,
|
||
library::place_path(&conn.account),
|
||
scratch,
|
||
ctl.face_model_id.borrow().clone(),
|
||
);
|
||
|
||
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,
|
||
// Replacing a damaged copy is said apart from an
|
||
// ordinary push: it is the one push that discarded
|
||
// something, and a log line that called it "pushed"
|
||
// would hide the only moment worth going back to.
|
||
match (report.catalog_uploaded, report.catalog_replaced) {
|
||
(true, true) => "pushed over a damaged copy",
|
||
(true, false) => "pushed",
|
||
_ => "not pushed",
|
||
},
|
||
if report.collections_gained > 0 {
|
||
format!(", {} collection(s) gained", report.collections_gained)
|
||
} else {
|
||
String::new()
|
||
}
|
||
);
|
||
w.set_library_syncing(false);
|
||
let summary = format!(
|
||
"{} shard(s) up, {} down",
|
||
report.shards_uploaded, report.shards_downloaded
|
||
);
|
||
job.finish(if report.did_anything() {
|
||
summary.clone()
|
||
} else {
|
||
"nothing to exchange".to_string()
|
||
});
|
||
if report.did_anything() {
|
||
w.set_library_status(format!("synced · {summary}").into());
|
||
}
|
||
// Adopted thumbnails and merged collections both change
|
||
// what the grid should show.
|
||
if report.thumbnails_adopted > 0
|
||
|| report.collections_gained > 0
|
||
|| report.members_gained > 0
|
||
{
|
||
load_window(&w, &ctl_cb);
|
||
}
|
||
// TRACES: FR-CAT-7
|
||
// And the sidebar, which the grid reload does not
|
||
// touch. Membership counts as a change: a sync that
|
||
// files 127 photographs into a collection both devices
|
||
// already had gains no *collection*, so keying this on
|
||
// `collections_gained` alone left the tree showing no
|
||
// number beside a collection that had just been filled.
|
||
//
|
||
// The scan already does this after it finishes; the
|
||
// sync merges the same tables and did not.
|
||
if report.collections_gained > 0 || report.members_gained > 0 {
|
||
let coll = ctl_cb.coll_ctl.borrow().as_ref().and_then(|w| w.upgrade());
|
||
if let Some(coll) = coll {
|
||
let borrow = ctl_cb.catalog();
|
||
let borrow = borrow.borrow();
|
||
if let Some(cat) = borrow.as_ref() {
|
||
crate::collections_ui::refresh_tree(&w, &coll, cat);
|
||
}
|
||
}
|
||
}
|
||
stop(&ctl_cb.sync_timer);
|
||
return;
|
||
}
|
||
crate::derived_sync::SyncMessage::Failed(e) => {
|
||
log::warn!("sync failed: {e}");
|
||
job.fail(e.to_string());
|
||
w.set_library_syncing(false);
|
||
// Not an error banner: a failed sync costs nothing —
|
||
// everything is still local and the next pass retries.
|
||
w.set_library_status(format!("sync failed: {e}").into());
|
||
stop(&ctl_cb.sync_timer);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
},
|
||
);
|
||
|
||
*ctl.sync_timer.borrow_mut() = Some(timer);
|
||
}
|
||
|
||
/// Start the whole-library sweep and report its progress.
|
||
///
|
||
/// The grid only ever fetches what is on screen, so without this the timeline
|
||
/// describes the fraction of the library that happened to be scrolled past.
|
||
/// This covers the rest.
|
||
fn start_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
let Some((conn, _)) = ctl.session.borrow().clone() else {
|
||
return;
|
||
};
|
||
|
||
// TRACES: FR-CAT-9
|
||
// The sweep is nothing but network reads — one header fetch per undated
|
||
// image, across the whole library. Offline it would spend a timeout on
|
||
// every one of them, running for hours to learn nothing, while the
|
||
// progress bar implied work was happening. It resumes on reconnect, and
|
||
// the images it has already dated stay dated.
|
||
if ctl.is_offline() {
|
||
log::debug!("offline: not starting the metadata sweep");
|
||
return;
|
||
}
|
||
|
||
let rx = library::spawn_sweep(conn.clone(), library::catalog_path(&conn.account));
|
||
|
||
let timer = slint::Timer::default();
|
||
let weak = window.as_weak();
|
||
let ctl_cb = ctl.clone();
|
||
|
||
// Hours on a large library, and entirely invisible outside the grid until
|
||
// now: the register is where a user who has gone to develop can still see
|
||
// that indexing is running and how far it has got.
|
||
let job = ctl
|
||
.activity
|
||
.begin(crate::activity::Kind::Index, "Indexing capture times");
|
||
|
||
timer.start(
|
||
slint::TimerMode::Repeated,
|
||
// Slower than the thumbnail drain: this runs for tens of minutes and
|
||
// its progress does not need per-frame accuracy.
|
||
std::time::Duration::from_millis(400),
|
||
move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
|
||
loop {
|
||
let msg = match rx.try_recv() {
|
||
Ok(m) => m,
|
||
Err(std::sync::mpsc::TryRecvError::Empty) => return,
|
||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||
w.set_library_sweep_total(0);
|
||
job.fail("stopped without finishing");
|
||
stop(&ctl_cb.sweep_timer);
|
||
return;
|
||
}
|
||
};
|
||
|
||
match msg {
|
||
library::SweepMessage::Total(n) => {
|
||
w.set_library_sweep_total(n as i32);
|
||
w.set_library_sweep_done(0);
|
||
job.total(n);
|
||
}
|
||
library::SweepMessage::Progress { done, dated } => {
|
||
w.set_library_sweep_done(done as i32);
|
||
job.progress(done, w.get_library_sweep_total() as usize);
|
||
// Rebuild as it goes: the histogram growing while the
|
||
// sweep runs is the visible sign it is working.
|
||
if dated > 0 {
|
||
let borrow = ctl_cb.catalog.borrow();
|
||
if let Some(catalog) = borrow.as_ref() {
|
||
refresh_timeline(&w, catalog, &ctl_cb);
|
||
}
|
||
}
|
||
}
|
||
library::SweepMessage::Finished { dated } => {
|
||
log::info!("sweep finished: {dated} dated");
|
||
job.finish(format!("{dated} dated"));
|
||
w.set_library_sweep_total(0);
|
||
{
|
||
let borrow = ctl_cb.catalog.borrow();
|
||
if let Some(catalog) = borrow.as_ref() {
|
||
refresh_timeline(&w, catalog, &ctl_cb);
|
||
}
|
||
}
|
||
// TRACES: NFR-R2
|
||
// The scheduled backup, at the moment the catalog is
|
||
// quietest and a day's edits have just been folded in.
|
||
// On its own thread, with its own connection: a copy
|
||
// of a 130 MB file is a second or two the Slint loop
|
||
// must not spend, and it runs beside the sync below,
|
||
// which is only a reader of the same file.
|
||
spawn_scheduled_backup(&ctl_cb);
|
||
// Now that indexing is complete, hand the result to the
|
||
// server so a second device inherits it rather than
|
||
// repeating hours of range fetches.
|
||
start_derived_sync(&w, &ctl_cb);
|
||
stop(&ctl_cb.sweep_timer);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
},
|
||
);
|
||
|
||
*ctl.sweep_timer.borrow_mut() = Some(timer);
|
||
}
|
||
|
||
/// TRACES: FR-CAT-3 | FR-NC-3 | FR-NC-7
|
||
/// Thumbnail every photograph in the library, then hand the shards over.
|
||
///
|
||
/// # Why it is asked for rather than assumed
|
||
///
|
||
/// The grid's own fetching is demand-driven on purpose: a remote library is
|
||
/// browsed over a connection that must not be saturated to show one screen
|
||
/// (FR-NC-3). But that leaves the thumbnail store holding only what has been
|
||
/// looked at, and the store is the one derived thing worth syncing — a second
|
||
/// device that downloads the shards gets a full grid without touching a RAW.
|
||
/// So the complete set is worth an hour of transfers *once*, on a machine
|
||
/// plugged in, at a moment the user chose. That is this.
|
||
///
|
||
/// The sync at the end is not a separate courtesy: a filled store that never
|
||
/// leaves this device is most of the cost for none of the point.
|
||
fn start_thumbnail_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
let Some((conn, _)) = ctl.session.borrow().clone() else {
|
||
return;
|
||
};
|
||
|
||
// Already running. A second pass would re-fetch everything the first is
|
||
// part way through — the work list is built from what the store lacks, and
|
||
// the first pass has not filled it yet.
|
||
if ctl.thumb_sweep_timer.borrow().is_some() && window.get_library_thumbnailing() {
|
||
return;
|
||
}
|
||
|
||
// TRACES: FR-CAT-9
|
||
// Every image here is a range fetch. Offline the pass would spend a
|
||
// timeout per photograph and store nothing, so it is refused rather than
|
||
// started — and said so, because this one was pressed deliberately and a
|
||
// button that silently does nothing is worse than one that declines.
|
||
if ctl.is_offline() {
|
||
log::debug!("offline: not starting the thumbnail pass");
|
||
window.set_library_status("Offline — thumbnailing needs the server.".into());
|
||
return;
|
||
}
|
||
|
||
window.set_library_thumbnailing(true);
|
||
let rx = library::spawn_thumbnail_sweep(
|
||
conn.clone(),
|
||
library::catalog_path(&conn.account),
|
||
library::thumbs_dir(&conn.account),
|
||
);
|
||
|
||
let timer = slint::Timer::default();
|
||
let weak = window.as_weak();
|
||
let ctl_cb = ctl.clone();
|
||
|
||
let job = ctl.activity.begin(
|
||
crate::activity::Kind::Thumbnails,
|
||
"Thumbnailing the library",
|
||
);
|
||
|
||
// The total arrives in the first message rather than up front — counting
|
||
// it means asking the store about every image — and the progress messages
|
||
// after it carry only cumulative counts, so it is kept here rather than
|
||
// re-derived.
|
||
let total = std::cell::Cell::new(0usize);
|
||
|
||
timer.start(
|
||
slint::TimerMode::Repeated,
|
||
// As slow as the metadata sweep's drain, and for the same reason: this
|
||
// runs for tens of minutes and a chunk lands every few seconds.
|
||
std::time::Duration::from_millis(400),
|
||
move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
|
||
loop {
|
||
let msg = match rx.try_recv() {
|
||
Ok(m) => m,
|
||
Err(std::sync::mpsc::TryRecvError::Empty) => return,
|
||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||
w.set_library_thumbnailing(false);
|
||
job.fail("stopped without finishing");
|
||
stop(&ctl_cb.thumb_sweep_timer);
|
||
return;
|
||
}
|
||
};
|
||
|
||
match msg {
|
||
library::ThumbSweepMessage::Total(n) => {
|
||
total.set(n);
|
||
job.total(n);
|
||
w.set_library_status(format!("Thumbnailing {n} photograph(s)…").into());
|
||
}
|
||
library::ThumbSweepMessage::Progress { done, stored } => {
|
||
job.progress(done, total.get());
|
||
// Dates arrive on the same headers, so the histogram
|
||
// grows as this runs — the visible sign it is working
|
||
// on a page that is not the grid.
|
||
if stored > 0 {
|
||
let borrow = ctl_cb.catalog.borrow();
|
||
if let Some(catalog) = borrow.as_ref() {
|
||
refresh_timeline(&w, catalog, &ctl_cb);
|
||
}
|
||
}
|
||
}
|
||
library::ThumbSweepMessage::Finished {
|
||
stored,
|
||
failed,
|
||
offline,
|
||
} => {
|
||
log::info!(
|
||
"thumbnail pass finished: {stored} stored, {failed} without a preview"
|
||
);
|
||
w.set_library_thumbnailing(false);
|
||
stop(&ctl_cb.thumb_sweep_timer);
|
||
|
||
let summary = if offline {
|
||
format!("{stored} stored · stopped, server unreachable")
|
||
} else if failed > 0 {
|
||
format!("{stored} stored · {failed} with no usable preview")
|
||
} else {
|
||
format!("{stored} stored")
|
||
};
|
||
w.set_library_status(summary.clone().into());
|
||
// Finished rather than failed even when the server
|
||
// dropped: the pass is resumable and what it did build
|
||
// is stored, and a red row would invite the user to
|
||
// redo work that is already done.
|
||
job.finish(summary);
|
||
|
||
{
|
||
let borrow = ctl_cb.catalog.borrow();
|
||
if let Some(catalog) = borrow.as_ref() {
|
||
refresh_timeline(&w, catalog, &ctl_cb);
|
||
}
|
||
}
|
||
// Cells that showed a placeholder have pixels now, and
|
||
// the grid asks the store once per photograph — so
|
||
// without this the library the pass just thumbnailed
|
||
// stays blank until something else reloads the window.
|
||
if w.get_library_visible() {
|
||
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);
|
||
}
|
||
// And now every photograph in the library has a
|
||
// thumbnail, which is the only moment all of its burst
|
||
// signatures can be computed. See `bursts::start_pass`,
|
||
// which owns the pass and everything it needs to drain
|
||
// itself; what it wants from here is the paths and a
|
||
// way to say the grid has something new to draw.
|
||
if let Some((conn, _)) = ctl_cb.session.borrow().clone() {
|
||
let weak_after = w.as_weak();
|
||
let ctl_after = ctl_cb.clone();
|
||
crate::bursts::start_pass(
|
||
library::catalog_path(&conn.account),
|
||
library::thumbs_dir(&conn.account),
|
||
move |bursts| {
|
||
let Some(w) = weak_after.upgrade() else {
|
||
return;
|
||
};
|
||
if bursts > 0 && w.get_library_visible() {
|
||
schedule_reload(&w, &ctl_after);
|
||
}
|
||
},
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
},
|
||
);
|
||
|
||
*ctl.thumb_sweep_timer.borrow_mut() = Some(timer);
|
||
}
|
||
|
||
/// Rebuild the timeline histogram from the catalog.
|
||
///
|
||
/// Bucket size follows the span of the library, the way darktable's does:
|
||
/// a decade of photographs buckets by year, a single trip by day. Picking it
|
||
/// from the data rather than fixing it means the histogram is informative at
|
||
/// both scales instead of one flat bar or ten thousand slivers.
|
||
fn refresh_timeline(window: &AppWindow, catalog: &Catalog, ctl: &Rc<LibraryController>) {
|
||
// Where the grid sits, if nothing has said so yet.
|
||
//
|
||
// **The marker used to rest greyed at mid-track until the first scroll or
|
||
// scrub**, on the reasoning that anchoring it would imply a choice the user
|
||
// had not made. That reads the marker as reporting an intention, and it does
|
||
// not: the sidebar's whole claim is to say *when* you are, and the answer is
|
||
// known from the first frame — the grid is at the top of the library, or at
|
||
// whatever position was restored into it. A launch that opened with the
|
||
// marker halfway down an axis whose photographs were all from the wrong end
|
||
// was simply wrong, and dimmed rather than absent, which made it look like a
|
||
// reading rather than the absence of one.
|
||
//
|
||
// Seeded here rather than at the end of the scan because this is the one
|
||
// place that decides what the marker says, and it runs on every route that
|
||
// builds the axis. Only when nothing has claimed it: a scroll or a scrub
|
||
// sets it directly, and this must never overwrite them.
|
||
let unanchored = ctl.current_bucket.borrow().is_none();
|
||
if unanchored {
|
||
if let Some(when) = capture_time_at(ctl, catalog, ctl.resume_at.get()) {
|
||
*ctl.current_bucket.borrow_mut() = Some(when);
|
||
}
|
||
}
|
||
|
||
// Scoped to whatever the grid is showing. A collection's histogram drawn
|
||
// over the whole library's span said almost nothing: every bar for a
|
||
// fortnight in Arosa landed in one column of a fifteen-year axis.
|
||
let scope = *ctl.scope.borrow();
|
||
let filter = ctl.filter.borrow().clone();
|
||
// Passed whole, date range included, and both queries below lift that
|
||
// range themselves — see `span_scoped` and `timeline_uniform`. Every other
|
||
// term still applies, because a histogram of the five-star frames is a
|
||
// fair question; the range does not, because the axis is *how a range is
|
||
// chosen* and drawing through it would empty every bin outside the band
|
||
// and leave nothing to widen back into.
|
||
let span = match library::span_scoped(catalog, scope, &filter) {
|
||
Some(s) => s,
|
||
None => {
|
||
// No dated images yet. An empty histogram is honest — EXIF is read
|
||
// as thumbnails load, so this populates as the user browses.
|
||
window.set_library_timeline(slint::ModelRc::new(slint::VecModel::from(vec![])));
|
||
window.set_library_timeline_label(slint::SharedString::new());
|
||
return;
|
||
}
|
||
};
|
||
|
||
// A range is drawn *over* this span as a band, not substituted for it —
|
||
// see the band below. The scale a narrow range deserves is reached by
|
||
// zooming the axis instead, which is a gesture a finger has (pinch) and
|
||
// does not disturb the range while it is being adjusted.
|
||
//
|
||
// Zoom narrows the span around wherever the view sits rather than around
|
||
// the library's midpoint, so zooming in keeps what you were looking at.
|
||
let zoom = *ctl.timeline_zoom.borrow();
|
||
let (from, to) = zoomed_span(span, zoom, *ctl.timeline_centre.borrow());
|
||
|
||
// A fixed number of equal bins, re-cut on every zoom (FR-CAT-6). The
|
||
// count is the user's — see `LibrarySettings::timeline_bars` for why the
|
||
// calendar units it replaced made zooming in draw a coarser picture.
|
||
let bins = ctl.timeline_bars.get().max(1);
|
||
// Calendar units survive as a *label* vocabulary: what a bin is called
|
||
// depends on how long it is, not on how the counting was done.
|
||
let granularity = dr_catalog::Granularity::for_bucket((to - from).max(1) / bins as i64);
|
||
let buckets = match library::timeline_uniform(catalog, scope, &filter, from, to, bins) {
|
||
Ok(b) => b,
|
||
Err(e) => {
|
||
log::debug!("timeline: {e}");
|
||
return;
|
||
}
|
||
};
|
||
|
||
// Normalise against the tallest bar. Counts vary by orders of magnitude
|
||
// between a quiet month and a wedding, so a linear scale against the total
|
||
// would render most buckets invisible.
|
||
let peak = buckets.iter().map(|b| b.count).max().unwrap_or(1).max(1);
|
||
let mut previous: Option<(i64, i64)> = None;
|
||
|
||
let bars: Vec<TimelineBar> = buckets
|
||
.iter()
|
||
.map(|b| {
|
||
let (y, m, _, _) = civil_from_unix(b.start);
|
||
// Label only where a period begins, so a month-bucketed axis reads
|
||
// "2024 … Mar … Apr" rather than repeating the year on every bar.
|
||
let period = match previous {
|
||
None => format!("{y}"),
|
||
Some((py, _)) if py != y => format!("{y}"),
|
||
Some((_, pm)) if pm != m && granularity_labels_months(granularity) => {
|
||
month_abbrev(m).to_string()
|
||
}
|
||
_ => String::new(),
|
||
};
|
||
previous = Some((y, m));
|
||
|
||
TimelineBar {
|
||
// Square root rather than linear: it keeps a 3-image day
|
||
// visible beside a 400-image one without a log scale's
|
||
// misleading flatness.
|
||
//
|
||
// A bin holding nothing draws nothing. The floor exists so a
|
||
// quiet day is not rounded away, and empty bins are common now
|
||
// that every one of them is emitted — a sliver on each would
|
||
// draw a library that has photographs in months it does not.
|
||
height: if b.count == 0 {
|
||
0.0
|
||
} else {
|
||
((b.count as f32 / peak as f32).sqrt()).clamp(0.02, 1.0)
|
||
},
|
||
start: b.start as i32,
|
||
count: b.count as i32,
|
||
label: format_bucket(b.start, granularity).into(),
|
||
period_label: period.into(),
|
||
}
|
||
})
|
||
.collect();
|
||
|
||
// Where the grid currently sits, as a fraction of the visible span.
|
||
//
|
||
// This is the exact inverse of what a click produces: the widget maps a y
|
||
// to a fraction of its track and `on_library_scrub_fraction` interpolates
|
||
// `from + (to - from) * f`, so the marker must invert that same expression
|
||
// or it lands somewhere other than the pointer.
|
||
//
|
||
// The earlier version sent a *bar index* instead. Bars occupy one equal
|
||
// slot each regardless of how much time they cover, and `rposition` snaps
|
||
// to the bucket's start edge, so the marker sat at the top of whichever
|
||
// slot contained the instant — near enough on a dense uniform axis, plainly
|
||
// wrong on a sparse one, and never under the click.
|
||
let current = *ctl.current_bucket.borrow();
|
||
let fraction = current.map(|t| fraction_at((from, to), t)).unwrap_or(-1.0);
|
||
|
||
// The chosen range in those same coordinates, for the band. Both ends or
|
||
// neither: half a band is a line the user cannot tell from a handle.
|
||
let (band_from, band_to) = match (filter.captured_from, filter.captured_to) {
|
||
(Some(a), Some(b)) => (fraction_at((from, to), a), fraction_at((from, to), b)),
|
||
_ => (-1.0, -1.0),
|
||
};
|
||
|
||
// Which bar to light: the *bin* holding the grid's instant, not the
|
||
// instant itself. A bar's start is its bin's left edge — one of a fixed
|
||
// set of positions — so comparing it against an arbitrary capture time
|
||
// would light nothing, where before every bar started on a photograph.
|
||
//
|
||
// Taken from the bars themselves rather than recomputed, because the edges
|
||
// are integer division and a second spelling of `from + i * span / bins`
|
||
// is a second chance to be a second out.
|
||
let lit = current
|
||
.and_then(|t| {
|
||
let i = bin_of((from, to), t, bins);
|
||
buckets.get(i).map(|b| b.start)
|
||
})
|
||
.unwrap_or(0);
|
||
window.set_library_current_bucket(lit as i32);
|
||
window.set_library_current_fraction(fraction);
|
||
window.set_library_range_from_fraction(band_from);
|
||
window.set_library_range_to_fraction(band_to);
|
||
window.set_library_timeline_anchored(current.is_some());
|
||
window
|
||
.set_library_timeline_label(format!("{} – {}", format_date(from), format_date(to)).into());
|
||
window.set_library_timeline(slint::ModelRc::new(slint::VecModel::from(bars)));
|
||
}
|
||
|
||
/// Whether this bucket size is fine enough for month labels to mean anything.
|
||
///
|
||
/// A year-bucketed axis labelled by month would put twelve labels on one bar.
|
||
/// Month-sized bins are excluded too, now that the axis has a fixed number of
|
||
/// them: a bin of about a month changes month at nearly every bar, so the rule
|
||
/// below would write a label on all sixty-four of them and the right-hand
|
||
/// strip would be a column of overlapping text. Below a month the labels are
|
||
/// occasional again, which is what makes them readable.
|
||
fn granularity_labels_months(g: dr_catalog::Granularity) -> bool {
|
||
matches!(
|
||
g,
|
||
dr_catalog::Granularity::Day | dr_catalog::Granularity::Hour
|
||
)
|
||
}
|
||
|
||
/// Full month name, for the grid's headings.
|
||
fn month_name(m: i64) -> &'static str {
|
||
const NAMES: [&str; 12] = [
|
||
"January",
|
||
"February",
|
||
"March",
|
||
"April",
|
||
"May",
|
||
"June",
|
||
"July",
|
||
"August",
|
||
"September",
|
||
"October",
|
||
"November",
|
||
"December",
|
||
];
|
||
NAMES[((m - 1).clamp(0, 11)) as usize]
|
||
}
|
||
|
||
fn month_abbrev(m: i64) -> &'static str {
|
||
const NAMES: [&str; 12] = [
|
||
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||
];
|
||
NAMES[((m - 1).clamp(0, 11)) as usize]
|
||
}
|
||
|
||
/// The instant a fraction along a span names.
|
||
///
|
||
/// One home for `from + (to - from) * f`, because three things now depend on
|
||
/// it agreeing with itself: a scrub, the marker drawn where that scrub landed,
|
||
/// and the ends of the date range dragged along the same axis. A second copy
|
||
/// is a second chance for a click and what it produces to disagree.
|
||
fn instant_at(span: (i64, i64), f: f32) -> i64 {
|
||
span.0 + ((span.1 - span.0) as f64 * f.clamp(0.0, 1.0) as f64) as i64
|
||
}
|
||
|
||
/// Where an instant sits along a span, 0..1. The exact inverse of
|
||
/// [`instant_at`], clamped so an instant off the end of the axis is drawn at
|
||
/// the end it went past rather than off the widget.
|
||
fn fraction_at(span: (i64, i64), t: i64) -> f32 {
|
||
((t - span.0) as f64 / (span.1 - span.0).max(1) as f64).clamp(0.0, 1.0) as f32
|
||
}
|
||
|
||
/// Which of `bins` equal bins across `span` holds `t`.
|
||
///
|
||
/// The same arithmetic `library::timeline_uniform` groups by, in the same
|
||
/// order — integer division on the offset from the start — so the bar this
|
||
/// names is the bar that counted the photograph. Clamped, because the grid's
|
||
/// instant can sit outside a zoomed axis.
|
||
fn bin_of(span: (i64, i64), t: i64, bins: u32) -> usize {
|
||
let width = (span.1 - span.0).max(1);
|
||
let i = (t - span.0).clamp(0, width) * bins as i64 / width;
|
||
i.clamp(0, bins as i64 - 1) as usize
|
||
}
|
||
|
||
/// Midnight UTC at or before `t`.
|
||
///
|
||
/// `rem_euclid` rather than `%`: a negative timestamp — a scanned archive
|
||
/// holds photographs from before 1970 — would otherwise truncate *towards*
|
||
/// zero and name the following midnight, putting the day off by one.
|
||
fn day_start(t: i64) -> i64 {
|
||
t - t.rem_euclid(86_400)
|
||
}
|
||
|
||
/// The range a drag of the band's two ends names, as it is stored.
|
||
///
|
||
/// Whole days at both ends, which does three things. It is what the typed
|
||
/// fields mean, so a dragged range reads back out of them as the two days it
|
||
/// covers rather than as two instants nobody chose. It gives the band a floor:
|
||
/// two handles dragged onto each other name one day, not an empty grid. And it
|
||
/// makes the gesture reproducible — the same day is the same range however
|
||
/// close the finger came to a bucket edge.
|
||
///
|
||
/// Ends dragged past each other are put back in order rather than refused: the
|
||
/// user has described the same two edges either way round.
|
||
fn dragged_range(span: (i64, i64), from: f32, to: f32) -> (i64, i64) {
|
||
let (from, to) = if from <= to { (from, to) } else { (to, from) };
|
||
let start = day_start(instant_at(span, from));
|
||
// Inclusive of the closing day. `parse_date` gives midnight, and a range
|
||
// ending there would exclude every photograph taken on the day named.
|
||
let end = day_start(instant_at(span, to)) + 86_400 - 1;
|
||
(start, end)
|
||
}
|
||
|
||
/// Narrow a span by a zoom level, centred on `centre`.
|
||
///
|
||
/// Each step halves or doubles the visible duration. Clamped to the library's
|
||
/// own extent so zooming out cannot wander past the first or last photograph.
|
||
fn zoomed_span(full: (i64, i64), zoom: i32, centre: Option<i64>) -> (i64, i64) {
|
||
let (lo, hi) = full;
|
||
if zoom <= 0 {
|
||
return (lo, hi);
|
||
}
|
||
let duration = (hi - lo).max(1);
|
||
// 2^zoom, saturating: a very deep zoom must not shift the duration to zero.
|
||
let factor = 1i64 << zoom.min(20);
|
||
let window = (duration / factor).max(3600);
|
||
|
||
let mid = centre.unwrap_or(lo + duration / 2);
|
||
let half = window / 2;
|
||
let (mut from, mut to) = (mid - half, mid + half);
|
||
|
||
// Slide rather than shrink at the ends, so the window keeps its size.
|
||
if from < lo {
|
||
to += lo - from;
|
||
from = lo;
|
||
}
|
||
if to > hi {
|
||
from -= to - hi;
|
||
to = hi;
|
||
}
|
||
(from.max(lo), to.min(hi))
|
||
}
|
||
|
||
/// Earliest and latest capture time in the catalog.
|
||
/// The timeline's extent, over exactly the images the grid is showing.
|
||
///
|
||
/// Scrubbing and zooming must measure the same span the histogram is drawn
|
||
/// over. When this read the whole library while the bars were scoped to a
|
||
/// collection, a scrub landed at an instant the collection did not contain and
|
||
/// the view jumped somewhere the user had not asked for.
|
||
fn catalog_span(catalog: &Catalog, ctl: &Rc<LibraryController>) -> Option<(i64, i64)> {
|
||
// `span_scoped` reports the extent with the date range lifted, which is
|
||
// what every caller here wants: they measure the axis that is on screen,
|
||
// and that axis spans the library rather than the chosen range. Measured
|
||
// through the range, narrowing it would move the ground under the very
|
||
// handles doing the narrowing — each drag re-scaling the axis, so the next
|
||
// one meant something else.
|
||
library::span_scoped(catalog, *ctl.scope.borrow(), &ctl.filter.borrow())
|
||
}
|
||
|
||
/// TRACES: NFR-P5
|
||
/// Capture time of the image at row `ordinal` in the grid's own ordering.
|
||
///
|
||
/// `None` for an ordinal that lands among the undated tail, which sorts last
|
||
/// and has no place on a capture-time axis.
|
||
///
|
||
/// # Answered from the loaded window, not from the catalog
|
||
///
|
||
/// This moves the timeline marker, so it is called on **every scroll event** —
|
||
/// every row the view crosses, by design, or the marker would advance in jerks
|
||
/// while the photographs beside it moved smoothly.
|
||
///
|
||
/// It used to be a query, and `LIMIT 1 OFFSET n` is not a seek: SQLite reaches
|
||
/// row `n` by producing and discarding the `n` before it. So its cost grew with
|
||
/// how far down the library the user had scrolled — 0.02 ms near the top, 0.7 ms
|
||
/// at twenty thousand — and it was paid per row crossed, on the thread drawing
|
||
/// the frame. A flick down a long library therefore got *choppier the further
|
||
/// it went*, which is a strange enough symptom to be worth naming.
|
||
///
|
||
/// The window the grid has already read holds this answer. `window_move` keeps
|
||
/// the whole view inside that window, so the lookup is a vector index at the
|
||
/// position the ordinal has in it.
|
||
///
|
||
/// # The fallback, and why it is also more correct than what it replaces
|
||
///
|
||
/// Outside the loaded window — briefly, after a scrub or a keyboard jump, before
|
||
/// the load lands — it falls back to the query. That query counts in a
|
||
/// *dated-only* ordering while `ordinal` is a grid row, so the two disagree
|
||
/// wherever undated images sit in between; it is kept because it is close
|
||
/// enough for a marker that is about to be corrected, and because being wrong
|
||
/// there is what it always did. The window path has no such disagreement: it
|
||
/// reads the very cell the row belongs to.
|
||
fn capture_time_at(ctl: &Rc<LibraryController>, catalog: &Catalog, ordinal: usize) -> Option<i64> {
|
||
{
|
||
let offset = *ctl.offset.borrow();
|
||
let loaded = ctl.captured_at.borrow();
|
||
if ordinal >= offset {
|
||
if let Some(when) = loaded.get(ordinal - offset) {
|
||
return *when;
|
||
}
|
||
}
|
||
}
|
||
capture_time_from_catalog(catalog, ordinal)
|
||
}
|
||
|
||
/// The fallback of [`capture_time_at`], for a row outside the loaded window.
|
||
fn capture_time_from_catalog(catalog: &Catalog, ordinal: usize) -> Option<i64> {
|
||
catalog
|
||
.connection()
|
||
.query_row(
|
||
&format!(
|
||
"SELECT captured_at FROM images
|
||
WHERE shadowed_by IS NULL AND captured_at IS NOT NULL
|
||
AND {}
|
||
ORDER BY captured_at
|
||
LIMIT 1 OFFSET ?1",
|
||
dr_catalog::bursts::not_collapsed_away("images")
|
||
),
|
||
[ordinal as i64],
|
||
|r| r.get::<_, i64>(0),
|
||
)
|
||
.ok()
|
||
}
|
||
|
||
/// Jump the grid to the first image at or after `when`.
|
||
///
|
||
/// This is the scrub: the window moves, the library does not narrow. Every
|
||
/// image stays reachable, which is why this is a position rather than a
|
||
/// filter.
|
||
fn scrub_to(window: &AppWindow, ctl: &Rc<LibraryController>, when: i64) {
|
||
let position = {
|
||
let borrow = ctl.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
// How many rows precede this instant **in the grid's own ordering**.
|
||
// That ordinal is the scroll offset, so any disagreement with the
|
||
// grid's query lands the view somewhere else entirely.
|
||
//
|
||
// The earlier version counted only dated images. With 2,400 of 19,841
|
||
// dated, a click near the end of the axis produced an ordinal of ~2,400
|
||
// against a grid of 19,841 rows — the view landed near the top however
|
||
// far down the axis the pointer went.
|
||
//
|
||
// Undated images sort last (`captured_at IS NULL` first in the ORDER
|
||
// BY), so they never precede a dated one and the predicate below stays
|
||
// a simple `<`. Shadowed rows are excluded here exactly as the grid
|
||
// excludes them.
|
||
//
|
||
// A burst folded up occupies one row of the grid, so it must occupy one
|
||
// row of this count as well: an ordinal taken over the unfolded library
|
||
// would overshoot by every frame hidden earlier in it.
|
||
catalog
|
||
.connection()
|
||
.query_row(
|
||
&format!(
|
||
"SELECT count(*) FROM images
|
||
WHERE shadowed_by IS NULL
|
||
AND captured_at IS NOT NULL
|
||
AND captured_at < ?1
|
||
AND {}",
|
||
dr_catalog::bursts::not_collapsed_away("images")
|
||
),
|
||
[when],
|
||
|r| r.get::<_, i64>(0),
|
||
)
|
||
.unwrap_or(0) as usize
|
||
};
|
||
|
||
// Record where the grid now sits, which anchors the timeline marker.
|
||
// `refresh_timeline` seeds this from the view's own position when nothing
|
||
// has claimed it, so by the time a scrub arrives it is an update rather than
|
||
// the first word.
|
||
*ctl.current_bucket.borrow_mut() = Some(when);
|
||
*ctl.offset.borrow_mut() = position;
|
||
// Move the viewport as well as the window. Cells are drawn at their
|
||
// absolute place in the library, so loading rows around image 15,000 while
|
||
// the viewport sits at row 0 shows an empty grid until the user scrolls.
|
||
window.set_library_scroll_to(position as i32);
|
||
window.set_library_scroll_token(window.get_library_scroll_token() + 1);
|
||
load_window(window, ctl);
|
||
}
|
||
|
||
/// TRACES: FR-CAT-7
|
||
/// Put the view back where the photographer was after the library changed
|
||
/// under it.
|
||
///
|
||
/// Deleting is the case this exists for. The grid draws each cell at its
|
||
/// absolute place in the library — `(i + offset) / columns` — and a delete does
|
||
/// two things at once: the library gets shorter, and `offset` is re-clamped
|
||
/// downward so the loaded window still fills. Neither touches `viewport-y`, so
|
||
/// the view ends up pointing at a different part of the library, or past the
|
||
/// end of it entirely. That is the "goes blank and jumps somewhere random".
|
||
///
|
||
/// Anchored on the *ordinal the view was showing*, clamped into what is left.
|
||
/// Not on the deleted image's own position, which no longer exists, and not on
|
||
/// the top of the library, which would throw away the scroll position on every
|
||
/// delete — after removing one frame from a wall of twenty thousand, the next
|
||
/// one you want is the one that just moved into its place.
|
||
pub fn restore_position(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
let total = window.get_library_total().max(0) as usize;
|
||
if total == 0 {
|
||
return;
|
||
}
|
||
// The first *visible* ordinal, not the loaded window's start.
|
||
//
|
||
// Anchoring on `offset` was the first attempt and it still jumped, because
|
||
// the two are deliberately different: the window begins about a quarter of
|
||
// a screen above the view so scrolling up has rows ready, and by this point
|
||
// in `load_window` it has also been re-clamped against the new, smaller
|
||
// total. Seeking to it therefore moved the view to somewhere the user was
|
||
// not — a smaller jump than before, but the same fault.
|
||
//
|
||
// `resume_at` is what the grid last reported as its first visible image,
|
||
// which is the thing the photographer is actually looking at.
|
||
let anchor = ctl.resume_at.get().min(total - 1);
|
||
window.set_library_scroll_to(anchor as i32);
|
||
window.set_library_scroll_token(window.get_library_scroll_token() + 1);
|
||
}
|
||
|
||
/// Format a bucket start for the histogram's hover label.
|
||
fn format_bucket(t: i64, g: dr_catalog::Granularity) -> String {
|
||
let (y, m, d, h) = civil_from_unix(t);
|
||
match g {
|
||
dr_catalog::Granularity::Year => format!("{y}"),
|
||
dr_catalog::Granularity::Month => format!("{y}-{m:02}"),
|
||
dr_catalog::Granularity::Day => format!("{y}-{m:02}-{d:02}"),
|
||
dr_catalog::Granularity::Hour => format!("{y}-{m:02}-{d:02} {h:02}:00"),
|
||
}
|
||
}
|
||
|
||
/// A capture instant as `YYYY-MM-DD`.
|
||
///
|
||
/// Shared with the exporter, which resolves the `{date}` token from the same
|
||
/// reading so a filename and the timeline cannot disagree about what day a
|
||
/// photograph was taken.
|
||
pub fn format_date(t: i64) -> String {
|
||
dr_types::format_date(t)
|
||
}
|
||
|
||
/// Unix seconds to a civil date, in the tuple shape this module reads it in.
|
||
///
|
||
/// The algorithm moved to `dr_types::time` when import folder templates
|
||
/// (FR-CAT-10) became a third caller. Three readings of the same instant that
|
||
/// could drift apart is one too many: an image filed under a date the timeline
|
||
/// does not show it on is a file the user cannot find.
|
||
fn civil_from_unix(t: i64) -> (i64, i64, i64, i64) {
|
||
let c = dr_types::civil_from_unix(t);
|
||
(c.year, c.month, c.day, c.hour)
|
||
}
|
||
|
||
/// Copy decoded RGBA into a Slint image.
|
||
///
|
||
/// This is a CPU copy, which is acceptable here and not in the develop path:
|
||
/// a 256px thumbnail is 256 KB and happens once per image, where the canvas
|
||
/// would pay per frame (ARCH §6.1).
|
||
fn to_slint_image(width: u32, height: u32, rgba: &[u8]) -> slint::Image {
|
||
let mut buf = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::new(width, height);
|
||
let expected = (width as usize) * (height as usize) * 4;
|
||
let src = &rgba[..expected.min(rgba.len())];
|
||
buf.make_mut_bytes()[..src.len()].copy_from_slice(src);
|
||
slint::Image::from_rgba8(buf)
|
||
}
|
||
|
||
/// Walk the keyboard cursor through the library — the arrow keys.
|
||
///
|
||
/// **The cursor is a library ordinal, not a row of the loaded window.** That is
|
||
/// what lets it walk past the window's edge: the window is a few screenfuls
|
||
/// around wherever the user is looking, and a cursor held as a row would stop
|
||
/// at its end or, worse, keep counting into cells belonging to different
|
||
/// photographs. Moving out of the window reloads it around the new position,
|
||
/// which is the same thing scrolling does.
|
||
///
|
||
/// The grid supplies the step, because how far "down" is depends on how many
|
||
/// columns the window happens to be showing, and only the grid knows that. It
|
||
/// does not clamp: `Home` and `End` arrive as a step longer than the library
|
||
/// and are clamped here, where the total is known.
|
||
fn move_cursor(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
coll: &Rc<crate::collections_ui::CollectionsController>,
|
||
delta: i32,
|
||
extend: bool,
|
||
) {
|
||
let total = window.get_library_total().max(0) as usize;
|
||
if total == 0 {
|
||
return;
|
||
}
|
||
|
||
let Some(from) = coll.cursor() else {
|
||
// The first press takes hold of the grid rather than moving in it. A
|
||
// key that jumped to image zero would lose wherever the user had
|
||
// scrolled to, and one that started from a cell off the top of the
|
||
// screen would appear to do nothing but scroll.
|
||
//
|
||
// The first *visible* ordinal, not the window's start: the loaded
|
||
// window deliberately begins a quarter of a screen above the view, so
|
||
// its first cell is one the user cannot see.
|
||
let at = ctl.resume_at.get().min(total - 1);
|
||
place_cursor(window, ctl, coll, at, false);
|
||
return;
|
||
};
|
||
|
||
// Saturating in `isize`, so a `Home` expressed as minus the library's
|
||
// length does not wrap round to the end.
|
||
let next = (from as isize)
|
||
.saturating_add(delta as isize)
|
||
.clamp(0, total as isize - 1) as usize;
|
||
if next == from {
|
||
// Already at the end being pressed toward. Nothing to move, and
|
||
// reloading the window would be a visible jerk for no movement.
|
||
return;
|
||
}
|
||
place_cursor(window, ctl, coll, next, extend);
|
||
}
|
||
|
||
/// Put the cursor on one image, bringing the window with it.
|
||
fn place_cursor(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
coll: &Rc<crate::collections_ui::CollectionsController>,
|
||
at: usize,
|
||
extend: bool,
|
||
) {
|
||
// Bring the window to the cursor if it has walked out of it. Centred a
|
||
// quarter in, exactly as a scroll does it, so continuing in the same
|
||
// direction has loaded cells to move into rather than another reload on
|
||
// the very next press.
|
||
let loaded = window.get_library_cells().row_count();
|
||
let offset = *ctl.offset.borrow();
|
||
if at < offset || at >= offset + loaded {
|
||
let size = *ctl.window.borrow();
|
||
let total = window.get_library_total().max(0) as usize;
|
||
*ctl.offset.borrow_mut() = window_start(at, size, total);
|
||
load_window(window, ctl);
|
||
}
|
||
|
||
// Re-read: `load_window` clamps the offset against the library's end, so
|
||
// the window may not start where it was asked to.
|
||
let offset = *ctl.offset.borrow();
|
||
let ids = ctl.visible_ids();
|
||
let Some(row) = at.checked_sub(offset).filter(|r| *r < ids.len()) else {
|
||
// The window could not be brought to the cursor — an empty or
|
||
// shrinking library. Leaving the cursor where it was is better than
|
||
// pointing it at nothing.
|
||
return;
|
||
};
|
||
|
||
if !extend {
|
||
// An arrow key collapses the selection onto the cursor. A *click* on
|
||
// an already-selected cell deliberately leaves the selection alone —
|
||
// that exception exists so a multi-image drag can start from one of
|
||
// its members — and there is no drag behind a keystroke.
|
||
coll.clear_selection();
|
||
}
|
||
|
||
crate::collections_ui::select_row(window, coll, &ids, offset, row, false, extend);
|
||
window.set_library_cursor(at as i32);
|
||
}
|
||
|
||
/// Say where in the library the photograph now open sits.
|
||
///
|
||
/// **After `on_open_image`, never before.** The generic open path resets the
|
||
/// readout to "1 of 1" on its way in, because until the photo roll existed the
|
||
/// grid really did hand develop a single path with nothing to walk to. It has
|
||
/// a window of them now, so the honest answer is the ordinal in the library —
|
||
/// not the row in the loaded window, which is an artefact of how much has been
|
||
/// paged in and would jump about as the window moves.
|
||
fn report_position(window: &AppWindow, ctl: &Rc<LibraryController>, row: usize) {
|
||
let offset = *ctl.offset.borrow();
|
||
window.set_index((offset + row) as i32);
|
||
window.set_total(window.get_library_total());
|
||
}
|
||
|
||
// --- where the photographer was (FR-UI-8) --------------------------------
|
||
|
||
/// How long a movement has to settle before it is written down.
|
||
///
|
||
/// A flick reports a scroll several times per screenful, and a place is a file
|
||
/// write — one per event would put disk I/O inside the gesture NFR-P5 governs,
|
||
/// for a record only the *last* of which is ever read. Longer than
|
||
/// [`GEOMETRY_SETTLE`] because nothing on screen waits for this: the reload a
|
||
/// geometry change schedules is visible, and this is not.
|
||
const PLACE_SETTLE: std::time::Duration = std::time::Duration::from_millis(750);
|
||
|
||
/// The photographer moved. Write it down, once they stop.
|
||
///
|
||
/// Replacing the timer rather than adding one is what makes this a debounce:
|
||
/// a `slint::Timer` is stopped by being dropped, so the previous pending write
|
||
/// goes with the old handle.
|
||
fn note_place(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
if ctl.applying_place.get() {
|
||
// A restore is not a movement — see `applying_place`.
|
||
return;
|
||
}
|
||
|
||
// The handover latch closes on the first movement — see `place_untouched`.
|
||
ctl.place_untouched.set(false);
|
||
|
||
if ctl.place_store.borrow().is_none() {
|
||
return;
|
||
}
|
||
|
||
let timer = slint::Timer::default();
|
||
let weak = window.as_weak();
|
||
let held = ctl.clone();
|
||
timer.start(slint::TimerMode::SingleShot, PLACE_SETTLE, move || {
|
||
if let Some(w) = weak.upgrade() {
|
||
write_place(&w, &held);
|
||
}
|
||
});
|
||
*ctl.place_timer.borrow_mut() = Some(timer);
|
||
}
|
||
|
||
/// Write the place out now, without waiting for a settle.
|
||
///
|
||
/// For the moments there may be no "later": leaving the grid for develop, and
|
||
/// coming back. Both are single deliberate acts rather than runs of events, and
|
||
/// both change the one field a debounce is most likely to lose — which view the
|
||
/// user is in.
|
||
fn write_place(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
if ctl.applying_place.get() {
|
||
return;
|
||
}
|
||
// Opening a photograph is as much "the photographer has started" as
|
||
// scrolling is, and this is the path that records it — so the handover
|
||
// latch closes here too, not only in `note_place`. Without it a record
|
||
// arriving from another device could pull someone out of the image they had
|
||
// just opened.
|
||
ctl.place_untouched.set(false);
|
||
|
||
let Some(place) = current_place(window, ctl) else {
|
||
return;
|
||
};
|
||
if let Some(store) = ctl.place_store.borrow().as_ref() {
|
||
store.save(&place);
|
||
}
|
||
}
|
||
|
||
/// Where the photographer is, as a record that can be written down.
|
||
///
|
||
/// `None` where there is nothing worth recording: no library open, or the
|
||
/// launch screen in front of everything. A place is never written from the
|
||
/// launch screen — the user is *choosing* a library there, and recording "you
|
||
/// were at the launch screen" would make the next launch skip the position it
|
||
/// had just been asked to keep.
|
||
fn current_place(window: &AppWindow, ctl: &Rc<LibraryController>) -> Option<dr_types::Place> {
|
||
use dr_types::{Place, PlaceScope, Screen};
|
||
|
||
if window.get_show_launch() || !window.get_library_open() {
|
||
return None;
|
||
}
|
||
|
||
// Which view, and — the same question in both — which photograph.
|
||
//
|
||
// In the grid it is the first one visible, which `resume_at` already
|
||
// tracks; in develop it is the open one, which `report_position` keeps
|
||
// `index` holding. Both are library ordinals against the same ordering, so
|
||
// the lookup below is one piece of code rather than two.
|
||
let in_library = window.get_show_library();
|
||
let at = if in_library {
|
||
ctl.resume_at.get()
|
||
} else {
|
||
window.get_index().max(0) as usize
|
||
};
|
||
|
||
// Resolved through the loaded window rather than by a query: the model has
|
||
// the path and the capture time side by side already, and this runs on a
|
||
// settle after scrolling — the one moment a query is least welcome.
|
||
let offset = *ctl.offset.borrow();
|
||
let row = at.checked_sub(offset);
|
||
let path = row
|
||
.and_then(|r| ctl.paths.borrow().get(r).cloned())
|
||
.unwrap_or_default();
|
||
let captured_at = row.and_then(|r| ctl.captured_at.borrow().get(r).copied().flatten());
|
||
|
||
// The scope, in the vocabulary both devices share. A collection this device
|
||
// cannot name is recorded as the whole library rather than as a name
|
||
// nothing can resolve — see `collections::uuid_of`.
|
||
let scope = if ctl.viewing_trash.get() {
|
||
PlaceScope::Trash
|
||
} else {
|
||
match *ctl.scope.borrow() {
|
||
None => PlaceScope::Library,
|
||
Some(id) => {
|
||
let borrow = ctl.catalog.borrow();
|
||
let uuid = borrow
|
||
.as_ref()
|
||
.and_then(|c| dr_catalog::collections::uuid_of(c.connection(), id).ok())
|
||
.flatten();
|
||
match uuid {
|
||
Some(uuid) => PlaceScope::Collection { uuid },
|
||
None => PlaceScope::Library,
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
Some(Place {
|
||
at: library::now_secs(),
|
||
device: ctl.place_device.borrow().clone(),
|
||
screen: if in_library {
|
||
Screen::Library
|
||
} else {
|
||
Screen::Develop
|
||
},
|
||
scope,
|
||
path,
|
||
captured_at,
|
||
filter: (&*ctl.filter.borrow()).into(),
|
||
})
|
||
}
|
||
|
||
/// Put the photographer back where a record says they were.
|
||
///
|
||
/// # The order is the whole function
|
||
///
|
||
/// Scope, then filter, then position, then — last of all — the view. Each step
|
||
/// changes what an ordinal *means*: the grid is a window over a query, and a
|
||
/// row resolved before the scope was narrowed names a photograph in a different
|
||
/// list. Resolving the path last is what makes the restored position address the
|
||
/// grid the user is actually about to see.
|
||
///
|
||
/// # Nothing here is allowed to fail loudly
|
||
///
|
||
/// Every step degrades to "the state we would have been in anyway". A collection
|
||
/// this device has not merged yet leaves the scope at the whole library; a
|
||
/// photograph that has been deleted falls back to when it was taken; a capture
|
||
/// time that resolves to nothing leaves the grid at the top. A place is a
|
||
/// convenience, and an application that would not open because one was stale
|
||
/// would be trading something that matters for something that does not.
|
||
pub(crate) fn apply_place(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
||
place: &dr_types::Place,
|
||
) {
|
||
use dr_types::{PlaceScope, Screen};
|
||
|
||
ctl.applying_place.set(true);
|
||
|
||
// --- the scope --------------------------------------------------------
|
||
//
|
||
// Through the same callback the sidebar fires, rather than by setting the
|
||
// two controllers' fields by hand. Both of them hold a scope, and so does
|
||
// the header's label and the reorderable flag; there is exactly one piece
|
||
// of code that keeps those four in step and this is not the place to write
|
||
// a second.
|
||
let selected = match &place.scope {
|
||
PlaceScope::Library => 0,
|
||
PlaceScope::Trash => -1,
|
||
PlaceScope::Collection { uuid } => {
|
||
let borrow = ctl.catalog.borrow();
|
||
let found = borrow
|
||
.as_ref()
|
||
.and_then(|c| dr_catalog::collections::id_for_uuid(c.connection(), uuid).ok())
|
||
.flatten();
|
||
drop(borrow);
|
||
match found {
|
||
Some(id) => id.0 as i32,
|
||
// Recorded on another device, and this one has not merged the
|
||
// collection yet. The whole library is the honest fallback: an
|
||
// empty grid under a name nothing can resolve reads as data
|
||
// loss.
|
||
None => {
|
||
log::info!("the stored place names a collection this device does not have yet");
|
||
0
|
||
}
|
||
}
|
||
}
|
||
};
|
||
window.invoke_collection_select(selected);
|
||
|
||
// --- the filter -------------------------------------------------------
|
||
*ctl.filter.borrow_mut() = (&place.filter).into();
|
||
push_filter(window, ctl);
|
||
|
||
// --- the position -----------------------------------------------------
|
||
let resolved = resolve_place(window, ctl, place);
|
||
|
||
// The filter and the scope have both moved the window to the top; this is
|
||
// what puts it back, and it has to run after them for that reason.
|
||
let total = window.get_library_total().max(0) as usize;
|
||
let resolved = resolved.filter(|(at, _)| *at < total);
|
||
match resolved {
|
||
Some((at, _)) => {
|
||
ctl.resume_at.set(at);
|
||
// Moves the capture-time marker with it — see `resume_position`.
|
||
resume_position(window, ctl, coll_ctl, None);
|
||
}
|
||
None => refilter(window, ctl),
|
||
}
|
||
|
||
// --- and the view -----------------------------------------------------
|
||
//
|
||
// Only where the photograph itself was found. A capture time that merely
|
||
// landed in the right week is a good answer for the grid and no answer at
|
||
// all for a canvas: develop would open on a path that no longer resolves
|
||
// and show a filename over an empty frame.
|
||
if place.screen == Screen::Develop {
|
||
if let Some((at, Found::Photograph)) = resolved {
|
||
if let Some(open) = ctl.open_image.borrow().clone() {
|
||
window.set_show_library(false);
|
||
window.set_library_roll_centre(true);
|
||
let offset = *ctl.offset.borrow();
|
||
if let Some(row) = at.checked_sub(offset) {
|
||
window.set_library_roll_current(row as i32);
|
||
}
|
||
open(place.path.clone());
|
||
// After the open, which resets the readout to "1 of 1" on its
|
||
// way in — the same ordering `report_position` documents.
|
||
window.set_index(at as i32);
|
||
window.set_total(window.get_library_total());
|
||
}
|
||
}
|
||
}
|
||
|
||
// Last, so nothing between here and the top is mistaken for the
|
||
// photographer having moved.
|
||
ctl.applying_place.set(false);
|
||
}
|
||
|
||
/// The library ordinal a record names, or `None` if it names nothing here.
|
||
///
|
||
/// Two ways, and the second is why a place survives its subject. The path is
|
||
/// exact and is tried first. Failing that — the photograph was deleted, moved,
|
||
/// or filtered out by the very filter this place restored — the capture time
|
||
/// puts the grid in the right week, which is close enough that the user
|
||
/// recognises where they are.
|
||
fn resolve_place(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
place: &dr_types::Place,
|
||
) -> Option<(usize, Found)> {
|
||
let borrow = ctl.catalog.borrow();
|
||
let catalog = borrow.as_ref()?;
|
||
|
||
if !place.path.is_empty() {
|
||
match library::ordinal_of_path(
|
||
catalog,
|
||
*ctl.scope.borrow(),
|
||
&ctl.filter.borrow(),
|
||
ctl.viewing_trash.get(),
|
||
&place.path,
|
||
) {
|
||
Ok(Some(at)) => return Some((at, Found::Photograph)),
|
||
Ok(None) => {}
|
||
Err(e) => log::debug!("resolving the stored place: {e}"),
|
||
}
|
||
}
|
||
|
||
let when = place.captured_at?;
|
||
drop(borrow);
|
||
// Through the scrubber's own route, so "an instant, as an ordinal" has one
|
||
// spelling. It sets the viewport and the loaded window together, which is
|
||
// exactly what a restore needs, and it anchors the timeline marker on the
|
||
// way.
|
||
scrub_to(window, ctl, when);
|
||
// Where it landed, read back rather than recomputed: `scrub_to` sets the
|
||
// offset to the ordinal it resolved and `load_window` then clamps it against
|
||
// the end of the library, so the clamped value is the one the grid is
|
||
// actually showing.
|
||
Some((*ctl.offset.borrow(), Found::Thereabouts))
|
||
}
|
||
|
||
/// How exactly a place was resolved.
|
||
///
|
||
/// The distinction is what stops a restore reopening develop on the wrong
|
||
/// photograph. Landing in the right week is a perfectly good answer for a grid
|
||
/// — the user recognises where they are — and no answer at all for a canvas,
|
||
/// which would open on a path that no longer resolves and show a filename over
|
||
/// an empty frame.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
enum Found {
|
||
/// The photograph itself is still in this grid, at this ordinal.
|
||
Photograph,
|
||
/// It is not, so this is where its capture time falls instead.
|
||
Thereabouts,
|
||
}
|
||
|
||
/// Put the whole rating filter on screen.
|
||
///
|
||
/// The filter bar is drawn from half a dozen properties, each of which is set
|
||
/// where its own control is handled — which is right while the user is moving
|
||
/// them one at a time, and leaves nothing that can restore all of them at once.
|
||
/// A restored place that narrowed the grid without moving the stars would show
|
||
/// a filtered library under a bar claiming it was not filtered, and the user's
|
||
/// first act would be to try to clear a filter the bar says is off.
|
||
fn push_filter(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
let filter = ctl.filter.borrow().clone();
|
||
window.set_library_filter_min_rating(filter.min_rating as i32);
|
||
window.set_library_filter_unjudged(filter.unjudged);
|
||
// The bar's own numbering, from `on_library_filter_flag_changed`. It has no
|
||
// code for `Unflagged` because it cannot produce one — "nothing has judged
|
||
// this" is the `unjudged` chip, over both stars and flags — so a place can
|
||
// never carry one either, and it falls in with "no flag constraint".
|
||
window.set_library_filter_flag(match filter.flag {
|
||
Some(dr_types::FlagState::Pick) => 1,
|
||
Some(dr_types::FlagState::Reject) => 2,
|
||
_ => 0,
|
||
});
|
||
window.set_library_local_only(filter.local_only);
|
||
ctl.local_only.set(filter.local_only);
|
||
window.set_library_filter_eyes_open(filter.eyes_open);
|
||
push_people_chips(window, ctl);
|
||
}
|
||
|
||
/// Bring the grid back to where the photographer left it, and — coming out of
|
||
/// develop — put the keyboard cursor on the photograph they were editing.
|
||
///
|
||
/// # Two positions, not one
|
||
///
|
||
/// `resume_at` is where the *grid* was when it was left. `focus` is the
|
||
/// photograph develop was showing, which after a walk along the photo roll can
|
||
/// be a thousand rows away from it. Restoring only the first is what made
|
||
/// leaving develop feel like losing the frame you had just finished: the grid
|
||
/// came back to a screenful that no longer had it in.
|
||
///
|
||
/// So both are honoured, and which one wins is decided by whether they
|
||
/// disagree. If the open photograph is inside the screenful the grid was left
|
||
/// showing — the ordinary case, where develop was opened and closed on the same
|
||
/// frame — the remembered position is used unchanged and nothing appears to
|
||
/// move. If it is not, the grid goes to the photograph, because that is the one
|
||
/// the user was last looking at.
|
||
///
|
||
/// # Why the cursor rather than the selection
|
||
///
|
||
/// Deliberately not [`place_cursor`], which also rewrites the selection. A
|
||
/// selection of forty photographs assembled in the grid, then one of them
|
||
/// opened to check it, must survive the trip back — discarding it is the kind
|
||
/// of silent loss that stops people using the develop view mid-cull. The
|
||
/// cursor is a position, not a judgement, so it is safe to move.
|
||
///
|
||
/// The viewport is left to the grid's own `reveal()`, which moves as little as
|
||
/// will bring the cursor's row into view. That is what makes the two cases
|
||
/// above one rule rather than two: a cursor already on screen moves nothing.
|
||
fn resume_position(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
coll: &Rc<crate::collections_ui::CollectionsController>,
|
||
focus: Option<usize>,
|
||
) {
|
||
let total = window.get_library_total().max(0) as usize;
|
||
if total == 0 {
|
||
// Files named on the command line: there is no grid behind them, and
|
||
// an ordinal read off the readout would name a row of nothing.
|
||
return;
|
||
}
|
||
|
||
let resume = ctl.resume_at.get().min(total - 1);
|
||
let focus = focus.map(|at| at.min(total - 1));
|
||
|
||
// A screenful, as the grid last measured itself. The comparison has to be
|
||
// against what is *visible*, not against the loaded window, which is
|
||
// several screenfuls wide — an open photograph loaded but scrolled past is
|
||
// still one the user cannot see.
|
||
let viewport = ctl.viewport_cells.get().max(1);
|
||
let anchor = match focus {
|
||
Some(at) if at >= resume && at < resume + viewport => resume,
|
||
Some(at) => at,
|
||
None => resume,
|
||
};
|
||
|
||
// Centred like `on_library_scrolled` does it, so scrolling either way from
|
||
// the restored position has loaded rows to move into.
|
||
let window_size = *ctl.window.borrow();
|
||
*ctl.offset.borrow_mut() = window_start(anchor, window_size, total);
|
||
load_window(window, 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.
|
||
window.set_library_scroll_to(anchor as i32);
|
||
window.set_library_scroll_token(window.get_library_scroll_token() + 1);
|
||
|
||
if let Some(at) = focus {
|
||
coll.set_cursor(Some(at));
|
||
window.set_library_cursor(at as i32);
|
||
}
|
||
|
||
// The view has moved, so the capture-time marker has to move with it.
|
||
//
|
||
// `load_window` will not do it: it rebuilds the axis only when the scope,
|
||
// the filter or the total has changed, and none of them has — this is the
|
||
// same library seen from a different row. Without this the marker stayed
|
||
// wherever it was when develop was entered, which after a walk along the
|
||
// photo roll can be months away from the photographs now on screen.
|
||
reanchor_timeline(window, ctl, anchor);
|
||
}
|
||
|
||
/// Put the capture-time marker where the view now is.
|
||
///
|
||
/// Clearing the anchor and re-running the axis rather than computing a fraction
|
||
/// here: `refresh_timeline` seeds itself from the view's position when nothing
|
||
/// has claimed one, and it owns every other thing the sidebar draws — the bars,
|
||
/// the range band, the lit bucket. A second place that positioned the marker
|
||
/// would be a second chance to disagree with the bars under it, which is the
|
||
/// bug the `current-fraction` note in `library.slint` records.
|
||
fn reanchor_timeline(window: &AppWindow, ctl: &Rc<LibraryController>, at: usize) {
|
||
ctl.resume_at.set(at);
|
||
*ctl.current_bucket.borrow_mut() = None;
|
||
let borrow = ctl.catalog.borrow();
|
||
if let Some(catalog) = borrow.as_ref() {
|
||
refresh_timeline(window, catalog, ctl);
|
||
}
|
||
}
|
||
|
||
/// Connect the grid's callbacks.
|
||
pub fn wire<F>(
|
||
window: &AppWindow,
|
||
ctl: Rc<LibraryController>,
|
||
coll_ctl: Rc<crate::collections_ui::CollectionsController>,
|
||
on_open_image: F,
|
||
on_leave_develop: Rc<dyn Fn()>,
|
||
) where
|
||
F: Fn(String) + 'static,
|
||
{
|
||
// So a rebuilt window can put the selection ticks back. Weak, or the two
|
||
// controllers would hold each other alive for the life of the process.
|
||
*ctl.coll_ctl.borrow_mut() = Some(Rc::downgrade(&coll_ctl));
|
||
|
||
// TRACES: FR-CAT-13
|
||
// The reload the settings page offers when a sidecar disagrees.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_settings_xmp_reload(move || {
|
||
if let Some(w) = weak.upgrade() {
|
||
start_xmp_reload(&w, &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"));
|
||
|
||
// TRACES: FR-UI-4
|
||
// The gesture reference. Pushed once, here, rather than on demand: the
|
||
// table is a compiled-in constant, so there is nothing to be fresh about
|
||
// and nothing to recompute — a callback to fill it would only be a way for
|
||
// it to be empty the first time the sheet opens.
|
||
window.set_library_gestures(slint::ModelRc::new(slint::VecModel::from(
|
||
crate::gestures::rows()
|
||
.into_iter()
|
||
.map(|r| GestureRow {
|
||
heading: r.heading.into(),
|
||
title: r.title.into(),
|
||
touch: r.touch.into(),
|
||
pointer: r.pointer.into(),
|
||
keys: r.keys.into(),
|
||
})
|
||
.collect::<Vec<_>>(),
|
||
)));
|
||
|
||
// Shared rather than moved: a click and `Return` both open an image, and
|
||
// they are two callbacks.
|
||
let on_open_image: OpenImage = Rc::new(on_open_image);
|
||
// TRACES: FR-UI-8
|
||
// And a third caller, which is not a callback at all: a place recorded in
|
||
// develop reopens it at launch. Through the same closure, so the edit-saving
|
||
// and identity bookkeeping it does are not something a restore can skip.
|
||
*ctl.open_image.borrow_mut() = Some(on_open_image.clone());
|
||
|
||
{
|
||
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| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
|
||
// The press stayed put, so it was a tap and not a drag: whatever it
|
||
// held back can be applied now. See `collections_ui::Press`.
|
||
crate::collections_ui::commit_press(&w, &coll_for_click, &ctl.visible_ids());
|
||
|
||
// 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.
|
||
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);
|
||
// A develop session begins here, so the roll centres on this
|
||
// photograph the first time it settles rather than merely
|
||
// scrolling it into view at one edge. Raised here and not in
|
||
// `on_library_roll_pick`, which is a step *within* a session —
|
||
// see `PhotoRoll::centre-request`.
|
||
w.set_library_roll_centre(true);
|
||
on_open_image(path);
|
||
report_position(&w, &ctl, i as usize);
|
||
// TRACES: FR-UI-8
|
||
// Written now rather than on a settle: which view you are in is
|
||
// the field a debounce is most likely to lose, and quitting
|
||
// straight from develop is exactly the case worth getting right.
|
||
write_place(&w, &ctl);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 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);
|
||
// TRACES: FR-UI-8
|
||
// Debounced, unlike the two entries into develop: the roll is walked
|
||
// frame by frame and every step is a different photograph.
|
||
note_place(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
// --- 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, and centres on it
|
||
// because this too begins a session.
|
||
w.set_library_roll_current(row.unwrap_or(0) as i32);
|
||
w.set_library_roll_centre(true);
|
||
on_open(path);
|
||
report_position(&w, &ctl, row.unwrap_or(0));
|
||
write_place(&w, &ctl);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 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_library_visible() {
|
||
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_library_visible() {
|
||
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.**
|
||
//
|
||
// The grid is gated on an `if`, so leaving it 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.
|
||
//
|
||
// **`library-visible`, not `show-library`, and the difference is the
|
||
// whole bug.** `show-library` says "the library rather than develop"
|
||
// and stays true while Settings, Import, People or the launch screen
|
||
// replaces the window — all four of which take the grid down just as
|
||
// opening an image does. So the teardown's scroll-to-zero passed this
|
||
// guard, `resume_at` was set to 0, and coming back from any of those
|
||
// four screens landed at the top of the library however deep in it the
|
||
// user had been. The property is computed once in `app.slint` beside
|
||
// the `if` it is spelled from, so the two cannot drift apart again.
|
||
//
|
||
// 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_library_visible() {
|
||
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);
|
||
|
||
// TRACES: FR-UI-8
|
||
// And where the *next launch* will look for it. Debounced: a flick
|
||
// reports several of these per screenful.
|
||
note_place(&w, &ctl);
|
||
|
||
// The same position where a rebuilt grid will look for it.
|
||
//
|
||
// `scroll-to` is read by `seek()`, which runs on a `scroll-token`
|
||
// change and on `init` — so writing it here without bumping the
|
||
// token cannot move the grid that is on screen, and *is* what the
|
||
// next one reads when it is built. That is every route back to the
|
||
// grid at once: Settings, Import, People and the launch screen all
|
||
// tear the subtree down and rebuild it, and none of them goes
|
||
// through `on_back_to_library` to have the position replayed by
|
||
// hand. Without this they each rebuilt against whatever `scroll-to`
|
||
// was last *set* to — a stale scrub, or zero — and landed there.
|
||
w.set_library_scroll_to(first_visible as i32);
|
||
|
||
// 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, on the photograph they were editing,
|
||
// rather than to the top. The grid is gated on an `if` in the markup, so it
|
||
// is rebuilt from nothing and its Flickable starts at row 0; the position
|
||
// has to be replayed explicitly. See [`resume_position`] for which of the
|
||
// two positions wins when they disagree.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
let coll_ctl = coll_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();
|
||
|
||
// Where the open photograph sits in the library, which is exactly
|
||
// what `report_position` keeps `index` holding — including after
|
||
// every step along the photo roll, which is the case that makes
|
||
// this differ from `resume_at`.
|
||
let open = w.get_index().max(0) as usize;
|
||
resume_position(&w, &ctl, &coll_ctl, Some(open));
|
||
|
||
w.set_show_library(true);
|
||
// TRACES: FR-UI-8
|
||
// After the flag, not before: `current_place` reads it to say which
|
||
// view the record is of, and this is the moment it becomes the grid.
|
||
write_place(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
{
|
||
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);
|
||
});
|
||
}
|
||
|
||
// Fold a burst up, or open it out (FR-CULL-5). A reload rather than a repaint, because
|
||
// it changes what the grid's query returns — see [`crate::bursts::toggle`].
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_burst_toggled(move |row| {
|
||
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 };
|
||
|
||
let changed = {
|
||
let borrow = ctl.catalog.borrow();
|
||
match borrow.as_ref() {
|
||
Some(catalog) => crate::bursts::toggle(catalog, id),
|
||
None => false,
|
||
}
|
||
};
|
||
if changed {
|
||
ctl.requested.borrow_mut().clear();
|
||
load_window(&w, &ctl);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Name the frame an open burst folds to (FR-CULL-5). The default is the
|
||
// earliest of them, chosen because it is a fact about the clock and not a
|
||
// judgement about the photograph; this is the photographer, who is the only
|
||
// one who knows which of the twelve is the keeper, saying otherwise.
|
||
//
|
||
// A repaint of the badges rather than a reload, which is what separates it
|
||
// from folding a burst up: the grid's query returns the same rows either
|
||
// way while the group is open, and only the mark on them moves. Refilled
|
||
// for the whole window in one statement — the tick has to leave the frame
|
||
// that was carrying it, and that frame is a different cell.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_burst_representative_chosen(move |row| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let ids: Vec<dr_types::ImageId> = ctl
|
||
.image_ids
|
||
.borrow()
|
||
.iter()
|
||
.map(|id| dr_types::ImageId(*id as u64))
|
||
.collect();
|
||
let Some(id) = ids.get(row as usize).copied() else {
|
||
return;
|
||
};
|
||
|
||
let borrow = ctl.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
if crate::bursts::choose(catalog, id) {
|
||
crate::bursts::sync_badges(&w, catalog, &ids);
|
||
}
|
||
});
|
||
}
|
||
|
||
// A rating or flag key. Applies to the whole selection, which is what
|
||
// makes judging a run of frames one keystroke rather than forty.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
let coll_for_keys = coll_ctl.clone();
|
||
window.on_library_judged(move |rating, flag| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let chosen = coll_for_keys.selected();
|
||
|
||
// Exactly one axis is meant per keystroke; the other arrives as
|
||
// -1 so a star press cannot disturb a flag or the reverse.
|
||
if rating >= 0 {
|
||
apply_judgement(&w, &ctl, &chosen, Some(rating.clamp(0, 5) as u8), None);
|
||
} else if flag >= 0 {
|
||
apply_judgement(&w, &ctl, &chosen, None, Some(flag_from_code(flag)));
|
||
}
|
||
});
|
||
}
|
||
|
||
// --- keywords (FR-CAT-5, FR-CAT-6) ------------------------------------
|
||
//
|
||
// Three callbacks and no state of their own: the sheet's open/shut is local
|
||
// to the `.slint` file, and what a keyword applies to is the grid selection
|
||
// the collections controller already owns. A second copy of either here is
|
||
// a second thing that can disagree with the first.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
let coll_for_keywords = coll_ctl.clone();
|
||
window.on_library_keywords_opened(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
refresh_keywords(&w, &ctl, &coll_for_keywords.selected());
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_assign_keyword(move |word| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
apply_keyword(&w, &ctl, word.as_str(), true);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_unassign_keyword(move |word| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
apply_keyword(&w, &ctl, word.as_str(), false);
|
||
});
|
||
}
|
||
|
||
// --- the filter bar ---------------------------------------------------
|
||
//
|
||
// Each of these narrows what the grid *queries*, so all three reset the
|
||
// scroll offset: the window's position was an ordinal into a different
|
||
// set of images and means nothing once the set changes.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_filter_min_rating_changed(move |n| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
ctl.filter.borrow_mut().min_rating = n.clamp(0, 5) as u8;
|
||
// Stars and "unrated" are contradictory terms — asking for four
|
||
// stars *and* nothing judged matches nothing at all, which reads
|
||
// as a broken filter rather than an impossible question.
|
||
if n > 0 {
|
||
ctl.filter.borrow_mut().unjudged = false;
|
||
}
|
||
w.set_library_filter_min_rating(n.clamp(0, 5));
|
||
w.set_library_filter_unjudged(ctl.filter.borrow().unjudged);
|
||
refilter(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_filter_unjudged_changed(move |on| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
{
|
||
let mut f = ctl.filter.borrow_mut();
|
||
f.unjudged = on;
|
||
// See above: the two cannot both hold.
|
||
if on {
|
||
f.min_rating = 0;
|
||
f.flag = None;
|
||
}
|
||
}
|
||
w.set_library_filter_unjudged(on);
|
||
if on {
|
||
w.set_library_filter_min_rating(0);
|
||
w.set_library_filter_flag(0);
|
||
}
|
||
refilter(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_identity_show_photos(move |id, add| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let person = dr_catalog::faces::PersonId(id.max(0) as u64);
|
||
|
||
{
|
||
let mut f = ctl.filter.borrow_mut();
|
||
if !add {
|
||
f.people.clear();
|
||
}
|
||
// Adding the same person twice is a no-op rather than a
|
||
// duplicate chip — and, under `All`, rather than a filter that
|
||
// silently asks for them twice and matches the same pictures.
|
||
if !f.people.contains(&person.0) {
|
||
f.people.push(person.0);
|
||
}
|
||
}
|
||
push_people_chips(&w, &ctl);
|
||
|
||
// Leaving the Identity screen for the grid is the whole point of
|
||
// the button: the answer to "who is this" is a set of photographs,
|
||
// and they are shown where photographs are shown.
|
||
w.set_show_identity(false);
|
||
w.set_show_library(true);
|
||
refilter(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_filter_person_cleared(move |id| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
ctl.filter
|
||
.borrow_mut()
|
||
.people
|
||
.retain(|p| *p != id.max(0) as u64);
|
||
drop_eyes_open_without_people(&w, &ctl);
|
||
push_people_chips(&w, &ctl);
|
||
refilter(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_people_listed(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
push_people_roster(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_filter_person_toggled(move |id| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let person = id.max(0) as u64;
|
||
{
|
||
let mut f = ctl.filter.borrow_mut();
|
||
if let Some(at) = f.people.iter().position(|p| *p == person) {
|
||
f.people.remove(at);
|
||
} else {
|
||
f.people.push(person);
|
||
}
|
||
}
|
||
drop_eyes_open_without_people(&w, &ctl);
|
||
push_people_chips(&w, &ctl);
|
||
refilter(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_filter_eyes_open_toggled(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let on = {
|
||
let mut f = ctl.filter.borrow_mut();
|
||
f.eyes_open = !f.eyes_open;
|
||
f.eyes_open
|
||
};
|
||
w.set_library_filter_eyes_open(on);
|
||
refilter(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_filter_people_mode_toggled(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
{
|
||
let mut f = ctl.filter.borrow_mut();
|
||
f.people_mode = match f.people_mode {
|
||
library::PeopleMode::Any => library::PeopleMode::All,
|
||
library::PeopleMode::All => library::PeopleMode::Any,
|
||
};
|
||
}
|
||
push_people_chips(&w, &ctl);
|
||
refilter(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_filter_flag_changed(move |f| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
{
|
||
let mut filter = ctl.filter.borrow_mut();
|
||
filter.flag = match f {
|
||
1 => Some(dr_types::FlagState::Pick),
|
||
2 => Some(dr_types::FlagState::Reject),
|
||
_ => None,
|
||
};
|
||
if f > 0 {
|
||
filter.unjudged = false;
|
||
}
|
||
}
|
||
w.set_library_filter_flag(f);
|
||
w.set_library_filter_unjudged(ctl.filter.borrow().unjudged);
|
||
refilter(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
// Turn the range on, over the period the histogram is showing.
|
||
//
|
||
// The range is taken from the timeline rather than typed into two date
|
||
// fields: finding the fortnight is what the histogram is *for*, and having
|
||
// found it the user should not have to read the dates off the axis and key
|
||
// them back in.
|
||
//
|
||
// A starting point rather than the answer. What this puts on the axis is a
|
||
// band with two draggable ends, and dragging them is how the range is
|
||
// actually stated — on a phone it is the only way that does not need a
|
||
// keyboard. Seeding it to the whole visible span means the first drag
|
||
// narrows from what the user is already looking at.
|
||
//
|
||
// Toggling off clears both ends rather than remembering them — a range you
|
||
// cannot see the extent of is a filter that looks like an empty library.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_toggle_date_range(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
// Whether the *controls* are showing, which is not the same as
|
||
// whether a range is set — and reading it from the filter was why
|
||
// pressing this did nothing. The fields are how a range gets set,
|
||
// so requiring one before they appear is a door locked from the
|
||
// inside.
|
||
let on = !w.get_library_range_active();
|
||
if on {
|
||
// Seeded from the span the axis is drawn over, which is what
|
||
// the user is looking at when they ask for "this range".
|
||
//
|
||
// Only a seed. If there is no catalog yet, or nothing in it
|
||
// carries a capture date — dates are read from EXIF as
|
||
// thumbnails load, so a freshly opened library has none — the
|
||
// fields simply open empty and wait to be typed into. This
|
||
// used to `return` in both cases, which meant the button
|
||
// silently did nothing on exactly the libraries where a person
|
||
// is most likely to be looking for a date.
|
||
let seeded = {
|
||
let borrow = ctl.catalog.borrow();
|
||
borrow
|
||
.as_ref()
|
||
.and_then(|catalog| catalog_span(catalog, &ctl))
|
||
.map(|span| {
|
||
zoomed_span(
|
||
span,
|
||
*ctl.timeline_zoom.borrow(),
|
||
*ctl.timeline_centre.borrow(),
|
||
)
|
||
})
|
||
};
|
||
if let Some(span) = seeded {
|
||
// Through the same days-inclusive conversion a drag uses,
|
||
// so a seeded range and a dragged one are the same kind of
|
||
// thing — and so `show_range` reads this one back out as
|
||
// the two days it covers.
|
||
let (from, to) = dragged_range(span, 0.0, 1.0);
|
||
let mut f = ctl.filter.borrow_mut();
|
||
f.captured_from = Some(from);
|
||
f.captured_to = Some(to);
|
||
}
|
||
} else {
|
||
let mut f = ctl.filter.borrow_mut();
|
||
f.captured_from = None;
|
||
f.captured_to = None;
|
||
}
|
||
w.set_library_range_active(on);
|
||
show_range(&w, &ctl);
|
||
refilter(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
// TRACES: FR-CAT-6 | FR-UI-2
|
||
// The ends of the range, dragged along the axis they were chosen from.
|
||
//
|
||
// This is the control the two typed fields were standing in for. On a
|
||
// phone they could not do the job: `YYYY-MM-DD` keyed into a 108px field
|
||
// behind a soft keyboard, to name a day already drawn on the axis a thumb
|
||
// away. Nor was the chip a way round them — its span comes from the
|
||
// timeline's zoom and pan, and those are a wheel and a middle button,
|
||
// neither of which a touch screen has. So on Android the date range was a
|
||
// filter that could be turned on and not aimed.
|
||
//
|
||
// The span is recomputed here rather than remembered from the last draw.
|
||
// It is the same expression `refresh_timeline` uses, over the same
|
||
// date-lifted extent, so a handle dropped at a fraction of the track names
|
||
// the instant that fraction was drawn at.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_timeline_range_changed(move |a, b| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let borrow = ctl.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
let Some(full) = catalog_span(catalog, &ctl) else {
|
||
return;
|
||
};
|
||
let span = zoomed_span(
|
||
full,
|
||
*ctl.timeline_zoom.borrow(),
|
||
*ctl.timeline_centre.borrow(),
|
||
);
|
||
drop(borrow);
|
||
|
||
let (from, to) = dragged_range(span, a, b);
|
||
{
|
||
let mut f = ctl.filter.borrow_mut();
|
||
f.captured_from = Some(from);
|
||
f.captured_to = Some(to);
|
||
}
|
||
|
||
// A dragged range cannot be mistyped, so any standing complaint
|
||
// about the fields is now about a range that no longer exists.
|
||
w.set_library_range_invalid(false);
|
||
w.set_library_range_active(true);
|
||
show_range(&w, &ctl);
|
||
// Reloads the window, and `load_window` redraws the timeline —
|
||
// which is what puts the band back under the finger that dropped
|
||
// it, at the whole days it was snapped to.
|
||
refilter(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
// TRACES: FR-CAT-6
|
||
// The ends of the range, typed.
|
||
//
|
||
// The chip alone used to be the whole control and it read its span from
|
||
// the timeline's zoom, which is zero until someone zooms — so "limit to
|
||
// range" set the range to the entire library and appeared to do nothing.
|
||
// The span is shown now, and these two fields are how it is corrected.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_range_edited(move |from, to| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
|
||
// Both ends, or neither. A range with one end parsed and the other
|
||
// silently dropped is a filter nobody asked for, and the grid
|
||
// going empty is a poor way to find out a date was mistyped.
|
||
let (Some(from), Some(to)) = (
|
||
dr_types::parse_date(from.as_str()),
|
||
dr_types::parse_date(to.as_str()),
|
||
) else {
|
||
w.set_library_range_invalid(true);
|
||
return;
|
||
};
|
||
|
||
// Typed the other way round is a slip, not an error: the user has
|
||
// said which two days they mean and there is exactly one range
|
||
// between them.
|
||
let (from, to) = if from <= to { (from, to) } else { (to, from) };
|
||
|
||
{
|
||
let mut f = ctl.filter.borrow_mut();
|
||
f.captured_from = Some(from);
|
||
// Inclusive of the last day. "To the 5th" means the whole of
|
||
// the 5th — `parse_date` returns its midnight, and a range
|
||
// ending there would exclude every photograph taken on the day
|
||
// the user named. The same rule `dragged_range` applies.
|
||
f.captured_to = Some(to + 86_400 - 1);
|
||
}
|
||
w.set_library_range_invalid(false);
|
||
w.set_library_range_active(true);
|
||
show_range(&w, &ctl);
|
||
refilter(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
// TRACES: FR-CAT-9
|
||
// "On this device" — the images openable without a server. Composes with
|
||
// the rating terms rather than replacing them: "five-star frames I can
|
||
// actually edit on this train" is one filter, not a mode.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_toggle_local_only(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let on = !ctl.local_only();
|
||
ctl.set_local_only(on);
|
||
ctl.filter.borrow_mut().local_only = on;
|
||
w.set_library_local_only(on);
|
||
refilter(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
// TRACES: FR-NC-6a
|
||
// The header's way in to the offline question, for the collection the grid
|
||
// is scoped to. It opens the same prompt the sidebar's tray and the long
|
||
// press open, rather than pinning outright: three affordances that did two
|
||
// different things — one asking, two acting — is how a user comes to avoid
|
||
// all three.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_library_toggle_pin_scope(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let Some(scope) = *ctl.scope.borrow() else {
|
||
return;
|
||
};
|
||
open_offline_prompt(&w, &ctl, scope);
|
||
});
|
||
}
|
||
|
||
// TRACES: FR-NC-6a | FR-UI-4
|
||
// The sidebar's way in: the tray on a row, tapped.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_collection_offline_menu(move |id| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
open_offline_prompt(&w, &ctl, dr_types::CollectionId(id as u64));
|
||
});
|
||
}
|
||
|
||
// The hold on a sidebar row — which arms the drag that rearranges the tree
|
||
// and opens the row menu — is wired in `collections_ui`, with the rest of
|
||
// what a collection row does. It used to be here because it opened the
|
||
// offline question and nothing else; that question is now one item in that
|
||
// menu, so the gesture belongs with the menu rather than with the transfers.
|
||
|
||
// 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);
|
||
});
|
||
}
|
||
|
||
// Last, and in its own module: the answers to a damaged catalog have
|
||
// nothing to do with the library view except that they run before it
|
||
// exists.
|
||
crate::recovery_ui::wire(window, &ctl, &coll_ctl);
|
||
}
|
||
|
||
/// Reload the grid after the filter changed.
|
||
///
|
||
/// The offset is reset because it is an ordinal into the filtered set: keeping
|
||
/// it would land the user in the middle of a narrowed library with no sense of
|
||
/// how they got there, or past its end entirely.
|
||
/// Put the identity filter's people on the filter bar.
|
||
///
|
||
/// One chip each, so a selection of three is three things to look at and three
|
||
/// things to remove — a single chip saying "3 people" would be a filter the
|
||
/// user can only clear wholesale.
|
||
///
|
||
/// The label is the People screen's own wording, unnamed groups included, so
|
||
/// the same group does not read one way on one screen and another way on the
|
||
/// next.
|
||
/// Drop the eyes-open term when the last person leaves the filter.
|
||
///
|
||
/// The chip lives beside the people chips and goes with them; a term that
|
||
/// stayed on after its chip had gone would narrow the grid with nothing on
|
||
/// the bar to say so, which is the one state a filter bar must never be in.
|
||
fn drop_eyes_open_without_people(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
let mut f = ctl.filter.borrow_mut();
|
||
if f.people.is_empty() && f.eyes_open {
|
||
f.eyes_open = false;
|
||
window.set_library_filter_eyes_open(false);
|
||
}
|
||
}
|
||
|
||
fn push_people_chips(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
let ids = ctl.filter.borrow().people.clone();
|
||
if ids.is_empty() {
|
||
window
|
||
.set_library_filter_people(slint::ModelRc::new(slint::VecModel::from(
|
||
Vec::<PersonChip>::new(),
|
||
)));
|
||
// The tray, if it is open, has to lose its ticks with the chips: the
|
||
// roster carries `picked` and is the same fact drawn a second time.
|
||
push_people_roster(window, ctl);
|
||
return;
|
||
}
|
||
|
||
let borrow = ctl.catalog.borrow();
|
||
let people = borrow
|
||
.as_ref()
|
||
.and_then(|cat| dr_catalog::faces::people(cat.connection()).ok())
|
||
.unwrap_or_default();
|
||
|
||
let chips: Vec<PersonChip> = ids
|
||
.iter()
|
||
.map(|id| {
|
||
let name = people
|
||
.iter()
|
||
.find(|p| p.id.0 == *id)
|
||
.map(|p| {
|
||
if p.name.trim().is_empty() {
|
||
format!("Unnamed ({} faces)", p.confirmed_faces + p.suggested_faces)
|
||
} else {
|
||
p.name.clone()
|
||
}
|
||
})
|
||
// A person deleted from under the filter still needs a chip,
|
||
// or there would be no way to take them back out of it.
|
||
.unwrap_or_else(|| "Person".to_string());
|
||
PersonChip {
|
||
id: *id as i32,
|
||
name: name.into(),
|
||
// Not asked for on this side. The bar's job here is to say who
|
||
// the grid is narrowed to and offer a way out of it; a face
|
||
// count beside each would be a second number competing with the
|
||
// image counts already on the bar.
|
||
faces: -1,
|
||
picked: true,
|
||
}
|
||
})
|
||
.collect();
|
||
|
||
window.set_library_filter_people(slint::ModelRc::new(slint::VecModel::from(chips)));
|
||
window.set_library_filter_people_all(matches!(
|
||
ctl.filter.borrow().people_mode,
|
||
library::PeopleMode::All
|
||
));
|
||
push_people_roster(window, ctl);
|
||
}
|
||
|
||
/// Fill the filter bar's people tray.
|
||
///
|
||
/// Rebuilt whole rather than patched, because `picked` is on every row and a
|
||
/// toggle changes two things at once — the chip that was pressed and, under
|
||
/// `All`, what the whole filter means.
|
||
///
|
||
/// **Named people first, then by how much of them the library holds.** The
|
||
/// catalog orders by face count alone, which on a real library puts a dozen
|
||
/// unnamed strangers ahead of the two people the user has actually named — and
|
||
/// the tray scrolls horizontally, so anything past the first few chips costs a
|
||
/// gesture to reach. Naming somebody is the user saying they matter; the order
|
||
/// says it back.
|
||
fn push_people_roster(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
let picked = ctl.filter.borrow().people.clone();
|
||
let borrow = ctl.catalog.borrow();
|
||
let people = borrow
|
||
.as_ref()
|
||
.and_then(|cat| dr_catalog::faces::people(cat.connection()).ok())
|
||
.unwrap_or_default();
|
||
|
||
// Sorted as (unnamed, -faces) pairs beside the chip rather than by
|
||
// re-reading the drawn label: "is this person named" is a fact about the
|
||
// record, and recovering it from the `Unnamed (n faces)` wording would put
|
||
// a sort key inside a string meant for a human to read.
|
||
let mut rows: Vec<(bool, i64, PersonChip)> = people
|
||
.iter()
|
||
.filter(|p| {
|
||
let on = picked.contains(&p.id.0);
|
||
// A person already in the filter always has a chip, whatever else
|
||
// is true of them: the tray is where the filter is taken apart, and
|
||
// a term with no control is a term the user cannot remove.
|
||
//
|
||
// Otherwise: nobody the user set aside, and nobody with no faces —
|
||
// a named person emptied by a split would be a chip that narrows
|
||
// the grid to nothing whatever else is on the bar.
|
||
on || (!p.ignored && p.confirmed_faces + p.suggested_faces > 0)
|
||
})
|
||
.map(|p| {
|
||
let faces = p.confirmed_faces + p.suggested_faces;
|
||
let unnamed = p.name.trim().is_empty();
|
||
(
|
||
unnamed,
|
||
faces as i64,
|
||
PersonChip {
|
||
id: p.id.0 as i32,
|
||
name: if unnamed {
|
||
format!("Unnamed ({faces} faces)").into()
|
||
} else {
|
||
p.name.clone().into()
|
||
},
|
||
faces: faces as i32,
|
||
picked: picked.contains(&p.id.0),
|
||
},
|
||
)
|
||
})
|
||
.collect();
|
||
// Stable, so the catalog's own tiebreak by name survives inside each of the
|
||
// two blocks.
|
||
rows.sort_by_key(|(unnamed, faces, _)| (*unnamed, -faces));
|
||
|
||
let rows: Vec<PersonChip> = rows.into_iter().map(|(_, _, chip)| chip).collect();
|
||
window.set_library_people(slint::ModelRc::new(slint::VecModel::from(rows)));
|
||
}
|
||
|
||
fn refilter(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
*ctl.offset.borrow_mut() = 0;
|
||
ctl.requested.borrow_mut().clear();
|
||
load_window(window, ctl);
|
||
// TRACES: FR-UI-8
|
||
// A narrowed grid is part of where you are: coming back to five-star frames
|
||
// of one person and finding the whole library is the same loss of place as
|
||
// coming back to the top of it.
|
||
note_place(window, ctl);
|
||
}
|
||
|
||
fn stop(slot: &RefCell<Option<slint::Timer>>) {
|
||
if let Some(t) = slot.borrow().as_ref() {
|
||
t.stop();
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn a_scrub_fraction_interpolates_within_the_span() {
|
||
// The point of going fractional: a slow drag must advance
|
||
// continuously rather than sitting still until it crosses a bucket
|
||
// edge and then jumping a whole month.
|
||
let span = (0, 1_000);
|
||
assert_eq!(instant_at(span, 0.0), 0);
|
||
assert_eq!(instant_at(span, 0.5), 500);
|
||
assert_eq!(instant_at(span, 1.0), 1_000);
|
||
// Distinct fractions inside one bucket must give distinct instants.
|
||
assert_ne!(instant_at(span, 0.10), instant_at(span, 0.11));
|
||
}
|
||
|
||
#[test]
|
||
fn the_marker_lands_on_the_fraction_that_was_clicked() {
|
||
// The bug this guards: the marker was placed from a *bar index* while
|
||
// the click was a fraction of the track, so it never appeared under
|
||
// the pointer. Marker and click must be inverses.
|
||
let span = (1_000, 9_000);
|
||
for f in [0.0_f32, 0.1, 0.25, 0.5, 0.75, 1.0] {
|
||
let round_trip = fraction_at(span, instant_at(span, f));
|
||
assert!(
|
||
(round_trip - f).abs() < 1e-3,
|
||
"clicked {f}, marker drawn at {round_trip}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn an_instant_outside_the_visible_span_pins_the_marker_to_an_end() {
|
||
// Zooming in leaves the grid's instant outside the axis. The marker
|
||
// belongs at the edge it went past, not off the widget.
|
||
let span = (1_000, 2_000);
|
||
assert_eq!(fraction_at(span, 0), 0.0);
|
||
assert_eq!(fraction_at(span, 5_000), 1.0);
|
||
}
|
||
|
||
#[test]
|
||
fn a_scrub_fraction_outside_the_axis_is_clamped() {
|
||
// A drag that leaves the widget still reports a position; it must land
|
||
// at an end rather than off the timeline.
|
||
let span = (100, 200);
|
||
assert_eq!(instant_at(span, -3.0), 100);
|
||
assert_eq!(instant_at(span, 9.9), 200);
|
||
}
|
||
|
||
/// A fortnight in March 2024, in whole days, as the axis would span it.
|
||
const MARCH: (i64, i64) = (1_709_251_200, 1_710_460_800);
|
||
|
||
#[test]
|
||
fn a_dragged_range_covers_whole_days_at_both_ends() {
|
||
// What makes a dragged range the same kind of thing as a typed one:
|
||
// `show_range` reads the closing day back out of the stored second, so
|
||
// an end left mid-afternoon would be shown as the day before.
|
||
let (from, to) = dragged_range(MARCH, 0.13, 0.61);
|
||
assert_eq!(day_start(from), from, "opens at a midnight");
|
||
assert_eq!(
|
||
day_start(to + 1),
|
||
to + 1,
|
||
"closes at the last second of a day"
|
||
);
|
||
assert_eq!(dr_types::format_date(from), "2024-03-02");
|
||
assert_eq!(dr_types::format_date(to - 86_400 + 1), "2024-03-09");
|
||
}
|
||
|
||
#[test]
|
||
fn ends_dragged_past_each_other_name_the_same_range() {
|
||
// Pulling the early end below the late one is how a range that was
|
||
// narrowed too far gets corrected; refusing it would make starting
|
||
// again the easier move.
|
||
assert_eq!(
|
||
dragged_range(MARCH, 0.6, 0.2),
|
||
dragged_range(MARCH, 0.2, 0.6)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn both_ends_dragged_together_name_one_day() {
|
||
// The floor the day snapping buys. Two handles on the same pixel is a
|
||
// gesture that happens; an empty grid is not what it means.
|
||
let (from, to) = dragged_range(MARCH, 0.5, 0.5);
|
||
assert_eq!(to - from, 86_399);
|
||
}
|
||
|
||
#[test]
|
||
fn the_band_is_drawn_where_the_handle_was_dropped() {
|
||
// The band is placed by `fraction_at` and the range comes from
|
||
// `instant_at`. If those two ever disagreed the band would settle
|
||
// somewhere other than where the finger let go, which reads as the
|
||
// drag having been ignored.
|
||
for f in [0.0_f32, 0.25, 0.5, 0.75, 1.0] {
|
||
let (from, _) = dragged_range(MARCH, f, f);
|
||
let drawn = fraction_at(MARCH, from);
|
||
// Within a day of the span, which is the snapping and nothing else.
|
||
let day = 86_400.0 / (MARCH.1 - MARCH.0) as f32;
|
||
assert!((drawn - f).abs() <= day, "dropped at {f}, drawn at {drawn}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn every_instant_on_the_axis_lands_in_a_bin() {
|
||
// The bar under a date and the bar that counted it must be the same
|
||
// bar: `bin_of` is the UI's copy of the division the SQL groups by.
|
||
let span = (1_000, 9_000);
|
||
assert_eq!(bin_of(span, 1_000, 8), 0);
|
||
assert_eq!(bin_of(span, 1_999, 8), 0);
|
||
assert_eq!(bin_of(span, 2_000, 8), 1);
|
||
// The last instant belongs to the last bin rather than to one past it.
|
||
assert_eq!(bin_of(span, 9_000, 8), 7);
|
||
}
|
||
|
||
#[test]
|
||
fn an_instant_off_the_zoomed_axis_lands_at_the_end_it_went_past() {
|
||
// Zooming in leaves the grid's position outside the span. The lit bar
|
||
// belongs at the edge, not at an index that does not exist.
|
||
let span = (1_000, 9_000);
|
||
assert_eq!(bin_of(span, -50_000, 8), 0);
|
||
assert_eq!(bin_of(span, 50_000, 8), 7);
|
||
// And a degenerate axis still answers with a bin.
|
||
assert_eq!(bin_of((5, 5), 5, 32), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn a_day_before_1970_starts_at_its_own_midnight() {
|
||
// `%` truncates towards zero, which on a negative timestamp names the
|
||
// *following* midnight — a scanned archive of negatives would have
|
||
// every range off by a day at the early end.
|
||
assert_eq!(day_start(-1), -86_400);
|
||
assert_eq!(day_start(-86_400), -86_400);
|
||
assert_eq!(day_start(-86_401), -172_800);
|
||
}
|
||
|
||
/// One pinch update, as the handler applies it: fold the ratio in, take
|
||
/// out whole doublings, carry the remainder.
|
||
fn pinch_step(accum: &mut f32, ratio: f32) -> i32 {
|
||
*accum *= ratio;
|
||
let steps = accum.log2().trunc() as i32;
|
||
if steps != 0 {
|
||
*accum /= (2.0f32).powi(steps);
|
||
}
|
||
steps
|
||
}
|
||
|
||
#[test]
|
||
fn a_pinch_accumulates_until_it_reaches_a_doubling() {
|
||
// A continuous gesture against discrete levels. Small spreads must
|
||
// accumulate rather than each being rounded to a step.
|
||
let mut accum = 1.0f32;
|
||
let mut steps = 0;
|
||
for _ in 0..12 {
|
||
steps += pinch_step(&mut accum, 1.1);
|
||
}
|
||
// 1.1^12 is 3.138x, which is log2 = 1.65 — one whole doubling, with
|
||
// the rest carried rather than discarded or rounded up.
|
||
assert_eq!(steps, 1);
|
||
assert!((accum - 1.569).abs() < 0.01, "remainder carried: {accum}");
|
||
}
|
||
|
||
#[test]
|
||
fn one_large_pinch_yields_every_level_it_crossed() {
|
||
// The bug this replaced: stepping at most once per update turned an
|
||
// 8x spread — three doublings — into a single zoom level, so a fast
|
||
// gesture lost most of its travel.
|
||
let mut accum = 1.0f32;
|
||
assert_eq!(pinch_step(&mut accum, 8.0), 3);
|
||
assert!((accum - 1.0).abs() < 0.001);
|
||
|
||
let mut accum = 1.0f32;
|
||
assert_eq!(pinch_step(&mut accum, 0.125), -3);
|
||
}
|
||
|
||
#[test]
|
||
fn pinching_in_and_back_out_returns_to_where_it_started() {
|
||
let mut accum = 1.0f32;
|
||
let steps: i32 = [2.0f32, 2.0, 0.5, 0.5]
|
||
.iter()
|
||
.map(|r| pinch_step(&mut accum, *r))
|
||
.sum();
|
||
assert_eq!(steps, 0);
|
||
assert!((accum - 1.0).abs() < 0.001, "no drift: {accum}");
|
||
}
|
||
|
||
#[test]
|
||
fn a_pinch_below_a_doubling_takes_no_step() {
|
||
// Otherwise the axis would flicker between levels on the smallest
|
||
// finger movement.
|
||
let mut accum = 1.0f32;
|
||
assert_eq!(pinch_step(&mut accum, 1.4), 0);
|
||
assert_eq!(pinch_step(&mut accum, 0.72), 0, "back roughly to 1.0");
|
||
}
|
||
|
||
/// One zoom step, as the handler applies it.
|
||
fn zoom_cell(current: f32, delta: i32) -> f32 {
|
||
let next = if delta > 0 {
|
||
current * 1.25
|
||
} else {
|
||
current / 1.25
|
||
};
|
||
next.clamp(MIN_CELL_SIZE, MAX_CELL_SIZE)
|
||
}
|
||
|
||
#[test]
|
||
fn cell_zoom_steps_geometrically_and_reverses() {
|
||
// Geometric so the gesture feels the same at either end; a fixed pixel
|
||
// step is imperceptible at 400px and violent at 90px.
|
||
let a = zoom_cell(180.0, 1);
|
||
assert!((a - 225.0).abs() < 0.01);
|
||
assert!(
|
||
(zoom_cell(a, -1) - 180.0).abs() < 0.01,
|
||
"in then out returns"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn cell_zoom_stays_within_its_bounds() {
|
||
let mut size = 180.0;
|
||
for _ in 0..40 {
|
||
size = zoom_cell(size, 1);
|
||
}
|
||
assert_eq!(size, MAX_CELL_SIZE);
|
||
|
||
for _ in 0..40 {
|
||
size = zoom_cell(size, -1);
|
||
}
|
||
assert_eq!(size, MIN_CELL_SIZE);
|
||
}
|
||
|
||
#[test]
|
||
fn zooming_past_the_grid_class_asks_for_the_large_one() {
|
||
use dr_thumbs::ThumbSize;
|
||
// The point of the second class: past 256px a grid thumbnail is being
|
||
// upscaled, and the softness shows.
|
||
assert_eq!(ThumbSize::for_cell(180), ThumbSize::Grid);
|
||
assert_eq!(
|
||
ThumbSize::for_cell(zoom_cell(225.0, 1) as u32),
|
||
ThumbSize::Large
|
||
);
|
||
// And zooming back down does not keep paying for it.
|
||
assert_eq!(
|
||
ThumbSize::for_cell(zoom_cell(281.0, -1) as u32),
|
||
ThumbSize::Grid
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_request_key_survives_the_window_moving() {
|
||
use dr_thumbs::ThumbSize;
|
||
use std::collections::HashSet;
|
||
|
||
// Keyed on the photograph, not its position. The grid is a window over
|
||
// the catalog, so row 7 is a different image after every scroll — a
|
||
// set of row indices had to be cleared on each move, and every visible
|
||
// cell then looked unrequested and was re-issued.
|
||
let mut requested: HashSet<(i64, ThumbSize)> = HashSet::new();
|
||
|
||
// A screenful at rows 0..3, holding images 100..103.
|
||
for id in 100..103 {
|
||
assert!(requested.insert((id, ThumbSize::Grid)), "first sight");
|
||
}
|
||
|
||
// Scrolled: the same photographs now occupy different rows.
|
||
for id in 100..103 {
|
||
assert!(
|
||
!requested.insert((id, ThumbSize::Grid)),
|
||
"image {id} must not be requested twice"
|
||
);
|
||
}
|
||
|
||
// A genuinely new photograph still is.
|
||
assert!(requested.insert((200, ThumbSize::Grid)));
|
||
}
|
||
|
||
#[test]
|
||
fn the_two_size_classes_are_requested_independently() {
|
||
use dr_thumbs::ThumbSize;
|
||
use std::collections::HashSet;
|
||
|
||
// Holding the 256px version says nothing about the large one, so
|
||
// zooming past the boundary must still ask.
|
||
let mut requested: HashSet<(i64, ThumbSize)> = HashSet::new();
|
||
assert!(requested.insert((1, ThumbSize::Grid)));
|
||
assert!(
|
||
requested.insert((1, ThumbSize::Large)),
|
||
"the large class is a separate request"
|
||
);
|
||
assert!(!requested.insert((1, ThumbSize::Grid)));
|
||
}
|
||
|
||
#[test]
|
||
fn zoom_zero_is_the_whole_library() {
|
||
let full = (1_000, 2_000);
|
||
assert_eq!(zoomed_span(full, 0, None), full);
|
||
// A remembered centre must not narrow the span at zoom 0.
|
||
assert_eq!(zoomed_span(full, 0, Some(1_500)), full);
|
||
}
|
||
|
||
#[test]
|
||
fn each_zoom_step_halves_the_span() {
|
||
let day = 86_400;
|
||
let full = (0, 64 * day);
|
||
let (a, b) = zoomed_span(full, 1, Some(32 * day));
|
||
assert_eq!(b - a, 32 * day);
|
||
let (a, b) = zoomed_span(full, 2, Some(32 * day));
|
||
assert_eq!(b - a, 16 * day);
|
||
}
|
||
|
||
#[test]
|
||
fn zooming_centres_on_the_given_instant() {
|
||
let day = 86_400;
|
||
let full = (0, 100 * day);
|
||
let (a, b) = zoomed_span(full, 1, Some(60 * day));
|
||
assert_eq!((a + b) / 2, 60 * day, "centred where asked");
|
||
}
|
||
|
||
#[test]
|
||
fn a_window_at_the_edge_slides_rather_than_shrinking() {
|
||
// Clamping both ends would silently halve the span near the start of
|
||
// the library, so the axis would show less detail there than in the
|
||
// middle for the same zoom level.
|
||
let day = 86_400;
|
||
let full = (0, 100 * day);
|
||
let (a, b) = zoomed_span(full, 1, Some(0));
|
||
assert_eq!(a, 0, "cannot start before the library does");
|
||
assert_eq!(b - a, 50 * day, "keeps its width");
|
||
|
||
let (a, b) = zoomed_span(full, 1, Some(100 * day));
|
||
assert_eq!(b, 100 * day);
|
||
assert_eq!(b - a, 50 * day);
|
||
}
|
||
|
||
#[test]
|
||
fn a_deep_zoom_does_not_collapse_to_nothing() {
|
||
// A zero-width span would make every bucket empty, which reads as a
|
||
// broken axis rather than a deep zoom.
|
||
let full = (0, 86_400);
|
||
let (a, b) = zoomed_span(full, 12, Some(43_200));
|
||
assert!(b > a, "span stays positive");
|
||
}
|
||
|
||
#[test]
|
||
fn a_single_instant_library_does_not_divide_by_zero() {
|
||
let full = (1_700_000_000, 1_700_000_000);
|
||
let (a, b) = zoomed_span(full, 5, None);
|
||
assert!(a <= b);
|
||
}
|
||
|
||
#[test]
|
||
fn month_names_cover_the_year_and_clamp() {
|
||
assert_eq!(month_name(1), "January");
|
||
assert_eq!(month_name(12), "December");
|
||
assert_eq!(month_abbrev(3), "Mar");
|
||
// A corrupt month must not panic the grid.
|
||
assert_eq!(month_name(0), "January");
|
||
assert_eq!(month_name(99), "December");
|
||
}
|
||
|
||
#[test]
|
||
fn civil_dates_round_trip_at_boundaries() {
|
||
// Era-based date maths is silently wrong at year and leap boundaries
|
||
// if the algorithm is transcribed slightly off, and the symptom is an
|
||
// image landing in the wrong histogram bucket.
|
||
assert_eq!(civil_from_unix(0), (1970, 1, 1, 0));
|
||
assert_eq!(civil_from_unix(1_786_285_800), (2026, 8, 9, 14));
|
||
// Leap day.
|
||
assert_eq!(civil_from_unix(1_709_164_800), (2024, 2, 29, 0));
|
||
// Last second of a year, and the first of the next.
|
||
assert_eq!(civil_from_unix(1_735_689_599), (2024, 12, 31, 23));
|
||
assert_eq!(civil_from_unix(1_735_689_600), (2025, 1, 1, 0));
|
||
}
|
||
|
||
#[test]
|
||
fn dates_before_the_epoch_do_not_wrap() {
|
||
// Scanned film carries capture dates well before 1970; a negative
|
||
// timestamp must floor rather than truncate toward zero.
|
||
let (y, _, _, _) = civil_from_unix(-1);
|
||
assert_eq!(y, 1969);
|
||
}
|
||
|
||
/// A bar is named after how long it is.
|
||
///
|
||
/// The axis no longer buckets by calendar unit — it cuts the visible span
|
||
/// into a fixed number of equal bins — so the unit survives only as a
|
||
/// vocabulary for *labelling* one. What this guards is that zooming in
|
||
/// never produces a coarser label: the complaint it came from was a
|
||
/// fortnight drawn as one column of a year-per-bar axis.
|
||
#[test]
|
||
fn a_bin_is_labelled_at_its_own_scale() {
|
||
use dr_catalog::Granularity;
|
||
const DAY: i64 = 86_400;
|
||
|
||
// Fifteen years across 32 bars is nearly six months a bar, which on a
|
||
// ratio measure is nearer a year than a month — so the bars are
|
||
// labelled by year, which is what a fifteen-year axis wants anyway.
|
||
assert_eq!(
|
||
Granularity::for_bucket(15 * 365 * DAY / 32),
|
||
Granularity::Year
|
||
);
|
||
|
||
// A fortnight across the same 32 bars is about ten hours a bar, which
|
||
// is nearer a day than an hour and is labelled as a date.
|
||
assert_eq!(Granularity::for_bucket(14 * DAY / 32), Granularity::Day);
|
||
|
||
// Zoom on until a bar is an hour or two, and it is named by the hour.
|
||
assert_eq!(Granularity::for_bucket(2 * 3600), Granularity::Hour);
|
||
|
||
// The property rather than the thresholds: a shorter bin never gets a
|
||
// coarser label than a longer one.
|
||
let mut previous = i64::MAX;
|
||
for days in [3650, 730, 180, 60, 14, 3, 1] {
|
||
let g = Granularity::for_bucket(days * DAY / 32);
|
||
let bucket = match g {
|
||
Granularity::Year => 365 * DAY,
|
||
Granularity::Month => 30 * DAY,
|
||
Granularity::Day => DAY,
|
||
Granularity::Hour => 3600,
|
||
};
|
||
assert!(
|
||
bucket <= previous,
|
||
"{days} days chose a coarser bin label than the span above it"
|
||
);
|
||
previous = bucket;
|
||
}
|
||
}
|
||
|
||
/// Deleting must not move the view somewhere the user did not ask to be.
|
||
///
|
||
/// The grid draws each cell at its absolute place in the library, and a
|
||
/// delete shortens the library *and* re-clamps the loaded window's offset.
|
||
/// Neither touches the viewport, so before this the grid went blank — the
|
||
/// view left pointing past the end of the content — or appeared to jump,
|
||
/// the same scroll position now addressing different photographs.
|
||
///
|
||
/// The anchor is the last *visible* ordinal the grid reported, clamped into
|
||
/// what is left — not the loaded window's `offset`, which sits about a
|
||
/// quarter of a screen above the view and is itself re-clamped by the
|
||
/// delete. Anchoring on that was the first attempt and still jumped.
|
||
#[test]
|
||
fn a_shorter_library_anchors_the_view_instead_of_losing_it() {
|
||
// The rule `restore_position` applies, stated where it can be checked.
|
||
let anchor = |offset: usize, total: usize| -> Option<usize> {
|
||
if total == 0 {
|
||
None
|
||
} else {
|
||
Some(offset.min(total - 1))
|
||
}
|
||
};
|
||
|
||
// The everyday case: one frame removed from the middle of a wall of
|
||
// twenty thousand. The view does not move — the photograph that slid
|
||
// into the gap is the one you want next.
|
||
assert_eq!(anchor(12_000, 19_999), Some(12_000));
|
||
|
||
// Near the end, which is where the blank came from: the view was
|
||
// showing ordinals past what now exists, so it lands on the last.
|
||
assert_eq!(anchor(19_990, 5), Some(4));
|
||
|
||
// Emptied entirely. Seeking into an empty library would be the same
|
||
// fault in the other direction, so there is nothing to do.
|
||
assert_eq!(anchor(500, 0), None);
|
||
|
||
// The boundary: an offset equal to the new total is one past the end.
|
||
assert_eq!(anchor(10, 10), Some(9));
|
||
}
|
||
|
||
#[test]
|
||
fn bucket_labels_match_their_granularity() {
|
||
use dr_catalog::Granularity;
|
||
let t = 1_786_285_800; // 2026-08-09 14:30 UTC
|
||
assert_eq!(format_bucket(t, Granularity::Year), "2026");
|
||
assert_eq!(format_bucket(t, Granularity::Month), "2026-08");
|
||
assert_eq!(format_bucket(t, Granularity::Day), "2026-08-09");
|
||
assert_eq!(format_bucket(t, Granularity::Hour), "2026-08-09 14:00");
|
||
}
|
||
|
||
#[test]
|
||
fn rgba_shorter_than_declared_does_not_panic() {
|
||
// A truncated decode must degrade to a partial image, not abort the
|
||
// grid. Decoders handle untrusted input (NFR-SEC-1).
|
||
let img = to_slint_image(4, 4, &[0u8; 8]);
|
||
assert_eq!(img.size().width, 4);
|
||
}
|
||
|
||
#[test]
|
||
fn rgba_longer_than_declared_is_truncated() {
|
||
let img = to_slint_image(2, 2, &[255u8; 1024]);
|
||
assert_eq!(img.size().width, 2);
|
||
assert_eq!(img.size().height, 2);
|
||
}
|
||
|
||
// --- moving the loaded window ------------------------------------------
|
||
//
|
||
// A screenful of 120 cells, four of them loaded, out of 24,000.
|
||
|
||
const ON_SCREEN: usize = 120;
|
||
const W: usize = ON_SCREEN * SCREENFULS;
|
||
const TOTAL: usize = 24_000;
|
||
|
||
/// Where the window lands for a view at `first_visible`, as the scroll
|
||
/// handler would leave it.
|
||
fn settle(first_visible: usize) -> usize {
|
||
window_start(first_visible, W, TOTAL)
|
||
}
|
||
|
||
#[test]
|
||
fn a_view_well_inside_the_loaded_window_does_not_move_it() {
|
||
// The whole point of loading screenfuls either side: scrolling within
|
||
// them must not touch the catalog.
|
||
let at = settle(5_000);
|
||
assert_eq!(window_move(5_000, at, W, ON_SCREEN, TOTAL), None);
|
||
assert_eq!(window_move(5_100, at, W, ON_SCREEN, TOTAL), None);
|
||
}
|
||
|
||
#[test]
|
||
fn the_whole_view_stays_inside_the_loaded_window() {
|
||
// The bug this rule exists for: the test used to ask only where the
|
||
// *first* visible cell was, so the window sat still while the bottom
|
||
// of the view hung a quarter of a screenful past the last loaded cell
|
||
// — rows the model does not hold and the grid therefore draws blank.
|
||
let mut at = settle(0);
|
||
for first_visible in (0..TOTAL - ON_SCREEN).step_by(10) {
|
||
if let Some(moved) = window_move(first_visible, at, W, ON_SCREEN, TOTAL) {
|
||
at = moved;
|
||
}
|
||
assert!(
|
||
first_visible >= at && first_visible + ON_SCREEN <= at + W,
|
||
"view {first_visible}..{} is not covered by the window {at}..{}",
|
||
first_visible + ON_SCREEN,
|
||
at + W
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn scrolling_back_a_row_does_not_reload() {
|
||
// The mirror fault, and the more expensive one: the window is placed a
|
||
// quarter of itself behind the view, and the old margin declared the
|
||
// view too close to the top at exactly that distance. So every single
|
||
// row scrolled upward re-read the catalog and rebuilt every cell.
|
||
let at = settle(5_200);
|
||
for row in 1..=4 {
|
||
let first_visible = 5_200 - row * 10;
|
||
assert_eq!(
|
||
window_move(first_visible, at, W, ON_SCREEN, TOTAL),
|
||
None,
|
||
"row {first_visible} reloaded the window it was already inside"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_view_reaching_the_edge_of_the_loaded_window_moves_it() {
|
||
// Far enough down that scrolling on would run into rows nobody has
|
||
// read.
|
||
let at = settle(5_000);
|
||
let far = at + W - ON_SCREEN;
|
||
let moved = window_move(far, at, W, ON_SCREEN, TOTAL).expect("the window follows the view");
|
||
assert_eq!(moved, far - W / 4, "placed a quarter behind the view");
|
||
}
|
||
|
||
#[test]
|
||
fn the_window_moves_about_once_a_screenful() {
|
||
// Too eager is a stutter under the finger, so the rule is checked from
|
||
// both sides: the view must cross most of a screenful between reloads.
|
||
let mut at = settle(0);
|
||
let mut last = 0;
|
||
let mut gaps = Vec::new();
|
||
for first_visible in (0..12_000).step_by(10) {
|
||
if let Some(moved) = window_move(first_visible, at, W, ON_SCREEN, TOTAL) {
|
||
at = moved;
|
||
gaps.push(first_visible - last);
|
||
last = first_visible;
|
||
}
|
||
}
|
||
assert!(
|
||
gaps.iter().skip(1).all(|g| *g >= ON_SCREEN),
|
||
"reloaded after less than a screenful of travel: {gaps:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn the_top_of_the_library_is_not_reloaded_on_every_row() {
|
||
// `first_visible` cannot be placed further back than zero, so the
|
||
// margin test can never be satisfied here. Before this rule every one
|
||
// of these re-read the catalog to arrive at the offset it already had,
|
||
// which is the stutter at the top of every scope.
|
||
for first_visible in [0, 6, 30, 89] {
|
||
assert_eq!(
|
||
window_move(first_visible, 0, W, ON_SCREEN, TOTAL),
|
||
None,
|
||
"row {first_visible} asked for a move to offset 0, which is where it is"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_end_of_the_library_is_not_reloaded_on_every_row() {
|
||
// The mirror of the above, and the worse of the two: the window is
|
||
// clamped to `max_offset` while the view keeps travelling past it.
|
||
let pinned = TOTAL - W;
|
||
for first_visible in [TOTAL - W / 2, TOTAL - 30, TOTAL - 1] {
|
||
assert_eq!(
|
||
window_move(first_visible, pinned, W, ON_SCREEN, TOTAL),
|
||
None,
|
||
"row {first_visible} asked for a move to the offset it already had"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_window_never_starts_past_the_last_full_screenful() {
|
||
// Otherwise a scrub to the very end loads a handful of cells and the
|
||
// rest of the window addresses images that do not exist.
|
||
let moved =
|
||
window_move(TOTAL - 1, 0, W, ON_SCREEN, TOTAL).expect("a scrub to the end moves");
|
||
assert_eq!(moved, TOTAL - W);
|
||
}
|
||
|
||
#[test]
|
||
fn a_library_smaller_than_the_window_stays_at_the_beginning() {
|
||
// `max_offset` is zero, so there is one valid position and the view
|
||
// must never ask for another.
|
||
assert_eq!(window_move(0, 0, W, ON_SCREEN, 40), None);
|
||
assert_eq!(window_move(39, 0, W, ON_SCREEN, 40), None);
|
||
}
|
||
|
||
// --- which cells are fetched first -------------------------------------
|
||
|
||
#[test]
|
||
fn the_visible_cells_are_fetched_before_anything_else() {
|
||
// The window holds a quarter of itself above the view, and in model
|
||
// order those cells — which nobody is looking at — were fetched first,
|
||
// ahead of the whole screen.
|
||
let window: Vec<usize> = (0..W).collect();
|
||
let first_on_screen = W / 4;
|
||
let mut order = window.clone();
|
||
order.sort_by_key(|row| fetch_rank(*row, first_on_screen, ON_SCREEN));
|
||
|
||
assert_eq!(
|
||
&order[..ON_SCREEN],
|
||
&window[first_on_screen..first_on_screen + ON_SCREEN],
|
||
"the screen is not fetched first, in reading order"
|
||
);
|
||
// The bottom row of the grid — the one reported missing — comes before
|
||
// every offscreen cell rather than after all of them.
|
||
let bottom = first_on_screen + ON_SCREEN - 1;
|
||
assert!(
|
||
order.iter().position(|r| *r == bottom).unwrap() < ON_SCREEN,
|
||
"the bottom row of the grid is still behind offscreen cells"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn offscreen_cells_are_fetched_nearest_first_below_before_above() {
|
||
let first_on_screen = W / 4;
|
||
let past = first_on_screen + ON_SCREEN;
|
||
// The row just below the view beats the row just above it, and both
|
||
// beat rows further out on their own side.
|
||
assert!(
|
||
fetch_rank(past, first_on_screen, ON_SCREEN)
|
||
< fetch_rank(first_on_screen - 1, first_on_screen, ON_SCREEN)
|
||
);
|
||
assert!(
|
||
fetch_rank(past, first_on_screen, ON_SCREEN)
|
||
< fetch_rank(past + 1, first_on_screen, ON_SCREEN)
|
||
);
|
||
assert!(
|
||
fetch_rank(first_on_screen - 1, first_on_screen, ON_SCREEN)
|
||
< fetch_rank(0, first_on_screen, ON_SCREEN)
|
||
);
|
||
}
|
||
|
||
// --- carrying thumbnails across a reload -------------------------------
|
||
//
|
||
// The black flash: `load_window` rebuilds every row, and until these it
|
||
// rebuilt them empty — so the three quarters of the grid the user was
|
||
// looking at went to `Theme.ground` and back on every scroll.
|
||
|
||
/// A model of cells, `has_thumb` set for the ids named.
|
||
fn model_of(ids: &[i64], with_pixels: &[i64]) -> slint::ModelRc<LibraryCell> {
|
||
let rows: Vec<LibraryCell> = ids
|
||
.iter()
|
||
.map(|id| LibraryCell {
|
||
has_thumb: with_pixels.contains(id),
|
||
thumbnail: if with_pixels.contains(id) {
|
||
to_slint_image(2, 2, &[255u8; 16])
|
||
} else {
|
||
slint::Image::default()
|
||
},
|
||
..Default::default()
|
||
})
|
||
.collect();
|
||
slint::ModelRc::new(slint::VecModel::from(rows))
|
||
}
|
||
|
||
#[test]
|
||
fn a_reload_keeps_the_pixels_of_photographs_that_are_still_in_the_window() {
|
||
let ids = [10i64, 11, 12];
|
||
let previous = model_of(&ids, &[10, 12]);
|
||
let classes = vec![Some(dr_thumbs::ThumbSize::Grid); 3];
|
||
|
||
let held = hold_thumbnails(&previous, &ids, &classes);
|
||
|
||
assert!(held.contains_key(&10), "a drawn cell is carried");
|
||
assert!(held.contains_key(&12));
|
||
assert!(
|
||
!held.contains_key(&11),
|
||
"a cell still waiting has nothing to carry"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_no_preview_verdict_is_carried_too() {
|
||
// Otherwise every scroll re-asks a question the server has already
|
||
// answered, and the cell blinks from "no preview" back to "…".
|
||
let ids = [7i64];
|
||
let rows = vec![LibraryCell {
|
||
unavailable: true,
|
||
..Default::default()
|
||
}];
|
||
let previous = slint::ModelRc::new(slint::VecModel::from(rows));
|
||
|
||
let held = hold_thumbnails(&previous, &ids, &[Some(dr_thumbs::ThumbSize::Grid)]);
|
||
assert!(held[&7].unavailable);
|
||
assert!(!held[&7].has_thumb);
|
||
}
|
||
|
||
#[test]
|
||
fn a_carried_cell_is_not_fetched_again_but_a_newly_scrolled_in_one_is() {
|
||
// The scroll this describes: the window moved down by one image, so
|
||
// 11 and 12 are still on screen and 13 has just arrived.
|
||
let ids = [10i64, 11, 12];
|
||
let previous = model_of(&ids, &[10, 11, 12]);
|
||
let held = hold_thumbnails(&previous, &ids, &[Some(dr_thumbs::ThumbSize::Grid); 3]);
|
||
|
||
let served = already_served(&held, [11i64, 12, 13].into_iter());
|
||
|
||
assert!(served.contains(&(11, dr_thumbs::ThumbSize::Grid)));
|
||
assert!(served.contains(&(12, dr_thumbs::ThumbSize::Grid)));
|
||
assert!(
|
||
!served.contains(&(13, dr_thumbs::ThumbSize::Grid)),
|
||
"a photograph the window has just reached must still be fetched"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_photograph_scrolled_out_of_the_window_is_fetched_again_on_return() {
|
||
// The trap in keeping the set rather than rebuilding it: image 10 was
|
||
// served once, but the model that held its pixels is long gone, so the
|
||
// cell would sit blank for ever if it still counted as served.
|
||
let held = hold_thumbnails(
|
||
&model_of(&[10], &[10]),
|
||
&[10],
|
||
&[Some(dr_thumbs::ThumbSize::Grid)],
|
||
);
|
||
|
||
let served = already_served(&held, [40i64, 41].into_iter());
|
||
assert!(served.is_empty(), "nothing in this window is already drawn");
|
||
}
|
||
|
||
#[test]
|
||
fn a_carried_thumbnail_does_not_satisfy_a_zoom_past_its_class() {
|
||
// Zooming past 256px reloads the window. The cell keeps showing the
|
||
// small thumbnail — no flash — but the large one must still be asked
|
||
// for, or the grid would stay soft until something else forced a fetch.
|
||
let held = hold_thumbnails(
|
||
&model_of(&[5], &[5]),
|
||
&[5],
|
||
&[Some(dr_thumbs::ThumbSize::Grid)],
|
||
);
|
||
let mut served = already_served(&held, [5i64].into_iter());
|
||
|
||
assert!(
|
||
served.insert((5, dr_thumbs::ThumbSize::Large)),
|
||
"the large class is still unserved"
|
||
);
|
||
assert!(
|
||
!served.insert((5, dr_thumbs::ThumbSize::Grid)),
|
||
"and the small one is not asked for twice"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_cell_whose_class_was_never_recorded_is_fetched_again() {
|
||
// Pixels with no class are pixels from before this bookkeeping existed
|
||
// — or from a row the drain never reached. Showing them is right;
|
||
// claiming they were served is not, because nothing knows at what size.
|
||
let held = hold_thumbnails(&model_of(&[5], &[5]), &[5], &[None]);
|
||
assert!(held.contains_key(&5), "still drawn");
|
||
assert!(already_served(&held, [5i64].into_iter()).is_empty());
|
||
}
|
||
|
||
/// A thumbnail drain must be able to tell that the window it was started
|
||
/// for has been replaced.
|
||
///
|
||
/// This is the whole of the black-grid fix, reduced to the comparison the
|
||
/// timer callback makes. `row` is an index into the window that requested
|
||
/// the fetch, so a drain that keeps writing after a reload paints
|
||
/// thumbnails onto unrelated photographs — and, worse, its `stop` lands on
|
||
/// the *current* batch's timer and leaves the new fetches undrained.
|
||
#[test]
|
||
fn a_reload_makes_an_in_flight_thumbnail_batch_stale() {
|
||
let ctl = LibraryController::new(crate::activity::ActivityLog::new());
|
||
|
||
// What `drain_thumbnails` captures when the batch is spawned.
|
||
let mine = ctl.generation.get();
|
||
assert_eq!(ctl.generation.get(), mine, "its own batch is live");
|
||
|
||
// What `load_window` does just before swapping the model.
|
||
ctl.generation.set(ctl.generation.get().wrapping_add(1));
|
||
|
||
assert_ne!(
|
||
ctl.generation.get(),
|
||
mine,
|
||
"the batch must recognise itself as stale once the model is replaced"
|
||
);
|
||
}
|
||
|
||
/// Each load is distinct, so two reloads cannot alias back to a live batch.
|
||
#[test]
|
||
fn every_window_load_takes_a_fresh_generation() {
|
||
let ctl = LibraryController::new(crate::activity::ActivityLog::new());
|
||
let seen: Vec<u64> = (0..4)
|
||
.map(|_| {
|
||
let g = ctl.generation.get();
|
||
ctl.generation.set(g.wrapping_add(1));
|
||
g
|
||
})
|
||
.collect();
|
||
|
||
assert_eq!(seen, vec![0, 1, 2, 3]);
|
||
}
|
||
|
||
/// The prefetch order is the walking order: closest first, next before
|
||
/// previous at every distance, and the window's edges cut it short.
|
||
#[test]
|
||
fn neighbours_are_listed_closest_first_working_outwards() {
|
||
let ctl = LibraryController::new(crate::activity::ActivityLog::new());
|
||
*ctl.paths.borrow_mut() = ["a", "b", "c", "d", "e", "f"].map(String::from).to_vec();
|
||
|
||
assert_eq!(ctl.neighbours_of("c", 2), vec!["d", "b", "e", "a"]);
|
||
assert_eq!(
|
||
ctl.neighbours_of("c", 10),
|
||
vec!["d", "b", "e", "a", "f"],
|
||
"past an edge the other side keeps going"
|
||
);
|
||
assert_eq!(
|
||
ctl.neighbours_of("a", 1),
|
||
vec!["b"],
|
||
"nothing before the first"
|
||
);
|
||
assert_eq!(
|
||
ctl.neighbours_of("f", 1),
|
||
vec!["e"],
|
||
"nothing after the last"
|
||
);
|
||
assert!(
|
||
ctl.neighbours_of("c", 0).is_empty(),
|
||
"zero fetches nothing ahead"
|
||
);
|
||
assert!(
|
||
ctl.neighbours_of("z", 3).is_empty(),
|
||
"a photograph outside the window has no neighbours to fetch"
|
||
);
|
||
}
|
||
|
||
/// A controller holding a catalog of four named images.
|
||
fn with_catalog() -> Rc<LibraryController> {
|
||
let ctl = LibraryController::new(crate::activity::ActivityLog::new());
|
||
let catalog = Catalog::in_memory().expect("in-memory catalog");
|
||
let c = catalog.connection();
|
||
c.execute(
|
||
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')",
|
||
[],
|
||
)
|
||
.expect("root");
|
||
for (id, name) in [(1i64, "a.CR2"), (2, "b.CR2"), (3, "c.CR2"), (4, "d.CR2")] {
|
||
c.execute(
|
||
"INSERT INTO images(id, root_id, source_ref, added_at) VALUES (?1, 1, ?2, 0)",
|
||
rusqlite::params![id, name],
|
||
)
|
||
.expect("image");
|
||
}
|
||
*ctl.catalog.borrow_mut() = Some(catalog);
|
||
ctl
|
||
}
|
||
|
||
/// Just the names, for assertions that are about order rather than ids.
|
||
fn named(ctl: &Rc<LibraryController>, images: &[dr_types::ImageId]) -> Vec<String> {
|
||
ctl.selected_image_paths(images)
|
||
.into_iter()
|
||
.map(|(_, path)| path)
|
||
.collect()
|
||
}
|
||
|
||
/// TRACES: FR-EXP-7
|
||
#[test]
|
||
fn a_selection_resolves_to_paths_in_the_order_it_was_given() {
|
||
// `IN (…)` returns rows in whatever order suits SQLite, and an export's
|
||
// `{seq}` token counts through the batch — so a lookup that handed back
|
||
// the database's order would number the photographs in an order the
|
||
// user never saw.
|
||
let ctl = with_catalog();
|
||
let paths = named(&ctl, &[dr_types::ImageId(3), dr_types::ImageId(1)]);
|
||
assert_eq!(paths, vec!["c.CR2", "a.CR2"]);
|
||
}
|
||
|
||
// --- what the status line says about a keyword (FR-CAT-5) -------------
|
||
//
|
||
// Split out from the callback for the same reason `decide_drop` is: the
|
||
// sheet cannot be driven from a test, and this is the part that can
|
||
// actually mislead someone.
|
||
|
||
/// TRACES: FR-CAT-5
|
||
#[test]
|
||
fn a_partly_applied_keyword_reports_the_honest_count() {
|
||
// Nine of the twelve already had it. Claiming twelve is how a user
|
||
// learns that the counts are decorative.
|
||
assert_eq!(
|
||
keyword_summary("puffin", 3, 12, true),
|
||
"Added “puffin” to 3 of 12"
|
||
);
|
||
}
|
||
|
||
/// TRACES: FR-CAT-5
|
||
#[test]
|
||
fn a_keyword_that_changed_nothing_says_so_rather_than_claiming_success() {
|
||
assert_eq!(
|
||
keyword_summary("puffin", 0, 12, true),
|
||
"Every selected photograph already had “puffin”"
|
||
);
|
||
assert_eq!(
|
||
keyword_summary("puffin", 0, 12, false),
|
||
"None of the selected photographs had “puffin”"
|
||
);
|
||
}
|
||
|
||
/// TRACES: FR-CAT-5
|
||
#[test]
|
||
fn one_photograph_is_singular() {
|
||
// "Added to 1 photographs" is the kind of small wrongness that makes
|
||
// the rest of the interface look unfinished.
|
||
assert_eq!(
|
||
keyword_summary("puffin", 1, 1, true),
|
||
"Added “puffin” to 1 photograph"
|
||
);
|
||
assert_eq!(
|
||
keyword_summary("puffin", 2, 2, true),
|
||
"Added “puffin” to 2 photographs"
|
||
);
|
||
}
|
||
|
||
/// TRACES: FR-CAT-5
|
||
#[test]
|
||
fn removing_a_keyword_reads_as_removal() {
|
||
assert_eq!(
|
||
keyword_summary("blurry", 4, 4, false),
|
||
"Removed “blurry” from 4 photographs"
|
||
);
|
||
}
|
||
|
||
/// TRACES: FR-CAT-5
|
||
#[test]
|
||
fn typing_a_word_with_nothing_selected_says_what_it_did_do() {
|
||
// It builds the vocabulary, which is a legitimate thing to do ahead of
|
||
// a shoot — so it must not report itself as having keyworded nothing.
|
||
assert_eq!(
|
||
keyword_summary("puffin", 0, 0, true),
|
||
"Added “puffin” to the keyword list"
|
||
);
|
||
}
|
||
|
||
/// TRACES: FR-EXP-7
|
||
#[test]
|
||
fn a_selection_outside_the_loaded_window_still_resolves() {
|
||
// The property the catalog lookup exists for. Selection is by id and
|
||
// survives a scrub, so a selection made before scrolling routinely
|
||
// names photographs no row holds — and `paths`, the loaded window, is
|
||
// empty here precisely to prove nothing is being read from it.
|
||
let ctl = with_catalog();
|
||
assert!(ctl.paths.borrow().is_empty());
|
||
assert_eq!(named(&ctl, &[dr_types::ImageId(4)]), ["d.CR2"]);
|
||
}
|
||
|
||
/// TRACES: FR-EXP-7
|
||
#[test]
|
||
fn an_image_that_vanished_under_the_selection_is_skipped() {
|
||
// Deleted between the selection and the click. There is nothing to
|
||
// export and nothing to fix, so it drops out rather than becoming a
|
||
// failure row the user can do nothing about.
|
||
let ctl = with_catalog();
|
||
let paths = named(
|
||
&ctl,
|
||
&[
|
||
dr_types::ImageId(1),
|
||
dr_types::ImageId(99),
|
||
dr_types::ImageId(2),
|
||
],
|
||
);
|
||
assert_eq!(paths, vec!["a.CR2", "b.CR2"]);
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-UI-2
|
||
/// A filename as a caption: without the part that says how it is stored.
|
||
///
|
||
/// A grid cell is about four words wide, and `.CR2` spends one of them saying
|
||
/// something the photographer already knows — every frame in a RAW library
|
||
/// ends the same way, so the extension distinguishes nothing while taking
|
||
/// room from the part that does. The name is elided under pressure, and it is
|
||
/// the *end* that goes, so an extension can push the digits that identify a
|
||
/// frame off the visible part of its own label.
|
||
///
|
||
/// Display only. `LibraryCell::name` keeps the true filename and
|
||
/// `remote_path` the full path, because both are used to find the file again
|
||
/// and a stem is not a filename.
|
||
///
|
||
/// **The case this is wrong for**, worth knowing before it is reported: a
|
||
/// library holding `IMG_1234.CR2` beside `IMG_1234.JPG` shows two cells
|
||
/// captioned `IMG_1234`. They are still two rows with two thumbnails and two
|
||
/// entries in the info panel, and RAW+JPEG pairs are usually shot to be one
|
||
/// photograph anyway — but the caption alone no longer separates them.
|
||
fn without_extension(name: &str) -> &str {
|
||
match name.rsplit_once('.') {
|
||
// The guard is for a leading dot: `.hidden` splits to an empty stem,
|
||
// and a dotfile's name starts with that dot rather than ending with an
|
||
// extension. A *trailing* dot needs no guard — `odd.` splits to `odd`,
|
||
// which is the better caption anyway.
|
||
Some((stem, _)) if !stem.is_empty() => stem,
|
||
_ => name,
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod display_name_tests {
|
||
use super::without_extension;
|
||
|
||
#[test]
|
||
fn an_extension_is_dropped_and_nothing_else_is() {
|
||
assert_eq!(without_extension("IMG_1234.CR2"), "IMG_1234");
|
||
assert_eq!(without_extension("IMG_1234.jpeg"), "IMG_1234");
|
||
// Only the last one: a name can contain dots and they are part of it.
|
||
assert_eq!(without_extension("2026.08.23-a.dng"), "2026.08.23-a");
|
||
// Nothing to drop.
|
||
assert_eq!(without_extension("IMG_1234"), "IMG_1234");
|
||
// A dotfile is not an extensionless name with an extension.
|
||
assert_eq!(without_extension(".hidden"), ".hidden");
|
||
// A trailing dot is an empty extension, and dropping it is right.
|
||
assert_eq!(without_extension("odd."), "odd");
|
||
assert_eq!(without_extension(""), "");
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-CAT-6
|
||
/// Show the range the filter is actually using.
|
||
///
|
||
/// Pushed back rather than left as the user typed it, because the filter may
|
||
/// have adjusted it — ends given backwards are swapped, and the closing day is
|
||
/// extended to include itself. A field showing something other than what is
|
||
/// being filtered on is worse than one showing nothing.
|
||
fn show_range(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
let f = ctl.filter.borrow();
|
||
let text = |t: Option<i64>| -> slint::SharedString {
|
||
t.map(dr_types::format_date).unwrap_or_default().into()
|
||
};
|
||
window.set_library_range_from(text(f.captured_from));
|
||
// The stored end is the last second of the closing day; naming that day is
|
||
// what the user typed and what they should read back.
|
||
window.set_library_range_to(text(f.captured_to.map(|t| t - 86_400 + 1)));
|
||
}
|