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_edge_cases() {
879        let items = vec![S("a"), S("ab"), S("abc"), S(""), S("   ")];
880        let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
881
882        // Single character filtering
883        list.set_filter_text("a");
884        list.apply_filter();
885        assert!(list.len() >= 3, "Should match 'a', 'ab', 'abc'");
886
887        // Empty filter should show all non-empty items
888        list.set_filter_text("");
889        list.apply_filter();
890        assert_eq!(list.filter_state, FilterState::Unfiltered);
891
892        // Very short items
893        list.set_filter_text("ab");
894        list.apply_filter();
895        assert!(list.len() >= 2, "Should match 'ab', 'abc'");
896
897        // Ensure no panics with edge cases
898        let rendered = list.view();
899        assert!(!rendered.is_empty());
900    }
901}