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::row;
16use super::rows::{self, RowScroll};
17use super::scrollbar::ScrollbarStyle;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum ItemKind {
22 Normal,
24 Faint,
26 Header,
28 Gap,
30}
31
32#[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 #[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 #[must_use]
51 pub fn header(label: impl Into<String>) -> Self {
52 Self { kind: ItemKind::Header, ..Self::new(label) }
53 }
54
55 #[must_use]
57 pub fn gap() -> Self {
58 Self { kind: ItemKind::Gap, ..Self::new("") }
59 }
60
61 #[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 #[must_use]
71 pub fn detail(mut self, detail: impl Into<String>) -> Self {
72 self.detail = Some(detail.into());
73 self
74 }
75
76 #[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
90pub 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
115enum 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 #[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 #[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 #[must_use]
161 pub fn scrollbar(mut self, style: ScrollbarStyle) -> Self {
162 self.scrollbar = Some(style);
163 self
164 }
165
166 #[must_use]
168 pub fn selected(mut self, index: Option<usize>) -> Self {
169 self.selected = index;
170 self
171 }
172
173 #[must_use]
175 pub fn checked(mut self, checked: Vec<bool>) -> Self {
176 self.checked = Some(checked);
177 self
178 }
179
180 #[must_use]
182 pub fn empty_text(mut self, text: impl Into<String>) -> Self {
183 self.empty = text.into();
184 self
185 }
186
187 #[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 #[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 #[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 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 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 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}