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:
|
||||
fast up/down 0–120 ms to full coverage, cold landings bounded by decode
|
||||
throughput, chaos scrolling max oldest in-flight job 217 ms (was 8 s),
|
||||
0 invariant violations in ~1,450 ticks, 0 panics/ANRs. Next: M4 viewer.
|
||||
0 invariant violations in ~1,450 ticks, 0 panics/ANRs. M4 viewer is
|
||||
DONE (2026-10-29 — see §9); next is M5 permissions.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -141,8 +142,8 @@ thumbnail engine, and it is framework-agnostic:
|
|||
└────────────────────────────────────────────────────────────────────┘
|
||||
┌─ UI (Slint) ───────────────────────────────────────────────────────┐
|
||||
│ grid screen: ListView (vertical) → row of N square Image cells │
|
||||
│ viewer screen: full-size async decode (once, cached) + manual │
|
||||
│ two-finger pinch transform (no built-in in 1.18) │
|
||||
│ viewer screen: full-size async decode (once, cached) + │
|
||||
│ ScaleRotateGestureHandler pinch + 1-finger pan │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
|
|
@ -196,12 +197,13 @@ cancellation, (5) EXIF rotation applied per frame.
|
|||
`internal/core/graphics/image/cache.rs`) — too small and the wrong layer
|
||||
for us; our cache lives in Rust (disk + compressed-bytes LRU).
|
||||
No mipmaps in the Skia renderer — irrelevant since we pre-downsample.
|
||||
- **Viewer**: no pinch helper in Slint 1.18 (`TouchArea` has no pinch
|
||||
callback; grepped the stdlib). Manual two-finger math on `TouchArea`
|
||||
(distance/centroid → scale/translation, ~50 lines; crib
|
||||
`slint-src/examples/native-gestures` — source checkout is at
|
||||
`~/prog/android/ui/slint-src`, tag v1.18.1). Double-tap-to-zoom is a nice
|
||||
extra (M6).
|
||||
- **Viewer** (DONE in M4): pinch is the `ScaleRotateGestureHandler`
|
||||
element — the 1.18 CORE synthesizes `PinchGesture` from raw two-finger
|
||||
touches on every backend (the second finger never reaches a `TouchArea`;
|
||||
see §6.15/16). Single-finger pan + double-tap-to-zoom are a small state
|
||||
machine in `src/viewer.rs` driven by `TouchArea::pointer-event`. Crib:
|
||||
`slint-src/examples/native-gestures` (source at
|
||||
`~/prog/android/ui/slint-src`, tag v1.18.1).
|
||||
- **Screens**: single `Window` with a stacked layout (grid + viewer overlay)
|
||||
or a `Navigator`-style state property; keep it simple until M4.
|
||||
- **Safe area**: always pad with `root.safe-area-insets.{top,bottom}` (done
|
||||
|
|
@ -250,6 +252,42 @@ cancellation, (5) EXIF rotation applied per frame.
|
|||
13. `ListView` has no `spacing`/`gap` property in 1.18 — bake inter-row
|
||||
rhythm into the item height (we add a 4 px bottom margin inside
|
||||
`GridRow`; `ROW_SPACING` in lib.rs must match).
|
||||
14. **`TouchArea` grabs EVERY press (mouse and touch) — `input_event` on
|
||||
`Pressed` unconditionally returns `GrabMouse` (internal/core/items/
|
||||
input_items.rs), which ABORTS the sibling traversal**
|
||||
(`send_mouse_event_to_item` → `VisitChildrenResult::abort`). Siblings
|
||||
are visited FRONT-TO-BACK = last-declared first (`TraversalOrder::
|
||||
FrontToBack` in item_tree.rs iterates `count-1 … 0`), so the TOPMOST
|
||||
(last-declared) TouchArea at a position wins the press, and any
|
||||
earlier-declared TouchArea under it never sees the event. The viewer's
|
||||
close button therefore must be declared AFTER the full-surface
|
||||
TouchArea (M4 — this cost a session of debugging).
|
||||
15. **The second finger NEVER reaches a `TouchArea`.** The CORE
|
||||
(not the backend) owns two-finger semantics: `TouchState::process`
|
||||
(internal/core/input.rs) turns per-finger `InternalEvent::Touch` into
|
||||
mouse events, and when a second finger lands it synthesizes a Release
|
||||
for the first finger and, after an 8 px threshold, emits
|
||||
`PinchGesture`/`RotationGesture` mouse events — on EVERY backend,
|
||||
including Android (the android-activity backend does deliver the
|
||||
per-finger `Touch` events; the core converts them). Consequences:
|
||||
`TouchArea::pointer-event` (1.18's `touch-finger-id`) only ever sees
|
||||
ONE finger; manual two-finger math is dead code. When a pinch finger
|
||||
lifts, the core re-presses the survivor (a fresh `Pressed`), which
|
||||
seamlessly continues as a single-finger pan.
|
||||
16. **Pinch in 1.18 = the `ScaleRotateGestureHandler` element** (added in
|
||||
1.18.0; declared in internal/compiler/builtin_elements.rs, NOT in any
|
||||
.slint file — `git grep ScaleRotateGestureHandler` the compiler crate).
|
||||
API: `in enabled`, `out active`, `out scale` (CUMULATIVE from 1.0 at
|
||||
gesture start), `out rotation`, `out center` (Point, in the element's
|
||||
coordinates), callbacks `started`/`updated`/`ended`/`cancelled`.
|
||||
`@expands_to_parent_geometry`. While active it INTERCEPTS all
|
||||
non-gesture events (its `input_event_filter_before_children`).
|
||||
Correct usage: snapshot your transform at `started`, apply
|
||||
`snapshot · scale` about `center` at each `updated` (never multiply
|
||||
incrementally — `scale` is cumulative), commit at `ended` (the
|
||||
properties read identity once inactive). See `src/viewer.rs` +
|
||||
`src/main.slint` (Viewer) and upstream
|
||||
`slint-src/examples/native-gestures`.
|
||||
|
||||
## 7. Permissions & platform integration (Android)
|
||||
|
||||
|
|
@ -543,8 +581,32 @@ flush (Mali driver back-pressure). Fix + evidence: §8.9 and
|
|||
photos): first screen ~0.7 s cold at 500 px; scroll = sustained 60 fps
|
||||
with 0 display hitches (cold and warm cache) — see §8 for the
|
||||
measurement method (gfxinfo does NOT track Slint's window).*
|
||||
- **M4 — viewer**: tap cell → full decode + overlay; pinch/pan (manual
|
||||
math), double-tap zoom; back gesture/button; EXIF orientation correct.
|
||||
- **M4 — viewer (DONE, 2026-10-29)**: tap cell → full decode (turbojpeg
|
||||
DCT shrink, 2×-screen cap bounded by GPU max texture, EXIF orientation
|
||||
applied) + full-screen overlay; pinch zoom (Slint's
|
||||
`ScaleRotateGestureHandler` — see §6.15/16; transform math is
|
||||
`zoom_at`/`Input::pinch` in `src/viewer.rs`); single-finger pan +
|
||||
double-tap zoom (2.5× fit at tap point; again → reset to fit);
|
||||
back key (vendor patch: `Keycode::Back` → `CloseRequested`, the app's
|
||||
`on_close_requested` closes the viewer first) + × button; error text on
|
||||
decode failure. Verified on the emulator (pad_avd 1080×2400): all
|
||||
gestures incl. REAL two-finger pinch (sendevent MT-B — recipe below);
|
||||
phone: grid + viewer open + 4080×3072 decode OK (pinch not injectable
|
||||
on the user build — SELinux blocks /dev/input writes and `input`
|
||||
has no multi-pointer; trust the unit tests + emulator).
|
||||
**Emulator two-finger injection recipe** (userdebug + `adb root`;
|
||||
active touch device = the one under `Touch Input Mapper (mode - DIRECT)`
|
||||
in `dumpsys input` — on pad_avd that's event2, NOT the one `getevent`
|
||||
prints first; per-axis raw→px: X×W/32767, Y×H/32767): MT-B with explicit
|
||||
`ABS_MT_SLOT`(47) per finger, `ABS_MT_TRACKING_ID`(57) re-emitted on
|
||||
every move frame (values ≥0 = present, -1 = lifted), one SYN(0 0 0) per
|
||||
frame, BTN_TOUCH(1 330) on first-down/last-up. Each `sendevent` is a
|
||||
fork (~20 ms), so `input tap x y; input tap x y` in ONE shell is the
|
||||
way to make a ≤300 ms double-tap.
|
||||
Gotchas (also in §6.14–16): the close button MUST be declared after the
|
||||
full-surface TouchArea (TouchArea grabs + aborts sibling traversal);
|
||||
`ScaleRotateGestureHandler` is declared in the compiler crate, not any
|
||||
.slint file; Slint logical px = physical px / (dpi/160) on Android.
|
||||
- **M5 — permissions**: implement `perm.rs` per §7; launch flow when
|
||||
un-granted (settings card → open Settings → recheck on Resume); test the
|
||||
exact passgo-style flow on a fresh install.
|
||||
|
|
@ -734,6 +796,8 @@ on every launch; while active it also overrode the user's swipes.
|
|||
Fix: the debug-scroll hook (and all file knobs) are gone; the
|
||||
leftover file was deleted from the device.
|
||||
|
||||
**Next: M4 viewer** (tap cell → full decode + overlay, pinch/pan,
|
||||
double-tap zoom, back gesture, EXIF orientation). Work on the
|
||||
emulator first (user directive).
|
||||
**Next: M5 permissions** — implement `perm.rs` per §7 (launch flow when
|
||||
un-granted: settings card → open Settings → recheck on Resume); test the
|
||||
passgo-style flow on a fresh install. (M4 viewer is DONE, 2026-10-29 —
|
||||
see the M4 entry in §9 for the verification notes + emulator pinch
|
||||
injection recipe.)
|
||||
|
|
|
|||
|
|
@ -77,6 +77,15 @@ pub fn ready() -> bool {
|
|||
GPU.lock().unwrap().is_some()
|
||||
}
|
||||
|
||||
/// The GPU's max 2D texture size (for the viewer's decode cap — HANDOFF
|
||||
/// §10.3: 12 MP+ full decodes may exceed it). `None` until captured.
|
||||
pub fn max_texture_size() -> Option<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,
|
||||
/// submit the (async) upload and wrap it in a ready-to-present
|
||||
/// `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 scan;
|
||||
pub mod thumb;
|
||||
pub mod viewer;
|
||||
|
||||
/// Thread-priority helpers (jank fix): the app process runs at nice -10,
|
||||
/// which every spawned thread inherits — turbojpeg decode bursts then run at
|
||||
|
|
@ -272,6 +273,23 @@ struct AppState {
|
|||
painted_total: AtomicU64,
|
||||
/// Rate-limits the stall alarm. UI thread only.
|
||||
last_stall_log: Mutex<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.
|
||||
|
|
@ -287,6 +305,12 @@ fn state() -> &'static AppState {
|
|||
failed: Mutex::new(HashSet::new()),
|
||||
painted_total: AtomicU64::new(0),
|
||||
last_stall_log: Mutex::new(Instant::now()),
|
||||
viewer: Mutex::new(ViewerUi {
|
||||
gen: 0,
|
||||
open: false,
|
||||
input: None,
|
||||
pinch_base: None,
|
||||
}),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -490,6 +514,190 @@ fn viewport_for(y: f32, height: f32, width: f32, n: usize) -> Option<Range<usize
|
|||
..((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
|
||||
/// loader state. Call from the render notifier (UI thread).
|
||||
///
|
||||
|
|
@ -504,6 +712,9 @@ fn reset_grid_in_place(w: &Main, app: &AppState) {
|
|||
app.engine.lock().unwrap().bump_generation();
|
||||
app.inflight.lock().unwrap().clear();
|
||||
app.failed.lock().unwrap().clear();
|
||||
// The viewer's image (and its load) also belonged to the dying device:
|
||||
// close it; the tick/grid re-fill on the new one.
|
||||
close_viewer(w, app);
|
||||
let painted: Vec<usize> = app.painted.lock().unwrap().drain().collect();
|
||||
let n = {
|
||||
let entries = app.entries.lock().unwrap();
|
||||
|
|
@ -1077,6 +1288,75 @@ pub fn start_scan(window: &Main, wid: u32) {
|
|||
let app = state();
|
||||
|
||||
window.on_scroll_changed(move |y: f32, h: f32, w: f32| on_scroll(app, y, h, w));
|
||||
|
||||
// M4 viewer wiring (each handler gets its own clone of the weak ref).
|
||||
window.on_open_image({
|
||||
let weak = weak.clone();
|
||||
move |path| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let Some(app) = STATE.get() else { return };
|
||||
open_viewer(&w, app, path.to_string());
|
||||
}
|
||||
});
|
||||
window.on_viewer_pointer({
|
||||
let weak = weak.clone();
|
||||
move |finger, x, y, kind| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let Some(app) = STATE.get() else { return };
|
||||
on_viewer_pointer(&w, app, finger, x, y, kind);
|
||||
}
|
||||
});
|
||||
window.on_viewer_pinch_started(|| {
|
||||
let Some(app) = STATE.get() else { return };
|
||||
on_viewer_pinch_started(app);
|
||||
});
|
||||
window.on_viewer_pinch_updated({
|
||||
let weak = weak.clone();
|
||||
move |scale, cx, cy| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let Some(app) = STATE.get() else { return };
|
||||
on_viewer_pinch_updated(&w, app, scale, cx, cy);
|
||||
}
|
||||
});
|
||||
window.on_viewer_pinch_ended({
|
||||
let weak = weak.clone();
|
||||
move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let Some(app) = STATE.get() else { return };
|
||||
on_viewer_pinch_ended(&w, app);
|
||||
}
|
||||
});
|
||||
window.on_close_viewer({
|
||||
let weak = weak.clone();
|
||||
move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let Some(app) = STATE.get() else { return };
|
||||
close_viewer(&w, app);
|
||||
}
|
||||
});
|
||||
// Android: the back key arrives as a close REQUEST (vendor patch —
|
||||
// native semantics). While the viewer is open, back closes the viewer;
|
||||
// otherwise the window's default (hide) applies. Installed on Android
|
||||
// only: on desktop the default close path (app exit) must be kept.
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
let weak2 = weak.clone();
|
||||
window.window().on_close_requested(move || {
|
||||
let Some(app) = STATE.get() else {
|
||||
return slint::CloseRequestResponse::HideWindow
|
||||
};
|
||||
let open = app.viewer.lock().unwrap().open;
|
||||
if open {
|
||||
if let Some(w) = weak2.upgrade() {
|
||||
close_viewer(&w, app);
|
||||
}
|
||||
slint::CloseRequestResponse::KeepWindowShown
|
||||
} else {
|
||||
slint::CloseRequestResponse::HideWindow
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
start_drain_timer(&weak, wid);
|
||||
|
||||
std::thread::spawn(move || {
|
||||
|
|
|
|||
214
src/main.slint
214
src/main.slint
|
|
@ -29,10 +29,21 @@ export struct Item {
|
|||
// row have path == "").
|
||||
component CellView inherits Rectangle {
|
||||
in property <Cell> cell;
|
||||
callback cell-clicked();
|
||||
background: #1a1a1a;
|
||||
border-radius: 4px;
|
||||
clip: true;
|
||||
|
||||
// Whole-cell tap target (M4: opens the viewer). A drag past the slop is
|
||||
// taken over by the ListView's Flickable, which cancels the press, so
|
||||
// scrolling is unaffected.
|
||||
TouchArea {
|
||||
enabled: cell.path != "";
|
||||
clicked => {
|
||||
root.cell-clicked();
|
||||
}
|
||||
}
|
||||
|
||||
// Placeholder while the thumbnail is in flight. STATIC on purpose:
|
||||
// animation-tick() in a per-cell binding is O(ALL CELLS) every frame
|
||||
// (ListView keeps every row alive; bindings are not visibility-gated),
|
||||
|
|
@ -61,6 +72,7 @@ component CellView inherits Rectangle {
|
|||
// cw + 4; lib.rs ROW_SPACING must match).
|
||||
component GridRow inherits Rectangle {
|
||||
in property <Item> row-data;
|
||||
callback cell-clicked(path: string);
|
||||
property <length> cw: (self.width - 8px) / 3;
|
||||
width: 100%;
|
||||
height: cw + 4px;
|
||||
|
|
@ -71,6 +83,149 @@ component GridRow inherits Rectangle {
|
|||
height: root.cw;
|
||||
x: c * (root.cw + 4px);
|
||||
visible: cell.path != "";
|
||||
cell-clicked => {
|
||||
root.cell-clicked(cell.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Full-screen viewer overlay (M4). Declared AFTER the grid in Main so it
|
||||
// sits on top; its TouchArea blocks grid input while open. The transform is
|
||||
// owned by Rust (src/viewer.rs): the element is sized to
|
||||
// (v-img-w·v-scale)×(v-img-h·v-scale) at (v-tx, v-ty).
|
||||
component Viewer inherits Rectangle {
|
||||
in property <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.
|
||||
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
|
||||
// negative while scrolled down). The reconcile tick polls these every
|
||||
// tick — including during momentum scrolling, where the `scrolled`
|
||||
|
|
@ -122,7 +305,38 @@ export component Main inherits Window {
|
|||
}
|
||||
for row in root.items: GridRow {
|
||||
row-data: row;
|
||||
cell-clicked(path) => {
|
||||
root.open-image(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Viewer overlay: declared last so it renders above the grid.
|
||||
Viewer {
|
||||
open: root.viewer-open;
|
||||
top-inset: root.safe-area-insets.top;
|
||||
image: root.viewer-image;
|
||||
scale: root.v-scale;
|
||||
tx: root.v-tx;
|
||||
ty: root.v-ty;
|
||||
img-w: root.v-img-w;
|
||||
img-h: root.v-img-h;
|
||||
status: root.v-status;
|
||||
pointer-event(finger, x, y, kind) => {
|
||||
root.viewer-pointer(finger, x, y, kind);
|
||||
}
|
||||
pinch-started => {
|
||||
root.viewer-pinch-started();
|
||||
}
|
||||
pinch-updated(scale, cx, cy) => {
|
||||
root.viewer-pinch-updated(scale, cx, cy);
|
||||
}
|
||||
pinch-ended => {
|
||||
root.viewer-pinch-ended();
|
||||
}
|
||||
closed => {
|
||||
root.close-viewer();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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,16 +282,37 @@ impl AndroidWindowAdapter {
|
|||
loop {
|
||||
let mut result = Ok(());
|
||||
let read_input = iter.next(|event| match event {
|
||||
InputEvent::KeyEvent(key_event) => match map_key_event(key_event) {
|
||||
Some(ev) => match self.window.dispatch_event_with_result(ev) {
|
||||
Ok(WindowEventDispatchResult::Accepted) => InputStatus::Handled,
|
||||
Ok(_) => InputStatus::Unhandled,
|
||||
Err(e) => {
|
||||
result = Err(e);
|
||||
InputStatus::Unhandled
|
||||
InputEvent::KeyEvent(key_event) => {
|
||||
// Patch (gallery, M4 2026-10-29): Android Back = close
|
||||
// REQUEST (native semantics), not a key event — the app's
|
||||
// on_close_requested decides (it closes the full-screen
|
||||
// viewer first). Slint 1.18 core has no Back-key close
|
||||
// handling, so without this the key does nothing.
|
||||
if key_event.key_code() == Keycode::Back
|
||||
&& key_event.action() == KeyAction::Down
|
||||
&& key_event.repeat_count() == 0
|
||||
{
|
||||
match self.window.dispatch_event_with_result(WindowEvent::CloseRequested)
|
||||
{
|
||||
Ok(_) => InputStatus::Handled,
|
||||
Err(e) => {
|
||||
result = Err(e);
|
||||
InputStatus::Unhandled
|
||||
}
|
||||
}
|
||||
},
|
||||
None => InputStatus::Unhandled,
|
||||
} else {
|
||||
match map_key_event(key_event) {
|
||||
Some(ev) => match self.window.dispatch_event_with_result(ev) {
|
||||
Ok(WindowEventDispatchResult::Accepted) => InputStatus::Handled,
|
||||
Ok(_) => InputStatus::Unhandled,
|
||||
Err(e) => {
|
||||
result = Err(e);
|
||||
InputStatus::Unhandled
|
||||
}
|
||||
},
|
||||
None => InputStatus::Unhandled,
|
||||
}
|
||||
}
|
||||
},
|
||||
InputEvent::MotionEvent(motion_event) => {
|
||||
let offset = self.offset.get();
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user