Skip to main content

rosace_scroll/
scroll_view.rs

1use rosace_render::SkiaCanvas;
2
3use crate::{
4    controller::ScrollController,
5    physics::{MomentumState, ScrollDirection, ScrollPhysics},
6    scrollbar::render_scrollbar,
7};
8
9/// A scrollable viewport widget.
10///
11/// The caller is responsible for rendering content at an origin offset by
12/// `scroll_offset()`. `ScrollView` manages momentum physics, clamping,
13/// scrollbar visibility, and fade-out.
14pub struct ScrollView {
15    pub direction: ScrollDirection,
16    pub physics: ScrollPhysics,
17    pub show_scrollbar: bool,
18    pub controller: ScrollController,
19    pub content_width: f32,
20    pub content_height: f32,
21    pub viewport_width: f32,
22    pub viewport_height: f32,
23    momentum: MomentumState,
24    scrollbar_opacity: f32,
25    scrollbar_fade_timer: f32,
26}
27
28impl ScrollView {
29    /// Create a new `ScrollView` sized to the given viewport dimensions.
30    pub fn new(viewport_w: f32, viewport_h: f32) -> Self {
31        let controller = ScrollController::new();
32        controller.viewport_size.set([viewport_w, viewport_h]);
33        Self {
34            direction: ScrollDirection::Vertical,
35            physics: ScrollPhysics::default(),
36            show_scrollbar: true,
37            controller,
38            content_width: viewport_w,
39            content_height: viewport_h,
40            viewport_width: viewport_w,
41            viewport_height: viewport_h,
42            momentum: MomentumState::new(),
43            scrollbar_opacity: 0.0,
44            scrollbar_fade_timer: 0.0,
45        }
46    }
47
48    // Builder-style setters -----------------------------------------------
49
50    pub fn direction(mut self, d: ScrollDirection) -> Self {
51        self.direction = d;
52        self
53    }
54
55    pub fn physics(mut self, p: ScrollPhysics) -> Self {
56        self.physics = p;
57        self
58    }
59
60    pub fn show_scrollbar(mut self, s: bool) -> Self {
61        self.show_scrollbar = s;
62        self
63    }
64
65    pub fn content_size(mut self, w: f32, h: f32) -> Self {
66        self.content_width = w;
67        self.content_height = h;
68        self.controller.content_size.set([w, h]);
69        self
70    }
71
72    // Frame update ---------------------------------------------------------
73
74    /// Call each frame (with elapsed seconds `dt`) to advance momentum and fade
75    /// the scrollbar out after a period of inactivity.
76    pub fn tick(&mut self, dt: f32) {
77        let (dx, dy) = self.momentum.tick(self.physics);
78        if dx.abs() > 0.0 || dy.abs() > 0.0 {
79            self.controller.scroll_by(dx, dy);
80            self.scrollbar_opacity = 1.0;
81            self.scrollbar_fade_timer = 1.5;
82        }
83        // Fade scrollbar after 1.5 s of inactivity.
84        if self.scrollbar_fade_timer > 0.0 {
85            self.scrollbar_fade_timer -= dt;
86            if self.scrollbar_fade_timer <= 0.0 {
87                self.scrollbar_opacity = (self.scrollbar_opacity - dt * 2.0).max(0.0);
88            }
89        }
90    }
91
92    // Input ----------------------------------------------------------------
93
94    /// Call on a pointer/mouse drag event with the delta since the last event.
95    pub fn on_scroll(&mut self, dx: f32, dy: f32) {
96        self.momentum.push(dx, dy);
97        self.controller.scroll_by(dx, dy);
98        self.scrollbar_opacity = 1.0;
99        self.scrollbar_fade_timer = 1.5;
100    }
101
102    // Rendering ------------------------------------------------------------
103
104    /// Render the scroll chrome (scrollbar only).
105    ///
106    /// Content must be rendered by the caller, translated by `-scroll_offset()`.
107    pub fn render_chrome(&self, canvas: &mut SkiaCanvas, x: f32, y: f32) {
108        if !self.show_scrollbar {
109            return;
110        }
111        let [ox, oy] = self.controller.offset();
112        match self.direction {
113            ScrollDirection::Vertical | ScrollDirection::Both => {
114                render_scrollbar(
115                    canvas,
116                    ScrollDirection::Vertical,
117                    x,
118                    y,
119                    self.viewport_width,
120                    self.viewport_height,
121                    oy,
122                    self.content_height,
123                    self.scrollbar_opacity,
124                );
125            }
126            ScrollDirection::Horizontal => {
127                render_scrollbar(
128                    canvas,
129                    ScrollDirection::Horizontal,
130                    x,
131                    y,
132                    self.viewport_width,
133                    self.viewport_height,
134                    ox,
135                    self.content_width,
136                    self.scrollbar_opacity,
137                );
138            }
139        }
140    }
141
142    // Accessors ------------------------------------------------------------
143
144    /// Returns the current `[offset_x, offset_y]` to subtract from the content
145    /// drawing origin.
146    pub fn scroll_offset(&self) -> [f32; 2] {
147        self.controller.offset()
148    }
149
150    pub fn controller(&self) -> &ScrollController {
151        &self.controller
152    }
153}
154
155// ---------------------------------------------------------------------------
156// Tests
157// ---------------------------------------------------------------------------
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn scroll_view_on_scroll_updates_offset() {
164        let mut sv = ScrollView::new(300.0, 400.0).content_size(300.0, 1200.0);
165        sv.on_scroll(0.0, 50.0);
166        let [_x, y] = sv.scroll_offset();
167        assert_eq!(y, 50.0);
168    }
169
170    #[test]
171    fn scroll_view_on_scroll_clamps_to_content_bounds() {
172        let mut sv = ScrollView::new(300.0, 400.0).content_size(300.0, 600.0);
173        // max_y = 600 - 400 = 200
174        sv.on_scroll(0.0, 9999.0);
175        let [_x, y] = sv.scroll_offset();
176        assert_eq!(y, 200.0);
177    }
178
179    #[test]
180    fn scroll_view_tick_advances_momentum() {
181        let mut sv = ScrollView::new(300.0, 400.0)
182            .content_size(300.0, 2000.0)
183            .physics(ScrollPhysics::Momentum { friction: 0.92 });
184        sv.on_scroll(0.0, 100.0);
185        // tick consumes the velocity
186        let before = sv.scroll_offset()[1];
187        sv.tick(0.016);
188        let after = sv.scroll_offset()[1];
189        // After tick the position should have moved further.
190        assert!(after >= before);
191    }
192
193    #[test]
194    fn scroll_view_render_chrome_does_not_panic() {
195        let mut canvas = SkiaCanvas::new(300, 400);
196        canvas.clear(rosace_render::Color::WHITE);
197        let sv = ScrollView::new(300.0, 400.0).content_size(300.0, 800.0);
198        sv.render_chrome(&mut canvas, 0.0, 0.0);
199    }
200
201    #[test]
202    fn scroll_view_controller_accessible() {
203        let sv = ScrollView::new(300.0, 400.0).content_size(300.0, 800.0);
204        let c = sv.controller();
205        // Default offset is zero.
206        assert_eq!(c.offset(), [0.0, 0.0]);
207    }
208}