ratatui_kit/components/scroll_view/
mod.rs

1//! ScrollView 组件:可滚动视图容器,支持横向/纵向滚动条,适合长列表、文档阅读等场景。
2//!
3//! ## 用法示例
4//!
5//! ### 自动管理滚动状态(推荐)
6//! ```rust
7//! element!(ScrollView(
8//!     scroll_bars: ScrollBars::default(),
9//! ){
10//!     // 子内容
11//! })
12//! ```
13//!
14//! ### 手动管理滚动状态
15//! ```rust
16//! let scroll_state = hooks.use_state(ScrollViewState::default);
17//!
18//! hooks.use_local_events(move |event| {
19//!     scroll_state.write().handle_event(&event);
20//! });
21//!
22//! element!(ScrollView(
23//!     scroll_view_state: scroll_state,
24//!     scroll_bars: ScrollBars::default(),
25//! ){
26//!     // 子内容
27//! })
28//! ```
29//!
30//! ScrollView 支持两种使用方式:
31//! 1. 不传递 `scroll_view_state` 参数,组件会自动管理滚动状态
32//! 2. 传递由 `use_state` 创建的 `scroll_view_state` 参数,手动管理滚动状态
33//!
34//! 当需要对滚动行为进行精确控制时(如程序化滚动、与其他状态联动等),建议使用手动管理模式。
35
36use crate::{AnyElement, Component, layout_style::LayoutStyle};
37use crate::{Hook, State, UseEffect, UseEvents, UseState};
38use ratatui::{
39    buffer::Buffer,
40    layout::{Constraint, Direction, Layout, Rect},
41};
42use ratatui_kit_macros::{Props, with_layout_style};
43mod state;
44pub use state::ScrollViewState;
45mod scrollbars;
46pub use scrollbars::{ScrollBars, ScrollbarVisibility};
47
48#[with_layout_style]
49#[derive(Default, Props)]
50/// ScrollView 组件属性。
51pub struct ScrollViewProps<'a> {
52    /// 子元素列表。
53    pub children: Vec<AnyElement<'a>>,
54    /// 滚动条配置。
55    pub scroll_bars: ScrollBars<'static>,
56    /// 滚动状态。
57    pub scroll_view_state: Option<State<ScrollViewState>>,
58}
59
60/// ScrollView 组件实现。
61pub struct ScrollView {
62    scroll_bars: ScrollBars<'static>,
63}
64
65impl Component for ScrollView {
66    type Props<'a> = ScrollViewProps<'a>;
67
68    fn new(props: &Self::Props<'_>) -> Self {
69        Self {
70            scroll_bars: props.scroll_bars.clone(),
71        }
72    }
73
74    fn update(
75        &mut self,
76        props: &mut Self::Props<'_>,
77        mut hooks: crate::Hooks,
78        updater: &mut crate::ComponentUpdater,
79    ) {
80        let layout_style = props.layout_style();
81
82        let scrollbars = hooks.use_state(|| props.scroll_bars.clone());
83
84        let this_scroll_view_state = hooks.use_state(ScrollViewState::default);
85
86        hooks.use_effect(
87            || {
88                *scrollbars.write() = props.scroll_bars.clone();
89            },
90            props.scroll_bars.clone(),
91        );
92
93        hooks.use_hook(|| UseScrollImpl {
94            scroll_view_state: props.scroll_view_state.unwrap_or(this_scroll_view_state),
95            scrollbars,
96            area: None,
97        });
98
99        hooks.use_local_events({
100            let props_scroll_view_state = props.scroll_view_state;
101            move |event| {
102                if props_scroll_view_state.is_none() {
103                    this_scroll_view_state.write().handle_event(&event);
104                }
105            }
106        });
107
108        self.scroll_bars = props.scroll_bars.clone();
109
110        updater.set_layout_style(layout_style);
111        updater.update_children(&mut props.children, None);
112    }
113
114    fn calc_children_areas(
115        &self,
116        children: &crate::Components,
117        layout_style: &LayoutStyle,
118        drawer: &mut crate::ComponentDrawer<'_, '_>,
119    ) -> Vec<ratatui::prelude::Rect> {
120        let constraint_sum = |d: Direction, len: u16| {
121            children
122                .get_constraints(d)
123                .iter()
124                .map(|c| match c {
125                    Constraint::Length(h) => *h,
126                    Constraint::Percentage(p) => len * *p / 100,
127                    Constraint::Ratio(r, n) => {
128                        if *n != 0 {
129                            len * (*r as u16) / (*n as u16)
130                        } else {
131                            0
132                        }
133                    }
134                    Constraint::Min(min) => *min,
135                    Constraint::Max(max) => *max,
136                    Constraint::Fill(i) => len * i,
137                })
138                .collect::<Vec<_>>()
139        };
140
141        let old_width_height = {
142            let area = drawer.area;
143            match layout_style.flex_direction {
144                Direction::Horizontal => {
145                    let sum_w = constraint_sum(Direction::Horizontal, area.width);
146                    let sum_count = sum_w.len();
147                    let sum_w = sum_w.iter().sum::<u16>()
148                        + ((sum_count as i32 - 1) * layout_style.gap) as u16;
149                    let sum_h = constraint_sum(Direction::Vertical, area.height)
150                        .into_iter()
151                        .max()
152                        .unwrap_or_default();
153                    (sum_w, sum_h)
154                }
155                Direction::Vertical => {
156                    let sum_h = constraint_sum(Direction::Vertical, area.height);
157                    let sum_count = sum_h.len();
158                    let sum_h = sum_h.iter().sum::<u16>()
159                        + ((sum_count as i32 - 1) * layout_style.gap) as u16;
160                    let sum_w = constraint_sum(Direction::Horizontal, area.width)
161                        .into_iter()
162                        .max()
163                        .unwrap_or_default();
164                    (sum_w, sum_h)
165                }
166            }
167        };
168
169        let horizontal_space = drawer.area.width as i32 - old_width_height.0 as i32 + 1;
170        let vertical_space = drawer.area.height as i32 - old_width_height.1 as i32 + 1;
171        let (show_horizontal, show_vertical) = self
172            .scroll_bars
173            .visible_scrollbars(horizontal_space, vertical_space);
174
175        let (width, height, justify_constraints, align_constraints) = {
176            let mut area = drawer.area;
177            if show_horizontal {
178                area.height -= 1;
179            }
180            if show_vertical {
181                area.width -= 1;
182            }
183            match layout_style.flex_direction {
184                Direction::Horizontal => {
185                    let widths = constraint_sum(Direction::Horizontal, area.width);
186                    let sum_count = widths.len();
187
188                    let justify_constraints = widths
189                        .iter()
190                        .map(|c| Constraint::Length(*c))
191                        .collect::<Vec<Constraint>>();
192
193                    let sum_w = widths.iter().sum::<u16>()
194                        + ((sum_count as i32 - 1) * layout_style.gap) as u16;
195
196                    let heights = constraint_sum(Direction::Vertical, area.height);
197                    let sum_h = heights.iter().max().copied().unwrap_or_default();
198
199                    let align_constraints = heights
200                        .iter()
201                        .map(|c| Constraint::Length(*c))
202                        .collect::<Vec<Constraint>>();
203
204                    (sum_w, sum_h, justify_constraints, align_constraints)
205                }
206                Direction::Vertical => {
207                    let heights = constraint_sum(Direction::Vertical, area.height);
208                    let sum_count = heights.len();
209
210                    let justify_constraints = heights
211                        .iter()
212                        .map(|c| Constraint::Length(*c))
213                        .collect::<Vec<Constraint>>();
214
215                    let sum_h = heights.iter().sum::<u16>()
216                        + ((sum_count as i32 - 1) * layout_style.gap) as u16;
217
218                    let widths = constraint_sum(Direction::Horizontal, area.width);
219                    let sum_w = widths.iter().max().copied().unwrap_or_default();
220
221                    let align_constraints = widths
222                        .iter()
223                        .map(|c| Constraint::Length(*c))
224                        .collect::<Vec<Constraint>>();
225
226                    (sum_w, sum_h, justify_constraints, align_constraints)
227                }
228            }
229        };
230
231        let rect = Rect::new(0, 0, width, height);
232        drawer.scroll_buffer = Some(Buffer::empty(rect));
233
234        drawer.area = drawer.buffer_mut().area;
235
236        // flex layout
237        let layout = layout_style.get_layout().constraints(justify_constraints);
238        let areas = layout.split(drawer.area);
239
240        let mut new_areas: Vec<ratatui::prelude::Rect> = vec![];
241
242        let rev_direction = match layout_style.flex_direction {
243            Direction::Horizontal => Direction::Vertical,
244            Direction::Vertical => Direction::Horizontal,
245        };
246        for (area, constraint) in areas.iter().zip(align_constraints.iter()) {
247            let area = Layout::new(rev_direction, [constraint]).split(*area)[0];
248            new_areas.push(area);
249        }
250
251        new_areas
252    }
253}
254
255pub struct UseScrollImpl {
256    scroll_view_state: State<ScrollViewState>,
257    scrollbars: State<ScrollBars<'static>>,
258    area: Option<ratatui::layout::Rect>,
259}
260
261impl Hook for UseScrollImpl {
262    fn pre_component_draw(&mut self, drawer: &mut crate::ComponentDrawer) {
263        self.area = Some(drawer.area);
264    }
265    fn post_component_draw(&mut self, drawer: &mut crate::ComponentDrawer) {
266        let buffer = drawer.scroll_buffer.take().unwrap();
267        let scrollbars = self.scrollbars.read();
268
269        scrollbars.render_ref(
270            self.area.unwrap_or_default(),
271            drawer.buffer_mut(),
272            &mut self.scroll_view_state.write_no_update(),
273            &buffer,
274        );
275    }
276}