1#![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
27pub const DEFAULT_SCROLLBAR_THICKNESS: f32 = 8.0;
29pub const DEFAULT_MIN_THUMB_EXTENT: f32 = 24.0;
36
37#[derive(Clone, Copy, Debug, PartialEq)]
39pub struct ScrollbarColors {
40 pub track: Color,
42 pub thumb: Color,
44 pub dragged_thumb: Color,
46}
47
48impl ScrollbarColors {
49 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#[derive(Clone, Copy, Debug, PartialEq)]
71pub struct ScrollbarSpec {
72 pub thickness: f32,
74 pub min_thumb_extent: f32,
76 pub corner_radius: Option<f32>,
78 pub colors: ScrollbarColors,
79 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 pub fn resolved_corner_radius(&self, thickness: f32) -> f32 {
115 self.corner_radius.unwrap_or(thickness * 0.5).max(0.0)
116 }
117
118 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#[composable]
141pub fn VerticalScrollbar(modifier: Modifier, state: ScrollState) -> NodeId {
142 Scrollbar(modifier, state, Axis::Vertical, ScrollbarSpec::default())
143}
144
145#[composable]
147pub fn HorizontalScrollbar(modifier: Modifier, state: ScrollState) -> NodeId {
148 Scrollbar(modifier, state, Axis::Horizontal, ScrollbarSpec::default())
149}
150
151#[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 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
199pub 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}