tessera_foundation/
scroll_physics.rs1use std::{collections::VecDeque, time::Duration};
7
8use tessera_ui::time::Instant;
9
10#[derive(Clone, Copy, Debug, Default, PartialEq)]
12pub struct ScrollVelocity {
13 pub x: f32,
15 pub y: f32,
17}
18
19impl ScrollVelocity {
20 pub const fn new(x: f32, y: f32) -> Self {
22 Self { x, y }
23 }
24
25 pub fn is_zero(self) -> bool {
27 self.x == 0.0 && self.y == 0.0
28 }
29}
30
31#[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 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 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 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 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#[derive(Clone, Copy, Debug, PartialEq)]
121pub struct ExponentialInertia {
122 pub position_x: f32,
124 pub position_y: f32,
126 pub velocity_x: f32,
128 pub velocity_y: f32,
130 decay_rate: f32,
131}
132
133impl ExponentialInertia {
134 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 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 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}