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:
parent
572409b702
commit
59787f1d3f
58
HANDOFF.md
58
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
|
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
|
||||||
|
|
|
||||||
338
src/lib.rs
338
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:
|
//! 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);
|
let active = app.active.lock().unwrap().clone();
|
||||||
buffer.make_mut_bytes().copy_from_slice(&r.rgb);
|
|
||||||
let image = Image::from_rgb8(buffer);
|
// 1. Paints: fresh in-range results + deferred that re-entered.
|
||||||
painted += 1;
|
let mut deferred = app.deferred.lock().unwrap();
|
||||||
m.set_row_data(
|
let mut paints: Vec<JobResult> = Vec::new();
|
||||||
r.id,
|
for r in results {
|
||||||
Item {
|
if active.contains(&r.id) {
|
||||||
path: SharedString::from(r.path.clone()),
|
paints.push(r);
|
||||||
image,
|
} 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 || {
|
||||||
|
|
|
||||||
108
src/main.slint
108
src/main.slint
|
|
@ -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 {
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
14
src/thumb.rs
14
src/thumb.rs
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user