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