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. With [`wrap`](Self::wrap) ↓ on the last
102/// row goes on to the first and ↑ on the first to the last. A click on a row selects and activates
103/// it, or with [`activate_on(Click::Double)`](Self::activate_on) only selects it and a double
104/// click activates; in a multi-select list a click on the check mark (or the cell after it) only
105/// toggles.
106/// Style keys: `list-item` with `hover`, `selected`, `focus`, `pressed`; `list-item.faint`,
107/// `list-header`, `list-detail`, `scrollbar`.
108pub struct List<Msg> {
109    items: Items,
110    selected: Option<usize>,
111    checked: Option<Vec<bool>>,
112    empty: String,
113    on_select: Option<IndexMessage<Msg>>,
114    on_activate: Option<IndexMessage<Msg>>,
115    on_toggle: Option<IndexMessage<Msg>>,
116    scrollbar: Option<ScrollbarStyle>,
117    activate_on: Click,
118    wrap: bool,
119    label_first: bool,
120}
121
122/// Cells between a row's label and its detail.
123const DETAIL_GAP: u16 = 2;
124
125/// The fewest cells a cut detail keeps: fewer say nothing and are left out.
126const DETAIL_MIN: u16 = 4;
127
128/// The detail a row shows beside `label` when the label and the detail have `room` cells between
129/// them: all of it when both fit, cut with `…` when the label would otherwise be cut, and nothing
130/// when too little would be left of it. The label is what a person chooses by, so it gives way
131/// last.
132fn fit_detail(detail: &str, label: &str, room: u16) -> Option<String> {
133    let left = room.saturating_sub(text::width(label)).saturating_sub(DETAIL_GAP);
134    let whole = text::width(detail);
135    if whole <= left {
136        Some(detail.to_owned())
137    } else if left >= DETAIL_MIN.min(whole) {
138        Some(text::truncate(detail, left).into_owned())
139    } else {
140        None
141    }
142}
143
144/// The last press on a row, to tell a double click in a list that activates on two.
145#[derive(Default)]
146struct Presses(LastPress<usize>);
147
148/// The rows of a list: built for this frame, or shared with the application's state.
149enum Items {
150    Owned(Vec<ListItem>),
151    Shared(Arc<[ListItem]>),
152}
153
154impl Deref for Items {
155    type Target = [ListItem];
156
157    fn deref(&self) -> &[ListItem] {
158        match self {
159            Self::Owned(items) => items,
160            Self::Shared(items) => items,
161        }
162    }
163}
164
165impl<Msg: 'static> List<Msg> {
166    /// A list of `items`.
167    #[must_use]
168    pub fn new(items: impl IntoIterator<Item = ListItem>) -> Self {
169        Self::with_items(Items::Owned(items.into_iter().collect()))
170    }
171
172    /// A list of `items` kept by the application, e.g. in its state: building the list in `view`
173    /// only clones the `Arc`, however many items there are.
174    #[must_use]
175    pub fn shared(items: Arc<[ListItem]>) -> Self {
176        Self::with_items(Items::Shared(items))
177    }
178
179    fn with_items(items: Items) -> Self {
180        Self {
181            items,
182            selected: None,
183            checked: None,
184            empty: String::new(),
185            on_select: None,
186            on_activate: None,
187            on_toggle: None,
188            scrollbar: None,
189            activate_on: Click::Single,
190            wrap: false,
191            label_first: false,
192        }
193    }
194
195    /// Draws the scrollbar in `style` whatever the theme chooses.
196    #[must_use]
197    pub fn scrollbar(mut self, style: ScrollbarStyle) -> Self {
198        self.scrollbar = Some(style);
199        self
200    }
201
202    /// The selected row index.
203    #[must_use]
204    pub fn selected(mut self, index: Option<usize>) -> Self {
205        self.selected = index;
206        self
207    }
208
209    /// Turns the list into a multi-select list; `checked[i]` tells whether row `i` is checked.
210    #[must_use]
211    pub fn checked(mut self, checked: Vec<bool>) -> Self {
212        self.checked = Some(checked);
213        self
214    }
215
216    /// Text shown when there are no items.
217    #[must_use]
218    pub fn empty_text(mut self, text: impl Into<String>) -> Self {
219        self.empty = text.into();
220        self
221    }
222
223    /// Message for moving the selection to a row.
224    #[must_use]
225    pub fn on_select(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
226        self.on_select = Some(Box::new(message));
227        self
228    }
229
230    /// Message for opening a row (Enter, click).
231    #[must_use]
232    pub fn on_activate(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
233        self.on_activate = Some(Box::new(message));
234        self
235    }
236
237    /// How many clicks activate a row: [`Click::Single`], the default, selects and activates at
238    /// once; [`Click::Double`] only selects on a click and activates on a second press on the same
239    /// row within [`Click::INTERVAL`]. Enter activates either way.
240    ///
241    /// With [`Click::Double`] a click reports its row through [`List::on_select`] even when that
242    /// row is selected already, so the application can tell a row the person pointed at from one
243    /// it selected by itself.
244    #[must_use]
245    pub fn activate_on(mut self, click: Click) -> Self {
246        self.activate_on = click;
247        self
248    }
249
250    /// Message for checking or unchecking a row in a multi-select list (Space, click on the mark).
251    #[must_use]
252    pub fn on_toggle(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
253        self.on_toggle = Some(Box::new(message));
254        self
255    }
256
257    /// Lets ↓ (or j) on the last selectable row go on to the first and ↑ (or k) on the first go
258    /// to the last, as a menu does; headers and gaps are skipped. PgUp/PgDn and Home/End still
259    /// stop at the ends. Off by default: the keys stop at either end.
260    #[must_use]
261    pub fn wrap(mut self, wrap: bool) -> Self {
262        self.wrap = wrap;
263        self
264    }
265
266    /// When a row is too narrow for its label and its detail, cuts the detail first, with `…`, and
267    /// leaves it out when fewer than four cells of it would remain, so the label stays whole as
268    /// long as it fits on its own. For rows chosen by their label, where the detail only explains.
269    /// Off by default: the detail keeps its width and the label is cut, for rows whose detail is
270    /// what they are about, such as a measurement or an address.
271    #[must_use]
272    pub fn label_first(mut self, label_first: bool) -> Self {
273        self.label_first = label_first;
274        self
275    }
276
277    /// The next selectable row one step from the selection, wrapping round the ends when the
278    /// list wraps.
279    fn neighbour(&self, step: isize) -> Option<usize> {
280        let next = self.next_selectable(self.selected, step);
281        if self.wrap && self.selected.is_some() && next == self.selected {
282            return self.next_selectable(None, step);
283        }
284        next
285    }
286
287    fn next_selectable(&self, from: Option<usize>, step: isize) -> Option<usize> {
288        let len = isize::try_from(self.items.len()).ok()?;
289        let mut index = from.map_or(if step > 0 { -1 } else { len }, |i| isize::try_from(i).unwrap_or(0));
290        loop {
291            index += step;
292            if index < 0 || index >= len {
293                return from;
294            }
295            let candidate = usize::try_from(index).ok()?;
296            if self.items[candidate].selectable() {
297                return Some(candidate);
298            }
299        }
300    }
301
302    fn select(&self, cx: &mut EventCx<'_, Msg>, index: Option<usize>) {
303        if let (Some(index), Some(message)) = (index, &self.on_select)
304            && Some(index) != self.selected
305        {
306            cx.emit(message(index));
307        }
308    }
309
310    fn activate(&self, cx: &mut EventCx<'_, Msg>, index: usize) {
311        if let Some(message) = &self.on_activate {
312            cx.memory::<RowScroll>().flashed = Some(index);
313            cx.flash();
314            cx.emit(message(index));
315        }
316    }
317
318    fn row_at(&self, cx: &mut EventCx<'_, Msg>, y: i32) -> Option<usize> {
319        let area = cx.area();
320        let offset = cx.memory::<RowScroll>().offset;
321        let row = usize::try_from(y - area.y).ok()?;
322        let index = offset + row;
323        (row < usize::from(area.height) && index < self.items.len()).then_some(index)
324    }
325
326    /// Cells from the left edge through the check mark and its air: a press there toggles.
327    fn check_column(env: &Env) -> u16 {
328        let widest = ["select-on", "select-off"].map(|key| text::width(&env.icons().glyph(key))).into_iter().max();
329        row::LEAD + widest.unwrap_or(1) + 1
330    }
331}
332
333impl<Msg: 'static> Widget<Msg> for List<Msg> {
334    fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
335        let rows = if self.items.is_empty() { 1 } else { self.items.len() };
336        let widest = self
337            .items
338            .iter()
339            .map(|item| {
340                cells::sum([
341                    text::width(&item.label),
342                    item.detail.as_deref().map_or(0, |d| text::width(d).saturating_add(2)),
343                    item.icon.as_ref().map_or(0, |_| 2),
344                    if self.checked.is_some() { 2 } else { 0 },
345                    5,
346                ])
347            })
348            .max()
349            .unwrap_or_else(|| text::width(&self.empty).saturating_add(3));
350        Size::new(widest, clamp_u16(i32::try_from(rows).unwrap_or(i32::MAX))).min(available)
351    }
352
353    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
354        cx.register_hit(area);
355        if self.items.is_empty() {
356            let faint = cx.style("list-header", None, &[]).text();
357            cx.text(area.x + 2, area.y, &self.empty, faint, area.width.saturating_sub(2));
358            return;
359        }
360        let focused = cx.is_focused();
361        let pressed = cx.is_pressed();
362        let pointer = cx.pointer();
363        let visible = usize::from(area.height);
364        let (offset, flashed) = {
365            let scroll = cx.memory::<RowScroll>();
366            (scroll.follow(self.selected, self.items.len(), visible), scroll.flashed)
367        };
368        let content_width = area.width.saturating_sub(u16::from(self.items.len() > visible));
369
370        for (row, index) in (offset..self.items.len()).take(visible).enumerate() {
371            let item = &self.items[index];
372            let row_rect = Rect::new(area.x, area.y + i32::try_from(row).unwrap_or(0), content_width, 1);
373            match item.kind {
374                ItemKind::Gap => continue,
375                ItemKind::Header => {
376                    let style = cx.style("list-header", None, &[]).text();
377                    cx.text(row_rect.x + 2, row_rect.y, &item.label, style, content_width.saturating_sub(3));
378                    continue;
379                }
380                ItemKind::Normal | ItemKind::Faint => {}
381            }
382            let hovered = pointer.is_some_and(|(x, y)| row_rect.contains(x, y));
383            let states =
384                rows::row_states(hovered, Some(index) == self.selected, focused, pressed && flashed == Some(index));
385            let variant = (item.kind == ItemKind::Faint).then_some("faint");
386            let style = cx.style("list-item", variant, &states);
387            let text_style = style.text();
388            let fixed: Vec<row::Mark> = self
389                .checked
390                .as_ref()
391                .map(|checked| row::check(cx, checked.get(index).copied().unwrap_or(false)))
392                .into_iter()
393                .collect();
394            let icon: Vec<row::Mark> =
395                item.icon.iter().map(|key| row::icon(cx, key, item.icon_color.as_deref(), text_style.fg)).collect();
396            let marks = fixed.iter().chain(&icon).map(|(glyph, _)| text::width(glyph).saturating_add(1));
397            let label_room = content_width.saturating_sub(cells::sum(marks.chain([row::LEAD, 1, 1])));
398            let detail = match item.detail.as_deref() {
399                Some(detail) if self.label_first => fit_detail(detail, &item.label, label_room),
400                detail => detail.map(str::to_owned),
401            };
402            let detail_width = detail.as_deref().map_or(0, |d| text::width(d).saturating_add(DETAIL_GAP));
403            let parts =
404                row::Parts { fixed: &fixed, sliding: &icon, label: &item.label, trailing: detail_width, indent: 0 };
405            row::paint_parts(cx, row_rect, &style, rows::slide(cx, &states) > 0, &parts);
406
407            if let Some(detail) = &detail {
408                let detail_style = cx.style("list-detail", None, &states).text();
409                row::paint_trailing(cx, row_rect, detail, detail_style);
410            }
411        }
412        rows::paint_scrollbar(cx, area, self.items.len(), offset, self.scrollbar);
413    }
414
415    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
416        let area = cx.area();
417        let page = usize::from(area.height.max(1));
418        match event {
419            Event::Key(key) => {
420                let target = if key.is_plain(Key::Up) || key.is_plain(Key::Char('k')) {
421                    self.neighbour(-1)
422                } else if key.is_plain(Key::Down) || key.is_plain(Key::Char('j')) {
423                    self.neighbour(1)
424                } else if key.is_plain(Key::Home) {
425                    self.next_selectable(None, 1)
426                } else if key.is_plain(Key::End) {
427                    self.next_selectable(None, -1)
428                } else if key.is_plain(Key::PageUp) || key.is_plain(Key::PageDown) {
429                    let down = key.is_plain(Key::PageDown);
430                    let mut index = self.selected;
431                    for _ in 0..page {
432                        index = self.next_selectable(index, if down { 1 } else { -1 });
433                    }
434                    index
435                } else if key.is_plain(Key::Enter) {
436                    if let Some(index) = self.selected {
437                        self.activate(cx, index);
438                    }
439                    return self.selected.is_some() && self.on_activate.is_some();
440                } else if key.is_plain(Key::Space) {
441                    let Some(index) = self.selected else { return false };
442                    if let (Some(_), Some(toggle)) = (&self.checked, &self.on_toggle) {
443                        cx.emit(toggle(index));
444                        return true;
445                    }
446                    self.activate(cx, index);
447                    return self.on_activate.is_some();
448                } else {
449                    return false;
450                };
451                if target == self.selected {
452                    return target.is_some();
453                }
454                self.select(cx, target);
455                true
456            }
457            Event::Mouse(mouse) => {
458                if rows::scroll_mouse(cx, mouse, area, self.items.len()) {
459                    return true;
460                }
461                if mouse.kind != MouseKind::Down(MouseButton::Left) {
462                    return false;
463                }
464                let Some(index) = self.row_at(cx, mouse.y).filter(|i| self.items[*i].selectable()) else {
465                    return false;
466                };
467                // The check mark never slides, so its column is the same on every row.
468                if let (Some(_), Some(toggle)) = (&self.checked, &self.on_toggle)
469                    && mouse.x < area.x + i32::from(Self::check_column(cx.env()))
470                {
471                    cx.memory::<Presses>().0.forget();
472                    cx.emit(toggle(index));
473                    return true;
474                }
475                if self.activate_on == Click::Double {
476                    let now = cx.now();
477                    let double = cx.memory::<Presses>().0.press(index, now);
478                    if double {
479                        self.select(cx, Some(index));
480                        self.activate(cx, index);
481                    } else if let Some(message) = &self.on_select {
482                        cx.emit(message(index));
483                    }
484                    return true;
485                }
486                self.select(cx, Some(index));
487                self.activate(cx, index);
488                true
489            }
490            _ => false,
491        }
492    }
493
494    fn focusable(&self) -> bool {
495        self.items.iter().any(ListItem::selectable)
496    }
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502    use crate::runtime::{App, Command, Harness};
503    use crate::widget::View;
504
505    struct Demo {
506        count: usize,
507        selected: Option<usize>,
508        opened: Vec<usize>,
509        checked: Option<Vec<bool>>,
510        /// Whether rows activate on a double click.
511        double: bool,
512    }
513
514    #[derive(Clone)]
515    enum Msg {
516        Select(usize),
517        Open(usize),
518        Toggle(usize),
519    }
520
521    impl App for Demo {
522        type Msg = Msg;
523        fn update(&mut self, msg: Msg) -> Command<Msg> {
524            match msg {
525                Msg::Select(i) => self.selected = Some(i),
526                Msg::Open(i) => self.opened.push(i),
527                Msg::Toggle(i) => {
528                    if let Some(checked) = &mut self.checked {
529                        checked[i] = !checked[i];
530                    }
531                }
532            }
533            Command::none()
534        }
535        fn view(&self, ui: &mut View<'_, Msg>) {
536            let mut items = vec![ListItem::header("CONTAINERS")];
537            items.extend((0..self.count).map(|i| ListItem::new(format!("item {i}")).detail("ready")));
538            let mut list = List::new(items)
539                .selected(self.selected)
540                .on_select(Msg::Select)
541                .on_activate(Msg::Open)
542                .on_toggle(Msg::Toggle);
543            if let Some(checked) = &self.checked {
544                list = list.checked(checked.clone());
545            }
546            if self.double {
547                list = list.activate_on(Click::Double);
548            }
549            ui.add(list).fill().id("list");
550        }
551    }
552
553    fn demo(count: usize) -> Demo {
554        Demo { count, selected: None, opened: Vec::new(), checked: None, double: false }
555    }
556
557    #[test]
558    fn keyboard_skips_headers_and_selected_row_slides() {
559        let mut h = Harness::new(demo(3), 24, 4);
560        h.press("tab").press("down");
561        assert_eq!(h.app().selected, Some(1));
562        let screen = h.screen();
563        assert_eq!(screen, "  CONTAINERS\n▌  item 0         ready\n  item 1          ready\n  item 2          ready\n");
564        h.press("up");
565        assert_eq!(h.app().selected, Some(1));
566        h.press("enter");
567        assert_eq!(h.app().opened, vec![1]);
568    }
569
570    struct Wrapping {
571        selected: Option<usize>,
572        wrap: bool,
573        selections: usize,
574    }
575
576    impl App for Wrapping {
577        type Msg = usize;
578        fn update(&mut self, index: usize) -> Command<usize> {
579            self.selected = Some(index);
580            self.selections += 1;
581            Command::none()
582        }
583        fn view(&self, ui: &mut View<'_, usize>) {
584            let items = [
585                ListItem::header("PROFILES"),
586                ListItem::new("alpha"),
587                ListItem::new("beta"),
588                ListItem::new("gamma"),
589                ListItem::gap(),
590            ];
591            ui.add(List::new(items).selected(self.selected).wrap(self.wrap).on_select(|i| i)).fill().id("list");
592        }
593    }
594
595    /// The label of the row the pillar stands on.
596    fn pillar_row(h: &Harness<Wrapping>) -> String {
597        let screen = h.screen();
598        let line = screen.lines().find(|line| line.starts_with('▌')).unwrap_or_default();
599        line.trim_start_matches('▌').trim().to_owned()
600    }
601
602    #[test]
603    fn a_wrapping_list_goes_round_its_ends_past_headers_and_gaps() {
604        let mut h = Harness::new(Wrapping { selected: None, wrap: true, selections: 0 }, 20, 5);
605        h.set_reduced_motion(true);
606        h.press("tab").press("end");
607        assert_eq!(pillar_row(&h), "gamma");
608        h.press("down");
609        assert_eq!(pillar_row(&h), "alpha", "down on the last row reaches the first, past the gap and the header");
610        h.press("k");
611        assert_eq!(pillar_row(&h), "gamma", "k on the first row reaches the last");
612        h.press("j");
613        assert_eq!(pillar_row(&h), "alpha");
614        assert_eq!(h.app().selections, 4, "every step reports its row once");
615        h.press("pgup");
616        assert_eq!(pillar_row(&h), "alpha", "a page stops at the end");
617        h.press("home");
618        assert_eq!(pillar_row(&h), "alpha");
619    }
620
621    #[test]
622    fn a_list_stops_at_its_ends_unless_it_wraps() {
623        let mut h = Harness::new(Wrapping { selected: None, wrap: false, selections: 0 }, 20, 5);
624        h.set_reduced_motion(true);
625        h.press("tab").press("end").press("down");
626        assert_eq!(pillar_row(&h), "gamma");
627        h.press("home").press("up");
628        assert_eq!(pillar_row(&h), "alpha");
629    }
630
631    #[test]
632    fn a_wrapping_list_of_one_row_keeps_it_and_uses_the_key() {
633        use crate::widgets::Button;
634        struct One(usize);
635        impl App for One {
636            type Msg = usize;
637            fn update(&mut self, _: usize) -> Command<usize> {
638                self.0 += 1;
639                Command::none()
640            }
641            fn view(&self, ui: &mut View<'_, usize>) {
642                ui.add(List::new([ListItem::new("only")]).selected(Some(0)).wrap(true).on_select(|i| i)).fill();
643                ui.add(Button::new("after").on_press(99)).id("after");
644            }
645        }
646        let mut h = Harness::new(One(0), 20, 3);
647        h.press("tab").press("down").press("up");
648        assert_eq!(h.app().0, 0);
649        assert!(!h.is_focused("after"));
650    }
651
652    /// A list of one row, `label` with `detail`, drawn `width` cells wide.
653    fn detailed(label: &'static str, detail: &'static str, width: u16) -> String {
654        struct Detailed(&'static str, &'static str);
655        impl App for Detailed {
656            type Msg = ();
657            fn update(&mut self, (): ()) -> Command<()> {
658                Command::none()
659            }
660            fn view(&self, ui: &mut View<'_, ()>) {
661                let item = ListItem::new(self.0).icon("dot", None).detail(self.1);
662                ui.add(List::new([item]).label_first(true)).fill();
663            }
664        }
665        Harness::new(Detailed(label, detail), width, 1).screen().lines().next().unwrap_or_default().to_owned()
666    }
667
668    #[test]
669    fn a_detail_too_long_for_the_row_is_cut_and_the_label_stays_whole() {
670        let detail = "in the workspace's own container, started on demand";
671        let line = detailed("Shell", detail, 40);
672        assert!(line.contains("Shell"), "the label is whole: {line:?}");
673        assert!(line.contains("in the") && line.trim_end().ends_with('…'), "the detail is cut: {line:?}");
674        assert!(text::width(&line) <= 40);
675    }
676
677    #[test]
678    fn a_detail_with_no_room_left_is_left_out() {
679        let line = detailed("A rather long conversation title", "in the workspace", 40);
680        assert!(line.contains("A rather long conversation title"), "{line:?}");
681        assert!(!line.contains("in the") && !line.contains('…'), "no scrap of the detail: {line:?}");
682    }
683
684    #[test]
685    fn without_label_first_the_detail_keeps_its_width() {
686        struct Plain;
687        impl App for Plain {
688            type Msg = ();
689            fn update(&mut self, (): ()) -> Command<()> {
690                Command::none()
691            }
692            fn view(&self, ui: &mut View<'_, ()>) {
693                let item = ListItem::new("flathub").detail("https://dl.flathub.org/repo/flathub.flatpakrepo");
694                ui.add(List::new([item])).fill();
695            }
696        }
697        let line = Harness::new(Plain, 56, 1).screen().lines().next().unwrap_or_default().to_owned();
698        // Fifty-six cells hold the address but not the name beside it: the name is cut.
699        assert!(line.contains("https://dl.flathub.org/repo/flathub.flatpakrepo"), "{line:?}");
700        assert!(!line.contains("flathub "), "{line:?}");
701    }
702
703    #[test]
704    fn a_wide_row_shows_label_and_detail_whole() {
705        let line = detailed("Shell", "in the workspace's own container", 80);
706        assert!(line.contains("Shell") && line.trim_end().ends_with("in the workspace's own container"), "{line:?}");
707    }
708
709    #[test]
710    fn a_shared_list_looks_and_behaves_like_a_built_one() {
711        struct Shared {
712            items: Arc<[ListItem]>,
713            selected: Option<usize>,
714            shared: bool,
715        }
716        impl App for Shared {
717            type Msg = usize;
718            fn update(&mut self, index: usize) -> Command<usize> {
719                self.selected = Some(index);
720                Command::none()
721            }
722            fn view(&self, ui: &mut View<'_, usize>) {
723                let list =
724                    if self.shared { List::shared(Arc::clone(&self.items)) } else { List::new(self.items.to_vec()) };
725                ui.add(list.selected(self.selected).on_select(|index| index)).fill().id("list");
726            }
727        }
728        let items: Arc<[ListItem]> = (0..1000).map(|i| ListItem::new(format!("deploy {i}")).detail("ready")).collect();
729        let run = |shared: bool| {
730            let mut h = Harness::new(Shared { items: Arc::clone(&items), selected: None, shared }, 24, 4);
731            h.press("tab").press("down").press("pgdn").press("down");
732            (h.app().selected, h.html("list"))
733        };
734        let shared = run(true);
735        assert_eq!(shared.0, Some(5));
736        assert_eq!(shared, run(false));
737        assert_eq!(Arc::strong_count(&items), 1, "the list let go of the shared items");
738    }
739
740    #[test]
741    fn scrolls_to_follow_selection_and_draws_scrollbar() {
742        let mut h = Harness::new(demo(100_000), 24, 5);
743        h.press("tab").press("end");
744        assert_eq!(h.app().selected, Some(100_000));
745        let screen = h.screen();
746        assert!(screen.contains("item 99999"), "{screen}");
747        assert!(!super::super::scrollbar::column(&h, 23).contains(' '), "{screen}");
748    }
749
750    #[test]
751    fn click_selects_and_opens_and_wheel_scrolls() {
752        let mut h = Harness::new(demo(20), 24, 5);
753        h.click_text("item 2");
754        assert_eq!(h.app().selected, Some(3));
755        assert_eq!(h.app().opened, vec![3]);
756        h.mouse(MouseKind::ScrollDown, 3, 2);
757        assert!(!h.screen().contains("CONTAINERS"));
758    }
759
760    #[test]
761    fn activating_on_a_double_click_selects_with_one_and_opens_with_two() {
762        let mut h = Harness::new(Demo { double: true, ..demo(5) }, 24, 7);
763        h.click_text("item 2");
764        assert_eq!((h.app().selected, h.app().opened.as_slice()), (Some(3), &[][..]), "one click selects");
765        h.advance(Click::INTERVAL).click_text("item 2");
766        assert!(h.app().opened.is_empty(), "two clicks further apart than the interval are two clicks");
767        h.click_text("item 2");
768        assert_eq!(h.app().opened, [3], "a double click opens");
769        h.click_text("item 2");
770        assert_eq!(h.app().opened, [3], "a third press starts over");
771        h.click_text("item 0").click_text("item 2");
772        assert_eq!(h.app().opened, [3], "presses on two rows are no double click");
773        h.press("enter");
774        assert_eq!(h.app().opened, [3, 3], "Enter opens the selected row");
775    }
776
777    fn multi(count: usize) -> Demo {
778        Demo { checked: Some(vec![false; count + 1]), ..demo(count) }
779    }
780
781    fn without_slide(app: Demo, width: u16, height: u16) -> Harness<Demo> {
782        let mut env = crate::env::Env::builtin();
783        env.set_slide(false);
784        Harness::with_env(app, env, width, height)
785    }
786
787    #[test]
788    fn multi_select_toggles_with_space() {
789        let mut h = Harness::new(multi(2), 24, 3);
790        h.press("tab").press("down").press("space");
791        assert_eq!(h.app().checked.as_deref(), Some(&[false, true, false][..]));
792        assert_eq!(h.screen(), "  CONTAINERS\n▌ ☑  item 0       ready\n  ☐ item 1        ready\n");
793        assert_eq!(h.fg(2, 1), h.env().theme().color("accent"), "a checked mark takes the accent");
794        assert_eq!(h.fg(2, 2), h.env().theme().color("muted"), "an unchecked mark is faint");
795    }
796
797    #[test]
798    fn check_marks_stay_put_while_the_label_slides() {
799        let mut h = Harness::new(multi(3), 24, 4);
800        h.hover(8, 2);
801        let screen = h.screen();
802        assert_eq!(screen, "  CONTAINERS\n  ☐ item 0        ready\n▌ ☐  item 1       ready\n  ☐ item 2        ready\n");
803        let column = |line: &str| line.chars().position(|c| c == '☐');
804        let lines: Vec<&str> = screen.lines().skip(1).collect();
805        assert!(lines.iter().all(|line| column(line) == Some(2)), "the mark column never moves:\n{screen}");
806        assert_eq!(h.bg(2, 2), h.env().theme().color("raised"), "the mark sits on the raised row");
807
808        let mut h = without_slide(multi(3), 24, 4);
809        h.hover(8, 2);
810        assert_eq!(
811            h.screen(),
812            "  CONTAINERS\n  ☐ item 0        ready\n▌ ☐ item 1        ready\n  ☐ item 2        ready\n"
813        );
814    }
815
816    #[test]
817    fn a_click_on_the_mark_toggles_and_a_click_on_the_label_opens() {
818        let mut h = Harness::new(multi(3), 24, 4);
819        // Row 2 of the screen is item 1, index 2 after the heading.
820        h.hover(8, 2).click(2, 2);
821        assert_eq!(h.app().checked.as_deref(), Some(&[false, false, true, false][..]));
822        assert_eq!((h.app().selected, h.app().opened.as_slice()), (None, &[][..]), "the mark only toggles");
823        h.click(3, 3);
824        assert_eq!(h.app().checked.as_deref(), Some(&[false, false, true, true][..]), "its air cell counts too");
825        h.click(4, 3);
826        assert_eq!((h.app().selected, h.app().opened.as_slice()), (Some(3), &[3][..]), "the label opens the row");
827        assert_eq!(h.app().checked.as_deref(), Some(&[false, false, true, true][..]));
828    }
829
830    #[test]
831    fn a_label_is_cut_at_the_same_place_resting_and_sliding() {
832        struct Long;
833        impl App for Long {
834            type Msg = ();
835            fn update(&mut self, _: ()) -> Command<()> {
836                Command::none()
837            }
838            fn view(&self, ui: &mut View<'_, ()>) {
839                let items = ["docs-preview-environment", "nightly-integration-tests"]
840                    .map(|name| ListItem::new(name).icon("dot", Some("success")).detail("running"));
841                ui.add(List::new(items).checked(vec![true, false])).fill();
842            }
843        }
844        let mut h = Harness::new(Long, 24, 2);
845        assert_eq!(h.screen(), "  ☑ ● docs-p…   running\n  ☐ ● nightl…   running\n");
846        h.hover(10, 1);
847        assert_eq!(h.screen(), "  ☑ ● docs-p…   running\n▌ ☐  ● nightl…  running\n");
848    }
849
850    #[test]
851    fn ascii_marks_are_letters_not_brackets() {
852        let mut h = Harness::new(multi(2), 24, 3);
853        h.set_glyph_mode(crate::icons::GlyphMode::Ascii);
854        h.press("tab").press("down").press("space");
855        assert_eq!(h.screen(), "  CONTAINERS\n  x  item 0       ready\n  o item 1        ready\n");
856        assert_eq!(h.bg(2, 1), h.env().theme().color("active"), "the selection shows by surface alone");
857    }
858
859    #[test]
860    fn empty_list_shows_empty_text() {
861        struct Empty;
862        impl App for Empty {
863            type Msg = ();
864            fn update(&mut self, _: ()) -> Command<()> {
865                Command::none()
866            }
867            fn view(&self, ui: &mut View<'_, ()>) {
868                ui.add(List::new(Vec::new()).empty_text("Nothing here")).fill();
869            }
870        }
871        assert_eq!(Harness::new(Empty, 20, 1).screen(), "  Nothing here\n");
872    }
873}