bubbletea_widgets/list/
mod.rs

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