Skip to main content

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}