//! Library state for the running window, and the window-sizing constants //! that decide how much of the catalog is loaded. //! //! `LibraryController` is the piece every other module in this directory //! borrows from, so its fields are `pub(super)` rather than reached through //! accessor methods: the area of behaviour that touches one field — loading, //! the timeline, ratings, keywords — owns its own file instead of all of it //! living beside the fields it reads. See `docs/dev/code-health.md` CH-1. use std::cell::RefCell; use std::path::PathBuf; use std::rc::Rc; use dr_catalog::Catalog; use dr_sync::Connection; use dr_types::FormatFilter; use crate::library; /// 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. pub(super) 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. pub(super) 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. pub(super) const MIN_CELL_SIZE: f32 = 90.0; pub(super) 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)] pub(super) struct LibraryFacts { pub(super) scope: Option, pub(super) filter: library::RatingFilter, pub(super) trash: bool, pub(super) 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. pub(super) catalog: Rc>>, /// Remote paths for the rows currently in the model, parallel to it. pub(super) paths: RefCell>, /// `oc:fileid` and file length per row, parallel to the model. pub(super) file_ids: RefCell>>, pub(super) sizes: RefCell>, /// Catalog row ids and whether each still needs its EXIF read. pub(super) image_ids: RefCell>, pub(super) needs_metadata: RefCell>, /// When each row in the model was taken, parallel to it. /// /// Kept so the timeline marker can be moved from the window the grid has /// already read rather than from a query per scroll event — see /// [`capture_time_at`]. pub(super) captured_at: RefCell>>, /// The size class of the pixels each row is currently showing, `None` for a /// row still waiting. /// /// Parallel to the model, and the reason [`load_window`] can carry a /// thumbnail across a reload without re-deciding whether it is sharp /// enough: a cell holding the 256px class while the grid has since been /// zoomed past that must keep showing what it has *and* still ask for the /// large one. Recording the class the pixels came from is what tells those /// two states apart — without it a carried thumbnail either blocks the /// sharper fetch forever or is re-fetched on every scroll. pub(super) thumb_class: RefCell>>, /// Where in the catalog the current window starts. Scrubbing moves this. pub(super) offset: RefCell, /// The first visible ordinal, kept so returning from the develop view lands /// where the user left rather than at the top. /// /// Recorded when the grid is left, not read back when it is re-entered: /// `show-library` gates an `if` in the markup, so the whole grid subtree is /// destroyed and rebuilt, and the rebuilt `Flickable` reports a scroll to /// row 0 before anything can restore the old position. Capturing at the /// moment of departure is the only value the reset cannot overwrite. /// /// Separate from `offset`, which is the *loaded window*'s start and sits a /// quarter-window above the view. Restoring that as a viewport position /// would land the user consistently short of where they were. pub(super) resume_at: std::cell::Cell, /// The photograph open in develop, by catalog id, when it was opened from /// this library. /// /// **An id, not a row of the loaded window.** The roll marks a row and the /// keyboard steps from one, but a row only names a photograph until the /// window is next re-read — a step past its edge, a background sync, a /// judgement that drops the frame out of a filter. Each reload finds this /// id again in the new window (see `window::follow_open`) and puts the /// roll's mark, and the ordinal `index` reports, back on it. pub(super) roll_open: std::cell::Cell>, /// The open photograph was not in the window last read, although the /// ordinal it held still was: it has left the grid — a judgement under a /// filter, most often — and the photograph after it has moved up into its /// place. A step forward is then onto that ordinal, not past it, or the /// frame that closed the gap would be skipped. pub(super) roll_left: std::cell::Cell, /// How many cells to load, derived from what the viewport can show. /// /// A fixed count is wrong in both directions: too small on a maximised 4K /// window, wasteful on a narrow one. The grid measures itself and reports /// its screenful; this is [`SCREENFULS`] of them. pub(super) window: RefCell, /// What the whole-library readouts on screen were last computed for, so a /// window that merely moved does not recompute them. See [`LibraryFacts`]. pub(super) library_facts: RefCell>, /// 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. pub(super) viewport_cells: std::cell::Cell, /// Which rows already have a thumbnail fetch issued, so scrolling back /// does not refetch what is already on screen. /// Which images have a fetch in flight or already served, keyed on /// `(image_id, size class)`. /// /// **Identity, not row index.** The grid is a window over the catalog, so /// row 7 means a different photograph after every scroll — a set of /// indices had to be cleared on each window move, which made every visible /// cell look unrequested and re-issued the whole screenful on every /// scroll. /// /// The size class is part of the key because the two resolutions are /// fetched independently: holding the 256px one says nothing about whether /// the large one has been asked for. pub(super) requested: RefCell>, /// 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. pub(super) catalog_timer: RefCell>, pub(super) scan_timer: RefCell>, pub(super) thumb_timer: RefCell>, /// The whole-library sweep, which outlives any one grid window. pub(super) sweep_timer: RefCell>, /// TRACES: FR-CAT-3 /// Drains the whole-library thumbnail pass. Held for the same reason as /// the others, and read as well as written: it is what stops a second /// press of the button starting a pass alongside the first. pub(super) thumb_sweep_timer: RefCell>, /// Pushing shards and the catalog to the server. pub(super) sync_timer: RefCell>, /// 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. pub(super) session: RefCell>, /// Which collection narrows the grid, owned by [`crate::collections_ui`] /// and read here. Shared rather than passed per call because a rescan, a /// scrub and a drop all reload the window and must all honour it. pub(super) scope: RefCell>, /// TRACES: FR-EXP-10 /// Which album narrows the grid instead, owned by /// [`crate::collections_ui`] beside `scope`. At most one of the two is set: /// the sidebar has one selection. pub(super) album: std::cell::Cell>, /// 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. pub(super) open_image: RefCell>, /// 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. pub(super) place_store: RefCell>, /// 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. pub(super) place_device: RefCell, /// Coalesces the writes a flick would otherwise make one of per event. pub(super) place_timer: RefCell>, /// 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. pub(super) place_fetch_timer: RefCell>, /// 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. pub(super) applying_place: std::cell::Cell, /// 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. pub(super) place_untouched: std::cell::Cell, /// 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. pub(super) viewing_trash: std::cell::Cell, /// The selection's owner, so a rebuilt window can restore the `selected` /// flags it just cleared. /// /// `Weak` because the two controllers outlive each other only through the /// window, and an `Rc` both ways would leak both. Set once at wiring; a /// `None` here means the grid simply draws nothing selected, which is the /// old behaviour rather than a crash. pub(super) coll_ctl: RefCell>>, /// Timeline view state: how far zoomed in, and around what instant. /// /// Zoom is a level rather than a span so the axis halves and doubles in /// even steps; the centre is what keeps the thing you were looking at in /// view as you zoom. pub(super) timeline_zoom: RefCell, pub(super) timeline_centre: RefCell>, /// Pinch ratio accumulated since the last zoom step was taken. /// /// A pinch is continuous and zoom levels are discrete, so the ratio is /// held until it reaches a doubling. Without it a slow spread would either /// do nothing or, if each update were rounded, leap several levels. pub(super) pinch_accum: RefCell, /// 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. pub(super) current_bucket: RefCell>, /// What the rating filter bar is narrowed to. /// /// Held here beside `scope` and for the same reason: a scrub, a rescan and /// a drop all reload the window, and every one of them must honour it or /// the filter silently lapses. pub(super) filter: RefCell, /// TRACES: FR-CAT-9 /// Drains the outbox uploader. Held so that a repaint while one is /// already running does not start a second against the same channel — /// this handle *is* the "a drain is in flight" state. pub(super) outbox_timer: RefCell>, /// Whether the outbox might hold something, so the common case costs a /// boolean rather than a directory walk. /// /// `refresh_offline` runs on scan progress as well as on a genuine /// connectivity change, so the drain is *asked* far more often than there /// is anything to send. Starts `true` so the first ask after launch does /// walk — edits queued in a previous session are exactly the ones that /// need sending, and nothing in memory knows about them. pub(super) outbox_maybe_dirty: std::cell::Cell, /// Drains the sidecar writer. Held so a second judgement replaces the /// timer rather than leaving two draining the same finished channel. pub(super) sidecar_timer: RefCell>, /// TRACES: FR-CAT-13 /// The same, for a batch of XMP writes or a reload. pub(super) xmp_timer: RefCell>, /// Which window load the model belongs to, bumped by [`load_window`]. /// /// A thumbnail worker addresses cells by *row index into the window that /// asked for it*. A reload replaces the model, so once the generation has /// moved on every row still in flight names a different photograph — /// applying it paints thumbnails onto unrelated cells, or marks a cell /// "no preview" for a fetch never attempted against it. Worse, a stale /// drain reaching `Disconnected` would call `stop` on `thumb_timer`, which /// by then holds the *current* batch's timer: the new worker then fetched /// into a channel nobody drained and the grid stayed black until a scroll /// forced another load. /// /// A `Cell` rather than a `RefCell`: it is read inside a timer callback /// that already holds borrows of other fields, and a `u64` needs no borrow /// tracking. pub(super) generation: std::cell::Cell, /// TRACES: FR-CAT-9 /// Whether the server is reachable, inferred from what the workers saw. /// /// Lives on the controller rather than in a global because it is scoped to /// one open library: signing into a different account starts a fresh /// judgement, and carrying the old one over would report a server down /// that was never contacted. pub(super) reachability: RefCell, /// 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. pub(super) root_lost: RefCell>, /// TRACES: FR-NC-6a /// Drains the pin downloader. Held so a second pin replaces the timer /// rather than leaving two draining the same finished channel. /// Coalesces the reloads a run of geometry changes would otherwise each /// demand — see [`schedule_reload`]. pub(super) geometry_timer: RefCell>, /// The ordinal the view was on when the current run of geometry changes /// began, so the settle returns to the photograph the user was looking at /// rather than to wherever the re-flow left the viewport. pub(super) pending_anchor: std::cell::Cell>, pub(super) pin_timer: RefCell>, /// TRACES: FR-NC-6a /// Which collection the offline question is being asked about. /// /// Held rather than passed through the window because the prompt's three /// answers arrive as three separate callbacks, and a dialogue that read its /// subject back out of a string property would act on whatever the sidebar /// had been rebuilt to say since. pub(super) offline_target: std::cell::Cell>, /// 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. pub(super) local_only: std::cell::Cell, /// TRACES: FR-NC-6a /// The ceiling on passively cached originals, from the settings page. /// /// Held here rather than read from `SettingsStore` at each use because the /// workers that need it run on threads with no config access — the same /// reason [`library::CacheContext`] takes it as a field. A `Cell` because /// the settings page can move it while a library is open, and the next /// fetch must use the new figure rather than one captured at open. pub(super) cache_budget: std::cell::Cell, /// TRACES: FR-NC-6a /// Whether opening an image in develop keeps its original on disk. /// /// Held beside the budget and for the same reason. Distinct from a zero /// budget: this switches the passive population off entirely, while a /// small budget still keeps a working set. A metered or small-disk device /// wants the first. pub(super) keep_opened: std::cell::Cell, /// 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. pub(super) fetch_ahead: std::cell::Cell, /// TRACES: FR-CAT-13 | NFR-R4 /// Whether judgements and keywords also go to the `.xmp` beside the /// original. Mirrors `LibrarySettings::write_xmp_sidecars`. pub(super) write_xmp: std::cell::Cell, /// TRACES: FR-CAT-6 /// How many bars the capture-time axis is cut into, from the settings /// page. /// /// Held here beside the cache budget and for the same reason: the axis is /// redrawn from a dozen callbacks that have no config access, and reading /// a JSON file on every scroll to answer "how many bars" would be the /// wrong shape of question. A `Cell` because the page can change it while /// a library is open. pub(super) timeline_bars: std::cell::Cell, /// 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. pub(super) face_model_id: RefCell, /// 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. pub(super) activity: Rc, } impl LibraryController { pub fn new(activity: Rc) -> Rc { Rc::new(Self { activity, catalog: Rc::new(RefCell::new(None)), paths: RefCell::new(Vec::new()), file_ids: RefCell::new(Vec::new()), sizes: RefCell::new(Vec::new()), image_ids: RefCell::new(Vec::new()), needs_metadata: RefCell::new(Vec::new()), captured_at: RefCell::new(Vec::new()), thumb_class: RefCell::new(Vec::new()), offset: RefCell::new(0), resume_at: std::cell::Cell::new(0), roll_open: std::cell::Cell::new(None), roll_left: std::cell::Cell::new(false), 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), album: std::cell::Cell::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( crate::inference::model_id(dr_types::FaceDetector::default()).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) { 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 { 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 /// What the grid already holds for `path`: the thumbnail its cell is /// drawing, if it has one yet, and the file's length as the scan recorded /// it. /// /// For the develop view while the original comes down. The thumbnail is /// the one already decoded for the grid — no store read, no decode — and /// the length gives the progress bar a denominator when the server sends /// none of its own. pub fn preview_for_path( &self, window: &crate::AppWindow, path: &str, ) -> (Option, Option) { use slint::{ComponentHandle as _, Model as _}; let Some(row) = self.paths.borrow().iter().position(|p| p == path) else { return (None, None); }; let size = self.sizes.borrow().get(row).copied().filter(|&s| s > 0); let thumbnail = window .global::() .get_library_cells() .row_data(row) .filter(|c| c.has_thumb) .map(|c| c.thumbnail); (thumbnail, size) } /// 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 { 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 { let image = self.image_id_for_path(path)?; let borrow = self.catalog.borrow(); let catalog = borrow.as_ref()?; let conn = catalog.connection(); let id = match dr_catalog::rating::default_version_id(conn, image) { Ok(id) => id, Err(e) => { log::debug!("no version for {path}: {e}"); return None; } }; conn.query_row("SELECT uuid FROM versions WHERE id = ?1", [id], |r| { r.get(0) }) .ok() } /// TRACES: FR-CAT-9 | FR-NC-10 /// Where cached sidecars and the upload outbox live for the open library. /// /// Beside the catalog and the originals cache, for the same reason those /// two sit together: all three are per-account and are discarded together. /// A separate directory rather than a subfolder of `originals` because the /// two have opposite lifetimes — originals are evicted under a budget /// (FR-NC-6a), and a queued edit must never be. pub fn sidecar_cache_dir(&self) -> Option { let borrow = self.session.borrow(); let (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 { 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 { let dir = self.cache_dir()?; match dr_catalog::Cache::open(&dir, self.cache_budget.get()) { Ok(c) => Some(c), Err(e) => { // Not fatal: without a cache every open is a download, which // is exactly the behaviour that existed before this. log::warn!("originals cache unavailable: {e}"); None } } } /// TRACES: FR-NC-6a /// Everything a full fetch needs to read and write the cache. /// /// Assembled here because the develop callback holds only a path, while /// the cache is keyed on `ImageId` and lives beside a catalog whose /// location comes from the session. `None` where any part is missing — a /// grid row that has scrolled away, or no library open — and the fetch /// then simply goes to the network. pub fn cache_context(&self, path: &str) -> Option { self.cache_context_for(self.image_id_for_path(path)?) } /// TRACES: FR-EXP-7 | FR-NC-6a /// The same, for an image named by id rather than by path. /// /// A batch export needs this one: its selection is by catalog id and may /// include photographs that have scrolled out of the loaded window, where /// [`Self::image_id_for_path`] has nothing to match against. Going through /// the path would quietly hand those images no cache at all, and a batch of /// three hundred would re-download every one of them. pub fn cache_context_for(&self, image: dr_types::ImageId) -> Option { let borrow = self.session.borrow(); let (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>> { 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 { 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 { self.image_ids .borrow() .iter() .map(|id| dr_types::ImageId(*id as u64)) .collect() } /// TRACES: FR-CAT-5 /// Catalog ids of grid rows `first..=last`, loaded or not. /// /// The counterpart to [`Self::visible_ids`], and the reason both exist: a /// shift-click names a run by its two ends, and everything between them is /// usually off screen. Answered from the catalog, through the same scope, /// filter and ordering this controller reads its window with — so the run /// is the run the user can see themselves selecting, continued past the /// edge of what has been loaded. /// /// Empty before a library is open, and empty if the query fails: a /// selection gesture is not worth an error dialog, and the caller keeps /// what was already selected. pub fn ids_in_span(&self, first: usize, last: usize) -> Vec { let borrow = self.catalog.borrow(); let Some(catalog) = borrow.as_ref() else { return Vec::new(); }; library::read_ids_span( catalog, self.grid_scope(), &self.filter.borrow(), self.viewing_trash.get(), first, last, ) .unwrap_or_else(|e| { log::warn!("reading the selected range: {e}"); Vec::new() }) } /// TRACES: FR-EXP-7 /// Remote paths for a set of selected images, in the order they were given. /// /// Answered from the catalog rather than from `paths`, which is only the /// loaded window. Selection is by id precisely so that it survives a scrub /// (see [`crate::collections_ui`]), so a selection made before scrolling /// routinely names photographs no row currently holds — and an export that /// silently dropped those would be worse than one that refused. /// /// An id the catalog has never heard of is skipped rather than reported: it /// can only mean the image was deleted between the selection and the click, /// and there is nothing to export and nothing to fix. /// /// Each path comes back beside the id it belongs to, because that skipping /// means the ids that come out are not the ids that went in — and the caller /// still has to find each photograph's cache, which is keyed on the id. pub fn selected_image_paths( &self, images: &[dr_types::ImageId], ) -> Vec<(dr_types::ImageId, String)> { if images.is_empty() { return Vec::new(); } let borrow = self.catalog.borrow(); let Some(catalog) = borrow.as_ref() else { return Vec::new(); }; let placeholders = std::iter::repeat_n("?", images.len()) .collect::>() .join(","); let sql = format!("SELECT id, source_ref FROM images WHERE id IN ({placeholders})"); let params: Vec = images .iter() .map(|i| rusqlite::types::Value::Integer(i.0 as i64)) .collect(); let Ok(mut stmt) = catalog.connection().prepare(&sql) else { return Vec::new(); }; let Ok(rows) = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| { Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)) }) else { return Vec::new(); }; // `IN` returns rows in whatever order suits SQLite, and the export's // `{seq}` token counts through the batch — so the answer is put back // into the order the caller asked in rather than the one it arrived in. let found: std::collections::HashMap = rows.flatten().collect(); images .iter() .filter_map(|id| found.get(&(id.0 as i64)).map(|path| (*id, path.clone()))) .collect() } /// TRACES: FR-EXP-7 /// The selection, addressed the way an export worker can fetch it. /// /// Assembled here because a worker thread can reach neither the catalog nor /// the session, and both are needed to say where a cached original lives. pub fn export_sources(&self, images: &[dr_types::ImageId]) -> Vec { self.export_sources_with_ids(images).0 } /// TRACES: FR-EXP-10 /// [`Self::export_sources`], and the image each source is, in the same /// order — what an album records its files against. pub fn export_sources_with_ids( &self, images: &[dr_types::ImageId], ) -> (Vec, Vec>) { self.selected_image_paths(images) .into_iter() .map(|(id, path)| { ( crate::export::Source::Library { path, cache: self.cache_context_for(id), }, Some(id), ) }) .unzip() } /// 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 { self.session.borrow().as_ref().map(|(c, _)| c.clone()) } /// What the grid is narrowed to, collection or album. Every reader of the /// grid's images goes through this rather than `scope`, so an album is /// honoured by the count, the cells, the timeline and a shift-click alike. pub(crate) fn grid_scope(&self) -> Option { match (self.album.get(), *self.scope.borrow()) { (Some(album), _) => Some(library::Scope::Album(album)), (None, Some(c)) => Some(library::Scope::Collection(c)), (None, None) => None, } } /// TRACES: FR-EXP-10 /// Narrow the grid to the photographs behind an album's files. /// /// Clears the collection scope and the trash: the sidebar has one /// selection, and an album is not inside either. pub fn set_album(&self, album: Option) { self.album.set(album); if album.is_some() { *self.scope.borrow_mut() = None; self.viewing_trash.set(false); } *self.offset.borrow_mut() = 0; self.requested.borrow_mut().clear(); } /// Narrow the grid to a collection, or to the whole library with `None`. pub fn set_scope(&self, scope: Option) { *self.scope.borrow_mut() = scope; self.album.set(None); // 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.album.set(None); } *self.offset.borrow_mut() = 0; self.requested.borrow_mut().clear(); } } /// 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>>` written out twice is a /// signature nobody reads. pub(super) type OpenImage = Rc; pub(super) fn stop(slot: &RefCell>) { if let Some(t) = slot.borrow().as_ref() { t.stop(); } } #[cfg(test)] mod tests { use super::*; /// 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 { let ctl = LibraryController::new(crate::activity::ActivityLog::new()); let catalog = Catalog::in_memory().expect("in-memory catalog"); let c = catalog.connection(); c.execute( "INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')", [], ) .expect("root"); for (id, name) in [(1i64, "a.CR2"), (2, "b.CR2"), (3, "c.CR2"), (4, "d.CR2")] { c.execute( "INSERT INTO images(id, root_id, source_ref, added_at) VALUES (?1, 1, ?2, 0)", rusqlite::params![id, name], ) .expect("image"); } *ctl.catalog.borrow_mut() = Some(catalog); ctl } /// Just the names, for assertions that are about order rather than ids. fn named(ctl: &Rc, images: &[dr_types::ImageId]) -> Vec { ctl.selected_image_paths(images) .into_iter() .map(|(_, path)| path) .collect() } /// TRACES: FR-EXP-7 #[test] fn a_selection_resolves_to_paths_in_the_order_it_was_given() { // `IN (…)` returns rows in whatever order suits SQLite, and an export's // `{seq}` token counts through the batch — so a lookup that handed back // the database's order would number the photographs in an order the // user never saw. let ctl = with_catalog(); let paths = named(&ctl, &[dr_types::ImageId(3), dr_types::ImageId(1)]); assert_eq!(paths, vec!["c.CR2", "a.CR2"]); } /// 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"]); } }