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//! ## MAJOR BUG FIXES - Viewport Scrolling & Filter Input (v0.1.4+)
9//!
10//! This implementation includes comprehensive fixes for critical list component issues:
11//!
12//! ### 🔧 Fixed Issues
13//! 1. **Filter Input Accumulation**: Characters now accumulate correctly during typing
14//! 2. **Cursor Highlighting Loss**: Selection indicator persists during viewport scrolling  
15//! 3. **Viewport Page Jumping**: Smooth one-item-at-a-time scrolling instead of jarring page jumps
16//! 4. **Upward Scrolling Problems**: Proper viewport adjustment when scrolling up
17//!
18//! ### 🏗️ Architecture Changes
19//! - **Smooth Viewport Scrolling**: `viewport_start` field + `sync_viewport_with_cursor()` method
20//! - **Proper Index Handling**: Original item indices for cursor highlighting, viewport-relative for display
21//! - **Enhanced Filter Input**: Direct textinput component integration instead of manual string manipulation
22//! - **State Management**: Improved filtering state transitions to prevent input blocking
23//!
24//! ### 📋 Key Design Decisions
25//! - **Index Semantics**: Render delegates receive original item indices for consistent cursor highlighting
26//! - **Viewport Strategy**: Only scroll when cursor moves outside visible bounds (context preservation)
27//! - **Filter Integration**: Maintain `Filtering` state during typing, only change to `FilterApplied` on Enter
28//! - **Event Forwarding**: Proper KeyMsg creation and forwarding to textinput component
29//!
30//! ### Filtering States
31//! The list supports fuzzy filtering with three states:
32//! - `Unfiltered`: No filter active
33//! - `Filtering`: User is typing a filter; input is shown in the header
34//! - `FilterApplied`: Filter accepted; only matching items are displayed
35//!
36//! When filtering is active, fuzzy match indices are stored per item and delegates can use
37//! them to apply character-level highlighting (see `defaultitem`).
38//!
39//! ### Help Integration
40//! The list implements `help::KeyMap`, so you can embed `help::Model` and get contextual
41//! help automatically based on the current filtering state.
42
43pub mod defaultitem;
44pub mod keys;
45pub mod style;
46
47use crate::{help, key, paginator, spinner, textinput};
48use bubbletea_rs::{Cmd, KeyMsg, Model as BubbleTeaModel, Msg};
49use crossterm::event::KeyCode;
50use fuzzy_matcher::skim::SkimMatcherV2;
51use fuzzy_matcher::FuzzyMatcher;
52use lipgloss_extras::lipgloss;
53use std::fmt::Display;
54
55// --- Traits (Interfaces) ---
56
57/// An item that can be displayed in the list.
58pub trait Item: Display + Clone {
59    /// The value to use when filtering this item.
60    fn filter_value(&self) -> String;
61}
62
63/// A delegate encapsulates the functionality for a list item.
64pub trait ItemDelegate<I: Item> {
65    /// Renders the item's view.
66    fn render(&self, m: &Model<I>, index: usize, item: &I) -> String;
67    /// The height of the list item.
68    fn height(&self) -> usize;
69    /// The spacing between list items.
70    fn spacing(&self) -> usize;
71    /// The update loop for the item.
72    fn update(&self, msg: &Msg, m: &mut Model<I>) -> Option<Cmd>;
73}
74
75// --- Filter ---
76#[derive(Debug, Clone)]
77#[allow(dead_code)]
78struct FilteredItem<I: Item> {
79    index: usize, // index in original items list
80    item: I,
81    matches: Vec<usize>,
82}
83
84// --- Model ---
85
86/// Current filtering state of the list.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub enum FilterState {
89    /// No filtering is active; all items are shown.
90    Unfiltered,
91    /// User is typing a filter term; live filtering UI is shown.
92    Filtering,
93    /// A filter term has been applied; only matching items are shown.
94    FilterApplied,
95}
96
97/// List model containing items, filtering, pagination, and styling.
98pub struct Model<I: Item> {
99    /// Title rendered in the list header when not filtering.
100    pub title: String,
101    items: Vec<I>,
102    delegate: Box<dyn ItemDelegate<I> + Send + Sync>,
103
104    // Components
105    /// Text input used for entering the filter term.
106    pub filter_input: textinput::Model,
107    /// Paginator controlling visible item slice.
108    pub paginator: paginator::Model,
109    /// Spinner used during expensive operations (optional usage).
110    pub spinner: spinner::Model,
111    /// Help model for displaying key bindings.
112    pub help: help::Model,
113    /// Key bindings for list navigation and filtering.
114    pub keymap: keys::ListKeyMap,
115
116    // State
117    filter_state: FilterState,
118    filtered_items: Vec<FilteredItem<I>>,
119    cursor: usize,
120    /// First visible item index for smooth scrolling.
121    ///
122    /// This field enables smooth viewport scrolling instead of jarring page jumps.
123    /// Unlike paginator-based scrolling which jumps entire pages at once, this approach
124    /// tracks the exact starting position of the visible window, allowing the viewport
125    /// to slide smoothly as the cursor moves.
126    ///
127    /// Key behaviors:
128    /// - Only scrolls when cursor moves outside visible bounds
129    /// - Preserves context by keeping previously visible items in view when possible
130    /// - Provides one-item-at-a-time scrolling for better user experience
131    ///
132    /// This was added to fix the issue where page-based scrolling caused:
133    /// 1. Loss of cursor highlighting during viewport changes
134    /// 2. Jarring jumps that lost visual context
135    /// 3. Poor upward scrolling behavior
136    viewport_start: usize,
137    width: usize,
138    height: usize,
139
140    // Styles
141    /// Visual styles for list elements and states.
142    pub styles: style::ListStyles,
143
144    // Status bar labeling
145    status_item_singular: Option<String>,
146    status_item_plural: Option<String>,
147}
148
149impl<I: Item + Send + Sync + 'static> Model<I> {
150    /// Creates a new list with items, delegate, and initial dimensions.
151    pub fn new(
152        items: Vec<I>,
153        delegate: impl ItemDelegate<I> + Send + Sync + 'static,
154        width: usize,
155        height: usize,
156    ) -> Self {
157        let mut filter_input = textinput::new();
158        filter_input.set_placeholder("Filter...");
159        let mut paginator = paginator::Model::new();
160        paginator.set_per_page(10);
161
162        let mut s = Self {
163            title: "List".to_string(),
164            items,
165            delegate: Box::new(delegate),
166            filter_input,
167            paginator,
168            spinner: spinner::Model::new(),
169            help: help::Model::new(),
170            keymap: keys::ListKeyMap::default(),
171            filter_state: FilterState::Unfiltered,
172            filtered_items: vec![],
173            cursor: 0,
174            viewport_start: 0,
175            width,
176            height,
177            styles: style::ListStyles::default(),
178            status_item_singular: None,
179            status_item_plural: None,
180        };
181        s.update_pagination();
182        s
183    }
184
185    /// Replace all items in the list and reset pagination if needed.
186    pub fn set_items(&mut self, items: Vec<I>) {
187        self.items = items;
188        self.update_pagination();
189    }
190    /// Returns a copy of the items currently visible (filtered if applicable).
191    pub fn visible_items(&self) -> Vec<I> {
192        if self.filter_state == FilterState::Unfiltered {
193            self.items.clone()
194        } else {
195            self.filtered_items.iter().map(|f| f.item.clone()).collect()
196        }
197    }
198    /// Sets the filter input text.
199    pub fn set_filter_text(&mut self, s: &str) {
200        self.filter_input.set_value(s);
201    }
202    /// Sets the current filtering state.
203    pub fn set_filter_state(&mut self, st: FilterState) {
204        self.filter_state = st;
205    }
206    /// Sets the singular/plural nouns used in the status bar.
207    pub fn set_status_bar_item_name(&mut self, singular: &str, plural: &str) {
208        self.status_item_singular = Some(singular.to_string());
209        self.status_item_plural = Some(plural.to_string());
210    }
211    /// Renders the status bar string, including position and help.
212    pub fn status_view(&self) -> String {
213        self.view_footer()
214    }
215
216    /// Get matches for an item by its original index in the full items list.
217    /// Returns None if the item doesn't have matches (unfiltered or didn't match filter).
218    pub fn matches_for_original_item(&self, original_index: usize) -> Option<&Vec<usize>> {
219        if self.filter_state == FilterState::Unfiltered {
220            return None;
221        }
222
223        // Find the FilteredItem that corresponds to this original index
224        self.filtered_items
225            .iter()
226            .find(|fi| fi.index == original_index)
227            .map(|fi| &fi.matches)
228    }
229
230    /// Sets the list title and returns `self` for chaining.
231    pub fn with_title(mut self, title: &str) -> Self {
232        self.title = title.to_string();
233        self
234    }
235    /// Returns a reference to the currently selected item, if any.
236    pub fn selected_item(&self) -> Option<&I> {
237        if self.filter_state == FilterState::Unfiltered {
238            self.items.get(self.cursor)
239        } else {
240            self.filtered_items.get(self.cursor).map(|fi| &fi.item)
241        }
242    }
243    /// Returns the current cursor position (0-based).
244    pub fn cursor(&self) -> usize {
245        self.cursor
246    }
247    /// Returns the number of items in the current view (filtered or not).
248    pub fn len(&self) -> usize {
249        if self.filter_state == FilterState::Unfiltered {
250            self.items.len()
251        } else {
252            self.filtered_items.len()
253        }
254    }
255    /// Returns `true` if there are no items to display.
256    pub fn is_empty(&self) -> bool {
257        self.len() == 0
258    }
259
260    fn update_pagination(&mut self) {
261        let item_count = self.len();
262        let item_height = self.delegate.height() + self.delegate.spacing();
263        let available_height = self.height.saturating_sub(4);
264        let per_page = if item_height > 0 {
265            available_height / item_height
266        } else {
267            10
268        }
269        .max(1);
270        self.paginator.set_per_page(per_page);
271        self.paginator
272            .set_total_pages(item_count.div_ceil(per_page));
273        if self.cursor >= item_count {
274            self.cursor = item_count.saturating_sub(1);
275        }
276    }
277
278    /// Synchronize the viewport with the cursor position using smooth scrolling.
279    ///
280    /// This method implements smooth, context-preserving viewport scrolling that only adjusts
281    /// the view when necessary. It replaces the previous page-jumping behavior that caused
282    /// cursor highlighting issues and poor user experience.
283    ///
284    /// ## Algorithm
285    /// 1. If all items fit in the viewport, no scrolling is needed
286    /// 2. Calculate current viewport bounds (start to start + size)
287    /// 3. Only scroll if cursor moves outside visible bounds:
288    ///    - Cursor above viewport: Scroll up to show cursor at top
289    ///    - Cursor below viewport: Scroll down just enough to show cursor at bottom
290    ///    - Cursor within viewport: Don't scroll (preserves context)
291    /// 4. Clamp viewport to valid range to prevent overscrolling
292    ///
293    /// ## Benefits over page-based scrolling
294    /// - **Smooth movement**: One-item-at-a-time instead of page jumps
295    /// - **Context preservation**: Keeps previously visible items when possible
296    /// - **Cursor highlighting**: Index tracking remains consistent
297    /// - **Better UX**: Natural scrolling that follows cursor movement
298    ///
299    /// ## Bug fixes addressed
300    /// - Fixed cursor highlighting loss during viewport transitions
301    /// - Fixed upward scrolling display issues
302    /// - Fixed jarring page jumps that disoriented users
303    ///
304    /// This ensures the cursor remains visible while preserving context and avoiding page jumps.
305    fn sync_viewport_with_cursor(&mut self) {
306        let viewport_size = self.paginator.per_page;
307        let total_items = self.len();
308
309        if viewport_size == 0 || total_items <= viewport_size {
310            // All items fit in viewport, no scrolling needed
311            self.viewport_start = 0;
312            return;
313        }
314
315        // Calculate current viewport bounds
316        let viewport_end = self.viewport_start + viewport_size;
317
318        // Only adjust viewport if cursor is outside visible bounds
319        if self.cursor < self.viewport_start {
320            // Cursor moved up beyond viewport, scroll up to show cursor
321            self.viewport_start = self.cursor;
322        } else if self.cursor >= viewport_end {
323            // Cursor moved down beyond viewport, scroll down just enough to show cursor
324            self.viewport_start = self.cursor - viewport_size + 1;
325        }
326        // If cursor is within viewport bounds, don't change anything (preserves context)
327
328        // Clamp viewport_start to valid range
329        self.viewport_start = self
330            .viewport_start
331            .min(total_items.saturating_sub(viewport_size));
332    }
333
334    #[allow(dead_code)]
335    fn matches_for_item(&self, index: usize) -> Option<&Vec<usize>> {
336        if index < self.filtered_items.len() {
337            Some(&self.filtered_items[index].matches)
338        } else {
339            None
340        }
341    }
342
343    fn apply_filter(&mut self) {
344        let filter_term = self.filter_input.value().to_lowercase();
345        if filter_term.is_empty() {
346            self.filter_state = FilterState::Unfiltered;
347            self.filtered_items.clear();
348        } else {
349            let matcher = SkimMatcherV2::default();
350            self.filtered_items = self
351                .items
352                .iter()
353                .enumerate()
354                .filter_map(|(i, item)| {
355                    matcher
356                        .fuzzy_indices(&item.filter_value(), &filter_term)
357                        .map(|(_score, indices)| FilteredItem {
358                            index: i,
359                            item: item.clone(),
360                            matches: indices,
361                        })
362                })
363                .collect();
364            // CRITICAL FIX: Set state to Filtering (not FilterApplied) here so the user can
365            // continue typing. State changes to FilterApplied only happen on Enter/Esc.
366            //
367            // This prevents the filter input bug where typing subsequent characters
368            // after the first one was ignored, while still allowing len() to work correctly.
369            self.filter_state = FilterState::Filtering;
370        }
371        self.cursor = 0;
372        self.update_pagination();
373    }
374
375    fn view_header(&self) -> String {
376        if self.filter_state == FilterState::Filtering {
377            let prompt = self.styles.filter_prompt.clone().render("Filter:");
378            format!("{} {}", prompt, self.filter_input.view())
379        } else {
380            let mut header = self.title.clone();
381            if self.filter_state == FilterState::FilterApplied {
382                header.push_str(&format!(" (filtered: {})", self.len()));
383            }
384            self.styles.title.clone().render(&header)
385        }
386    }
387
388    fn view_items(&self) -> String {
389        if self.is_empty() {
390            return self.styles.no_items.clone().render("No items");
391        }
392
393        let items_to_render: Vec<(usize, &I)> = if self.filter_state == FilterState::Unfiltered {
394            self.items.iter().enumerate().collect()
395        } else {
396            self.filtered_items
397                .iter()
398                .map(|fi| (fi.index, &fi.item))
399                .collect()
400        };
401
402        // VIEWPORT SCROLLING FIX: Use viewport-based bounds for smooth scrolling instead of paginator page jumps
403        //
404        // This replaces the previous paginator.get_slice_bounds() approach which caused jarring page jumps.
405        // Instead of jumping entire pages, we now use a sliding window (viewport_start + viewport_size)
406        // that moves smoothly as the cursor navigates. This provides:
407        // 1. Smooth one-item-at-a-time scrolling
408        // 2. Context preservation (previous items remain visible when possible)
409        // 3. Consistent cursor highlighting (no index confusion)
410        let start = self.viewport_start;
411        let viewport_size = self.paginator.per_page;
412        let end = (start + viewport_size).min(items_to_render.len());
413        let mut items = Vec::new();
414
415        // CURSOR HIGHLIGHTING FIX: Render each item individually using the delegate
416        //
417        // The key fix here is passing *orig_idx instead of a viewport-relative index.
418        // This ensures that cursor highlighting works correctly across viewport scrolling.
419        for (_filtered_idx, (orig_idx, item)) in items_to_render
420            .iter()
421            .enumerate()
422            .take(end.min(items_to_render.len()))
423            .skip(start)
424        {
425            // CRITICAL: Use the original item index for cursor highlighting comparison
426            //
427            // The cursor position (self.cursor) is always tracked in terms of the original item indices,
428            // not viewport-relative positions. By passing *orig_idx to the delegate's render method,
429            // we ensure that the delegate can correctly compare (index == m.cursor) for highlighting.
430            //
431            // Previous bug: Passing viewport-relative indices caused highlighting to break because
432            // the comparison (viewport_index == cursor_position) would fail when scrolling.
433            //
434            // Example: If cursor is at position 7 and viewport shows items 5-9:
435            // - Wrong: Pass viewport index 2 (for item 7 in viewport) -> highlighting breaks
436            // - Right: Pass original index 7 -> highlighting works correctly
437            let item_output = self.delegate.render(self, *orig_idx, item);
438            items.push(item_output);
439        }
440
441        // Join items with newlines, respecting spacing
442        let separator = "\n".repeat(self.delegate.spacing().max(1));
443        items.join(&separator)
444    }
445
446    fn view_footer(&self) -> String {
447        let mut footer = String::new();
448        if !self.is_empty() {
449            let singular = self.status_item_singular.as_deref().unwrap_or("item");
450            let plural = self.status_item_plural.as_deref().unwrap_or("items");
451            let noun = if self.len() == 1 { singular } else { plural };
452            footer.push_str(&format!("{}/{} {}", self.cursor + 1, self.len(), noun));
453        }
454        let help_view = self.help.view(self);
455        if !help_view.is_empty() {
456            footer.push('\n');
457            footer.push_str(&help_view);
458        }
459        footer
460    }
461}
462
463// Help integration from the list model
464impl<I: Item> help::KeyMap for Model<I> {
465    fn short_help(&self) -> Vec<&key::Binding> {
466        match self.filter_state {
467            FilterState::Filtering => vec![&self.keymap.accept_filter, &self.keymap.cancel_filter],
468            _ => vec![
469                &self.keymap.cursor_up,
470                &self.keymap.cursor_down,
471                &self.keymap.filter,
472            ],
473        }
474    }
475    fn full_help(&self) -> Vec<Vec<&key::Binding>> {
476        match self.filter_state {
477            FilterState::Filtering => {
478                vec![vec![&self.keymap.accept_filter, &self.keymap.cancel_filter]]
479            }
480            _ => vec![
481                vec![
482                    &self.keymap.cursor_up,
483                    &self.keymap.cursor_down,
484                    &self.keymap.next_page,
485                    &self.keymap.prev_page,
486                ],
487                vec![
488                    &self.keymap.go_to_start,
489                    &self.keymap.go_to_end,
490                    &self.keymap.filter,
491                    &self.keymap.clear_filter,
492                ],
493            ],
494        }
495    }
496}
497
498impl<I: Item + Send + Sync + 'static> BubbleTeaModel for Model<I> {
499    fn init() -> (Self, Option<Cmd>) {
500        let model = Self::new(vec![], defaultitem::DefaultDelegate::new(), 80, 24);
501        (model, None)
502    }
503    fn update(&mut self, msg: Msg) -> Option<Cmd> {
504        if self.filter_state == FilterState::Filtering {
505            if let Some(key_msg) = msg.downcast_ref::<KeyMsg>() {
506                match key_msg.key {
507                    crossterm::event::KeyCode::Esc => {
508                        self.filter_state = if self.filtered_items.is_empty() {
509                            FilterState::Unfiltered
510                        } else {
511                            FilterState::FilterApplied
512                        };
513                        self.filter_input.blur();
514                        return None;
515                    }
516                    crossterm::event::KeyCode::Enter => {
517                        self.apply_filter();
518                        self.filter_state = FilterState::FilterApplied;
519                        self.filter_input.blur();
520                        return None;
521                    }
522                    crossterm::event::KeyCode::Char(c) => {
523                        // FILTER INPUT FIX: Create a new KeyMsg specifically for the textinput component
524                        //
525                        // This replaces the previous manual string manipulation approach which was
526                        // error-prone and didn't handle all textinput features correctly.
527                        // By forwarding the key event directly to the textinput component, we ensure
528                        // proper character accumulation and input handling.
529                        let textinput_msg = Box::new(KeyMsg {
530                            key: KeyCode::Char(c),
531                            modifiers: key_msg.modifiers,
532                        }) as Msg;
533                        self.filter_input.update(textinput_msg);
534                        self.apply_filter();
535                    }
536                    crossterm::event::KeyCode::Backspace => {
537                        // FILTER INPUT FIX: Forward backspace to textinput for proper handling
538                        //
539                        // Previous approach used manual string.pop() which didn't handle cursor
540                        // positioning or other textinput features. This ensures consistent behavior.
541                        let textinput_msg = Box::new(KeyMsg {
542                            key: KeyCode::Backspace,
543                            modifiers: key_msg.modifiers,
544                        }) as Msg;
545                        self.filter_input.update(textinput_msg);
546                        self.apply_filter();
547                    }
548                    crossterm::event::KeyCode::Delete => { /* ignore delete for now */ }
549                    crossterm::event::KeyCode::Left => {
550                        let pos = self.filter_input.position();
551                        if pos > 0 {
552                            self.filter_input.set_cursor(pos - 1);
553                        }
554                    }
555                    crossterm::event::KeyCode::Right => {
556                        let pos = self.filter_input.position();
557                        self.filter_input.set_cursor(pos + 1);
558                    }
559                    crossterm::event::KeyCode::Home => {
560                        self.filter_input.cursor_start();
561                    }
562                    crossterm::event::KeyCode::End => {
563                        self.filter_input.cursor_end();
564                    }
565                    _ => {}
566                }
567            }
568            return None;
569        }
570
571        if let Some(key_msg) = msg.downcast_ref::<KeyMsg>() {
572            if self.keymap.cursor_up.matches(key_msg) {
573                if self.cursor > 0 {
574                    self.cursor -= 1;
575                    // SMOOTH SCROLLING FIX: Sync viewport after every cursor movement
576                    // This ensures the cursor remains visible and the viewport scrolls smoothly
577                    self.sync_viewport_with_cursor();
578                }
579            } else if self.keymap.cursor_down.matches(key_msg) {
580                if self.cursor < self.len().saturating_sub(1) {
581                    self.cursor += 1;
582                    // SMOOTH SCROLLING FIX: Sync viewport after every cursor movement
583                    // This replaces the previous page-jumping behavior with smooth scrolling
584                    self.sync_viewport_with_cursor();
585                }
586            } else if self.keymap.go_to_start.matches(key_msg) {
587                self.cursor = 0;
588                // SMOOTH SCROLLING FIX: Ensure viewport shows the beginning of the list
589                self.sync_viewport_with_cursor();
590            } else if self.keymap.go_to_end.matches(key_msg) {
591                self.cursor = self.len().saturating_sub(1);
592                // SMOOTH SCROLLING FIX: Ensure viewport shows the end of the list
593                self.sync_viewport_with_cursor();
594            } else if self.keymap.filter.matches(key_msg) {
595                self.filter_state = FilterState::Filtering;
596                // propagate the blink command so it is polled by runtime
597                return Some(self.filter_input.focus());
598            } else if self.keymap.clear_filter.matches(key_msg) {
599                self.filter_input.set_value("");
600                self.filter_state = FilterState::Unfiltered;
601                self.filtered_items.clear();
602                self.cursor = 0;
603                self.update_pagination();
604            }
605        }
606        None
607    }
608    fn view(&self) -> String {
609        lipgloss::join_vertical(
610            lipgloss::LEFT,
611            &[&self.view_header(), &self.view_items(), &self.view_footer()],
612        )
613    }
614}
615
616// Re-export commonly used types
617pub use defaultitem::{DefaultDelegate, DefaultItem, DefaultItemStyles};
618pub use keys::ListKeyMap;
619pub use style::ListStyles;
620
621#[cfg(test)]
622mod tests {
623    use super::*;
624
625    #[derive(Clone)]
626    struct S(&'static str);
627    impl std::fmt::Display for S {
628        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
629            write!(f, "{}", self.0)
630        }
631    }
632    impl Item for S {
633        fn filter_value(&self) -> String {
634            self.0.to_string()
635        }
636    }
637
638    #[test]
639    fn test_status_bar_item_name() {
640        let mut list = Model::new(
641            vec![S("foo"), S("bar")],
642            defaultitem::DefaultDelegate::new(),
643            10,
644            10,
645        );
646        let v = list.status_view();
647        assert!(v.contains("2 items"));
648        list.set_items(vec![S("foo")]);
649        let v = list.status_view();
650        assert!(v.contains("1 item"));
651    }
652
653    #[test]
654    fn test_status_bar_without_items() {
655        let list = Model::new(Vec::<S>::new(), defaultitem::DefaultDelegate::new(), 10, 10);
656        assert!(list.status_view().contains("No items") || list.is_empty());
657    }
658
659    #[test]
660    fn test_custom_status_bar_item_name() {
661        let mut list = Model::new(
662            vec![S("foo"), S("bar")],
663            defaultitem::DefaultDelegate::new(),
664            10,
665            10,
666        );
667        list.set_status_bar_item_name("connection", "connections");
668        assert!(list.status_view().contains("2 connections"));
669        list.set_items(vec![S("foo")]);
670        assert!(list.status_view().contains("1 connection"));
671        list.set_items(vec![]);
672        // When empty, status_view currently just shows help or empty; ensure no panic
673        let _ = list.status_view();
674    }
675
676    #[test]
677    fn test_set_filter_text_and_state_visible_items() {
678        let tc = vec![S("foo"), S("bar"), S("baz")];
679        let mut list = Model::new(tc.clone(), defaultitem::DefaultDelegate::new(), 10, 10);
680        list.set_filter_text("ba");
681        list.set_filter_state(FilterState::Unfiltered);
682        assert_eq!(list.visible_items().len(), tc.len());
683
684        list.set_filter_state(FilterState::Filtering);
685        list.apply_filter();
686        let vis = list.visible_items();
687        assert_eq!(vis.len(), 2); // bar, baz
688
689        list.set_filter_state(FilterState::FilterApplied);
690        let vis2 = list.visible_items();
691        assert_eq!(vis2.len(), 2);
692    }
693
694    #[test]
695    fn test_selection_highlighting_works() {
696        let items = vec![S("first item"), S("second item"), S("third item")];
697        let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
698
699        // Test that view renders without crashing and includes styling
700        let view_output = list.view();
701        assert!(!view_output.is_empty(), "View should not be empty");
702
703        // Test selection highlighting by checking that cursor position affects rendering
704        let first_view = list.view();
705        list.cursor = 1; // Move cursor to second item
706        let second_view = list.view();
707
708        // The views should be different because of selection highlighting
709        assert_ne!(
710            first_view, second_view,
711            "Selection highlighting should change the view"
712        );
713    }
714
715    #[test]
716    fn test_filter_highlighting_works() {
717        let items = vec![S("apple pie"), S("banana bread"), S("carrot cake")];
718        let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
719
720        // Apply filter that should match only some items
721        list.set_filter_text("ap");
722        list.apply_filter(); // Actually apply the filter to process matches
723
724        let filtered_view = list.view();
725        assert!(
726            !filtered_view.is_empty(),
727            "Filtered view should not be empty"
728        );
729
730        // Check that filtering worked ("ap" should only match "apple pie")
731        assert_eq!(list.len(), 1, "Should have 1 item matching 'ap'");
732
733        // Test that matches are stored correctly
734        assert!(
735            !list.filtered_items.is_empty(),
736            "Filtered items should have match data"
737        );
738        if !list.filtered_items.is_empty() {
739            assert!(
740                !list.filtered_items[0].matches.is_empty(),
741                "First filtered item should have matches"
742            );
743            // Check that the matched item is indeed "apple pie"
744            assert_eq!(list.filtered_items[0].item.0, "apple pie");
745        }
746    }
747
748    #[test]
749    fn test_filter_highlighting_segment_based() {
750        let items = vec![S("Nutella"), S("Linux"), S("Python")];
751        let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
752
753        // Test contiguous match highlighting - filter "nut" should match only "Nutella"
754        list.set_filter_text("nut");
755        list.apply_filter();
756
757        assert_eq!(list.len(), 1, "Should have 1 item matching 'nut'");
758        assert_eq!(list.filtered_items[0].item.0, "Nutella");
759
760        // Verify that matches are [0, 1, 2] for "Nut" in "Nutella"
761        let matches = &list.filtered_items[0].matches;
762        assert_eq!(
763            matches.len(),
764            3,
765            "Should have 3 character matches for 'nut'"
766        );
767        assert_eq!(matches[0], 0, "First match should be at index 0 (N)");
768        assert_eq!(matches[1], 1, "Second match should be at index 1 (u)");
769        assert_eq!(matches[2], 2, "Third match should be at index 2 (t)");
770
771        // Test the actual highlighting by rendering - it should not have character separation
772        let rendered = list.view();
773
774        // The rendered output should not be empty and should render without errors
775        assert!(!rendered.is_empty(), "Rendered view should not be empty");
776
777        // Test that our highlighting function works directly with contiguous segments
778        use super::defaultitem::apply_character_highlighting;
779        let test_result = apply_character_highlighting(
780            "Nutella",
781            &[0, 1, 2], // Consecutive indices should be rendered as a single segment
782            &lipgloss::Style::new().bold(true),
783            &lipgloss::Style::new(),
784        );
785        // The result should contain styled text and be longer due to ANSI codes
786        assert!(
787            test_result.len() > "Nutella".len(),
788            "Highlighted text should be longer due to ANSI codes"
789        );
790
791        // Verify the fix works: test with non-consecutive matches too
792        let test_result_sparse = apply_character_highlighting(
793            "Nutella",
794            &[0, 2, 4], // Non-consecutive indices: N_t_l
795            &lipgloss::Style::new().underline(true),
796            &lipgloss::Style::new(),
797        );
798        assert!(
799            test_result_sparse.len() > "Nutella".len(),
800            "Sparse highlighted text should also work"
801        );
802    }
803
804    #[test]
805    fn test_filter_ansi_efficiency() {
806        // Test that consecutive matches use fewer ANSI codes than character-by-character
807        use super::defaultitem::apply_character_highlighting;
808        let highlight_style = lipgloss::Style::new().bold(true);
809        let normal_style = lipgloss::Style::new();
810
811        let consecutive_result = apply_character_highlighting(
812            "Hello",
813            &[0, 1, 2], // "Hel" - should be one ANSI block
814            &highlight_style,
815            &normal_style,
816        );
817
818        let sparse_result = apply_character_highlighting(
819            "Hello",
820            &[0, 2, 4], // "H_l_o" - should be three ANSI blocks
821            &highlight_style,
822            &normal_style,
823        );
824
825        // Consecutive matches should result in more efficient ANSI usage
826        // This is a rough heuristic - consecutive should have fewer style applications
827        assert!(
828            consecutive_result.len() < sparse_result.len(),
829            "Consecutive highlighting should be more efficient than sparse highlighting"
830        );
831    }
832
833    #[test]
834    fn test_filter_unicode_characters() {
835        let items = vec![S("café"), S("naïve"), S("🦀 rust"), S("北京")];
836        let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
837
838        // Test filtering with accented characters
839        list.set_filter_text("caf");
840        list.apply_filter();
841        assert_eq!(list.len(), 1);
842        assert_eq!(list.filtered_items[0].item.0, "café");
843
844        // Test filtering with emoji
845        list.set_filter_text("rust");
846        list.apply_filter();
847        assert_eq!(list.len(), 1);
848        assert_eq!(list.filtered_items[0].item.0, "🦀 rust");
849
850        // Ensure rendering doesn't crash with unicode
851        let rendered = list.view();
852        assert!(!rendered.is_empty());
853    }
854
855    #[test]
856    fn test_filter_highlighting_no_pipe_characters() {
857        // Regression test for issue where pipe characters (│) were inserted
858        // between highlighted and non-highlighted text segments
859        let items = vec![S("Nutella"), S("Linux"), S("Python")];
860        let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
861
862        // Test case from debug output: filter "nut" should only match "Nutella"
863        list.set_filter_text("nut");
864        list.apply_filter();
865
866        assert_eq!(
867            list.len(),
868            1,
869            "Filter 'nut' should match exactly 1 item (Nutella)"
870        );
871        assert_eq!(list.filtered_items[0].item.0, "Nutella");
872
873        // First test non-selected state (cursor on different item)
874        // This should not have any borders/pipes
875        if !list.is_empty() {
876            list.cursor = list.len(); // Set cursor beyond items to deselect
877        }
878        let unselected_rendered = list.view();
879
880        // For unselected items, there should be no pipe characters between highlighted segments
881        // This is the specific issue from the debug output: "N│utella"
882        assert!(
883            !unselected_rendered.contains("N│u") && !unselected_rendered.contains("ut│e"),
884            "Unselected item rendering should not have pipe characters between highlighted segments. Output: {:?}",
885            unselected_rendered
886        );
887
888        // Selected items can have a left border pipe, but not between text segments
889        list.cursor = 0; // Select the first item
890        let selected_rendered = list.view();
891
892        // Check that the pipe is only at the beginning (left border) not between text
893        assert!(
894            !selected_rendered.contains("N│u") && !selected_rendered.contains("ut│e"),
895            "Selected item should not have pipe characters between highlighted text segments. Output: {:?}",
896            selected_rendered
897        );
898
899        // Test another case: filter "li" on "Linux" - test unselected first
900        list.set_filter_text("li");
901        list.apply_filter();
902
903        assert_eq!(list.len(), 1);
904        assert_eq!(list.filtered_items[0].item.0, "Linux");
905
906        // Test unselected Linux (no borders)
907        list.cursor = list.len(); // Deselect
908        let linux_unselected = list.view();
909        assert!(
910            !linux_unselected.contains("Li│n") && !linux_unselected.contains("i│n"),
911            "Unselected Linux should not have pipes between highlighted segments. Output: {:?}",
912            linux_unselected
913        );
914    }
915
916    #[test]
917    fn test_filter_highlighting_visual_correctness() {
918        // This test focuses on the visual correctness of the rendered output
919        // to catch issues like unwanted characters, malformed ANSI, etc.
920        let items = vec![S("Testing"), S("Visual"), S("Correctness")];
921        let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
922
923        // Test 1: Single character filter
924        list.set_filter_text("t");
925        list.apply_filter();
926
927        let rendered = list.view();
928        // Should not contain any malformed character sequences
929        assert!(
930            !rendered.contains("│T")
931                && !rendered.contains("T│")
932                && !rendered.contains("│t")
933                && !rendered.contains("t│"),
934            "Single character highlighting should not have pipe artifacts. Output: {:?}",
935            rendered
936        );
937
938        // Test 2: Multi-character contiguous filter
939        list.set_filter_text("test");
940        list.apply_filter();
941
942        let rendered = list.view();
943        // Should not have pipes between consecutive highlighted characters
944        assert!(
945            !rendered.contains("T│e") && !rendered.contains("e│s") && !rendered.contains("s│t"),
946            "Contiguous highlighting should not have character separation. Output: {:?}",
947            rendered
948        );
949
950        // Test 3: Check that highlighting preserves text integrity
951        // The word "Testing" should appear as a complete word, just with some characters styled
952        assert!(
953            rendered.contains("Testing") || rendered.matches("Test").count() > 0,
954            "Original text should be preserved in some form. Output: {:?}",
955            rendered
956        );
957    }
958
959    #[test]
960    fn test_filter_highlighting_ansi_efficiency() {
961        // Test that we don't generate excessive ANSI escape sequences
962        let items = vec![S("AbCdEfGh")];
963        let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
964
965        // Filter that matches every other character: A_C_E_G
966        list.set_filter_text("aceg");
967        list.apply_filter();
968
969        // Test unselected state to avoid border artifacts
970        list.cursor = list.len(); // Deselect
971        let rendered = list.view();
972
973        // Count ANSI reset sequences - there should not be excessive resets
974        let reset_count = rendered.matches("\x1b[0m").count();
975        let total_length = rendered.len();
976
977        // Heuristic: if resets are more than 20% of total output length, something's wrong
978        assert!(
979            reset_count < total_length / 5,
980            "Too many ANSI reset sequences detected ({} resets in {} chars). This suggests inefficient styling. Output: {:?}",
981            reset_count, total_length, rendered
982        );
983
984        // Should not have malformed escape sequences
985        assert!(
986            !rendered.contains("\x1b[0m│") && !rendered.contains("│\x1b["),
987            "ANSI sequences should not be mixed with pipe characters. Output: {:?}",
988            rendered
989        );
990    }
991
992    #[test]
993    fn test_filter_highlighting_state_consistency() {
994        // Test that highlighting works consistently across different states
995        let items = vec![S("StateTest"), S("Another"), S("ThirdItem")];
996        let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
997
998        list.set_filter_text("st");
999        list.apply_filter();
1000
1001        // Test unselected state
1002        list.cursor = list.len(); // Deselect all
1003        let unselected = list.view();
1004
1005        // Test selected state
1006        list.cursor = 0; // Select first item
1007        let selected = list.view();
1008
1009        // Both should be free of character separation artifacts
1010        assert!(
1011            !unselected.contains("S│t") && !unselected.contains("t│a"),
1012            "Unselected state should not have character separation. Output: {:?}",
1013            unselected
1014        );
1015
1016        assert!(
1017            !selected.contains("S│t") && !selected.contains("t│a"),
1018            "Selected state should not have character separation. Output: {:?}",
1019            selected
1020        );
1021
1022        // Selected state can have left border, but it should be at the beginning
1023        if selected.contains("│") {
1024            let lines: Vec<&str> = selected.lines().collect();
1025            for line in lines {
1026                if line.contains("StateTest") || line.contains("st") {
1027                    // If there's a pipe, it should be at the start of content, not between characters
1028                    if let Some(pipe_pos) = line.find("│") {
1029                        let after_pipe = &line[pipe_pos + "│".len()..];
1030                        assert!(
1031                            !after_pipe.contains("│"),
1032                            "Only one pipe should appear per line (left border). Line: {:?}",
1033                            line
1034                        );
1035                    }
1036                }
1037            }
1038        }
1039    }
1040
1041    #[test]
1042    fn test_filter_highlighting_spacing_issue() {
1043        // Regression test for extra space between highlighted and non-highlighted segments
1044        // This test reproduces the exact scenario reported by the user
1045        let items = vec![
1046            S("Raspberry Pi's"),
1047            S("Nutella"),
1048            S("Bitter melon"),
1049            S("Nice socks"),
1050            S("Eight hours of sleep"),
1051            S("Cats"),
1052            S("Plantasia, the album"),
1053            S("Pour over coffee"),
1054            S("VR"),
1055            S("Noguchi Lamps"),
1056            S("Linux"),
1057            S("Business school"),
1058        ];
1059
1060        let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 24);
1061
1062        // Test filtering with 'n' to match multiple items including Nutella
1063        list.set_filter_text("n");
1064        list.apply_filter();
1065
1066        // Make sure Nutella is in the filtered results
1067        let has_nutella = list
1068            .filtered_items
1069            .iter()
1070            .any(|item| item.item.0 == "Nutella");
1071        assert!(has_nutella, "Nutella should be in filtered results");
1072
1073        // Find Nutella in the filtered results and select it
1074        for (i, filtered_item) in list.filtered_items.iter().enumerate() {
1075            if filtered_item.item.0 == "Nutella" {
1076                list.cursor = i;
1077                break;
1078            }
1079        }
1080
1081        let rendered = list.view();
1082
1083        // Debug: Print the rendered output to console for manual inspection
1084        println!("\n=== FILTER HIGHLIGHTING TEST OUTPUT ===");
1085        println!("{}", rendered);
1086        println!("=== END OUTPUT ===\n");
1087
1088        // Check for the exact spacing issue the user reported: "│ N utella"
1089        let has_nutella_spacing_issue =
1090            rendered.contains("│ N utella") || rendered.contains("N utella");
1091
1092        if has_nutella_spacing_issue {
1093            panic!(
1094                "❌ SPACING ISSUE DETECTED: Found '│ N utella' or 'N utella' in output. \
1095                   Expected '│ Nutella' or 'Nutella' without extra spaces."
1096            );
1097        }
1098
1099        // Also check for other spacing issues mentioned in the user report
1100        let other_spacing_issues = rendered.contains("I t's") ||  // "It's" broken up
1101                                  rendered.contains("N  ice") ||  // "Nice" with extra space
1102                                  rendered.contains("Li  n  ux"); // "Linux" with multiple spaces
1103
1104        if other_spacing_issues {
1105            panic!("❌ ADDITIONAL SPACING ISSUES: Found other words with extra spaces in highlighting.");
1106        }
1107
1108        println!("✅ No spacing issues detected in filter highlighting.");
1109    }
1110
1111    #[test]
1112    fn test_filter_edge_cases() {
1113        let items = vec![S("a"), S("ab"), S("abc"), S(""), S("   ")];
1114        let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
1115
1116        // Single character filtering
1117        list.set_filter_text("a");
1118        list.apply_filter();
1119        assert!(list.len() >= 3, "Should match 'a', 'ab', 'abc'");
1120
1121        // Empty filter should show all non-empty items
1122        list.set_filter_text("");
1123        list.apply_filter();
1124        assert_eq!(list.filter_state, FilterState::Unfiltered);
1125
1126        // Very short items
1127        list.set_filter_text("ab");
1128        list.apply_filter();
1129        assert!(list.len() >= 2, "Should match 'ab', 'abc'");
1130
1131        // Ensure no panics with edge cases
1132        let rendered = list.view();
1133        assert!(!rendered.is_empty());
1134    }
1135}