Move the timeline marker from the window the grid already read
Build and test / Desktop (Linux) (push) Failing after 1m1s
Build and test / Layer separation (push) Successful in 24s
Traceability / Requirement traces (push) Successful in 22s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Build and test / Android (aarch64) (push) Failing after 22m19s

The last of the per-scroll queries, and the strangest of them: this one got
slower the further down the library you had scrolled.

The marker has to follow every scroll event or it advances in jerks while the
photographs beside it move smoothly — that part is right and stays. What was
wrong is that each event asked the catalog `LIMIT 1 OFFSET n`, and that is not
a seek: SQLite reaches row `n` by producing and discarding the `n` rows before
it. 0.02 ms near the top of the library, 0.7 ms at twenty thousand, per row
crossed, on the thread drawing the frame. A flick therefore got choppier the
longer it went on.

The window the grid has already read holds the answer, and since the loaded
window now covers the whole view, the row is nearly always in it. So this is a
vector index at the position the ordinal has in the window, and the query
survives only as the fallback for a row outside it — briefly, after a scrub or
a keyboard jump, before the load lands.

The fallback is also the less correct of the two, which is worth recording
rather than quietly keeping: it counts in a dated-only ordering while the
argument is a grid row, so the two disagree wherever undated frames sit in
between. It is kept because a marker about to be corrected is not worth a
second index, 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.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-25 20:01:51 +02:00
co-authored by Claude Opus 5
parent 0f231afe85
commit 3330f350a4
2 changed files with 72 additions and 29 deletions
+51 -8
View File
@@ -101,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.
///
@@ -335,6 +341,7 @@ 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),
@@ -2200,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))
@@ -4038,18 +4046,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(
@@ -4649,7 +4692,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();