From 02dec624051f9fea62b53ca8bc8d58348940fc00 Mon Sep 17 00:00:00 2001 From: Greg Pomerantz Date: Thu, 29 Oct 2026 10:51:56 -0400 Subject: [PATCH] M4: full-screen viewer with pinch/pan zoom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- HANDOFF.md | 92 ++- src/gputex.rs | 9 + src/lib.rs | 280 +++++++ src/main.slint | 214 ++++++ src/viewer.rs | 681 ++++++++++++++++++ .../androidwindowadapter.rs | 39 +- 6 files changed, 1292 insertions(+), 23 deletions(-) create mode 100644 src/viewer.rs diff --git a/HANDOFF.md b/HANDOFF.md index 01d6280..5b3cb55 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -23,7 +23,8 @@ queued work; per-worker cap drops the oldest tail with a `skipped` result). Stress-verified on the emulator (debug build) + phone before shipping: fast up/down 0–120 ms to full coverage, cold landings bounded by decode throughput, chaos scrolling max oldest in-flight job 217 ms (was 8 s), -0 invariant violations in ~1,450 ticks, 0 panics/ANRs. Next: M4 viewer. +0 invariant violations in ~1,450 ticks, 0 panics/ANRs. M4 viewer is +DONE (2026-10-29 — see §9); next is M5 permissions. --- @@ -141,8 +142,8 @@ thumbnail engine, and it is framework-agnostic: └────────────────────────────────────────────────────────────────────┘ ┌─ 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) │ +│ viewer screen: full-size async decode (once, cached) + │ +│ ScaleRotateGestureHandler pinch + 1-finger pan │ └────────────────────────────────────────────────────────────────────┘ ``` @@ -196,12 +197,13 @@ cancellation, (5) EXIF rotation applied per frame. `internal/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 (`TouchArea` has no pinch - callback; grepped the stdlib). Manual two-finger math on `TouchArea` - (distance/centroid → scale/translation, ~50 lines; crib - `slint-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). +- **Viewer** (DONE in M4): pinch is the `ScaleRotateGestureHandler` + element — the 1.18 CORE synthesizes `PinchGesture` from raw two-finger + touches on every backend (the second finger never reaches a `TouchArea`; + see §6.15/16). Single-finger pan + double-tap-to-zoom are a small state + machine in `src/viewer.rs` driven by `TouchArea::pointer-event`. Crib: + `slint-src/examples/native-gestures` (source at + `~/prog/android/ui/slint-src`, tag v1.18.1). - **Screens**: single `Window` with a stacked layout (grid + viewer overlay) or a `Navigator`-style state property; keep it simple until M4. - **Safe area**: always pad with `root.safe-area-insets.{top,bottom}` (done @@ -250,6 +252,42 @@ cancellation, (5) EXIF rotation applied per frame. 13. `ListView` has no `spacing`/`gap` property in 1.18 — bake inter-row rhythm into the item height (we add a 4 px bottom margin inside `GridRow`; `ROW_SPACING` in lib.rs must match). +14. **`TouchArea` grabs EVERY press (mouse and touch) — `input_event` on + `Pressed` unconditionally returns `GrabMouse` (internal/core/items/ + input_items.rs), which ABORTS the sibling traversal** + (`send_mouse_event_to_item` → `VisitChildrenResult::abort`). Siblings + are visited FRONT-TO-BACK = last-declared first (`TraversalOrder:: + FrontToBack` in item_tree.rs iterates `count-1 … 0`), so the TOPMOST + (last-declared) TouchArea at a position wins the press, and any + earlier-declared TouchArea under it never sees the event. The viewer's + close button therefore must be declared AFTER the full-surface + TouchArea (M4 — this cost a session of debugging). +15. **The second finger NEVER reaches a `TouchArea`.** The CORE + (not the backend) owns two-finger semantics: `TouchState::process` + (internal/core/input.rs) turns per-finger `InternalEvent::Touch` into + mouse events, and when a second finger lands it synthesizes a Release + for the first finger and, after an 8 px threshold, emits + `PinchGesture`/`RotationGesture` mouse events — on EVERY backend, + including Android (the android-activity backend does deliver the + per-finger `Touch` events; the core converts them). Consequences: + `TouchArea::pointer-event` (1.18's `touch-finger-id`) only ever sees + ONE finger; manual two-finger math is dead code. When a pinch finger + lifts, the core re-presses the survivor (a fresh `Pressed`), which + seamlessly continues as a single-finger pan. +16. **Pinch in 1.18 = the `ScaleRotateGestureHandler` element** (added in + 1.18.0; declared in internal/compiler/builtin_elements.rs, NOT in any + .slint file — `git grep ScaleRotateGestureHandler` the compiler crate). + API: `in enabled`, `out active`, `out scale` (CUMULATIVE from 1.0 at + gesture start), `out rotation`, `out center` (Point, in the element's + coordinates), callbacks `started`/`updated`/`ended`/`cancelled`. + `@expands_to_parent_geometry`. While active it INTERCEPTS all + non-gesture events (its `input_event_filter_before_children`). + Correct usage: snapshot your transform at `started`, apply + `snapshot · scale` about `center` at each `updated` (never multiply + incrementally — `scale` is cumulative), commit at `ended` (the + properties read identity once inactive). See `src/viewer.rs` + + `src/main.slint` (Viewer) and upstream + `slint-src/examples/native-gestures`. ## 7. Permissions & platform integration (Android) @@ -543,8 +581,32 @@ flush (Mali driver back-pressure). Fix + evidence: §8.9 and 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. +- **M4 — viewer (DONE, 2026-10-29)**: tap cell → full decode (turbojpeg + DCT shrink, 2×-screen cap bounded by GPU max texture, EXIF orientation + applied) + full-screen overlay; pinch zoom (Slint's + `ScaleRotateGestureHandler` — see §6.15/16; transform math is + `zoom_at`/`Input::pinch` in `src/viewer.rs`); single-finger pan + + double-tap zoom (2.5× fit at tap point; again → reset to fit); + back key (vendor patch: `Keycode::Back` → `CloseRequested`, the app's + `on_close_requested` closes the viewer first) + × button; error text on + decode failure. Verified on the emulator (pad_avd 1080×2400): all + gestures incl. REAL two-finger pinch (sendevent MT-B — recipe below); + phone: grid + viewer open + 4080×3072 decode OK (pinch not injectable + on the user build — SELinux blocks /dev/input writes and `input` + has no multi-pointer; trust the unit tests + emulator). + **Emulator two-finger injection recipe** (userdebug + `adb root`; + active touch device = the one under `Touch Input Mapper (mode - DIRECT)` + in `dumpsys input` — on pad_avd that's event2, NOT the one `getevent` + prints first; per-axis raw→px: X×W/32767, Y×H/32767): MT-B with explicit + `ABS_MT_SLOT`(47) per finger, `ABS_MT_TRACKING_ID`(57) re-emitted on + every move frame (values ≥0 = present, -1 = lifted), one SYN(0 0 0) per + frame, BTN_TOUCH(1 330) on first-down/last-up. Each `sendevent` is a + fork (~20 ms), so `input tap x y; input tap x y` in ONE shell is the + way to make a ≤300 ms double-tap. + Gotchas (also in §6.14–16): the close button MUST be declared after the + full-surface TouchArea (TouchArea grabs + aborts sibling traversal); + `ScaleRotateGestureHandler` is declared in the compiler crate, not any + .slint file; Slint logical px = physical px / (dpi/160) on Android. - **M5 — permissions**: implement `perm.rs` per §7; launch flow when un-granted (settings card → open Settings → recheck on Resume); test the exact passgo-style flow on a fresh install. @@ -734,6 +796,8 @@ on every launch; while active it also overrode the user's swipes. Fix: the debug-scroll hook (and all file knobs) are gone; the leftover file was deleted from the device. -**Next: M4 viewer** (tap cell → full decode + overlay, pinch/pan, -double-tap zoom, back gesture, EXIF orientation). Work on the -emulator first (user directive). +**Next: M5 permissions** — implement `perm.rs` per §7 (launch flow when +un-granted: settings card → open Settings → recheck on Resume); test the +passgo-style flow on a fresh install. (M4 viewer is DONE, 2026-10-29 — +see the M4 entry in §9 for the verification notes + emulator pinch +injection recipe.) diff --git a/src/gputex.rs b/src/gputex.rs index 6f04589..dfb1fb0 100644 --- a/src/gputex.rs +++ b/src/gputex.rs @@ -77,6 +77,15 @@ pub fn ready() -> bool { GPU.lock().unwrap().is_some() } +/// The GPU's max 2D texture size (for the viewer's decode cap — HANDOFF +/// §10.3: 12 MP+ full decodes may exceed it). `None` until captured. +pub fn max_texture_size() -> Option { + GPU.lock() + .unwrap() + .as_ref() + .map(|c| c.device.limits().max_texture_dimension_2d) +} + /// Create a GPU-resident `Rgba8Unorm` texture from packed RGBA8 bytes, /// submit the (async) upload and wrap it in a ready-to-present /// `slint::Image`. **Runs on the UI thread (render thread)** — see the diff --git a/src/lib.rs b/src/lib.rs index 870f4de..b6ba246 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -80,6 +80,7 @@ pub mod gputex; pub mod images; pub mod scan; pub mod thumb; +pub mod viewer; /// Thread-priority helpers (jank fix): the app process runs at nice -10, /// which every spawned thread inherits — turbojpeg decode bursts then run at @@ -272,6 +273,23 @@ struct AppState { painted_total: AtomicU64, /// Rate-limits the stall alarm. UI thread only. last_stall_log: Mutex, + /// Viewer (M4) state. UI thread only. `gen` invalidates an in-flight + /// load (reopen / close / list reset / world recreation); the load + /// thread checks it before touching the UI. + viewer: Mutex, +} + +/// Viewer (M4) UI-thread state (see `viewer.rs` for the transform + input +/// state machine). +struct ViewerUi { + gen: u64, + open: bool, + /// The pan/tap state machine; `None` while closed or loading. (Pinch + /// is driven by Slint's ScaleRotateGestureHandler — see `pinch_base`.) + input: Option, + /// Transform snapshot at the pinch's `started`; the handler's + /// cumulative scale is applied about its center (`Input::pinch`). + pinch_base: Option, } /// Create the pool + cache dir once per process. @@ -287,6 +305,12 @@ fn state() -> &'static AppState { failed: Mutex::new(HashSet::new()), painted_total: AtomicU64::new(0), last_stall_log: Mutex::new(Instant::now()), + viewer: Mutex::new(ViewerUi { + gen: 0, + open: false, + input: None, + pinch_base: None, + }), } }) } @@ -490,6 +514,190 @@ fn viewport_for(y: f32, height: f32, width: f32, n: usize) -> Option u32 { + let logical = w.get_win_w().max(w.get_win_h()); + #[cfg(target_os = "android")] + let ratio = ANDROID_APP + .get() + .and_then(|a| a.config().density()) + .map(|d| d as f32 / 160.0) + .unwrap_or(1.0); + #[cfg(not(target_os = "android"))] + let ratio: f32 = 1.0; + (logical * ratio).max(1.0) as u32 +} + +/// Open the viewer (M4) for `path` (a grid cell tap). Full decode on a +/// background thread (capped at 2× the physical screen, bounded by the GPU +/// max texture size — `viewer::decode_cap`), delivered between frames on +/// the UI thread, uploaded to Slint's wgpu device like the grid cells +/// (gputex) so the first-present frame pays ~0. +fn open_viewer(w: &Main, app: &AppState, path: String) { + let gen = { + let mut v = app.viewer.lock().unwrap(); + v.gen += 1; // invalidate a previous load + v.open = true; + v.input = None; + v.pinch_base = None; + v.gen + }; + let phys_max = phys_screen_max(w); + let cap = viewer::decode_cap(phys_max, gputex::max_texture_size()); + log::info!("gallery: viewer open {path} (cap {cap} px, gen {gen})"); + w.set_viewer_image(Image::default()); + w.set_v_status("Loading…".into()); + w.set_viewer_open(true); + let weak = w.as_weak(); + std::thread::Builder::new() + .name("viewer-load".into()) + .spawn(move || { + // Same demotion as the grid workers: a full decode (up to ~100 + // MB of pixels at 2× screen) must not starve UI/vsync. + prio::set_self_nice(2); + let res = viewer::load(&path, cap); + let _ = slint::invoke_from_event_loop(move || { + let Some(w) = weak.upgrade() else { return }; + let Some(app) = STATE.get() else { return }; + { + let v = app.viewer.lock().unwrap(); + if !v.open || v.gen != gen { + return; // viewer closed/replaced meanwhile + } + } + match res { + Ok(full) => { + let image = + match gputex::upload_rgba(&full.rgba, full.width, full.height) { + Some(img) => img, + None => { + let mut buffer = + SharedPixelBuffer::::new(full.width, full.height); + buffer.make_mut_bytes().copy_from_slice(&full.rgba); + Image::from_rgba8(buffer) + } + }; + let vw = w.get_win_w(); + let vh = w.get_win_h(); + let t = viewer::fit(full.width as f32, full.height as f32, vw, vh); + app.viewer.lock().unwrap().input = Some(viewer::Input::new( + full.width as f32, + full.height as f32, + vw, + vh, + )); + w.set_viewer_image(image); + w.set_v_img_w(full.width as f32); + w.set_v_img_h(full.height as f32); + w.set_v_scale(t.s); + w.set_v_tx(t.tx); + w.set_v_ty(t.ty); + w.set_v_status("".into()); + log::info!( + "gallery: viewer loaded {}x{} in {} ms", + full.width, + full.height, + full.ms + ); + } + Err(e) => { + w.set_v_status(format!("Can't open image: {e}").into()); + log::error!("gallery: viewer load failed: {e}"); + } + } + }); + }) + .ok(); +} + +/// The viewer's pinch (Slint's ScaleRotateGestureHandler) has started: +/// snapshot the current transform — the handler's `updated` reports a +/// CUMULATIVE scale from this point, always applied about the CURRENT +/// gesture center from this snapshot (no incremental compounding). +fn on_viewer_pinch_started(app: &AppState) { + let mut v = app.viewer.lock().unwrap(); + if v.pinch_base.is_some() { + return; // already pinching (duplicate started) + } + v.pinch_base = v.input.as_ref().map(|i| i.transform()); + // The core synthesized a Release for the first finger when the second + // went down: drop the pending tap/pan state so it can't misfire. + if let Some(i) = v.input.as_mut() { + i.reset_for_pinch(); + } +} + +/// Pinch step: apply the cumulative `scale` about (cx, cy) to the snapshot +/// and publish the transform. (Slint keeps the handler active until both +/// fingers lift; the survivor is then re-pressed and pans via +/// `on_viewer_pointer`.) +fn on_viewer_pinch_updated(w: &Main, app: &AppState, scale: f32, cx: f32, cy: f32) { + let t = { + let v = app.viewer.lock().unwrap(); + match (v.pinch_base, v.input.as_ref()) { + (Some(base), Some(i)) => Some(i.pinch(base, scale, cx, cy)), + _ => None, + } + }; + if let Some(t) = t { + w.set_v_scale(t.s); + w.set_v_tx(t.tx); + w.set_v_ty(t.ty); + } +} + +/// Pinch ended/cancelled: commit. The transform is already at its final +/// value (the last `updated`); sync the pan state machine to it so a +/// survivor finger pans from the pinch result. +fn on_viewer_pinch_ended(w: &Main, app: &AppState) { + let t = viewer::Transform { + s: w.get_v_scale(), + tx: w.get_v_tx(), + ty: w.get_v_ty(), + }; + let mut v = app.viewer.lock().unwrap(); + v.pinch_base = None; + if let Some(i) = v.input.as_mut() { + i.set_transform(t); + } +} + +/// Viewer overlay pointer event (M4): route to the input state machine and +/// publish the new transform. `kind`: 0 down, 1 move, 2 up, 3 cancel. +fn on_viewer_pointer(w: &Main, app: &AppState, finger: i32, x: f32, y: f32, kind: i32) { + let phase = match kind { + 0 => viewer::Phase::Down, + 1 => viewer::Phase::Move, + 2 => viewer::Phase::Up, + _ => viewer::Phase::Cancel, + }; + let event = { + let mut v = app.viewer.lock().unwrap(); + v.input.as_mut().and_then(|i| i.pointer(finger, x, y, phase, Instant::now())) + }; + if let Some(viewer::InputEvent::Changed(t)) = event { + w.set_v_scale(t.s); + w.set_v_tx(t.tx); + w.set_v_ty(t.ty); + } +} + /// Clear all painted cells IN PLACE (the eviction path) and reset the /// loader state. Call from the render notifier (UI thread). /// @@ -504,6 +712,9 @@ fn reset_grid_in_place(w: &Main, app: &AppState) { app.engine.lock().unwrap().bump_generation(); app.inflight.lock().unwrap().clear(); app.failed.lock().unwrap().clear(); + // The viewer's image (and its load) also belonged to the dying device: + // close it; the tick/grid re-fill on the new one. + close_viewer(w, app); let painted: Vec = app.painted.lock().unwrap().drain().collect(); let n = { let entries = app.entries.lock().unwrap(); @@ -1077,6 +1288,75 @@ pub fn start_scan(window: &Main, wid: u32) { let app = state(); window.on_scroll_changed(move |y: f32, h: f32, w: f32| on_scroll(app, y, h, w)); + + // M4 viewer wiring (each handler gets its own clone of the weak ref). + window.on_open_image({ + let weak = weak.clone(); + move |path| { + let Some(w) = weak.upgrade() else { return }; + let Some(app) = STATE.get() else { return }; + open_viewer(&w, app, path.to_string()); + } + }); + window.on_viewer_pointer({ + let weak = weak.clone(); + move |finger, x, y, kind| { + let Some(w) = weak.upgrade() else { return }; + let Some(app) = STATE.get() else { return }; + on_viewer_pointer(&w, app, finger, x, y, kind); + } + }); + window.on_viewer_pinch_started(|| { + let Some(app) = STATE.get() else { return }; + on_viewer_pinch_started(app); + }); + window.on_viewer_pinch_updated({ + let weak = weak.clone(); + move |scale, cx, cy| { + let Some(w) = weak.upgrade() else { return }; + let Some(app) = STATE.get() else { return }; + on_viewer_pinch_updated(&w, app, scale, cx, cy); + } + }); + window.on_viewer_pinch_ended({ + let weak = weak.clone(); + move || { + let Some(w) = weak.upgrade() else { return }; + let Some(app) = STATE.get() else { return }; + on_viewer_pinch_ended(&w, app); + } + }); + window.on_close_viewer({ + let weak = weak.clone(); + move || { + let Some(w) = weak.upgrade() else { return }; + let Some(app) = STATE.get() else { return }; + close_viewer(&w, app); + } + }); + // Android: the back key arrives as a close REQUEST (vendor patch — + // native semantics). While the viewer is open, back closes the viewer; + // otherwise the window's default (hide) applies. Installed on Android + // only: on desktop the default close path (app exit) must be kept. + #[cfg(target_os = "android")] + { + let weak2 = weak.clone(); + window.window().on_close_requested(move || { + let Some(app) = STATE.get() else { + return slint::CloseRequestResponse::HideWindow + }; + let open = app.viewer.lock().unwrap().open; + if open { + if let Some(w) = weak2.upgrade() { + close_viewer(&w, app); + } + slint::CloseRequestResponse::KeepWindowShown + } else { + slint::CloseRequestResponse::HideWindow + } + }); + } + start_drain_timer(&weak, wid); std::thread::spawn(move || { diff --git a/src/main.slint b/src/main.slint index 22c311e..4850085 100644 --- a/src/main.slint +++ b/src/main.slint @@ -29,10 +29,21 @@ export struct Item { // row have path == ""). component CellView inherits Rectangle { in property cell; + callback cell-clicked(); background: #1a1a1a; border-radius: 4px; clip: true; + // Whole-cell tap target (M4: opens the viewer). A drag past the slop is + // taken over by the ListView's Flickable, which cancels the press, so + // scrolling is unaffected. + TouchArea { + enabled: cell.path != ""; + clicked => { + root.cell-clicked(); + } + } + // Placeholder while the thumbnail is in flight. STATIC on purpose: // animation-tick() in a per-cell binding is O(ALL CELLS) every frame // (ListView keeps every row alive; bindings are not visibility-gated), @@ -61,6 +72,7 @@ component CellView inherits Rectangle { // cw + 4; lib.rs ROW_SPACING must match). component GridRow inherits Rectangle { in property row-data; + callback cell-clicked(path: string); property cw: (self.width - 8px) / 3; width: 100%; height: cw + 4px; @@ -71,6 +83,149 @@ component GridRow inherits Rectangle { height: root.cw; x: c * (root.cw + 4px); visible: cell.path != ""; + cell-clicked => { + root.cell-clicked(cell.path); + } + } +} + +// Full-screen viewer overlay (M4). Declared AFTER the grid in Main so it +// sits on top; its TouchArea blocks grid input while open. The transform is +// owned by Rust (src/viewer.rs): the element is sized to +// (v-img-w·v-scale)×(v-img-h·v-scale) at (v-tx, v-ty). +component Viewer inherits Rectangle { + in property open; + in property top-inset; + in property image; + in property scale: 1.0; + in property tx: 0.0; + in property ty: 0.0; + in property img-w: 0.0; + in property img-h: 0.0; + in property status: ""; + /// Pointer event: (finger id, x, y in viewer px, kind: 0 down, 1 move, + /// 2 up, 3 cancel). Single finger only — see the pinch handler below. + callback pointer-event(finger: int, x: float, y: float, kind: int); + /// Pinch (ScaleRotateGestureHandler): the cumulative scale since + /// `pinch-started` (1.0 = unchanged) and the gesture center (viewer px). + callback pinch-started(); + callback pinch-updated(scale: float, cx: float, cy: float); + callback pinch-ended(); + callback closed(); + + function pointer-kind(k: PointerEventKind) -> int { + if k == PointerEventKind.down { + return 0; + } + if k == PointerEventKind.move { + return 1; + } + if k == PointerEventKind.up { + return 2; + } + return 3; + } + + width: 100%; + height: 100%; + visible: root.open; + background: #000; + clip: true; + + Image { + x: root.tx * 1px; + y: root.ty * 1px; + width: root.img-w * root.scale * 1px; + height: root.img-h * root.scale * 1px; + source: root.image; + image-fit: fill; + visible: root.status == ""; + } + + Text { + x: 16px; + y: 0px; + width: root.width - 32px; + height: 100%; + text: root.status; + wrap: word-wrap; // 1.18: wrapping is opt-in via `wrap` + horizontal-alignment: center; + vertical-alignment: center; + color: #8ab4f8; + font-size: 16px; + visible: root.status != ""; + } + + // Full-surface input, single finger: pan / double-tap — the math lives + // in src/viewer.rs. (The SECOND finger never reaches this area: Slint + // 1.18's core converts two-finger touches into PinchGesture events — + // see the pinch handler below.) mouse-x/y are `length`; the math wants + // plain logical px. + TouchArea { + enabled: root.status == ""; + pointer-event(e) => { + root.pointer-event(e.touch-finger-id, self.mouse-x / 1px, self.mouse-y / 1px, root.pointer-kind(e.kind)); + } + } + + + // Close button (top-right, under the status bar). DECLARED AFTER the + // full-surface TouchArea: Slint visits siblings front-to-back (last + // declared FIRST) and 1.18's TouchArea grabs the press it receives + // (GrabMouse), which ABORTS the sibling traversal — so the topmost + // TouchArea at a position wins the press. The full-surface area is + // invisible, so z-order has no visual cost. + Rectangle { + x: root.width - 56px; + y: root.top-inset + 8px; + width: 44px; + height: 44px; + border-radius: 22px; + // Slint 1.18 parses 8-digit hex as RRGGBBAA (alpha LAST). + background: #ffffff40; + + Text { + x: 0px; + y: 0px; + width: 100%; + height: 100%; + // U+00D7 — the default font has no U+2715 (✕ → tofu). + text: "×"; + color: #fff; + font-size: 22px; + horizontal-alignment: center; + vertical-alignment: center; + } + + TouchArea { + clicked => { + root.closed(); + } + } + } + + // Pinch: Slint 1.18's core synthesizes the PinchGesture from raw + // two-finger touches on EVERY backend (TouchState in internal/core/ + // input.rs; the Android backend delivers per-finger Touch events, and + // the core converts them after an 8 px threshold). The handler reports + // a CUMULATIVE scale (from 1.0 at start) about the (moving) center; + // Rust applies it via zoom_at from a snapshot taken at `started` + // (viewer.rs Input::pinch). `updated` fires on every step; `ended`/ + // `cancelled` commit (the handler's scale reads 1.0 once inactive). + pinch := ScaleRotateGestureHandler { + enabled: root.status == ""; + started => { + root.pinch-started(); + } + updated => { + root.pinch-updated(pinch.scale, pinch.center.x / 1px, pinch.center.y / 1px); + } + ended => { + root.pinch-ended(); + } + cancelled => { + root.pinch-ended(); + } } } @@ -88,6 +243,34 @@ export component Main inherits Window { /// delivers results. in property <[Item]> items; + // Mirrors of the window's logical size for the viewer (Rust reads them; + // bound one-way, like list-y for the grid). + in property win-w: self.width; + in property win-h: self.height; + + // M4 viewer. Rust owns the open state + transform (src/viewer.rs); + // these are mirrors the overlay renders. + in property viewer-open: false; + in property viewer-image; + in property v-scale: 1.0; + in property v-tx: 0.0; + in property v-ty: 0.0; + in property v-img-w: 0.0; + in property v-img-h: 0.0; + in property v-status: ""; + /// A grid cell was tapped: path of the entry to open. + callback open-image(path: string); + /// Viewer overlay pointer event: (finger, x, y, kind). Single finger + /// (pan / double-tap) — pinch goes through the handler callbacks below. + callback viewer-pointer(finger: int, x: float, y: float, kind: int); + /// Viewer pinch (ScaleRotateGestureHandler): started / cumulative + /// (scale, center) / ended. + callback viewer-pinch-started(); + callback viewer-pinch-updated(scale: float, cx: float, cy: float); + callback viewer-pinch-ended(); + /// Close the viewer (× button / Android back key). + callback close-viewer(); + // Rust-readable mirrors of the list's ACTUAL geometry (content-y is // negative while scrolled down). The reconcile tick polls these every // tick — including during momentum scrolling, where the `scrolled` @@ -122,7 +305,38 @@ export component Main inherits Window { } for row in root.items: GridRow { row-data: row; + cell-clicked(path) => { + root.open-image(path); + } } } } + + // Viewer overlay: declared last so it renders above the grid. + Viewer { + open: root.viewer-open; + top-inset: root.safe-area-insets.top; + image: root.viewer-image; + scale: root.v-scale; + tx: root.v-tx; + ty: root.v-ty; + img-w: root.v-img-w; + img-h: root.v-img-h; + status: root.v-status; + pointer-event(finger, x, y, kind) => { + root.viewer-pointer(finger, x, y, kind); + } + pinch-started => { + root.viewer-pinch-started(); + } + pinch-updated(scale, cx, cy) => { + root.viewer-pinch-updated(scale, cx, cy); + } + pinch-ended => { + root.viewer-pinch-ended(); + } + closed => { + root.close-viewer(); + } + } } diff --git a/src/viewer.rs b/src/viewer.rs new file mode 100644 index 0000000..3edecd5 --- /dev/null +++ b/src/viewer.rs @@ -0,0 +1,681 @@ +//! Full-screen viewer (M4): full-size decode + pinch/pan/zoom transform. +//! +//! Pinch: Slint 1.18's CORE synthesizes `PinchGesture` from raw two-finger +//! touches on every backend (`TouchState` in internal/core/input.rs — the +//! Android backend delivers per-finger `InternalEvent::Touch`, and the core +//! converts two fingers into a gesture after an 8 px threshold). The +//! `ScaleRotateGestureHandler` element consumes it; lib.rs applies its +//! cumulative scale about the gesture center via `zoom_at`. Consequence: +//! the second finger NEVER reaches a `TouchArea` — the core swallows it +//! (and synthesizes a Release for the first finger), so `Input` below is +//! single-finger. All geometry is a pure function of (transform, image +//! size, viewport size), unit-tested without the UI. +//! +//! Transform model: the image's natural (post-EXIF) size is (iw, ih) +//! "content units" (decoded pixels). `Transform { s, tx, ty }` maps content +//! → viewer: a content point p lands at (tx + p.x·s, ty + p.y·s). `s` is +//! clamped to [fit, fit·MAX_ZOOM]; `tx`/`ty` are centered when the scaled +//! image is smaller than the viewport and otherwise clamped so the image +//! always covers the viewer (no panning into blank areas). +//! +//! Gestures: +//! - single finger: pan (after a 12 px slop so taps never drift). +//! - two fingers: pinch — Slint's `ScaleRotateGestureHandler` (main.slint); +//! the transform math is `zoom_at` below. When one finger lifts, the +//! core re-presses the survivor, which continues as a pan here. +//! - double tap (2 taps ≤300 ms apart, ≤48 px): zoom to 2.5× fit at the tap +//! point; while already zoomed, reset to fit. + +use std::time::Instant; + +use turbojpeg::ScalingFactor; + +use crate::images; + +/// Maximum pinch zoom, relative to the fit scale. +pub const MAX_ZOOM: f32 = 20.0; +/// A second tap within this time (and distance) of the first is a double +/// tap. +const DOUBLE_TAP_MS: u64 = 300; +const DOUBLE_TAP_PX: f32 = 48.0; +/// A single-finger press that moves this far (logical px) becomes a pan, +/// not a tap. +const PAN_SLOP: f32 = 12.0; +/// A press held longer than this is not a tap. +const TAP_MAX_MS: u64 = 500; + +/// Content → viewer transform (see module docs). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Transform { + pub s: f32, + pub tx: f32, + pub ty: f32, +} + +/// Scale at which the image just fits the viewport (aspect kept, centered). +pub fn fit_scale(iw: f32, ih: f32, vw: f32, vh: f32) -> f32 { + if iw <= 0.0 || ih <= 0.0 || vw <= 0.0 || vh <= 0.0 { + return 1.0; + } + (vw / iw).min(vh / ih) +} + +/// The centered fit transform. +pub fn fit(iw: f32, ih: f32, vw: f32, vh: f32) -> Transform { + let s = fit_scale(iw, ih, vw, vh); + Transform { + s, + tx: (vw - iw * s) / 2.0, + ty: (vh - ih * s) / 2.0, + } +} + +/// Clamp zoom to [fit, fit·MAX_ZOOM] and translation to "image covers the +/// viewport" (centered while smaller than the viewport). +pub fn clamp(t: Transform, iw: f32, ih: f32, vw: f32, vh: f32) -> Transform { + let fit_s = fit_scale(iw, ih, vw, vh); + let s = t.s.clamp(fit_s, fit_s * MAX_ZOOM); + let w = iw * s; + let h = ih * s; + let tx = if w <= vw { (vw - w) / 2.0 } else { t.tx.clamp(vw - w, 0.0) }; + let ty = if h <= vh { (vh - h) / 2.0 } else { t.ty.clamp(vh - h, 0.0) }; + Transform { s, tx, ty } +} + +/// Zoom by `factor` so the content point under (cx, cy) stays under (cx, cy). +pub fn zoom_at( + t: Transform, + cx: f32, + cy: f32, + factor: f32, + iw: f32, + ih: f32, + vw: f32, + vh: f32, +) -> Transform { + let s = t.s * factor.max(f32::MIN); + let f = if t.s > 0.0 { s / t.s } else { 1.0 }; + let tx = cx - (cx - t.tx) * f; + let ty = cy - (cy - t.ty) * f; + clamp(Transform { s, tx, ty }, iw, ih, vw, vh) +} + +/// Pan by (dx, dy) (viewer px), then clamp. +pub fn pan_by(t: Transform, dx: f32, dy: f32, iw: f32, ih: f32, vw: f32, vh: f32) -> Transform { + clamp(Transform { s: t.s, tx: t.tx + dx, ty: t.ty + dy }, iw, ih, vw, vh) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Phase { + Down, + Move, + Up, + Cancel, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum InputEvent { + /// The transform changed (pinch / pan / double tap). + Changed(Transform), +} + +fn dist2(a: (f32, f32), b: (f32, f32)) -> f32 { + let dx = a.0 - b.0; + let dy = a.1 - b.1; + dx * dx + dy * dy +} + +/// Single-finger → transform state machine (pinch is handled by Slint's +/// `ScaleRotateGestureHandler` — see module docs). Owns the current +/// transform; `pointer` returns the new one when it changed. `now` is +/// monotonic (the caller's clock) — the double-tap timing is testable by +/// feeding instants. +pub struct Input { + iw: f32, + ih: f32, + vw: f32, + vh: f32, + t: Transform, + /// Where the (single) finger went down; `None` once it becomes a pan. + /// A released finger that never left this point is a tap. + down_at: Option<(f32, f32, Instant)>, + /// Last delivered position while panning. + pan_last: Option<(f32, f32)>, + /// Last completed tap (double-tap detection). + last_tap: Option<(f32, f32, Instant)>, +} + +impl Input { + pub fn new(iw: f32, ih: f32, vw: f32, vh: f32) -> Self { + Self { + iw, + ih, + vw, + vh, + t: fit(iw, ih, vw, vh), + down_at: None, + pan_last: None, + last_tap: None, + } + } + + pub fn transform(&self) -> Transform { + self.t + } + + /// The `ScaleRotateGestureHandler`'s cumulative `scale` about `center`, + /// applied to `base` (a snapshot taken at `started`). Pure — the caller + /// commits via `set_transform` when the gesture ends. + pub fn pinch(&self, base: Transform, scale: f32, cx: f32, cy: f32) -> Transform { + zoom_at(base, cx, cy, scale.max(f32::MIN), self.iw, self.ih, self.vw, self.vh) + } + + /// A pinch has taken over the transform: drop the pending tap/pan state + /// (the core's synthesized Release for the first finger would otherwise + /// register a tap mid-pinch). + pub fn reset_for_pinch(&mut self) { + self.down_at = None; + self.pan_last = None; + self.last_tap = None; + } + + /// Adopt an externally computed transform (after a pinch ends) so the + /// next pan continues from there. + pub fn set_transform(&mut self, t: Transform) { + self.t = t; + } + + pub fn pointer( + &mut self, + _finger: i32, + x: f32, + y: f32, + phase: Phase, + now: Instant, + ) -> Option { + match phase { + Phase::Down => { + self.down_at = Some((x, y, now)); + self.pan_last = Some((x, y)); + None + } + Phase::Move => { + if let Some((x0, y0, _)) = self.down_at { + if dist2((x0, y0), (x, y)) > PAN_SLOP * PAN_SLOP { + // Panning starts: adopt the current position so the + // first pan step doesn't include the slop jump. + self.down_at = None; + self.pan_last = Some((x, y)); + } + return None; + } + if let Some((px, py)) = self.pan_last { + self.pan_last = Some((x, y)); + self.commit(pan_by(self.t, x - px, y - py, self.iw, self.ih, self.vw, self.vh)) + } else { + None + } + } + Phase::Up => { + let Some((x0, y0, t0)) = self.down_at.take() else { + return None; + }; + self.pan_last = None; + if dist2((x0, y0), (x, y)) <= PAN_SLOP * PAN_SLOP + && now.duration_since(t0).as_millis() <= TAP_MAX_MS as u128 + { + let is_double = self.last_tap.is_some_and(|(lx, ly, lt)| { + now.duration_since(lt).as_millis() <= DOUBLE_TAP_MS as u128 + && dist2((lx, ly), (x, y)) <= DOUBLE_TAP_PX * DOUBLE_TAP_PX + }); + self.last_tap = None; + if is_double { + return self.commit(self.double_tap_at(x, y)); + } + self.last_tap = Some((x, y, now)); + } + None + } + Phase::Cancel => { + self.down_at = None; + self.pan_last = None; + None + } + } + } + + /// Double-tap: zoom to 2.5× fit at (x, y); reset to fit when zoomed. + fn double_tap_at(&self, x: f32, y: f32) -> Transform { + let fit_s = fit_scale(self.iw, self.ih, self.vw, self.vh); + if self.t.s > fit_s * 1.1 { + fit(self.iw, self.ih, self.vw, self.vh) + } else { + zoom_at(self.t, x, y, fit_s * 2.5 / self.t.s.max(f32::MIN), self.iw, self.ih, self.vw, self.vh) + } + } + + fn commit(&mut self, t: Transform) -> Option { + if t != self.t { + self.t = t; + Some(InputEvent::Changed(t)) + } else { + None + } + } +} + +/// A full image for the viewer: RGBA8, EXIF orientation applied, longest +/// side ≤ `cap`. +pub struct Full { + pub rgba: Vec, + pub width: u32, + pub height: u32, + /// Decode wall time (ms), for the load log. + pub ms: u128, +} + +/// Longest-side cap for the full decode: 2× the physical screen (stays +/// sharp up to 2× zoom) bounded by the GPU's max 2D texture size +/// (12 MP+ files at full size would exceed it on some devices — +/// HANDOFF §10.3). +pub fn decode_cap(screen_max_phys: u32, max_texture: Option) -> u32 { + let mut cap = 2u32.saturating_mul(screen_max_phys); + if let Some(m) = max_texture { + cap = cap.min(m); + } + cap.max(512) +} + +/// The smallest DCT shrink (1/2, 1/4, 1/8) whose result is ≤ `cap` on the +/// longest side; `ONE` when the file already fits. +pub fn pick_viewer_scale(max_dim: u32, cap: u32) -> ScalingFactor { + if max_dim <= cap { + return ScalingFactor::ONE; + } + for denom in [2u32, 4, 8] { + if max_dim / denom <= cap { + return ScalingFactor::new(1, denom as usize); + } + } + ScalingFactor::new(1, 8) +} + +/// Decode `path` for the viewer (runs on a background thread): full size up +/// to the `cap` longest side, EXIF orientation applied, expanded to RGBA8. +pub fn load(path: &str, cap: u32) -> Result { + let t0 = Instant::now(); + let p = std::path::Path::new(path); + let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()); + let (rgb, w, h) = match ext.as_deref() { + Some("jpg") | Some("jpeg") => load_jpeg(p, cap)?, + Some("png") => load_png(p, cap)?, + _ => return Err(format!("unsupported type: {}", p.display())), + }; + Ok(Full { + rgba: images::to_rgba8(&rgb), + width: w, + height: h, + ms: t0.elapsed().as_millis(), + }) +} + +fn load_jpeg(path: &std::path::Path, cap: u32) -> Result<(Vec, u32, u32), String> { + use turbojpeg::Decompressor; + let data = std::fs::read(path).map_err(|e| format!("read {}: {e}", path.display()))?; + let mut d = Decompressor::new().map_err(|e| e.to_string())?; + let header = d.read_header(&data).map_err(|e| format!("jpeg header: {e}"))?; + let (fw, fh) = (header.width as u32, header.height as u32); + let orientation = images::exif_orientation(&data); + // The post-orientation longest side equals the raw max (90/270 swap + // w/h), so the shrink can be picked on the raw dims. + let scale = pick_viewer_scale(fw.max(fh), cap); + let (rgb, w, h) = images::decode_jpeg_scaled(&data, scale)?; + Ok(images::apply_orientation(rgb, w, h, orientation)) +} + +fn load_png(path: &std::path::Path, cap: u32) -> Result<(Vec, u32, u32), String> { + // PNGs are rare (27 on the test device) — full decode + CPU resize is + // fine; orientation is 1 (no EXIF in the PNGs we've seen). + let img = image::ImageReader::open(path) + .map_err(|e| format!("open {}: {e}", path.display()))? + .with_guessed_format() + .map_err(|e| e.to_string())? + .decode() + .map_err(|e| e.to_string())?; + let rgb = img.to_rgb8(); + let (w, h) = (rgb.width(), rgb.height()); + if w.max(h) > cap { + let (nw, nh) = if w >= h { + (cap, (h as f64 * cap as f64 / w as f64).max(1.0).round() as u32) + } else { + ((w as f64 * cap as f64 / h as f64).max(1.0).round() as u32, cap) + }; + let buf = + image::ImageBuffer::, Vec>::from_raw(w, h, rgb.into_raw()) + .expect("buffer size matches dims"); + let r = image::imageops::resize(&buf, nw, nh, image::imageops::FilterType::Triangle); + let (rw, rh) = (r.width(), r.height()); + Ok((r.into_raw(), rw, rh)) + } else { + Ok((rgb.into_raw(), w, h)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + /// Phone-like viewport (logical px) + a 4:3 landscape 4000×3000 photo. + const VW: f32 = 1080.0; + const VH: f32 = 2340.0; + const IW: f32 = 4000.0; + const IH: f32 = 3000.0; + + #[test] + fn fit_centers_and_preserves_aspect() { + let t = fit(IW, IH, VW, VH); + // Width-limited: fills VW exactly, vertically centered. + assert!((t.s - VW / IW).abs() < 1e-4); + assert!(t.tx.abs() < 1e-3); + assert!((t.ty - (VH - IH * t.s) / 2.0).abs() < 1e-3); + } + + #[test] + fn clamp_zoom_bounds() { + let fit_s = fit_scale(IW, IH, VW, VH); + let low = clamp(Transform { s: fit_s / 2.0, tx: 0.0, ty: 0.0 }, IW, IH, VW, VH); + assert!((low.s - fit_s).abs() < 1e-4); + let high = clamp(Transform { s: fit_s * 1000.0, tx: 0.0, ty: 0.0 }, IW, IH, VW, VH); + assert!((high.s - fit_s * MAX_ZOOM).abs() < 1e-2); + } + + #[test] + fn clamp_pans_only_when_larger() { + // At fit the image is exactly VW wide → panning is disabled + // (centered). + let t = fit(IW, IH, VW, VH); + let c = clamp(Transform { s: t.s, tx: t.tx + 500.0, ty: t.ty }, IW, IH, VW, VH); + assert!((c.tx - t.tx).abs() < 1e-3); + // Zoomed 2× it's 2·VW wide → tx ∈ [VW − 2·VW, 0]. + let z = zoom_at(t, VW / 2.0, VH / 2.0, 2.0, IW, IH, VW, VH); + let c = clamp(Transform { s: z.s, tx: 9999.0, ty: z.ty }, IW, IH, VW, VH); + assert!((c.tx).abs() < 1e-3); // clamped to 0 (right edge) + let c = clamp(Transform { s: z.s, tx: -9999.0, ty: z.ty }, IW, IH, VW, VH); + assert!((c.tx - (VW - IW * z.s)).abs() < 1e-3); // clamped to left edge + } + + #[test] + fn zoom_at_keeps_point_under_center() { + // Portrait image: after 2× zoom it exceeds the viewport on BOTH + // axes, so the clamp can't re-center and the fixation is exact. + let (iw, ih) = (3000.0, 4000.0); + let t0 = fit(iw, ih, VW, VH); + // Center point: after 2× zoom the clamped translation stays in + // range, so the fixation holds exactly. + let (cx, cy) = (540.0, 1170.0); + let t1 = zoom_at(t0, cx, cy, 2.0, iw, ih, VW, VH); + let px = (cx - t0.tx) / t0.s; + let py = (cy - t0.ty) / t0.s; + assert!((t1.s - t0.s * 2.0).abs() < 1e-3); + assert!((t1.tx + px * t1.s - cx).abs() < 1e-2); + assert!((t1.ty + py * t1.s - cy).abs() < 1e-2); + } + + #[test] + fn zoom_at_recenters_smaller_axis() { + // Landscape at fit fills the width; zoomed 2× it still doesn't fill + // the height → the y axis is re-centered (fixation sacrificed there) + // while the x axis keeps it. + let t0 = fit(IW, IH, VW, VH); + let (cx, cy) = (300.0, 500.0); + let t1 = zoom_at(t0, cx, cy, 2.0, IW, IH, VW, VH); + let px = (cx - t0.tx) / t0.s; + assert!((t1.tx + px * t1.s - cx).abs() < 1e-2); + let h = IH * t1.s; + assert!((t1.ty - (VH - h) / 2.0).abs() < 1e-3); + } + + /// An input that has just double-tapped (zoomed to 2.5× fit). + fn input_zoomed() -> Input { + let mut i = Input::new(IW, IH, VW, VH); + let t = Instant::now(); + i.pointer(1, VW / 2.0, VH / 2.0, Phase::Down, t).is_none().then(|| ()); + i.pointer(1, VW / 2.0, VH / 2.0, Phase::Up, t).is_none().then(|| ()); + let t2 = t + Duration::from_millis(100); + i.pointer(1, VW / 2.0, VH / 2.0, Phase::Down, t2).is_none().then(|| ()); + assert!(matches!( + i.pointer(1, VW / 2.0, VH / 2.0, Phase::Up, t2), + Some(InputEvent::Changed(_)) + )); + i + } + + #[test] + fn move_below_slop_is_tap_not_pan() { + let mut i = Input::new(IW, IH, VW, VH); + let t0 = i.transform(); + let t = Instant::now(); + i.pointer(1, 100.0, 100.0, Phase::Down, t).is_none().then(|| ()); + i.pointer(1, 105.0, 105.0, Phase::Move, t).is_none().then(|| ()); + i.pointer(1, 105.0, 105.0, Phase::Up, t).is_none().then(|| ()); + assert_eq!(i.transform(), t0); + } + + #[test] + fn drag_beyond_slop_pans_without_jump() { + let mut i = input_zoomed(); + let t0 = i.transform(); + let t = Instant::now(); + i.pointer(1, 500.0, 1000.0, Phase::Down, t).is_none().then(|| ()); + // 20 px > slop: the pan starts here and this step must NOT move the + // transform (no jump). + i.pointer(1, 520.0, 1000.0, Phase::Move, t).is_none().then(|| ()); + assert_eq!(i.transform(), t0); + let Some(InputEvent::Changed(t1)) = + i.pointer(1, 560.0, 1000.0, Phase::Move, t) + else { + panic!("expected a pan step"); + }; + // The finger moved 40 px right → the image moves with it. + assert!((t1.tx - (t0.tx + 40.0)).abs() < 1e-3); + assert!((t1.ty - t0.ty).abs() < 1e-3); + } + + #[test] + fn double_tap_zooms_in_and_resets() { + let mut i = input_zoomed(); + let fit_s = fit_scale(IW, IH, VW, VH); + assert!((i.transform().s - fit_s * 2.5).abs() < 1e-3); + // Second double tap (after the 300 ms window) resets to fit. + let t = Instant::now() + Duration::from_secs(1); + i.pointer(1, VW / 2.0, VH / 2.0, Phase::Down, t).is_none().then(|| ()); + i.pointer(1, VW / 2.0, VH / 2.0, Phase::Up, t).is_none().then(|| ()); + let t2 = t + Duration::from_millis(100); + i.pointer(1, VW / 2.0, VH / 2.0, Phase::Down, t2).is_none().then(|| ()); + let Some(InputEvent::Changed(t_final)) = + i.pointer(1, VW / 2.0, VH / 2.0, Phase::Up, t2) + else { + panic!("expected double-tap reset"); + }; + assert!((t_final.s - fit_s).abs() < 1e-3); + } + + #[test] + fn two_slow_taps_are_not_double() { + let mut i = Input::new(IW, IH, VW, VH); + let t0 = i.transform(); + let t = Instant::now(); + i.pointer(1, 300.0, 300.0, Phase::Down, t).is_none().then(|| ()); + i.pointer(1, 300.0, 300.0, Phase::Up, t).is_none().then(|| ()); + let t2 = t + Duration::from_millis(400); // > 300 ms + i.pointer(1, 300.0, 300.0, Phase::Down, t2).is_none().then(|| ()); + i.pointer(1, 300.0, 300.0, Phase::Up, t2).is_none().then(|| ()); + assert_eq!(i.transform(), t0); + } + + /// The ScaleRotateGestureHandler sequence: `started` snapshots the + /// transform, `updated` reports the CUMULATIVE scale (from 1.0) about + /// the (moving) center. `Input::pinch` must zoom about the CURRENT + /// center of each event, from the original snapshot. + #[test] + fn pinch_handler_sequence() { + let mut i = Input::new(IW, IH, VW, VH); + let base = i.transform(); + // Gesture center (540, 1170), cumulative scale 3.0 → s = 3× fit. + let t1 = i.pinch(base, 3.0, 540.0, 1170.0); + assert!((t1.s - base.s * 3.0).abs() < 1e-2); + // The content point under the center stays under it. + let px = (540.0 - base.tx) / base.s; + let py = (1170.0 - base.ty) / base.s; + assert!((t1.tx + px * t1.s - 540.0).abs() < 1e-2); + assert!((t1.ty + py * t1.s - 1170.0).abs() < 1e-2); + // A later event reports 4.0 CUMULATIVE (not ×4 of the last): the + // transform is always base × scale — no incremental compounding. + let t2 = i.pinch(base, 4.0, 540.0, 1170.0); + assert!((t2.s - base.s * 4.0).abs() < 1e-2); + assert!((t2.tx + px * t2.s - 540.0).abs() < 1e-2); + } + + #[test] + fn pinch_survivor_pan_exact() { + // Pinch to 3× about (540,1170); the core re-presses the survivor; + // the next single-finger drag pans from the pinch result. + let mut i = Input::new(IW, IH, VW, VH); + let base = i.transform(); + let pinch_t = i.pinch(base, 3.0, 540.0, 1170.0); + i.set_transform(pinch_t); + i.reset_for_pinch(); + let t = Instant::now(); + i.pointer(1, 540.0, 1170.0, Phase::Down, t).is_none().then(|| ()); + // 20 px > slop: pan starts, no jump. + i.pointer(1, 560.0, 1170.0, Phase::Move, t).is_none().then(|| ()); + assert_eq!(i.transform(), pinch_t); + let Some(InputEvent::Changed(t1)) = + i.pointer(1, 600.0, 1170.0, Phase::Move, t) + else { + panic!("pan"); + }; + // 40 px right from the pinch result. + assert!((t1.tx - (pinch_t.tx + 40.0)).abs() < 1e-3); + assert!((t1.ty - pinch_t.ty).abs() < 1e-3); + } + + #[test] + fn reset_for_pinch_drops_pending_tap() { + // A press that the core turns into a pinch must not register as a + // tap when the synthesized Release arrives (reset_for_pinch runs on + // the gesture's `started`). + let mut i = Input::new(IW, IH, VW, VH); + let t0 = i.transform(); + let t = Instant::now(); + i.pointer(1, 540.0, 1170.0, Phase::Down, t).is_none().then(|| ()); + i.reset_for_pinch(); + i.pointer(1, 540.0, 1170.0, Phase::Up, t).is_none().then(|| ()); + // No tap was registered: a second tap alone is not a double tap. + let t2 = t + Duration::from_millis(100); + i.pointer(1, 540.0, 1170.0, Phase::Down, t2).is_none().then(|| ()); + i.pointer(1, 540.0, 1170.0, Phase::Up, t2).is_none().then(|| ()); + assert_eq!(i.transform(), t0); + } + + #[test] + fn cancel_clears_press_state() { + let mut i = Input::new(IW, IH, VW, VH); + let t0 = i.transform(); + let t = Instant::now(); + i.pointer(1, 490.0, 1170.0, Phase::Down, t).is_none().then(|| ()); + i.pointer(1, 390.0, 1170.0, Phase::Cancel, t).is_none().then(|| ()); + // A late Up for the cancelled press is a no-op (no tap). + i.pointer(1, 380.0, 1170.0, Phase::Up, t).is_none().then(|| ()); + assert_eq!(i.transform(), t0); + } + + #[test] + fn pick_viewer_scale_lands_at_or_below_cap() { + assert_eq!(pick_viewer_scale(1000, 4000), ScalingFactor::ONE); + assert_eq!(pick_viewer_scale(8000, 4000), ScalingFactor::new(1, 2)); + assert_eq!(pick_viewer_scale(8192, 4000), ScalingFactor::new(1, 4)); + assert_eq!(pick_viewer_scale(32000, 4000), ScalingFactor::new(1, 8)); + assert_eq!(pick_viewer_scale(100_000, 4000), ScalingFactor::new(1, 8)); + } + + #[test] + fn decode_cap_bounds_by_texture_limit() { + assert_eq!(decode_cap(1500, None), 3000); + assert_eq!(decode_cap(1500, Some(2048)), 2048); + assert_eq!(decode_cap(1500, Some(4000)), 3000); + } + + fn gradient_jpeg(path: &std::path::Path, w: u32, h: u32) { + let mut rgb = Vec::with_capacity(3 * (w * h) as usize); + for y in 0..h { + for x in 0..w { + rgb.push((x * 255 / w.saturating_sub(1).max(1)) as u8); + rgb.push((y * 255 / h.saturating_sub(1).max(1)) as u8); + rgb.push(((x + y) as f32 * 127.5 / (w + h) as f32) as u8); + } + } + let jpeg = crate::images::encode_jpeg(&rgb, w, h, 90).unwrap(); + std::fs::write(path, jpeg).unwrap(); + } + + #[test] + fn load_jpeg_full_and_capped() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("v.jpg"); + gradient_jpeg(&p, 2000, 1500); + + // cap above the file: full size. + let f = load(p.to_str().unwrap(), 4096).unwrap(); + assert_eq!((f.width, f.height), (2000, 1500)); + assert_eq!(f.rgba.len(), 4 * 2000 * 1500); + + // cap 1000: 1/2 shrink → 1000×750. + let f = load(p.to_str().unwrap(), 1000).unwrap(); + assert_eq!((f.width, f.height), (1000, 750)); + assert_eq!(f.rgba.len(), 4 * 1000 * 750); + } + + #[test] + fn load_jpeg_exif_orientation() { + // 400×200 landscape + EXIF Orientation=6 (90° CW) → 200×400 portrait. + use exif::{experimental::Writer, Field, In, Tag, Value}; + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("o.jpg"); + let mut rgb = Vec::with_capacity(3 * 400 * 200); + for _ in 0..(400 * 200) { + rgb.extend_from_slice(&[10, 20, 30]); + } + let jpeg = crate::images::encode_jpeg(&rgb, 400, 200, 90).unwrap(); + let mut cursor = std::io::Cursor::new(Vec::new()); + let field = Field { + tag: Tag::Orientation, + ifd_num: In::PRIMARY, + value: Value::Short(vec![6]), + }; + let mut writer = Writer::new(); + writer.push_field(&field); + writer.write(&mut cursor, true).unwrap(); + let tiff = cursor.into_inner(); + let mut app1 = vec![0xFF, 0xE1]; + app1.extend_from_slice(&((tiff.len() + 8) as u16).to_be_bytes()); + app1.extend_from_slice(b"Exif\0\0"); + app1.extend_from_slice(&tiff); + let mut with_exif = vec![0xFF, 0xD8]; + with_exif.extend_from_slice(&app1); + with_exif.extend_from_slice(&jpeg[2..]); + std::fs::write(&p, with_exif).unwrap(); + + let f = load(p.to_str().unwrap(), 4096).unwrap(); + assert_eq!((f.width, f.height), (200, 400)); + } + + #[test] + fn load_unsupported_type_errors() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("x.heic"); + std::fs::write(&p, [0u8; 8]).unwrap(); + assert!(load(p.to_str().unwrap(), 1000).is_err()); + } +} diff --git a/vendor/i-slint-backend-android-activity-1.18.1/androidwindowadapter.rs b/vendor/i-slint-backend-android-activity-1.18.1/androidwindowadapter.rs index d9bdf70..3097bc9 100644 --- a/vendor/i-slint-backend-android-activity-1.18.1/androidwindowadapter.rs +++ b/vendor/i-slint-backend-android-activity-1.18.1/androidwindowadapter.rs @@ -282,16 +282,37 @@ impl AndroidWindowAdapter { loop { let mut result = Ok(()); let read_input = iter.next(|event| match event { - InputEvent::KeyEvent(key_event) => match map_key_event(key_event) { - Some(ev) => match self.window.dispatch_event_with_result(ev) { - Ok(WindowEventDispatchResult::Accepted) => InputStatus::Handled, - Ok(_) => InputStatus::Unhandled, - Err(e) => { - result = Err(e); - InputStatus::Unhandled + InputEvent::KeyEvent(key_event) => { + // Patch (gallery, M4 2026-10-29): Android Back = close + // REQUEST (native semantics), not a key event — the app's + // on_close_requested decides (it closes the full-screen + // viewer first). Slint 1.18 core has no Back-key close + // handling, so without this the key does nothing. + if key_event.key_code() == Keycode::Back + && key_event.action() == KeyAction::Down + && key_event.repeat_count() == 0 + { + match self.window.dispatch_event_with_result(WindowEvent::CloseRequested) + { + Ok(_) => InputStatus::Handled, + Err(e) => { + result = Err(e); + InputStatus::Unhandled + } } - }, - None => InputStatus::Unhandled, + } else { + match map_key_event(key_event) { + Some(ev) => match self.window.dispatch_event_with_result(ev) { + Ok(WindowEventDispatchResult::Accepted) => InputStatus::Handled, + Ok(_) => InputStatus::Unhandled, + Err(e) => { + result = Err(e); + InputStatus::Unhandled + } + }, + None => InputStatus::Unhandled, + } + } }, InputEvent::MotionEvent(motion_event) => { let offset = self.offset.get();