Skip to main content

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_event_handler_with_options(
19//     EventScope::Current,
20//     EventPriority::Normal,
21//     EventOptions { hit_test: true },
22//     move |event| {
23//         scroll_state.write().handle_event(&event);
24//         EventResult::Ignored
25//     },
26// );
27//
28// element!(ScrollView(
29//     scroll_view_state: scroll_state,
30//     scroll_bars: ScrollBars::default(),
31// ){
32//     // 子内容
33// })
34// ```
35//
36// ScrollView 支持两种使用方式:
37// 1. 不传递 `scroll_view_state` 参数,组件会自动管理滚动状态
38// 2. 传递由 `use_state` 创建的 `scroll_view_state` 参数,手动管理滚动状态
39//
40// 当需要对滚动行为进行精确控制时(如程序化滚动、与其他状态联动等),建议使用手动管理模式。
41
42use crate::{AnyElement, Component, layout_style::LayoutStyle};
43use crate::{
44    Hook, State, UseEventHandler, UseState,
45    input::{EventOptions, EventPriority, EventResult, EventScope},
46};
47use ratatui::{
48    buffer::Buffer,
49    layout::{Constraint, Direction, Layout, Rect, Size},
50    widgets::Block,
51};
52use ratatui_kit_macros::{Props, with_layout_style};
53mod state;
54pub use state::ScrollViewState;
55mod scrollbars;
56pub use scrollbars::{ScrollBars, ScrollbarVisibility};
57
58#[with_layout_style]
59#[derive(Default, Props)]
60// ScrollView 组件属性。
61pub struct ScrollViewProps<'a> {
62    // 子元素列表。
63    pub children: Vec<AnyElement<'a>>,
64    // 滚动条配置。
65    pub scroll_bars: ScrollBars<'static>,
66    // 滚动状态。
67    pub scroll_view_state: Option<State<ScrollViewState>>,
68
69    // 可选边框块。
70    pub block: Option<Block<'static>>,
71
72    pub disabled: bool,
73}
74
75// ScrollView 组件实现。
76pub struct ScrollView {
77    scroll_bars: ScrollBars<'static>,
78    block: Option<Block<'static>>,
79}
80
81fn clamp_u16(value: u128) -> u16 {
82    value.min(u16::MAX as u128) as u16
83}
84
85fn constraints_to_lengths(constraints: &[Constraint], len: u16) -> Vec<u16> {
86    constraints
87        .iter()
88        .map(|constraint| match constraint {
89            Constraint::Length(value) => *value,
90            Constraint::Percentage(percent) => {
91                clamp_u16(u128::from(len) * u128::from(*percent) / 100)
92            }
93            Constraint::Ratio(numerator, denominator) => {
94                if *denominator == 0 {
95                    0
96                } else {
97                    clamp_u16(u128::from(len) * u128::from(*numerator) / u128::from(*denominator))
98                }
99            }
100            Constraint::Min(value) => *value,
101            Constraint::Max(value) => *value,
102            Constraint::Fill(weight) => clamp_u16(u128::from(len) * u128::from(*weight)),
103        })
104        .collect()
105}
106
107fn gap_sum(count: usize, gap: i32) -> u16 {
108    if count == 0 {
109        return 0;
110    }
111
112    let total = count.saturating_sub(1) as i128 * i128::from(gap);
113    if total <= 0 {
114        0
115    } else {
116        clamp_u16(total as u128)
117    }
118}
119
120fn sum_with_gap(lengths: &[u16], gap: i32) -> u16 {
121    if lengths.is_empty() {
122        return 0;
123    }
124
125    let sum = lengths
126        .iter()
127        .fold(0u128, |sum, value| sum.saturating_add(u128::from(*value)));
128    clamp_u16(sum.saturating_add(u128::from(gap_sum(lengths.len(), gap))))
129}
130
131fn cross_direction(direction: Direction) -> Direction {
132    match direction {
133        Direction::Horizontal => Direction::Vertical,
134        Direction::Vertical => Direction::Horizontal,
135    }
136}
137
138fn area_len(area: Rect, direction: Direction) -> u16 {
139    match direction {
140        Direction::Horizontal => area.width,
141        Direction::Vertical => area.height,
142    }
143}
144
145fn lengths_to_constraints(lengths: &[u16]) -> Vec<Constraint> {
146    lengths
147        .iter()
148        .map(|length| Constraint::Length(*length))
149        .collect()
150}
151
152fn content_size(
153    direction: Direction,
154    main_lengths: &[u16],
155    cross_lengths: &[u16],
156    gap: i32,
157) -> (u16, u16) {
158    let main = sum_with_gap(main_lengths, gap);
159    let cross = cross_lengths.iter().max().copied().unwrap_or_default();
160
161    match direction {
162        Direction::Horizontal => (main, cross),
163        Direction::Vertical => (cross, main),
164    }
165}
166
167impl Component for ScrollView {
168    type Props<'a> = ScrollViewProps<'a>;
169
170    fn new(props: &Self::Props<'_>) -> Self {
171        Self {
172            scroll_bars: props.scroll_bars.clone(),
173            block: props.block.clone(),
174        }
175    }
176
177    fn update(
178        &mut self,
179        props: &mut Self::Props<'_>,
180        mut hooks: crate::Hooks,
181        updater: &mut crate::ComponentUpdater,
182    ) {
183        // 手写 Component 的 hooks 默认 context=None;先升级为 context-aware 以便用 use_event_handler。
184        // 所有 hooks 操作须置于后续 `&mut updater`(set_layout_style / update_children)之前。
185        let mut hooks = hooks.with_context_stack(updater.component_context_stack());
186
187        let layout_style = props.layout_style();
188
189        let this_scroll_view_state = hooks.use_state(ScrollViewState::default);
190
191        let disabled = props.disabled;
192        self.block = props.block.clone();
193
194        {
195            let hook = hooks.use_hook(|| UseScrollImpl {
196                scroll_view_state: props.scroll_view_state.unwrap_or(this_scroll_view_state),
197                scrollbars: props.scroll_bars.clone(),
198                area: None,
199                has_block: props.block.is_some(),
200            });
201            hook.scroll_view_state = props.scroll_view_state.unwrap_or(this_scroll_view_state);
202            hook.scrollbars = props.scroll_bars.clone();
203            hook.has_block = props.block.is_some();
204        }
205
206        // 滚动事件:Current 层 + 鼠标命中过滤(复刻旧 use_local_events 的 in_component 语义)。
207        // 返回 Ignored 不阻断——handle_event 对非滚动事件无副作用,且不应吃掉同层其它 handler 的按键。
208        hooks.use_event_handler_with_options(
209            EventScope::Current,
210            EventPriority::Normal,
211            EventOptions { hit_test: true },
212            {
213                let props_scroll_view_state = props.scroll_view_state;
214                move |event| {
215                    if props_scroll_view_state.is_none() && !disabled {
216                        this_scroll_view_state.write().handle_event(&event);
217                    }
218                    EventResult::Ignored
219                }
220            },
221        );
222
223        self.scroll_bars = props.scroll_bars.clone();
224
225        updater.set_layout_style(layout_style);
226        updater.update_children(&mut props.children, None);
227    }
228
229    fn calc_children_areas(
230        &self,
231        children: &crate::Components,
232        layout_style: &LayoutStyle,
233        drawer: &mut crate::ComponentDrawer<'_, '_>,
234    ) -> Vec<ratatui::prelude::Rect> {
235        let constraint_sum =
236            |d: Direction, len: u16| constraints_to_lengths(&children.get_constraints(d), len);
237
238        let axis_lengths = |area: Rect| {
239            let main_direction = layout_style.flex_direction;
240            let cross_direction = cross_direction(main_direction);
241            let main_lengths = constraint_sum(main_direction, area_len(area, main_direction));
242            let cross_lengths = constraint_sum(cross_direction, area_len(area, cross_direction));
243            (main_lengths, cross_lengths)
244        };
245
246        let old_width_height = {
247            let area = drawer.area;
248            let (main_lengths, cross_lengths) = axis_lengths(area);
249            content_size(
250                layout_style.flex_direction,
251                &main_lengths,
252                &cross_lengths,
253                layout_style.gap,
254            )
255        };
256
257        let scrollbar_layout = self.scroll_bars.layout_for(
258            drawer.area,
259            Size::new(old_width_height.0, old_width_height.1),
260        );
261
262        let (width, height, justify_constraints, align_constraints) = {
263            let area = scrollbar_layout.visible_area;
264            let (main_lengths, cross_lengths) = axis_lengths(area);
265            let (width, height) = content_size(
266                layout_style.flex_direction,
267                &main_lengths,
268                &cross_lengths,
269                layout_style.gap,
270            );
271            (
272                width,
273                height,
274                lengths_to_constraints(&main_lengths),
275                lengths_to_constraints(&cross_lengths),
276            )
277        };
278
279        let rect = Rect::new(0, 0, width, height);
280        drawer.scroll_buffer = Some(Buffer::empty(rect));
281
282        drawer.area = drawer.buffer_mut().area;
283
284        // flex layout
285        let layout = layout_style.get_layout().constraints(justify_constraints);
286        let areas = layout.split(drawer.area);
287
288        let mut new_areas: Vec<ratatui::prelude::Rect> = vec![];
289
290        let rev_direction = cross_direction(layout_style.flex_direction);
291        for (area, constraint) in areas.iter().zip(align_constraints.iter()) {
292            let area = Layout::new(rev_direction, [constraint]).split(*area)[0];
293            new_areas.push(area);
294        }
295
296        new_areas
297    }
298
299    fn draw(&mut self, drawer: &mut crate::ComponentDrawer<'_, '_>) {
300        if let Some(block) = self.block.as_ref() {
301            let inner_area = block.inner(drawer.area);
302            drawer.render_widget(block, drawer.area);
303            drawer.area = inner_area;
304        }
305    }
306}
307
308pub struct UseScrollImpl {
309    scroll_view_state: State<ScrollViewState>,
310    scrollbars: ScrollBars<'static>,
311    area: Option<ratatui::layout::Rect>,
312    has_block: bool,
313}
314
315impl Hook for UseScrollImpl {
316    fn pre_component_draw(&mut self, drawer: &mut crate::ComponentDrawer) {
317        self.area = Some(if self.has_block {
318            Rect {
319                x: drawer.area.x + 1,
320                y: drawer.area.y + 1,
321                width: drawer.area.width.saturating_sub(1),
322                height: drawer.area.height.saturating_sub(2),
323            }
324        } else {
325            drawer.area
326        });
327    }
328    fn post_component_draw(&mut self, drawer: &mut crate::ComponentDrawer) {
329        let buffer = drawer.scroll_buffer.take().unwrap();
330
331        self.scrollbars.render_ref(
332            self.area.unwrap_or_default(),
333            drawer.buffer_mut(),
334            &mut self.scroll_view_state.write_no_update(),
335            &buffer,
336        );
337    }
338}