Skip to main content

retroglyph_ui/widget/
list.rs

1//! [`List`]: a scrollable, single-column list with a [`ListState`]-driven highlighted item.
2use 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/// A scrollable, single-column list of plain-text items with a [`ListState`]-driven highlighted
17/// item: `Table`'s single-column sibling, sharing its windowing and selection story.
18///
19/// One `item` renders per line, top-aligned in the area it's rendered into and clipped to
20/// `area.width()`. `state.offset()` is the index of the first item drawn: rendering draws
21/// whatever window `offset` names and does not clamp or auto-scroll it, matching
22/// [`Table`](super::Table)'s and [`ListState`]'s existing "only the caller knows the viewport
23/// height" design. Call [`state.ensure_visible(visible_item_count)`](ListState::ensure_visible)
24/// before rendering to keep `state.selected()` on-screen. If `selected()` is `Some` and its item
25/// falls within the visible window, that item is drawn with an inverted highlight background; if
26/// it has scrolled out of view, nothing is highlighted.
27///
28/// `item_style` and `selected_style` default to [`Theme::DARK`], as if [`List::theme`] had been
29/// called; set a different [`Theme`] with [`List::theme`]/[`List::theme_on`] or override a single
30/// field with [`List::item_style`]/[`List::selected_style`].
31///
32/// As an [`InteractiveWidget`], a single id covers the whole list: a click selects the row under
33/// [`Response::pointer_pos`], resolved from this list's own row geometry (`state.offset()` plus
34/// the pointer's row within `surface.area()`) rather than from a separate id per row, and the
35/// wheel scrolls it via [`Response::scroll_delta`]/[`ListState::scroll_by`].
36///
37/// Selection is style-only by default (a swap to `selected_style`), the same as before
38/// [`List::highlight_symbol`] existed. Set it to prefix the selected item with a marker like
39/// `"> "`, the one selection signal that survives `set_plain_mode`'s style stripping, or that
40/// reads clearly on a 16-color backend where `selected_style`'s background isn't distinct enough.
41/// [`List::highlight_spacing`] controls whether that marker column reserves width even when
42/// nothing is selected, and [`List::direction`] draws the same windowed items from the bottom of
43/// the area upward instead of the top downward, for a chat/log-style list that grows upward.
44///
45/// # Examples
46///
47/// ```
48/// use retroglyph_core::grid::{Grid, Rect};
49/// use retroglyph_ui::state::ListState;
50/// use retroglyph_ui::widget::{List, StatefulWidget};
51/// use retroglyph_ui::Surface;
52///
53/// let items = ["Alpha", "Bravo", "Charlie"];
54/// let mut state = ListState::new();
55/// state.select(Some(1));
56///
57/// let area = Rect::new(0, 0, 20, 3);
58/// let mut grid = Grid::new(20, 3);
59/// List::new(&items).render(&mut Surface::new(&mut grid, area, 0), &mut state);
60/// ```
61#[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    /// A list of `items`, styled from [`Theme::DARK`] (as if [`List::theme`] had been called); set
73    /// [`List::theme`]/[`List::theme_on`] for a different [`Theme`] or
74    /// [`List::item_style`]/[`List::selected_style`] for a one-off override.
75    #[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    /// Set the style of unselected items.
89    #[must_use]
90    pub const fn item_style(mut self, style: Style) -> Self {
91        self.item_style = style;
92        self
93    }
94
95    /// Set the style of the selected item, including its background fill.
96    #[must_use]
97    pub const fn selected_style(mut self, style: Style) -> Self {
98        self.selected_style = style;
99        self
100    }
101
102    /// Prefix the selected item with `symbol`, e.g. `"> "`. Empty by default (no marker column,
103    /// the same style-only highlight as before this method existed).
104    ///
105    /// While the marker column is reserved (see [`List::highlight_spacing`]), unselected items'
106    /// text still starts one column past it, so every item's text lines up regardless of which
107    /// row is selected; that reserved column itself is left untouched (not filled) on unselected
108    /// rows, the same "caller's backdrop shows through" behavior [`List::item_style`] already has
109    /// for any unset background.
110    #[must_use]
111    pub const fn highlight_symbol(mut self, symbol: &'a str) -> Self {
112        self.highlight_symbol = symbol;
113        self
114    }
115
116    /// Whether the marker column [`List::highlight_symbol`] draws into reserves width even when
117    /// nothing is selected. Defaults to [`HighlightSpacing::WhenSelected`].
118    #[must_use]
119    pub const fn highlight_spacing(mut self, spacing: HighlightSpacing) -> Self {
120        self.highlight_spacing = spacing;
121        self
122    }
123
124    /// Which end of the area this list's items render from. Defaults to
125    /// [`ListDirection::TopToBottom`].
126    #[must_use]
127    pub const fn direction(mut self, direction: ListDirection) -> Self {
128        self.direction = direction;
129        self
130    }
131
132    /// Applies `theme`'s named roles to this list: `item_style` becomes `theme.fg` on
133    /// `theme.panel_bg`, and `selected_style` becomes `theme.bg` on `theme.accent`.
134    ///
135    /// `item_style` sets an explicit background rather than leaving it at [`Style::new()`]'s
136    /// default: an unset background isn't "transparent" once a real backend draws it (a bare
137    /// `Color::Default` cell paints as solid black behind the glyph; see
138    /// `retroglyph-software`'s `DEFAULT_BG`), so this widget assumes it's drawn on
139    /// `theme.panel_bg`, true when composed with a themed [`super::Panel`]/[`super::Modal`].
140    /// Drawing this list directly on the raw screen background instead needs a manual
141    /// `.item_style(...)` override afterwards.
142    ///
143    /// Call before any manual [`List::item_style`]/[`List::selected_style`] override you want to
144    /// keep.
145    #[must_use]
146    pub fn theme(self, theme: Theme) -> Self {
147        self.theme_on(theme, theme.panel_bg)
148    }
149
150    /// Same as [`List::theme`], but `item_style` is drawn on `bg` instead of `theme.panel_bg` --
151    /// for a list drawn directly on a backdrop other than a themed [`super::Panel`]/
152    /// [`super::Modal`]'s fill. [`List::theme`] is exactly `theme_on(theme, theme.panel_bg)`.
153    #[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    /// The marker column's width in this render: [`List::highlight_symbol`]'s display width if
163    /// [`List::highlight_spacing`] says to reserve it right now, `0` otherwise (including when
164    /// `highlight_symbol` is empty: there's nothing to reserve room for).
165    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    /// The shared drawing routine both [`StatefulWidget::render`] and
184    /// [`InteractiveWidget::render`] use.
185    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            // `item_index - state.offset()` is a row within the visible window, so it never
197            // exceeds `visible_items` (this surface's own `u16` height).
198            #[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                // `row_in_window < visible_items <= height`, so this never underflows.
203                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    /// The item index at `pos`, given `state`'s current scroll offset and [`List::direction`], or
244    /// `None` if `pos` falls past the last item (not clamped to the last item).
245    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    /// One row per item; `width` is ignored, since items are truncated rather than wrapped.
271    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    /// A single id covers the whole list: clicking resolves which row via
282    /// [`Response::pointer_pos`] and this list's own row geometry, rather than each row
283    /// registering its own id.
284    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        // A click past the last row selects nothing: it's neither clamped to the last item nor
292        // left as whatever was selected before.
293        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); // window is [Charlie, Delta]
360        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)); // "Alpha"
375        state.set_offset(2); // but the window starts at "Charlie"
376        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); // neither visible row is highlighted
381    }
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'); // "a muc"
424    }
425
426    #[test]
427    fn ensure_visible_before_render_keeps_selection_on_screen() {
428        let area = Rect::new(0, 0, 20, 2); // 2 visible items
429        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)); // "Delta", off the front of the default window
435        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)), // row 1 -> "Bravo"
511            ..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); // 2 visible rows
526        let names = items(&["Alpha", "Bravo", "Charlie", "Delta"]);
527        let list = List::new(&names);
528        let mut state = ListState::new();
529        state.set_offset(2); // window is [Charlie, Delta]
530
531        let response: Response<()> = Response {
532            hovered: true,
533            clicked: true,
534            pointer_pos: Some(Pos::new(2, 1)), // row 1 of the window -> "Delta" (index 3)
535            ..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        // Unselected row's marker column is blank, and its text still starts past it.
583        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(">>>"); // marker is wider than the list
592
593        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        // Row 1 belongs to "B", so it must not show any spillover from row 0's marker (the
599        // marker is wider than the whole list, so there's no room left for item text either).
600        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)); // "Alpha" draws at the bottom row under this direction
687        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)), // bottom row -> "Alpha" under this direction
705            ..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}