Skip to main content

ratatui_kit/components/scroll_view/
scrollbars.rs

1// Scrollbars 组件:滚动视图的滚动条配置与渲染,支持横向/纵向滚动条、可见性控制、自定义样式,
2// 以及 `over_border` 开关(滚动条盖在 block 边框上还是退到框内)。
3//
4// ## 用法示例
5// ```rust
6// element!(ScrollView(
7//     scrollbars: Scrollbars {
8//         vertical_scrollbar_visibility: ScrollbarVisibility::Always,
9//         horizontal_scrollbar_visibility: ScrollbarVisibility::Automatic,
10//         over_border: false, // 想让滚动条退到边框内侧时设 false(默认 true 盖边框)
11//         ..Default::default()
12//     },
13//     // ...
14// ))
15// ```
16// 可灵活控制滚动条的显示策略和样式,适合长列表、表格、文档等场景。
17
18use super::ScrollViewState;
19use ratatui::{
20    buffer::Buffer,
21    layout::{Position, Rect, Size},
22    widgets::{Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget},
23};
24use ratatui_kit_macros::Props;
25
26#[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Hash)]
27// 滚动条可见性枚举。
28pub enum ScrollbarVisibility {
29    // 仅在需要时渲染滚动条。
30    #[default]
31    Automatic,
32    // 始终渲染滚动条。
33    Always,
34    // 从不渲染滚动条(隐藏)。
35    Never,
36}
37
38#[derive(Props, Clone, Hash)]
39// 滚动条配置。
40pub struct Scrollbars<'a> {
41    // 纵向滚动条可见性。
42    pub vertical_scrollbar_visibility: ScrollbarVisibility,
43    // 横向滚动条可见性。
44    pub horizontal_scrollbar_visibility: ScrollbarVisibility,
45    // 纵向滚动条样式。
46    pub vertical_scrollbar: Scrollbar<'a>,
47    // 横向滚动条样式。
48    pub horizontal_scrollbar: Scrollbar<'a>,
49    /// 有 block 边框时,滚动条画在边框环上(不占内容区)还是退到 `block.inner()` 内
50    /// (占用一行/一列)。默认 `true`(盖边框,观感更好);无 block 时退化为框内。
51    pub over_border: bool,
52}
53
54impl Default for Scrollbars<'_> {
55    fn default() -> Self {
56        Self {
57            vertical_scrollbar_visibility: ScrollbarVisibility::Automatic,
58            horizontal_scrollbar_visibility: ScrollbarVisibility::Automatic,
59            vertical_scrollbar: Scrollbar::new(ScrollbarOrientation::VerticalRight),
60            horizontal_scrollbar: Scrollbar::new(ScrollbarOrientation::HorizontalBottom),
61            over_border: true,
62        }
63    }
64}
65
66impl Scrollbars<'_> {
67    // 单轴可见性:无「显示一条会挤占另一轴」的耦合时用(over_border 模式,滚动条在边框上不占内容)。
68    fn axis_show(visibility: ScrollbarVisibility, space: i32) -> bool {
69        match visibility {
70            ScrollbarVisibility::Always => true,
71            ScrollbarVisibility::Never => false,
72            ScrollbarVisibility::Automatic => space < 0,
73        }
74    }
75
76    /// 子节点布局所用区域:ring(over_border)模式 = 整个 inner;inset 模式 = inner 扣掉将显示的滚动条。
77    /// 供 `calc_children_areas` 决定内容缓冲宽高。
78    pub(crate) fn content_area(&self, inner: Rect, content: Size, ring: bool) -> Rect {
79        if ring {
80            return inner;
81        }
82        let horizontal_space = inner.width as i32 - content.width as i32;
83        let vertical_space = inner.height as i32 - content.height as i32;
84        let (show_horizontal, show_vertical) =
85            self.visible_scrollbars(horizontal_space, vertical_space);
86        Rect {
87            width: inner.width.saturating_sub(show_vertical as u16),
88            height: inner.height.saturating_sub(show_horizontal as u16),
89            ..inner
90        }
91    }
92
93    fn render_visible_area(&self, dst: Rect, buf: &mut Buffer, src: Rect, scroll_buffer: &Buffer) {
94        for (src_row, dst_row) in src.rows().zip(dst.rows()) {
95            for (src_col, dst_col) in src_row.columns().zip(dst_row.columns()) {
96                buf[dst_col] = scroll_buffer[src_col].clone();
97            }
98        }
99    }
100
101    // 朝向内部固定,避免调用方设错朝向导致布局/渲染不一致(只放开符号/样式覆盖)。
102    fn render_scrollbar(
103        scrollbar: &Scrollbar<'_>,
104        orientation: ScrollbarOrientation,
105        area: Rect,
106        buf: &mut Buffer,
107        position: u16,
108        content_len: u16,
109        viewport_len: u16,
110    ) {
111        let hidden = content_len.saturating_sub(viewport_len);
112        let mut scrollbar_state = ScrollbarState::new(hidden as usize).position(position as usize);
113        scrollbar
114            .clone()
115            .orientation(orientation)
116            .render(area, buf, &mut scrollbar_state);
117    }
118
119    pub fn visible_scrollbars(&self, horizontal_space: i32, vertical_space: i32) -> (bool, bool) {
120        type V = ScrollbarVisibility;
121
122        match (
123            self.horizontal_scrollbar_visibility,
124            self.vertical_scrollbar_visibility,
125        ) {
126            // 直接渲染,无需检查适配值
127            (V::Always, V::Always) => (true, true),
128            (V::Never, V::Never) => (false, false),
129            (V::Always, V::Never) => (true, false),
130            (V::Never, V::Always) => (false, true),
131
132            // Auto => 仅在不适配时渲染滚动条
133            (V::Automatic, V::Never) => (horizontal_space < 0, false),
134            (V::Never, V::Automatic) => (false, vertical_space < 0),
135
136            // Auto => 渲染滚动条如果:
137            //   不适配;或
138            //   完全适配(另一个滚动条占用一行导致触发)
139            (V::Always, V::Automatic) => (true, vertical_space <= 0),
140            (V::Automatic, V::Always) => (horizontal_space <= 0, true),
141
142            // 仅依赖适配值
143            (V::Automatic, V::Automatic) => {
144                if horizontal_space >= 0 && vertical_space >= 0 {
145                    // 两个方向都有足够空间
146                    (false, false)
147                } else if horizontal_space < 0 && vertical_space < 0 {
148                    // 两个方向都没有足够空间
149                    (true, true)
150                } else if horizontal_space > 0 && vertical_space < 0 {
151                    // 水平适配,垂直不适配
152                    (false, true)
153                } else if horizontal_space < 0 && vertical_space > 0 {
154                    // 垂直适配,水平不适配
155                    (true, false)
156                } else {
157                    // 一个方向完全适配,另一个方向不适配,导致两个滚动条都可见,因为另一个滚动条会占用缓冲区的一行
158                    (true, true)
159                }
160            }
161        }
162    }
163
164    /// 是否 ring(盖边框)模式:`over_border` 开启,且 block 在右侧与下方各留了一格边框可画。
165    /// `calc_children_areas` 与 `render_ref` **共用同一判定**,避免两处发散(partial-border block)。
166    pub(crate) fn ring(&self, outer: Rect, inner: Rect) -> bool {
167        self.over_border && inner.right() < outer.right() && inner.bottom() < outer.bottom()
168    }
169
170    /// 渲染可见窗口 + 滚动条。
171    ///
172    /// - `outer`:组件外框(有 block 时含边框);`inner`:`block.inner()`(无 block 时 = outer)。
173    /// - ring(盖边框)= `over_border` 且 inner 右/下方各有一格边框可画;此时视口 = 整个 inner,
174    ///   滚动条画在边框环上、不占内容;否则 inset:视口 = inner 扣掉显示的滚动条。
175    /// - 偏移量按**视口**裁剪(保证最后一行/列可达),`page_size` = 视口(供翻页/`is_at_bottom`)。
176    pub fn render_ref(
177        &self,
178        outer: Rect,
179        inner: Rect,
180        buf: &mut Buffer,
181        state: &mut ScrollViewState,
182        scroll_buffer: &Buffer,
183    ) {
184        let content = scroll_buffer.area.as_size();
185        let ring = self.ring(outer, inner);
186
187        let horizontal_space = inner.width as i32 - content.width as i32;
188        let vertical_space = inner.height as i32 - content.height as i32;
189        let (show_horizontal, show_vertical) = if ring {
190            // 边框上的滚动条不挤占内容,两轴独立按真实溢出判断,无角落耦合。
191            (
192                Self::axis_show(self.horizontal_scrollbar_visibility, horizontal_space),
193                Self::axis_show(self.vertical_scrollbar_visibility, vertical_space),
194            )
195        } else {
196            self.visible_scrollbars(horizontal_space, vertical_space)
197        };
198
199        // 视口:ring = 整个 inner;inset = inner 扣掉显示的滚动条。
200        let viewport = if ring {
201            inner.as_size()
202        } else {
203            Size::new(
204                inner.width.saturating_sub(show_vertical as u16),
205                inner.height.saturating_sub(show_horizontal as u16),
206            )
207        };
208
209        // 按视口裁剪偏移:内容放得下时 `saturating_sub` 为 0,`min` 自然把偏移归零。
210        let x = state
211            .offset
212            .x
213            .min(content.width.saturating_sub(viewport.width));
214        let y = state
215            .offset
216            .y
217            .min(content.height.saturating_sub(viewport.height));
218        state.offset = Position::new(x, y);
219        state.size = Some(content);
220        state.page_size = Some(viewport);
221
222        // 把可见窗口 blit 到 inner 左上角(严格限制在 inner 内,无滚动条方向的边框得以保留)。
223        let src = Rect::new(x, y, viewport.width, viewport.height).intersection(scroll_buffer.area);
224        let dst = Rect::new(inner.x, inner.y, viewport.width, viewport.height);
225        self.render_visible_area(dst, buf, src, scroll_buffer);
226
227        // 滚动条位置:ring 时 viewport = inner,故 `inner.x + viewport.width == inner.right()`(边框环上);
228        // inset 时即 inner 内最后一列/行。两种情形同一表达式,无需分支。
229        if show_vertical {
230            let area = Rect::new(inner.x + viewport.width, inner.y, 1, viewport.height);
231            Self::render_scrollbar(
232                &self.vertical_scrollbar,
233                ScrollbarOrientation::VerticalRight,
234                area,
235                buf,
236                y,
237                content.height,
238                viewport.height,
239            );
240        }
241        if show_horizontal {
242            let area = Rect::new(inner.x, inner.y + viewport.height, viewport.width, 1);
243            Self::render_scrollbar(
244                &self.horizontal_scrollbar,
245                ScrollbarOrientation::HorizontalBottom,
246                area,
247                buf,
248                x,
249                content.width,
250                viewport.width,
251            );
252        }
253    }
254}