1use ratatui::{
2 buffer::Buffer,
3 layout::{Position, Rect},
4 style::{Style, Styled},
5 widgets::{block::BlockExt, Block, StatefulWidget, Widget},
6};
7
8use crate::{utils::layout_on_viewport, ListState};
9
10#[allow(clippy::module_name_repetitions)]
13pub struct ListView<'a, T> {
14 pub item_count: usize,
16
17 pub builder: ListBuilder<'a, T>,
19
20 pub scroll_axis: ScrollAxis,
22
23 pub style: Style,
25
26 pub block: Option<Block<'a>>,
28
29 pub(crate) scroll_padding: u16,
31
32 pub(crate) infinite_scrolling: bool,
35}
36
37impl<'a, T> ListView<'a, T> {
38 #[must_use]
40 pub fn new(builder: ListBuilder<'a, T>, item_count: usize) -> Self {
41 Self {
42 builder,
43 item_count,
44 scroll_axis: ScrollAxis::Vertical,
45 style: Style::default(),
46 block: None,
47 scroll_padding: 0,
48 infinite_scrolling: true,
49 }
50 }
51
52 #[must_use]
54 pub fn is_empty(&self) -> bool {
55 self.item_count == 0
56 }
57
58 #[must_use]
60 pub fn len(&self) -> usize {
61 self.item_count
62 }
63
64 #[must_use]
66 pub fn block(mut self, block: Block<'a>) -> Self {
67 self.block = Some(block);
68 self
69 }
70
71 #[must_use]
73 pub fn style<S: Into<Style>>(mut self, style: S) -> Self {
74 self.style = style.into();
75 self
76 }
77
78 #[must_use]
80 pub fn scroll_axis(mut self, scroll_axis: ScrollAxis) -> Self {
81 self.scroll_axis = scroll_axis;
82 self
83 }
84
85 #[must_use]
87 pub fn scroll_padding(mut self, scroll_padding: u16) -> Self {
88 self.scroll_padding = scroll_padding;
89 self
90 }
91
92 #[must_use]
94 pub fn infinite_scrolling(mut self, infinite_scrolling: bool) -> Self {
95 self.infinite_scrolling = infinite_scrolling;
96 self
97 }
98}
99
100impl<T> Styled for ListView<'_, T> {
101 type Item = Self;
102
103 fn style(&self) -> Style {
104 self.style
105 }
106
107 fn set_style<S: Into<Style>>(mut self, style: S) -> Self::Item {
108 self.style = style.into();
109 self
110 }
111}
112
113impl<'a, T: Copy + 'a> From<Vec<T>> for ListView<'a, T> {
114 fn from(value: Vec<T>) -> Self {
115 let item_count = value.len();
116 let builder = ListBuilder::new(move |context| (value[context.index], 1));
117
118 ListView::new(builder, item_count)
119 }
120}
121
122pub struct ListBuildContext {
125 pub index: usize,
127
128 pub is_selected: bool,
130
131 pub scroll_axis: ScrollAxis,
133
134 pub cross_axis_size: u16,
136}
137
138type ListBuilderClosure<'a, T> = dyn Fn(&ListBuildContext) -> (T, u16) + 'a;
140
141pub struct ListBuilder<'a, T> {
143 closure: Box<ListBuilderClosure<'a, T>>,
144}
145
146impl<'a, T> ListBuilder<'a, T> {
147 pub fn new<F>(closure: F) -> Self
164 where
165 F: Fn(&ListBuildContext) -> (T, u16) + 'a,
166 {
167 ListBuilder {
168 closure: Box::new(closure),
169 }
170 }
171
172 pub(crate) fn call_closure(&self, context: &ListBuildContext) -> (T, u16) {
174 (self.closure)(context)
175 }
176}
177
178#[derive(Debug, Default, Clone, Copy)]
180pub enum ScrollAxis {
181 #[default]
183 Vertical,
184
185 Horizontal,
187}
188
189impl<T: Widget> StatefulWidget for ListView<'_, T> {
190 type State = ListState;
191
192 fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
193 state.set_num_elements(self.item_count);
194 state.set_infinite_scrolling(self.infinite_scrolling);
195
196 buf.set_style(area, self.style);
198
199 self.block.render(area, buf);
201 let area = self.block.inner_if_some(area);
202
203 if self.item_count == 0 {
205 return;
206 }
207
208 let (main_axis_size, cross_axis_size) = match self.scroll_axis {
210 ScrollAxis::Vertical => (area.height, area.width),
211 ScrollAxis::Horizontal => (area.width, area.height),
212 };
213
214 let (mut scroll_axis_pos, cross_axis_pos) = match self.scroll_axis {
216 ScrollAxis::Vertical => (area.top(), area.left()),
217 ScrollAxis::Horizontal => (area.left(), area.top()),
218 };
219
220 let mut viewport = layout_on_viewport(
223 state,
224 &self.builder,
225 self.item_count,
226 main_axis_size,
227 cross_axis_size,
228 self.scroll_axis,
229 self.scroll_padding,
230 );
231
232 let (start, end) = (
233 state.view_state.offset,
234 viewport.len() + state.view_state.offset,
235 );
236 for i in start..end {
237 let Some(element) = viewport.remove(&i) else {
238 break;
239 };
240 let visible_main_axis_size = element
241 .main_axis_size
242 .saturating_sub(element.truncation.value());
243 let area = match self.scroll_axis {
244 ScrollAxis::Vertical => Rect::new(
245 cross_axis_pos,
246 scroll_axis_pos,
247 cross_axis_size,
248 visible_main_axis_size,
249 ),
250 ScrollAxis::Horizontal => Rect::new(
251 scroll_axis_pos,
252 cross_axis_pos,
253 visible_main_axis_size,
254 cross_axis_size,
255 ),
256 };
257
258 if element.truncation.value() > 0 {
260 render_truncated(
261 element.widget,
262 area,
263 buf,
264 element.main_axis_size,
265 &element.truncation,
266 self.style,
267 self.scroll_axis,
268 );
269 } else {
270 element.widget.render(area, buf);
271 }
272
273 scroll_axis_pos += visible_main_axis_size;
274 }
275 }
276}
277
278fn render_truncated<T: Widget>(
281 item: T,
282 available_area: Rect,
283 buf: &mut Buffer,
284 untruncated_size: u16,
285 truncation: &Truncation,
286 base_style: Style,
287 scroll_axis: ScrollAxis,
288) {
289 let (width, height) = match scroll_axis {
291 ScrollAxis::Vertical => (available_area.width, untruncated_size),
292 ScrollAxis::Horizontal => (untruncated_size, available_area.height),
293 };
294 let mut hidden_buffer = Buffer::empty(Rect {
295 x: available_area.left(),
296 y: available_area.top(),
297 width,
298 height,
299 });
300 hidden_buffer.set_style(hidden_buffer.area, base_style);
301 item.render(hidden_buffer.area, &mut hidden_buffer);
302
303 match scroll_axis {
305 ScrollAxis::Vertical => {
306 let offset = match truncation {
307 Truncation::Top(value) => *value,
308 _ => 0,
309 };
310 for y in available_area.top()..available_area.bottom() {
311 let y_off = y + offset;
312 for x in available_area.left()..available_area.right() {
313 if let Some(to) = buf.cell_mut(Position::new(x, y)) {
314 if let Some(from) = hidden_buffer.cell(Position::new(x, y_off)) {
315 *to = from.clone();
316 }
317 }
318 }
319 }
320 }
321 ScrollAxis::Horizontal => {
322 let offset = match truncation {
323 Truncation::Top(value) => *value,
324 _ => 0,
325 };
326 for x in available_area.left()..available_area.right() {
327 let x_off = x + offset;
328 for y in available_area.top()..available_area.bottom() {
329 if let Some(to) = buf.cell_mut(Position::new(x, y)) {
330 if let Some(from) = hidden_buffer.cell(Position::new(x_off, y)) {
331 *to = from.clone();
332 }
333 }
334 }
335 }
336 }
337 };
338}
339
340#[derive(Debug, Clone, Default, PartialEq, PartialOrd, Eq, Ord)]
341pub(crate) enum Truncation {
342 #[default]
343 None,
344 Top(u16),
345 Bot(u16),
346}
347
348impl Truncation {
349 pub(crate) fn value(&self) -> u16 {
350 match self {
351 Self::Top(value) | Self::Bot(value) => *value,
352 Self::None => 0,
353 }
354 }
355}
356
357#[cfg(test)]
358mod test {
359 use crate::ListBuilder;
360 use ratatui::widgets::Block;
361
362 use super::*;
363 use ratatui::widgets::Borders;
364
365 struct TestItem {}
366 impl Widget for TestItem {
367 fn render(self, area: Rect, buf: &mut Buffer)
368 where
369 Self: Sized,
370 {
371 Block::default().borders(Borders::ALL).render(area, buf);
372 }
373 }
374
375 fn test_data(total_height: u16) -> (Rect, Buffer, ListView<'static, TestItem>, ListState) {
376 let area = Rect::new(0, 0, 5, total_height);
377 let list = ListView::new(ListBuilder::new(|_| (TestItem {}, 3)), 3);
378 (area, Buffer::empty(area), list, ListState::default())
379 }
380
381 #[test]
382 fn not_truncated() {
383 let (area, mut buf, list, mut state) = test_data(9);
385
386 list.render(area, &mut buf, &mut state);
388
389 assert_buffer_eq(
391 buf,
392 Buffer::with_lines(vec![
393 "┌───┐",
394 "│ │",
395 "└───┘",
396 "┌───┐",
397 "│ │",
398 "└───┘",
399 "┌───┐",
400 "│ │",
401 "└───┘",
402 ]),
403 )
404 }
405
406 #[test]
407 fn empty_list() {
408 let area = Rect::new(0, 0, 5, 2);
410 let mut buf = Buffer::empty(area);
411 let mut state = ListState::default();
412 let builder = ListBuilder::new(|_| (TestItem {}, 0));
413 let list = ListView::new(builder, 0);
414
415 list.render(area, &mut buf, &mut state);
417
418 assert_buffer_eq(buf, Buffer::with_lines(vec![" ", " "]))
420 }
421
422 #[test]
423 fn zero_size() {
424 let (area, mut buf, list, mut state) = test_data(0);
426
427 list.render(area, &mut buf, &mut state);
429
430 assert_buffer_eq(buf, Buffer::empty(area))
432 }
433
434 #[test]
435 fn truncated_bot() {
436 let (area, mut buf, list, mut state) = test_data(8);
438
439 list.render(area, &mut buf, &mut state);
441
442 assert_buffer_eq(
444 buf,
445 Buffer::with_lines(vec![
446 "┌───┐",
447 "│ │",
448 "└───┘",
449 "┌───┐",
450 "│ │",
451 "└───┘",
452 "┌───┐",
453 "│ │",
454 ]),
455 )
456 }
457
458 #[test]
459 fn truncated_top() {
460 let (area, mut buf, list, mut state) = test_data(8);
462 state.select(Some(2));
463
464 list.render(area, &mut buf, &mut state);
466
467 assert_buffer_eq(
469 buf,
470 Buffer::with_lines(vec![
471 "│ │",
472 "└───┘",
473 "┌───┐",
474 "│ │",
475 "└───┘",
476 "┌───┐",
477 "│ │",
478 "└───┘",
479 ]),
480 )
481 }
482
483 #[test]
484 fn scroll_up() {
485 let (area, mut buf, list, mut state) = test_data(8);
486 state.select(Some(2));
488 list.render(area, &mut buf, &mut state);
489 assert_buffer_eq(
490 buf,
491 Buffer::with_lines(vec![
492 "│ │",
493 "└───┘",
494 "┌───┐",
495 "│ │",
496 "└───┘",
497 "┌───┐",
498 "│ │",
499 "└───┘",
500 ]),
501 );
502
503 let (_, mut buf, list, _) = test_data(8);
505 state.select(Some(1));
506 list.render(area, &mut buf, &mut state);
507 assert_buffer_eq(
508 buf,
509 Buffer::with_lines(vec![
510 "│ │",
511 "└───┘",
512 "┌───┐",
513 "│ │",
514 "└───┘",
515 "┌───┐",
516 "│ │",
517 "└───┘",
518 ]),
519 )
520 }
521
522 fn assert_buffer_eq(actual: Buffer, expected: Buffer) {
523 if actual.area != expected.area {
524 panic!(
525 "buffer areas not equal expected: {:?} actual: {:?}",
526 expected, actual
527 );
528 }
529 let diff = expected.diff(&actual);
530 if !diff.is_empty() {
531 panic!(
532 "buffer contents not equal\nexpected: {:?}\nactual: {:?}",
533 expected, actual,
534 );
535 }
536 assert_eq!(actual, expected, "buffers not equal");
537 }
538}