rosace_scroll/controller.rs
1use crate::physics::ScrollPhysics;
2use rosace_state::Atom;
3
4/// Controls a [`ScrollView`] programmatically.
5///
6/// All clones share the same underlying atoms so that separate handles can
7/// observe and mutate the scroll position from different call sites.
8#[derive(Clone)]
9pub struct ScrollController {
10 /// Current scroll offset `[x, y]` in pixels.
11 pub offset: Atom<[f32; 2]>,
12 pub content_size: Atom<[f32; 2]>,
13 pub viewport_size: Atom<[f32; 2]>,
14 /// Drag-to-pan + momentum bookkeeping (D108/Phase 26 Step 2). Internal —
15 /// deliberately NOT subscribed to the owning component in `for_ctx`
16 /// (unlike `offset`/`content_size`/`viewport_size`): these are written
17 /// every frame during a drag/momentum decay, and the visible repaint
18 /// already flows through `offset`'s own subscribed writes — subscribing
19 /// these too would dirty the whole component every frame for no benefit.
20 /// Absolute screen point of the last streamed drag position, `None`
21 /// when not currently dragging.
22 last_drag_point: Atom<Option<[f32; 2]>>,
23 /// The drag's DOWN point, kept until [`Self::DRAG_SLOP`] is exceeded.
24 drag_origin: Atom<Option<[f32; 2]>>,
25 /// The real, currently-tracked drag/momentum velocity in px/s — computed
26 /// from the actual offset delta each frame while dragging, never a
27 /// fixed/assumed constant.
28 velocity: Atom<[f32; 2]>,
29 /// `offset` as of the last frame, used to derive `velocity` this frame.
30 last_offset_for_velocity: Atom<[f32; 2]>,
31 /// Whether this controller was `pressed` (per `PaintCtx::pressed()`) as
32 /// of the last frame — detects the true→false transition that seeds
33 /// momentum from the tracked velocity.
34 was_pressed: Atom<bool>,
35 /// Real elapsed time (seconds) since the last wheel/trackpad event —
36 /// reset to 0 by the wheel callback, advanced each frame by
37 /// `advance_wheel_idle`. Used (not a per-frame boolean) because wheel
38 /// events don't arrive on a perfectly regular one-per-frame schedule —
39 /// an earlier per-frame flag version sprang back the instant a single
40 /// frame happened to have no fresh event, then got pushed forward again
41 /// by the next one, producing a visible jitter/oscillation right at the
42 /// boundary (found via real trackpad testing — described as "vibration,
43 /// scroll a little up and down"). A short real-time grace period
44 /// (`WHEEL_IDLE_GRACE`) instead of a single-frame check absorbs that
45 /// irregularity.
46 wheel_idle_time: Atom<f32>,
47}
48
49/// How long (real seconds) with no wheel event before a gesture is
50/// considered truly over and momentum/spring-back are allowed to run.
51/// Short enough that release still feels immediate, long enough to absorb
52/// normal gaps between OS-delivered wheel events during one continuous
53/// gesture.
54pub const WHEEL_IDLE_GRACE: f32 = 0.12;
55
56/// Maximum tracked velocity (px/s), clamped in `track_velocity`/
57/// `set_velocity`. Without a cap, a very fast flick produced a
58/// proportionally very long coast — found via real trackpad testing +
59/// direct calculation: at friction=0.92 even a modest 200px/s release took
60/// 1.2s to decay below the stop threshold, and higher speeds took longer
61/// still (1.9s+ at 6000px/s) — real touch/trackpad scroll views (iOS,
62/// Android) cap max fling velocity for exactly this reason, so a hard flick
63/// doesn't feel unbounded/sluggish at the tail. 2500px/s is a fast, real
64/// flick speed, not an arbitrary round number — chosen so the slowest
65/// legitimate release still coasts, while capping how long the tail can run.
66pub const MAX_VELOCITY: f32 = 2500.0;
67
68/// Velocity magnitude (px/s) below which coasting is considered "stopped."
69/// Raised from an earlier 0.5 — at 0.92 friction the tail from 0.5 down to
70/// truly zero motion is imperceptible but still real seconds of elapsed
71/// time; 15px/s is still much slower than any perceptible motion but cuts
72/// the long, invisible tail short. Combined with `MAX_VELOCITY` and a
73/// slightly higher friction (`ScrollStyle::default_for_platform`), brings
74/// total coast time for the full realistic velocity range down to
75/// ~0.35s-0.7s (confirmed by direct calculation), instead of 1.2s-1.9s+.
76pub const COAST_STOP_THRESHOLD: f32 = 15.0;
77
78impl ScrollController {
79 /// Create (or retrieve) a controller persisted in component state — the
80 /// scroll position survives rebuilds. Follows the hook rules: call
81 /// unconditionally in `build()`, stable order.
82 pub fn for_ctx(ctx: &mut rosace_core::Context) -> Self {
83 let ctrl = ctx.state(Self::new()).get();
84 // The inner atoms are framework-created (use_atom) — nothing
85 // subscribes to them by default, so a scroll_to/wheel write would
86 // request a frame that repaints NOTHING (cache-hit). Subscribing the
87 // owning component makes controller writes dirty it like ctx.state
88 // atoms do. (Duplicate subscribes are ignored.)
89 let id = ctx.component_id();
90 ctrl.offset.subscribe(id);
91 ctrl.content_size.subscribe(id);
92 ctrl.viewport_size.subscribe(id);
93 ctrl
94 }
95
96 pub fn new() -> Self {
97 Self {
98 offset: rosace_state::use_atom([0.0f32; 2]),
99 content_size: rosace_state::use_atom([0.0f32; 2]),
100 viewport_size: rosace_state::use_atom([0.0f32; 2]),
101 last_drag_point: rosace_state::use_atom(None),
102 drag_origin: rosace_state::use_atom(None),
103 velocity: rosace_state::use_atom([0.0f32; 2]),
104 last_offset_for_velocity: rosace_state::use_atom([0.0f32; 2]),
105 was_pressed: rosace_state::use_atom(false),
106 wheel_idle_time: rosace_state::use_atom(f32::MAX),
107 }
108 }
109
110 /// Jump to an absolute position, clamped to valid bounds.
111 pub fn scroll_to(&self, x: f32, y: f32) {
112 let [cw, ch] = self.content_size.get();
113 let [vw, vh] = self.viewport_size.get();
114 let nx = x.clamp(0.0, (cw - vw).max(0.0));
115 let ny = y.clamp(0.0, (ch - vh).max(0.0));
116 self.offset.set([nx, ny]);
117 }
118
119 /// Scroll to the top (y = 0), preserving x.
120 pub fn scroll_to_top(&self) {
121 let [x, _] = self.offset.get();
122 self.offset.set([x, 0.0]);
123 }
124
125 /// Scroll to the bottom (y = content_height − viewport_height), preserving x.
126 pub fn scroll_to_bottom(&self) {
127 let [x, _] = self.offset.get();
128 let [_, ch] = self.content_size.get();
129 let [_, vh] = self.viewport_size.get();
130 self.offset.set([x, (ch - vh).max(0.0)]);
131 }
132
133 /// Add `(dx, dy)` to the current offset, clamped to valid bounds.
134 pub fn scroll_by(&self, dx: f32, dy: f32) {
135 let [ox, oy] = self.offset.get();
136 let [cw, ch] = self.content_size.get();
137 let [vw, vh] = self.viewport_size.get();
138 let new_x = (ox + dx).clamp(0.0, (cw - vw).max(0.0));
139 let new_y = (oy + dy).clamp(0.0, (ch - vh).max(0.0));
140 self.offset.set([new_x, new_y]);
141 }
142
143 /// Returns the current `[offset_x, offset_y]`.
144 pub fn offset(&self) -> [f32; 2] {
145 self.offset.get()
146 }
147
148 /// Snapshot the current position for later restoration.
149 pub fn save_position(&self) -> [f32; 2] {
150 self.offset.get()
151 }
152
153 /// Restore a previously saved position.
154 pub fn restore_position(&self, pos: [f32; 2]) {
155 self.offset.set(pos);
156 }
157
158 // ── Drag-to-pan + momentum (D108/Phase 26 Step 2) ──────────────────────
159
160 /// Drag slop (Phase 32 bug fix, user-reported): a press must travel
161 /// this many logical px from its DOWN point before drag-to-pan
162 /// engages. Without it, the 1-3 px of natural pointer jitter during a
163 /// plain click pans the view — visible whenever the click lands on
164 /// non-interactive content inside a scroll view (a hit falls through
165 /// to the viewport's positional drag region). 6 px matches the common
166 /// touch-slop convention (small enough that intentional drags feel
167 /// instant, large enough that clicks never pan).
168 pub const DRAG_SLOP: f32 = 6.0;
169
170 /// Streamed absolute drag position → delta since the last call.
171 /// Returns (0, 0) on the first call of a drag AND while the pointer
172 /// stays within [`Self::DRAG_SLOP`] of the down point — see its doc.
173 /// Call `end_drag` on release so the next drag starts fresh.
174 pub fn drag_delta(&self, x: f32, y: f32) -> (f32, f32) {
175 let prev = self.last_drag_point.get();
176 self.last_drag_point.set(Some([x, y]));
177 let Some([px, py]) = prev else {
178 self.drag_origin.set(Some([x, y]));
179 return (0.0, 0.0);
180 };
181 if let Some([ox, oy]) = self.drag_origin.get() {
182 if (x - ox).hypot(y - oy) <= Self::DRAG_SLOP {
183 return (0.0, 0.0); // still a click, not a drag
184 }
185 self.drag_origin.set(None); // slop exceeded — drag is real
186 }
187 (x - px, y - py)
188 }
189
190 /// Clears drag-position tracking — call on release so the next drag
191 /// doesn't diff against a stale point.
192 pub fn end_drag(&self) {
193 self.last_drag_point.set(None);
194 self.drag_origin.set(None);
195 }
196
197 /// Recomputes `velocity` from the real offset delta since the last call,
198 /// in px/s — the actual measured drag/momentum speed, never an assumed
199 /// constant. Call once per frame while dragging or coasting. Clamped to
200 /// `MAX_VELOCITY` — see its doc comment for why.
201 pub fn track_velocity(&self, dt: f32) {
202 if dt <= 0.0 {
203 return;
204 }
205 let now = self.offset.get();
206 let prev = self.last_offset_for_velocity.get();
207 let vx = ((now[0] - prev[0]) / dt).clamp(-MAX_VELOCITY, MAX_VELOCITY);
208 let vy = ((now[1] - prev[1]) / dt).clamp(-MAX_VELOCITY, MAX_VELOCITY);
209 self.velocity.set([vx, vy]);
210 self.last_offset_for_velocity.set(now);
211 }
212
213 /// The most recently tracked velocity (px/s) — see `track_velocity`.
214 pub fn velocity(&self) -> [f32; 2] {
215 self.velocity.get()
216 }
217
218 /// Sets the tracked velocity directly (px/s) — for input sources that
219 /// aren't a continuous drag `track_velocity` can measure frame-to-frame
220 /// (e.g. a discrete wheel/trackpad event), so `coast` still has a real
221 /// speed to decay from once the events stop arriving. Clamped to
222 /// `MAX_VELOCITY` — see its doc comment for why.
223 pub fn set_velocity(&self, v: [f32; 2]) {
224 self.velocity.set([v[0].clamp(-MAX_VELOCITY, MAX_VELOCITY), v[1].clamp(-MAX_VELOCITY, MAX_VELOCITY)]);
225 }
226
227 /// Whether this controller was `pressed` as of the last frame — used to
228 /// detect the true→false transition that hands off to momentum.
229 pub fn was_pressed(&self) -> bool {
230 self.was_pressed.get()
231 }
232
233 pub fn set_was_pressed(&self, v: bool) {
234 self.was_pressed.set(v);
235 }
236
237 /// Called by a wheel/trackpad scroll callback when it fires — resets
238 /// the idle clock to 0.
239 pub fn mark_wheel_active(&self) {
240 self.wheel_idle_time.set(0.0);
241 }
242
243 /// Advances the wheel-idle clock by one real frame — call once per
244 /// frame regardless of whether a wheel event landed.
245 pub fn advance_wheel_idle(&self, dt: f32) {
246 let t = self.wheel_idle_time.get();
247 if t < f32::MAX / 2.0 {
248 self.wheel_idle_time.set(t + dt);
249 }
250 }
251
252 /// Whether a wheel/trackpad event landed within the last
253 /// [`WHEEL_IDLE_GRACE`] real seconds — the caller uses this to hold off
254 /// `coast`'s momentum/spring-back until the gesture has genuinely
255 /// stopped, not just "no event in this exact frame" (see
256 /// `wheel_idle_time`'s doc comment for why a single-frame check jittered).
257 pub fn wheel_recently_active(&self) -> bool {
258 self.wheel_idle_time.get() < WHEEL_IDLE_GRACE
259 }
260
261 /// Current drag/momentum speed (px/s), for callers that just need "is
262 /// this still visibly moving" (e.g. an auto-hiding scrollbar) without
263 /// caring about direction. Zero once `coast` has fully settled.
264 pub fn velocity_magnitude(&self) -> f32 {
265 let [vx, vy] = self.velocity.get();
266 (vx * vx + vy * vy).sqrt()
267 }
268
269 /// Whether the current offset sits past either bound — used by `coast`
270 /// and by callers that need to keep a `Bounce` spring recovering even
271 /// while something else (e.g. a still-live wheel-idle gate) is holding
272 /// off the rest of `coast`'s own logic.
273 pub fn is_overscrolled(&self) -> bool {
274 let [ox, oy] = self.offset.get();
275 let [cw, ch] = self.content_size.get();
276 let [vw, vh] = self.viewport_size.get();
277 let max_x = (cw - vw).max(0.0);
278 let max_y = (ch - vh).max(0.0);
279 ox < 0.0 || ox > max_x || oy < 0.0 || oy > max_y
280 }
281
282 /// Advances one frame of post-release momentum/bounce, using the real
283 /// velocity `track_velocity`/`set_velocity` measured from actual input.
284 /// Returns `true` while still moving/settling (caller should keep
285 /// requesting frames); `false` once fully at rest.
286 pub fn coast(&self, physics: ScrollPhysics, dt: f32) -> bool {
287 // Under `Bounce`, an ALREADY-overscrolled offset springs back
288 // immediately, regardless of remaining velocity — matching real
289 // platforms (iOS `UIScrollView`, Android `OverScroller`), which
290 // switch to spring recovery the instant the edge is crossed rather
291 // than waiting for velocity to fully decay first. The first version
292 // of this function waited for velocity to decay below the 0.5
293 // threshold before ever calling `settle_bounce` — at friction=0.92
294 // that's measured at ~1.35s for a real 400px/s release velocity
295 // (confirmed by direct calculation, not assumed), during which the
296 // view sat frozen mid-overscroll. Matches real trackpad testing:
297 // "scroll, blank space, ~1 second pause, then springs back."
298 if let ScrollPhysics::Bounce { spring_stiffness, .. } = physics {
299 if self.is_overscrolled() {
300 self.velocity.set([0.0, 0.0]);
301 return self.settle_bounce(spring_stiffness, dt);
302 }
303 }
304 let [vx, vy] = self.velocity.get(); // px/s
305 if vx.abs() > COAST_STOP_THRESHOLD || vy.abs() > COAST_STOP_THRESHOLD {
306 let friction = match physics {
307 ScrollPhysics::Momentum { friction } | ScrollPhysics::Bounce { friction, .. } => friction,
308 _ => { self.velocity.set([0.0, 0.0]); return false; }
309 };
310 let dt = dt.max(0.0001);
311 // Move by the real per-frame distance at the CURRENT velocity —
312 // found via real on-device testing that applying the raw px/s
313 // value directly (velocity as if it were "pixels this frame")
314 // moved hundreds of pixels in a single frame instead of a smooth
315 // coast; the headless test's large synthetic `dt` had masked
316 // this unit mismatch.
317 self.apply_momentum(vx * dt, vy * dt, physics);
318 // Decay is exponential in REAL elapsed time, not a flat
319 // per-call multiplier — `friction` is tuned as "per 1/60s
320 // tick," so scale the exponent by dt. A flat `*= friction` per
321 // `coast()` call (this function's first version) made total
322 // coast distance depend on how often the caller happened to
323 // call it — twice the calls per second decayed twice as fast
324 // in real time — same exponential-ease shape `PaintCtx::
325 // animate_to` already uses elsewhere for the same reason.
326 let decay = friction.powf(dt / (1.0 / 60.0));
327 let (nvx, nvy) = (vx * decay, vy * decay);
328 self.velocity.set(if nvx.abs() < COAST_STOP_THRESHOLD && nvy.abs() < COAST_STOP_THRESHOLD { [0.0, 0.0] } else { [nvx, nvy] });
329 return true;
330 }
331 if let ScrollPhysics::Bounce { spring_stiffness, .. } = physics {
332 return self.settle_bounce(spring_stiffness, dt);
333 }
334 false
335 }
336
337 /// Hard-stops all coasting immediately and clamps the offset into
338 /// bounds — used when animations are globally disabled, so release
339 /// never coasts or bounces.
340 pub fn stop_coasting(&self) {
341 self.velocity.set([0.0, 0.0]);
342 self.scroll_by(0.0, 0.0);
343 }
344
345 /// Applies a `(dx, dy)` step to the offset. Under `Bounce`, overscroll is
346 /// allowed but resisted (35% magnitude) while already out of bounds and
347 /// moving further out; moving back toward bounds is full-speed. Every
348 /// other physics hard-clamps, identical to `scroll_by`.
349 pub fn apply_momentum(&self, dx: f32, dy: f32, physics: ScrollPhysics) {
350 let [ox, oy] = self.offset.get();
351 let [cw, ch] = self.content_size.get();
352 let [vw, vh] = self.viewport_size.get();
353 let max_x = (cw - vw).max(0.0);
354 let max_y = (ch - vh).max(0.0);
355 match physics {
356 ScrollPhysics::Bounce { .. } => {
357 let nx = bounce_axis(ox, dx, max_x);
358 let ny = bounce_axis(oy, dy, max_y);
359 self.offset.set([nx, ny]);
360 }
361 _ => {
362 let nx = (ox + dx).clamp(0.0, max_x);
363 let ny = (oy + dy).clamp(0.0, max_y);
364 self.offset.set([nx, ny]);
365 }
366 }
367 }
368
369 /// Like [`Self::apply_momentum`], but reports whether the offset
370 /// actually moved — `false` means this scroll is already fully
371 /// exhausted in this exact direction (hard-clamped with nothing left,
372 /// or already stretched to `MAX_OVERSCROLL` under `Bounce`) and the
373 /// delta was NOT applied at all. Callers driving nested scroll
374 /// chains (an inner `ScrollView` sitting inside an outer one) use
375 /// this to decide whether to also offer the same delta to an
376 /// enclosing scrollable ancestor: keep walking outward until one
377 /// reports `true`, or the chain runs out.
378 pub fn try_apply_delta(&self, dx: f32, dy: f32, physics: ScrollPhysics) -> bool {
379 let before = self.offset.get();
380 self.apply_momentum(dx, dy, physics);
381 self.offset.get() != before
382 }
383
384 /// Eases an out-of-bounds offset back to the nearest valid bound —
385 /// called once velocity has settled while `Bounce`-configured and still
386 /// overscrolled. Same exponential-ease shape as `PaintCtx::animate_to`.
387 /// Returns `true` while still settling (caller should keep requesting
388 /// frames); `false` once within bounds (nothing left to do).
389 pub fn settle_bounce(&self, spring_stiffness: f32, dt: f32) -> bool {
390 let [ox, oy] = self.offset.get();
391 let [cw, ch] = self.content_size.get();
392 let [vw, vh] = self.viewport_size.get();
393 let max_x = (cw - vw).max(0.0);
394 let max_y = (ch - vh).max(0.0);
395 let target_x = ox.clamp(0.0, max_x);
396 let target_y = oy.clamp(0.0, max_y);
397 if (ox - target_x).abs() < 0.5 && (oy - target_y).abs() < 0.5 {
398 if ox != target_x || oy != target_y {
399 self.offset.set([target_x, target_y]);
400 }
401 return false;
402 }
403 let alpha = 1.0 - (-dt * spring_stiffness).exp();
404 let nx = ox + (target_x - ox) * alpha;
405 let ny = oy + (target_y - oy) * alpha;
406 self.offset.set([nx, ny]);
407 true
408 }
409}
410
411/// Rubber-band a single axis: resisted whenever a step would INCREASE the
412/// overscroll magnitude (whether starting exactly at the bound or already
413/// past it), full-speed whenever it would decrease it or stays in bounds.
414/// Standalone free fn so it's directly unit-testable without an Atom.
415/// Maximum overscroll distance past either edge under `Bounce` — matches
416/// the ballpark of iOS's own `UIScrollView` bounce limit. Without a cap,
417/// resistance (35% per step) only slows growth, it doesn't stop it — many
418/// repeated wheel/momentum steps in the same direction could push the
419/// offset arbitrarily far past the real content into blank space with no
420/// visible edge to spring back from. Found via real trackpad testing (the
421/// user scrolled into "some blank" past the end of the list), not
422/// predicted up front.
423const MAX_OVERSCROLL: f32 = 120.0;
424
425fn bounce_axis(offset: f32, delta: f32, max: f32) -> f32 {
426 let overscroll = |o: f32| if o < 0.0 { o } else if o > max { o - max } else { 0.0 };
427 let before = overscroll(offset);
428 let raw_next = offset + delta;
429 let after_raw = overscroll(raw_next);
430 let next = if after_raw.abs() > before.abs() {
431 offset + delta * 0.35
432 } else {
433 raw_next
434 };
435 next.clamp(-MAX_OVERSCROLL, max + MAX_OVERSCROLL)
436}
437
438impl Default for ScrollController {
439 fn default() -> Self {
440 Self::new()
441 }
442}
443
444// ---------------------------------------------------------------------------
445// Tests
446// ---------------------------------------------------------------------------
447#[cfg(test)]
448mod tests {
449 use super::*;
450
451 fn controller_with_size(content_w: f32, content_h: f32, vp_w: f32, vp_h: f32) -> ScrollController {
452 let c = ScrollController::new();
453 c.content_size.set([content_w, content_h]);
454 c.viewport_size.set([vp_w, vp_h]);
455 c
456 }
457
458 #[test]
459 fn scroll_by_clamps_to_bounds() {
460 let c = controller_with_size(500.0, 800.0, 300.0, 400.0);
461 c.scroll_by(9999.0, 9999.0);
462 let [x, y] = c.offset();
463 assert_eq!(x, 200.0); // max_x = 500 - 300
464 assert_eq!(y, 400.0); // max_y = 800 - 400
465 }
466
467 #[test]
468 fn scroll_by_negative_clamps_to_zero() {
469 let c = controller_with_size(500.0, 800.0, 300.0, 400.0);
470 c.scroll_by(100.0, 100.0);
471 c.scroll_by(-9999.0, -9999.0);
472 let [x, y] = c.offset();
473 assert_eq!(x, 0.0);
474 assert_eq!(y, 0.0);
475 }
476
477 #[test]
478 fn scroll_to_top_sets_y_to_zero() {
479 let c = controller_with_size(500.0, 800.0, 300.0, 400.0);
480 c.scroll_by(50.0, 200.0);
481 c.scroll_to_top();
482 let [_x, y] = c.offset();
483 assert_eq!(y, 0.0);
484 }
485
486 #[test]
487 fn scroll_to_bottom_sets_y_to_max() {
488 let c = controller_with_size(500.0, 800.0, 300.0, 400.0);
489 c.scroll_to_bottom();
490 let [_x, y] = c.offset();
491 assert_eq!(y, 400.0); // 800 - 400
492 }
493
494 #[test]
495 fn save_and_restore_position() {
496 let c = controller_with_size(500.0, 800.0, 300.0, 400.0);
497 c.scroll_by(50.0, 100.0);
498 let pos = c.save_position();
499 c.scroll_by(50.0, 100.0);
500 c.restore_position(pos);
501 assert_eq!(c.offset(), [50.0, 100.0]);
502 }
503
504 #[test]
505 fn drag_delta_is_zero_on_first_call_then_real_deltas_after() {
506 let c = ScrollController::new();
507 assert_eq!(c.drag_delta(100.0, 50.0), (0.0, 0.0));
508 assert_eq!(c.drag_delta(110.0, 45.0), (10.0, -5.0));
509 assert_eq!(c.drag_delta(90.0, 45.0), (-20.0, 0.0));
510 }
511
512 #[test]
513 fn click_jitter_within_slop_never_pans() {
514 // The user-reported Phase 32 bug: a plain click's 1-3px pointer
515 // jitter must not move the view.
516 let c = ScrollController::new();
517 assert_eq!(c.drag_delta(100.0, 100.0), (0.0, 0.0)); // down
518 assert_eq!(c.drag_delta(102.0, 101.0), (0.0, 0.0)); // jitter
519 assert_eq!(c.drag_delta(99.0, 100.0), (0.0, 0.0)); // jitter back
520 // A real drag past the slop engages, diffing from the last point.
521 assert_eq!(c.drag_delta(120.0, 100.0), (21.0, 0.0));
522 // And keeps streaming normally afterwards.
523 assert_eq!(c.drag_delta(125.0, 104.0), (5.0, 4.0));
524 }
525
526 #[test]
527 fn end_drag_resets_so_the_next_drag_starts_fresh() {
528 let c = ScrollController::new();
529 c.drag_delta(100.0, 100.0);
530 c.end_drag();
531 assert_eq!(c.drag_delta(150.0, 120.0), (0.0, 0.0));
532 }
533
534 #[test]
535 fn track_velocity_reflects_the_real_offset_speed() {
536 let c = controller_with_size(500.0, 2000.0, 300.0, 400.0);
537 c.scroll_by(0.0, 100.0);
538 c.track_velocity(0.5); // 100px in 0.5s = 200px/s
539 assert_eq!(c.velocity(), [0.0, 200.0]);
540 }
541
542 #[test]
543 fn was_pressed_round_trips() {
544 let c = ScrollController::new();
545 assert!(!c.was_pressed());
546 c.set_was_pressed(true);
547 assert!(c.was_pressed());
548 }
549
550 #[test]
551 fn try_apply_delta_reports_true_while_room_remains() {
552 let c = controller_with_size(500.0, 800.0, 300.0, 400.0);
553 let moved = c.try_apply_delta(0.0, 50.0, ScrollPhysics::Momentum { friction: 0.92 });
554 assert!(moved, "there's 400px of room (max_y=400), a 50px step must move it");
555 assert_eq!(c.offset(), [0.0, 50.0]);
556 }
557
558 #[test]
559 fn try_apply_delta_reports_false_once_hard_clamped_and_exhausted() {
560 let c = controller_with_size(500.0, 800.0, 300.0, 400.0);
561 c.scroll_by(0.0, 400.0); // already at max_y
562 let moved = c.try_apply_delta(0.0, 50.0, ScrollPhysics::Momentum { friction: 0.92 });
563 assert!(!moved, "already at the hard bound with no Bounce give — nothing left to absorb");
564 assert_eq!(c.offset(), [0.0, 400.0], "the declined delta must not have been applied");
565 }
566
567 #[test]
568 fn try_apply_delta_still_reports_true_for_resisted_bounce_overscroll() {
569 let c = controller_with_size(500.0, 800.0, 300.0, 400.0);
570 c.scroll_by(0.0, 400.0); // already at max_y
571 let physics = ScrollPhysics::Bounce { friction: 0.92, spring_stiffness: 12.0 };
572 let moved = c.try_apply_delta(0.0, 50.0, physics);
573 assert!(moved, "Bounce still has overscroll room even at the hard bound — must consume it");
574 assert!(c.offset()[1] > 400.0, "must have stretched past the hard bound, got {:?}", c.offset());
575 }
576
577 #[test]
578 fn try_apply_delta_reports_false_once_bounce_overscroll_is_also_maxed_out() {
579 let c = controller_with_size(500.0, 800.0, 300.0, 400.0);
580 let physics = ScrollPhysics::Bounce { friction: 0.92, spring_stiffness: 12.0 };
581 // Push well past MAX_OVERSCROLL with repeated large deltas.
582 for _ in 0..50 {
583 c.try_apply_delta(0.0, 500.0, physics);
584 }
585 let before = c.offset();
586 let moved = c.try_apply_delta(0.0, 500.0, physics);
587 assert!(!moved, "fully stretched to MAX_OVERSCROLL — genuinely exhausted, must decline");
588 assert_eq!(c.offset(), before);
589 }
590
591 #[test]
592 fn apply_momentum_hard_clamps_under_momentum_physics() {
593 let c = controller_with_size(500.0, 800.0, 300.0, 400.0);
594 c.apply_momentum(9999.0, 9999.0, ScrollPhysics::Momentum { friction: 0.92 });
595 assert_eq!(c.offset(), [200.0, 400.0]); // same hard bounds as scroll_by
596 }
597
598 #[test]
599 fn apply_momentum_allows_resisted_overscroll_under_bounce() {
600 let c = controller_with_size(500.0, 800.0, 300.0, 400.0);
601 let physics = ScrollPhysics::Bounce { friction: 0.92, spring_stiffness: 12.0 };
602 c.apply_momentum(0.0, -40.0, physics); // drag past the top edge
603 let [_, y] = c.offset();
604 assert!(y < 0.0, "overscroll must go negative under Bounce, got {y}");
605 assert_eq!(y, -14.0, "resisted to 35% of the raw delta"); // -40 * 0.35
606 }
607
608 #[test]
609 fn apply_momentum_moving_back_toward_bounds_is_not_resisted() {
610 let c = controller_with_size(500.0, 800.0, 300.0, 400.0);
611 let physics = ScrollPhysics::Bounce { friction: 0.92, spring_stiffness: 12.0 };
612 c.apply_momentum(0.0, -40.0, physics); // overscroll to y = -14
613 c.apply_momentum(0.0, 14.0, physics); // moving back toward 0: full speed
614 let [_, y] = c.offset();
615 assert!((y - 0.0).abs() < 0.01, "expected to land back at 0, got {y}");
616 }
617
618 #[test]
619 fn settle_bounce_eases_an_overscrolled_offset_back_to_the_bound() {
620 let c = controller_with_size(500.0, 800.0, 300.0, 400.0);
621 c.offset.set([0.0, -20.0]); // simulate an overscroll above the top
622 let mut still_settling = true;
623 for _ in 0..200 {
624 still_settling = c.settle_bounce(12.0, 0.05);
625 if !still_settling {
626 break;
627 }
628 }
629 assert!(!still_settling, "must eventually settle");
630 assert_eq!(c.offset(), [0.0, 0.0]);
631 }
632
633 #[test]
634 fn settle_bounce_is_a_no_op_when_already_in_bounds() {
635 let c = controller_with_size(500.0, 800.0, 300.0, 400.0);
636 c.offset.set([50.0, 100.0]);
637 assert!(!c.settle_bounce(12.0, 0.05));
638 assert_eq!(c.offset(), [50.0, 100.0]);
639 }
640
641 #[test]
642 fn coast_springs_back_immediately_when_already_overscrolled_under_bounce_not_after_velocity_decays() {
643 // Regression test for a real bug found via real trackpad testing +
644 // direct calculation (not assumed): the first version of `coast`
645 // wouldn't call `settle_bounce` until velocity decayed below the
646 // 0.5 threshold — at friction=0.92 and a real ~400px/s release
647 // velocity that's ~1.35s of the view sitting frozen mid-overscroll
648 // before any spring-back motion began at all. Real platforms spring
649 // back the instant the edge is crossed, independent of velocity.
650 let c = controller_with_size(500.0, 800.0, 300.0, 400.0);
651 c.offset.set([0.0, -60.0]); // already overscrolled above the top
652 c.set_velocity([0.0, -400.0]); // still carrying a lot of speed
653 let physics = ScrollPhysics::Bounce { friction: 0.92, spring_stiffness: 12.0 };
654
655 let still_active = c.coast(physics, 1.0 / 60.0);
656
657 assert!(still_active, "must still be settling, not yet at rest");
658 let [_, y] = c.offset();
659 assert!(
660 y > -60.0,
661 "must have started easing back toward the bound on the VERY FIRST call, not stayed frozen at -60 while velocity decays: got {y}"
662 );
663 assert_eq!(c.velocity(), [0.0, 0.0], "velocity is superseded by spring recovery once overscrolled");
664 }
665
666 #[test]
667 fn set_velocity_clamps_to_max_velocity() {
668 // Regression test for a real finding from live testing: an
669 // unbounded velocity meant a very fast flick produced a
670 // proportionally very long coast, feeling sluggish/stuck rather
671 // than snappy. A very fast raw estimate must be capped.
672 let c = ScrollController::new();
673 c.set_velocity([0.0, 100_000.0]);
674 assert_eq!(c.velocity(), [0.0, MAX_VELOCITY]);
675 c.set_velocity([0.0, -100_000.0]);
676 assert_eq!(c.velocity(), [0.0, -MAX_VELOCITY]);
677 }
678
679 #[test]
680 fn track_velocity_clamps_to_max_velocity() {
681 let c = controller_with_size(500.0, 100_000.0, 300.0, 400.0);
682 c.scroll_by(0.0, 10_000.0); // a huge one-frame jump (not realistic input, just exercising the clamp)
683 c.track_velocity(1.0 / 60.0); // would be 600_000 px/s unclamped
684 assert_eq!(c.velocity(), [0.0, MAX_VELOCITY]);
685 }
686
687 #[test]
688 fn full_realistic_velocity_range_settles_within_under_a_second() {
689 // Confirms the tuned friction/threshold/clamp combination (0.88,
690 // 15px/s, 2500px/s) actually delivers what the direct-calculation
691 // analysis promised — every realistic release speed, including the
692 // clamped maximum, settles in well under a second, not the
693 // 1.2s-1.9s+ the original 0.92/0.5px/s combination measured out to.
694 let physics = ScrollPhysics::Momentum { friction: 0.88 };
695 for v0 in [200.0, 800.0, 2500.0, 100_000.0] {
696 let c = controller_with_size(500.0, 1_000_000.0, 300.0, 400.0);
697 c.set_velocity([0.0, v0]);
698 let mut elapsed = 0.0;
699 let dt = 1.0 / 60.0;
700 while c.coast(physics, dt) && elapsed < 5.0 {
701 elapsed += dt;
702 }
703 assert!(elapsed < 1.0, "v0={v0} took {elapsed:.2}s to settle, expected well under 1s");
704 }
705 }
706
707 #[test]
708 fn coast_applies_a_dt_scaled_step_not_the_raw_px_per_second_value() {
709 // Regression test for a real bug found via on-device testing: velocity
710 // is tracked in px/s, but `MomentumState`'s friction model is a
711 // discrete per-tick decay expecting a per-frame pixel amount. The
712 // first implementation applied the raw px/s value directly — at a
713 // realistic 60fps dt this meant a single `coast()` call could jump
714 // hundreds of pixels in one frame instead of a smooth step.
715 let c = controller_with_size(500.0, 100_000.0, 300.0, 400.0);
716 c.set_velocity([0.0, 800.0]); // a fast but real drag speed, px/s
717 let dt = 1.0 / 60.0; // a realistic frame time, NOT a large synthetic one
718 c.coast(ScrollPhysics::Momentum { friction: 0.92 }, dt);
719 let [_, y] = c.offset();
720 // At 800 px/s over one ~60fps frame, the real step is ~13.3px — a
721 // step anywhere near the raw 800 value would mean the unit bug is
722 // back.
723 assert!(y < 50.0, "one frame of coast at 800px/s, dt=1/60 must move roughly 13px, not the raw velocity, got {y}");
724 assert!(y > 0.0, "must still move forward some real amount, got {y}");
725 }
726
727 #[test]
728 fn coast_velocity_decay_is_dt_independent_over_a_fixed_time_span() {
729 // The dt-scaling fix must not make total coast distance depend on
730 // how finely the frames are chopped up — half as much movement per
731 // tick, twice as many ticks over the same wall-clock time, should
732 // land at roughly the same total distance.
733 let physics = ScrollPhysics::Momentum { friction: 0.92 };
734 let coarse = controller_with_size(500.0, 100_000.0, 300.0, 400.0);
735 coarse.set_velocity([0.0, 600.0]);
736 for _ in 0..30 {
737 coarse.coast(physics, 1.0 / 30.0); // 1 real second, 30 ticks
738 }
739
740 let fine = controller_with_size(500.0, 100_000.0, 300.0, 400.0);
741 fine.set_velocity([0.0, 600.0]);
742 for _ in 0..60 {
743 fine.coast(physics, 1.0 / 60.0); // 1 real second, 60 ticks
744 }
745
746 let [_, y_coarse] = coarse.offset();
747 let [_, y_fine] = fine.offset();
748 let diff = (y_coarse - y_fine).abs();
749 assert!(
750 diff < y_coarse.max(y_fine) * 0.15,
751 "total coast distance over the same real time must be roughly frame-rate independent: coarse={y_coarse} fine={y_fine}"
752 );
753 }
754
755 #[test]
756 fn wheel_recently_active_is_true_immediately_after_marking_then_false_once_the_grace_period_elapses() {
757 let c = ScrollController::new();
758 assert!(!c.wheel_recently_active(), "nothing marked yet");
759 c.mark_wheel_active();
760 assert!(c.wheel_recently_active(), "must report recently active right after marking");
761 // Advance in small steps (mirrors real per-frame calls), same
762 // total as slightly more than the grace period.
763 for _ in 0..20 {
764 c.advance_wheel_idle(WHEEL_IDLE_GRACE / 10.0);
765 }
766 assert!(!c.wheel_recently_active(), "must go stale once the grace period has elapsed");
767 }
768
769 #[test]
770 fn wheel_recently_active_survives_a_gap_shorter_than_the_grace_period() {
771 // The exact bug this replaced: a single frame with no wheel event
772 // must NOT immediately flip this to false — only a real gap of at
773 // least WHEEL_IDLE_GRACE seconds should.
774 let c = ScrollController::new();
775 c.mark_wheel_active();
776 c.advance_wheel_idle(WHEEL_IDLE_GRACE * 0.3); // a short gap, e.g. one uneven frame
777 assert!(c.wheel_recently_active(), "a short gap within the grace period must not reset activity");
778 }
779}