ratatui_kit/components/scroll_view/
mod.rs1use 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)]
49pub struct ScrollViewProps<'a> {
51 pub children: Vec<AnyElement<'a>>,
53 pub scrollbars: Scrollbars<'static>,
55 pub state: Option<State<ScrollViewState>>,
57
58 pub block: Option<Block<'static>>,
60
61 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
84pub struct ScrollView {
86 scrollbars: Scrollbars<'static>,
87 block: Option<Block<'static>>,
88 outer: Option<Rect>,
91 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 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 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 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 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 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 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 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 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 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 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 self.outer = Some(drawer.area);
341 }
342 fn post_component_draw(&mut self, drawer: &mut crate::ComponentDrawer) {
343 let Some(buffer) = drawer.pop_scroll_buffer() else {
345 return;
346 };
347 let outer = self.outer.unwrap_or_default();
348 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}