M3: 3-column grid UI with shimmer, scroll prefetch, bounded retention

- 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.
This commit is contained in:
Greg Pomerantz 2026-10-27 18:13:28 -04:00
parent 572409b702
commit 59787f1d3f
4 changed files with 408 additions and 112 deletions

View File

@ -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 from the framework evaluation that preceded it, so no learning has to be
rediscovered. 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 instant list paint, M2 thumbnail engine (turbojpeg DCT-shrink decode, worker
pool, disk cache, cancellation) all verified: unit tests, 5k synthetic engine pool, disk cache, cancellation), M3 grid UI (3-column ListView grid, shimmer
test, and visually on a real Pixel 9 Pro (8,577 photos, thumbnails in every placeholders, scroll-driven prefetch, memory-bound cell retention) all
row). Next: M3 grid UI. 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: - Grant "All files access" to the app on the emulator after install:
`adb shell am start -a android.settings.MANAGE_ALL_FILES_ACCESS_PERMISSION` `adb shell am start -a android.settings.MANAGE_ALL_FILES_ACCESS_PERMISSION`
(or Settings UI). `adb install -r` keeps the grant. (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`, - Perf measurement: `adb shell perfetto` / `adb shell dumpsys gfxinfo wow.gmp.gallery`,
`systrace`-style capture via Perfetto trace shell. Cold start: `systrace`-style capture via Perfetto trace shell. Cold start:
`adb shell am start -W`. `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). 9. Desktop Linux: needs `libfontconfig1` + `pkg-config` (pure Rust on macOS).
10. Retained-mode redraw: changing a property re-renders only affected 10. Retained-mode redraw: changing a property re-renders only affected
elements — design bindings so thumbnail delivery touches one property. 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> 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) ## 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). are often both attached).
- build.sh keywords: `debug` (x86_64), `debug-aarch64`, `x86_64`, - build.sh keywords: `debug` (x86_64), `debug-aarch64`, `x86_64`,
`aarch64`; second arg `no-install` to skip install. `aarch64`; second arg `no-install` to skip install.
- **M3 — grid UI**: ListView grid, cell sizing, placeholder shimmer, - **M3 — grid UI (DONE 2026-10-27)**: 3-column `ListView` grid (`GridRow` =
pull-to-refresh optional; Rust `Model` ↔ Slint; thumbnail delivery → one list item holding up to 3 square `CellView`s), shimmer placeholder via
cell image swap. *Accept: 60 fps scroll on 5k items (emulator: no dropped- a time-driven `animation-tick()` expression, scroll-driven prefetch
frame spikes > 16 ms in gfxinfo).* (`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 - **M4 — viewer**: tap cell → full decode + overlay; pinch/pan (manual
math), double-tap zoom; back gesture/button; EXIF orientation correct. math), double-tap zoom; back gesture/button; EXIF orientation correct.
- **M5 — permissions**: implement `perm.rs` per §7; launch flow when - **M5 — permissions**: implement `perm.rs` per §7; launch flow when

View File

@ -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: //! Threading model:
//! - scan thread: one per launch, walks storage, owns the scan DB connection. //! - 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 once; workers own one DB connection each (WAL).
//! - UI thread: owns the window + model; drains engine results 10x/s via a //! - 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). //! 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!(); slint::include_modules!();
@ -15,6 +22,7 @@ pub mod scan;
pub mod thumb; pub mod thumb;
use std::collections::HashSet; use std::collections::HashSet;
use std::ops::Range;
use std::path::Path; use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Mutex, OnceLock}; use std::sync::{Mutex, OnceLock};
@ -25,25 +33,45 @@ use slint::{
}; };
use crate::scan::Entry; 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 /// Must match main.slint (GridRow layout + ListView spacing + padding).
/// the "newest" order, i.e. what the user actually sees first. 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; const PREFETCH_FIRST: usize = 200;
/// Rows on each side of the visible range that scroll-prefetch keeps ready. /// Rows of margin on each side of the visible range (prefetch + retention).
const SCROLL_MARGIN: usize = 50; const SCROLL_MARGIN_ROWS: usize = 4;
/// Row height must match main.slint. /// Max finished-but-out-of-range results held for later painting.
const ROW_HEIGHT: f32 = 36.0; 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; const DRAIN_INTERVAL_MS: u64 = 100;
static STATE: OnceLock<AppState> = OnceLock::new(); static STATE: OnceLock<AppState> = OnceLock::new();
struct AppState { struct AppState {
engine: Engine, 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<Vec<Entry>>, entries: Mutex<Vec<Entry>>,
/// Paths already handed to the pool for the current list. UI thread only. /// Paths already handed to the pool for the current list. UI thread only.
submitted: Mutex<HashSet<String>>, submitted: Mutex<HashSet<String>>,
/// Entry indexes whose image currently lives in the model. UI thread only.
painted_ids: Mutex<HashSet<usize>>,
/// Entry range the model is allowed to keep pixels for (visible ± margin).
/// UI thread only.
active: Mutex<Range<usize>>,
/// Finished results outside the active range, painted when it catches up.
deferred: Mutex<Vec<JobResult>>,
/// Total thumbnails painted; drives the acceptance log cadence. /// Total thumbnails painted; drives the acceptance log cadence.
painted: AtomicU64, painted: AtomicU64,
} }
@ -55,25 +83,66 @@ fn state() -> &'static AppState {
let cache_dir = base.join("thumbs"); let cache_dir = base.join("thumbs");
let workers = let workers =
(std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4) / 2).clamp(2, 8); (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!( log::info!(
"gallery: thumb engine up: {} workers, target {} px, cache {}", "gallery: thumb engine up: {} workers, target {} px, cache {}",
workers, workers,
LIST_TARGET, GRID_TARGET,
engine.cache_dir().display() engine.cache_dir().display()
); );
AppState { AppState {
engine, engine,
entries: Mutex::new(Vec::new()), entries: Mutex::new(Vec::new()),
submitted: Mutex::new(HashSet::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), 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<Cell>), 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<Item> {
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::<Vec<_>>();
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<Cell>> {
item.cells.as_any().downcast_ref::<VecModel<Cell>>()
}
/// Replace the list model (UI thread) + set the status line. A model /// Replace the list model (UI thread) + set the status line. A model
/// replacement invalidates in-flight work for the previous list: bump the /// replacement invalidates all in-flight work: bump the generation, clear
/// generation and clear the submitted set so repaints re-prefetch. /// submitted/painted/deferred, and re-anchor the active range at the top.
/// `do_prefetch` is false for cold-scan streaming batches (nothing to show /// `do_prefetch` is false for cold-scan streaming batches (nothing to show
/// yet — re-submitting the top rows every batch would waste decode work). /// yet — re-submitting the top rows every batch would waste decode work).
fn set_list(weak: &slint::Weak<Main>, entries: Vec<Entry>, status: String, do_prefetch: bool) { fn set_list(weak: &slint::Weak<Main>, entries: Vec<Entry>, status: String, do_prefetch: bool) {
@ -83,21 +152,19 @@ fn set_list(weak: &slint::Weak<Main>, entries: Vec<Entry>, status: String, do_pr
let Some(app) = STATE.get() else { return }; let Some(app) = STATE.get() else { return };
app.engine.bump_generation(); app.engine.bump_generation();
app.submitted.lock().unwrap().clear(); app.submitted.lock().unwrap().clear();
app.painted_ids.lock().unwrap().clear();
app.deferred.lock().unwrap().clear();
*app.entries.lock().unwrap() = entries.clone(); *app.entries.lock().unwrap() = entries.clone();
let items: Vec<Item> = entries let n = entries.len();
.iter() *app.active.lock().unwrap() = 0..INITIAL_ACTIVE_CELLS.min(n);
.map(|e| Item {
path: SharedString::from(e.path.clone()),
image: Image::default(),
})
.collect();
if let Some(m) = w.get_items().as_any().downcast_ref::<VecModel<Item>>() { if let Some(m) = w.get_items().as_any().downcast_ref::<VecModel<Item>>() {
m.set_vec(items); m.set_vec(chunk(&entries));
} }
w.set_status(status.into()); w.set_status(status.into());
if do_prefetch { if do_prefetch {
// Top of the list starts filling now (cold: decodes; warm: cache hits). // 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<Main>, 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. /// UI thread only.
fn prefetch(app: &AppState, range: std::ops::Range<usize>) { fn prefetch(app: &AppState, range: Range<usize>) {
if range.is_empty() { if range.is_empty() {
return; return;
} }
@ -146,17 +213,137 @@ fn prefetch(app: &AppState, range: std::ops::Range<usize>) {
} }
} }
/// Scroll-driven prefetch (from main.slint `scrolled => on-scroll-changed`). /// Apply a batch of cell paints + clears, replacing each affected OUTER row
/// content-y is negative while scrolled down. /// exactly once (UI thread). Row replacement — not in-place nested-model
fn on_scroll(app: &AppState, y: f32, height: f32) { /// updates — is the only update path that reliably reaches ListView rows that
let first = (y.abs() / ROW_HEIGHT) as usize; /// were recycled while off-screen: the outer row_changed makes the ListView
let visible = (height / ROW_HEIGHT) as usize; /// re-bind `row-data`, and the fresh nested model read picks up the new
prefetch(app, first.saturating_sub(SCROLL_MARGIN)..first + visible + SCROLL_MARGIN); /// state. Returns the number of paint cells applied.
fn apply_row_batch(
app: &AppState,
m: &VecModel<Item>,
entries: &[Entry],
paints: &[JobResult],
clears: &[usize],
) -> usize {
use std::collections::BTreeMap;
// row -> (col, new cell) updates
let mut rows: BTreeMap<usize, Vec<(usize, Cell)>> = 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::<Rgb8Pixel>::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<Cell> = (
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. /// Scroll event (main.slint `scrolled` → `scroll-changed(y, height, width)`;
/// Slint 1.18 timers stop when dropped, and Timer is !Send (UI thread only): /// content-y is negative while scrolled). MUST stay cheap — it fires on every
/// we leak the one handle for the process lifetime (8 bytes). /// 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<Main>) { fn start_drain_timer(weak: &slint::Weak<Main>) {
let weak = weak.clone(); let weak = weak.clone();
let timer = Timer::default(); let timer = Timer::default();
@ -166,37 +353,70 @@ fn start_drain_timer(weak: &slint::Weak<Main>) {
move || { move || {
let Some(app) = STATE.get() else { return }; let Some(app) = STATE.get() else { return };
let results = app.engine.drain(); let results = app.engine.drain();
if results.is_empty() {
return;
}
let Some(w) = weak.upgrade() else { return }; let Some(w) = weak.upgrade() else { return };
let model = w.get_items(); let model = w.get_items();
let Some(m) = model.as_any().downcast_ref::<VecModel<Item>>() else { let Some(m) = model.as_any().downcast_ref::<VecModel<Item>>() else {
return return
}; };
let entries = app.entries.lock().unwrap(); let entries = app.entries.lock().unwrap();
let mut painted = 0usize; if entries.is_empty() {
for r in &results { return;
// 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::<Rgb8Pixel>::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,
},
);
} }
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<JobResult> = 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<usize> = 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 { if painted > 0 {
let total = let total =
app.painted.fetch_add(painted as u64, Ordering::Relaxed) + painted as u64; app.painted.fetch_add(painted as u64, Ordering::Relaxed) + painted as u64;
@ -216,15 +436,15 @@ fn start_drain_timer(weak: &slint::Weak<Main>) {
} }
/// M1: cached DB paints instantly on relaunch; a background walk refreshes it /// 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 /// (streaming into the list on cold start). M3: the pool fills grid cells as
/// as they appear — top rows on paint, the visible range on scroll. /// they appear — top cells on paint, the visible range on scroll.
pub fn start_scan(window: &Main) { pub fn start_scan(window: &Main) {
let model = ModelRc::new(VecModel::<Item>::default()); let model = ModelRc::new(VecModel::<Item>::default());
window.set_items(model); window.set_items(model);
let weak = window.as_weak(); let weak = window.as_weak();
let app = state(); 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); start_drain_timer(&weak);
std::thread::spawn(move || { std::thread::spawn(move || {

View File

@ -1,31 +1,87 @@
// Gallery — M2: metadata list with streaming thumbnails. // Gallery — M3: 3-column thumbnail grid with shimmer placeholders.
// Rows are 36px: 28px thumbnail (image-fit: cover) + path text. //
// `scrolled` drives Rust-side prefetch (lib.rs::on_scroll); the grid (M3) // The ListView virtualizes ROWS; each row holds up to 3 square cells.
// replaces the row body with square image 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"; import { ListView, VerticalBox } from "std-widgets.slint";
export struct Item { export struct Cell {
path: string, path: string,
image: image, 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> 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 <Item> row-data;
property <length> 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 { export component Main inherits Window {
title: "Gallery"; title: "Gallery";
/// Fired by the ListView on every scroll step: (content-y, list height). /// Fired by the ListView on every scroll step: (content-y, list height,
/// content-y is negative while scrolled down. (Rust setter: on_scroll_changed) /// list width). content-y is negative while scrolled down.
callback scroll-changed(y: length, height: length); /// (Rust setter: on_scroll_changed)
callback scroll-changed(y: length, height: length, width: length);
/// Scan/DB status line. /// Scan/DB status line.
in property <string> status: "Starting…"; in property <string> status: "Starting…";
/// Rows, newest first. `image` starts empty and fills as the thumbnail /// Rows of 3 cells, newest first. Cells fill as the thumbnail pool
/// pool delivers results. /// delivers results.
in property <[Item]> items; in property <[Item]> items;
VerticalBox { VerticalBox {
width: 100%; width: 100%;
spacing: 8px; spacing: 8px;
padding: 12px; padding: 12px; // = GRID_PADDING in lib.rs
// Respect status-bar / nav-bar cutouts (built into the Slint window // Respect status-bar / nav-bar cutouts (built into the Slint window
// on Android via safe-area-insets). // on Android via safe-area-insets).
padding-top: 12px + root.safe-area-insets.top; padding-top: 12px + root.safe-area-insets.top;
@ -41,34 +97,12 @@ export component Main inherits Window {
ListView { ListView {
width: 100%; width: 100%;
vertical-stretch: 1; vertical-stretch: 1;
// Scroll-driven prefetch: keep ~SCROLL_MARGIN rows on each side // Scroll-driven prefetch + memory bound (see lib.rs::on_scroll).
// of the visible range submitted to the pool (see lib.rs).
scrolled => { 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 { for row in root.items: GridRow {
height: 36px; row-data: row;
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;
}
} }
} }
} }

View File

@ -28,10 +28,10 @@ use sha1::{Digest, Sha1};
use crate::db; use crate::db;
use crate::images; use crate::images;
/// M2 list thumbnails: 28 px rows x 2 (Retina). The M3 grid will ask for /// Grid thumbnails: ~500 px (longest side). Cells render ~350–420 px physical
/// 500 px cells (HANDOFF §4) — the target is part of the cache key, so both /// (3 columns) on a phone; 500 keeps them sharp. The target is part of the
/// sizes can coexist. /// cache key, so a future different-size target coexists in the same dir.
pub const LIST_TARGET: u32 = 64; pub const GRID_TARGET: u32 = 500;
const JPEG_QUALITY: u8 = 85; const JPEG_QUALITY: u8 = 85;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -374,7 +374,7 @@ mod tests {
}).collect::<Vec<_>>(); }).collect::<Vec<_>>();
// 2. Cold run. // 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 gen = eng.generation();
let t0 = Instant::now(); let t0 = Instant::now();
eng.submit(&entries.iter().enumerate().map(|(i, e)| Job { 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"); assert_eq!(std::fs::read_dir(&cache).unwrap().count(), N, "cache holds all entries");
// 3. Warm run: everything must come from the disk cache. // 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 gen2 = eng2.generation();
let t0 = Instant::now(); let t0 = Instant::now();
eng2.submit(&entries.iter().enumerate().map(|(i, e)| Job { 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()); assert!(warm_total.as_secs_f64() < cold_total.as_secs_f64());
// 4. Cancellation: submit, bump generation before draining -> dropped. // 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 stale = eng3.generation();
let p = &entries[0]; let p = &entries[0];
// Use a fresh path (mtime bump) so it is a cache miss and takes time. // Use a fresh path (mtime bump) so it is a cache miss and takes time.