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