Skip to main content

ratatui_kit/components/scroll_view/
mod.rs

1// ScrollView 组件:可滚动视图容器,支持横向/纵向滚动条,适合长列表、文档阅读等场景。
2//
3// ## 用法示例
4//
5// ### 自动管理滚动状态(推荐)
6// ```rust
7// element!(ScrollView {
8//     // 子内容(内置键鼠滚动由 active 默认开启)
9// })
10// ```
11//
12// ### 外部状态(读偏移 / 程序化滚动;与 active 正交,不会关掉内置滚动)
13// ```rust
14// let scroll_state = hooks.use_state(ScrollViewState::default);
15//
16// element!(ScrollView(
17//     state: scroll_state,
18//     scrollbars: Scrollbars::default(),
19// ){
20//     // 子内容
21// })
22// ```
23//
24// ScrollView 的两种模式是正交的:
25// 1. 不传 `state`,组件用内部滚动状态;
26// 2. 传 `state`,页面可读偏移、程序化滚动(`scroll_to_visible` / `is_at_bottom`),
27//    同时 `active`(默认 true)仍提供内置键鼠滚动。
28//
29// `Scrollbars::over_border`(默认 true)控制滚动条盖在 block 边框上还是退到框内。
30
31use crate::{AnyElement, Component, layout_style::LayoutStyle};
32use crate::{
33    Hook, State, UseEventHandler, UseState,
34    input::{EventOptions, EventPriority, EventResult, EventScope},
35};
36use ratatui::{
37    buffer::Buffer,
38    layout::{Constraint, Direction, Layout, Rect, Size},
39    widgets::Block,
40};
41use ratatui_kit_macros::{Props, with_layout_style};
42mod state;
43pub use state::ScrollViewState;
44mod scrollbars;
45pub use scrollbars::{ScrollbarVisibility, Scrollbars};
46
47#[with_layout_style]
48#[derive(Props)]
49// ScrollView 组件属性。
50pub struct ScrollViewProps<'a> {
51    // 子元素列表。
52    pub children: Vec<AnyElement<'a>>,
53    // 滚动条配置。
54    pub scrollbars: Scrollbars<'static>,
55    // 外部滚动状态(与 `active` 正交:传了也不会关掉内置滚动)。
56    pub state: Option<State<ScrollViewState>>,
57
58    // 可选边框块。
59    pub block: Option<Block<'static>>,
60
61    // 是否启用内置键鼠滚动(默认 true),与其它选择类组件的 `active` 约定一致。
62    pub active: bool,
63}
64
65impl Default for ScrollViewProps<'_> {
66    fn default() -> Self {
67        Self {
68            children: Vec::new(),
69            scrollbars: Scrollbars::default(),
70            state: None,
71            block: None,
72            active: true,
73            margin: Default::default(),
74            offset: Default::default(),
75            width: Default::default(),
76            height: Default::default(),
77            gap: Default::default(),
78            flex_direction: Default::default(),
79            justify_content: Default::default(),
80        }
81    }
82}
83
84// ScrollView 组件实现。
85pub struct ScrollView {
86    scrollbars: Scrollbars<'static>,
87    block: Option<Block<'static>>,
88    // draw() 在把 area 缩成 block.inner 之前暂存的外框;供 calc_children_areas 与 render_ref
89    // 共用同一个 `ring` 几何判定(单一真源)。
90    outer: Option<Rect>,
91    // 解析后的滚动状态(内部或外部),供 calc_children_areas 记录子节点区域以支持 scroll_to_index。
92    scroll_view_state: Option<State<ScrollViewState>>,
93}
94
95fn clamp_u16(value: u128) -> u16 {
96    value.min(u16::MAX as u128) as u16
97}
98
99fn constraints_to_lengths(constraints: &[Constraint], len: u16) -> Vec<u16> {
100    constraints
101        .iter()
102        .map(|constraint| match constraint {
103            Constraint::Length(value) => *value,
104            Constraint::Percentage(percent) => {
105                clamp_u16(u128::from(len) * u128::from(*percent) / 100)
106            }
107            Constraint::Ratio(numerator, denominator) => {
108                if *denominator == 0 {
109                    0
110                } else {
111                    clamp_u16(u128::from(len) * u128::from(*numerator) / u128::from(*denominator))
112                }
113            }
114            Constraint::Min(value) => *value,
115            Constraint::Max(value) => *value,
116            Constraint::Fill(weight) => clamp_u16(u128::from(len) * u128::from(*weight)),
117        })
118        .collect()
119}
120
121fn gap_sum(count: usize, gap: i32) -> u16 {
122    if count == 0 {
123        return 0;
124    }
125
126    let total = count.saturating_sub(1) as i128 * i128::from(gap);
127    if total <= 0 {
128        0
129    } else {
130        clamp_u16(total as u128)
131    }
132}
133
134fn sum_with_gap(lengths: &[u16], gap: i32) -> u16 {
135    if lengths.is_empty() {
136        return 0;
137    }
138
139    let sum = lengths
140        .iter()
141        .fold(0u128, |sum, value| sum.saturating_add(u128::from(*value)));
142    clamp_u16(sum.saturating_add(u128::from(gap_sum(lengths.len(), gap))))
143}
144
145fn cross_direction(direction: Direction) -> Direction {
146    match direction {
147        Direction::Horizontal => Direction::Vertical,
148        Direction::Vertical => Direction::Horizontal,
149    }
150}
151
152fn area_len(area: Rect, direction: Direction) -> u16 {
153    match direction {
154        Direction::Horizontal => area.width,
155        Direction::Vertical => area.height,
156    }
157}
158
159fn lengths_to_constraints(lengths: &[u16]) -> Vec<Constraint> {
160    lengths
161        .iter()
162        .map(|length| Constraint::Length(*length))
163        .collect()
164}
165
166fn content_size(
167    direction: Direction,
168    main_lengths: &[u16],
169    cross_lengths: &[u16],
170    gap: i32,
171) -> (u16, u16) {
172    let main = sum_with_gap(main_lengths, gap);
173    let cross = cross_lengths.iter().max().copied().unwrap_or_default();
174
175    match direction {
176        Direction::Horizontal => (main, cross),
177        Direction::Vertical => (cross, main),
178    }
179}
180
181impl Component for ScrollView {
182    type Props<'a> = ScrollViewProps<'a>;
183
184    fn new(props: &Self::Props<'_>) -> Self {
185        Self {
186            scrollbars: props.scrollbars.clone(),
187            block: props.block.clone(),
188            outer: None,
189            scroll_view_state: None,
190        }
191    }
192
193    fn update(
194        &mut self,
195        props: &mut Self::Props<'_>,
196        mut hooks: crate::Hooks,
197        updater: &mut crate::ComponentUpdater,
198    ) {
199        // 手写 Component 的 hooks 默认 context=None;先升级为 context-aware 以便用 use_event_handler。
200        // 所有 hooks 操作须置于后续 `&mut updater`(set_layout_style / update_children)之前。
201        let mut hooks = hooks.with_context_stack(updater.component_context_stack());
202
203        let layout_style = props.layout_style();
204
205        let this_scroll_view_state = hooks.use_state(ScrollViewState::default);
206        // 外部 state 与 active 正交:传外部 state 也不关掉内置滚动(与 Select/Table 一致)。
207        let state = props.state.unwrap_or(this_scroll_view_state);
208        let active = props.active;
209        self.block = props.block.clone();
210
211        {
212            let hook = hooks.use_hook(|| UseScrollImpl {
213                scroll_view_state: state,
214                scrollbars: props.scrollbars.clone(),
215                outer: None,
216                block: props.block.clone(),
217            });
218            hook.scroll_view_state = state;
219            hook.scrollbars = props.scrollbars.clone();
220            hook.block = props.block.clone();
221        }
222
223        // 滚动事件:Current 层 + 鼠标命中过滤。命中的滚动键/滚轮返回 Consumed,不再无声漏给兄弟 handler。
224        hooks.use_event_handler_with_options(
225            EventScope::Current,
226            EventPriority::Normal,
227            EventOptions { hit_test: true },
228            move |event| {
229                if active && state.write().handle_event(&event) {
230                    EventResult::Consumed
231                } else {
232                    EventResult::Ignored
233                }
234            },
235        );
236
237        self.scrollbars = props.scrollbars.clone();
238        self.scroll_view_state = Some(state);
239
240        updater.set_layout_style(layout_style);
241        updater.update_children(&mut props.children, None);
242    }
243
244    fn calc_children_areas(
245        &self,
246        children: &crate::Components,
247        layout_style: &LayoutStyle,
248        drawer: &mut crate::ComponentDrawer<'_, '_>,
249    ) -> Vec<ratatui::prelude::Rect> {
250        let constraint_sum =
251            |d: Direction, len: u16| constraints_to_lengths(&children.get_constraints(d), len);
252
253        let axis_lengths = |area: Rect| {
254            let main_direction = layout_style.flex_direction;
255            let cross_direction = cross_direction(main_direction);
256            let main_lengths = constraint_sum(main_direction, area_len(area, main_direction));
257            let cross_lengths = constraint_sum(cross_direction, area_len(area, cross_direction));
258            (main_lengths, cross_lengths)
259        };
260
261        // 此处 `drawer.area` 已是 `block.inner()`(draw() 在 calc 之前设置)。先按 inner 算一遍子长度。
262        let inner = drawer.area;
263        let (mut main_lengths, mut cross_lengths) = axis_lengths(inner);
264        let old_width_height = content_size(
265            layout_style.flex_direction,
266            &main_lengths,
267            &cross_lengths,
268            layout_style.gap,
269        );
270
271        // ring(盖边框)与 render_ref 共用同一几何判定(单一真源);ring 时子节点铺满整个 inner,否则扣掉滚动条。
272        let ring = self.scrollbars.ring(self.outer.unwrap_or(inner), inner);
273        let content_area = self.scrollbars.content_area(
274            inner,
275            Size::new(old_width_height.0, old_width_height.1),
276            ring,
277        );
278
279        // 仅当内容区因滚动条收窄时才重算(ring / 无滚动条时与上面完全一致,直接复用,省 4 次 Vec 分配)。
280        if content_area != inner {
281            (main_lengths, cross_lengths) = axis_lengths(content_area);
282        }
283        let (width, height) = content_size(
284            layout_style.flex_direction,
285            &main_lengths,
286            &cross_lengths,
287            layout_style.gap,
288        );
289        let justify_constraints = lengths_to_constraints(&main_lengths);
290        let align_constraints = lengths_to_constraints(&cross_lengths);
291
292        let rect = Rect::new(0, 0, width, height);
293        drawer.push_scroll_buffer(Buffer::empty(rect));
294
295        drawer.area = drawer.buffer_mut().area;
296
297        // flex layout
298        let layout = layout_style.get_layout().constraints(justify_constraints);
299        let areas = layout.split(drawer.area);
300
301        let mut new_areas: Vec<ratatui::prelude::Rect> = vec![];
302
303        let rev_direction = cross_direction(layout_style.flex_direction);
304        for (area, constraint) in areas.iter().zip(align_constraints.iter()) {
305            let area = Layout::new(rev_direction, [constraint]).split(*area)[0];
306            new_areas.push(area);
307        }
308
309        // 记录子节点在内容缓冲中的区域,供 `ScrollViewState::scroll_to_index` 联动滚动。
310        // 用 write_no_update:布局阶段写入不应触发重渲染(与 hook 写 offset 同理)。
311        if let Some(state) = self.scroll_view_state {
312            state.write_no_update().child_areas = new_areas.clone();
313        }
314
315        new_areas
316    }
317
318    fn draw(&mut self, drawer: &mut crate::ComponentDrawer<'_, '_>) {
319        // 暂存外框供 calc_children_areas 的 `ring` 判定复用(与 render_ref 同一真源)。
320        self.outer = Some(drawer.area);
321        if let Some(block) = self.block.as_ref() {
322            let inner_area = block.inner(drawer.area);
323            drawer.render_widget(block, drawer.area);
324            drawer.area = inner_area;
325        }
326    }
327}
328
329pub struct UseScrollImpl {
330    scroll_view_state: State<ScrollViewState>,
331    scrollbars: Scrollbars<'static>,
332    // 组件外框(pre_component_draw 在 draw() 把 area 改成 inner 之前捕获)。
333    outer: Option<ratatui::layout::Rect>,
334    block: Option<Block<'static>>,
335}
336
337impl Hook for UseScrollImpl {
338    fn pre_component_draw(&mut self, drawer: &mut crate::ComponentDrawer) {
339        // 此刻 drawer.area 仍是组件完整区(draw() 尚未把它缩成 block.inner)。
340        self.outer = Some(drawer.area);
341    }
342    fn post_component_draw(&mut self, drawer: &mut crate::ComponentDrawer) {
343        // pop 本层内容缓冲(嵌套安全:guard 避免 unwrap on None);pop 后 buffer_mut 回到外层。
344        let Some(buffer) = drawer.pop_scroll_buffer() else {
345            return;
346        };
347        let outer = self.outer.unwrap_or_default();
348        // inner 与 draw() 用同一 block.inner(),对部分边框/padding/标题一致。
349        let inner = self
350            .block
351            .as_ref()
352            .map(|block| block.inner(outer))
353            .unwrap_or(outer);
354
355        self.scrollbars.render_ref(
356            outer,
357            inner,
358            drawer.buffer_mut(),
359            &mut self.scroll_view_state.write_no_update(),
360            &buffer,
361        );
362    }
363}