gallery/src/viewer.rs
Greg Pomerantz e10a71b00a Viewer input fixes: tap during fling stops the fling (no open); zoom sticks at native resolution
1) Tap/touch during a fling: the ListView's Flickable already stops the
   fling when a press lands (its press handler resets the fling
   animation — slint core items/flickable.rs, remove_binding =
   set_animated_value(current, none)); the bug was that the press is
   then forwarded to the cell (~100 ms later, FORWARD_DELAY) and the
   release opened the viewer. The cell TouchArea now reports its
   pointer-event down (1.18 has no pressed callback) via
   cell-pressed → on_cell_pressing, which marks the tap as a
   fling-stopper when the grid moved within the last 150 ms (the tick
   tracks raw list-y changes in grid_motion); on_open_image then
   swallows the tap.

2) Zooming past max resolution moved the image: zoom_at applied the
   raw factor to tx/ty BEFORE clamping s — at the cap s stayed put
   but the translation still shifted about the (moving) pinch
   center, so the image followed the fingers like a two-finger drag.
   zoom_at now clamps s first (factor derived from the clamped s:
   at the cap factor = 1, a true no-op), and Input::pinch sticks at
   both caps (the handler reports a CUMULATIVE scale from the pinch
   start, so past the cap every later update would otherwise drift).
   The zoom cap is now NATIVE resolution: max(fit, 1.0) — 1 device
   pixel per image pixel (small images keep fit); zooming further
   only pixelates, so it is not allowed (was 20× fit).

Tests: zoom_at_past_cap_is_noop, pinch_sticks_at_caps (new),
clamp_zoom_bounds (native cap + small-image fit), updated
pinch_handler_sequence (4× fit now clamps at native). 36/36.
Emulator: fling+tap → 0 viewer opens, grid settled; normal tap
still opens (cached path).
2026-10-29 12:30:42 -04:00

