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| {
179 let metrics = state.metrics();
180 let Some(geometry) = metrics.thumb(bounds) else {
181 return;
182 };
183 let scroll = content_delta_for_thumb_drag(delta, track, geometry, metrics.max_offset);
184 if scroll != 0.0 {
185 state.dispatch_raw_delta(scroll);
186 }
187 });
188
189 let drawn = dragged.clone();
190 Canvas(
191 Modifier::empty()
192 .fill_max_size()
193 .draggable(axis, dragged.clone()),
194 move |scope: &mut dyn DrawScope| {
195 draw_scrollbar(scope, state, axis, spec, drawn.is_dragging());
196 },
197 );
198 })
199}
200
201pub fn draw_scrollbar(
206 scope: &mut dyn DrawScope,
207 state: ScrollState,
208 axis: Axis,
209 spec: ScrollbarSpec,
210 dragging: bool,
211) {
212 let size = scope.size();
213 let track = if axis.is_vertical() {
214 size.height
215 } else {
216 size.width
217 };
218 let thickness = if axis.is_vertical() {
219 size.width
220 } else {
221 size.height
222 };
223 if track <= 0.0 || thickness <= 0.0 {
224 return;
225 }
226
227 let metrics = state.metrics();
228 let geometry = metrics.thumb(spec.thumb_bounds(track));
229 if geometry.is_none() && spec.hide_when_content_fits {
230 return;
231 }
232
233 let radii = CornerRadii::uniform(spec.resolved_corner_radius(thickness));
234 if spec.colors.track.3 > 0.0 {
235 scope.draw_round_rect_at(
236 Rect::from_size(size),
237 Brush::Solid(spec.colors.track),
238 radii,
239 );
240 }
241
242 let Some(geometry) = geometry else {
243 return;
244 };
245 let thumb_extent = (geometry.length * track).max(0.0);
246 let thumb_offset = (geometry.offset * track).max(0.0);
247 if thumb_extent <= 0.0 {
248 return;
249 }
250 let rect = if axis.is_vertical() {
251 Rect::from_origin_size(
252 Point::new(0.0, thumb_offset),
253 Size::new(thickness, thumb_extent),
254 )
255 } else {
256 Rect::from_origin_size(
257 Point::new(thumb_offset, 0.0),
258 Size::new(thumb_extent, thickness),
259 )
260 };
261 scope.draw_round_rect_at(rect, Brush::Solid(spec.colors.thumb_for(dragging)), radii);
262}
263
264#[cfg(test)]
265mod tests {
266 use std::sync::Arc;
267
268 use cranpose_core::{DefaultScheduler, Runtime};
269 use cranpose_ui_graphics::{DrawPrimitive, DrawScopeDefault};
270
271 use super::*;
272
273 fn scrollable_state(viewport: f32, content: f32, offset: f32) -> ScrollState {
274 let state = ScrollState::new(0.0);
275 state.set_viewport_extent(viewport);
276 state.set_max_value((content - viewport).max(0.0));
277 state.scroll_to(offset);
278 state
279 }
280
281 fn scene(
282 size: Size,
283 state: ScrollState,
284 axis: Axis,
285 spec: ScrollbarSpec,
286 ) -> Vec<DrawPrimitive> {
287 let mut scope = DrawScopeDefault::new(size);
288 draw_scrollbar(&mut scope, state, axis, spec, false);
289 scope.into_primitives()
290 }
291
292 fn rects(primitives: &[DrawPrimitive]) -> Vec<Rect> {
293 primitives
294 .iter()
295 .filter_map(|primitive| match primitive {
296 DrawPrimitive::Rect { rect, .. } => Some(*rect),
297 DrawPrimitive::RoundRect { rect, .. } => Some(*rect),
298 _ => None,
299 })
300 .collect()
301 }
302
303 #[test]
304 fn colors_answer_for_the_current_interaction() {
305 let colors = ScrollbarColors::default();
306 assert_eq!(colors.thumb_for(false), colors.thumb);
307 assert_eq!(colors.thumb_for(true), colors.dragged_thumb);
308 }
309
310 #[test]
311 fn a_bar_is_a_pill_unless_it_was_asked_for_something_squarer() {
312 let spec = ScrollbarSpec::default();
313 assert_eq!(spec.resolved_corner_radius(8.0), 4.0);
314 assert_eq!(spec.corner_radius(0.0).resolved_corner_radius(8.0), 0.0);
315 assert_eq!(spec.corner_radius(-3.0).resolved_corner_radius(8.0), 0.0);
316 }
317
318 #[test]
319 fn spec_builders_clamp_to_drawable_values() {
320 let spec = ScrollbarSpec::default()
321 .thickness(-4.0)
322 .min_thumb_extent(-1.0)
323 .hide_when_content_fits(false);
324 assert_eq!(spec.thickness, 0.0);
325 assert_eq!(spec.min_thumb_extent, 0.0);
326 assert!(!spec.hide_when_content_fits);
327 }
328
329 #[test]
330 fn content_that_fits_draws_nothing_at_all() {
331 let _runtime = Runtime::new(Arc::new(DefaultScheduler));
332 let _app_context = crate::render_state::app_context_test_scope();
333 let state = scrollable_state(200.0, 200.0, 0.0);
334 let primitives = scene(
335 Size::new(8.0, 200.0),
336 state,
337 Axis::Vertical,
338 ScrollbarSpec::default(),
339 );
340 assert!(primitives.is_empty());
341 }
342
343 #[test]
344 fn content_that_fits_still_draws_its_track_when_the_bar_is_pinned_visible() {
345 let _runtime = Runtime::new(Arc::new(DefaultScheduler));
346 let _app_context = crate::render_state::app_context_test_scope();
347 let state = scrollable_state(200.0, 200.0, 0.0);
348 let spec = ScrollbarSpec::default().hide_when_content_fits(false);
349 let primitives = scene(Size::new(8.0, 200.0), state, Axis::Vertical, spec);
350 assert_eq!(
351 rects(&primitives),
352 vec![Rect::from_size(Size::new(8.0, 200.0))]
353 );
354 }
355
356 #[test]
357 fn the_thumb_is_the_share_of_content_on_screen_and_moves_with_it() {
358 let _runtime = Runtime::new(Arc::new(DefaultScheduler));
359 let _app_context = crate::render_state::app_context_test_scope();
360 let spec = ScrollbarSpec::default().min_thumb_extent(0.0);
361
362 let top = scrollable_state(200.0, 800.0, 0.0);
363 let drawn = rects(&scene(Size::new(8.0, 200.0), top, Axis::Vertical, spec));
364 assert_eq!(drawn.len(), 2, "a track and a thumb");
365 assert_eq!(
366 drawn[1],
367 Rect::from_origin_size(Point::new(0.0, 0.0), Size::new(8.0, 50.0))
368 );
369
370 let bottom = scrollable_state(200.0, 800.0, 600.0);
371 let drawn = rects(&scene(Size::new(8.0, 200.0), bottom, Axis::Vertical, spec));
372 assert_eq!(
373 drawn[1],
374 Rect::from_origin_size(Point::new(0.0, 150.0), Size::new(8.0, 50.0))
375 );
376 }
377
378 #[test]
379 fn a_horizontal_bar_lays_its_thumb_along_the_other_axis() {
380 let _runtime = Runtime::new(Arc::new(DefaultScheduler));
381 let _app_context = crate::render_state::app_context_test_scope();
382 let spec = ScrollbarSpec::default().min_thumb_extent(0.0);
383 let state = scrollable_state(200.0, 800.0, 600.0);
384 let drawn = rects(&scene(Size::new(200.0, 8.0), state, Axis::Horizontal, spec));
385 assert_eq!(
386 drawn[1],
387 Rect::from_origin_size(Point::new(150.0, 0.0), Size::new(50.0, 8.0))
388 );
389 }
390
391 #[test]
392 fn a_very_long_document_keeps_a_thumb_big_enough_to_grab() {
393 let _runtime = Runtime::new(Arc::new(DefaultScheduler));
394 let _app_context = crate::render_state::app_context_test_scope();
395 let state = scrollable_state(200.0, 200_000.0, 0.0);
396 let drawn = rects(&scene(
397 Size::new(8.0, 200.0),
398 state,
399 Axis::Vertical,
400 ScrollbarSpec::default(),
401 ));
402 assert_eq!(drawn[1].height, DEFAULT_MIN_THUMB_EXTENT);
403 }
404
405 #[test]
406 fn a_bar_with_no_room_draws_nothing() {
407 let _runtime = Runtime::new(Arc::new(DefaultScheduler));
408 let _app_context = crate::render_state::app_context_test_scope();
409 let state = scrollable_state(200.0, 800.0, 0.0);
410 assert!(
411 scene(
412 Size::new(0.0, 200.0),
413 state,
414 Axis::Vertical,
415 ScrollbarSpec::default()
416 )
417 .is_empty()
418 );
419 assert!(
420 scene(
421 Size::new(8.0, 0.0),
422 state,
423 Axis::Vertical,
424 ScrollbarSpec::default()
425 )
426 .is_empty()
427 );
428 }
429
430 #[test]
431 fn a_specs_thumb_bounds_state_its_minimum_as_a_track_fraction() {
432 let spec = ScrollbarSpec::default();
433 let bounds = spec.thumb_bounds(240.0);
434 assert!(
435 (bounds.minimum() - DEFAULT_MIN_THUMB_EXTENT / 240.0).abs() < 1.0e-6,
436 "a 24dp floor on a 240dp track is a tenth of it"
437 );
438 assert_eq!(bounds.maximum(), 1.0, "a thumb may still fill its track");
439 }
440
441 #[test]
442 fn a_thumb_floor_taller_than_its_track_asks_for_the_whole_track() {
443 let bounds = ScrollbarSpec::default().thumb_bounds(10.0);
444 assert_eq!(
445 bounds.minimum(),
446 1.0,
447 "a 24dp floor cannot fit in 10dp, so the thumb takes everything"
448 );
449 assert_eq!(bounds.maximum(), 1.0);
450 }
451
452 #[test]
453 fn a_track_with_no_extent_leaves_the_thumb_unbounded() {
454 for track in [0.0_f32, -40.0, f32::NAN, f32::INFINITY] {
455 let bounds = ScrollbarSpec::default().thumb_bounds(track);
456 assert_eq!(
457 bounds.minimum(),
458 0.0,
459 "track {track} must not floor a thumb"
460 );
461 assert_eq!(bounds.maximum(), 1.0);
462 }
463 }
464
465 #[test]
466 fn resolved_corner_radius_is_a_pill_until_a_caller_squares_it() {
467 let spec = ScrollbarSpec::default();
468 assert_eq!(
469 spec.resolved_corner_radius(8.0),
470 4.0,
471 "half the thickness reads as a pill"
472 );
473 assert_eq!(
474 ScrollbarSpec::default()
475 .corner_radius(0.0)
476 .resolved_corner_radius(8.0),
477 0.0,
478 "a caller asking for square corners gets them"
479 );
480 assert_eq!(
481 ScrollbarSpec::default()
482 .corner_radius(-5.0)
483 .resolved_corner_radius(8.0),
484 0.0,
485 "a negative radius is not a shape"
486 );
487 }
488}