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, Size};
15
16#[derive(Debug, Default, Clone, Copy, 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}
26
27impl ScrollViewState {
28    // 创建一个偏移量为 (0, 0) 的新滚动视图状态
29    pub fn new() -> Self {
30        Self::default()
31    }
32
33    // 创建一个带有指定偏移量的新滚动视图状态
34    pub fn with_offset(offset: Position) -> Self {
35        Self {
36            offset,
37            ..Default::default()
38        }
39    }
40
41    // 设置滚动视图状态的偏移量
42    pub const fn set_offset(&mut self, offset: Position) {
43        self.offset = offset;
44    }
45
46    // 获取滚动视图状态的偏移量
47    pub const fn offset(&self) -> Position {
48        self.offset
49    }
50
51    // 向上滚动一行
52    pub const fn scroll_up(&mut self) {
53        self.offset.y = self.offset.y.saturating_sub(1);
54    }
55
56    // 向下滚动一行
57    pub const fn scroll_down(&mut self) {
58        self.offset.y = self.offset.y.saturating_add(1);
59    }
60
61    // 向下滚动一页
62    pub fn scroll_page_down(&mut self) {
63        let page_size = self.page_size.map_or(1, |size| size.height);
64        // 我们减去 1 以确保页面之间有一行重叠
65        self.offset.y = self.offset.y.saturating_add(page_size).saturating_sub(1);
66    }
67
68    // 向上滚动一页
69    pub fn scroll_page_up(&mut self) {
70        let page_size = self.page_size.map_or(1, |size| size.height);
71        // 我们加上 1 以确保页面之间有一行重叠
72        self.offset.y = self.offset.y.saturating_add(1).saturating_sub(page_size);
73    }
74
75    // 向左滚动一列
76    pub const fn scroll_left(&mut self) {
77        self.offset.x = self.offset.x.saturating_sub(1);
78    }
79
80    // 向右滚动一列
81    pub const fn scroll_right(&mut self) {
82        self.offset.x = self.offset.x.saturating_add(1);
83    }
84
85    // 滚动到缓冲区顶部
86    pub const fn scroll_to_top(&mut self) {
87        self.offset = Position::ORIGIN;
88    }
89
90    // 滚动到缓冲区底部
91    pub fn scroll_to_bottom(&mut self) {
92        // 渲染调用会调整偏移量以确保不会滚动到缓冲区末尾之后,所以这里可以将偏移量设置为最大值
93        let bottom = self
94            .size
95            .map_or(u16::MAX, |size| size.height.saturating_sub(1));
96        self.offset.y = bottom;
97    }
98
99    /// The content size (the full scrollable buffer). `None` before the first render.
100    pub const fn size(&self) -> Option<Size> {
101        self.size
102    }
103
104    /// The visible page size (viewport after scrollbars). `None` before the first render.
105    pub const fn page_size(&self) -> Option<Size> {
106        self.page_size
107    }
108
109    /// Whether the last content row is visible in the current page.
110    ///
111    /// Returns `true` before the first render (size unknown). Ported from upstream
112    /// `tui-scrollview`; relies on `page_size` meaning the visible viewport.
113    pub fn is_at_bottom(&self) -> bool {
114        let Some(size) = self.size else {
115            return true;
116        };
117        let bottom = size.height.saturating_sub(1);
118        let page_size = self.page_size.map_or(1, |size| size.height);
119        self.offset.y.saturating_add(page_size) > bottom
120    }
121
122    /// Scroll the vertical offset the minimum amount so the row range
123    /// `[y, y + height)` is inside the visible page. No-op if already visible.
124    ///
125    /// The render pass re-clamps against the content, so this only needs to move
126    /// the offset toward the target.
127    pub fn scroll_to_visible(&mut self, y: u16, height: u16) {
128        let page = self.page_size.map_or(u16::MAX, |size| size.height);
129        let top = self.offset.y;
130        let target_bottom = y.saturating_add(height);
131        if y < top {
132            // target starts above the viewport → align its top to the viewport top
133            self.offset.y = y;
134        } else if target_bottom > top.saturating_add(page) {
135            // target ends below the viewport → align its bottom to the viewport bottom
136            self.offset.y = target_bottom.saturating_sub(page);
137        }
138    }
139
140    /// Returns `true` if the event was a scroll input this state acted on.
141    pub fn handle_event(&mut self, event: &Event) -> bool {
142        match event {
143            Event::Key(key) if key.kind == KeyEventKind::Press => match key.code {
144                KeyCode::Up | KeyCode::Char('k') => self.scroll_up(),
145                KeyCode::Down | KeyCode::Char('j') => self.scroll_down(),
146                KeyCode::Left | KeyCode::Char('h') => self.scroll_left(),
147                KeyCode::Right | KeyCode::Char('l') => self.scroll_right(),
148                KeyCode::PageUp => self.scroll_page_up(),
149                KeyCode::PageDown => self.scroll_page_down(),
150                KeyCode::Home => self.scroll_to_top(),
151                KeyCode::End => self.scroll_to_bottom(),
152                _ => return false,
153            },
154            Event::Mouse(event) => match event.kind {
155                MouseEventKind::ScrollDown => self.scroll_down(),
156                MouseEventKind::ScrollUp => self.scroll_up(),
157                MouseEventKind::ScrollLeft => self.scroll_left(),
158                MouseEventKind::ScrollRight => self.scroll_right(),
159                _ => return false,
160            },
161            _ => return false,
162        }
163        true
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn is_at_bottom_requires_the_last_row_to_be_visible() {
173        let mut state = ScrollViewState {
174            offset: Position::new(0, 4),
175            size: Some(Size::new(1, 10)),
176            page_size: Some(Size::new(1, 5)),
177        };
178        assert!(!state.is_at_bottom());
179        state.offset.y = 5;
180        assert!(state.is_at_bottom());
181    }
182
183    #[test]
184    fn is_at_bottom_before_first_render() {
185        let state = ScrollViewState::default();
186        assert!(state.is_at_bottom());
187    }
188
189    #[test]
190    fn scroll_to_visible_only_moves_when_outside_the_page() {
191        let mut state = ScrollViewState {
192            offset: Position::new(0, 2),
193            size: Some(Size::new(1, 20)),
194            page_size: Some(Size::new(1, 5)),
195        };
196        // already visible (rows 2..7 shown, target row 3) → no change
197        state.scroll_to_visible(3, 1);
198        assert_eq!(state.offset.y, 2);
199        // below the viewport (target row 9) → align its bottom to the viewport bottom
200        state.scroll_to_visible(9, 1);
201        assert_eq!(state.offset.y, 5); // 9 + 1 - 5
202        // above the viewport (target row 1) → align its top
203        state.scroll_to_visible(1, 1);
204        assert_eq!(state.offset.y, 1);
205    }
206}