tui_widget_list/
view.rs

1use ratatui::{
2    buffer::Buffer,
3    layout::{Position, Rect},
4    style::{Style, Styled},
5    widgets::{Block, BlockExt as _, Scrollbar, StatefulWidget, Widget},
6};
7
8use crate::{utils::layout_on_viewport, ListState};
9
10/// A struct representing a list view.
11/// The widget displays a scrollable list of items.
12#[allow(clippy::module_name_repetitions)]
13pub struct ListView<'a, T> {
14    /// The total number of items in the list
15    pub item_count: usize,
16
17    ///  A `ListBuilder<T>` responsible for constructing the items in the list.
18    pub builder: ListBuilder<'a, T>,
19
20    /// Specifies the scroll axis. Either `Vertical` or `Horizontal`.
21    pub scroll_axis: ScrollAxis,
22
23    /// The base style of the list view.
24    pub style: Style,
25
26    /// The base block surrounding the widget list.
27    pub block: Option<Block<'a>>,
28
29    /// The scrollbar.
30    pub scrollbar: Option<Scrollbar<'a>>,
31
32    /// The scroll padding.
33    pub(crate) scroll_padding: u16,
34
35    /// Whether infinite scrolling is enabled or not.
36    /// Disabled by default.
37    pub(crate) infinite_scrolling: bool,
38}
39
40impl<'a, T> ListView<'a, T> {
41    /// Creates a new `ListView` with a builder an item count.
42    #[must_use]
43    pub fn new(builder: ListBuilder<'a, T>, item_count: usize) -> Self {
44        Self {
45            builder,
46            item_count,
47            scroll_axis: ScrollAxis::Vertical,
48            style: Style::default(),
49            block: None,
50            scrollbar: None,
51            scroll_padding: 0,
52            infinite_scrolling: true,
53        }
54    }
55
56    /// Checks whether the widget list is empty.
57    #[must_use]
58    pub fn is_empty(&self) -> bool {
59        self.item_count == 0
60    }
61
62    /// Returns the length of the widget list.
63    #[must_use]
64    pub fn len(&self) -> usize {
65        self.item_count
66    }
67
68    /// Sets the block style that surrounds the whole List.
69    #[must_use]
70    pub fn block(mut self, block: Block<'a>) -> Self {
71        self.block = Some(block);
72        self
73    }
74
75    /// Sets the scrollbar of the List.
76    #[must_use]
77    pub fn scrollbar(mut self, scrollbar: Scrollbar<'a>) -> Self {
78        self.scrollbar = Some(scrollbar);
79        self
80    }
81
82    /// Set the base style of the List.
83    #[must_use]
84    pub fn style<S: Into<Style>>(mut self, style: S) -> Self {
85        self.style = style.into();
86        self
87    }
88
89    /// Set the scroll axis of the list.
90    #[must_use]
91    pub fn scroll_axis(mut self, scroll_axis: ScrollAxis) -> Self {
92        self.scroll_axis = scroll_axis;
93        self
94    }
95
96    /// Set the scroll padding of the list.
97    #[must_use]
98    pub fn scroll_padding(mut self, scroll_padding: u16) -> Self {
99        self.scroll_padding = scroll_padding;
100        self
101    }
102
103    /// Specify whether infinite scrolling should be enabled or not.
104    #[must_use]
105    pub fn infinite_scrolling(mut self, infinite_scrolling: bool) -> Self {
106        self.infinite_scrolling = infinite_scrolling;
107        self
108    }
109}
110
111impl<T> Styled for ListView<'_, T> {
112    type Item = Self;
113
114    fn style(&self) -> Style {
115        self.style
116    }
117
118    fn set_style<S: Into<Style>>(mut self, style: S) -> Self::Item {
119        self.style = style.into();
120        self
121    }
122}
123
124impl<'a, T: Copy + 'a> From<Vec<T>> for ListView<'a, T> {
125    fn from(value: Vec<T>) -> Self {
126        let item_count = value.len();
127        let builder = ListBuilder::new(move |context| (value[context.index], 1));
128
129        ListView::new(builder, item_count)
130    }
131}
132
133/// This structure holds information about the item's position, selection
134/// status, scrolling behavior, and size along the cross axis.
135pub struct ListBuildContext {
136    /// The position of the item in the list.
137    pub index: usize,
138
139    /// A boolean flag indicating whether the item is currently selected.
140    pub is_selected: bool,
141
142    /// Defines the axis along which the list can be scrolled.
143    pub scroll_axis: ScrollAxis,
144
145    /// The size of the item along the cross axis.
146    pub cross_axis_size: u16,
147}
148
149/// A type alias for the closure.
150type ListBuilderClosure<'a, T> = dyn Fn(&ListBuildContext) -> (T, u16) + 'a;
151
152/// The builder for constructing list elements in a `ListView<T>`
153pub struct ListBuilder<'a, T> {
154    closure: Box<ListBuilderClosure<'a, T>>,
155}
156
157impl<'a, T> ListBuilder<'a, T> {
158    /// Creates a new `ListBuilder` taking a closure as a parameter
159    ///
160    /// # Example
161    /// ```
162    /// use ratatui::text::Line;
163    /// use tui_widget_list::ListBuilder;
164    ///
165    /// let builder = ListBuilder::new(|context| {
166    ///     let mut item = Line::from(format!("Item {:0}", context.index));
167    ///
168    ///     // Return the size of the widget along the main axis.
169    ///     let main_axis_size = 1;
170    ///
171    ///     (item, main_axis_size)
172    /// });
173    /// ```
174    pub fn new<F>(closure: F) -> Self
175    where
176        F: Fn(&ListBuildContext) -> (T, u16) + 'a,
177    {
178        ListBuilder {
179            closure: Box::new(closure),
180        }
181    }
182
183    /// Method to call the stored closure.
184    pub(crate) fn call_closure(&self, context: &ListBuildContext) -> (T, u16) {
185        (self.closure)(context)
186    }
187}
188
189/// Represents the scroll axis of a list.
190#[derive(Debug, Default, Clone, Copy)]
191pub enum ScrollAxis {
192    /// Indicates vertical scrolling. This is the default.
193    #[default]
194    Vertical,
195
196    /// Indicates horizontal scrolling.
197    Horizontal,
198}
199
200impl<T: Widget> StatefulWidget for ListView<'_, T> {
201    type State = ListState;
202
203    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
204        state.set_num_elements(self.item_count);
205        state.set_infinite_scrolling(self.infinite_scrolling);
206
207        // Set the base style
208        buf.set_style(area, self.style);
209
210        // Set the base block
211        if let Some(ref block) = self.block {
212            block.render(area, buf);
213        }
214        let inner_area = self.block.inner_if_some(area);
215
216        // List is empty
217        if self.item_count == 0 {
218            return;
219        }
220
221        // Set the dimension along the scroll axis and the cross axis
222        let (main_axis_size, cross_axis_size) = match self.scroll_axis {
223            ScrollAxis::Vertical => (inner_area.height, inner_area.width),
224            ScrollAxis::Horizontal => (inner_area.width, inner_area.height),
225        };
226
227        // The coordinates of the first item with respect to the top left corner
228        let (mut scroll_axis_pos, cross_axis_pos) = match self.scroll_axis {
229            ScrollAxis::Vertical => (inner_area.top(), inner_area.left()),
230            ScrollAxis::Horizontal => (inner_area.left(), inner_area.top()),
231        };
232
233        // Determine which widgets to show on the viewport and how much space they
234        // get assigned to.
235        let mut viewport = layout_on_viewport(
236            state,
237            &self.builder,
238            self.item_count,
239            main_axis_size,
240            cross_axis_size,
241            self.scroll_axis,
242            self.scroll_padding,
243        );
244        state.update_scrollbar_state(
245            &self.builder,
246            self.item_count,
247            main_axis_size,
248            cross_axis_size,
249            self.scroll_axis,
250        );
251
252        let (start, end) = (
253            state.view_state.offset,
254            viewport.len() + state.view_state.offset,
255        );
256        for i in start..end {
257            let Some(element) = viewport.remove(&i) else {
258                break;
259            };
260            let visible_main_axis_size = element
261                .main_axis_size
262                .saturating_sub(element.truncation.value());
263            let area = match self.scroll_axis {
264                ScrollAxis::Vertical => Rect::new(
265                    cross_axis_pos,
266                    scroll_axis_pos,
267                    cross_axis_size,
268                    visible_main_axis_size,
269                ),
270                ScrollAxis::Horizontal => Rect::new(
271                    scroll_axis_pos,
272                    cross_axis_pos,
273                    visible_main_axis_size,
274                    cross_axis_size,
275                ),
276            };
277
278            // Render truncated widgets.
279            if element.truncation.value() > 0 {
280                render_truncated(
281                    element.widget,
282                    area,
283                    buf,
284                    element.main_axis_size,
285                    &element.truncation,
286                    self.style,
287                    self.scroll_axis,
288                );
289            } else {
290                element.widget.render(area, buf);
291            }
292
293            scroll_axis_pos += visible_main_axis_size;
294        }
295
296        // Render the scrollbar
297        if let Some(scrollbar) = self.scrollbar {
298            scrollbar.render(area, buf, &mut state.scrollbar_state);
299        }
300    }
301}
302
303/// Render a truncated widget into a buffer. The method renders the widget fully into
304/// a hidden buffer and moves the visible content into `buf`.
305fn render_truncated<T: Widget>(
306    item: T,
307    available_area: Rect,
308    buf: &mut Buffer,
309    untruncated_size: u16,
310    truncation: &Truncation,
311    base_style: Style,
312    scroll_axis: ScrollAxis,
313) {
314    // Create an hidden buffer for rendering the truncated element
315    let (width, height) = match scroll_axis {
316        ScrollAxis::Vertical => (available_area.width, untruncated_size),
317        ScrollAxis::Horizontal => (untruncated_size, available_area.height),
318    };
319    let mut hidden_buffer = Buffer::empty(Rect {
320        x: available_area.left(),
321        y: available_area.top(),
322        width,
323        height,
324    });
325    hidden_buffer.set_style(hidden_buffer.area, base_style);
326    item.render(hidden_buffer.area, &mut hidden_buffer);
327
328    // Copy the visible part from the hidden buffer to the main buffer
329    match scroll_axis {
330        ScrollAxis::Vertical => {
331            let offset = match truncation {
332                Truncation::Top(value) => *value,
333                _ => 0,
334            };
335            for y in available_area.top()..available_area.bottom() {
336                let y_off = y + offset;
337                for x in available_area.left()..available_area.right() {
338                    if let Some(to) = buf.cell_mut(Position::new(x, y)) {
339                        if let Some(from) = hidden_buffer.cell(Position::new(x, y_off)) {
340                            *to = from.clone();
341                        }
342                    }
343                }
344            }
345        }
346        ScrollAxis::Horizontal => {
347            let offset = match truncation {
348                Truncation::Top(value) => *value,
349                _ => 0,
350            };
351            for x in available_area.left()..available_area.right() {
352                let x_off = x + offset;
353                for y in available_area.top()..available_area.bottom() {
354                    if let Some(to) = buf.cell_mut(Position::new(x, y)) {
355                        if let Some(from) = hidden_buffer.cell(Position::new(x_off, y)) {
356                            *to = from.clone();
357                        }
358                    }
359                }
360            }
361        }
362    };
363}
364
365#[derive(Debug, Clone, Default, PartialEq, PartialOrd, Eq, Ord)]
366pub(crate) enum Truncation {
367    #[default]
368    None,
369    Top(u16),
370    Bot(u16),
371}
372
373impl Truncation {
374    pub(crate) fn value(&self) -> u16 {
375        match self {
376            Self::Top(value) | Self::Bot(value) => *value,
377            Self::None => 0,
378        }
379    }
380}
381
382#[cfg(test)]
383mod test {
384    use crate::ListBuilder;
385    use ratatui::widgets::Block;
386
387    use super::*;
388    use ratatui::widgets::Borders;
389
390    struct TestItem {}
391    impl Widget for TestItem {
392        fn render(self, area: Rect, buf: &mut Buffer)
393        where
394            Self: Sized,
395        {
396            Block::default().borders(Borders::ALL).render(area, buf);
397        }
398    }
399
400    fn test_data(total_height: u16) -> (Rect, Buffer, ListView<'static, TestItem>, ListState) {
401        let area = Rect::new(0, 0, 5, total_height);
402        let list = ListView::new(ListBuilder::new(|_| (TestItem {}, 3)), 3);
403        (area, Buffer::empty(area), list, ListState::default())
404    }
405
406    #[test]
407    fn not_truncated() {
408        // given
409        let (area, mut buf, list, mut state) = test_data(9);
410
411        // when
412        list.render(area, &mut buf, &mut state);
413
414        // then
415        assert_buffer_eq(
416            buf,
417            Buffer::with_lines(vec![
418                "┌───┐",
419                "│   │",
420                "└───┘",
421                "┌───┐",
422                "│   │",
423                "└───┘",
424                "┌───┐",
425                "│   │",
426                "└───┘",
427            ]),
428        )
429    }
430
431    #[test]
432    fn empty_list() {
433        // given
434        let area = Rect::new(0, 0, 5, 2);
435        let mut buf = Buffer::empty(area);
436        let mut state = ListState::default();
437        let builder = ListBuilder::new(|_| (TestItem {}, 0));
438        let list = ListView::new(builder, 0);
439
440        // when
441        list.render(area, &mut buf, &mut state);
442
443        // then
444        assert_buffer_eq(buf, Buffer::with_lines(vec!["     ", "     "]))
445    }
446
447    #[test]
448    fn zero_size() {
449        // given
450        let (area, mut buf, list, mut state) = test_data(0);
451
452        // when
453        list.render(area, &mut buf, &mut state);
454
455        // then
456        assert_buffer_eq(buf, Buffer::empty(area))
457    }
458
459    #[test]
460    fn truncated_bot() {
461        // given
462        let (area, mut buf, list, mut state) = test_data(8);
463
464        // when
465        list.render(area, &mut buf, &mut state);
466
467        // then
468        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 truncated_top() {
485        // given
486        let (area, mut buf, list, mut state) = test_data(8);
487        state.select(Some(2));
488
489        // when
490        list.render(area, &mut buf, &mut state);
491
492        // then
493        assert_buffer_eq(
494            buf,
495            Buffer::with_lines(vec![
496                "│   │",
497                "└───┘",
498                "┌───┐",
499                "│   │",
500                "└───┘",
501                "┌───┐",
502                "│   │",
503                "└───┘",
504            ]),
505        )
506    }
507
508    #[test]
509    fn scroll_up() {
510        let (area, mut buf, list, mut state) = test_data(8);
511        // Select last element and render
512        state.select(Some(2));
513        list.render(area, &mut buf, &mut state);
514        assert_buffer_eq(
515            buf,
516            Buffer::with_lines(vec![
517                "│   │",
518                "└───┘",
519                "┌───┐",
520                "│   │",
521                "└───┘",
522                "┌───┐",
523                "│   │",
524                "└───┘",
525            ]),
526        );
527
528        // Select first element and render
529        let (_, mut buf, list, _) = test_data(8);
530        state.select(Some(1));
531        list.render(area, &mut buf, &mut state);
532        assert_buffer_eq(
533            buf,
534            Buffer::with_lines(vec![
535                "│   │",
536                "└───┘",
537                "┌───┐",
538                "│   │",
539                "└───┘",
540                "┌───┐",
541                "│   │",
542                "└───┘",
543            ]),
544        )
545    }
546
547    fn assert_buffer_eq(actual: Buffer, expected: Buffer) {
548        if actual.area != expected.area {
549            panic!(
550                "buffer areas not equal expected: {:?} actual: {:?}",
551                expected, actual
552            );
553        }
554        let diff = expected.diff(&actual);
555        if !diff.is_empty() {
556            panic!(
557                "buffer contents not equal\nexpected: {:?}\nactual: {:?}",
558                expected, actual,
559            );
560        }
561        assert_eq!(actual, expected, "buffers not equal");
562    }
563}