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}