tui_scrollview/state.rs
1use ratatui_core::layout::{Position, Size};
2
3#[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Hash)]
4pub struct ScrollViewState {
5 /// The offset is the number of rows and columns to shift the scroll view by.
6 pub(crate) offset: Position,
7 /// The size of the scroll view. Not set until the first render call.
8 pub(crate) size: Option<Size>,
9 /// The size of a page of the scroll view. Not set until the first render call.
10 pub(crate) page_size: Option<Size>,
11}
12
13impl ScrollViewState {
14 /// Create a new scroll view state with an offset of (0, 0)
15 pub fn new() -> Self {
16 Self::default()
17 }
18
19 /// Create a new scroll view state with the given offset
20 pub fn with_offset(offset: Position) -> Self {
21 Self {
22 offset,
23 ..Default::default()
24 }
25 }
26
27 /// Set the offset of the scroll view state
28 pub const fn set_offset(&mut self, offset: Position) {
29 self.offset = offset;
30 }
31
32 /// Get the offset of the scroll view state
33 pub const fn offset(&self) -> Position {
34 self.offset
35 }
36
37 /// Returns the full content buffer size from the latest render.
38 ///
39 /// Returns `None` before the first render. Changes to the content size are reflected after
40 /// the next render.
41 pub const fn size(&self) -> Option<Size> {
42 self.size
43 }
44
45 /// Returns the viewport size from the latest render, excluding visible scrollbars.
46 ///
47 /// Returns `None` before the first render. Changes to the render area or scrollbar visibility
48 /// are reflected after the next render. The viewport can be larger than the content buffer.
49 pub const fn page_size(&self) -> Option<Size> {
50 self.page_size
51 }
52
53 /// Move the scroll view state up by one row
54 pub const fn scroll_up(&mut self) {
55 self.offset.y = self.offset.y.saturating_sub(1);
56 }
57
58 /// Move the scroll view state down by one row
59 pub const fn scroll_down(&mut self) {
60 self.offset.y = self.offset.y.saturating_add(1);
61 }
62
63 /// Move the scroll view state down by one page
64 pub fn scroll_page_down(&mut self) {
65 let page_size = self.page_size.map_or(1, |size| size.height);
66 // we subtract 1 to ensure that there is a one row overlap between pages
67 self.offset.y = self.offset.y.saturating_add(page_size).saturating_sub(1);
68 }
69
70 /// Move the scroll view state up by one page
71 pub fn scroll_page_up(&mut self) {
72 let page_size = self.page_size.map_or(1, |size| size.height);
73 // we add 1 to ensure that there is a one row overlap between pages
74 self.offset.y = self.offset.y.saturating_add(1).saturating_sub(page_size);
75 }
76
77 /// Move the scroll view state left by one column
78 pub const fn scroll_left(&mut self) {
79 self.offset.x = self.offset.x.saturating_sub(1);
80 }
81
82 /// Move the scroll view state right by one column
83 pub const fn scroll_right(&mut self) {
84 self.offset.x = self.offset.x.saturating_add(1);
85 }
86
87 /// Move the scroll view state to the top of the buffer
88 pub const fn scroll_to_top(&mut self) {
89 self.offset = Position::ORIGIN;
90 }
91
92 /// Move the scroll view state to the bottom of the buffer
93 ///
94 /// If the buffer size is not yet computed (done during the first rendering), it will not
95 /// be taken into account and the scroll offset will be set to the maximum value: `u16::MAX`
96 pub fn scroll_to_bottom(&mut self) {
97 // the render call will adjust the offset to ensure that we don't scroll past the end of
98 // the buffer, so we can set the offset to the maximum value here
99 let bottom = self
100 .size
101 .map_or(u16::MAX, |size| size.height.saturating_sub(1));
102 self.offset.y = bottom;
103 }
104
105 /// True if the scroll view state is at the bottom of the buffer
106 ///
107 /// This takes the page size into account. It returns true if the last row in the buffer is
108 /// visible in the current page.
109 ///
110 /// The buffer and the page size are unknown until computed during the first rendering. If the
111 /// buffer size is not yet known, this function always returns true. If the page size is not yet
112 /// known, the current row is treated as a one-row page.
113 ///
114 /// Saturating arithmetic prevents large offsets from overflowing when they are combined with
115 /// the page size.
116 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
126#[cfg(test)]
127mod tests {
128 use super::*;
129
130 #[test]
131 fn dimensions_are_unknown_before_rendering() {
132 for state in [
133 ScrollViewState::default(),
134 ScrollViewState::new(),
135 ScrollViewState::with_offset(Position::new(2, 3)),
136 ] {
137 assert_eq!(state.size(), None);
138 assert_eq!(state.page_size(), None);
139 }
140 }
141
142 #[test]
143 fn is_at_bottom_requires_the_last_row_to_be_visible() {
144 let mut state = ScrollViewState {
145 offset: Position::new(0, 4),
146 size: Some(Size::new(1, 10)),
147 page_size: Some(Size::new(1, 5)),
148 };
149
150 assert!(!state.is_at_bottom());
151
152 state.offset.y = 5;
153
154 assert!(state.is_at_bottom());
155 }
156}