1pub mod defaultitem;
44pub mod keys;
45pub mod style;
46
47use crate::{help, key, paginator, spinner, textinput};
48use bubbletea_rs::{Cmd, KeyMsg, Model as BubbleTeaModel, Msg};
49use crossterm::event::KeyCode;
50use fuzzy_matcher::skim::SkimMatcherV2;
51use fuzzy_matcher::FuzzyMatcher;
52use lipgloss_extras::lipgloss;
53use std::fmt::Display;
54
55pub trait Item: Display + Clone {
59 fn filter_value(&self) -> String;
61}
62
63pub trait ItemDelegate<I: Item> {
65 fn render(&self, m: &Model<I>, index: usize, item: &I) -> String;
67 fn height(&self) -> usize;
69 fn spacing(&self) -> usize;
71 fn update(&self, msg: &Msg, m: &mut Model<I>) -> Option<Cmd>;
73}
74
75#[derive(Debug, Clone)]
77#[allow(dead_code)]
78struct FilteredItem<I: Item> {
79 index: usize, item: I,
81 matches: Vec<usize>,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
88pub enum FilterState {
89 Unfiltered,
91 Filtering,
93 FilterApplied,
95}
96
97pub struct Model<I: Item> {
99 pub title: String,
101 items: Vec<I>,
102 delegate: Box<dyn ItemDelegate<I> + Send + Sync>,
103
104 pub filter_input: textinput::Model,
107 pub paginator: paginator::Model,
109 pub spinner: spinner::Model,
111 pub help: help::Model,
113 pub keymap: keys::ListKeyMap,
115
116 filter_state: FilterState,
118 filtered_items: Vec<FilteredItem<I>>,
119 cursor: usize,
120 viewport_start: usize,
137 width: usize,
138 height: usize,
139
140 pub styles: style::ListStyles,
143
144 status_item_singular: Option<String>,
146 status_item_plural: Option<String>,
147}
148
149impl<I: Item + Send + Sync + 'static> Model<I> {
150 pub fn new(
152 items: Vec<I>,
153 delegate: impl ItemDelegate<I> + Send + Sync + 'static,
154 width: usize,
155 height: usize,
156 ) -> Self {
157 let mut filter_input = textinput::new();
158 filter_input.set_placeholder("Filter...");
159 let mut paginator = paginator::Model::new();
160 paginator.set_per_page(10);
161
162 let mut s = Self {
163 title: "List".to_string(),
164 items,
165 delegate: Box::new(delegate),
166 filter_input,
167 paginator,
168 spinner: spinner::Model::new(),
169 help: help::Model::new(),
170 keymap: keys::ListKeyMap::default(),
171 filter_state: FilterState::Unfiltered,
172 filtered_items: vec![],
173 cursor: 0,
174 viewport_start: 0,
175 width,
176 height,
177 styles: style::ListStyles::default(),
178 status_item_singular: None,
179 status_item_plural: None,
180 };
181 s.update_pagination();
182 s
183 }
184
185 pub fn set_items(&mut self, items: Vec<I>) {
187 self.items = items;
188 self.update_pagination();
189 }
190 pub fn visible_items(&self) -> Vec<I> {
192 if self.filter_state == FilterState::Unfiltered {
193 self.items.clone()
194 } else {
195 self.filtered_items.iter().map(|f| f.item.clone()).collect()
196 }
197 }
198 pub fn set_filter_text(&mut self, s: &str) {
200 self.filter_input.set_value(s);
201 }
202 pub fn set_filter_state(&mut self, st: FilterState) {
204 self.filter_state = st;
205 }
206 pub fn set_status_bar_item_name(&mut self, singular: &str, plural: &str) {
208 self.status_item_singular = Some(singular.to_string());
209 self.status_item_plural = Some(plural.to_string());
210 }
211 pub fn status_view(&self) -> String {
213 self.view_footer()
214 }
215
216 pub fn matches_for_original_item(&self, original_index: usize) -> Option<&Vec<usize>> {
219 if self.filter_state == FilterState::Unfiltered {
220 return None;
221 }
222
223 self.filtered_items
225 .iter()
226 .find(|fi| fi.index == original_index)
227 .map(|fi| &fi.matches)
228 }
229
230 pub fn with_title(mut self, title: &str) -> Self {
232 self.title = title.to_string();
233 self
234 }
235 pub fn selected_item(&self) -> Option<&I> {
237 if self.filter_state == FilterState::Unfiltered {
238 self.items.get(self.cursor)
239 } else {
240 self.filtered_items.get(self.cursor).map(|fi| &fi.item)
241 }
242 }
243 pub fn cursor(&self) -> usize {
245 self.cursor
246 }
247 pub fn len(&self) -> usize {
249 if self.filter_state == FilterState::Unfiltered {
250 self.items.len()
251 } else {
252 self.filtered_items.len()
253 }
254 }
255 pub fn is_empty(&self) -> bool {
257 self.len() == 0
258 }
259
260 fn update_pagination(&mut self) {
261 let item_count = self.len();
262 let item_height = self.delegate.height() + self.delegate.spacing();
263 let available_height = self.height.saturating_sub(4);
264 let per_page = if item_height > 0 {
265 available_height / item_height
266 } else {
267 10
268 }
269 .max(1);
270 self.paginator.set_per_page(per_page);
271 self.paginator
272 .set_total_pages(item_count.div_ceil(per_page));
273 if self.cursor >= item_count {
274 self.cursor = item_count.saturating_sub(1);
275 }
276 }
277
278 fn sync_viewport_with_cursor(&mut self) {
306 let viewport_size = self.paginator.per_page;
307 let total_items = self.len();
308
309 if viewport_size == 0 || total_items <= viewport_size {
310 self.viewport_start = 0;
312 return;
313 }
314
315 let viewport_end = self.viewport_start + viewport_size;
317
318 if self.cursor < self.viewport_start {
320 self.viewport_start = self.cursor;
322 } else if self.cursor >= viewport_end {
323 self.viewport_start = self.cursor - viewport_size + 1;
325 }
326 self.viewport_start = self
330 .viewport_start
331 .min(total_items.saturating_sub(viewport_size));
332 }
333
334 #[allow(dead_code)]
335 fn matches_for_item(&self, index: usize) -> Option<&Vec<usize>> {
336 if index < self.filtered_items.len() {
337 Some(&self.filtered_items[index].matches)
338 } else {
339 None
340 }
341 }
342
343 fn apply_filter(&mut self) {
344 let filter_term = self.filter_input.value().to_lowercase();
345 if filter_term.is_empty() {
346 self.filter_state = FilterState::Unfiltered;
347 self.filtered_items.clear();
348 } else {
349 let matcher = SkimMatcherV2::default();
350 self.filtered_items = self
351 .items
352 .iter()
353 .enumerate()
354 .filter_map(|(i, item)| {
355 matcher
356 .fuzzy_indices(&item.filter_value(), &filter_term)
357 .map(|(_score, indices)| FilteredItem {
358 index: i,
359 item: item.clone(),
360 matches: indices,
361 })
362 })
363 .collect();
364 self.filter_state = FilterState::Filtering;
370 }
371 self.cursor = 0;
372 self.update_pagination();
373 }
374
375 fn view_header(&self) -> String {
376 if self.filter_state == FilterState::Filtering {
377 let prompt = self.styles.filter_prompt.clone().render("Filter:");
378 format!("{} {}", prompt, self.filter_input.view())
379 } else {
380 let mut header = self.title.clone();
381 if self.filter_state == FilterState::FilterApplied {
382 header.push_str(&format!(" (filtered: {})", self.len()));
383 }
384 self.styles.title.clone().render(&header)
385 }
386 }
387
388 fn view_items(&self) -> String {
389 if self.is_empty() {
390 return self.styles.no_items.clone().render("No items");
391 }
392
393 let items_to_render: Vec<(usize, &I)> = if self.filter_state == FilterState::Unfiltered {
394 self.items.iter().enumerate().collect()
395 } else {
396 self.filtered_items
397 .iter()
398 .map(|fi| (fi.index, &fi.item))
399 .collect()
400 };
401
402 let start = self.viewport_start;
411 let viewport_size = self.paginator.per_page;
412 let end = (start + viewport_size).min(items_to_render.len());
413 let mut items = Vec::new();
414
415 for (_filtered_idx, (orig_idx, item)) in items_to_render
420 .iter()
421 .enumerate()
422 .take(end.min(items_to_render.len()))
423 .skip(start)
424 {
425 let item_output = self.delegate.render(self, *orig_idx, item);
438 items.push(item_output);
439 }
440
441 let separator = "\n".repeat(self.delegate.spacing().max(1));
443 items.join(&separator)
444 }
445
446 fn view_footer(&self) -> String {
447 let mut footer = String::new();
448 if !self.is_empty() {
449 let singular = self.status_item_singular.as_deref().unwrap_or("item");
450 let plural = self.status_item_plural.as_deref().unwrap_or("items");
451 let noun = if self.len() == 1 { singular } else { plural };
452 footer.push_str(&format!("{}/{} {}", self.cursor + 1, self.len(), noun));
453 }
454 let help_view = self.help.view(self);
455 if !help_view.is_empty() {
456 footer.push('\n');
457 footer.push_str(&help_view);
458 }
459 footer
460 }
461}
462
463impl<I: Item> help::KeyMap for Model<I> {
465 fn short_help(&self) -> Vec<&key::Binding> {
466 match self.filter_state {
467 FilterState::Filtering => vec![&self.keymap.accept_filter, &self.keymap.cancel_filter],
468 _ => vec![
469 &self.keymap.cursor_up,
470 &self.keymap.cursor_down,
471 &self.keymap.filter,
472 ],
473 }
474 }
475 fn full_help(&self) -> Vec<Vec<&key::Binding>> {
476 match self.filter_state {
477 FilterState::Filtering => {
478 vec![vec![&self.keymap.accept_filter, &self.keymap.cancel_filter]]
479 }
480 _ => vec![
481 vec![
482 &self.keymap.cursor_up,
483 &self.keymap.cursor_down,
484 &self.keymap.next_page,
485 &self.keymap.prev_page,
486 ],
487 vec![
488 &self.keymap.go_to_start,
489 &self.keymap.go_to_end,
490 &self.keymap.filter,
491 &self.keymap.clear_filter,
492 ],
493 ],
494 }
495 }
496}
497
498impl<I: Item + Send + Sync + 'static> BubbleTeaModel for Model<I> {
499 fn init() -> (Self, Option<Cmd>) {
500 let model = Self::new(vec![], defaultitem::DefaultDelegate::new(), 80, 24);
501 (model, None)
502 }
503 fn update(&mut self, msg: Msg) -> Option<Cmd> {
504 if self.filter_state == FilterState::Filtering {
505 if let Some(key_msg) = msg.downcast_ref::<KeyMsg>() {
506 match key_msg.key {
507 crossterm::event::KeyCode::Esc => {
508 self.filter_state = if self.filtered_items.is_empty() {
509 FilterState::Unfiltered
510 } else {
511 FilterState::FilterApplied
512 };
513 self.filter_input.blur();
514 return None;
515 }
516 crossterm::event::KeyCode::Enter => {
517 self.apply_filter();
518 self.filter_state = FilterState::FilterApplied;
519 self.filter_input.blur();
520 return None;
521 }
522 crossterm::event::KeyCode::Char(c) => {
523 let textinput_msg = Box::new(KeyMsg {
530 key: KeyCode::Char(c),
531 modifiers: key_msg.modifiers,
532 }) as Msg;
533 self.filter_input.update(textinput_msg);
534 self.apply_filter();
535 }
536 crossterm::event::KeyCode::Backspace => {
537 let textinput_msg = Box::new(KeyMsg {
542 key: KeyCode::Backspace,
543 modifiers: key_msg.modifiers,
544 }) as Msg;
545 self.filter_input.update(textinput_msg);
546 self.apply_filter();
547 }
548 crossterm::event::KeyCode::Delete => { }
549 crossterm::event::KeyCode::Left => {
550 let pos = self.filter_input.position();
551 if pos > 0 {
552 self.filter_input.set_cursor(pos - 1);
553 }
554 }
555 crossterm::event::KeyCode::Right => {
556 let pos = self.filter_input.position();
557 self.filter_input.set_cursor(pos + 1);
558 }
559 crossterm::event::KeyCode::Home => {
560 self.filter_input.cursor_start();
561 }
562 crossterm::event::KeyCode::End => {
563 self.filter_input.cursor_end();
564 }
565 _ => {}
566 }
567 }
568 return None;
569 }
570
571 if let Some(key_msg) = msg.downcast_ref::<KeyMsg>() {
572 if self.keymap.cursor_up.matches(key_msg) {
573 if self.cursor > 0 {
574 self.cursor -= 1;
575 self.sync_viewport_with_cursor();
578 }
579 } else if self.keymap.cursor_down.matches(key_msg) {
580 if self.cursor < self.len().saturating_sub(1) {
581 self.cursor += 1;
582 self.sync_viewport_with_cursor();
585 }
586 } else if self.keymap.go_to_start.matches(key_msg) {
587 self.cursor = 0;
588 self.sync_viewport_with_cursor();
590 } else if self.keymap.go_to_end.matches(key_msg) {
591 self.cursor = self.len().saturating_sub(1);
592 self.sync_viewport_with_cursor();
594 } else if self.keymap.filter.matches(key_msg) {
595 self.filter_state = FilterState::Filtering;
596 return Some(self.filter_input.focus());
598 } else if self.keymap.clear_filter.matches(key_msg) {
599 self.filter_input.set_value("");
600 self.filter_state = FilterState::Unfiltered;
601 self.filtered_items.clear();
602 self.cursor = 0;
603 self.update_pagination();
604 }
605 }
606 None
607 }
608 fn view(&self) -> String {
609 lipgloss::join_vertical(
610 lipgloss::LEFT,
611 &[&self.view_header(), &self.view_items(), &self.view_footer()],
612 )
613 }
614}
615
616pub use defaultitem::{DefaultDelegate, DefaultItem, DefaultItemStyles};
618pub use keys::ListKeyMap;
619pub use style::ListStyles;
620
621#[cfg(test)]
622mod tests {
623 use super::*;
624
625 #[derive(Clone)]
626 struct S(&'static str);
627 impl std::fmt::Display for S {
628 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
629 write!(f, "{}", self.0)
630 }
631 }
632 impl Item for S {
633 fn filter_value(&self) -> String {
634 self.0.to_string()
635 }
636 }
637
638 #[test]
639 fn test_status_bar_item_name() {
640 let mut list = Model::new(
641 vec![S("foo"), S("bar")],
642 defaultitem::DefaultDelegate::new(),
643 10,
644 10,
645 );
646 let v = list.status_view();
647 assert!(v.contains("2 items"));
648 list.set_items(vec![S("foo")]);
649 let v = list.status_view();
650 assert!(v.contains("1 item"));
651 }
652
653 #[test]
654 fn test_status_bar_without_items() {
655 let list = Model::new(Vec::<S>::new(), defaultitem::DefaultDelegate::new(), 10, 10);
656 assert!(list.status_view().contains("No items") || list.is_empty());
657 }
658
659 #[test]
660 fn test_custom_status_bar_item_name() {
661 let mut list = Model::new(
662 vec![S("foo"), S("bar")],
663 defaultitem::DefaultDelegate::new(),
664 10,
665 10,
666 );
667 list.set_status_bar_item_name("connection", "connections");
668 assert!(list.status_view().contains("2 connections"));
669 list.set_items(vec![S("foo")]);
670 assert!(list.status_view().contains("1 connection"));
671 list.set_items(vec![]);
672 let _ = list.status_view();
674 }
675
676 #[test]
677 fn test_set_filter_text_and_state_visible_items() {
678 let tc = vec![S("foo"), S("bar"), S("baz")];
679 let mut list = Model::new(tc.clone(), defaultitem::DefaultDelegate::new(), 10, 10);
680 list.set_filter_text("ba");
681 list.set_filter_state(FilterState::Unfiltered);
682 assert_eq!(list.visible_items().len(), tc.len());
683
684 list.set_filter_state(FilterState::Filtering);
685 list.apply_filter();
686 let vis = list.visible_items();
687 assert_eq!(vis.len(), 2); list.set_filter_state(FilterState::FilterApplied);
690 let vis2 = list.visible_items();
691 assert_eq!(vis2.len(), 2);
692 }
693
694 #[test]
695 fn test_selection_highlighting_works() {
696 let items = vec![S("first item"), S("second item"), S("third item")];
697 let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
698
699 let view_output = list.view();
701 assert!(!view_output.is_empty(), "View should not be empty");
702
703 let first_view = list.view();
705 list.cursor = 1; let second_view = list.view();
707
708 assert_ne!(
710 first_view, second_view,
711 "Selection highlighting should change the view"
712 );
713 }
714
715 #[test]
716 fn test_filter_highlighting_works() {
717 let items = vec![S("apple pie"), S("banana bread"), S("carrot cake")];
718 let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
719
720 list.set_filter_text("ap");
722 list.apply_filter(); let filtered_view = list.view();
725 assert!(
726 !filtered_view.is_empty(),
727 "Filtered view should not be empty"
728 );
729
730 assert_eq!(list.len(), 1, "Should have 1 item matching 'ap'");
732
733 assert!(
735 !list.filtered_items.is_empty(),
736 "Filtered items should have match data"
737 );
738 if !list.filtered_items.is_empty() {
739 assert!(
740 !list.filtered_items[0].matches.is_empty(),
741 "First filtered item should have matches"
742 );
743 assert_eq!(list.filtered_items[0].item.0, "apple pie");
745 }
746 }
747
748 #[test]
749 fn test_filter_highlighting_segment_based() {
750 let items = vec![S("Nutella"), S("Linux"), S("Python")];
751 let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
752
753 list.set_filter_text("nut");
755 list.apply_filter();
756
757 assert_eq!(list.len(), 1, "Should have 1 item matching 'nut'");
758 assert_eq!(list.filtered_items[0].item.0, "Nutella");
759
760 let matches = &list.filtered_items[0].matches;
762 assert_eq!(
763 matches.len(),
764 3,
765 "Should have 3 character matches for 'nut'"
766 );
767 assert_eq!(matches[0], 0, "First match should be at index 0 (N)");
768 assert_eq!(matches[1], 1, "Second match should be at index 1 (u)");
769 assert_eq!(matches[2], 2, "Third match should be at index 2 (t)");
770
771 let rendered = list.view();
773
774 assert!(!rendered.is_empty(), "Rendered view should not be empty");
776
777 use super::defaultitem::apply_character_highlighting;
779 let test_result = apply_character_highlighting(
780 "Nutella",
781 &[0, 1, 2], &lipgloss::Style::new().bold(true),
783 &lipgloss::Style::new(),
784 );
785 assert!(
787 test_result.len() > "Nutella".len(),
788 "Highlighted text should be longer due to ANSI codes"
789 );
790
791 let test_result_sparse = apply_character_highlighting(
793 "Nutella",
794 &[0, 2, 4], &lipgloss::Style::new().underline(true),
796 &lipgloss::Style::new(),
797 );
798 assert!(
799 test_result_sparse.len() > "Nutella".len(),
800 "Sparse highlighted text should also work"
801 );
802 }
803
804 #[test]
805 fn test_filter_ansi_efficiency() {
806 use super::defaultitem::apply_character_highlighting;
808 let highlight_style = lipgloss::Style::new().bold(true);
809 let normal_style = lipgloss::Style::new();
810
811 let consecutive_result = apply_character_highlighting(
812 "Hello",
813 &[0, 1, 2], &highlight_style,
815 &normal_style,
816 );
817
818 let sparse_result = apply_character_highlighting(
819 "Hello",
820 &[0, 2, 4], &highlight_style,
822 &normal_style,
823 );
824
825 assert!(
828 consecutive_result.len() < sparse_result.len(),
829 "Consecutive highlighting should be more efficient than sparse highlighting"
830 );
831 }
832
833 #[test]
834 fn test_filter_unicode_characters() {
835 let items = vec![S("café"), S("naïve"), S("🦀 rust"), S("北京")];
836 let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
837
838 list.set_filter_text("caf");
840 list.apply_filter();
841 assert_eq!(list.len(), 1);
842 assert_eq!(list.filtered_items[0].item.0, "café");
843
844 list.set_filter_text("rust");
846 list.apply_filter();
847 assert_eq!(list.len(), 1);
848 assert_eq!(list.filtered_items[0].item.0, "🦀 rust");
849
850 let rendered = list.view();
852 assert!(!rendered.is_empty());
853 }
854
855 #[test]
856 fn test_filter_highlighting_no_pipe_characters() {
857 let items = vec![S("Nutella"), S("Linux"), S("Python")];
860 let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
861
862 list.set_filter_text("nut");
864 list.apply_filter();
865
866 assert_eq!(
867 list.len(),
868 1,
869 "Filter 'nut' should match exactly 1 item (Nutella)"
870 );
871 assert_eq!(list.filtered_items[0].item.0, "Nutella");
872
873 if !list.is_empty() {
876 list.cursor = list.len(); }
878 let unselected_rendered = list.view();
879
880 assert!(
883 !unselected_rendered.contains("N│u") && !unselected_rendered.contains("ut│e"),
884 "Unselected item rendering should not have pipe characters between highlighted segments. Output: {:?}",
885 unselected_rendered
886 );
887
888 list.cursor = 0; let selected_rendered = list.view();
891
892 assert!(
894 !selected_rendered.contains("N│u") && !selected_rendered.contains("ut│e"),
895 "Selected item should not have pipe characters between highlighted text segments. Output: {:?}",
896 selected_rendered
897 );
898
899 list.set_filter_text("li");
901 list.apply_filter();
902
903 assert_eq!(list.len(), 1);
904 assert_eq!(list.filtered_items[0].item.0, "Linux");
905
906 list.cursor = list.len(); let linux_unselected = list.view();
909 assert!(
910 !linux_unselected.contains("Li│n") && !linux_unselected.contains("i│n"),
911 "Unselected Linux should not have pipes between highlighted segments. Output: {:?}",
912 linux_unselected
913 );
914 }
915
916 #[test]
917 fn test_filter_highlighting_visual_correctness() {
918 let items = vec![S("Testing"), S("Visual"), S("Correctness")];
921 let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
922
923 list.set_filter_text("t");
925 list.apply_filter();
926
927 let rendered = list.view();
928 assert!(
930 !rendered.contains("│T")
931 && !rendered.contains("T│")
932 && !rendered.contains("│t")
933 && !rendered.contains("t│"),
934 "Single character highlighting should not have pipe artifacts. Output: {:?}",
935 rendered
936 );
937
938 list.set_filter_text("test");
940 list.apply_filter();
941
942 let rendered = list.view();
943 assert!(
945 !rendered.contains("T│e") && !rendered.contains("e│s") && !rendered.contains("s│t"),
946 "Contiguous highlighting should not have character separation. Output: {:?}",
947 rendered
948 );
949
950 assert!(
953 rendered.contains("Testing") || rendered.matches("Test").count() > 0,
954 "Original text should be preserved in some form. Output: {:?}",
955 rendered
956 );
957 }
958
959 #[test]
960 fn test_filter_highlighting_ansi_efficiency() {
961 let items = vec![S("AbCdEfGh")];
963 let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
964
965 list.set_filter_text("aceg");
967 list.apply_filter();
968
969 list.cursor = list.len(); let rendered = list.view();
972
973 let reset_count = rendered.matches("\x1b[0m").count();
975 let total_length = rendered.len();
976
977 assert!(
979 reset_count < total_length / 5,
980 "Too many ANSI reset sequences detected ({} resets in {} chars). This suggests inefficient styling. Output: {:?}",
981 reset_count, total_length, rendered
982 );
983
984 assert!(
986 !rendered.contains("\x1b[0m│") && !rendered.contains("│\x1b["),
987 "ANSI sequences should not be mixed with pipe characters. Output: {:?}",
988 rendered
989 );
990 }
991
992 #[test]
993 fn test_filter_highlighting_state_consistency() {
994 let items = vec![S("StateTest"), S("Another"), S("ThirdItem")];
996 let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
997
998 list.set_filter_text("st");
999 list.apply_filter();
1000
1001 list.cursor = list.len(); let unselected = list.view();
1004
1005 list.cursor = 0; let selected = list.view();
1008
1009 assert!(
1011 !unselected.contains("S│t") && !unselected.contains("t│a"),
1012 "Unselected state should not have character separation. Output: {:?}",
1013 unselected
1014 );
1015
1016 assert!(
1017 !selected.contains("S│t") && !selected.contains("t│a"),
1018 "Selected state should not have character separation. Output: {:?}",
1019 selected
1020 );
1021
1022 if selected.contains("│") {
1024 let lines: Vec<&str> = selected.lines().collect();
1025 for line in lines {
1026 if line.contains("StateTest") || line.contains("st") {
1027 if let Some(pipe_pos) = line.find("│") {
1029 let after_pipe = &line[pipe_pos + "│".len()..];
1030 assert!(
1031 !after_pipe.contains("│"),
1032 "Only one pipe should appear per line (left border). Line: {:?}",
1033 line
1034 );
1035 }
1036 }
1037 }
1038 }
1039 }
1040
1041 #[test]
1042 fn test_filter_highlighting_spacing_issue() {
1043 let items = vec![
1046 S("Raspberry Pi's"),
1047 S("Nutella"),
1048 S("Bitter melon"),
1049 S("Nice socks"),
1050 S("Eight hours of sleep"),
1051 S("Cats"),
1052 S("Plantasia, the album"),
1053 S("Pour over coffee"),
1054 S("VR"),
1055 S("Noguchi Lamps"),
1056 S("Linux"),
1057 S("Business school"),
1058 ];
1059
1060 let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 24);
1061
1062 list.set_filter_text("n");
1064 list.apply_filter();
1065
1066 let has_nutella = list
1068 .filtered_items
1069 .iter()
1070 .any(|item| item.item.0 == "Nutella");
1071 assert!(has_nutella, "Nutella should be in filtered results");
1072
1073 for (i, filtered_item) in list.filtered_items.iter().enumerate() {
1075 if filtered_item.item.0 == "Nutella" {
1076 list.cursor = i;
1077 break;
1078 }
1079 }
1080
1081 let rendered = list.view();
1082
1083 println!("\n=== FILTER HIGHLIGHTING TEST OUTPUT ===");
1085 println!("{}", rendered);
1086 println!("=== END OUTPUT ===\n");
1087
1088 let has_nutella_spacing_issue =
1090 rendered.contains("│ N utella") || rendered.contains("N utella");
1091
1092 if has_nutella_spacing_issue {
1093 panic!(
1094 "❌ SPACING ISSUE DETECTED: Found '│ N utella' or 'N utella' in output. \
1095 Expected '│ Nutella' or 'Nutella' without extra spaces."
1096 );
1097 }
1098
1099 let other_spacing_issues = rendered.contains("I t's") || rendered.contains("N ice") || rendered.contains("Li n ux"); if other_spacing_issues {
1105 panic!("❌ ADDITIONAL SPACING ISSUES: Found other words with extra spaces in highlighting.");
1106 }
1107
1108 println!("✅ No spacing issues detected in filter highlighting.");
1109 }
1110
1111 #[test]
1112 fn test_filter_edge_cases() {
1113 let items = vec![S("a"), S("ab"), S("abc"), S(""), S(" ")];
1114 let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
1115
1116 list.set_filter_text("a");
1118 list.apply_filter();
1119 assert!(list.len() >= 3, "Should match 'a', 'ab', 'abc'");
1120
1121 list.set_filter_text("");
1123 list.apply_filter();
1124 assert_eq!(list.filter_state, FilterState::Unfiltered);
1125
1126 list.set_filter_text("ab");
1128 list.apply_filter();
1129 assert!(list.len() >= 2, "Should match 'ab', 'abc'");
1130
1131 let rendered = list.view();
1133 assert!(!rendered.is_empty());
1134 }
1135}