diff --git a/HANDOFF.md b/HANDOFF.md index 1b1fe02..a831b80 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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<(), diff --git a/src/db.rs b/src/db.rs index 383afe2..3c9dc29 100644 --- a/src/db.rs +++ b/src/db.rs @@ -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 diff --git a/src/lib.rs b/src/lib.rs index b4c7877..0d3a19d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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
, paths: Vec, 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::>() { + m.set_vec(paths.into_iter().map(SharedString::from).collect::>()); + } + w.set_status(status.into()); + }); +} + +/// Set only the status line (never touches the model). +fn set_status(weak: &slint::Weak
, 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::::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::>() { - m.set_vec(paths.into_iter().map(SharedString::from).collect::>()); - } - 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")); }); } diff --git a/src/scan.rs b/src/scan.rs index 46b43ac..8a39bdd 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -43,24 +43,36 @@ pub fn roots() -> Vec { /// 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 { + 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 { 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, on_progress: &dyn Fn(usize)) { +fn walk_dir(dir: &Path, out: &mut Vec, 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, on_progress: &dyn Fn(usize)) { size: md.len() as i64, }); if out.len() % 512 == 0 { - on_progress(out.len()); + on_batch(out); } } }