Skip to main content

retroglyph_ui/state/
scroll.rs

1use crate::interact::Response;
2
3/// Configurable physics constants for [`ScrollState`].
4///
5/// Not `#[non_exhaustive]`: unlike this crate's enums, this type's whole point is direct struct-
6/// literal construction, which `#[non_exhaustive]` would forbid for external crates (including
7/// via functional update syntax). Construct a custom value off [`ScrollPhysics::DEFAULT`] with
8/// functional update syntax instead of naming every field, so a field added here later needs at
9/// most a call-site addition rather than a rewrite:
10///
11/// ```
12/// use retroglyph_ui::state::ScrollPhysics;
13///
14/// let physics = ScrollPhysics { friction: 6.0, ..ScrollPhysics::DEFAULT };
15/// ```
16#[derive(Clone, Copy, Debug, PartialEq)]
17pub struct ScrollPhysics {
18    /// Exponential friction decay constant. Higher means faster deceleration.
19    pub friction: f32,
20    /// Stiffness of the overscroll spring.
21    pub stiffness: f32,
22    /// Damping of the overscroll spring.
23    pub damping: f32,
24    /// Maximum rows/cells the viewport can be rubber-banded past the edge.
25    pub rubber_band_limit: f32,
26}
27
28impl ScrollPhysics {
29    /// The default scroll physics parameters as a constant.
30    ///
31    /// The spring pair (`stiffness`/`damping`) is close to but deliberately under critical
32    /// damping: critical would be `damping = 2 * sqrt(stiffness) = 2 * sqrt(180) ~= 26.8`, and
33    /// `24.0` sits just below that, so the rubber band settles with one small visible bounce
34    /// rather than a dead stop. `friction` and the `stiffness` magnitude were tuned by feel for a
35    /// row-per-item viewport at ~60fps, not derived; treat them as adjustable.
36    pub const DEFAULT: Self = Self {
37        friction: 4.5,
38        stiffness: 180.0,
39        damping: 24.0,
40        rubber_band_limit: 4.0,
41    };
42}
43
44impl Default for ScrollPhysics {
45    fn default() -> Self {
46        Self::DEFAULT
47    }
48}
49
50/// Scroll state for smooth, momentum-based scrolling with rubber-banding.
51///
52/// Keeps track of the current fractional scroll offset, velocity, and
53/// drag-to-scroll gestures. Completely separate from drawing, and generic over
54/// time: takes a time delta step to decay velocity or animate snap-back,
55/// making it deterministic and suitable for unit tests.
56///
57/// For a *row*-based viewport (a menu, a list of fixed-height items) where content scrolls a
58/// whole row at a time and there's no momentum to animate, reach for [`crate::state::ListState`]
59/// instead: its `offset` is a plain `usize`, clamped only at zero, with no velocity or physics
60/// step. The two don't compose into one type on purpose (see [`crate::state::ListState`]'s own doc
61/// comment): pick whichever one matches what's actually scrolling: continuous/pixel-ish
62/// content reaches for `ScrollState`, a discrete item list reaches for `ListState`.
63#[derive(Clone, Debug, PartialEq)]
64pub struct ScrollState {
65    offset: f32,
66    velocity: f32,
67    dragging: bool,
68    time_accumulator: f32,
69    last_pointer_y: f32,
70    /// Recent (time, pointer-y) samples for estimating fling velocity at drag end, oldest first.
71    /// Four is enough to average out one jittery pointer report while staying inside the ~150ms
72    /// look-back window `calculate_fling_velocity` uses; more just holds staler samples the
73    /// window discards anyway. Only `samples[..sample_len]` is meaningful.
74    samples: [(f32, f32); 4],
75    sample_len: usize,
76    physics: ScrollPhysics,
77}
78
79impl Default for ScrollState {
80    fn default() -> Self {
81        Self::new()
82    }
83}
84
85#[allow(clippy::suboptimal_flops)]
86impl ScrollState {
87    /// Create a new `ScrollState` at offset 0.0 with default physics.
88    #[must_use]
89    pub const fn new() -> Self {
90        Self {
91            offset: 0.0,
92            velocity: 0.0,
93            dragging: false,
94            time_accumulator: 0.0,
95            last_pointer_y: 0.0,
96            samples: [(0.0, 0.0); 4],
97            sample_len: 0,
98            physics: ScrollPhysics::DEFAULT,
99        }
100    }
101
102    /// Create a new `ScrollState` with custom physics.
103    #[must_use]
104    pub const fn with_physics(physics: ScrollPhysics) -> Self {
105        Self {
106            offset: 0.0,
107            velocity: 0.0,
108            dragging: false,
109            time_accumulator: 0.0,
110            last_pointer_y: 0.0,
111            samples: [(0.0, 0.0); 4],
112            sample_len: 0,
113            physics,
114        }
115    }
116
117    /// The current fractional scroll offset.
118    #[must_use]
119    pub const fn offset(&self) -> f32 {
120        self.offset
121    }
122
123    /// Set the offset directly, clamping it to bounds.
124    pub const fn set_offset(&mut self, offset: f32, max_offset: f32) {
125        let max = if max_offset > 0.0 { max_offset } else { 0.0 };
126        self.offset = if offset < 0.0 {
127            0.0
128        } else if offset > max {
129            max
130        } else {
131            offset
132        };
133        self.velocity = 0.0;
134    }
135
136    /// The current velocity in items/second.
137    #[must_use]
138    pub const fn velocity(&self) -> f32 {
139        self.velocity
140    }
141
142    /// Whether a drag gesture is currently active.
143    #[must_use]
144    pub const fn dragging(&self) -> bool {
145        self.dragging
146    }
147
148    /// Returns the integer part of the offset, clamped to positive.
149    #[must_use]
150    pub fn integer_offset(&self) -> usize {
151        if self.offset < 0.0 {
152            0
153        } else {
154            // `self.offset` is checked non-negative above and scroll offsets never approach
155            // usize::MAX, so truncation can't happen in practice.
156            #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
157            {
158                self.offset as usize
159            }
160        }
161    }
162
163    /// Returns the fractional remainder of the offset (0.0..1.0).
164    #[must_use]
165    pub fn fractional_offset(&self) -> f32 {
166        if self.offset < 0.0 {
167            self.offset
168        } else {
169            // Truncate to the integer part via usize (offset is non-negative here), then back to
170            // f32 to subtract. Scroll offsets stay well under 2^24 items, so the round-trip is
171            // exact in practice despite f32's 23-bit mantissa.
172            #[allow(
173                clippy::cast_possible_truncation,
174                clippy::cast_sign_loss,
175                clippy::cast_precision_loss
176            )]
177            let int_part = self.offset as usize as f32;
178            self.offset - int_part
179        }
180    }
181
182    /// Update physics for a single frame step.
183    ///
184    /// Decays momentum if in bounds, or animates the rubber-band spring back to
185    /// boundaries if out of bounds. Has no effect if dragging is active.
186    #[allow(clippy::while_float)]
187    pub fn tick(&mut self, dt: core::time::Duration, max_offset: f32) {
188        let dt_secs = dt.as_secs_f32();
189        if dt_secs <= 0.0 {
190            return;
191        }
192        self.time_accumulator += dt_secs;
193
194        if self.dragging {
195            return;
196        }
197
198        let max_offset = max_offset.max(0.0);
199        // 8ms cap so a long frame delta is integrated as several small spring steps instead of
200        // one large one. The explicit spring integration below diverges once a single step grows
201        // past roughly `1 / sqrt(stiffness)` seconds; 8ms stays well inside that for the default
202        // stiffness of 180 and still subdivides a dropped-to-30fps 33ms frame into 5 steps.
203        let max_step = 0.008;
204        let mut remaining = dt_secs;
205
206        while remaining > 0.0 {
207            let step = remaining.min(max_step);
208            remaining -= step;
209
210            if self.offset >= 0.0 && self.offset <= max_offset {
211                // In bounds: apply friction decay
212                self.velocity *= retroglyph_core::math::exp(-self.physics.friction * step);
213                self.offset += self.velocity * step;
214
215                // Stop moving once velocity drops below 0.05 items/second: below this the
216                // remaining motion is imperceptible per frame, so snapping to zero avoids an
217                // indefinite exponential tail that never quite reaches exactly 0.0.
218                if self.velocity.abs() < 0.05 {
219                    self.velocity = 0.0;
220                }
221            } else {
222                // Out of bounds: apply spring snapback force
223                let target = if self.offset < 0.0 { 0.0 } else { max_offset };
224                let overshoot = self.offset - target;
225
226                let force = -overshoot * self.physics.stiffness;
227                let damping_force = -self.velocity * self.physics.damping;
228                let acceleration = force + damping_force;
229
230                self.velocity += acceleration * step;
231                self.offset += self.velocity * step;
232
233                // Snap to target once within 0.01 items and slower than 0.2 items/second: both
234                // thresholds were picked by feel as "close enough to be indistinguishable", not
235                // derived; the pair together avoids a spring that oscillates forever chasing an
236                // exact zero.
237                if (self.offset - target).abs() < 0.01 && self.velocity.abs() < 0.2 {
238                    self.offset = target;
239                    self.velocity = 0.0;
240                    break;
241                }
242            }
243        }
244    }
245
246    /// Begin a drag gesture at pointer coordinate `y`.
247    pub const fn begin_drag(&mut self, y: f32) {
248        self.dragging = true;
249        self.velocity = 0.0;
250        self.last_pointer_y = y;
251        self.samples = [(0.0, 0.0); 4];
252        self.sample_len = 0;
253        self.record_sample(self.time_accumulator, y);
254    }
255
256    /// Update the drag gesture with a new pointer coordinate `y`.
257    pub fn update_drag(&mut self, y: f32, max_offset: f32) {
258        if !self.dragging {
259            self.begin_drag(y);
260            return;
261        }
262
263        let mut delta_y = self.last_pointer_y - y; // dragging UP increases offset
264        let max_offset = max_offset.max(0.0);
265        let proposed = self.offset + delta_y;
266
267        // Apply rubber-band resistance when dragging past boundaries
268        if proposed < 0.0 && delta_y < 0.0 {
269            let overshoot = if self.offset < 0.0 {
270                -self.offset
271            } else {
272                -proposed / 2.0
273            };
274            let resistance = (1.0 - overshoot / self.physics.rubber_band_limit).clamp(0.0, 1.0);
275            delta_y *= resistance;
276        } else if proposed > max_offset && delta_y > 0.0 {
277            let overshoot = if self.offset > max_offset {
278                self.offset - max_offset
279            } else {
280                (proposed - max_offset) / 2.0
281            };
282            let resistance = (1.0 - overshoot / self.physics.rubber_band_limit).clamp(0.0, 1.0);
283            delta_y *= resistance;
284        }
285
286        self.offset += delta_y;
287        self.last_pointer_y = y;
288        self.record_sample(self.time_accumulator, y);
289    }
290
291    /// End the current drag gesture, initiating a fling if pointer speed was sufficient.
292    pub fn end_drag(&mut self) {
293        if !self.dragging {
294            return;
295        }
296        self.dragging = false;
297        self.velocity = self.calculate_fling_velocity();
298    }
299
300    /// Apply a scroll wheel impulse directly to velocity.
301    pub fn scroll_by_wheel(&mut self, delta: f32) {
302        if !self.dragging {
303            // One wheel notch -> 12 items/second of fling; paired with `friction: 4.5` this coasts
304            // a notch roughly two-to-three rows before stopping. Tuned by feel, not derived.
305            self.velocity += delta * 12.0;
306        }
307    }
308
309    /// Feeds a frame's resolved [`Response::scroll_delta`] straight into
310    /// [`scroll_by_wheel`](Self::scroll_by_wheel), so a widget wires wheel input by calling
311    /// [`Interaction::interact`](crate::interact::Interaction::interact) with [`Sense::SCROLL`](crate::interact::Sense::SCROLL)
312    /// and handing the [`Response`] here, instead of re-deriving it from raw
313    /// [`MouseEventKind::Scroll`](retroglyph_core::event::MouseEventKind::Scroll) events the way a widget
314    /// with no route to `ScrollState` has to.
315    ///
316    /// A no-op if nothing scrolled this frame (`scroll_delta` is `0`), which also makes this safe
317    /// to call unconditionally every frame rather than gating it on a dirty check first. Drag-to-scroll
318    /// isn't covered here: `Response` reports only *whether* a drag is in progress
319    /// ([`Response::dragging`]), not a pointer position, so that gesture still goes through
320    /// [`begin_drag`](Self::begin_drag)/[`update_drag`](Self::update_drag)/[`end_drag`](Self::end_drag)
321    /// directly, using [`Interaction::pointer`](crate::interact::Interaction::pointer)'s position alongside
322    /// this `Response`.
323    ///
324    /// # Examples
325    ///
326    /// ```
327    /// use retroglyph_core::grid::Rect;
328    /// use retroglyph_ui::interact::{Interaction, Sense};
329    /// use retroglyph_ui::state::ScrollState;
330    ///
331    /// #[derive(Clone, Copy, PartialEq, Eq)]
332    /// struct Id;
333    ///
334    /// let mut interaction = Interaction::new();
335    /// let mut scroll = ScrollState::new();
336    ///
337    /// interaction.begin_frame();
338    /// let response = interaction.interact(Rect::new(0, 0, 10, 5), Id, Sense::scroll());
339    /// scroll.apply(&response); // a no-op here: nothing scrolled this frame
340    /// interaction.end_frame();
341    ///
342    /// assert_eq!(scroll.velocity(), 0.0);
343    /// ```
344    pub fn apply<Id>(&mut self, response: &Response<Id>) {
345        let delta = response.scroll_delta();
346        if delta != 0 {
347            #[allow(clippy::cast_precision_loss)] // scroll deltas stay tiny: a handful of wheel
348            // notches per frame, nowhere near f32's 24-bit exact-integer range.
349            self.scroll_by_wheel(delta as f32);
350        }
351    }
352
353    const fn record_sample(&mut self, time: f32, y: f32) {
354        if self.sample_len < self.samples.len() {
355            self.samples[self.sample_len] = (time, y);
356            self.sample_len += 1;
357        } else {
358            // Full: evict the oldest by shifting everything left one slot, then append. Hand-
359            // written instead of `copy_within` because this runs in a `const fn` (`copy_within`
360            // isn't const-stable) and the array is fixed at 4 elements.
361            let mut i = 0;
362            while i + 1 < self.samples.len() {
363                self.samples[i] = self.samples[i + 1];
364                i += 1;
365            }
366            self.samples[self.samples.len() - 1] = (time, y);
367        }
368    }
369
370    fn calculate_fling_velocity(&self) -> f32 {
371        let samples = &self.samples[..self.sample_len];
372
373        if samples.len() < 2 {
374            return 0.0;
375        }
376
377        let newest = samples[samples.len() - 1];
378
379        // If the latest sample is older than 100ms, the pointer stopped moving before the drag
380        // ended (drag paused, not a fling): 100ms is long enough that a genuinely flinging finger
381        // would still be producing fresh samples, but short enough to reject a deliberate pause.
382        if self.time_accumulator - newest.0 > 0.1 {
383            return 0.0;
384        }
385
386        // Look back for oldest sample within 150ms of newest
387        let mut oldest = newest;
388        for &sample in samples[..samples.len() - 1].iter().rev() {
389            // 150ms look-back: recent enough to reflect the finger's final motion, long enough
390            // to smooth over one or two skipped/jittery pointer reports. Widening it would average
391            // in motion from earlier in the drag that no longer reflects the release speed.
392            if newest.0 - sample.0 <= 0.15 {
393                oldest = sample;
394            } else {
395                break;
396            }
397        }
398
399        let dt = newest.0 - oldest.0;
400        // Guard against dividing by a near-zero time delta, which would produce a huge spurious
401        // velocity from two samples that are really just one pointer report apart.
402        if dt < 0.01 {
403            return 0.0;
404        }
405
406        (oldest.1 - newest.1) / dt
407    }
408}
409
410#[cfg(test)]
411#[allow(clippy::float_cmp)]
412mod tests {
413    use super::*;
414
415    #[test]
416    fn scroll_state_starts_at_zero() {
417        let s = ScrollState::new();
418        assert_eq!(s.offset(), 0.0);
419        assert_eq!(s.velocity(), 0.0);
420        assert!(!s.dragging());
421        assert_eq!(s.integer_offset(), 0);
422        assert_eq!(s.fractional_offset(), 0.0);
423    }
424
425    #[test]
426    fn scroll_state_set_offset_clamps() {
427        let mut s = ScrollState::new();
428        s.set_offset(10.0, 5.0);
429        assert_eq!(s.offset(), 5.0);
430        s.set_offset(-2.0, 5.0);
431        assert_eq!(s.offset(), 0.0);
432    }
433
434    #[test]
435    fn scroll_state_drag_moves_offset() {
436        let mut s = ScrollState::new();
437        s.begin_drag(10.0);
438        assert!(s.dragging());
439        s.update_drag(7.0, 10.0);
440        assert_eq!(s.offset(), 3.0);
441        s.update_drag(8.0, 10.0);
442        assert_eq!(s.offset(), 2.0);
443    }
444
445    #[test]
446    fn scroll_state_drag_resistance_past_bounds() {
447        let mut s = ScrollState::new();
448        s.begin_drag(10.0);
449        s.update_drag(15.0, 10.0);
450        assert!(s.offset() < 0.0);
451        assert!(s.offset() > -5.0);
452
453        let mut s = ScrollState::new();
454        s.set_offset(10.0, 10.0);
455        s.begin_drag(10.0);
456        s.update_drag(5.0, 10.0);
457        assert!(s.offset() > 10.0);
458        assert!(s.offset() < 15.0);
459    }
460
461    #[test]
462    fn scroll_state_fling_momentum_and_friction() {
463        let mut s = ScrollState::new();
464        s.begin_drag(10.0);
465        s.tick(core::time::Duration::from_millis(50), 10.0);
466        s.update_drag(5.0, 10.0);
467        s.tick(core::time::Duration::from_millis(50), 10.0);
468        s.update_drag(0.0, 10.0);
469        s.end_drag();
470
471        assert!(s.velocity() > 0.0);
472        let init_vel = s.velocity();
473
474        s.tick(core::time::Duration::from_millis(100), 10.0);
475        assert!(s.velocity() < init_vel);
476        assert!(s.offset() > 10.0);
477    }
478
479    #[test]
480    fn scroll_state_fling_velocity_wraps_past_four_samples() {
481        // Drives 6 update_drag calls (7 samples total including begin_drag's), forcing the
482        // 4-slot sample buffer to evict twice. This exercises the eviction path (`sample_len ==
483        // samples.len()` in `record_sample`), which no other test reaches.
484        let mut s = ScrollState::new();
485        s.begin_drag(0.0);
486        for step in 1_u32..=6 {
487            s.tick(core::time::Duration::from_millis(10), 10.0);
488            #[allow(clippy::cast_precision_loss)] // step stays well under f32's exact range
489            s.update_drag(step as f32, 10.0);
490        }
491        s.end_drag();
492
493        // Pointer moved consistently in the positive-y direction, i.e. offset decreasing, so the
494        // fling velocity should be negative (matches update_drag's `delta_y = last - y` sign
495        // convention for downward drag).
496        assert!(s.velocity() < 0.0);
497    }
498
499    #[test]
500    fn scroll_state_spring_snapback() {
501        let mut s = ScrollState::new();
502        s.offset = -2.0;
503        assert_eq!(s.offset(), -2.0);
504
505        s.tick(core::time::Duration::from_millis(100), 10.0);
506        assert!(s.offset() > -2.0);
507
508        for _ in 0..50 {
509            s.tick(core::time::Duration::from_millis(16), 10.0);
510        }
511        assert_eq!(s.offset(), 0.0);
512        assert_eq!(s.velocity(), 0.0);
513    }
514
515    #[test]
516    fn scroll_state_scroll_wheel() {
517        let mut s = ScrollState::new();
518        s.scroll_by_wheel(2.0);
519        assert!(s.velocity() > 0.0);
520        s.tick(core::time::Duration::from_millis(100), 10.0);
521        assert!(s.offset() > 0.0);
522    }
523
524    fn response_with_scroll_delta(scroll_delta: i32) -> Response<()> {
525        Response {
526            scroll_delta,
527            ..Response::default()
528        }
529    }
530
531    #[test]
532    fn apply_feeds_scroll_delta_into_the_wheel_impulse() {
533        let mut applied = ScrollState::new();
534        applied.apply(&response_with_scroll_delta(2));
535
536        let mut direct = ScrollState::new();
537        direct.scroll_by_wheel(2.0);
538
539        assert_eq!(applied.velocity(), direct.velocity());
540        assert!(applied.velocity() > 0.0);
541    }
542
543    #[test]
544    fn apply_is_a_no_op_when_nothing_scrolled() {
545        let mut s = ScrollState::new();
546        s.apply(&response_with_scroll_delta(0));
547        assert_eq!(s.velocity(), 0.0);
548        assert_eq!(s.offset(), 0.0);
549    }
550
551    #[test]
552    fn apply_negative_delta_scrolls_backward() {
553        let mut s = ScrollState::new();
554        s.set_offset(5.0, 10.0);
555        s.apply(&response_with_scroll_delta(-1));
556        assert!(s.velocity() < 0.0);
557    }
558
559    #[test]
560    fn apply_is_ignored_while_dragging_like_scroll_by_wheel() {
561        let mut s = ScrollState::new();
562        s.begin_drag(0.0);
563        s.apply(&response_with_scroll_delta(3));
564        assert_eq!(s.velocity(), 0.0);
565    }
566}