use std::ops::Range;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::{layout::Rect, math::Vec2};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum ScrollAnchor {
#[default]
Top,
Bottom,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct ScrollState {
pub offset: usize,
pub total_items: usize,
pub viewport_items: usize,
pub anchor: ScrollAnchor,
}
impl ScrollState {
pub const fn new() -> Self {
Self {
offset: 0,
total_items: 0,
viewport_items: 0,
anchor: ScrollAnchor::Top,
}
}
pub const fn with_anchor(mut self, anchor: ScrollAnchor) -> Self {
self.anchor = anchor;
self
}
pub fn set_bounds(&mut self, total_items: usize, viewport_items: usize) {
let was_at_end = self.is_at_end();
self.total_items = total_items;
self.viewport_items = viewport_items.max(1);
if self.anchor == ScrollAnchor::Bottom && was_at_end {
self.offset = self.max_offset();
} else {
self.offset = self.offset.min(self.max_offset());
}
}
pub fn max_offset(self) -> usize {
self.total_items.saturating_sub(self.viewport_items.max(1))
}
pub fn is_at_end(self) -> bool {
self.offset >= self.max_offset()
}
pub fn scroll_to(&mut self, offset: usize) {
self.offset = offset.min(self.max_offset());
}
pub fn scroll_by(&mut self, delta: isize) {
let next = if delta.is_negative() {
self.offset.saturating_sub(delta.unsigned_abs())
} else {
self.offset.saturating_add(delta as usize)
};
self.scroll_to(next);
}
pub fn scroll_to_start(&mut self) {
self.offset = 0;
}
pub fn scroll_to_end(&mut self) {
self.offset = self.max_offset();
}
pub fn ensure_visible(&mut self, index: usize) {
if index < self.offset {
self.scroll_to(index);
} else {
let viewport = self.viewport_items.max(1);
let end = self.offset.saturating_add(viewport);
if index >= end {
self.scroll_to(index.saturating_sub(viewport - 1));
}
}
}
pub fn visible_range(self) -> Range<usize> {
let start = self.offset.min(self.max_offset());
let end = start
.saturating_add(self.viewport_items.max(1))
.min(self.total_items);
start..end
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum ScrollAxis {
Horizontal,
Vertical,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum ScrollbarAnchor {
Start,
End,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct AbsoluteOffset {
pub x: f32,
pub y: f32,
}
impl AbsoluteOffset {
pub const ZERO: Self = Self { x: 0.0, y: 0.0 };
pub const fn new(x: f32, y: f32) -> Self {
Self { x, y }
}
pub fn to_vec2(self) -> Vec2 {
Vec2::new(
finite_or(self.x, 0.0).max(0.0),
finite_or(self.y, 0.0).max(0.0),
)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct RelativeOffset {
pub x: f32,
pub y: f32,
}
impl RelativeOffset {
pub const ZERO: Self = Self { x: 0.0, y: 0.0 };
pub const fn new(x: f32, y: f32) -> Self {
Self { x, y }
}
pub fn clamped(self) -> Self {
Self {
x: clamp01(self.x),
y: clamp01(self.y),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct ScrollbarConfig {
pub width_px: f32,
pub margin_px: f32,
pub min_thumb_px: f32,
pub anchor: ScrollbarAnchor,
}
impl ScrollbarConfig {
pub const fn new(width_px: f32) -> Self {
Self {
width_px,
margin_px: 0.0,
min_thumb_px: 18.0,
anchor: ScrollbarAnchor::End,
}
}
pub fn with_margin(mut self, margin_px: f32) -> Self {
self.margin_px = finite_or(margin_px, 0.0).clamp(0.0, 256.0);
self
}
pub fn with_min_thumb(mut self, min_thumb_px: f32) -> Self {
self.min_thumb_px = finite_or(min_thumb_px, 18.0).clamp(1.0, 512.0);
self
}
pub const fn with_anchor(mut self, anchor: ScrollbarAnchor) -> Self {
self.anchor = anchor;
self
}
pub fn sanitized(self) -> Self {
Self {
width_px: finite_or(self.width_px, 6.0).clamp(1.0, 96.0),
margin_px: finite_or(self.margin_px, 0.0).clamp(0.0, 256.0),
min_thumb_px: finite_or(self.min_thumb_px, 18.0).clamp(1.0, 512.0),
anchor: self.anchor,
}
}
}
impl Default for ScrollbarConfig {
fn default() -> Self {
Self::new(6.0)
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum ScrollDirection {
Vertical(ScrollbarConfig),
Horizontal(ScrollbarConfig),
Both {
horizontal: ScrollbarConfig,
vertical: ScrollbarConfig,
},
}
impl ScrollDirection {
pub fn horizontal(self) -> Option<ScrollbarConfig> {
match self {
Self::Horizontal(config)
| Self::Both {
horizontal: config, ..
} => Some(config.sanitized()),
Self::Vertical(_) => None,
}
}
pub fn vertical(self) -> Option<ScrollbarConfig> {
match self {
Self::Vertical(config)
| Self::Both {
vertical: config, ..
} => Some(config.sanitized()),
Self::Horizontal(_) => None,
}
}
}
impl Default for ScrollDirection {
fn default() -> Self {
Self::Vertical(ScrollbarConfig::default())
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct ScrollViewport {
pub bounds: Rect,
pub content_size: Vec2,
pub offset: Vec2,
}
impl ScrollViewport {
pub fn new(bounds: Rect, content_size: Vec2) -> Self {
let mut viewport = Self {
bounds: sanitize_rect(bounds),
content_size: sanitize_size(content_size),
offset: Vec2::ZERO,
};
viewport.clamp_offset();
viewport
}
pub fn with_offset(mut self, offset: impl Into<Vec2>) -> Self {
self.offset = sanitize_offset(offset.into());
self.clamp_offset();
self
}
pub fn max_offset(self) -> Vec2 {
let bounds = sanitize_rect(self.bounds);
let content = sanitize_size(self.content_size);
Vec2::new(
(content.x - bounds.width).max(0.0),
(content.y - bounds.height).max(0.0),
)
}
pub fn absolute_offset(self) -> AbsoluteOffset {
let offset = sanitize_offset(self.offset);
AbsoluteOffset::new(offset.x, offset.y)
}
pub fn relative_offset(self) -> RelativeOffset {
let max = self.max_offset();
let offset = sanitize_offset(self.offset);
RelativeOffset::new(
if max.x <= f32::EPSILON {
0.0
} else {
offset.x / max.x
},
if max.y <= f32::EPSILON {
0.0
} else {
offset.y / max.y
},
)
.clamped()
}
pub fn set_absolute_offset(&mut self, offset: AbsoluteOffset) {
self.offset = offset.to_vec2();
self.clamp_offset();
}
pub fn set_relative_offset(&mut self, offset: RelativeOffset) {
let offset = offset.clamped();
let max = self.max_offset();
self.offset = Vec2::new(max.x * offset.x, max.y * offset.y);
self.clamp_offset();
}
pub fn scroll_by(&mut self, delta: Vec2) {
self.offset = sanitize_offset(self.offset + sanitize_delta(delta));
self.clamp_offset();
}
pub fn visible_rect(self) -> Rect {
let offset = sanitize_offset(self.offset);
let bounds = sanitize_rect(self.bounds);
Rect::new(offset.x, offset.y, bounds.width, bounds.height)
}
pub fn ensure_visible(&mut self, rect: Rect) {
let rect = sanitize_rect(rect);
if rect.is_empty() {
return;
}
let bounds = sanitize_rect(self.bounds);
let mut offset = sanitize_offset(self.offset);
if rect.x < offset.x {
offset.x = rect.x;
} else if rect.right() > offset.x + bounds.width {
offset.x = rect.right() - bounds.width;
}
if rect.y < offset.y {
offset.y = rect.y;
} else if rect.bottom() > offset.y + bounds.height {
offset.y = rect.bottom() - bounds.height;
}
self.offset = offset;
self.clamp_offset();
}
pub fn preserve_anchor(&mut self, previous_content_size: Vec2, anchor: RelativeOffset) {
let previous = sanitize_size(previous_content_size);
let current = sanitize_size(self.content_size);
let anchor = anchor.clamped();
let delta = Vec2::new(
(current.x - previous.x).max(0.0) * anchor.x,
(current.y - previous.y).max(0.0) * anchor.y,
);
self.offset = sanitize_offset(self.offset + delta);
self.clamp_offset();
}
pub fn scrollbars(self, direction: ScrollDirection) -> Scrollbars {
Scrollbars {
horizontal: direction
.horizontal()
.map(|config| ScrollbarGeometry::for_axis(self, ScrollAxis::Horizontal, config))
.unwrap_or_else(|| ScrollbarGeometry::hidden(ScrollAxis::Horizontal)),
vertical: direction
.vertical()
.map(|config| ScrollbarGeometry::for_axis(self, ScrollAxis::Vertical, config))
.unwrap_or_else(|| ScrollbarGeometry::hidden(ScrollAxis::Vertical)),
}
}
fn clamp_offset(&mut self) {
let max = self.max_offset();
self.offset = Vec2::new(
sanitize_axis(self.offset.x).min(max.x),
sanitize_axis(self.offset.y).min(max.y),
);
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct ScrollbarGeometry {
pub axis: ScrollAxis,
pub track: Rect,
pub thumb: Rect,
pub visible: bool,
}
impl ScrollbarGeometry {
pub fn hidden(axis: ScrollAxis) -> Self {
Self {
axis,
track: Rect::ZERO,
thumb: Rect::ZERO,
visible: false,
}
}
pub fn for_axis(viewport: ScrollViewport, axis: ScrollAxis, config: ScrollbarConfig) -> Self {
let bounds = sanitize_rect(viewport.bounds);
let content = sanitize_size(viewport.content_size);
if bounds.is_empty() {
return Self::hidden(axis);
}
let config = config.sanitized();
let margin = config.margin_px.min(match axis {
ScrollAxis::Horizontal => bounds.height * 0.45,
ScrollAxis::Vertical => bounds.width * 0.45,
});
let width = config.width_px;
let track = match axis {
ScrollAxis::Horizontal => {
let y = match config.anchor {
ScrollbarAnchor::Start => bounds.y + margin,
ScrollbarAnchor::End => bounds.bottom() - margin - width,
};
Rect::new(
bounds.x + margin,
y,
(bounds.width - margin * 2.0).max(0.0),
width,
)
}
ScrollAxis::Vertical => {
let x = match config.anchor {
ScrollbarAnchor::Start => bounds.x + margin,
ScrollbarAnchor::End => bounds.right() - margin - width,
};
Rect::new(
x,
bounds.y + margin,
width,
(bounds.height - margin * 2.0).max(0.0),
)
}
};
let (viewport_len, content_len, offset) = match axis {
ScrollAxis::Horizontal => (bounds.width, content.x, sanitize_axis(viewport.offset.x)),
ScrollAxis::Vertical => (bounds.height, content.y, sanitize_axis(viewport.offset.y)),
};
if content_len <= viewport_len || viewport_len <= 0.0 || track.is_empty() {
return Self {
axis,
track,
thumb: Rect::ZERO,
visible: false,
};
}
let track_len = match axis {
ScrollAxis::Horizontal => track.width,
ScrollAxis::Vertical => track.height,
};
let thumb_len = (track_len * viewport_len / content_len)
.clamp(config.min_thumb_px.min(track_len), track_len);
let max_offset = (content_len - viewport_len).max(0.0);
let progress = if max_offset <= f32::EPSILON {
0.0
} else {
(offset / max_offset).clamp(0.0, 1.0)
};
let travel = (track_len - thumb_len).max(0.0) * progress;
let thumb = match axis {
ScrollAxis::Horizontal => Rect::new(track.x + travel, track.y, thumb_len, track.height),
ScrollAxis::Vertical => Rect::new(track.x, track.y + travel, track.width, thumb_len),
};
Self {
axis,
track,
thumb,
visible: true,
}
}
pub fn offset_from_thumb_position(
self,
viewport: ScrollViewport,
pointer: Vec2,
) -> AbsoluteOffset {
if !self.visible || self.track.is_empty() || self.thumb.is_empty() {
return viewport.absolute_offset();
}
let max = viewport.max_offset();
let progress = match self.axis {
ScrollAxis::Horizontal => {
let travel = (self.track.width - self.thumb.width).max(0.0);
if travel <= f32::EPSILON {
0.0
} else {
((pointer.x - self.track.x - self.thumb.width * 0.5) / travel).clamp(0.0, 1.0)
}
}
ScrollAxis::Vertical => {
let travel = (self.track.height - self.thumb.height).max(0.0);
if travel <= f32::EPSILON {
0.0
} else {
((pointer.y - self.track.y - self.thumb.height * 0.5) / travel).clamp(0.0, 1.0)
}
}
};
match self.axis {
ScrollAxis::Horizontal => AbsoluteOffset::new(max.x * progress, viewport.offset.y),
ScrollAxis::Vertical => AbsoluteOffset::new(viewport.offset.x, max.y * progress),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Scrollbars {
pub horizontal: ScrollbarGeometry,
pub vertical: ScrollbarGeometry,
}
pub fn platform_scroll_delta(delta: Vec2, shift_pressed: bool) -> Vec2 {
let delta = sanitize_delta(delta);
if shift_pressed {
Vec2::new(delta.y, delta.x)
} else {
delta
}
}
fn sanitize_rect(rect: Rect) -> Rect {
if rect.is_finite() && rect.width >= 0.0 && rect.height >= 0.0 {
rect
} else {
Rect::ZERO
}
}
fn sanitize_size(size: Vec2) -> Vec2 {
Vec2::new(sanitize_axis(size.x), sanitize_axis(size.y))
}
fn sanitize_offset(offset: Vec2) -> Vec2 {
Vec2::new(sanitize_axis(offset.x), sanitize_axis(offset.y))
}
fn sanitize_delta(delta: Vec2) -> Vec2 {
Vec2::new(finite_or(delta.x, 0.0), finite_or(delta.y, 0.0))
}
fn sanitize_axis(value: f32) -> f32 {
finite_or(value, 0.0).max(0.0)
}
fn clamp01(value: f32) -> f32 {
if value.is_finite() {
value.clamp(0.0, 1.0)
} else {
0.0
}
}
fn finite_or(value: f32, fallback: f32) -> f32 {
if value.is_finite() {
value
} else {
fallback
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scroll_clamps_to_bounds() {
let mut scroll = ScrollState::new();
scroll.set_bounds(100, 10);
scroll.scroll_to(999);
assert_eq!(scroll.offset, 90);
assert_eq!(scroll.visible_range(), 90..100);
}
#[test]
fn bottom_anchor_stays_at_end_when_content_grows() {
let mut scroll = ScrollState::new().with_anchor(ScrollAnchor::Bottom);
scroll.set_bounds(10, 4);
scroll.scroll_to_end();
scroll.set_bounds(18, 4);
assert_eq!(scroll.visible_range(), 14..18);
}
#[test]
fn ensure_visible_moves_minimally() {
let mut scroll = ScrollState::new();
scroll.set_bounds(40, 5);
scroll.ensure_visible(12);
assert_eq!(scroll.visible_range(), 8..13);
scroll.ensure_visible(10);
assert_eq!(scroll.visible_range(), 8..13);
}
#[test]
fn visible_range_clamps_stale_public_offsets_to_last_page() {
let scroll = ScrollState {
offset: 999,
total_items: 100,
viewport_items: 10,
anchor: ScrollAnchor::Top,
};
assert_eq!(scroll.visible_range(), 90..100);
}
#[test]
fn viewport_offsets_round_trip_absolute_and_relative() {
let mut viewport =
ScrollViewport::new(Rect::new(0.0, 0.0, 200.0, 100.0), Vec2::new(500.0, 300.0));
viewport.set_absolute_offset(AbsoluteOffset::new(150.0, 50.0));
assert_eq!(viewport.absolute_offset(), AbsoluteOffset::new(150.0, 50.0));
assert_eq!(viewport.relative_offset(), RelativeOffset::new(0.5, 0.25));
viewport.set_relative_offset(RelativeOffset::new(1.5, f32::NAN));
assert_eq!(viewport.absolute_offset(), AbsoluteOffset::new(300.0, 0.0));
}
#[test]
fn viewport_ensure_visible_moves_minimally_on_both_axes() {
let mut viewport =
ScrollViewport::new(Rect::new(0.0, 0.0, 100.0, 80.0), Vec2::new(400.0, 400.0));
viewport.ensure_visible(Rect::new(120.0, 160.0, 40.0, 20.0));
assert_eq!(viewport.absolute_offset(), AbsoluteOffset::new(60.0, 100.0));
viewport.ensure_visible(Rect::new(20.0, 40.0, 10.0, 10.0));
assert_eq!(viewport.absolute_offset(), AbsoluteOffset::new(20.0, 40.0));
}
#[test]
fn scrollbar_geometry_reflects_relative_offset() {
let viewport =
ScrollViewport::new(Rect::new(0.0, 0.0, 200.0, 100.0), Vec2::new(500.0, 300.0))
.with_offset(Vec2::new(150.0, 100.0));
let bars = viewport.scrollbars(ScrollDirection::Both {
horizontal: ScrollbarConfig::new(8.0).with_margin(2.0),
vertical: ScrollbarConfig::new(6.0).with_margin(2.0),
});
assert!(bars.horizontal.visible);
assert!(bars.vertical.visible);
assert_eq!(bars.vertical.track.x, 192.0);
assert!(bars.vertical.thumb.y > bars.vertical.track.y);
assert!(bars.horizontal.thumb.x > bars.horizontal.track.x);
}
#[test]
fn thumb_drag_maps_back_to_absolute_offset() {
let viewport =
ScrollViewport::new(Rect::new(0.0, 0.0, 100.0, 100.0), Vec2::new(100.0, 300.0));
let bar =
ScrollbarGeometry::for_axis(viewport, ScrollAxis::Vertical, ScrollbarConfig::new(10.0));
let next = bar.offset_from_thumb_position(viewport, Vec2::new(96.0, bar.track.bottom()));
assert!(bar.visible);
assert_eq!(next.y, 200.0);
}
#[test]
fn platform_scroll_delta_swaps_axes_when_shift_is_pressed() {
assert_eq!(
platform_scroll_delta(Vec2::new(1.0, -8.0), false),
Vec2::new(1.0, -8.0)
);
assert_eq!(
platform_scroll_delta(Vec2::new(1.0, -8.0), true),
Vec2::new(-8.0, 1.0)
);
}
}