1use crate::theme::Theme;
2use crate::view::selection::{SelectionState, scroll_into_view};
3use crate::view::widgets::{render_vertical_scrollbar, row_area, rows_and_track};
4use crate::view::wrap::{as_u16, fit_line};
5use clankerdiff_ratatui::theme::SelectionState as ThemeSelectionState;
6use ratatui::buffer::Buffer;
7use ratatui::layout::Rect;
8use ratatui::style::Style;
9use ratatui::text::Line;
10use ratatui::widgets::{Block, Paragraph, StatefulWidget, Widget};
11use unicode_width::UnicodeWidthStr;
12
13pub struct ListView<'a> {
25 rows: Rows<'a>,
26 theme: &'a Theme,
27 empty_message: &'a str,
28 block: Option<Block<'static>>,
29 scrollbar: bool,
30 highlight: Option<Style>,
31 highlight_symbol: Option<&'static str>,
32 highlight_horizontal_padding: u16,
33}
34
35impl<'a> ListView<'a> {
36 pub fn new(rows: Vec<Line<'static>>, theme: &'a Theme) -> Self {
39 let len = rows.len();
40 let mut rows = rows;
41 Self::lazy(len, move |index| std::mem::take(&mut rows[index]), theme)
42 }
43
44 pub fn lazy(len: usize, row: impl FnMut(usize) -> Line<'static> + 'a, theme: &'a Theme) -> Self {
46 Self {
47 rows: Rows { len, build: Box::new(row) },
48 theme,
49 empty_message: "",
50 block: None,
51 scrollbar: false,
52 highlight: None,
53 highlight_symbol: None,
54 highlight_horizontal_padding: 0,
55 }
56 }
57
58 pub fn empty_message(mut self, message: &'a str) -> Self {
60 self.empty_message = message;
61 self
62 }
63
64 pub fn block(mut self, block: Block<'static>) -> Self {
65 self.block = Some(block);
66 self
67 }
68
69 pub fn bordered(self, title: impl Into<String>) -> Self {
71 let style = Style::new().fg(self.theme.text_primary);
72 self.block(Block::bordered().title(title.into()).style(style))
73 }
74
75 pub fn pane(self, empty_message: &'a str) -> Self {
76 let highlight = self.theme.selection_style(ThemeSelectionState::Focused);
77 self.empty_message(empty_message).highlight_style(highlight)
78 }
79
80 pub fn scrollbar(mut self) -> Self {
83 self.scrollbar = true;
84 self
85 }
86
87 pub fn highlight_style(mut self, style: Style) -> Self {
88 self.highlight = Some(style);
89 self
90 }
91
92 pub fn highlight_symbol(mut self, symbol: &'static str) -> Self {
93 self.highlight_symbol = Some(symbol);
94 self
95 }
96
97 pub fn highlight_horizontal_padding(mut self, padding: u16) -> Self {
98 self.highlight_horizontal_padding = padding;
99 self
100 }
101}
102
103impl StatefulWidget for ListView<'_> {
104 type State = SelectionState;
105
106 fn render(self, area: Rect, buf: &mut Buffer, selection: &mut Self::State) {
107 let Self {
108 mut rows,
109 theme,
110 empty_message,
111 block,
112 scrollbar,
113 highlight,
114 highlight_symbol,
115 highlight_horizontal_padding,
116 } = self;
117 let inner = block.as_ref().map_or(area, |block| block.inner(area));
118 if let Some(block) = block {
119 block.render(area, buf);
120 }
121
122 if rows.len == 0 {
123 selection.set_rows_area(Rect::ZERO);
124 Paragraph::new(empty_message).style(Style::new().fg(theme.muted)).render(inner, buf);
125 return;
126 }
127
128 let (rows_area, track_area) = rows_and_track(inner, scrollbar);
129 selection.set_rows_area(rows_area);
130 let height = usize::from(rows_area.height);
131 if height == 0 {
132 return;
133 }
134
135 let selected = selection.selected().map(|selected| selected.min(rows.len - 1));
136 let offset = visible_offset(selection.offset(), selected, rows.len, height);
137 selection.set_offset(offset);
140
141 let symbol_width = as_u16(highlight_symbol.map_or(0, str::width));
142 let content_width = usize::from(rows_area.width.saturating_sub(symbol_width));
143 let highlight = highlight.unwrap_or_else(|| theme.selection_style(ThemeSelectionState::Selected));
144
145 for (drawn, index) in (offset..rows.len.min(offset + height)).enumerate() {
146 let Some(whole) = row_area(rows_area, drawn) else {
147 break;
148 };
149 let content = Rect { x: whole.x + symbol_width, width: whole.width.saturating_sub(symbol_width), ..whole };
150 fit_line(rows.build(index), content_width, Style::default()).render(content, buf);
151 if selected == Some(index) {
152 let highlight_area = Rect {
155 x: whole.x.saturating_sub(highlight_horizontal_padding),
156 width: whole
157 .width
158 .saturating_add(track_area.width)
159 .saturating_add(highlight_horizontal_padding.saturating_mul(2)),
160 ..whole
161 };
162 buf.set_style(highlight_area, highlight);
163 if let Some(symbol) = highlight_symbol {
164 Line::raw(symbol).render(Rect { width: symbol_width, ..whole }, buf);
165 }
166 }
167 }
168
169 if scrollbar {
170 render_vertical_scrollbar(track_area, buf, rows.len, offset);
171 }
172 }
173}
174
175struct Rows<'a> {
177 len: usize,
178 build: Box<dyn FnMut(usize) -> Line<'static> + 'a>,
179}
180
181impl Rows<'_> {
182 fn build(&mut self, index: usize) -> Line<'static> {
183 (self.build)(index)
184 }
185}
186
187fn visible_offset(offset: usize, selected: Option<usize>, len: usize, height: usize) -> usize {
194 let last = len.saturating_sub(1);
195 let offset = offset.min(last);
196 let Some(selected) = selected else {
197 return offset;
198 };
199 let padding = usize::from(height >= 3);
202 let target = if (selected + padding).min(last) >= offset + height {
203 (selected + padding).min(last)
204 } else if selected.saturating_sub(padding) < offset {
205 selected.saturating_sub(padding)
206 } else {
207 selected
208 };
209 scroll_into_view(offset, target, height)
210}
211
212#[cfg(test)]
213mod tests {
214 use super::{ListView, visible_offset};
215 use crate::theme::Theme;
216 use crate::view::selection::SelectionState;
217 use ratatui::Terminal;
218 use ratatui::backend::TestBackend;
219 use ratatui::text::Line;
220
221 #[test]
222 fn builds_only_the_rows_it_draws() {
223 let theme = Theme::default();
224 let mut selection = SelectionState::new(50_000);
225 selection.select(Some(1_000), 50_000);
226 let mut built: Vec<usize> = Vec::new();
227
228 let mut terminal = Terminal::new(TestBackend::new(8, 3)).unwrap();
229 terminal
230 .draw(|frame| {
231 let rows = |index: usize| {
232 built.push(index);
233 Line::raw(index.to_string())
234 };
235 frame.render_stateful_widget(ListView::lazy(50_000, rows, &theme), frame.area(), &mut selection);
236 })
237 .unwrap();
238
239 assert_eq!(built, vec![999, 1_000, 1_001], "only the visible window is formatted");
240 }
241
242 #[test]
243 fn keeps_a_row_of_context_beyond_the_selection() {
244 assert_eq!(visible_offset(0, Some(4), 20, 5), 1, "scrolls one past the selection moving down");
245 assert_eq!(visible_offset(5, Some(5), 20, 5), 4, "scrolls one before the selection moving up");
246 assert_eq!(visible_offset(0, Some(2), 20, 5), 0, "a selection with context either side stays put");
247 }
248
249 #[test]
250 fn drops_the_context_row_when_the_viewport_cannot_hold_it() {
251 assert_eq!(visible_offset(0, Some(1), 20, 2), 0, "the selection is already visible");
252 assert_eq!(visible_offset(0, Some(2), 20, 2), 1, "scrolls only as far as the selection");
253 }
254
255 #[test]
256 fn clamps_to_the_rows_that_exist() {
257 assert_eq!(visible_offset(0, Some(19), 20, 5), 15, "the last row cannot scroll past the end");
258 assert_eq!(visible_offset(30, Some(19), 20, 5), 18, "an offset past the end is pulled back to the rows");
259 assert_eq!(visible_offset(3, None, 20, 5), 3, "an unselected list keeps its offset");
260 }
261}