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).
798 lines
31 KiB
Rust
798 lines
31 KiB
Rust
//! 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());
|
||
}
|
||
}
|