Stop zooming rebuilding the grid twice a frame

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
re-queried the catalog, rebuilt all 360 rows of the model, re-read the
badges and ratings for every one of them and spawned a thumbnail batch,
synchronously, on the thread 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 that work is urgent, because none of it is about which
photographs are on screen. The window holds the same images however they
are laid out — the model already has them, and the cells re-flow from
`columns` and `cell-size` with Rust not involved at all. What the reload
actually recomputes is which cells begin a row, so the month headings
land correctly, and which thumbnail size class to ask for now. Both can
wait for the gesture to finish, so both are now coalesced behind a single
settle timer: replacing the timer drops the previous one, and only the
last report of a run lives long enough to fire.

The anchor is 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 the
drift; reading the anchor at the end would faithfully return to wherever
it had wandered. Taking it at the start returns to the photograph the
user was looking at when they started the gesture.
This commit is contained in:
2026-08-21 20:44:56 +02:00
parent 12a457b8d0
commit d2c909414c
+90 -27
View File
@@ -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<Option<slint::Timer>>,
/// 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<Option<usize>>,
pin_timer: RefCell<Option<slint::Timer>>,
/// 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<LibraryController>) {
// 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<F>(
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<F>(
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);
});
}