ratatui_kit/components/scroll_view/
state.rs1use crossterm::event::{Event, KeyCode, KeyEventKind, MouseEventKind};
14use ratatui::layout::{Position, Rect, Size};
15
16#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)]
17pub struct ScrollViewState {
19 pub(crate) offset: Position,
21 pub(crate) size: Option<Size>,
23 pub(crate) page_size: Option<Size>,
25 pub(crate) child_areas: Vec<Rect>,
28}
29
30impl ScrollViewState {
31 pub fn new() -> Self {
33 Self::default()
34 }
35
36 pub fn with_offset(offset: Position) -> Self {
38 Self {
39 offset,
40 ..Default::default()
41 }
42 }
43
44 pub const fn set_offset(&mut self, offset: Position) {
46 self.offset = offset;
47 }
48
49 pub const fn offset(&self) -> Position {
51 self.offset
52 }
53
54 pub const fn scroll_up(&mut self) {
56 self.offset.y = self.offset.y.saturating_sub(1);
57 }
58
59 pub const fn scroll_down(&mut self) {
61 self.offset.y = self.offset.y.saturating_add(1);
62 }
63
64 pub fn scroll_page_down(&mut self) {
66 let page_size = self.page_size.map_or(1, |size| size.height);
67 self.offset.y = self.offset.y.saturating_add(page_size).saturating_sub(1);
69 }
70
71 pub fn scroll_page_up(&mut self) {
73 let page_size = self.page_size.map_or(1, |size| size.height);
74 self.offset.y = self.offset.y.saturating_add(1).saturating_sub(page_size);
76 }
77
78 pub const fn scroll_left(&mut self) {
80 self.offset.x = self.offset.x.saturating_sub(1);
81 }
82
83 pub const fn scroll_right(&mut self) {
85 self.offset.x = self.offset.x.saturating_add(1);
86 }
87
88 pub const fn scroll_to_top(&mut self) {
90 self.offset = Position::ORIGIN;
91 }
92
93 pub fn scroll_to_bottom(&mut self) {
95 let bottom = self
97 .size
98 .map_or(u16::MAX, |size| size.height.saturating_sub(1));
99 self.offset.y = bottom;
100 }
101
102 pub const fn size(&self) -> Option<Size> {
104 self.size
105 }
106
107 pub const fn page_size(&self) -> Option<Size> {
109 self.page_size
110 }
111
112 pub fn is_at_bottom(&self) -> bool {
117 let Some(size) = self.size else {
118 return true;
119 };
120 let bottom = size.height.saturating_sub(1);
121 let page_size = self.page_size.map_or(1, |size| size.height);
122 self.offset.y.saturating_add(page_size) > bottom
123 }
124
125 pub fn scroll_to_visible(&mut self, y: u16, height: u16) {
131 let page = self.page_size.map_or(u16::MAX, |size| size.height);
132 let top = self.offset.y;
133 let target_bottom = y.saturating_add(height);
134 if y < top {
135 self.offset.y = y;
137 } else if target_bottom > top.saturating_add(page) {
138 self.offset.y = target_bottom.saturating_sub(page);
140 }
141 }
142
143 pub fn child_area(&self, index: usize) -> Option<Rect> {
147 self.child_areas.get(index).copied()
148 }
149
150 pub fn scroll_to_index(&mut self, index: usize) {
158 if let Some(area) = self.child_area(index) {
159 self.scroll_to_visible(area.y, area.height);
160 }
161 }
162
163 pub fn handle_event(&mut self, event: &Event) -> bool {
165 match event {
166 Event::Key(key) if key.kind == KeyEventKind::Press => match key.code {
167 KeyCode::Up | KeyCode::Char('k') => self.scroll_up(),
168 KeyCode::Down | KeyCode::Char('j') => self.scroll_down(),
169 KeyCode::Left | KeyCode::Char('h') => self.scroll_left(),
170 KeyCode::Right | KeyCode::Char('l') => self.scroll_right(),
171 KeyCode::PageUp => self.scroll_page_up(),
172 KeyCode::PageDown => self.scroll_page_down(),
173 KeyCode::Home => self.scroll_to_top(),
174 KeyCode::End => self.scroll_to_bottom(),
175 _ => return false,
176 },
177 Event::Mouse(event) => match event.kind {
178 MouseEventKind::ScrollDown => self.scroll_down(),
179 MouseEventKind::ScrollUp => self.scroll_up(),
180 MouseEventKind::ScrollLeft => self.scroll_left(),
181 MouseEventKind::ScrollRight => self.scroll_right(),
182 _ => return false,
183 },
184 _ => return false,
185 }
186 true
187 }
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193
194 #[test]
195 fn is_at_bottom_requires_the_last_row_to_be_visible() {
196 let mut state = ScrollViewState {
197 offset: Position::new(0, 4),
198 size: Some(Size::new(1, 10)),
199 page_size: Some(Size::new(1, 5)),
200 ..Default::default()
201 };
202 assert!(!state.is_at_bottom());
203 state.offset.y = 5;
204 assert!(state.is_at_bottom());
205 }
206
207 #[test]
208 fn is_at_bottom_before_first_render() {
209 let state = ScrollViewState::default();
210 assert!(state.is_at_bottom());
211 }
212
213 #[test]
214 fn scroll_to_index_brings_child_into_view() {
215 let mut state = ScrollViewState {
216 size: Some(Size::new(10, 20)),
217 page_size: Some(Size::new(10, 3)),
218 child_areas: (0..10).map(|y| Rect::new(0, y, 10, 1)).collect(),
219 ..Default::default()
220 };
221 state.scroll_to_index(8);
223 assert_eq!(state.offset.y, 6);
224 state.scroll_to_index(100);
226 assert_eq!(state.offset.y, 6);
227 state.scroll_to_index(7);
229 assert_eq!(state.offset.y, 6);
230 }
231
232 #[test]
233 fn scroll_to_visible_only_moves_when_outside_the_page() {
234 let mut state = ScrollViewState {
235 offset: Position::new(0, 2),
236 size: Some(Size::new(1, 20)),
237 page_size: Some(Size::new(1, 5)),
238 ..Default::default()
239 };
240 state.scroll_to_visible(3, 1);
242 assert_eq!(state.offset.y, 2);
243 state.scroll_to_visible(9, 1);
245 assert_eq!(state.offset.y, 5); state.scroll_to_visible(1, 1);
248 assert_eq!(state.offset.y, 1);
249 }
250}