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:
parent
d5bbb41bbd
commit
02dec62405
92
HANDOFF.md
92
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:
|
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.)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
280
src/lib.rs
280
src/lib.rs
|
|
@ -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 || {
|
||||||
|
|
|
||||||
214
src/main.slint
214
src/main.slint
|
|
@ -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
681
src/viewer.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user