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