Skip to main content

repose_ui/
scroll.rs

1//! # Scroll model
2//!
3//! Repose separates visual scroll containers from scroll state.
4//!
5//! This file implements inertial scroll states.
6//!
7//! Velocities are expressed in px/sec and integrated with dt,
8//! so behavior is frame-rate independent.
9
10use repose_core::*;
11use std::cell::{Cell, RefCell};
12use std::rc::Rc;
13use web_time::Instant;
14
15const OVERSHOOT_DECAY_PER_60HZ: f32 = 0.78;
16
17/// A connection between a nested scrollable and its nearest ancestor scrollable.
18/// Allows coordinated scrolling: the parent can pre-consume deltas before the
19/// child processes them, and post-consume leftovers after the child is done.
20///
21/// This enables patterns like collapsing toolbars (parent pre-consumes upward
22/// scroll) and pull-to-refresh (parent post-consumes overscroll from child).
23pub struct NestedScrollConnection {
24    /// Called with the original delta before the child processes it.
25    /// Return the delta remaining for the child after parent pre-consumption.
26    pub on_pre_scroll: Option<Rc<dyn Fn(Vec2) -> Vec2>>,
27    /// Called with the leftover delta after the child has processed it.
28    /// Return the final leftover (after parent post-consumption).
29    pub on_post_scroll: Option<Rc<dyn Fn(Vec2) -> Vec2>>,
30}
31
32impl Default for NestedScrollConnection {
33    fn default() -> Self {
34        Self::new()
35    }
36}
37
38impl NestedScrollConnection {
39    pub fn new() -> Self {
40        Self {
41            on_pre_scroll: None,
42            on_post_scroll: None,
43        }
44    }
45    /// Pre-consume scroll before the child processes it.
46    /// `f` receives the original delta, returns what remains for the child.
47    pub fn pre_scroll(mut self, f: impl Fn(Vec2) -> Vec2 + 'static) -> Self {
48        self.on_pre_scroll = Some(Rc::new(f));
49        self
50    }
51    /// Post-consume leftover scroll after the child has processed it.
52    /// `f` receives the leftover from the child, returns the final leftover.
53    pub fn post_scroll(mut self, f: impl Fn(Vec2) -> Vec2 + 'static) -> Self {
54        self.on_post_scroll = Some(Rc::new(f));
55        self
56    }
57}
58
59fn run_pre_scroll(conn: &RefCell<Option<NestedScrollConnection>>, d: Vec2) -> Vec2 {
60    if let Some(ref parent) = *conn.borrow()
61        && let Some(ref pre) = parent.on_pre_scroll
62    {
63        return pre(d);
64    }
65    d
66}
67
68fn run_post_scroll(conn: &RefCell<Option<NestedScrollConnection>>, leftover: Vec2) -> Vec2 {
69    if let Some(ref parent) = *conn.borrow()
70        && let Some(ref post) = parent.on_post_scroll
71    {
72        return post(leftover);
73    }
74    leftover
75}
76
77/// Handles velocity estimation from input deltas, frame-rate-independent
78/// exponential decay, edge snapping, and animation state tracking.
79pub struct ScrollPhysics {
80    vel: f32,
81    last_t: Instant,
82    last_input_t: Instant,
83    animating: bool,
84    stop_velocity: f32,
85    input_activate_velocity: f32,
86}
87
88impl ScrollPhysics {
89    pub(crate) fn new(
90        _decay_per_60hz: f32,
91        stop_velocity: f32,
92        input_activate_velocity: f32,
93    ) -> Self {
94        let now = Instant::now();
95        Self {
96            vel: 0.0,
97            last_t: now,
98            last_input_t: now,
99            animating: false,
100            stop_velocity,
101            input_activate_velocity,
102        }
103    }
104
105    /// Record a scroll input of `consumed` px. Estimates velocity using
106    /// EWMA smoothing over recent frames to avoid spikes from frame jitter.
107    /// Capped to `max_velocity` px/s.
108    pub(crate) fn record_input(&mut self, consumed: f32) {
109        let now = Instant::now();
110        let raw_dt = (now - self.last_input_t).as_secs_f32();
111        let dt = raw_dt.clamp(1.0 / 240.0, 1.0 / 15.0);
112        self.last_input_t = now;
113
114        // Reset velocity if a significant gap occurred (finger was lifted/paused)
115        if raw_dt > 0.1 {
116            self.vel = 0.0;
117        }
118
119        let instant_vel = consumed / dt;
120        const SMOOTHING: f32 = 0.35;
121        self.vel = self.vel * (1.0 - SMOOTHING) + instant_vel * SMOOTHING;
122
123        const MAX_VEL: f32 = 8000.0;
124        self.vel = self.vel.clamp(-MAX_VEL, MAX_VEL);
125
126        self.animating = self.vel.abs() > self.input_activate_velocity;
127    }
128
129    /// Return frame dt, capped to avoid physics explosion on lag spikes.
130    pub(crate) fn dt(&mut self) -> f32 {
131        let now = Instant::now();
132        let dt = (now - self.last_t).as_secs_f32().min(0.1);
133        self.last_t = now;
134        dt
135    }
136
137    /// Tick physics: integrate velocity over dt, apply decay, detect edges.
138    /// Returns `Some(new_offset)` if still animating or `None` if stopped.
139    pub(crate) fn tick_integrate(&mut self, current: f32, min: f32, max: f32) -> Option<f32> {
140        if !self.animating {
141            return None;
142        }
143
144        let dt = self.dt();
145        if dt <= 0.0 {
146            return None;
147        }
148
149        let vel0 = self.vel;
150        if vel0.abs() < self.stop_velocity {
151            self.vel = 0.0;
152            self.animating = false;
153            return None;
154        }
155
156        let new = (current + vel0 * dt).clamp(min, max);
157
158        // Hit an edge: stop immediately.
159        if (new - current).abs() < 0.01 && (current <= min || current >= max) {
160            self.vel = 0.0;
161            self.animating = false;
162            return None;
163        }
164
165        // Velocity-dependent decay: more friction at low speeds, less at high.
166        // This matches Android's spline-based fling where effective friction
167        // decreases as velocity increases (ln(0.78)/ln(0.9) spline scaling).
168        let speed = self.vel.abs();
169        let t = (speed / 4000.0).min(1.0);
170        let effective_decay = 0.85 + t * 0.10; // ranges from 0.85 (slow) to 0.95 (fast)
171        let decay = effective_decay.powf(dt * 60.0);
172        self.vel = vel0 * decay;
173
174        // Re-check stop threshold after decay (avoids sub-threshold oscillation).
175        if self.vel.abs() < self.stop_velocity {
176            self.vel = 0.0;
177            self.animating = false;
178            return None;
179        }
180
181        Some(new)
182    }
183
184    pub(crate) fn is_animating(&self) -> bool {
185        self.animating
186    }
187}
188
189/// Inertial scroll state (single axis Y).
190pub struct ScrollState {
191    scroll_offset: Signal<f32>,
192    viewport_height: Signal<f32>,
193    content_height: Signal<f32>,
194    physics: RefCell<ScrollPhysics>,
195    overscroll: Signal<f32>,
196    overscroll_enabled: bool,
197    parent_connection: RefCell<Option<NestedScrollConnection>>,
198    show_scrollbar: Cell<bool>,
199    prev_tick: Cell<Instant>,
200}
201
202impl Default for ScrollState {
203    fn default() -> Self {
204        Self::new()
205    }
206}
207
208impl ScrollState {
209    pub fn new() -> Self {
210        Self {
211            scroll_offset: signal(0.0),
212            viewport_height: signal(0.0),
213            content_height: signal(0.0),
214            physics: RefCell::new(ScrollPhysics::new(0.90, 15.0, 50.0)),
215            overscroll: signal(0.0),
216            overscroll_enabled: true,
217            parent_connection: RefCell::new(None),
218            show_scrollbar: Cell::new(true),
219            prev_tick: Cell::new(Instant::now()),
220        }
221    }
222
223    /// Enable or disable the overscroll rubber-band effect (enabled by default).
224    pub fn set_overscroll_enabled(&mut self, enabled: bool) {
225        self.overscroll_enabled = enabled;
226    }
227
228    /// Returns the current overscroll offset (negative = pulled down past top,
229    /// positive = pulled up past bottom).
230    pub fn overscroll_offset(&self) -> f32 {
231        self.overscroll.get()
232    }
233
234    /// Set a parent nested scroll connection for coordinated scrolling.
235    /// This enables parent-child scroll coordination (e.g., collapsing toolbars,
236    /// pull-to-refresh). Call this before the scroll area is first composed.
237    pub fn set_nested_scroll_parent(&self, conn: NestedScrollConnection) {
238        *self.parent_connection.borrow_mut() = Some(conn);
239    }
240
241    /// Enable or disable the scrollbar (enabled by default).
242    pub fn set_show_scrollbar(&self, show: bool) {
243        self.show_scrollbar.set(show);
244    }
245
246    pub fn set_viewport_height(&self, h: f32) {
247        let h = h.max(0.0);
248        if (self.viewport_height.get() - h).abs() > 0.5 {
249            self.viewport_height.set(h);
250            self.clamp_offset();
251        }
252    }
253    pub fn set_content_height(&self, h: f32) {
254        let h = h.max(0.0);
255        if (self.content_height.get() - h).abs() > 0.5 {
256            self.content_height.set(h);
257            self.clamp_offset();
258        }
259    }
260    /// Set the overscroll value directly. Used by pull-to-refresh to
261    /// reset overscroll when the refresh completes.
262    pub fn set_overscroll(&self, val: f32) {
263        self.overscroll.set(val);
264    }
265    pub fn set_offset(&self, off: f32) {
266        let vh = self.viewport_height.get();
267        let ch = self.content_height.get();
268        let max_off = (ch - vh).max(0.0);
269        self.scroll_offset.set(off.clamp(0.0, max_off));
270    }
271
272    fn clamp_offset(&self) {
273        let vh = self.viewport_height.get();
274        let ch = self.content_height.get();
275        let max_off = (ch - vh).max(0.0);
276        self.scroll_offset.update(|o| {
277            if *o > max_off {
278                *o = max_off;
279            }
280            if *o < 0.0 {
281                *o = 0.0;
282            }
283        });
284    }
285
286    pub fn get(&self) -> f32 {
287        self.scroll_offset.get()
288    }
289
290    /// Consume dy (pixels), clamp to bounds, return leftover.
291    /// When overscroll is enabled and the scroll boundary is reached,
292    /// applies rubber-band resistance instead of returning leftover.
293    pub fn scroll_immediate(&self, dy: f32) -> f32 {
294        let before = self.scroll_offset.get();
295        let vh = self.viewport_height.get();
296        let ch = self.content_height.get();
297        let max_off = (ch - vh).max(0.0);
298
299        // Handle existing overscroll: if scrolling toward reducing it, ease off first
300        let os = self.overscroll.get();
301        if self.overscroll_enabled && os.abs() > 0.5 {
302            // os.signum() * dy < 0  → scrolling toward reducing overscroll
303            if os.signum() * dy < 0.0 {
304                // Reduce overscroll, then process remainder as normal scroll
305                let reduction = dy.abs().min(os.abs());
306                self.overscroll.set(os - os.signum() * reduction);
307                let remainder = dy - dy.signum() * reduction;
308                if remainder.abs() > 0.5 {
309                    let new_off = (before + remainder).clamp(0.0, max_off);
310                    self.scroll_offset.set(new_off);
311                    let consumed = new_off - before;
312                    let leftover = remainder - consumed;
313                    self.physics.borrow_mut().record_input(consumed);
314                    return leftover;
315                }
316                return 0.0;
317            } else {
318                // Scrolling further into overscroll - apply rubber-band
319                let total = os + dy;
320                let bandied = Self::rubber_band(total, 150.0);
321                self.overscroll.set(bandied);
322                self.physics.borrow_mut().record_input(dy);
323                return 0.0;
324            }
325        }
326
327        let can_overscroll = self.overscroll_enabled && max_off > 5.0;
328        let new_off = (before + dy).clamp(0.0, max_off);
329        self.scroll_offset.set(new_off);
330
331        let consumed = new_off - before;
332        let leftover = dy - consumed;
333
334        // If at boundary with leftover, apply rubber-band overscroll
335        if can_overscroll
336            && leftover.abs() > 0.5
337            && ((before <= 0.0 && dy < 0.0) || (before >= max_off && dy > 0.0))
338        {
339            let bandied = Self::rubber_band(leftover, 150.0);
340            self.overscroll.set(os + bandied);
341            self.physics.borrow_mut().record_input(consumed);
342            return 0.0;
343        }
344
345        self.physics.borrow_mut().record_input(consumed);
346
347        leftover
348    }
349
350    /// Rubber-band function: applies increasing resistance as the offset grows.
351    /// `amount` is the raw delta past the boundary, `max` controls the stiffness.
352    /// Lower `max` = stiffer (more resistance). Higher = looser (more travel).
353    fn rubber_band(amount: f32, max: f32) -> f32 {
354        let sign = amount.signum();
355        let abs_val = amount.abs();
356        if abs_val <= 0.0 {
357            return 0.0;
358        }
359        (1.0 - 1.0 / (1.0 + abs_val / max)) * max * sign
360    }
361
362    /// Advance physics one tick; returns true if animating.
363    pub fn tick(&self) -> bool {
364        let now = Instant::now();
365        let dt = (now - self.prev_tick.get()).as_secs_f32().min(0.1);
366        self.prev_tick.set(now);
367
368        if self.overscroll_enabled {
369            let os = self.overscroll.get();
370            if os.abs() > 0.5 {
371                let decayed = os * OVERSHOOT_DECAY_PER_60HZ.powf(dt * 60.0);
372                if decayed.abs() < 0.5 {
373                    self.overscroll.set(0.0);
374                } else {
375                    self.overscroll.set(decayed);
376                }
377                request_frame();
378                return true;
379            }
380        }
381
382        let vh = self.viewport_height.get();
383        let ch = self.content_height.get();
384        let max_off = (ch - vh).max(0.0);
385
386        let mut p = self.physics.borrow_mut();
387        if let Some(new_off) = p.tick_integrate(self.scroll_offset.get(), 0.0, max_off) {
388            drop(p);
389            self.scroll_offset.set(new_off);
390            request_frame();
391            true
392        } else {
393            false
394        }
395    }
396}
397
398/// X-only state
399pub struct HorizontalScrollState {
400    scroll_offset: Signal<f32>,
401    viewport_width: Signal<f32>,
402    content_width: Signal<f32>,
403    physics: RefCell<ScrollPhysics>,
404    overscroll: Signal<f32>,
405    overscroll_enabled: bool,
406    parent_connection: RefCell<Option<NestedScrollConnection>>,
407    show_scrollbar: Cell<bool>,
408    prev_tick: Cell<Instant>,
409}
410
411impl Default for HorizontalScrollState {
412    fn default() -> Self {
413        Self::new()
414    }
415}
416
417impl HorizontalScrollState {
418    pub fn new() -> Self {
419        Self {
420            scroll_offset: signal(0.0),
421            viewport_width: signal(0.0),
422            content_width: signal(0.0),
423            physics: RefCell::new(ScrollPhysics::new(0.90, 15.0, 50.0)),
424            overscroll: signal(0.0),
425            overscroll_enabled: true,
426            parent_connection: RefCell::new(None),
427            show_scrollbar: Cell::new(true),
428            prev_tick: Cell::new(Instant::now()),
429        }
430    }
431
432    /// Enable or disable the overscroll rubber-band effect (enabled by default).
433    pub fn set_overscroll_enabled(&mut self, enabled: bool) {
434        self.overscroll_enabled = enabled;
435    }
436
437    /// Returns the current overscroll offset.
438    pub fn overscroll_offset(&self) -> f32 {
439        self.overscroll.get()
440    }
441
442    /// Set a parent nested scroll connection for coordinated scrolling.
443    pub fn set_nested_scroll_parent(&self, conn: NestedScrollConnection) {
444        *self.parent_connection.borrow_mut() = Some(conn);
445    }
446
447    /// Enable or disable the scrollbar (enabled by default).
448    pub fn set_show_scrollbar(&self, show: bool) {
449        self.show_scrollbar.set(show);
450    }
451
452    pub fn set_viewport_width(&self, w: f32) {
453        let w = w.max(0.0);
454        if (self.viewport_width.get() - w).abs() > 0.5 {
455            self.viewport_width.set(w);
456            self.clamp();
457        }
458    }
459    pub fn set_content_width(&self, w: f32) {
460        let w = w.max(0.0);
461        if (self.content_width.get() - w).abs() > 0.5 {
462            self.content_width.set(w);
463            self.clamp();
464        }
465    }
466    pub fn set_overscroll(&self, val: f32) {
467        self.overscroll.set(val);
468    }
469    pub fn set_offset(&self, off: f32) {
470        let max_off = (self.content_width.get() - self.viewport_width.get()).max(0.0);
471        self.scroll_offset.set(off.clamp(0.0, max_off));
472    }
473    fn clamp(&self) {
474        let max_off = (self.content_width.get() - self.viewport_width.get()).max(0.0);
475        self.scroll_offset.update(|o| {
476            *o = o.clamp(0.0, max_off);
477        });
478    }
479    pub fn get(&self) -> f32 {
480        self.scroll_offset.get()
481    }
482    pub fn scroll_immediate(&self, dx: f32) -> f32 {
483        let before = self.scroll_offset.get();
484        let max_off = (self.content_width.get() - self.viewport_width.get()).max(0.0);
485
486        let os = self.overscroll.get();
487        if self.overscroll_enabled && os.abs() > 0.5 {
488            if os.signum() * dx < 0.0 {
489                let reduction = dx.abs().min(os.abs());
490                self.overscroll.set(os - os.signum() * reduction);
491                let remainder = dx - dx.signum() * reduction;
492                if remainder.abs() > 0.5 {
493                    let new_off = (before + remainder).clamp(0.0, max_off);
494                    self.scroll_offset.set(new_off);
495                    let consumed = new_off - before;
496                    let leftover = remainder - consumed;
497                    self.physics.borrow_mut().record_input(consumed);
498                    return leftover;
499                }
500                return 0.0;
501            } else {
502                let total = os + dx;
503                let bandied = ScrollState::rubber_band(total, 150.0);
504                self.overscroll.set(bandied);
505                self.physics.borrow_mut().record_input(dx);
506                return 0.0;
507            }
508        }
509
510        let can_overscroll = self.overscroll_enabled && max_off > 5.0;
511        let new_off = (before + dx).clamp(0.0, max_off);
512        self.scroll_offset.set(new_off);
513
514        let consumed = new_off - before;
515        let leftover = dx - consumed;
516
517        if can_overscroll
518            && leftover.abs() > 0.5
519            && ((before <= 0.0 && dx < 0.0) || (before >= max_off && dx > 0.0))
520        {
521            let bandied = ScrollState::rubber_band(leftover, 150.0);
522            self.overscroll.set(os + bandied);
523            self.physics.borrow_mut().record_input(consumed);
524            return 0.0;
525        }
526
527        self.physics.borrow_mut().record_input(consumed);
528        leftover
529    }
530    pub fn tick(&self) -> bool {
531        let now = Instant::now();
532        let dt = (now - self.prev_tick.get()).as_secs_f32().min(0.1);
533        self.prev_tick.set(now);
534
535        if self.overscroll_enabled {
536            let os = self.overscroll.get();
537            if os.abs() > 0.5 {
538                let decayed = os * OVERSHOOT_DECAY_PER_60HZ.powf(dt * 60.0);
539                if decayed.abs() < 0.5 {
540                    self.overscroll.set(0.0);
541                } else {
542                    self.overscroll.set(decayed);
543                }
544                request_frame();
545                return true;
546            }
547        }
548
549        let max_off = (self.content_width.get() - self.viewport_width.get()).max(0.0);
550
551        let mut p = self.physics.borrow_mut();
552        if let Some(new_off) = p.tick_integrate(self.scroll_offset.get(), 0.0, max_off) {
553            drop(p);
554            self.scroll_offset.set(new_off);
555            request_frame();
556            true
557        } else {
558            false
559        }
560    }
561}
562
563/// 2D state
564pub struct ScrollStateXY {
565    off_x: Signal<f32>,
566    off_y: Signal<f32>,
567    vp_w: Signal<f32>,
568    vp_h: Signal<f32>,
569    c_w: Signal<f32>,
570    c_h: Signal<f32>,
571    physics_x: RefCell<ScrollPhysics>,
572    physics_y: RefCell<ScrollPhysics>,
573    os_x: Signal<f32>,
574    os_y: Signal<f32>,
575    overscroll_enabled: bool,
576    parent_connection: RefCell<Option<NestedScrollConnection>>,
577    show_scrollbar: Cell<bool>,
578    prev_tick: Cell<Instant>,
579}
580impl Default for ScrollStateXY {
581    fn default() -> Self {
582        Self::new()
583    }
584}
585
586impl ScrollStateXY {
587    pub fn new() -> Self {
588        Self {
589            off_x: signal(0.0),
590            off_y: signal(0.0),
591            vp_w: signal(0.0),
592            vp_h: signal(0.0),
593            c_w: signal(0.0),
594            c_h: signal(0.0),
595            physics_x: RefCell::new(ScrollPhysics::new(0.90, 15.0, 50.0)),
596            physics_y: RefCell::new(ScrollPhysics::new(0.90, 15.0, 50.0)),
597            os_x: signal(0.0),
598            os_y: signal(0.0),
599            overscroll_enabled: true,
600            parent_connection: RefCell::new(None),
601            show_scrollbar: Cell::new(true),
602            prev_tick: Cell::new(Instant::now()),
603        }
604    }
605
606    /// Enable or disable the overscroll rubber-band effect (enabled by default).
607    pub fn set_overscroll_enabled(&mut self, enabled: bool) {
608        self.overscroll_enabled = enabled;
609    }
610
611    /// Enable or disable the scrollbar (enabled by default).
612    pub fn set_show_scrollbar(&self, show: bool) {
613        self.show_scrollbar.set(show);
614    }
615
616    /// Returns the current overscroll offsets as (x, y).
617    pub fn overscroll_offset(&self) -> (f32, f32) {
618        (self.os_x.get(), self.os_y.get())
619    }
620
621    /// Set a parent nested scroll connection for coordinated scrolling.
622    pub fn set_nested_scroll_parent(&self, conn: NestedScrollConnection) {
623        *self.parent_connection.borrow_mut() = Some(conn);
624    }
625
626    pub fn set_viewport(&self, w: f32, h: f32) {
627        let w = w.max(0.0);
628        let h = h.max(0.0);
629        let changed = (self.vp_w.get() - w).abs() > 0.5 || (self.vp_h.get() - h).abs() > 0.5;
630        if changed {
631            self.vp_w.set(w);
632            self.vp_h.set(h);
633            self.clamp();
634        }
635    }
636    pub fn set_content(&self, w: f32, h: f32) {
637        let w = w.max(0.0);
638        let h = h.max(0.0);
639        let changed = (self.c_w.get() - w).abs() > 0.5 || (self.c_h.get() - h).abs() > 0.5;
640        if changed {
641            self.c_w.set(w);
642            self.c_h.set(h);
643            self.clamp();
644        }
645    }
646    pub fn set_offset_xy(&self, x: f32, y: f32) {
647        let max_x = (self.c_w.get() - self.vp_w.get()).max(0.0);
648        let max_y = (self.c_h.get() - self.vp_h.get()).max(0.0);
649        self.off_x.set(x.clamp(0.0, max_x));
650        self.off_y.set(y.clamp(0.0, max_y));
651    }
652    fn clamp(&self) {
653        let max_x = (self.c_w.get() - self.vp_w.get()).max(0.0);
654        let max_y = (self.c_h.get() - self.vp_h.get()).max(0.0);
655        self.off_x.update(|x| *x = x.clamp(0.0, max_x));
656        self.off_y.update(|y| *y = y.clamp(0.0, max_y));
657    }
658    pub fn get(&self) -> (f32, f32) {
659        (self.off_x.get(), self.off_y.get())
660    }
661    fn rubber_band(amount: f32, max: f32) -> f32 {
662        let sign = amount.signum();
663        let abs_val = amount.abs();
664        let result = if abs_val <= 0.0 {
665            0.0
666        } else {
667            (1.0 - 1.0 / (1.0 + abs_val / max)) * max
668        };
669        result * sign
670    }
671    fn os_scroll_axis(
672        os: &Signal<f32>,
673        overscroll_enabled: bool,
674        before: f32,
675        max_off: f32,
676        dx: f32,
677        physics: &mut ScrollPhysics,
678    ) -> f32 {
679        let os_val = os.get();
680        if overscroll_enabled && os_val.abs() > 0.5 {
681            if os_val.signum() * dx < 0.0 {
682                let reduction = dx.abs().min(os_val.abs());
683                os.set(os_val - os_val.signum() * reduction);
684                let remainder = dx - dx.signum() * reduction;
685                if remainder.abs() > 0.5 {
686                    let new_off = (before + remainder).clamp(0.0, max_off);
687                    let consumed = new_off - before;
688                    let leftover = remainder - consumed;
689                    physics.record_input(consumed);
690                    return leftover;
691                }
692                return 0.0;
693            } else {
694                let total = os_val + dx;
695                let bandied = Self::rubber_band(total, 150.0);
696                os.set(bandied);
697                physics.record_input(dx);
698                return 0.0;
699            }
700        }
701
702        let can_os = overscroll_enabled && max_off > 5.0;
703        let new_off = (before + dx).clamp(0.0, max_off);
704        let consumed = new_off - before;
705        let leftover = dx - consumed;
706
707        if can_os
708            && leftover.abs() > 0.5
709            && ((before <= 0.0 && dx < 0.0) || (before >= max_off && dx > 0.0))
710        {
711            let bandied = Self::rubber_band(leftover, 150.0);
712            os.set(os_val + bandied);
713            physics.record_input(consumed);
714            return 0.0;
715        }
716
717        physics.record_input(consumed);
718        leftover
719    }
720    pub fn scroll_immediate(&self, d: Vec2) -> Vec2 {
721        let bx = self.off_x.get();
722        let by = self.off_y.get();
723        let max_x = (self.c_w.get() - self.vp_w.get()).max(0.0);
724        let max_y = (self.c_h.get() - self.vp_h.get()).max(0.0);
725
726        let mut px = self.physics_x.borrow_mut();
727        let mut py = self.physics_y.borrow_mut();
728        let lx = Self::os_scroll_axis(&self.os_x, self.overscroll_enabled, bx, max_x, d.x, &mut px);
729        let ly = Self::os_scroll_axis(&self.os_y, self.overscroll_enabled, by, max_y, d.y, &mut py);
730        drop((px, py));
731
732        Vec2 { x: lx, y: ly }
733    }
734    fn tick_os_axis(os: &Signal<f32>, enabled: bool, dt: f32) -> bool {
735        if !enabled {
736            return false;
737        }
738        let v = os.get();
739        if v.abs() > 0.5 {
740            let decayed = v * OVERSHOOT_DECAY_PER_60HZ.powf(dt * 60.0);
741            if decayed.abs() < 0.5 {
742                os.set(0.0);
743            } else {
744                os.set(decayed);
745            }
746            request_frame();
747            true
748        } else {
749            false
750        }
751    }
752    /// Advance physics for both axes using a shared dt.
753    /// Returns true if either axis is still animating.
754    pub fn tick(&self) -> bool {
755        let now = Instant::now();
756        let dt = (now - self.prev_tick.get()).as_secs_f32().min(0.1);
757        self.prev_tick.set(now);
758
759        if self.overscroll_enabled {
760            if Self::tick_os_axis(&self.os_x, true, dt) || Self::tick_os_axis(&self.os_y, true, dt)
761            {
762                return true;
763            }
764        }
765
766        let max_x = (self.c_w.get() - self.vp_w.get()).max(0.0);
767        let max_y = (self.c_h.get() - self.vp_h.get()).max(0.0);
768
769        let (bx, by) = (self.off_x.get(), self.off_y.get());
770
771        let mut px = self.physics_x.borrow_mut();
772        let mut py = self.physics_y.borrow_mut();
773
774        if !px.animating && !py.animating {
775            return false;
776        }
777
778        // Integrate X
779        if px.animating {
780            if px.vel.abs() < px.stop_velocity {
781                px.vel = 0.0;
782                px.animating = false;
783            } else {
784                let nx = (bx + px.vel * dt).clamp(0.0, max_x);
785                if (nx - bx).abs() < 0.01 && (bx <= 0.0 || bx >= max_x) {
786                    px.vel = 0.0;
787                    px.animating = false;
788                } else {
789                    let speed = px.vel.abs();
790                    let t = (speed / 4000.0).min(1.0);
791                    let effective_decay = 0.85 + t * 0.10;
792                    px.vel *= effective_decay.powf(dt * 60.0);
793                    if px.vel.abs() < px.stop_velocity {
794                        px.vel = 0.0;
795                        px.animating = false;
796                    } else {
797                        self.off_x.set(nx);
798                    }
799                }
800            }
801        }
802
803        // Integrate Y
804        if py.animating {
805            if py.vel.abs() < py.stop_velocity {
806                py.vel = 0.0;
807                py.animating = false;
808            } else {
809                let ny = (by + py.vel * dt).clamp(0.0, max_y);
810                if (ny - by).abs() < 0.01 && (by <= 0.0 || by >= max_y) {
811                    py.vel = 0.0;
812                    py.animating = false;
813                } else {
814                    let speed = py.vel.abs();
815                    let t = (speed / 4000.0).min(1.0);
816                    let effective_decay = 0.85 + t * 0.10;
817                    py.vel *= effective_decay.powf(dt * 60.0);
818                    if py.vel.abs() < py.stop_velocity {
819                        py.vel = 0.0;
820                        py.animating = false;
821                    } else {
822                        self.off_y.set(ny);
823                    }
824                }
825            }
826        }
827
828        let running = px.animating || py.animating;
829        if running {
830            request_frame();
831        }
832        running
833    }
834}
835
836/// Remembered ScrollState (requires unique key).
837pub fn remember_scroll_state(key: impl Into<String>) -> Rc<ScrollState> {
838    repose_core::remember_with_key(key.into(), ScrollState::new)
839}
840
841pub fn remember_horizontal_scroll_state(key: impl Into<String>) -> Rc<HorizontalScrollState> {
842    repose_core::remember_with_key(key.into(), HorizontalScrollState::new)
843}
844pub fn remember_scroll_state_xy(key: impl Into<String>) -> Rc<ScrollStateXY> {
845    repose_core::remember_with_key(key.into(), ScrollStateXY::new)
846}
847
848/// Scroll container with inertia, like verticalScroll.
849pub fn ScrollArea(modifier: Modifier, state: Rc<ScrollState>, content: View) -> View {
850    let on_scroll = {
851        let st = state.clone();
852        Rc::new(move |d: Vec2| -> Vec2 {
853            let d = run_pre_scroll(&st.parent_connection, d);
854            let leftover_y = st.scroll_immediate(d.y);
855            let result = Vec2 {
856                x: d.x,
857                y: leftover_y,
858            };
859            run_post_scroll(&st.parent_connection, result)
860        })
861    };
862    let set_viewport = {
863        let st = state.clone();
864        Rc::new(move |h: f32| st.set_viewport_height(h))
865    };
866    let set_content = {
867        let st = state.clone();
868        Rc::new(move |h: f32| st.set_content_height(h))
869    };
870    let get_scroll = {
871        let st = state.clone();
872        Rc::new(move || st.get() + st.overscroll_offset())
873    };
874    let set_scroll = {
875        let st = state.clone();
876        Rc::new(move |off: f32| st.set_offset(off))
877    };
878    let tick_scroll = {
879        let st = state.clone();
880        Rc::new(move || {
881            st.tick();
882        })
883    };
884    View::new(
885        0,
886        ViewKind::ScrollV {
887            on_scroll: Some(on_scroll),
888            set_viewport_height: Some(set_viewport),
889            set_content_height: Some(set_content),
890            get_scroll_offset: Some(get_scroll),
891            set_scroll_offset: Some(set_scroll),
892            show_scrollbar: state.show_scrollbar.get(),
893            tick_scroll: Some(tick_scroll),
894        },
895    )
896    .modifier(modifier)
897    .with_children(vec![content])
898}
899
900pub fn HorizontalScrollArea(
901    modifier: Modifier,
902    state: Rc<HorizontalScrollState>,
903    mut content: View,
904) -> View {
905    content.modifier = content.modifier.flex_shrink(0.0);
906    let on_scroll = {
907        let st = state.clone();
908        Rc::new(move |d: Vec2| -> Vec2 {
909            let d = run_pre_scroll(&st.parent_connection, d);
910            let leftover_x = st.scroll_immediate(d.x);
911            let result = Vec2 {
912                x: leftover_x,
913                y: d.y,
914            };
915            run_post_scroll(&st.parent_connection, result)
916        })
917    };
918    let set_viewport_w = {
919        let st = state.clone();
920        Rc::new(move |w: f32| st.set_viewport_width(w))
921    };
922    let set_content_w = {
923        let st = state.clone();
924        Rc::new(move |w: f32| st.set_content_width(w))
925    };
926    let get_scroll_xy = {
927        let st = state.clone();
928        Rc::new(move || (st.get() + st.overscroll_offset(), 0.0))
929    };
930    let set_xy = {
931        let st = state.clone();
932        Rc::new(move |x: f32, _y: f32| st.set_offset(x))
933    };
934    let tick_scroll = {
935        let st = state.clone();
936        Rc::new(move || {
937            st.tick();
938        })
939    };
940    View::new(
941        0,
942        ViewKind::ScrollXY {
943            on_scroll: Some(on_scroll),
944            set_viewport_width: Some(set_viewport_w),
945            set_viewport_height: None,
946            set_content_width: Some(set_content_w),
947            set_content_height: None,
948            get_scroll_offset_xy: Some(get_scroll_xy),
949            set_scroll_offset_xy: Some(set_xy),
950            show_scrollbar: state.show_scrollbar.get(),
951            tick_scroll: Some(tick_scroll),
952        },
953    )
954    .modifier(modifier)
955    .with_children(vec![content])
956}
957
958pub fn ScrollAreaXY(modifier: Modifier, state: Rc<ScrollStateXY>, content: View) -> View {
959    let on_scroll = {
960        let st = state.clone();
961        Rc::new(move |d: Vec2| -> Vec2 {
962            let d = run_pre_scroll(&st.parent_connection, d);
963            let result = st.scroll_immediate(d);
964            run_post_scroll(&st.parent_connection, result)
965        })
966    };
967    let set_vw = {
968        let st = state.clone();
969        Rc::new(move |w: f32| st.set_viewport(w, st.vp_h.get()))
970    };
971    let set_vh = {
972        let st = state.clone();
973        Rc::new(move |h: f32| st.set_viewport(st.vp_w.get(), h))
974    };
975    let set_cw = {
976        let st = state.clone();
977        Rc::new(move |w: f32| st.set_content(w, st.c_h.get()))
978    };
979    let set_ch = {
980        let st = state.clone();
981        Rc::new(move |h: f32| st.set_content(st.c_w.get(), h))
982    };
983    let get_xy = {
984        let st = state.clone();
985        Rc::new(move || {
986            let (ox, oy) = st.get();
987            let (osx, osy) = st.overscroll_offset();
988            (ox + osx, oy + osy)
989        })
990    };
991    let set_xy = {
992        let st = state.clone();
993        Rc::new(move |x: f32, y: f32| st.set_offset_xy(x, y))
994    };
995    let tick_scroll = {
996        let st = state.clone();
997        Rc::new(move || {
998            st.tick();
999        })
1000    };
1001
1002    View::new(
1003        0,
1004        ViewKind::ScrollXY {
1005            on_scroll: Some(on_scroll),
1006            set_viewport_width: Some(set_vw),
1007            set_viewport_height: Some(set_vh),
1008            set_content_width: Some(set_cw),
1009            set_content_height: Some(set_ch),
1010            get_scroll_offset_xy: Some(get_xy),
1011            set_scroll_offset_xy: Some(set_xy),
1012            show_scrollbar: state.show_scrollbar.get(),
1013            tick_scroll: Some(tick_scroll),
1014        },
1015    )
1016    .modifier(modifier)
1017    .with_children(vec![content])
1018}