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//! ## Architecture Overview
9//!
10//! This list component uses several key architectural patterns for smooth interaction:
11//!
12//! ### 🎯 Core Design Principles
13//! 1. **Viewport-Based Scrolling**: Maintains smooth, context-preserving navigation
14//! 2. **Index Consistency**: Uses original item indices for cursor tracking across all states
15//! 3. **Real-Time Filtering**: Integrates textinput component for responsive filter interaction
16//! 4. **State-Driven UI**: Clear separation between filtering, navigation, and display states
17//!
18//! ### 🏗️ Key Components
19//! - **Viewport Management**: `viewport_start` field tracks visible window position
20//! - **Index Strategy**: Delegates receive original indices for consistent highlighting
21//! - **Filter Integration**: Direct textinput forwarding preserves all input features
22//! - **State Coordination**: Filtering states control UI behavior and key handling
23//!
24//! ### 📋 Implementation Strategy
25//! - **Viewport Scrolling**: Only adjusts view when cursor moves outside visible bounds
26//! - **Index Semantics**: Render delegates use original positions for cursor comparison
27//! - **Filter States**: `Filtering` during typing, `FilterApplied` after acceptance
28//! - **Event Handling**: KeyMsg forwarding maintains textinput component consistency
29//!
30//! ### Filtering States
31//! The list supports fuzzy filtering with three states:
32//! - `Unfiltered`: No filter active
33//! - `Filtering`: User is typing a filter; input is shown in the header
34//! - `FilterApplied`: Filter accepted; only matching items are displayed
35//!
36//! When filtering is active, fuzzy match indices are stored per item and delegates can use
37//! them to apply character-level highlighting (see `defaultitem`).
38//!
39//! ### Help Integration
40//! The list implements `help::KeyMap`, so you can embed `help::Model` and get contextual
41//! help automatically based on the current filtering state.
42
43// Module declarations
44
45/// Default item implementation and delegate for basic list functionality.
46///
47/// This module provides ready-to-use implementations for common list use cases:
48/// - `DefaultItem`: A simple string-based item with title and description
49/// - `DefaultDelegate`: A delegate that renders items with proper highlighting
50/// - `DefaultItemStyles`: Customizable styling for default item rendering
51///
52/// These components handle fuzzy match highlighting, cursor styling, and basic
53/// item representation without requiring custom implementations.
54///
55/// # Examples
56///
57/// ```
58/// use bubbletea_widgets::list::{Model, DefaultDelegate, DefaultItem};
59///
60/// let items = vec![
61///     DefaultItem::new("Task 1", "Complete documentation"),
62///     DefaultItem::new("Task 2", "Review pull requests"),
63/// ];
64///
65/// let list = Model::new(items, DefaultDelegate::new(), 80, 24);
66/// ```
67pub mod defaultitem;
68
69/// Key bindings and keyboard input handling for list navigation.
70///
71/// This module defines the key mapping system that controls how users interact
72/// with the list component. It includes:
73/// - `ListKeyMap`: Configurable key bindings for all list operations
74/// - Default key mappings following common terminal UI conventions
75/// - Support for custom key binding overrides
76///
77/// The key system integrates with the help system to provide contextual
78/// keyboard shortcuts based on the current list state.
79///
80/// # Examples
81///
82/// ```
83/// use bubbletea_widgets::list::{Model, DefaultDelegate, DefaultItem, ListKeyMap};
84///
85/// let items = vec![DefaultItem::new("Item", "Description")];
86/// let list = Model::new(items, DefaultDelegate::new(), 80, 24);
87///
88/// // The key mappings are used internally by the list component
89/// // They can be customized when creating custom list implementations
90/// ```
91pub mod keys;
92
93/// Visual styling and theming for list components.
94///
95/// This module provides comprehensive styling options for customizing the
96/// appearance of list components:
97/// - `ListStyles`: Complete styling configuration for all visual elements
98/// - Color schemes that adapt to light/dark terminal themes
99/// - Typography and border styling options
100/// - Default styles following terminal UI conventions
101///
102/// The styling system supports both built-in themes and complete customization
103/// for applications with specific branding requirements.
104///
105/// # Examples
106///
107/// ```
108/// use bubbletea_widgets::list::{Model, DefaultDelegate, DefaultItem, ListStyles};
109///
110/// let items = vec![DefaultItem::new("Item", "Description")];
111/// let list = Model::new(items, DefaultDelegate::new(), 80, 24);
112///
113/// // Get the default styling - customization is done by modifying the struct fields
114/// let default_styles = ListStyles::default();
115/// // Styling can be customized by creating a new ListStyles instance
116/// ```
117pub mod style;
118
119// Internal modules
120mod api;
121mod filtering;
122mod model;
123mod rendering;
124mod types;
125
126// Re-export public types from submodules
127
128/// The main list component model.
129///
130/// `Model<I>` is a generic list component that can display any items implementing
131/// the `Item` trait. It provides filtering, navigation, pagination, and customizable
132/// rendering through the delegate pattern.
133///
134/// # Type Parameters
135///
136/// * `I` - The item type that must implement `Item + Send + Sync + 'static`
137///
138/// # Examples
139///
140/// ```
141/// use bubbletea_widgets::list::{Model, DefaultDelegate, DefaultItem};
142///
143/// let items = vec![
144///     DefaultItem::new("Apple", "Red fruit"),
145///     DefaultItem::new("Banana", "Yellow fruit"),
146/// ];
147/// let list = Model::new(items, DefaultDelegate::new(), 80, 24);
148/// ```
149pub use model::Model;
150
151/// Key binding configuration for list navigation and interaction.
152///
153/// `ListKeyMap` defines all the keyboard shortcuts used for list operations
154/// including navigation, filtering, and help. It can be customized to match
155/// application-specific key binding preferences.
156pub use keys::ListKeyMap;
157
158/// Visual styling configuration for list appearance.
159///
160/// `ListStyles` contains all styling options for customizing the visual
161/// appearance of list components including colors, typography, and borders.
162/// It supports both light and dark terminal themes automatically.
163pub use style::ListStyles;
164
165/// Core traits and types for list functionality.
166///
167/// These are the fundamental building blocks for creating custom list items
168/// and delegates:
169///
170/// - `Item`: Trait for displayable and filterable list items
171/// - `ItemDelegate`: Trait for customizing item rendering and behavior  
172/// - `FilterState`: Enum representing the current filtering state
173/// - `FilterStateInfo`: Detailed information about filter status
174///
175/// # Examples
176///
177/// ```
178/// use bubbletea_widgets::list::{Item, ItemDelegate, FilterState, Model};
179/// use std::fmt::Display;
180///
181/// #[derive(Clone)]
182/// struct MyItem {
183///     name: String,
184///     value: i32,
185/// }
186///
187/// impl Display for MyItem {
188///     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189///         write!(f, "{}: {}", self.name, self.value)
190///     }
191/// }
192///
193/// impl Item for MyItem {
194///     fn filter_value(&self) -> String {
195///         format!("{} {}", self.name, self.value)
196///     }
197/// }
198/// ```
199pub use types::{FilterState, FilterStateInfo, Item, ItemDelegate};
200
201/// Ready-to-use implementations for common list scenarios.
202///
203/// These provide drop-in functionality for typical list use cases:
204///
205/// - `DefaultItem`: Simple string-based items with title and description
206/// - `DefaultDelegate`: Standard item rendering with highlighting support
207/// - `DefaultItemStyles`: Styling configuration for default rendering
208///
209/// Perfect for prototyping or applications that don't need custom item types.
210///
211/// # Examples
212///
213/// ```
214/// use bubbletea_widgets::list::{DefaultItem, DefaultDelegate, DefaultItemStyles, Model};
215///
216/// // Create items using the default implementation
217/// let items = vec![
218///     DefaultItem::new("First Item", "Description 1"),
219///     DefaultItem::new("Second Item", "Description 2"),
220/// ];
221///
222/// // Use the default delegate for rendering
223/// let delegate = DefaultDelegate::new();
224/// let list = Model::new(items, delegate, 80, 24);
225/// ```
226pub use defaultitem::{DefaultDelegate, DefaultItem, DefaultItemStyles};
227
228use crate::{help, key};
229use bubbletea_rs::{Cmd, KeyMsg, Model as BubbleTeaModel, Msg};
230use crossterm::event::KeyCode;
231
232// Help integration - provides contextual key bindings based on current state
233impl<I: Item> help::KeyMap for Model<I> {
234    /// Returns key bindings for compact help display.
235    ///
236    /// Provides a minimal set of the most important key bindings
237    /// based on the current list state. The bindings change depending
238    /// on whether the user is actively filtering or navigating.
239    ///
240    /// # Context-Sensitive Help
241    ///
242    /// - **While filtering**: Shows Enter (accept) and Escape (cancel) bindings
243    /// - **Normal navigation**: Shows up/down navigation and filter activation
244    fn short_help(&self) -> Vec<&key::Binding> {
245        match self.filter_state {
246            FilterState::Filtering => vec![&self.keymap.accept_filter, &self.keymap.cancel_filter],
247            _ => vec![
248                &self.keymap.cursor_up,
249                &self.keymap.cursor_down,
250                &self.keymap.filter,
251                &self.keymap.quit,
252                &self.keymap.show_full_help, // Add "? more" to match Go version
253            ],
254        }
255    }
256
257    /// Returns all key bindings organized into logical groups.
258    ///
259    /// Provides comprehensive help information with bindings grouped by
260    /// functionality. The grouping helps users understand related actions
261    /// and discover advanced features.
262    ///
263    /// # Binding Groups
264    ///
265    /// 1. **Cursor movement**: Up/down navigation
266    /// 2. **Page navigation**: Page up/down, home/end
267    /// 3. **Filtering**: Start filter, clear filter, accept
268    /// 4. **Help and quit**: Show help, quit application
269    fn full_help(&self) -> Vec<Vec<&key::Binding>> {
270        vec![
271            // Column 1: Primary Navigation
272            vec![
273                &self.keymap.cursor_up,
274                &self.keymap.cursor_down,
275                &self.keymap.next_page,
276                &self.keymap.prev_page,
277                &self.keymap.go_to_start,
278                &self.keymap.go_to_end,
279            ],
280            // Column 2: Filtering Actions
281            vec![
282                &self.keymap.filter,
283                &self.keymap.clear_filter,
284                &self.keymap.accept_filter,
285                &self.keymap.cancel_filter,
286            ],
287            // Column 3: Help and Quit
288            vec![
289                &self.keymap.show_full_help,
290                &self.keymap.close_full_help,
291                &self.keymap.quit,
292            ],
293        ]
294    }
295}
296
297// BubbleTeaModel implementation - integrates with bubbletea-rs runtime
298impl<I: Item + Send + Sync + 'static> BubbleTeaModel for Model<I> {
299    /// Initializes a new empty list model with default settings.
300    ///
301    /// This creates a new list with no items, using the default delegate
302    /// and standard dimensions. This method is called by the BubbleTea
303    /// runtime when the model is first created.
304    ///
305    /// # Returns
306    ///
307    /// A tuple containing:
308    /// - The initialized list model with default settings
309    /// - `None` (no initial command to execute)
310    ///
311    /// # Default Configuration
312    ///
313    /// - Empty items list
314    /// - `DefaultDelegate` for rendering
315    /// - 80 columns × 24 rows dimensions
316    /// - Default styling and key bindings
317    fn init() -> (Self, Option<Cmd>) {
318        let model = Self::new(vec![], defaultitem::DefaultDelegate::new(), 80, 24);
319        (model, None)
320    }
321
322    /// Handles keyboard input and state updates.
323    ///
324    /// This method processes all user input and updates the list state accordingly.
325    /// It implements different input handling modes based on the current filtering state:
326    ///
327    /// ## While Filtering (`FilterState::Filtering`)
328    ///
329    /// - **Escape**: Cancel filtering, return to previous state
330    /// - **Enter**: Accept current filter, change to `FilterApplied` state
331    /// - **Characters**: Add to filter text, update results in real-time
332    /// - **Backspace**: Remove characters from filter
333    /// - **Arrow keys**: Navigate filter input cursor position
334    ///
335    /// ## Normal Navigation (other states)
336    ///
337    /// - **Up/Down**: Move cursor through items with smooth viewport scrolling
338    /// - **Page Up/Page Down**: Move cursor by one page (viewport height)
339    /// - **Home/End**: Jump to first/last item
340    /// - **/** : Start filtering mode
341    /// - **Ctrl+C**: Clear any active filter
342    ///
343    /// # Viewport and Paginator Management
344    ///
345    /// The update method automatically:
346    /// - Manages viewport scrolling to ensure the cursor remains visible
347    /// - Synchronizes the paginator component to reflect the current page
348    fn update(&mut self, msg: Msg) -> Option<Cmd> {
349        if self.filter_state == FilterState::Filtering {
350            if let Some(key_msg) = msg.downcast_ref::<KeyMsg>() {
351                match key_msg.key {
352                    crossterm::event::KeyCode::Esc => {
353                        self.filter_state = if self.filtered_items.is_empty() {
354                            FilterState::Unfiltered
355                        } else {
356                            FilterState::FilterApplied
357                        };
358                        self.filter_input.blur();
359                        return None;
360                    }
361                    crossterm::event::KeyCode::Enter => {
362                        self.apply_filter();
363                        self.filter_state = FilterState::FilterApplied;
364                        self.filter_input.blur();
365                        return None;
366                    }
367                    crossterm::event::KeyCode::Char(c) => {
368                        // Forward character input to the textinput component for proper handling.
369                        // Creating a new KeyMsg preserves the original event context while ensuring
370                        // the textinput receives all necessary information for features like cursor
371                        // positioning, selection, and character encoding.
372                        let textinput_msg = Box::new(KeyMsg {
373                            key: KeyCode::Char(c),
374                            modifiers: key_msg.modifiers,
375                        }) as Msg;
376                        self.filter_input.update(textinput_msg);
377                        self.apply_filter();
378                    }
379                    crossterm::event::KeyCode::Backspace => {
380                        // Forward backspace events to textinput for complete input handling.
381                        // The textinput component manages cursor positioning, selection deletion,
382                        // and other editing features that require coordinated state management.
383                        let textinput_msg = Box::new(KeyMsg {
384                            key: KeyCode::Backspace,
385                            modifiers: key_msg.modifiers,
386                        }) as Msg;
387                        self.filter_input.update(textinput_msg);
388                        self.apply_filter();
389                    }
390                    // Handle cursor movement within filter input
391                    crossterm::event::KeyCode::Left => {
392                        let pos = self.filter_input.position();
393                        if pos > 0 {
394                            self.filter_input.set_cursor(pos - 1);
395                        }
396                    }
397                    crossterm::event::KeyCode::Right => {
398                        let pos = self.filter_input.position();
399                        self.filter_input.set_cursor(pos + 1);
400                    }
401                    crossterm::event::KeyCode::Home => {
402                        self.filter_input.cursor_start();
403                    }
404                    crossterm::event::KeyCode::End => {
405                        self.filter_input.cursor_end();
406                    }
407                    _ => {}
408                }
409            }
410            return None;
411        }
412
413        if let Some(key_msg) = msg.downcast_ref::<KeyMsg>() {
414            if self.keymap.cursor_up.matches(key_msg) {
415                if self.cursor > 0 {
416                    if self.is_cursor_at_viewport_top() {
417                        // Page-turning behavior: move to last item of previous page
418                        let items_per_view = self.calculate_items_per_view();
419                        self.cursor -= 1;
420                        self.viewport_start = self.cursor.saturating_sub(items_per_view - 1);
421                    } else {
422                        // Normal single-item navigation
423                        self.cursor -= 1;
424                        self.sync_viewport_with_cursor();
425                    }
426                }
427            } else if self.keymap.cursor_down.matches(key_msg) {
428                if self.cursor < self.len().saturating_sub(1) {
429                    if self.is_cursor_at_viewport_bottom() {
430                        // Page-turning behavior: move to first item of next page
431                        self.cursor += 1;
432                        self.viewport_start = self.cursor;
433                    } else {
434                        // Normal single-item navigation
435                        self.cursor += 1;
436                        self.sync_viewport_with_cursor();
437                    }
438                }
439            } else if self.keymap.go_to_start.matches(key_msg) {
440                self.cursor = 0;
441                // Adjust viewport to show the beginning of the list when jumping to start.
442                self.sync_viewport_with_cursor();
443            } else if self.keymap.go_to_end.matches(key_msg) {
444                self.cursor = self.len().saturating_sub(1);
445                // Adjust viewport to show the end of the list when jumping to last item.
446                self.sync_viewport_with_cursor();
447            } else if self.keymap.next_page.matches(key_msg) {
448                // Page Down: Move cursor forward by one page (viewport height).
449                // This provides quick navigation through long lists.
450                let items_len = self.len();
451                if items_len > 0 {
452                    self.cursor = (self.cursor + self.per_page).min(items_len - 1);
453                    self.sync_viewport_with_cursor();
454                }
455            } else if self.keymap.prev_page.matches(key_msg) {
456                // Page Up: Move cursor backward by one page (viewport height).
457                // Saturating subtraction ensures we don't underflow.
458                self.cursor = self.cursor.saturating_sub(self.per_page);
459                self.sync_viewport_with_cursor();
460            } else if self.keymap.filter.matches(key_msg) {
461                self.filter_state = FilterState::Filtering;
462                // Return focus command to enable cursor blinking in filter input
463                return Some(self.filter_input.focus());
464            } else if self.keymap.clear_filter.matches(key_msg) {
465                self.filter_input.set_value("");
466                self.filter_state = FilterState::Unfiltered;
467                self.filtered_items.clear();
468                self.cursor = 0;
469                self.update_pagination();
470            } else if self.keymap.show_full_help.matches(key_msg)
471                || self.keymap.close_full_help.matches(key_msg)
472            {
473                self.help.show_all = !self.help.show_all;
474                self.update_pagination(); // Recalculate layout since help height changes
475            } else if self.keymap.quit.matches(key_msg) {
476                return Some(bubbletea_rs::quit());
477            } else if key_msg.key == crossterm::event::KeyCode::Enter {
478                // Handle item selection
479                if let Some(selected_item) = self.selected_item() {
480                    // Get the original index for the delegate callback
481                    let original_index = if self.filter_state == FilterState::Unfiltered {
482                        self.cursor
483                    } else if let Some(filtered_item) = self.filtered_items.get(self.cursor) {
484                        filtered_item.index
485                    } else {
486                        return None;
487                    };
488
489                    // Call the delegate's on_select callback
490                    if let Some(cmd) = self.delegate.on_select(original_index, selected_item) {
491                        return Some(cmd);
492                    }
493                }
494            }
495
496            // Synchronize the paginator component with the current cursor position.
497            // This calculation determines which "page" the cursor is on based on
498            // items per page, ensuring the pagination indicator (dots) accurately
499            // reflects the user's position in the list.
500            if self.per_page > 0 {
501                self.paginator.page = self.cursor / self.per_page;
502            }
503        }
504        None
505    }
506
507    /// Renders the complete list view as a formatted string.
508    ///
509    /// This method combines all visual components of the list into a single
510    /// string suitable for terminal display. The layout adapts based on the
511    /// current filtering state and available content.
512    ///
513    /// # Returns
514    ///
515    /// A formatted string containing the complete list UI with ANSI styling codes.
516    ///
517    /// # Layout Structure
518    ///
519    /// The view consists of three vertically stacked sections:
520    ///
521    /// 1. **Header**: Title or filter input (depending on state)
522    ///    - Normal: "List Title" or "List Title (filtered: N)"
523    ///    - Filtering: "Filter: > user_input"
524    ///
525    /// 2. **Items**: The main content area showing visible items
526    ///    - Styled according to the current delegate
527    ///    - Shows "No items" message when empty
528    ///    - Viewport-based rendering for large lists
529    ///
530    /// 3. **Footer**: Status and help information
531    ///    - Status: "X/Y items" format
532    ///    - Help: Context-sensitive key bindings
533    ///
534    /// # Performance
535    ///
536    /// The view method only renders items currently visible in the viewport,
537    /// ensuring consistent performance regardless of total item count.
538    ///
539    /// # Examples
540    ///
541    /// ```
542    /// # use bubbletea_widgets::list::{Model, DefaultDelegate, DefaultItem};
543    /// # use bubbletea_rs::Model as BubbleTeaModel;
544    /// let list = Model::new(
545    ///     vec![DefaultItem::new("Item 1", "Description")],
546    ///     DefaultDelegate::new(),
547    ///     80, 24
548    /// );
549    ///
550    /// let output = list.view();
551    /// // Contains formatted list with title, items, and status bar
552    /// ```
553    fn view(&self) -> String {
554        let mut sections = Vec::new();
555
556        // Header: Title or filter input
557        let header = self.view_header();
558        if !header.is_empty() {
559            sections.push(header);
560        }
561
562        // Items: Main content area
563        let items = self.view_items();
564        if !items.is_empty() {
565            sections.push(items);
566        }
567
568        // Spinner: Loading indicator
569        if self.show_spinner {
570            let spinner_view = self.spinner.view();
571            if !spinner_view.is_empty() {
572                sections.push(spinner_view);
573            }
574        }
575
576        // Pagination: Page indicators
577        if self.show_pagination && !self.is_empty() && self.paginator.total_pages > 1 {
578            let pagination_view = self.paginator.view();
579            if !pagination_view.is_empty() {
580                let styled_pagination = self
581                    .styles
582                    .pagination_style
583                    .clone()
584                    .render(&pagination_view);
585                sections.push(styled_pagination);
586            }
587        }
588
589        // Footer: Status and help
590        let footer = self.view_footer();
591        if !footer.is_empty() {
592            sections.push(footer);
593        }
594
595        sections.join("\n")
596    }
597}