M1: instant list paint + streaming first scan

- relaunch: DB list paints immediately ('N files (cached)'), background
  walk refreshes ('Updating… N') — no more 5.5 s blank list on warm start
- first launch: walk_stream() feeds the list in 512-file batches while
  scanning; UI never waits for the full DB build
- skip hidden dirs (.thumbnails/.trash/…): -559 entries on the phone
- db.rs: cfg-gate ANDROID_PACKAGE (dead-code warning on desktop)

Phone (8.5k photos): cold scan 4.2 s with live list; warm relaunch
paints instantly, rescan 4.2 s in background.
This commit is contained in:
Greg Pomerantz 2026-10-27 13:45:37 -04:00
parent e1982eade4
commit c72b00f7ec
4 changed files with 84 additions and 32 deletions

View File

@ -273,6 +273,15 @@ pool threads), and `logcat` pool timing logs behind a `debug_timing` flag.
thread → sqlite → `VecModel` in a `ListView` (paths as text). Acceptance
verified on emulator: **5002 files in 153 ms**; relaunch `0+ 0~ 0-` in 2 ms;
mtime-touch → `1~`, delete → `1-`. Status line shows `N files (a+ u~ d–) in X ms`.
List paint is instant on relaunch: the DB list is shown first
("N files (cached)"), the background walk then runs and refreshes
("Updating… N"). First launch streams results into the list in 512-file
batches while scanning — the UI never waits for the full DB build
(`walk_stream`). Hidden dirs (`.thumbnails`, …) are skipped.
Real-device numbers (Pixel-class, 8.5k photos): cold scan ~4.2–5.9 s
(filesystem metadata cost — the walk is the bottleneck, not sqlite),
relaunch paint instant, rescan ~4.2 s. Walk parallelism is an M2/M7
optimization candidate.
Gotchas hit (also see §6/§12):
- `slint::Result` / `slint::WindowHandle` **do not exist in 1.18.1** as
published on crates.io — desktop bin is `fn main() -> Result<(),

View File

@ -8,6 +8,7 @@ use std::path::PathBuf;
use crate::scan::Entry;
#[cfg(target_os = "android")]
const ANDROID_PACKAGE: &str = "wow.gmp.gallery";
/// App-writable data dir on Android. Prefers the per-app external dir

View File

@ -5,39 +5,76 @@ pub mod scan;
use slint::{ComponentHandle, Model, SharedString, VecModel};
/// M1: kick off the background scan + DB refresh; UI updates are posted to
/// the event loop (the scan thread must never touch the window directly).
/// Called from both the desktop and Android entry points after `Main::new()`.
/// Fill the window's list model from `paths` and set the status line.
fn set_list(weak: &slint::Weak<Main>, paths: Vec<String>, status: String) {
let weak = weak.clone();
let _ = slint::invoke_from_event_loop(move || {
let Some(w) = weak.upgrade() else { return };
if let Some(m) = w.get_items().as_any().downcast_ref::<VecModel<SharedString>>() {
m.set_vec(paths.into_iter().map(SharedString::from).collect::<Vec<_>>());
}
w.set_status(status.into());
});
}
/// Set only the status line (never touches the model).
fn set_status(weak: &slint::Weak<Main>, status: String) {
let weak = weak.clone();
let _ = slint::invoke_from_event_loop(move || {
if let Some(w) = weak.upgrade() {
w.set_status(status.into());
}
});
}
/// M1: metadata from sqlite paints the list immediately (first frame < 100 ms
/// per HANDOFF §8); a background walk then refreshes the DB and the list.
/// UI updates are posted to the event loop — the scan thread never touches the
/// window directly. Called from both entry points after `Main::new()`.
pub fn start_scan(window: &Main) {
let model = slint::ModelRc::new(VecModel::<SharedString>::default());
window.set_items(model);
let weak = window.as_weak();
std::thread::spawn(move || {
let t0 = std::time::Instant::now();
let roots = scan::roots();
let entries = scan::walk(&roots, &|n| {
let weak = weak.clone();
let msg = format!("Scanning… {}", n);
let _ = slint::invoke_from_event_loop(move || {
if let Some(w) = weak.upgrade() {
w.set_status(msg.into());
}
});
});
let mut db = match db::open(&db::db_path()) {
Ok(db) => db,
Err(e) => {
eprintln!("gallery: db error: {e}");
let _ = slint::invoke_from_event_loop( move || {
if let Some(w) = weak.upgrade() {
w.set_status(format!("db error: {e}").into());
}
});
set_list(&weak, Vec::new(), format!("db error: {e}"));
return;
}
};
// 1. Instant first paint from the cached DB (relaunch path).
let cached = db::list_paths(&mut db).unwrap_or_default();
let n0 = cached.len();
let db_warm = n0 > 0;
if db_warm {
set_list(&weak, cached, format!("{n0} files (cached)"));
}
// 2. Background rescan: detect new/changed/removed files.
// Cold start: stream results into the list as they're found, so the
// UI fills in while the scan runs (never waits for the full DB).
// Warm start: keep the cached list, only update the status line.
let t0 = std::time::Instant::now();
let roots = scan::roots();
let entries = scan::walk_stream(&roots, &|batch| {
let n = batch.len();
if db_warm {
set_status(&weak, format!("Updating… {n}"));
} else {
let mut sorted = batch.to_vec();
sorted.sort_by(|a, b| b.mtime.cmp(&a.mtime).then_with(|| a.path.cmp(&b.path)));
set_list(
&weak,
sorted.into_iter().map(|e| e.path).collect(),
format!("Scanning… {n}"),
);
}
});
let (ins, upd, del) = db::apply(&mut db, &entries).unwrap_or_else(|e| {
eprintln!("gallery: db apply error: {e}");
(0, 0, 0)
@ -47,14 +84,7 @@ pub fn start_scan(window: &Main) {
let ms = t0.elapsed().as_millis();
log::info!("gallery: scan {n} files ({ins}+ {upd}~ {del}-) in {ms} ms");
let weak = weak.clone();
let _ = slint::invoke_from_event_loop(move || {
let Some(w) = weak.upgrade() else { return };
if let Some(m) = w.get_items().as_any().downcast_ref::<VecModel<SharedString>>() {
m.set_vec(paths.into_iter().map(SharedString::from).collect::<Vec<_>>());
}
w.set_status(format!("{n} files ({ins}+ {upd}~ {del}–) in {ms} ms").into());
});
set_list(&weak, paths, format!("{n} files ({ins}+ {upd}~ {del}–) in {ms} ms"));
});
}

View File

@ -43,24 +43,36 @@ pub fn roots() -> Vec<PathBuf> {
/// Recursively collect all image entries below `roots`.
/// `on_progress(n)` is called every 512 files found.
pub fn walk(roots: &[PathBuf], on_progress: &dyn Fn(usize)) -> Vec<Entry> {
walk_stream(roots, &|batch| on_progress(batch.len()))
}
/// Like `walk`, but streams progress: `on_batch(&entries)` receives ALL
/// entries collected so far every 512 new files, so the UI can show results
/// while the scan is still running (first launch must not wait for the full
/// DB build).
pub fn walk_stream(roots: &[PathBuf], on_batch: &dyn Fn(&[Entry])) -> Vec<Entry> {
let mut out = Vec::new();
for r in roots {
if r.is_dir() {
walk_dir(r, &mut out, on_progress);
walk_dir(r, &mut out, on_batch);
}
}
out
}
fn walk_dir(dir: &Path, out: &mut Vec<Entry>, on_progress: &dyn Fn(usize)) {
fn walk_dir(dir: &Path, out: &mut Vec<Entry>, on_batch: &dyn Fn(&[Entry])) {
let Ok(rd) = std::fs::read_dir(dir) else { return };
for ent in rd.flatten() {
let p = ent.path();
let Ok(ft) = ent.file_type() else { continue };
if ft.is_dir() {
// Skip hidden dirs (.thumbnails, .trash, …) — metadata, not photos.
if p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with('.')) {
continue;
}
// Don't follow symlinked dirs (loop protection).
if !p.is_symlink() {
walk_dir(&p, out, on_progress);
walk_dir(&p, out, on_batch);
}
continue;
}
@ -84,7 +96,7 @@ fn walk_dir(dir: &Path, out: &mut Vec<Entry>, on_progress: &dyn Fn(usize)) {
size: md.len() as i64,
});
if out.len() % 512 == 0 {
on_progress(out.len());
on_batch(out);
}
}
}