Files
DarkRoom/ui/dr-ui/src/library_ui/window.rs
T
dtourolle 19dd3257e3 Release the offset borrow before bring_window_to reloads the window
Holding D in develop across the edge of the loaded window panicked with
"RefCell already borrowed" at the first step that had to move it. The
`*ctl.offset.borrow()` written inside the `if let` condition is a
temporary that lives to the end of the `if let` block (edition 2021),
and the block calls `load_window`, which borrows the offset mutably.
The unit tests drive the placement arithmetic, not the RefCells, so
they could not see it; stepping 400 frames in the app did.

The offset is copied out before the test. `follow_open` gets the same
treatment for `image_ids`: the lookup's result is bound first, so no
borrow is held while it writes properties back to the window.
2026-09-25 23:06:24 -04:00

1428 lines
60 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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_label_counts, 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)
}
/// Where the loaded window has to move to so that it holds ordinal `at`, or
/// `None` if it already does.
///
/// Placed by [`window_start`], a quarter in, as a scroll places it — so a walk
/// that carries on in the same direction has loaded cells to move into rather
/// than another reload on the very next step.
pub(super) fn window_for(
at: usize,
offset: usize,
loaded: usize,
window_size: usize,
total: usize,
) -> Option<usize> {
(at < offset || at >= offset + loaded).then(|| window_start(at, window_size, total))
}
/// Load the window around library ordinal `at` if it is not already in it,
/// and say which row of it `at` now is.
///
/// `None` when the window could not be brought there — an empty or shrinking
/// library — which callers treat as "do nothing" rather than guess at a row.
pub(super) fn bring_window_to(
window: &AppWindow,
ctl: &Rc<LibraryController>,
at: usize,
) -> Option<usize> {
let loaded = ctl.paths.borrow().len();
let size = *ctl.window.borrow();
let total = window.global::<Library>().get_library_total().max(0) as usize;
// Copied out first: a `borrow()` written in the `if let` below would live
// to the end of its block, and `load_window` borrows the offset mutably.
let offset = *ctl.offset.borrow();
if let Some(offset) = window_for(at, offset, loaded, size, total) {
*ctl.offset.borrow_mut() = offset;
load_window(window, ctl);
}
// Re-read: `load_window` clamps the offset against the library's end, so
// the window may not start where it was asked to.
let offset = *ctl.offset.borrow();
at.checked_sub(offset)
.filter(|row| *row < ctl.paths.borrow().len())
}
/// Record that the photograph at `row` of the loaded window is the one now
/// open in develop, and mark it on the roll.
pub(super) fn mark_open(window: &AppWindow, ctl: &LibraryController, row: usize) {
ctl.roll_open.set(ctl.image_ids.borrow().get(row).copied());
ctl.roll_left.set(false);
window
.global::<Library>()
.set_library_roll_current(row as i32);
}
/// Put the roll's mark back on the open photograph after the window was
/// re-read.
///
/// **The mark is a row, and a reload changes what every row holds.** It used
/// to be set when a photograph was opened and never again, so once the window
/// moved — a step off its end, a background sync, a judgement under a filter —
/// the roll marked whichever photograph had taken that row, the rating keys in
/// develop judged it, and the next step walked on from it. Found by id in the
/// window just read: a scan of one window, not a query.
///
/// Where it is found, `index` is corrected with it, since that ordinal is what
/// the next step and the return to the grid start from. Where it is not, the
/// mark comes off; and if its ordinal is still inside the window, it is a
/// photograph that has left the grid rather than one the window has moved away
/// from — see [`LibraryController::roll_left`].
fn follow_open(window: &AppWindow, ctl: &LibraryController, offset: usize) {
let Some(open) = ctl.roll_open.get() else {
return;
};
let index = window.get_index().max(0) as usize;
let found = locate_open(open, &ctl.image_ids.borrow(), offset, index);
match found {
Ok(row) => {
ctl.roll_left.set(false);
window
.global::<Library>()
.set_library_roll_current(row as i32);
window.set_index((offset + row) as i32);
}
Err(left) => {
ctl.roll_left.set(left);
window.global::<Library>().set_library_roll_current(-1);
}
}
}
/// Which row of a window starting at `offset` holds photograph `open`, or, if
/// none does, whether it has left the grid: its last known ordinal `index` is
/// still inside the window, so something else now sits there.
pub(super) fn locate_open(
open: i64,
ids: &[i64],
offset: usize,
index: usize,
) -> Result<usize, bool> {
ids.iter()
.position(|id| *id == open)
.ok_or(index >= offset && index < offset + ids.len())
}
/// 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,
label: 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 rows have just changed meaning, so the roll's mark — a row — has to
// be found again rather than left pointing at whoever sits there now.
follow_open(window, ctl, offset);
// 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);
refresh_label_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]);
}
}