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}