ratatui_kit/components/scroll_view/
mod.rs1use 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)]
60pub struct ScrollViewProps<'a> {
62 pub children: Vec<AnyElement<'a>>,
64 pub scroll_bars: ScrollBars<'static>,
66 pub scroll_view_state: Option<State<ScrollViewState>>,
68
69 pub block: Option<Block<'static>>,
71
72 pub disabled: bool,
73}
74
75pub 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 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 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 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}