M4.1: instant viewer (no splash) + idle full-res prefetch

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).
This commit is contained in:
Greg Pomerantz 2026-10-29 11:53:45 -04:00
parent 02dec62405
commit 9a1654136f
3 changed files with 518 additions and 53 deletions

View File

@ -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

View File

@ -277,19 +277,110 @@ struct AppState {
/// load (reopen / close / list reset / world recreation); the load
/// thread checks it before touching the UI.
viewer: Mutex<ViewerUi>,
/// 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<FullCache>,
/// Full-res decodes in flight (on-demand or prefetch). path → submit
/// time. UI thread only.
full_inflight: Mutex<HashMap<String, Instant>>,
/// 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<HashSet<String>>,
/// 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<HashMap<usize, (u32, u32)>>,
}
/// 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<viewer::Input>,
/// Transform snapshot at the pinch's `started`; the handler's
/// cumulative scale is applied about its center (`Input::pinch`).
pinch_base: Option<viewer::Transform>,
/// 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<String>,
}
/// 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<u8>,
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<u8>, 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<Main>, entries: Vec<Entry>, 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<Range<usize
}
/// Close the viewer (M4): the × button, the Android back key (delivered as
/// a close request — see the vendor patch), or a world reset. Bumps `gen`
/// so an in-flight full decode is dropped at delivery.
/// a close request — see the vendor patch), or a world reset. An in-flight
/// full decode keeps running (its result lands in the cache — prefetch
/// value) but never touches the closed viewer (the delivery checks
/// `open` + `cur`).
fn close_viewer(w: &Main, app: &AppState) {
let mut v = app.viewer.lock().unwrap();
v.gen += 1;
v.open = false;
v.input = None;
v.pinch_base = None;
v.cur = None;
v.waiting = None;
w.set_viewer_open(false);
w.set_v_status("".into());
}
@ -549,24 +660,156 @@ fn phys_screen_max(w: &Main) -> 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::<Rgba8Pixel>::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);
let weak = w.as_weak();
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::<VecModel<Item>>()?;
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);
}
/// 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<Main>,
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,33 +818,61 @@ 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::<Rgba8Pixel>::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::<Rgba8Pixel>::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);
app.viewer.lock().unwrap().input = Some(viewer::Input::new(
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);
@ -610,15 +881,80 @@ fn open_viewer(w: &Main, app: &AppState, path: String) {
w.set_v_ty(t.ty);
w.set_v_status("".into());
log::info!(
"gallery: viewer loaded {}x{} in {} ms",
"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()
})
};
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: full loaded {}x{} in {} ms (cache {n}/{} · {} MB)",
full.width,
full.height,
full.ms,
FULL_MAX_ENTRIES,
mp / 1_000_000
);
}
Err(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());
log::error!("gallery: viewer load failed: {e}");
}
}
}
});
@ -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<usize>) {
if app.full_inflight.lock().unwrap().len() >= FULL_MAX_INFLIGHT {
return;
}
let center = (viewport.start + viewport.end) / 2;
let mut cands: Vec<usize> = 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<Main>, 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<Main>, 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<Main>, 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<Main>, 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.

View File

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