M1: metadata scan + sqlite DB + placeholder list
- scan.rs: recursive walk of Pictures/DCIM (GALLERY_SCAN_DIRS override), image-ext detection, mtime/size capture, no symlink-dir following - db.rs: rusqlite(bundled) store; incremental apply (insert/update/delete) keyed on path with mtime/size change detection; derived columns reset on change; Android DB path in Android/data/<pkg>/files (EXTERNAL_DATA_DIR is unset in this NativeActivity setup) - lib.rs: start_scan() background thread -> invoke_from_event_loop -> VecModel + status line; called from desktop and Android entry points - main.slint: ListView of paths (M1), status line with scan stats - main.rs: fix desktop bin for 1.18.1 (no slint::Result; ComponentHandle) - build.sh: export NDK clang CC_/CXX_/AR_ per target (bundled sqlite) - tests: 5k-file scan < 2s, incremental apply, roundtrip Verified on emulator: 5002 files in 153-226 ms; relaunch 0+ 0~ 0-; touch -> 1~, delete -> 1-.
This commit is contained in:
parent
53ba74747d
commit
e1982eade4
74
Cargo.lock
generated
74
Cargo.lock
generated
|
|
@ -1186,6 +1186,18 @@ dependencies = [
|
|||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||
|
||||
[[package]]
|
||||
name = "fallible-streaming-iterator"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
|
|
@ -1484,8 +1496,10 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"android_logger",
|
||||
"log",
|
||||
"rusqlite",
|
||||
"slint",
|
||||
"slint-build",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1727,6 +1741,15 @@ dependencies = [
|
|||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb"
|
||||
dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
|
|
@ -2592,6 +2615,17 @@ dependencies = [
|
|||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.39.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "14a5f01b87b92705d07b433c06f96cbc764637d81a0bffb5734ce17cf0f9a2b3"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libudev-sys"
|
||||
version = "0.1.4"
|
||||
|
|
@ -3847,6 +3881,30 @@ dependencies = [
|
|||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsqlite-vfs"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "353b74f9810d5b085b6c42c9d86a5f0fdb795553235cdd48753b32e3809d18a7"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.41.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c026b4444579e8d754556c6c75d040d69c4a44283cdb648ab8a8d5965c2cfa8"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
"libsqlite3-sys",
|
||||
"smallvec",
|
||||
"sqlite-wasm-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
|
|
@ -4289,6 +4347,16 @@ dependencies = [
|
|||
"bitflags 2.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlite-wasm-rs"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "36bd8e29bf08189a005b2df1df99823aa0861cabe9044e5f31ed54ca292ee618"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"rsqlite-vfs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
|
|
@ -4829,6 +4897,12 @@ dependencies = [
|
|||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
|
|
|
|||
|
|
@ -10,10 +10,14 @@ crate-type = ["cdylib", "lib"]
|
|||
slint = { version = "1.18.1", features = ["backend-android-activity-06"] }
|
||||
log = "0.4"
|
||||
android_logger = { version = "0.14", target-family = "android" }
|
||||
rusqlite = { version = "0.41.0", features = ["bundled"] }
|
||||
|
||||
[build-dependencies]
|
||||
slint-build = "1.18.1"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.27.0"
|
||||
|
||||
# --- Android packaging (cargo-apk2 1.5) ---
|
||||
# NOTE: uses_permission is a TOP-LEVEL android key (ndk-build2 manifest layer),
|
||||
# not under [package.metadata.android.application]. max_sdk_version supported.
|
||||
|
|
|
|||
29
HANDOFF.md
29
HANDOFF.md
|
|
@ -269,11 +269,30 @@ pool threads), and `logcat` pool timing logs behind a `debug_timing` flag.
|
|||
- **M0 — scaffold (DONE)**: builds (x86_64 + aarch64 toolchain), installs,
|
||||
runs on emulator; permission-aware init + Resume hook; build script;
|
||||
manifest with permissions; this document.
|
||||
- **M1 — metadata**: `db.rs` + `scan.rs`; scan Pictures+DCIM on first launch
|
||||
(background thread, progress in UI); sqlite table; list of items as a
|
||||
Slint `Model` fed to a placeholder `ListView` (paths as text, no images).
|
||||
*Accept: 5k files scanned < 2 s; relaunch reuses DB; mtime-changed files
|
||||
refreshed.*
|
||||
- **M1 — metadata (DONE, 2026-10-27)**: `db.rs` + `scan.rs`; background scan
|
||||
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`.
|
||||
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<(),
|
||||
slint::PlatformError>` + `use slint::ComponentHandle;`. (The M0 desktop
|
||||
bin never actually compiled; only the Android lib was built. The eval
|
||||
sample `slint_app` is broken the same way.)
|
||||
- `.slint` modulo is `Math.mod(x, y)`, not `%`.
|
||||
- ListView rows: `for path[index] in root.items: …` (`index` is not in
|
||||
scope unless named; `parent` = the ListView).
|
||||
- `rusqlite(bundled)` needs `CC_<triple>` pointing at NDK clang for
|
||||
Android — `scripts/build.sh` now exports them.
|
||||
- Android DB path: `EXTERNAL_DATA_DIR` is **not set** by this
|
||||
NativeActivity setup and `temp_dir()` = `/data/user/0` (unwritable).
|
||||
Use `/storage/emulated/0/Android/data/<pkg>/files`, internal files dir
|
||||
fallback (see `db::android_data_dir`).
|
||||
- Emulator: grant all-files access once via
|
||||
`adb shell appops set wow.gmp.gallery MANAGE_EXTERNAL_STORAGE allow`; it
|
||||
survives `adb install -r`.
|
||||
- Files pushed with `adb push` are owned by `shell` mode 640 — `chmod 644`
|
||||
or the app can't read them.
|
||||
- **M2 — thumbnail engine**: `thumb.rs` + `images.rs`; disk cache;
|
||||
cancellation; pool; synthetic 5k test set; timing logs.
|
||||
*Accept: first grid fill < 2 s; re-launch fills instantly from disk cache;
|
||||
|
|
|
|||
|
|
@ -3,9 +3,10 @@
|
|||
A high-performance image gallery for Android (primary), Linux and macOS —
|
||||
Rust + [Slint](https://slint.dev) 1.18.
|
||||
|
||||
**Status: M0 (scaffold).** See **[HANDOFF.md](HANDOFF.md)** — the development
|
||||
hand-off with the full plan: architecture, thumbnail pipeline, milestones
|
||||
M1–M7, Slint gotchas, permission flow, and performance targets.
|
||||
**Status: M1 (metadata scan + DB) done.** See **[HANDOFF.md](HANDOFF.md)** —
|
||||
the development hand-off with the full plan: architecture, thumbnail
|
||||
pipeline, milestones M1–M7, Slint gotchas, permission flow, and performance
|
||||
targets. Next: M2 (thumbnail engine).
|
||||
|
||||
## Build
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,15 @@ cd "$(dirname "$0")/.."
|
|||
export ANDROID_HOME=$HOME/android-sdk
|
||||
export ANDROID_NDK_ROOT=$HOME/android-sdk/ndk/27.0.12077973
|
||||
export PATH="$HOME/.cargo/bin:$ANDROID_HOME/platform-tools:$PATH"
|
||||
# C/C++ cross-compilers for bundled native deps (libsqlite3-sys via rusqlite).
|
||||
# The `cc` crate picks these up per target triple (min sdk 26).
|
||||
NDK_BIN=$ANDROID_NDK_ROOT/toolchains/llvm/prebuilt/linux-x86_64/bin
|
||||
export CC_x86_64_linux_android=$NDK_BIN/x86_64-linux-android26-clang
|
||||
export CXX_x86_64_linux_android=$NDK_BIN/x86_64-linux-android26-clang++
|
||||
export AR_x86_64_linux_android=$NDK_BIN/llvm-ar
|
||||
export CC_aarch64_linux_android=$NDK_BIN/aarch64-linux-android26-clang
|
||||
export CXX_aarch64_linux_android=$NDK_BIN/aarch64-linux-android26-clang++
|
||||
export AR_aarch64_linux_android=$NDK_BIN/llvm-ar
|
||||
# Debug-keystore signing is fine for personal use / Play internal testing.
|
||||
export CARGO_APK_RELEASE_KEYSTORE=$HOME/.android/debug.keystore
|
||||
export CARGO_APK_RELEASE_KEYSTORE_PASSWORD=android
|
||||
|
|
|
|||
201
src/db.rs
Normal file
201
src/db.rs
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
//! SQLite metadata store (M1). Schema follows HANDOFF.md §4:
|
||||
//! one row per image; M1 fills `path/mtime/size`, the thumbnail engine (M2)
|
||||
//! fills `width/height/orientation/thumb_path`, `date_taken` later.
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::scan::Entry;
|
||||
|
||||
const ANDROID_PACKAGE: &str = "wow.gmp.gallery";
|
||||
|
||||
/// App-writable data dir on Android. Prefers the per-app external dir
|
||||
/// (/storage/emulated/0/Android/data/<pkg>/files) so the DB is easy to
|
||||
/// inspect via `adb`; falls back to the internal files dir, which is always
|
||||
/// writable. Neither `EXTERNAL_DATA_DIR` (not set by this NativeActivity
|
||||
/// setup) nor `temp_dir()` (=/data/user/0, not writable) can be trusted.
|
||||
#[cfg(target_os = "android")]
|
||||
fn android_data_dir() -> PathBuf {
|
||||
if let Some(ext) = std::env::var_os("EXTERNAL_DATA_DIR").map(PathBuf::from) {
|
||||
if std::fs::create_dir_all(&ext).is_ok() {
|
||||
return ext;
|
||||
}
|
||||
}
|
||||
if let Some(storage) = std::env::var_os("EXTERNAL_STORAGE").map(PathBuf::from) {
|
||||
let ext = storage
|
||||
.join("Android/data")
|
||||
.join(ANDROID_PACKAGE)
|
||||
.join("files");
|
||||
if std::fs::create_dir_all(&ext).is_ok() {
|
||||
return ext;
|
||||
}
|
||||
}
|
||||
PathBuf::from(format!("/data/data/{ANDROID_PACKAGE}/files"))
|
||||
}
|
||||
|
||||
/// DB file lives in app-private storage:
|
||||
/// - Android: per-app external dir (see `android_data_dir`), internal fallback.
|
||||
/// - Desktop: $XDG_DATA_HOME/gallery (or ~/.local/share/gallery).
|
||||
pub fn db_path() -> PathBuf {
|
||||
#[cfg(target_os = "android")]
|
||||
let dir = android_data_dir();
|
||||
#[cfg(not(target_os = "android"))]
|
||||
let dir = {
|
||||
let data_home = std::env::var_os("XDG_DATA_HOME").map(PathBuf::from);
|
||||
let base = data_home.unwrap_or_else(|| {
|
||||
let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_default();
|
||||
home.join(".local/share")
|
||||
});
|
||||
base.join("gallery")
|
||||
};
|
||||
dir.join("gallery.db")
|
||||
}
|
||||
|
||||
pub fn open(path: &std::path::Path) -> Result<Connection, rusqlite::Error> {
|
||||
let conn = Connection::open(path)?;
|
||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS items (
|
||||
path TEXT PRIMARY KEY,
|
||||
mtime INTEGER NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
orientation INTEGER,
|
||||
thumb_path TEXT,
|
||||
date_taken INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS items_mtime ON items (mtime DESC);",
|
||||
)?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
/// Apply a full filesystem snapshot incrementally: insert new paths, refresh
|
||||
/// rows whose mtime/size changed, delete vanished ones.
|
||||
/// Returns (inserted, updated, deleted).
|
||||
pub fn apply(
|
||||
conn: &mut Connection,
|
||||
entries: &[Entry],
|
||||
) -> Result<(usize, usize, usize), rusqlite::Error> {
|
||||
// Existing state: path -> (mtime, size)
|
||||
let mut existing: HashMap<String, (i64, i64)> = HashMap::with_capacity(entries.len());
|
||||
{
|
||||
let mut st = conn.prepare("SELECT path, mtime, size FROM items")?;
|
||||
let rows = st.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
r.get::<_, i64>(1)?,
|
||||
r.get::<_, i64>(2)?,
|
||||
))
|
||||
})?;
|
||||
for row in rows {
|
||||
let (p, m, s) = row?;
|
||||
existing.insert(p, (m, s));
|
||||
}
|
||||
}
|
||||
|
||||
let mut inserted = 0;
|
||||
let mut updated = 0;
|
||||
let mut deleted = 0;
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
let mut ins = tx.prepare(
|
||||
"INSERT INTO items (path, mtime, size) VALUES (?1, ?2, ?3)",
|
||||
)?;
|
||||
// mtime/size changed → content changed → derived columns are stale.
|
||||
let mut upd = tx.prepare(
|
||||
"UPDATE items SET mtime = ?2, size = ?3, width = NULL, height = NULL, \
|
||||
orientation = NULL, thumb_path = NULL, date_taken = NULL WHERE path = ?1",
|
||||
)?;
|
||||
let mut del = tx.prepare("DELETE FROM items WHERE path = ?1")?;
|
||||
|
||||
let mut seen = HashSet::with_capacity(entries.len());
|
||||
for e in entries {
|
||||
seen.insert(e.path.clone());
|
||||
match existing.get(&e.path) {
|
||||
Some(&(m, s)) if m == e.mtime && s == e.size => {}
|
||||
Some(_) => {
|
||||
upd.execute(params![e.path, e.mtime, e.size])?;
|
||||
updated += 1;
|
||||
}
|
||||
None => {
|
||||
ins.execute(params![e.path, e.mtime, e.size])?;
|
||||
inserted += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
for p in existing.keys() {
|
||||
if !seen.contains(p) {
|
||||
del.execute(params![p])?;
|
||||
deleted += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok((inserted, updated, deleted))
|
||||
}
|
||||
|
||||
/// All known paths, newest first.
|
||||
pub fn list_paths(conn: &mut Connection) -> Result<Vec<String>, rusqlite::Error> {
|
||||
let mut st = conn.prepare("SELECT path FROM items ORDER BY mtime DESC, path ASC")?;
|
||||
let rows = st.query_map([], |r| r.get::<_, String>(0))?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
out.push(row?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::scan::{walk, Entry};
|
||||
|
||||
fn entry(path: &str, mtime: i64, size: i64) -> Entry {
|
||||
Entry { path: path.into(), mtime, size }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incremental_apply() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut conn = open(&dir.path().join("t.db")).unwrap();
|
||||
|
||||
let (i, u, d) = apply(&mut conn, &[entry("/a/1.jpg", 1, 10), entry("/a/2.jpg", 2, 20)]).unwrap();
|
||||
assert_eq!((i, u, d), (2, 0, 0));
|
||||
|
||||
// No changes → no-op.
|
||||
let (i, u, d) = apply(&mut conn, &[entry("/a/1.jpg", 1, 10), entry("/a/2.jpg", 2, 20)]).unwrap();
|
||||
assert_eq!((i, u, d), (0, 0, 0));
|
||||
|
||||
// mtime changed → update; 3.jpg new; 2.jpg removed.
|
||||
let (i, u, d) = apply(
|
||||
&mut conn,
|
||||
&[entry("/a/1.jpg", 5, 11), entry("/a/3.jpg", 3, 30)],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((i, u, d), (1, 1, 1));
|
||||
|
||||
let paths = list_paths(&mut conn).unwrap();
|
||||
assert_eq!(paths, vec!["/a/1.jpg", "/a/3.jpg"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_then_apply_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("sub")).unwrap();
|
||||
std::fs::write(dir.path().join("a.jpg"), b"xx").unwrap();
|
||||
std::fs::write(dir.path().join("sub").join("b.png"), b"y").unwrap();
|
||||
std::fs::write(dir.path().join("notes.txt"), b"nope").unwrap();
|
||||
|
||||
let db_dir = tempfile::tempdir().unwrap();
|
||||
let mut conn = open(&db_dir.path().join("t.db")).unwrap();
|
||||
let entries = walk(&[dir.path().to_path_buf()], &|_| {});
|
||||
let (i, u, d) = apply(&mut conn, &entries).unwrap();
|
||||
assert_eq!((i, u, d), (2, 0, 0));
|
||||
|
||||
let paths = list_paths(&mut conn).unwrap();
|
||||
assert_eq!(paths.len(), 2);
|
||||
assert!(paths.iter().all(|p| p.contains(dir.path().to_str().unwrap())));
|
||||
}
|
||||
}
|
||||
59
src/lib.rs
59
src/lib.rs
|
|
@ -1,5 +1,63 @@
|
|||
slint::include_modules!();
|
||||
|
||||
pub mod db;
|
||||
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()`.
|
||||
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());
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (ins, upd, del) = db::apply(&mut db, &entries).unwrap_or_else(|e| {
|
||||
eprintln!("gallery: db apply error: {e}");
|
||||
(0, 0, 0)
|
||||
});
|
||||
let paths = db::list_paths(&mut db).unwrap_or_default();
|
||||
let n = paths.len();
|
||||
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());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
use std::sync::OnceLock;
|
||||
|
||||
|
|
@ -39,5 +97,6 @@ fn android_main(app: slint::android::AndroidApp) {
|
|||
.unwrap();
|
||||
|
||||
let main_window = Main::new().unwrap();
|
||||
start_scan(&main_window);
|
||||
main_window.run().unwrap();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
use gallery::Main;
|
||||
use slint::ComponentHandle;
|
||||
|
||||
fn main() -> slint::Result {
|
||||
fn main() -> Result<(), slint::PlatformError> {
|
||||
let main_window = Main::new()?;
|
||||
gallery::start_scan(&main_window);
|
||||
main_window.show()?;
|
||||
main_window.run()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
// Gallery — M0 scaffold. Real UI arrives with M3 (grid) per HANDOFF.md.
|
||||
import { VerticalBox } from "std-widgets.slint";
|
||||
// Gallery — M1: metadata scan + placeholder list.
|
||||
// The real grid (image cells) arrives with M3 per HANDOFF.md.
|
||||
import { ListView, VerticalBox } from "std-widgets.slint";
|
||||
|
||||
export component Main inherits Window {
|
||||
title: "Gallery";
|
||||
|
||||
in property <string> status: "M0 scaffold — see HANDOFF.md";
|
||||
/// Scan/DB status line ("Scanning… 1234", "5123 files (42+ 3~ 1–) in 380 ms").
|
||||
in property <string> status: "Scanning…";
|
||||
/// Scanned file paths, newest first (M1: shown as text rows).
|
||||
in property <[string]> items;
|
||||
|
||||
VerticalBox {
|
||||
width: 100%;
|
||||
|
|
@ -21,12 +25,24 @@ export component Main inherits Window {
|
|||
font-weight: 600;
|
||||
}
|
||||
Text { text: "\{status}"; font-size: 14px; }
|
||||
// M3: the virtualized grid (ListView) goes here.
|
||||
Rectangle {
|
||||
|
||||
// M1: paths as text. M3 replaces the row body with square image cells.
|
||||
ListView {
|
||||
width: 100%;
|
||||
vertical-stretch: 1;
|
||||
background: #222;
|
||||
border-radius: 8px;
|
||||
for path[index] in root.items: Rectangle {
|
||||
height: 28px;
|
||||
width: parent.width;
|
||||
background: Math.mod(index, 2) == 0 ? #1e1e1e : #242424;
|
||||
border-radius: 4px;
|
||||
Text {
|
||||
x: 8px;
|
||||
text: path;
|
||||
font-size: 13px;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
136
src/scan.rs
Normal file
136
src/scan.rs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
//! Directory walk + image detection (M1).
|
||||
//!
|
||||
//! Plain recursive scan of the roots (Pictures + DCIM on Android). This is
|
||||
//! what makes the app testable without MediaStore; MediaStore integration is
|
||||
//! a later refinement (HANDOFF.md §7.4).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// One image on disk. `mtime` is nanoseconds since the Unix epoch (i64).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Entry {
|
||||
pub path: String,
|
||||
pub mtime: i64,
|
||||
pub size: i64,
|
||||
}
|
||||
|
||||
/// Lower-case image extensions we index. HEIC is the Android camera default.
|
||||
pub const IMAGE_EXTS: &[&str] = &[
|
||||
"jpg", "jpeg", "png", "webp", "heic", "heif", "bmp", "tif", "tiff",
|
||||
];
|
||||
|
||||
/// Scan roots. `GALLERY_SCAN_DIRS` (colon-separated) overrides — test hook.
|
||||
pub fn roots() -> Vec<PathBuf> {
|
||||
if let Ok(d) = std::env::var("GALLERY_SCAN_DIRS") {
|
||||
return d
|
||||
.split(':')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.collect();
|
||||
}
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
let ext = PathBuf::from("/storage/emulated/0");
|
||||
vec![ext.join("Pictures"), ext.join("DCIM")]
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_default();
|
||||
vec![home.join("Pictures"), home.join("DCIM")]
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let mut out = Vec::new();
|
||||
for r in roots {
|
||||
if r.is_dir() {
|
||||
walk_dir(r, &mut out, on_progress);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn walk_dir(dir: &Path, out: &mut Vec<Entry>, on_progress: &dyn Fn(usize)) {
|
||||
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() {
|
||||
// Don't follow symlinked dirs (loop protection).
|
||||
if !p.is_symlink() {
|
||||
walk_dir(&p, out, on_progress);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !ft.is_file() {
|
||||
continue;
|
||||
}
|
||||
let Some(ext) = p.extension().and_then(|e| e.to_str()) else { continue };
|
||||
if !IMAGE_EXTS.contains(&ext.to_ascii_lowercase().as_str()) {
|
||||
continue;
|
||||
}
|
||||
let Ok(md) = std::fs::metadata(&p) else { continue };
|
||||
let mtime = md
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_nanos() as i64)
|
||||
.unwrap_or(0);
|
||||
out.push(Entry {
|
||||
path: p.to_string_lossy().into_owned(),
|
||||
mtime,
|
||||
size: md.len() as i64,
|
||||
});
|
||||
if out.len() % 512 == 0 {
|
||||
on_progress(out.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
fn make_tree(root: &Path, n_files: usize, subdirs: usize) {
|
||||
for d in 0..subdirs {
|
||||
let dir = root.join(format!("dir{d}"));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
}
|
||||
for i in 0..n_files {
|
||||
let dir = if i % 2 == 0 { root.join(format!("dir{}", i % subdirs)) } else { root.to_path_buf() };
|
||||
let mut f = std::fs::File::create(dir.join(format!("img{i}.jpg"))).unwrap();
|
||||
f.write_all(&vec![0u8; i % 64]).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_images_skips_others() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("a.jpg"), b"x").unwrap();
|
||||
std::fs::write(dir.path().join("B.PNG"), b"x").unwrap();
|
||||
std::fs::write(dir.path().join("c.txt"), b"x").unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("d")).unwrap();
|
||||
std::fs::write(dir.path().join("d").join("e.heic"), b"x").unwrap();
|
||||
let out = walk(&[dir.path().to_path_buf()], &|_| {});
|
||||
assert_eq!(out.len(), 3);
|
||||
assert!(out.iter().all(|e| e.size == 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_5k_files_under_2s() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
make_tree(dir.path(), 5000, 8);
|
||||
let t = std::time::Instant::now();
|
||||
let out = walk(&[dir.path().to_path_buf()], &|_| {});
|
||||
let elapsed = t.elapsed();
|
||||
assert_eq!(out.len(), 5000);
|
||||
assert!(
|
||||
elapsed.as_millis() < 2000,
|
||||
"scan took {:?} (> 2 s)",
|
||||
elapsed
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user