retroglyph_widgets/state/
scroll.rs1#[derive(Clone, Copy, Debug, PartialEq)]
3pub struct ScrollPhysics {
4 pub friction: f32,
6 pub stiffness: f32,
8 pub damping: f32,
10 pub rubber_band_limit: f32,
12}
13
14impl ScrollPhysics {
15 pub const DEFAULT: Self = Self {
17 friction: 4.5,
18 stiffness: 180.0,
19 damping: 24.0,
20 rubber_band_limit: 4.0,
21 };
22}
23
24impl Default for ScrollPhysics {
25 fn default() -> Self {
26 Self::DEFAULT
27 }
28}
29
30#[derive(Clone, Debug, PartialEq)]
37pub struct ScrollState {
38 offset: f32,
39 velocity: f32,
40 dragging: bool,
41 time_accumulator: f32,
42 last_pointer_y: f32,
43 samples: [Option<(f32, f32)>; 4],
44 samples_idx: usize,
45 physics: ScrollPhysics,
46}
47
48impl Default for ScrollState {
49 fn default() -> Self {
50 Self::new()
51 }
52}
53
54#[allow(clippy::suboptimal_flops)]
55impl ScrollState {
56 #[must_use]
58 pub const fn new() -> Self {
59 Self {
60 offset: 0.0,
61 velocity: 0.0,
62 dragging: false,
63 time_accumulator: 0.0,
64 last_pointer_y: 0.0,
65 samples: [None; 4],
66 samples_idx: 0,
67 physics: ScrollPhysics::DEFAULT,
68 }
69 }
70
71 #[must_use]
73 pub const fn with_physics(physics: ScrollPhysics) -> Self {
74 Self {
75 offset: 0.0,
76 velocity: 0.0,
77 dragging: false,
78 time_accumulator: 0.0,
79 last_pointer_y: 0.0,
80 samples: [None; 4],
81 samples_idx: 0,
82 physics,
83 }
84 }
85
86 #[must_use]
88 pub const fn offset(&self) -> f32 {
89 self.offset
90 }
91
92 pub const fn set_offset(&mut self, offset: f32, max_offset: f32) {
94 let max = if max_offset > 0.0 { max_offset } else { 0.0 };
95 self.offset = if offset < 0.0 {
96 0.0
97 } else if offset > max {
98 max
99 } else {
100 offset
101 };
102 self.velocity = 0.0;
103 }
104
105 #[must_use]
107 pub const fn velocity(&self) -> f32 {
108 self.velocity
109 }
110
111 #[must_use]
113 pub const fn dragging(&self) -> bool {
114 self.dragging
115 }
116
117 #[must_use]
119 pub fn integer_offset(&self) -> usize {
120 if self.offset < 0.0 {
121 0
122 } else {
123 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
126 {
127 self.offset as usize
128 }
129 }
130 }
131
132 #[must_use]
134 pub fn fractional_offset(&self) -> f32 {
135 if self.offset < 0.0 {
136 self.offset
137 } else {
138 #[allow(
142 clippy::cast_possible_truncation,
143 clippy::cast_sign_loss,
144 clippy::cast_precision_loss
145 )]
146 let int_part = self.offset as usize as f32;
147 self.offset - int_part
148 }
149 }
150
151 #[allow(clippy::while_float)]
156 pub fn tick(&mut self, dt: core::time::Duration, max_offset: f32) {
157 let dt_secs = dt.as_secs_f32();
158 if dt_secs <= 0.0 {
159 return;
160 }
161 self.time_accumulator += dt_secs;
162
163 if self.dragging {
164 return;
165 }
166
167 let max_offset = max_offset.max(0.0);
168 let max_step = 0.008; let mut remaining = dt_secs;
170
171 while remaining > 0.0 {
172 let step = remaining.min(max_step);
173 remaining -= step;
174
175 if self.offset >= 0.0 && self.offset <= max_offset {
176 self.velocity *= f32::exp(-self.physics.friction * step);
178 self.offset += self.velocity * step;
179
180 if self.velocity.abs() < 0.05 {
182 self.velocity = 0.0;
183 }
184 } else {
185 let target = if self.offset < 0.0 { 0.0 } else { max_offset };
187 let overshoot = self.offset - target;
188
189 let force = -overshoot * self.physics.stiffness;
190 let damping_force = -self.velocity * self.physics.damping;
191 let acceleration = force + damping_force;
192
193 self.velocity += acceleration * step;
194 self.offset += self.velocity * step;
195
196 if (self.offset - target).abs() < 0.01 && self.velocity.abs() < 0.2 {
198 self.offset = target;
199 self.velocity = 0.0;
200 break;
201 }
202 }
203 }
204 }
205
206 pub const fn begin_drag(&mut self, y: f32) {
208 self.dragging = true;
209 self.velocity = 0.0;
210 self.last_pointer_y = y;
211 self.samples = [None; 4];
212 self.samples_idx = 0;
213 self.record_sample(self.time_accumulator, y);
214 }
215
216 pub fn update_drag(&mut self, y: f32, max_offset: f32) {
218 if !self.dragging {
219 self.begin_drag(y);
220 return;
221 }
222
223 let mut delta_y = self.last_pointer_y - y; let max_offset = max_offset.max(0.0);
225 let proposed = self.offset + delta_y;
226
227 if proposed < 0.0 && delta_y < 0.0 {
229 let overshoot = if self.offset < 0.0 {
230 -self.offset
231 } else {
232 -proposed / 2.0
233 };
234 let resistance = (1.0 - overshoot / self.physics.rubber_band_limit).clamp(0.0, 1.0);
235 delta_y *= resistance;
236 } else if proposed > max_offset && delta_y > 0.0 {
237 let overshoot = if self.offset > max_offset {
238 self.offset - max_offset
239 } else {
240 (proposed - max_offset) / 2.0
241 };
242 let resistance = (1.0 - overshoot / self.physics.rubber_band_limit).clamp(0.0, 1.0);
243 delta_y *= resistance;
244 }
245
246 self.offset += delta_y;
247 self.last_pointer_y = y;
248 self.record_sample(self.time_accumulator, y);
249 }
250
251 pub fn end_drag(&mut self) {
253 if !self.dragging {
254 return;
255 }
256 self.dragging = false;
257 self.velocity = self.calculate_fling_velocity();
258 }
259
260 pub fn scroll_by_wheel(&mut self, delta: f32) {
262 if !self.dragging {
263 self.velocity += delta * 12.0;
264 }
265 }
266
267 const fn record_sample(&mut self, time: f32, y: f32) {
268 self.samples[self.samples_idx] = Some((time, y));
269 self.samples_idx = (self.samples_idx + 1) % self.samples.len();
270 }
271
272 fn calculate_fling_velocity(&self) -> f32 {
273 let mut valid = [None; 4];
274 let mut count = 0;
275 for i in 0..4 {
276 let idx = (self.samples_idx + i) % 4;
277 if let Some(sample) = self.samples[idx] {
278 valid[count] = Some(sample);
279 count += 1;
280 }
281 }
282
283 if count < 2 {
284 return 0.0;
285 }
286
287 let newest = valid[count - 1].unwrap();
288
289 if self.time_accumulator - newest.0 > 0.1 {
291 return 0.0;
292 }
293
294 let mut oldest = newest;
296 for i in (0..count - 1).rev() {
297 let sample = valid[i].unwrap();
298 if newest.0 - sample.0 <= 0.15 {
299 oldest = sample;
300 } else {
301 break;
302 }
303 }
304
305 let dt = newest.0 - oldest.0;
306 if dt < 0.01 {
307 return 0.0;
308 }
309
310 (oldest.1 - newest.1) / dt
311 }
312}
313
314#[cfg(test)]
315#[allow(clippy::float_cmp)]
316mod tests {
317 use super::*;
318
319 #[test]
320 fn scroll_state_starts_at_zero() {
321 let s = ScrollState::new();
322 assert_eq!(s.offset(), 0.0);
323 assert_eq!(s.velocity(), 0.0);
324 assert!(!s.dragging());
325 assert_eq!(s.integer_offset(), 0);
326 assert_eq!(s.fractional_offset(), 0.0);
327 }
328
329 #[test]
330 fn scroll_state_set_offset_clamps() {
331 let mut s = ScrollState::new();
332 s.set_offset(10.0, 5.0);
333 assert_eq!(s.offset(), 5.0);
334 s.set_offset(-2.0, 5.0);
335 assert_eq!(s.offset(), 0.0);
336 }
337
338 #[test]
339 fn scroll_state_drag_moves_offset() {
340 let mut s = ScrollState::new();
341 s.begin_drag(10.0);
342 assert!(s.dragging());
343 s.update_drag(7.0, 10.0);
344 assert_eq!(s.offset(), 3.0);
345 s.update_drag(8.0, 10.0);
346 assert_eq!(s.offset(), 2.0);
347 }
348
349 #[test]
350 fn scroll_state_drag_resistance_past_bounds() {
351 let mut s = ScrollState::new();
352 s.begin_drag(10.0);
353 s.update_drag(15.0, 10.0);
354 assert!(s.offset() < 0.0);
355 assert!(s.offset() > -5.0);
356
357 let mut s = ScrollState::new();
358 s.set_offset(10.0, 10.0);
359 s.begin_drag(10.0);
360 s.update_drag(5.0, 10.0);
361 assert!(s.offset() > 10.0);
362 assert!(s.offset() < 15.0);
363 }
364
365 #[test]
366 fn scroll_state_fling_momentum_and_friction() {
367 let mut s = ScrollState::new();
368 s.begin_drag(10.0);
369 s.tick(core::time::Duration::from_millis(50), 10.0);
370 s.update_drag(5.0, 10.0);
371 s.tick(core::time::Duration::from_millis(50), 10.0);
372 s.update_drag(0.0, 10.0);
373 s.end_drag();
374
375 assert!(s.velocity() > 0.0);
376 let init_vel = s.velocity();
377
378 s.tick(core::time::Duration::from_millis(100), 10.0);
379 assert!(s.velocity() < init_vel);
380 assert!(s.offset() > 10.0);
381 }
382
383 #[test]
384 fn scroll_state_spring_snapback() {
385 let mut s = ScrollState::new();
386 s.offset = -2.0;
387 assert_eq!(s.offset(), -2.0);
388
389 s.tick(core::time::Duration::from_millis(100), 10.0);
390 assert!(s.offset() > -2.0);
391
392 for _ in 0..50 {
393 s.tick(core::time::Duration::from_millis(16), 10.0);
394 }
395 assert_eq!(s.offset(), 0.0);
396 assert_eq!(s.velocity(), 0.0);
397 }
398
399 #[test]
400 fn scroll_state_scroll_wheel() {
401 let mut s = ScrollState::new();
402 s.scroll_by_wheel(2.0);
403 assert!(s.velocity() > 0.0);
404 s.tick(core::time::Duration::from_millis(100), 10.0);
405 assert!(s.offset() > 0.0);
406 }
407}