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::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/// 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::{List, ListState, StatefulWidget, Surface};
50///
51/// let items = ["Alpha", "Bravo", "Charlie"];
52/// let mut state = ListState::new();
53/// state.select(Some(1));
54///
55/// let area = Rect::new(0, 0, 20, 3);
56/// let mut grid = Grid::new(20, 3);
57/// List::new(&items).render(&mut Surface::new(&mut grid, area, 0), &mut state);
58/// ```
59#[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    /// A list of `items`, styled from [`Theme::DARK`] (as if [`List::theme`] had been called); set
71    /// [`List::theme`]/[`List::theme_on`] for a different [`Theme`] or
72    /// [`List::item_style`]/[`List::selected_style`] for a one-off override.
73    #[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    /// Set the style of unselected items.
87    #[must_use]
88    pub const fn item_style(mut self, style: Style) -> Self {
89        self.item_style = style;
90        self
91    }
92
93    /// Set the style of the selected item, including its background fill.
94    #[must_use]
95    pub const fn selected_style(mut self, style: Style) -> Self {
96        self.selected_style = style;
97        self
98    }
99
100    /// Prefix the selected item with `symbol`, e.g. `"> "`. Empty by default (no marker column,
101    /// the same style-only highlight as before this method existed).
102    ///
103    /// While the marker column is reserved (see [`List::highlight_spacing`]), unselected items'
104    /// text still starts one column past it, so every item's text lines up regardless of which
105    /// row is selected; that reserved column itself is left untouched (not filled) on unselected
106    /// rows, the same "caller's backdrop shows through" behavior [`List::item_style`] already has
107    /// for any unset background.
108    #[must_use]
109    pub const fn highlight_symbol(mut self, symbol: &'a str) -> Self {
110        self.highlight_symbol = symbol;
111        self
112    }
113
114    /// Whether the marker column [`List::highlight_symbol`] draws into reserves width even when
115    /// nothing is selected. Defaults to [`HighlightSpacing::WhenSelected`].
116    #[must_use]
117    pub const fn highlight_spacing(mut self, spacing: HighlightSpacing) -> Self {
118        self.highlight_spacing = spacing;
119        self
120    }
121
122    /// Which end of the area this list's items render from. Defaults to
123    /// [`ListDirection::TopToBottom`].
124    #[must_use]
125    pub const fn direction(mut self, direction: ListDirection) -> Self {
126        self.direction = direction;
127        self
128    }
129
130    /// Applies `theme`'s named roles to this list: `item_style` becomes `theme.fg` on
131    /// `theme.panel_bg`, and `selected_style` becomes `theme.bg` on `theme.accent`.
132    ///
133    /// `item_style` sets an explicit background rather than leaving it at [`Style::new()`]'s
134    /// default: an unset background isn't "transparent" once a real backend draws it (a bare
135    /// `Color::Default` cell paints as solid black behind the glyph; see
136    /// `retroglyph-software`'s `DEFAULT_BG`), so this widget assumes it's drawn on
137    /// `theme.panel_bg`, true when composed with a themed [`super::Panel`]/[`super::Modal`].
138    /// Drawing this list directly on the raw screen background instead needs a manual
139    /// `.item_style(...)` override afterwards.
140    ///
141    /// Call before any manual [`List::item_style`]/[`List::selected_style`] override you want to
142    /// keep.
143    #[must_use]
144    pub fn theme(self, theme: Theme) -> Self {
145        self.theme_on(theme, theme.panel_bg)
146    }
147
148    /// Same as [`List::theme`], but `item_style` is drawn on `bg` instead of `theme.panel_bg` --
149    /// for a list drawn directly on a backdrop other than a themed [`super::Panel`]/
150    /// [`super::Modal`]'s fill. [`List::theme`] is exactly `theme_on(theme, theme.panel_bg)`.
151    #[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    /// The marker column's width in this render: [`List::highlight_symbol`]'s display width if
161    /// [`List::highlight_spacing`] says to reserve it right now, `0` otherwise (including when
162    /// `highlight_symbol` is empty: there's nothing to reserve room for).
163    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    /// The shared drawing routine both [`StatefulWidget::render`] and
182    /// [`InteractiveWidget::render`] use.
183    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            // `item_index - state.offset()` is a row within the visible window, so it never
195            // exceeds `visible_items` (this surface's own `u16` height).
196            #[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                // `row_in_window < visible_items <= height`, so this never underflows.
201                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    /// The item index at `pos`, given `state`'s current scroll offset and [`List::direction`], or
242    /// `None` if `pos` falls past the last item (not clamped to the last item).
243    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    /// One row per item; `width` is ignored, since items are truncated rather than wrapped.
269    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    /// A single id covers the whole list: clicking resolves which row via
280    /// [`Response::pointer_pos`] and this list's own row geometry, rather than each row
281    /// registering its own id.
282    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        // A click past the last row selects nothing: it's neither clamped to the last item nor
290        // left as whatever was selected before.
291        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); // window is [Charlie, Delta]
358        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)); // "Alpha"
373        state.set_offset(2); // but the window starts at "Charlie"
374        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); // neither visible row is highlighted
379    }
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'); // "a muc"
422    }
423
424    #[test]
425    fn ensure_visible_before_render_keeps_selection_on_screen() {
426        let area = Rect::new(0, 0, 20, 2); // 2 visible items
427        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)); // "Delta", off the front of the default window
433        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)), // row 1 -> "Bravo"
509            ..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); // 2 visible rows
524        let names = items(&["Alpha", "Bravo", "Charlie", "Delta"]);
525        let list = List::new(&names);
526        let mut state = ListState::new();
527        state.set_offset(2); // window is [Charlie, Delta]
528
529        let response: Response<()> = Response {
530            hovered: true,
531            clicked: true,
532            pointer_pos: Some(Pos::new(2, 1)), // row 1 of the window -> "Delta" (index 3)
533            ..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        // Unselected row's marker column is blank, and its text still starts past it.
581        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(">>>"); // marker is wider than the list
590
591        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        // Row 1 belongs to "B", so it must not show any spillover from row 0's marker (the
597        // marker is wider than the whole list, so there's no room left for item text either).
598        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)); // "Alpha" draws at the bottom row under this direction
685        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)), // bottom row -> "Alpha" under this direction
703            ..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}