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::RefCell;
12use std::rc::Rc;
13use web_time::Instant;
14
15/// Inertial scroll state (single axis Y).
16pub struct ScrollState {
17    scroll_offset: Signal<f32>,
18    viewport_height: Signal<f32>,
19    content_height: Signal<f32>,
20
21    // physics (px/sec)
22    vel: RefCell<f32>,
23    last_t: RefCell<Instant>,
24    last_input_t: RefCell<Instant>,
25    animating: RefCell<bool>,
26}
27
28impl Default for ScrollState {
29    fn default() -> Self {
30        Self::new()
31    }
32}
33
34impl ScrollState {
35    pub fn new() -> Self {
36        let now = Instant::now();
37        Self {
38            scroll_offset: signal(0.0),
39            viewport_height: signal(0.0),
40            content_height: signal(0.0),
41            vel: RefCell::new(0.0),
42            last_t: RefCell::new(now),
43            last_input_t: RefCell::new(now),
44            animating: RefCell::new(false),
45        }
46    }
47
48    pub fn set_viewport_height(&self, h: f32) {
49        self.viewport_height.set(h.max(0.0));
50        self.clamp_offset();
51    }
52    pub fn set_content_height(&self, h: f32) {
53        self.content_height.set(h.max(0.0));
54        self.clamp_offset();
55    }
56    pub fn set_offset(&self, off: f32) {
57        let vh = self.viewport_height.get();
58        let ch = self.content_height.get();
59        let max_off = (ch - vh).max(0.0);
60        self.scroll_offset.set(off.clamp(0.0, max_off));
61    }
62
63    fn clamp_offset(&self) {
64        let vh = self.viewport_height.get();
65        let ch = self.content_height.get();
66        let max_off = (ch - vh).max(0.0);
67        self.scroll_offset.update(|o| {
68            if *o > max_off {
69                *o = max_off;
70            }
71            if *o < 0.0 {
72                *o = 0.0;
73            }
74        });
75    }
76
77    pub fn get(&self) -> f32 {
78        self.scroll_offset.get()
79    }
80
81    /// Consume dy (pixels), clamp to bounds, return leftover.
82    pub fn scroll_immediate(&self, dy: f32) -> f32 {
83        let before = self.scroll_offset.get();
84        let vh = self.viewport_height.get();
85        let ch = self.content_height.get();
86        let max_off = (ch - vh).max(0.0);
87
88        let new_off = (before + dy).clamp(0.0, max_off);
89        self.scroll_offset.set(new_off);
90
91        let consumed = new_off - before;
92        let leftover = dy - consumed;
93
94        // Estimate input velocity (px/sec) based on time since last input.
95        let now = Instant::now();
96        let dt = (now - *self.last_input_t.borrow())
97            .as_secs_f32()
98            .clamp(1.0 / 240.0, 1.0 / 15.0);
99        *self.last_input_t.borrow_mut() = now;
100
101        *self.vel.borrow_mut() = consumed / dt;
102        *self.animating.borrow_mut() = self.vel.borrow().abs() > 10.0;
103
104        leftover
105    }
106
107    /// Advance physics one tick; returns true if animating.
108    pub fn tick(&self) -> bool {
109        if !*self.animating.borrow() {
110            return false;
111        }
112
113        let now = Instant::now();
114        let dt = (now - *self.last_t.borrow()).as_secs_f32().min(0.1);
115        *self.last_t.borrow_mut() = now;
116        if dt <= 0.0 {
117            return false;
118        }
119
120        let vel0 = *self.vel.borrow();
121        if vel0.abs() < 5.0 {
122            *self.vel.borrow_mut() = 0.0;
123            *self.animating.borrow_mut() = false;
124            return false;
125        }
126
127        let before = self.scroll_offset.get();
128        let vh = self.viewport_height.get();
129        let ch = self.content_height.get();
130        let max_off = (ch - vh).max(0.0);
131
132        // Integrate
133        let new_off = (before + vel0 * dt).clamp(0.0, max_off);
134        self.scroll_offset.set(new_off);
135
136        // If we hit an edge, stop quickly.
137        if (new_off - before).abs() < 0.01 && (before <= 0.0 || before >= max_off) {
138            *self.vel.borrow_mut() = 0.0;
139            *self.animating.borrow_mut() = false;
140            return false;
141        }
142
143        // Frame-rate independent decay; matches ~0.9 per 60Hz “frame”.
144        let decay_per_60hz = 0.90f32;
145        let decay = decay_per_60hz.powf(dt * 60.0);
146        *self.vel.borrow_mut() = vel0 * decay;
147
148        request_frame();
149
150        true
151    }
152}
153
154/// X-only state
155pub struct HorizontalScrollState {
156    scroll_offset: Signal<f32>,
157    viewport_width: Signal<f32>,
158    content_width: Signal<f32>,
159    vel: RefCell<f32>, // px/sec
160    last_t: RefCell<Instant>,
161    last_input_t: RefCell<Instant>,
162    animating: RefCell<bool>,
163}
164impl Default for HorizontalScrollState {
165    fn default() -> Self {
166        Self::new()
167    }
168}
169
170impl HorizontalScrollState {
171    pub fn new() -> Self {
172        let now = Instant::now();
173        Self {
174            scroll_offset: signal(0.0),
175            viewport_width: signal(0.0),
176            content_width: signal(0.0),
177            vel: RefCell::new(0.0),
178            last_t: RefCell::new(now),
179            last_input_t: RefCell::new(now),
180            animating: RefCell::new(false),
181        }
182    }
183    pub fn set_viewport_width(&self, w: f32) {
184        self.viewport_width.set(w.max(0.0));
185        self.clamp();
186    }
187    pub fn set_content_width(&self, w: f32) {
188        self.content_width.set(w.max(0.0));
189        self.clamp();
190    }
191    pub fn set_offset(&self, off: f32) {
192        let max_off = (self.content_width.get() - self.viewport_width.get()).max(0.0);
193        self.scroll_offset.set(off.clamp(0.0, max_off));
194    }
195    fn clamp(&self) {
196        let max_off = (self.content_width.get() - self.viewport_width.get()).max(0.0);
197        self.scroll_offset.update(|o| {
198            *o = o.clamp(0.0, max_off);
199        });
200    }
201    pub fn get(&self) -> f32 {
202        self.scroll_offset.get()
203    }
204    pub fn scroll_immediate(&self, dx: f32) -> f32 {
205        let before = self.scroll_offset.get();
206        let max_off = (self.content_width.get() - self.viewport_width.get()).max(0.0);
207        let new_off = (before + dx).clamp(0.0, max_off);
208        self.scroll_offset.set(new_off);
209
210        let consumed = new_off - before;
211        let leftover = dx - consumed;
212
213        let now = Instant::now();
214        let dt = (now - *self.last_input_t.borrow())
215            .as_secs_f32()
216            .clamp(1.0 / 240.0, 1.0 / 15.0);
217        *self.last_input_t.borrow_mut() = now;
218
219        *self.vel.borrow_mut() = consumed / dt;
220        *self.animating.borrow_mut() = self.vel.borrow().abs() > 10.0;
221
222        leftover
223    }
224    pub fn tick(&self) -> bool {
225        if !*self.animating.borrow() {
226            return false;
227        }
228
229        let now = Instant::now();
230        let dt = (now - *self.last_t.borrow()).as_secs_f32().min(0.1);
231        *self.last_t.borrow_mut() = now;
232        if dt <= 0.0 {
233            return false;
234        }
235
236        let vel0 = *self.vel.borrow();
237        if vel0.abs() < 5.0 {
238            *self.animating.borrow_mut() = false;
239            *self.vel.borrow_mut() = 0.0;
240            return false;
241        }
242
243        let before = self.scroll_offset.get();
244        let max_off = (self.content_width.get() - self.viewport_width.get()).max(0.0);
245        let new_off = (before + vel0 * dt).clamp(0.0, max_off);
246        self.scroll_offset.set(new_off);
247
248        if (new_off - before).abs() < 0.01 && (before <= 0.0 || before >= max_off) {
249            *self.vel.borrow_mut() = 0.0;
250            *self.animating.borrow_mut() = false;
251            return false;
252        }
253
254        let decay_per_60hz = 0.90f32;
255        let decay = decay_per_60hz.powf(dt * 60.0);
256        *self.vel.borrow_mut() = vel0 * decay;
257
258        request_frame();
259
260        true
261    }
262}
263
264/// 2D state
265pub struct ScrollStateXY {
266    off_x: Signal<f32>,
267    off_y: Signal<f32>,
268    vp_w: Signal<f32>,
269    vp_h: Signal<f32>,
270    c_w: Signal<f32>,
271    c_h: Signal<f32>,
272    vel_x: RefCell<f32>, // px/sec
273    vel_y: RefCell<f32>, // px/sec
274    last_t: RefCell<Instant>,
275    last_input_t: RefCell<Instant>,
276    animating: RefCell<bool>,
277}
278impl Default for ScrollStateXY {
279    fn default() -> Self {
280        Self::new()
281    }
282}
283
284impl ScrollStateXY {
285    pub fn new() -> Self {
286        let now = Instant::now();
287        Self {
288            off_x: signal(0.0),
289            off_y: signal(0.0),
290            vp_w: signal(0.0),
291            vp_h: signal(0.0),
292            c_w: signal(0.0),
293            c_h: signal(0.0),
294            vel_x: RefCell::new(0.0),
295            vel_y: RefCell::new(0.0),
296            last_t: RefCell::new(now),
297            last_input_t: RefCell::new(now),
298            animating: RefCell::new(false),
299        }
300    }
301    pub fn set_viewport(&self, w: f32, h: f32) {
302        self.vp_w.set(w.max(0.0));
303        self.vp_h.set(h.max(0.0));
304        self.clamp();
305    }
306    pub fn set_content(&self, w: f32, h: f32) {
307        self.c_w.set(w.max(0.0));
308        self.c_h.set(h.max(0.0));
309        self.clamp();
310    }
311    pub fn set_offset_xy(&self, x: f32, y: f32) {
312        let max_x = (self.c_w.get() - self.vp_w.get()).max(0.0);
313        let max_y = (self.c_h.get() - self.vp_h.get()).max(0.0);
314        self.off_x.set(x.clamp(0.0, max_x));
315        self.off_y.set(y.clamp(0.0, max_y));
316    }
317    fn clamp(&self) {
318        let max_x = (self.c_w.get() - self.vp_w.get()).max(0.0);
319        let max_y = (self.c_h.get() - self.vp_h.get()).max(0.0);
320        self.off_x.update(|x| *x = x.clamp(0.0, max_x));
321        self.off_y.update(|y| *y = y.clamp(0.0, max_y));
322    }
323    pub fn get(&self) -> (f32, f32) {
324        (self.off_x.get(), self.off_y.get())
325    }
326    pub fn scroll_immediate(&self, d: Vec2) -> Vec2 {
327        let bx = self.off_x.get();
328        let by = self.off_y.get();
329
330        let max_x = (self.c_w.get() - self.vp_w.get()).max(0.0);
331        let max_y = (self.c_h.get() - self.vp_h.get()).max(0.0);
332
333        let nx = (bx + d.x).clamp(0.0, max_x);
334        let ny = (by + d.y).clamp(0.0, max_y);
335
336        self.off_x.set(nx);
337        self.off_y.set(ny);
338
339        let consumed_x = nx - bx;
340        let consumed_y = ny - by;
341
342        let now = Instant::now();
343        let dt = (now - *self.last_input_t.borrow())
344            .as_secs_f32()
345            .clamp(1.0 / 240.0, 1.0 / 15.0);
346        *self.last_input_t.borrow_mut() = now;
347
348        *self.vel_x.borrow_mut() = consumed_x / dt;
349        *self.vel_y.borrow_mut() = consumed_y / dt;
350        *self.animating.borrow_mut() =
351            self.vel_x.borrow().abs() > 10.0 || self.vel_y.borrow().abs() > 10.0;
352
353        Vec2 {
354            x: d.x - consumed_x,
355            y: d.y - consumed_y,
356        }
357    }
358    pub fn tick(&self) -> bool {
359        if !*self.animating.borrow() {
360            return false;
361        }
362
363        let now = Instant::now();
364        let dt = (now - *self.last_t.borrow()).as_secs_f32().min(0.1);
365        *self.last_t.borrow_mut() = now;
366        if dt <= 0.0 {
367            return false;
368        }
369
370        let vx0 = *self.vel_x.borrow();
371        let vy0 = *self.vel_y.borrow();
372        if vx0.abs() < 5.0 && vy0.abs() < 5.0 {
373            *self.animating.borrow_mut() = false;
374            *self.vel_x.borrow_mut() = 0.0;
375            *self.vel_y.borrow_mut() = 0.0;
376            return false;
377        }
378
379        let (bx, by) = (self.off_x.get(), self.off_y.get());
380        let max_x = (self.c_w.get() - self.vp_w.get()).max(0.0);
381        let max_y = (self.c_h.get() - self.vp_h.get()).max(0.0);
382
383        let nx = (bx + vx0 * dt).clamp(0.0, max_x);
384        let ny = (by + vy0 * dt).clamp(0.0, max_y);
385
386        self.off_x.set(nx);
387        self.off_y.set(ny);
388
389        // stop quickly at edges
390        if (nx - bx).abs() < 0.01 && (bx <= 0.0 || bx >= max_x) {
391            *self.vel_x.borrow_mut() = 0.0;
392        }
393        if (ny - by).abs() < 0.01 && (by <= 0.0 || by >= max_y) {
394            *self.vel_y.borrow_mut() = 0.0;
395        }
396
397        let decay_per_60hz = 0.95f32;
398        let decay = decay_per_60hz.powf(dt * 60.0);
399        *self.vel_x.borrow_mut() *= decay;
400        *self.vel_y.borrow_mut() *= decay;
401
402        *self.animating.borrow_mut() =
403            self.vel_x.borrow().abs() > 5.0 || self.vel_y.borrow().abs() > 5.0;
404
405        if *self.animating.borrow() {
406            request_frame();
407            return true;
408        }
409        false
410    }
411}
412
413/// Remembered ScrollState (requires unique key).
414pub fn remember_scroll_state(key: impl Into<String>) -> Rc<ScrollState> {
415    repose_core::remember_with_key(key.into(), ScrollState::new)
416}
417
418pub fn remember_horizontal_scroll_state(key: impl Into<String>) -> Rc<HorizontalScrollState> {
419    repose_core::remember_with_key(key.into(), HorizontalScrollState::new)
420}
421pub fn remember_scroll_state_xy(key: impl Into<String>) -> Rc<ScrollStateXY> {
422    repose_core::remember_with_key(key.into(), ScrollStateXY::new)
423}
424
425/// Scroll container with inertia, like verticalScroll.
426pub fn ScrollArea(modifier: Modifier, state: Rc<ScrollState>, content: View) -> View {
427    let st_clone = state.clone();
428    let on_scroll = {
429        Rc::new(move |d: Vec2| -> Vec2 {
430            Vec2 {
431                x: d.x,
432                y: st_clone.scroll_immediate(d.y),
433            }
434        })
435    };
436    let set_viewport = {
437        let st = state.clone();
438        Rc::new(move |h: f32| st.set_viewport_height(h))
439    };
440    let set_content = {
441        let st = state.clone();
442        Rc::new(move |h: f32| st.set_content_height(h))
443    };
444    let get_scroll = {
445        let st = state.clone();
446        Rc::new(move || {
447            st.tick();
448            st.get()
449        })
450    };
451    let set_scroll = {
452        let st = state.clone();
453        Rc::new(move |off: f32| st.set_offset(off))
454    };
455    View::new(
456        0,
457        ViewKind::ScrollV {
458            on_scroll: Some(on_scroll),
459            set_viewport_height: Some(set_viewport),
460            set_content_height: Some(set_content),
461            get_scroll_offset: Some(get_scroll),
462            set_scroll_offset: Some(set_scroll),
463        },
464    )
465    .modifier(modifier)
466    .with_children(vec![content])
467}
468
469pub fn HorizontalScrollArea(
470    modifier: Modifier,
471    state: Rc<HorizontalScrollState>,
472    content: View,
473) -> View {
474    let st_clone = state.clone();
475    let on_scroll = {
476        Rc::new(move |d: Vec2| -> Vec2 {
477            // Most mice only generate vertical wheel. If dx is zero, treat dy as horizontal scroll.
478            // Do also consume that vertical delta so parent vertical scrollers don't steal it.
479            let use_dx = if d.x.abs() > 0.001 { d.x } else { d.y };
480            let leftover_x = st_clone.scroll_immediate(use_dx);
481            Vec2 {
482                x: leftover_x,
483                y: if d.x.abs() > 0.001 { d.y } else { 0.0 },
484            }
485        })
486    };
487    let set_viewport_w = {
488        let st = state.clone();
489        Rc::new(move |w: f32| st.set_viewport_width(w))
490    };
491    let set_content_w = {
492        let st = state.clone();
493        Rc::new(move |w: f32| st.set_content_width(w))
494    };
495    let get_scroll_xy = {
496        let st = state.clone();
497        Rc::new(move || {
498            st.tick();
499            (st.get(), 0.0)
500        })
501    };
502    let set_xy = {
503        let st = state.clone();
504        Rc::new(move |x: f32, _y: f32| st.set_offset(x))
505    };
506    View::new(
507        0,
508        ViewKind::ScrollXY {
509            on_scroll: Some(on_scroll),
510            set_viewport_width: Some(set_viewport_w),
511            set_viewport_height: None,
512            set_content_width: Some(set_content_w),
513            set_content_height: None,
514            get_scroll_offset_xy: Some(get_scroll_xy),
515            set_scroll_offset_xy: Some(set_xy),
516        },
517    )
518    .modifier(modifier)
519    .with_children(vec![content])
520}
521
522pub fn ScrollAreaXY(modifier: Modifier, state: Rc<ScrollStateXY>, content: View) -> View {
523    let on_scroll = {
524        let st = state.clone();
525        Rc::new(move |d: Vec2| -> Vec2 { st.scroll_immediate(d) })
526    };
527    let set_vw = {
528        let st = state.clone();
529        Rc::new(move |w: f32| st.set_viewport(w, st.vp_h.get()))
530    };
531    let set_vh = {
532        let st = state.clone();
533        Rc::new(move |h: f32| st.set_viewport(st.vp_w.get(), h))
534    };
535    let set_cw = {
536        let st = state.clone();
537        Rc::new(move |w: f32| {
538            st.set_content(w, st.c_h.get());
539        })
540    };
541    let set_ch = {
542        let st = state.clone();
543        Rc::new(move |h: f32| {
544            st.set_content(st.c_w.get(), h);
545        })
546    };
547    let get_xy = {
548        let st = state.clone();
549        Rc::new(move || {
550            st.tick();
551            st.get()
552        })
553    };
554    let set_xy = {
555        let st = state.clone();
556        Rc::new(move |x: f32, y: f32| st.set_offset_xy(x, y))
557    };
558
559    View::new(
560        0,
561        ViewKind::ScrollXY {
562            on_scroll: Some(on_scroll),
563            set_viewport_width: Some(set_vw),
564            set_viewport_height: Some(set_vh),
565            set_content_width: Some(set_cw),
566            set_content_height: Some(set_ch),
567            get_scroll_offset_xy: Some(get_xy),
568            set_scroll_offset_xy: Some(set_xy),
569        },
570    )
571    .modifier(modifier)
572    .with_children(vec![content])
573}