1use 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#[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 #[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 #[must_use]
71 pub const fn item_style(mut self, style: Style) -> Self {
72 self.item_style = style;
73 self
74 }
75
76 #[must_use]
78 pub const fn selected_style(mut self, style: Style) -> Self {
79 self.selected_style = style;
80 self
81 }
82
83 #[must_use]
97 pub fn theme(self, theme: Theme) -> Self {
98 self.theme_on(theme, theme.panel_bg)
99 }
100
101 #[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 #[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); 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)); state.set_offset(2); 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); }
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'); }
261
262 #[test]
263 fn ensure_visible_before_render_keeps_selection_on_screen() {
264 let area = Rect::new(0, 0, 20, 2); 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)); 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}