Skip to main content

frust_core/
input.rs

1//! Pure, deterministic gesture helpers: slop/wheel constants, a trailing-window
2//! [`VelocityTracker`] and fling-decay math (cross-checked against masonry's).
3//! Times are logical ms; positions and velocities are logical px and px/s.
4
5use std::collections::VecDeque;
6
7/// Press-to-drag threshold for touch (Flutter's tap-vs-drag slop, 18 px).
8pub const TOUCH_SLOP: f64 = 18.0;
9
10/// Mouse drag threshold; unused until events carry an input kind, so widgets
11/// use [`TOUCH_SLOP`] for every pointer.
12pub const MOUSE_SLOP: f64 = 3.0;
13
14/// Logical px per wheel `LineDelta` line (a common cross-toolkit value).
15pub const WHEEL_LINE_PX: f64 = 40.0;
16
17/// Per-ms fling decay (iOS "normal" deceleration): `v(t) = v0 · FLING_DECAY^t`.
18pub const FLING_DECAY: f64 = 0.998;
19
20/// Speed in px/s (~0.5 px/frame at 60 Hz) below which a fling stops.
21pub const FLING_STOP: f64 = 30.0;
22
23/// The trailing window [`VelocityTracker`] keeps samples for.
24pub const VELOCITY_WINDOW_MS: f64 = 100.0;
25
26/// `v0` after `elapsed_ms` of decay; halves in ≈347 ms.
27pub fn fling_decay(v0: f64, elapsed_ms: f64) -> f64 {
28    v0 * FLING_DECAY.powf(elapsed_ms)
29}
30
31/// Distance in px a fling of `v0` px/s covers in `elapsed_ms`: the exact
32/// integral of [`fling_decay`], `(v0/1000) · (FLING_DECAY^T − 1) / ln(FLING_DECAY)`.
33pub fn fling_displacement(v0: f64, elapsed_ms: f64) -> f64 {
34    (v0 / 1000.0) * (FLING_DECAY.powf(elapsed_ms) - 1.0) / FLING_DECAY.ln()
35}
36
37/// Recent `(time_ms, position)` samples for a drag's release velocity: a
38/// delta-over-window estimate, not Flutter's least-squares fit.
39#[derive(Clone, Debug, Default)]
40pub struct VelocityTracker {
41    samples: VecDeque<(f64, f64)>,
42}
43
44impl VelocityTracker {
45    pub fn new() -> Self {
46        Self {
47            samples: VecDeque::new(),
48        }
49    }
50
51    /// Drop all samples; call on the `Down` that starts a gesture.
52    pub fn clear(&mut self) {
53        self.samples.clear();
54    }
55
56    /// Record a sample, pruning those older than the window.
57    pub fn record(&mut self, time_ms: f64, position: f64) {
58        self.samples.push_back((time_ms, position));
59        let cutoff = time_ms - VELOCITY_WINDOW_MS;
60        while let Some(&(t, _)) = self.samples.front() {
61            if t < cutoff {
62                self.samples.pop_front();
63            } else {
64                break;
65            }
66        }
67    }
68
69    /// Velocity in px/s across the window; `0.0` with under two samples or no span.
70    pub fn velocity(&self) -> f64 {
71        if self.samples.len() < 2 {
72            return 0.0;
73        }
74        let (t0, p0) = *self.samples.front().unwrap();
75        let (t1, p1) = *self.samples.back().unwrap();
76        let dt = t1 - t0;
77        if dt <= 0.0 {
78            return 0.0;
79        }
80        (p1 - p0) / dt * 1000.0
81    }
82
83    pub fn len(&self) -> usize {
84        self.samples.len()
85    }
86
87    pub fn is_empty(&self) -> bool {
88        self.samples.is_empty()
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn fling_decay_halves_in_about_347ms() {
98        let v = fling_decay(1000.0, 347.0);
99        assert!((v - 500.0).abs() < 1.0, "expected ~500, got {v}");
100        // Exactly at t=0 the velocity is unchanged.
101        assert_eq!(fling_decay(1000.0, 0.0), 1000.0);
102    }
103
104    #[test]
105    fn fling_displacement_matches_numeric_integration() {
106        let v0 = 800.0;
107        let total_ms = 500.0;
108        // Numeric integration of fling_decay over [0, total_ms] in 1 ms steps.
109        let mut numeric = 0.0;
110        let step = 1.0;
111        let mut t = 0.0;
112        while t < total_ms {
113            numeric += fling_decay(v0, t) / 1000.0 * step;
114            t += step;
115        }
116        let closed = fling_displacement(v0, total_ms);
117        let rel_err = (closed - numeric).abs() / numeric.abs();
118        assert!(rel_err < 0.01, "closed {closed} vs numeric {numeric}");
119        assert!(closed > 0.0);
120    }
121
122    #[test]
123    fn fling_displacement_sign_follows_velocity() {
124        assert!(fling_displacement(500.0, 100.0) > 0.0);
125        assert!(fling_displacement(-500.0, 100.0) < 0.0);
126        assert_eq!(fling_displacement(0.0, 100.0), 0.0);
127    }
128
129    #[test]
130    fn velocity_is_delta_over_window() {
131        let mut vt = VelocityTracker::new();
132        vt.record(0.0, 100.0);
133        vt.record(50.0, 50.0); // moved -50 px in 50 ms → -1000 px/s
134        assert!((vt.velocity() - (-1000.0)).abs() < 1e-9);
135    }
136
137    #[test]
138    fn velocity_empty_or_single_sample_is_zero() {
139        let mut vt = VelocityTracker::new();
140        assert_eq!(vt.velocity(), 0.0);
141        vt.record(0.0, 10.0);
142        assert_eq!(vt.velocity(), 0.0);
143        assert!(!vt.is_empty());
144    }
145
146    #[test]
147    fn old_samples_are_pruned_from_the_window() {
148        let mut vt = VelocityTracker::new();
149        vt.record(0.0, 0.0);
150        vt.record(50.0, 10.0);
151        // 200 ms is > VELOCITY_WINDOW_MS past the first two samples.
152        vt.record(200.0, 40.0);
153        // Only samples within [100, 200] survive → just the last one, plus any
154        // at/after the cutoff. The t=0 and t=50 samples are pruned.
155        assert_eq!(vt.len(), 1);
156        assert_eq!(vt.velocity(), 0.0); // single surviving sample
157    }
158
159    #[test]
160    fn clear_resets_the_tracker() {
161        let mut vt = VelocityTracker::new();
162        vt.record(0.0, 1.0);
163        vt.record(10.0, 2.0);
164        vt.clear();
165        assert!(vt.is_empty());
166        assert_eq!(vt.velocity(), 0.0);
167    }
168}