diff --git a/ui/dr-ui/src/library_ui.rs b/ui/dr-ui/src/library_ui.rs
index 806034d..2f86dfa 100644
--- a/ui/dr-ui/src/library_ui.rs
+++ b/ui/dr-ui/src/library_ui.rs
@@ -206,6 +206,13 @@ pub struct LibraryController {
/// TRACES: FR-NC-6a
/// Drains the pin downloader. Held so a second pin replaces the timer
/// rather than leaving two draining the same finished channel.
+ /// Coalesces the reloads a run of geometry changes would otherwise each
+ /// demand — see [`schedule_reload`].
+ geometry_timer: RefCell>,
+ /// The ordinal the view was on when the current run of geometry changes
+ /// began, so the settle returns to the photograph the user was looking at
+ /// rather than to wherever the re-flow left the viewport.
+ pending_anchor: std::cell::Cell >,
pin_timer: RefCell >,
/// TRACES: FR-NC-6a
/// Which collection the offline question is being asked about.
@@ -286,6 +293,8 @@ impl LibraryController {
reachability: RefCell::new(dr_sync::Reachability::new()),
outbox_timer: RefCell::new(None),
outbox_maybe_dirty: std::cell::Cell::new(true),
+ geometry_timer: RefCell::new(None),
+ pending_anchor: std::cell::Cell::new(None),
pin_timer: RefCell::new(None),
offline_target: std::cell::Cell::new(None),
row_hold_timer: RefCell::new(None),
@@ -1608,6 +1617,73 @@ fn open_catalog_for_offline(
}
}
+/// How long a run of geometry changes has to stop for before the window is
+/// reloaded against it.
+///
+/// Long enough to swallow a whole gesture's worth of steps, short enough that a
+/// single deliberate step still feels immediate.
+const GEOMETRY_SETTLE: std::time::Duration = std::time::Duration::from_millis(140);
+
+/// Reload the window once the grid's geometry has stopped changing.
+///
+/// # Why this is deferred when a scroll is not
+///
+/// A pinch is not one zoom step, it is a stream of them, and every step
+/// changes both the column count and the capacity — two reports. Each report
+/// used to re-query the catalog, rebuild all 360 rows of the model, re-read the
+/// badges and ratings for every one of them and spawn a thumbnail batch,
+/// synchronously, on the thread that is trying to draw the frame. Twice per
+/// step. That is why zooming juddered while scrolling the same grid is smooth:
+/// a scroll reloads a few times per screenful, a zoom reloaded twice a frame.
+///
+/// None of it is urgent, because none of it is about *which* photographs are on
+/// screen. A column change moves the cells and a zoom resizes them, but the
+/// window holds the same images either way — the model already has them, and
+/// the cells re-flow from `columns` and `cell-size` without Rust being involved
+/// at all. What the reload actually recomputes is which cells begin a row, so
+/// the month headings land in the right places, and which thumbnail size class
+/// to ask for now. Both can wait for the gesture to finish.
+///
+/// # The anchor
+///
+/// Captured on the *first* report of a run rather than read when the timer
+/// fires. As the grid re-flows, the viewport keeps its pixel offset while the
+/// rows move underneath it, so the view drifts and reports its drift — reading
+/// the anchor at the end would faithfully return to wherever it had wandered
+/// to. Taking it at the start returns to the photograph the user was actually
+/// looking at when they began the gesture.
+fn schedule_reload(window: &AppWindow, ctl: &Rc) {
+ // Only the first report of a run sets it; the rest of the run reuses it.
+ if ctl.pending_anchor.get().is_none() {
+ ctl.pending_anchor.set(Some(ctl.resume_at.get()));
+ }
+
+ let timer = slint::Timer::default();
+ let weak = window.as_weak();
+ let ctl_cb = ctl.clone();
+ timer.start(slint::TimerMode::SingleShot, GEOMETRY_SETTLE, move || {
+ let Some(w) = weak.upgrade() else { return };
+ let anchor = ctl_cb.pending_anchor.take().unwrap_or(0);
+ if !w.get_show_library() {
+ return;
+ }
+
+ // The window re-centred on the anchor, and the viewport sent back to
+ // it: cells are drawn at their absolute place in the library, so a
+ // change to `columns` moves every one of them and a viewport left
+ // where it was would be pointing at rows the window no longer covers.
+ let window_size = *ctl_cb.window.borrow();
+ *ctl_cb.offset.borrow_mut() = anchor.saturating_sub(window_size / 4);
+ load_window(&w, &ctl_cb);
+ w.set_library_scroll_to(anchor as i32);
+ w.set_library_scroll_token(w.get_library_scroll_token() + 1);
+ });
+
+ // Replacing the slot drops the previous timer, which is what makes this
+ // coalesce: only the last report of a run lives long enough to fire.
+ *ctl.geometry_timer.borrow_mut() = Some(timer);
+}
+
/// Show what the catalog already holds, without waiting for the scan.
///
/// # Why a launch should not be a scan
@@ -3517,33 +3593,17 @@ pub fn wire(
return;
}
- // **The viewport has to be re-anchored, not just the window.**
- //
- // Cells are drawn at their absolute place in the library, so
- // the row a photograph sits on is `index / columns` — and this
- // callback is the news that `columns` just changed. Every cell
- // therefore moved. The Flickable's `viewport-y` did not: the
- // view was left pointing at a row that now holds entirely
- // different photographs, thousands of images from the ones the
- // loaded window covers. The grid goes blank, and stays blank
- // until a scroll reports a first-visible row and drags the
- // window back under the view.
- //
- // That is the "the gallery randomly goes blank and a scroll
- // fixes it" report. Nothing about its triggers is rare: a
- // resize, the collections sidebar opening, a zoom step, or
+ // Deferred, and re-anchored when it lands — see
+ // [`schedule_reload`]. A column change moves every cell in the
+ // grid, because a cell is drawn at its absolute place in the
+ // library and the row that resolves to is `index / columns`.
+ // The viewport does not move with them, so without the
+ // re-anchor the view is left pointing at rows the loaded window
+ // no longer covers and the grid draws nothing at all — the
+ // "gallery randomly goes blank until I scroll" report, whose
+ // triggers are a resize, the sidebar opening, a zoom step, or
// turning the tablet over.
- //
- // `resume_at` is the last first-visible ordinal reported, so it
- // names the photograph the user was looking at. Sending it back
- // through `scroll-to` puts that same photograph at the top of
- // the view, at whatever row it now occupies.
- let anchor = ctl.resume_at.get();
- let window_size = *ctl.window.borrow();
- *ctl.offset.borrow_mut() = anchor.saturating_sub(window_size / 4);
- load_window(&w, &ctl);
- w.set_library_scroll_to(anchor as i32);
- w.set_library_scroll_token(w.get_library_scroll_token() + 1);
+ schedule_reload(&w, &ctl);
}
});
}
@@ -3564,7 +3624,10 @@ pub fn wire(
return;
}
*ctl.window.borrow_mut() = capacity;
- load_window(&w, &ctl);
+ // Coalesced with the column change that almost always accompanies
+ // it: resizing the cells alters both, and reloading once per report
+ // meant two full rebuilds per zoom step.
+ schedule_reload(&w, &ctl);
});
}