diff --git a/src/lib.rs b/src/lib.rs index 4edc8b0..e202ec7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -160,7 +160,7 @@ pub mod prio { use std::collections::{HashMap, HashSet}; use std::ops::Range; use std::path::Path; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant}; @@ -213,6 +213,12 @@ const MAX_EVICT_CELLS_PER_TICK: usize = 4; /// refresh (8 ms on 120 Hz, ~16 ms on 60 Hz). Per-tick work is tiny and /// idempotent, so jitter is harmless. const DRAIN_INTERVAL_MS: u64 = 8; +/// A cell press younger than this window after the last grid motion is a +/// fling-stopper, not a tap. The Flickable delays forwarding the press to +/// the cell ~100 ms (FORWARD_DELAY in slint core), and the fling stops at +/// the touch-down itself, so the window must cover delay + tick latency. +const TAP_VS_FLING_MS: u64 = 150; + /// A viewport cell waiting longer than this for its in-flight job is a /// stall; log a diagnostic (rate-limited). const STALL_ALARM_MS: u64 = 500; @@ -291,6 +297,17 @@ struct AppState { /// set: a full decode can fail where the 400 px thumb succeeds, e.g. /// the GPU texture cap). UI thread only. full_failed: Mutex>, + /// Grid motion: (last list-y seen, when it last CHANGED by more than + /// half a px). The tick polls list-y every ~8 ms, so a fling updates + /// the time every frame until it settles (even below one row of + /// scroll — the viewport RANGE change alone would miss slow tails). + /// A cell press younger than 150 ms is a touch DURING A FLING: the + /// Flickable's own press handling stops the fling, so the release + /// must not open the viewer (`tap_suppressed`). UI thread only. + grid_motion: Mutex<(f32, Instant)>, + /// Set by `on_cell_pressing` when the grid was still moving; consumed + /// (swap) by `on_open_image`, which then does not open the viewer. + tap_suppressed: AtomicBool, /// Decoded sizes of painted cells: entry index → (w, h). Mirrors /// `painted` (insert on paint, drop on eviction) so a tapped cell's /// viewer can be fitted to its thumbnail — the Image itself is read @@ -406,6 +423,8 @@ fn state() -> &'static AppState { full_cache: Mutex::new(FullCache { lru: Vec::new(), pixels: 0 }), full_inflight: Mutex::new(HashMap::new()), full_failed: Mutex::new(HashSet::new()), + grid_motion: Mutex::new((0.0, Instant::now())), + tap_suppressed: AtomicBool::new(false), thumb_dims: Mutex::new(HashMap::new()), } }) @@ -1431,6 +1450,17 @@ fn start_drain_timer(weak: &slint::Weak
, wid: u32) { ); } *app.viewport.lock().unwrap() = vp.clone(); + // Grid motion (drag or fling): remember the last change + // of the RAW scroll position — a cell press within the + // next 150 ms is a fling-stopper, not a tap + // (on_cell_pressing). Half a px beats float noise. + { + let y = w.get_list_y(); + let mut gm = app.grid_motion.lock().unwrap(); + if (y - gm.0).abs() > 0.5 { + *gm = (y, Instant::now()); + } + } (vp, stable) } None => (app.viewport.lock().unwrap().clone(), true), @@ -1684,12 +1714,37 @@ pub fn start_scan(window: &Main, wid: u32) { window.on_scroll_changed(move |y: f32, h: f32, w: f32| on_scroll(app, y, h, w)); + // A finger went down on a cell. If the grid was still moving (a fling + // in progress), this touch's job is to STOP the fling — and that + // already happens in the ListView's Flickable, whose press handler + // resets the fling animation (slint core items/flickable.rs: + // `remove_binding` = set the content position to its current value + // with no animation). What must NOT happen is the release opening + // the viewer: mark it so `on_open_image` swallows the tap. + window.on_cell_pressing(move || { + let Some(app) = STATE.get() else { return }; + let moving = app + .grid_motion + .lock() + .unwrap() + .1 + .elapsed() + < Duration::from_millis(TAP_VS_FLING_MS); + app.tap_suppressed.store(moving, Ordering::Relaxed); + }); + // 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 }; + if app.tap_suppressed.swap(false, Ordering::Relaxed) { + // Tap during a fling: the touch stopped the fling; do not + // open the image. + log::debug!("gallery: tap during fling — suppressed"); + return; + } open_viewer(&w, app, path.to_string()); } }); diff --git a/src/main.slint b/src/main.slint index 4850085..442affb 100644 --- a/src/main.slint +++ b/src/main.slint @@ -30,15 +30,26 @@ export struct Item { component CellView inherits Rectangle { in property cell; callback cell-clicked(); + callback cell-pressed(); 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. + // scrolling is unaffected. The `pressed` hook lets lib.rs detect a + // touch DURING A FLING: the Flickable's own press handling already + // stops the fling (it resets the fling animation on press — slint core + // items/flickable.rs); the tap on release must then NOT open the + // viewer (lib.rs suppresses it when the grid was still moving). TouchArea { enabled: cell.path != ""; + // 1.18's TouchArea has no `pressed` callback — use pointer-event. + pointer-event(e) => { + if (e.kind == PointerEventKind.down) { + root.cell-pressed(); + } + } clicked => { root.cell-clicked(); } @@ -73,6 +84,7 @@ component CellView inherits Rectangle { component GridRow inherits Rectangle { in property row-data; callback cell-clicked(path: string); + callback cell-pressed(); property cw: (self.width - 8px) / 3; width: 100%; height: cw + 4px; @@ -86,6 +98,9 @@ component GridRow inherits Rectangle { cell-clicked => { root.cell-clicked(cell.path); } + cell-pressed => { + root.cell-pressed(); + } } } @@ -260,6 +275,9 @@ export component Main inherits Window { in property v-status: ""; /// A grid cell was tapped: path of the entry to open. callback open-image(path: string); + // A finger went down on a cell (before the tap is known): lets lib.rs + // mark the tap as "fling-stopper" when the grid is still moving. + callback cell-pressing(); /// 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); @@ -308,6 +326,9 @@ export component Main inherits Window { cell-clicked(path) => { root.open-image(path); } + cell-pressed => { + root.cell-pressing(); + } } } } diff --git a/src/viewer.rs b/src/viewer.rs index 9ed714b..987272e 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -14,9 +14,12 @@ //! 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). +//! 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). @@ -32,8 +35,6 @@ 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; @@ -70,11 +71,12 @@ pub fn fit(iw: f32, ih: f32, vw: f32, vh: f32) -> Transform { } } -/// Clamp zoom to [fit, fit·MAX_ZOOM] and translation to "image covers the -/// viewport" (centered while smaller than the viewport). +/// 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_ZOOM); + 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) }; @@ -83,6 +85,12 @@ pub fn clamp(t: Transform, iw: f32, ih: f32, vw: f32, vh: f32) -> Transform { } /// 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, @@ -93,7 +101,8 @@ pub fn zoom_at( vw: f32, vh: f32, ) -> Transform { - let s = t.s * factor.max(f32::MIN); + 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; @@ -166,7 +175,23 @@ impl Input { /// 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) } @@ -404,8 +429,53 @@ mod tests { 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 - fit_s * MAX_ZOOM).abs() < 1e-2); + 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] @@ -550,8 +620,10 @@ mod tests { 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 - base.s * 4.0).abs() < 1e-2); + assert!((t2.s - 1.0).abs() < 1e-3); assert!((t2.tx + px * t2.s - 540.0).abs() < 1e-2); }