1use 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
30pub const DEFAULT_SCROLLBAR_THICKNESS: f32 = 8.0;
32pub const DEFAULT_MIN_THUMB_EXTENT: f32 = 24.0;
39
40#[derive(Clone, Copy, Debug, PartialEq)]
42pub struct ScrollbarColors {
43 pub track: Color,
45 pub thumb: Color,
47 pub dragged_thumb: Color,
49}
50
51impl ScrollbarColors {
52 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#[derive(Clone, Copy, Debug, PartialEq)]
74pub struct ScrollbarSpec {
75 pub thickness: f32,
77 pub min_thumb_extent: f32,
79 pub corner_radius: Option<f32>,
81 pub colors: ScrollbarColors,
82 pub hide_when_content_fits: bool,
87}
88
89impl ScrollbarSpec {
90 pub fn thickness(mut self, thickness: f32) -> Self {
91 self.thickness = thickness.max(0.0);
92 self
93 }
94
95 pub fn min_thumb_extent(mut self, extent: f32) -> Self {
96 self.min_thumb_extent = extent.max(0.0);
97 self
98 }
99
100 pub fn corner_radius(mut self, radius: f32) -> Self {
101 self.corner_radius = Some(radius.max(0.0));
102 self
103 }
104
105 pub fn colors(mut self, colors: ScrollbarColors) -> Self {
106 self.colors = colors;
107 self
108 }
109
110 pub fn hide_when_content_fits(mut self, hide: bool) -> Self {
111 self.hide_when_content_fits = hide;
112 self
113 }
114
115 pub fn resolved_corner_radius(&self, thickness: f32) -> f32 {
118 self.corner_radius.unwrap_or(thickness * 0.5).max(0.0)
119 }
120
121 pub fn thumb_bounds(&self, track: f32) -> ThumbBounds {
123 ThumbBounds::at_least(self.min_thumb_extent, track)
124 }
125}
126
127impl Default for ScrollbarSpec {
128 fn default() -> Self {
129 Self {
130 thickness: DEFAULT_SCROLLBAR_THICKNESS,
131 min_thumb_extent: DEFAULT_MIN_THUMB_EXTENT,
132 corner_radius: None,
133 colors: ScrollbarColors::default(),
134 hide_when_content_fits: true,
135 }
136 }
137}
138
139#[composable]
144pub fn VerticalScrollbar(modifier: Modifier, state: ScrollState) -> NodeId {
145 Scrollbar(modifier, state, Axis::Vertical, ScrollbarSpec::default())
146}
147
148#[composable]
150pub fn HorizontalScrollbar(modifier: Modifier, state: ScrollState) -> NodeId {
151 Scrollbar(modifier, state, Axis::Horizontal, ScrollbarSpec::default())
152}
153
154#[composable]
156pub fn Scrollbar(
157 modifier: Modifier,
158 state: ScrollState,
159 axis: Axis,
160 spec: ScrollbarSpec,
161) -> NodeId {
162 BoxWithConstraints(modifier, move |constraints: BoxWithConstraintsScopeImpl| {
163 let constraints = constraints.constraints();
164 let track = if axis.is_vertical() {
165 constraints.max_height
166 } else {
167 constraints.max_width
168 };
169 let track = if track.is_finite() {
170 track.max(0.0)
171 } else {
172 0.0
173 };
174 let bounds = spec.thumb_bounds(track);
175
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().fill_max_size().draggable(axis, dragged),
190 move |scope: &mut dyn DrawScope| {
191 draw_scrollbar(scope, state, axis, spec, drawn.is_dragging());
192 },
193 );
194 })
195}
196
197pub fn draw_scrollbar(
202 scope: &mut dyn DrawScope,
203 state: ScrollState,
204 axis: Axis,
205 spec: ScrollbarSpec,
206 dragging: bool,
207) {
208 let size = scope.size();
209 let track = if axis.is_vertical() {
210 size.height
211 } else {
212 size.width
213 };
214 let thickness = if axis.is_vertical() {
215 size.width
216 } else {
217 size.height
218 };
219 if track <= 0.0 || thickness <= 0.0 {
220 return;
221 }
222
223 let metrics = state.metrics();
224 let geometry = metrics.thumb(spec.thumb_bounds(track));
225 if geometry.is_none() && spec.hide_when_content_fits {
226 return;
227 }
228
229 let radii = CornerRadii::uniform(spec.resolved_corner_radius(thickness));
230 if spec.colors.track.3 > 0.0 {
231 scope.draw_round_rect_at(
232 Rect::from_size(size),
233 Brush::Solid(spec.colors.track),
234 radii,
235 );
236 }
237
238 let Some(geometry) = geometry else {
239 return;
240 };
241 let thumb_extent = (geometry.length * track).max(0.0);
242 let thumb_offset = (geometry.offset * track).max(0.0);
243 if thumb_extent <= 0.0 {
244 return;
245 }
246 let rect = if axis.is_vertical() {
247 Rect::from_origin_size(
248 Point::new(0.0, thumb_offset),
249 Size::new(thickness, thumb_extent),
250 )
251 } else {
252 Rect::from_origin_size(
253 Point::new(thumb_offset, 0.0),
254 Size::new(thumb_extent, thickness),
255 )
256 };
257 scope.draw_round_rect_at(rect, Brush::Solid(spec.colors.thumb_for(dragging)), radii);
258}
259
260#[cfg(test)]
261#[path = "tests/scrollbar_tests.rs"]
262mod tests;