Skip to main content

rosace_scroll/
scrollbar.rs

1use rosace_core::types::{Point, Rect, Size};
2use rosace_render::{Color, SkiaCanvas};
3
4use crate::physics::ScrollDirection;
5
6/// Fractional position and size of a scrollbar thumb within its track.
7pub struct ScrollbarMetrics {
8    /// Thumb position as a fraction of the available track space (0.0–1.0).
9    pub thumb_pos: f32,
10    /// Thumb size as a fraction of the total track length (0.0–1.0).
11    pub thumb_size: f32,
12}
13
14impl ScrollbarMetrics {
15    /// Compute thumb metrics for a single axis.
16    pub fn compute(offset: f32, content_size: f32, viewport_size: f32) -> Self {
17        if content_size <= viewport_size {
18            return Self {
19                thumb_pos: 0.0,
20                thumb_size: 1.0,
21            };
22        }
23        let ratio = viewport_size / content_size;
24        let max_offset = content_size - viewport_size;
25        Self {
26            thumb_pos: (offset / max_offset) * (1.0 - ratio),
27            thumb_size: ratio.clamp(0.05, 1.0),
28        }
29    }
30}
31
32/// Render a scrollbar thumb onto `canvas` for the given viewport and scroll state.
33///
34/// `opacity` in `0.0–1.0` controls the thumb alpha (used for the fade-out effect).
35#[allow(clippy::too_many_arguments)]
36pub fn render_scrollbar(
37    canvas: &mut SkiaCanvas,
38    direction: ScrollDirection,
39    viewport_x: f32,
40    viewport_y: f32,
41    viewport_w: f32,
42    viewport_h: f32,
43    offset: f32,
44    content_size: f32,
45    opacity: f32,
46) {
47    if opacity <= 0.0 {
48        return;
49    }
50    let alpha = (opacity * 120.0) as u8;
51    let thumb_color = Color::rgba(180, 185, 210, alpha);
52    const TRACK_W: f32 = 6.0;
53    const MARGIN: f32 = 3.0;
54
55    match direction {
56        ScrollDirection::Vertical | ScrollDirection::Both => {
57            let metrics =
58                ScrollbarMetrics::compute(offset, content_size, viewport_h);
59            let track_h = viewport_h - MARGIN * 2.0;
60            let thumb_h = (track_h * metrics.thumb_size).max(20.0);
61            let thumb_y = viewport_y + MARGIN + track_h * metrics.thumb_pos;
62            let thumb_x = viewport_x + viewport_w - TRACK_W - MARGIN;
63            canvas.fill_rect(
64                Rect {
65                    origin: Point {
66                        x: thumb_x,
67                        y: thumb_y,
68                    },
69                    size: Size {
70                        width: TRACK_W,
71                        height: thumb_h,
72                    },
73                },
74                thumb_color,
75            );
76        }
77        ScrollDirection::Horizontal => {
78            let metrics =
79                ScrollbarMetrics::compute(offset, content_size, viewport_w);
80            let track_w = viewport_w - MARGIN * 2.0;
81            let thumb_w = (track_w * metrics.thumb_size).max(20.0);
82            let thumb_x = viewport_x + MARGIN + track_w * metrics.thumb_pos;
83            let thumb_y = viewport_y + viewport_h - TRACK_W - MARGIN;
84            canvas.fill_rect(
85                Rect {
86                    origin: Point {
87                        x: thumb_x,
88                        y: thumb_y,
89                    },
90                    size: Size {
91                        width: thumb_w,
92                        height: TRACK_W,
93                    },
94                },
95                thumb_color,
96            );
97        }
98    }
99}
100
101// ---------------------------------------------------------------------------
102// Tests
103// ---------------------------------------------------------------------------
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn scrollbar_metrics_thumb_size_equals_ratio_when_content_exceeds_viewport() {
110        let m = ScrollbarMetrics::compute(0.0, 400.0, 200.0);
111        // ratio = 200/400 = 0.5
112        assert!((m.thumb_size - 0.5).abs() < 1e-6);
113    }
114
115    #[test]
116    fn scrollbar_metrics_thumb_size_is_one_when_content_fits() {
117        let m = ScrollbarMetrics::compute(0.0, 100.0, 200.0);
118        assert_eq!(m.thumb_size, 1.0);
119        assert_eq!(m.thumb_pos, 0.0);
120    }
121
122    #[test]
123    fn scrollbar_metrics_thumb_pos_at_max_offset() {
124        // offset = max = 200 (400-200), ratio=0.5, pos should be 0.5*(1-0.5)=0.25
125        let m = ScrollbarMetrics::compute(200.0, 400.0, 200.0);
126        assert!((m.thumb_pos - 0.5).abs() < 1e-5);
127    }
128
129    #[test]
130    fn render_scrollbar_does_not_panic_with_zero_opacity() {
131        let mut canvas = SkiaCanvas::new(200, 400);
132        canvas.clear(Color::WHITE);
133        // Should return early without drawing anything or panicking.
134        render_scrollbar(
135            &mut canvas,
136            ScrollDirection::Vertical,
137            0.0, 0.0, 200.0, 400.0,
138            50.0, 800.0, 0.0,
139        );
140    }
141
142    #[test]
143    fn render_scrollbar_vertical_does_not_panic() {
144        let mut canvas = SkiaCanvas::new(200, 400);
145        canvas.clear(Color::WHITE);
146        render_scrollbar(
147            &mut canvas,
148            ScrollDirection::Vertical,
149            0.0, 0.0, 200.0, 400.0,
150            100.0, 800.0, 1.0,
151        );
152    }
153
154    #[test]
155    fn render_scrollbar_horizontal_does_not_panic() {
156        let mut canvas = SkiaCanvas::new(400, 200);
157        canvas.clear(Color::WHITE);
158        render_scrollbar(
159            &mut canvas,
160            ScrollDirection::Horizontal,
161            0.0, 0.0, 400.0, 200.0,
162            50.0, 800.0, 0.8,
163        );
164    }
165}