//! 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 { match phase { Phase::Down => { self.down_at = Some((x, y, now)); self.pan_last = Some((x, y)); None } Phase::Move => { if let Some((x0, y0, _)) = self.down_at { if dist2((x0, y0), (x, y)) > PAN_SLOP * PAN_SLOP { // Panning starts: adopt the current position so the // first pan step doesn't include the slop jump. self.down_at = None; self.pan_last = Some((x, y)); } return None; } if let Some((px, py)) = self.pan_last { self.pan_last = Some((x, y)); self.commit(pan_by(self.t, x - px, y - py, self.iw, self.ih, self.vw, self.vh)) } else { None } } Phase::Up => { let Some((x0, y0, t0)) = self.down_at.take() else { return None; }; self.pan_last = None; if dist2((x0, y0), (x, y)) <= PAN_SLOP * PAN_SLOP && now.duration_since(t0).as_millis() <= TAP_MAX_MS as u128 { let is_double = self.last_tap.is_some_and(|(lx, ly, lt)| { now.duration_since(lt).as_millis() <= DOUBLE_TAP_MS as u128 && dist2((lx, ly), (x, y)) <= DOUBLE_TAP_PX * DOUBLE_TAP_PX }); self.last_tap = None; if is_double { return self.commit(self.double_tap_at(x, y)); } self.last_tap = Some((x, y, now)); } None } Phase::Cancel => { self.down_at = None; self.pan_last = None; None } } } /// Double-tap: zoom to 2.5× fit at (x, y); reset to fit when zoomed. fn double_tap_at(&self, x: f32, y: f32) -> Transform { let fit_s = fit_scale(self.iw, self.ih, self.vw, self.vh); if self.t.s > fit_s * 1.1 { fit(self.iw, self.ih, self.vw, self.vh) } else { zoom_at(self.t, x, y, fit_s * 2.5 / self.t.s.max(f32::MIN), self.iw, self.ih, self.vw, self.vh) } } fn commit(&mut self, t: Transform) -> Option { if t != self.t { self.t = t; Some(InputEvent::Changed(t)) } else { None } } } /// A full image for the viewer: RGBA8, EXIF orientation applied, longest /// side ≤ `cap`. pub struct Full { pub rgba: Vec, pub width: u32, pub height: u32, /// Decode wall time (ms), for the load log. pub ms: u128, } /// Longest-side cap for the full decode: 2× the physical screen (stays /// sharp up to 2× zoom) bounded by the GPU's max 2D texture size /// (12 MP+ files at full size would exceed it on some devices — /// HANDOFF §10.3). pub fn decode_cap(screen_max_phys: u32, max_texture: Option) -> u32 { let mut cap = 2u32.saturating_mul(screen_max_phys); if let Some(m) = max_texture { cap = cap.min(m); } cap.max(512) } /// The smallest DCT shrink (1/2, 1/4, 1/8) whose result is ≤ `cap` on the /// longest side; `ONE` when the file already fits. pub fn pick_viewer_scale(max_dim: u32, cap: u32) -> ScalingFactor { if max_dim <= cap { return ScalingFactor::ONE; } for denom in [2u32, 4, 8] { if max_dim / denom <= cap { return ScalingFactor::new(1, denom as usize); } } ScalingFactor::new(1, 8) } /// Decode `path` for the viewer (runs on a background thread): full size up /// to the `cap` longest side, EXIF orientation applied, expanded to RGBA8. pub fn load(path: &str, cap: u32) -> Result { let t0 = Instant::now(); let p = std::path::Path::new(path); let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()); let (rgb, w, h) = match ext.as_deref() { Some("jpg") | Some("jpeg") => load_jpeg(p, cap)?, Some("png") => load_png(p, cap)?, _ => return Err(format!("unsupported type: {}", p.display())), }; Ok(Full { rgba: images::to_rgba8(&rgb), width: w, height: h, ms: t0.elapsed().as_millis(), }) } fn load_jpeg(path: &std::path::Path, cap: u32) -> Result<(Vec, u32, u32), String> { use turbojpeg::Decompressor; let data = std::fs::read(path).map_err(|e| format!("read {}: {e}", path.display()))?; let mut d = Decompressor::new().map_err(|e| e.to_string())?; let header = d.read_header(&data).map_err(|e| format!("jpeg header: {e}"))?; let (fw, fh) = (header.width as u32, header.height as u32); let orientation = images::exif_orientation(&data); // The post-orientation longest side equals the raw max (90/270 swap // w/h), so the shrink can be picked on the raw dims. let scale = pick_viewer_scale(fw.max(fh), cap); let (rgb, w, h) = images::decode_jpeg_scaled(&data, scale)?; Ok(images::apply_orientation(rgb, w, h, orientation)) } fn load_png(path: &std::path::Path, cap: u32) -> Result<(Vec, u32, u32), String> { // PNGs are rare (27 on the test device) — full decode + CPU resize is // fine; orientation is 1 (no EXIF in the PNGs we've seen). let img = image::ImageReader::open(path) .map_err(|e| format!("open {}: {e}", path.display()))? .with_guessed_format() .map_err(|e| e.to_string())? .decode() .map_err(|e| e.to_string())?; let rgb = img.to_rgb8(); let (w, h) = (rgb.width(), rgb.height()); if w.max(h) > cap { let (nw, nh) = if w >= h { (cap, (h as f64 * cap as f64 / w as f64).max(1.0).round() as u32) } else { ((w as f64 * cap as f64 / h as f64).max(1.0).round() as u32, cap) }; let buf = image::ImageBuffer::, Vec>::from_raw(w, h, rgb.into_raw()) .expect("buffer size matches dims"); let r = image::imageops::resize(&buf, nw, nh, image::imageops::FilterType::Triangle); let (rw, rh) = (r.width(), r.height()); Ok((r.into_raw(), rw, rh)) } else { Ok((rgb.into_raw(), w, h)) } } #[cfg(test)] mod tests { use super::*; use std::time::Duration; /// Phone-like viewport (logical px) + a 4:3 landscape 4000×3000 photo. const VW: f32 = 1080.0; const VH: f32 = 2340.0; const IW: f32 = 4000.0; const IH: f32 = 3000.0; #[test] fn fit_centers_and_preserves_aspect() { let t = fit(IW, IH, VW, VH); // Width-limited: fills VW exactly, vertically centered. assert!((t.s - VW / IW).abs() < 1e-4); assert!(t.tx.abs() < 1e-3); assert!((t.ty - (VH - IH * t.s) / 2.0).abs() < 1e-3); } #[test] fn clamp_zoom_bounds() { let fit_s = fit_scale(IW, IH, VW, VH); let low = clamp(Transform { s: fit_s / 2.0, tx: 0.0, ty: 0.0 }, IW, IH, VW, VH); assert!((low.s - fit_s).abs() < 1e-4); // 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()); } }