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