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

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:
//! - 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<AppState> = 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<Vec<Entry>>,
/// Paths already handed to the pool for the current list. UI thread only.
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.
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<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
/// 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<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 };
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<Item> = 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::<VecModel<Item>>() {
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<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.
fn prefetch(app: &AppState, range: std::ops::Range<usize>) {
fn prefetch(app: &AppState, range: Range<usize>) {
if range.is_empty() {
return;
}
@ -146,17 +213,137 @@ fn prefetch(app: &AppState, range: std::ops::Range<usize>) {
}
}
/// 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<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.
/// 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<Main>) {
let weak = weak.clone();
let timer = Timer::default();
@ -166,37 +353,70 @@ fn start_drain_timer(weak: &slint::Weak<Main>) {
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::<VecModel<Item>>() 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::<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,
},
);
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<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 {
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<Main>) {
}
/// 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::<Item>::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 || {

View File

@ -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> 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 {
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 <string> 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;
}
}
}

View File

@ -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::<Vec<_>>();
// 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.