Skip to main content

wisp/view/
list_view.rs

1use crate::view::selection::{SelectionState, scroll_into_view};
2use crate::theme::Theme;
3use crate::view::widgets::{render_vertical_scrollbar, row_area, rows_and_track};
4use crate::view::wrap::{as_u16, fit_line};
5use ratatui::buffer::Buffer;
6use ratatui::layout::Rect;
7use ratatui::style::Style;
8use ratatui::text::Line;
9use ratatui::widgets::{Block, Paragraph, StatefulWidget, Widget};
10use unicode_width::UnicodeWidthStr;
11
12/// Rows drawn against a [`SelectionState`], with the chrome every list pane in
13/// the UI puts around one: an optional border, an optional scrollbar, and a
14/// placeholder for when there is nothing to show.
15///
16/// Only the rows on screen are built, so the pickers that index a whole working
17/// tree cost a screenful of work per frame rather than one row per entry.
18///
19/// Rows are fitted to the columns actually left over here, so callers building
20/// them never work out how much the border, highlight symbol, and scrollbar
21/// take. Rendering records where the rows landed, so a later click can be
22/// hit-tested against the same area they were drawn into.
23pub struct ListView<'a> {
24    rows: Rows<'a>,
25    theme: &'a Theme,
26    empty_message: &'a str,
27    block: Option<Block<'static>>,
28    scrollbar: bool,
29    highlight: Option<Style>,
30    highlight_symbol: Option<&'static str>,
31    highlight_horizontal_padding: u16,
32}
33
34impl<'a> ListView<'a> {
35    /// Rows already in hand, for the lists short enough that building them all
36    /// costs nothing.
37    pub fn new(rows: Vec<Line<'static>>, theme: &'a Theme) -> Self {
38        let len = rows.len();
39        let mut rows = rows;
40        Self::lazy(len, move |index| std::mem::take(&mut rows[index]), theme)
41    }
42
43    /// `len` rows, each built by `row` only if it is drawn.
44    pub fn lazy(len: usize, row: impl FnMut(usize) -> Line<'static> + 'a, theme: &'a Theme) -> Self {
45        Self {
46            rows: Rows { len, build: Box::new(row) },
47            theme,
48            empty_message: "",
49            block: None,
50            scrollbar: false,
51            highlight: None,
52            highlight_symbol: None,
53            highlight_horizontal_padding: 0,
54        }
55    }
56
57    /// Shown in place of the rows when there are none.
58    pub fn empty_message(mut self, message: &'a str) -> Self {
59        self.empty_message = message;
60        self
61    }
62
63    pub fn block(mut self, block: Block<'static>) -> Self {
64        self.block = Some(block);
65        self
66    }
67
68    /// Wraps the list in a titled border, the standard full-pane picker chrome.
69    pub fn bordered(self, title: impl Into<String>) -> Self {
70        let style = Style::new().fg(self.theme.text_primary);
71        self.block(Block::bordered().title(title.into()).style(style))
72    }
73
74    /// The chrome every settings pane shares: an inverted highlight and the
75    /// standard "nothing here" placeholder.
76    pub fn pane(self, empty_message: &'a str) -> Self {
77        let highlight = Style::new().fg(self.theme.background).bg(self.theme.text_primary);
78        self.empty_message(empty_message).highlight_style(highlight)
79    }
80
81    /// Reserves the rightmost column for a scrollbar, so the track never sits on
82    /// top of the rows.
83    pub fn scrollbar(mut self) -> Self {
84        self.scrollbar = true;
85        self
86    }
87
88    pub fn highlight_style(mut self, style: Style) -> Self {
89        self.highlight = Some(style);
90        self
91    }
92
93    pub fn highlight_symbol(mut self, symbol: &'static str) -> Self {
94        self.highlight_symbol = Some(symbol);
95        self
96    }
97
98    pub fn highlight_horizontal_padding(mut self, padding: u16) -> Self {
99        self.highlight_horizontal_padding = padding;
100        self
101    }
102}
103
104impl StatefulWidget for ListView<'_> {
105    type State = SelectionState;
106
107    fn render(self, area: Rect, buf: &mut Buffer, selection: &mut Self::State) {
108        let Self {
109            mut rows,
110            theme,
111            empty_message,
112            block,
113            scrollbar,
114            highlight,
115            highlight_symbol,
116            highlight_horizontal_padding,
117        } = self;
118        let inner = block.as_ref().map_or(area, |block| block.inner(area));
119        if let Some(block) = block {
120            block.render(area, buf);
121        }
122
123        if rows.len == 0 {
124            selection.set_rows_area(Rect::ZERO);
125            Paragraph::new(empty_message).style(Style::new().fg(theme.muted)).render(inner, buf);
126            return;
127        }
128
129        let (rows_area, track_area) = rows_and_track(inner, scrollbar);
130        selection.set_rows_area(rows_area);
131        let height = usize::from(rows_area.height);
132        if height == 0 {
133            return;
134        }
135
136        let selected = selection.selected().map(|selected| selected.min(rows.len - 1));
137        let offset = visible_offset(selection.offset(), selected, rows.len, height);
138        // Clicks are hit-tested against the offset the rows were drawn from, so
139        // the window this frame settled on has to be written back.
140        selection.set_offset(offset);
141
142        let symbol_width = as_u16(highlight_symbol.map_or(0, str::width));
143        let content_width = usize::from(rows_area.width.saturating_sub(symbol_width));
144        let highlight = highlight.unwrap_or_else(|| Style::new().fg(theme.text_primary).bg(theme.sidebar_bg));
145
146        for (drawn, index) in (offset..rows.len.min(offset + height)).enumerate() {
147            let Some(whole) = row_area(rows_area, drawn) else {
148                break;
149            };
150            let content = Rect { x: whole.x + symbol_width, width: whole.width.saturating_sub(symbol_width), ..whole };
151            fit_line(rows.build(index), content_width, Style::default()).render(content, buf);
152            if selected == Some(index) {
153                // Painted over the row rather than patched into its spans, so a
154                // highlight always wins against whatever colours the row uses.
155                let highlight_area = Rect {
156                    x: whole.x.saturating_sub(highlight_horizontal_padding),
157                    width: whole
158                        .width
159                        .saturating_add(track_area.width)
160                        .saturating_add(highlight_horizontal_padding.saturating_mul(2)),
161                    ..whole
162                };
163                buf.set_style(highlight_area, highlight);
164                if let Some(symbol) = highlight_symbol {
165                    Line::raw(symbol).render(Rect { width: symbol_width, ..whole }, buf);
166                }
167            }
168        }
169
170        if scrollbar {
171            render_vertical_scrollbar(track_area, buf, rows.len, offset);
172        }
173    }
174}
175
176/// A list's rows, built by index on demand.
177struct Rows<'a> {
178    len: usize,
179    build: Box<dyn FnMut(usize) -> Line<'static> + 'a>,
180}
181
182impl Rows<'_> {
183    fn build(&mut self, index: usize) -> Line<'static> {
184        (self.build)(index)
185    }
186}
187
188/// The first row to draw: `offset` moved the least it can to keep `selected` on
189/// screen, with a row of context beyond it where the viewport allows.
190///
191/// This is the scrolling [`List`](ratatui::widgets::List) does from its own
192/// `ListState`, reproduced for one-row items because choosing the window up
193/// front is what lets the rest of the rows go unbuilt.
194fn visible_offset(offset: usize, selected: Option<usize>, len: usize, height: usize) -> usize {
195    let last = len.saturating_sub(1);
196    let offset = offset.min(last);
197    let Some(selected) = selected else {
198        return offset;
199    };
200    // The padding is dropped rather than honoured on a viewport too short to
201    // show the selection with a row either side of it.
202    let padding = usize::from(height >= 3);
203    let target = if (selected + padding).min(last) >= offset + height {
204        (selected + padding).min(last)
205    } else if selected.saturating_sub(padding) < offset {
206        selected.saturating_sub(padding)
207    } else {
208        selected
209    };
210    scroll_into_view(offset, target, height)
211}
212
213#[cfg(test)]
214mod tests {
215    use super::{ListView, visible_offset};
216    use crate::view::selection::SelectionState;
217    use crate::theme::Theme;
218    use ratatui::Terminal;
219    use ratatui::backend::TestBackend;
220    use ratatui::text::Line;
221
222    #[test]
223    fn builds_only_the_rows_it_draws() {
224        let theme = Theme::default();
225        let mut selection = SelectionState::new(50_000);
226        selection.select(Some(1_000), 50_000);
227        let mut built: Vec<usize> = Vec::new();
228
229        let mut terminal = Terminal::new(TestBackend::new(8, 3)).unwrap();
230        terminal
231            .draw(|frame| {
232                let rows = |index: usize| {
233                    built.push(index);
234                    Line::raw(index.to_string())
235                };
236                frame.render_stateful_widget(ListView::lazy(50_000, rows, &theme), frame.area(), &mut selection);
237            })
238            .unwrap();
239
240        assert_eq!(built, vec![999, 1_000, 1_001], "only the visible window is formatted");
241    }
242
243    #[test]
244    fn keeps_a_row_of_context_beyond_the_selection() {
245        assert_eq!(visible_offset(0, Some(4), 20, 5), 1, "scrolls one past the selection moving down");
246        assert_eq!(visible_offset(5, Some(5), 20, 5), 4, "scrolls one before the selection moving up");
247        assert_eq!(visible_offset(0, Some(2), 20, 5), 0, "a selection with context either side stays put");
248    }
249
250    #[test]
251    fn drops_the_context_row_when_the_viewport_cannot_hold_it() {
252        assert_eq!(visible_offset(0, Some(1), 20, 2), 0, "the selection is already visible");
253        assert_eq!(visible_offset(0, Some(2), 20, 2), 1, "scrolls only as far as the selection");
254    }
255
256    #[test]
257    fn clamps_to_the_rows_that_exist() {
258        assert_eq!(visible_offset(0, Some(19), 20, 5), 15, "the last row cannot scroll past the end");
259        assert_eq!(visible_offset(30, Some(19), 20, 5), 18, "an offset past the end is pulled back to the rows");
260        assert_eq!(visible_offset(3, None, 20, 5), 3, "an unselected list keeps its offset");
261    }
262}