Skip to main content

bubbles/
list.rs

1//! Feature-rich list component with filtering and pagination.
2//!
3//! This module provides a list widget with optional filtering, pagination,
4//! help display, status messages, and spinner for TUI applications.
5//!
6//! # Example
7//!
8//! ```rust
9//! use bubbles::list::{List, Item, DefaultDelegate};
10//!
11//! #[derive(Clone)]
12//! struct MyItem {
13//!     title: String,
14//!     description: String,
15//! }
16//!
17//! impl Item for MyItem {
18//!     fn filter_value(&self) -> &str {
19//!         &self.title
20//!     }
21//! }
22//!
23//! let items = vec![
24//!     MyItem { title: "Apple".into(), description: "A fruit".into() },
25//!     MyItem { title: "Banana".into(), description: "Another fruit".into() },
26//! ];
27//!
28//! let list = List::new(items, DefaultDelegate::new(), 80, 24);
29//! ```
30
31use crate::help::Help;
32use crate::key::{Binding, matches};
33use crate::paginator::Paginator;
34use crate::spinner::{SpinnerModel, TickMsg};
35use crate::textinput::TextInput;
36use bubbletea::{Cmd, KeyMsg, Message, Model, MouseAction, MouseButton, MouseMsg};
37use lipgloss::{Color, Style};
38use std::time::Duration;
39
40/// Trait for items that can be displayed in a list.
41pub trait Item: Clone + Send + 'static {
42    /// Returns the value used for filtering.
43    fn filter_value(&self) -> &str;
44}
45
46/// Trait for rendering list items.
47pub trait ItemDelegate<I: Item>: Clone + Send + 'static {
48    /// Returns the height of each item in lines.
49    fn height(&self) -> usize;
50
51    /// Returns the spacing between items.
52    fn spacing(&self) -> usize;
53
54    /// Renders an item.
55    fn render(&self, item: &I, index: usize, selected: bool, width: usize) -> String;
56
57    /// Updates the delegate (optional).
58    fn update(&mut self, _msg: &Message, _item: &mut I) -> Option<Cmd> {
59        None
60    }
61}
62
63/// Default delegate for simple item rendering.
64#[derive(Debug, Clone)]
65pub struct DefaultDelegate {
66    /// Style for normal items.
67    pub normal_style: Style,
68    /// Style for selected items.
69    pub selected_style: Style,
70    /// Height of each item.
71    pub item_height: usize,
72    /// Spacing between items.
73    pub item_spacing: usize,
74}
75
76impl Default for DefaultDelegate {
77    fn default() -> Self {
78        Self::new()
79    }
80}
81
82impl DefaultDelegate {
83    /// Creates a new default delegate.
84    #[must_use]
85    pub fn new() -> Self {
86        Self {
87            normal_style: Style::new(),
88            selected_style: Style::new().foreground_color(Color::from("212")).bold(),
89            item_height: 1,
90            item_spacing: 0,
91        }
92    }
93
94    /// Sets the item height.
95    #[must_use]
96    pub fn with_height(mut self, h: usize) -> Self {
97        self.item_height = h;
98        self
99    }
100
101    /// Sets the item spacing.
102    #[must_use]
103    pub fn with_spacing(mut self, s: usize) -> Self {
104        self.item_spacing = s;
105        self
106    }
107}
108
109impl<I: Item> ItemDelegate<I> for DefaultDelegate {
110    fn height(&self) -> usize {
111        self.item_height
112    }
113
114    fn spacing(&self) -> usize {
115        self.item_spacing
116    }
117
118    fn render(&self, item: &I, _index: usize, selected: bool, width: usize) -> String {
119        let value = item.filter_value();
120
121        // Truncate based on display width
122        let truncated = {
123            use unicode_width::UnicodeWidthStr;
124            if UnicodeWidthStr::width(value) <= width {
125                value.to_string()
126            } else if width == 0 {
127                String::new()
128            } else {
129                let target_width = width.saturating_sub(1);
130                let mut current_width = 0;
131                let mut result = String::new();
132
133                for c in value.chars() {
134                    let w = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
135                    if current_width + w > target_width {
136                        break;
137                    }
138                    result.push(c);
139                    current_width += w;
140                }
141                format!("{}…", result)
142            }
143        };
144
145        if selected {
146            self.selected_style.render(&truncated)
147        } else {
148            self.normal_style.render(&truncated)
149        }
150    }
151}
152
153/// Represents a match rank from filtering.
154#[derive(Debug, Clone)]
155pub struct Rank {
156    /// Index of the item in the original list.
157    pub index: usize,
158    /// Indices of matched characters.
159    pub matched_indices: Vec<usize>,
160}
161
162/// Filter state.
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub enum FilterState {
165    /// No filter applied.
166    Unfiltered,
167    /// User is actively filtering.
168    Filtering,
169    /// Filter has been applied.
170    FilterApplied,
171}
172
173impl std::fmt::Display for FilterState {
174    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175        match self {
176            Self::Unfiltered => write!(f, "unfiltered"),
177            Self::Filtering => write!(f, "filtering"),
178            Self::FilterApplied => write!(f, "filter applied"),
179        }
180    }
181}
182
183/// Type alias for filter functions.
184pub type FilterFn = Box<dyn Fn(&str, &[String]) -> Vec<Rank> + Send + Sync>;
185
186/// Default filter using simple substring matching.
187pub fn default_filter(term: &str, targets: &[String]) -> Vec<Rank> {
188    let term_lower = term.to_lowercase();
189    targets
190        .iter()
191        .enumerate()
192        .filter(|(_, target)| target.to_lowercase().contains(&term_lower))
193        .map(|(index, target)| {
194            // Find match indices
195            let target_lower = target.to_lowercase();
196            let start = target_lower.find(&term_lower).unwrap_or(0);
197            let matched_indices: Vec<usize> = (start..start + term.len()).collect();
198            Rank {
199                index,
200                matched_indices,
201            }
202        })
203        .collect()
204}
205
206/// Key bindings for list navigation.
207#[derive(Debug, Clone)]
208pub struct KeyMap {
209    /// Move cursor up.
210    pub cursor_up: Binding,
211    /// Move cursor down.
212    pub cursor_down: Binding,
213    /// Next page.
214    pub next_page: Binding,
215    /// Previous page.
216    pub prev_page: Binding,
217    /// Go to start.
218    pub goto_start: Binding,
219    /// Go to end.
220    pub goto_end: Binding,
221    /// Start filtering.
222    pub filter: Binding,
223    /// Clear filter.
224    pub clear_filter: Binding,
225    /// Cancel filtering.
226    pub cancel_while_filtering: Binding,
227    /// Accept filter.
228    pub accept_while_filtering: Binding,
229    /// Show full help.
230    pub show_full_help: Binding,
231    /// Close full help.
232    pub close_full_help: Binding,
233    /// Quit.
234    pub quit: Binding,
235    /// Force quit.
236    pub force_quit: Binding,
237}
238
239impl Default for KeyMap {
240    fn default() -> Self {
241        Self {
242            cursor_up: Binding::new().keys(&["up", "k"]).help("↑/k", "up"),
243            cursor_down: Binding::new().keys(&["down", "j"]).help("↓/j", "down"),
244            next_page: Binding::new()
245                .keys(&["right", "l", "pgdown"])
246                .help("→/l", "next page"),
247            prev_page: Binding::new()
248                .keys(&["left", "h", "pgup"])
249                .help("←/h", "prev page"),
250            goto_start: Binding::new().keys(&["home", "g"]).help("g/home", "start"),
251            goto_end: Binding::new().keys(&["end", "G"]).help("G/end", "end"),
252            filter: Binding::new().keys(&["/"]).help("/", "filter"),
253            clear_filter: Binding::new().keys(&["esc"]).help("esc", "clear filter"),
254            cancel_while_filtering: Binding::new().keys(&["esc"]).help("esc", "cancel"),
255            accept_while_filtering: Binding::new()
256                .keys(&["enter"])
257                .help("enter", "apply filter"),
258            show_full_help: Binding::new().keys(&["?"]).help("?", "help"),
259            close_full_help: Binding::new()
260                .keys(&["esc", "?"])
261                .help("?/esc", "close help"),
262            quit: Binding::new().keys(&["q"]).help("q", "quit"),
263            force_quit: Binding::new()
264                .keys(&["ctrl+c"])
265                .help("ctrl+c", "force quit"),
266        }
267    }
268}
269
270/// Styles for the list.
271#[derive(Debug, Clone)]
272pub struct Styles {
273    /// Title style.
274    pub title: Style,
275    /// Title bar style.
276    pub title_bar: Style,
277    /// Filter prompt style.
278    pub filter_prompt: Style,
279    /// Filter cursor style.
280    pub filter_cursor: Style,
281    /// Status bar style.
282    pub status_bar: Style,
283    /// Status empty style.
284    pub status_empty: Style,
285    /// No items style.
286    pub no_items: Style,
287    /// Pagination style.
288    pub pagination: Style,
289    /// Help style.
290    pub help: Style,
291    /// Active pagination dot.
292    pub active_pagination_dot: Style,
293    /// Inactive pagination dot.
294    pub inactive_pagination_dot: Style,
295    /// Divider dot.
296    pub divider_dot: Style,
297}
298
299impl Default for Styles {
300    fn default() -> Self {
301        Self {
302            title: Style::new().bold(),
303            title_bar: Style::new(),
304            filter_prompt: Style::new(),
305            filter_cursor: Style::new(),
306            status_bar: Style::new().foreground_color(Color::from("240")),
307            status_empty: Style::new().foreground_color(Color::from("240")),
308            no_items: Style::new().foreground_color(Color::from("240")),
309            pagination: Style::new(),
310            help: Style::new().foreground_color(Color::from("240")),
311            active_pagination_dot: Style::new().foreground_color(Color::from("212")),
312            inactive_pagination_dot: Style::new().foreground_color(Color::from("240")),
313            divider_dot: Style::new().foreground_color(Color::from("240")),
314        }
315    }
316}
317
318/// Message for filter matches.
319#[derive(Debug, Clone)]
320pub struct FilterMatchesMsg(pub Vec<Rank>);
321
322/// Message for status message timeout.
323#[derive(Debug, Clone, Copy)]
324pub struct StatusMessageTimeoutMsg;
325
326/// List model with filtering, pagination, and more.
327#[derive(Clone)]
328pub struct List<I: Item, D: ItemDelegate<I>> {
329    /// Title of the list.
330    pub title: String,
331    /// Whether to show the title.
332    pub show_title: bool,
333    /// Whether to show the filter input.
334    pub show_filter: bool,
335    /// Whether to show the status bar.
336    pub show_status_bar: bool,
337    /// Whether to show pagination.
338    pub show_pagination: bool,
339    /// Whether to show help.
340    pub show_help: bool,
341    /// Whether filtering is enabled.
342    pub filtering_enabled: bool,
343    /// Whether infinite scrolling is enabled.
344    pub infinite_scrolling: bool,
345    /// Singular name for items.
346    pub item_name_singular: String,
347    /// Plural name for items.
348    pub item_name_plural: String,
349    /// Key bindings.
350    pub key_map: KeyMap,
351    /// Styles.
352    pub styles: Styles,
353    /// Status message lifetime.
354    pub status_message_lifetime: Duration,
355    /// Whether mouse wheel scrolling is enabled.
356    pub mouse_wheel_enabled: bool,
357    /// Number of items to scroll per mouse wheel tick.
358    pub mouse_wheel_delta: usize,
359    /// Whether mouse click selection is enabled.
360    pub mouse_click_enabled: bool,
361
362    // Components
363    /// Spinner for loading state.
364    spinner: SpinnerModel,
365    /// Paginator.
366    paginator: Paginator,
367    /// Help view.
368    help: Help,
369    /// Filter input.
370    filter_input: TextInput,
371
372    // State
373    items: Vec<I>,
374    filtered_indices: Vec<usize>,
375    delegate: D,
376    width: usize,
377    height: usize,
378    cursor: usize,
379    filter_state: FilterState,
380    show_spinner: bool,
381    status_message: Option<String>,
382}
383
384impl<I: Item, D: ItemDelegate<I>> List<I, D> {
385    /// Creates a new list with the given items and delegate.
386    #[must_use]
387    pub fn new(items: Vec<I>, delegate: D, width: usize, height: usize) -> Self {
388        let items_len = items.len();
389        let filtered_indices: Vec<usize> = (0..items_len).collect();
390
391        // Calculate per_page based on height and delegate
392        let item_height = delegate.height() + delegate.spacing();
393        let available = height.saturating_sub(4); // Reserve space for chrome
394        let per_page = available / item_height.max(1);
395
396        let mut paginator = Paginator::new().per_page(per_page.max(1));
397        paginator.set_total_pages_from_items(items_len);
398
399        let mut filter_input = TextInput::new();
400        filter_input.prompt = "Filter: ".to_string();
401
402        Self {
403            title: String::new(),
404            show_title: true,
405            show_filter: true,
406            show_status_bar: true,
407            show_pagination: true,
408            show_help: true,
409            filtering_enabled: true,
410            infinite_scrolling: false,
411            item_name_singular: "item".to_string(),
412            item_name_plural: "items".to_string(),
413            key_map: KeyMap::default(),
414            styles: Styles::default(),
415            status_message_lifetime: Duration::from_secs(1),
416            mouse_wheel_enabled: true,
417            mouse_wheel_delta: 1,
418            mouse_click_enabled: true,
419            spinner: SpinnerModel::new(),
420            paginator,
421            help: Help::new(),
422            filter_input,
423            items,
424            filtered_indices,
425            delegate,
426            width,
427            height,
428            cursor: 0,
429            filter_state: FilterState::Unfiltered,
430            show_spinner: false,
431            status_message: None,
432        }
433    }
434
435    /// Sets the title.
436    #[must_use]
437    pub fn title(mut self, title: impl Into<String>) -> Self {
438        self.title = title.into();
439        self
440    }
441
442    /// Enables or disables mouse wheel scrolling (builder pattern).
443    #[must_use]
444    pub fn mouse_wheel(mut self, enabled: bool) -> Self {
445        self.mouse_wheel_enabled = enabled;
446        self
447    }
448
449    /// Sets the number of items to scroll per mouse wheel tick (builder pattern).
450    #[must_use]
451    pub fn mouse_wheel_delta(mut self, delta: usize) -> Self {
452        self.mouse_wheel_delta = delta;
453        self
454    }
455
456    /// Enables or disables mouse click item selection (builder pattern).
457    #[must_use]
458    pub fn mouse_click(mut self, enabled: bool) -> Self {
459        self.mouse_click_enabled = enabled;
460        self
461    }
462
463    /// Sets the items.
464    pub fn set_items(&mut self, items: Vec<I>) {
465        let len = items.len();
466        self.items = items;
467        self.filtered_indices = (0..len).collect();
468        self.paginator.set_total_pages_from_items(len);
469        self.paginator.set_page(0);
470        self.cursor = 0;
471    }
472
473    /// Returns the items.
474    #[must_use]
475    pub fn items(&self) -> &[I] {
476        &self.items
477    }
478
479    /// Returns visible items based on current filter.
480    #[must_use]
481    pub fn visible_items(&self) -> Vec<&I> {
482        self.filtered_indices
483            .iter()
484            .filter_map(|&i| self.items.get(i))
485            .collect()
486    }
487
488    /// Returns the current cursor index in the filtered list.
489    #[must_use]
490    pub fn index(&self) -> usize {
491        self.cursor
492    }
493
494    /// Returns the currently selected item.
495    #[must_use]
496    pub fn selected_item(&self) -> Option<&I> {
497        self.filtered_indices
498            .get(self.cursor)
499            .and_then(|&i| self.items.get(i))
500    }
501
502    /// Selects an item by index.
503    pub fn select(&mut self, index: usize) {
504        self.cursor = index.min(self.filtered_indices.len().saturating_sub(1));
505    }
506
507    /// Moves the cursor up.
508    pub fn cursor_up(&mut self) {
509        if self.filtered_indices.is_empty() {
510            return;
511        }
512        if self.cursor == 0 {
513            if self.infinite_scrolling {
514                self.cursor = self.filtered_indices.len() - 1;
515            }
516        } else {
517            self.cursor -= 1;
518        }
519    }
520
521    /// Moves the cursor down.
522    pub fn cursor_down(&mut self) {
523        if self.filtered_indices.is_empty() {
524            return;
525        }
526        if self.cursor >= self.filtered_indices.len() - 1 {
527            if self.infinite_scrolling {
528                self.cursor = 0;
529            }
530        } else {
531            self.cursor += 1;
532        }
533    }
534
535    /// Returns the filter state.
536    #[must_use]
537    pub fn filter_state(&self) -> FilterState {
538        self.filter_state
539    }
540
541    /// Returns the current filter value.
542    #[must_use]
543    pub fn filter_value(&self) -> String {
544        self.filter_input.value()
545    }
546
547    /// Sets the filter value.
548    pub fn set_filter_value(&mut self, value: &str) {
549        self.filter_input.set_value(value);
550        self.apply_filter();
551    }
552
553    /// Resets the filter.
554    pub fn reset_filter(&mut self) {
555        self.filter_input.reset();
556        self.filter_state = FilterState::Unfiltered;
557        self.filtered_indices = (0..self.items.len()).collect();
558        self.paginator.set_total_pages_from_items(self.items.len());
559        self.paginator.set_page(0);
560        self.cursor = 0;
561    }
562
563    /// Applies the current filter.
564    fn apply_filter(&mut self) {
565        let term = self.filter_input.value();
566        if term.is_empty() {
567            self.reset_filter();
568            return;
569        }
570
571        let targets: Vec<String> = self
572            .items
573            .iter()
574            .map(|i| i.filter_value().to_string())
575            .collect();
576        let ranks = default_filter(&term, &targets);
577
578        self.filtered_indices = ranks.iter().map(|r| r.index).collect();
579        self.paginator
580            .set_total_pages_from_items(self.filtered_indices.len());
581        self.paginator.set_page(0);
582        self.cursor = 0;
583        self.filter_state = FilterState::FilterApplied;
584    }
585
586    /// Starts the spinner.
587    /// Returns a message that should be passed to update to start the animation.
588    pub fn start_spinner(&mut self) -> Option<Message> {
589        self.show_spinner = true;
590        Some(self.spinner.tick())
591    }
592
593    /// Stops the spinner.
594    pub fn stop_spinner(&mut self) {
595        self.show_spinner = false;
596    }
597
598    /// Returns whether the spinner is visible.
599    #[must_use]
600    pub fn spinner_visible(&self) -> bool {
601        self.show_spinner
602    }
603
604    /// Sets a new status message.
605    pub fn new_status_message(&mut self, msg: impl Into<String>) -> Option<Cmd> {
606        self.status_message = Some(msg.into());
607        let lifetime = self.status_message_lifetime;
608        Some(Cmd::new(move || {
609            std::thread::sleep(lifetime);
610            Message::new(StatusMessageTimeoutMsg)
611        }))
612    }
613
614    /// Returns the current status message.
615    #[must_use]
616    pub fn status_message(&self) -> Option<&str> {
617        self.status_message.as_deref()
618    }
619
620    /// Sets the width.
621    pub fn set_width(&mut self, w: usize) {
622        self.width = w;
623        self.help.width = w;
624    }
625
626    /// Sets the height.
627    pub fn set_height(&mut self, h: usize) {
628        self.height = h;
629        self.update_pagination();
630    }
631
632    /// Returns the width.
633    #[must_use]
634    pub fn width(&self) -> usize {
635        self.width
636    }
637
638    /// Returns the height.
639    #[must_use]
640    pub fn height(&self) -> usize {
641        self.height
642    }
643
644    /// Returns a reference to the paginator.
645    #[must_use]
646    pub fn paginator(&self) -> &Paginator {
647        &self.paginator
648    }
649
650    /// Updates pagination based on height and delegate.
651    fn update_pagination(&mut self) {
652        let item_height = self.delegate.height() + self.delegate.spacing();
653        let available = self.height.saturating_sub(4); // Reserve space for chrome
654        let per_page = available / item_height.max(1);
655        // Rebuild paginator with new per_page
656        self.paginator = Paginator::new().per_page(per_page);
657        self.paginator
658            .set_total_pages_from_items(self.filtered_indices.len());
659    }
660
661    /// Updates the list based on messages.
662    pub fn update(&mut self, msg: Message) -> Option<Cmd> {
663        // Handle status message timeout
664        if msg.is::<StatusMessageTimeoutMsg>() {
665            self.status_message = None;
666            return None;
667        }
668
669        // Handle spinner updates - check for tick message first
670        if self.show_spinner && msg.is::<TickMsg>() {
671            return self.spinner.update(msg);
672        }
673
674        // Handle key messages
675        if let Some(key) = msg.downcast_ref::<KeyMsg>() {
676            let key_str = key.to_string();
677
678            // Handle filtering state
679            if self.filter_state == FilterState::Filtering {
680                if matches(&key_str, &[&self.key_map.cancel_while_filtering]) {
681                    self.reset_filter();
682                    return None;
683                }
684                if matches(&key_str, &[&self.key_map.accept_while_filtering]) {
685                    self.apply_filter();
686                    self.filter_state = FilterState::FilterApplied;
687                    self.filter_input.blur();
688                    return None;
689                }
690
691                // Pass to filter input
692                return self.filter_input.update(msg);
693            }
694
695            // Normal navigation
696            if matches(&key_str, &[&self.key_map.cursor_up]) {
697                self.cursor_up();
698            } else if matches(&key_str, &[&self.key_map.cursor_down]) {
699                self.cursor_down();
700            } else if matches(&key_str, &[&self.key_map.next_page]) {
701                self.paginator.next_page();
702                // Move cursor to first item of new page
703                let start = self.paginator.page() * self.paginator.get_per_page();
704                self.cursor = if self.filtered_indices.is_empty() {
705                    0
706                } else {
707                    start.min(self.filtered_indices.len() - 1)
708                };
709            } else if matches(&key_str, &[&self.key_map.prev_page]) {
710                self.paginator.prev_page();
711                let start = self.paginator.page() * self.paginator.get_per_page();
712                self.cursor = if self.filtered_indices.is_empty() {
713                    0
714                } else {
715                    start.min(self.filtered_indices.len() - 1)
716                };
717            } else if matches(&key_str, &[&self.key_map.goto_start]) {
718                self.cursor = 0;
719                self.paginator.set_page(0);
720            } else if matches(&key_str, &[&self.key_map.goto_end]) {
721                self.cursor = self.filtered_indices.len().saturating_sub(1);
722                self.paginator
723                    .set_page(self.paginator.get_total_pages().saturating_sub(1));
724            } else if matches(&key_str, &[&self.key_map.filter]) && self.filtering_enabled {
725                self.filter_state = FilterState::Filtering;
726                self.filter_input.focus();
727            } else if matches(&key_str, &[&self.key_map.clear_filter]) {
728                self.reset_filter();
729            } else if matches(&key_str, &[&self.key_map.show_full_help]) {
730                self.help.show_all = true;
731            } else if matches(&key_str, &[&self.key_map.close_full_help]) {
732                self.help.show_all = false;
733            }
734        }
735
736        // Handle mouse events
737        if let Some(mouse) = msg.downcast_ref::<MouseMsg>() {
738            // Only respond to press events
739            if mouse.action != MouseAction::Press {
740                return None;
741            }
742
743            match mouse.button {
744                // Wheel scrolling moves cursor
745                MouseButton::WheelUp if self.mouse_wheel_enabled => {
746                    for _ in 0..self.mouse_wheel_delta {
747                        self.cursor_up();
748                    }
749                }
750                MouseButton::WheelDown if self.mouse_wheel_enabled => {
751                    for _ in 0..self.mouse_wheel_delta {
752                        self.cursor_down();
753                    }
754                }
755                // Click to select item
756                MouseButton::Left if self.mouse_click_enabled => {
757                    // Calculate y offset for items area
758                    // Title takes 1 line if shown
759                    // Filter input takes 1 line if filtering
760                    let mut content_start_y = 0usize;
761                    if self.show_title && !self.title.is_empty() {
762                        content_start_y += 1;
763                    }
764                    if self.show_filter && self.filter_state == FilterState::Filtering {
765                        content_start_y += 1;
766                    }
767
768                    let click_y = mouse.y as usize;
769                    if click_y >= content_start_y {
770                        let item_height = self.delegate.height() + self.delegate.spacing();
771                        let relative_y = click_y - content_start_y;
772                        let item_index_in_view = relative_y / item_height.max(1);
773
774                        // Convert to global cursor position
775                        let per_page = self.paginator.get_per_page();
776                        let page_start = self.paginator.page() * per_page;
777                        let target_cursor = page_start + item_index_in_view;
778
779                        // Only select if within bounds
780                        if target_cursor < self.filtered_indices.len() {
781                            self.cursor = target_cursor;
782                        }
783                    }
784                }
785                _ => {}
786            }
787        }
788
789        None
790    }
791
792    /// Renders the list.
793    #[must_use]
794    pub fn view(&self) -> String {
795        let mut sections = Vec::new();
796
797        // Title
798        if self.show_title && !self.title.is_empty() {
799            sections.push(self.styles.title.render(&self.title));
800        }
801
802        // Filter input
803        if self.show_filter && self.filter_state == FilterState::Filtering {
804            sections.push(self.filter_input.view());
805        }
806
807        // Items
808        if self.filtered_indices.is_empty() {
809            sections.push(self.styles.no_items.render("No items."));
810        } else {
811            let per_page = self.paginator.get_per_page();
812            let start = self.paginator.page() * per_page;
813            let end = (start + per_page).min(self.filtered_indices.len());
814
815            for (view_idx, &item_idx) in self.filtered_indices[start..end].iter().enumerate() {
816                let global_idx = start + view_idx;
817                let selected = global_idx == self.cursor;
818
819                if let Some(item) = self.items.get(item_idx) {
820                    let rendered = self.delegate.render(item, global_idx, selected, self.width);
821                    sections.push(rendered);
822                }
823            }
824        }
825
826        // Spinner
827        if self.show_spinner {
828            sections.push(self.spinner.view());
829        }
830
831        // Status bar
832        if self.show_status_bar {
833            let status = if let Some(status) = self.status_message.as_deref() {
834                status.to_string()
835            } else {
836                let count = self.filtered_indices.len();
837                if count == 1 {
838                    format!("1 {}", self.item_name_singular)
839                } else {
840                    format!("{} {}", count, self.item_name_plural)
841                }
842            };
843            sections.push(self.styles.status_bar.render(&status));
844        }
845
846        // Pagination
847        if self.show_pagination && self.paginator.get_total_pages() > 1 {
848            sections.push(self.paginator.view());
849        }
850
851        // Help
852        if self.show_help {
853            let bindings: Vec<&Binding> = vec![
854                &self.key_map.cursor_up,
855                &self.key_map.cursor_down,
856                &self.key_map.filter,
857                &self.key_map.quit,
858            ];
859            sections.push(
860                self.styles
861                    .help
862                    .render(&self.help.short_help_view(&bindings)),
863            );
864        }
865
866        sections.join("\n")
867    }
868
869    /// Initializes the list (called when used as a standalone Model).
870    ///
871    /// Returns `None` by default since lists are typically initialized with items.
872    /// Override or use `start_spinner()` if loading items asynchronously.
873    #[must_use]
874    pub fn init(&self) -> Option<Cmd> {
875        None
876    }
877}
878
879/// Implement the Model trait for standalone bubbletea usage.
880impl<I: Item, D: ItemDelegate<I>> Model for List<I, D> {
881    fn init(&self) -> Option<Cmd> {
882        List::init(self)
883    }
884
885    fn update(&mut self, msg: Message) -> Option<Cmd> {
886        List::update(self, msg)
887    }
888
889    fn view(&self) -> String {
890        List::view(self)
891    }
892}
893
894// Implement Debug manually since FilterFn doesn't implement Debug
895impl<I: Item + std::fmt::Debug, D: ItemDelegate<I> + std::fmt::Debug> std::fmt::Debug
896    for List<I, D>
897{
898    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
899        f.debug_struct("List")
900            .field("title", &self.title)
901            .field("items_count", &self.items.len())
902            .field("cursor", &self.cursor)
903            .field("filter_state", &self.filter_state)
904            .finish()
905    }
906}
907
908#[cfg(test)]
909mod tests {
910    use super::*;
911
912    #[derive(Debug, Clone)]
913    struct TestItem {
914        name: String,
915    }
916
917    impl Item for TestItem {
918        fn filter_value(&self) -> &str {
919            &self.name
920        }
921    }
922
923    fn test_items() -> Vec<TestItem> {
924        vec![
925            TestItem {
926                name: "Apple".into(),
927            },
928            TestItem {
929                name: "Banana".into(),
930            },
931            TestItem {
932                name: "Cherry".into(),
933            },
934            TestItem {
935                name: "Date".into(),
936            },
937        ]
938    }
939
940    #[test]
941    fn test_list_new() {
942        let list = List::new(test_items(), DefaultDelegate::new(), 80, 24);
943        assert_eq!(list.items().len(), 4);
944        assert_eq!(list.index(), 0);
945    }
946
947    #[test]
948    fn test_list_navigation() {
949        let mut list = List::new(test_items(), DefaultDelegate::new(), 80, 24);
950
951        assert_eq!(list.index(), 0);
952
953        list.cursor_down();
954        assert_eq!(list.index(), 1);
955
956        list.cursor_down();
957        assert_eq!(list.index(), 2);
958
959        list.cursor_up();
960        assert_eq!(list.index(), 1);
961    }
962
963    #[test]
964    fn test_list_selected_item() {
965        let mut list = List::new(test_items(), DefaultDelegate::new(), 80, 24);
966
967        assert_eq!(list.selected_item().map(|i| i.name.as_str()), Some("Apple"));
968
969        list.cursor_down();
970        assert_eq!(
971            list.selected_item().map(|i| i.name.as_str()),
972            Some("Banana")
973        );
974    }
975
976    #[test]
977    fn test_list_filter() {
978        let mut list = List::new(test_items(), DefaultDelegate::new(), 80, 24);
979
980        list.set_filter_value("an");
981
982        // Should match "Banana"
983        assert_eq!(list.visible_items().len(), 1);
984        assert_eq!(list.visible_items()[0].name, "Banana");
985    }
986
987    #[test]
988    fn test_list_reset_filter() {
989        let mut list = List::new(test_items(), DefaultDelegate::new(), 80, 24);
990
991        list.set_filter_value("an");
992        assert_eq!(list.visible_items().len(), 1);
993
994        list.reset_filter();
995        assert_eq!(list.visible_items().len(), 4);
996    }
997
998    #[test]
999    fn test_cancel_filter_resets_pagination() {
1000        let mut list = List::new(test_items(), DefaultDelegate::new(), 80, 6);
1001
1002        list.filter_state = FilterState::Filtering;
1003        list.filtered_indices = vec![0];
1004        list.paginator
1005            .set_total_pages_from_items(list.filtered_indices.len());
1006
1007        let key_msg = Message::new(KeyMsg::from_type(bubbletea::KeyType::Esc));
1008        let _ = list.update(key_msg);
1009
1010        assert_eq!(list.filter_state, FilterState::Unfiltered);
1011        assert_eq!(list.filtered_indices.len(), list.items.len());
1012        assert_eq!(list.paginator.get_total_pages(), 2);
1013        assert_eq!(list.cursor, 0);
1014    }
1015
1016    #[test]
1017    fn test_list_filter_state() {
1018        let list = List::new(test_items(), DefaultDelegate::new(), 80, 24);
1019        assert_eq!(list.filter_state(), FilterState::Unfiltered);
1020    }
1021
1022    #[test]
1023    fn test_list_infinite_scroll() {
1024        let mut list = List::new(test_items(), DefaultDelegate::new(), 80, 24);
1025        list.infinite_scrolling = true;
1026
1027        // At start, going up should wrap to end
1028        list.cursor_up();
1029        assert_eq!(list.index(), 3);
1030
1031        // Going down should wrap to start
1032        list.cursor_down();
1033        assert_eq!(list.index(), 0);
1034    }
1035
1036    #[test]
1037    fn test_list_status_message() {
1038        let mut list = List::new(test_items(), DefaultDelegate::new(), 80, 24);
1039
1040        assert!(list.status_message().is_none());
1041
1042        list.new_status_message("Test message");
1043        assert_eq!(list.status_message(), Some("Test message"));
1044    }
1045
1046    #[test]
1047    fn test_list_status_message_uses_singular_name() {
1048        let items = vec![TestItem {
1049            name: "Apple".into(),
1050        }];
1051        let mut list = List::new(items, DefaultDelegate::new(), 80, 6);
1052        list.item_name_singular = "fruit".to_string();
1053        list.item_name_plural = "fruits".to_string();
1054
1055        let view = list.view();
1056        assert!(view.contains("1 fruit"));
1057    }
1058
1059    #[test]
1060    fn test_list_apply_filter_resets_page() {
1061        let mut list = List::new(test_items(), DefaultDelegate::new(), 80, 5);
1062        list.paginator.set_page(2);
1063
1064        list.set_filter_value("a");
1065
1066        assert_eq!(list.paginator.page(), 0);
1067    }
1068
1069    #[test]
1070    fn test_list_reset_filter_resets_page() {
1071        let mut list = List::new(test_items(), DefaultDelegate::new(), 80, 5);
1072        list.paginator.set_page(3);
1073
1074        list.reset_filter();
1075
1076        assert_eq!(list.paginator.page(), 0);
1077    }
1078
1079    #[test]
1080    fn test_list_spinner() {
1081        let mut list = List::new(test_items(), DefaultDelegate::new(), 80, 24);
1082
1083        assert!(!list.spinner_visible());
1084
1085        list.start_spinner();
1086        assert!(list.spinner_visible());
1087
1088        list.stop_spinner();
1089        assert!(!list.spinner_visible());
1090    }
1091
1092    #[test]
1093    fn test_list_view() {
1094        let list = List::new(test_items(), DefaultDelegate::new(), 80, 24).title("Fruits");
1095
1096        let view = list.view();
1097        assert!(view.contains("Fruits"));
1098        assert!(view.contains("Apple"));
1099    }
1100
1101    #[test]
1102    fn test_default_filter() {
1103        let targets = vec![
1104            "Apple".to_string(),
1105            "Banana".to_string(),
1106            "Cherry".to_string(),
1107        ];
1108
1109        let ranks = default_filter("an", &targets);
1110        assert_eq!(ranks.len(), 1);
1111        assert_eq!(ranks[0].index, 1); // Banana
1112    }
1113
1114    #[test]
1115    fn test_default_delegate() {
1116        let delegate = DefaultDelegate::new().with_height(2).with_spacing(1);
1117        assert_eq!(delegate.item_height, 2);
1118        assert_eq!(delegate.item_spacing, 1);
1119    }
1120
1121    #[test]
1122    fn test_keymap_default() {
1123        let km = KeyMap::default();
1124        assert!(!km.cursor_up.get_keys().is_empty());
1125        assert!(!km.filter.get_keys().is_empty());
1126    }
1127
1128    #[test]
1129    fn test_filter_state_display() {
1130        assert_eq!(FilterState::Unfiltered.to_string(), "unfiltered");
1131        assert_eq!(FilterState::Filtering.to_string(), "filtering");
1132        assert_eq!(FilterState::FilterApplied.to_string(), "filter applied");
1133    }
1134
1135    // Model trait implementation tests
1136
1137    #[test]
1138    fn test_model_trait_init_returns_none() {
1139        let list = List::new(test_items(), DefaultDelegate::new(), 80, 24);
1140        // Use the Model trait method explicitly
1141        let cmd = Model::init(&list);
1142        assert!(cmd.is_none(), "Model::init should return None for List");
1143    }
1144
1145    #[test]
1146    fn test_model_trait_view_returns_content() {
1147        let list = List::new(test_items(), DefaultDelegate::new(), 80, 24).title("Test List");
1148        // Use the Model trait method explicitly
1149        let view = Model::view(&list);
1150        assert!(view.contains("Test List"), "View should contain the title");
1151        assert!(view.contains("Apple"), "View should contain first item");
1152    }
1153
1154    #[test]
1155    fn test_model_trait_update_handles_messages() {
1156        let mut list = List::new(test_items(), DefaultDelegate::new(), 80, 24);
1157        assert_eq!(list.index(), 0);
1158
1159        // Create a down key message to navigate
1160        let key_msg = Message::new(KeyMsg {
1161            key_type: bubbletea::KeyType::Runes,
1162            runes: vec!['j'], // 'j' is mapped to cursor_down
1163            alt: false,
1164            paste: false,
1165        });
1166
1167        // Use the Model trait method explicitly
1168        let _ = Model::update(&mut list, key_msg);
1169        assert_eq!(list.index(), 1, "Cursor should have moved down");
1170    }
1171
1172    #[test]
1173    fn test_list_satisfies_model_bounds() {
1174        // This test verifies List can be used where Model + Send + 'static is required
1175        fn accepts_model<M: Model + Send + 'static>(_model: M) {}
1176        let list = List::new(test_items(), DefaultDelegate::new(), 80, 24);
1177        accepts_model(list);
1178    }
1179
1180    #[test]
1181    fn test_list_pagination_calculation() {
1182        // Test pagination calculation: available_height = height - 4 (chrome overhead)
1183        // items_per_page = available_height / (item_height + spacing)
1184        //
1185        // Note: Rust uses a fixed 4-line chrome overhead (title, status, help, pagination)
1186        // while Go dynamically calculates actual rendered heights of each section.
1187        // This may result in different items_per_page values between implementations.
1188        let list = List::new(test_items(), DefaultDelegate::new(), 80, 10);
1189        // With height=10, chrome=4, available=6, item_height=1, per_page=6
1190        assert_eq!(list.paginator().get_per_page(), 6);
1191    }
1192
1193    #[test]
1194    fn test_list_paginator_accessor() {
1195        let list = List::new(test_items(), DefaultDelegate::new(), 80, 24);
1196        // Verify paginator accessor returns valid reference
1197        assert!(list.paginator().get_per_page() > 0);
1198        assert_eq!(list.paginator().page(), 0);
1199    }
1200
1201    #[test]
1202    fn test_list_pagination_with_many_items() {
1203        let items: Vec<TestItem> = (1..=50)
1204            .map(|i| TestItem {
1205                name: format!("Item {}", i),
1206            })
1207            .collect();
1208        let list = List::new(items, DefaultDelegate::new(), 80, 10);
1209        // With 50 items, per_page=6, total_pages should be 9 (50/6 rounded up)
1210        assert_eq!(list.paginator().get_total_pages(), 9);
1211    }
1212}