Skip to main content

tessera_foundation/
scroll_physics.rs

1//! Time-based scroll physics shared by scrollable components.
2//!
3//! The primitives in this module keep their state in floating point pixels so
4//! sub-pixel input is accumulated until it can be presented by the renderer.
5
6use std::{collections::VecDeque, time::Duration};
7
8use tessera_ui::time::Instant;
9
10/// A two-dimensional velocity in pixels per second.
11#[derive(Clone, Copy, Debug, Default, PartialEq)]
12pub struct ScrollVelocity {
13    /// Horizontal velocity in pixels per second.
14    pub x: f32,
15    /// Vertical velocity in pixels per second.
16    pub y: f32,
17}
18
19impl ScrollVelocity {
20    /// Creates a velocity from horizontal and vertical components.
21    pub const fn new(x: f32, y: f32) -> Self {
22        Self { x, y }
23    }
24
25    /// Returns true when both components are zero.
26    pub fn is_zero(self) -> bool {
27        self.x == 0.0 && self.y == 0.0
28    }
29}
30
31/// A bounded, time-weighted velocity estimator for pointer samples.
32#[derive(Clone, Debug, PartialEq)]
33pub struct ScrollVelocityTracker {
34    samples: VecDeque<(Instant, Duration, ScrollVelocity)>,
35    pending_delta: ScrollVelocity,
36    last_sample_time: Instant,
37    sample_window: Duration,
38    idle_cutoff: Duration,
39}
40
41impl ScrollVelocityTracker {
42    /// Creates a tracker with the supplied history and idle durations.
43    pub fn new(now: Instant, sample_window: Duration, idle_cutoff: Duration) -> Self {
44        Self {
45            samples: VecDeque::new(),
46            pending_delta: ScrollVelocity::default(),
47            last_sample_time: now,
48            sample_window,
49            idle_cutoff,
50        }
51    }
52
53    /// Adds a displacement sample, accumulating samples with equal timestamps.
54    pub fn push_delta(&mut self, now: Instant, dx: f32, dy: f32) {
55        if now < self.last_sample_time || !dx.is_finite() || !dy.is_finite() {
56            return;
57        }
58        self.pending_delta.x += dx;
59        self.pending_delta.y += dy;
60        let duration = now.duration_since(self.last_sample_time);
61        if duration.is_zero() {
62            return;
63        }
64        let dt = duration.as_secs_f32();
65        self.last_sample_time = now;
66        let velocity = ScrollVelocity::new(self.pending_delta.x / dt, self.pending_delta.y / dt);
67        self.pending_delta = ScrollVelocity::default();
68        if velocity.x.is_finite() && velocity.y.is_finite() {
69            self.samples.push_back((now, duration, velocity));
70            self.prune(now);
71        }
72    }
73
74    /// Returns the weighted velocity at `now`, if recent samples exist.
75    pub fn resolve(&mut self, now: Instant) -> Option<ScrollVelocity> {
76        self.prune(now);
77        let window = self.sample_window.as_secs_f32().max(f32::EPSILON);
78        let mut sum = ScrollVelocity::default();
79        let mut weight_sum = 0.0;
80        for &(timestamp, duration, velocity) in &self.samples {
81            let age = now
82                .duration_since(timestamp)
83                .as_secs_f32()
84                .clamp(0.0, window);
85            let covered = duration.as_secs_f32().min((window - age).max(0.0));
86            let weight = covered * (window - age - covered * 0.5);
87            sum.x += velocity.x * weight;
88            sum.y += velocity.y * weight;
89            weight_sum += weight;
90        }
91        if weight_sum <= f32::EPSILON {
92            return None;
93        }
94        let idle = now.duration_since(self.last_sample_time).as_secs_f32();
95        let cutoff = self.idle_cutoff.as_secs_f32().max(f32::EPSILON);
96        let damping = (1.0 - idle / cutoff).clamp(0.0, 1.0);
97        Some(ScrollVelocity::new(
98            sum.x / weight_sum * damping,
99            sum.y / weight_sum * damping,
100        ))
101    }
102
103    /// Returns whether no sample arrived within the idle cutoff.
104    pub fn is_idle(&self, now: Instant) -> bool {
105        now.duration_since(self.last_sample_time) >= self.idle_cutoff
106    }
107
108    fn prune(&mut self, now: Instant) {
109        while let Some(&(timestamp, _, _)) = self.samples.front() {
110            if now.duration_since(timestamp) > self.sample_window || self.samples.len() > 128 {
111                self.samples.pop_front();
112            } else {
113                break;
114            }
115        }
116    }
117}
118
119/// Exponentially decaying inertial motion with exact time integration.
120#[derive(Clone, Copy, Debug, PartialEq)]
121pub struct ExponentialInertia {
122    /// Current offset in pixels.
123    pub position_x: f32,
124    /// Current offset in pixels.
125    pub position_y: f32,
126    /// Current velocity in pixels per second.
127    pub velocity_x: f32,
128    /// Current velocity in pixels per second.
129    pub velocity_y: f32,
130    decay_rate: f32,
131}
132
133impl ExponentialInertia {
134    /// Creates inertial motion with a positive decay rate in inverse seconds.
135    pub fn new(
136        position_x: f32,
137        position_y: f32,
138        velocity: ScrollVelocity,
139        decay_rate: f32,
140    ) -> Self {
141        Self {
142            position_x,
143            position_y,
144            velocity_x: velocity.x,
145            velocity_y: velocity.y,
146            decay_rate: decay_rate.max(0.0),
147        }
148    }
149
150    /// Advances motion by `dt`, returning the integrated displacement.
151    pub fn advance(&mut self, dt: Duration) -> ScrollVelocity {
152        let seconds = dt.as_secs_f32().max(0.0);
153        if seconds == 0.0 {
154            return ScrollVelocity::default();
155        }
156        let displacement_factor = if self.decay_rate > f32::EPSILON {
157            (-self.decay_rate * seconds).exp_m1().abs() / self.decay_rate
158        } else {
159            seconds
160        };
161        let dx = self.velocity_x * displacement_factor;
162        let dy = self.velocity_y * displacement_factor;
163        self.position_x += dx;
164        self.position_y += dy;
165        let decay = (-self.decay_rate * seconds).exp();
166        self.velocity_x *= decay;
167        self.velocity_y *= decay;
168        ScrollVelocity::new(dx, dy)
169    }
170
171    /// Returns the current velocity.
172    pub fn velocity(&self) -> ScrollVelocity {
173        ScrollVelocity::new(self.velocity_x, self.velocity_y)
174    }
175}
176
177#[cfg(test)]
178mod velocity_tests {
179    use super::*;
180    #[test]
181    fn equal_timestamp_samples_accumulate_and_idle_release_stops() {
182        let now = Instant::now();
183        let mut tracker =
184            ScrollVelocityTracker::new(now, Duration::from_millis(90), Duration::from_millis(65));
185        tracker.push_delta(now, 2.0, 0.0);
186        tracker.push_delta(now + Duration::from_millis(10), 8.0, 0.0);
187        assert!(
188            (tracker
189                .resolve(now + Duration::from_millis(10))
190                .expect("recent samples should resolve")
191                .x
192                - 1000.0)
193                .abs()
194                < 0.01
195        );
196        assert!(
197            tracker
198                .resolve(now + Duration::from_millis(80))
199                .expect("idle samples should resolve to zero")
200                .is_zero()
201        );
202    }
203    #[test]
204    fn constant_velocity_is_independent_of_sample_frequency() {
205        let now = Instant::now();
206        for interval in [5, 10, 20] {
207            let mut tracker = ScrollVelocityTracker::new(
208                now,
209                Duration::from_millis(90),
210                Duration::from_millis(65),
211            );
212            for elapsed in (interval..=80).step_by(interval as usize) {
213                tracker.push_delta(now + Duration::from_millis(elapsed), interval as f32, 0.0);
214            }
215            assert!(
216                (tracker
217                    .resolve(now + Duration::from_millis(80))
218                    .expect("recent samples should resolve")
219                    .x
220                    - 1000.0)
221                    .abs()
222                    < 0.01
223            );
224        }
225    }
226}
227#[cfg(test)]
228mod tests {
229    use super::{ExponentialInertia, ScrollVelocity};
230    use std::time::Duration;
231
232    #[test]
233    fn exponential_decay_integral_is_frame_rate_independent() {
234        let velocity = ScrollVelocity::new(600.0, 0.0);
235        let mut sixty = ExponentialInertia::new(0.0, 0.0, velocity, 5.0);
236        let mut one_twenty = ExponentialInertia::new(0.0, 0.0, velocity, 5.0);
237        for _ in 0..60 {
238            sixty.advance(Duration::from_secs_f32(1.0 / 60.0));
239        }
240        for _ in 0..120 {
241            one_twenty.advance(Duration::from_secs_f32(1.0 / 120.0));
242        }
243        assert!((sixty.position_x - one_twenty.position_x).abs() < 0.01);
244        assert!((sixty.velocity_x - one_twenty.velocity_x).abs() < 0.01);
245    }
246}