Skip to main content

tui_widget_list/
view.rs

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/// A struct representing a list view.
19/// The widget displays a scrollable list of items.
20#[allow(clippy::module_name_repetitions)]
21pub struct ListView<'a, T> {
22    /// The total number of items in the list
23    pub item_count: usize,
24
25    ///  A `ListBuilder<T>` responsible for constructing the items in the list.
26    pub builder: ListBuilder<'a, T>,
27
28    /// Specifies the scroll axis. Either `Vertical` or `Horizontal`.
29    pub scroll_axis: ScrollAxis,
30
31    /// Specifies the scroll direction. Either `Forward` or `Backward`.
32    pub scroll_direction: ScrollDirection,
33
34    /// The base style of the list view.
35    pub style: Style,
36
37    /// The base block surrounding the widget list.
38    pub block: Option<Block<'a>>,
39
40    /// The scrollbar.
41    pub scrollbar: Option<Scrollbar<'a>>,
42
43    /// The scroll padding.
44    pub(crate) scroll_padding: u16,
45
46    /// Whether infinite scrolling is enabled or not.
47    /// Disabled by default.
48    pub(crate) infinite_scrolling: bool,
49}
50
51impl<'a, T> ListView<'a, T> {
52    /// Creates a new `ListView` with a builder an item count.
53    #[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    /// Checks whether the widget list is empty.
69    #[must_use]
70    pub fn is_empty(&self) -> bool {
71        self.item_count == 0
72    }
73
74    /// Returns the length of the widget list.
75    #[must_use]
76    pub fn len(&self) -> usize {
77        self.item_count
78    }
79
80    /// Sets the block style that surrounds the whole List.
81    #[must_use]
82    pub fn block(mut self, block: Block<'a>) -> Self {
83        self.block = Some(block);
84        self
85    }
86
87    /// Sets the scrollbar of the List.
88    #[must_use]
89    pub fn scrollbar(mut self, scrollbar: Scrollbar<'a>) -> Self {
90        self.scrollbar = Some(scrollbar);
91        self
92    }
93
94    /// Set the base style of the List.
95    #[must_use]
96    pub fn style<S: Into<Style>>(mut self, style: S) -> Self {
97        self.style = style.into();
98        self
99    }
100
101    /// Set the scroll axis of the list.
102    #[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    /// Set the scroll padding of the list.
115    #[must_use]
116    pub fn scroll_padding(mut self, scroll_padding: u16) -> Self {
117        self.scroll_padding = scroll_padding;
118        self
119    }
120
121    /// Specify whether infinite scrolling should be enabled or not.
122    #[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
151/// This structure holds information about the item's position, selection
152/// status, scrolling behavior, and size along the cross axis.
153pub struct ListBuildContext {
154    /// The position of the item in the list.
155    pub index: usize,
156
157    /// A boolean flag indicating whether the item is currently selected.
158    pub is_selected: bool,
159
160    /// Defines the axis along which the list can be scrolled.
161    pub scroll_axis: ScrollAxis,
162
163    /// The size of the item along the cross axis.
164    pub cross_axis_size: u16,
165}
166
167/// A type alias for the closure.
168type ListBuilderClosure<'a, T> = dyn Fn(&ListBuildContext) -> (T, u16) + 'a;
169
170/// The builder for constructing list elements in a `ListView<T>`
171pub struct ListBuilder<'a, T> {
172    closure: Box<ListBuilderClosure<'a, T>>,
173}
174
175impl<'a, T> ListBuilder<'a, T> {
176    /// Creates a new `ListBuilder` taking a closure as a parameter
177    ///
178    /// # Example
179    /// ```
180    /// use ratatui::text::Line;
181    /// use tui_widget_list::ListBuilder;
182    ///
183    /// let builder = ListBuilder::new(|context| {
184    ///     let mut item = Line::from(format!("Item {:0}", context.index));
185    ///
186    ///     // Return the size of the widget along the main axis.
187    ///     let main_axis_size = 1;
188    ///
189    ///     (item, main_axis_size)
190    /// });
191    /// ```
192    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    /// Method to call the stored closure.
202    pub(crate) fn call_closure(&self, context: &ListBuildContext) -> (T, u16) {
203        (self.closure)(context)
204    }
205}
206
207/// Represents the scroll axis of a list.
208#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
209pub enum ScrollAxis {
210    /// Indicates vertical scrolling. This is the default.
211    #[default]
212    Vertical,
213
214    /// Indicates horizontal scrolling.
215    Horizontal,
216}
217
218impl ScrollAxis {
219    /// Returns `(main_axis_size, cross_axis_size)` for the given area.
220    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    /// Returns `(scroll_axis_pos, cross_axis_pos)` for the given area.
228    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    /// Builds a `Rect` from axis-agnostic positions and sizes.
236    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/// Represents the scroll direction of a list.
261#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
262pub enum ScrollDirection {
263    /// Indicates forward scrolling (top to bottom or left to right). This is the default.
264    #[default]
265    Forward,
266
267    /// Indicates backward scrolling (bottom to top or right to left).
268    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        // Render base style and optional surrounding block
276        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        // Store layout information for post-render queries (e.g. hit testing)
283        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        // Resolve which items are visible and how they fit on the viewport
294        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        // Backward direction: align items to the end of the axis
314        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        // Render each visible item and cache sizes for hit testing
323        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            // If the selected item overflows the viewport, apply item_scroll as truncation
335            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
406/// Renders a widget into `buf`, clipping it if partially visible.
407fn 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    // Copy the visible part from the hidden buffer to the main buffer
435    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        // given
515        let (area, mut buf, list, mut state) = test_data(9);
516
517        // when
518        list.render(area, &mut buf, &mut state);
519
520        // then
521        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        // given
540        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        // when
547        list.render(area, &mut buf, &mut state);
548
549        // then
550        assert_buffer_eq(buf, Buffer::with_lines(vec!["     ", "     "]))
551    }
552
553    #[test]
554    fn zero_size() {
555        // given
556        let (area, mut buf, list, mut state) = test_data(0);
557
558        // when
559        list.render(area, &mut buf, &mut state);
560
561        // then
562        assert_buffer_eq(buf, Buffer::empty(area))
563    }
564
565    #[test]
566    fn truncated_bot() {
567        // given
568        let (area, mut buf, list, mut state) = test_data(8);
569
570        // when
571        list.render(area, &mut buf, &mut state);
572
573        // then
574        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        // given
592        let (area, mut buf, list, mut state) = test_data(8);
593        state.select(Some(2));
594
595        // when
596        list.render(area, &mut buf, &mut state);
597
598        // then
599        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        // Select last element and render
618        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        // Select first element and render
635        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        // Render: shows top 7 rows
665        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        // next() scrolls within and content_offset becomes 1
681        state.next();
682        assert_eq!(state.selected, Some(0));
683        assert_eq!(state.item_scroll, 1);
684
685        // Render at offset 1: shows rows 1-7
686        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        // next at max offset jumps to item 1
707        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}