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> {
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
122const DETAIL_GAP: u16 = 2;
124
125const DETAIL_MIN: u16 = 4;
127
128fn 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#[derive(Default)]
146struct Presses(LastPress<usize>);
147
148enum 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 #[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 #[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 #[must_use]
197 pub fn scrollbar(mut self, style: ScrollbarStyle) -> Self {
198 self.scrollbar = Some(style);
199 self
200 }
201
202 #[must_use]
204 pub fn selected(mut self, index: Option<usize>) -> Self {
205 self.selected = index;
206 self
207 }
208
209 #[must_use]
211 pub fn checked(mut self, checked: Vec<bool>) -> Self {
212 self.checked = Some(checked);
213 self
214 }
215
216 #[must_use]
218 pub fn empty_text(mut self, text: impl Into<String>) -> Self {
219 self.empty = text.into();
220 self
221 }
222
223 #[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 #[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 #[must_use]
245 pub fn activate_on(mut self, click: Click) -> Self {
246 self.activate_on = click;
247 self
248 }
249
250 #[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 #[must_use]
261 pub fn wrap(mut self, wrap: bool) -> Self {
262 self.wrap = wrap;
263 self
264 }
265
266 #[must_use]
272 pub fn label_first(mut self, label_first: bool) -> Self {
273 self.label_first = label_first;
274 self
275 }
276
277 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 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 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 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 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 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 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 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}