tui_widget_list/
view.rs

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/// 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 scroll padding.
30    pub(crate) scroll_padding: u16,
31
32    /// Whether infinite scrolling is enabled or not.
33    /// Disabled by default.
34    pub(crate) infinite_scrolling: bool,
35}
36
37impl<'a, T> ListView<'a, T> {
38    /// Creates a new `ListView` with a builder an item count.
39    #[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    /// Checks whether the widget list is empty.
53    #[must_use]
54    pub fn is_empty(&self) -> bool {
55        self.item_count == 0
56    }
57
58    /// Returns the length of the widget list.
59    #[must_use]
60    pub fn len(&self) -> usize {
61        self.item_count
62    }
63
64    /// Sets the block style that surrounds the whole List.
65    #[must_use]
66    pub fn block(mut self, block: Block<'a>) -> Self {
67        self.block = Some(block);
68        self
69    }
70
71    /// Set the base style of the List.
72    #[must_use]
73    pub fn style<S: Into<Style>>(mut self, style: S) -> Self {
74        self.style = style.into();
75        self
76    }
77
78    /// Set the scroll axis of the list.
79    #[must_use]
80    pub fn scroll_axis(mut self, scroll_axis: ScrollAxis) -> Self {
81        self.scroll_axis = scroll_axis;
82        self
83    }
84
85    /// Set the scroll padding of the list.
86    #[must_use]
87    pub fn scroll_padding(mut self, scroll_padding: u16) -> Self {
88        self.scroll_padding = scroll_padding;
89        self
90    }
91
92    /// Specify whether infinite scrolling should be enabled or not.
93    #[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
122/// This structure holds information about the item's position, selection
123/// status, scrolling behavior, and size along the cross axis.
124pub struct ListBuildContext {
125    /// The position of the item in the list.
126    pub index: usize,
127
128    /// A boolean flag indicating whether the item is currently selected.
129    pub is_selected: bool,
130
131    /// Defines the axis along which the list can be scrolled.
132    pub scroll_axis: ScrollAxis,
133
134    /// The size of the item along the cross axis.
135    pub cross_axis_size: u16,
136}
137
138/// A type alias for the closure.
139type ListBuilderClosure<'a, T> = dyn Fn(&ListBuildContext) -> (T, u16) + 'a;
140
141/// The builder for constructing list elements in a `ListView<T>`
142pub struct ListBuilder<'a, T> {
143    closure: Box<ListBuilderClosure<'a, T>>,
144}
145
146impl<'a, T> ListBuilder<'a, T> {
147    /// Creates a new `ListBuilder` taking a closure as a parameter
148    ///
149    /// # Example
150    /// ```
151    /// use ratatui::text::Line;
152    /// use tui_widget_list::ListBuilder;
153    ///
154    /// let builder = ListBuilder::new(|context| {
155    ///     let mut item = Line::from(format!("Item {:0}", context.index));
156    ///
157    ///     // Return the size of the widget along the main axis.
158    ///     let main_axis_size = 1;
159    ///
160    ///     (item, main_axis_size)
161    /// });
162    /// ```
163    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    /// Method to call the stored closure.
173    pub(crate) fn call_closure(&self, context: &ListBuildContext) -> (T, u16) {
174        (self.closure)(context)
175    }
176}
177
178/// Represents the scroll axis of a list.
179#[derive(Debug, Default, Clone, Copy)]
180pub enum ScrollAxis {
181    /// Indicates vertical scrolling. This is the default.
182    #[default]
183    Vertical,
184
185    /// Indicates horizontal scrolling.
186    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        // Set the base style
197        buf.set_style(area, self.style);
198
199        // Set the base block
200        self.block.render(area, buf);
201        let area = self.block.inner_if_some(area);
202
203        // List is empty
204        if self.item_count == 0 {
205            return;
206        }
207
208        // Set the dimension along the scroll axis and the cross axis
209        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        // The coordinates of the first item with respect to the top left corner
215        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        // Determine which widgets to show on the viewport and how much space they
221        // get assigned to.
222        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            // Render truncated widgets.
259            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
278/// Render a truncated widget into a buffer. The method renders the widget fully into
279/// a hidden buffer and moves the visible content into `buf`.
280fn 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    // Create an hidden buffer for rendering the truncated element
290    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    // Copy the visible part from the hidden buffer to the main buffer
304    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        // given
384        let (area, mut buf, list, mut state) = test_data(9);
385
386        // when
387        list.render(area, &mut buf, &mut state);
388
389        // then
390        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        // given
409        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        // when
416        list.render(area, &mut buf, &mut state);
417
418        // then
419        assert_buffer_eq(buf, Buffer::with_lines(vec!["     ", "     "]))
420    }
421
422    #[test]
423    fn zero_size() {
424        // given
425        let (area, mut buf, list, mut state) = test_data(0);
426
427        // when
428        list.render(area, &mut buf, &mut state);
429
430        // then
431        assert_buffer_eq(buf, Buffer::empty(area))
432    }
433
434    #[test]
435    fn truncated_bot() {
436        // given
437        let (area, mut buf, list, mut state) = test_data(8);
438
439        // when
440        list.render(area, &mut buf, &mut state);
441
442        // then
443        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        // given
461        let (area, mut buf, list, mut state) = test_data(8);
462        state.select(Some(2));
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 scroll_up() {
485        let (area, mut buf, list, mut state) = test_data(8);
486        // Select last element and render
487        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        // Select first element and render
504        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}