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