bubbletea_widgets/list/
mod.rs

1//! List component with filtering, pagination, contextual help, and customizable rendering.
2//!
3//! This module exposes a generic `Model<I: Item>` plus supporting traits and submodules:
4//! - `Item`: Implement for your item type; must be `Display + Clone` and return a `filter_value()`
5//! - `ItemDelegate`: Controls item `render`, `height`, `spacing`, and `update`
6//! - Submodules: `defaultitem`, `keys`, and `style`
7//!
8//! ### Filtering States
9//! The list supports fuzzy filtering with three states:
10//! - `Unfiltered`: No filter active
11//! - `Filtering`: User is typing a filter; input is shown in the header
12//! - `FilterApplied`: Filter accepted; only matching items are displayed
13//!
14//! When filtering is active, fuzzy match indices are stored per item and delegates can use
15//! them to apply character-level highlighting (see `defaultitem`).
16//!
17//! ### Help Integration
18//! The list implements `help::KeyMap`, so you can embed `help::Model` and get contextual
19//! help automatically based on the current filtering state.
20
21pub 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
32// --- Traits (Interfaces) ---
33
34/// An item that can be displayed in the list.
35pub trait Item: Display + Clone {
36    /// The value to use when filtering this item.
37    fn filter_value(&self) -> String;
38}
39
40/// A delegate encapsulates the functionality for a list item.
41pub trait ItemDelegate<I: Item> {
42    /// Renders the item's view.
43    fn render(&self, m: &Model<I>, index: usize, item: &I) -> String;
44    /// The height of the list item.
45    fn height(&self) -> usize;
46    /// The spacing between list items.
47    fn spacing(&self) -> usize;
48    /// The update loop for the item.
49    fn update(&self, msg: &Msg, m: &mut Model<I>) -> Option<Cmd>;
50}
51
52// --- Filter ---
53#[derive(Debug, Clone)]
54#[allow(dead_code)]
55struct FilteredItem<I: Item> {
56    index: usize, // index in original items list
57    item: I,
58    matches: Vec<usize>,
59}
60
61// --- Model ---
62
63/// Current filtering state of the list.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum FilterState {
66    /// No filtering is active; all items are shown.
67    Unfiltered,
68    /// User is typing a filter term; live filtering UI is shown.
69    Filtering,
70    /// A filter term has been applied; only matching items are shown.
71    FilterApplied,
72}
73
74/// List model containing items, filtering, pagination, and styling.
75pub struct Model<I: Item> {
76    /// Title rendered in the list header when not filtering.
77    pub title: String,
78    items: Vec<I>,
79    delegate: Box<dyn ItemDelegate<I> + Send + Sync>,
80
81    // Components
82    /// Text input used for entering the filter term.
83    pub filter_input: textinput::Model,
84    /// Paginator controlling visible item slice.
85    pub paginator: paginator::Model,
86    /// Spinner used during expensive operations (optional usage).
87    pub spinner: spinner::Model,
88    /// Help model for displaying key bindings.
89    pub help: help::Model,
90    /// Key bindings for list navigation and filtering.
91    pub keymap: keys::ListKeyMap,
92
93    // State
94    filter_state: FilterState,
95    filtered_items: Vec<FilteredItem<I>>,
96    cursor: usize,
97    width: usize,
98    height: usize,
99
100    // Styles
101    /// Visual styles for list elements and states.
102    pub styles: style::ListStyles,
103
104    // Status bar labeling
105    status_item_singular: Option<String>,
106    status_item_plural: Option<String>,
107}
108
109impl<I: Item + Send + Sync + 'static> Model<I> {
110    /// Creates a new list with items, delegate, and initial dimensions.
111    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    /// Replace all items in the list and reset pagination if needed.
145    pub fn set_items(&mut self, items: Vec<I>) {
146        self.items = items;
147        self.update_pagination();
148    }
149    /// Returns a copy of the items currently visible (filtered if applicable).
150    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    /// Sets the filter input text.
158    pub fn set_filter_text(&mut self, s: &str) {
159        self.filter_input.set_value(s);
160    }
161    /// Sets the current filtering state.
162    pub fn set_filter_state(&mut self, st: FilterState) {
163        self.filter_state = st;
164    }
165    /// Sets the singular/plural nouns used in the status bar.
166    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    /// Renders the status bar string, including position and help.
171    pub fn status_view(&self) -> String {
172        self.view_footer()
173    }
174
175    /// Sets the list title and returns `self` for chaining.
176    pub fn with_title(mut self, title: &str) -> Self {
177        self.title = title.to_string();
178        self
179    }
180    /// Returns a reference to the currently selected item, if any.
181    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    /// Returns the current cursor position (0-based).
189    pub fn cursor(&self) -> usize {
190        self.cursor
191    }
192    /// Returns the number of items in the current view (filtered or not).
193    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    /// Returns `true` if there are no items to display.
201    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        // Render each item individually using the delegate
290        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            // The item index in the current visible list (for selection highlighting)
297            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        // Join items with newlines, respecting spacing
303        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
324// Help integration from the list model
325impl<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 => { /* ignore delete for now */ }
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                // propagate the blink command so it is polled by runtime
433                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
452// Re-export commonly used types
453pub 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        // When empty, status_view currently just shows help or empty; ensure no panic
509        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); // bar, baz
524
525        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        // Test that view renders without crashing and includes styling
536        let view_output = list.view();
537        assert!(!view_output.is_empty(), "View should not be empty");
538
539        // Test selection highlighting by checking that cursor position affects rendering
540        let first_view = list.view();
541        list.cursor = 1; // Move cursor to second item
542        let second_view = list.view();
543
544        // The views should be different because of selection highlighting
545        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        // Apply filter that should match only some items
557        list.set_filter_text("ap");
558        list.apply_filter(); // Actually apply the filter to process matches
559
560        let filtered_view = list.view();
561        assert!(
562            !filtered_view.is_empty(),
563            "Filtered view should not be empty"
564        );
565
566        // Check that filtering worked ("ap" should only match "apple pie")
567        assert_eq!(list.len(), 1, "Should have 1 item matching 'ap'");
568
569        // Test that matches are stored correctly
570        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            // Check that the matched item is indeed "apple pie"
580            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        // Test contiguous match highlighting - filter "nut" should match only "Nutella"
590        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        // Verify that matches are [0, 1, 2] for "Nut" in "Nutella"
597        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        // Test the actual highlighting by rendering - it should not have character separation
608        let rendered = list.view();
609
610        // The rendered output should not be empty and should render without errors
611        assert!(!rendered.is_empty(), "Rendered view should not be empty");
612
613        // Test that our highlighting function works directly with contiguous segments
614        use super::defaultitem::apply_character_highlighting;
615        let test_result = apply_character_highlighting(
616            "Nutella",
617            &[0, 1, 2], // Consecutive indices should be rendered as a single segment
618            &lipgloss::Style::new().bold(true),
619            &lipgloss::Style::new(),
620        );
621        // The result should contain styled text and be longer due to ANSI codes
622        assert!(
623            test_result.len() > "Nutella".len(),
624            "Highlighted text should be longer due to ANSI codes"
625        );
626
627        // Verify the fix works: test with non-consecutive matches too
628        let test_result_sparse = apply_character_highlighting(
629            "Nutella",
630            &[0, 2, 4], // Non-consecutive indices: N_t_l
631            &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        // Test that consecutive matches use fewer ANSI codes than character-by-character
643        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], // "Hel" - should be one ANSI block
650            &highlight_style,
651            &normal_style,
652        );
653
654        let sparse_result = apply_character_highlighting(
655            "Hello",
656            &[0, 2, 4], // "H_l_o" - should be three ANSI blocks
657            &highlight_style,
658            &normal_style,
659        );
660
661        // Consecutive matches should result in more efficient ANSI usage
662        // This is a rough heuristic - consecutive should have fewer style applications
663        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        // Test filtering with accented characters
675        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        // Test filtering with emoji
681        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        // Ensure rendering doesn't crash with unicode
687        let rendered = list.view();
688        assert!(!rendered.is_empty());
689    }
690
691    #[test]
692    fn test_filter_highlighting_no_pipe_characters() {
693        // Regression test for issue where pipe characters (│) were inserted
694        // between highlighted and non-highlighted text segments
695        let items = vec![S("Nutella"), S("Linux"), S("Python")];
696        let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
697
698        // Test case from debug output: filter "nut" should only match "Nutella"
699        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        // First test non-selected state (cursor on different item)
710        // This should not have any borders/pipes
711        if !list.is_empty() {
712            list.cursor = list.len(); // Set cursor beyond items to deselect
713        }
714        let unselected_rendered = list.view();
715
716        // For unselected items, there should be no pipe characters between highlighted segments
717        // This is the specific issue from the debug output: "N│utella"
718        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        // Selected items can have a left border pipe, but not between text segments
725        list.cursor = 0; // Select the first item
726        let selected_rendered = list.view();
727
728        // Check that the pipe is only at the beginning (left border) not between text
729        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        // Test another case: filter "li" on "Linux" - test unselected first
736        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        // Test unselected Linux (no borders)
743        list.cursor = list.len(); // Deselect
744        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        // This test focuses on the visual correctness of the rendered output
755        // to catch issues like unwanted characters, malformed ANSI, etc.
756        let items = vec![S("Testing"), S("Visual"), S("Correctness")];
757        let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
758
759        // Test 1: Single character filter
760        list.set_filter_text("t");
761        list.apply_filter();
762
763        let rendered = list.view();
764        // Should not contain any malformed character sequences
765        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        // Test 2: Multi-character contiguous filter
775        list.set_filter_text("test");
776        list.apply_filter();
777
778        let rendered = list.view();
779        // Should not have pipes between consecutive highlighted characters
780        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        // Test 3: Check that highlighting preserves text integrity
787        // The word "Testing" should appear as a complete word, just with some characters styled
788        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        // Test that we don't generate excessive ANSI escape sequences
798        let items = vec![S("AbCdEfGh")];
799        let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
800
801        // Filter that matches every other character: A_C_E_G
802        list.set_filter_text("aceg");
803        list.apply_filter();
804
805        // Test unselected state to avoid border artifacts
806        list.cursor = list.len(); // Deselect
807        let rendered = list.view();
808
809        // Count ANSI reset sequences - there should not be excessive resets
810        let reset_count = rendered.matches("\x1b[0m").count();
811        let total_length = rendered.len();
812
813        // Heuristic: if resets are more than 20% of total output length, something's wrong
814        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        // Should not have malformed escape sequences
821        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        // Test that highlighting works consistently across different states
831        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        // Test unselected state
838        list.cursor = list.len(); // Deselect all
839        let unselected = list.view();
840
841        // Test selected state
842        list.cursor = 0; // Select first item
843        let selected = list.view();
844
845        // Both should be free of character separation artifacts
846        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        // Selected state can have left border, but it should be at the beginning
859        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 there's a pipe, it should be at the start of content, not between characters
864                    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        // Regression test for extra space between highlighted and non-highlighted segments
880        // This test reproduces the exact scenario reported by the user
881        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        // Test filtering with 'n' to match multiple items including Nutella
899        list.set_filter_text("n");
900        list.apply_filter();
901
902        // Make sure Nutella is in the filtered results
903        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        // Find Nutella in the filtered results and select it
910        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        // Debug: Print the rendered output to console for manual inspection
920        println!("\n=== FILTER HIGHLIGHTING TEST OUTPUT ===");
921        println!("{}", rendered);
922        println!("=== END OUTPUT ===\n");
923
924        // Check for the exact spacing issue the user reported: "│ N utella"
925        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        // Also check for other spacing issues mentioned in the user report
936        let other_spacing_issues = rendered.contains("I t's") ||  // "It's" broken up
937                                  rendered.contains("N  ice") ||  // "Nice" with extra space
938                                  rendered.contains("Li  n  ux"); // "Linux" with multiple spaces
939
940        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        // Single character filtering
953        list.set_filter_text("a");
954        list.apply_filter();
955        assert!(list.len() >= 3, "Should match 'a', 'ab', 'abc'");
956
957        // Empty filter should show all non-empty items
958        list.set_filter_text("");
959        list.apply_filter();
960        assert_eq!(list.filter_state, FilterState::Unfiltered);
961
962        // Very short items
963        list.set_filter_text("ab");
964        list.apply_filter();
965        assert!(list.len() >= 2, "Should match 'ab', 'abc'");
966
967        // Ensure no panics with edge cases
968        let rendered = list.view();
969        assert!(!rendered.is_empty());
970    }
971}