Skip to main content

ratatui_kit/components/scroll_view/
state.rs

1// ScrollViewState:滚动视图的状态管理结构,记录偏移量、尺寸、页大小等。
2//
3// 常与 ScrollView 组件配合使用,支持键盘/鼠标事件驱动的滚动。
4//
5// ## 用法示例
6// ```rust
7// let scroll_state = hooks.use_state(ScrollViewState::default);
8// element!(ScrollView(scroll_view_state: scroll_state) { ... })
9// // 在事件处理器中调用 `scroll_state.write().handle_event(&event)`。
10// ```
11// 支持上下左右/翻页/鼠标滚轮等多种滚动方式。
12
13use crossterm::event::{Event, KeyCode, KeyEventKind, MouseEventKind};
14use ratatui::layout::{Position, Rect, Size};
15
16#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)]
17// 滚动视图状态。
18pub struct ScrollViewState {
19    // 偏移量是滚动视图需要移动的行数和列数。
20    pub(crate) offset: Position,
21    // 滚动视图的尺寸。在第一次渲染调用前不会被设置。
22    pub(crate) size: Option<Size>,
23    // 滚动视图一页的尺寸。在第一次渲染调用前不会被设置。
24    pub(crate) page_size: Option<Size>,
25    // 每个直接子节点在内容缓冲中的区域(内容坐标),由 ScrollView 每帧记录。
26    // 供 `scroll_to_index` 把某个子节点滚进视口——用于"选中项联动滚动"。
27    pub(crate) child_areas: Vec<Rect>,
28}
29
30impl ScrollViewState {
31    // 创建一个偏移量为 (0, 0) 的新滚动视图状态
32    pub fn new() -> Self {
33        Self::default()
34    }
35
36    // 创建一个带有指定偏移量的新滚动视图状态
37    pub fn with_offset(offset: Position) -> Self {
38        Self {
39            offset,
40            ..Default::default()
41        }
42    }
43
44    // 设置滚动视图状态的偏移量
45    pub const fn set_offset(&mut self, offset: Position) {
46        self.offset = offset;
47    }
48
49    // 获取滚动视图状态的偏移量
50    pub const fn offset(&self) -> Position {
51        self.offset
52    }
53
54    // 向上滚动一行
55    pub const fn scroll_up(&mut self) {
56        self.offset.y = self.offset.y.saturating_sub(1);
57    }
58
59    // 向下滚动一行
60    pub const fn scroll_down(&mut self) {
61        self.offset.y = self.offset.y.saturating_add(1);
62    }
63
64    // 向下滚动一页
65    pub fn scroll_page_down(&mut self) {
66        let page_size = self.page_size.map_or(1, |size| size.height);
67        // 我们减去 1 以确保页面之间有一行重叠
68        self.offset.y = self.offset.y.saturating_add(page_size).saturating_sub(1);
69    }
70
71    // 向上滚动一页
72    pub fn scroll_page_up(&mut self) {
73        let page_size = self.page_size.map_or(1, |size| size.height);
74        // 我们加上 1 以确保页面之间有一行重叠
75        self.offset.y = self.offset.y.saturating_add(1).saturating_sub(page_size);
76    }
77
78    // 向左滚动一列
79    pub const fn scroll_left(&mut self) {
80        self.offset.x = self.offset.x.saturating_sub(1);
81    }
82
83    // 向右滚动一列
84    pub const fn scroll_right(&mut self) {
85        self.offset.x = self.offset.x.saturating_add(1);
86    }
87
88    // 滚动到缓冲区顶部
89    pub const fn scroll_to_top(&mut self) {
90        self.offset = Position::ORIGIN;
91    }
92
93    // 滚动到缓冲区底部
94    pub fn scroll_to_bottom(&mut self) {
95        // 渲染调用会调整偏移量以确保不会滚动到缓冲区末尾之后,所以这里可以将偏移量设置为最大值
96        let bottom = self
97            .size
98            .map_or(u16::MAX, |size| size.height.saturating_sub(1));
99        self.offset.y = bottom;
100    }
101
102    /// The content size (the full scrollable buffer). `None` before the first render.
103    pub const fn size(&self) -> Option<Size> {
104        self.size
105    }
106
107    /// The visible page size (viewport after scrollbars). `None` before the first render.
108    pub const fn page_size(&self) -> Option<Size> {
109        self.page_size
110    }
111
112    /// Whether the last content row is visible in the current page.
113    ///
114    /// Returns `true` before the first render (size unknown). Ported from upstream
115    /// `tui-scrollview`; relies on `page_size` meaning the visible viewport.
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    /// Scroll the vertical offset the minimum amount so the row range
126    /// `[y, y + height)` is inside the visible page. No-op if already visible.
127    ///
128    /// The render pass re-clamps against the content, so this only needs to move
129    /// the offset toward the target.
130    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            // target starts above the viewport → align its top to the viewport top
136            self.offset.y = y;
137        } else if target_bottom > top.saturating_add(page) {
138            // target ends below the viewport → align its bottom to the viewport bottom
139            self.offset.y = target_bottom.saturating_sub(page);
140        }
141    }
142
143    /// The content-buffer area of the direct child at `index` (in child order),
144    /// as recorded by `ScrollView` on the last render. `None` before the first
145    /// render or when `index` is out of range.
146    pub fn child_area(&self, index: usize) -> Option<Rect> {
147        self.child_areas.get(index).copied()
148    }
149
150    /// Scroll so the direct child at `index` (in child order) is visible.
151    ///
152    /// This is the "follow the selection" primitive: after a page moves its
153    /// selection over a list of `ScrollView` children, call this with the
154    /// selected index so the viewport tracks it. No-op if `index` is unknown
155    /// (e.g. before the first render). Child geometry is selection-independent,
156    /// so the last recorded layout is correct for the new selection.
157    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    /// Returns `true` if the event was a scroll input this state acted on.
164    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        // child 8 sits at y=8 (below the 3-row viewport) → offset = 8 + 1 - 3
222        state.scroll_to_index(8);
223        assert_eq!(state.offset.y, 6);
224        // out-of-range index is a no-op
225        state.scroll_to_index(100);
226        assert_eq!(state.offset.y, 6);
227        // child already visible → no change
228        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        // already visible (rows 2..7 shown, target row 3) → no change
241        state.scroll_to_visible(3, 1);
242        assert_eq!(state.offset.y, 2);
243        // below the viewport (target row 9) → align its bottom to the viewport bottom
244        state.scroll_to_visible(9, 1);
245        assert_eq!(state.offset.y, 5); // 9 + 1 - 5
246        // above the viewport (target row 1) → align its top
247        state.scroll_to_visible(1, 1);
248        assert_eq!(state.offset.y, 1);
249    }
250}