bubbletea_widgets/list/
mod.rs

1//! List component with filtering, pagination, contextual help, and customizable rendering.
2//!
3//! This module exposes a generic `Model<I: Item>` plus supporting traits and submodules:
4//! - `Item`: Implement for your item type; must be `Display + Clone` and return a `filter_value()`
5//! - `ItemDelegate`: Controls item `render`, `height`, `spacing`, and `update`
6//! - Submodules: `defaultitem`, `keys`, and `style`
7//!
8//! ### Filtering States
9//! The list supports fuzzy filtering with three states:
10//! - `Unfiltered`: No filter active
11//! - `Filtering`: User is typing a filter; input is shown in the header
12//! - `FilterApplied`: Filter accepted; only matching items are displayed
13//!
14//! When filtering is active, fuzzy match indices are stored per item and delegates can use
15//! them to apply character-level highlighting (see `defaultitem`).
16//!
17//! ### Help Integration
18//! The list implements `help::KeyMap`, so you can embed `help::Model` and get contextual
19//! help automatically based on the current filtering state.
20
21pub mod defaultitem;
22pub mod keys;
23pub mod style;
24
25use crate::{help, key, paginator, spinner, textinput};
26use bubbletea_rs::{Cmd, KeyMsg, Model as BubbleTeaModel, Msg};
27use fuzzy_matcher::skim::SkimMatcherV2;
28use fuzzy_matcher::FuzzyMatcher;
29use lipgloss;
30use std::fmt::Display;
31
32// --- Traits (Interfaces) ---
33
34/// An item that can be displayed in the list.
35pub trait Item: Display + Clone {
36    /// The value to use when filtering this item.
37    fn filter_value(&self) -> String;
38}
39
40/// A delegate encapsulates the functionality for a list item.
41pub trait ItemDelegate<I: Item> {
42    /// Renders the item's view.
43    fn render(&self, m: &Model<I>, index: usize, item: &I) -> String;
44    /// The height of the list item.
45    fn height(&self) -> usize;
46    /// The spacing between list items.
47    fn spacing(&self) -> usize;
48    /// The update loop for the item.
49    fn update(&self, msg: &Msg, m: &mut Model<I>) -> Option<Cmd>;
50}
51
52// --- Filter ---
53#[derive(Debug, Clone)]
54#[allow(dead_code)]
55struct FilteredItem<I: Item> {
56    index: usize, // index in original items list
57    item: I,
58    matches: Vec<usize>,
59}
60
61// --- Model ---
62
63/// Current filtering state of the list.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum FilterState {
66    /// No filtering is active; all items are shown.
67    Unfiltered,
68    /// User is typing a filter term; live filtering UI is shown.
69    Filtering,
70    /// A filter term has been applied; only matching items are shown.
71    FilterApplied,
72}
73
74/// List model containing items, filtering, pagination, and styling.
75pub struct Model<I: Item> {
76    /// Title rendered in the list header when not filtering.
77    pub title: String,
78    items: Vec<I>,
79    delegate: Box<dyn ItemDelegate<I> + Send + Sync>,
80
81    // Components
82    /// Text input used for entering the filter term.
83    pub filter_input: textinput::Model,
84    /// Paginator controlling visible item slice.
85    pub paginator: paginator::Model,
86    /// Spinner used during expensive operations (optional usage).
87    pub spinner: spinner::Model,
88    /// Help model for displaying key bindings.
89    pub help: help::Model,
90    /// Key bindings for list navigation and filtering.
91    pub keymap: keys::ListKeyMap,
92
93    // State
94    filter_state: FilterState,
95    filtered_items: Vec<FilteredItem<I>>,
96    cursor: usize,
97    width: usize,
98    height: usize,
99
100    // Styles
101    /// Visual styles for list elements and states.
102    pub styles: style::ListStyles,
103
104    // Status bar labeling
105    status_item_singular: Option<String>,
106    status_item_plural: Option<String>,
107}
108
109impl<I: Item + Send + Sync + 'static> Model<I> {
110    /// Creates a new list with items, delegate, and initial dimensions.
111    pub fn new(
112        items: Vec<I>,
113        delegate: impl ItemDelegate<I> + Send + Sync + 'static,
114        width: usize,
115        height: usize,
116    ) -> Self {
117        let mut filter_input = textinput::new();
118        filter_input.set_placeholder("Filter...");
119        let mut paginator = paginator::Model::new();
120        paginator.set_per_page(10);
121
122        let mut s = Self {
123            title: "List".to_string(),
124            items,
125            delegate: Box::new(delegate),
126            filter_input,
127            paginator,
128            spinner: spinner::Model::new(),
129            help: help::Model::new(),
130            keymap: keys::ListKeyMap::default(),
131            filter_state: FilterState::Unfiltered,
132            filtered_items: vec![],
133            cursor: 0,
134            width,
135            height,
136            styles: style::ListStyles::default(),
137            status_item_singular: None,
138            status_item_plural: None,
139        };
140        s.update_pagination();
141        s
142    }
143
144    /// Replace all items in the list and reset pagination if needed.
145    pub fn set_items(&mut self, items: Vec<I>) {
146        self.items = items;
147        self.update_pagination();
148    }
149    /// Returns a copy of the items currently visible (filtered if applicable).
150    pub fn visible_items(&self) -> Vec<I> {
151        if self.filter_state == FilterState::Unfiltered {
152            self.items.clone()
153        } else {
154            self.filtered_items.iter().map(|f| f.item.clone()).collect()
155        }
156    }
157    /// Sets the filter input text.
158    pub fn set_filter_text(&mut self, s: &str) {
159        self.filter_input.set_value(s);
160    }
161    /// Sets the current filtering state.
162    pub fn set_filter_state(&mut self, st: FilterState) {
163        self.filter_state = st;
164    }
165    /// Sets the singular/plural nouns used in the status bar.
166    pub fn set_status_bar_item_name(&mut self, singular: &str, plural: &str) {
167        self.status_item_singular = Some(singular.to_string());
168        self.status_item_plural = Some(plural.to_string());
169    }
170    /// Renders the status bar string, including position and help.
171    pub fn status_view(&self) -> String {
172        self.view_footer()
173    }
174
175    /// Sets the list title and returns `self` for chaining.
176    pub fn with_title(mut self, title: &str) -> Self {
177        self.title = title.to_string();
178        self
179    }
180    /// Returns a reference to the currently selected item, if any.
181    pub fn selected_item(&self) -> Option<&I> {
182        if self.filter_state == FilterState::Unfiltered {
183            self.items.get(self.cursor)
184        } else {
185            self.filtered_items.get(self.cursor).map(|fi| &fi.item)
186        }
187    }
188    /// Returns the current cursor position (0-based).
189    pub fn cursor(&self) -> usize {
190        self.cursor
191    }
192    /// Returns the number of items in the current view (filtered or not).
193    pub fn len(&self) -> usize {
194        if self.filter_state == FilterState::Unfiltered {
195            self.items.len()
196        } else {
197            self.filtered_items.len()
198        }
199    }
200    /// Returns `true` if there are no items to display.
201    pub fn is_empty(&self) -> bool {
202        self.len() == 0
203    }
204
205    fn update_pagination(&mut self) {
206        let item_count = self.len();
207        let item_height = self.delegate.height() + self.delegate.spacing();
208        let available_height = self.height.saturating_sub(4);
209        let per_page = if item_height > 0 {
210            available_height / item_height
211        } else {
212            10
213        }
214        .max(1);
215        self.paginator.set_per_page(per_page);
216        self.paginator
217            .set_total_pages(item_count.div_ceil(per_page));
218        if self.cursor >= item_count {
219            self.cursor = item_count.saturating_sub(1);
220        }
221    }
222
223    #[allow(dead_code)]
224    fn matches_for_item(&self, index: usize) -> Option<&Vec<usize>> {
225        if index < self.filtered_items.len() {
226            Some(&self.filtered_items[index].matches)
227        } else {
228            None
229        }
230    }
231
232    fn apply_filter(&mut self) {
233        let filter_term = self.filter_input.value().to_lowercase();
234        if filter_term.is_empty() {
235            self.filter_state = FilterState::Unfiltered;
236            self.filtered_items.clear();
237        } else {
238            let matcher = SkimMatcherV2::default();
239            self.filtered_items = self
240                .items
241                .iter()
242                .enumerate()
243                .filter_map(|(i, item)| {
244                    matcher
245                        .fuzzy_indices(&item.filter_value(), &filter_term)
246                        .map(|(_score, indices)| FilteredItem {
247                            index: i,
248                            item: item.clone(),
249                            matches: indices,
250                        })
251                })
252                .collect();
253            self.filter_state = FilterState::FilterApplied;
254        }
255        self.cursor = 0;
256        self.update_pagination();
257    }
258
259    fn view_header(&self) -> String {
260        if self.filter_state == FilterState::Filtering {
261            let prompt = self.styles.filter_prompt.clone().render("Filter:");
262            format!("{} {}", prompt, self.filter_input.view())
263        } else {
264            let mut header = self.title.clone();
265            if self.filter_state == FilterState::FilterApplied {
266                header.push_str(&format!(" (filtered: {})", self.len()));
267            }
268            self.styles.title.clone().render(&header)
269        }
270    }
271
272    fn view_items(&self) -> String {
273        if self.is_empty() {
274            return self.styles.no_items.clone().render("No items");
275        }
276
277        let items_to_render: Vec<(usize, &I)> = if self.filter_state == FilterState::Unfiltered {
278            self.items.iter().enumerate().collect()
279        } else {
280            self.filtered_items
281                .iter()
282                .map(|fi| (fi.index, &fi.item))
283                .collect()
284        };
285
286        let (start, end) = self.paginator.get_slice_bounds(items_to_render.len());
287        let mut result = String::new();
288
289        // Render each item individually using the delegate
290        for (list_idx, (_orig_idx, item)) in items_to_render
291            .iter()
292            .enumerate()
293            .take(end.min(items_to_render.len()))
294            .skip(start)
295        {
296            // The item index in the current visible list (for selection highlighting)
297            let visible_index = start + list_idx;
298            let item_output = self.delegate.render(self, visible_index, item);
299
300            if !result.is_empty() {
301                // Add spacing between items
302                for _ in 0..self.delegate.spacing() {
303                    result.push('\n');
304                }
305            }
306
307            result.push_str(&item_output);
308        }
309
310        result
311    }
312
313    fn view_footer(&self) -> String {
314        let mut footer = String::new();
315        if !self.is_empty() {
316            let singular = self.status_item_singular.as_deref().unwrap_or("item");
317            let plural = self.status_item_plural.as_deref().unwrap_or("items");
318            let noun = if self.len() == 1 { singular } else { plural };
319            footer.push_str(&format!("{}/{} {}", self.cursor + 1, self.len(), noun));
320        }
321        let help_view = self.help.view(self);
322        if !help_view.is_empty() {
323            footer.push('\n');
324            footer.push_str(&help_view);
325        }
326        footer
327    }
328}
329
330// Help integration from the list model
331impl<I: Item> help::KeyMap for Model<I> {
332    fn short_help(&self) -> Vec<&key::Binding> {
333        match self.filter_state {
334            FilterState::Filtering => vec![&self.keymap.accept_filter, &self.keymap.cancel_filter],
335            _ => vec![
336                &self.keymap.cursor_up,
337                &self.keymap.cursor_down,
338                &self.keymap.filter,
339            ],
340        }
341    }
342    fn full_help(&self) -> Vec<Vec<&key::Binding>> {
343        match self.filter_state {
344            FilterState::Filtering => {
345                vec![vec![&self.keymap.accept_filter, &self.keymap.cancel_filter]]
346            }
347            _ => vec![
348                vec![
349                    &self.keymap.cursor_up,
350                    &self.keymap.cursor_down,
351                    &self.keymap.next_page,
352                    &self.keymap.prev_page,
353                ],
354                vec![
355                    &self.keymap.go_to_start,
356                    &self.keymap.go_to_end,
357                    &self.keymap.filter,
358                    &self.keymap.clear_filter,
359                ],
360            ],
361        }
362    }
363}
364
365impl<I: Item + Send + Sync + 'static> BubbleTeaModel for Model<I> {
366    fn init() -> (Self, Option<Cmd>) {
367        let model = Self::new(vec![], defaultitem::DefaultDelegate::new(), 80, 24);
368        (model, None)
369    }
370    fn update(&mut self, msg: Msg) -> Option<Cmd> {
371        if self.filter_state == FilterState::Filtering {
372            if let Some(key_msg) = msg.downcast_ref::<KeyMsg>() {
373                match key_msg.key {
374                    crossterm::event::KeyCode::Esc => {
375                        self.filter_state = if self.filtered_items.is_empty() {
376                            FilterState::Unfiltered
377                        } else {
378                            FilterState::FilterApplied
379                        };
380                        self.filter_input.blur();
381                        return None;
382                    }
383                    crossterm::event::KeyCode::Enter => {
384                        self.apply_filter();
385                        self.filter_input.blur();
386                        return None;
387                    }
388                    crossterm::event::KeyCode::Char(c) => {
389                        let mut s = self.filter_input.value();
390                        s.push(c);
391                        self.filter_input.set_value(&s);
392                        self.apply_filter();
393                    }
394                    crossterm::event::KeyCode::Backspace => {
395                        let mut s = self.filter_input.value();
396                        s.pop();
397                        self.filter_input.set_value(&s);
398                        self.apply_filter();
399                    }
400                    crossterm::event::KeyCode::Delete => { /* ignore delete for now */ }
401                    crossterm::event::KeyCode::Left => {
402                        let pos = self.filter_input.position();
403                        if pos > 0 {
404                            self.filter_input.set_cursor(pos - 1);
405                        }
406                    }
407                    crossterm::event::KeyCode::Right => {
408                        let pos = self.filter_input.position();
409                        self.filter_input.set_cursor(pos + 1);
410                    }
411                    crossterm::event::KeyCode::Home => {
412                        self.filter_input.cursor_start();
413                    }
414                    crossterm::event::KeyCode::End => {
415                        self.filter_input.cursor_end();
416                    }
417                    _ => {}
418                }
419            }
420            return None;
421        }
422
423        if let Some(key_msg) = msg.downcast_ref::<KeyMsg>() {
424            if self.keymap.cursor_up.matches(key_msg) {
425                if self.cursor > 0 {
426                    self.cursor -= 1;
427                }
428            } else if self.keymap.cursor_down.matches(key_msg) {
429                if self.cursor < self.len().saturating_sub(1) {
430                    self.cursor += 1;
431                }
432            } else if self.keymap.go_to_start.matches(key_msg) {
433                self.cursor = 0;
434            } else if self.keymap.go_to_end.matches(key_msg) {
435                self.cursor = self.len().saturating_sub(1);
436            } else if self.keymap.filter.matches(key_msg) {
437                self.filter_state = FilterState::Filtering;
438                // propagate the blink command so it is polled by runtime
439                return Some(self.filter_input.focus());
440            } else if self.keymap.clear_filter.matches(key_msg) {
441                self.filter_input.set_value("");
442                self.filter_state = FilterState::Unfiltered;
443                self.filtered_items.clear();
444                self.cursor = 0;
445                self.update_pagination();
446            }
447        }
448        None
449    }
450    fn view(&self) -> String {
451        lipgloss::join_vertical(
452            lipgloss::LEFT,
453            &[&self.view_header(), &self.view_items(), &self.view_footer()],
454        )
455    }
456}
457
458// Re-export commonly used types
459pub use defaultitem::{DefaultDelegate, DefaultItem, DefaultItemStyles};
460pub use keys::ListKeyMap;
461pub use style::ListStyles;
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    #[derive(Clone)]
468    struct S(&'static str);
469    impl std::fmt::Display for S {
470        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
471            write!(f, "{}", self.0)
472        }
473    }
474    impl Item for S {
475        fn filter_value(&self) -> String {
476            self.0.to_string()
477        }
478    }
479
480    #[test]
481    fn test_status_bar_item_name() {
482        let mut list = Model::new(
483            vec![S("foo"), S("bar")],
484            defaultitem::DefaultDelegate::new(),
485            10,
486            10,
487        );
488        let v = list.status_view();
489        assert!(v.contains("2 items"));
490        list.set_items(vec![S("foo")]);
491        let v = list.status_view();
492        assert!(v.contains("1 item"));
493    }
494
495    #[test]
496    fn test_status_bar_without_items() {
497        let list = Model::new(Vec::<S>::new(), defaultitem::DefaultDelegate::new(), 10, 10);
498        assert!(list.status_view().contains("No items") || list.is_empty());
499    }
500
501    #[test]
502    fn test_custom_status_bar_item_name() {
503        let mut list = Model::new(
504            vec![S("foo"), S("bar")],
505            defaultitem::DefaultDelegate::new(),
506            10,
507            10,
508        );
509        list.set_status_bar_item_name("connection", "connections");
510        assert!(list.status_view().contains("2 connections"));
511        list.set_items(vec![S("foo")]);
512        assert!(list.status_view().contains("1 connection"));
513        list.set_items(vec![]);
514        // When empty, status_view currently just shows help or empty; ensure no panic
515        let _ = list.status_view();
516    }
517
518    #[test]
519    fn test_set_filter_text_and_state_visible_items() {
520        let tc = vec![S("foo"), S("bar"), S("baz")];
521        let mut list = Model::new(tc.clone(), defaultitem::DefaultDelegate::new(), 10, 10);
522        list.set_filter_text("ba");
523        list.set_filter_state(FilterState::Unfiltered);
524        assert_eq!(list.visible_items().len(), tc.len());
525
526        list.set_filter_state(FilterState::Filtering);
527        list.apply_filter();
528        let vis = list.visible_items();
529        assert_eq!(vis.len(), 2); // bar, baz
530
531        list.set_filter_state(FilterState::FilterApplied);
532        let vis2 = list.visible_items();
533        assert_eq!(vis2.len(), 2);
534    }
535
536    #[test]
537    fn test_selection_highlighting_works() {
538        let items = vec![S("first item"), S("second item"), S("third item")];
539        let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
540
541        // Test that view renders without crashing and includes styling
542        let view_output = list.view();
543        assert!(!view_output.is_empty(), "View should not be empty");
544
545        // Test selection highlighting by checking that cursor position affects rendering
546        let first_view = list.view();
547        list.cursor = 1; // Move cursor to second item
548        let second_view = list.view();
549
550        // The views should be different because of selection highlighting
551        assert_ne!(
552            first_view, second_view,
553            "Selection highlighting should change the view"
554        );
555    }
556
557    #[test]
558    fn test_filter_highlighting_works() {
559        let items = vec![S("apple pie"), S("banana bread"), S("carrot cake")];
560        let mut list = Model::new(items, defaultitem::DefaultDelegate::new(), 80, 20);
561
562        // Apply filter that should match only some items
563        list.set_filter_text("ap");
564        list.apply_filter(); // Actually apply the filter to process matches
565
566        let filtered_view = list.view();
567        assert!(
568            !filtered_view.is_empty(),
569            "Filtered view should not be empty"
570        );
571
572        // Check that filtering worked ("ap" should only match "apple pie")
573        assert_eq!(list.len(), 1, "Should have 1 item matching 'ap'");
574
575        // Test that matches are stored correctly
576        assert!(
577            !list.filtered_items.is_empty(),
578            "Filtered items should have match data"
579        );
580        if !list.filtered_items.is_empty() {
581            assert!(
582                !list.filtered_items[0].matches.is_empty(),
583                "First filtered item should have matches"
584            );
585            // Check that the matched item is indeed "apple pie"
586            assert_eq!(list.filtered_items[0].item.0, "apple pie");
587        }
588    }
589}