1#![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
32pub const DEFAULT_SCROLLBAR_THICKNESS: f32 = 8.0;
34pub const DEFAULT_MIN_THUMB_EXTENT: f32 = 24.0;
41
42#[derive(Clone, Copy, Debug, PartialEq)]
44pub struct ScrollbarColors {
45 pub track: Color,
47 pub thumb: Color,
49 pub dragged_thumb: Color,
51}
52
53impl ScrollbarColors {
54 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#[derive(Clone, Copy, Debug, PartialEq)]
76pub struct ScrollbarSpec {
77 pub thickness: f32,
79 pub min_thumb_extent: f32,
81 pub corner_radius: Option<f32>,
83 pub colors: ScrollbarColors,
84 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 pub fn resolved_corner_radius(&self, thickness: f32) -> f32 {
120 self.corner_radius.unwrap_or(thickness * 0.5).max(0.0)
121 }
122
123 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#[composable]
146pub fn VerticalScrollbar(modifier: Modifier, state: ScrollState) -> NodeId {
147 Scrollbar(modifier, state, Axis::Vertical, ScrollbarSpec::default())
148}
149
150#[composable]
152pub fn HorizontalScrollbar(modifier: Modifier, state: ScrollState) -> NodeId {
153 Scrollbar(modifier, state, Axis::Horizontal, ScrollbarSpec::default())
154}
155
156#[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 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
204pub 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}