rae 0.1.11

Renderer-neutral widget, layout, sanitization, and GLSL shader primitives for Rust desktop tools.
Documentation
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);
    }
}