scrin 0.1.83

A terminal UI toolkit with panes, widgets, overlays, animations, and Aisling-powered effects/loaders.
Documentation
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StickyScroll {
    None,
    Top,
    Bottom,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ScrollState {
    pub offset: usize,
    pub total: usize,
    pub viewport: usize,
    pub sticky: StickyScroll,
}

impl ScrollState {
    pub fn new() -> Self {
        Self {
            offset: 0,
            total: 0,
            viewport: 0,
            sticky: StickyScroll::None,
        }
    }

    pub fn with_sticky(mut self, sticky: StickyScroll) -> Self {
        self.sticky = sticky;
        self
    }

    pub fn set_bounds(&mut self, total: usize, viewport: usize) {
        let was_at_bottom = self.is_at_bottom();
        self.total = total;
        self.viewport = viewport;
        match self.sticky {
            StickyScroll::Top => self.offset = 0,
            StickyScroll::Bottom if was_at_bottom => self.scroll_to_bottom(),
            _ => self.clamp(),
        }
    }

    pub fn max_offset(&self) -> usize {
        self.total.saturating_sub(self.viewport)
    }

    pub fn clamp(&mut self) {
        self.offset = self.offset.min(self.max_offset());
    }

    pub fn scroll_up(&mut self, lines: usize) {
        self.offset = self.offset.saturating_sub(lines);
    }

    pub fn scroll_down(&mut self, lines: usize) {
        self.offset = (self.offset + lines).min(self.max_offset());
    }

    pub fn page_up(&mut self) {
        self.scroll_up(self.viewport.max(1));
    }

    pub fn page_down(&mut self) {
        self.scroll_down(self.viewport.max(1));
    }

    pub fn scroll_to_top(&mut self) {
        self.offset = 0;
    }

    pub fn scroll_to_bottom(&mut self) {
        self.offset = self.max_offset();
    }

    pub fn is_at_top(&self) -> bool {
        self.offset == 0
    }

    pub fn is_at_bottom(&self) -> bool {
        self.offset >= self.max_offset()
    }

    pub fn visible_range(&self) -> std::ops::Range<usize> {
        let start = self.offset.min(self.total);
        let end = (start + self.viewport).min(self.total);
        start..end
    }
}

impl Default for ScrollState {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn scroll_state_clamps_offset() {
        let mut state = ScrollState::new();
        state.set_bounds(100, 10);
        state.scroll_down(500);
        assert_eq!(state.offset, 90);
        state.scroll_up(500);
        assert_eq!(state.offset, 0);
    }

    #[test]
    fn scroll_state_visible_range() {
        let mut state = ScrollState::new();
        state.set_bounds(20, 5);
        state.scroll_down(3);
        assert_eq!(state.visible_range(), 3..8);
    }

    #[test]
    fn scroll_state_sticky_bottom_tracks_growth() {
        let mut state = ScrollState::new().with_sticky(StickyScroll::Bottom);
        state.set_bounds(10, 5);
        state.scroll_to_bottom();
        state.set_bounds(20, 5);
        assert_eq!(state.offset, 15);
    }
}