From 59787f1d3f740d0ea497791c9381f0d0d118f0c6 Mon Sep 17 00:00:00 2001 From: Greg Pomerantz Date: Tue, 27 Oct 2026 18:13:28 -0400 Subject: [PATCH] M3: 3-column grid UI with shimmer, scroll prefetch, bounded retention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - main.slint: ListView of GridRows (3 square CellViews each); Cell struct {path, image, loaded}; shimmer placeholder via animation-tick() expression (1.18 has no from:/to: animation objects); scroll-changed callback feeds Rust. - lib.rs: active-range model (visible rows ± 4-row margin); on_scroll is cheap (re-anchor + submit prefetch only); a 10 Hz drain timer owns ALL model mutation, capped at 16 row-replacements/tick (ANR guard); apply_row_batch replaces each affected outer row once — the only update path that reaches ListView rows recycled while off-screen; clearing a cell releases its dedupe entry so re-entry re-submits (disk-cache hit). - thumb.rs: LIST_TARGET(64) -> GRID_TARGET(500); cache key includes target. - Verified on emulator (150 real JPEGs + 5k stubs): first paint 0.6 s cold, warm relaunch from disk cache, 0 ANRs through 16 fast swipes, backlog drains to 0. 13 unit tests + 5k engine test pass. - HANDOFF: M3 status, Slint gotchas 11-13 (in-property binding, nested model updates vs row recycling, ListView spacing), emulator test-data notes. --- HANDOFF.md | 58 +++++++-- src/lib.rs | 340 ++++++++++++++++++++++++++++++++++++++++--------- src/main.slint | 108 ++++++++++------ src/thumb.rs | 14 +- 4 files changed, 408 insertions(+), 112 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index d6c475b..d9be850 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -6,11 +6,14 @@ point for development: it captures every decision, gotcha, and measurement from the framework evaluation that preceded it, so no learning has to be rediscovered. -**Status: M2 done (2026-10-27)** — M0 scaffold, M1 metadata scan + sqlite + +**Status: M3 done (2026-10-27)** — M0 scaffold, M1 metadata scan + sqlite + instant list paint, M2 thumbnail engine (turbojpeg DCT-shrink decode, worker -pool, disk cache, cancellation) all verified: unit tests, 5k synthetic engine -test, and visually on a real Pixel 9 Pro (8,577 photos, thumbnails in every -row). Next: M3 grid UI. +pool, disk cache, cancellation), M3 grid UI (3-column ListView grid, shimmer +placeholders, scroll-driven prefetch, memory-bound cell retention) all +verified: 13 unit tests, 5k synthetic engine test, and visually on the +emulator (150 real JPEGs: first paint 0.6 s cold, warm relaunch from disk +cache, 0 ANRs through 16 fast swipes). M2 was also visually verified on a +real Pixel 9 Pro (8,577 photos). Next: M4 viewer. --- @@ -84,6 +87,17 @@ cargo run # desktop (Linux needs libfontconfig1+pkg-config) - Grant "All files access" to the app on the emulator after install: `adb shell am start -a android.settings.MANAGE_ALL_FILES_ACCESS_PERMISSION` (or Settings UI). `adb install -r` keeps the grant. +- **Emulator test data (M3)**: the AVD's `/Pictures/gallery-test/` has ~5k + *fake* JPEG stubs (zlib garbage — they scan fine but fail to decode, good + for volume tests). Real, decodable test JPEGs live in + `/Pictures/realtest/` (150, generated with PIL). They were `touch`-ed to a + future mtime so they sort to the TOP of the grid — when reasoning about + "which cells should have images", remember only cells 0–149 are real; + everything below is stubs and correctly shows shimmer. `adb push` of a + directory nests a subfolder (push files individually or `mv` up); + `adb shell touch -d '…'` works for setting mtimes. The thumb cache is at + `/sdcard/Android/data/wow.gmp.gallery/files/thumbs` (wipe it to force + re-decode). - Perf measurement: `adb shell perfetto` / `adb shell dumpsys gfxinfo wow.gmp.gallery`, `systrace`-style capture via Perfetto trace shell. Cold start: `adb shell am start -W`. @@ -205,6 +219,24 @@ cancellation, (5) EXIF rotation applied per frame. 9. Desktop Linux: needs `libfontconfig1` + `pkg-config` (pure Rust on macOS). 10. Retained-mode redraw: changing a property re-renders only affected elements — design bindings so thumbnail delivery touches one property. +11. **`in property` is NOT auto-bound to a same-named `for` variable.** In + `for cell[c] in row.cells: CellView { ... }` you must write + `CellView { cell: cell; ... }` — otherwise the component's `in property + cell` stays at its default (empty) and the cell renders blank + while *looking* correct. (This silently broke M3 for an hour.) +12. **Updating a nested model in place does NOT reach a ListView row that + was recycled while off-screen.** In-place `inner.set_row_data(col, …)` + updates only the currently-live `CellView`s; a row whose `GridRow` + instance was recycled keeps showing stale cells until the next scroll. + The reliable path is to replace the WHOLE outer row: + `outer.set_row_data(row, Item { cells: ModelRc::new(VecModel::from(…)) })` + — the outer `row_changed` makes the ListView re-bind `row-data` and the + fresh nested read picks up the new state. Batch cell changes per row and + do ≤ ~16 row-replacements per 100 ms tick (see `apply_row_batch`), or a + fast scroll mass-updates and ANRs. +13. `ListView` has no `spacing`/`gap` property in 1.18 — bake inter-row + rhythm into the item height (we add a 4 px bottom margin inside + `GridRow`; `ROW_SPACING` in lib.rs must match). ## 7. Permissions & platform integration (Android) @@ -340,10 +372,20 @@ pool threads), and `logcat` pool timing logs behind a `debug_timing` flag. are often both attached). - build.sh keywords: `debug` (x86_64), `debug-aarch64`, `x86_64`, `aarch64`; second arg `no-install` to skip install. -- **M3 — grid UI**: ListView grid, cell sizing, placeholder shimmer, - pull-to-refresh optional; Rust `Model` ↔ Slint; thumbnail delivery → - cell image swap. *Accept: 60 fps scroll on 5k items (emulator: no dropped- - frame spikes > 16 ms in gfxinfo).* +- **M3 — grid UI (DONE 2026-10-27)**: 3-column `ListView` grid (`GridRow` = + one list item holding up to 3 square `CellView`s), shimmer placeholder via + a time-driven `animation-tick()` expression, scroll-driven prefetch + (`scrolled → on_scroll`), memory-bound retention (cells drop to shimmer + when they leave the active range; disk cache repaints them in ~10 ms). + Key fixes along the way (see §6.11–13): `in property` must be assigned + explicitly in a `for`; cell updates must replace the whole outer row + (`apply_row_batch`) and are capped at 16 rows/tick to avoid ANR on fast + scroll; `on_scroll` only re-anchors the active range + submits prefetch + (no model mutation), all mutation is in the 10 Hz drain timer. + *Verified (emulator, 150 real JPEGs + 5k stubs): first paint 0.6 s cold; + warm relaunch from disk cache; 0 ANRs through 16 fast swipes; backlog + drains to 0 when idle. Still to do on a real device: 60 fps scroll check + (gfxinfo) — emulator is software-GL so it's not a valid fps target.* - **M4 — viewer**: tap cell → full decode + overlay; pinch/pan (manual math), double-tap zoom; back gesture/button; EXIF orientation correct. - **M5 — permissions**: implement `perm.rs` per §7; launch flow when diff --git a/src/lib.rs b/src/lib.rs index d18e9d0..8db2122 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,4 @@ -//! Gallery app logic: scan (M1) + thumbnail engine (M2). +//! Gallery app logic: scan (M1) + thumbnail engine (M2) + grid UI (M3). //! //! Threading model: //! - scan thread: one per launch, walks storage, owns the scan DB connection. @@ -6,6 +6,13 @@ //! UI thread once; workers own one DB connection each (WAL). //! - UI thread: owns the window + model; drains engine results 10x/s via a //! Slint timer; builds `slint::Image` here (Image is !Send, pixel bytes are). +//! +//! M3 memory bound: grid cells decode at 500 px (~0.7 MB RGB8 each), so the +//! model may NOT keep pixels for the whole 8k+ entry list. Cells only hold +//! an image while inside the *active range* (visible rows ± margin). Results +//! that finish out-of-range are held in a small deferred queue and painted +//! when the range catches up; cells that scroll out of range are dropped back +//! to shimmer (the disk cache repaints them in ~10 ms on re-entry). slint::include_modules!(); @@ -15,6 +22,7 @@ pub mod scan; pub mod thumb; use std::collections::HashSet; +use std::ops::Range; use std::path::Path; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Mutex, OnceLock}; @@ -25,25 +33,45 @@ use slint::{ }; use crate::scan::Entry; -use crate::thumb::{Engine, Job, LIST_TARGET}; +use crate::thumb::{Engine, Job, JobResult, GRID_TARGET}; -/// Rows to submit to the pool whenever the list is (re)painted: the top of -/// the "newest" order, i.e. what the user actually sees first. +/// Must match main.slint (GridRow layout + ListView spacing + padding). +const GRID_COLS: usize = 3; +const GRID_PADDING: f32 = 12.0; +const GRID_GAP: f32 = 4.0; +const ROW_SPACING: f32 = 4.0; +/// Entries to submit whenever the (re)paints the top of the list. const PREFETCH_FIRST: usize = 200; -/// Rows on each side of the visible range that scroll-prefetch keeps ready. -const SCROLL_MARGIN: usize = 50; -/// Row height must match main.slint. -const ROW_HEIGHT: f32 = 36.0; +/// Rows of margin on each side of the visible range (prefetch + retention). +const SCROLL_MARGIN_ROWS: usize = 4; +/// Max finished-but-out-of-range results held for later painting. +const DEFERRED_CAP: usize = 1024; +/// Max ROW replacements per drain tick. Bounding per-tick work is what keeps +/// the UI thread at frame rate: without it, the first scroll after a 200-cell +/// cold fill mass-updated ~150 cells in one event-loop pass (ANR, 2026-10-27). +const MAX_ROWS_PER_TICK: usize = 16; +/// Cells per tick implied by the row cap (paints); clears share the budget. +const MAX_PAINTS_PER_TICK: usize = MAX_ROWS_PER_TICK * GRID_COLS; +/// Retention range at list (re)set, before the first scroll event: a couple +/// of screens plus margin, in cells. +const INITIAL_ACTIVE_CELLS: usize = 60; const DRAIN_INTERVAL_MS: u64 = 100; static STATE: OnceLock = OnceLock::new(); struct AppState { engine: Engine, - /// Current list (newest first); source for scroll prefetch. UI thread only. + /// Current list (newest first); source for prefetch + paint guards. UI thread only. entries: Mutex>, /// Paths already handed to the pool for the current list. UI thread only. submitted: Mutex>, + /// Entry indexes whose image currently lives in the model. UI thread only. + painted_ids: Mutex>, + /// Entry range the model is allowed to keep pixels for (visible ± margin). + /// UI thread only. + active: Mutex>, + /// Finished results outside the active range, painted when it catches up. + deferred: Mutex>, /// Total thumbnails painted; drives the acceptance log cadence. painted: AtomicU64, } @@ -55,25 +83,66 @@ fn state() -> &'static AppState { let cache_dir = base.join("thumbs"); let workers = (std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4) / 2).clamp(2, 8); - let engine = Engine::new(cache_dir, workers, LIST_TARGET).expect("thumb engine"); + let engine = Engine::new(cache_dir, workers, GRID_TARGET).expect("thumb engine"); log::info!( "gallery: thumb engine up: {} workers, target {} px, cache {}", workers, - LIST_TARGET, + GRID_TARGET, engine.cache_dir().display() ); AppState { engine, entries: Mutex::new(Vec::new()), submitted: Mutex::new(HashSet::new()), + painted_ids: Mutex::new(HashSet::new()), + active: Mutex::new(0..0), + deferred: Mutex::new(Vec::new()), painted: AtomicU64::new(0), } }) } +fn empty_cell() -> Cell { + Cell { + path: SharedString::default(), + image: Image::default(), + loaded: false, + } +} + +/// Chunk entries into rows of GRID_COLS (trailing cells empty). The `cells` +/// field is a nested model (ModelRc), not a Vec: the grid template +/// iterates it per row. Cell updates (apply_row_batch) replace the whole +/// outer row — the only update path that reaches ListView rows recycled +/// while off-screen (verified on emulator, 2026-10-27). +fn chunk(entries: &[Entry]) -> Vec { + entries + .chunks(GRID_COLS) + .map(|ch| { + let mut cells = ch + .iter() + .map(|e| Cell { + path: SharedString::from(e.path.clone()), + image: Image::default(), + loaded: false, + }) + .collect::>(); + cells.resize(GRID_COLS, empty_cell()); + Item { + cells: ModelRc::new(VecModel::from(cells)), + } + }) + .collect() +} + +/// The nested cell model of a row, if the row is a live grid row. +fn cells_of(item: &Item) -> Option<&VecModel> { + item.cells.as_any().downcast_ref::>() +} + /// Replace the list model (UI thread) + set the status line. A model -/// replacement invalidates in-flight work for the previous list: bump the -/// generation and clear the submitted set so repaints re-prefetch. +/// replacement invalidates all in-flight work: bump the generation, clear +/// submitted/painted/deferred, and re-anchor the active range at the top. /// `do_prefetch` is false for cold-scan streaming batches (nothing to show /// yet — re-submitting the top rows every batch would waste decode work). fn set_list(weak: &slint::Weak
, entries: Vec, status: String, do_prefetch: bool) { @@ -83,21 +152,19 @@ fn set_list(weak: &slint::Weak
, entries: Vec, status: String, do_pr let Some(app) = STATE.get() else { return }; app.engine.bump_generation(); app.submitted.lock().unwrap().clear(); + app.painted_ids.lock().unwrap().clear(); + app.deferred.lock().unwrap().clear(); *app.entries.lock().unwrap() = entries.clone(); - let items: Vec = entries - .iter() - .map(|e| Item { - path: SharedString::from(e.path.clone()), - image: Image::default(), - }) - .collect(); + let n = entries.len(); + *app.active.lock().unwrap() = 0..INITIAL_ACTIVE_CELLS.min(n); if let Some(m) = w.get_items().as_any().downcast_ref::>() { - m.set_vec(items); + m.set_vec(chunk(&entries)); } w.set_status(status.into()); if do_prefetch { // Top of the list starts filling now (cold: decodes; warm: cache hits). - prefetch(app, 0..PREFETCH_FIRST); + let end = PREFETCH_FIRST.min(n); + prefetch(app, 0..end); } }); } @@ -112,9 +179,9 @@ fn set_status(weak: &slint::Weak
, status: String) { }); } -/// Submit not-yet-submitted entries in `range` (row indexes) to the pool. +/// Submit not-yet-submitted entries in `range` (entry indexes) to the pool. /// UI thread only. -fn prefetch(app: &AppState, range: std::ops::Range) { +fn prefetch(app: &AppState, range: Range) { if range.is_empty() { return; } @@ -146,17 +213,137 @@ fn prefetch(app: &AppState, range: std::ops::Range) { } } -/// Scroll-driven prefetch (from main.slint `scrolled => on-scroll-changed`). -/// content-y is negative while scrolled down. -fn on_scroll(app: &AppState, y: f32, height: f32) { - let first = (y.abs() / ROW_HEIGHT) as usize; - let visible = (height / ROW_HEIGHT) as usize; - prefetch(app, first.saturating_sub(SCROLL_MARGIN)..first + visible + SCROLL_MARGIN); +/// Apply a batch of cell paints + clears, replacing each affected OUTER row +/// exactly once (UI thread). Row replacement — not in-place nested-model +/// updates — is the only update path that reliably reaches ListView rows that +/// were recycled while off-screen: the outer row_changed makes the ListView +/// re-bind `row-data`, and the fresh nested model read picks up the new +/// state. Returns the number of paint cells applied. +fn apply_row_batch( + app: &AppState, + m: &VecModel, + entries: &[Entry], + paints: &[JobResult], + clears: &[usize], +) -> usize { + use std::collections::BTreeMap; + // row -> (col, new cell) updates + let mut rows: BTreeMap> = BTreeMap::new(); + for r in paints { + if !r.ok || r.id >= entries.len() || entries[r.id].path != r.path { + continue; // list moved on; drop + } + let mut buffer = SharedPixelBuffer::::new(r.width, r.height); + buffer.make_mut_bytes().copy_from_slice(&r.rgb); + rows.entry(r.id / GRID_COLS) + .or_default() + .push(( + r.id % GRID_COLS, + Cell { + path: SharedString::from(r.path.clone()), + image: Image::from_rgb8(buffer), + loaded: true, + }, + )); + } + for &id in clears { + if id >= entries.len() { + continue; + } + rows.entry(id / GRID_COLS).or_default().push(( + id % GRID_COLS, + Cell { + path: SharedString::from(entries[id].path.clone()), + image: Image::default(), + loaded: false, + }, + )); + } + let mut n = 0usize; + for (row, updates) in rows { + if row >= m.row_count() { + continue; + } + let Some(item) = m.row_data(row) else { + continue; + }; + let Some(cells) = cells_of(&item) else { + continue; + }; + let mut new_cells: Vec = ( + 0..cells.row_count().min(GRID_COLS) + ) + .map(|i| cells.row_data(i).unwrap_or_else(empty_cell)) + .collect(); + new_cells.resize(GRID_COLS, empty_cell()); + let mut touched = false; + for (col, c) in updates { + if col >= GRID_COLS { + continue; + } + let id = row * GRID_COLS + col; + if c.loaded { + if id >= entries.len() || entries[id].path != c.path.as_str() { + continue; + } + app.painted_ids.lock().unwrap().insert(id); + n += 1; + } else { + if id >= entries.len() || entries[id].path != c.path.as_str() { + continue; + } + app.painted_ids.lock().unwrap().remove(&id); + // Release the dedupe entry so the next prefetch re-submits + // this cell when it scrolls back into range (its result was + // consumed when painted; without this it would shimmer + // forever). Re-submit is a disk-cache hit. + app.submitted.lock().unwrap().remove(&entries[id].path); + } + new_cells[col] = c; + touched = true; + } + if touched { + m.set_row_data( + row, + Item { + cells: ModelRc::new(VecModel::from(new_cells)), + }, + ); + } + } + n } -/// 10x/s: take finished thumbnails, guard against list resets, paint rows. -/// Slint 1.18 timers stop when dropped, and Timer is !Send (UI thread only): -/// we leak the one handle for the process lifetime (8 bytes). +/// Scroll event (main.slint `scrolled` → `scroll-changed(y, height, width)`; +/// content-y is negative while scrolled). MUST stay cheap — it fires on every +/// scroll frame: only re-anchor the active range and submit prefetch jobs. +/// ALL model mutations happen in the 10 Hz drain timer (capped per tick). +fn on_scroll(app: &AppState, y: f32, height: f32, width: f32) { + if width <= 0.0 { + return; + } + let cell = (width - 2.0 * GRID_PADDING - 2.0 * GRID_GAP) / GRID_COLS as f32; + let row_h = cell + ROW_SPACING; + let n = app.entries.lock().unwrap().len(); + if n == 0 { + return; + } + let first = (y.abs() / row_h) as usize; + let visible = (height / row_h) as usize + 1; + let margin = SCROLL_MARGIN_ROWS * GRID_COLS; + let new = (first * GRID_COLS) + .saturating_sub(margin) + ..((first + visible + SCROLL_MARGIN_ROWS) * GRID_COLS).min(n); + *app.active.lock().unwrap() = new.clone(); + prefetch(app, new); +} + +/// 10x/s: drain finished thumbnails; paint what's in the active range; +/// clear pixels that left it; defer the rest. Row replacements are capped +/// at MAX_ROWS_PER_TICK per tick so no single event-loop pass can mass- +/// reflow the grid (ANR guard). Slint 1.18 timers stop when dropped, and +/// Timer is !Send (UI thread only): we leak the one handle for the process +/// lifetime (8 bytes). fn start_drain_timer(weak: &slint::Weak
) { let weak = weak.clone(); let timer = Timer::default(); @@ -166,37 +353,70 @@ fn start_drain_timer(weak: &slint::Weak
) { move || { let Some(app) = STATE.get() else { return }; let results = app.engine.drain(); - if results.is_empty() { - return; - } let Some(w) = weak.upgrade() else { return }; let model = w.get_items(); let Some(m) = model.as_any().downcast_ref::>() else { return }; let entries = app.entries.lock().unwrap(); - let mut painted = 0usize; - for r in &results { - // Guard: list may have been reset between submit and delivery. - if !r.ok - || r.id >= m.row_count() - || r.id >= entries.len() - || entries[r.id].path != r.path - { - continue; - } - let mut buffer = SharedPixelBuffer::::new(r.width, r.height); - buffer.make_mut_bytes().copy_from_slice(&r.rgb); - let image = Image::from_rgb8(buffer); - painted += 1; - m.set_row_data( - r.id, - Item { - path: SharedString::from(r.path.clone()), - image, - }, - ); + if entries.is_empty() { + return; } + let active = app.active.lock().unwrap().clone(); + + // 1. Paints: fresh in-range results + deferred that re-entered. + let mut deferred = app.deferred.lock().unwrap(); + let mut paints: Vec = Vec::new(); + for r in results { + if active.contains(&r.id) { + paints.push(r); + } else if deferred.len() < DEFERRED_CAP { + deferred.push(r); + } else { + // Cap hit (very fast scroll): forget this one; the next + // prefetch re-submits it (clear the dedupe entry). + app.submitted.lock().unwrap().remove(&r.path); + } + } + let mut kept = Vec::with_capacity(deferred.len()); + for r in deferred.drain(..) { + if active.contains(&r.id) { + paints.push(r); + } else { + kept.push(r); + } + } + deferred.extend(kept); + drop(deferred); + + // 2. Cap the tick's paint work; the overflow waits in deferred. + if paints.len() > MAX_PAINTS_PER_TICK { + let overflow = paints.split_off(MAX_PAINTS_PER_TICK); + let mut deferred = app.deferred.lock().unwrap(); + for r in overflow { + if deferred.len() < DEFERRED_CAP { + deferred.push(r); + } + } + } + + // 3. Clears: painted cells outside the active range, within the + // remaining row budget (memory bound, spread over ticks). + let rows_used = (paints.len() + GRID_COLS - 1) / GRID_COLS; + let clear_budget = (MAX_ROWS_PER_TICK - rows_used.min(MAX_ROWS_PER_TICK)) * GRID_COLS; + let to_clear: Vec = app + .painted_ids + .lock() + .unwrap() + .iter() + .copied() + .filter(|id| !active.contains(id)) + .take(clear_budget) + .collect(); + + // 4. Apply: each affected outer row replaced exactly once. + let painted = apply_row_batch(app, m, &entries, &paints, &to_clear); + if painted > 0 { let total = app.painted.fetch_add(painted as u64, Ordering::Relaxed) + painted as u64; @@ -216,15 +436,15 @@ fn start_drain_timer(weak: &slint::Weak
) { } /// M1: cached DB paints instantly on relaunch; a background walk refreshes it -/// (streaming into the list on cold start). M2: the thumbnail pool fills rows -/// as they appear — top rows on paint, the visible range on scroll. +/// (streaming into the list on cold start). M3: the pool fills grid cells as +/// they appear — top cells on paint, the visible range on scroll. pub fn start_scan(window: &Main) { let model = ModelRc::new(VecModel::::default()); window.set_items(model); let weak = window.as_weak(); let app = state(); - window.on_scroll_changed(move |y: f32, h: f32| on_scroll(app, y, h)); + window.on_scroll_changed(move |y: f32, h: f32, w: f32| on_scroll(app, y, h, w)); start_drain_timer(&weak); std::thread::spawn(move || { diff --git a/src/main.slint b/src/main.slint index 0a43e38..ef934be 100644 --- a/src/main.slint +++ b/src/main.slint @@ -1,31 +1,87 @@ -// Gallery — M2: metadata list with streaming thumbnails. -// Rows are 36px: 28px thumbnail (image-fit: cover) + path text. -// `scrolled` drives Rust-side prefetch (lib.rs::on_scroll); the grid (M3) -// replaces the row body with square image cells. +// Gallery — M3: 3-column thumbnail grid with shimmer placeholders. +// +// The ListView virtualizes ROWS; each row holds up to 3 square cells. +// `scrolled` drives Rust-side prefetch (lib.rs::on_scroll), which also owns +// the memory bound: cells scrolled far off-screen are dropped back to +// shimmer (their pixels live in the disk cache, repaint is instant). import { ListView, VerticalBox } from "std-widgets.slint"; -export struct Item { +export struct Cell { path: string, image: image, + loaded: bool, +} + +export struct Item { + cells: [Cell], +} + +// One square grid cell: shimmer pulse until `loaded`, then the thumbnail. +// `visible` doubles as "is this a real entry" (trailing cells of the last +// row have path == ""). +component CellView inherits Rectangle { + in property cell; + background: #1a1a1a; + border-radius: 4px; + clip: true; + + // Shimmer: time-driven expression (1.18 style, same pattern as Slint's + // own spinner widgets) — animation-tick() marks the binding as + // time-dependent, so it re-evaluates every frame while visible. + Rectangle { + width: 100%; + height: 100%; + background: #242424; + visible: !cell.loaded && cell.path != ""; + opacity: 0.45 + 0.55 * Math.abs(Math.sin(360deg * animation-tick() / 1300ms)); + } + + Image { + width: 100%; + height: 100%; + source: cell.image; + image-fit: cover; + visible: cell.loaded; + } +} + +// One grid row: up to 3 equal square cells with 4 px gaps (= GRID_GAP in +// lib.rs). ListView items are back-to-back with no spacing property, so the +// 4 px vertical rhythm is a bottom margin inside the row (row height = +// cw + 4; lib.rs ROW_SPACING must match). +component GridRow inherits Rectangle { + in property row-data; + property cw: (self.width - 8px) / 3; + width: 100%; + height: cw + 4px; + + for cell[c] in row-data.cells: CellView { + cell: cell; + width: root.cw; + height: root.cw; + x: c * (root.cw + 4px); + visible: cell.path != ""; + } } export component Main inherits Window { title: "Gallery"; - /// Fired by the ListView on every scroll step: (content-y, list height). - /// content-y is negative while scrolled down. (Rust setter: on_scroll_changed) - callback scroll-changed(y: length, height: length); + /// Fired by the ListView on every scroll step: (content-y, list height, + /// list width). content-y is negative while scrolled down. + /// (Rust setter: on_scroll_changed) + callback scroll-changed(y: length, height: length, width: length); /// Scan/DB status line. in property status: "Starting…"; - /// Rows, newest first. `image` starts empty and fills as the thumbnail - /// pool delivers results. + /// Rows of 3 cells, newest first. Cells fill as the thumbnail pool + /// delivers results. in property <[Item]> items; VerticalBox { width: 100%; spacing: 8px; - padding: 12px; + padding: 12px; // = GRID_PADDING in lib.rs // Respect status-bar / nav-bar cutouts (built into the Slint window // on Android via safe-area-insets). padding-top: 12px + root.safe-area-insets.top; @@ -41,34 +97,12 @@ export component Main inherits Window { ListView { width: 100%; vertical-stretch: 1; - // Scroll-driven prefetch: keep ~SCROLL_MARGIN rows on each side - // of the visible range submitted to the pool (see lib.rs). + // Scroll-driven prefetch + memory bound (see lib.rs::on_scroll). scrolled => { - root.scroll-changed(self.content-y, self.visible-height); + root.scroll-changed(self.content-y, self.visible-height, self.width); } - for item[index] in root.items: Rectangle { - height: 36px; - width: parent.width; - background: Math.mod(index, 2) == 0 ? #1e1e1e : #242424; - border-radius: 4px; - Image { - x: 4px; - y: 4px; - width: 28px; - height: 28px; - source: item.image; - image-fit: cover; - } - Text { - x: 38px; - y: 0; - width: parent.width - 46px; - height: 100%; - text: item.path; - font-size: 13px; - vertical-alignment: center; - overflow: elide; - } + for row in root.items: GridRow { + row-data: row; } } } diff --git a/src/thumb.rs b/src/thumb.rs index 631989b..a454026 100644 --- a/src/thumb.rs +++ b/src/thumb.rs @@ -28,10 +28,10 @@ use sha1::{Digest, Sha1}; use crate::db; use crate::images; -/// M2 list thumbnails: 28 px rows x 2 (Retina). The M3 grid will ask for -/// 500 px cells (HANDOFF §4) — the target is part of the cache key, so both -/// sizes can coexist. -pub const LIST_TARGET: u32 = 64; +/// Grid thumbnails: ~500 px (longest side). Cells render ~350–420 px physical +/// (3 columns) on a phone; 500 keeps them sharp. The target is part of the +/// cache key, so a future different-size target coexists in the same dir. +pub const GRID_TARGET: u32 = 500; const JPEG_QUALITY: u8 = 85; #[derive(Debug, Clone)] @@ -374,7 +374,7 @@ mod tests { }).collect::>(); // 2. Cold run. - let eng = Engine::new(cache.clone(), 4, LIST_TARGET).unwrap(); + let eng = Engine::new(cache.clone(), 4, GRID_TARGET).unwrap(); let gen = eng.generation(); let t0 = Instant::now(); eng.submit(&entries.iter().enumerate().map(|(i, e)| Job { @@ -403,7 +403,7 @@ mod tests { assert_eq!(std::fs::read_dir(&cache).unwrap().count(), N, "cache holds all entries"); // 3. Warm run: everything must come from the disk cache. - let eng2 = Engine::new(cache.clone(), 4, LIST_TARGET).unwrap(); + let eng2 = Engine::new(cache.clone(), 4, GRID_TARGET).unwrap(); let gen2 = eng2.generation(); let t0 = Instant::now(); eng2.submit(&entries.iter().enumerate().map(|(i, e)| Job { @@ -425,7 +425,7 @@ mod tests { assert!(warm_total.as_secs_f64() < cold_total.as_secs_f64()); // 4. Cancellation: submit, bump generation before draining -> dropped. - let eng3 = Engine::new(cache.clone(), 4, LIST_TARGET).unwrap(); + let eng3 = Engine::new(cache.clone(), 4, GRID_TARGET).unwrap(); let stale = eng3.generation(); let p = &entries[0]; // Use a fresh path (mtime bump) so it is a cache miss and takes time.