use cranpose_core::NodeId;
use cranpose_ui_graphics::{Brush, Color, CornerRadii, DrawScope, Point, Rect, Size};
use cranpose_ui_layout::Axis;
use crate::{
composable,
draggable::rememberDraggableState,
modifier::Modifier,
scroll::ScrollState,
scrollbar::{ThumbBounds, content_delta_for_thumb_drag},
widgets::{
BoxWithConstraints, Canvas,
scopes::{BoxWithConstraintsScope, BoxWithConstraintsScopeImpl},
},
};
pub const DEFAULT_SCROLLBAR_THICKNESS: f32 = 8.0;
pub const DEFAULT_MIN_THUMB_EXTENT: f32 = 24.0;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ScrollbarColors {
pub track: Color,
pub thumb: Color,
pub dragged_thumb: Color,
}
impl ScrollbarColors {
pub fn thumb_for(self, dragging: bool) -> Color {
if dragging {
self.dragged_thumb
} else {
self.thumb
}
}
}
impl Default for ScrollbarColors {
fn default() -> Self {
Self {
track: Color(0.0, 0.0, 0.0, 0.06),
thumb: Color(0.0, 0.0, 0.0, 0.32),
dragged_thumb: Color(0.0, 0.0, 0.0, 0.56),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ScrollbarSpec {
pub thickness: f32,
pub min_thumb_extent: f32,
pub corner_radius: Option<f32>,
pub colors: ScrollbarColors,
pub hide_when_content_fits: bool,
}
impl ScrollbarSpec {
pub fn thickness(mut self, thickness: f32) -> Self {
self.thickness = thickness.max(0.0);
self
}
pub fn min_thumb_extent(mut self, extent: f32) -> Self {
self.min_thumb_extent = extent.max(0.0);
self
}
pub fn corner_radius(mut self, radius: f32) -> Self {
self.corner_radius = Some(radius.max(0.0));
self
}
pub fn colors(mut self, colors: ScrollbarColors) -> Self {
self.colors = colors;
self
}
pub fn hide_when_content_fits(mut self, hide: bool) -> Self {
self.hide_when_content_fits = hide;
self
}
pub fn resolved_corner_radius(&self, thickness: f32) -> f32 {
self.corner_radius.unwrap_or(thickness * 0.5).max(0.0)
}
pub fn thumb_bounds(&self, track: f32) -> ThumbBounds {
ThumbBounds::at_least(self.min_thumb_extent, track)
}
}
impl Default for ScrollbarSpec {
fn default() -> Self {
Self {
thickness: DEFAULT_SCROLLBAR_THICKNESS,
min_thumb_extent: DEFAULT_MIN_THUMB_EXTENT,
corner_radius: None,
colors: ScrollbarColors::default(),
hide_when_content_fits: true,
}
}
}
#[composable]
pub fn VerticalScrollbar(modifier: Modifier, state: ScrollState) -> NodeId {
Scrollbar(modifier, state, Axis::Vertical, ScrollbarSpec::default())
}
#[composable]
pub fn HorizontalScrollbar(modifier: Modifier, state: ScrollState) -> NodeId {
Scrollbar(modifier, state, Axis::Horizontal, ScrollbarSpec::default())
}
#[composable]
pub fn Scrollbar(
modifier: Modifier,
state: ScrollState,
axis: Axis,
spec: ScrollbarSpec,
) -> NodeId {
BoxWithConstraints(modifier, move |constraints: BoxWithConstraintsScopeImpl| {
let constraints = constraints.constraints();
let track = if axis.is_vertical() {
constraints.max_height
} else {
constraints.max_width
};
let track = if track.is_finite() {
track.max(0.0)
} else {
0.0
};
let bounds = spec.thumb_bounds(track);
let dragged = rememberDraggableState(move |delta| {
let metrics = state.metrics();
let Some(geometry) = metrics.thumb(bounds) else {
return;
};
let scroll = content_delta_for_thumb_drag(delta, track, geometry, metrics.max_offset);
if scroll != 0.0 {
state.dispatch_raw_delta(scroll);
}
});
let drawn = dragged.clone();
Canvas(
Modifier::empty().fill_max_size().draggable(axis, dragged),
move |scope: &mut dyn DrawScope| {
draw_scrollbar(scope, state, axis, spec, drawn.is_dragging());
},
);
})
}
pub fn draw_scrollbar(
scope: &mut dyn DrawScope,
state: ScrollState,
axis: Axis,
spec: ScrollbarSpec,
dragging: bool,
) {
let size = scope.size();
let track = if axis.is_vertical() {
size.height
} else {
size.width
};
let thickness = if axis.is_vertical() {
size.width
} else {
size.height
};
if track <= 0.0 || thickness <= 0.0 {
return;
}
let metrics = state.metrics();
let geometry = metrics.thumb(spec.thumb_bounds(track));
if geometry.is_none() && spec.hide_when_content_fits {
return;
}
let radii = CornerRadii::uniform(spec.resolved_corner_radius(thickness));
if spec.colors.track.3 > 0.0 {
scope.draw_round_rect_at(
Rect::from_size(size),
Brush::Solid(spec.colors.track),
radii,
);
}
let Some(geometry) = geometry else {
return;
};
let thumb_extent = (geometry.length * track).max(0.0);
let thumb_offset = (geometry.offset * track).max(0.0);
if thumb_extent <= 0.0 {
return;
}
let rect = if axis.is_vertical() {
Rect::from_origin_size(
Point::new(0.0, thumb_offset),
Size::new(thickness, thumb_extent),
)
} else {
Rect::from_origin_size(
Point::new(thumb_offset, 0.0),
Size::new(thumb_extent, thickness),
)
};
scope.draw_round_rect_at(rect, Brush::Solid(spec.colors.thumb_for(dragging)), radii);
}
#[cfg(test)]
#[path = "tests/scrollbar_tests.rs"]
mod tests;