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)]
124 {
125 self.offset as usize
126 }
127 }
128 }
129
130 #[must_use]
132 pub fn fractional_offset(&self) -> f32 {
133 if self.offset < 0.0 {
134 self.offset
135 } else {
136 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
137 let int_part = self.offset as usize as f32;
138 self.offset - int_part
139 }
140 }
141
142 #[allow(clippy::while_float)]
147 pub fn tick(&mut self, dt: core::time::Duration, max_offset: f32) {
148 let dt_secs = dt.as_secs_f32();
149 if dt_secs <= 0.0 {
150 return;
151 }
152 self.time_accumulator += dt_secs;
153
154 if self.dragging {
155 return;
156 }
157
158 let max_offset = max_offset.max(0.0);
159 let max_step = 0.008; let mut remaining = dt_secs;
161
162 while remaining > 0.0 {
163 let step = remaining.min(max_step);
164 remaining -= step;
165
166 if self.offset >= 0.0 && self.offset <= max_offset {
167 self.velocity *= f32::exp(-self.physics.friction * step);
169 self.offset += self.velocity * step;
170
171 if self.velocity.abs() < 0.05 {
173 self.velocity = 0.0;
174 }
175 } else {
176 let target = if self.offset < 0.0 { 0.0 } else { max_offset };
178 let overshoot = self.offset - target;
179
180 let force = -overshoot * self.physics.stiffness;
181 let damping_force = -self.velocity * self.physics.damping;
182 let acceleration = force + damping_force;
183
184 self.velocity += acceleration * step;
185 self.offset += self.velocity * step;
186
187 if (self.offset - target).abs() < 0.01 && self.velocity.abs() < 0.2 {
189 self.offset = target;
190 self.velocity = 0.0;
191 break;
192 }
193 }
194 }
195 }
196
197 pub const fn begin_drag(&mut self, y: f32) {
199 self.dragging = true;
200 self.velocity = 0.0;
201 self.last_pointer_y = y;
202 self.samples = [None; 4];
203 self.samples_idx = 0;
204 self.record_sample(self.time_accumulator, y);
205 }
206
207 pub fn update_drag(&mut self, y: f32, max_offset: f32) {
209 if !self.dragging {
210 self.begin_drag(y);
211 return;
212 }
213
214 let mut delta_y = self.last_pointer_y - y; let max_offset = max_offset.max(0.0);
216 let proposed = self.offset + delta_y;
217
218 if proposed < 0.0 && delta_y < 0.0 {
220 let overshoot = if self.offset < 0.0 {
221 -self.offset
222 } else {
223 -proposed / 2.0
224 };
225 let resistance = (1.0 - overshoot / self.physics.rubber_band_limit).clamp(0.0, 1.0);
226 delta_y *= resistance;
227 } else if proposed > max_offset && delta_y > 0.0 {
228 let overshoot = if self.offset > max_offset {
229 self.offset - max_offset
230 } else {
231 (proposed - max_offset) / 2.0
232 };
233 let resistance = (1.0 - overshoot / self.physics.rubber_band_limit).clamp(0.0, 1.0);
234 delta_y *= resistance;
235 }
236
237 self.offset += delta_y;
238 self.last_pointer_y = y;
239 self.record_sample(self.time_accumulator, y);
240 }
241
242 pub fn end_drag(&mut self) {
244 if !self.dragging {
245 return;
246 }
247 self.dragging = false;
248 self.velocity = self.calculate_fling_velocity();
249 }
250
251 pub fn scroll_by_wheel(&mut self, delta: f32) {
253 if !self.dragging {
254 self.velocity += delta * 12.0;
255 }
256 }
257
258 const fn record_sample(&mut self, time: f32, y: f32) {
259 self.samples[self.samples_idx] = Some((time, y));
260 self.samples_idx = (self.samples_idx + 1) % self.samples.len();
261 }
262
263 fn calculate_fling_velocity(&self) -> f32 {
264 let mut valid = [None; 4];
265 let mut count = 0;
266 for i in 0..4 {
267 let idx = (self.samples_idx + i) % 4;
268 if let Some(sample) = self.samples[idx] {
269 valid[count] = Some(sample);
270 count += 1;
271 }
272 }
273
274 if count < 2 {
275 return 0.0;
276 }
277
278 let newest = valid[count - 1].unwrap();
279
280 if self.time_accumulator - newest.0 > 0.1 {
282 return 0.0;
283 }
284
285 let mut oldest = newest;
287 for i in (0..count - 1).rev() {
288 let sample = valid[i].unwrap();
289 if newest.0 - sample.0 <= 0.15 {
290 oldest = sample;
291 } else {
292 break;
293 }
294 }
295
296 let dt = newest.0 - oldest.0;
297 if dt < 0.01 {
298 return 0.0;
299 }
300
301 (oldest.1 - newest.1) / dt
302 }
303}
304
305#[cfg(test)]
306#[allow(clippy::float_cmp)]
307mod tests {
308 use super::*;
309
310 #[test]
311 fn scroll_state_starts_at_zero() {
312 let s = ScrollState::new();
313 assert_eq!(s.offset(), 0.0);
314 assert_eq!(s.velocity(), 0.0);
315 assert!(!s.dragging());
316 assert_eq!(s.integer_offset(), 0);
317 assert_eq!(s.fractional_offset(), 0.0);
318 }
319
320 #[test]
321 fn scroll_state_set_offset_clamps() {
322 let mut s = ScrollState::new();
323 s.set_offset(10.0, 5.0);
324 assert_eq!(s.offset(), 5.0);
325 s.set_offset(-2.0, 5.0);
326 assert_eq!(s.offset(), 0.0);
327 }
328
329 #[test]
330 fn scroll_state_drag_moves_offset() {
331 let mut s = ScrollState::new();
332 s.begin_drag(10.0);
333 assert!(s.dragging());
334 s.update_drag(7.0, 10.0);
335 assert_eq!(s.offset(), 3.0);
336 s.update_drag(8.0, 10.0);
337 assert_eq!(s.offset(), 2.0);
338 }
339
340 #[test]
341 fn scroll_state_drag_resistance_past_bounds() {
342 let mut s = ScrollState::new();
343 s.begin_drag(10.0);
344 s.update_drag(15.0, 10.0);
345 assert!(s.offset() < 0.0);
346 assert!(s.offset() > -5.0);
347
348 let mut s = ScrollState::new();
349 s.set_offset(10.0, 10.0);
350 s.begin_drag(10.0);
351 s.update_drag(5.0, 10.0);
352 assert!(s.offset() > 10.0);
353 assert!(s.offset() < 15.0);
354 }
355
356 #[test]
357 fn scroll_state_fling_momentum_and_friction() {
358 let mut s = ScrollState::new();
359 s.begin_drag(10.0);
360 s.tick(core::time::Duration::from_millis(50), 10.0);
361 s.update_drag(5.0, 10.0);
362 s.tick(core::time::Duration::from_millis(50), 10.0);
363 s.update_drag(0.0, 10.0);
364 s.end_drag();
365
366 assert!(s.velocity() > 0.0);
367 let init_vel = s.velocity();
368
369 s.tick(core::time::Duration::from_millis(100), 10.0);
370 assert!(s.velocity() < init_vel);
371 assert!(s.offset() > 10.0);
372 }
373
374 #[test]
375 fn scroll_state_spring_snapback() {
376 let mut s = ScrollState::new();
377 s.offset = -2.0;
378 assert_eq!(s.offset(), -2.0);
379
380 s.tick(core::time::Duration::from_millis(100), 10.0);
381 assert!(s.offset() > -2.0);
382
383 for _ in 0..50 {
384 s.tick(core::time::Duration::from_millis(16), 10.0);
385 }
386 assert_eq!(s.offset(), 0.0);
387 assert_eq!(s.velocity(), 0.0);
388 }
389
390 #[test]
391 fn scroll_state_scroll_wheel() {
392 let mut s = ScrollState::new();
393 s.scroll_by_wheel(2.0);
394 assert!(s.velocity() > 0.0);
395 s.tick(core::time::Duration::from_millis(100), 10.0);
396 assert!(s.offset() > 0.0);
397 }
398}