M4: full-screen viewer with pinch/pan zoom

- 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.
This commit is contained in:
Greg Pomerantz 2026-10-29 10:51:56 -04:00
parent d5bbb41bbd
commit 02dec62405
6 changed files with 1292 additions and 23 deletions

View File

@ -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: Stress-verified on the emulator (debug build) + phone before shipping:
fast up/down 0–120 ms to full coverage, cold landings bounded by decode 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), 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) ───────────────────────────────────────────────────────┐ ┌─ UI (Slint) ───────────────────────────────────────────────────────┐
│ grid screen: ListView (vertical) → row of N square Image cells │ │ grid screen: ListView (vertical) → row of N square Image cells │
│ viewer screen: full-size async decode (once, cached) + manual │ │ viewer screen: full-size async decode (once, cached) + │
│ two-finger pinch transform (no built-in in 1.18) │ │ 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 `internal/core/graphics/image/cache.rs`) — too small and the wrong layer
for us; our cache lives in Rust (disk + compressed-bytes LRU). for us; our cache lives in Rust (disk + compressed-bytes LRU).
No mipmaps in the Skia renderer — irrelevant since we pre-downsample. No mipmaps in the Skia renderer — irrelevant since we pre-downsample.
- **Viewer**: no pinch helper in Slint 1.18 (`TouchArea` has no pinch - **Viewer** (DONE in M4): pinch is the `ScaleRotateGestureHandler`
callback; grepped the stdlib). Manual two-finger math on `TouchArea` element — the 1.18 CORE synthesizes `PinchGesture` from raw two-finger
(distance/centroid → scale/translation, ~50 lines; crib touches on every backend (the second finger never reaches a `TouchArea`;
`slint-src/examples/native-gestures` — source checkout is at see §6.15/16). Single-finger pan + double-tap-to-zoom are a small state
`~/prog/android/ui/slint-src`, tag v1.18.1). Double-tap-to-zoom is a nice machine in `src/viewer.rs` driven by `TouchArea::pointer-event`. Crib:
extra (M6). `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) - **Screens**: single `Window` with a stacked layout (grid + viewer overlay)
or a `Navigator`-style state property; keep it simple until M4. or a `Navigator`-style state property; keep it simple until M4.
- **Safe area**: always pad with `root.safe-area-insets.{top,bottom}` (done - **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 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 rhythm into the item height (we add a 4 px bottom margin inside
`GridRow`; `ROW_SPACING` in lib.rs must match). `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) ## 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 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 with 0 display hitches (cold and warm cache) — see §8 for the
measurement method (gfxinfo does NOT track Slint's window).* measurement method (gfxinfo does NOT track Slint's window).*
- **M4 — viewer**: tap cell → full decode + overlay; pinch/pan (manual - **M4 — viewer (DONE, 2026-10-29)**: tap cell → full decode (turbojpeg
math), double-tap zoom; back gesture/button; EXIF orientation correct. 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 - **M5 — permissions**: implement `perm.rs` per §7; launch flow when
un-granted (settings card → open Settings → recheck on Resume); test the un-granted (settings card → open Settings → recheck on Resume); test the
exact passgo-style flow on a fresh install. 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 Fix: the debug-scroll hook (and all file knobs) are gone; the
leftover file was deleted from the device. leftover file was deleted from the device.
**Next: M4 viewer** (tap cell → full decode + overlay, pinch/pan, **Next: M5 permissions** — implement `perm.rs` per §7 (launch flow when
double-tap zoom, back gesture, EXIF orientation). Work on the un-granted: settings card → open Settings → recheck on Resume); test the
emulator first (user directive). 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.)

View File

