//! 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, } /// **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, ids: &[i64], classes: &[Option], ) -> std::collections::HashMap { 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, ids: impl Iterator, ) -> std::collections::HashSet<(i64, dr_thumbs::ThumbSize)> { use dr_thumbs::ThumbSize; let mut served = std::collections::HashSet::new(); for id in ids { let Some(class) = held.get(&id).and_then(|h| h.class) else { continue; }; served.insert((id, class)); // The large class is everything the grid class would be, and more: a // cell the store answered with it has nothing left to ask for. if class == ThumbSize::Large { served.insert((id, ThumbSize::Grid)); } } served } /// 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 { // 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 { (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, at: usize, ) -> Option { let loaded = ctl.paths.borrow().len(); let size = *ctl.window.borrow(); let total = window.global::().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()) } /// TRACES: FR-MRG-6 /// Ask the grid to show ordinal `at` in its first row. /// /// The grid scrolls by slots, which a panorama's cell makes different from /// ordinals; this is the one conversion, so no caller spells `scroll-to` /// in the wrong unit. pub(super) fn seek_to(window: &AppWindow, ctl: &LibraryController, at: usize) { let slot = ctl.layout.borrow().slot_of(at); window .global::() .set_library_scroll_to(slot as i32); window .global::() .set_library_scroll_token(window.global::().get_library_scroll_token() + 1); } /// Put the keyboard cursor on ordinal `at`, telling the grid where its cell /// is so its row can be kept in view. pub(super) fn show_cursor(window: &AppWindow, ctl: &LibraryController, at: usize) { window.global::().set_library_cursor(at as i32); window .global::() .set_library_cursor_slot(ctl.layout.borrow().slot_of(at) as i32); } /// 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::() .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::() .set_library_roll_current(row as i32); window.set_index((offset + row) as i32); } Err(left) => { ctl.roll_left.set(left); window.global::().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 { 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) { 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.grid_scope(); 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); // A different list has its panoramas in different places. if describes_something_new { *ctl.wide.borrow_mut() = None; } // 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::() .set_library_scope_pinned(match scope { Some(library::Scope::Collection(id)) => { dr_catalog::collections::descendants(catalog.connection(), id) .map(|ids| collection_images(catalog, &ids)) .map(|images| scope_is_pinned(catalog, &images)) .unwrap_or(false) } // An album is not pinned as a unit; its photographs are // pinned where they are filed. Some(library::Scope::Album(_)) | 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::().get_library_total().max(0) as usize; window.global::().set_library_total(total as i32); // TRACES: FR-CAT-7 | FR-EXP-10 // The sidebar's "All photographs" is the whole library whatever is // scoped. The same number as `total` when nothing is — no second count // then — and otherwise read only when the facts above moved, so a // scroll inside an album does not recount the library. if scope.is_none() && !trash { window .global::() .set_library_whole_total(total as i32); } else if describes_something_new { let whole = library::total_images_scoped(catalog, None, &filter).unwrap_or(0); window .global::() .set_library_whole_total(whole 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::().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::().set_library_reorderable( !trash && matches!(scope, Some(library::Scope::Collection(c)) if 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::() .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::().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(), ); // TRACES: FR-MRG-6 // Where each cell goes: the layout of the whole list, checked against // what this window says about its own photographs' shapes and read again // if they disagree — a reorder, a merge, a composite's size learned. let columns = window.global::().get_library_columns().max(1) as usize; let layout = place_cells( catalog, ctl, &cells, offset, total, columns, scope, &filter, trash, ); window .global::() .set_library_total_slots(layout.total_slots() as i32); let cursor = window.global::().get_library_cursor(); if cursor >= 0 { window .global::() .set_library_cursor_slot(layout.slot_of(cursor as usize) as i32); } // 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 headings = period_headings( cells.iter().enumerate().map(|(i, c)| { ( c.captured_at.map(|t| { let (y, m, _, _) = civil_from_unix(t); (y, m) }), layout.slot_of(offset + i), ) }), columns, ); // What the outgoing model is still holding — see [`hold_thumbnails`]. let held = { let previous = window.global::().get_library_cells(); let ids = ctl.image_ids.borrow(); let classes = ctl.thumb_class.borrow(); hold_thumbnails(&previous, &ids, &classes) }; // A composite a merge has just catalogued is drawn from the merge's own // thumbnails until the store can serve it (FR-MRG-6), and counts as // served at that class so no fetch is spent on a file still uploading. let cell_pixels = window.global::().get_library_cell_size().max(1.0) as u32; let mut held = held; { let merged = ctl.merged_thumbs.borrow(); for (i, c) in cells.iter().enumerate() { let Some(made) = merged.get(&c.image_id) else { continue; }; let wanted = dr_thumbs::ThumbSize::for_span(layout.class_span(offset + i), cell_pixels); if held .get(&c.image_id) .is_some_and(|h| h.class == Some(wanted)) { continue; } let pick = made .iter() .find(|(class, _)| *class == wanted) .or_else(|| made.iter().max_by_key(|(class, _)| class.edge())); if let Some((class, image)) = pick { held.insert( c.image_id, Held { thumbnail: image.clone(), has_thumb: true, unavailable: false, class: Some(*class), }, ); } } } let rows: Vec = cells .iter() .zip(headings) .enumerate() .map(|(i, (c, heading))| { let carried = held.get(&c.image_id); LibraryCell { slot: layout.slot_of(offset + i) as i32, span: layout.span_of(offset + i) as i32, 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::() .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 = 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); } /// TRACES: FR-MRG-6 /// The layout of the current list, made to agree with the window just read. /// /// The panoramas' ordinals are kept between loads — a scroll does not move /// them — and read again only when they are unknown or when this window /// finds a photograph wide that the layout does not, or the other way round. /// That check is over the window's own rows, in memory; the read it can /// trigger is one query ([`library::wide_ordinals`]). #[allow(clippy::too_many_arguments)] fn place_cells( catalog: &dr_catalog::Catalog, ctl: &LibraryController, cells: &[library::LibraryCell], offset: usize, total: usize, columns: usize, scope: Option, filter: &library::RatingFilter, trash: bool, ) -> super::layout::Layout { use super::layout::{natural_span, Layout}; let full_width = dr_plat::is_touch_first(); let natural: Vec = cells.iter().map(|c| natural_span(c.aspect)).collect(); let read = || -> Vec<(usize, usize)> { match library::wide_ordinals(catalog, scope, filter, trash) { Ok(wide) => wide .into_iter() .map(|(ordinal, aspect)| (ordinal, natural_span(Some(aspect)))) .collect(), Err(e) => { log::warn!("reading the grid's panoramas: {e}"); Vec::new() } } }; if ctl.wide.borrow().is_none() { *ctl.wide.borrow_mut() = Some(read()); } let pack = |wide: &[(usize, usize)]| Layout::pack(columns, total, full_width, wide); let mut layout = pack(ctl.wide.borrow().as_deref().unwrap_or_default()); if !layout.agrees_with(offset, &natural, full_width) { let wide = read(); layout = pack(&wide); *ctl.wide.borrow_mut() = Some(wide); } *ctl.layout.borrow_mut() = layout.clone(); layout } /// 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) { let Some((conn, _)) = ctl.session.borrow().clone() else { return; }; // The drawn cell size decides which class to ask for, and a panorama's // cell asks for the wide one (FR-MRG-6). Carried through to the drain per // row, so a cell it fills can record what it is now showing. let cell_pixels = window.global::().get_library_cell_size().max(1.0) as u32; let offset = *ctl.offset.borrow(); let classes: Vec = { let layout = ctl.layout.borrow(); (0..ctl.paths.borrow().len()) .map(|i| dr_thumbs::ThumbSize::for_span(layout.class_span(offset + i), cell_pixels)) .collect() }; let mut wanted: Vec = { 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 = classes .get(i) .copied() .unwrap_or(dr_thumbs::ThumbSize::Grid); // 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(offset); 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, classes); } /// 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, remote_path: &str, mut render: impl FnMut(u32) -> Result<(u32, u32, Vec), 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::().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::().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, 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, ctl: Rc, rx: Receiver, requested: usize, classes: Vec, ) { 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::().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, standing_in, } => { // Date reads produce no cell, so they are counted into // the bar's denominator or it finishes while work is // still running. A stand-in is delivered once from the // store and again from the fetch, so it counts twice. job.add_total(dating + standing_in); 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::().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. The // pixels' own class, not the one the row asked for: // a stand-in recorded as the class it stands in for // would never be replaced. record_class(&ctl_cb, t.row, t.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. if let Some(class) = classes.get(row) { 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). pub(super) fn to_slint_image(width: u32, height: u32, rgba: &[u8]) -> slint::Image { let mut buf = slint::SharedPixelBuffer::::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 = (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 { let rows: Vec = 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_large_thumbnail_satisfies_the_grid_class() { // Zooming out past 256px reloads the window at the grid class. A cell // already showing the large class has every pixel the small one would // give, so asking the store for it again is a read for nothing. let held = hold_thumbnails( &model_of(&[5], &[5]), &[5], &[Some(dr_thumbs::ThumbSize::Large)], ); let served = already_served(&held, [5i64].into_iter()); assert!(served.contains(&(5, dr_thumbs::ThumbSize::Large))); assert!(served.contains(&(5, dr_thumbs::ThumbSize::Grid))); } #[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 = (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]); } }