Three root causes, all fixed and verified on the Pixel 9 Pro:
1. Present phase (backend + renderer): VSYNC_LOCKED_RENDER defers the
render to the vsync wake; RENDER_AHEAD presents the previous frame at
the start of render() (phase = wake + ~1.1 ms) and holds the new
frame one slot. Crash fix: a held wgpu frame must be PRESENTED, never
merely dropped, before the next acquire (AlreadyAcquired panic at
launch otherwise); discard_pending_frame presents too.
2. Full-res prefetch churn (src/lib.rs): the 8-slot full cache cycled
through a ~20-cell viewport forever (137 decodes/20 s) and each
eviction free()'d a 50 MB Vec on the UI thread (20-52 ms CPU spikes,
~7/s = the residual SLOWEV NonMain jank). Fix: 60 s tombstone
cooldown on evicted paths (idle prefetch skips, urgent tap bypasses)
+ off-thread reaper for the freed blobs (insert: 21 ms -> 4 us).
3. (earlier) Skia final flush + Mailbox present mode (vendor, M3).
Instrumentation (all behind --features perf): OTHR/INVK/INVK_TAG/FLCK
lines for cross-thread closure timing and push-site tagging.
Warm measured swipe, 8580-file grid: cadence median 8.33 ms (exact
120 Hz), p99 15.5 ms, 5/552 gaps >16.7 ms (0.9%, max 28.3 ms, zero
>33 ms); PRES phase median ~994 us, zero late frames. Docs updated
(docs/BUGS/scroll-jank-120hz.md, full scorecard + line reference).
Also: vendored i-slint-core + wgpu-hal (vkshim interposition patch),
vkshim profiling tooling, backend made a non-optional dep, debuggable
flag for simpleperf (REMOVE before shipping).
- src/viewer.rs: full-size decode (turbojpeg DCT shrink, 2x-screen cap
bounded by GPU max texture, EXIF orientation applied) + transform
model (fit/clamp/zoom_at) + single-finger pan & double-tap state
machine. 18 unit tests.
- Pinch: Slint 1.18's ScaleRotateGestureHandler. Key finding: the CORE
(TouchState in internal/core/input.rs), not the backend, synthesizes
PinchGesture from raw two-finger touches on every backend including
Android — the second finger never reaches a TouchArea, so manual
two-finger math is dead code (the earlier draft was removed).
- main.slint: Viewer overlay (image + status + close button + full-
surface TouchArea + ScaleRotateGestureHandler). The close button must
be declared AFTER the full-surface TouchArea: 1.18 TouchArea grabs
every press (GrabMouse) and aborts the front-to-back sibling
traversal.
- lib.rs: open/close/pinch/pointer wiring; Android back key (via the
vendor CloseRequested patch) closes the viewer first.
- vendor: androidwindowadapter back-key -> CloseRequested patch
(Slint 1.18 has no back-key close handling).
- gputex.rs: max_texture_size() for the decode cap.
- HANDOFF: M4 done; new gotchas 6.14-16; emulator two-finger sendevent
(MT-B) injection recipe.
Verified on emulator (pad_avd): real two-finger pinch zoom, double-tap
zoom/reset, pan, button + back close, grid scroll unaffected. Phone
(Pixel 9 Pro, release aarch64): grid, viewer open, 4080x3072 decode,
double-tap, back. Pinch not injectable on the user build (SELinux
blocks /dev/input writes) — covered by unit tests + emulator.
Residual scroll jank at 120 Hz (suspected Slint event-loop bug) needs
per-frame + per-stage timing the logcat ring buffer can't hold. New
cargo feature `perf` (default OFF — all sites are cfg-gated, zero
overhead when off):
- FR: per-frame app-side render cost + frame gap, from the rendering
notifier (BeforeRendering/AfterRendering).
- STG: event-loop stage costs (wake/timers/events/poll/lastrender) from
timestamps instrumented into the pinned vendor copy of
i-slint-backend-android-activity (stg.rs; Cargo.toml now depends on
it directly + optional under `perf`).
- DECODE: per-thumbnail-job cost on the workers.
- All lines mirrored to /sdcard/Download/.gallery_perf.log (logcat
rotates too fast at ~120 lines/s).
Format + reading guide: docs/BUGS/scroll-jank-120hz.md.