Skip to main content

retroglyph_ui/state/
scroll.rs

1use crate::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::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::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::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. Four is enough
71    /// to average out one jittery pointer report while staying inside the ~150ms look-back window
72    /// `calculate_fling_velocity` uses; more just holds staler samples the window discards anyway.
73    samples: [Option<(f32, f32)>; 4],
74    samples_idx: usize,
75    physics: ScrollPhysics,
76}
77
78impl Default for ScrollState {
79    fn default() -> Self {
80        Self::new()
81    }
82}
83
84#[allow(clippy::suboptimal_flops)]
85impl ScrollState {
86    /// Create a new `ScrollState` at offset 0.0 with default physics.
87    #[must_use]
88    pub const fn new() -> Self {
89        Self {
90            offset: 0.0,
91            velocity: 0.0,
92            dragging: false,
93            time_accumulator: 0.0,
94            last_pointer_y: 0.0,
95            samples: [None; 4],
96            samples_idx: 0,
97            physics: ScrollPhysics::DEFAULT,
98        }
99    }
100
101    /// Create a new `ScrollState` with custom physics.
102    #[must_use]
103    pub const fn with_physics(physics: ScrollPhysics) -> Self {
104        Self {
105            offset: 0.0,
106            velocity: 0.0,
107            dragging: false,
108            time_accumulator: 0.0,
109            last_pointer_y: 0.0,
110            samples: [None; 4],
111            samples_idx: 0,
112            physics,
113        }
114    }
115
116    /// The current fractional scroll offset.
117    #[must_use]
118    pub const fn offset(&self) -> f32 {
119        self.offset
120    }
121
122    /// Set the offset directly, clamping it to bounds.
123    pub const fn set_offset(&mut self, offset: f32, max_offset: f32) {
124        let max = if max_offset > 0.0 { max_offset } else { 0.0 };
125        self.offset = if offset < 0.0 {
126            0.0
127        } else if offset > max {
128            max
129        } else {
130            offset
131        };
132        self.velocity = 0.0;
133    }
134
135    /// The current velocity in items/second.
136    #[must_use]
137    pub const fn velocity(&self) -> f32 {
138        self.velocity
139    }
140
141    /// Whether a drag gesture is currently active.
142    #[must_use]
143    pub const fn dragging(&self) -> bool {
144        self.dragging
145    }
146
147    /// Returns the integer part of the offset, clamped to positive.
148    #[must_use]
149    pub fn integer_offset(&self) -> usize {
150        if self.offset < 0.0 {
151            0
152        } else {
153            // `self.offset` is checked non-negative above and scroll offsets never approach
154            // usize::MAX, so truncation can't happen in practice.
155            #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
156            {
157                self.offset as usize
158            }
159        }
160    }
161
162    /// Returns the fractional remainder of the offset (0.0..1.0).
163    #[must_use]
164    pub fn fractional_offset(&self) -> f32 {
165        if self.offset < 0.0 {
166            self.offset
167        } else {
168            // Truncate to the integer part via usize (offset is non-negative here), then back to
169            // f32 to subtract. Scroll offsets stay well under 2^24 items, so the round-trip is
170            // exact in practice despite f32's 23-bit mantissa.
171            #[allow(
172                clippy::cast_possible_truncation,
173                clippy::cast_sign_loss,
174                clippy::cast_precision_loss
175            )]
176            let int_part = self.offset as usize as f32;
177            self.offset - int_part
178        }
179    }
180
181    /// Update physics for a single frame step.
182    ///
183    /// Decays momentum if in bounds, or animates the rubber-band spring back to
184    /// boundaries if out of bounds. Has no effect if dragging is active.
185    #[allow(clippy::while_float)]
186    pub fn tick(&mut self, dt: core::time::Duration, max_offset: f32) {
187        let dt_secs = dt.as_secs_f32();
188        if dt_secs <= 0.0 {
189            return;
190        }
191        self.time_accumulator += dt_secs;
192
193        if self.dragging {
194            return;
195        }
196
197        let max_offset = max_offset.max(0.0);
198        // 8ms cap so a long frame delta is integrated as several small spring steps instead of
199        // one large one. The explicit spring integration below diverges once a single step grows
200        // past roughly `1 / sqrt(stiffness)` seconds; 8ms stays well inside that for the default
201        // stiffness of 180 and still subdivides a dropped-to-30fps 33ms frame into 5 steps.
202        let max_step = 0.008;
203        let mut remaining = dt_secs;
204
205        while remaining > 0.0 {
206            let step = remaining.min(max_step);
207            remaining -= step;
208
209            if self.offset >= 0.0 && self.offset <= max_offset {
210                // In bounds: apply friction decay
211                self.velocity *= retroglyph_core::math::exp(-self.physics.friction * step);
212                self.offset += self.velocity * step;
213
214                // Stop moving once velocity drops below 0.05 items/second: below this the
215                // remaining motion is imperceptible per frame, so snapping to zero avoids an
216                // indefinite exponential tail that never quite reaches exactly 0.0.
217                if self.velocity.abs() < 0.05 {
218                    self.velocity = 0.0;
219                }
220            } else {
221                // Out of bounds: apply spring snapback force
222                let target = if self.offset < 0.0 { 0.0 } else { max_offset };
223                let overshoot = self.offset - target;
224
225                let force = -overshoot * self.physics.stiffness;
226                let damping_force = -self.velocity * self.physics.damping;
227                let acceleration = force + damping_force;
228
229                self.velocity += acceleration * step;
230                self.offset += self.velocity * step;
231
232                // Snap to target once within 0.01 items and slower than 0.2 items/second: both
233                // thresholds were picked by feel as "close enough to be indistinguishable", not
234                // derived; the pair together avoids a spring that oscillates forever chasing an
235                // exact zero.
236                if (self.offset - target).abs() < 0.01 && self.velocity.abs() < 0.2 {
237                    self.offset = target;
238                    self.velocity = 0.0;
239                    break;
240                }
241            }
242        }
243    }
244
245    /// Begin a drag gesture at pointer coordinate `y`.
246    pub const fn begin_drag(&mut self, y: f32) {
247        self.dragging = true;
248        self.velocity = 0.0;
249        self.last_pointer_y = y;
250        self.samples = [None; 4];
251        self.samples_idx = 0;
252        self.record_sample(self.time_accumulator, y);
253    }
254
255    /// Update the drag gesture with a new pointer coordinate `y`.
256    pub fn update_drag(&mut self, y: f32, max_offset: f32) {
257        if !self.dragging {
258            self.begin_drag(y);
259            return;
260        }
261
262        let mut delta_y = self.last_pointer_y - y; // dragging UP increases offset
263        let max_offset = max_offset.max(0.0);
264        let proposed = self.offset + delta_y;
265
266        // Apply rubber-band resistance when dragging past boundaries
267        if proposed < 0.0 && delta_y < 0.0 {
268            let overshoot = if self.offset < 0.0 {
269                -self.offset
270            } else {
271                -proposed / 2.0
272            };
273            let resistance = (1.0 - overshoot / self.physics.rubber_band_limit).clamp(0.0, 1.0);
274            delta_y *= resistance;
275        } else if proposed > max_offset && delta_y > 0.0 {
276            let overshoot = if self.offset > max_offset {
277                self.offset - max_offset
278            } else {
279                (proposed - max_offset) / 2.0
280            };
281            let resistance = (1.0 - overshoot / self.physics.rubber_band_limit).clamp(0.0, 1.0);
282            delta_y *= resistance;
283        }
284
285        self.offset += delta_y;
286        self.last_pointer_y = y;
287        self.record_sample(self.time_accumulator, y);
288    }
289
290    /// End the current drag gesture, initiating a fling if pointer speed was sufficient.
291    pub fn end_drag(&mut self) {
292        if !self.dragging {
293            return;
294        }
295        self.dragging = false;
296        self.velocity = self.calculate_fling_velocity();
297    }
298
299    /// Apply a scroll wheel impulse directly to velocity.
300    pub fn scroll_by_wheel(&mut self, delta: f32) {
301        if !self.dragging {
302            // One wheel notch -> 12 items/second of fling; paired with `friction: 4.5` this coasts
303            // a notch roughly two-to-three rows before stopping. Tuned by feel, not derived.
304            self.velocity += delta * 12.0;
305        }
306    }
307
308    /// Feeds a frame's resolved [`Response::scroll_delta`] straight into
309    /// [`scroll_by_wheel`](Self::scroll_by_wheel), so a widget wires wheel input by calling
310    /// [`Interaction::interact`](crate::Interaction::interact) with [`Sense::SCROLL`](crate::Sense::SCROLL)
311    /// and handing the [`Response`] here, instead of re-deriving it from raw
312    /// [`MouseEventKind::Scroll`](retroglyph_core::event::MouseEventKind::Scroll) events the way a widget
313    /// with no route to `ScrollState` has to.
314    ///
315    /// A no-op if nothing scrolled this frame (`scroll_delta` is `0`), which also makes this safe
316    /// to call unconditionally every frame rather than gating it on a dirty check first. Drag-to-scroll
317    /// isn't covered here: `Response` reports only *whether* a drag is in progress
318    /// ([`Response::dragging`]), not a pointer position, so that gesture still goes through
319    /// [`begin_drag`](Self::begin_drag)/[`update_drag`](Self::update_drag)/[`end_drag`](Self::end_drag)
320    /// directly, using [`Interaction::pointer`](crate::Interaction::pointer)'s position alongside
321    /// this `Response`.
322    ///
323    /// # Examples
324    ///
325    /// ```
326    /// use retroglyph_core::grid::Rect;
327    /// use retroglyph_ui::{Interaction, ScrollState, Sense};
328    ///
329    /// #[derive(Clone, Copy, PartialEq, Eq)]
330    /// struct Id;
331    ///
332    /// let mut interaction = Interaction::new();
333    /// let mut scroll = ScrollState::new();
334    ///
335    /// interaction.begin_frame();
336    /// let response = interaction.interact(Rect::new(0, 0, 10, 5), Id, Sense::scroll());
337    /// scroll.apply(&response); // a no-op here: nothing scrolled this frame
338    /// interaction.end_frame();
339    ///
340    /// assert_eq!(scroll.velocity(), 0.0);
341    /// ```
342    pub fn apply<Id>(&mut self, response: &Response<Id>) {
343        let delta = response.scroll_delta();
344        if delta != 0 {
345            #[allow(clippy::cast_precision_loss)] // scroll deltas stay tiny: a handful of wheel
346            // notches per frame, nowhere near f32's 24-bit exact-integer range.
347            self.scroll_by_wheel(delta as f32);
348        }
349    }
350
351    const fn record_sample(&mut self, time: f32, y: f32) {
352        self.samples[self.samples_idx] = Some((time, y));
353        self.samples_idx = (self.samples_idx + 1) % self.samples.len();
354    }
355
356    fn calculate_fling_velocity(&self) -> f32 {
357        let mut valid = [None; 4];
358        let mut count = 0;
359        for i in 0..self.samples.len() {
360            let idx = (self.samples_idx + i) % self.samples.len();
361            if let Some(sample) = self.samples[idx] {
362                valid[count] = Some(sample);
363                count += 1;
364            }
365        }
366
367        if count < 2 {
368            return 0.0;
369        }
370
371        // `count >= 2` was just checked above, so `valid[count - 1]` was written by the loop
372        // above (which fills `valid[0..count]` in order) and is always `Some`.
373        let newest = valid[count - 1].expect("valid[count - 1] is populated for count >= 2");
374
375        // If the latest sample is older than 100ms, the pointer stopped moving before the drag
376        // ended (drag paused, not a fling): 100ms is long enough that a genuinely flinging finger
377        // would still be producing fresh samples, but short enough to reject a deliberate pause.
378        if self.time_accumulator - newest.0 > 0.1 {
379            return 0.0;
380        }
381
382        // Look back for oldest sample within 150ms of newest
383        let mut oldest = newest;
384        for i in (0..count - 1).rev() {
385            // `i` ranges over `0..count - 1`, all populated by the loop above.
386            let sample = valid[i].expect("valid[i] is populated for i < count");
387            // 150ms look-back: recent enough to reflect the finger's final motion, long enough
388            // to smooth over one or two skipped/jittery pointer reports. Widening it would average
389            // in motion from earlier in the drag that no longer reflects the release speed.
390            if newest.0 - sample.0 <= 0.15 {
391                oldest = sample;
392            } else {
393                break;
394            }
395        }
396
397        let dt = newest.0 - oldest.0;
398        // Guard against dividing by a near-zero time delta, which would produce a huge spurious
399        // velocity from two samples that are really just one pointer report apart.
400        if dt < 0.01 {
401            return 0.0;
402        }
403
404        (oldest.1 - newest.1) / dt
405    }
406}
407
408#[cfg(test)]
409#[allow(clippy::float_cmp)]
410mod tests {
411    use super::*;
412
413    #[test]
414    fn scroll_state_starts_at_zero() {
415        let s = ScrollState::new();
416        assert_eq!(s.offset(), 0.0);
417        assert_eq!(s.velocity(), 0.0);
418        assert!(!s.dragging());
419        assert_eq!(s.integer_offset(), 0);
420        assert_eq!(s.fractional_offset(), 0.0);
421    }
422
423    #[test]
424    fn scroll_state_set_offset_clamps() {
425        let mut s = ScrollState::new();
426        s.set_offset(10.0, 5.0);
427        assert_eq!(s.offset(), 5.0);
428        s.set_offset(-2.0, 5.0);
429        assert_eq!(s.offset(), 0.0);
430    }
431
432    #[test]
433    fn scroll_state_drag_moves_offset() {
434        let mut s = ScrollState::new();
435        s.begin_drag(10.0);
436        assert!(s.dragging());
437        s.update_drag(7.0, 10.0);
438        assert_eq!(s.offset(), 3.0);
439        s.update_drag(8.0, 10.0);
440        assert_eq!(s.offset(), 2.0);
441    }
442
443    #[test]
444    fn scroll_state_drag_resistance_past_bounds() {
445        let mut s = ScrollState::new();
446        s.begin_drag(10.0);
447        s.update_drag(15.0, 10.0);
448        assert!(s.offset() < 0.0);
449        assert!(s.offset() > -5.0);
450
451        let mut s = ScrollState::new();
452        s.set_offset(10.0, 10.0);
453        s.begin_drag(10.0);
454        s.update_drag(5.0, 10.0);
455        assert!(s.offset() > 10.0);
456        assert!(s.offset() < 15.0);
457    }
458
459    #[test]
460    fn scroll_state_fling_momentum_and_friction() {
461        let mut s = ScrollState::new();
462        s.begin_drag(10.0);
463        s.tick(core::time::Duration::from_millis(50), 10.0);
464        s.update_drag(5.0, 10.0);
465        s.tick(core::time::Duration::from_millis(50), 10.0);
466        s.update_drag(0.0, 10.0);
467        s.end_drag();
468
469        assert!(s.velocity() > 0.0);
470        let init_vel = s.velocity();
471
472        s.tick(core::time::Duration::from_millis(100), 10.0);
473        assert!(s.velocity() < init_vel);
474        assert!(s.offset() > 10.0);
475    }
476
477    #[test]
478    fn scroll_state_spring_snapback() {
479        let mut s = ScrollState::new();
480        s.offset = -2.0;
481        assert_eq!(s.offset(), -2.0);
482
483        s.tick(core::time::Duration::from_millis(100), 10.0);
484        assert!(s.offset() > -2.0);
485
486        for _ in 0..50 {
487            s.tick(core::time::Duration::from_millis(16), 10.0);
488        }
489        assert_eq!(s.offset(), 0.0);
490        assert_eq!(s.velocity(), 0.0);
491    }
492
493    #[test]
494    fn scroll_state_scroll_wheel() {
495        let mut s = ScrollState::new();
496        s.scroll_by_wheel(2.0);
497        assert!(s.velocity() > 0.0);
498        s.tick(core::time::Duration::from_millis(100), 10.0);
499        assert!(s.offset() > 0.0);
500    }
501
502    fn response_with_scroll_delta(scroll_delta: i32) -> Response<()> {
503        Response {
504            scroll_delta,
505            ..Response::default()
506        }
507    }
508
509    #[test]
510    fn apply_feeds_scroll_delta_into_the_wheel_impulse() {
511        let mut applied = ScrollState::new();
512        applied.apply(&response_with_scroll_delta(2));
513
514        let mut direct = ScrollState::new();
515        direct.scroll_by_wheel(2.0);
516
517        assert_eq!(applied.velocity(), direct.velocity());
518        assert!(applied.velocity() > 0.0);
519    }
520
521    #[test]
522    fn apply_is_a_no_op_when_nothing_scrolled() {
523        let mut s = ScrollState::new();
524        s.apply(&response_with_scroll_delta(0));
525        assert_eq!(s.velocity(), 0.0);
526        assert_eq!(s.offset(), 0.0);
527    }
528
529    #[test]
530    fn apply_negative_delta_scrolls_backward() {
531        let mut s = ScrollState::new();
532        s.set_offset(5.0, 10.0);
533        s.apply(&response_with_scroll_delta(-1));
534        assert!(s.velocity() < 0.0);
535    }
536
537    #[test]
538    fn apply_is_ignored_while_dragging_like_scroll_by_wheel() {
539        let mut s = ScrollState::new();
540        s.begin_drag(0.0);
541        s.apply(&response_with_scroll_delta(3));
542        assert_eq!(s.velocity(), 0.0);
543    }
544}