ratatui_kit/components/scroll_view/
scrollbars.rs

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