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}
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 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 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 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 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 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 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 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 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 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 self.outer = Some(drawer.area);
331 }
332 fn post_component_draw(&mut self, drawer: &mut crate::ComponentDrawer) {
333 let Some(buffer) = drawer.pop_scroll_buffer() else {
335 return;
336 };
337 let outer = self.outer.unwrap_or_default();
338 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}