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); }); }