Skip to main content

repose_core/
scroll.rs

1use std::cell::{Cell, RefCell};
2use std::rc::Rc;
3
4use web_time::Instant;
5
6use crate::nested_scroll::{NestedScrollConnection, NestedScrollSource};
7use crate::{ControlVisualSet, Dp, Signal, Vec2, request_frame, signal};
8
9/// Axis (or axes) a scroll modifier operates on.
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub enum ScrollAxis {
12    Vertical,
13    Horizontal,
14    Both,
15}
16
17// Holds all callbacks that the layout engine needs from a scroll state.
18// Stored in `Modifier.scroll`.
19
20#[derive(Clone, Debug, PartialEq)]
21pub struct ScrollbarStyle {
22    pub thickness: Dp,
23    pub track_inset: Dp,
24    pub min_thumb_length: Dp,
25    pub fixed_thumb_length: Option<Dp>,
26    pub radius: Option<Dp>,
27    pub track_visuals: ControlVisualSet,
28    pub thumb_visuals: ControlVisualSet,
29}
30
31impl Default for ScrollbarStyle {
32    fn default() -> Self {
33        Self {
34            thickness: Dp(4.0),
35            track_inset: Dp(2.0),
36            min_thumb_length: Dp(24.0),
37            fixed_thumb_length: None,
38            radius: None,
39            track_visuals: ControlVisualSet::default(),
40            thumb_visuals: ControlVisualSet::default(),
41        }
42    }
43}
44
45impl ScrollbarStyle {
46    pub fn with_thickness(mut self, thickness: Dp) -> Self {
47        self.thickness = thickness;
48        self
49    }
50
51    pub fn with_track_inset(mut self, track_inset: Dp) -> Self {
52        self.track_inset = track_inset;
53        self
54    }
55
56    pub fn with_min_thumb_length(mut self, min_thumb_length: Dp) -> Self {
57        self.min_thumb_length = min_thumb_length;
58        self
59    }
60
61    pub fn with_fixed_thumb_length(mut self, length: Option<Dp>) -> Self {
62        self.fixed_thumb_length = length;
63        self
64    }
65
66    pub fn with_radius(mut self, radius: Option<Dp>) -> Self {
67        self.radius = radius;
68        self
69    }
70
71    pub fn with_track_visuals(mut self, visuals: ControlVisualSet) -> Self {
72        self.track_visuals = visuals;
73        self
74    }
75
76    pub fn with_thumb_visuals(mut self, visuals: ControlVisualSet) -> Self {
77        self.thumb_visuals = visuals;
78        self
79    }
80}
81
82/// Callbacks for a single-axis (vertical or horizontal) scroll container.
83#[derive(Clone, Default)]
84pub struct ScrollAxisBinding {
85    pub on_scroll: Option<Rc<dyn Fn(Vec2) -> Vec2>>,
86    /// Set viewport size along the scroll axis (height for V, width for H).
87    pub set_viewport_main: Option<Rc<dyn Fn(f32)>>,
88    /// Set content size along the scroll axis.
89    pub set_content_main: Option<Rc<dyn Fn(f32)>>,
90    pub get_offset_main: Option<Rc<dyn Fn() -> f32>>,
91    pub set_offset_main: Option<Rc<dyn Fn(f32)>>,
92    pub show_scrollbar: bool,
93    pub tick: Option<Rc<dyn Fn()>>,
94    pub set_nested_scroll_parent: Option<Rc<dyn Fn(NestedScrollConnection)>>,
95}
96
97impl std::fmt::Debug for ScrollAxisBinding {
98    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        f.debug_struct("ScrollAxisBinding")
100            .field("has_on_scroll", &self.on_scroll.is_some())
101            .field("set_viewport_main", &self.set_viewport_main.is_some())
102            .field("set_content_main", &self.set_content_main.is_some())
103            .field("get_offset_main", &self.get_offset_main.is_some())
104            .field("set_offset_main", &self.set_offset_main.is_some())
105            .field("show_scrollbar", &self.show_scrollbar)
106            .field("tick", &self.tick.is_some())
107            .field(
108                "set_nested_scroll_parent",
109                &self.set_nested_scroll_parent.is_some(),
110            )
111            .finish()
112    }
113}
114
115/// Callbacks for a 2D (both-axis) scroll container.
116#[derive(Clone, Default)]
117pub struct ScrollBothBinding {
118    pub on_scroll: Option<Rc<dyn Fn(Vec2) -> Vec2>>,
119    pub set_viewport_width: Option<Rc<dyn Fn(f32)>>,
120    pub set_viewport_height: Option<Rc<dyn Fn(f32)>>,
121    pub set_content_width: Option<Rc<dyn Fn(f32)>>,
122    pub set_content_height: Option<Rc<dyn Fn(f32)>>,
123    pub get_offset_xy: Option<Rc<dyn Fn() -> (f32, f32)>>,
124    pub set_offset_xy: Option<Rc<dyn Fn(f32, f32)>>,
125    pub show_scrollbar: bool,
126    pub tick: Option<Rc<dyn Fn()>>,
127    pub set_nested_scroll_parent: Option<Rc<dyn Fn(NestedScrollConnection)>>,
128}
129
130impl std::fmt::Debug for ScrollBothBinding {
131    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132        f.debug_struct("ScrollBothBinding")
133            .field("has_on_scroll", &self.on_scroll.is_some())
134            .field("set_viewport_width", &self.set_viewport_width.is_some())
135            .field("set_viewport_height", &self.set_viewport_height.is_some())
136            .field("set_content_width", &self.set_content_width.is_some())
137            .field("set_content_height", &self.set_content_height.is_some())
138            .field("get_offset_xy", &self.get_offset_xy.is_some())
139            .field("set_offset_xy", &self.set_offset_xy.is_some())
140            .field("show_scrollbar", &self.show_scrollbar)
141            .field("tick", &self.tick.is_some())
142            .field(
143                "set_nested_scroll_parent",
144                &self.set_nested_scroll_parent.is_some(),
145            )
146            .finish()
147    }
148}
149
150#[derive(Clone, Debug)]
151pub enum ScrollBinding {
152    Vertical(ScrollAxisBinding),
153    Horizontal(ScrollAxisBinding),
154    Both(ScrollBothBinding),
155}
156
157impl ScrollBinding {
158    pub fn axis(&self) -> ScrollAxis {
159        match self {
160            ScrollBinding::Vertical(_) => ScrollAxis::Vertical,
161            ScrollBinding::Horizontal(_) => ScrollAxis::Horizontal,
162            ScrollBinding::Both(_) => ScrollAxis::Both,
163        }
164    }
165}
166
167const OVERSHOOT_DECAY_PER_60HZ: f32 = 0.78;
168
169/// Per-60Hz-frame velocity decay at low speed. Higher stops the fling sooner.
170pub const SCROLL_DECAY_PER_60HZ: f32 = 0.85;
171/// Fling is abandoned once speed falls below this (px/s).
172pub const SCROLL_STOP_VELOCITY: f32 = 15.0;
173/// A gesture must exceed this speed (px/s) to leave a fling behind.
174pub const SCROLL_FLING_ACTIVATE_VELOCITY: f32 = 50.0;
175/// Rubber-band asymptote: overscroll never stretches past this many px.
176pub const SCROLL_OVERSROLL_BAND_MAX: f32 = 150.0;
177
178/// Clamp a scroll offset into `[0, max]` with NaN/inf sanitization.
179/// `f32::clamp` propagates NaN, so one bad write would poison the offset
180/// signal permanently (every later frame reads NaN). Central helper so all
181/// `set_offset`/`scroll_immediate` paths stay sanitized long-term.
182fn clamp_offset(off: f32, max: f32) -> f32 {
183    let max = if max.is_finite() { max.max(0.0) } else { 0.0 };
184    if off.is_finite() {
185        off.clamp(0.0, max)
186    } else {
187        0.0
188    }
189}
190
191/// Asymptotic rubber band: never reaches `max`, so pulling harder always
192/// moves a little more without ever running away.
193pub fn rubber_band(amount: f32, max: f32) -> f32 {
194    let abs_val = amount.abs();
195    if abs_val <= 0.0 {
196        return 0.0;
197    }
198    (1.0 - 1.0 / (1.0 + abs_val / max)) * max * amount.signum()
199}
200
201/// Fold `leftover` into one axis's rubber band after the nested parent chain
202/// has declined it. Returns whatever is still unconsumed.
203pub fn os_scroll_axis(
204    off: &Signal<f32>,
205    os: &Signal<f32>,
206    overscroll_enabled: bool,
207    max_off: f32,
208    before: f32,
209    leftover: f32,
210    physics: &RefCell<ScrollPhysics>,
211) -> f32 {
212    if !overscroll_enabled || leftover.abs() < 0.001 {
213        return leftover;
214    }
215    let os_val = os.get();
216    // Recovering from an active rubber band: absorb the reverse delta first.
217    if os_val.abs() > 0.5 && os_val.signum() * leftover < 0.0 {
218        let reduction = leftover.abs().min(os_val.abs());
219        os.set(os_val - os_val.signum() * reduction);
220        let remainder = leftover - leftover.signum() * reduction;
221        if remainder.abs() > 0.5 {
222            let new_off = clamp_offset(before + remainder, max_off);
223            off.set(new_off);
224            let consumed = new_off - before;
225            physics.borrow_mut().record_nested_input(consumed);
226            return remainder - consumed;
227        }
228        return 0.0;
229    }
230
231    // At the edge, pushing further: stretch the rubber band.
232    let at_edge = (before <= 0.0 && leftover < 0.0) || (before >= max_off && leftover > 0.0);
233    if max_off > 5.0 && at_edge && leftover.abs() > 0.5 {
234        os.set(os_val + rubber_band(leftover, SCROLL_OVERSROLL_BAND_MAX));
235        physics.borrow_mut().record_nested_input(0.0);
236        return 0.0;
237    }
238    leftover
239}
240
241/// Decay one axis's rubber band back to rest. Returns true while it is active.
242pub fn tick_os_axis(os: &Signal<f32>, enabled: bool, dt: f32) -> bool {
243    if !enabled {
244        return false;
245    }
246    let v = os.get();
247    if v.abs() > 0.5 {
248        let decayed = v * OVERSHOOT_DECAY_PER_60HZ.powf(dt * 60.0);
249        if decayed.abs() < 0.5 {
250            os.set(0.0);
251        } else {
252            os.set(decayed);
253        }
254        request_frame();
255        true
256    } else {
257        false
258    }
259}
260
261/// Handles velocity estimation from input deltas, frame-rate-independent
262/// exponential decay, edge snapping, and animation state tracking.
263#[derive(Clone)]
264pub struct ScrollPhysics {
265    pub(crate) vel: f32,
266    last_t: Instant,
267    last_input_t: Instant,
268    pub(crate) animating: bool,
269    /// Set when a delta arrived since the last integration.
270    input_pending: bool,
271    last_dt: f32,
272    decay_per_60hz: f32,
273    stop_velocity: f32,
274    input_activate_velocity: f32,
275}
276
277impl Default for ScrollPhysics {
278    fn default() -> Self {
279        Self::new()
280    }
281}
282
283impl ScrollPhysics {
284    pub fn new() -> Self {
285        Self::with_tuning(
286            SCROLL_DECAY_PER_60HZ,
287            SCROLL_STOP_VELOCITY,
288            SCROLL_FLING_ACTIVATE_VELOCITY,
289        )
290    }
291
292    pub fn with_tuning(
293        decay_per_60hz: f32,
294        stop_velocity: f32,
295        input_activate_velocity: f32,
296    ) -> Self {
297        let now = Instant::now();
298        Self {
299            vel: 0.0,
300            last_t: now,
301            last_input_t: now,
302            animating: false,
303            input_pending: false,
304            last_dt: 1.0 / 60.0,
305            decay_per_60hz,
306            stop_velocity,
307            input_activate_velocity,
308        }
309    }
310
311    /// Feed a delta that arrived from real user input.
312    ///
313    /// A first sample adopts its velocity outright, and a delta opposing an
314    /// in-flight fling takes over from it. Both mirror Compose, where a new
315    /// `scroll(MutatePriority.UserInput)` cancels the fling coroutine holding
316    /// the `MutatorMutex` so the drag wins. Smoothing only applies between
317    /// samples of one gesture: averaging up from zero on every frame would
318    /// leave every flick at a fraction of its real speed.
319    pub fn record_user_input(&mut self, consumed: f32) {
320        let (instant_vel, fresh) = self.sample(consumed);
321        if fresh || (self.animating && instant_vel * self.vel < 0.0) {
322            self.vel = instant_vel;
323        } else {
324            const SMOOTHING: f32 = 0.35;
325            self.vel = self.vel * (1.0 - SMOOTHING) + instant_vel * SMOOTHING;
326        }
327        self.finish_sample();
328    }
329
330    /// Feed a delta that arrived as leftover from a nested child. Never takes
331    /// over an in-flight fling: Compose's `dispatchRawDelta` bypasses mutual
332    /// exclusion precisely so a parent keeps coasting.
333    pub fn record_nested_input(&mut self, consumed: f32) {
334        let (instant_vel, _) = self.sample(consumed);
335        const SMOOTHING: f32 = 0.35;
336        self.vel = self.vel * (1.0 - SMOOTHING) + instant_vel * SMOOTHING;
337        self.finish_sample();
338    }
339
340    fn sample(&mut self, consumed: f32) -> (f32, bool) {
341        let now = Instant::now();
342        let raw_dt = (now - self.last_input_t).as_secs_f32();
343        let dt = raw_dt.clamp(1.0 / 240.0, 1.0 / 15.0);
344        self.last_dt = dt;
345        // A gap this long means a new gesture rather than another sample.
346        let fresh = raw_dt > 0.05;
347        self.last_input_t = now;
348        if raw_dt > 0.1 {
349            self.vel = 0.0;
350        }
351        (consumed / dt, fresh)
352    }
353
354    fn finish_sample(&mut self) {
355        const MAX_VEL: f32 = 8000.0;
356        self.vel = self.vel.clamp(-MAX_VEL, MAX_VEL);
357        self.animating = self.vel.abs() > self.input_activate_velocity;
358        self.input_pending = true;
359    }
360
361    fn dt(&mut self) -> f32 {
362        let now = Instant::now();
363        let dt = (now - self.last_t).as_secs_f32().min(0.1);
364        self.last_t = now;
365        dt
366    }
367
368    pub fn tick_integrate(&mut self, current: f32, min: f32, max: f32) -> Option<f32> {
369        // Sample the clock first, unconditionally. Bailing out before this
370        // would let `last_t` go stale for the whole duration of a drag and
371        // then dump all of it into the first fling frame, which shows up as a
372        // single large lurch the moment the finger lifts.
373        let dt = self.dt();
374        if self.input_pending {
375            // A delta was applied since the last integration, so this frame's
376            // motion is already accounted for. Compose feeds velocity from the
377            // gesture layer and runs no integrator mid-drag; we infer velocity
378            // from the same delta stream we also apply, so integrating here
379            // would move the surface by roughly 2x.
380            self.input_pending = false;
381            return None;
382        }
383        if !self.animating {
384            return None;
385        }
386        if dt <= 0.0 {
387            return None;
388        }
389        let vel0 = self.vel;
390        if vel0.abs() < self.stop_velocity {
391            self.vel = 0.0;
392            self.animating = false;
393            return None;
394        }
395        let new = {
396            let raw = current + vel0 * dt;
397            let raw = if raw.is_finite() { raw } else { current };
398            raw.clamp(min, max.max(min))
399        };
400        if (new - current).abs() < 0.01 && (current <= min || current >= max) {
401            self.vel = 0.0;
402            self.animating = false;
403            return None;
404        }
405        let speed = self.vel.abs();
406        let t = (speed / 4000.0).min(1.0);
407        let effective_decay = (self.decay_per_60hz + t * 0.10).min(0.999);
408        let decay = effective_decay.powf(dt * 60.0);
409        self.vel = vel0 * decay;
410        if self.vel.abs() < self.stop_velocity {
411            self.vel = 0.0;
412            self.animating = false;
413            return None;
414        }
415        Some(new)
416    }
417
418    pub fn is_animating(&self) -> bool {
419        self.animating
420    }
421}
422
423/// Inertial scroll state (single axis Y).
424#[derive(Clone)]
425pub struct ScrollState {
426    scroll_offset: Signal<f32>,
427    viewport_height: Signal<f32>,
428    content_height: Signal<f32>,
429    physics: Rc<RefCell<ScrollPhysics>>,
430    overscroll: Signal<f32>,
431    overscroll_enabled: Cell<bool>,
432    pub(crate) parent_connection: Rc<RefCell<Option<NestedScrollConnection>>>,
433    show_scrollbar: Cell<bool>,
434    prev_tick: Cell<Instant>,
435}
436
437impl Default for ScrollState {
438    fn default() -> Self {
439        Self::new()
440    }
441}
442
443impl ScrollState {
444    pub fn new() -> Self {
445        Self {
446            scroll_offset: signal(0.0),
447            viewport_height: signal(0.0),
448            content_height: signal(0.0),
449            physics: Rc::new(RefCell::new(ScrollPhysics::new())),
450            overscroll: signal(0.0),
451            overscroll_enabled: Cell::new(true),
452            parent_connection: Rc::new(RefCell::new(None)),
453            show_scrollbar: Cell::new(true),
454            prev_tick: Cell::new(Instant::now()),
455        }
456    }
457
458    pub fn set_overscroll_enabled(&self, enabled: bool) {
459        self.overscroll_enabled.set(enabled);
460    }
461
462    pub fn overscroll_offset(&self) -> f32 {
463        self.overscroll.get()
464    }
465
466    pub fn set_nested_scroll_parent(&self, conn: NestedScrollConnection) {
467        *self.parent_connection.borrow_mut() = Some(conn);
468    }
469
470    /// Use it to wire a child scrollable to a parent scroll container
471    /// explicitly: `child_state.set_nested_scroll_parent(parent_state.connection())`.
472    pub fn connection(&self) -> NestedScrollConnection {
473        let this = Rc::new(self.clone());
474        let pc = Rc::clone(&this.parent_connection);
475        NestedScrollConnection::new().on_post_scroll(
476            move |_consumed: Vec2, available: Vec2, _source: NestedScrollSource| -> Vec2 {
477                if available.x.abs() < 0.001 && available.y.abs() < 0.001 {
478                    return Vec2::ZERO;
479                }
480                let leftover_y = this.scroll_nested(available.y);
481                let after = run_post_scroll(
482                    &pc,
483                    Vec2 {
484                        x: available.x,
485                        y: leftover_y,
486                    },
487                );
488                Vec2 {
489                    x: available.x - after.x,
490                    y: available.y - after.y,
491                }
492            },
493        )
494    }
495
496    pub fn set_show_scrollbar(&self, show: bool) {
497        self.show_scrollbar.set(show);
498    }
499
500    pub fn set_viewport_height(&self, h: f32) {
501        let h = h.max(0.0);
502        if (self.viewport_height.get() - h).abs() > 0.5 {
503            self.viewport_height.set(h);
504            self.clamp_offset();
505        }
506    }
507
508    pub fn set_content_height(&self, h: f32) {
509        let h = h.max(0.0);
510        if (self.content_height.get() - h).abs() > 0.5 {
511            self.content_height.set(h);
512            self.clamp_offset();
513        }
514    }
515
516    pub fn set_overscroll(&self, val: f32) {
517        self.overscroll.set(val);
518    }
519
520    pub fn set_offset(&self, off: f32) {
521        let off = if off.is_finite() { off } else { 0.0 };
522        let vh = self.viewport_height.get();
523        let ch = self.content_height.get();
524        let max_off = (ch - vh).max(0.0);
525        self.scroll_offset.set(clamp_offset(off, max_off));
526    }
527
528    fn clamp_offset(&self) {
529        let vh = self.viewport_height.get();
530        let ch = self.content_height.get();
531        let max_off = (ch - vh).max(0.0);
532        self.scroll_offset.update(|o| {
533            if *o > max_off {
534                *o = max_off;
535            }
536            if *o < 0.0 {
537                *o = 0.0;
538            }
539        });
540    }
541
542    pub fn get(&self) -> f32 {
543        self.scroll_offset.get()
544    }
545
546    /// Apply a scroll delta from real user input. Pre-empts any in-flight
547    /// fling, matching Compose where a new `scroll(UserInput)` cancels the
548    /// fling coroutine holding the `MutatorMutex`.
549    pub fn scroll_immediate(&self, dy: f32) -> f32 {
550        self.apply_delta(dy, true)
551    }
552
553    /// Apply a delta that arrived as leftover from a nested child. This is
554    /// Compose's `dispatchRawDelta`: it bypasses mutual exclusion, so it must
555    /// not cancel this container's own fling.
556    pub fn scroll_nested(&self, dy: f32) -> f32 {
557        self.apply_delta(dy, false)
558    }
559
560    fn apply_delta(&self, dy: f32, user: bool) -> f32 {
561        let dy = if dy.is_finite() { dy } else { 0.0 };
562        let before = self.scroll_offset.get();
563        let vh = self.viewport_height.get();
564        let ch = self.content_height.get();
565        let max_off = (ch - vh).max(0.0);
566
567        let new_off = clamp_offset(before + dy, max_off);
568        self.scroll_offset.set(new_off);
569        let consumed = new_off - before;
570        if user {
571            self.physics.borrow_mut().record_user_input(consumed);
572        } else {
573            self.physics.borrow_mut().record_nested_input(consumed);
574        }
575        dy - consumed
576    }
577
578    /// Feed leftover (after the nested parent chain has had its chance via
579    /// post-scroll) into the rubber-band overscroll. Returns the amount still
580    /// unconsumed. Overscroll deliberately runs LAST so parents get first dibs.
581    pub fn apply_overscroll(&self, leftover: f32) -> f32 {
582        let vh = self.viewport_height.get();
583        let ch = self.content_height.get();
584        os_scroll_axis(
585            &self.scroll_offset,
586            &self.overscroll,
587            self.overscroll_enabled.get(),
588            (ch - vh).max(0.0),
589            self.scroll_offset.get(),
590            leftover,
591            &self.physics,
592        )
593    }
594
595    pub fn tick(&self) -> bool {
596        let now = Instant::now();
597        let dt = (now - self.prev_tick.get()).as_secs_f32().min(0.1);
598        self.prev_tick.set(now);
599
600        if tick_os_axis(&self.overscroll, self.overscroll_enabled.get(), dt) {
601            return true;
602        }
603
604        let vh = self.viewport_height.get();
605        let ch = self.content_height.get();
606        let max_off = (ch - vh).max(0.0);
607
608        let mut p = self.physics.borrow_mut();
609        if let Some(new_off) = p.tick_integrate(self.scroll_offset.get(), 0.0, max_off) {
610            drop(p);
611            self.scroll_offset.set(new_off);
612            request_frame();
613            true
614        } else {
615            false
616        }
617    }
618
619    pub fn show_scrollbar(&self) -> bool {
620        self.show_scrollbar.get()
621    }
622
623    /// Build a `ScrollBinding::Vertical` from this state for use with `Modifier::vertical_scroll`.
624    pub fn to_binding(&self) -> ScrollBinding {
625        let this = Rc::new(self.clone());
626        let pc = Rc::clone(&this.parent_connection);
627        let c_on_scroll = {
628            let state = Rc::clone(&pc);
629            let this = Rc::clone(&this);
630            Rc::new(move |d: Vec2| -> Vec2 {
631                let d = run_pre_scroll(&state, d);
632                // Clamp this scroller first; leftover goes to the nested parent
633                // chain (post-scroll) BEFORE rubber-band overscroll.
634                let leftover_y = this.scroll_immediate(d.y);
635                let result = Vec2 {
636                    x: d.x,
637                    y: leftover_y,
638                };
639                let after_parent = run_post_scroll(&state, result);
640                let final_y = this.apply_overscroll(after_parent.y);
641                Vec2 {
642                    x: after_parent.x,
643                    y: final_y,
644                }
645            })
646        } as Rc<dyn Fn(Vec2) -> Vec2>;
647        let c_set_viewport = {
648            let this = Rc::clone(&this);
649            Rc::new(move |h: f32| this.set_viewport_height(h))
650        };
651        let c_set_content = {
652            let this = Rc::clone(&this);
653            Rc::new(move |h: f32| this.set_content_height(h))
654        };
655        let c_get = {
656            let this = Rc::clone(&this);
657            Rc::new(move || this.get() + this.overscroll_offset())
658        };
659        let c_set = {
660            let this = Rc::clone(&this);
661            Rc::new(move |off: f32| this.set_offset(off))
662        };
663        let c_tick = {
664            let this = Rc::clone(&this);
665            Rc::new(move || {
666                this.tick();
667            })
668        };
669        let c_set_nested = {
670            let state = Rc::clone(&pc);
671            Rc::new(move |conn| {
672                *state.borrow_mut() = Some(conn);
673            })
674        };
675        ScrollBinding::Vertical(ScrollAxisBinding {
676            on_scroll: Some(c_on_scroll),
677            set_viewport_main: Some(c_set_viewport),
678            set_content_main: Some(c_set_content),
679            get_offset_main: Some(c_get),
680            set_offset_main: Some(c_set),
681            show_scrollbar: self.show_scrollbar(),
682            tick: Some(c_tick),
683            set_nested_scroll_parent: Some(c_set_nested),
684        })
685    }
686}
687
688/// X-only state
689#[derive(Clone)]
690pub struct HorizontalScrollState {
691    scroll_offset: Signal<f32>,
692    viewport_width: Signal<f32>,
693    content_width: Signal<f32>,
694    physics: Rc<RefCell<ScrollPhysics>>,
695    overscroll: Signal<f32>,
696    overscroll_enabled: Cell<bool>,
697    pub(crate) parent_connection: Rc<RefCell<Option<NestedScrollConnection>>>,
698    show_scrollbar: Cell<bool>,
699    prev_tick: Cell<Instant>,
700}
701
702impl Default for HorizontalScrollState {
703    fn default() -> Self {
704        Self::new()
705    }
706}
707
708impl HorizontalScrollState {
709    pub fn new() -> Self {
710        Self {
711            scroll_offset: signal(0.0),
712            viewport_width: signal(0.0),
713            content_width: signal(0.0),
714            physics: Rc::new(RefCell::new(ScrollPhysics::new())),
715            overscroll: signal(0.0),
716            overscroll_enabled: Cell::new(true),
717            parent_connection: Rc::new(RefCell::new(None)),
718            show_scrollbar: Cell::new(true),
719            prev_tick: Cell::new(Instant::now()),
720        }
721    }
722
723    pub fn set_overscroll_enabled(&self, enabled: bool) {
724        self.overscroll_enabled.set(enabled);
725    }
726
727    pub fn overscroll_offset(&self) -> f32 {
728        self.overscroll.get()
729    }
730
731    pub fn set_nested_scroll_parent(&self, conn: NestedScrollConnection) {
732        *self.parent_connection.borrow_mut() = Some(conn);
733    }
734
735    /// A `NestedScrollConnection` that consumes leftover scroll by scrolling
736    /// THIS container, then chains anything still unabsorbed up to this
737    /// container's own parent connection.
738    pub fn connection(&self) -> NestedScrollConnection {
739        let this = Rc::new(self.clone());
740        let pc = Rc::clone(&this.parent_connection);
741        NestedScrollConnection::new().on_post_scroll(
742            move |_consumed: Vec2, available: Vec2, _source: NestedScrollSource| -> Vec2 {
743                if available.x.abs() < 0.001 && available.y.abs() < 0.001 {
744                    return Vec2::ZERO;
745                }
746                let leftover_x = this.scroll_nested(available.x);
747                let after = run_post_scroll(
748                    &pc,
749                    Vec2 {
750                        x: leftover_x,
751                        y: available.y,
752                    },
753                );
754                Vec2 {
755                    x: available.x - after.x,
756                    y: available.y - after.y,
757                }
758            },
759        )
760    }
761
762    pub fn set_show_scrollbar(&self, show: bool) {
763        self.show_scrollbar.set(show);
764    }
765
766    pub fn set_viewport_width(&self, w: f32) {
767        let w = w.max(0.0);
768        if (self.viewport_width.get() - w).abs() > 0.5 {
769            self.viewport_width.set(w);
770            self.clamp();
771        }
772    }
773
774    pub fn set_content_width(&self, w: f32) {
775        let w = w.max(0.0);
776        if (self.content_width.get() - w).abs() > 0.5 {
777            self.content_width.set(w);
778            self.clamp();
779        }
780    }
781
782    pub fn set_overscroll(&self, val: f32) {
783        self.overscroll.set(val);
784    }
785
786    pub fn set_offset(&self, off: f32) {
787        let max_off = (self.content_width.get() - self.viewport_width.get()).max(0.0);
788        self.scroll_offset.set(clamp_offset(off, max_off));
789    }
790
791    fn clamp(&self) {
792        let max_off = (self.content_width.get() - self.viewport_width.get()).max(0.0);
793        self.scroll_offset.update(|o| {
794            *o = clamp_offset(*o, max_off);
795        });
796    }
797
798    pub fn get(&self) -> f32 {
799        self.scroll_offset.get()
800    }
801
802    /// Apply a scroll delta from real user input; pre-empts any in-flight
803    /// fling. See [`ScrollState::scroll_immediate`].
804    pub fn scroll_immediate(&self, dx: f32) -> f32 {
805        self.apply_delta(dx, true)
806    }
807
808    /// Apply leftover from a nested child without cancelling this container's
809    /// own fling. See [`ScrollState::scroll_nested`].
810    pub fn scroll_nested(&self, dx: f32) -> f32 {
811        self.apply_delta(dx, false)
812    }
813
814    fn apply_delta(&self, dx: f32, user: bool) -> f32 {
815        let dx = if dx.is_finite() { dx } else { 0.0 };
816        let before = self.scroll_offset.get();
817        let max_off = (self.content_width.get() - self.viewport_width.get()).max(0.0);
818
819        let new_off = clamp_offset(before + dx, max_off);
820        self.scroll_offset.set(new_off);
821        let consumed = new_off - before;
822        if user {
823            self.physics.borrow_mut().record_user_input(consumed);
824        } else {
825            self.physics.borrow_mut().record_nested_input(consumed);
826        }
827        dx - consumed
828    }
829
830    /// Feed leftover (after the nested parent chain) into rubber-band overscroll.
831    pub fn apply_overscroll(&self, leftover: f32) -> f32 {
832        let max_off = (self.content_width.get() - self.viewport_width.get()).max(0.0);
833        os_scroll_axis(
834            &self.scroll_offset,
835            &self.overscroll,
836            self.overscroll_enabled.get(),
837            max_off,
838            self.scroll_offset.get(),
839            leftover,
840            &self.physics,
841        )
842    }
843
844    pub fn tick(&self) -> bool {
845        let now = Instant::now();
846        let dt = (now - self.prev_tick.get()).as_secs_f32().min(0.1);
847        self.prev_tick.set(now);
848
849        if tick_os_axis(&self.overscroll, self.overscroll_enabled.get(), dt) {
850            return true;
851        }
852
853        let max_off = (self.content_width.get() - self.viewport_width.get()).max(0.0);
854        let mut p = self.physics.borrow_mut();
855        if let Some(new_off) = p.tick_integrate(self.scroll_offset.get(), 0.0, max_off) {
856            drop(p);
857            self.scroll_offset.set(new_off);
858            request_frame();
859            true
860        } else {
861            false
862        }
863    }
864
865    pub fn show_scrollbar(&self) -> bool {
866        self.show_scrollbar.get()
867    }
868
869    pub fn to_binding(&self) -> ScrollBinding {
870        let this = Rc::new(self.clone());
871        let pc = Rc::clone(&this.parent_connection);
872        let c_on_scroll = {
873            let state = Rc::clone(&pc);
874            let this = Rc::clone(&this);
875            Rc::new(move |d: Vec2| -> Vec2 {
876                let d = run_pre_scroll(&state, d);
877                let leftover_x = this.scroll_immediate(d.x);
878                let result = Vec2 {
879                    x: leftover_x,
880                    y: d.y,
881                };
882                let after_parent = run_post_scroll(&state, result);
883                let final_x = this.apply_overscroll(after_parent.x);
884                Vec2 {
885                    x: final_x,
886                    y: after_parent.y,
887                }
888            })
889        } as Rc<dyn Fn(Vec2) -> Vec2>;
890        let c_set_vp = {
891            let this = Rc::clone(&this);
892            Rc::new(move |w: f32| this.set_viewport_width(w))
893        };
894        let c_set_ct = {
895            let this = Rc::clone(&this);
896            Rc::new(move |w: f32| this.set_content_width(w))
897        };
898        let c_get = {
899            let this = Rc::clone(&this);
900            Rc::new(move || this.get() + this.overscroll_offset())
901        };
902        let c_set = {
903            let this = Rc::clone(&this);
904            Rc::new(move |off: f32| this.set_offset(off))
905        };
906        let c_tick = {
907            let this = Rc::clone(&this);
908            Rc::new(move || {
909                this.tick();
910            })
911        };
912        let c_set_nested = {
913            let state = Rc::clone(&pc);
914            Rc::new(move |conn| {
915                *state.borrow_mut() = Some(conn);
916            })
917        };
918        ScrollBinding::Horizontal(ScrollAxisBinding {
919            on_scroll: Some(c_on_scroll),
920            set_viewport_main: Some(c_set_vp),
921            set_content_main: Some(c_set_ct),
922            get_offset_main: Some(c_get),
923            set_offset_main: Some(c_set),
924            show_scrollbar: this.show_scrollbar(),
925            tick: Some(c_tick),
926            set_nested_scroll_parent: Some(c_set_nested),
927        })
928    }
929}
930
931/// 2D state
932#[derive(Clone)]
933pub struct ScrollStateXY {
934    off_x: Signal<f32>,
935    off_y: Signal<f32>,
936    vp_w: Signal<f32>,
937    vp_h: Signal<f32>,
938    c_w: Signal<f32>,
939    c_h: Signal<f32>,
940    physics_x: Rc<RefCell<ScrollPhysics>>,
941    physics_y: Rc<RefCell<ScrollPhysics>>,
942    os_x: Signal<f32>,
943    os_y: Signal<f32>,
944    overscroll_enabled: Cell<bool>,
945    pub(crate) parent_connection: Rc<RefCell<Option<NestedScrollConnection>>>,
946    show_scrollbar: Cell<bool>,
947    prev_tick: Cell<Instant>,
948}
949
950impl Default for ScrollStateXY {
951    fn default() -> Self {
952        Self::new()
953    }
954}
955
956impl ScrollStateXY {
957    pub fn new() -> Self {
958        Self {
959            off_x: signal(0.0),
960            off_y: signal(0.0),
961            vp_w: signal(0.0),
962            vp_h: signal(0.0),
963            c_w: signal(0.0),
964            c_h: signal(0.0),
965            physics_x: Rc::new(RefCell::new(ScrollPhysics::new())),
966            physics_y: Rc::new(RefCell::new(ScrollPhysics::new())),
967            os_x: signal(0.0),
968            os_y: signal(0.0),
969            overscroll_enabled: Cell::new(true),
970            parent_connection: Rc::new(RefCell::new(None)),
971            show_scrollbar: Cell::new(true),
972            prev_tick: Cell::new(Instant::now()),
973        }
974    }
975
976    pub fn set_overscroll_enabled(&self, enabled: bool) {
977        self.overscroll_enabled.set(enabled);
978    }
979
980    pub fn set_show_scrollbar(&self, show: bool) {
981        self.show_scrollbar.set(show);
982    }
983
984    pub fn overscroll_offset(&self) -> (f32, f32) {
985        (self.os_x.get(), self.os_y.get())
986    }
987
988    pub fn set_nested_scroll_parent(&self, conn: NestedScrollConnection) {
989        *self.parent_connection.borrow_mut() = Some(conn);
990    }
991
992    /// A `NestedScrollConnection` that consumes leftover scroll by scrolling
993    /// THIS container, then chains anything still unabsorbed up to this
994    /// container's own parent connection.
995    pub fn connection(&self) -> NestedScrollConnection {
996        let this = Rc::new(self.clone());
997        let pc = Rc::clone(&this.parent_connection);
998        NestedScrollConnection::new().on_post_scroll(
999            move |_consumed: Vec2, available: Vec2, _source: NestedScrollSource| -> Vec2 {
1000                if available.x.abs() < 0.001 && available.y.abs() < 0.001 {
1001                    return Vec2::ZERO;
1002                }
1003                let after = this.scroll_nested(available);
1004                let after2 = run_post_scroll(&pc, after);
1005                Vec2 {
1006                    x: available.x - after2.x,
1007                    y: available.y - after2.y,
1008                }
1009            },
1010        )
1011    }
1012
1013    pub fn set_viewport(&self, w: f32, h: f32) {
1014        let w = w.max(0.0);
1015        let h = h.max(0.0);
1016        let changed = (self.vp_w.get() - w).abs() > 0.5 || (self.vp_h.get() - h).abs() > 0.5;
1017        if changed {
1018            self.vp_w.set(w);
1019            self.vp_h.set(h);
1020            self.clamp();
1021        }
1022    }
1023    pub fn set_content(&self, w: f32, h: f32) {
1024        let w = w.max(0.0);
1025        let h = h.max(0.0);
1026        let changed = (self.c_w.get() - w).abs() > 0.5 || (self.c_h.get() - h).abs() > 0.5;
1027        if changed {
1028            self.c_w.set(w);
1029            self.c_h.set(h);
1030            self.clamp();
1031        }
1032    }
1033    pub fn set_offset_xy(&self, x: f32, y: f32) {
1034        let max_x = (self.c_w.get() - self.vp_w.get()).max(0.0);
1035        let max_y = (self.c_h.get() - self.vp_h.get()).max(0.0);
1036        self.off_x.set(clamp_offset(x, max_x));
1037        self.off_y.set(clamp_offset(y, max_y));
1038    }
1039    fn clamp(&self) {
1040        let max_x = (self.c_w.get() - self.vp_w.get()).max(0.0);
1041        let max_y = (self.c_h.get() - self.vp_h.get()).max(0.0);
1042        self.off_x.update(|x| *x = clamp_offset(*x, max_x));
1043        self.off_y.update(|y| *y = clamp_offset(*y, max_y));
1044    }
1045    pub fn get(&self) -> (f32, f32) {
1046        (self.off_x.get(), self.off_y.get())
1047    }
1048
1049    fn os_scroll_axis(
1050        off: &Signal<f32>,
1051        os: &Signal<f32>,
1052        overscroll_enabled: bool,
1053        max_off: f32,
1054        before: f32,
1055        leftover: f32,
1056        physics: &RefCell<ScrollPhysics>,
1057    ) -> f32 {
1058        if !overscroll_enabled || leftover.abs() < 0.001 {
1059            return leftover;
1060        }
1061        let os_val = os.get();
1062        // Recovering from an active rubber band: absorb the reverse delta first.
1063        if os_val.abs() > 0.5 && os_val.signum() * leftover < 0.0 {
1064            let reduction = leftover.abs().min(os_val.abs());
1065            os.set(os_val - os_val.signum() * reduction);
1066            let remainder = leftover - leftover.signum() * reduction;
1067            if remainder.abs() > 0.5 {
1068                let new_off = clamp_offset(before + remainder, max_off);
1069                off.set(new_off);
1070                let consumed = new_off - before;
1071                physics.borrow_mut().record_nested_input(consumed);
1072                return remainder - consumed;
1073            }
1074            return 0.0;
1075        }
1076
1077        // At the edge, pushing further: stretch the rubber band.
1078        let at_edge = (before <= 0.0 && leftover < 0.0) || (before >= max_off && leftover > 0.0);
1079        if max_off > 5.0 && at_edge && leftover.abs() > 0.5 {
1080            os.set(os_val + rubber_band(leftover, SCROLL_OVERSROLL_BAND_MAX));
1081            physics.borrow_mut().record_nested_input(0.0);
1082            return 0.0;
1083        }
1084        leftover
1085    }
1086    /// Apply a scroll delta from real user input; pre-empts any in-flight
1087    /// fling. See [`ScrollState::scroll_immediate`].
1088    pub fn scroll_immediate(&self, d: Vec2) -> Vec2 {
1089        self.apply_delta(d, true)
1090    }
1091
1092    /// Apply leftover from a nested child without cancelling this container's
1093    /// own fling. See [`ScrollState::scroll_nested`].
1094    pub fn scroll_nested(&self, d: Vec2) -> Vec2 {
1095        self.apply_delta(d, false)
1096    }
1097
1098    fn apply_delta(&self, d: Vec2, user: bool) -> Vec2 {
1099        let d = Vec2 {
1100            x: if d.x.is_finite() { d.x } else { 0.0 },
1101            y: if d.y.is_finite() { d.y } else { 0.0 },
1102        };
1103        let max_x = (self.c_w.get() - self.vp_w.get()).max(0.0);
1104        let max_y = (self.c_h.get() - self.vp_h.get()).max(0.0);
1105        let (bx, by) = (self.off_x.get(), self.off_y.get());
1106
1107        let nx = clamp_offset(bx + d.x, max_x);
1108        let ny = clamp_offset(by + d.y, max_y);
1109        self.off_x.set(nx);
1110        self.off_y.set(ny);
1111        let (cx, cy) = (nx - bx, ny - by);
1112
1113        let mut px = self.physics_x.borrow_mut();
1114        let mut py = self.physics_y.borrow_mut();
1115        if user {
1116            px.record_user_input(cx);
1117            py.record_user_input(cy);
1118        } else {
1119            px.record_nested_input(cx);
1120            py.record_nested_input(cy);
1121        }
1122        drop((px, py));
1123
1124        Vec2 {
1125            x: d.x - cx,
1126            y: d.y - cy,
1127        }
1128    }
1129
1130    /// Feed leftover (after the nested parent chain) into rubber-band overscroll.
1131    pub fn apply_overscroll(&self, d: Vec2) -> Vec2 {
1132        let max_x = (self.c_w.get() - self.vp_w.get()).max(0.0);
1133        let max_y = (self.c_h.get() - self.vp_h.get()).max(0.0);
1134        let (bx, by) = (self.off_x.get(), self.off_y.get());
1135
1136        let lx = Self::os_scroll_axis(
1137            &self.off_x,
1138            &self.os_x,
1139            self.overscroll_enabled.get(),
1140            max_x,
1141            bx,
1142            d.x,
1143            &self.physics_x,
1144        );
1145        let ly = Self::os_scroll_axis(
1146            &self.off_y,
1147            &self.os_y,
1148            self.overscroll_enabled.get(),
1149            max_y,
1150            by,
1151            d.y,
1152            &self.physics_y,
1153        );
1154        Vec2 { x: lx, y: ly }
1155    }
1156    pub fn tick(&self) -> bool {
1157        let now = Instant::now();
1158        let dt = (now - self.prev_tick.get()).as_secs_f32().min(0.1);
1159        self.prev_tick.set(now);
1160
1161        if self.overscroll_enabled.get()
1162            && (tick_os_axis(&self.os_x, true, dt) || tick_os_axis(&self.os_y, true, dt))
1163        {
1164            return true;
1165        }
1166
1167        let max_x = (self.c_w.get() - self.vp_w.get()).max(0.0);
1168        let max_y = (self.c_h.get() - self.vp_h.get()).max(0.0);
1169        let (bx, by) = (self.off_x.get(), self.off_y.get());
1170
1171        let mut px = self.physics_x.borrow_mut();
1172        let mut py = self.physics_y.borrow_mut();
1173        if !px.animating && !py.animating {
1174            return false;
1175        }
1176        if px.animating {
1177            if px.vel.abs() < px.stop_velocity {
1178                px.vel = 0.0;
1179                px.animating = false;
1180            } else {
1181                let nx = clamp_offset(bx + px.vel * dt, max_x);
1182                if (nx - bx).abs() < 0.01 && (bx <= 0.0 || bx >= max_x) {
1183                    px.vel = 0.0;
1184                    px.animating = false;
1185                } else {
1186                    let speed = px.vel.abs();
1187                    let t = (speed / 4000.0).min(1.0);
1188                    let effective_decay = 0.85 + t * 0.10;
1189                    px.vel *= effective_decay.powf(dt * 60.0);
1190                    if px.vel.abs() < px.stop_velocity {
1191                        px.vel = 0.0;
1192                        px.animating = false;
1193                    } else {
1194                        self.off_x.set(nx);
1195                    }
1196                }
1197            }
1198        }
1199        if py.animating {
1200            if py.vel.abs() < py.stop_velocity {
1201                py.vel = 0.0;
1202                py.animating = false;
1203            } else {
1204                let ny = clamp_offset(by + py.vel * dt, max_y);
1205                if (ny - by).abs() < 0.01 && (by <= 0.0 || by >= max_y) {
1206                    py.vel = 0.0;
1207                    py.animating = false;
1208                } else {
1209                    let speed = py.vel.abs();
1210                    let t = (speed / 4000.0).min(1.0);
1211                    let effective_decay = 0.85 + t * 0.10;
1212                    py.vel *= effective_decay.powf(dt * 60.0);
1213                    if py.vel.abs() < py.stop_velocity {
1214                        py.vel = 0.0;
1215                        py.animating = false;
1216                    } else {
1217                        self.off_y.set(ny);
1218                    }
1219                }
1220            }
1221        }
1222        let running = px.animating || py.animating;
1223        if running {
1224            request_frame();
1225        }
1226        running
1227    }
1228
1229    pub fn show_scrollbar(&self) -> bool {
1230        self.show_scrollbar.get()
1231    }
1232
1233    pub fn to_binding(&self) -> ScrollBinding {
1234        let this = Rc::new(self.clone());
1235        let pc = Rc::clone(&this.parent_connection);
1236        let c_on_scroll = {
1237            let state = Rc::clone(&pc);
1238            let this = Rc::clone(&this);
1239            Rc::new(move |d: Vec2| -> Vec2 {
1240                let d = run_pre_scroll(&state, d);
1241                let result = this.scroll_immediate(d);
1242                let after_parent = run_post_scroll(&state, result);
1243                this.apply_overscroll(after_parent)
1244            })
1245        } as Rc<dyn Fn(Vec2) -> Vec2>;
1246        let c_set_vw = {
1247            let this = Rc::clone(&this);
1248            Rc::new(move |w: f32| this.set_viewport(w, this.vp_h.get()))
1249        };
1250        let c_set_vh = {
1251            let this = Rc::clone(&this);
1252            Rc::new(move |h: f32| this.set_viewport(this.vp_w.get(), h))
1253        };
1254        let c_set_cw = {
1255            let this = Rc::clone(&this);
1256            Rc::new(move |w: f32| this.set_content(w, this.c_h.get()))
1257        };
1258        let c_set_ch = {
1259            let this = Rc::clone(&this);
1260            Rc::new(move |h: f32| this.set_content(this.c_w.get(), h))
1261        };
1262        let c_get_xy = {
1263            let this = Rc::clone(&this);
1264            Rc::new(move || {
1265                let (ox, oy) = this.get();
1266                let (osx, osy) = this.overscroll_offset();
1267                (ox + osx, oy + osy)
1268            })
1269        };
1270        let c_set_xy = {
1271            let this = Rc::clone(&this);
1272            Rc::new(move |x: f32, y: f32| this.set_offset_xy(x, y))
1273        };
1274        let c_tick = {
1275            let this = Rc::clone(&this);
1276            Rc::new(move || {
1277                this.tick();
1278            })
1279        };
1280        let c_set_nested = {
1281            let state = Rc::clone(&pc);
1282            Rc::new(move |conn| {
1283                *state.borrow_mut() = Some(conn);
1284            })
1285        };
1286        ScrollBinding::Both(ScrollBothBinding {
1287            on_scroll: Some(c_on_scroll),
1288            set_viewport_width: Some(c_set_vw),
1289            set_viewport_height: Some(c_set_vh),
1290            set_content_width: Some(c_set_cw),
1291            set_content_height: Some(c_set_ch),
1292            get_offset_xy: Some(c_get_xy),
1293            set_offset_xy: Some(c_set_xy),
1294            show_scrollbar: this.show_scrollbar(),
1295            tick: Some(c_tick),
1296            set_nested_scroll_parent: Some(c_set_nested),
1297        })
1298    }
1299}
1300
1301pub fn run_pre_scroll(conn: &RefCell<Option<NestedScrollConnection>>, d: Vec2) -> Vec2 {
1302    if let Some(ref parent) = *conn.borrow() {
1303        let consumed = parent.dispatch_pre_scroll(d, NestedScrollSource::UserInput);
1304        d - consumed
1305    } else {
1306        d
1307    }
1308}
1309
1310pub fn run_post_scroll(conn: &RefCell<Option<NestedScrollConnection>>, leftover: Vec2) -> Vec2 {
1311    if let Some(ref parent) = *conn.borrow() {
1312        let added =
1313            parent.dispatch_post_scroll(Vec2::ZERO, leftover, NestedScrollSource::UserInput);
1314        leftover - added
1315    } else {
1316        leftover
1317    }
1318}
1319
1320#[cfg(test)]
1321mod fling_tests {
1322    use super::*;
1323
1324    const FRAME: web_time::Duration = web_time::Duration::from_millis(16);
1325
1326    fn seeded() -> ScrollState {
1327        let st = ScrollState::new();
1328        st.set_viewport_height(100.0);
1329        st.set_content_height(4000.0);
1330        st
1331    }
1332
1333    /// Advance one 60Hz frame. The physics derives `dt` from the wall clock, so
1334    /// a test that never advances real time cannot observe fling integration at
1335    /// all and would pass even with the double-count restored.
1336    fn frame() {
1337        std::thread::sleep(FRAME);
1338    }
1339
1340    /// A sustained drag must move the surface by the input and nothing more.
1341    /// This is the regression: the per-frame integrator used to run on the same
1342    /// frames that applied the drag delta, so travel was roughly doubled.
1343    #[test]
1344    fn drag_does_not_double_count() {
1345        let st = seeded();
1346        let (frames, step) = (10, 30.0);
1347        for _ in 0..frames {
1348            st.scroll_immediate(step);
1349            frame();
1350            st.tick();
1351        }
1352        let expected = step * frames as f32;
1353        assert!(
1354            (st.get() - expected).abs() < 1.0,
1355            "drag of {expected} over {frames} frames moved {}",
1356            st.get()
1357        );
1358    }
1359
1360    /// Once input stops arriving the stored velocity must carry the surface on
1361    /// as a fling, so a flick still coasts.
1362    #[test]
1363    fn fling_continues_after_input_stops() {
1364        let st = seeded();
1365        st.scroll_immediate(200.0);
1366        frame();
1367        st.tick();
1368        let at_release = st.get();
1369        let mut coasted = 0.0;
1370        for _ in 0..10 {
1371            frame();
1372            if st.tick() {
1373                coasted += 1.0;
1374            }
1375        }
1376        assert!(coasted > 0.0, "fling stopped immediately after release");
1377        assert!(st.get() > at_release, "fling must travel further");
1378    }
1379
1380    /// The first frame after release must continue at roughly the speed the
1381    /// finger was moving. A lurch here reads as a jitter kick: the integrator
1382    /// used to bail out before sampling the clock, so `last_t` went stale for
1383    /// the whole drag and all of it landed in one frame.
1384    #[test]
1385    fn release_is_continuous_with_the_drag() {
1386        for step in [8.0f32, 30.0, 70.0] {
1387            let st = seeded();
1388            for _ in 0..10 {
1389                st.scroll_immediate(step);
1390                frame();
1391                st.tick();
1392            }
1393            let released = st.get();
1394            frame();
1395            st.tick();
1396            let first = st.get() - released;
1397            assert!(
1398                (first - step).abs() < step * 0.25,
1399                "drag at {step}px/frame released into a {first}px step"
1400            );
1401        }
1402    }
1403
1404    /// A drag landing mid-fling follows the finger instead of being yanked by
1405    /// the stale velocity.
1406    #[test]
1407    fn drag_cancels_stale_fling_direction() {
1408        let st = seeded();
1409        for _ in 0..6 {
1410            st.scroll_immediate(200.0);
1411            frame();
1412            st.tick();
1413        }
1414        let before = st.get();
1415        for _ in 0..6 {
1416            st.scroll_immediate(-20.0);
1417            frame();
1418            st.tick();
1419        }
1420        assert!(
1421            st.get() < before,
1422            "reverse drag must win over the old fling ({} -> {})",
1423            before,
1424            st.get()
1425        );
1426    }
1427
1428    /// A parent handed leftover by a child must still be able to fling.
1429    #[test]
1430    fn parent_flings_after_taking_child_leftover() {
1431        let st = seeded();
1432        let conn = st.connection();
1433        let consumed = conn.dispatch_post_scroll(
1434            Vec2::ZERO,
1435            Vec2 { x: 0.0, y: 200.0 },
1436            NestedScrollSource::UserInput,
1437        );
1438        // `on_post_scroll` reports what THIS container consumed.
1439        assert!(
1440            (consumed.y - 200.0).abs() < 0.001,
1441            "consumed {}",
1442            consumed.y
1443        );
1444        assert!(st.get() > 0.0, "parent scrolled");
1445        assert!(st.physics.borrow().is_animating(), "parent has velocity");
1446        frame();
1447        st.tick();
1448        let before_coast = st.get();
1449        frame();
1450        st.tick();
1451        assert!(
1452            st.get() > before_coast,
1453            "parent must coast: nested velocity reached the driven physics"
1454        );
1455    }
1456}