Root cause: the worker job queue was FIFO (mpsc). Scrolling down filled it with slow cold decodes of far cells; scrolling back up re-submitted the top cells as fast disk-cache hits that then waited behind the whole backlog — seconds of shimmer. Deterministic repro measured 6.3 s behind a 180-cold backlog (emulator UI showed the same while 220 cold jobs waited 0.5-0.9 s). Fix: job queue is now Mutex<VecDeque> + Condvar; Engine::submit prepends each new batch so the most-recent intent (the viewport, or what you scrolled back to) is decoded before stale far-away prefetch. Cold backlog keeps warming the disk cache in the background (total work unchanged). - regression: thumb::tests::scroll_back_cache_hit_not_starved (6.3 s -> 130 ms) - dispatcher exits via a closed flag set in Engine::drop (replaces the mpsc channel-close shutdown) - HANDOFF: M3 fix note, prepend rationale, emulator realtest_extra dataset
29 KiB
Gallery — development hand-off
A high-performance image gallery for Android (primary), Linux and macOS, written in Rust with the Slint UI toolkit. This document is the starting 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: 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), M3 grid UI (3-column ListView grid, shimmer placeholders, scroll-driven prefetch, memory-bound cell retention) all verified: 14 unit tests (incl. the scroll-back FIFO-starvation regression), 5k synthetic engine test, on the emulator (real JPEGs: first paint 0.6 s cold, warm relaunch from disk cache, 0 ANRs through 16 fast swipes), and on a real Pixel 9 Pro (8,577 photos: grid renders, scroll = 60 fps with 0 hitches). The reported scroll-back-to-top shimmer was root-caused (FIFO job queue starvation) and fixed (prepend-on-submit) + regression-tested. Next: M4 viewer.
1. Goals
- Browse and view a local photo collection (thousands of images) that stays smooth: instant grid, no decode jank, cached thumbnails.
- Full-screen viewer with pinch-zoom/pan.
- Correct EXIF orientation, HEIC support (Android camera default).
- Same codebase on macOS + Linux (desktop
cargo run) and Android (cargo apk2). - Quality of the platform experience matters: safe-area insets, IME-free UI, native-feeling widgets, animations — first-class support preferred over manual patching.
Non-goals (for now): editing, cloud sync, video, widgets/live-updates.
2. Why Slint (decision summary)
Full evaluation in /mnt/Backup/prog/android/ui/REPORT.md (same tree:
~/prog/android/ui/REPORT.md), raw notes in NOTES.md there. Headline:
| Slint 1.18.1 | eframe/egui 0.37.0 | |
|---|---|---|
| Virtualized list (the gallery's lifeline) | ✅ ListView std-widget, only visible rows instantiated |
❌ ScrollArea::show lays out all children; show_rows = manual virtualization API |
| Async image → UI | swap slint::Image into a property; only that tile redraws (retained mode) |
ctx.load_texture + custom loader; visible set rebuilt every frame |
| Lifecycle hook for permissions | ✅ init_with_event_listener (Start/Resume/Pause/Stop/SaveState/...) |
❌ none in App trait (winit swallows it) |
| Android safe-area | ✅ safe-area-insets built in |
manual 32px-ish panel workaround (winit#3910) |
| Animation | ✅ declarative built-in engine | hand-rolled |
| IME text entry | ✅ | ✅ |
| APK size (release aarch64) | 8.57 MB | 2.87 MB |
| Clean build time (x86_64, this machine) | 195 s / 270 crates | 76 s / 137 crates |
| Desktop Linux system deps | libfontconfig1 + pkg-config |
none |
| License | royalty-free for proprietary mobile/desktop (embedded excluded) / GPLv3 / commercial | MIT OR Apache-2.0 |
| Min Android API | 26 | 24 |
For a stateful, form-and-grid app, the declarative model was the right fit
(see the discussion about pad's immediate-mode frame friction); egui wins
for games/dashboards and on APK size. Known Slint caveat: an open upstream
"abort on pointer scroll" bug in the Android backend (todo!() in the
pointer-event path) — verify it's fixed in 1.19 before relying on heavy
scrolling; if it fires, the panic is obvious in logcat.
3. Environment, build & test loop
Everything below is verified working on this machine (2026-10-27).
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"
# Rust 1.99.1 via rustup; tools: cargo-apk2 1.5.0, cargo-ndk 4.1.2
./scripts/build.sh debug # debug x86_64 APK + adb install + launch
./scripts/build.sh # release x86_64 (emulator)
./scripts/build.sh aarch64 # release aarch64 (device)
cargo run # desktop (Linux needs libfontconfig1+pkg-config)
- Emulator:
emulator -avd pad_avd -no-window -no-audio -gpu off -no-snapshot(google_apis x86_64, Android 15).-gpu off -no-snapshotmatters — swiftshader_indirect + snapshot crashed the emulator once during the eval. If it won't start, the host key lost its wireless pairing — see the pairing procedure in~/.pi/agent/AGENTS.md(phone) / emulator docs (this AVD). - APK lands at
target/<debug|release>/apk/gallery.apk. - Release signing uses the debug keystore
(
CARGO_APK_RELEASE_KEYSTORE=$HOME/.android/debug.keystore,CARGO_APK_RELEASE_KEYSTORE_PASSWORD=android) — fine for personal use; for Play, generate a real keystore and point the env vars at it. cargo apk2quirk:--target(not-t).--libbuilds the cdylib.- 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 -rkeeps 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 weretouch-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. **/Pictures/realtest_extra/holds 700 more real, decodable JPEGs (PIL, 1200–4032 px, mtime 2031 so they sort to the very top, above the 150 inrealtest) — added for the scroll-back backlog repro; the real region is now ~850 files (283 rows).adb pushof a directory nests a subfolder (push files individually ormvup);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.
4. Architecture
The UI framework is only the display layer. The performance lives in the thumbnail engine, and it is framework-agnostic:
┌─ metadata ─────────────────────────────────────────────────────────┐
│ sqlite (rusqlite, bundled): path, mtime, size, w, h, orientation, │
│ thumb_path, date_taken — one background scan pass │
└──────────────┬─────────────────────────────────────────────────────┘
│ model → Slint (list of visible-range items)
┌─ thumbnail pool (tokio-free: N std::threads, N ≈ cores/2) ─────────┐
│ per job: token cancellation (AtomicU64 gen counter) + priority │
│ 1. disk cache hit? .thumbs/{sha1(path|mtime|size)}.jpg → done │
│ 2. decode AT TARGET SIZE (never full-size for the grid): │
│ JPEG: zune-jpeg "turbo" (decode+downscale in ONE pass, │
│ pure Rust, actively maintained) or turbojpeg (SIMD) │
│ HEIC: imagepipe (0.5, 2026-08) — VERIFY Android NDK build │
│ PNG: image/png │
│ 3. apply EXIF orientation (once, here — never per frame) │
│ 4. re-encode small JPEG → disk cache │
│ 5. channel → UI: (id, slint::Image) │
│ prefetch: decode visible ± 1 row; cancel items scrolled out │
└────────────────────────────────────────────────────────────────────┘
┌─ UI (Slint) ───────────────────────────────────────────────────────┐
│ grid screen: ListView (vertical) → row of N square Image cells │
│ viewer screen: full-size async decode (once, cached) + manual │
│ two-finger pinch transform (no built-in in 1.18) │
└────────────────────────────────────────────────────────────────────┘
Module layout:
src/
lib.rs # android_main + platform init + scan/UI glue (M1/M2)
main.rs # desktop entry [exists]
main.slint # screens (split into ui/ components at M3) [exists]
db.rs # sqlite schema + incremental scan apply [done M1]
scan.rs # directory walk, image detection, walk_stream [done M1]
thumb.rs # pool, dispatcher, disk cache, generation cancel [done M2]
images.rs # turbojpeg shrink-decode, EXIF orientation, PNG [done M2]
viewer.rs # full-size load + transform state (M4)
perm.rs # storage permission check/request (M5)
Crate choices (all verified current on crates.io, 2026-10):
| crate | version | why |
|---|---|---|
slint / slint-build |
1.18.1 | pinned; upgrade to 1.19 after the pointer-scroll fix lands |
rusqlite |
0.41, bundled feature |
SQLite without system deps; cross-compiles to Android |
turbojpeg |
1.5.1, cmake feature |
libjpeg-turbo DCT-level shrink decode (1/2,1/4,1/8) — 48 MP at 1/8 peaks ~2.3 MB vs 137 MB full decode. Vendors + cross-builds libjpeg-turbo via CMake/NDK (verified). Chosen over libvips (same DCT shrink mechanism, +6–10 MB of .so files, LGPL, 4.3k downloads) and over zune-jpeg (no DCT shrink = full-size decode for 12 MP photos). |
image |
0.25, default-features=false, png |
PNG thumbs + imageops::resize final fit |
kamadak-exif |
0.6 | EXIF orientation tag (all 8 values applied as pixel maps); experimental::Writer used only in tests |
sha1 |
0.11 | cache keys |
imagepipe |
— (deferred) | HEIC: the real device has 0 HEIC files (9109 JPEG, 27 PNG), so HEIC support is deferred; image can gain heic or libvips can replace the JPEG path later — the engine API is decoder-agnostic |
Decode sizes: grid tiles render at ~cell×2 (Retina) — e.g. 500 px side. Full-screen: full decode once (12 MP ≈ 30–80 ms off-thread, fine), cached in the in-memory LRU until memory pressure.
The five failure modes the current (unknown) app likely has — this design eliminates all five: (1) full-size decode for grid cells, (2) no disk thumbnail cache, (3) no list virtualization, (4) decode on UI thread without cancellation, (5) EXIF rotation applied per frame.
5. UI plan (Slint patterns)
- Grid:
ListViewis the only virtualized std-widget (confirmed in source docs: "Only visible rows are instantiated"). Grid = verticalListView, each row a horizontal layout of squareImagecells (columns ≈floor(window-width / cell); recompute on resize). Cells bindimage <=> model.image— when the pool delivers a thumbnail, only that cell invalidates (retained mode; no repaint calls to remember). - Placeholder→image: cell shows a
Rectangle(shimmer optional, M6) until itsimageis set. - Image cache: Slint's built-in path-based LRU is 5 MB (
CLruCacheininternal/core/graphics/image/cache.rs) — too small and the wrong layer for us; our cache lives in Rust (disk + compressed-bytes LRU). No mipmaps in the Skia renderer — irrelevant since we pre-downsample. - Viewer: no pinch helper in Slint 1.18 (
TouchAreahas no pinch callback; grepped the stdlib). Manual two-finger math onTouchArea(distance/centroid → scale/translation, ~50 lines; cribslint-src/examples/native-gestures— source checkout is at~/prog/android/ui/slint-src, tag v1.18.1). Double-tap-to-zoom is a nice extra (M6). - Screens: single
Windowwith a stacked layout (grid + viewer overlay) or aNavigator-style state property; keep it simple until M4. - Safe area: always pad with
root.safe-area-insets.{top,bottom}(done in the scaffold). - Animation: use built-in
animatestatements (syntax:animate <prop> { duration: ...; easing: ...; }— it is a statement, not an element). Slide/fade for the viewer is a one-liner.
6. Slint 1.18 API gotchas (all hit firsthand in the eval)
animateis a statement (animate width { duration: 300ms; }), not anAnimation {}element.- Text input widget is
LineEdit, notTextField. - Markdown interpolation:
text: "Count: \{count}"(backslash-brace). lengthis the unit type for lengths (property <length>, notlogical<length>in the 1.18 Rust API).- No Rust macros/closures inside
.slintcallbacks — callbacks call global functions or invoke Rust via@global/ imported callbacks. - 1.18 ↔ 1.19 docs drift — when reading docs online, check which
version; the local source at
~/prog/android/ui/slint-src(tag v1.18.1) is ground truth for the pinned version.git grepbeats the docs. slint::android::init_with_event_listener(app, |event: &PollEvent| ...)re-exports the wholeandroid-activitycrate asslint::android::android_activity;AndroidAppis also re-exported atslint::android::AndroidApp.slint::Imageruntime construction:Image::from_rgba8_premultiplied(alsofrom_array); assign to animageproperty to update a cell.- Desktop Linux: needs
libfontconfig1+pkg-config(pure Rust on macOS). - Retained-mode redraw: changing a property re-renders only affected elements — design bindings so thumbnail delivery touches one property.
in propertyis NOT auto-bound to a same-namedforvariable. Infor cell[c] in row.cells: CellView { ... }you must writeCellView { cell: cell; ... }— otherwise the component'sin property <Cell> cellstays at its default (empty) and the cell renders blank while looking correct. (This silently broke M3 for an hour.)- 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-liveCellViews; a row whoseGridRowinstance 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 outerrow_changedmakes the ListView re-bindrow-dataand the fresh nested read picks up the new state. Batch cell changes per row and do ≤ ~16 row-replacements per 100 ms tick (seeapply_row_batch), or a fast scroll mass-updates and ANRs. ListViewhas nospacing/gapproperty in 1.18 — bake inter-row rhythm into the item height (we add a 4 px bottom margin insideGridRow;ROW_SPACINGin lib.rs must match).
7. Permissions & platform integration (Android)
Declared in Cargo.toml (verified schema in cargo-apk2 1.5 / ndk-build2:
uses_permission is top-level under [package.metadata.android], and
Permission supports only name + max_sdk_version):
READ_MEDIA_IMAGES, READ_EXTERNAL_STORAGE (maxSdk 32),
MANAGE_EXTERNAL_STORAGE.
Runtime flow (M5), using the ANDROID_APP clone stashed in src/lib.rs:
- MANAGE_EXTERNAL_STORAGE (primary path, same as passgo): no runtime
dialog exists — check
Environment.isExternalStorageManager()via JNI, if false launchSettings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSIONviaANDROID_APP.get().run_on_java_main_thread(...)(attachJNIEnvfromvm_as_ptr(); Activity fromactivity_as_ptr()), then re-check onMainEvent::Resume— the hook is already wired insrc/lib.rs(TODO(M5) marker). - READ_MEDIA_IMAGES / READ_EXTERNAL_STORAGE:
requestPermissionsvia the same JNI route; the bareNativeActivitydoes not forwardonRequestPermissionsResultto native, so either (a) pollcheckSelfPermissionafter requesting (good enough), or (b) ship a customNativeActivitysubclass via cargo-apk2java_sources = "android/java"(manifestactivity namepoints at it) — the proper callback, reusable pattern from the eval. - Facts that constrain design (verified in android-activity 0.6.1 source):
no
NewIntentevent;poll_eventsmay only run on theandroid_mainthread (Slint owns it — don't add a second consumer);AndroidAppisCloneand a clone works from any thread (run_on_java_main_thread,vm_as_ptr,activity_as_ptr). - Source discovery for photos: start with a plain directory scan of
/storage/emulated/0/Pictures+DCIM(works with all-files access, emulator-friendly, and it's what makes the app testable without MediaStore). MediaStore integration (API-aware, scoped toREAD_MEDIA_IMAGES) is a later refinement if directory scanning proves insufficient.
8. Performance targets & measurement
Targets (emulator is NOT representative for absolute decode times — validate on a real device with the aarch64 build):
- Grid: 60 fps scrolling at 5k+ items; zero full-size decodes on the hot path.
- First frame: grid of placeholders < 100 ms after launch (metadata from sqlite, images async).
- Thumbnail decode (grid size): < 5 ms off-thread per JPEG (measure with
std::time::Instantlogs; zune-jpeg turbo should be well under). - Steady-state memory: in-memory thumb cache bounded (~64–128 MB compressed bytes on a phone) + Slint's texture memory for visible tiles only.
- Cold start (emulator, release): compare against the eval baseline (Slint sample ≈ 1–2 s; gallery must not regress > 0.5 s).
Scroll-FPS measurement (real device, verified 2026-10-27):
dumpsys gfxinfo wow.gmp.gallery reports 0 frames for Slint — the
NativeActivity window renders via its own EGL surface and is not tracked by
gfxinfo on Android 15 (works for normal View apps). Use SurfaceFlinger's
per-layer latency instead:
# find the layer name:
adb shell dumpsys SurfaceFlinger | grep "VRI-wow.gmp.gallery"
# while scrolling (swipes via `input swipe`): capture 1 s of frame history
adb shell "dumpsys SurfaceFlinger --latency 'VRI-wow.gmp.gallery/android.app.NativeActivity#<id>'"
# each line: latch_time display_time … — display_time is the vsync grid.
# fps = 1e6 / avg(display gap); jank = display gaps > ~25 ms; the latch
# column shows frame PRODUCTION timing (p95 ~17 ms = on time at 60 Hz).
Result (Pixel 9 Pro, release aarch64, 8,577 photos, 500 px thumbs):
sustained 60.0 fps, 0 display hitches while scrolling, both cold cache
(production p95 21–25 ms) and warm cache (production avg 16.6 ms). The
display presents at a 60 Hz vsync grid (Slint requests Default framerate;
120 Hz is a later polish item, not an M3 acceptance criterion).
Note: the shimmer animation-tick() keeps frames flowing even at rest,
so latency history is continuous — filter by swiping if you need
scroll-only frames.
Test data (M2): generate a synthetic set on the device — a small Rust bin
or Python script that writes N=5,000 JPEGs of varied sizes/orientations into
/storage/emulated/0/Pictures/gallery-test/ (include a few HEICs if
imagepipe works; include rotated EXIF). Reuse it for every perf run.
Measure with: adb shell am start -W (cold start),
dumpsys gfxinfo wow.gmp.gallery framestats, Perfetto trace (GPU + main +
pool threads), and logcat pool timing logs behind a debug_timing flag.
9. Milestones
-
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 (DONE, 2026-10-27):
db.rs+scan.rs; background scan thread → sqlite →VecModelin aListView(paths as text). Acceptance verified on emulator: 5002 files in 153 ms; relaunch0+ 0~ 0-in 2 ms; mtime-touch →1~, delete →1-. Status line showsN 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::WindowHandledo not exist in 1.18.1 as published on crates.io — desktop bin isfn main() -> Result<(), slint::PlatformError>+use slint::ComponentHandle;. (The M0 desktop bin never actually compiled; only the Android lib was built. The eval sampleslint_appis broken the same way.).slintmodulo isMath.mod(x, y), not%.- ListView rows:
for path[index] in root.items: …(indexis not in scope unless named;parent= the ListView). rusqlite(bundled)needsCC_<triple>pointing at NDK clang for Android —scripts/build.shnow exports them.- Android DB path:
EXTERNAL_DATA_DIRis not set by this NativeActivity setup andtemp_dir()=/data/user/0(unwritable). Use/storage/emulated/0/Android/data/<pkg>/files, internal files dir fallback (seedb::android_data_dir). - Emulator: grant all-files access once via
adb shell appops set wow.gmp.gallery MANAGE_EXTERNAL_STORAGE allow; it survivesadb install -r. - Files pushed with
adb pushare owned byshellmode 640 —chmod 644or the app can't read them.
-
M2 — thumbnail engine (DONE, 2026-10-27):
images.rs(turbojpeg DCT-level shrink decode via 1/2/1/4/1/8ScalingFactor, all 8 EXIF orientations as uniform(x,y)→(dx,dy)pixel maps,image::imageops::resizefinal fit, re-encode to disk cache at q85; PNG viaimage) +thumb.rs(dispatcher thread round-robining to N=cores/2 workers clamped 2..8, unbounded job queue, disk cachethumbs/{sha1(path|mtime|size|target)}.jpg— target in the key so list-64px and grid-500px coexist; atomic.tmp→rename; generation counter for cancellation; per-worker sqlite connection writesset_thumbw/h/orientation/thumb_path) +lib.rs(process-wideAppStateinOnceLock, 10 Hz drain timer —Timeris!Send, leaked withstd::mem::forget, scroll-prefetch with 50-row margin,do_prefetchflag so streaming scan batches don't re-prefetch) +main.slint(rows carryimagenow). Accept, verified: 12 unit tests + 5k synthetic engine test (cold fill, 100% cache warm refill, generation cancel). Release aarch64 APK 9.8 MB. Real device (Pixel 9 Pro, 8577 photos): cold scan ~5 s with list streaming; warm relaunch paints the cached list instantly; thumbnails render in every visible row (screenshot-verified); relaunch cache hits ~100% (255–258 of first 256 jobs); 2 WebP files misnamed.jpgfail gracefully (logged, row stays empty). Worker log cadence: every 256 jobs. Known transient (unreproducible): the very first aarch64-release install rendered a pure-black screen (no drain-timer "painted" logs) until the next launch; it did not reproduce on the emulator (screen-off launch + wake) nor across repeated device launches. Suspected screen-lock / display state race on first GL frame. If it recurs: force a window refresh on Resume (see §10.7). Build gotchas hit (also in build.sh comments):ANDROID_PLATFORM=android-26(NDK CMake toolchain) conflicts with theandroid-buildcrate (Slint's Java compile) unlessANDROID_JARis set explicitly toplatforms/android-35/android.jar.- x86_64 host builds of turbojpeg-sys need
nasm(~/.local/bin/nasm; aarch64 does not). ADB_SERIAL=…env var picks the device in build.sh (phone + emulator are often both attached).- build.sh keywords:
debug(x86_64),debug-aarch64,x86_64,aarch64; second argno-installto skip install.
-
M3 — grid UI (DONE 2026-10-27): 3-column
ListViewgrid (GridRow= one list item holding up to 3 squareCellViews), shimmer placeholder via a time-drivenanimation-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 propertymust be assigned explicitly in afor; 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_scrollonly re-anchors the active range + submits prefetch (no model mutation), all mutation is in the 10 Hz drain timer.Scroll-back-to-top shimmer (fixed 2026-10-27, reported from the phone): scrolling down fills the worker queue with slow cold decodes of far cells; scrolling back up re-submits the top cells as fast disk-cache HITS, but on the old FIFO job queue they waited behind the whole cold backlog — seconds of shimmer (deterministic repro: 6.3 s behind a 180-cold backlog). Fix: the job queue is now a
Mutex<VecDeque>+CondvarandEngine::submitprepends each new batch (most-recent intent first; stale far prefetch can no longer starve the viewport). Regression testthumb::tests::scroll_back_cache_hit_not_starved(6.3 s → 130 ms). Notempsccan't do this — it's FIFO-only; a priority queue needs the mutex+condvar (dispatcher exits via aclosedflag set inEngine::drop).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. Verified on Pixel 9 Pro (release aarch64, 8,577 photos): first screen ~0.7 s cold at 500 px; scroll = sustained 60 fps with 0 display hitches (cold and warm cache) — see §8 for the measurement method (gfxinfo does NOT track Slint's window).
-
M4 — viewer: tap cell → full decode + overlay; pinch/pan (manual math), double-tap zoom; back gesture/button; EXIF orientation correct.
-
M5 — permissions: implement
perm.rsper §7; launch flow when un-granted (settings card → open Settings → recheck on Resume); test the exact passgo-style flow on a fresh install. -
M6 — polish: dark theme, animations (slide/fade), search/filter (name/date), sort; double-tap zoom; battery-friendly (pool idles, no busy repaint — Slint is retained, verify with gfxinfo "total frames").
-
M7 — perf & release: real-device (aarch64) pass; Perfetto; memory bounds; release APK size check (expect ~8–9 MB like the sample); consider the 1.19 upgrade (verify pointer-scroll fix first).
10. Open questions & risks
- HEIC (RESOLVED for now): the real device has 0 HEIC files (9109
JPEG, 27 PNG), so HEIC is deferred. When needed: add a decoder to
images.rsdispatch (libheif-rs /imageheic feature / libvips); the engine API is decoder-agnostic. - Slint pointer-scroll
todo!()panic (open issue in the Android backend at eval time): could fire on trackpad/touch scroll. Test scrolling on M3; if it fires, patch locally (the fix is small) or upgrade to 1.19 when available. - Max texture size: full-screen decode of 12 MP+ images (e.g. 8192 px) may exceed GPU max texture size on some devices — verify on M4; fall back to a 2×-max downscale for display.
- Slint license: royalty-free tier covers proprietary mobile/desktop/ web; embedded devices excluded — irrelevant for us, but the repo README should state the chosen tier (royalty-free) once the app ships.
- Docs drift: trust the pinned source (
~/prog/android/ui/slint-src@ v1.18.1) over online docs; bump pin + re-verify after any Slint upgrade. - rusqlite
bundledcompiles SQLite from source in every target (adds ~30–60 s to clean builds) — acceptable; revisit if it hurts. - Transient black screen (M2 first launch, unreproducible): one
aarch64-release launch rendered pure black with a dead drain timer until
relaunch; not reproduced on emulator or device since. Candidate fix if it
recurs: in the
init_with_event_listenerResume handler, callwindow.request_redraw()/ checkwindow.size()(a 0×0 or stale surface after a screen-off start would match the symptoms). Don't add it speculatively.
11. Pointers
- Framework eval (full report + raw notes + both sample apps + screenshots):
~/prog/android/ui/—REPORT.md,NOTES.md,slint_app/,eframe_app/,slint-src/(v1.18.1),egui-src/(0.37.0). - Slint docs (version-sensitive!): https://slint.dev/docs — Android guide:
docs/guide/platforms/mobile/android.mdxin the source tree. examples/native-gestures,demos/inslint-src— reference code for gestures, lists, images.- passgo (existing Go app using the same all-files-access pattern):
~/prog/go/passgo— itsscripts/build.shand manifest handling are a proven reference for the permission UX flow. - Emulator AVD:
pad_avd(google_apis x86_64, API 35); start command in §3. - Gitea: push target
git+ssh://gitea@git.wow.st/gmp/gallery.git(repo must exist on the server first, like the other gmp/* repos).