1use std::collections::HashMap;
2
3use ratatui_core::{
4 buffer::Buffer,
5 layout::{Position, Rect},
6 style::{Style, Styled},
7 widgets::{StatefulWidget, Widget},
8};
9use ratatui_widgets::block::Block;
10use ratatui_widgets::block::BlockExt;
11use ratatui_widgets::scrollbar::Scrollbar;
12
13use crate::{
14 utils::{compute_viewport_layout, ViewportElement},
15 ListState,
16};
17
18#[allow(clippy::module_name_repetitions)]
21pub struct ListView<'a, T> {
22 pub item_count: usize,
24
25 pub builder: ListBuilder<'a, T>,
27
28 pub scroll_axis: ScrollAxis,
30
31 pub scroll_direction: ScrollDirection,
33
34 pub style: Style,
36
37 pub block: Option<Block<'a>>,
39
40 pub scrollbar: Option<Scrollbar<'a>>,
42
43 pub(crate) scroll_padding: u16,
45
46 pub(crate) infinite_scrolling: bool,
49}
50
51impl<'a, T> ListView<'a, T> {
52 #[must_use]
54 pub fn new(builder: ListBuilder<'a, T>, item_count: usize) -> Self {
55 Self {
56 builder,
57 item_count,
58 scroll_axis: ScrollAxis::Vertical,
59 scroll_direction: ScrollDirection::Forward,
60 style: Style::default(),
61 block: None,
62 scrollbar: None,
63 scroll_padding: 0,
64 infinite_scrolling: true,
65 }
66 }
67
68 #[must_use]
70 pub fn is_empty(&self) -> bool {
71 self.item_count == 0
72 }
73
74 #[must_use]
76 pub fn len(&self) -> usize {
77 self.item_count
78 }
79
80 #[must_use]
82 pub fn block(mut self, block: Block<'a>) -> Self {
83 self.block = Some(block);
84 self
85 }
86
87 #[must_use]
89 pub fn scrollbar(mut self, scrollbar: Scrollbar<'a>) -> Self {
90 self.scrollbar = Some(scrollbar);
91 self
92 }
93
94 #[must_use]
96 pub fn style<S: Into<Style>>(mut self, style: S) -> Self {
97 self.style = style.into();
98 self
99 }
100
101 #[must_use]
103 pub fn scroll_axis(mut self, scroll_axis: ScrollAxis) -> Self {
104 self.scroll_axis = scroll_axis;
105 self
106 }
107
108 #[must_use]
109 pub fn scroll_direction(mut self, scroll_direction: ScrollDirection) -> Self {
110 self.scroll_direction = scroll_direction;
111 self
112 }
113
114 #[must_use]
116 pub fn scroll_padding(mut self, scroll_padding: u16) -> Self {
117 self.scroll_padding = scroll_padding;
118 self
119 }
120
121 #[must_use]
123 pub fn infinite_scrolling(mut self, infinite_scrolling: bool) -> Self {
124 self.infinite_scrolling = infinite_scrolling;
125 self
126 }
127}
128
129impl<T> Styled for ListView<'_, T> {
130 type Item = Self;
131
132 fn style(&self) -> Style {
133 self.style
134 }
135
136 fn set_style<S: Into<Style>>(mut self, style: S) -> Self::Item {
137 self.style = style.into();
138 self
139 }
140}
141
142impl<'a, T: Copy + 'a> From<Vec<T>> for ListView<'a, T> {
143 fn from(value: Vec<T>) -> Self {
144 let item_count = value.len();
145 let builder = ListBuilder::new(move |context| (value[context.index], 1));
146
147 ListView::new(builder, item_count)
148 }
149}
150
151pub struct ListBuildContext {
154 pub index: usize,
156
157 pub is_selected: bool,
159
160 pub scroll_axis: ScrollAxis,
162
163 pub cross_axis_size: u16,
165}
166
167type ListBuilderClosure<'a, T> = dyn Fn(&ListBuildContext) -> (T, u16) + 'a;
169
170pub struct ListBuilder<'a, T> {
172 closure: Box<ListBuilderClosure<'a, T>>,
173}
174
175impl<'a, T> ListBuilder<'a, T> {
176 pub fn new<F>(closure: F) -> Self
193 where
194 F: Fn(&ListBuildContext) -> (T, u16) + 'a,
195 {
196 ListBuilder {
197 closure: Box::new(closure),
198 }
199 }
200
201 pub(crate) fn call_closure(&self, context: &ListBuildContext) -> (T, u16) {
203 (self.closure)(context)
204 }
205}
206
207#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
209pub enum ScrollAxis {
210 #[default]
212 Vertical,
213
214 Horizontal,
216}
217
218impl ScrollAxis {
219 pub(crate) fn sizes(self, area: Rect) -> (u16, u16) {
221 match self {
222 Self::Vertical => (area.height, area.width),
223 Self::Horizontal => (area.width, area.height),
224 }
225 }
226
227 pub(crate) fn origin(self, area: Rect) -> (u16, u16) {
229 match self {
230 Self::Vertical => (area.top(), area.left()),
231 Self::Horizontal => (area.left(), area.top()),
232 }
233 }
234
235 pub(crate) fn rect(
237 self,
238 scroll_axis_pos: u16,
239 cross_axis_pos: u16,
240 main_axis_size: u16,
241 cross_axis_size: u16,
242 ) -> Rect {
243 match self {
244 Self::Vertical => Rect::new(
245 cross_axis_pos,
246 scroll_axis_pos,
247 cross_axis_size,
248 main_axis_size,
249 ),
250 Self::Horizontal => Rect::new(
251 scroll_axis_pos,
252 cross_axis_pos,
253 main_axis_size,
254 cross_axis_size,
255 ),
256 }
257 }
258}
259
260#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
262pub enum ScrollDirection {
263 #[default]
265 Forward,
266
267 Backward,
269}
270
271impl<T: Widget> StatefulWidget for ListView<'_, T> {
272 type State = ListState;
273
274 fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
275 buf.set_style(area, self.style);
277 if let Some(ref block) = self.block {
278 block.render(area, buf);
279 }
280 let inner_area = self.block.inner_if_some(area);
281
282 state.set_num_elements(self.item_count);
284 state.set_infinite_scrolling(self.infinite_scrolling);
285 state.set_inner_area(inner_area);
286 state.set_scroll_axis(self.scroll_axis);
287 state.set_scroll_direction(self.scroll_direction);
288
289 if self.item_count == 0 {
290 return;
291 }
292
293 let (main_axis_size, cross_axis_size) = self.scroll_axis.sizes(inner_area);
295 state.set_last_main_axis_size(main_axis_size);
296 let (mut scroll_axis_pos, cross_axis_pos) = self.scroll_axis.origin(inner_area);
297
298 let mut viewport = resolve_viewport(
299 state,
300 &self.builder,
301 self.item_count,
302 main_axis_size,
303 cross_axis_size,
304 self.scroll_axis,
305 self.scroll_padding,
306 );
307
308 let (start, end) = (
309 state.view_state.offset,
310 viewport.len() + state.view_state.offset,
311 );
312
313 if self.scroll_direction == ScrollDirection::Backward {
315 let total_visible: u16 = (start..end)
316 .filter_map(|i| viewport.get(&i))
317 .map(|e| e.visible_size())
318 .sum();
319 scroll_axis_pos += main_axis_size.saturating_sub(total_visible);
320 }
321
322 let mut cached_sizes: HashMap<usize, u16> = HashMap::new();
324 let mut cached_total_sizes: HashMap<usize, u16> = HashMap::new();
325 let viewport_end = scroll_axis_pos + main_axis_size;
326
327 for i in start..end {
328 let Some(element) = viewport.remove(&i) else {
329 break;
330 };
331
332 cached_total_sizes.insert(i, element.main_axis_size);
333
334 let truncation = if Some(i) == state.selected
336 && state.item_scroll() > 0
337 && element.main_axis_size > main_axis_size
338 {
339 Truncation::Top(state.item_scroll())
340 } else {
341 element.truncation
342 };
343
344 let visible_size = element
345 .main_axis_size
346 .saturating_sub(truncation.value())
347 .min(viewport_end.saturating_sub(scroll_axis_pos));
348
349 cached_sizes.insert(i, visible_size);
350
351 render_clipped(
352 element.widget,
353 self.scroll_axis.rect(
354 scroll_axis_pos,
355 cross_axis_pos,
356 visible_size,
357 cross_axis_size,
358 ),
359 buf,
360 element.main_axis_size,
361 &truncation,
362 self.style,
363 self.scroll_axis,
364 );
365
366 scroll_axis_pos += visible_size;
367 }
368
369 state.set_visible_main_axis_sizes(cached_sizes);
370 state.set_total_main_axis_sizes(cached_total_sizes);
371
372 if let Some(scrollbar) = self.scrollbar {
373 scrollbar.render(area, buf, &mut state.scrollbar_state);
374 }
375 }
376}
377
378fn resolve_viewport<T>(
379 state: &mut ListState,
380 builder: &ListBuilder<T>,
381 item_count: usize,
382 main_axis_size: u16,
383 cross_axis_size: u16,
384 scroll_axis: ScrollAxis,
385 scroll_padding: u16,
386) -> HashMap<usize, ViewportElement<T>> {
387 let viewport = compute_viewport_layout(
388 state,
389 builder,
390 item_count,
391 main_axis_size,
392 cross_axis_size,
393 scroll_axis,
394 scroll_padding,
395 );
396 state.update_scrollbar_state(
397 builder,
398 item_count,
399 main_axis_size,
400 cross_axis_size,
401 scroll_axis,
402 );
403 viewport
404}
405
406fn render_clipped<T: Widget>(
408 item: T,
409 available_area: Rect,
410 buf: &mut Buffer,
411 untruncated_size: u16,
412 truncation: &Truncation,
413 base_style: Style,
414 scroll_axis: ScrollAxis,
415) {
416 if truncation.value() == 0 {
417 item.render(available_area, buf);
418 return;
419 }
420
421 let (width, height) = match scroll_axis {
422 ScrollAxis::Vertical => (available_area.width, untruncated_size),
423 ScrollAxis::Horizontal => (untruncated_size, available_area.height),
424 };
425 let mut hidden_buffer = Buffer::empty(Rect {
426 x: available_area.left(),
427 y: available_area.top(),
428 width,
429 height,
430 });
431 hidden_buffer.set_style(hidden_buffer.area, base_style);
432 item.render(hidden_buffer.area, &mut hidden_buffer);
433
434 match scroll_axis {
436 ScrollAxis::Vertical => {
437 let offset = match truncation {
438 Truncation::Top(value) => *value,
439 _ => 0,
440 };
441 for y in available_area.top()..available_area.bottom() {
442 let y_off = y + offset;
443 for x in available_area.left()..available_area.right() {
444 if let Some(to) = buf.cell_mut(Position::new(x, y)) {
445 if let Some(from) = hidden_buffer.cell(Position::new(x, y_off)) {
446 *to = from.clone();
447 }
448 }
449 }
450 }
451 }
452 ScrollAxis::Horizontal => {
453 let offset = match truncation {
454 Truncation::Top(value) => *value,
455 _ => 0,
456 };
457 for x in available_area.left()..available_area.right() {
458 let x_off = x + offset;
459 for y in available_area.top()..available_area.bottom() {
460 if let Some(to) = buf.cell_mut(Position::new(x, y)) {
461 if let Some(from) = hidden_buffer.cell(Position::new(x_off, y)) {
462 *to = from.clone();
463 }
464 }
465 }
466 }
467 }
468 }
469}
470
471#[derive(Debug, Clone, Default, PartialEq, PartialOrd, Eq, Ord)]
472pub(crate) enum Truncation {
473 #[default]
474 None,
475 Top(u16),
476 Bot(u16),
477}
478
479impl Truncation {
480 pub(crate) fn value(&self) -> u16 {
481 match self {
482 Self::Top(value) | Self::Bot(value) => *value,
483 Self::None => 0,
484 }
485 }
486}
487
488#[cfg(test)]
489mod test {
490 use crate::ListBuilder;
491 use ratatui::widgets::Block;
492
493 use super::*;
494 use ratatui::widgets::Borders;
495
496 struct TestItem {}
497 impl Widget for TestItem {
498 fn render(self, area: Rect, buf: &mut Buffer)
499 where
500 Self: Sized,
501 {
502 Block::default().borders(Borders::ALL).render(area, buf);
503 }
504 }
505
506 fn test_data(total_height: u16) -> (Rect, Buffer, ListView<'static, TestItem>, ListState) {
507 let area = Rect::new(0, 0, 5, total_height);
508 let list = ListView::new(ListBuilder::new(|_| (TestItem {}, 3)), 3);
509 (area, Buffer::empty(area), list, ListState::default())
510 }
511
512 #[test]
513 fn not_truncated() {
514 let (area, mut buf, list, mut state) = test_data(9);
516
517 list.render(area, &mut buf, &mut state);
519
520 assert_buffer_eq(
522 buf,
523 Buffer::with_lines(vec![
524 "┌───┐",
525 "│ │",
526 "└───┘",
527 "┌───┐",
528 "│ │",
529 "└───┘",
530 "┌───┐",
531 "│ │",
532 "└───┘",
533 ]),
534 )
535 }
536
537 #[test]
538 fn empty_list() {
539 let area = Rect::new(0, 0, 5, 2);
541 let mut buf = Buffer::empty(area);
542 let mut state = ListState::default();
543 let builder = ListBuilder::new(|_| (TestItem {}, 0));
544 let list = ListView::new(builder, 0);
545
546 list.render(area, &mut buf, &mut state);
548
549 assert_buffer_eq(buf, Buffer::with_lines(vec![" ", " "]))
551 }
552
553 #[test]
554 fn zero_size() {
555 let (area, mut buf, list, mut state) = test_data(0);
557
558 list.render(area, &mut buf, &mut state);
560
561 assert_buffer_eq(buf, Buffer::empty(area))
563 }
564
565 #[test]
566 fn truncated_bot() {
567 let (area, mut buf, list, mut state) = test_data(8);
569
570 list.render(area, &mut buf, &mut state);
572
573 assert_buffer_eq(
575 buf,
576 Buffer::with_lines(vec![
577 "┌───┐",
578 "│ │",
579 "└───┘",
580 "┌───┐",
581 "│ │",
582 "└───┘",
583 "┌───┐",
584 "│ │",
585 ]),
586 )
587 }
588
589 #[test]
590 fn truncated_top() {
591 let (area, mut buf, list, mut state) = test_data(8);
593 state.select(Some(2));
594
595 list.render(area, &mut buf, &mut state);
597
598 assert_buffer_eq(
600 buf,
601 Buffer::with_lines(vec![
602 "│ │",
603 "└───┘",
604 "┌───┐",
605 "│ │",
606 "└───┘",
607 "┌───┐",
608 "│ │",
609 "└───┘",
610 ]),
611 )
612 }
613
614 #[test]
615 fn scroll_up() {
616 let (area, mut buf, list, mut state) = test_data(8);
617 state.select(Some(2));
619 list.render(area, &mut buf, &mut state);
620 assert_buffer_eq(
621 buf,
622 Buffer::with_lines(vec![
623 "│ │",
624 "└───┘",
625 "┌───┐",
626 "│ │",
627 "└───┘",
628 "┌───┐",
629 "│ │",
630 "└───┘",
631 ]),
632 );
633
634 let (_, mut buf, list, _) = test_data(8);
636 state.select(Some(1));
637 list.render(area, &mut buf, &mut state);
638 assert_buffer_eq(
639 buf,
640 Buffer::with_lines(vec![
641 "│ │",
642 "└───┘",
643 "┌───┐",
644 "│ │",
645 "└───┘",
646 "┌───┐",
647 "│ │",
648 "└───┘",
649 ]),
650 )
651 }
652
653 #[test]
654 fn scroll_within_large_item() {
655 let area = Rect::new(0, 0, 5, 7);
656 let builder = ListBuilder::new(|ctx| {
657 let size = if ctx.index == 0 { 8 } else { 3 };
658 (TestItem {}, size)
659 });
660 let list = ListView::new(builder, 2);
661 let mut state = ListState::default();
662 state.select(Some(0));
663
664 let mut buf = Buffer::empty(area);
666 list.render(area, &mut buf, &mut state);
667 assert_buffer_eq(
668 buf,
669 Buffer::with_lines(vec![
670 "┌───┐",
671 "│ │",
672 "│ │",
673 "│ │",
674 "│ │",
675 "│ │",
676 "│ │",
677 ]),
678 );
679
680 state.next();
682 assert_eq!(state.selected, Some(0));
683 assert_eq!(state.item_scroll, 1);
684
685 let mut buf = Buffer::empty(area);
687 let builder = ListBuilder::new(|ctx| {
688 let size = if ctx.index == 0 { 8 } else { 3 };
689 (TestItem {}, size)
690 });
691 let list = ListView::new(builder, 2);
692 list.render(area, &mut buf, &mut state);
693 assert_buffer_eq(
694 buf,
695 Buffer::with_lines(vec![
696 "│ │",
697 "│ │",
698 "│ │",
699 "│ │",
700 "│ │",
701 "│ │",
702 "└───┘",
703 ]),
704 );
705
706 state.next();
708 assert_eq!(state.selected, Some(1));
709 assert_eq!(state.item_scroll, 0);
710 }
711
712 fn assert_buffer_eq(actual: Buffer, expected: Buffer) {
713 if actual.area != expected.area {
714 panic!(
715 "buffer areas not equal expected: {:?} actual: {:?}",
716 expected, actual
717 );
718 }
719 let diff = expected.diff(&actual);
720 if !diff.is_empty() {
721 panic!(
722 "buffer contents not equal\nexpected: {:?}\nactual: {:?}",
723 expected, actual,
724 );
725 }
726 assert_eq!(actual, expected, "buffers not equal");
727 }
728}