@ -77,6 +77,15 @@ pub fn ready() -> bool {
GPU.lock().unwrap().is_some() 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<u32> {
GPU.lock()
.unwrap()
.as_ref()
.map(|c| c.device.limits().max_texture_dimension_2d)
}
/// Create a GPU-resident `Rgba8Unorm` texture from packed RGBA8 bytes, /// Create a GPU-resident `Rgba8Unorm` texture from packed RGBA8 bytes,
/// submit the (async) upload and wrap it in a ready-to-present /// submit the (async) upload and wrap it in a ready-to-present
/// `slint::Image`. **Runs on the UI thread (render thread)** — see the /// `slint::Image`. **Runs on the UI thread (render thread)** — see the

View File

@ -80,6 +80,7 @@ pub mod gputex;
pub mod images; pub mod images;
pub mod scan; pub mod scan;
pub mod thumb; pub mod thumb;
pub mod viewer;
/// Thread-priority helpers (jank fix): the app process runs at nice -10, /// Thread-priority helpers (jank fix): the app process runs at nice -10,
/// which every spawned thread inherits — turbojpeg decode bursts then run at /// which every spawned thread inherits — turbojpeg decode bursts then run at
@ -272,6 +273,23 @@ struct AppState {
painted_total: AtomicU64, painted_total: AtomicU64,
/// Rate-limits the stall alarm. UI thread only. /// Rate-limits the stall alarm. UI thread only.
last_stall_log: Mutex<Instant>, last_stall_log: Mutex<Instant>,
/// 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<ViewerUi>,
}
/// 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<viewer::Input>,
/// Transform snapshot at the pinch's `started`; the handler's
/// cumulative scale is applied about its center (`Input::pinch`).
pinch_base: Option<viewer::Transform>,
} }
/// Create the pool + cache dir once per process. /// Create the pool + cache dir once per process.
@ -287,6 +305,12 @@ fn state() -> &'static AppState {
failed: Mutex::new(HashSet::new()), failed: Mutex::new(HashSet::new()),
painted_total: AtomicU64::new(0), painted_total: AtomicU64::new(0),
last_stall_log: Mutex::new(Instant::now()), 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<Range<usize
..((first + visible + MARGIN_ROWS) * GRID_COLS).min(n)) ..((first + visible + MARGIN_ROWS) * GRID_COLS).min(n))
} }
/// Close the viewer (M4): the × button, the Android back key (delivered as
/// a close request — see the vendor patch), or a world reset. Bumps `gen`
/// so an in-flight full decode is dropped at delivery.
fn close_viewer(w: &Main, app: &AppState) {
let mut v = app.viewer.lock().unwrap();
v.gen += 1;
v.open = false;
v.input = None;
v.pinch_base = None;
w.set_viewer_open(false);
w.set_v_status("".into());
}
/// The window's longest side in PHYSICAL px (for the decode cap). Slint
/// 1.18's public Window API has no pixel-ratio getter; on Android the
/// density comes from the app config (dpi / 160, the same formula the
/// backend uses for its scale factor), on desktop it's 1.
fn phys_screen_max(w: &Main) -> 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::<Rgba8Pixel>::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 /// Clear all painted cells IN PLACE (the eviction path) and reset the
/// loader state. Call from the render notifier (UI thread). /// 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.engine.lock().unwrap().bump_generation();
app.inflight.lock().unwrap().clear(); app.inflight.lock().unwrap().clear();
app.failed.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<usize> = app.painted.lock().unwrap().drain().collect(); let painted: Vec<usize> = app.painted.lock().unwrap().drain().collect();
let n = { let n = {
let entries = app.entries.lock().unwrap(); let entries = app.entries.lock().unwrap();
@ -1077,6 +1288,75 @@ pub fn start_scan(window: &Main, wid: u32) {
let app = state(); let app = state();
window.on_scroll_changed(move |y: f32, h: f32, w: f32| on_scroll(app, y, h, w)); 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); start_drain_timer(&weak, wid);
std::thread::spawn(move || { std::thread::spawn(move || {

View File

@ -29,10 +29,21 @@ export struct Item {
// row have path == ""). // row have path == "").
component CellView inherits Rectangle { component CellView inherits Rectangle {
in property <Cell> cell; in property <Cell> cell;
callback cell-clicked();
background: #1a1a1a; background: #1a1a1a;
border-radius: 4px; border-radius: 4px;
clip: true; 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: // Placeholder while the thumbnail is in flight. STATIC on purpose:
// animation-tick() in a per-cell binding is O(ALL CELLS) every frame // animation-tick() in a per-cell binding is O(ALL CELLS) every frame
// (ListView keeps every row alive; bindings are not visibility-gated), // (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). // cw + 4; lib.rs ROW_SPACING must match).
component GridRow inherits Rectangle { component GridRow inherits Rectangle {
in property <Item> row-data; in property <Item> row-data;
callback cell-clicked(path: string);
property <length> cw: (self.width - 8px) / 3; property <length> cw: (self.width - 8px) / 3;
width: 100%; width: 100%;
height: cw + 4px; height: cw + 4px;
@ -71,6 +83,149 @@ component GridRow inherits Rectangle {
height: root.cw; height: root.cw;
x: c * (root.cw + 4px); x: c * (root.cw + 4px);
visible: cell.path != ""; 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 <bool> open;
in property <length> top-inset;
in property <image> image;
in property <float> scale: 1.0;
in property <float> tx: 0.0;
in property <float> ty: 0.0;
in property <float> img-w: 0.0;
in property <float> img-h: 0.0;
in property <string> 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. /// delivers results.
in property <[Item]> items; 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 <length> win-w: self.width;
in property <length> win-h: self.height;
// M4 viewer. Rust owns the open state + transform (src/viewer.rs);
// these are mirrors the overlay renders.
in property <bool> viewer-open: false;
in property <image> viewer-image;
in property <float> v-scale: 1.0;
in property <float> v-tx: 0.0;
in property <float> v-ty: 0.0;
in property <float> v-img-w: 0.0;
in property <float> v-img-h: 0.0;
in property <string> 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 // Rust-readable mirrors of the list's ACTUAL geometry (content-y is
// negative while scrolled down). The reconcile tick polls these every // negative while scrolled down). The reconcile tick polls these every
// tick — including during momentum scrolling, where the `scrolled` // tick — including during momentum scrolling, where the `scrolled`
@ -122,7 +305,38 @@ export component Main inherits Window {
} }
for row in root.items: GridRow { for row in root.items: GridRow {
row-data: row; 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();
}
}
} }

681
src/viewer.rs Normal file
View File

@ -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<InputEvent> {
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<InputEvent> {
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<u8>,
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>) -> 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<Full, String> {
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<u8>, 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<u8>, 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::<image::Rgb<u8>, Vec<u8>>::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());
}
}

View File

@ -282,7 +282,26 @@ impl AndroidWindowAdapter {
loop { loop {
let mut result = Ok(()); let mut result = Ok(());
let read_input = iter.next(|event| match event { let read_input = iter.next(|event| match event {
InputEvent::KeyEvent(key_event) => match map_key_event(key_event) { 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
}
}
} else {
match map_key_event(key_event) {
Some(ev) => match self.window.dispatch_event_with_result(ev) { Some(ev) => match self.window.dispatch_event_with_result(ev) {
Ok(WindowEventDispatchResult::Accepted) => InputStatus::Handled, Ok(WindowEventDispatchResult::Accepted) => InputStatus::Handled,
Ok(_) => InputStatus::Unhandled, Ok(_) => InputStatus::Unhandled,
@ -292,6 +311,8 @@ impl AndroidWindowAdapter {
} }
}, },
None => InputStatus::Unhandled, None => InputStatus::Unhandled,
}
}
}, },
InputEvent::MotionEvent(motion_event) => { InputEvent::MotionEvent(motion_event) => {
let offset = self.offset.get(); let offset = self.offset.get();