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