Skip to main content

retroglyph_widgets/state/
scroll.rs

1/// Configurable physics constants for [`ScrollState`].
2#[derive(Clone, Copy, Debug, PartialEq)]
3pub struct ScrollPhysics {
4    /// Exponential friction decay constant. Higher means faster deceleration.
5    pub friction: f32,
6    /// Stiffness of the overscroll spring.
7    pub stiffness: f32,
8    /// Damping of the overscroll spring.
9    pub damping: f32,
10    /// Maximum rows/cells the viewport can be rubber-banded past the edge.
11    pub rubber_band_limit: f32,
12}
13
14impl ScrollPhysics {
15    /// The default scroll physics parameters as a constant.
16    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/// Scroll state for smooth, momentum-based scrolling with rubber-banding.
31///
32/// Keeps track of the current fractional scroll offset, velocity, and
33/// drag-to-scroll gestures. Completely separate from drawing, and generic over
34/// time: takes a time delta step to decay velocity or animate snap-back,
35/// making it deterministic and suitable for unit tests.
36#[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    /// Create a new `ScrollState` at offset 0.0 with default physics.
57    #[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    /// Create a new `ScrollState` with custom physics.
72    #[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    /// The current fractional scroll offset.
87    #[must_use]
88    pub const fn offset(&self) -> f32 {
89        self.offset
90    }
91
92    /// Set the offset directly, clamping it to bounds.
93    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    /// The current velocity in items/second.
106    #[must_use]
107    pub const fn velocity(&self) -> f32 {
108        self.velocity
109    }
110
111    /// Whether a drag gesture is currently active.
112    #[must_use]
113    pub const fn dragging(&self) -> bool {
114        self.dragging
115    }
116
117    /// Returns the integer part of the offset, clamped to positive.
118    #[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    /// Returns the fractional remainder of the offset (0.0..1.0).
131    #[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    /// Update physics for a single frame step.
143    ///
144    /// Decays momentum if in bounds, or animates the rubber-band spring back to
145    /// boundaries if out of bounds. Has no effect if dragging is active.
146    #[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; // 8ms maximum step size for stable spring integration
160        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                // In bounds: apply friction decay
168                self.velocity *= f32::exp(-self.physics.friction * step);
169                self.offset += self.velocity * step;
170
171                // Stop moving if velocity becomes tiny
172                if self.velocity.abs() < 0.05 {
173                    self.velocity = 0.0;
174                }
175            } else {
176                // Out of bounds: apply spring snapback force
177                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                // Snap when close enough to target and nearly stopped
188                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    /// Begin a drag gesture at pointer coordinate `y`.
198    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    /// Update the drag gesture with a new pointer coordinate `y`.
208    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; // dragging UP increases offset
215        let max_offset = max_offset.max(0.0);
216        let proposed = self.offset + delta_y;
217
218        // Apply rubber-band resistance when dragging past boundaries
219        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    /// End the current drag gesture, initiating a fling if pointer speed was sufficient.
243    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    /// Apply a scroll wheel impulse directly to velocity.
252    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 latest sample is older than 100ms, drag paused (no fling)
281        if self.time_accumulator - newest.0 > 0.1 {
282            return 0.0;
283        }
284
285        // Look back for oldest sample within 150ms of newest
286        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}