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