Merge master into film-simulation
🐳 Android image / Build and push (push) Successful in 0s
Build and test / android-image (push) Successful in 1s
Build and test / Desktop (Linux) (push) Failing after 59s
Build and test / Layer separation (push) Successful in 26s
Traceability / Requirement traces (push) Successful in 1m1s
Build and test / Android (aarch64) (push) Failing after 22m44s

Master gained the library's index paging while this branch was building
the film simulation, and the two met in library_ui.rs. Only the generated
traceability matrix conflicted; it is regenerated here rather than
hand-resolved, which is what it is for.
This commit is contained in:
2026-08-25 20:34:28 +02:00
4 changed files with 379 additions and 90 deletions
+125 -26
View File
@@ -61,6 +61,30 @@ const MIN_WINDOW: usize = 24;
const MIN_CELL_SIZE: f32 = 90.0;
const MAX_CELL_SIZE: f32 = 420.0;
/// TRACES: NFR-P5
/// What the grid's whole-library readouts are an answer about.
///
/// The timeline's bars, the filter chips' counts, the "on this device" count
/// and whether the scoped collection is pinned all describe the *library*, not
/// the window over it — and [`load_window`] recomputed every one of them each
/// time the window moved, which is several times per screenful of scrolling.
/// Together they are a `MIN`/`MAX`, a `GROUP BY`, two counts and, under a
/// collection, three more queries: about 8 ms of SQLite on the thread that is
/// trying to draw the frame, for four answers that scrolling cannot change.
///
/// So they are recomputed when this changes and not otherwise. `total` is in
/// here as the change detector as much as anything else: it is already read on
/// every load for the scrollbar, and a scan landing, a delete or a restore all
/// move it. What it cannot see — a rating edited under an unchanged count — is
/// covered because the paths that do that refresh the chips themselves.
#[derive(Clone, Copy, PartialEq, Eq)]
struct LibraryFacts {
scope: Option<dr_types::CollectionId>,
filter: library::RatingFilter,
trash: bool,
total: usize,
}
/// Library state for the running window.
pub struct LibraryController {
/// Shared with [`crate::collections_ui`], which edits collections against
@@ -77,6 +101,12 @@ pub struct LibraryController {
/// Catalog row ids and whether each still needs its EXIF read.
image_ids: RefCell<Vec<i64>>,
needs_metadata: RefCell<Vec<bool>>,
/// When each row in the model was taken, parallel to it.
///
/// Kept so the timeline marker can be moved from the window the grid has
/// already read rather than from a query per scroll event — see
/// [`capture_time_at`].
captured_at: RefCell<Vec<Option<i64>>>,
/// The size class of the pixels each row is currently showing, `None` for a
/// row still waiting.
///
@@ -109,6 +139,9 @@ pub struct LibraryController {
/// window, wasteful on a narrow one. The grid measures itself and reports
/// its screenful; this is [`SCREENFULS`] of them.
window: RefCell<usize>,
/// What the whole-library readouts on screen were last computed for, so a
/// window that merely moved does not recompute them. See [`LibraryFacts`].
library_facts: std::cell::Cell<Option<LibraryFacts>>,
/// How many cells the viewport shows at once, as the grid last reported.
///
/// Kept beside `window` rather than divided back out of it, because
@@ -308,11 +341,13 @@ impl LibraryController {
sizes: RefCell::new(Vec::new()),
image_ids: RefCell::new(Vec::new()),
needs_metadata: RefCell::new(Vec::new()),
captured_at: RefCell::new(Vec::new()),
thumb_class: RefCell::new(Vec::new()),
offset: RefCell::new(0),
resume_at: std::cell::Cell::new(0),
window: RefCell::new((INITIAL_VIEWPORT_CELLS * SCREENFULS).max(MIN_WINDOW)),
viewport_cells: std::cell::Cell::new(INITIAL_VIEWPORT_CELLS),
library_facts: std::cell::Cell::new(None),
requested: RefCell::new(Default::default()),
scan_timer: RefCell::new(None),
thumb_timer: RefCell::new(None),
@@ -2004,24 +2039,39 @@ fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
// query rather than another predicate threaded through the scoped one.
let trash = ctl.viewing_trash.get();
// 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.
window.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,
});
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,
trash,
total,
};
let describes_something_new = ctl.library_facts.get() != Some(facts);
ctl.library_facts.set(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.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.
@@ -2036,8 +2086,17 @@ fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
*ctl.offset.borrow_mut() = offset;
window.set_library_offset(offset as i32);
// The date range this window covers, so the scrubber can label itself.
refresh_timeline(window, catalog, ctl);
// 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);
}
let cells = if trash {
library::read_trashed_cells(catalog, offset, window_size)
@@ -2148,6 +2207,7 @@ fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
*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))
@@ -2178,9 +2238,13 @@ fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
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, so
// they are refreshed here rather than per cell.
refresh_rating_counts(window, catalog);
// The filter chips' counts describe the whole library, not this window —
// which is exactly why they are not recomputed for a window that moved.
// A judgement changes them and calls this itself (see `apply_judgement`),
// so a cull still watches its own chips move.
if describes_something_new {
refresh_rating_counts(window, catalog);
}
// The library got shorter while the view was looking at it — a delete.
//
@@ -3987,18 +4051,53 @@ fn catalog_span(catalog: &Catalog, ctl: &Rc<LibraryController>) -> Option<(i64,
library::span_scoped(catalog, *ctl.scope.borrow(), &ctl.filter.borrow())
}
/// TRACES: NFR-P5
/// Capture time of the image at row `ordinal` in the grid's own ordering.
///
/// The inverse of the count in [`scrub_to`], and it must stay the inverse: the
/// same `shadowed_by IS NULL` exclusion and the same ordering, or scrolling
/// would report an instant the scrub would never produce for that row.
///
/// `None` for an ordinal that lands among the undated tail, which sorts last
/// and has no place on a capture-time axis.
///
/// Called on every scroll event, so it has to stay cheap: the `images_captured`
/// index makes it a seek along already-ordered rows rather than a sort.
fn capture_time_at(catalog: &Catalog, ordinal: usize) -> Option<i64> {
/// # Answered from the loaded window, not from the catalog
///
/// This moves the timeline marker, so it is called on **every scroll event** —
/// every row the view crosses, by design, or the marker would advance in jerks
/// while the photographs beside it moved smoothly.
///
/// It used to be a query, and `LIMIT 1 OFFSET n` is not a seek: SQLite reaches
/// row `n` by producing and discarding the `n` before it. So its cost grew with
/// how far down the library the user had scrolled — 0.02 ms near the top, 0.7 ms
/// at twenty thousand — and it was paid per row crossed, on the thread drawing
/// the frame. A flick down a long library therefore got *choppier the further
/// it went*, which is a strange enough symptom to be worth naming.
///
/// The window the grid has already read holds this answer. `window_move` keeps
/// the whole view inside that window, so the lookup is a vector index at the
/// position the ordinal has in it.
///
/// # The fallback, and why it is also more correct than what it replaces
///
/// Outside the loaded window — briefly, after a scrub or a keyboard jump, before
/// the load lands — it falls back to the query. That query counts in a
/// *dated-only* ordering while `ordinal` is a grid row, so the two disagree
/// wherever undated images sit in between; it is kept because it is close
/// enough for a marker that is about to be corrected, and because being wrong
/// there is what it always did. The window path has no such disagreement: it
/// reads the very cell the row belongs to.
fn capture_time_at(ctl: &Rc<LibraryController>, catalog: &Catalog, ordinal: usize) -> Option<i64> {
{
let offset = *ctl.offset.borrow();
let loaded = ctl.captured_at.borrow();
if ordinal >= offset {
if let Some(when) = loaded.get(ordinal - offset) {
return *when;
}
}
}
capture_time_from_catalog(catalog, ordinal)
}
/// The fallback of [`capture_time_at`], for a row outside the loaded window.
fn capture_time_from_catalog(catalog: &Catalog, ordinal: usize) -> Option<i64> {
catalog
.connection()
.query_row(
@@ -4598,7 +4697,7 @@ pub fn wire<F>(
{
let borrow = ctl.catalog.borrow();
if let Some(catalog) = borrow.as_ref() {
if let Some(when) = capture_time_at(catalog, first_visible) {
if let Some(when) = capture_time_at(&ctl, catalog, first_visible) {
*ctl.current_bucket.borrow_mut() = Some(when);
let zoom = *ctl.timeline_zoom.borrow();