1use retroglyph_core::color::{Color, Style};
3use retroglyph_core::grid::Rect;
4
5use super::window::visible_window;
6use super::{HighlightSpacing, InteractiveWidget, ListDirection, Measure, StatefulWidget};
7use crate::Align;
8use crate::ListState;
9use crate::Response;
10use crate::Sense;
11use crate::Surface;
12use crate::Theme;
13use crate::draw::fill_rect;
14use crate::text::draw_clipped;
15
16#[derive(Clone, Copy, Debug)]
60pub struct List<'a> {
61 items: &'a [&'a str],
62 item_style: Style,
63 selected_style: Style,
64 highlight_symbol: &'a str,
65 highlight_spacing: HighlightSpacing,
66 direction: ListDirection,
67}
68
69impl<'a> List<'a> {
70 #[must_use]
74 pub fn new(items: &'a [&'a str]) -> Self {
75 Self {
76 items,
77 item_style: Style::new(),
78 selected_style: Style::new(),
79 highlight_symbol: "",
80 highlight_spacing: HighlightSpacing::WhenSelected,
81 direction: ListDirection::TopToBottom,
82 }
83 .theme(Theme::DARK)
84 }
85
86 #[must_use]
88 pub const fn item_style(mut self, style: Style) -> Self {
89 self.item_style = style;
90 self
91 }
92
93 #[must_use]
95 pub const fn selected_style(mut self, style: Style) -> Self {
96 self.selected_style = style;
97 self
98 }
99
100 #[must_use]
109 pub const fn highlight_symbol(mut self, symbol: &'a str) -> Self {
110 self.highlight_symbol = symbol;
111 self
112 }
113
114 #[must_use]
117 pub const fn highlight_spacing(mut self, spacing: HighlightSpacing) -> Self {
118 self.highlight_spacing = spacing;
119 self
120 }
121
122 #[must_use]
125 pub const fn direction(mut self, direction: ListDirection) -> Self {
126 self.direction = direction;
127 self
128 }
129
130 #[must_use]
144 pub fn theme(self, theme: Theme) -> Self {
145 self.theme_on(theme, theme.panel_bg)
146 }
147
148 #[must_use]
152 pub fn theme_on(mut self, theme: Theme, bg: Color) -> Self {
153 self.item_style = Style::new().fg(theme.fg).bg(bg);
154 self.selected_style = Style::new().fg(theme.bg).bg(theme.accent);
155 self
156 }
157}
158
159impl List<'_> {
160 fn marker_width(&self, has_selection: bool) -> u16 {
164 let symbol_width = retroglyph_core::text::width(self.highlight_symbol);
165 if symbol_width == 0 {
166 return 0;
167 }
168 match self.highlight_spacing {
169 HighlightSpacing::Always => symbol_width,
170 HighlightSpacing::WhenSelected => {
171 if has_selection {
172 symbol_width
173 } else {
174 0
175 }
176 }
177 HighlightSpacing::Never => 0,
178 }
179 }
180
181 fn draw(&self, surface: &mut Surface<'_>, state: &ListState) {
184 let (width, height) = (surface.width(), surface.height());
185 if width == 0 || height == 0 {
186 return;
187 }
188
189 let visible_items = usize::from(height);
190 let selected = state.selected();
191 let marker_width = self.marker_width(selected.is_some());
192 let text_width = width.saturating_sub(marker_width);
193 for (item_index, &item) in visible_window(self.items, state.offset(), visible_items) {
194 #[allow(clippy::cast_possible_truncation)]
197 let row_in_window = (item_index - state.offset()) as u16;
198 let y = match self.direction {
199 ListDirection::TopToBottom => row_in_window,
200 ListDirection::BottomToTop => height - 1 - row_in_window,
202 };
203 let is_selected = Some(item_index) == selected;
204 let style = if is_selected {
205 fill_rect(
206 surface,
207 Rect::new(0, y, width, 1),
208 ' ',
209 Style::new().bg(self.selected_style.background()),
210 );
211 self.selected_style
212 } else {
213 self.item_style
214 };
215 if marker_width > 0 {
216 let marker = if is_selected {
217 self.highlight_symbol
218 } else {
219 ""
220 };
221 let _ = draw_clipped(
222 surface,
223 (0, y),
224 marker_width.min(width),
225 marker,
226 Align::Left,
227 style,
228 );
229 }
230 let _ = draw_clipped(
231 surface,
232 (marker_width, y),
233 text_width,
234 item,
235 Align::Left,
236 style,
237 );
238 }
239 }
240
241 fn index_at(
244 &self,
245 area: Rect,
246 state: &ListState,
247 pos: retroglyph_core::grid::Pos,
248 ) -> Option<usize> {
249 let row = pos.y.checked_sub(area.top())?;
250 let row = match self.direction {
251 ListDirection::TopToBottom => row,
252 ListDirection::BottomToTop => area.height().checked_sub(1)?.checked_sub(row)?,
253 };
254 let index = state.offset() + usize::from(row);
255 (index < self.items.len()).then_some(index)
256 }
257}
258
259impl StatefulWidget for List<'_> {
260 type State = ListState;
261
262 fn render(&self, surface: &mut Surface<'_>, state: &mut Self::State) {
263 self.draw(surface, state);
264 }
265}
266
267impl Measure for List<'_> {
268 fn height_for(&self, _width: u16) -> u16 {
270 #[allow(clippy::cast_possible_truncation)]
271 let height = self.items.len().min(usize::from(u16::MAX)) as u16;
272 height
273 }
274}
275
276impl<Id> InteractiveWidget<Id> for List<'_> {
277 type State = ListState;
278
279 fn sense(&self) -> Sense {
283 Sense::click() | Sense::SCROLL | Sense::HOVER
284 }
285
286 fn render(&self, surface: &mut Surface<'_>, state: &mut Self::State, response: Response<Id>) {
287 let area = surface.area();
288
289 if response.clicked()
292 && let Some(pos) = response.pointer_pos()
293 && let Some(index) = self.index_at(area, state, pos)
294 {
295 state.select(Some(index));
296 }
297 let scroll_delta = response.scroll_delta();
298 if scroll_delta != 0 {
299 state.scroll_by(scroll_delta);
300 }
301
302 self.draw(surface, state);
303 }
304}
305
306#[cfg(test)]
307mod tests {
308 use alloc::vec::Vec;
309
310 use retroglyph_core::grid::{Grid, Pos};
311
312 use super::*;
313
314 #[test]
315 fn list_widget_highlights_the_selected_item() {
316 let area = Rect::new(0, 0, 20, 2);
317 let items = ["Alpha", "Bravo"];
318 let list = List::new(&items);
319
320 let mut grid = Grid::new(20, 2);
321 let mut state = ListState::new();
322 state.select(Some(1));
323 StatefulWidget::render(&list, &mut Surface::new(&mut grid, area, 0), &mut state);
324
325 let highlighted_bg = grid[Pos::new(0, 1)].style().background();
326 let plain_bg = grid[Pos::new(0, 0)].style().background();
327 assert_ne!(highlighted_bg, plain_bg);
328 }
329
330 #[test]
331 fn list_widget_highlights_nothing_when_unselected() {
332 let area = Rect::new(0, 0, 20, 2);
333 let items = ["Alpha", "Bravo"];
334 let list = List::new(&items);
335
336 let mut grid = Grid::new(20, 2);
337 let mut state = ListState::new();
338 StatefulWidget::render(&list, &mut Surface::new(&mut grid, area, 0), &mut state);
339
340 let row0_bg = grid[Pos::new(0, 0)].style().background();
341 let row1_bg = grid[Pos::new(0, 1)].style().background();
342 assert_eq!(row0_bg, row1_bg);
343 }
344
345 fn items<'a>(names: &[&'a str]) -> Vec<&'a str> {
346 names.to_vec()
347 }
348
349 #[test]
350 fn scroll_offset_renders_the_window_starting_at_offset() {
351 let area = Rect::new(0, 0, 20, 2);
352 let names = items(&["Alpha", "Bravo", "Charlie", "Delta"]);
353 let list = List::new(&names);
354
355 let mut grid = Grid::new(20, 2);
356 let mut state = ListState::new();
357 state.set_offset(2); StatefulWidget::render(&list, &mut Surface::new(&mut grid, area, 0), &mut state);
359
360 assert_eq!(grid[Pos::new(0, 0)].glyph(), 'C');
361 assert_eq!(grid[Pos::new(0, 1)].glyph(), 'D');
362 }
363
364 #[test]
365 fn selection_scrolled_out_of_view_highlights_nothing() {
366 let area = Rect::new(0, 0, 20, 2);
367 let names = items(&["Alpha", "Bravo", "Charlie", "Delta"]);
368 let list = List::new(&names);
369
370 let mut grid = Grid::new(20, 2);
371 let mut state = ListState::new();
372 state.select(Some(0)); state.set_offset(2); StatefulWidget::render(&list, &mut Surface::new(&mut grid, area, 0), &mut state);
375
376 let row0_bg = grid[Pos::new(0, 0)].style().background();
377 let row1_bg = grid[Pos::new(0, 1)].style().background();
378 assert_eq!(row0_bg, row1_bg); }
380
381 #[test]
382 fn item_style_can_be_overridden() {
383 let area = Rect::new(0, 0, 20, 1);
384 let items = ["Alpha"];
385 let custom = Style::new().fg(Color::RED);
386 let list = List::new(&items).item_style(custom);
387
388 let mut grid = Grid::new(20, 1);
389 let mut state = ListState::new();
390 StatefulWidget::render(&list, &mut Surface::new(&mut grid, area, 0), &mut state);
391
392 assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Color::RED);
393 }
394
395 #[test]
396 fn selected_style_can_be_overridden() {
397 let area = Rect::new(0, 0, 20, 1);
398 let items = ["Alpha"];
399 let custom = Style::new().fg(Color::GREEN).bg(Color::BLUE);
400 let list = List::new(&items).selected_style(custom);
401
402 let mut grid = Grid::new(20, 1);
403 let mut state = ListState::new();
404 state.select(Some(0));
405 StatefulWidget::render(&list, &mut Surface::new(&mut grid, area, 0), &mut state);
406
407 assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Color::GREEN);
408 assert_eq!(grid[Pos::new(0, 0)].style().background(), Color::BLUE);
409 }
410
411 #[test]
412 fn clips_long_items_to_area_width() {
413 let area = Rect::new(0, 0, 5, 1);
414 let items = ["a much longer item than fits"];
415 let list = List::new(&items);
416
417 let mut grid = Grid::new(5, 1);
418 let mut state = ListState::new();
419 StatefulWidget::render(&list, &mut Surface::new(&mut grid, area, 0), &mut state);
420
421 assert_eq!(grid[Pos::new(4, 0)].glyph(), 'c'); }
423
424 #[test]
425 fn ensure_visible_before_render_keeps_selection_on_screen() {
426 let area = Rect::new(0, 0, 20, 2); let names = items(&["Alpha", "Bravo", "Charlie", "Delta"]);
428 let list = List::new(&names);
429
430 let mut grid = Grid::new(20, 2);
431 let mut state = ListState::new();
432 state.select(Some(3)); state.ensure_visible(2);
434 StatefulWidget::render(&list, &mut Surface::new(&mut grid, area, 0), &mut state);
435
436 assert_eq!(grid[Pos::new(0, 1)].glyph(), 'D');
437 let highlighted_bg = grid[Pos::new(0, 1)].style().background();
438 let plain_bg = grid[Pos::new(0, 0)].style().background();
439 assert_ne!(highlighted_bg, plain_bg);
440 }
441
442 #[test]
443 fn height_for_is_the_item_count() {
444 let items = ["Alpha", "Bravo", "Charlie"];
445 assert_eq!(List::new(&items).height_for(80), 3);
446 }
447
448 #[test]
449 fn zero_height_is_a_no_op() {
450 let area = Rect::new(0, 0, 20, 0);
451 let items = ["Alpha"];
452 let list = List::new(&items);
453
454 let mut grid = Grid::new(20, 1);
455 let mut state = ListState::new();
456 StatefulWidget::render(&list, &mut Surface::new(&mut grid, area, 0), &mut state);
457
458 assert_eq!(grid[Pos::new(0, 0)].glyph(), ' ');
459 }
460
461 #[test]
462 fn theme_maps_named_roles_onto_item_and_selected_styles() {
463 let area = Rect::new(0, 0, 20, 2);
464 let items = ["Alpha", "Bravo"];
465 let list = List::new(&items).theme(Theme::DARK);
466
467 let mut grid = Grid::new(20, 2);
468 let mut state = ListState::new();
469 state.select(Some(1));
470 StatefulWidget::render(&list, &mut Surface::new(&mut grid, area, 0), &mut state);
471
472 assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Theme::DARK.fg);
473 assert_eq!(
474 grid[Pos::new(0, 0)].style().background(),
475 Theme::DARK.panel_bg
476 );
477 assert_eq!(grid[Pos::new(0, 1)].style().foreground(), Theme::DARK.bg);
478 assert_eq!(
479 grid[Pos::new(0, 1)].style().background(),
480 Theme::DARK.accent
481 );
482 }
483
484 #[test]
485 fn theme_on_uses_the_given_backdrop_instead_of_panel_bg() {
486 let area = Rect::new(0, 0, 20, 1);
487 let items = ["Alpha"];
488 let list = List::new(&items).theme_on(Theme::DARK, Color::Default);
489
490 let mut grid = Grid::new(20, 1);
491 let mut state = ListState::new();
492 StatefulWidget::render(&list, &mut Surface::new(&mut grid, area, 0), &mut state);
493
494 assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Theme::DARK.fg);
495 assert_eq!(grid[Pos::new(0, 0)].style().background(), Color::Default);
496 }
497
498 #[test]
499 fn click_selects_the_row_under_the_pointer() {
500 let area = Rect::new(0, 0, 20, 3);
501 let names = items(&["Alpha", "Bravo", "Charlie"]);
502 let list = List::new(&names);
503 let mut state = ListState::new();
504
505 let response: Response<()> = Response {
506 hovered: true,
507 clicked: true,
508 pointer_pos: Some(Pos::new(2, 1)), ..Response::default()
510 };
511 let mut grid = Grid::new(20, 3);
512 InteractiveWidget::render(
513 &list,
514 &mut Surface::new(&mut grid, area, 0),
515 &mut state,
516 response,
517 );
518 assert_eq!(state.selected(), Some(1));
519 }
520
521 #[test]
522 fn click_selects_the_row_under_the_pointer_with_a_scroll_offset() {
523 let area = Rect::new(0, 0, 20, 2); let names = items(&["Alpha", "Bravo", "Charlie", "Delta"]);
525 let list = List::new(&names);
526 let mut state = ListState::new();
527 state.set_offset(2); let response: Response<()> = Response {
530 hovered: true,
531 clicked: true,
532 pointer_pos: Some(Pos::new(2, 1)), ..Response::default()
534 };
535 let mut grid = Grid::new(20, 2);
536 InteractiveWidget::render(
537 &list,
538 &mut Surface::new(&mut grid, area, 0),
539 &mut state,
540 response,
541 );
542 assert_eq!(state.selected(), Some(3));
543 }
544
545 #[test]
546 fn wheel_scroll_moves_the_offset() {
547 let area = Rect::new(0, 0, 20, 2);
548 let names = items(&["Alpha", "Bravo", "Charlie", "Delta"]);
549 let list = List::new(&names);
550 let mut state = ListState::new();
551
552 let response: Response<()> = Response {
553 scroll_delta: 2,
554 ..Response::default()
555 };
556 let mut grid = Grid::new(20, 2);
557 InteractiveWidget::render(
558 &list,
559 &mut Surface::new(&mut grid, area, 0),
560 &mut state,
561 response,
562 );
563 assert_eq!(state.offset(), 2);
564 }
565
566 #[test]
567 fn highlight_symbol_prefixes_only_the_selected_item() {
568 let area = Rect::new(0, 0, 20, 2);
569 let items = ["Alpha", "Bravo"];
570 let list = List::new(&items).highlight_symbol("> ");
571
572 let mut grid = Grid::new(20, 2);
573 let mut state = ListState::new();
574 state.select(Some(1));
575 StatefulWidget::render(&list, &mut Surface::new(&mut grid, area, 0), &mut state);
576
577 assert_eq!(grid[Pos::new(0, 1)].glyph(), '>');
578 assert_eq!(grid[Pos::new(1, 1)].glyph(), ' ');
579 assert_eq!(grid[Pos::new(2, 1)].glyph(), 'B');
580 assert_eq!(grid[Pos::new(0, 0)].glyph(), ' ');
582 assert_eq!(grid[Pos::new(2, 0)].glyph(), 'A');
583 }
584
585 #[test]
586 fn list_highlight_symbol_does_not_bleed_onto_the_next_row() {
587 let area = Rect::new(0, 0, 2, 2);
588 let names = items(&["A", "B"]);
589 let list = List::new(&names).highlight_symbol(">>>"); let mut grid = Grid::new(2, 2);
592 let mut state = ListState::new();
593 state.select(Some(0));
594 StatefulWidget::render(&list, &mut Surface::new(&mut grid, area, 0), &mut state);
595
596 assert_eq!(grid[Pos::new(0, 1)].glyph(), ' ');
599 assert_eq!(grid[Pos::new(1, 1)].glyph(), ' ');
600 }
601
602 #[test]
603 fn no_highlight_symbol_is_the_default_style_only_behavior() {
604 let area = Rect::new(0, 0, 20, 1);
605 let items = ["Alpha"];
606 let list = List::new(&items);
607
608 let mut grid = Grid::new(20, 1);
609 let mut state = ListState::new();
610 state.select(Some(0));
611 StatefulWidget::render(&list, &mut Surface::new(&mut grid, area, 0), &mut state);
612
613 assert_eq!(grid[Pos::new(0, 0)].glyph(), 'A');
614 }
615
616 #[test]
617 fn highlight_spacing_when_selected_reserves_no_column_until_something_is_selected() {
618 let area = Rect::new(0, 0, 20, 1);
619 let items = ["Alpha"];
620 let list = List::new(&items).highlight_symbol("> ");
621
622 let mut grid = Grid::new(20, 1);
623 let mut state = ListState::new();
624 StatefulWidget::render(&list, &mut Surface::new(&mut grid, area, 0), &mut state);
625
626 assert_eq!(grid[Pos::new(0, 0)].glyph(), 'A');
627 }
628
629 #[test]
630 fn highlight_spacing_always_reserves_the_column_even_when_unselected() {
631 let area = Rect::new(0, 0, 20, 1);
632 let items = ["Alpha"];
633 let list = List::new(&items)
634 .highlight_symbol("> ")
635 .highlight_spacing(HighlightSpacing::Always);
636
637 let mut grid = Grid::new(20, 1);
638 let mut state = ListState::new();
639 StatefulWidget::render(&list, &mut Surface::new(&mut grid, area, 0), &mut state);
640
641 assert_eq!(grid[Pos::new(0, 0)].glyph(), ' ');
642 assert_eq!(grid[Pos::new(2, 0)].glyph(), 'A');
643 }
644
645 #[test]
646 fn highlight_spacing_never_suppresses_the_symbol_even_when_selected() {
647 let area = Rect::new(0, 0, 20, 1);
648 let items = ["Alpha"];
649 let list = List::new(&items)
650 .highlight_symbol("> ")
651 .highlight_spacing(HighlightSpacing::Never);
652
653 let mut grid = Grid::new(20, 1);
654 let mut state = ListState::new();
655 state.select(Some(0));
656 StatefulWidget::render(&list, &mut Surface::new(&mut grid, area, 0), &mut state);
657
658 assert_eq!(grid[Pos::new(0, 0)].glyph(), 'A');
659 }
660
661 #[test]
662 fn bottom_to_top_direction_draws_the_first_visible_item_at_the_bottom_row() {
663 let area = Rect::new(0, 0, 20, 3);
664 let names = items(&["Alpha", "Bravo", "Charlie"]);
665 let list = List::new(&names).direction(ListDirection::BottomToTop);
666
667 let mut grid = Grid::new(20, 3);
668 let mut state = ListState::new();
669 StatefulWidget::render(&list, &mut Surface::new(&mut grid, area, 0), &mut state);
670
671 assert_eq!(grid[Pos::new(0, 2)].glyph(), 'A');
672 assert_eq!(grid[Pos::new(0, 1)].glyph(), 'B');
673 assert_eq!(grid[Pos::new(0, 0)].glyph(), 'C');
674 }
675
676 #[test]
677 fn bottom_to_top_direction_highlights_the_selected_row_in_its_flipped_position() {
678 let area = Rect::new(0, 0, 20, 3);
679 let names = items(&["Alpha", "Bravo", "Charlie"]);
680 let list = List::new(&names).direction(ListDirection::BottomToTop);
681
682 let mut grid = Grid::new(20, 3);
683 let mut state = ListState::new();
684 state.select(Some(0)); StatefulWidget::render(&list, &mut Surface::new(&mut grid, area, 0), &mut state);
686
687 let highlighted_bg = grid[Pos::new(0, 2)].style().background();
688 let plain_bg = grid[Pos::new(0, 1)].style().background();
689 assert_ne!(highlighted_bg, plain_bg);
690 }
691
692 #[test]
693 fn bottom_to_top_direction_click_selects_the_flipped_row() {
694 let area = Rect::new(0, 0, 20, 3);
695 let names = items(&["Alpha", "Bravo", "Charlie"]);
696 let list = List::new(&names).direction(ListDirection::BottomToTop);
697 let mut state = ListState::new();
698
699 let response: Response<()> = Response {
700 hovered: true,
701 clicked: true,
702 pointer_pos: Some(Pos::new(2, 2)), ..Response::default()
704 };
705 let mut grid = Grid::new(20, 3);
706 InteractiveWidget::render(
707 &list,
708 &mut Surface::new(&mut grid, area, 0),
709 &mut state,
710 response,
711 );
712 assert_eq!(state.selected(), Some(0));
713 }
714}