The develop view reported a remote original on its way through the error message, so it read "Could not load image" over "Downloading…". It did so on every step along the roll, including a cached frame that was ready within a tick, so each step flashed the error. Waiting is now its own state. On the step, the grid's thumbnail of the photograph stands in at once. Only when a transfer is really on the wire does it dim under "Not on this device yet", with a line like "Downloading — 12.4 of 38.0 MB" and a progress bar. The bytes come from a new RemoteBackend::get_reporting. The Nextcloud backend overrides it to read the body chunk by chunk; the default reports once at the end. Progress is kept in the in-flight registry by path, because a step usually lands on a frame the prefetcher is already fetching. The catalog's file length stands in when the server sends no Content-Length.
1092 lines
51 KiB
Rust
1092 lines
51 KiB
Rust
//! 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<dr_types::CollectionId>,
|
|
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<RefCell<Option<Catalog>>>,
|
|
/// Remote paths for the rows currently in the model, parallel to it.
|
|
pub(super) paths: RefCell<Vec<String>>,
|
|
/// `oc:fileid` and file length per row, parallel to the model.
|
|
pub(super) file_ids: RefCell<Vec<Option<u64>>>,
|
|
pub(super) sizes: RefCell<Vec<u64>>,
|
|
/// Catalog row ids and whether each still needs its EXIF read.
|
|
pub(super) image_ids: RefCell<Vec<i64>>,
|
|
pub(super) 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`].
|
|
pub(super) 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.
|
|
pub(super) thumb_class: RefCell<Vec<Option<dr_thumbs::ThumbSize>>>,
|
|
/// Where in the catalog the current window starts. Scrubbing moves this.
|
|
pub(super) 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.
|
|
pub(super) resume_at: std::cell::Cell<usize>,
|
|
/// 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<Option<i64>>,
|
|
/// 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<bool>,
|
|
/// 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<usize>,
|
|
/// 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<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.
|
|
pub(super) 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.
|
|
pub(super) 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.
|
|
pub(super) catalog_timer: RefCell<Option<slint::Timer>>,
|
|
pub(super) scan_timer: RefCell<Option<slint::Timer>>,
|
|
pub(super) thumb_timer: RefCell<Option<slint::Timer>>,
|
|
/// The whole-library sweep, which outlives any one grid window.
|
|
pub(super) 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.
|
|
pub(super) thumb_sweep_timer: RefCell<Option<slint::Timer>>,
|
|
/// Pushing shards and the catalog to the server.
|
|
pub(super) 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.
|
|
pub(super) 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.
|
|
pub(super) 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.
|
|
pub(super) 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.
|
|
pub(super) 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.
|
|
pub(super) place_device: RefCell<String>,
|
|
/// Coalesces the writes a flick would otherwise make one of per event.
|
|
pub(super) 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.
|
|
pub(super) 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.
|
|
pub(super) 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.
|
|
pub(super) 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.
|
|
pub(super) 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.
|
|
pub(super) 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.
|
|
pub(super) timeline_zoom: RefCell<i32>,
|
|
pub(super) 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.
|
|
pub(super) 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.
|
|
pub(super) 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.
|
|
pub(super) 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.
|
|
pub(super) 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.
|
|
pub(super) 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.
|
|
pub(super) sidecar_timer: RefCell<Option<slint::Timer>>,
|
|
/// TRACES: FR-CAT-13
|
|
/// The same, for a batch of XMP writes or a reload.
|
|
pub(super) 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.
|
|
pub(super) 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.
|
|
pub(super) 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.
|
|
pub(super) 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`].
|
|
pub(super) 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.
|
|
pub(super) pending_anchor: std::cell::Cell<Option<usize>>,
|
|
pub(super) 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.
|
|
pub(super) 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.
|
|
pub(super) 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.
|
|
pub(super) 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.
|
|
pub(super) 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.
|
|
pub(super) 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`.
|
|
pub(super) 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.
|
|
pub(super) 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.
|
|
pub(super) 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.
|
|
pub(super) 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),
|
|
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),
|
|
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<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
|
|
/// 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<slint::Image>, Option<u64>) {
|
|
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::<crate::Library>()
|
|
.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<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();
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
pub(super) type OpenImage = Rc<dyn Fn(String)>;
|
|
|
|
pub(super) fn stop(slot: &RefCell<Option<slint::Timer>>) {
|
|
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<LibraryController> {
|
|
let ctl = LibraryController::new(crate::activity::ActivityLog::new());
|
|
let catalog = Catalog::in_memory().expect("in-memory catalog");
|
|
let c = catalog.connection();
|
|
c.execute(
|
|
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')",
|
|
[],
|
|
)
|
|
.expect("root");
|
|
for (id, name) in [(1i64, "a.CR2"), (2, "b.CR2"), (3, "c.CR2"), (4, "d.CR2")] {
|
|
c.execute(
|
|
"INSERT INTO images(id, root_id, source_ref, added_at) VALUES (?1, 1, ?2, 0)",
|
|
rusqlite::params![id, name],
|
|
)
|
|
.expect("image");
|
|
}
|
|
*ctl.catalog.borrow_mut() = Some(catalog);
|
|
ctl
|
|
}
|
|
|
|
/// Just the names, for assertions that are about order rather than ids.
|
|
fn named(ctl: &Rc<LibraryController>, images: &[dr_types::ImageId]) -> Vec<String> {
|
|
ctl.selected_image_paths(images)
|
|
.into_iter()
|
|
.map(|(_, path)| path)
|
|
.collect()
|
|
}
|
|
|
|
/// TRACES: FR-EXP-7
|
|
#[test]
|
|
fn a_selection_resolves_to_paths_in_the_order_it_was_given() {
|
|
// `IN (…)` returns rows in whatever order suits SQLite, and an export's
|
|
// `{seq}` token counts through the batch — so a lookup that handed back
|
|
// the database's order would number the photographs in an order the
|
|
// user never saw.
|
|
let ctl = with_catalog();
|
|
let paths = named(&ctl, &[dr_types::ImageId(3), dr_types::ImageId(1)]);
|
|
assert_eq!(paths, vec!["c.CR2", "a.CR2"]);
|
|
}
|
|
|
|
/// TRACES: FR-EXP-7
|
|
#[test]
|
|
fn a_selection_outside_the_loaded_window_still_resolves() {
|
|
// The property the catalog lookup exists for. Selection is by id and
|
|
// survives a scrub, so a selection made before scrolling routinely
|
|
// names photographs no row holds — and `paths`, the loaded window, is
|
|
// empty here precisely to prove nothing is being read from it.
|
|
let ctl = with_catalog();
|
|
assert!(ctl.paths.borrow().is_empty());
|
|
assert_eq!(named(&ctl, &[dr_types::ImageId(4)]), ["d.CR2"]);
|
|
}
|
|
|
|
/// TRACES: FR-EXP-7
|
|
#[test]
|
|
fn an_image_that_vanished_under_the_selection_is_skipped() {
|
|
// Deleted between the selection and the click. There is nothing to
|
|
// export and nothing to fix, so it drops out rather than becoming a
|
|
// failure row the user can do nothing about.
|
|
let ctl = with_catalog();
|
|
let paths = named(
|
|
&ctl,
|
|
&[
|
|
dr_types::ImageId(1),
|
|
dr_types::ImageId(99),
|
|
dr_types::ImageId(2),
|
|
],
|
|
);
|
|
assert_eq!(paths, vec!["a.CR2", "b.CR2"]);
|
|
}
|
|
}
|