Skip to main content

frust_core/
input.rs

1//! Pure input/gesture helpers: slop/wheel constants, a trailing-window
2//! [`VelocityTracker`], and the fling-decay math (numbers cross-checked
3//! against masonry's implementation).
4//!
5//! Everything here is deterministic and dependency-free so it is exercised
6//! entirely by unit tests — the interactive widgets layer their
7//! event/paint behaviour on top of these primitives. Times are **logical
8//! milliseconds** and positions/velocities are **logical pixels** (px, px/s) to
9//! match the density-independent coordinate space events arrive in.
10
11use std::collections::VecDeque;
12
13/// Touch drag threshold: how far a contact may move before a press becomes a
14/// drag/scroll (Flutter's tap-vs-drag slop, 18 logical px).
15pub const TOUCH_SLOP: f64 = 18.0;
16
17/// Mouse drag threshold (kept smaller than [`TOUCH_SLOP`] because a mouse is far
18/// more precise). Reserved for when events carry an input-kind flag; v1 widgets
19/// use [`TOUCH_SLOP`] uniformly.
20pub const MOUSE_SLOP: f64 = 3.0;
21
22/// Logical pixels per wheel *line* — the constant a `LineDelta` wheel notch is
23/// multiplied by to get a pixel scroll amount (a sane cross-toolkit value).
24pub const WHEEL_LINE_PX: f64 = 40.0;
25
26/// Per-millisecond exponential fling decay factor (iOS "normal" deceleration):
27/// `v(t) = v0 · FLING_DECAY.powf(t_ms)`.
28pub const FLING_DECAY: f64 = 0.998;
29
30/// Fling stop threshold, in px/s (~0.5 px/frame at 60 Hz): below this speed a
31/// fling is finished and the animation stops requesting frames.
32pub const FLING_STOP: f64 = 30.0;
33
34/// Trailing window, in milliseconds, over which [`VelocityTracker`] estimates
35/// velocity — older samples are pruned.
36pub const VELOCITY_WINDOW_MS: f64 = 100.0;
37
38/// Velocity of `v0` after `elapsed_ms` of exponential fling decay.
39///
40/// `v(t) = v0 · FLING_DECAY^t` with `t` in milliseconds. Halves in ≈347 ms
41/// (`ln 0.5 / ln 0.998`).
42pub fn fling_decay(v0: f64, elapsed_ms: f64) -> f64 {
43    v0 * FLING_DECAY.powf(elapsed_ms)
44}
45
46/// Closed-form displacement (in px) travelled by a fling of initial velocity
47/// `v0` (px/s) over `elapsed_ms` milliseconds.
48///
49/// This is the exact integral of [`fling_decay`] over `[0, elapsed_ms]`:
50/// `x(T) = (v0/1000) · (FLING_DECAY^T − 1) / ln(FLING_DECAY)`. The `/1000`
51/// converts the px/s velocity into px given the millisecond time base; the
52/// result matches a numeric integration of [`fling_decay`] to well within 1%.
53pub fn fling_displacement(v0: f64, elapsed_ms: f64) -> f64 {
54    (v0 / 1000.0) * (FLING_DECAY.powf(elapsed_ms) - 1.0) / FLING_DECAY.ln()
55}
56
57/// A ring of recent `(time_ms, position)` samples used to estimate the release
58/// velocity of a drag for flinging.
59///
60/// Samples older than [`VELOCITY_WINDOW_MS`] (relative to the newest) are
61/// pruned on [`record`](Self::record); [`velocity`](Self::velocity) is a simple
62/// delta-over-window estimate (px/s) — enough for v1 fling, without Flutter's
63/// least-squares regression.
64#[derive(Clone, Debug, Default)]
65pub struct VelocityTracker {
66    samples: VecDeque<(f64, f64)>,
67}
68
69impl VelocityTracker {
70    /// An empty tracker.
71    pub fn new() -> Self {
72        Self {
73            samples: VecDeque::new(),
74        }
75    }
76
77    /// Drop all samples (call on the `Down` that begins a fresh gesture).
78    pub fn clear(&mut self) {
79        self.samples.clear();
80    }
81
82    /// Record a `(time_ms, position)` sample, pruning any now older than the
83    /// trailing window.
84    pub fn record(&mut self, time_ms: f64, position: f64) {
85        self.samples.push_back((time_ms, position));
86        let cutoff = time_ms - VELOCITY_WINDOW_MS;
87        while let Some(&(t, _)) = self.samples.front() {
88            if t < cutoff {
89                self.samples.pop_front();
90            } else {
91                break;
92            }
93        }
94    }
95
96    /// Estimate the current velocity in px/s from the delta across the retained
97    /// window. Returns `0.0` with fewer than two samples or a zero time span.
98    pub fn velocity(&self) -> f64 {
99        if self.samples.len() < 2 {
100            return 0.0;
101        }
102        let (t0, p0) = *self.samples.front().unwrap();
103        let (t1, p1) = *self.samples.back().unwrap();
104        let dt = t1 - t0;
105        if dt <= 0.0 {
106            return 0.0;
107        }
108        (p1 - p0) / dt * 1000.0
109    }
110
111    /// The number of retained samples (for tests/introspection).
112    pub fn len(&self) -> usize {
113        self.samples.len()
114    }
115
116    /// Whether the tracker holds no samples.
117    pub fn is_empty(&self) -> bool {
118        self.samples.is_empty()
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn fling_decay_halves_in_about_347ms() {
128        let v = fling_decay(1000.0, 347.0);
129        assert!((v - 500.0).abs() < 1.0, "expected ~500, got {v}");
130        // Exactly at t=0 the velocity is unchanged.
131        assert_eq!(fling_decay(1000.0, 0.0), 1000.0);
132    }
133
134    #[test]
135    fn fling_displacement_matches_numeric_integration() {
136        let v0 = 800.0;
137        let total_ms = 500.0;
138        // Numeric integration of fling_decay over [0, total_ms] in 1 ms steps.
139        let mut numeric = 0.0;
140        let step = 1.0;
141        let mut t = 0.0;
142        while t < total_ms {
143            numeric += fling_decay(v0, t) / 1000.0 * step;
144            t += step;
145        }
146        let closed = fling_displacement(v0, total_ms);
147        let rel_err = (closed - numeric).abs() / numeric.abs();
148        assert!(rel_err < 0.01, "closed {closed} vs numeric {numeric}");
149        assert!(closed > 0.0);
150    }
151
152    #[test]
153    fn fling_displacement_sign_follows_velocity() {
154        assert!(fling_displacement(500.0, 100.0) > 0.0);
155        assert!(fling_displacement(-500.0, 100.0) < 0.0);
156        assert_eq!(fling_displacement(0.0, 100.0), 0.0);
157    }
158
159    #[test]
160    fn velocity_is_delta_over_window() {
161        let mut vt = VelocityTracker::new();
162        vt.record(0.0, 100.0);
163        vt.record(50.0, 50.0); // moved -50 px in 50 ms → -1000 px/s
164        assert!((vt.velocity() - (-1000.0)).abs() < 1e-9);
165    }
166
167    #[test]
168    fn velocity_empty_or_single_sample_is_zero() {
169        let mut vt = VelocityTracker::new();
170        assert_eq!(vt.velocity(), 0.0);
171        vt.record(0.0, 10.0);
172        assert_eq!(vt.velocity(), 0.0);
173        assert!(!vt.is_empty());
174    }
175
176    #[test]
177    fn old_samples_are_pruned_from_the_window() {
178        let mut vt = VelocityTracker::new();
179        vt.record(0.0, 0.0);
180        vt.record(50.0, 10.0);
181        // 200 ms is > VELOCITY_WINDOW_MS past the first two samples.
182        vt.record(200.0, 40.0);
183        // Only samples within [100, 200] survive → just the last one, plus any
184        // at/after the cutoff. The t=0 and t=50 samples are pruned.
185        assert_eq!(vt.len(), 1);
186        assert_eq!(vt.velocity(), 0.0); // single surviving sample
187    }
188
189    #[test]
190    fn clear_resets_the_tracker() {
191        let mut vt = VelocityTracker::new();
192        vt.record(0.0, 1.0);
193        vt.record(10.0, 2.0);
194        vt.clear();
195        assert!(vt.is_empty());
196        assert_eq!(vt.velocity(), 0.0);
197    }
198}