1use crate::help::Help;
40use crate::key::{Binding, matches};
41use crate::paginator::{Paginator, Type as PaginatorType};
42use crate::spinner::{SpinnerModel, TickMsg};
43use crate::textinput::TextInput;
44use bubbletea::{Cmd, KeyMsg, Message, Model, MouseAction, MouseButton, MouseMsg};
45use lipgloss::{Color, Style, height as lipgloss_height};
46use std::time::Duration;
47
48pub trait Item: Clone + Send + 'static {
50 fn filter_value(&self) -> &str;
52
53 fn title(&self) -> &str {
57 self.filter_value()
58 }
59
60 fn description(&self) -> &str {
65 ""
66 }
67}
68
69pub trait ItemDelegate<I: Item>: Clone + Send + 'static {
71 fn height(&self) -> usize;
73
74 fn spacing(&self) -> usize;
76
77 fn render(&self, item: &I, index: usize, selected: bool, width: usize) -> String;
79
80 fn update(&mut self, _msg: &Message, _item: &mut I) -> Option<Cmd> {
82 None
83 }
84}
85
86#[derive(Debug, Clone)]
88pub struct DefaultDelegate {
89 pub show_description: bool,
91 pub normal_style: Style,
93 pub selected_style: Style,
95 pub item_height: usize,
97 pub item_spacing: usize,
99}
100
101impl Default for DefaultDelegate {
102 fn default() -> Self {
103 Self::new()
104 }
105}
106
107impl DefaultDelegate {
108 #[must_use]
110 pub fn new() -> Self {
111 Self {
112 show_description: true,
113 normal_style: Style::new(),
114 selected_style: Style::new().foreground_color(Color::from("212")).bold(),
115 item_height: 2,
117 item_spacing: 1,
118 }
119 }
120
121 #[must_use]
123 pub fn with_height(mut self, h: usize) -> Self {
124 self.item_height = h;
125 self
126 }
127
128 #[must_use]
130 pub fn with_spacing(mut self, s: usize) -> Self {
131 self.item_spacing = s;
132 self
133 }
134
135 #[must_use]
137 pub fn with_show_description(mut self, v: bool) -> Self {
138 self.show_description = v;
139 self
140 }
141}
142
143impl<I: Item> ItemDelegate<I> for DefaultDelegate {
144 fn height(&self) -> usize {
145 if !self.show_description {
146 return 1;
147 }
148 self.item_height
149 }
150
151 fn spacing(&self) -> usize {
152 self.item_spacing
153 }
154
155 fn render(&self, item: &I, _index: usize, selected: bool, width: usize) -> String {
156 let title = item.title();
157 let desc = item.description();
158
159 let truncate = |value: &str| {
161 use unicode_width::UnicodeWidthStr;
162 if UnicodeWidthStr::width(value) <= width {
163 value.to_string()
164 } else if width == 0 {
165 String::new()
166 } else {
167 let target_width = width.saturating_sub(1);
168 let mut current_width = 0;
169 let mut result = String::new();
170
171 for c in value.chars() {
172 let w = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
173 if current_width + w > target_width {
174 break;
175 }
176 result.push(c);
177 current_width += w;
178 }
179 format!("{}…", result)
180 }
181 };
182
183 let title_trunc = truncate(title);
184 let desc_trunc = truncate(desc);
185
186 if selected {
187 if self.show_description {
188 format!(
189 "{}\n{}",
190 self.selected_style.render(&title_trunc),
191 self.selected_style.render(&desc_trunc)
192 )
193 } else {
194 self.selected_style.render(&title_trunc)
195 }
196 } else {
197 if self.show_description {
198 format!(
199 "{}\n{}",
200 self.normal_style.render(&title_trunc),
201 self.normal_style.render(&desc_trunc)
202 )
203 } else {
204 self.normal_style.render(&title_trunc)
205 }
206 }
207 }
208}
209
210#[derive(Debug, Clone)]
212pub struct Rank {
213 pub index: usize,
215 pub matched_indices: Vec<usize>,
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
221pub enum FilterState {
222 Unfiltered,
224 Filtering,
226 FilterApplied,
228}
229
230impl std::fmt::Display for FilterState {
231 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
232 match self {
233 Self::Unfiltered => write!(f, "unfiltered"),
234 Self::Filtering => write!(f, "filtering"),
235 Self::FilterApplied => write!(f, "filter applied"),
236 }
237 }
238}
239
240pub type FilterFn = Box<dyn Fn(&str, &[String]) -> Vec<Rank> + Send + Sync>;
242
243pub fn default_filter(term: &str, targets: &[String]) -> Vec<Rank> {
245 let term_lower = term.to_lowercase();
246 targets
247 .iter()
248 .enumerate()
249 .filter(|(_, target)| target.to_lowercase().contains(&term_lower))
250 .map(|(index, target)| {
251 let target_lower = target.to_lowercase();
253 let start = target_lower.find(&term_lower).unwrap_or(0);
254 let matched_indices: Vec<usize> = (start..start + term.len()).collect();
255 Rank {
256 index,
257 matched_indices,
258 }
259 })
260 .collect()
261}
262
263#[derive(Debug, Clone)]
265pub struct KeyMap {
266 pub cursor_up: Binding,
268 pub cursor_down: Binding,
270 pub next_page: Binding,
272 pub prev_page: Binding,
274 pub goto_start: Binding,
276 pub goto_end: Binding,
278 pub filter: Binding,
280 pub clear_filter: Binding,
282 pub cancel_while_filtering: Binding,
284 pub accept_while_filtering: Binding,
286 pub show_full_help: Binding,
288 pub close_full_help: Binding,
290 pub quit: Binding,
292 pub force_quit: Binding,
294}
295
296impl Default for KeyMap {
297 fn default() -> Self {
298 Self {
299 cursor_up: Binding::new().keys(&["up", "k"]).help("↑/k", "up"),
300 cursor_down: Binding::new().keys(&["down", "j"]).help("↓/j", "down"),
301 next_page: Binding::new()
302 .keys(&["right", "l", "pgdown"])
303 .help("→/l", "next page"),
304 prev_page: Binding::new()
305 .keys(&["left", "h", "pgup"])
306 .help("←/h", "prev page"),
307 goto_start: Binding::new().keys(&["home", "g"]).help("g/home", "start"),
308 goto_end: Binding::new().keys(&["end", "G"]).help("G/end", "end"),
309 filter: Binding::new().keys(&["/"]).help("/", "filter"),
310 clear_filter: Binding::new().keys(&["esc"]).help("esc", "clear filter"),
311 cancel_while_filtering: Binding::new().keys(&["esc"]).help("esc", "cancel"),
312 accept_while_filtering: Binding::new()
313 .keys(&["enter"])
314 .help("enter", "apply filter"),
315 show_full_help: Binding::new().keys(&["?"]).help("?", "help"),
316 close_full_help: Binding::new()
317 .keys(&["esc", "?"])
318 .help("?/esc", "close help"),
319 quit: Binding::new().keys(&["q"]).help("q", "quit"),
320 force_quit: Binding::new()
321 .keys(&["ctrl+c"])
322 .help("ctrl+c", "force quit"),
323 }
324 }
325}
326
327#[derive(Debug, Clone)]
329pub struct Styles {
330 pub title: Style,
332 pub title_bar: Style,
334 pub filter_prompt: Style,
336 pub filter_cursor: Style,
338 pub status_bar: Style,
340 pub status_empty: Style,
342 pub no_items: Style,
344 pub pagination: Style,
346 pub help: Style,
348 pub active_pagination_dot: Style,
350 pub inactive_pagination_dot: Style,
352 pub divider_dot: Style,
354}
355
356impl Default for Styles {
357 fn default() -> Self {
358 Self {
360 title_bar: Style::new().padding((0u16, 0u16, 1u16, 2u16)),
361 title: Style::new()
362 .background_color(Color::from("62"))
363 .foreground_color(Color::from("230"))
364 .padding((0u16, 1u16)),
365 filter_prompt: Style::new().foreground_color(Color::from("#ECFD65")),
366 filter_cursor: Style::new().foreground_color(Color::from("#EE6FF8")),
367 status_bar: Style::new()
368 .foreground_color(Color::from("240"))
369 .padding((0u16, 0u16, 1u16, 2u16)),
370 status_empty: Style::new().foreground_color(Color::from("240")),
371 no_items: Style::new().foreground_color(Color::from("240")),
372 pagination: Style::new().padding_left(2),
373 help: Style::new()
374 .foreground_color(Color::from("240"))
375 .padding((1u16, 0u16, 0u16, 2u16)),
376 active_pagination_dot: Style::new()
377 .foreground_color(Color::from("240"))
378 .set_string("•"),
379 inactive_pagination_dot: Style::new()
380 .foreground_color(Color::from("240"))
381 .set_string("•"),
382 divider_dot: Style::new()
383 .foreground_color(Color::from("240"))
384 .set_string(" • "),
385 }
386 }
387}
388
389#[derive(Debug, Clone)]
391pub struct FilterMatchesMsg(pub Vec<Rank>);
392
393#[derive(Debug, Clone, Copy)]
395pub struct StatusMessageTimeoutMsg;
396
397#[derive(Clone)]
399pub struct List<I: Item, D: ItemDelegate<I>> {
400 pub title: String,
402 pub show_title: bool,
404 pub show_filter: bool,
406 pub show_status_bar: bool,
408 pub show_pagination: bool,
410 pub show_help: bool,
412 pub filtering_enabled: bool,
414 pub infinite_scrolling: bool,
416 pub item_name_singular: String,
418 pub item_name_plural: String,
420 pub key_map: KeyMap,
422 pub styles: Styles,
424 pub status_message_lifetime: Duration,
426 pub mouse_wheel_enabled: bool,
428 pub mouse_wheel_delta: usize,
430 pub mouse_click_enabled: bool,
432
433 spinner: SpinnerModel,
436 paginator: Paginator,
438 help: Help,
440 filter_input: TextInput,
442
443 items: Vec<I>,
445 filtered_indices: Vec<usize>,
446 delegate: D,
447 width: usize,
448 height: usize,
449 cursor: usize,
450 filter_state: FilterState,
451 show_spinner: bool,
452 status_message: Option<String>,
453}
454
455impl<I: Item, D: ItemDelegate<I>> List<I, D> {
456 #[must_use]
458 pub fn new(items: Vec<I>, delegate: D, width: usize, height: usize) -> Self {
459 let items_len = items.len();
460 let filtered_indices: Vec<usize> = (0..items_len).collect();
461
462 let mut filter_input = TextInput::new();
463 filter_input.prompt = "Filter: ".to_string();
464 filter_input.focus();
466
467 let mut list = Self {
468 title: "List".to_string(),
470 show_title: true,
471 show_filter: true,
472 show_status_bar: true,
473 show_pagination: true,
474 show_help: true,
475 filtering_enabled: true,
476 infinite_scrolling: false,
477 item_name_singular: "item".to_string(),
478 item_name_plural: "items".to_string(),
479 key_map: KeyMap::default(),
480 styles: Styles::default(),
481 status_message_lifetime: Duration::from_secs(1),
482 mouse_wheel_enabled: true,
483 mouse_wheel_delta: 1,
484 mouse_click_enabled: true,
485 spinner: SpinnerModel::new(),
486 paginator: Paginator::new().display_type(PaginatorType::Dots),
487 help: Help::new(),
488 filter_input,
489 items,
490 filtered_indices,
491 delegate,
492 width,
493 height,
494 cursor: 0,
495 filter_state: FilterState::Unfiltered,
496 show_spinner: false,
497 status_message: None,
498 };
499
500 list.update_pagination();
502 list
503 }
504
505 #[must_use]
507 pub fn title(mut self, title: impl Into<String>) -> Self {
508 self.title = title.into();
509 self
510 }
511
512 #[must_use]
514 pub fn mouse_wheel(mut self, enabled: bool) -> Self {
515 self.mouse_wheel_enabled = enabled;
516 self
517 }
518
519 #[must_use]
521 pub fn mouse_wheel_delta(mut self, delta: usize) -> Self {
522 self.mouse_wheel_delta = delta;
523 self
524 }
525
526 #[must_use]
528 pub fn mouse_click(mut self, enabled: bool) -> Self {
529 self.mouse_click_enabled = enabled;
530 self
531 }
532
533 pub fn set_items(&mut self, items: Vec<I>) {
535 let len = items.len();
536 self.items = items;
537 self.filtered_indices = (0..len).collect();
538 self.paginator.set_total_pages_from_items(len);
539 self.paginator.set_page(0);
540 self.cursor = 0;
541 }
542
543 #[must_use]
545 pub fn items(&self) -> &[I] {
546 &self.items
547 }
548
549 #[must_use]
551 pub fn visible_items(&self) -> Vec<&I> {
552 self.filtered_indices
553 .iter()
554 .filter_map(|&i| self.items.get(i))
555 .collect()
556 }
557
558 #[must_use]
560 pub fn index(&self) -> usize {
561 self.cursor
562 }
563
564 #[must_use]
566 pub fn selected_item(&self) -> Option<&I> {
567 self.filtered_indices
568 .get(self.cursor)
569 .and_then(|&i| self.items.get(i))
570 }
571
572 pub fn select(&mut self, index: usize) {
574 self.cursor = index.min(self.filtered_indices.len().saturating_sub(1));
575 }
576
577 pub fn cursor_up(&mut self) {
579 if self.filtered_indices.is_empty() {
580 return;
581 }
582 if self.cursor == 0 {
583 if self.infinite_scrolling {
584 self.cursor = self.filtered_indices.len() - 1;
585 }
586 } else {
587 self.cursor -= 1;
588 }
589 }
590
591 pub fn cursor_down(&mut self) {
593 if self.filtered_indices.is_empty() {
594 return;
595 }
596 if self.cursor >= self.filtered_indices.len() - 1 {
597 if self.infinite_scrolling {
598 self.cursor = 0;
599 }
600 } else {
601 self.cursor += 1;
602 }
603 }
604
605 #[must_use]
607 pub fn filter_state(&self) -> FilterState {
608 self.filter_state
609 }
610
611 #[must_use]
613 pub fn filter_value(&self) -> String {
614 self.filter_input.value()
615 }
616
617 pub fn set_filter_value(&mut self, value: &str) {
619 self.filter_input.set_value(value);
620 self.apply_filter();
621 }
622
623 pub fn reset_filter(&mut self) {
625 self.filter_input.reset();
626 self.filter_state = FilterState::Unfiltered;
627 self.filtered_indices = (0..self.items.len()).collect();
628 self.paginator.set_total_pages_from_items(self.items.len());
629 self.paginator.set_page(0);
630 self.cursor = 0;
631 self.update_pagination();
632 }
633
634 fn apply_filter(&mut self) {
636 let term = self.filter_input.value();
637 if term.is_empty() {
638 self.reset_filter();
639 return;
640 }
641
642 let targets: Vec<String> = self
643 .items
644 .iter()
645 .map(|i| i.filter_value().to_string())
646 .collect();
647 let ranks = default_filter(&term, &targets);
648
649 self.filtered_indices = ranks.iter().map(|r| r.index).collect();
650 self.paginator
651 .set_total_pages_from_items(self.filtered_indices.len());
652 self.paginator.set_page(0);
653 self.cursor = 0;
654 self.filter_state = FilterState::FilterApplied;
655 self.update_pagination();
656 }
657
658 pub fn start_spinner(&mut self) -> Option<Message> {
661 self.show_spinner = true;
662 self.update_pagination();
663 Some(self.spinner.tick())
664 }
665
666 pub fn stop_spinner(&mut self) {
668 self.show_spinner = false;
669 self.update_pagination();
670 }
671
672 #[must_use]
674 pub fn spinner_visible(&self) -> bool {
675 self.show_spinner
676 }
677
678 pub fn new_status_message(&mut self, msg: impl Into<String>) -> Option<Cmd> {
680 self.status_message = Some(msg.into());
681 let lifetime = self.status_message_lifetime;
682 Some(Cmd::new(move || {
683 std::thread::sleep(lifetime);
684 Message::new(StatusMessageTimeoutMsg)
685 }))
686 }
687
688 #[must_use]
690 pub fn status_message(&self) -> Option<&str> {
691 self.status_message.as_deref()
692 }
693
694 pub fn set_width(&mut self, w: usize) {
696 self.width = w;
697 self.help.width = w;
698 }
699
700 pub fn set_height(&mut self, h: usize) {
702 self.height = h;
703 self.update_pagination();
704 }
705
706 #[must_use]
708 pub fn width(&self) -> usize {
709 self.width
710 }
711
712 #[must_use]
714 pub fn height(&self) -> usize {
715 self.height
716 }
717
718 #[must_use]
720 pub fn paginator(&self) -> &Paginator {
721 &self.paginator
722 }
723
724 fn update_pagination(&mut self) {
726 let items_len = self.filtered_indices.len();
727 let item_height = (self.delegate.height() + self.delegate.spacing()).max(1);
728
729 let mut avail_height = self.height;
733
734 if self.show_title || (self.show_filter && self.filtering_enabled) {
735 avail_height = avail_height.saturating_sub(lipgloss_height(&self.title_view()));
736 }
737 if self.show_status_bar {
738 avail_height = avail_height.saturating_sub(lipgloss_height(&self.status_view()));
739 }
740 if self.show_pagination {
741 avail_height = avail_height.saturating_sub(lipgloss_height(&self.pagination_view()));
742 }
743 if self.show_help {
744 avail_height = avail_height.saturating_sub(lipgloss_height(&self.help_view()));
745 }
746
747 let per_page = (avail_height / item_height).max(1);
748
749 let current_page = self.paginator.page();
750 let mut paginator = self.paginator.clone().per_page(per_page);
751 paginator.set_total_pages_from_items(items_len);
752 let max_page = paginator.get_total_pages().saturating_sub(1);
753 paginator.set_page(current_page.min(max_page));
754 self.paginator = paginator;
755 }
756
757 pub fn update(&mut self, msg: Message) -> Option<Cmd> {
759 if msg.is::<StatusMessageTimeoutMsg>() {
761 self.status_message = None;
762 return None;
763 }
764
765 if self.show_spinner && msg.is::<TickMsg>() {
767 return self.spinner.update(msg);
768 }
769
770 if let Some(key) = msg.downcast_ref::<KeyMsg>() {
772 let key_str = key.to_string();
773
774 if self.filter_state == FilterState::Filtering {
776 if matches(&key_str, &[&self.key_map.cancel_while_filtering]) {
777 self.reset_filter();
778 return None;
779 }
780 if matches(&key_str, &[&self.key_map.accept_while_filtering]) {
781 self.apply_filter();
782 self.filter_state = FilterState::FilterApplied;
783 self.filter_input.blur();
784 return None;
785 }
786
787 return self.filter_input.update(msg);
789 }
790
791 if matches(&key_str, &[&self.key_map.cursor_up]) {
793 self.cursor_up();
794 } else if matches(&key_str, &[&self.key_map.cursor_down]) {
795 self.cursor_down();
796 } else if matches(&key_str, &[&self.key_map.next_page]) {
797 self.paginator.next_page();
798 let start = self.paginator.page() * self.paginator.get_per_page();
800 self.cursor = if self.filtered_indices.is_empty() {
801 0
802 } else {
803 start.min(self.filtered_indices.len() - 1)
804 };
805 } else if matches(&key_str, &[&self.key_map.prev_page]) {
806 self.paginator.prev_page();
807 let start = self.paginator.page() * self.paginator.get_per_page();
808 self.cursor = if self.filtered_indices.is_empty() {
809 0
810 } else {
811 start.min(self.filtered_indices.len() - 1)
812 };
813 } else if matches(&key_str, &[&self.key_map.goto_start]) {
814 self.cursor = 0;
815 self.paginator.set_page(0);
816 } else if matches(&key_str, &[&self.key_map.goto_end]) {
817 self.cursor = self.filtered_indices.len().saturating_sub(1);
818 self.paginator
819 .set_page(self.paginator.get_total_pages().saturating_sub(1));
820 } else if matches(&key_str, &[&self.key_map.filter]) && self.filtering_enabled {
821 self.filter_state = FilterState::Filtering;
822 self.filter_input.focus();
823 self.update_pagination();
824 } else if matches(&key_str, &[&self.key_map.clear_filter]) {
825 self.reset_filter();
826 } else if matches(&key_str, &[&self.key_map.show_full_help]) {
827 self.help.show_all = true;
828 } else if matches(&key_str, &[&self.key_map.close_full_help]) {
829 self.help.show_all = false;
830 }
831 }
832
833 if let Some(mouse) = msg.downcast_ref::<MouseMsg>() {
835 if mouse.action != MouseAction::Press {
837 return None;
838 }
839
840 match mouse.button {
841 MouseButton::WheelUp if self.mouse_wheel_enabled => {
843 for _ in 0..self.mouse_wheel_delta {
844 self.cursor_up();
845 }
846 }
847 MouseButton::WheelDown if self.mouse_wheel_enabled => {
848 for _ in 0..self.mouse_wheel_delta {
849 self.cursor_down();
850 }
851 }
852 MouseButton::Left if self.mouse_click_enabled => {
854 let mut content_start_y = 0usize;
858 if self.show_title && !self.title.is_empty() {
859 content_start_y += 1;
860 }
861 if self.show_filter && self.filter_state == FilterState::Filtering {
862 content_start_y += 1;
863 }
864
865 let click_y = mouse.y as usize;
866 if click_y >= content_start_y {
867 let item_height = self.delegate.height() + self.delegate.spacing();
868 let relative_y = click_y - content_start_y;
869 let item_index_in_view = relative_y / item_height.max(1);
870
871 let per_page = self.paginator.get_per_page();
873 let page_start = self.paginator.page() * per_page;
874 let target_cursor = page_start + item_index_in_view;
875
876 if target_cursor < self.filtered_indices.len() {
878 self.cursor = target_cursor;
879 }
880 }
881 }
882 _ => {}
883 }
884 }
885
886 None
887 }
888
889 fn title_view(&self) -> String {
890 let mut view = String::new();
891
892 if self.show_filter && self.filter_state == FilterState::Filtering {
894 view.push_str(&self.filter_input.view());
895 } else if self.show_title {
896 view.push_str(&self.styles.title.render(&self.title));
897
898 if self.filter_state != FilterState::Filtering
900 && let Some(msg) = self.status_message.as_deref()
901 {
902 view.push_str(" ");
903 view.push_str(msg);
904 }
905 }
906
907 if self.show_spinner {
909 let spinner_view = self.spinner.view();
910 let gap = " ";
912 if self.width > 0 {
913 let current_w = lipgloss::width(&view);
914 let spinner_w = lipgloss::width(&spinner_view);
915 if current_w + lipgloss::width(gap) + spinner_w <= self.width {
916 view.push_str(gap);
917 view.push_str(&spinner_view);
918 }
919 }
920 }
921
922 if view.is_empty() {
923 return view;
924 }
925 self.styles.title_bar.render(&view)
926 }
927
928 fn status_view(&self) -> String {
929 let total_items = self.items.len();
930 let visible_items = self.filtered_indices.len();
931
932 let item_name = if visible_items == 1 {
933 &self.item_name_singular
934 } else {
935 &self.item_name_plural
936 };
937
938 let items_display = format!("{visible_items} {item_name}");
939
940 let mut status = String::new();
941 if self.filter_state == FilterState::Filtering {
942 if visible_items == 0 {
943 status = self.styles.status_empty.render("Nothing matched");
944 } else {
945 status = items_display;
946 }
947 } else if total_items == 0 {
948 status = self
949 .styles
950 .status_empty
951 .render(&format!("No {}", self.item_name_plural));
952 } else {
953 if self.filter_state == FilterState::FilterApplied {
954 let mut f = self.filter_input.value();
955 f = f.trim().to_string();
956 if f.chars().count() > 10 {
958 f = f.chars().take(10).collect::<String>() + "…";
959 }
960 status.push('“');
961 status.push_str(&f);
962 status.push_str("” ");
963 }
964 status.push_str(&items_display);
965 }
966
967 let num_filtered = total_items.saturating_sub(visible_items);
968 if num_filtered > 0 {
969 status.push_str(&self.styles.divider_dot.render(" • "));
970 status.push_str(&format!("{num_filtered} filtered"));
971 }
972
973 self.styles.status_bar.render(&status)
974 }
975
976 fn pagination_view(&self) -> String {
977 if self.paginator.get_total_pages() < 2 {
978 return String::new();
979 }
980 self.styles.pagination.render(&self.paginator.view())
981 }
982
983 fn help_view(&self) -> String {
984 let bindings: Vec<&Binding> = vec![
985 &self.key_map.cursor_up,
986 &self.key_map.cursor_down,
987 &self.key_map.filter,
988 &self.key_map.quit,
989 ];
990 self.styles
991 .help
992 .render(&self.help.short_help_view(&bindings))
993 }
994
995 fn populated_view(&self) -> String {
996 if self.filtered_indices.is_empty() {
997 if self.filter_state == FilterState::Filtering {
998 return String::new();
999 }
1000 return self
1001 .styles
1002 .no_items
1003 .render(&format!("No {}.", self.item_name_plural));
1004 }
1005
1006 let total_visible = self.filtered_indices.len();
1007 let per_page = self.paginator.get_per_page();
1008 let (start, end) = self.paginator.get_slice_bounds(total_visible);
1009
1010 let mut out = String::new();
1011 for (i, &item_idx) in self.filtered_indices[start..end].iter().enumerate() {
1012 let global_idx = start + i;
1013 let selected = global_idx == self.cursor;
1014 if let Some(item) = self.items.get(item_idx) {
1015 out.push_str(&self.delegate.render(item, global_idx, selected, self.width));
1016 if i != (end - start).saturating_sub(1) {
1017 out.push_str(&"\n".repeat(self.delegate.spacing() + 1));
1018 }
1019 }
1020 }
1021
1022 let items_on_page = end.saturating_sub(start);
1025 if items_on_page < per_page {
1026 let n = (per_page - items_on_page) * (self.delegate.height() + self.delegate.spacing());
1027 out.push_str(&"\n".repeat(n));
1028 }
1029
1030 out
1031 }
1032
1033 #[must_use]
1035 pub fn view(&self) -> String {
1036 let mut sections: Vec<String> = Vec::new();
1037
1038 if self.show_title || (self.show_filter && self.filtering_enabled) {
1039 sections.push(self.title_view());
1040 }
1041
1042 sections.push(self.populated_view());
1043
1044 if self.show_status_bar {
1045 sections.push(self.status_view());
1046 }
1047
1048 if self.show_pagination {
1049 sections.push(self.pagination_view());
1050 }
1051
1052 if self.show_help {
1053 sections.push(self.help_view());
1054 }
1055
1056 sections.join("\n")
1057 }
1058
1059 #[must_use]
1064 pub fn init(&self) -> Option<Cmd> {
1065 None
1066 }
1067}
1068
1069impl<I: Item, D: ItemDelegate<I>> Model for List<I, D> {
1071 fn init(&self) -> Option<Cmd> {
1072 List::init(self)
1073 }
1074
1075 fn update(&mut self, msg: Message) -> Option<Cmd> {
1076 List::update(self, msg)
1077 }
1078
1079 fn view(&self) -> String {
1080 List::view(self)
1081 }
1082}
1083
1084impl<I: Item + std::fmt::Debug, D: ItemDelegate<I> + std::fmt::Debug> std::fmt::Debug
1086 for List<I, D>
1087{
1088 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1089 f.debug_struct("List")
1090 .field("title", &self.title)
1091 .field("items_count", &self.items.len())
1092 .field("cursor", &self.cursor)
1093 .field("filter_state", &self.filter_state)
1094 .finish()
1095 }
1096}
1097
1098#[cfg(test)]
1099mod tests {
1100 use super::*;
1101
1102 #[derive(Debug, Clone)]
1103 struct TestItem {
1104 name: String,
1105 }
1106
1107 impl Item for TestItem {
1108 fn filter_value(&self) -> &str {
1109 &self.name
1110 }
1111 }
1112
1113 fn test_items() -> Vec<TestItem> {
1114 vec![
1115 TestItem {
1116 name: "Apple".into(),
1117 },
1118 TestItem {
1119 name: "Banana".into(),
1120 },
1121 TestItem {
1122 name: "Cherry".into(),
1123 },
1124 TestItem {
1125 name: "Date".into(),
1126 },
1127 ]
1128 }
1129
1130 #[test]
1131 fn test_list_new() {
1132 let list = List::new(test_items(), DefaultDelegate::new(), 80, 24);
1133 assert_eq!(list.items().len(), 4);
1134 assert_eq!(list.index(), 0);
1135 }
1136
1137 #[test]
1138 fn test_list_navigation() {
1139 let mut list = List::new(test_items(), DefaultDelegate::new(), 80, 24);
1140
1141 assert_eq!(list.index(), 0);
1142
1143 list.cursor_down();
1144 assert_eq!(list.index(), 1);
1145
1146 list.cursor_down();
1147 assert_eq!(list.index(), 2);
1148
1149 list.cursor_up();
1150 assert_eq!(list.index(), 1);
1151 }
1152
1153 #[test]
1154 fn test_list_selected_item() {
1155 let mut list = List::new(test_items(), DefaultDelegate::new(), 80, 24);
1156
1157 assert_eq!(list.selected_item().map(|i| i.name.as_str()), Some("Apple"));
1158
1159 list.cursor_down();
1160 assert_eq!(
1161 list.selected_item().map(|i| i.name.as_str()),
1162 Some("Banana")
1163 );
1164 }
1165
1166 #[test]
1167 fn test_list_filter() {
1168 let mut list = List::new(test_items(), DefaultDelegate::new(), 80, 24);
1169
1170 list.set_filter_value("an");
1171
1172 assert_eq!(list.visible_items().len(), 1);
1174 assert_eq!(list.visible_items()[0].name, "Banana");
1175 }
1176
1177 #[test]
1178 fn test_list_reset_filter() {
1179 let mut list = List::new(test_items(), DefaultDelegate::new(), 80, 24);
1180
1181 list.set_filter_value("an");
1182 assert_eq!(list.visible_items().len(), 1);
1183
1184 list.reset_filter();
1185 assert_eq!(list.visible_items().len(), 4);
1186 }
1187
1188 #[test]
1189 fn test_cancel_filter_resets_pagination() {
1190 let mut list = List::new(test_items(), DefaultDelegate::new(), 80, 10);
1191
1192 list.filter_state = FilterState::Filtering;
1193 list.filtered_indices = vec![0];
1194 list.paginator
1195 .set_total_pages_from_items(list.filtered_indices.len());
1196
1197 let key_msg = Message::new(KeyMsg::from_type(bubbletea::KeyType::Esc));
1198 let _ = list.update(key_msg);
1199
1200 assert_eq!(list.filter_state, FilterState::Unfiltered);
1201 assert_eq!(list.filtered_indices.len(), list.items.len());
1202 let per_page = list.paginator.get_per_page();
1204 let expected_pages = list.items.len().div_ceil(per_page);
1205 assert_eq!(list.paginator.get_total_pages(), expected_pages);
1206 assert_eq!(list.cursor, 0);
1207 }
1208
1209 #[test]
1210 fn test_list_filter_state() {
1211 let list = List::new(test_items(), DefaultDelegate::new(), 80, 24);
1212 assert_eq!(list.filter_state(), FilterState::Unfiltered);
1213 }
1214
1215 #[test]
1216 fn test_list_infinite_scroll() {
1217 let mut list = List::new(test_items(), DefaultDelegate::new(), 80, 24);
1218 list.infinite_scrolling = true;
1219
1220 list.cursor_up();
1222 assert_eq!(list.index(), 3);
1223
1224 list.cursor_down();
1226 assert_eq!(list.index(), 0);
1227 }
1228
1229 #[test]
1230 fn test_list_status_message() {
1231 let mut list = List::new(test_items(), DefaultDelegate::new(), 80, 24);
1232
1233 assert!(list.status_message().is_none());
1234
1235 list.new_status_message("Test message");
1236 assert_eq!(list.status_message(), Some("Test message"));
1237 }
1238
1239 #[test]
1240 fn test_list_status_message_uses_singular_name() {
1241 let items = vec![TestItem {
1242 name: "Apple".into(),
1243 }];
1244 let mut list = List::new(items, DefaultDelegate::new(), 80, 6);
1245 list.item_name_singular = "fruit".to_string();
1246 list.item_name_plural = "fruits".to_string();
1247
1248 let view = list.view();
1249 assert!(view.contains("1 fruit"));
1250 }
1251
1252 #[test]
1253 fn test_list_apply_filter_resets_page() {
1254 let mut list = List::new(test_items(), DefaultDelegate::new(), 80, 5);
1255 list.paginator.set_page(2);
1256
1257 list.set_filter_value("a");
1258
1259 assert_eq!(list.paginator.page(), 0);
1260 }
1261
1262 #[test]
1263 fn test_list_reset_filter_resets_page() {
1264 let mut list = List::new(test_items(), DefaultDelegate::new(), 80, 5);
1265 list.paginator.set_page(3);
1266
1267 list.reset_filter();
1268
1269 assert_eq!(list.paginator.page(), 0);
1270 }
1271
1272 #[test]
1273 fn test_list_spinner() {
1274 let mut list = List::new(test_items(), DefaultDelegate::new(), 80, 24);
1275
1276 assert!(!list.spinner_visible());
1277
1278 list.start_spinner();
1279 assert!(list.spinner_visible());
1280
1281 list.stop_spinner();
1282 assert!(!list.spinner_visible());
1283 }
1284
1285 #[test]
1286 fn test_list_view() {
1287 let list = List::new(test_items(), DefaultDelegate::new(), 80, 24).title("Fruits");
1288
1289 let view = list.view();
1290 assert!(view.contains("Fruits"));
1291 assert!(view.contains("Apple"));
1292 }
1293
1294 #[test]
1295 fn test_default_filter() {
1296 let targets = vec![
1297 "Apple".to_string(),
1298 "Banana".to_string(),
1299 "Cherry".to_string(),
1300 ];
1301
1302 let ranks = default_filter("an", &targets);
1303 assert_eq!(ranks.len(), 1);
1304 assert_eq!(ranks[0].index, 1); }
1306
1307 #[test]
1308 fn test_default_delegate() {
1309 let delegate = DefaultDelegate::new().with_height(2).with_spacing(1);
1310 assert_eq!(delegate.item_height, 2);
1311 assert_eq!(delegate.item_spacing, 1);
1312 }
1313
1314 #[test]
1315 fn test_keymap_default() {
1316 let km = KeyMap::default();
1317 assert!(!km.cursor_up.get_keys().is_empty());
1318 assert!(!km.filter.get_keys().is_empty());
1319 }
1320
1321 #[test]
1322 fn test_filter_state_display() {
1323 assert_eq!(FilterState::Unfiltered.to_string(), "unfiltered");
1324 assert_eq!(FilterState::Filtering.to_string(), "filtering");
1325 assert_eq!(FilterState::FilterApplied.to_string(), "filter applied");
1326 }
1327
1328 #[test]
1331 fn test_model_trait_init_returns_none() {
1332 let list = List::new(test_items(), DefaultDelegate::new(), 80, 24);
1333 let cmd = Model::init(&list);
1335 assert!(cmd.is_none(), "Model::init should return None for List");
1336 }
1337
1338 #[test]
1339 fn test_model_trait_view_returns_content() {
1340 let list = List::new(test_items(), DefaultDelegate::new(), 80, 24).title("Test List");
1341 let view = Model::view(&list);
1343 assert!(view.contains("Test List"), "View should contain the title");
1344 assert!(view.contains("Apple"), "View should contain first item");
1345 }
1346
1347 #[test]
1348 fn test_model_trait_update_handles_messages() {
1349 let mut list = List::new(test_items(), DefaultDelegate::new(), 80, 24);
1350 assert_eq!(list.index(), 0);
1351
1352 let key_msg = Message::new(KeyMsg {
1354 key_type: bubbletea::KeyType::Runes,
1355 runes: vec!['j'], alt: false,
1357 paste: false,
1358 });
1359
1360 let _ = Model::update(&mut list, key_msg);
1362 assert_eq!(list.index(), 1, "Cursor should have moved down");
1363 }
1364
1365 #[test]
1366 fn test_list_satisfies_model_bounds() {
1367 fn accepts_model<M: Model + Send + 'static>(_model: M) {}
1369 let list = List::new(test_items(), DefaultDelegate::new(), 80, 24);
1370 accepts_model(list);
1371 }
1372
1373 #[test]
1374 fn test_list_pagination_calculation() {
1375 let delegate = DefaultDelegate::new()
1378 .with_show_description(false)
1379 .with_spacing(0)
1380 .with_height(1);
1381 let list = List::new(test_items(), delegate, 80, 10);
1382
1383 let mut avail_height = list.height;
1384 if list.show_title || (list.show_filter && list.filtering_enabled) {
1385 avail_height = avail_height.saturating_sub(lipgloss_height(&list.title_view()));
1386 }
1387 if list.show_status_bar {
1388 avail_height = avail_height.saturating_sub(lipgloss_height(&list.status_view()));
1389 }
1390 if list.show_pagination {
1391 avail_height = avail_height.saturating_sub(lipgloss_height(&list.pagination_view()));
1392 }
1393 if list.show_help {
1394 avail_height = avail_height.saturating_sub(lipgloss_height(&list.help_view()));
1395 }
1396
1397 let item_height = (<DefaultDelegate as ItemDelegate<TestItem>>::height(&list.delegate)
1398 + <DefaultDelegate as ItemDelegate<TestItem>>::spacing(&list.delegate))
1399 .max(1);
1400 let expected_per_page = (avail_height / item_height).max(1);
1401 assert_eq!(list.paginator().get_per_page(), expected_per_page);
1402 }
1403
1404 #[test]
1405 fn test_list_paginator_accessor() {
1406 let list = List::new(test_items(), DefaultDelegate::new(), 80, 24);
1407 assert!(list.paginator().get_per_page() > 0);
1409 assert_eq!(list.paginator().page(), 0);
1410 }
1411
1412 #[test]
1413 fn test_list_pagination_with_many_items() {
1414 let items: Vec<TestItem> = (1..=50)
1415 .map(|i| TestItem {
1416 name: format!("Item {}", i),
1417 })
1418 .collect();
1419 let delegate = DefaultDelegate::new()
1420 .with_show_description(false)
1421 .with_spacing(0)
1422 .with_height(1);
1423 let list = List::new(items, delegate, 80, 10);
1424 let per_page = list.paginator().get_per_page();
1425 let expected_pages = list.items.len().div_ceil(per_page);
1426 assert_eq!(list.paginator().get_total_pages(), expected_pages);
1427 }
1428}