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}
92
93fn clamp_u16(value: u128) -> u16 {
94    value.min(u16::MAX as u128) as u16
95}
96
97fn constraints_to_lengths(constraints: &[Constraint], len: u16) -> Vec<u16> {
98    constraints
99        .iter()
100        .map(|constraint| match constraint {
101            Constraint::Length(value) => *value,
102            Constraint::Percentage(percent) => {
103                clamp_u16(u128::from(len) * u128::from(*percent) / 100)
104            }
105            Constraint::Ratio(numerator, denominator) => {
106                if *denominator == 0 {
107                    0
108                } else {
109                    clamp_u16(u128::from(len) * u128::from(*numerator) / u128::from(*denominator))
110                }
111            }
112            Constraint::Min(value) => *value,
113            Constraint::Max(value) => *value,
114            Constraint::Fill(weight) => clamp_u16(u128::from(len) * u128::from(*weight)),
115        })
116        .collect()
117}
118
119fn gap_sum(count: usize, gap: i32) -> u16 {
120    if count == 0 {
121        return 0;
122    }
123
124    let total = count.saturating_sub(1) as i128 * i128::from(gap);
125    if total <= 0 {
126        0
127    } else {
128        clamp_u16(total as u128)
129    }
130}
131
132fn sum_with_gap(lengths: &[u16], gap: i32) -> u16 {
133    if lengths.is_empty() {
134        return 0;
135    }
136
137    let sum = lengths
138        .iter()
139        .fold(0u128, |sum, value| sum.saturating_add(u128::from(*value)));
140    clamp_u16(sum.saturating_add(u128::from(gap_sum(lengths.len(), gap))))
141}
142
143fn cross_direction(direction: Direction) -> Direction {
144    match direction {
145        Direction::Horizontal => Direction::Vertical,
146        Direction::Vertical => Direction::Horizontal,
147    }
148}
149
150fn area_len(area: Rect, direction: Direction) -> u16 {
151    match direction {
152        Direction::Horizontal => area.width,
153        Direction::Vertical => area.height,
154    }
155}
156
157fn lengths_to_constraints(lengths: &[u16]) -> Vec<Constraint> {
158    lengths
159        .iter()
160        .map(|length| Constraint::Length(*length))
161        .collect()
162}
163
164fn content_size(
165    direction: Direction,
166    main_lengths: &[u16],
167    cross_lengths: &[u16],
168    gap: i32,
169) -> (u16, u16) {
170    let main = sum_with_gap(main_lengths, gap);
171    let cross = cross_lengths.iter().max().copied().unwrap_or_default();
172
173    match direction {
174        Direction::Horizontal => (main, cross),
175        Direction::Vertical => (cross, main),
176    }
177}
178
179impl Component for ScrollView {
180    type Props<'a> = ScrollViewProps<'a>;
181
182    fn new(props: &Self::Props<'_>) -> Self {
183        Self {
184            scrollbars: props.scrollbars.clone(),
185            block: props.block.clone(),
186            outer: None,
187        }
188    }
189
190    fn update(
191        &mut self,
192        props: &mut Self::Props<'_>,
193        mut hooks: crate::Hooks,
194        updater: &mut crate::ComponentUpdater,
195    ) {
196        // 手写 Component 的 hooks 默认 context=None;先升级为 context-aware 以便用 use_event_handler。
197        // 所有 hooks 操作须置于后续 `&mut updater`(set_layout_style / update_children)之前。
198        let mut hooks = hooks.with_context_stack(updater.component_context_stack());
199
200        let layout_style = props.layout_style();
201
202        let this_scroll_view_state = hooks.use_state(ScrollViewState::default);
203        // 外部 state 与 active 正交:传外部 state 也不关掉内置滚动(与 Select/Table 一致)。
204        let state = props.state.unwrap_or(this_scroll_view_state);
205        let active = props.active;
206        self.block = props.block.clone();
207
208        {
209            let hook = hooks.use_hook(|| UseScrollImpl {
210                scroll_view_state: state,
211                scrollbars: props.scrollbars.clone(),
212                outer: None,
213                block: props.block.clone(),
214            });
215            hook.scroll_view_state = state;
216            hook.scrollbars = props.scrollbars.clone();
217            hook.block = props.block.clone();
218        }
219
220        // 滚动事件:Current 层 + 鼠标命中过滤。命中的滚动键/滚轮返回 Consumed,不再无声漏给兄弟 handler。
221        hooks.use_event_handler_with_options(
222            EventScope::Current,
223            EventPriority::Normal,
224            EventOptions { hit_test: true },
225            move |event| {
226                if active && state.write().handle_event(&event) {
227                    EventResult::Consumed
228                } else {
229                    EventResult::Ignored
230                }
231            },
232        );
233
234        self.scrollbars = props.scrollbars.clone();
235
236        updater.set_layout_style(layout_style);
237        updater.update_children(&mut props.children, None);
238    }
239
240    fn calc_children_areas(
241        &self,
242        children: &crate::Components,
243        layout_style: &LayoutStyle,
244        drawer: &mut crate::ComponentDrawer<'_, '_>,
245    ) -> Vec<ratatui::prelude::Rect> {
246        let constraint_sum =
247            |d: Direction, len: u16| constraints_to_lengths(&children.get_constraints(d), len);
248
249        let axis_lengths = |area: Rect| {
250            let main_direction = layout_style.flex_direction;
251            let cross_direction = cross_direction(main_direction);
252            let main_lengths = constraint_sum(main_direction, area_len(area, main_direction));
253            let cross_lengths = constraint_sum(cross_direction, area_len(area, cross_direction));
254            (main_lengths, cross_lengths)
255        };
256
257        // 此处 `drawer.area` 已是 `block.inner()`(draw() 在 calc 之前设置)。先按 inner 算一遍子长度。
258        let inner = drawer.area;
259        let (mut main_lengths, mut cross_lengths) = axis_lengths(inner);
260        let old_width_height = content_size(
261            layout_style.flex_direction,
262            &main_lengths,
263            &cross_lengths,
264            layout_style.gap,
265        );
266
267        // ring(盖边框)与 render_ref 共用同一几何判定(单一真源);ring 时子节点铺满整个 inner,否则扣掉滚动条。
268        let ring = self.scrollbars.ring(self.outer.unwrap_or(inner), inner);
269        let content_area = self.scrollbars.content_area(
270            inner,
271            Size::new(old_width_height.0, old_width_height.1),
272            ring,
273        );
274
275        // 仅当内容区因滚动条收窄时才重算(ring / 无滚动条时与上面完全一致,直接复用,省 4 次 Vec 分配)。
276        if content_area != inner {
277            (main_lengths, cross_lengths) = axis_lengths(content_area);
278        }
279        let (width, height) = content_size(
280            layout_style.flex_direction,
281            &main_lengths,
282            &cross_lengths,
283            layout_style.gap,
284        );
285        let justify_constraints = lengths_to_constraints(&main_lengths);
286        let align_constraints = lengths_to_constraints(&cross_lengths);
287
288        let rect = Rect::new(0, 0, width, height);
289        drawer.push_scroll_buffer(Buffer::empty(rect));
290
291        drawer.area = drawer.buffer_mut().area;
292
293        // flex layout
294        let layout = layout_style.get_layout().constraints(justify_constraints);
295        let areas = layout.split(drawer.area);
296
297        let mut new_areas: Vec<ratatui::prelude::Rect> = vec![];
298
299        let rev_direction = cross_direction(layout_style.flex_direction);
300        for (area, constraint) in areas.iter().zip(align_constraints.iter()) {
301            let area = Layout::new(rev_direction, [constraint]).split(*area)[0];
302            new_areas.push(area);
303        }
304
305        new_areas
306    }
307
308    fn draw(&mut self, drawer: &mut crate::ComponentDrawer<'_, '_>) {
309        // 暂存外框供 calc_children_areas 的 `ring` 判定复用(与 render_ref 同一真源)。
310        self.outer = Some(drawer.area);
311        if let Some(block) = self.block.as_ref() {
312            let inner_area = block.inner(drawer.area);
313            drawer.render_widget(block, drawer.area);
314            drawer.area = inner_area;
315        }
316    }
317}
318
319pub struct UseScrollImpl {
320    scroll_view_state: State<ScrollViewState>,
321    scrollbars: Scrollbars<'static>,
322    // 组件外框(pre_component_draw 在 draw() 把 area 改成 inner 之前捕获)。
323    outer: Option<ratatui::layout::Rect>,
324    block: Option<Block<'static>>,
325}
326
327impl Hook for UseScrollImpl {
328    fn pre_component_draw(&mut self, drawer: &mut crate::ComponentDrawer) {
329        // 此刻 drawer.area 仍是组件完整区(draw() 尚未把它缩成 block.inner)。
330        self.outer = Some(drawer.area);
331    }
332    fn post_component_draw(&mut self, drawer: &mut crate::ComponentDrawer) {
333        // pop 本层内容缓冲(嵌套安全:guard 避免 unwrap on None);pop 后 buffer_mut 回到外层。
334        let Some(buffer) = drawer.pop_scroll_buffer() else {
335            return;
336        };
337        let outer = self.outer.unwrap_or_default();
338        // inner 与 draw() 用同一 block.inner(),对部分边框/padding/标题一致。
339        let inner = self
340            .block
341            .as_ref()
342            .map(|block| block.inner(outer))
343            .unwrap_or(outer);
344
345        self.scrollbars.render_ref(
346            outer,
347            inner,
348            drawer.buffer_mut(),
349            &mut self.scroll_view_state.write_no_update(),
350            &buffer,
351        );
352    }
353}