Skip to main content

cranpose_ui/widgets/
scrollbar.rs

1//! An explicit scrollbar: a track, a thumb that reports the scroll position,
2//! and a thumb the user can drag.
3//!
4//! A scroll indicator that only reports is half a scrollbar. On a desktop, and
5//! on any platform driven by a mouse, the bar is also a control: grabbing the
6//! thumb and pulling it is how a long document is crossed, and a bar that
7//! cannot be grabbed sends the user back to the wheel for every long jump.
8//!
9//! The thumb geometry is [`crate::scrollbar`], shared with the curved indicator
10//! a round watch draws, so both answer "how long is the thumb and where does it
11//! sit" the same way. The drag is [`Modifier::draggable`], so pulling a thumb
12//! obeys the same touch slop and axis locking as scrolling the content itself.
13
14#![allow(non_snake_case)]
15
16use crate::composable;
17use crate::draggable::rememberDraggableState;
18use crate::modifier::Modifier;
19use crate::scroll::ScrollState;
20use crate::scrollbar::{content_delta_for_thumb_drag, ThumbBounds};
21use crate::widgets::scopes::{BoxWithConstraintsScope, BoxWithConstraintsScopeImpl};
22use crate::widgets::{BoxWithConstraints, Canvas};
23use cranpose_core::NodeId;
24use cranpose_ui_graphics::{Brush, Color, CornerRadii, DrawScope, Point, Rect, Size};
25use cranpose_ui_layout::Axis;
26
27/// How wide a bar is across its short axis.
28pub const DEFAULT_SCROLLBAR_THICKNESS: f32 = 8.0;
29/// How short the thumb may get, in logical pixels.
30///
31/// A thumb proportional to a very long document shrinks to a sliver nobody can
32/// hit; a floor in pixels is what keeps it grabbable, and a floor as a fraction
33/// of the track — which is what a watch's indicator uses — would make the bar
34/// lie about how much content there is on short lists.
35pub const DEFAULT_MIN_THUMB_EXTENT: f32 = 24.0;
36
37/// The colours a [`Scrollbar`] paints with.
38#[derive(Clone, Copy, Debug, PartialEq)]
39pub struct ScrollbarColors {
40    /// The rail behind the thumb. Fully transparent hides it.
41    pub track: Color,
42    /// The thumb at rest.
43    pub thumb: Color,
44    /// The thumb while it is being dragged.
45    pub dragged_thumb: Color,
46}
47
48impl ScrollbarColors {
49    /// The thumb colour for the current interaction.
50    pub fn thumb_for(self, dragging: bool) -> Color {
51        if dragging {
52            self.dragged_thumb
53        } else {
54            self.thumb
55        }
56    }
57}
58
59impl Default for ScrollbarColors {
60    fn default() -> Self {
61        Self {
62            track: Color(0.0, 0.0, 0.0, 0.06),
63            thumb: Color(0.0, 0.0, 0.0, 0.32),
64            dragged_thumb: Color(0.0, 0.0, 0.0, 0.56),
65        }
66    }
67}
68
69/// How a [`Scrollbar`] is drawn and how short its thumb may get.
70#[derive(Clone, Copy, Debug, PartialEq)]
71pub struct ScrollbarSpec {
72    /// Width across the short axis.
73    pub thickness: f32,
74    /// The shortest the thumb may get, in logical pixels.
75    pub min_thumb_extent: f32,
76    /// Corner radius of the track and the thumb. Defaults to a full pill.
77    pub corner_radius: Option<f32>,
78    pub colors: ScrollbarColors,
79    /// Whether the bar disappears entirely when the content fits.
80    ///
81    /// A bar left visible over content that cannot scroll invites a drag that
82    /// does nothing.
83    pub hide_when_content_fits: bool,
84}
85
86impl ScrollbarSpec {
87    pub fn thickness(mut self, thickness: f32) -> Self {
88        self.thickness = thickness.max(0.0);
89        self
90    }
91
92    pub fn min_thumb_extent(mut self, extent: f32) -> Self {
93        self.min_thumb_extent = extent.max(0.0);
94        self
95    }
96
97    pub fn corner_radius(mut self, radius: f32) -> Self {
98        self.corner_radius = Some(radius.max(0.0));
99        self
100    }
101
102    pub fn colors(mut self, colors: ScrollbarColors) -> Self {
103        self.colors = colors;
104        self
105    }
106
107    pub fn hide_when_content_fits(mut self, hide: bool) -> Self {
108        self.hide_when_content_fits = hide;
109        self
110    }
111
112    /// The corner radius to paint with on a bar of this thickness: a full pill
113    /// unless the caller asked for something squarer.
114    pub fn resolved_corner_radius(&self, thickness: f32) -> f32 {
115        self.corner_radius.unwrap_or(thickness * 0.5).max(0.0)
116    }
117
118    /// How short the thumb may get on a track of `track`, as a fraction.
119    pub fn thumb_bounds(&self, track: f32) -> ThumbBounds {
120        ThumbBounds::at_least(self.min_thumb_extent, track)
121    }
122}
123
124impl Default for ScrollbarSpec {
125    fn default() -> Self {
126        Self {
127            thickness: DEFAULT_SCROLLBAR_THICKNESS,
128            min_thumb_extent: DEFAULT_MIN_THUMB_EXTENT,
129            corner_radius: None,
130            colors: ScrollbarColors::default(),
131            hide_when_content_fits: true,
132        }
133    }
134}
135
136/// A vertical scrollbar for `state`, drawn down the space the modifier gives it.
137///
138/// Place it beside or over the scrolling content — a `Box` with the bar aligned
139/// to the end edge is the ordinary arrangement.
140#[composable]
141pub fn VerticalScrollbar(modifier: Modifier, state: ScrollState) -> NodeId {
142    Scrollbar(modifier, state, Axis::Vertical, ScrollbarSpec::default())
143}
144
145/// A horizontal scrollbar for `state`.
146#[composable]
147pub fn HorizontalScrollbar(modifier: Modifier, state: ScrollState) -> NodeId {
148    Scrollbar(modifier, state, Axis::Horizontal, ScrollbarSpec::default())
149}
150
151/// A scrollbar along `axis`, drawn and bounded by `spec`.
152#[composable]
153pub fn Scrollbar(
154    modifier: Modifier,
155    state: ScrollState,
156    axis: Axis,
157    spec: ScrollbarSpec,
158) -> NodeId {
159    BoxWithConstraints(modifier, move |constraints: BoxWithConstraintsScopeImpl| {
160        let constraints = constraints.constraints();
161        let track = if axis.is_vertical() {
162            constraints.max_height
163        } else {
164            constraints.max_width
165        };
166        let track = if track.is_finite() {
167            track.max(0.0)
168        } else {
169            0.0
170        };
171        let bounds = spec.thumb_bounds(track);
172
173        // The drag runs against the metrics of the frame the finger is in, not
174        // the ones composition happened to see, so a thumb dragged while the
175        // content is still growing keeps following the finger.
176        let dragged = rememberDraggableState(move |delta| {
177            let metrics = state.metrics();
178            let Some(geometry) = metrics.thumb(bounds) else {
179                return;
180            };
181            let scroll = content_delta_for_thumb_drag(delta, track, geometry, metrics.max_offset);
182            if scroll != 0.0 {
183                state.dispatch_raw_delta(scroll);
184            }
185        });
186
187        let drawn = dragged.clone();
188        Canvas(
189            Modifier::empty()
190                .fill_max_size()
191                .draggable(axis, dragged.clone()),
192            move |scope: &mut dyn DrawScope| {
193                draw_scrollbar(scope, state, axis, spec, drawn.is_dragging());
194            },
195        );
196    })
197}
198
199/// Draws a scrollbar into a scope whose bounds are the whole bar.
200///
201/// Split out from the composable so the picture can be asserted against a bare
202/// draw scope, and so an application still drawing its own chrome can use it.
203pub fn draw_scrollbar(
204    scope: &mut dyn DrawScope,
205    state: ScrollState,
206    axis: Axis,
207    spec: ScrollbarSpec,
208    dragging: bool,
209) {
210    let size = scope.size();
211    let track = if axis.is_vertical() {
212        size.height
213    } else {
214        size.width
215    };
216    let thickness = if axis.is_vertical() {
217        size.width
218    } else {
219        size.height
220    };
221    if track <= 0.0 || thickness <= 0.0 {
222        return;
223    }
224
225    let metrics = state.metrics();
226    let geometry = metrics.thumb(spec.thumb_bounds(track));
227    if geometry.is_none() && spec.hide_when_content_fits {
228        return;
229    }
230
231    let radii = CornerRadii::uniform(spec.resolved_corner_radius(thickness));
232    if spec.colors.track.3 > 0.0 {
233        scope.draw_round_rect_at(
234            Rect::from_size(size),
235            Brush::Solid(spec.colors.track),
236            radii,
237        );
238    }
239
240    let Some(geometry) = geometry else {
241        return;
242    };
243    let thumb_extent = (geometry.length * track).max(0.0);
244    let thumb_offset = (geometry.offset * track).max(0.0);
245    if thumb_extent <= 0.0 {
246        return;
247    }
248    let rect = if axis.is_vertical() {
249        Rect::from_origin_size(
250            Point::new(0.0, thumb_offset),
251            Size::new(thickness, thumb_extent),
252        )
253    } else {
254        Rect::from_origin_size(
255            Point::new(thumb_offset, 0.0),
256            Size::new(thumb_extent, thickness),
257        )
258    };
259    scope.draw_round_rect_at(rect, Brush::Solid(spec.colors.thumb_for(dragging)), radii);
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use cranpose_core::{DefaultScheduler, Runtime};
266    use cranpose_ui_graphics::{DrawPrimitive, DrawScopeDefault};
267    use std::sync::Arc;
268
269    fn scrollable_state(viewport: f32, content: f32, offset: f32) -> ScrollState {
270        let state = ScrollState::new(0.0);
271        state.set_viewport_extent(viewport);
272        state.set_max_value((content - viewport).max(0.0));
273        state.scroll_to(offset);
274        state
275    }
276
277    fn scene(
278        size: Size,
279        state: ScrollState,
280        axis: Axis,
281        spec: ScrollbarSpec,
282    ) -> Vec<DrawPrimitive> {
283        let mut scope = DrawScopeDefault::new(size);
284        draw_scrollbar(&mut scope, state, axis, spec, false);
285        scope.into_primitives()
286    }
287
288    fn rects(primitives: &[DrawPrimitive]) -> Vec<Rect> {
289        primitives
290            .iter()
291            .filter_map(|primitive| match primitive {
292                DrawPrimitive::Rect { rect, .. } => Some(*rect),
293                DrawPrimitive::RoundRect { rect, .. } => Some(*rect),
294                _ => None,
295            })
296            .collect()
297    }
298
299    #[test]
300    fn colors_answer_for_the_current_interaction() {
301        let colors = ScrollbarColors::default();
302        assert_eq!(colors.thumb_for(false), colors.thumb);
303        assert_eq!(colors.thumb_for(true), colors.dragged_thumb);
304    }
305
306    #[test]
307    fn a_bar_is_a_pill_unless_it_was_asked_for_something_squarer() {
308        let spec = ScrollbarSpec::default();
309        assert_eq!(spec.resolved_corner_radius(8.0), 4.0);
310        assert_eq!(spec.corner_radius(0.0).resolved_corner_radius(8.0), 0.0);
311        assert_eq!(spec.corner_radius(-3.0).resolved_corner_radius(8.0), 0.0);
312    }
313
314    #[test]
315    fn spec_builders_clamp_to_drawable_values() {
316        let spec = ScrollbarSpec::default()
317            .thickness(-4.0)
318            .min_thumb_extent(-1.0)
319            .hide_when_content_fits(false);
320        assert_eq!(spec.thickness, 0.0);
321        assert_eq!(spec.min_thumb_extent, 0.0);
322        assert!(!spec.hide_when_content_fits);
323    }
324
325    #[test]
326    fn content_that_fits_draws_nothing_at_all() {
327        let _runtime = Runtime::new(Arc::new(DefaultScheduler));
328        let _app_context = crate::render_state::app_context_test_scope();
329        let state = scrollable_state(200.0, 200.0, 0.0);
330        let primitives = scene(
331            Size::new(8.0, 200.0),
332            state,
333            Axis::Vertical,
334            ScrollbarSpec::default(),
335        );
336        assert!(primitives.is_empty());
337    }
338
339    #[test]
340    fn content_that_fits_still_draws_its_track_when_the_bar_is_pinned_visible() {
341        let _runtime = Runtime::new(Arc::new(DefaultScheduler));
342        let _app_context = crate::render_state::app_context_test_scope();
343        let state = scrollable_state(200.0, 200.0, 0.0);
344        let spec = ScrollbarSpec::default().hide_when_content_fits(false);
345        let primitives = scene(Size::new(8.0, 200.0), state, Axis::Vertical, spec);
346        assert_eq!(
347            rects(&primitives),
348            vec![Rect::from_size(Size::new(8.0, 200.0))]
349        );
350    }
351
352    #[test]
353    fn the_thumb_is_the_share_of_content_on_screen_and_moves_with_it() {
354        let _runtime = Runtime::new(Arc::new(DefaultScheduler));
355        let _app_context = crate::render_state::app_context_test_scope();
356        let spec = ScrollbarSpec::default().min_thumb_extent(0.0);
357
358        let top = scrollable_state(200.0, 800.0, 0.0);
359        let drawn = rects(&scene(Size::new(8.0, 200.0), top, Axis::Vertical, spec));
360        assert_eq!(drawn.len(), 2, "a track and a thumb");
361        assert_eq!(
362            drawn[1],
363            Rect::from_origin_size(Point::new(0.0, 0.0), Size::new(8.0, 50.0))
364        );
365
366        let bottom = scrollable_state(200.0, 800.0, 600.0);
367        let drawn = rects(&scene(Size::new(8.0, 200.0), bottom, Axis::Vertical, spec));
368        assert_eq!(
369            drawn[1],
370            Rect::from_origin_size(Point::new(0.0, 150.0), Size::new(8.0, 50.0))
371        );
372    }
373
374    #[test]
375    fn a_horizontal_bar_lays_its_thumb_along_the_other_axis() {
376        let _runtime = Runtime::new(Arc::new(DefaultScheduler));
377        let _app_context = crate::render_state::app_context_test_scope();
378        let spec = ScrollbarSpec::default().min_thumb_extent(0.0);
379        let state = scrollable_state(200.0, 800.0, 600.0);
380        let drawn = rects(&scene(Size::new(200.0, 8.0), state, Axis::Horizontal, spec));
381        assert_eq!(
382            drawn[1],
383            Rect::from_origin_size(Point::new(150.0, 0.0), Size::new(50.0, 8.0))
384        );
385    }
386
387    #[test]
388    fn a_very_long_document_keeps_a_thumb_big_enough_to_grab() {
389        let _runtime = Runtime::new(Arc::new(DefaultScheduler));
390        let _app_context = crate::render_state::app_context_test_scope();
391        let state = scrollable_state(200.0, 200_000.0, 0.0);
392        let drawn = rects(&scene(
393            Size::new(8.0, 200.0),
394            state,
395            Axis::Vertical,
396            ScrollbarSpec::default(),
397        ));
398        assert_eq!(drawn[1].height, DEFAULT_MIN_THUMB_EXTENT);
399    }
400
401    #[test]
402    fn a_bar_with_no_room_draws_nothing() {
403        let _runtime = Runtime::new(Arc::new(DefaultScheduler));
404        let _app_context = crate::render_state::app_context_test_scope();
405        let state = scrollable_state(200.0, 800.0, 0.0);
406        assert!(scene(
407            Size::new(0.0, 200.0),
408            state,
409            Axis::Vertical,
410            ScrollbarSpec::default()
411        )
412        .is_empty());
413        assert!(scene(
414            Size::new(8.0, 0.0),
415            state,
416            Axis::Vertical,
417            ScrollbarSpec::default()
418        )
419        .is_empty());
420    }
421
422    #[test]
423    fn a_specs_thumb_bounds_state_its_minimum_as_a_track_fraction() {
424        let spec = ScrollbarSpec::default();
425        let bounds = spec.thumb_bounds(240.0);
426        assert!(
427            (bounds.minimum() - DEFAULT_MIN_THUMB_EXTENT / 240.0).abs() < 1.0e-6,
428            "a 24dp floor on a 240dp track is a tenth of it"
429        );
430        assert_eq!(bounds.maximum(), 1.0, "a thumb may still fill its track");
431    }
432
433    #[test]
434    fn a_thumb_floor_taller_than_its_track_asks_for_the_whole_track() {
435        let bounds = ScrollbarSpec::default().thumb_bounds(10.0);
436        assert_eq!(
437            bounds.minimum(),
438            1.0,
439            "a 24dp floor cannot fit in 10dp, so the thumb takes everything"
440        );
441        assert_eq!(bounds.maximum(), 1.0);
442    }
443
444    #[test]
445    fn a_track_with_no_extent_leaves_the_thumb_unbounded() {
446        for track in [0.0_f32, -40.0, f32::NAN, f32::INFINITY] {
447            let bounds = ScrollbarSpec::default().thumb_bounds(track);
448            assert_eq!(
449                bounds.minimum(),
450                0.0,
451                "track {track} must not floor a thumb"
452            );
453            assert_eq!(bounds.maximum(), 1.0);
454        }
455    }
456
457    #[test]
458    fn resolved_corner_radius_is_a_pill_until_a_caller_squares_it() {
459        let spec = ScrollbarSpec::default();
460        assert_eq!(
461            spec.resolved_corner_radius(8.0),
462            4.0,
463            "half the thickness reads as a pill"
464        );
465        assert_eq!(
466            ScrollbarSpec::default()
467                .corner_radius(0.0)
468                .resolved_corner_radius(8.0),
469            0.0,
470            "a caller asking for square corners gets them"
471        );
472        assert_eq!(
473            ScrollbarSpec::default()
474                .corner_radius(-5.0)
475                .resolved_corner_radius(8.0),
476            0.0,
477            "a negative radius is not a shape"
478        );
479    }
480}