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
14use cranpose_core::NodeId;
15use cranpose_ui_graphics::{Brush, Color, CornerRadii, DrawScope, Point, Rect, Size};
16use cranpose_ui_layout::Axis;
17
18use crate::{
19    composable,
20    draggable::rememberDraggableState,
21    modifier::Modifier,
22    scroll::ScrollState,
23    scrollbar::{ThumbBounds, content_delta_for_thumb_drag},
24    widgets::{
25        BoxWithConstraints, Canvas,
26        scopes::{BoxWithConstraintsScope, BoxWithConstraintsScopeImpl},
27    },
28};
29
30/// How wide a bar is across its short axis.
31pub const DEFAULT_SCROLLBAR_THICKNESS: f32 = 8.0;
32/// How short the thumb may get, in logical pixels.
33///
34/// A thumb proportional to a very long document shrinks to a sliver nobody can
35/// hit; a floor in pixels is what keeps it grabbable, and a floor as a fraction
36/// of the track — which is what a watch's indicator uses — would make the bar
37/// lie about how much content there is on short lists.
38pub const DEFAULT_MIN_THUMB_EXTENT: f32 = 24.0;
39
40/// The colours a [`Scrollbar`] paints with.
41#[derive(Clone, Copy, Debug, PartialEq)]
42pub struct ScrollbarColors {
43    /// The rail behind the thumb. Fully transparent hides it.
44    pub track: Color,
45    /// The thumb at rest.
46    pub thumb: Color,
47    /// The thumb while it is being dragged.
48    pub dragged_thumb: Color,
49}
50
51impl ScrollbarColors {
52    /// The thumb colour for the current interaction.
53    pub fn thumb_for(self, dragging: bool) -> Color {
54        if dragging {
55            self.dragged_thumb
56        } else {
57            self.thumb
58        }
59    }
60}
61
62impl Default for ScrollbarColors {
63    fn default() -> Self {
64        Self {
65            track: Color(0.0, 0.0, 0.0, 0.06),
66            thumb: Color(0.0, 0.0, 0.0, 0.32),
67            dragged_thumb: Color(0.0, 0.0, 0.0, 0.56),
68        }
69    }
70}
71
72/// How a [`Scrollbar`] is drawn and how short its thumb may get.
73#[derive(Clone, Copy, Debug, PartialEq)]
74pub struct ScrollbarSpec {
75    /// Width across the short axis.
76    pub thickness: f32,
77    /// The shortest the thumb may get, in logical pixels.
78    pub min_thumb_extent: f32,
79    /// Corner radius of the track and the thumb. Defaults to a full pill.
80    pub corner_radius: Option<f32>,
81    /// Colors of the track and the thumb.
82    pub colors: ScrollbarColors,
83    /// Whether the bar disappears entirely when the content fits.
84    ///
85    /// A bar left visible over content that cannot scroll invites a drag that
86    /// does nothing.
87    pub hide_when_content_fits: bool,
88}
89
90impl ScrollbarSpec {
91    /// Sets the width across the short axis, in logical pixels. Negative values count as zero.
92    pub fn thickness(mut self, thickness: f32) -> Self {
93        self.thickness = thickness.max(0.0);
94        self
95    }
96
97    /// Sets the shortest the thumb may get, in logical pixels. Negative values count as zero.
98    pub fn min_thumb_extent(mut self, extent: f32) -> Self {
99        self.min_thumb_extent = extent.max(0.0);
100        self
101    }
102
103    /// Sets the corner radius of the track and the thumb, in logical pixels. Negative values count
104    /// as zero.
105    pub fn corner_radius(mut self, radius: f32) -> Self {
106        self.corner_radius = Some(radius.max(0.0));
107        self
108    }
109
110    /// Sets the colors of the track and the thumb.
111    pub fn colors(mut self, colors: ScrollbarColors) -> Self {
112        self.colors = colors;
113        self
114    }
115
116    /// Sets whether the bar disappears when the content fits.
117    pub fn hide_when_content_fits(mut self, hide: bool) -> Self {
118        self.hide_when_content_fits = hide;
119        self
120    }
121
122    /// The corner radius to paint with on a bar of this thickness: a full pill
123    /// unless the caller asked for something squarer.
124    pub fn resolved_corner_radius(&self, thickness: f32) -> f32 {
125        self.corner_radius.unwrap_or(thickness * 0.5).max(0.0)
126    }
127
128    /// How short the thumb may get on a track of `track`, as a fraction.
129    pub fn thumb_bounds(&self, track: f32) -> ThumbBounds {
130        ThumbBounds::at_least(self.min_thumb_extent, track)
131    }
132}
133
134impl Default for ScrollbarSpec {
135    fn default() -> Self {
136        Self {
137            thickness: DEFAULT_SCROLLBAR_THICKNESS,
138            min_thumb_extent: DEFAULT_MIN_THUMB_EXTENT,
139            corner_radius: None,
140            colors: ScrollbarColors::default(),
141            hide_when_content_fits: true,
142        }
143    }
144}
145
146/// A vertical scrollbar for `state`, drawn down the space the modifier gives it.
147///
148/// Place it beside or over the scrolling content — a `Box` with the bar aligned
149/// to the end edge is the ordinary arrangement.
150#[composable]
151pub fn VerticalScrollbar(modifier: Modifier, state: ScrollState) -> NodeId {
152    Scrollbar(modifier, state, Axis::Vertical, ScrollbarSpec::default())
153}
154
155/// A horizontal scrollbar for `state`.
156#[composable]
157pub fn HorizontalScrollbar(modifier: Modifier, state: ScrollState) -> NodeId {
158    Scrollbar(modifier, state, Axis::Horizontal, ScrollbarSpec::default())
159}
160
161/// A scrollbar along `axis`, drawn and bounded by `spec`.
162#[composable]
163pub fn Scrollbar(
164    modifier: Modifier,
165    state: ScrollState,
166    axis: Axis,
167    spec: ScrollbarSpec,
168) -> NodeId {
169    BoxWithConstraints(modifier, move |constraints: BoxWithConstraintsScopeImpl| {
170        let constraints = constraints.constraints();
171        let track = if axis.is_vertical() {
172            constraints.max_height
173        } else {
174            constraints.max_width
175        };
176        let track = if track.is_finite() {
177            track.max(0.0)
178        } else {
179            0.0
180        };
181        let bounds = spec.thumb_bounds(track);
182
183        let dragged = rememberDraggableState(move |delta| {
184            let metrics = state.metrics();
185            let Some(geometry) = metrics.thumb(bounds) else {
186                return;
187            };
188            let scroll = content_delta_for_thumb_drag(delta, track, geometry, metrics.max_offset);
189            if scroll != 0.0 {
190                state.dispatch_raw_delta(scroll);
191            }
192        });
193
194        let drawn = dragged.clone();
195        Canvas(
196            Modifier::empty().fill_max_size().draggable(axis, dragged),
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)]
268#[path = "tests/scrollbar_tests.rs"]
269mod tests;