The scan/opening/status/error lines, offline mode and its retry, pinning a collection offline, the sync and thumbnail-sweep state, and the callbacks that route the grid to a rescan, a sync, another library, a panorama merge or an export — the last of what AppWindow still carried under the library- prefix — move onto the `Library` global started earlier on this branch. Rust reaches them through window.global::<Library>() rather than window.set_/get_/on_/invoke_ on the root. library-visible is the one name that stays: it is computed from active-page and active-view, the shell's own routing state, which a global cannot read. AppWindow now declares no other library- property or callback.
1320 lines
56 KiB
Rust
1320 lines
56 KiB
Rust
//! Filling the grid model from the catalog: which window of it is loaded,
|
||
//! which cells it can carry across a reload without redecoding, and the
|
||
//! thumbnail fetch and drain that fill the rest in.
|
||
//!
|
||
//! This is the piece [`crate::library_ui`]'s catalog reads are proportional
|
||
//! to (`docs/catalog.md` §1): [`load_window`] reads one screenful's worth
|
||
//! rather than the library, and [`hold_thumbnails`] is what keeps a scroll
|
||
//! from redecoding pixels already on screen. See `docs/dev/code-health.md`
|
||
//! CH-1.
|
||
|
||
use std::rc::Rc;
|
||
use std::sync::mpsc::Receiver;
|
||
|
||
use slint::{ComponentHandle, Model as _};
|
||
|
||
use crate::library::{self, ThumbnailMessage};
|
||
use crate::{AppWindow, Library, LibraryCell};
|
||
|
||
use super::controller::{stop, LibraryController, LibraryFacts};
|
||
use super::filter_bar::period_headings;
|
||
use super::offline::{collection_images, refresh_offline, scope_is_pinned};
|
||
use super::ratings_keywords::{refresh_rating_counts, sync_ratings};
|
||
use super::sync::start_sweep;
|
||
use super::timeline::{civil_from_unix, format_date, refresh_timeline, restore_position};
|
||
|
||
/// What one cell of the outgoing model is worth keeping.
|
||
#[derive(Clone)]
|
||
struct Held {
|
||
thumbnail: slint::Image,
|
||
has_thumb: bool,
|
||
/// A completed fetch that found no preview. Worth carrying for the same
|
||
/// reason the pixels are: it is an answer about the file, and re-asking it
|
||
/// on every scroll is a fetch that will fail again.
|
||
unavailable: bool,
|
||
/// Which size class the pixels came from, so the reload can tell a cell
|
||
/// that is showing what it should from one that is showing the small class
|
||
/// while the grid has since been zoomed past it.
|
||
class: Option<dr_thumbs::ThumbSize>,
|
||
}
|
||
|
||
/// **What the outgoing model is still holding, keyed on the photograph.**
|
||
///
|
||
/// A reload replaces every row, and a scroll reloads once the view has
|
||
/// travelled a quarter of the loaded window — so three quarters of the cells
|
||
/// being rebuilt are the *same* photographs the user is looking at right now.
|
||
/// Rebuilding them empty blanked the whole grid to `Theme.ground` and refilled
|
||
/// it a beat later, once a worker had re-read and re-decoded every one of them
|
||
/// from the thumbnail store. That is the black flash that punctuated every
|
||
/// screenful of scrolling, and the one visible on a column change, a zoom step,
|
||
/// a filter and a return from develop.
|
||
///
|
||
/// Keyed on `image_id` rather than on the row, because the row is exactly what
|
||
/// a reload changes. Cheap: the values are `slint::Image` handles, so this is a
|
||
/// refcount per cell and no pixels move.
|
||
fn hold_thumbnails(
|
||
previous: &slint::ModelRc<LibraryCell>,
|
||
ids: &[i64],
|
||
classes: &[Option<dr_thumbs::ThumbSize>],
|
||
) -> std::collections::HashMap<i64, Held> {
|
||
ids.iter()
|
||
.enumerate()
|
||
.filter_map(|(row, id)| {
|
||
let cell = previous.row_data(row)?;
|
||
// Nothing to carry: a row still waiting is equally blank either
|
||
// way, and holding a default image would claim otherwise.
|
||
if !cell.has_thumb && !cell.unavailable {
|
||
return None;
|
||
}
|
||
Some((
|
||
*id,
|
||
Held {
|
||
thumbnail: cell.thumbnail,
|
||
has_thumb: cell.has_thumb,
|
||
unavailable: cell.unavailable,
|
||
class: classes.get(row).copied().flatten(),
|
||
},
|
||
))
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// The fetches a reloaded window does **not** have to make.
|
||
///
|
||
/// The set used to be cleared on every load, which said "every cell here is
|
||
/// still to be fetched" — true when every row came back empty, and false now
|
||
/// that the overlap keeps its pixels. Re-requesting them re-read and re-decoded
|
||
/// three quarters of the window from the store on every scroll.
|
||
///
|
||
/// Rebuilt rather than merely kept, and that is the half that is easy to get
|
||
/// wrong: an image served into a window the user has since scrolled away from
|
||
/// is no longer on screen, and a set that remembered it would leave that cell
|
||
/// permanently blank when they scrolled back. Only what the new model actually
|
||
/// holds counts as served — and only at the class it holds it at, so zooming
|
||
/// past the grid class still asks for the large one.
|
||
fn already_served(
|
||
held: &std::collections::HashMap<i64, Held>,
|
||
ids: impl Iterator<Item = i64>,
|
||
) -> std::collections::HashSet<(i64, dr_thumbs::ThumbSize)> {
|
||
ids.filter_map(|id| Some((id, held.get(&id)?.class?)))
|
||
.collect()
|
||
}
|
||
|
||
/// Where the loaded window starts for a view whose first visible cell is
|
||
/// `anchor`.
|
||
///
|
||
/// A quarter of the window sits above the view, so scrolling back has loaded
|
||
/// rows to move into, and the remaining three quarters below, because a grid
|
||
/// is read downward. Clamped to the last position where the window is still
|
||
/// full — otherwise a scrub to the very end loads a handful of cells and the
|
||
/// rest of the window addresses images that do not exist.
|
||
pub(super) fn window_start(anchor: usize, window_size: usize, total: usize) -> usize {
|
||
let max_offset = total.saturating_sub(window_size.min(total));
|
||
anchor.saturating_sub(window_size / 4).min(max_offset)
|
||
}
|
||
|
||
/// Where the loaded window should move to for a view at `first_visible`, or
|
||
/// `None` to leave it where it is.
|
||
///
|
||
/// # Why this is a rule and not four lines in the scroll handler
|
||
///
|
||
/// It decides how often the grid re-reads the catalog while a finger is on it,
|
||
/// and both of its ways of being wrong are invisible in the code and obvious
|
||
/// on a tablet: too eager and every scroll stutters, too lazy and the view
|
||
/// runs off the end of the loaded rows into blank ones.
|
||
///
|
||
/// # The rule
|
||
///
|
||
/// The window is placed by [`window_start`], and it moves once the view comes
|
||
/// within half a screenful of an edge of what is loaded — below the *bottom*
|
||
/// of the view going down, above its top going up.
|
||
///
|
||
/// # Measured against the view, not against a fraction of the window
|
||
///
|
||
/// Both halves of that used to be a quarter of `window_size`, and both were
|
||
/// wrong, in opposite directions and for the same reason: a quarter of a
|
||
/// three-screenful window is three quarters of a screenful, which is not a
|
||
/// quantity either edge of the view cares about.
|
||
///
|
||
/// - Downward it was too lax. The test asked where the *first* visible cell
|
||
/// was, so it kept the window still until `first_visible` was three quarters
|
||
/// of the way through it — by which point the bottom of the view had
|
||
/// travelled a quarter of a screenful past the last loaded cell. Those rows
|
||
/// are not in the model, so nothing is drawn for them: the last row of the
|
||
/// grid, blank, at a scroll position the user can sit at indefinitely.
|
||
/// - Upward it was too eager, and exactly so. The window is placed a quarter
|
||
/// of itself behind the view, and the old margin then declared the view too
|
||
/// close to the top at precisely that distance — so the first row scrolled
|
||
/// upward after any move re-read the catalog, rebuilt every cell, and
|
||
/// re-queried the badges and ratings, and so did the row after it.
|
||
///
|
||
/// # And it never moves to where it already is
|
||
///
|
||
/// That is the case the margin test alone gets wrong: at either end of a scope
|
||
/// the window is *pinned* — the first screenful cannot be placed further back
|
||
/// than zero, and the last cannot start past `max_offset` — so the margin is
|
||
/// unsatisfiable there and every row crossed in the first or last quarter of a
|
||
/// window re-read the catalog, rebuilt the model and issued a thumbnail batch
|
||
/// to arrive at the offset it already had. On a library of twenty-odd thousand
|
||
/// that is a stutter at the top and the bottom of every collection, which is
|
||
/// exactly where a cull begins and ends.
|
||
pub(super) fn window_move(
|
||
first_visible: usize,
|
||
current: usize,
|
||
window_size: usize,
|
||
on_screen: usize,
|
||
total: usize,
|
||
) -> Option<usize> {
|
||
// The same placement `load_window` applies, repeated here so this compares
|
||
// against where the window would come to rest rather than where it was
|
||
// asked to go.
|
||
let desired = window_start(first_visible, window_size, total);
|
||
if desired == current {
|
||
return None;
|
||
}
|
||
|
||
// Half a screenful of loaded cells beyond each edge of the view. Enough
|
||
// that a flick has somewhere to land before the reload it triggers has
|
||
// finished, small enough that the window still moves only about once per
|
||
// screenful of travel.
|
||
let slack = (on_screen / 2).max(1);
|
||
let above = first_visible >= current + slack;
|
||
let below = first_visible + on_screen + slack <= current + window_size;
|
||
if above && below {
|
||
return None;
|
||
}
|
||
|
||
Some(desired)
|
||
}
|
||
|
||
/// Fill the model from the catalog and start fetching thumbnails.
|
||
///
|
||
/// Reads the window starting at the controller's current offset, which the
|
||
/// scrubber moves. Without a movable offset the grid could only ever show the
|
||
/// first 120 of 23,971 images.
|
||
pub(super) fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
let borrow = ctl.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
|
||
// Everything below is scoped to the selected collection, if any: the total,
|
||
// the window, and the fetches issued for it. Reading the whole library here
|
||
// and filtering later would fetch thumbnails for images the user is not
|
||
// looking at, which on a remote library is the cost FR-NC-3 exists to
|
||
// avoid.
|
||
let scope = *ctl.scope.borrow();
|
||
let filter = ctl.filter.borrow().clone();
|
||
// The trash lists what every other view excludes, so it takes its own
|
||
// query rather than another predicate threaded through the scoped one.
|
||
let trash = ctl.viewing_trash.get();
|
||
|
||
let total = if trash {
|
||
library::total_trashed(catalog).unwrap_or(0)
|
||
} else {
|
||
library::total_images_scoped(catalog, scope, &filter).unwrap_or(0)
|
||
};
|
||
|
||
// What the whole-library readouts below describe. See [`LibraryFacts`].
|
||
let facts = LibraryFacts {
|
||
scope,
|
||
filter: filter.clone(),
|
||
trash,
|
||
total,
|
||
};
|
||
let describes_something_new = ctl.library_facts.borrow().as_ref() != Some(&facts);
|
||
*ctl.library_facts.borrow_mut() = Some(facts);
|
||
|
||
// TRACES: FR-NC-6a
|
||
// Whether the newly scoped collection is already pinned. Read here rather
|
||
// than remembered, because a pin outlives the session that made it — on
|
||
// reopening the library the button has to show what the catalog says, not
|
||
// what this run happens to have done.
|
||
//
|
||
// Three queries deep — descendants, their images, and whether every one of
|
||
// them is pinned — and the answer cannot change by scrolling.
|
||
if describes_something_new {
|
||
window
|
||
.global::<Library>()
|
||
.set_library_scope_pinned(match scope {
|
||
Some(id) => dr_catalog::collections::descendants(catalog.connection(), id)
|
||
.map(|ids| collection_images(catalog, &ids))
|
||
.map(|images| scope_is_pinned(catalog, &images))
|
||
.unwrap_or(false),
|
||
None => false,
|
||
});
|
||
}
|
||
// Read before it is overwritten: the property still holds what the library
|
||
// was last time this ran, and a library that has become shorter is the
|
||
// signal that something was deleted out from under the view.
|
||
let was = window.global::<Library>().get_library_total().max(0) as usize;
|
||
window.global::<Library>().set_library_total(total as i32);
|
||
let shrank = total < was;
|
||
|
||
// Clamp so a scrub to the very end still fills the window rather than
|
||
// showing a handful of cells.
|
||
let window_size = *ctl.window.borrow();
|
||
let offset = (*ctl.offset.borrow()).min(total.saturating_sub(window_size.min(total)));
|
||
*ctl.offset.borrow_mut() = offset;
|
||
window.global::<Library>().set_library_offset(offset as i32);
|
||
|
||
// The axis the scrubber draws. A `MIN`/`MAX` and a `GROUP BY` over the
|
||
// whole scope — 5.7 ms on 24,000 images — and the bars do not change as the
|
||
// grid scrolls, only the marker on them does. The marker is moved by the
|
||
// scroll handler on every event, without coming through here.
|
||
//
|
||
// Every other thing that *does* change the bars — a zoom, a pan, a scrub,
|
||
// dates landing from the thumbnail worker — calls `refresh_timeline`
|
||
// itself, so this being skipped cannot leave a stale axis on screen.
|
||
if describes_something_new {
|
||
refresh_timeline(window, catalog, ctl);
|
||
}
|
||
|
||
// TRACES: FR-CAT-7
|
||
// Whether this scope has an order a drag can change, which is what lets the
|
||
// grid draw an insertion caret and accept a drop.
|
||
//
|
||
// Answered here because this is the one place that runs on every re-read —
|
||
// a scope change, a reorder, a collection gaining a child that turns it
|
||
// into a set — so the caret cannot outlive the scope that justified it.
|
||
//
|
||
// The trash is never reorderable. It is a view of what was deleted, ordered
|
||
// by when, and it is not a collection at all.
|
||
window.global::<Library>().set_library_reorderable(
|
||
!trash
|
||
&& scope.is_some_and(|c| {
|
||
dr_catalog::collections::orders_manually(catalog.connection(), c).unwrap_or(false)
|
||
}),
|
||
);
|
||
|
||
let cells = if trash {
|
||
library::read_trashed_cells(catalog, offset, window_size)
|
||
} else {
|
||
library::read_cells_scoped(catalog, scope, &filter, offset, window_size)
|
||
};
|
||
let cells = match cells {
|
||
Ok(c) => c,
|
||
Err(e) => {
|
||
window
|
||
.global::<Library>()
|
||
.set_library_error(format!("reading catalog: {e}").into());
|
||
return;
|
||
}
|
||
};
|
||
|
||
// What the window currently spans, in the photographer's own terms.
|
||
let span = cells
|
||
.iter()
|
||
.filter_map(|c| c.captured_at)
|
||
.fold(None::<(i64, i64)>, |acc, t| {
|
||
Some(match acc {
|
||
None => (t, t),
|
||
Some((lo, hi)) => (lo.min(t), hi.max(t)),
|
||
})
|
||
});
|
||
window.global::<Library>().set_library_window_label(
|
||
match span {
|
||
Some((lo, hi)) => format!("{} – {}", format_date(lo), format_date(hi)),
|
||
// Nothing here has a date yet: EXIF is read as thumbnails load, so
|
||
// this fills in rather than being an error.
|
||
None => "dates not yet read".to_string(),
|
||
}
|
||
.into(),
|
||
);
|
||
|
||
// Month headings. The grid is ordered by capture time, so without these a
|
||
// wall of thumbnails gives no sense of *when* you are looking — the
|
||
// sidebar says it, but only if you consult it.
|
||
let columns = window.global::<Library>().get_library_columns().max(1) as usize;
|
||
let headings = period_headings(
|
||
cells.iter().map(|c| {
|
||
c.captured_at.map(|t| {
|
||
let (y, m, _, _) = civil_from_unix(t);
|
||
(y, m)
|
||
})
|
||
}),
|
||
offset,
|
||
columns,
|
||
);
|
||
|
||
// What the outgoing model is still holding — see [`hold_thumbnails`].
|
||
let held = {
|
||
let previous = window.global::<Library>().get_library_cells();
|
||
let ids = ctl.image_ids.borrow();
|
||
let classes = ctl.thumb_class.borrow();
|
||
hold_thumbnails(&previous, &ids, &classes)
|
||
};
|
||
|
||
let rows: Vec<LibraryCell> = cells
|
||
.iter()
|
||
.zip(headings)
|
||
.map(|(c, heading)| {
|
||
let carried = held.get(&c.image_id);
|
||
LibraryCell {
|
||
period_heading: heading.into(),
|
||
// A freshly loaded window has no drag in flight.
|
||
lifted: false,
|
||
name: without_extension(&c.name).into(),
|
||
thumbnail: carried.map(|h| h.thumbnail.clone()).unwrap_or_default(),
|
||
has_thumb: carried.is_some_and(|h| h.has_thumb),
|
||
unavailable: carried.is_some_and(|h| h.unavailable),
|
||
// All three are filled straight after by `collections_ui`,
|
||
// which owns the selection and queries the badge counts for the
|
||
// whole window in one statement rather than one per cell.
|
||
selected: false,
|
||
collection_count: 0,
|
||
// Likewise filled by `sync_ratings` below — one query for the
|
||
// window, not one per cell.
|
||
rating: 0,
|
||
flag: 0,
|
||
// And by `bursts::sync_badges`, in one more query for the
|
||
// window. Zero is "not in a burst", which is what almost every
|
||
// photograph in a library is.
|
||
burst_count: 0,
|
||
burst_expanded: false,
|
||
burst_representative: false,
|
||
}
|
||
})
|
||
.collect();
|
||
|
||
*ctl.paths.borrow_mut() = cells.iter().map(|c| c.remote_path.clone()).collect();
|
||
*ctl.file_ids.borrow_mut() = cells.iter().map(|c| c.file_id).collect();
|
||
*ctl.sizes.borrow_mut() = cells.iter().map(|c| c.size).collect();
|
||
*ctl.image_ids.borrow_mut() = cells.iter().map(|c| c.image_id).collect();
|
||
*ctl.needs_metadata.borrow_mut() = cells.iter().map(|c| c.metadata_state < 2).collect();
|
||
*ctl.captured_at.borrow_mut() = cells.iter().map(|c| c.captured_at).collect();
|
||
*ctl.thumb_class.borrow_mut() = cells
|
||
.iter()
|
||
.map(|c| held.get(&c.image_id).and_then(|h| h.class))
|
||
.collect();
|
||
*ctl.requested.borrow_mut() = already_served(&held, cells.iter().map(|c| c.image_id));
|
||
|
||
// The model is about to be replaced, so every thumbnail still in flight
|
||
// addresses a window that no longer exists. Bumping here — before the swap
|
||
// — is what lets `drain_thumbnails` recognise itself as stale. The rows
|
||
// those fetches would have filled are absent from `requested` above, so the
|
||
// batch started below asks for them again.
|
||
ctl.generation.set(ctl.generation.get().wrapping_add(1));
|
||
window
|
||
.global::<Library>()
|
||
.set_library_cells(slint::ModelRc::new(slint::VecModel::from(rows)));
|
||
|
||
// The model is fresh, so the "in this many collections" badges are all zero
|
||
// until refilled. One query for the whole window, not one per cell.
|
||
let ids: Vec<dr_types::ImageId> = cells
|
||
.iter()
|
||
.map(|c| dr_types::ImageId(c.image_id as u64))
|
||
.collect();
|
||
crate::collections_ui::sync_badges(window, catalog, &ids);
|
||
sync_ratings(window, catalog, &ids);
|
||
// How many frames each cell stands for, where it stands for several
|
||
// (FR-CULL-5).
|
||
crate::bursts::sync_badges(window, catalog, &ids);
|
||
// The rebuilt cells all carry `selected: false`, but the selection itself
|
||
// is a set of image ids and survives untouched. Without this the ticks
|
||
// vanished on every scroll — the selection was still there and still acted
|
||
// on, which is worse than losing it, because the user cannot see what the
|
||
// buttons are about to do.
|
||
if let Some(coll) = ctl.coll_ctl.borrow().as_ref().and_then(|w| w.upgrade()) {
|
||
crate::collections_ui::sync_selection(window, &coll, &ids);
|
||
}
|
||
// The filter chips' counts describe the whole library, not this window —
|
||
// which is exactly why they are not recomputed for a window that moved.
|
||
// A judgement changes them and calls this itself (see `apply_judgement`),
|
||
// so a cull still watches its own chips move.
|
||
if describes_something_new {
|
||
refresh_rating_counts(window, catalog);
|
||
}
|
||
|
||
// The library got shorter while the view was looking at it — a delete.
|
||
//
|
||
// Two things have already moved by this point and neither touches the
|
||
// viewport: the scrollable height shrank, and `offset` was re-clamped so
|
||
// the loaded window still fills. So the view is left pointing either past
|
||
// the end of the content (a blank grid) or at a different part of the
|
||
// library (an apparent jump to nowhere). Re-anchoring here puts it back on
|
||
// the photographs the user was actually looking at.
|
||
//
|
||
// Only on a shrink. Doing it on every load would fight a scrub, which sets
|
||
// exactly this property to go somewhere the user asked for.
|
||
if shrank {
|
||
restore_position(window, ctl);
|
||
}
|
||
|
||
if total == 0 {
|
||
return;
|
||
}
|
||
request_thumbnails(window, ctl);
|
||
}
|
||
|
||
/// Where a row of the loaded window sits in the fetch queue.
|
||
///
|
||
/// On screen first, top to bottom; then the rows below the view, nearest
|
||
/// first; then the rows above it, nearest first.
|
||
///
|
||
/// # Why the queue has an order at all
|
||
///
|
||
/// The batch is fetched one image at a time, two round trips each, and it is
|
||
/// abandoned wholesale the moment the window moves — so whatever is at the
|
||
/// back of it on a remote library is not slow to appear, it never appears.
|
||
/// Issued in model order, the back of the queue was the bottom of the screen
|
||
/// and everything below it, and the *front* was the quarter-window of cells
|
||
/// above the view that nobody was looking at. A scroll then abandoned the
|
||
/// batch and the new one started over, again from above the view: on a
|
||
/// library still filling its store, the last rows of the grid could be
|
||
/// starved for as long as the scrolling continued.
|
||
///
|
||
/// Below before above because that is where the view is going. Scrolling back
|
||
/// over cells already fetched is served from the store, and from `requested`
|
||
/// without a fetch at all.
|
||
fn fetch_rank(row: usize, first_on_screen: usize, on_screen: usize) -> (u8, usize) {
|
||
let past = first_on_screen + on_screen;
|
||
if row >= first_on_screen && row < past {
|
||
(0, row - first_on_screen)
|
||
} else if row >= past {
|
||
(1, row - past)
|
||
} else {
|
||
(2, first_on_screen - row)
|
||
}
|
||
}
|
||
|
||
/// Fetch thumbnails for rows in the model that do not have one yet.
|
||
fn request_thumbnails(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
let Some((conn, _)) = ctl.session.borrow().clone() else {
|
||
return;
|
||
};
|
||
|
||
// The drawn cell size decides which class to ask for. Chosen once for the
|
||
// batch rather than per row, and carried through to the drain so a cell it
|
||
// fills can record what it is now showing.
|
||
let cell_pixels = window.global::<Library>().get_library_cell_size().max(1.0) as u32;
|
||
let class = dr_thumbs::ThumbSize::for_cell(cell_pixels);
|
||
|
||
let mut wanted: Vec<library::ThumbnailRequest> = {
|
||
let paths = ctl.paths.borrow();
|
||
let file_ids = ctl.file_ids.borrow();
|
||
let sizes = ctl.sizes.borrow();
|
||
let image_ids = ctl.image_ids.borrow();
|
||
let needs_md = ctl.needs_metadata.borrow();
|
||
let mut requested = ctl.requested.borrow_mut();
|
||
paths
|
||
.iter()
|
||
.enumerate()
|
||
.filter_map(|(i, p)| {
|
||
let image_id = *image_ids.get(i)?;
|
||
// A zoomed grid asks for detail a 256px thumbnail cannot give,
|
||
// and a wall of small cells does not pay for it.
|
||
let thumb_size = class;
|
||
// Keyed on the photograph, so scrolling back over a cell that
|
||
// has already been served does not ask for it again.
|
||
if !requested.insert((image_id, thumb_size)) {
|
||
return None;
|
||
}
|
||
Some(library::ThumbnailRequest {
|
||
row: i,
|
||
path: p.clone(),
|
||
file_id: file_ids.get(i).copied().flatten(),
|
||
size: sizes.get(i).copied().unwrap_or(0),
|
||
image_id,
|
||
needs_metadata: needs_md.get(i).copied().unwrap_or(false),
|
||
thumb_size,
|
||
// The grid is filling a cell of a known size.
|
||
full_resolution: false,
|
||
})
|
||
})
|
||
.collect()
|
||
};
|
||
|
||
if wanted.is_empty() {
|
||
return;
|
||
}
|
||
|
||
// What is on screen, first — see [`fetch_rank`]. The rows keep addressing
|
||
// the model they were built against; only the order they are asked for in
|
||
// changes.
|
||
{
|
||
let first_on_screen = ctl.resume_at.get().saturating_sub(*ctl.offset.borrow());
|
||
let on_screen = ctl.viewport_cells.get().max(1);
|
||
wanted.sort_by_key(|r| fetch_rank(r.row, first_on_screen, on_screen));
|
||
}
|
||
|
||
let requested = wanted.len();
|
||
let rx = library::spawn_thumbnails(
|
||
conn.clone(),
|
||
wanted,
|
||
library::thumbs_dir(&conn.account),
|
||
library::catalog_path(&conn.account),
|
||
);
|
||
drain_thumbnails(window.as_weak(), ctl.clone(), rx, requested, class);
|
||
}
|
||
|
||
/// TRACES: FR-CAT-9 | FR-DEV-6
|
||
/// Replace a photograph's cached thumbnail with one rendered from its edit.
|
||
///
|
||
/// # Why the grid cannot be left alone
|
||
///
|
||
/// A thumbnail comes from the file's embedded preview, which is the camera's
|
||
/// idea of the photograph and knows nothing about what has been done to it
|
||
/// since. So a frame could be cropped, turned upright, and pulled two stops
|
||
/// back, and the grid would go on showing the original — the one view of a
|
||
/// library where an edit is least visible is the one the photographer spends
|
||
/// most of their time in.
|
||
///
|
||
/// # Both classes, and why
|
||
///
|
||
/// The store keys on the size class, so replacing only the one the grid
|
||
/// happens to be drawing at leaves the other holding the unedited preview —
|
||
/// and a zoom past the class boundary would show the edit undoing itself.
|
||
/// Each class is rendered separately because they are different sizes; a
|
||
/// downscale of the large one would be a second, worse resampler than the GPU
|
||
/// already applied.
|
||
///
|
||
/// # Silent on failure
|
||
///
|
||
/// The edit is saved to the sidecar by the caller before this runs, so nothing
|
||
/// here can lose work. A thumbnail that could not be re-rendered is a stale
|
||
/// cell, which the next scroll past it corrects from the store — worth a log
|
||
/// line and not worth an error in front of a photographer who has just
|
||
/// finished an image.
|
||
pub fn refresh_thumbnail(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
remote_path: &str,
|
||
mut render: impl FnMut(u32) -> Result<(u32, u32, Vec<u8>), String>,
|
||
) {
|
||
// Where this photograph sits in the loaded window. It is always in it: the
|
||
// develop view is reached from a cell, and the guards on the scroll and
|
||
// geometry handlers stop the window moving while it is open.
|
||
let Some(row) = ctl.paths.borrow().iter().position(|p| p == remote_path) else {
|
||
return;
|
||
};
|
||
let Some(file_id) = ctl.file_ids.borrow().get(row).copied().flatten() else {
|
||
// Nothing to key the store on. A photograph the scan recorded without
|
||
// a server file id cannot have a cached thumbnail either, so there is
|
||
// nothing here to correct.
|
||
return;
|
||
};
|
||
let Some((conn, _)) = ctl.session.borrow().clone() else {
|
||
return;
|
||
};
|
||
|
||
let mut store = match dr_thumbs::ThumbStore::open(&library::thumbs_dir(&conn.account)) {
|
||
Ok(s) => s,
|
||
Err(e) => {
|
||
log::warn!("re-thumbnailing {remote_path}: opening the store: {e}");
|
||
return;
|
||
}
|
||
};
|
||
|
||
// What the grid is drawing at, so the cell can be corrected on screen
|
||
// rather than only on disk.
|
||
let drawn = dr_thumbs::ThumbSize::for_cell(
|
||
window.global::<Library>().get_library_cell_size().max(1.0) as u32,
|
||
);
|
||
|
||
for class in [dr_thumbs::ThumbSize::Grid, dr_thumbs::ThumbSize::Large] {
|
||
let (w, h, rgba) = match render(class.edge()) {
|
||
Ok(r) => r,
|
||
Err(e) => {
|
||
log::warn!("re-thumbnailing {remote_path} at {class:?}: {e}");
|
||
continue;
|
||
}
|
||
};
|
||
|
||
match dr_thumbs::codec::encode_rgba(w, h, &rgba) {
|
||
Ok(bytes) => {
|
||
let thumb = dr_thumbs::Thumbnail {
|
||
width: w,
|
||
height: h,
|
||
bytes,
|
||
};
|
||
if let Err(e) = store.put(file_id, class, &thumb) {
|
||
log::warn!("re-thumbnailing {remote_path} at {class:?}: {e}");
|
||
}
|
||
}
|
||
Err(e) => {
|
||
log::warn!("re-thumbnailing {remote_path} at {class:?}: encoding: {e}");
|
||
continue;
|
||
}
|
||
}
|
||
|
||
if class == drawn {
|
||
// Straight into the model, so the edit is on the cell the moment
|
||
// the grid comes back rather than after a scroll evicts and
|
||
// refetches it.
|
||
let model = window.global::<Library>().get_library_cells();
|
||
if let Some(mut cell) = model.row_data(row) {
|
||
cell.thumbnail = to_slint_image(w, h, &rgba);
|
||
cell.has_thumb = true;
|
||
cell.unavailable = false;
|
||
model.set_row_data(row, cell);
|
||
}
|
||
record_class(ctl, row, class);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Note which size class a row's pixels came from.
|
||
///
|
||
/// Silent about a row past the end: the model and this vector are rebuilt
|
||
/// together by [`load_window`], and the generation check above already refuses
|
||
/// anything addressed to a window that has since moved.
|
||
fn record_class(ctl: &Rc<LibraryController>, row: usize, class: dr_thumbs::ThumbSize) {
|
||
if let Some(slot) = ctl.thumb_class.borrow_mut().get_mut(row) {
|
||
*slot = Some(class);
|
||
}
|
||
}
|
||
|
||
/// Apply thumbnails to the model as they arrive.
|
||
fn drain_thumbnails(
|
||
weak: slint::Weak<AppWindow>,
|
||
ctl: Rc<LibraryController>,
|
||
rx: Receiver<ThumbnailMessage>,
|
||
requested: usize,
|
||
class: dr_thumbs::ThumbSize,
|
||
) {
|
||
let timer = slint::Timer::default();
|
||
let ctl_cb = ctl.clone();
|
||
|
||
// One row per batch, measured against the cells this window asked for.
|
||
// Starting a new batch does not extend the last one: a scroll abandons
|
||
// whatever the previous window wanted, and a denominator carried across
|
||
// both would describe neither.
|
||
let job = ctl
|
||
.activity
|
||
.begin(crate::activity::Kind::Thumbnails, "Loading thumbnails");
|
||
job.total(requested);
|
||
// Which window this batch was requested for. Captured at spawn, compared on
|
||
// every tick.
|
||
let mine = ctl.generation.get();
|
||
|
||
timer.start(
|
||
slint::TimerMode::Repeated,
|
||
std::time::Duration::from_millis(100),
|
||
move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
|
||
// A reload replaced the model under this worker. Two things must
|
||
// not happen now, and both did:
|
||
//
|
||
// - Applying a row. `t.row` indexes the window that asked for it,
|
||
// so after a reload it names a different photograph — thumbnails
|
||
// landed on unrelated cells, and `Unavailable` marked cells
|
||
// "no preview" for a fetch never attempted against them.
|
||
// - Calling `stop`. `thumb_timer` holds the *current* batch's timer
|
||
// by now, so a stale drain reaching `Disconnected` killed the
|
||
// live drain instead of itself. The new worker then fetched into
|
||
// a channel nobody read, and the grid stayed black until a scroll
|
||
// forced yet another load — which is the flicker being chased.
|
||
//
|
||
// Returning without stopping is deliberate: this timer is no longer
|
||
// reachable through the controller, so it is dropped with its
|
||
// receiver when the slot is overwritten, and the worker exits on
|
||
// its next failed send.
|
||
if ctl_cb.generation.get() != mine {
|
||
return;
|
||
}
|
||
|
||
let model = w.global::<Library>().get_library_cells();
|
||
|
||
loop {
|
||
let msg = match rx.try_recv() {
|
||
Ok(m) => m,
|
||
Err(std::sync::mpsc::TryRecvError::Empty) => return,
|
||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||
// The worker finished or died. Either way nothing more
|
||
// is coming, so the bar must not sit part-filled
|
||
// forever.
|
||
//
|
||
// Quietly: a scroll starts one of these every second,
|
||
// and a history of them would bury anything worth
|
||
// reading.
|
||
job.finish_quietly();
|
||
stop(&ctl_cb.thumb_timer);
|
||
return;
|
||
}
|
||
};
|
||
|
||
match msg {
|
||
// Bookkeeping, not an outcome — reports the split between
|
||
// store and network without advancing the bar.
|
||
ThumbnailMessage::Plan {
|
||
cached,
|
||
fetching,
|
||
dating,
|
||
} => {
|
||
// Date reads produce no cell, so they are counted into
|
||
// the bar's denominator or it finishes while work is
|
||
// still running.
|
||
job.add_total(dating);
|
||
|
||
let mut parts = Vec::new();
|
||
if cached > 0 {
|
||
parts.push(format!("{cached} cached"));
|
||
}
|
||
if fetching > 0 {
|
||
parts.push(format!("fetching {fetching}"));
|
||
}
|
||
if dating > 0 {
|
||
parts.push(format!("reading {dating} dates"));
|
||
}
|
||
if !parts.is_empty() {
|
||
let status = parts.join(" · ");
|
||
job.detail(status.clone());
|
||
w.global::<Library>().set_library_status(status.into());
|
||
}
|
||
}
|
||
// A header-only date read. Advances the bar; draws nothing.
|
||
ThumbnailMessage::DateProgress => {
|
||
job.advance();
|
||
}
|
||
// Dates landed, so the histogram can now be built. This is
|
||
// what makes the timeline appear on a library whose
|
||
// thumbnails were all cached.
|
||
ThumbnailMessage::DatesRecorded(n) => {
|
||
log::info!("timeline: {n} new dates");
|
||
let borrow = ctl_cb.catalog.borrow();
|
||
if let Some(catalog) = borrow.as_ref() {
|
||
refresh_timeline(&w, catalog, &ctl_cb);
|
||
}
|
||
}
|
||
// Every real outcome advances the bar. Counting only
|
||
// successes would stall it on a library where some files
|
||
// carry no embedded preview.
|
||
ThumbnailMessage::Ready(t) => {
|
||
job.advance();
|
||
// Bytes arrived *from the server*, so it is reachable.
|
||
// This is what clears the banner when a connection
|
||
// returns while the user is simply scrolling, without
|
||
// waiting for a probe or a manual retry.
|
||
//
|
||
// Store hits are excluded deliberately: they are read
|
||
// from local disk and say nothing about the network. A
|
||
// window that is mostly cached delivers a run of them
|
||
// before the first request is even attempted, so
|
||
// counting them declared "back online" against a server
|
||
// that was plainly down.
|
||
if !t.from_cache
|
||
&& ctl_cb
|
||
.reachability
|
||
.borrow_mut()
|
||
.mark_reachable(std::time::Instant::now())
|
||
{
|
||
log::info!("back online");
|
||
refresh_offline(&w, &ctl_cb);
|
||
// The sweep was refused while offline, so nothing
|
||
// else would ever restart it — the grid would stay
|
||
// partially dated until the next launch.
|
||
start_sweep(&w, &ctl_cb);
|
||
}
|
||
if let Some(mut row) = model.row_data(t.row) {
|
||
row.thumbnail = to_slint_image(t.width, t.height, &t.rgba);
|
||
row.has_thumb = true;
|
||
model.set_row_data(t.row, row);
|
||
// What this cell is now showing, so the next reload
|
||
// can carry it over and know not to ask again.
|
||
record_class(&ctl_cb, t.row, class);
|
||
}
|
||
}
|
||
ThumbnailMessage::Unavailable { row, reason } => {
|
||
job.advance();
|
||
log::debug!("thumbnail {row}: {reason}");
|
||
if let Some(mut r) = model.row_data(row) {
|
||
r.unavailable = true;
|
||
model.set_row_data(row, r);
|
||
// A verdict is worth carrying too: "no preview" is
|
||
// an answer about the file, and re-asking it on
|
||
// every scroll is a fetch that will fail again.
|
||
record_class(&ctl_cb, row, class);
|
||
}
|
||
}
|
||
// TRACES: FR-CAT-9
|
||
ThumbnailMessage::Offline { reason } => {
|
||
log::info!("thumbnails stopped: {reason}");
|
||
// The batch is over, so the bar must not be left
|
||
// showing a partial fetch that will never finish — it
|
||
// would sweep for ever.
|
||
job.fail(reason.clone());
|
||
ctl_cb
|
||
.reachability
|
||
.borrow_mut()
|
||
.mark_unreachable(reason, std::time::Instant::now());
|
||
refresh_offline(&w, &ctl_cb);
|
||
|
||
// Cells left without pixels stay placeholders rather
|
||
// than being marked unavailable: the images are fine,
|
||
// and a reconnect should fill them in. Marking them
|
||
// would persist a verdict about the *file* from an
|
||
// event about the *connection*.
|
||
stop(&ctl_cb.thumb_timer);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
},
|
||
);
|
||
|
||
*ctl.thumb_timer.borrow_mut() = Some(timer);
|
||
}
|
||
|
||
/// Copy decoded RGBA into a Slint image.
|
||
///
|
||
/// This is a CPU copy, which is acceptable here and not in the develop path:
|
||
/// a 256px thumbnail is 256 KB and happens once per image, where the canvas
|
||
/// would pay per frame (ARCH §6.1).
|
||
fn to_slint_image(width: u32, height: u32, rgba: &[u8]) -> slint::Image {
|
||
let mut buf = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::new(width, height);
|
||
let expected = (width as usize) * (height as usize) * 4;
|
||
let src = &rgba[..expected.min(rgba.len())];
|
||
buf.make_mut_bytes()[..src.len()].copy_from_slice(src);
|
||
slint::Image::from_rgba8(buf)
|
||
}
|
||
|
||
/// TRACES: FR-UI-2
|
||
/// A filename as a caption: without the part that says how it is stored.
|
||
///
|
||
/// A grid cell is about four words wide, and `.CR2` spends one of them saying
|
||
/// something the photographer already knows — every frame in a RAW library
|
||
/// ends the same way, so the extension distinguishes nothing while taking
|
||
/// room from the part that does. The name is elided under pressure, and it is
|
||
/// the *end* that goes, so an extension can push the digits that identify a
|
||
/// frame off the visible part of its own label.
|
||
///
|
||
/// Display only. `LibraryCell::name` keeps the true filename and
|
||
/// `remote_path` the full path, because both are used to find the file again
|
||
/// and a stem is not a filename.
|
||
///
|
||
/// **The case this is wrong for**, worth knowing before it is reported: a
|
||
/// library holding `IMG_1234.CR2` beside `IMG_1234.JPG` shows two cells
|
||
/// captioned `IMG_1234`. They are still two rows with two thumbnails and two
|
||
/// entries in the info panel, and RAW+JPEG pairs are usually shot to be one
|
||
/// photograph anyway — but the caption alone no longer separates them.
|
||
fn without_extension(name: &str) -> &str {
|
||
match name.rsplit_once('.') {
|
||
// The guard is for a leading dot: `.hidden` splits to an empty stem,
|
||
// and a dotfile's name starts with that dot rather than ending with an
|
||
// extension. A *trailing* dot needs no guard — `odd.` splits to `odd`,
|
||
// which is the better caption anyway.
|
||
Some((stem, _)) if !stem.is_empty() => stem,
|
||
_ => name,
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod display_name_tests {
|
||
use super::without_extension;
|
||
|
||
#[test]
|
||
fn an_extension_is_dropped_and_nothing_else_is() {
|
||
assert_eq!(without_extension("IMG_1234.CR2"), "IMG_1234");
|
||
assert_eq!(without_extension("IMG_1234.jpeg"), "IMG_1234");
|
||
// Only the last one: a name can contain dots and they are part of it.
|
||
assert_eq!(without_extension("2026.08.23-a.dng"), "2026.08.23-a");
|
||
// Nothing to drop.
|
||
assert_eq!(without_extension("IMG_1234"), "IMG_1234");
|
||
// A dotfile is not an extensionless name with an extension.
|
||
assert_eq!(without_extension(".hidden"), ".hidden");
|
||
// A trailing dot is an empty extension, and dropping it is right.
|
||
assert_eq!(without_extension("odd."), "odd");
|
||
assert_eq!(without_extension(""), "");
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::super::controller::SCREENFULS;
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn a_request_key_survives_the_window_moving() {
|
||
use dr_thumbs::ThumbSize;
|
||
use std::collections::HashSet;
|
||
|
||
// Keyed on the photograph, not its position. The grid is a window over
|
||
// the catalog, so row 7 is a different image after every scroll — a
|
||
// set of row indices had to be cleared on each move, and every visible
|
||
// cell then looked unrequested and was re-issued.
|
||
let mut requested: HashSet<(i64, ThumbSize)> = HashSet::new();
|
||
|
||
// A screenful at rows 0..3, holding images 100..103.
|
||
for id in 100..103 {
|
||
assert!(requested.insert((id, ThumbSize::Grid)), "first sight");
|
||
}
|
||
|
||
// Scrolled: the same photographs now occupy different rows.
|
||
for id in 100..103 {
|
||
assert!(
|
||
!requested.insert((id, ThumbSize::Grid)),
|
||
"image {id} must not be requested twice"
|
||
);
|
||
}
|
||
|
||
// A genuinely new photograph still is.
|
||
assert!(requested.insert((200, ThumbSize::Grid)));
|
||
}
|
||
|
||
#[test]
|
||
fn the_two_size_classes_are_requested_independently() {
|
||
use dr_thumbs::ThumbSize;
|
||
use std::collections::HashSet;
|
||
|
||
// Holding the 256px version says nothing about the large one, so
|
||
// zooming past the boundary must still ask.
|
||
let mut requested: HashSet<(i64, ThumbSize)> = HashSet::new();
|
||
assert!(requested.insert((1, ThumbSize::Grid)));
|
||
assert!(
|
||
requested.insert((1, ThumbSize::Large)),
|
||
"the large class is a separate request"
|
||
);
|
||
assert!(!requested.insert((1, ThumbSize::Grid)));
|
||
}
|
||
|
||
#[test]
|
||
fn rgba_shorter_than_declared_does_not_panic() {
|
||
// A truncated decode must degrade to a partial image, not abort the
|
||
// grid. Decoders handle untrusted input (NFR-SEC-1).
|
||
let img = to_slint_image(4, 4, &[0u8; 8]);
|
||
assert_eq!(img.size().width, 4);
|
||
}
|
||
|
||
#[test]
|
||
fn rgba_longer_than_declared_is_truncated() {
|
||
let img = to_slint_image(2, 2, &[255u8; 1024]);
|
||
assert_eq!(img.size().width, 2);
|
||
assert_eq!(img.size().height, 2);
|
||
}
|
||
|
||
const ON_SCREEN: usize = 120;
|
||
|
||
const W: usize = ON_SCREEN * SCREENFULS;
|
||
|
||
const TOTAL: usize = 24_000;
|
||
|
||
/// Where the window lands for a view at `first_visible`, as the scroll
|
||
/// handler would leave it.
|
||
fn settle(first_visible: usize) -> usize {
|
||
window_start(first_visible, W, TOTAL)
|
||
}
|
||
|
||
#[test]
|
||
fn a_view_well_inside_the_loaded_window_does_not_move_it() {
|
||
// The whole point of loading screenfuls either side: scrolling within
|
||
// them must not touch the catalog.
|
||
let at = settle(5_000);
|
||
assert_eq!(window_move(5_000, at, W, ON_SCREEN, TOTAL), None);
|
||
assert_eq!(window_move(5_100, at, W, ON_SCREEN, TOTAL), None);
|
||
}
|
||
|
||
#[test]
|
||
fn the_whole_view_stays_inside_the_loaded_window() {
|
||
// The bug this rule exists for: the test used to ask only where the
|
||
// *first* visible cell was, so the window sat still while the bottom
|
||
// of the view hung a quarter of a screenful past the last loaded cell
|
||
// — rows the model does not hold and the grid therefore draws blank.
|
||
let mut at = settle(0);
|
||
for first_visible in (0..TOTAL - ON_SCREEN).step_by(10) {
|
||
if let Some(moved) = window_move(first_visible, at, W, ON_SCREEN, TOTAL) {
|
||
at = moved;
|
||
}
|
||
assert!(
|
||
first_visible >= at && first_visible + ON_SCREEN <= at + W,
|
||
"view {first_visible}..{} is not covered by the window {at}..{}",
|
||
first_visible + ON_SCREEN,
|
||
at + W
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn scrolling_back_a_row_does_not_reload() {
|
||
// The mirror fault, and the more expensive one: the window is placed a
|
||
// quarter of itself behind the view, and the old margin declared the
|
||
// view too close to the top at exactly that distance. So every single
|
||
// row scrolled upward re-read the catalog and rebuilt every cell.
|
||
let at = settle(5_200);
|
||
for row in 1..=4 {
|
||
let first_visible = 5_200 - row * 10;
|
||
assert_eq!(
|
||
window_move(first_visible, at, W, ON_SCREEN, TOTAL),
|
||
None,
|
||
"row {first_visible} reloaded the window it was already inside"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_view_reaching_the_edge_of_the_loaded_window_moves_it() {
|
||
// Far enough down that scrolling on would run into rows nobody has
|
||
// read.
|
||
let at = settle(5_000);
|
||
let far = at + W - ON_SCREEN;
|
||
let moved = window_move(far, at, W, ON_SCREEN, TOTAL).expect("the window follows the view");
|
||
assert_eq!(moved, far - W / 4, "placed a quarter behind the view");
|
||
}
|
||
|
||
#[test]
|
||
fn the_window_moves_about_once_a_screenful() {
|
||
// Too eager is a stutter under the finger, so the rule is checked from
|
||
// both sides: the view must cross most of a screenful between reloads.
|
||
let mut at = settle(0);
|
||
let mut last = 0;
|
||
let mut gaps = Vec::new();
|
||
for first_visible in (0..12_000).step_by(10) {
|
||
if let Some(moved) = window_move(first_visible, at, W, ON_SCREEN, TOTAL) {
|
||
at = moved;
|
||
gaps.push(first_visible - last);
|
||
last = first_visible;
|
||
}
|
||
}
|
||
assert!(
|
||
gaps.iter().skip(1).all(|g| *g >= ON_SCREEN),
|
||
"reloaded after less than a screenful of travel: {gaps:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn the_top_of_the_library_is_not_reloaded_on_every_row() {
|
||
// `first_visible` cannot be placed further back than zero, so the
|
||
// margin test can never be satisfied here. Before this rule every one
|
||
// of these re-read the catalog to arrive at the offset it already had,
|
||
// which is the stutter at the top of every scope.
|
||
for first_visible in [0, 6, 30, 89] {
|
||
assert_eq!(
|
||
window_move(first_visible, 0, W, ON_SCREEN, TOTAL),
|
||
None,
|
||
"row {first_visible} asked for a move to offset 0, which is where it is"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_end_of_the_library_is_not_reloaded_on_every_row() {
|
||
// The mirror of the above, and the worse of the two: the window is
|
||
// clamped to `max_offset` while the view keeps travelling past it.
|
||
let pinned = TOTAL - W;
|
||
for first_visible in [TOTAL - W / 2, TOTAL - 30, TOTAL - 1] {
|
||
assert_eq!(
|
||
window_move(first_visible, pinned, W, ON_SCREEN, TOTAL),
|
||
None,
|
||
"row {first_visible} asked for a move to the offset it already had"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_window_never_starts_past_the_last_full_screenful() {
|
||
// Otherwise a scrub to the very end loads a handful of cells and the
|
||
// rest of the window addresses images that do not exist.
|
||
let moved =
|
||
window_move(TOTAL - 1, 0, W, ON_SCREEN, TOTAL).expect("a scrub to the end moves");
|
||
assert_eq!(moved, TOTAL - W);
|
||
}
|
||
|
||
#[test]
|
||
fn a_library_smaller_than_the_window_stays_at_the_beginning() {
|
||
// `max_offset` is zero, so there is one valid position and the view
|
||
// must never ask for another.
|
||
assert_eq!(window_move(0, 0, W, ON_SCREEN, 40), None);
|
||
assert_eq!(window_move(39, 0, W, ON_SCREEN, 40), None);
|
||
}
|
||
|
||
#[test]
|
||
fn the_visible_cells_are_fetched_before_anything_else() {
|
||
// The window holds a quarter of itself above the view, and in model
|
||
// order those cells — which nobody is looking at — were fetched first,
|
||
// ahead of the whole screen.
|
||
let window: Vec<usize> = (0..W).collect();
|
||
let first_on_screen = W / 4;
|
||
let mut order = window.clone();
|
||
order.sort_by_key(|row| fetch_rank(*row, first_on_screen, ON_SCREEN));
|
||
|
||
assert_eq!(
|
||
&order[..ON_SCREEN],
|
||
&window[first_on_screen..first_on_screen + ON_SCREEN],
|
||
"the screen is not fetched first, in reading order"
|
||
);
|
||
// The bottom row of the grid — the one reported missing — comes before
|
||
// every offscreen cell rather than after all of them.
|
||
let bottom = first_on_screen + ON_SCREEN - 1;
|
||
assert!(
|
||
order.iter().position(|r| *r == bottom).unwrap() < ON_SCREEN,
|
||
"the bottom row of the grid is still behind offscreen cells"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn offscreen_cells_are_fetched_nearest_first_below_before_above() {
|
||
let first_on_screen = W / 4;
|
||
let past = first_on_screen + ON_SCREEN;
|
||
// The row just below the view beats the row just above it, and both
|
||
// beat rows further out on their own side.
|
||
assert!(
|
||
fetch_rank(past, first_on_screen, ON_SCREEN)
|
||
< fetch_rank(first_on_screen - 1, first_on_screen, ON_SCREEN)
|
||
);
|
||
assert!(
|
||
fetch_rank(past, first_on_screen, ON_SCREEN)
|
||
< fetch_rank(past + 1, first_on_screen, ON_SCREEN)
|
||
);
|
||
assert!(
|
||
fetch_rank(first_on_screen - 1, first_on_screen, ON_SCREEN)
|
||
< fetch_rank(0, first_on_screen, ON_SCREEN)
|
||
);
|
||
}
|
||
|
||
/// A model of cells, `has_thumb` set for the ids named.
|
||
fn model_of(ids: &[i64], with_pixels: &[i64]) -> slint::ModelRc<LibraryCell> {
|
||
let rows: Vec<LibraryCell> = ids
|
||
.iter()
|
||
.map(|id| LibraryCell {
|
||
has_thumb: with_pixels.contains(id),
|
||
thumbnail: if with_pixels.contains(id) {
|
||
to_slint_image(2, 2, &[255u8; 16])
|
||
} else {
|
||
slint::Image::default()
|
||
},
|
||
..Default::default()
|
||
})
|
||
.collect();
|
||
slint::ModelRc::new(slint::VecModel::from(rows))
|
||
}
|
||
|
||
#[test]
|
||
fn a_reload_keeps_the_pixels_of_photographs_that_are_still_in_the_window() {
|
||
let ids = [10i64, 11, 12];
|
||
let previous = model_of(&ids, &[10, 12]);
|
||
let classes = vec![Some(dr_thumbs::ThumbSize::Grid); 3];
|
||
|
||
let held = hold_thumbnails(&previous, &ids, &classes);
|
||
|
||
assert!(held.contains_key(&10), "a drawn cell is carried");
|
||
assert!(held.contains_key(&12));
|
||
assert!(
|
||
!held.contains_key(&11),
|
||
"a cell still waiting has nothing to carry"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_no_preview_verdict_is_carried_too() {
|
||
// Otherwise every scroll re-asks a question the server has already
|
||
// answered, and the cell blinks from "no preview" back to "…".
|
||
let ids = [7i64];
|
||
let rows = vec![LibraryCell {
|
||
unavailable: true,
|
||
..Default::default()
|
||
}];
|
||
let previous = slint::ModelRc::new(slint::VecModel::from(rows));
|
||
|
||
let held = hold_thumbnails(&previous, &ids, &[Some(dr_thumbs::ThumbSize::Grid)]);
|
||
assert!(held[&7].unavailable);
|
||
assert!(!held[&7].has_thumb);
|
||
}
|
||
|
||
#[test]
|
||
fn a_carried_cell_is_not_fetched_again_but_a_newly_scrolled_in_one_is() {
|
||
// The scroll this describes: the window moved down by one image, so
|
||
// 11 and 12 are still on screen and 13 has just arrived.
|
||
let ids = [10i64, 11, 12];
|
||
let previous = model_of(&ids, &[10, 11, 12]);
|
||
let held = hold_thumbnails(&previous, &ids, &[Some(dr_thumbs::ThumbSize::Grid); 3]);
|
||
|
||
let served = already_served(&held, [11i64, 12, 13].into_iter());
|
||
|
||
assert!(served.contains(&(11, dr_thumbs::ThumbSize::Grid)));
|
||
assert!(served.contains(&(12, dr_thumbs::ThumbSize::Grid)));
|
||
assert!(
|
||
!served.contains(&(13, dr_thumbs::ThumbSize::Grid)),
|
||
"a photograph the window has just reached must still be fetched"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_photograph_scrolled_out_of_the_window_is_fetched_again_on_return() {
|
||
// The trap in keeping the set rather than rebuilding it: image 10 was
|
||
// served once, but the model that held its pixels is long gone, so the
|
||
// cell would sit blank for ever if it still counted as served.
|
||
let held = hold_thumbnails(
|
||
&model_of(&[10], &[10]),
|
||
&[10],
|
||
&[Some(dr_thumbs::ThumbSize::Grid)],
|
||
);
|
||
|
||
let served = already_served(&held, [40i64, 41].into_iter());
|
||
assert!(served.is_empty(), "nothing in this window is already drawn");
|
||
}
|
||
|
||
#[test]
|
||
fn a_carried_thumbnail_does_not_satisfy_a_zoom_past_its_class() {
|
||
// Zooming past 256px reloads the window. The cell keeps showing the
|
||
// small thumbnail — no flash — but the large one must still be asked
|
||
// for, or the grid would stay soft until something else forced a fetch.
|
||
let held = hold_thumbnails(
|
||
&model_of(&[5], &[5]),
|
||
&[5],
|
||
&[Some(dr_thumbs::ThumbSize::Grid)],
|
||
);
|
||
let mut served = already_served(&held, [5i64].into_iter());
|
||
|
||
assert!(
|
||
served.insert((5, dr_thumbs::ThumbSize::Large)),
|
||
"the large class is still unserved"
|
||
);
|
||
assert!(
|
||
!served.insert((5, dr_thumbs::ThumbSize::Grid)),
|
||
"and the small one is not asked for twice"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_cell_whose_class_was_never_recorded_is_fetched_again() {
|
||
// Pixels with no class are pixels from before this bookkeeping existed
|
||
// — or from a row the drain never reached. Showing them is right;
|
||
// claiming they were served is not, because nothing knows at what size.
|
||
let held = hold_thumbnails(&model_of(&[5], &[5]), &[5], &[None]);
|
||
assert!(held.contains_key(&5), "still drawn");
|
||
assert!(already_served(&held, [5i64].into_iter()).is_empty());
|
||
}
|
||
|
||
/// A thumbnail drain must be able to tell that the window it was started
|
||
/// for has been replaced.
|
||
///
|
||
/// This is the whole of the black-grid fix, reduced to the comparison the
|
||
/// timer callback makes. `row` is an index into the window that requested
|
||
/// the fetch, so a drain that keeps writing after a reload paints
|
||
/// thumbnails onto unrelated photographs — and, worse, its `stop` lands on
|
||
/// the *current* batch's timer and leaves the new fetches undrained.
|
||
#[test]
|
||
fn a_reload_makes_an_in_flight_thumbnail_batch_stale() {
|
||
let ctl = LibraryController::new(crate::activity::ActivityLog::new());
|
||
|
||
// What `drain_thumbnails` captures when the batch is spawned.
|
||
let mine = ctl.generation.get();
|
||
assert_eq!(ctl.generation.get(), mine, "its own batch is live");
|
||
|
||
// What `load_window` does just before swapping the model.
|
||
ctl.generation.set(ctl.generation.get().wrapping_add(1));
|
||
|
||
assert_ne!(
|
||
ctl.generation.get(),
|
||
mine,
|
||
"the batch must recognise itself as stale once the model is replaced"
|
||
);
|
||
}
|
||
|
||
/// Each load is distinct, so two reloads cannot alias back to a live batch.
|
||
#[test]
|
||
fn every_window_load_takes_a_fresh_generation() {
|
||
let ctl = LibraryController::new(crate::activity::ActivityLog::new());
|
||
let seen: Vec<u64> = (0..4)
|
||
.map(|_| {
|
||
let g = ctl.generation.get();
|
||
ctl.generation.set(g.wrapping_add(1));
|
||
g
|
||
})
|
||
.collect();
|
||
|
||
assert_eq!(seen, vec![0, 1, 2, 3]);
|
||
}
|
||
}
|