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