bubbletea_widgets/list/
model.rs

1//! Main Model struct and core functionality for list components.
2//!
3//! This module contains the primary Model struct that represents a list component,
4//! along with its basic construction, state management, and accessor methods.
5
6use super::keys::ListKeyMap;
7use super::style::ListStyles;
8use super::types::{FilterState, FilteredItem, Item, ItemDelegate};
9use crate::{help, paginator, spinner, textinput};
10
11/// A flexible, interactive list component with filtering, pagination, and customizable rendering.
12///
13/// The `Model<I>` is the main list component that can display any items implementing the `Item` trait.
14/// It provides fuzzy filtering, keyboard navigation, viewport scrolling, help integration, and
15/// customizable styling through delegates.
16///
17/// # Features
18///
19/// - **Fuzzy filtering**: Real-time search with character-level highlighting
20/// - **Smooth scrolling**: Viewport-based navigation that maintains context
21/// - **Customizable rendering**: Delegate pattern for complete visual control
22/// - **Keyboard navigation**: Vim-style keys plus standard arrow navigation
23/// - **Contextual help**: Automatic help text generation from key bindings
24/// - **Responsive design**: Adapts to different terminal sizes
25/// - **State management**: Clean separation of filtering, selection, and view states
26///
27/// # Architecture
28///
29/// The list uses a viewport-based scrolling system that maintains smooth navigation
30/// context instead of discrete page jumps. Items are rendered using delegates that
31/// control appearance and behavior, while filtering uses fuzzy matching with
32/// character-level highlighting for search results.
33///
34/// # Navigation
35///
36/// - **Up/Down**: Move cursor through items with smooth viewport scrolling
37/// - **Page Up/Down**: Jump by pages while maintaining cursor visibility
38/// - **Home/End**: Jump to first/last item
39/// - **/** : Start filtering
40/// - **Enter**: Accept filter (while filtering)
41/// - **Escape**: Cancel filter (while filtering)
42/// - **Ctrl+C**: Clear active filter
43///
44/// # Filtering
45///
46/// The list supports fuzzy filtering with real-time preview:
47/// - Type "/" to start filtering
48/// - Type characters to filter items in real-time
49/// - Matched characters are highlighted in the results
50/// - Press Enter to accept the filter or Escape to cancel
51///
52/// # Styling
53///
54/// Visual appearance is controlled through the `ListStyles` struct and item delegates.
55/// The list adapts to light/dark terminal themes automatically and supports
56/// customizable colors, borders, and typography.
57///
58/// # Examples
59///
60/// ```
61/// use bubbletea_widgets::list::{Model, DefaultDelegate, DefaultItem};
62///
63/// let items = vec![
64///     DefaultItem::new("Task 1", "Complete documentation"),
65///     DefaultItem::new("Task 2", "Review pull requests"),
66/// ];
67/// let delegate = DefaultDelegate::new();
68/// let list = Model::new(items, delegate, 80, 24);
69/// ```
70///
71/// ## With Custom Items
72///
73/// ```
74/// use bubbletea_widgets::list::{Item, Model, DefaultDelegate};
75/// use std::fmt::Display;
76///
77/// #[derive(Clone)]
78/// struct CustomItem {
79///     title: String,
80///     priority: u8,
81/// }
82///
83/// impl Display for CustomItem {
84///     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85///         write!(f, "[{}] {}", self.priority, self.title)
86///     }
87/// }
88///
89/// impl Item for CustomItem {
90///     fn filter_value(&self) -> String {
91///         format!("{} priority:{}", self.title, self.priority)
92///     }
93/// }
94///
95/// let items = vec![
96///     CustomItem { title: "Fix bug".to_string(), priority: 1 },
97///     CustomItem { title: "Add feature".to_string(), priority: 2 },
98/// ];
99/// let list = Model::new(items, DefaultDelegate::new(), 80, 24);
100/// ```
101pub struct Model<I: Item> {
102    pub(super) title: String,
103    pub(super) items: Vec<I>,
104    pub(super) delegate: Box<dyn ItemDelegate<I> + Send + Sync>,
105
106    // Pagination
107    pub(super) paginator: paginator::Model,
108    pub(super) per_page: usize,
109
110    // UI State
111    #[allow(dead_code)]
112    pub(super) spinner: spinner::Model,
113    pub(super) width: usize,
114    pub(super) height: usize,
115    pub(super) styles: ListStyles,
116
117    // Status bar
118    pub(super) show_status_bar: bool,
119    #[allow(dead_code)]
120    pub(super) status_message_lifetime: usize,
121    pub(super) status_item_singular: Option<String>,
122    pub(super) status_item_plural: Option<String>,
123
124    // Help
125    pub(super) help: help::Model,
126    pub(super) keymap: ListKeyMap,
127
128    // State
129    pub(super) filter_state: FilterState,
130    pub(super) filtered_items: Vec<FilteredItem<I>>,
131    pub(super) cursor: usize,
132    /// First visible item index for smooth scrolling.
133    ///
134    /// This field tracks the index of the first item visible in the current viewport.
135    /// It enables smooth, context-preserving scrolling behavior instead of discrete
136    /// page jumps. The viewport scrolls automatically when the cursor moves outside
137    /// the visible area, maintaining visual continuity.
138    pub(super) viewport_start: usize,
139
140    // Filter
141    pub(super) filter_input: textinput::Model,
142}
143
144impl<I: Item + Send + Sync + 'static> Model<I> {
145    /// Creates a new list with the provided items, delegate, and dimensions.
146    ///
147    /// This is the primary constructor for creating a list component. The delegate
148    /// controls how items are rendered and behave, while the dimensions determine
149    /// the initial size for layout calculations.
150    ///
151    /// # Arguments
152    ///
153    /// * `items` - Vector of items to display in the list
154    /// * `delegate` - Item delegate that controls rendering and behavior
155    /// * `width` - Initial width in terminal columns (can be updated later)
156    /// * `height` - Initial height in terminal rows (affects pagination)
157    ///
158    /// # Returns
159    ///
160    /// A new `Model<I>` configured with default settings:
161    /// - Title set to "List"
162    /// - 10 items per page
163    /// - Cursor at position 0
164    /// - All items initially visible (no filtering)
165    /// - Status bar enabled with default item names
166    ///
167    /// # Examples
168    ///
169    /// ```
170    /// use bubbletea_widgets::list::{Model, DefaultDelegate, DefaultItem};
171    ///
172    /// let items = vec![
173    ///     DefaultItem::new("First", "Description 1"),
174    ///     DefaultItem::new("Second", "Description 2"),
175    /// ];
176    ///
177    /// let list = Model::new(items, DefaultDelegate::new(), 80, 24);
178    /// assert_eq!(list.len(), 2);
179    /// ```
180    pub fn new<D>(items: Vec<I>, delegate: D, width: usize, height: usize) -> Self
181    where
182        D: ItemDelegate<I> + Send + Sync + 'static,
183    {
184        let mut paginator = paginator::Model::new();
185        let per_page = 10;
186        paginator.set_per_page(per_page);
187        paginator.set_total_items(items.len());
188
189        Self {
190            title: "List".to_string(),
191            items,
192            delegate: Box::new(delegate),
193            paginator,
194            per_page,
195            spinner: spinner::new(&[]),
196            width,
197            height,
198            styles: ListStyles::default(),
199            show_status_bar: true,
200            status_message_lifetime: 1,
201            status_item_singular: None,
202            status_item_plural: None,
203            help: help::Model::new(),
204            keymap: ListKeyMap::default(),
205            filter_state: FilterState::Unfiltered,
206            filtered_items: vec![],
207            cursor: 0,
208            viewport_start: 0,
209            filter_input: textinput::new(),
210        }
211    }
212
213    /// Sets the items displayed in the list.
214    ///
215    /// This method replaces all current items with the provided vector.
216    /// The cursor is reset to position 0, and pagination is recalculated
217    /// based on the new item count.
218    ///
219    /// # Arguments
220    ///
221    /// * `items` - Vector of new items to display
222    ///
223    /// # Examples
224    ///
225    /// ```
226    /// # use bubbletea_widgets::list::{Model, DefaultDelegate, DefaultItem};
227    /// let mut list = Model::new(vec![], DefaultDelegate::new(), 80, 24);
228    ///
229    /// let items = vec![
230    ///     DefaultItem::new("Apple", "Red fruit"),
231    ///     DefaultItem::new("Banana", "Yellow fruit"),
232    /// ];
233    /// list.set_items(items);
234    /// assert_eq!(list.len(), 2);
235    /// ```
236    pub fn set_items(&mut self, items: Vec<I>) {
237        self.items = items;
238        self.cursor = 0;
239        self.update_pagination();
240    }
241
242    /// Returns a vector of currently visible items.
243    ///
244    /// The returned items reflect the current filtering state:
245    /// - When unfiltered: returns all items
246    /// - When filtered: returns only items matching the current filter
247    ///
248    /// # Returns
249    ///
250    /// A vector containing clones of all currently visible items.
251    ///
252    /// # Examples
253    ///
254    /// ```
255    /// # use bubbletea_widgets::list::{Model, DefaultDelegate, DefaultItem};
256    /// let items = vec![
257    ///     DefaultItem::new("First", "Description 1"),
258    ///     DefaultItem::new("Second", "Description 2"),
259    /// ];
260    ///
261    /// let list = Model::new(items, DefaultDelegate::new(), 80, 24);
262    /// let visible = list.visible_items();
263    /// assert_eq!(visible.len(), 2);
264    /// ```
265    pub fn visible_items(&self) -> Vec<I> {
266        if self.filter_state == FilterState::Unfiltered {
267            self.items.clone()
268        } else {
269            self.filtered_items
270                .iter()
271                .map(|fi| fi.item.clone())
272                .collect()
273        }
274    }
275
276    /// Sets the filter text without applying the filter.
277    ///
278    /// This method updates the filter input text but does not trigger
279    /// the filtering process. It's primarily used for programmatic
280    /// filter setup or testing.
281    ///
282    /// # Arguments
283    ///
284    /// * `s` - The filter text to set
285    ///
286    /// # Examples
287    ///
288    /// ```
289    /// # use bubbletea_widgets::list::{Model, DefaultDelegate, DefaultItem};
290    /// let mut list: Model<DefaultItem> = Model::new(vec![], DefaultDelegate::new(), 80, 24);
291    /// list.set_filter_text("search term");
292    /// // Filter text is set but not applied until filtering is activated
293    /// ```
294    pub fn set_filter_text(&mut self, s: &str) {
295        self.filter_input.set_value(s);
296    }
297
298    /// Sets the current filtering state.
299    ///
300    /// This method directly controls the list's filtering state without
301    /// triggering filter application. It's useful for programmatic state
302    /// management or testing specific filter conditions.
303    ///
304    /// # Arguments
305    ///
306    /// * `st` - The new filter state to set
307    ///
308    /// # Examples
309    ///
310    /// ```
311    /// # use bubbletea_widgets::list::{Model, DefaultDelegate, DefaultItem, FilterState};
312    /// let mut list: Model<DefaultItem> = Model::new(vec![], DefaultDelegate::new(), 80, 24);
313    /// list.set_filter_state(FilterState::Filtering);
314    /// // List is now in filtering mode
315    /// ```
316    pub fn set_filter_state(&mut self, st: FilterState) {
317        self.filter_state = st;
318    }
319
320    /// Sets custom singular and plural names for status bar items.
321    ///
322    /// The status bar displays item counts using these names. If not set,
323    /// defaults to "item" and "items".
324    ///
325    /// # Arguments
326    ///
327    /// * `singular` - Name for single item (e.g., "task")
328    /// * `plural` - Name for multiple items (e.g., "tasks")
329    ///
330    /// # Examples
331    ///
332    /// ```
333    /// # use bubbletea_widgets::list::{Model, DefaultDelegate, DefaultItem};
334    /// let mut list: Model<DefaultItem> = Model::new(vec![], DefaultDelegate::new(), 80, 24);
335    /// list.set_status_bar_item_name("task", "tasks");
336    /// // Status bar will now show "1 task" or "5 tasks"
337    /// ```
338    pub fn set_status_bar_item_name(&mut self, singular: &str, plural: &str) {
339        self.status_item_singular = Some(singular.to_string());
340        self.status_item_plural = Some(plural.to_string());
341    }
342
343    /// Renders the status bar as a formatted string.
344    ///
345    /// The status bar shows the current selection position and total item count,
346    /// using the custom item names if set. The format is "X/Y items" where X is
347    /// the current cursor position + 1, and Y is the total item count.
348    ///
349    /// # Returns
350    ///
351    /// A formatted status string, or empty string if status bar is disabled.
352    ///
353    /// # Examples
354    ///
355    /// ```
356    /// # use bubbletea_widgets::list::{Model, DefaultDelegate, DefaultItem};
357    /// let items = vec![
358    ///     DefaultItem::new("First", ""),
359    ///     DefaultItem::new("Second", ""),
360    /// ];
361    /// let list = Model::new(items, DefaultDelegate::new(), 80, 24);
362    /// let status = list.status_view();
363    /// assert!(status.contains("1/2"));
364    /// ```
365    pub fn status_view(&self) -> String {
366        if !self.show_status_bar {
367            return String::new();
368        }
369
370        let mut footer = String::new();
371        if !self.is_empty() {
372            let singular = self.status_item_singular.as_deref().unwrap_or("item");
373            let plural = self.status_item_plural.as_deref().unwrap_or("items");
374            let noun = if self.len() == 1 { singular } else { plural };
375            footer.push_str(&format!("{}/{} {}", self.cursor + 1, self.len(), noun));
376        }
377        let help_view = self.help.view(self);
378        if !help_view.is_empty() {
379            footer.push('\n');
380            footer.push_str(&help_view);
381        }
382        footer
383    }
384
385    /// Returns fuzzy match character indices for a given original item index.
386    ///
387    /// This method finds the character positions that matched the current filter
388    /// for a specific item identified by its original index in the full items list.
389    /// These indices can be used for character-level highlighting in custom delegates.
390    ///
391    /// # Arguments
392    ///
393    /// * `original_index` - The original index of the item in the full items list
394    ///
395    /// # Returns
396    ///
397    /// A reference to the vector of character indices that matched the filter,
398    /// or `None` if no matches exist for this item or if filtering is not active.
399    ///
400    /// # Examples
401    ///
402    /// ```
403    /// # use bubbletea_widgets::list::{Model, DefaultDelegate, DefaultItem};
404    /// let items = vec![DefaultItem::new("Apple", "Red fruit")];
405    /// let mut list = Model::new(items, DefaultDelegate::new(), 80, 24);
406    ///
407    /// // Apply a filter first
408    /// list.set_filter_text("app");
409    /// // In a real application, this would be done through user interaction
410    ///
411    /// if let Some(matches) = list.matches_for_original_item(0) {
412    ///     // matches contains the character indices that matched "app" in "Apple"
413    ///     println!("Matched characters at indices: {:?}", matches);
414    /// }
415    /// ```
416    pub fn matches_for_original_item(&self, original_index: usize) -> Option<&Vec<usize>> {
417        self.filtered_items
418            .iter()
419            .find(|fi| fi.index == original_index)
420            .map(|fi| &fi.matches)
421    }
422
423    /// Sets the list title.
424    ///
425    /// The title is displayed at the top of the list when not filtering.
426    /// During filtering, the title is replaced with the filter input interface.
427    ///
428    /// # Arguments
429    ///
430    /// * `title` - The new title for the list
431    ///
432    /// # Returns
433    ///
434    /// Self, for method chaining.
435    ///
436    /// # Examples
437    ///
438    /// ```
439    /// # use bubbletea_widgets::list::{Model, DefaultDelegate, DefaultItem};
440    /// let list: Model<DefaultItem> = Model::new(vec![], DefaultDelegate::new(), 80, 24)
441    ///     .with_title("My Tasks");
442    /// ```
443    ///
444    /// Or using the mutable method:
445    ///
446    /// ```
447    /// # use bubbletea_widgets::list::{Model, DefaultDelegate, DefaultItem};
448    /// let mut list: Model<DefaultItem> = Model::new(vec![], DefaultDelegate::new(), 80, 24);
449    /// list = list.with_title("My Tasks");
450    /// ```
451    pub fn with_title(mut self, title: &str) -> Self {
452        self.title = title.to_string();
453        self
454    }
455
456    /// Returns a reference to the currently selected item.
457    ///
458    /// The selected item is the one at the current cursor position. If the list
459    /// is empty or the cursor is out of bounds, returns `None`.
460    ///
461    /// # Returns
462    ///
463    /// A reference to the selected item, or `None` if no valid selection exists.
464    ///
465    /// # Examples
466    ///
467    /// ```
468    /// # use bubbletea_widgets::list::{Model, DefaultDelegate, DefaultItem};
469    /// let items = vec![
470    ///     DefaultItem::new("First", "Description 1"),
471    ///     DefaultItem::new("Second", "Description 2"),
472    /// ];
473    /// let list = Model::new(items, DefaultDelegate::new(), 80, 24);
474    ///
475    /// if let Some(selected) = list.selected_item() {
476    ///     println!("Selected: {}", selected);
477    /// }
478    /// ```
479    pub fn selected_item(&self) -> Option<&I> {
480        if self.filter_state == FilterState::Unfiltered {
481            self.items.get(self.cursor)
482        } else {
483            self.filtered_items.get(self.cursor).map(|fi| &fi.item)
484        }
485    }
486
487    /// Returns the current cursor position.
488    ///
489    /// The cursor represents the currently selected item index within the
490    /// visible (possibly filtered) list. This is always relative to the
491    /// currently visible items, not the original full list.
492    ///
493    /// # Returns
494    ///
495    /// The zero-based index of the currently selected item.
496    ///
497    /// # Examples
498    ///
499    /// ```
500    /// # use bubbletea_widgets::list::{Model, DefaultDelegate, DefaultItem};
501    /// let items = vec![
502    ///     DefaultItem::new("First", "Description"),
503    ///     DefaultItem::new("Second", "Description"),
504    /// ];
505    /// let list = Model::new(items, DefaultDelegate::new(), 80, 24);
506    /// assert_eq!(list.cursor(), 0); // Initially at first item
507    /// ```
508    pub fn cursor(&self) -> usize {
509        self.cursor
510    }
511
512    /// Returns the number of currently visible items.
513    ///
514    /// This count reflects the items actually visible to the user:
515    /// - When unfiltered: returns the total number of items
516    /// - When filtering is active: returns only the count of matching items
517    ///
518    /// # Returns
519    ///
520    /// The number of items currently visible in the list.
521    ///
522    /// # Examples
523    ///
524    /// ```
525    /// # use bubbletea_widgets::list::{Model, DefaultDelegate, DefaultItem};
526    /// let items = vec![
527    ///     DefaultItem::new("Apple", "Red"),
528    ///     DefaultItem::new("Banana", "Yellow"),
529    /// ];
530    /// let list = Model::new(items, DefaultDelegate::new(), 80, 24);
531    /// assert_eq!(list.len(), 2);
532    /// ```
533    pub fn len(&self) -> usize {
534        if self.filter_state == FilterState::Unfiltered {
535            self.items.len()
536        } else {
537            self.filtered_items.len()
538        }
539    }
540
541    /// Returns whether the list has no visible items.
542    ///
543    /// # Returns
544    ///
545    /// `true` if there are no currently visible items, `false` otherwise.
546    ///
547    /// # Examples
548    ///
549    /// ```
550    /// # use bubbletea_widgets::list::{Model, DefaultDelegate, DefaultItem};
551    /// let list: Model<DefaultItem> = Model::new(vec![], DefaultDelegate::new(), 80, 24);
552    /// assert!(list.is_empty());
553    /// ```
554    pub fn is_empty(&self) -> bool {
555        self.len() == 0
556    }
557
558    /// Updates pagination settings based on current item count and page size.
559    ///
560    /// This method recalculates pagination after changes to item count or
561    /// page size. It's called automatically after operations that affect
562    /// the visible item count.
563    pub(super) fn update_pagination(&mut self) {
564        let total = self.len();
565        self.paginator.set_total_items(total);
566
567        // Calculate how many items can fit in the available height
568        if self.height > 0 {
569            let item_height = self.delegate.height() + self.delegate.spacing();
570            let header_height = 1; // Title or filter input
571            let footer_height = if self.show_status_bar { 2 } else { 0 }; // Status + help
572
573            let available_height = self.height.saturating_sub(header_height + footer_height);
574            let items_per_page = if item_height > 0 {
575                (available_height / item_height).max(1)
576            } else {
577                10 // Fallback to default value
578            };
579
580            self.per_page = items_per_page;
581            self.paginator.set_per_page(items_per_page);
582        }
583    }
584}