1pub mod defaultitem;
22pub mod keys;
23pub mod style;
24
25use crate::{help, key, paginator, spinner, textinput};
26use bubbletea_rs::{Cmd, KeyMsg, Model as BubbleTeaModel, Msg};
27use fuzzy_matcher::skim::SkimMatcherV2;
28use fuzzy_matcher::FuzzyMatcher;
29use lipgloss_extras::lipgloss;
30use std::fmt::Display;
31
32pub trait Item: Display + Clone {
36 fn filter_value(&self) -> String;
38}
39
40pub trait ItemDelegate<I: Item> {
42 fn render(&self, m: &Model<I>, index: usize, item: &I) -> String;
44 fn height(&self) -> usize;
46 fn spacing(&self) -> usize;
48 fn update(&self, msg: &Msg, m: &mut Model<I>) -> Option<Cmd>;
50}
51
52#[derive(Debug, Clone)]
54#[allow(dead_code)]
55struct FilteredItem<I: Item> {
56 index: usize, item: I,
58 matches: Vec<usize>,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum FilterState {
66 Unfiltered,
68 Filtering,
70 FilterApplied,
72}
73
74pub struct Model<I: Item> {
76 pub title: String,
78 items: Vec<I>,
79 delegate: Box<dyn ItemDelegate<I> + Send + Sync>,
80
81 pub filter_input: textinput::Model,
84 pub paginator: paginator::Model,
86 pub spinner: spinner::Model,
88 pub help: help::Model,
90 pub keymap: keys::ListKeyMap,
92
93 filter_state: FilterState,
95 filtered_items: Vec<FilteredItem<I>>,
96 cursor: usize,
97 width: usize,
98 height: usize,
99
100 pub styles: style::ListStyles,
103
104 status_item_singular: Option<String>,
106 status_item_plural: Option<String>,
107}
108
109impl<I: Item + Send + Sync + 'static> Model<I> {
110 pub fn new(
112 items: Vec<I>,
113 delegate: impl ItemDelegate<I> + Send + Sync + 'static,
114 width: usize,
115 height: usize,
116 ) -> Self {
117 let mut filter_input = textinput::new();
118 filter_input.set_placeholder("Filter...");
119 let mut paginator = paginator::Model::new();
120 paginator.set_per_page(10);
121
122 let mut s = Self {
123 title: "List".to_string(),
124 items,
125 delegate: Box::new(delegate),
126 filter_input,
127 paginator,
128 spinner: spinner::Model::new(),
129 help: help::Model::new(),
130 keymap: keys::ListKeyMap::default(),
131 filter_state: FilterState::Unfiltered,
132 filtered_items: vec![],
133 cursor: 0,
134 width,
135 height,
136 styles: style::ListStyles::default(),
137 status_item_singular: None,
138 status_item_plural: None,
139 };
140 s.update_pagination();
141 s
142 }
143
144 pub fn set_items(&mut self, items: Vec<I>) {
146 self.items = items;
147 self.update_pagination();
148 }
149 pub fn visible_items(&self) -> Vec<I> {
151 if self.filter_state == FilterState::Unfiltered {
152 self.items.clone()
153 } else {
154 self.filtered_items.iter().map(|f| f.item.clone()).collect()
155 }
156 }
157 pub fn set_filter_text(&mut self, s: &str) {
159 self.filter_input.set_value(s);
160 }
161 pub fn set_filter_state(&mut self, st: FilterState) {
163 self.filter_state = st;
164 }
165 pub fn set_status_bar_item_name(&mut self, singular: &str, plural: &str) {
167 self.status_item_singular = Some(singular.to_string());
168 self.status_item_plural = Some(plural.to_string());
169 }
170 pub fn status_view(&self) -> String {
172 self.view_footer()
173 }
174
175 pub fn with_title(mut self, title: &str) -> Self {
177 self.title = title.to_string();
178 self
179 }
180 pub fn selected_item(&self) -> Option<&I> {
182 if self.filter_state == FilterState::Unfiltered {
183 self.items.get(self.cursor)
184 } else {
185 self.filtered_items.get(self.cursor).map(|fi| &fi.item)
186 }
187 }
188 pub fn cursor(&self) -> usize {
190 self.cursor
191 }
192 pub fn len(&self) -> usize {
194 if self.filter_state == FilterState::Unfiltered {
195 self.items.len()
196 } else {
197 self.filtered_items.len()
198 }
199 }
200 pub fn is_empty(&self) -> bool {
202 self.len() == 0
203 }
204
205 fn update_pagination(&mut self) {
206 let item_count = self.len();
207 let item_height = self.delegate.height() + self.delegate.spacing();
208 let available_height = self.height.saturating_sub(4);
209 let per_page = if item_height > 0 {
210 available_height / item_height
211 } else {
212 10
213 }
214 .max(1);
215 self.paginator.set_per_page(per_page);
216 self.paginator
217 .set_total_pages(item_count.div_ceil(per_page));
218 if self.cursor >= item_count {
219 self.cursor = item_count.saturating_sub(1);
220 }
221 }
222
223 #[allow(dead_code)]
224 fn matches_for_item(&self, index: usize) -> Option<&Vec<usize>> {
225 if index < self.filtered_items.len() {
226 Some(&self.filtered_items[index].matches)
227 } else {
228 None
229 }
230 }
231
232 fn apply_filter(&mut self) {
233 let filter_term = self.filter_input.value().to_lowercase();
234 if filter_term.is_empty() {
235 self.filter_state = FilterState::Unfiltered;
236 self.filtered_items.clear();
237 } else {
238 let matcher = SkimMatcherV2::default();
239 self.filtered_items = self
240 .items
241 .iter()
242 .enumerate()
243 .filter_map(|(i, item)| {
244 matcher
245 .fuzzy_indices(&item.filter_value(), &filter_term)
246 .map(|(_score, indices)| FilteredItem {
247 index: i,
248 item: item.clone(),
249 matches: indices,
250 })
251 })
252 .collect();
253 self.filter_state = FilterState::FilterApplied;
254 }
255 self.cursor = 0;
256 self.update_pagination();
257 }
258
259 fn view_header(&self) -> String {
260 if self.filter_state == FilterState::Filtering {
261 let prompt = self.styles.filter_prompt.clone().render("Filter:");
262 format!("{} {}", prompt, self.filter_input.view())
263 } else {
264 let mut header = self.title.clone();
265 if self.filter_state == FilterState::FilterApplied {
266 header.push_str(&format!(" (filtered: {})", self.len()));
267 }
268 self.styles.title.clone().render(&header)
269 }
270 }
271
272 fn view_items(&self) -> String {
273 if self.is_empty() {
274 return self.styles.no_items.clone().render("No items");
275 }
276
277 let items_to_render: Vec<(usize, &I)> = if self.filter_state == FilterState::Unfiltered {
278 self.items.iter().enumerate().collect()
279 } else {
280 self.filtered_items
281 .iter()
282 .map(|fi| (fi.index, &fi.item))
283 .collect()
284 };
285
286 let (start, end) = self.paginator.get_slice_bounds(items_to_render.len());
287 let mut items = Vec::new();
288
289 for (list_idx, (_orig_idx, item)) in items_to_render
291 .iter()
292 .enumerate()
293 .take(end.min(items_to_render.len()))
294 .skip(start)
295 {
296 let visible_index = start + list_idx;
298 let item_output = self.delegate.render(self, visible_index, item);
299 items.push(item_output);
300 }
301
302 let separator = "\n".repeat(self.delegate.spacing().max(1));
304 items.join(&separator)
305 }
306
307 fn view_footer(&self) -> String {
308 let mut footer = String::new();
309 if !self.is_empty() {
310 let singular = self.status_item_singular.as_deref().unwrap_or("item");
311 let plural = self.status_item_plural.as_deref().unwrap_or("items");
312 let noun = if self.len() == 1 { singular } else { plural };
313 footer.push_str(&format!("{}/{} {}", self.cursor + 1, self.len(), noun));
314 }
315 let help_view = self.help.view(self);
316 if !help_view.is_empty() {
317 footer.push('\n');
318 footer.push_str(&help_view);
319 }
320 footer
321 }
322}
323
324impl<I: Item> help::KeyMap for Model<I> {
326 fn short_help(&self) -> Vec<&key::Binding> {
327 match self.filter_state {
328 FilterState::Filtering => vec![&self.keymap.accept_filter, &self.keymap.cancel_filter],
329 _ => vec![
330 &self.keymap.cursor_up,
331 &self.keymap.cursor_down,
332 &self.keymap.filter,
333 ],
334 }
335 }
336 fn full_help(&self) -> Vec<Vec<&key::Binding>> {
337 match self.filter_state {
338 FilterState::Filtering => {
339 vec![vec![&self.keymap.accept_filter, &self.keymap.cancel_filter]]
340 }
341 _ => vec![
342 vec![
343 &self.keymap.cursor_up,
344 &self.keymap.cursor_down,
345 &self.keymap.next_page,
346 &self.keymap.prev_page,
347 ],
348 vec![
349 &self.keymap.go_to_start,
350 &self.keymap.go_to_end,
351 &self.keymap.filter,
352 &self.keymap.clear_filter,
353 ],
354 ],
355 }
356 }
357}
358
359impl<I: Item + Send + Sync + 'static> BubbleTeaModel for Model<I> {
360 fn init() -> (Self, Option<Cmd>) {
361 let model = Self::new(vec![], defaultitem::DefaultDelegate::new(), 80, 24);
362 (model, None)
363 }
364 fn update(&mut self, msg: Msg) -> Option<Cmd> {
365 if self.filter_state == FilterState::Filtering {
366 if let Some(key_msg) = msg.downcast_ref::<KeyMsg>() {
367 match key_msg.key {
368 crossterm::event::KeyCode::Esc => {
369 self.filter_state = if self.filtered_items.is_empty() {
370 FilterState::Unfiltered
371 } else {
372 FilterState::FilterApplied
373 };
374 self.filter_input.blur();
375 return None;
376 }
377 crossterm::event::KeyCode::Enter => {
378 self.apply_filter();
379 self.filter_input.blur();
380 return None;
381 }
382 crossterm::event::KeyCode::Char(c) => {
383 let mut s = self.filter_input.value();
384 s.push(c);
385 self.filter_input.set_value(&s);
386 self.apply_filter();
387 }
388 crossterm::event::KeyCode::Backspace => {
389 let mut s = self.filter_input.value();
390 s.pop();
391 self.filter_input.set_value(&s);
392 self.apply_filter();
393 }
394 crossterm::event::KeyCode::Delete => { }
395 crossterm::event::KeyCode::Left => {
396 let pos = self.filter_input.position();
397 if pos > 0 {
398 self.filter_input.set_cursor(pos - 1);
399 }
400 }
401 crossterm::event::KeyCode::Right => {
402 let pos = self.filter_input.position();
403 self.filter_input.set_cursor(pos + 1);
404 }
405 crossterm::event::KeyCode::Home => {
406 self.filter_input.cursor_start();
407 }
408 crossterm::event::KeyCode::End => {
409 self.filter_input.cursor_end();
410 }
411 _ => {}
412 }
413 }
414 return None;
415 }
416
417 if let Some(key_msg) = msg.downcast_ref::<KeyMsg>() {
418 if self.keymap.cursor_up.matches(key_msg) {
419 if self.cursor > 0 {
420 self.cursor -= 1;
421 }
422 } else if self.keymap.cursor_down.matches(key_msg) {
423 if self.cursor < self.len().saturating_sub(1) {
424 self.cursor += 1;
425 }
426 } else if self.keymap.go_to_start.matches(key_msg) {
427 self.cursor = 0;
428 } else if self.keymap.go_to_end.matches(key_msg) {
429 self.cursor = self.len().saturating_sub(1);
430 } else if self.keymap.filter.matches(key_msg) {
431 self.filter_state = FilterState::Filtering;
432 return Some(self.filter_input.focus());
434 } else if self.keymap.clear_filter.matches(key_msg) {
435 self.filter_input.set_value("");
436 self.filter_state = FilterState::Unfiltered;
437 self.filtered_items.clear();
438 self.cursor = 0;
439 self.update_pagination();
440 }
441 }
442 None
443 }
444 fn view(&self) -> String {
445 lipgloss::join_vertical(
446 lipgloss::LEFT,
447 &[&self.view_header(), &self.view_items(), &self.view_footer()],
448 )
449 }
450}
451
452pub use defaultitem::{DefaultDelegate, DefaultItem, DefaultItemStyles};
454pub use keys::ListKeyMap;
455pub use style::ListStyles;
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460
461 #[derive(Clone)]
462 struct S(&'static str);
463 impl std::fmt::Display for S {
464 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
465 write!(f, "{}", self.0)
466 }
467 }
468 impl Item for S {
469 fn filter_value(&self) -> String {
470 self.0.to_string()
471 }
472 }
473
474 #[test]
475 fn test_status_bar_item_name() {
476 let mut list = Model::new(
477 vec![S("foo"), S("bar")],
478 defaultitem::DefaultDelegate::new(),
479 10,
480 10,
481 );
482 let v = list.status_view();
483 assert!(v.contains("2 items"));
484 list.set_items(vec![S("foo")]);
485 let v = list.status_view();
486 assert!(v.contains("1 item"));
487 }
488
489 #[test]
490 fn test_status_bar_without_items() {
491 let list = Model::new(Vec::<S>::new(), defaultitem::DefaultDelegate::new(), 10, 10);
492 assert!(list.status_view().contains("No items") || list.is_empty());
493 }
494
495 #[test]
496 fn test_custom_status_bar_item_name() {
497 let mut list = Model::new(
498 vec![S("foo"), S("bar")],
499 defaultitem::DefaultDelegate::new(),
500 10,
501 10,
502 );
503 list.set_status_bar_item_name("connection", "connections");
504 assert!(list.status_view().contains("2 connections"));
505 list.set_items(vec![S("foo")]);
506 assert!(list.status_view().contains("1 connection"));
507 list.set_items(vec![]);
508 let _ = list.status_view();
510 }
511
512 #[test]
513 fn test_set_filter_text_and_state_visible_items() {
514 let tc = vec![S("foo"), S("bar"), S("baz")];
515 let mut list = Model::new(tc.clone(), defaultitem::DefaultDelegate::new(), 10, 10);
516 list.set_filter_text("ba");
517 list.set_filter_state(FilterState::Unfiltered);
518 assert_eq!(list.visible_items().len(), tc.len());
519
520 list.set_filter_state(FilterState::Filtering);
521 list.apply_filter();
522 let vis = list.visible_items();
523 assert_eq!(vis.len(), 2); list.set_filter_state(FilterState::FilterApplied);
526 let vis2 = list.visible_items();
527 assert_eq!(vis2.len(), 2);
528 }
529
530 #[test]
531 fn test_selection_highlighting_works() {
532 let items = vec![S("first item"), S("second item"), S("third item")];
533 let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
534
535 let view_output = list.view();
537 assert!(!view_output.is_empty(), "View should not be empty");
538
539 let first_view = list.view();
541 list.cursor = 1; let second_view = list.view();
543
544 assert_ne!(
546 first_view, second_view,
547 "Selection highlighting should change the view"
548 );
549 }
550
551 #[test]
552 fn test_filter_highlighting_works() {
553 let items = vec![S("apple pie"), S("banana bread"), S("carrot cake")];
554 let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
555
556 list.set_filter_text("ap");
558 list.apply_filter(); let filtered_view = list.view();
561 assert!(
562 !filtered_view.is_empty(),
563 "Filtered view should not be empty"
564 );
565
566 assert_eq!(list.len(), 1, "Should have 1 item matching 'ap'");
568
569 assert!(
571 !list.filtered_items.is_empty(),
572 "Filtered items should have match data"
573 );
574 if !list.filtered_items.is_empty() {
575 assert!(
576 !list.filtered_items[0].matches.is_empty(),
577 "First filtered item should have matches"
578 );
579 assert_eq!(list.filtered_items[0].item.0, "apple pie");
581 }
582 }
583
584 #[test]
585 fn test_filter_highlighting_segment_based() {
586 let items = vec![S("Nutella"), S("Linux"), S("Python")];
587 let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
588
589 list.set_filter_text("nut");
591 list.apply_filter();
592
593 assert_eq!(list.len(), 1, "Should have 1 item matching 'nut'");
594 assert_eq!(list.filtered_items[0].item.0, "Nutella");
595
596 let matches = &list.filtered_items[0].matches;
598 assert_eq!(
599 matches.len(),
600 3,
601 "Should have 3 character matches for 'nut'"
602 );
603 assert_eq!(matches[0], 0, "First match should be at index 0 (N)");
604 assert_eq!(matches[1], 1, "Second match should be at index 1 (u)");
605 assert_eq!(matches[2], 2, "Third match should be at index 2 (t)");
606
607 let rendered = list.view();
609
610 assert!(!rendered.is_empty(), "Rendered view should not be empty");
612
613 use super::defaultitem::apply_character_highlighting;
615 let test_result = apply_character_highlighting(
616 "Nutella",
617 &[0, 1, 2], &lipgloss::Style::new().bold(true),
619 &lipgloss::Style::new(),
620 );
621 assert!(
623 test_result.len() > "Nutella".len(),
624 "Highlighted text should be longer due to ANSI codes"
625 );
626
627 let test_result_sparse = apply_character_highlighting(
629 "Nutella",
630 &[0, 2, 4], &lipgloss::Style::new().underline(true),
632 &lipgloss::Style::new(),
633 );
634 assert!(
635 test_result_sparse.len() > "Nutella".len(),
636 "Sparse highlighted text should also work"
637 );
638 }
639
640 #[test]
641 fn test_filter_ansi_efficiency() {
642 use super::defaultitem::apply_character_highlighting;
644 let highlight_style = lipgloss::Style::new().bold(true);
645 let normal_style = lipgloss::Style::new();
646
647 let consecutive_result = apply_character_highlighting(
648 "Hello",
649 &[0, 1, 2], &highlight_style,
651 &normal_style,
652 );
653
654 let sparse_result = apply_character_highlighting(
655 "Hello",
656 &[0, 2, 4], &highlight_style,
658 &normal_style,
659 );
660
661 assert!(
664 consecutive_result.len() < sparse_result.len(),
665 "Consecutive highlighting should be more efficient than sparse highlighting"
666 );
667 }
668
669 #[test]
670 fn test_filter_unicode_characters() {
671 let items = vec![S("café"), S("naïve"), S("🦀 rust"), S("北京")];
672 let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
673
674 list.set_filter_text("caf");
676 list.apply_filter();
677 assert_eq!(list.len(), 1);
678 assert_eq!(list.filtered_items[0].item.0, "café");
679
680 list.set_filter_text("rust");
682 list.apply_filter();
683 assert_eq!(list.len(), 1);
684 assert_eq!(list.filtered_items[0].item.0, "🦀 rust");
685
686 let rendered = list.view();
688 assert!(!rendered.is_empty());
689 }
690
691 #[test]
692 fn test_filter_highlighting_no_pipe_characters() {
693 let items = vec![S("Nutella"), S("Linux"), S("Python")];
696 let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
697
698 list.set_filter_text("nut");
700 list.apply_filter();
701
702 assert_eq!(
703 list.len(),
704 1,
705 "Filter 'nut' should match exactly 1 item (Nutella)"
706 );
707 assert_eq!(list.filtered_items[0].item.0, "Nutella");
708
709 if !list.is_empty() {
712 list.cursor = list.len(); }
714 let unselected_rendered = list.view();
715
716 assert!(
719 !unselected_rendered.contains("N│u") && !unselected_rendered.contains("ut│e"),
720 "Unselected item rendering should not have pipe characters between highlighted segments. Output: {:?}",
721 unselected_rendered
722 );
723
724 list.cursor = 0; let selected_rendered = list.view();
727
728 assert!(
730 !selected_rendered.contains("N│u") && !selected_rendered.contains("ut│e"),
731 "Selected item should not have pipe characters between highlighted text segments. Output: {:?}",
732 selected_rendered
733 );
734
735 list.set_filter_text("li");
737 list.apply_filter();
738
739 assert_eq!(list.len(), 1);
740 assert_eq!(list.filtered_items[0].item.0, "Linux");
741
742 list.cursor = list.len(); let linux_unselected = list.view();
745 assert!(
746 !linux_unselected.contains("Li│n") && !linux_unselected.contains("i│n"),
747 "Unselected Linux should not have pipes between highlighted segments. Output: {:?}",
748 linux_unselected
749 );
750 }
751
752 #[test]
753 fn test_filter_highlighting_visual_correctness() {
754 let items = vec![S("Testing"), S("Visual"), S("Correctness")];
757 let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
758
759 list.set_filter_text("t");
761 list.apply_filter();
762
763 let rendered = list.view();
764 assert!(
766 !rendered.contains("│T")
767 && !rendered.contains("T│")
768 && !rendered.contains("│t")
769 && !rendered.contains("t│"),
770 "Single character highlighting should not have pipe artifacts. Output: {:?}",
771 rendered
772 );
773
774 list.set_filter_text("test");
776 list.apply_filter();
777
778 let rendered = list.view();
779 assert!(
781 !rendered.contains("T│e") && !rendered.contains("e│s") && !rendered.contains("s│t"),
782 "Contiguous highlighting should not have character separation. Output: {:?}",
783 rendered
784 );
785
786 assert!(
789 rendered.contains("Testing") || rendered.matches("Test").count() > 0,
790 "Original text should be preserved in some form. Output: {:?}",
791 rendered
792 );
793 }
794
795 #[test]
796 fn test_filter_highlighting_ansi_efficiency() {
797 let items = vec![S("AbCdEfGh")];
799 let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
800
801 list.set_filter_text("aceg");
803 list.apply_filter();
804
805 list.cursor = list.len(); let rendered = list.view();
808
809 let reset_count = rendered.matches("\x1b[0m").count();
811 let total_length = rendered.len();
812
813 assert!(
815 reset_count < total_length / 5,
816 "Too many ANSI reset sequences detected ({} resets in {} chars). This suggests inefficient styling. Output: {:?}",
817 reset_count, total_length, rendered
818 );
819
820 assert!(
822 !rendered.contains("\x1b[0m│") && !rendered.contains("│\x1b["),
823 "ANSI sequences should not be mixed with pipe characters. Output: {:?}",
824 rendered
825 );
826 }
827
828 #[test]
829 fn test_filter_highlighting_state_consistency() {
830 let items = vec![S("StateTest"), S("Another"), S("ThirdItem")];
832 let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
833
834 list.set_filter_text("st");
835 list.apply_filter();
836
837 list.cursor = list.len(); let unselected = list.view();
840
841 list.cursor = 0; let selected = list.view();
844
845 assert!(
847 !unselected.contains("S│t") && !unselected.contains("t│a"),
848 "Unselected state should not have character separation. Output: {:?}",
849 unselected
850 );
851
852 assert!(
853 !selected.contains("S│t") && !selected.contains("t│a"),
854 "Selected state should not have character separation. Output: {:?}",
855 selected
856 );
857
858 if selected.contains("│") {
860 let lines: Vec<&str> = selected.lines().collect();
861 for line in lines {
862 if line.contains("StateTest") || line.contains("st") {
863 if let Some(pipe_pos) = line.find("│") {
865 let after_pipe = &line[pipe_pos + "│".len()..];
866 assert!(
867 !after_pipe.contains("│"),
868 "Only one pipe should appear per line (left border). Line: {:?}",
869 line
870 );
871 }
872 }
873 }
874 }
875 }
876
877 #[test]
878 fn test_filter_highlighting_spacing_issue() {
879 let items = vec![
882 S("Raspberry Pi's"),
883 S("Nutella"),
884 S("Bitter melon"),
885 S("Nice socks"),
886 S("Eight hours of sleep"),
887 S("Cats"),
888 S("Plantasia, the album"),
889 S("Pour over coffee"),
890 S("VR"),
891 S("Noguchi Lamps"),
892 S("Linux"),
893 S("Business school"),
894 ];
895
896 let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 24);
897
898 list.set_filter_text("n");
900 list.apply_filter();
901
902 let has_nutella = list
904 .filtered_items
905 .iter()
906 .any(|item| item.item.0 == "Nutella");
907 assert!(has_nutella, "Nutella should be in filtered results");
908
909 for (i, filtered_item) in list.filtered_items.iter().enumerate() {
911 if filtered_item.item.0 == "Nutella" {
912 list.cursor = i;
913 break;
914 }
915 }
916
917 let rendered = list.view();
918
919 println!("\n=== FILTER HIGHLIGHTING TEST OUTPUT ===");
921 println!("{}", rendered);
922 println!("=== END OUTPUT ===\n");
923
924 let has_nutella_spacing_issue =
926 rendered.contains("│ N utella") || rendered.contains("N utella");
927
928 if has_nutella_spacing_issue {
929 panic!(
930 "❌ SPACING ISSUE DETECTED: Found '│ N utella' or 'N utella' in output. \
931 Expected '│ Nutella' or 'Nutella' without extra spaces."
932 );
933 }
934
935 let other_spacing_issues = rendered.contains("I t's") || rendered.contains("N ice") || rendered.contains("Li n ux"); if other_spacing_issues {
941 panic!("❌ ADDITIONAL SPACING ISSUES: Found other words with extra spaces in highlighting.");
942 }
943
944 println!("✅ No spacing issues detected in filter highlighting.");
945 }
946
947 #[test]
948 fn test_filter_edge_cases() {
949 let items = vec![S("a"), S("ab"), S("abc"), S(""), S(" ")];
950 let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
951
952 list.set_filter_text("a");
954 list.apply_filter();
955 assert!(list.len() >= 3, "Should match 'a', 'ab', 'abc'");
956
957 list.set_filter_text("");
959 list.apply_filter();
960 assert_eq!(list.filter_state, FilterState::Unfiltered);
961
962 list.set_filter_text("ab");
964 list.apply_filter();
965 assert!(list.len() >= 2, "Should match 'ab', 'abc'");
966
967 let rendered = list.view();
969 assert!(!rendered.is_empty());
970 }
971}