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