use crossterm::event::{Event, KeyCode, KeyEventKind, MouseEventKind};
use ratatui::layout::{Position, Rect, Size};
#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)]
pub struct ScrollViewState {
pub(crate) offset: Position,
pub(crate) size: Option<Size>,
pub(crate) page_size: Option<Size>,
pub(crate) child_areas: Vec<Rect>,
}
impl ScrollViewState {
pub fn new() -> Self {
Self::default()
}
pub fn with_offset(offset: Position) -> Self {
Self {
offset,
..Default::default()
}
}
pub const fn set_offset(&mut self, offset: Position) {
self.offset = offset;
}
pub const fn offset(&self) -> Position {
self.offset
}
pub const fn scroll_up(&mut self) {
self.offset.y = self.offset.y.saturating_sub(1);
}
pub const fn scroll_down(&mut self) {
self.offset.y = self.offset.y.saturating_add(1);
}
pub fn scroll_page_down(&mut self) {
let page_size = self.page_size.map_or(1, |size| size.height);
self.offset.y = self.offset.y.saturating_add(page_size).saturating_sub(1);
}
pub fn scroll_page_up(&mut self) {
let page_size = self.page_size.map_or(1, |size| size.height);
self.offset.y = self.offset.y.saturating_add(1).saturating_sub(page_size);
}
pub const fn scroll_left(&mut self) {
self.offset.x = self.offset.x.saturating_sub(1);
}
pub const fn scroll_right(&mut self) {
self.offset.x = self.offset.x.saturating_add(1);
}
pub const fn scroll_to_top(&mut self) {
self.offset = Position::ORIGIN;
}
pub fn scroll_to_bottom(&mut self) {
let bottom = self
.size
.map_or(u16::MAX, |size| size.height.saturating_sub(1));
self.offset.y = bottom;
}
pub const fn size(&self) -> Option<Size> {
self.size
}
pub const fn page_size(&self) -> Option<Size> {
self.page_size
}
pub fn is_at_bottom(&self) -> bool {
let Some(size) = self.size else {
return true;
};
let bottom = size.height.saturating_sub(1);
let page_size = self.page_size.map_or(1, |size| size.height);
self.offset.y.saturating_add(page_size) > bottom
}
pub fn scroll_to_visible(&mut self, y: u16, height: u16) {
let page = self.page_size.map_or(u16::MAX, |size| size.height);
let top = self.offset.y;
let target_bottom = y.saturating_add(height);
if y < top {
self.offset.y = y;
} else if target_bottom > top.saturating_add(page) {
self.offset.y = target_bottom.saturating_sub(page);
}
}
pub fn child_area(&self, index: usize) -> Option<Rect> {
self.child_areas.get(index).copied()
}
pub fn scroll_to_index(&mut self, index: usize) {
if let Some(area) = self.child_area(index) {
self.scroll_to_visible(area.y, area.height);
}
}
pub fn handle_event(&mut self, event: &Event) -> bool {
match event {
Event::Key(key) if key.kind == KeyEventKind::Press => match key.code {
KeyCode::Up | KeyCode::Char('k') => self.scroll_up(),
KeyCode::Down | KeyCode::Char('j') => self.scroll_down(),
KeyCode::Left | KeyCode::Char('h') => self.scroll_left(),
KeyCode::Right | KeyCode::Char('l') => self.scroll_right(),
KeyCode::PageUp => self.scroll_page_up(),
KeyCode::PageDown => self.scroll_page_down(),
KeyCode::Home => self.scroll_to_top(),
KeyCode::End => self.scroll_to_bottom(),
_ => return false,
},
Event::Mouse(event) => match event.kind {
MouseEventKind::ScrollDown => self.scroll_down(),
MouseEventKind::ScrollUp => self.scroll_up(),
MouseEventKind::ScrollLeft => self.scroll_left(),
MouseEventKind::ScrollRight => self.scroll_right(),
_ => return false,
},
_ => return false,
}
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_at_bottom_requires_the_last_row_to_be_visible() {
let mut state = ScrollViewState {
offset: Position::new(0, 4),
size: Some(Size::new(1, 10)),
page_size: Some(Size::new(1, 5)),
..Default::default()
};
assert!(!state.is_at_bottom());
state.offset.y = 5;
assert!(state.is_at_bottom());
}
#[test]
fn is_at_bottom_before_first_render() {
let state = ScrollViewState::default();
assert!(state.is_at_bottom());
}
#[test]
fn scroll_to_index_brings_child_into_view() {
let mut state = ScrollViewState {
size: Some(Size::new(10, 20)),
page_size: Some(Size::new(10, 3)),
child_areas: (0..10).map(|y| Rect::new(0, y, 10, 1)).collect(),
..Default::default()
};
state.scroll_to_index(8);
assert_eq!(state.offset.y, 6);
state.scroll_to_index(100);
assert_eq!(state.offset.y, 6);
state.scroll_to_index(7);
assert_eq!(state.offset.y, 6);
}
#[test]
fn scroll_to_visible_only_moves_when_outside_the_page() {
let mut state = ScrollViewState {
offset: Position::new(0, 2),
size: Some(Size::new(1, 20)),
page_size: Some(Size::new(1, 5)),
..Default::default()
};
state.scroll_to_visible(3, 1);
assert_eq!(state.offset.y, 2);
state.scroll_to_visible(9, 1);
assert_eq!(state.offset.y, 5); state.scroll_to_visible(1, 1);
assert_eq!(state.offset.y, 1);
}
}