From 9a1654136f37252636c8ef5ff1f3c3fa501cc209 Mon Sep 17 00:00:00 2001 From: Greg Pomerantz Date: Thu, 29 Oct 2026 11:53:45 -0400 Subject: [PATCH] M4.1: instant viewer (no splash) + idle full-res prefetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit open_viewer never shows 'Loading…' anymore: 1. full-res LRU cache hit (8 entries / 64 MP of RGBA bytes) -> the image is shown in the same frame as the tap (decode already done by idle prefetch or a previous view); the ~3 ms GPU upload is the grid's proven hitch-free path (slint::Image is !Send, so the cache holds bytes and uploads on tap); 2. else the tapped cell's THUMBNAIL (already a GPU texture, same aspect) is shown instantly and the full decode (urgent: bypasses the idle budget) swaps in silently on arrival - same aspect, so s is rescaled by old/new (Input::rebase_content), tx/ty (viewer px) stay put: identical bounds, one content change, any pan/zoom/pinch the user did on the thumb is preserved; 3. only if the cell has no painted thumb either is there a black frame (ViewerUi.waiting names the path the viewer waits on; a failed decode prints an error only for that path). Tick step 4c (prefetch_full): when the viewport is stable, the viewer is closed and the thumb pipeline is caught up (inflight empty after FILL - during fast scrolling it stays non-empty, so prefetch only starts once scrolling is done), decode + cache one center-out visible image per tick, max 2 in flight. In-flight decodes are NOT cancelled by scrolling: results still land in the cache (that IS the value). All caches clear on set_list / world reset. Verified on the emulator: cache-hit tap '(full 1200x1600 cached)' instant; uncached tap '(thumb 159x400 up, full in flight)' -> '(swapped in)' 137 ms later; corrupt file -> error text, no splash. New unit test: rebase_content_preserves_viewer_mapping (34 pass). --- HANDOFF.md | 26 +++ src/lib.rs | 501 ++++++++++++++++++++++++++++++++++++++++++++------ src/viewer.rs | 44 +++++ 3 files changed, 518 insertions(+), 53 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 5b3cb55..c9d9a8e 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -581,6 +581,32 @@ flush (Mali driver back-pressure). Fix + evidence: §8.9 and photos): first screen ~0.7 s cold at 500 px; scroll = sustained 60 fps with 0 display hitches (cold and warm cache) — see §8 for the measurement method (gfxinfo does NOT track Slint's window).* +- **M4.1 — instant viewer + idle prefetch (DONE, 2026-10-29)**: the + splash is gone. `open_viewer` never shows "Loading…": (1) the path is + in the full-res LRU cache (`full_cache`: 8 entries / 64 MP of RGBA + bytes — `slint::Image` is !Send, so the cache holds bytes and the + ~3 ms GPU upload happens at tap time, the grid's hitch-free path) → + shown instantly, no decode; (2) else the tapped cell's THUMBNAIL is + shown in the same frame as the tap (Image read from the model, size + from `thumb_dims`) and the full decode (urgent — bypasses the idle + budget) swaps in silently on arrival: same aspect → s is rescaled by + old/new via `Input::rebase_content`, tx/ty (viewer px) unchanged → + identical bounds, one content change, any pan/zoom/pinch the user did + on the thumb is preserved; (3) only if the cell has no painted thumb + either is there a black frame (`waiting` = the path the viewer is + waiting on; a failed decode prints an error only for that path — a + failing prefetch is silent). Tick step 4c (`prefetch_full`): while the + viewport is stable, the viewer is closed and the thumb pipeline is + caught up (`inflight` empty after FILL — during fast scrolling it + stays non-empty, so prefetch only starts when scrolling is done), one + center-out decode per tick, max 2 in flight (`full_inflight`), + results cached even if the viewer never opens. In-flight decodes are + NOT cancelled by scrolling (results still land in the cache). All + caches clear on `set_list`/world reset. Verified on the emulator: + cache-hit tap `(full 1200x1600 cached)` — instant; uncached tap + `(thumb 159x400 up, full in flight)` → `(swapped in)` 137 ms later; + corrupt file → error text (no splash). Unit test: + `rebase_content_preserves_viewer_mapping`. - **M4 — viewer (DONE, 2026-10-29)**: tap cell → full decode (turbojpeg DCT shrink, 2×-screen cap bounded by GPU max texture, EXIF orientation applied) + full-screen overlay; pinch zoom (Slint's diff --git a/src/lib.rs b/src/lib.rs index b6ba246..4edc8b0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -277,19 +277,110 @@ struct AppState { /// load (reopen / close / list reset / world recreation); the load /// thread checks it before touching the UI. viewer: Mutex, + /// Full-res viewer cache (M4.1): decoded full images (RGBA bytes — + /// `slint::Image` is !Send so the texture is uploaded on tap/swap, + /// the same hitch-free path the grid uses), LRU-capped by count AND + /// pixel budget. Idle prefetch and on-demand viewer loads both land + /// here; a tap on a cached path skips the DECODE (the expensive part) + /// and opens instantly. UI thread only. + full_cache: Mutex, + /// Full-res decodes in flight (on-demand or prefetch). path → submit + /// time. UI thread only. + full_inflight: Mutex>, + /// Paths whose FULL decode failed (separate from the thumb `failed` + /// set: a full decode can fail where the 400 px thumb succeeds, e.g. + /// the GPU texture cap). UI thread only. + full_failed: Mutex>, + /// Decoded sizes of painted cells: entry index → (w, h). Mirrors + /// `painted` (insert on paint, drop on eviction) so a tapped cell's + /// viewer can be fitted to its thumbnail — the Image itself is read + /// from the model at tap time (UI thread only). + thumb_dims: Mutex>, } /// Viewer (M4) UI-thread state (see `viewer.rs` for the transform + input /// state machine). struct ViewerUi { - gen: u64, open: bool, - /// The pan/tap state machine; `None` while closed or loading. (Pinch - /// is driven by Slint's ScaleRotateGestureHandler — see `pinch_base`.) + /// The pan/tap state machine; `None` while closed. (Pinch is driven by + /// Slint's ScaleRotateGestureHandler — see `pinch_base`.) input: Option, /// Transform snapshot at the pinch's `started`; the handler's /// cumulative scale is applied about its center (`Input::pinch`). pinch_base: Option, + /// The content currently on screen: (path, content w, content h, is + /// full-res). `false` = the thumbnail is showing; the in-flight full + /// decode swaps into it silently (`Input::rebase_content`). + cur: Option<(String, f32, f32, bool)>, + /// Case-3 open: no cached full, no painted thumb — the viewer is black + /// and waiting on `waiting`'s decode. A failed decode shows its error + /// only for the path we are actively waiting on (a failing prefetch + /// must not print an error over another image). + waiting: Option, +} + +/// Full-res decode budget (M4.1 prefetch): how much decoded+GPU image data +/// may be resident at once. ~64 MP = 256 MB of RGBA ≈ 5–8 full-phone +/// photos at the 2×-screen cap; the LRU evicts from the front. +const FULL_MAX_ENTRIES: usize = 8; +const FULL_MAX_PIXELS: u64 = 64_000_000; +/// Concurrent full-res decodes (prefetch or on-demand). Full decodes are +/// ~10–200 ms at nice +2; two keeps a tap responsive without contending +/// with the thumb pool. +const FULL_MAX_INFLIGHT: usize = 2; + +struct FullEntry { + rgba: Vec, + w: u32, + h: u32, + pixels: u64, +} + +/// LRU of decoded full images as RGBA bytes (see `FULL_MAX_*`). Bytes are +/// `Send` (unlike `slint::Image`); the GPU upload happens on the UI thread +/// at tap/swap time. UI thread only. +struct FullCache { + /// Least-recently-used at the FRONT. + lru: Vec<(String, FullEntry)>, + pixels: u64, +} + +impl FullCache { + fn contains(&self, path: &str) -> bool { + self.lru.iter().any(|(p, _)| p == path) + } + /// Look up + mark most-recently-used. + fn get(&mut self, path: &str) -> Option<&FullEntry> { + if let Some(pos) = self.lru.iter().position(|(p, _)| p == path) { + let e = self.lru.remove(pos); + self.lru.push(e); + return self.lru.last().map(|(_, e)| e); + } + None + } + fn insert(&mut self, path: String, rgba: Vec, w: u32, h: u32) { + let pixels = u64::from(w) * u64::from(h); + // Evict from the front while over budget (a single image larger + // than the whole budget is admitted alone — the LRU keeps it until + // the next insert). + while !self.lru.is_empty() + && (self.lru.len() >= FULL_MAX_ENTRIES || self.pixels + pixels > FULL_MAX_PIXELS) + { + if let Some((_, e)) = self.lru.first() { + self.pixels = self.pixels.saturating_sub(e.pixels); + } + self.lru.remove(0); + } + self.lru.push((path, FullEntry { rgba, w, h, pixels })); + self.pixels += pixels; + } + fn clear(&mut self) { + self.lru.clear(); + self.pixels = 0; + } + fn stats(&self) -> (usize, u64) { + (self.lru.len(), self.pixels) + } } /// Create the pool + cache dir once per process. @@ -306,11 +397,16 @@ fn state() -> &'static AppState { painted_total: AtomicU64::new(0), last_stall_log: Mutex::new(Instant::now()), viewer: Mutex::new(ViewerUi { - gen: 0, open: false, input: None, pinch_base: None, + cur: None, + waiting: None, }), + full_cache: Mutex::new(FullCache { lru: Vec::new(), pixels: 0 }), + full_inflight: Mutex::new(HashMap::new()), + full_failed: Mutex::new(HashSet::new()), + thumb_dims: Mutex::new(HashMap::new()), } }) } @@ -367,6 +463,10 @@ fn set_list(weak: &slint::Weak
, entries: Vec, status: String) { app.inflight.lock().unwrap().clear(); app.painted.lock().unwrap().clear(); app.failed.lock().unwrap().clear(); + app.full_cache.lock().unwrap().clear(); + app.full_inflight.lock().unwrap().clear(); + app.full_failed.lock().unwrap().clear(); + app.thumb_dims.lock().unwrap().clear(); *app.entries.lock().unwrap() = entries.clone(); let n = entries.len(); *app.viewport.lock().unwrap() = 0..INITIAL_VIEWPORT_CELLS.min(n); @@ -458,6 +558,13 @@ fn apply_cell_updates( }, ); app.painted.lock().unwrap().insert(r.id); + // The viewer opens on this texture instantly (M4.1) — remember its + // decoded size (the Image itself is read from the model at tap + // time). Bounded: mirrors `painted`, dropped on eviction. + app.thumb_dims + .lock() + .unwrap() + .insert(r.id, (r.width, r.height)); painted_now += 1; } for &id in clears { @@ -483,6 +590,7 @@ fn apply_cell_updates( }, ); app.painted.lock().unwrap().remove(&id); + app.thumb_dims.lock().unwrap().remove(&id); // Release the dedupe entry so the fill step re-submits this cell // when it is in the viewport again (re-submit is a disk-cache hit). app.inflight.lock().unwrap().remove(&entries[id].path); @@ -515,14 +623,17 @@ fn viewport_for(y: f32, height: f32, width: f32, n: usize) -> Option u32 { /// max texture size — `viewer::decode_cap`), delivered between frames on /// the UI thread, uploaded to Slint's wgpu device like the grid cells /// (gputex) so the first-present frame pays ~0. +/// +/// **No splash (M4.1):** the image is on screen in the SAME frame as the +/// tap — (1) a full-res already in `full_cache` (idle prefetch or a +/// previous view) is shown instantly with no decode; (2) otherwise the +/// tapped cell's THUMBNAIL (already a GPU texture, same aspect) is shown +/// at fit and the full decode swaps in silently on arrival — identical +/// bounds, one content change (`Input::rebase_content` preserves any +/// pan/zoom the user did on the thumb). Only if the tapped cell has no +/// painted thumb either (rare) is there a brief black frame. +/// The status line is reserved for real errors. fn open_viewer(w: &Main, app: &AppState, path: String) { - let gen = { + let vw = w.get_win_w(); + let vh = w.get_win_h(); + let cap = viewer::decode_cap(phys_screen_max(w), gputex::max_texture_size()); + { let mut v = app.viewer.lock().unwrap(); - v.gen += 1; // invalidate a previous load v.open = true; v.input = None; v.pinch_base = None; - v.gen + v.cur = None; + v.waiting = None; + } + w.set_v_status("".into()); + // 1. Cached full-res (idle prefetch / previous view) → the decode is + // already done; upload the cached bytes (the grid's hitch-free + // path) and show. + let hit = { + let mut cache = app.full_cache.lock().unwrap(); + cache.get(&path).map(|e| { + let image = gputex::upload_rgba(&e.rgba, e.w, e.h).unwrap_or_else(|| { + let mut buffer = SharedPixelBuffer::::new(e.w, e.h); + buffer.make_mut_bytes().copy_from_slice(&e.rgba); + Image::from_rgba8(buffer) + }); + (image, e.w, e.h) + }) }; - let phys_max = phys_screen_max(w); - let cap = viewer::decode_cap(phys_max, gputex::max_texture_size()); - log::info!("gallery: viewer open {path} (cap {cap} px, gen {gen})"); - w.set_viewer_image(Image::default()); - w.set_v_status("Loading…".into()); + if let Some((image, fw, fh)) = hit { + let (n, mp) = app.full_cache.lock().unwrap().stats(); + show_full(w, app, &path, image, fw, fh, vw, vh); + log::info!( + "gallery: viewer open {path} (full {fw}x{fh} cached, cache {n}/{} · {} MB)", + FULL_MAX_ENTRIES, + mp / 1_000_000 + ); + return; + } + // 2. The tapped cell's thumbnail → instant; full decode swaps in. + // The Image comes from the model (already a GPU texture); the + // decoded size from `thumb_dims` (mirrors `painted`). + if let Some((image, tw, th)) = painted_thumb(w, app, &path) { + let t = viewer::fit(tw as f32, th as f32, vw, vh); + { + let mut v = app.viewer.lock().unwrap(); + v.cur = Some((path.clone(), tw as f32, th as f32, false)); + v.input = Some(viewer::Input::new(tw as f32, th as f32, vw, vh)); + } + w.set_viewer_image(image); + w.set_v_img_w(tw as f32); + w.set_v_img_h(th as f32); + w.set_v_scale(t.s); + w.set_v_tx(t.tx); + w.set_v_ty(t.ty); + w.set_viewer_open(true); + log::info!("gallery: viewer open {path} (thumb {tw}x{th} up, full in flight)"); + } else { + // 3. Rare (tapped cell's thumb not painted): black until the + // full decode lands (a few hundred ms worst case). + w.set_viewer_image(Image::default()); + w.set_viewer_open(true); + app.viewer.lock().unwrap().waiting = Some(path.clone()); + log::info!("gallery: viewer open {path} (no thumb, waiting on decode)"); + } + // Kick the full decode (skipped if a prefetch for this path is already + // running — that result swaps in on arrival). URGENT: the user is + // staring at the thumbnail — this bypasses the idle budget (a + // temporary +1 overflow) so the swap never waits behind prefetches. + start_full_decode(&w.as_weak(), app, &path, cap, true); +} + +/// (M4.1) The tapped cell's painted thumbnail, if any: its Image (from +/// the model — the same GPU texture the grid is showing) and its decoded +/// size (from `thumb_dims`). UI thread only. +fn painted_thumb(w: &Main, app: &AppState, path: &str) -> Option<(Image, u32, u32)> { + let idx = { + let entries = app.entries.lock().unwrap(); + entries.iter().position(|e| e.path == path)? + }; + let dims = app.thumb_dims.lock().unwrap(); + let (tw, th) = *dims.get(&idx)?; + let items = w.get_items(); + let m = items.as_any().downcast_ref::>()?; + let item = m.row_data(idx / GRID_COLS)?; + let cells = cells_of(&item)?; + let cell = cells.row_data(idx % GRID_COLS)?; + if cell.loaded { + Some((cell.image, tw, th)) + } else { + None + } +} + +/// Show a full-res image as the viewer's content at fit (cache-hit open). +fn show_full(w: &Main, app: &AppState, path: &str, image: Image, fw: u32, fh: u32, vw: f32, vh: f32) { + let t = viewer::fit(fw as f32, fh as f32, vw, vh); + { + let mut v = app.viewer.lock().unwrap(); + v.cur = Some((path.to_string(), fw as f32, fh as f32, true)); + v.input = Some(viewer::Input::new(fw as f32, fh as f32, vw, vh)); + } + w.set_viewer_image(image); + w.set_v_img_w(fw as f32); + w.set_v_img_h(fh as f32); + w.set_v_scale(t.s); + w.set_v_tx(t.tx); + w.set_v_ty(t.ty); w.set_viewer_open(true); - let weak = w.as_weak(); +} + +/// Kick (or reuse) a full-res decode for `path`. `urgent` (the viewer is +/// showing this path's thumbnail and waiting) bypasses the +/// `FULL_MAX_INFLIGHT` budget — the swap must never queue behind idle +/// prefetches; idle prefetches respect it. On arrival (UI thread): insert +/// the bytes into `full_cache` (upload on tap), and — if the viewer is +/// showing this path's THUMBNAIL — upload + swap the texture in silently: +/// same aspect, so the transform's s is rescaled by old/new (tx/ty are +/// viewer px and stay put) → identical bounds, one content change, no +/// flicker, any user pan/zoom preserved. The result is cached even when +/// the viewer is closed — that is the prefetch value. +fn start_full_decode( + weak: &slint::Weak
, + app: &AppState, + path: &str, + cap: u32, + urgent: bool, +) { + { + let mut fi = app.full_inflight.lock().unwrap(); + if fi.contains_key(path) { + return; // a decode for this path is already running + } + if !urgent && fi.len() >= FULL_MAX_INFLIGHT { + return; // idle budget busy (~100 ms); the next idle tick retries + } + fi.insert(path.to_string(), Instant::now()); + } + let path = path.to_string(); + let weak = weak.clone(); std::thread::Builder::new() - .name("viewer-load".into()) + .name("full-load".into()) .spawn(move || { // Same demotion as the grid workers: a full decode (up to ~100 // MB of pixels at 2× screen) must not starve UI/vsync. @@ -575,50 +818,143 @@ fn open_viewer(w: &Main, app: &AppState, path: String) { let _ = slint::invoke_from_event_loop(move || { let Some(w) = weak.upgrade() else { return }; let Some(app) = STATE.get() else { return }; - { - let v = app.viewer.lock().unwrap(); - if !v.open || v.gen != gen { - return; // viewer closed/replaced meanwhile - } - } + app.full_inflight.lock().unwrap().remove(&path); match res { Ok(full) => { - let image = - match gputex::upload_rgba(&full.rgba, full.width, full.height) { - Some(img) => img, - None => { - let mut buffer = - SharedPixelBuffer::::new(full.width, full.height); + let (swap, wait) = { + let v = app.viewer.lock().unwrap(); + ( + // The viewer is showing this path's THUMB + // → silent swap (rebase). + v.open + && matches!( + v.cur.as_ref(), + Some((p, _, _, false)) if p == &path + ), + // Case 3: the viewer is black, waiting on + // exactly this decode → show full at fit. + v.open && v.waiting.as_deref() == Some(path.as_str()), + ) + }; + // Upload ONLY for a live swap/wait — a prefetch + // landing while the viewer is closed/stale just + // stores its bytes (the ~3 ms upload happens at + // tap time), so idle prefetch is VRAM-free. + let image = (swap || wait).then(|| { + // On THIS thread (the render thread) — the + // gputex known-good-context contract. + gputex::upload_rgba(&full.rgba, full.width, full.height) + .unwrap_or_else(|| { + let mut buffer = SharedPixelBuffer::::new( + full.width, + full.height, + ); buffer.make_mut_bytes().copy_from_slice(&full.rgba); Image::from_rgba8(buffer) + }) + }); + app.full_cache + .lock() + .unwrap() + .insert(path.clone(), full.rgba, full.width, full.height); + if let Some(image) = image { + if wait { + // Case 3 landing: first content — plain fit. + let vw = w.get_win_w(); + let vh = w.get_win_h(); + let t = viewer::fit(full.width as f32, full.height as f32, vw, vh); + let mut v = app.viewer.lock().unwrap(); + v.cur = + Some((path.clone(), full.width as f32, full.height as f32, true)); + v.input = Some(viewer::Input::new( + full.width as f32, + full.height as f32, + vw, + vh, + )); + v.waiting = None; + w.set_viewer_image(image); + w.set_v_img_w(full.width as f32); + w.set_v_img_h(full.height as f32); + w.set_v_scale(t.s); + w.set_v_tx(t.tx); + w.set_v_ty(t.ty); + w.set_v_status("".into()); + log::info!( + "gallery: full loaded {}x{} in {} ms (wait filled)", + full.width, + full.height, + full.ms + ); + return; + } + // Silent swap: same aspect → rescale s (tx/ty + // are viewer px: unchanged) → identical + // bounds, one content change, no flicker. + let t = { + let mut v = app.viewer.lock().unwrap(); + // Rescale the pinch snapshot the same way + // (rare: pinching the thumb while the full + // decode is in flight). + let k = if let Some((_, old_w, _, _)) = v.cur.as_ref() { + (full.width as f32) / old_w.max(f32::MIN) + } else { + 1.0 + }; + if let Some(b) = v.pinch_base.as_mut() { + b.s /= k; } + v.input.as_mut().map(|i| { + i.rebase_content(full.width as f32, full.height as f32); + i.transform() + }) }; - let vw = w.get_win_w(); - let vh = w.get_win_h(); - let t = viewer::fit(full.width as f32, full.height as f32, vw, vh); - app.viewer.lock().unwrap().input = Some(viewer::Input::new( - full.width as f32, - full.height as f32, - vw, - vh, - )); - w.set_viewer_image(image); - w.set_v_img_w(full.width as f32); - w.set_v_img_h(full.height as f32); - w.set_v_scale(t.s); - w.set_v_tx(t.tx); - w.set_v_ty(t.ty); - w.set_v_status("".into()); + if let Some(t) = t { + app.viewer.lock().unwrap().cur = + Some((path.clone(), full.width as f32, full.height as f32, true)); + w.set_viewer_image(image); + w.set_v_img_w(full.width as f32); + w.set_v_img_h(full.height as f32); + w.set_v_scale(t.s); + w.set_v_tx(t.tx); + w.set_v_ty(t.ty); + log::info!( + "gallery: full loaded {}x{} in {} ms (swapped in)", + full.width, + full.height, + full.ms + ); + return; + } + } + let (n, mp) = app.full_cache.lock().unwrap().stats(); log::info!( - "gallery: viewer loaded {}x{} in {} ms", + "gallery: full loaded {}x{} in {} ms (cache {n}/{} · {} MB)", full.width, full.height, - full.ms + full.ms, + FULL_MAX_ENTRIES, + mp / 1_000_000 ); } Err(e) => { - w.set_v_status(format!("Can't open image: {e}").into()); - log::error!("gallery: viewer load failed: {e}"); + app.full_failed.lock().unwrap().insert(path.clone()); + log::error!("gallery: full load failed {path}: {e}"); + // Error text only when the open viewer has NOTHING + // to show for this path (case 3: no thumb). A + // thumbnail is better than an error — keep it, + // silent. + let show_err = { + let v = app.viewer.lock().unwrap(); + // Only the path the open viewer is actively + // waiting on (case 3). A blurry thumb beats an + // error; a failing prefetch is nobody's + // viewer's problem. + v.open && v.waiting.as_deref() == Some(path.as_str()) + }; + if show_err { + w.set_v_status(format!("Can't open image: {e}").into()); + } } } }); @@ -626,6 +962,45 @@ fn open_viewer(w: &Main, app: &AppState, path: String) { .ok(); } +/// Tick step 4c (M4.1): pick the next uncached visible image — +/// center-out, the order a tap is most likely to come — and start its +/// full-res decode, subject to the in-flight budget. ONE candidate per +/// tick: with the tick at ~8 ms and decodes at ~10–200 ms, this spreads +/// the work across frames instead of bursting it. +fn prefetch_full(app: &AppState, w: &Main, entries: &[Entry], viewport: &Range) { + if app.full_inflight.lock().unwrap().len() >= FULL_MAX_INFLIGHT { + return; + } + let center = (viewport.start + viewport.end) / 2; + let mut cands: Vec = viewport.clone().into_iter().collect(); + cands.sort_by_key(|id| center.abs_diff(*id)); + for &id in &cands { + if id >= entries.len() { + continue; + } + let e = &entries[id]; + let skip = { + let cache = app.full_cache.lock().unwrap(); + let fi = app.full_inflight.lock().unwrap(); + let ff = app.full_failed.lock().unwrap(); + let failed = app.failed.lock().unwrap(); + cache.contains(&e.path) + || fi.contains_key(&e.path) + || ff.contains(&e.path) + // The 400 px thumb already failed → the full decode will + // too (same file); don't burn idle CPU on it. + || failed.contains(&(e.path.clone(), e.mtime, e.size)) + }; + if skip { + continue; + } + let cap = viewer::decode_cap(phys_screen_max(w), gputex::max_texture_size()); + log::debug!("gallery: full prefetch {}", e.path); + start_full_decode(&w.as_weak(), app, &e.path, cap, false); + return; // one per tick + } +} + /// The viewer's pinch (Slint's ScaleRotateGestureHandler) has started: /// snapshot the current transform — the handler's `updated` reports a /// CUMULATIVE scale from this point, always applied about the CURRENT @@ -712,6 +1087,11 @@ fn reset_grid_in_place(w: &Main, app: &AppState) { app.engine.lock().unwrap().bump_generation(); app.inflight.lock().unwrap().clear(); app.failed.lock().unwrap().clear(); + // Full-res cache + thumb images hold the dying device's textures too. + app.full_cache.lock().unwrap().clear(); + app.full_inflight.lock().unwrap().clear(); + app.full_failed.lock().unwrap().clear(); + app.thumb_dims.lock().unwrap().clear(); // The viewer's image (and its load) also belonged to the dying device: // close it; the tick/grid re-fill on the new one. close_viewer(w, app); @@ -1029,7 +1409,7 @@ fn start_drain_timer(weak: &slint::Weak
, wid: u32) { // 0. POLL: the list's ACTUAL scroll position (momentum-aware). // The scroll callback (on_scroll) is a fast-path hint for // drags; this is the ground truth. - let viewport = match viewport_for( + let (viewport, vp_stable) = match viewport_for( w.get_list_y(), w.get_list_height(), w.get_list_width(), @@ -1037,9 +1417,10 @@ fn start_drain_timer(weak: &slint::Weak
, wid: u32) { ) { Some(vp) => { let prev = app.viewport.lock().unwrap().clone(); + let stable = vp == prev; // DBG (debug level): viewport trajectory — proves the // poll tracks momentum flings (callback-only froze). - if vp != prev { + if !stable { log::debug!( "DBG vp y={:.0} {}..{} -> {}..{}", w.get_list_y(), @@ -1050,9 +1431,9 @@ fn start_drain_timer(weak: &slint::Weak
, wid: u32) { ); } *app.viewport.lock().unwrap() = vp.clone(); - vp + (vp, stable) } - None => app.viewport.lock().unwrap().clone(), + None => (app.viewport.lock().unwrap().clone(), true), }; // DBG: coverage at ~5 Hz (debug level: silent in production). @@ -1224,6 +1605,20 @@ fn start_drain_timer(weak: &slint::Weak
, wid: u32) { } } + // 4c. FULL-RES PREFETCH (M4.1): the screen is STILL (stable + // viewport), the viewer is closed, and the thumb pipeline is + // caught up (inflight empty AFTER FILL — during a fast scroll + // FILL keeps it non-empty, so prefetch only starts once the + // scroll is done being followed) → decode + GPU-load the + // visible images, center-first, so a tap opens instantly. + // In-flight full decodes are NOT cancelled by a scroll — + // their results land in the cache; new ones simply wait. + if vp_stable && !app.viewer.lock().unwrap().open { + if app.inflight.lock().unwrap().is_empty() { + prefetch_full(app, &w, &entries, &viewport); + } + } + // 5. Invariant (debug builds): the viewport is fully covered. // A violation means a visible cell is forgotten — the exact // bug class this design exists to make impossible. diff --git a/src/viewer.rs b/src/viewer.rs index 3edecd5..9ed714b 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -185,6 +185,24 @@ impl Input { self.t = t; } + /// The displayed content was swapped for a HIGHER-RESOLUTION version of + /// the same image (thumbnail → full res on load; uniform scale + /// `k = new_iw / iw`, aspect unchanged). Rescale the transform so the + /// viewer mapping is unchanged — viewer_p = tx + c·s must hold for the + /// new content c·k, so s' = s/k and tx/ty (viewer px) stay put. The + /// swap is therefore flicker-free: identical bounds, one content change. + pub fn rebase_content(&mut self, new_iw: f32, new_ih: f32) { + if self.iw <= 0.0 || new_iw <= 0.0 { + return; + } + let k = new_iw / self.iw; + let t = self.t; + self.t = + clamp(Transform { s: t.s / k, tx: t.tx, ty: t.ty }, new_iw, new_ih, self.vw, self.vh); + self.iw = new_iw; + self.ih = new_ih; + } + pub fn pointer( &mut self, _finger: i32, @@ -591,6 +609,32 @@ mod tests { assert_eq!(i.transform(), t0); } + #[test] + fn rebase_content_preserves_viewer_mapping() { + // Thumb (800×600) at fit, zoomed 2× about (300, 200) with a pan. + let (tw, th) = (800.0, 600.0); + let mut i = Input::new(tw, th, VW, VH); + let t0 = i.transform(); + i.set_transform(zoom_at(t0, 300.0, 200.0, 2.0, tw, th, VW, VH)); + let t_zoomed = i.transform(); + let px = (300.0 - t_zoomed.tx) / t_zoomed.s; + let py = (200.0 - t_zoomed.ty) / t_zoomed.s; + // Full res: 4× the thumb (3200×2400), same aspect. + i.rebase_content(3200.0, 2400.0); + let t1 = i.transform(); + assert!((t1.s - t_zoomed.s / 4.0).abs() < 1e-3); + assert!((t1.tx - t_zoomed.tx).abs() < 1e-3); + assert!((t1.ty - t_zoomed.ty).abs() < 1e-3); + // The same content point sits under the same viewer point. + assert!((t1.tx + px * t1.s * 4.0 - 300.0).abs() < 1e-2); + assert!((t1.ty + py * t1.s * 4.0 - 200.0).abs() < 1e-2); + // At fit, the rebase lands exactly on the full-res fit. + let mut j = Input::new(tw, th, VW, VH); + j.rebase_content(3200.0, 2400.0); + let fit_full = fit(3200.0, 2400.0, VW, VH); + assert!((j.transform().s - fit_full.s).abs() < 1e-4); + } + #[test] fn pick_viewer_scale_lands_at_or_below_cap() { assert_eq!(pick_viewer_scale(1000, 4000), ScalingFactor::ONE);