Skip to main content

qframe/widgets/
list.rs

1//! Virtualised lists with keyboard and mouse selection.
2
3use std::ops::Deref;
4use std::sync::Arc;
5
6use crate::env::Env;
7use crate::event::{Event, MouseButton, MouseKind};
8use crate::geometry::{Rect, Size, clamp_u16};
9use crate::keymap::Key;
10use crate::text;
11use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
12
13use super::IndexMessage;
14use super::cells;
15use super::click::{Click, LastPress};
16use super::row;
17use super::rows::{self, RowScroll};
18use super::scrollbar::ScrollbarStyle;
19
20/// What kind of row an item is.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum ItemKind {
23    /// A selectable row.
24    Normal,
25    /// A selectable row drawn faint, e.g. something not available yet.
26    Faint,
27    /// A section heading; never selected, skipped by the keyboard.
28    Header,
29    /// An empty row between sections; never selected.
30    Gap,
31}
32
33/// One row of a [`List`].
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct ListItem {
36    label: String,
37    icon: Option<String>,
38    icon_color: Option<String>,
39    detail: Option<String>,
40    kind: ItemKind,
41}
42
43impl ListItem {
44    /// A selectable row.
45    #[must_use]
46    pub fn new(label: impl Into<String>) -> Self {
47        Self { label: label.into(), icon: None, icon_color: None, detail: None, kind: ItemKind::Normal }
48    }
49
50    /// A section heading.
51    #[must_use]
52    pub fn header(label: impl Into<String>) -> Self {
53        Self { kind: ItemKind::Header, ..Self::new(label) }
54    }
55
56    /// An empty separating row.
57    #[must_use]
58    pub fn gap() -> Self {
59        Self { kind: ItemKind::Gap, ..Self::new("") }
60    }
61
62    /// Icon key drawn before the label, optionally in theme colour `color`.
63    #[must_use]
64    pub fn icon(mut self, key: impl Into<String>, color: Option<&str>) -> Self {
65        self.icon = Some(key.into());
66        self.icon_color = color.map(str::to_owned);
67        self
68    }
69
70    /// Faint text aligned right, e.g. a status or a count.
71    #[must_use]
72    pub fn detail(mut self, detail: impl Into<String>) -> Self {
73        self.detail = Some(detail.into());
74        self
75    }
76
77    /// Draws the row faint while keeping it selectable.
78    #[must_use]
79    pub fn faint(mut self, faint: bool) -> Self {
80        if faint {
81            self.kind = ItemKind::Faint;
82        }
83        self
84    }
85
86    fn selectable(&self) -> bool {
87        matches!(self.kind, ItemKind::Normal | ItemKind::Faint)
88    }
89}
90
91/// A vertical list that only draws the rows it shows, so it stays fast with any number of items.
92/// For a very long list, keep the items as an `Arc<[ListItem]>` in your state and pass it to
93/// [`List::shared`], so they are not built again every frame.
94///
95/// The application owns the selection and the checked rows; the list reports changes through
96/// messages. Hovered and selected rows raise their surface, show the accent pillar and slide
97/// their icon and label one cell right. The pillar, the check mark of a multi-select list and the
98/// detail column never move, so a mark is always where the pointer left it.
99///
100/// Keys while focused: ↑/↓ or k/j move, Home/End and PgUp/PgDn jump, Enter activates, Space
101/// toggles in multi-select lists and activates otherwise. A click on a row selects and activates
102/// it, or with [`activate_on(Click::Double)`](Self::activate_on) only selects it and a double
103/// click activates; in a multi-select list a click on the check mark (or the cell after it) only
104/// toggles.
105/// Style keys: `list-item` with `hover`, `selected`, `focus`, `pressed`; `list-item.faint`,
106/// `list-header`, `list-detail`, `scrollbar`.
107pub struct List<Msg> {
108    items: Items,
109    selected: Option<usize>,
110    checked: Option<Vec<bool>>,
111    empty: String,
112    on_select: Option<IndexMessage<Msg>>,
113    on_activate: Option<IndexMessage<Msg>>,
114    on_toggle: Option<IndexMessage<Msg>>,
115    scrollbar: Option<ScrollbarStyle>,
116    activate_on: Click,
117}
118
119/// The last press on a row, to tell a double click in a list that activates on two.
120#[derive(Default)]
121struct Presses(LastPress<usize>);
122
123/// The rows of a list: built for this frame, or shared with the application's state.
124enum Items {
125    Owned(Vec<ListItem>),
126    Shared(Arc<[ListItem]>),
127}
128
129impl Deref for Items {
130    type Target = [ListItem];
131
132    fn deref(&self) -> &[ListItem] {
133        match self {
134            Self::Owned(items) => items,
135            Self::Shared(items) => items,
136        }
137    }
138}
139
140impl<Msg: 'static> List<Msg> {
141    /// A list of `items`.
142    #[must_use]
143    pub fn new(items: impl IntoIterator<Item = ListItem>) -> Self {
144        Self::with_items(Items::Owned(items.into_iter().collect()))
145    }
146
147    /// A list of `items` kept by the application, e.g. in its state: building the list in `view`
148    /// only clones the `Arc`, however many items there are.
149    #[must_use]
150    pub fn shared(items: Arc<[ListItem]>) -> Self {
151        Self::with_items(Items::Shared(items))
152    }
153
154    fn with_items(items: Items) -> Self {
155        Self {
156            items,
157            selected: None,
158            checked: None,
159            empty: String::new(),
160            on_select: None,
161            on_activate: None,
162            on_toggle: None,
163            scrollbar: None,
164            activate_on: Click::Single,
165        }
166    }
167
168    /// Draws the scrollbar in `style` whatever the theme chooses.
169    #[must_use]
170    pub fn scrollbar(mut self, style: ScrollbarStyle) -> Self {
171        self.scrollbar = Some(style);
172        self
173    }
174
175    /// The selected row index.
176    #[must_use]
177    pub fn selected(mut self, index: Option<usize>) -> Self {
178        self.selected = index;
179        self
180    }
181
182    /// Turns the list into a multi-select list; `checked[i]` tells whether row `i` is checked.
183    #[must_use]
184    pub fn checked(mut self, checked: Vec<bool>) -> Self {
185        self.checked = Some(checked);
186        self
187    }
188
189    /// Text shown when there are no items.
190    #[must_use]
191    pub fn empty_text(mut self, text: impl Into<String>) -> Self {
192        self.empty = text.into();
193        self
194    }
195
196    /// Message for moving the selection to a row.
197    #[must_use]
198    pub fn on_select(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
199        self.on_select = Some(Box::new(message));
200        self
201    }
202
203    /// Message for opening a row (Enter, click).
204    #[must_use]
205    pub fn on_activate(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
206        self.on_activate = Some(Box::new(message));
207        self
208    }
209
210    /// How many clicks activate a row: [`Click::Single`], the default, selects and activates at
211    /// once; [`Click::Double`] only selects on a click and activates on a second press on the same
212    /// row within [`Click::INTERVAL`]. Enter activates either way.
213    ///
214    /// With [`Click::Double`] a click reports its row through [`List::on_select`] even when that
215    /// row is selected already, so the application can tell a row the person pointed at from one
216    /// it selected by itself.
217    #[must_use]
218    pub fn activate_on(mut self, click: Click) -> Self {
219        self.activate_on = click;
220        self
221    }
222
223    /// Message for checking or unchecking a row in a multi-select list (Space, click on the mark).
224    #[must_use]
225    pub fn on_toggle(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
226        self.on_toggle = Some(Box::new(message));
227        self
228    }
229
230    fn next_selectable(&self, from: Option<usize>, step: isize) -> Option<usize> {
231        let len = isize::try_from(self.items.len()).ok()?;
232        let mut index = from.map_or(if step > 0 { -1 } else { len }, |i| isize::try_from(i).unwrap_or(0));
233        loop {
234            index += step;
235            if index < 0 || index >= len {
236                return from;
237            }
238            let candidate = usize::try_from(index).ok()?;
239            if self.items[candidate].selectable() {
240                return Some(candidate);
241            }
242        }
243    }
244
245    fn select(&self, cx: &mut EventCx<'_, Msg>, index: Option<usize>) {
246        if let (Some(index), Some(message)) = (index, &self.on_select)
247            && Some(index) != self.selected
248        {
249            cx.emit(message(index));
250        }
251    }
252
253    fn activate(&self, cx: &mut EventCx<'_, Msg>, index: usize) {
254        if let Some(message) = &self.on_activate {
255            cx.memory::<RowScroll>().flashed = Some(index);
256            cx.flash();
257            cx.emit(message(index));
258        }
259    }
260
261    fn row_at(&self, cx: &mut EventCx<'_, Msg>, y: i32) -> Option<usize> {
262        let area = cx.area();
263        let offset = cx.memory::<RowScroll>().offset;
264        let row = usize::try_from(y - area.y).ok()?;
265        let index = offset + row;
266        (row < usize::from(area.height) && index < self.items.len()).then_some(index)
267    }
268
269    /// Cells from the left edge through the check mark and its air: a press there toggles.
270    fn check_column(env: &Env) -> u16 {
271        let widest = ["select-on", "select-off"].map(|key| text::width(&env.icons().glyph(key))).into_iter().max();
272        row::LEAD + widest.unwrap_or(1) + 1
273    }
274}
275
276impl<Msg: 'static> Widget<Msg> for List<Msg> {
277    fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
278        let rows = if self.items.is_empty() { 1 } else { self.items.len() };
279        let widest = self
280            .items
281            .iter()
282            .map(|item| {
283                cells::sum([
284                    text::width(&item.label),
285                    item.detail.as_deref().map_or(0, |d| text::width(d).saturating_add(2)),
286                    item.icon.as_ref().map_or(0, |_| 2),
287                    if self.checked.is_some() { 2 } else { 0 },
288                    5,
289                ])
290            })
291            .max()
292            .unwrap_or_else(|| text::width(&self.empty).saturating_add(3));
293        Size::new(widest, clamp_u16(i32::try_from(rows).unwrap_or(i32::MAX))).min(available)
294    }
295
296    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
297        cx.register_hit(area);
298        if self.items.is_empty() {
299            let faint = cx.style("list-header", None, &[]).text();
300            cx.text(area.x + 2, area.y, &self.empty, faint, area.width.saturating_sub(2));
301            return;
302        }
303        let focused = cx.is_focused();
304        let pressed = cx.is_pressed();
305        let pointer = cx.pointer();
306        let visible = usize::from(area.height);
307        let (offset, flashed) = {
308            let scroll = cx.memory::<RowScroll>();
309            (scroll.follow(self.selected, self.items.len(), visible), scroll.flashed)
310        };
311        let content_width = area.width.saturating_sub(u16::from(self.items.len() > visible));
312
313        for (row, index) in (offset..self.items.len()).take(visible).enumerate() {
314            let item = &self.items[index];
315            let row_rect = Rect::new(area.x, area.y + i32::try_from(row).unwrap_or(0), content_width, 1);
316            match item.kind {
317                ItemKind::Gap => continue,
318                ItemKind::Header => {
319                    let style = cx.style("list-header", None, &[]).text();
320                    cx.text(row_rect.x + 2, row_rect.y, &item.label, style, content_width.saturating_sub(3));
321                    continue;
322                }
323                ItemKind::Normal | ItemKind::Faint => {}
324            }
325            let hovered = pointer.is_some_and(|(x, y)| row_rect.contains(x, y));
326            let states =
327                rows::row_states(hovered, Some(index) == self.selected, focused, pressed && flashed == Some(index));
328            let variant = (item.kind == ItemKind::Faint).then_some("faint");
329            let style = cx.style("list-item", variant, &states);
330            let text_style = style.text();
331            let detail_width = item.detail.as_deref().map_or(0, |d| text::width(d).saturating_add(2));
332            let fixed: Vec<row::Mark> = self
333                .checked
334                .as_ref()
335                .map(|checked| row::check(cx, checked.get(index).copied().unwrap_or(false)))
336                .into_iter()
337                .collect();
338            let icon: Vec<row::Mark> =
339                item.icon.iter().map(|key| row::icon(cx, key, item.icon_color.as_deref(), text_style.fg)).collect();
340            let parts =
341                row::Parts { fixed: &fixed, sliding: &icon, label: &item.label, trailing: detail_width, indent: 0 };
342            row::paint_parts(cx, row_rect, &style, rows::slide(cx, &states) > 0, &parts);
343
344            if let Some(detail) = &item.detail {
345                let detail_style = cx.style("list-detail", None, &states).text();
346                row::paint_trailing(cx, row_rect, detail, detail_style);
347            }
348        }
349        rows::paint_scrollbar(cx, area, self.items.len(), offset, self.scrollbar);
350    }
351
352    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
353        let area = cx.area();
354        let page = usize::from(area.height.max(1));
355        match event {
356            Event::Key(key) => {
357                let target = if key.is_plain(Key::Up) || key.is_plain(Key::Char('k')) {
358                    self.next_selectable(self.selected, -1)
359                } else if key.is_plain(Key::Down) || key.is_plain(Key::Char('j')) {
360                    self.next_selectable(self.selected, 1)
361                } else if key.is_plain(Key::Home) {
362                    self.next_selectable(None, 1)
363                } else if key.is_plain(Key::End) {
364                    self.next_selectable(None, -1)
365                } else if key.is_plain(Key::PageUp) || key.is_plain(Key::PageDown) {
366                    let down = key.is_plain(Key::PageDown);
367                    let mut index = self.selected;
368                    for _ in 0..page {
369                        index = self.next_selectable(index, if down { 1 } else { -1 });
370                    }
371                    index
372                } else if key.is_plain(Key::Enter) {
373                    if let Some(index) = self.selected {
374                        self.activate(cx, index);
375                    }
376                    return self.selected.is_some() && self.on_activate.is_some();
377                } else if key.is_plain(Key::Space) {
378                    let Some(index) = self.selected else { return false };
379                    if let (Some(_), Some(toggle)) = (&self.checked, &self.on_toggle) {
380                        cx.emit(toggle(index));
381                        return true;
382                    }
383                    self.activate(cx, index);
384                    return self.on_activate.is_some();
385                } else {
386                    return false;
387                };
388                if target == self.selected {
389                    return target.is_some();
390                }
391                self.select(cx, target);
392                true
393            }
394            Event::Mouse(mouse) => {
395                if rows::scroll_mouse(cx, mouse, area, self.items.len()) {
396                    return true;
397                }
398                if mouse.kind != MouseKind::Down(MouseButton::Left) {
399                    return false;
400                }
401                let Some(index) = self.row_at(cx, mouse.y).filter(|i| self.items[*i].selectable()) else {
402                    return false;
403                };
404                // The check mark never slides, so its column is the same on every row.
405                if let (Some(_), Some(toggle)) = (&self.checked, &self.on_toggle)
406                    && mouse.x < area.x + i32::from(Self::check_column(cx.env()))
407                {
408                    cx.memory::<Presses>().0.forget();
409                    cx.emit(toggle(index));
410                    return true;
411                }
412                if self.activate_on == Click::Double {
413                    let now = cx.now();
414                    let double = cx.memory::<Presses>().0.press(index, now);
415                    if double {
416                        self.select(cx, Some(index));
417                        self.activate(cx, index);
418                    } else if let Some(message) = &self.on_select {
419                        cx.emit(message(index));
420                    }
421                    return true;
422                }
423                self.select(cx, Some(index));
424                self.activate(cx, index);
425                true
426            }
427            _ => false,
428        }
429    }
430
431    fn focusable(&self) -> bool {
432        self.items.iter().any(ListItem::selectable)
433    }
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439    use crate::runtime::{App, Command, Harness};
440    use crate::widget::View;
441
442    struct Demo {
443        count: usize,
444        selected: Option<usize>,
445        opened: Vec<usize>,
446        checked: Option<Vec<bool>>,
447        /// Whether rows activate on a double click.
448        double: bool,
449    }
450
451    #[derive(Clone)]
452    enum Msg {
453        Select(usize),
454        Open(usize),
455        Toggle(usize),
456    }
457
458    impl App for Demo {
459        type Msg = Msg;
460        fn update(&mut self, msg: Msg) -> Command<Msg> {
461            match msg {
462                Msg::Select(i) => self.selected = Some(i),
463                Msg::Open(i) => self.opened.push(i),
464                Msg::Toggle(i) => {
465                    if let Some(checked) = &mut self.checked {
466                        checked[i] = !checked[i];
467                    }
468                }
469            }
470            Command::none()
471        }
472        fn view(&self, ui: &mut View<'_, Msg>) {
473            let mut items = vec![ListItem::header("CONTAINERS")];
474            items.extend((0..self.count).map(|i| ListItem::new(format!("item {i}")).detail("ready")));
475            let mut list = List::new(items)
476                .selected(self.selected)
477                .on_select(Msg::Select)
478                .on_activate(Msg::Open)
479                .on_toggle(Msg::Toggle);
480            if let Some(checked) = &self.checked {
481                list = list.checked(checked.clone());
482            }
483            if self.double {
484                list = list.activate_on(Click::Double);
485            }
486            ui.add(list).fill().id("list");
487        }
488    }
489
490    fn demo(count: usize) -> Demo {
491        Demo { count, selected: None, opened: Vec::new(), checked: None, double: false }
492    }
493
494    #[test]
495    fn keyboard_skips_headers_and_selected_row_slides() {
496        let mut h = Harness::new(demo(3), 24, 4);
497        h.press("tab").press("down");
498        assert_eq!(h.app().selected, Some(1));
499        let screen = h.screen();
500        assert_eq!(screen, "  CONTAINERS\n▌  item 0         ready\n  item 1          ready\n  item 2          ready\n");
501        h.press("up");
502        assert_eq!(h.app().selected, Some(1));
503        h.press("enter");
504        assert_eq!(h.app().opened, vec![1]);
505    }
506
507    #[test]
508    fn a_shared_list_looks_and_behaves_like_a_built_one() {
509        struct Shared {
510            items: Arc<[ListItem]>,
511            selected: Option<usize>,
512            shared: bool,
513        }
514        impl App for Shared {
515            type Msg = usize;
516            fn update(&mut self, index: usize) -> Command<usize> {
517                self.selected = Some(index);
518                Command::none()
519            }
520            fn view(&self, ui: &mut View<'_, usize>) {
521                let list =
522                    if self.shared { List::shared(Arc::clone(&self.items)) } else { List::new(self.items.to_vec()) };
523                ui.add(list.selected(self.selected).on_select(|index| index)).fill().id("list");
524            }
525        }
526        let items: Arc<[ListItem]> = (0..1000).map(|i| ListItem::new(format!("deploy {i}")).detail("ready")).collect();
527        let run = |shared: bool| {
528            let mut h = Harness::new(Shared { items: Arc::clone(&items), selected: None, shared }, 24, 4);
529            h.press("tab").press("down").press("pgdn").press("down");
530            (h.app().selected, h.html("list"))
531        };
532        let shared = run(true);
533        assert_eq!(shared.0, Some(5));
534        assert_eq!(shared, run(false));
535        assert_eq!(Arc::strong_count(&items), 1, "the list let go of the shared items");
536    }
537
538    #[test]
539    fn scrolls_to_follow_selection_and_draws_scrollbar() {
540        let mut h = Harness::new(demo(100_000), 24, 5);
541        h.press("tab").press("end");
542        assert_eq!(h.app().selected, Some(100_000));
543        let screen = h.screen();
544        assert!(screen.contains("item 99999"), "{screen}");
545        assert!(!super::super::scrollbar::column(&h, 23).contains(' '), "{screen}");
546    }
547
548    #[test]
549    fn click_selects_and_opens_and_wheel_scrolls() {
550        let mut h = Harness::new(demo(20), 24, 5);
551        h.click_text("item 2");
552        assert_eq!(h.app().selected, Some(3));
553        assert_eq!(h.app().opened, vec![3]);
554        h.mouse(MouseKind::ScrollDown, 3, 2);
555        assert!(!h.screen().contains("CONTAINERS"));
556    }
557
558    #[test]
559    fn activating_on_a_double_click_selects_with_one_and_opens_with_two() {
560        let mut h = Harness::new(Demo { double: true, ..demo(5) }, 24, 7);
561        h.click_text("item 2");
562        assert_eq!((h.app().selected, h.app().opened.as_slice()), (Some(3), &[][..]), "one click selects");
563        h.advance(Click::INTERVAL).click_text("item 2");
564        assert!(h.app().opened.is_empty(), "two clicks further apart than the interval are two clicks");
565        h.click_text("item 2");
566        assert_eq!(h.app().opened, [3], "a double click opens");
567        h.click_text("item 2");
568        assert_eq!(h.app().opened, [3], "a third press starts over");
569        h.click_text("item 0").click_text("item 2");
570        assert_eq!(h.app().opened, [3], "presses on two rows are no double click");
571        h.press("enter");
572        assert_eq!(h.app().opened, [3, 3], "Enter opens the selected row");
573    }
574
575    fn multi(count: usize) -> Demo {
576        Demo { checked: Some(vec![false; count + 1]), ..demo(count) }
577    }
578
579    fn without_slide(app: Demo, width: u16, height: u16) -> Harness<Demo> {
580        let mut env = crate::env::Env::builtin();
581        env.set_slide(false);
582        Harness::with_env(app, env, width, height)
583    }
584
585    #[test]
586    fn multi_select_toggles_with_space() {
587        let mut h = Harness::new(multi(2), 24, 3);
588        h.press("tab").press("down").press("space");
589        assert_eq!(h.app().checked.as_deref(), Some(&[false, true, false][..]));
590        assert_eq!(h.screen(), "  CONTAINERS\n▌ ☑  item 0       ready\n  ☐ item 1        ready\n");
591        assert_eq!(h.fg(2, 1), h.env().theme().color("accent"), "a checked mark takes the accent");
592        assert_eq!(h.fg(2, 2), h.env().theme().color("muted"), "an unchecked mark is faint");
593    }
594
595    #[test]
596    fn check_marks_stay_put_while_the_label_slides() {
597        let mut h = Harness::new(multi(3), 24, 4);
598        h.hover(8, 2);
599        let screen = h.screen();
600        assert_eq!(screen, "  CONTAINERS\n  ☐ item 0        ready\n▌ ☐  item 1       ready\n  ☐ item 2        ready\n");
601        let column = |line: &str| line.chars().position(|c| c == '☐');
602        let lines: Vec<&str> = screen.lines().skip(1).collect();
603        assert!(lines.iter().all(|line| column(line) == Some(2)), "the mark column never moves:\n{screen}");
604        assert_eq!(h.bg(2, 2), h.env().theme().color("raised"), "the mark sits on the raised row");
605
606        let mut h = without_slide(multi(3), 24, 4);
607        h.hover(8, 2);
608        assert_eq!(
609            h.screen(),
610            "  CONTAINERS\n  ☐ item 0        ready\n▌ ☐ item 1        ready\n  ☐ item 2        ready\n"
611        );
612    }
613
614    #[test]
615    fn a_click_on_the_mark_toggles_and_a_click_on_the_label_opens() {
616        let mut h = Harness::new(multi(3), 24, 4);
617        // Row 2 of the screen is item 1, index 2 after the heading.
618        h.hover(8, 2).click(2, 2);
619        assert_eq!(h.app().checked.as_deref(), Some(&[false, false, true, false][..]));
620        assert_eq!((h.app().selected, h.app().opened.as_slice()), (None, &[][..]), "the mark only toggles");
621        h.click(3, 3);
622        assert_eq!(h.app().checked.as_deref(), Some(&[false, false, true, true][..]), "its air cell counts too");
623        h.click(4, 3);
624        assert_eq!((h.app().selected, h.app().opened.as_slice()), (Some(3), &[3][..]), "the label opens the row");
625        assert_eq!(h.app().checked.as_deref(), Some(&[false, false, true, true][..]));
626    }
627
628    #[test]
629    fn a_label_is_cut_at_the_same_place_resting_and_sliding() {
630        struct Long;
631        impl App for Long {
632            type Msg = ();
633            fn update(&mut self, _: ()) -> Command<()> {
634                Command::none()
635            }
636            fn view(&self, ui: &mut View<'_, ()>) {
637                let items = ["docs-preview-environment", "nightly-integration-tests"]
638                    .map(|name| ListItem::new(name).icon("dot", Some("success")).detail("running"));
639                ui.add(List::new(items).checked(vec![true, false])).fill();
640            }
641        }
642        let mut h = Harness::new(Long, 24, 2);
643        assert_eq!(h.screen(), "  ☑ ● docs-p…   running\n  ☐ ● nightl…   running\n");
644        h.hover(10, 1);
645        assert_eq!(h.screen(), "  ☑ ● docs-p…   running\n▌ ☐  ● nightl…  running\n");
646    }
647
648    #[test]
649    fn ascii_marks_are_letters_not_brackets() {
650        let mut h = Harness::new(multi(2), 24, 3);
651        h.set_glyph_mode(crate::icons::GlyphMode::Ascii);
652        h.press("tab").press("down").press("space");
653        assert_eq!(h.screen(), "  CONTAINERS\n  x  item 0       ready\n  o item 1        ready\n");
654        assert_eq!(h.bg(2, 1), h.env().theme().color("active"), "the selection shows by surface alone");
655    }
656
657    #[test]
658    fn empty_list_shows_empty_text() {
659        struct Empty;
660        impl App for Empty {
661            type Msg = ();
662            fn update(&mut self, _: ()) -> Command<()> {
663                Command::none()
664            }
665            fn view(&self, ui: &mut View<'_, ()>) {
666                ui.add(List::new(Vec::new()).empty_text("Nothing here")).fill();
667            }
668        }
669        assert_eq!(Harness::new(Empty, 20, 1).screen(), "  Nothing here\n");
670    }
671}