1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum ItemKind {
23 Normal,
25 Faint,
27 Header,
29 Gap,
31}
32
33#[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 #[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 #[must_use]
52 pub fn header(label: impl Into<String>) -> Self {
53 Self { kind: ItemKind::Header, ..Self::new(label) }
54 }
55
56 #[must_use]
58 pub fn gap() -> Self {
59 Self { kind: ItemKind::Gap, ..Self::new("") }
60 }
61
62 #[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 #[must_use]
72 pub fn detail(mut self, detail: impl Into<String>) -> Self {
73 self.detail = Some(detail.into());
74 self
75 }
76
77 #[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
91pub 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#[derive(Default)]
121struct Presses(LastPress<usize>);
122
123enum 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 #[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 #[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 #[must_use]
170 pub fn scrollbar(mut self, style: ScrollbarStyle) -> Self {
171 self.scrollbar = Some(style);
172 self
173 }
174
175 #[must_use]
177 pub fn selected(mut self, index: Option<usize>) -> Self {
178 self.selected = index;
179 self
180 }
181
182 #[must_use]
184 pub fn checked(mut self, checked: Vec<bool>) -> Self {
185 self.checked = Some(checked);
186 self
187 }
188
189 #[must_use]
191 pub fn empty_text(mut self, text: impl Into<String>) -> Self {
192 self.empty = text.into();
193 self
194 }
195
196 #[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 #[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 #[must_use]
218 pub fn activate_on(mut self, click: Click) -> Self {
219 self.activate_on = click;
220 self
221 }
222
223 #[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 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 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 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 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}