Skip to main content

ratatui_kit/components/scroll_view/
scrollbars.rs

1// ScrollBars 组件:滚动视图的滚动条配置与渲染,支持横向/纵向滚动条、可见性控制、自定义样式。
2//
3// ## 用法示例
4// ```rust
5// element!(ScrollView(
6//     scroll_bars: ScrollBars {
7//         vertical_scrollbar_visibility: ScrollbarVisibility::Always,
8//         horizontal_scrollbar_visibility: ScrollbarVisibility::Automatic,
9//         ..Default::default()
10//     },
11//     // ...
12// ))
13// ```
14// 可灵活控制滚动条的显示策略和样式,适合长列表、表格、文档等场景。
15
16use super::ScrollViewState;
17use ratatui::{
18    buffer::Buffer,
19    layout::{Rect, Size},
20    widgets::{Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget},
21};
22use ratatui_kit_macros::Props;
23
24#[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Hash)]
25// 滚动条可见性枚举。
26pub enum ScrollbarVisibility {
27    // 仅在需要时渲染滚动条。
28    #[default]
29    Automatic,
30    // 始终渲染滚动条。
31    Always,
32    // 从不渲染滚动条(隐藏)。
33    Never,
34}
35
36#[derive(Props, Clone, Hash)]
37// 滚动条配置。
38pub struct ScrollBars<'a> {
39    // 纵向滚动条可见性。
40    pub vertical_scrollbar_visibility: ScrollbarVisibility,
41    // 横向滚动条可见性。
42    pub horizontal_scrollbar_visibility: ScrollbarVisibility,
43    // 纵向滚动条样式。
44    pub vertical_scrollbar: Scrollbar<'a>,
45    // 横向滚动条样式。
46    pub horizontal_scrollbar: Scrollbar<'a>,
47}
48
49impl Default for ScrollBars<'_> {
50    fn default() -> Self {
51        Self {
52            vertical_scrollbar_visibility: ScrollbarVisibility::Automatic,
53            horizontal_scrollbar_visibility: ScrollbarVisibility::Automatic,
54            vertical_scrollbar: Scrollbar::new(ScrollbarOrientation::VerticalRight),
55            horizontal_scrollbar: Scrollbar::new(ScrollbarOrientation::HorizontalBottom),
56        }
57    }
58}
59
60pub(crate) struct ScrollbarLayout {
61    pub show_horizontal: bool,
62    pub show_vertical: bool,
63    pub visible_area: Rect,
64}
65
66impl ScrollBars<'_> {
67    pub(crate) fn layout_for(&self, area: Rect, content_size: Size) -> ScrollbarLayout {
68        let horizontal_space = area.width as i32 - content_size.width as i32;
69        let vertical_space = area.height as i32 - content_size.height as i32;
70        let (show_horizontal, show_vertical) =
71            self.visible_scrollbars(horizontal_space, vertical_space);
72
73        ScrollbarLayout {
74            show_horizontal,
75            show_vertical,
76            visible_area: Rect {
77                width: area.width.saturating_sub(show_vertical as u16),
78                height: area.height.saturating_sub(show_horizontal as u16),
79                ..area
80            },
81        }
82    }
83
84    fn render_visible_area(
85        &self,
86        area: Rect,
87        buf: &mut Buffer,
88        visible_area: Rect,
89        scroll_buffer: &Buffer,
90    ) {
91        // TODO: 这里可能有更高效的实现方式
92        for (src_row, dst_row) in visible_area.rows().zip(area.rows()) {
93            for (src_col, dst_col) in src_row.columns().zip(dst_row.columns()) {
94                buf[dst_col] = scroll_buffer[src_col].clone();
95            }
96        }
97    }
98
99    fn render_vertical_scrollbar(
100        &self,
101        area: Rect,
102        buf: &mut Buffer,
103        state: &ScrollViewState,
104        scroll_size: Size,
105    ) {
106        let scrollbar_height = scroll_size.height.saturating_sub(area.height);
107        let mut scrollbar_state =
108            ScrollbarState::new(scrollbar_height as usize).position(state.offset.y as usize);
109
110        self.vertical_scrollbar
111            .clone()
112            .render(area, buf, &mut scrollbar_state);
113    }
114
115    fn render_horizontal_scrollbar(
116        &self,
117        area: Rect,
118        buf: &mut Buffer,
119        state: &ScrollViewState,
120        scroll_size: Size,
121    ) {
122        let scrollbar_width = scroll_size.width.saturating_sub(area.width);
123
124        let mut scrollbar_state =
125            ScrollbarState::new(scrollbar_width as usize).position(state.offset.x as usize);
126        self.horizontal_scrollbar
127            .clone()
128            .render(area, buf, &mut scrollbar_state);
129    }
130
131    pub fn visible_scrollbars(&self, horizontal_space: i32, vertical_space: i32) -> (bool, bool) {
132        type V = ScrollbarVisibility;
133
134        match (
135            self.horizontal_scrollbar_visibility,
136            self.vertical_scrollbar_visibility,
137        ) {
138            // 直接渲染,无需检查适配值
139            (V::Always, V::Always) => (true, true),
140            (V::Never, V::Never) => (false, false),
141            (V::Always, V::Never) => (true, false),
142            (V::Never, V::Always) => (false, true),
143
144            // Auto => 仅在不适配时渲染滚动条
145            (V::Automatic, V::Never) => (horizontal_space < 0, false),
146            (V::Never, V::Automatic) => (false, vertical_space < 0),
147
148            // Auto => 渲染滚动条如果:
149            //   不适配;或
150            //   完全适配(另一个滚动条占用一行导致触发)
151            (V::Always, V::Automatic) => (true, vertical_space <= 0),
152            (V::Automatic, V::Always) => (horizontal_space <= 0, true),
153
154            // 仅依赖适配值
155            (V::Automatic, V::Automatic) => {
156                if horizontal_space >= 0 && vertical_space >= 0 {
157                    // 两个方向都有足够空间
158                    (false, false)
159                } else if horizontal_space < 0 && vertical_space < 0 {
160                    // 两个方向都没有足够空间
161                    (true, true)
162                } else if horizontal_space > 0 && vertical_space < 0 {
163                    // 水平适配,垂直不适配
164                    (false, true)
165                } else if horizontal_space < 0 && vertical_space > 0 {
166                    // 垂直适配,水平不适配
167                    (true, false)
168                } else {
169                    // 一个方向完全适配,另一个方向不适配,导致两个滚动条都可见,因为另一个滚动条会占用缓冲区的一行
170                    (true, true)
171                }
172            }
173        }
174    }
175
176    fn render_scrollbars(
177        &self,
178        area: Rect,
179        buf: &mut Buffer,
180        state: &mut ScrollViewState,
181        scroll_buffer: &Buffer,
182    ) -> Rect {
183        let size: ratatui::prelude::Size = scroll_buffer.area.as_size();
184        let layout = self.layout_for(area, size);
185        let horizontal_space = area.width as i32 - size.width as i32;
186        let vertical_space = area.height as i32 - size.height as i32;
187
188        // 如果该方向适配,则重置状态
189        if horizontal_space > 0 {
190            state.offset.x = 0;
191        }
192        if vertical_space > 0 {
193            state.offset.y = 0;
194        }
195
196        if layout.show_horizontal {
197            // 如果两个滚动条都渲染,避免角落重叠
198            let width = area.width.saturating_sub(layout.show_vertical as u16);
199            let render_area = Rect { width, ..area };
200            // 渲染滚动条,更新可用空间
201            self.render_horizontal_scrollbar(render_area, buf, state, size);
202        }
203
204        if layout.show_vertical {
205            // 如果两个滚动条都渲染,避免角落重叠
206            let height = area.height.saturating_sub(layout.show_horizontal as u16);
207            let render_area = Rect { height, ..area };
208            // 渲染滚动条,更新可用空间
209            self.render_vertical_scrollbar(render_area, buf, state, size);
210        }
211
212        Rect::new(
213            state.offset.x,
214            state.offset.y,
215            layout.visible_area.width,
216            layout.visible_area.height,
217        )
218    }
219
220    pub fn render_ref(
221        &self,
222        area: Rect,
223        buf: &mut Buffer,
224        state: &mut ScrollViewState,
225        scroll_buffer: &Buffer,
226    ) {
227        let (mut x, mut y) = state.offset.into();
228        // 确保不会在任一方向上滚动超过缓冲区末尾
229        let max_x_offset = scroll_buffer.area.width.saturating_sub(area.width);
230        let max_y_offset = scroll_buffer.area.height.saturating_sub(area.height);
231
232        x = x.min(max_x_offset);
233        y = y.min(max_y_offset);
234        state.offset = (x, y).into();
235        state.size = Some(scroll_buffer.area.as_size());
236        state.page_size = Some(area.into());
237        let visible_area = self
238            .render_scrollbars(area, buf, state, scroll_buffer)
239            .intersection(scroll_buffer.area);
240        self.render_visible_area(area, buf, visible_area, scroll_buffer);
241    }
242}