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            // `self.offset` is checked non-negative above and scroll offsets never approach
124            // usize::MAX, so truncation can't happen in practice.
125            #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
126            {
127                self.offset as usize
128            }
129        }
130    }
131
132    /// Returns the fractional remainder of the offset (0.0..1.0).
133    #[must_use]
134    pub fn fractional_offset(&self) -> f32 {
135        if self.offset < 0.0 {
136            self.offset
137        } else {
138            // Truncate to the integer part via usize (offset is non-negative here), then back to
139            // f32 to subtract. Scroll offsets stay well under 2^24 items, so the round-trip is
140            // exact in practice despite f32's 23-bit mantissa.
141            #[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    /// Update physics for a single frame step.
152    ///
153    /// Decays momentum if in bounds, or animates the rubber-band spring back to
154    /// boundaries if out of bounds. Has no effect if dragging is active.
155    #[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; // 8ms maximum step size for stable spring integration
169        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                // In bounds: apply friction decay
177                self.velocity *= f32::exp(-self.physics.friction * step);
178                self.offset += self.velocity * step;
179
180                // Stop moving if velocity becomes tiny
181                if self.velocity.abs() < 0.05 {
182                    self.velocity = 0.0;
183                }
184            } else {
185                // Out of bounds: apply spring snapback force
186                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                // Snap when close enough to target and nearly stopped
197                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    /// Begin a drag gesture at pointer coordinate `y`.
207    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    /// Update the drag gesture with a new pointer coordinate `y`.
217    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; // dragging UP increases offset
224        let max_offset = max_offset.max(0.0);
225        let proposed = self.offset + delta_y;
226
227        // Apply rubber-band resistance when dragging past boundaries
228        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    /// End the current drag gesture, initiating a fling if pointer speed was sufficient.
252    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    /// Apply a scroll wheel impulse directly to velocity.
261    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 latest sample is older than 100ms, drag paused (no fling)
290        if self.time_accumulator - newest.0 > 0.1 {
291            return 0.0;
292        }
293
294        // Look back for oldest sample within 150ms of newest
295        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}