use std::ops::Range;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[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.total_items);
let end = start
.saturating_add(self.viewport_items.max(1))
.min(self.total_items);
start..end
}
}
#[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);
}
}