Skip to main content

retroglyph_widgets/widget/
list.rs

1//! [`List`]: a scrollable, single-column list with a [`ListState`]-driven highlighted item.
2use retroglyph_core::{Backend, Color, Rect, Style, Terminal};
3
4use super::StatefulWidget;
5use super::window::visible_window;
6use crate::ListState;
7use crate::Theme;
8use crate::draw::fill_rect;
9use crate::text::truncate as truncate_to_cols;
10
11/// A scrollable, single-column list of plain-text items with a [`ListState`]-driven highlighted
12/// item -- `Table`'s single-column sibling, sharing its windowing and selection story.
13///
14/// One `item` renders per line, top-aligned in the area it's rendered into and clipped to
15/// `area.width()`. `state.offset()` is the index of the first item drawn -- rendering draws
16/// whatever window `offset` names and does not clamp or auto-scroll it, matching
17/// [`Table`](super::Table)'s and [`ListState`]'s existing "only the caller knows the viewport
18/// height" design. Call [`state.ensure_visible(visible_item_count)`](ListState::ensure_visible)
19/// before rendering to keep `state.selected()` on-screen. If `selected()` is `Some` and its item
20/// falls within the visible window, that item is drawn with an inverted highlight background; if
21/// it has scrolled out of view, nothing is highlighted.
22///
23/// `item_style` and `selected_style` each default to the same fixed palette as
24/// [`Table`](super::Table)'s `row_style`/`selected_style` (a dim gray-blue for unselected items,
25/// a bright-white-on-dark-blue highlight for the selected one); set them with
26/// [`List::item_style`]/[`List::selected_style`].
27#[derive(Clone, Copy, Debug)]
28pub struct List<'a> {
29    items: &'a [&'a str],
30    item_style: Style,
31    selected_style: Style,
32}
33
34impl<'a> List<'a> {
35    /// A list of `items` in the default style.
36    #[must_use]
37    pub fn new(items: &'a [&'a str]) -> Self {
38        Self {
39            items,
40            item_style: Style::new().fg(Color::Rgb {
41                r: 170,
42                g: 175,
43                b: 190,
44            }),
45            selected_style: Style::new().fg(Color::BRIGHT_WHITE).bg(Color::Rgb {
46                r: 40,
47                g: 60,
48                b: 90,
49            }),
50        }
51    }
52
53    /// Set the style of unselected items.
54    #[must_use]
55    pub const fn item_style(mut self, style: Style) -> Self {
56        self.item_style = style;
57        self
58    }
59
60    /// Set the style of the selected item, including its background fill.
61    #[must_use]
62    pub const fn selected_style(mut self, style: Style) -> Self {
63        self.selected_style = style;
64        self
65    }
66
67    /// Applies `theme`'s named roles to this list: `item_style` becomes `theme.fg` on
68    /// `theme.panel_bg`, and `selected_style` becomes `theme.bg` on `theme.accent`.
69    ///
70    /// `item_style` sets an explicit background rather than leaving it at [`Style::new()`]'s
71    /// default: an unset background isn't "transparent" once a real backend draws it (a bare
72    /// `Color::Default` cell paints as solid black behind the glyph -- see
73    /// `retroglyph-software`'s `DEFAULT_BG`), so this widget assumes it's drawn on
74    /// `theme.panel_bg`, true when composed with a themed [`super::Panel`]/[`super::Modal`].
75    /// Drawing this list directly on the raw screen background instead needs a manual
76    /// `.item_style(...)` override afterwards.
77    ///
78    /// Call before any manual [`List::item_style`]/[`List::selected_style`] override you want to
79    /// keep.
80    #[must_use]
81    pub fn theme(self, theme: Theme) -> Self {
82        self.theme_on(theme, theme.panel_bg)
83    }
84
85    /// Same as [`List::theme`], but `item_style` is drawn on `bg` instead of `theme.panel_bg` --
86    /// for a list drawn directly on a backdrop other than a themed [`super::Panel`]/
87    /// [`super::Modal`]'s fill. [`List::theme`] is exactly `theme_on(theme, theme.panel_bg)`.
88    #[must_use]
89    pub fn theme_on(mut self, theme: Theme, bg: Color) -> Self {
90        self.item_style = Style::new().fg(theme.fg).bg(bg);
91        self.selected_style = Style::new().fg(theme.bg).bg(theme.accent);
92        self
93    }
94}
95
96impl<B: Backend> StatefulWidget<B> for List<'_> {
97    type State = ListState;
98
99    fn render(self, area: Rect, term: &mut Terminal<B>, state: &mut Self::State) {
100        if area.width() == 0 || area.height() == 0 {
101            return;
102        }
103
104        let visible_items = area.height_usize();
105        let selected = state.selected();
106        for (item_index, &item) in visible_window(self.items, state.offset(), visible_items) {
107            let y = area.top() + (item_index - state.offset()) as u16;
108            let style = if Some(item_index) == selected {
109                fill_rect(
110                    term,
111                    Rect::new(area.left(), y, area.width(), 1),
112                    ' ',
113                    Style::new().bg(self.selected_style.background()),
114                );
115                self.selected_style
116            } else {
117                self.item_style
118            };
119            let text = truncate_to_cols(item, area.width_usize());
120            term.reset_style()
121                .fg(style.foreground())
122                .bg(style.background());
123            term.print(area.left(), y, text);
124        }
125        term.reset_style();
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use retroglyph_core::Headless;
132
133    use super::*;
134
135    #[test]
136    fn list_widget_highlights_the_selected_item() {
137        let area = Rect::new(0, 0, 20, 2);
138        let items = ["Alpha", "Bravo"];
139        let list = List::new(&items);
140
141        let mut term = Terminal::new(Headless::new(20, 2));
142        let mut state = ListState::new();
143        state.select(Some(1));
144        list.render(area, &mut term, &mut state);
145
146        let highlighted_bg = term.grid().get(0, 1).style().background();
147        let plain_bg = term.grid().get(0, 0).style().background();
148        assert_ne!(highlighted_bg, plain_bg);
149    }
150
151    #[test]
152    fn list_widget_highlights_nothing_when_unselected() {
153        let area = Rect::new(0, 0, 20, 2);
154        let items = ["Alpha", "Bravo"];
155        let list = List::new(&items);
156
157        let mut term = Terminal::new(Headless::new(20, 2));
158        let mut state = ListState::new();
159        list.render(area, &mut term, &mut state);
160
161        let row0_bg = term.grid().get(0, 0).style().background();
162        let row1_bg = term.grid().get(0, 1).style().background();
163        assert_eq!(row0_bg, row1_bg);
164    }
165
166    fn items<'a>(names: &[&'a str]) -> Vec<&'a str> {
167        names.to_vec()
168    }
169
170    #[test]
171    fn scroll_offset_renders_the_window_starting_at_offset() {
172        let area = Rect::new(0, 0, 20, 2);
173        let names = items(&["Alpha", "Bravo", "Charlie", "Delta"]);
174        let list = List::new(&names);
175
176        let mut term = Terminal::new(Headless::new(20, 2));
177        let mut state = ListState::new();
178        state.set_offset(2); // window is [Charlie, Delta]
179        list.render(area, &mut term, &mut state);
180
181        assert_eq!(term.grid().get(0, 0).glyph(), 'C');
182        assert_eq!(term.grid().get(0, 1).glyph(), 'D');
183    }
184
185    #[test]
186    fn selection_scrolled_out_of_view_highlights_nothing() {
187        let area = Rect::new(0, 0, 20, 2);
188        let names = items(&["Alpha", "Bravo", "Charlie", "Delta"]);
189        let list = List::new(&names);
190
191        let mut term = Terminal::new(Headless::new(20, 2));
192        let mut state = ListState::new();
193        state.select(Some(0)); // "Alpha"
194        state.set_offset(2); // but the window starts at "Charlie"
195        list.render(area, &mut term, &mut state);
196
197        let row0_bg = term.grid().get(0, 0).style().background();
198        let row1_bg = term.grid().get(0, 1).style().background();
199        assert_eq!(row0_bg, row1_bg); // neither visible row is highlighted
200    }
201
202    #[test]
203    fn item_style_can_be_overridden() {
204        let area = Rect::new(0, 0, 20, 1);
205        let items = ["Alpha"];
206        let custom = Style::new().fg(Color::RED);
207        let list = List::new(&items).item_style(custom);
208
209        let mut term = Terminal::new(Headless::new(20, 1));
210        let mut state = ListState::new();
211        list.render(area, &mut term, &mut state);
212
213        assert_eq!(term.grid().get(0, 0).style().foreground(), Color::RED);
214    }
215
216    #[test]
217    fn selected_style_can_be_overridden() {
218        let area = Rect::new(0, 0, 20, 1);
219        let items = ["Alpha"];
220        let custom = Style::new().fg(Color::GREEN).bg(Color::BLUE);
221        let list = List::new(&items).selected_style(custom);
222
223        let mut term = Terminal::new(Headless::new(20, 1));
224        let mut state = ListState::new();
225        state.select(Some(0));
226        list.render(area, &mut term, &mut state);
227
228        assert_eq!(term.grid().get(0, 0).style().foreground(), Color::GREEN);
229        assert_eq!(term.grid().get(0, 0).style().background(), Color::BLUE);
230    }
231
232    #[test]
233    fn clips_long_items_to_area_width() {
234        let area = Rect::new(0, 0, 5, 1);
235        let items = ["a much longer item than fits"];
236        let list = List::new(&items);
237
238        let mut term = Terminal::new(Headless::new(5, 1));
239        let mut state = ListState::new();
240        list.render(area, &mut term, &mut state);
241
242        assert_eq!(term.grid().get(4, 0).glyph(), 'c'); // "a muc"
243    }
244
245    #[test]
246    fn ensure_visible_before_render_keeps_selection_on_screen() {
247        let area = Rect::new(0, 0, 20, 2); // 2 visible items
248        let names = items(&["Alpha", "Bravo", "Charlie", "Delta"]);
249        let list = List::new(&names);
250
251        let mut term = Terminal::new(Headless::new(20, 2));
252        let mut state = ListState::new();
253        state.select(Some(3)); // "Delta", off the front of the default window
254        state.ensure_visible(2);
255        list.render(area, &mut term, &mut state);
256
257        assert_eq!(term.grid().get(0, 1).glyph(), 'D');
258        let highlighted_bg = term.grid().get(0, 1).style().background();
259        let plain_bg = term.grid().get(0, 0).style().background();
260        assert_ne!(highlighted_bg, plain_bg);
261    }
262
263    #[test]
264    fn zero_height_is_a_no_op() {
265        let area = Rect::new(0, 0, 20, 0);
266        let items = ["Alpha"];
267        let list = List::new(&items);
268
269        let mut term = Terminal::new(Headless::new(20, 1));
270        let mut state = ListState::new();
271        list.render(area, &mut term, &mut state);
272
273        assert_eq!(term.grid().get(0, 0).glyph(), ' ');
274    }
275
276    #[test]
277    fn theme_maps_named_roles_onto_item_and_selected_styles() {
278        let area = Rect::new(0, 0, 20, 2);
279        let items = ["Alpha", "Bravo"];
280        let list = List::new(&items).theme(Theme::DARK);
281
282        let mut term = Terminal::new(Headless::new(20, 2));
283        let mut state = ListState::new();
284        state.select(Some(1));
285        list.render(area, &mut term, &mut state);
286
287        assert_eq!(term.grid().get(0, 0).style().foreground(), Theme::DARK.fg);
288        assert_eq!(
289            term.grid().get(0, 0).style().background(),
290            Theme::DARK.panel_bg
291        );
292        assert_eq!(term.grid().get(0, 1).style().foreground(), Theme::DARK.bg);
293        assert_eq!(
294            term.grid().get(0, 1).style().background(),
295            Theme::DARK.accent
296        );
297    }
298
299    #[test]
300    fn theme_on_uses_the_given_backdrop_instead_of_panel_bg() {
301        let area = Rect::new(0, 0, 20, 1);
302        let items = ["Alpha"];
303        let list = List::new(&items).theme_on(Theme::DARK, Color::Default);
304
305        let mut term = Terminal::new(Headless::new(20, 1));
306        let mut state = ListState::new();
307        list.render(area, &mut term, &mut state);
308
309        assert_eq!(term.grid().get(0, 0).style().foreground(), Theme::DARK.fg);
310        assert_eq!(term.grid().get(0, 0).style().background(), Color::Default);
311    }
312}