use crate::event::Rect;
use crate::types::WidgetId;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ScrollAxis {
Vertical,
Horizontal,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Scrollbar {
pub id: WidgetId,
pub axis: ScrollAxis,
pub track: Rect,
pub thumb_start: f32,
pub thumb_len: f32,
#[serde(default)]
pub hovered: bool,
#[serde(default)]
pub dragging: bool,
}
impl Scrollbar {
pub fn vertical(
id: impl Into<WidgetId>,
track: Rect,
scroll: f32,
total: f32,
visible: f32,
min_thumb_len: f32,
) -> Self {
let (thumb_start, thumb_len) =
fit_thumb(scroll, total, visible, track.height, min_thumb_len);
Self {
id: id.into(),
axis: ScrollAxis::Vertical,
track,
thumb_start,
thumb_len,
hovered: false,
dragging: false,
}
}
pub fn horizontal(
id: impl Into<WidgetId>,
track: Rect,
scroll: f32,
total: f32,
visible: f32,
min_thumb_len: f32,
) -> Self {
let (thumb_start, thumb_len) =
fit_thumb(scroll, total, visible, track.width, min_thumb_len);
Self {
id: id.into(),
axis: ScrollAxis::Horizontal,
track,
thumb_start,
thumb_len,
hovered: false,
dragging: false,
}
}
}
impl Default for Scrollbar {
fn default() -> Self {
Self {
id: WidgetId::new(""),
axis: ScrollAxis::Vertical,
track: Rect::default(),
thumb_start: 0.0,
thumb_len: 0.0,
hovered: false,
dragging: false,
}
}
}
pub fn fit_thumb(
scroll: f32,
total: f32,
visible: f32,
track_len: f32,
min_thumb_len: f32,
) -> (f32, f32) {
if total <= 0.0 || track_len <= 0.0 || visible <= 0.0 || total <= visible {
return (0.0, 0.0);
}
let raw_len = (visible / total) * track_len;
let thumb_len = raw_len.max(min_thumb_len).min(track_len);
let scroll_range = (total - visible).max(1.0);
let travel = (track_len - thumb_len).max(0.0);
let thumb_start = (scroll / scroll_range).clamp(0.0, 1.0) * travel;
(thumb_start, thumb_len)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PressZone {
Before,
Thumb,
After,
}
impl Scrollbar {
pub fn press_zone(&self, pos: f32) -> PressZone {
let thumb_start = match self.axis {
ScrollAxis::Vertical => self.track.y + self.thumb_start,
ScrollAxis::Horizontal => self.track.x + self.thumb_start,
};
let thumb_end = thumb_start + self.thumb_len;
if pos < thumb_start {
PressZone::Before
} else if pos < thumb_end {
PressZone::Thumb
} else {
PressZone::After
}
}
pub fn travel(&self) -> f32 {
let track_len = match self.axis {
ScrollAxis::Vertical => self.track.height,
ScrollAxis::Horizontal => self.track.width,
};
(track_len - self.thumb_len).max(0.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ThumbDrag {
origin_pos: f32,
origin_offset: f32,
travel: f32,
max_offset: f32,
}
impl ThumbDrag {
pub fn begin(origin_pos: f32, origin_offset: f32, travel: f32, max_offset: f32) -> Self {
Self {
origin_pos,
origin_offset,
travel,
max_offset,
}
}
pub fn offset_at(&self, pos: f32) -> Option<f32> {
if self.travel <= 0.0 || self.max_offset <= 0.0 {
return None;
}
let delta = pos - self.origin_pos;
let scaled = delta / self.travel * self.max_offset;
Some((self.origin_offset + scaled).clamp(0.0, self.max_offset))
}
pub fn max_offset(&self) -> f32 {
self.max_offset
}
}
pub fn scroll_rows(scroll_offset: f32) -> usize {
if !scroll_offset.is_finite() || scroll_offset <= 0.0 {
0
} else {
scroll_offset.round() as usize
}
}
pub fn scroll_by_clamped(current: usize, delta: isize, max: usize) -> usize {
let cur = current as isize;
let max = max as isize;
(cur + delta).clamp(0, max) as usize
}
pub fn clamp_scroll_offset(scroll_offset: usize, content_len: usize) -> usize {
if content_len == 0 {
0
} else {
scroll_offset.min(content_len - 1)
}
}
pub fn scroll_by_clamped_f32(current: f32, delta: f32, max: f32) -> f32 {
let max = if max.is_finite() { max.max(0.0) } else { 0.0 };
let next = current + delta;
if next.is_finite() {
next.clamp(0.0, max)
} else {
0.0
}
}
pub fn clamp_scroll_offset_f32(scroll_offset: f32, content_len: usize) -> f32 {
if content_len == 0 {
0.0
} else {
scroll_by_clamped_f32(scroll_offset, 0.0, (content_len - 1) as f32)
}
}
pub fn wheel_delta(delta_y: f32, units_per_notch: f32) -> f32 {
if delta_y.is_finite() && units_per_notch.is_finite() {
-delta_y * units_per_notch
} else {
0.0
}
}
pub fn wheel_row_delta(delta_y: f32, rows_per_notch: f32) -> isize {
let rows = wheel_delta(delta_y, rows_per_notch);
if rows == 0.0 {
return 0;
}
let magnitude = rows.abs().round().max(1.0) as isize;
if rows < 0.0 {
-magnitude
} else {
magnitude
}
}
pub fn visible_window(
scroll_offset: usize,
content_len: usize,
viewport_rows: usize,
) -> (usize, usize) {
if content_len == 0 {
return (0, 0);
}
let start = clamp_scroll_offset(scroll_offset, content_len);
let end = (start + viewport_rows).min(content_len);
(start, end)
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
#[allow(dead_code)]
pub(crate) mod native_surface_paint {
use super::{ScrollAxis, Scrollbar};
use crate::paint_surface::PaintSurface;
use crate::theme::Theme;
use crate::Rect;
pub(crate) fn paint(scrollbar: &Scrollbar, surface: &mut dyn PaintSurface, theme: &Theme) {
let track = scrollbar.track;
if track.width <= 0.0 || track.height <= 0.0 {
return;
}
let track_alpha = if scrollbar.hovered || scrollbar.dragging {
0.35
} else {
0.20
};
let thumb_alpha = if scrollbar.dragging {
0.90
} else if scrollbar.hovered {
0.80
} else {
0.65
};
surface.surface_fill_rect(track, theme.scrollbar_track.with_alpha(track_alpha));
let thumb_rect = match scrollbar.axis {
ScrollAxis::Vertical => Rect::new(
track.x,
track.y + scrollbar.thumb_start,
track.width,
scrollbar.thumb_len,
),
ScrollAxis::Horizontal => Rect::new(
track.x + scrollbar.thumb_start,
track.y,
scrollbar.thumb_len,
track.height,
),
};
let thumb_radius = thumb_rect.width.min(thumb_rect.height) / 2.0;
surface.surface_fill_rounded_rect(
thumb_rect,
thumb_radius,
theme.scrollbar_thumb.with_alpha(thumb_alpha),
);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::ImagePaintResult;
use crate::event::Viewport;
use crate::types::{Color, WidgetId};
use crate::Image;
#[derive(Default)]
struct RecordingSurface {
fills: Vec<(Rect, Color)>,
rounded_radii: Vec<f32>,
}
impl PaintSurface for RecordingSurface {
fn surface_begin_frame(&mut self, _viewport: Viewport) {}
fn surface_end_frame(&mut self) {}
fn surface_viewport(&self) -> Viewport {
Viewport::new(200.0, 100.0, 1.0)
}
fn surface_line_height(&self) -> f32 {
16.0
}
fn surface_char_width(&self) -> f32 {
8.0
}
fn surface_measure_text(&self, _text: &str) -> (f32, f32) {
(0.0, 0.0)
}
fn surface_fill_rect(&mut self, rect: Rect, color: Color) {
self.fills.push((rect, color));
}
fn surface_fill_rounded_rect(&mut self, rect: Rect, radius: f32, color: Color) {
self.fills.push((rect, color));
self.rounded_radii.push(radius);
}
fn surface_stroke_rect(&mut self, _rect: Rect, _color: Color, _stroke_width: f32) {}
fn surface_stroke_rounded_rect(
&mut self,
_rect: Rect,
_radius: f32,
_color: Color,
_stroke_width: f32,
) {
}
fn surface_draw_text_run(&mut self, _rect: Rect, _text: &str, _color: Color) {}
fn surface_draw_line(
&mut self,
_from: crate::Point,
_to: crate::Point,
_color: Color,
_stroke_width: f32,
) {
}
fn surface_push_clip(&mut self, _rect: Rect) {}
fn surface_pop_clip(&mut self) {}
fn surface_draw_image(&mut self, _rect: Rect, _image: &Image) -> ImagePaintResult {
ImagePaintResult::Unsupported
}
}
fn vertical_bar(scroll: f32, total: f32, visible: f32) -> Scrollbar {
Scrollbar::vertical(
WidgetId::new("sb"),
Rect::new(0.0, 0.0, 8.0, 200.0),
scroll,
total,
visible,
20.0,
)
}
#[test]
fn track_and_thumb_paint_with_real_alpha_not_opaque() {
let sb = vertical_bar(0.0, 200.0, 50.0);
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(&sb, &mut surface, &theme);
assert_eq!(
surface.fills.len(),
2,
"expected exactly a track fill and a thumb fill"
);
for (_, color) in &surface.fills {
assert!(
color.a < 255,
"scrollbar fill must carry real alpha, got opaque a={}",
color.a
);
}
}
#[test]
fn resting_thumb_has_high_contrast_rounded_fill() {
let sb = vertical_bar(0.0, 200.0, 50.0);
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(&sb, &mut surface, &theme);
let (thumb_rect, thumb_color) = surface.fills[1];
assert!(
thumb_color.a >= 166,
"resting thumb alpha should floor at ~0.65, got a={}",
thumb_color.a
);
assert_eq!(
surface.rounded_radii.len(),
1,
"thumb should paint via surface_fill_rounded_rect, not a sharp-cornered fill"
);
assert!(
surface.rounded_radii[0] > 0.0,
"thumb corner radius should be positive"
);
assert!(
surface.rounded_radii[0] <= thumb_rect.width.min(thumb_rect.height) / 2.0 + 0.01
);
}
#[test]
fn dragging_increases_thumb_alpha() {
let mut sb = vertical_bar(0.0, 200.0, 50.0);
let theme = Theme::default();
let mut normal = RecordingSurface::default();
paint(&sb, &mut normal, &theme);
let normal_thumb_alpha = normal.fills[1].1.a;
sb.dragging = true;
let mut dragging = RecordingSurface::default();
paint(&sb, &mut dragging, &theme);
let dragging_thumb_alpha = dragging.fills[1].1.a;
assert!(
dragging_thumb_alpha > normal_thumb_alpha,
"dragging should raise thumb alpha: normal={normal_thumb_alpha}, dragging={dragging_thumb_alpha}"
);
}
#[test]
fn zero_size_track_paints_nothing() {
let sb = Scrollbar::vertical(
WidgetId::new("sb"),
Rect::new(0.0, 0.0, 0.0, 0.0),
0.0,
200.0,
50.0,
20.0,
);
let mut surface = RecordingSurface::default();
paint(&sb, &mut surface, &Theme::default());
assert!(surface.fills.is_empty());
}
#[test]
fn horizontal_thumb_rect_uses_width_axis() {
let track = Rect::new(0.0, 50.0, 100.0, 8.0);
let sb = Scrollbar::horizontal(WidgetId::new("h"), track, 0.0, 200.0, 40.0, 10.0);
let mut surface = RecordingSurface::default();
paint(&sb, &mut surface, &Theme::default());
let (thumb_rect, _) = surface.fills[1];
assert_eq!(thumb_rect.y, track.y);
assert_eq!(thumb_rect.height, track.height);
assert!(thumb_rect.width > 0.0 && thumb_rect.width <= track.width);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fit_thumb_zero_range_returns_zero() {
assert_eq!(fit_thumb(0.0, 0.0, 100.0, 200.0, 1.0), (0.0, 0.0));
assert_eq!(fit_thumb(0.0, 100.0, 100.0, 200.0, 1.0), (0.0, 0.0));
assert_eq!(fit_thumb(0.0, 100.0, 200.0, 200.0, 1.0), (0.0, 0.0));
}
#[test]
fn fit_thumb_proportional_size() {
let (start, len) = fit_thumb(0.0, 100.0, 20.0, 200.0, 1.0);
assert_eq!(start, 0.0);
assert!((len - 40.0).abs() < 0.01);
}
#[test]
fn fit_thumb_min_length_applied() {
let (_start, len) = fit_thumb(0.0, 1000.0, 1.0, 100.0, 10.0);
assert!(len >= 10.0);
}
#[test]
fn fit_thumb_clamped_to_track() {
let (_start, len) = fit_thumb(0.0, 100.0, 50.0, 30.0, 100.0);
assert!(len <= 30.0);
}
#[test]
fn fit_thumb_full_scroll_aligns_to_track_end() {
let (start, len) = fit_thumb(80.0, 100.0, 20.0, 200.0, 1.0);
assert!((start + len - 200.0).abs() < 0.01);
}
#[test]
fn fit_thumb_clamps_overscroll() {
let (start, len) = fit_thumb(500.0, 100.0, 20.0, 200.0, 1.0);
assert!((start + len - 200.0).abs() < 0.01);
}
#[test]
fn fit_thumb_horizontal_uses_width_units() {
let (start, len) = fit_thumb(50.0, 200.0, 100.0, 400.0, 1.0);
assert!((len - 200.0).abs() < 0.01);
assert!(start >= 0.0 && start + len <= 400.0);
}
#[test]
fn vertical_constructor_uses_track_height() {
let track = Rect::new(10.0, 20.0, 8.0, 100.0);
let sb = Scrollbar::vertical("v", track, 0.0, 200.0, 50.0, 5.0);
assert!(matches!(sb.axis, ScrollAxis::Vertical));
assert_eq!(sb.track.height, 100.0);
assert!((sb.thumb_len - 25.0).abs() < 0.01);
}
#[test]
fn horizontal_constructor_uses_track_width() {
let track = Rect::new(0.0, 0.0, 100.0, 4.0);
let sb = Scrollbar::horizontal("h", track, 0.0, 200.0, 100.0, 5.0);
assert!(matches!(sb.axis, ScrollAxis::Horizontal));
assert_eq!(sb.track.width, 100.0);
assert!((sb.thumb_len - 50.0).abs() < 0.01);
}
#[test]
fn clamp_scroll_offset_empty_content_returns_zero() {
assert_eq!(clamp_scroll_offset(0, 0), 0);
assert_eq!(clamp_scroll_offset(50, 0), 0);
}
#[test]
fn clamp_scroll_offset_in_range_is_unchanged() {
assert_eq!(clamp_scroll_offset(0, 10), 0);
assert_eq!(clamp_scroll_offset(5, 10), 5);
assert_eq!(clamp_scroll_offset(9, 10), 9);
}
#[test]
fn clamp_scroll_offset_pins_to_last_item() {
assert_eq!(clamp_scroll_offset(10, 10), 9);
assert_eq!(clamp_scroll_offset(1000, 10), 9);
}
#[test]
fn clamp_scroll_offset_single_item() {
assert_eq!(clamp_scroll_offset(0, 1), 0);
assert_eq!(clamp_scroll_offset(50, 1), 0);
}
#[test]
fn visible_window_empty_content_is_zero_zero() {
assert_eq!(visible_window(0, 0, 5), (0, 0));
assert_eq!(visible_window(50, 0, 5), (0, 0));
}
#[test]
fn visible_window_fits_entirely_in_viewport() {
assert_eq!(visible_window(0, 5, 10), (0, 5));
}
#[test]
fn visible_window_slices_a_middle_page() {
assert_eq!(visible_window(3, 20, 5), (3, 8));
}
#[test]
fn visible_window_clamps_end_to_content_len() {
assert_eq!(visible_window(18, 20, 5), (18, 20));
}
#[test]
fn visible_window_overscroll_still_shows_last_item() {
assert_eq!(visible_window(1000, 20, 5), (19, 20));
}
#[test]
fn visible_window_zero_viewport_rows_is_empty() {
assert_eq!(visible_window(3, 20, 0), (3, 3));
}
#[test]
fn press_zone_before_on_after_thumb() {
let sb = Scrollbar::vertical("v", Rect::new(0.0, 0.0, 8.0, 200.0), 40.0, 200.0, 40.0, 1.0);
assert_eq!(sb.thumb_start, 40.0);
assert!((sb.thumb_len - 40.0).abs() < 0.01);
assert_eq!(sb.press_zone(10.0), PressZone::Before);
assert_eq!(sb.press_zone(sb.thumb_start), PressZone::Thumb);
assert_eq!(
sb.press_zone(sb.thumb_start + sb.thumb_len - 1.0),
PressZone::Thumb
);
assert_eq!(
sb.press_zone(sb.thumb_start + sb.thumb_len),
PressZone::After
);
}
#[test]
fn press_zone_uses_x_axis_for_horizontal() {
let track = Rect::new(0.0, 50.0, 100.0, 8.0);
let sb = Scrollbar::horizontal("h", track, 0.0, 200.0, 40.0, 10.0);
assert_eq!(sb.press_zone(0.0), PressZone::Thumb);
assert_eq!(
sb.press_zone(track.x + sb.thumb_len + 1.0),
PressZone::After
);
}
#[test]
fn travel_is_track_len_minus_thumb_len() {
let sb = Scrollbar::vertical("v", Rect::new(0.0, 0.0, 8.0, 200.0), 0.0, 200.0, 40.0, 1.0);
assert!((sb.travel() - (200.0 - sb.thumb_len)).abs() < 0.01);
}
#[test]
fn travel_never_negative() {
let sb = Scrollbar::vertical(
"v",
Rect::new(0.0, 0.0, 8.0, 50.0),
0.0,
200.0,
40.0,
1000.0,
);
assert_eq!(sb.travel(), 0.0);
}
#[test]
fn thumb_drag_no_movement_returns_origin_offset() {
let drag = ThumbDrag::begin(100.0, 5.0, 80.0, 20.0);
assert_eq!(drag.offset_at(100.0), Some(5.0));
}
#[test]
fn thumb_drag_moves_proportionally() {
let drag = ThumbDrag::begin(0.0, 0.0, 80.0, 20.0);
assert_eq!(drag.offset_at(40.0), Some(10.0));
}
#[test]
fn thumb_drag_clamps_to_range() {
let drag = ThumbDrag::begin(0.0, 0.0, 80.0, 20.0);
assert_eq!(drag.offset_at(-1000.0), Some(0.0));
assert_eq!(drag.offset_at(1000.0), Some(20.0));
}
#[test]
fn thumb_drag_zero_travel_or_max_offset_is_none() {
assert_eq!(ThumbDrag::begin(0.0, 0.0, 0.0, 20.0).offset_at(50.0), None);
assert_eq!(ThumbDrag::begin(0.0, 0.0, 80.0, 0.0).offset_at(50.0), None);
}
#[test]
fn scroll_by_clamped_moves_within_range() {
assert_eq!(scroll_by_clamped(5, 3, 20), 8);
assert_eq!(scroll_by_clamped(5, -3, 20), 2);
}
#[test]
fn scroll_by_clamped_clamps_to_bounds() {
assert_eq!(scroll_by_clamped(5, -100, 20), 0);
assert_eq!(scroll_by_clamped(5, 100, 20), 20);
}
#[test]
fn scroll_rows_rounds_sub_row_offsets_to_the_nearest_row() {
assert_eq!(scroll_rows(0.0), 0);
assert_eq!(scroll_rows(0.4), 0);
assert_eq!(scroll_rows(0.5), 1);
assert_eq!(scroll_rows(2.49), 2);
assert_eq!(scroll_rows(2.5), 3);
assert_eq!(scroll_rows(7.0), 7);
}
#[test]
fn scroll_rows_floors_degenerate_offsets_to_zero() {
assert_eq!(scroll_rows(-3.0), 0);
assert_eq!(scroll_rows(f32::NAN), 0);
assert_eq!(scroll_rows(f32::INFINITY), 0);
assert_eq!(scroll_rows(f32::NEG_INFINITY), 0);
}
#[test]
fn scroll_by_clamped_f32_preserves_sub_row_precision() {
assert_eq!(scroll_by_clamped_f32(1.25, 0.5, 20.0), 1.75);
assert_eq!(scroll_by_clamped_f32(1.25, -0.5, 20.0), 0.75);
}
#[test]
fn scroll_by_clamped_f32_clamps_to_bounds() {
assert_eq!(scroll_by_clamped_f32(5.0, -100.0, 20.0), 0.0);
assert_eq!(scroll_by_clamped_f32(5.0, 100.0, 20.0), 20.0);
assert_eq!(scroll_by_clamped_f32(5.0, 1.0, -4.0), 0.0);
assert_eq!(scroll_by_clamped_f32(5.0, f32::NAN, 20.0), 0.0);
}
#[test]
fn clamp_scroll_offset_f32_pins_the_last_item_reachable() {
assert_eq!(clamp_scroll_offset_f32(2.5, 0), 0.0);
assert_eq!(clamp_scroll_offset_f32(2.5, 10), 2.5);
assert_eq!(clamp_scroll_offset_f32(40.0, 10), 9.0);
assert_eq!(clamp_scroll_offset_f32(-1.5, 10), 0.0);
}
#[test]
fn wheel_delta_flips_sign_and_scales_by_units_per_notch() {
assert_eq!(wheel_delta(1.0, 1.0), -1.0);
assert_eq!(wheel_delta(-1.0, 1.0), 1.0);
assert_eq!(wheel_delta(-2.5, 3.0), 7.5);
assert_eq!(wheel_delta(0.25, 16.0), -4.0);
}
#[test]
fn wheel_delta_resolves_non_finite_input_to_zero() {
assert_eq!(wheel_delta(f32::NAN, 1.0), 0.0);
assert_eq!(wheel_delta(f32::INFINITY, 1.0), 0.0);
assert_eq!(wheel_delta(1.0, f32::NAN), 0.0);
}
#[test]
fn wheel_row_delta_carries_the_gesture_magnitude_not_just_its_sign() {
assert_eq!(wheel_row_delta(1.0, 1.0), -1);
assert_eq!(wheel_row_delta(-1.0, 1.0), 1);
assert_eq!(wheel_row_delta(-4.0, 1.0), 4);
assert_eq!(wheel_row_delta(5.0, 1.0), -5);
assert_eq!(wheel_row_delta(-2.0, 3.0), 6);
}
#[test]
fn wheel_row_delta_never_swallows_a_sub_row_gesture() {
assert_eq!(wheel_row_delta(0.1, 1.0), -1);
assert_eq!(wheel_row_delta(-0.1, 1.0), 1);
assert_eq!(wheel_row_delta(0.0, 1.0), 0);
assert_eq!(wheel_row_delta(f32::NAN, 1.0), 0);
}
}