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(mut self, theme: Theme) -> Self {
82        self.item_style = Style::new().fg(theme.fg).bg(theme.panel_bg);
83        self.selected_style = Style::new().fg(theme.bg).bg(theme.accent);
84        self
85    }
86}
87
88impl<B: Backend> StatefulWidget<B> for List<'_> {
89    type State = ListState;
90
91    fn render(self, area: Rect, term: &mut Terminal<B>, state: &mut Self::State) {
92        if area.width() == 0 || area.height() == 0 {
93            return;
94        }
95
96        let visible_items = area.height_usize();
97        let selected = state.selected();
98        for (item_index, &item) in visible_window(self.items, state.offset(), visible_items) {
99            let y = area.top() + (item_index - state.offset()) as u16;
100            let style = if Some(item_index) == selected {
101                fill_rect(
102                    term,
103                    Rect::new(area.left(), y, area.width(), 1),
104                    ' ',
105                    Style::new().bg(self.selected_style.background()),
106                );
107                self.selected_style
108            } else {
109                self.item_style
110            };
111            let text = truncate_to_cols(item, area.width_usize());
112            term.reset_style()
113                .fg(style.foreground())
114                .bg(style.background());
115            term.print(area.left(), y, &text);
116        }
117        term.reset_style();
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use retroglyph_core::Headless;
124
125    use super::*;
126
127    #[test]
128    fn list_widget_highlights_the_selected_item() {
129        let area = Rect::new(0, 0, 20, 2);
130        let items = ["Alpha", "Bravo"];
131        let list = List::new(&items);
132
133        let mut term = Terminal::new(Headless::new(20, 2));
134        let mut state = ListState::new();
135        state.select(Some(1));
136        list.render(area, &mut term, &mut state);
137
138        let highlighted_bg = term.grid().get(0, 1).style().background();
139        let plain_bg = term.grid().get(0, 0).style().background();
140        assert_ne!(highlighted_bg, plain_bg);
141    }
142
143    #[test]
144    fn list_widget_highlights_nothing_when_unselected() {
145        let area = Rect::new(0, 0, 20, 2);
146        let items = ["Alpha", "Bravo"];
147        let list = List::new(&items);
148
149        let mut term = Terminal::new(Headless::new(20, 2));
150        let mut state = ListState::new();
151        list.render(area, &mut term, &mut state);
152
153        let row0_bg = term.grid().get(0, 0).style().background();
154        let row1_bg = term.grid().get(0, 1).style().background();
155        assert_eq!(row0_bg, row1_bg);
156    }
157
158    fn items<'a>(names: &[&'a str]) -> Vec<&'a str> {
159        names.to_vec()
160    }
161
162    #[test]
163    fn scroll_offset_renders_the_window_starting_at_offset() {
164        let area = Rect::new(0, 0, 20, 2);
165        let names = items(&["Alpha", "Bravo", "Charlie", "Delta"]);
166        let list = List::new(&names);
167
168        let mut term = Terminal::new(Headless::new(20, 2));
169        let mut state = ListState::new();
170        state.set_offset(2); // window is [Charlie, Delta]
171        list.render(area, &mut term, &mut state);
172
173        assert_eq!(term.grid().get(0, 0).glyph(), 'C');
174        assert_eq!(term.grid().get(0, 1).glyph(), 'D');
175    }
176
177    #[test]
178    fn selection_scrolled_out_of_view_highlights_nothing() {
179        let area = Rect::new(0, 0, 20, 2);
180        let names = items(&["Alpha", "Bravo", "Charlie", "Delta"]);
181        let list = List::new(&names);
182
183        let mut term = Terminal::new(Headless::new(20, 2));
184        let mut state = ListState::new();
185        state.select(Some(0)); // "Alpha"
186        state.set_offset(2); // but the window starts at "Charlie"
187        list.render(area, &mut term, &mut state);
188
189        let row0_bg = term.grid().get(0, 0).style().background();
190        let row1_bg = term.grid().get(0, 1).style().background();
191        assert_eq!(row0_bg, row1_bg); // neither visible row is highlighted
192    }
193
194    #[test]
195    fn item_style_can_be_overridden() {
196        let area = Rect::new(0, 0, 20, 1);
197        let items = ["Alpha"];
198        let custom = Style::new().fg(Color::RED);
199        let list = List::new(&items).item_style(custom);
200
201        let mut term = Terminal::new(Headless::new(20, 1));
202        let mut state = ListState::new();
203        list.render(area, &mut term, &mut state);
204
205        assert_eq!(term.grid().get(0, 0).style().foreground(), Color::RED);
206    }
207
208    #[test]
209    fn selected_style_can_be_overridden() {
210        let area = Rect::new(0, 0, 20, 1);
211        let items = ["Alpha"];
212        let custom = Style::new().fg(Color::GREEN).bg(Color::BLUE);
213        let list = List::new(&items).selected_style(custom);
214
215        let mut term = Terminal::new(Headless::new(20, 1));
216        let mut state = ListState::new();
217        state.select(Some(0));
218        list.render(area, &mut term, &mut state);
219
220        assert_eq!(term.grid().get(0, 0).style().foreground(), Color::GREEN);
221        assert_eq!(term.grid().get(0, 0).style().background(), Color::BLUE);
222    }
223
224    #[test]
225    fn clips_long_items_to_area_width() {
226        let area = Rect::new(0, 0, 5, 1);
227        let items = ["a much longer item than fits"];
228        let list = List::new(&items);
229
230        let mut term = Terminal::new(Headless::new(5, 1));
231        let mut state = ListState::new();
232        list.render(area, &mut term, &mut state);
233
234        assert_eq!(term.grid().get(4, 0).glyph(), 'c'); // "a muc"
235    }
236
237    #[test]
238    fn ensure_visible_before_render_keeps_selection_on_screen() {
239        let area = Rect::new(0, 0, 20, 2); // 2 visible items
240        let names = items(&["Alpha", "Bravo", "Charlie", "Delta"]);
241        let list = List::new(&names);
242
243        let mut term = Terminal::new(Headless::new(20, 2));
244        let mut state = ListState::new();
245        state.select(Some(3)); // "Delta", off the front of the default window
246        state.ensure_visible(2);
247        list.render(area, &mut term, &mut state);
248
249        assert_eq!(term.grid().get(0, 1).glyph(), 'D');
250        let highlighted_bg = term.grid().get(0, 1).style().background();
251        let plain_bg = term.grid().get(0, 0).style().background();
252        assert_ne!(highlighted_bg, plain_bg);
253    }
254
255    #[test]
256    fn zero_height_is_a_no_op() {
257        let area = Rect::new(0, 0, 20, 0);
258        let items = ["Alpha"];
259        let list = List::new(&items);
260
261        let mut term = Terminal::new(Headless::new(20, 1));
262        let mut state = ListState::new();
263        list.render(area, &mut term, &mut state);
264
265        assert_eq!(term.grid().get(0, 0).glyph(), ' ');
266    }
267
268    #[test]
269    fn theme_maps_named_roles_onto_item_and_selected_styles() {
270        let area = Rect::new(0, 0, 20, 2);
271        let items = ["Alpha", "Bravo"];
272        let list = List::new(&items).theme(Theme::DARK);
273
274        let mut term = Terminal::new(Headless::new(20, 2));
275        let mut state = ListState::new();
276        state.select(Some(1));
277        list.render(area, &mut term, &mut state);
278
279        assert_eq!(term.grid().get(0, 0).style().foreground(), Theme::DARK.fg);
280        assert_eq!(
281            term.grid().get(0, 0).style().background(),
282            Theme::DARK.panel_bg
283        );
284        assert_eq!(term.grid().get(0, 1).style().foreground(), Theme::DARK.bg);
285        assert_eq!(
286            term.grid().get(0, 1).style().background(),
287            Theme::DARK.accent
288        );
289    }
290}