798 lines
31 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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, max(fit, 1.0)] — the upper bound is NATIVE resolution
//! (1 device pixel per image pixel; small images keep fit): zooming past
//! it only pixelates, so the pinch must STICK there (do nothing, no drift
//! — see `zoom_at`/`Input::pinch`); `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;
/// 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, max(fit, 1.0)] (native resolution — see module
/// docs) 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(1.0));
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).
///
/// `s` is clamped BEFORE computing the effective factor: zooming past a
/// cap must be a no-op (s unchanged → factor 1 → tx/ty unchanged). Applying
/// the raw factor first and clamping `s` afterwards would shift tx/ty about
/// the (moving) gesture center even though `s` didn't change — the image
/// follows the fingers like a two-finger drag.
pub fn zoom_at(
t: Transform,
cx: f32,
cy: f32,
factor: f32,
iw: f32,
ih: f32,
vw: f32,
vh: f32,
) -> Transform {
let fit_s = fit_scale(iw, ih, vw, vh);
let s = (t.s * factor.max(f32::MIN)).clamp(fit_s, fit_s.max(1.0));
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.
///
/// STICKS at both zoom caps: the handler reports a CUMULATIVE scale
/// from the pinch start, so once the transform has hit a cap, later
/// updates keep requesting past it while the gesture center moves.
/// Applying those (even with the clamped s — see `zoom_at`) would
/// drift the image like a two-finger drag; at the cap the correct
/// behavior is: nothing.
pub fn pinch(&self, base: Transform, scale: f32, cx: f32, cy: f32) -> Transform {
let fit_s = fit_scale(self.iw, self.ih, self.vw, self.vh);
let max_s = fit_s.max(1.0);
let target = base.s * scale.max(f32::MIN);
let eps = max_s * 1e-3;
if self.t.s >= max_s - eps && target >= max_s
|| self.t.s <= fit_s + eps && target <= fit_s
{
return self.t;
}
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;
}
/// The displayed content was swapped for a HIGHER-RESOLUTION version of
/// the same image (thumbnail → full res on load; uniform scale
/// `k = new_iw / iw`, aspect unchanged). Rescale the transform so the
/// viewer mapping is unchanged — viewer_p = tx + c·s must hold for the
/// new content c·k, so s' = s/k and tx/ty (viewer px) stay put. The
/// swap is therefore flicker-free: identical bounds, one content change.
pub fn rebase_content(&mut self, new_iw: f32, new_ih: f32) {
if self.iw <= 0.0 || new_iw <= 0.0 {
return;
}
let k = new_iw / self.iw;
let t = self.t;
self.t =
clamp(Transform { s: t.s / k, tx: t.tx, ty: t.ty }, new_iw, new_ih, self.vw, self.vh);
self.iw = new_iw;
self.ih = new_ih;
}
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);
// Upper bound = native resolution (1.0); fit (0.27) is below it.
let high = clamp(Transform { s: fit_s * 1000.0, tx: 0.0, ty: 0.0 }, IW, IH, VW, VH);
assert!((high.s - 1.0).abs() < 1e-3);
// A SMALL image whose fit is already above native: fit wins (the
// image must be able to fill the screen).
let small = clamp(Transform { s: 10.0, tx: 0.0, ty: 0.0 }, 500.0, 400.0, VW, VH);
assert!((small.s - (VW / 500.0).min(VH / 400.0)).abs() < 1e-3);
}
#[test]
fn zoom_at_past_cap_is_noop() {
// At native resolution (s = 1.0, the cap for this photo), zooming
// FURTHER must leave the transform untouched — including tx/ty, no
// matter where the (moving) gesture center is.
let t = Transform { s: 1.0, tx: -500.0, ty: -300.0 };
let t = clamp(t, IW, IH, VW, VH);
let t2 = zoom_at(t, 100.0, 200.0, 1.5, IW, IH, VW, VH);
assert_eq!(t2, t);
// Zooming out from the cap works as usual.
let t3 = zoom_at(t, 540.0, 1170.0, 0.5, IW, IH, VW, VH);
assert!((t3.s - 0.5).abs() < 1e-4);
}
#[test]
fn pinch_sticks_at_caps() {
let mut i = Input::new(IW, IH, VW, VH);
let fit_s = fit_scale(IW, IH, VW, VH);
// Zoom to the native cap (a pinch from fit, base = fit).
let fit_t = i.transform();
let at_cap = i.pinch(fit_t, 1.0 / fit_s, 300.0, 400.0);
i.set_transform(at_cap);
assert!((i.transform().s - 1.0).abs() < 1e-3);
// Further pinch (cumulative scale keeps growing, center moves):
// NOTHING happens — no zoom, no drift.
for (scale, cx, cy) in [(2.0 / fit_s, 300.0, 400.0), (3.0 / fit_s, 900.0, 1900.0)] {
let t = i.pinch(fit_t, scale, cx, cy);
assert_eq!(t, i.transform());
}
// Zooming out again works.
let t = i.pinch(i.transform(), 0.5, 540.0, 1170.0);
assert!(t.s < i.transform().s);
// Same at the FIT cap (zoomed all the way out): pinching smaller
// is a no-op.
let mut i2 = Input::new(IW, IH, VW, VH);
let t2 = i2.transform();
assert_eq!(i2.pinch(t2, 0.5, 100.0, 100.0), t2);
assert_eq!(i2.pinch(t2, 0.01, 100.0, 100.0), t2);
}
#[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.
// 4× fit (1.08) exceeds the NATIVE-resolution cap (1.0): clamped,
// and the fixation about the center still holds.
let t2 = i.pinch(base, 4.0, 540.0, 1170.0);
assert!((t2.s - 1.0).abs() < 1e-3);
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 rebase_content_preserves_viewer_mapping() {
// Thumb (800×600) at fit, zoomed 2× about (300, 200) with a pan.
let (tw, th) = (800.0, 600.0);
let mut i = Input::new(tw, th, VW, VH);
let t0 = i.transform();
i.set_transform(zoom_at(t0, 300.0, 200.0, 2.0, tw, th, VW, VH));
let t_zoomed = i.transform();
let px = (300.0 - t_zoomed.tx) / t_zoomed.s;
let py = (200.0 - t_zoomed.ty) / t_zoomed.s;
// Full res: 4× the thumb (3200×2400), same aspect.
i.rebase_content(3200.0, 2400.0);
let t1 = i.transform();
assert!((t1.s - t_zoomed.s / 4.0).abs() < 1e-3);
assert!((t1.tx - t_zoomed.tx).abs() < 1e-3);
assert!((t1.ty - t_zoomed.ty).abs() < 1e-3);
// The same content point sits under the same viewer point.
assert!((t1.tx + px * t1.s * 4.0 - 300.0).abs() < 1e-2);
assert!((t1.ty + py * t1.s * 4.0 - 200.0).abs() < 1e-2);
// At fit, the rebase lands exactly on the full-res fit.
let mut j = Input::new(tw, th, VW, VH);
j.rebase_content(3200.0, 2400.0);
let fit_full = fit(3200.0, 2400.0, VW, VH);
assert!((j.transform().s - fit_full.s).abs() < 1e-4);
}
#[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());
}
}