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 ],
253 }
254 }
255
256 /// Returns all key bindings organized into logical groups.
257 ///
258 /// Provides comprehensive help information with bindings grouped by
259 /// functionality. The grouping helps users understand related actions
260 /// and discover advanced features.
261 ///
262 /// # Binding Groups
263 ///
264 /// 1. **Cursor movement**: Up/down navigation
265 /// 2. **Page navigation**: Page up/down, home/end
266 /// 3. **Filtering**: Start filter, clear filter, accept
267 /// 4. **Help and quit**: Show help, quit application
268 fn full_help(&self) -> Vec<Vec<&key::Binding>> {
269 vec![
270 vec![&self.keymap.cursor_up, &self.keymap.cursor_down],
271 vec![&self.keymap.next_page, &self.keymap.prev_page],
272 vec![
273 &self.keymap.go_to_start,
274 &self.keymap.go_to_end,
275 &self.keymap.filter,
276 &self.keymap.clear_filter,
277 ],
278 ]
279 }
280}
281
282// BubbleTeaModel implementation - integrates with bubbletea-rs runtime
283impl<I: Item + Send + Sync + 'static> BubbleTeaModel for Model<I> {
284 /// Initializes a new empty list model with default settings.
285 ///
286 /// This creates a new list with no items, using the default delegate
287 /// and standard dimensions. This method is called by the BubbleTea
288 /// runtime when the model is first created.
289 ///
290 /// # Returns
291 ///
292 /// A tuple containing:
293 /// - The initialized list model with default settings
294 /// - `None` (no initial command to execute)
295 ///
296 /// # Default Configuration
297 ///
298 /// - Empty items list
299 /// - `DefaultDelegate` for rendering
300 /// - 80 columns × 24 rows dimensions
301 /// - Default styling and key bindings
302 fn init() -> (Self, Option<Cmd>) {
303 let model = Self::new(vec![], defaultitem::DefaultDelegate::new(), 80, 24);
304 (model, None)
305 }
306
307 /// Handles keyboard input and state updates.
308 ///
309 /// This method processes all user input and updates the list state accordingly.
310 /// It implements different input handling modes based on the current filtering state:
311 ///
312 /// ## While Filtering (`FilterState::Filtering`)
313 ///
314 /// - **Escape**: Cancel filtering, return to previous state
315 /// - **Enter**: Accept current filter, change to `FilterApplied` state
316 /// - **Characters**: Add to filter text, update results in real-time
317 /// - **Backspace**: Remove characters from filter
318 /// - **Arrow keys**: Navigate filter input cursor position
319 ///
320 /// ## Normal Navigation (other states)
321 ///
322 /// - **Up/Down**: Move cursor through items with smooth viewport scrolling
323 /// - **Home/End**: Jump to first/last item
324 /// - **/** : Start filtering mode
325 /// - **Ctrl+C**: Clear any active filter
326 ///
327 /// # Viewport Management
328 ///
329 /// The update method automatically manages viewport scrolling to ensure
330 /// the cursor remains visible when navigating through items.
331 fn update(&mut self, msg: Msg) -> Option<Cmd> {
332 if self.filter_state == FilterState::Filtering {
333 if let Some(key_msg) = msg.downcast_ref::<KeyMsg>() {
334 match key_msg.key {
335 crossterm::event::KeyCode::Esc => {
336 self.filter_state = if self.filtered_items.is_empty() {
337 FilterState::Unfiltered
338 } else {
339 FilterState::FilterApplied
340 };
341 self.filter_input.blur();
342 return None;
343 }
344 crossterm::event::KeyCode::Enter => {
345 self.apply_filter();
346 self.filter_state = FilterState::FilterApplied;
347 self.filter_input.blur();
348 return None;
349 }
350 crossterm::event::KeyCode::Char(c) => {
351 // Forward character input to the textinput component for proper handling.
352 // Creating a new KeyMsg preserves the original event context while ensuring
353 // the textinput receives all necessary information for features like cursor
354 // positioning, selection, and character encoding.
355 let textinput_msg = Box::new(KeyMsg {
356 key: KeyCode::Char(c),
357 modifiers: key_msg.modifiers,
358 }) as Msg;
359 self.filter_input.update(textinput_msg);
360 self.apply_filter();
361 }
362 crossterm::event::KeyCode::Backspace => {
363 // Forward backspace events to textinput for complete input handling.
364 // The textinput component manages cursor positioning, selection deletion,
365 // and other editing features that require coordinated state management.
366 let textinput_msg = Box::new(KeyMsg {
367 key: KeyCode::Backspace,
368 modifiers: key_msg.modifiers,
369 }) as Msg;
370 self.filter_input.update(textinput_msg);
371 self.apply_filter();
372 }
373 // Handle cursor movement within filter input
374 crossterm::event::KeyCode::Left => {
375 let pos = self.filter_input.position();
376 if pos > 0 {
377 self.filter_input.set_cursor(pos - 1);
378 }
379 }
380 crossterm::event::KeyCode::Right => {
381 let pos = self.filter_input.position();
382 self.filter_input.set_cursor(pos + 1);
383 }
384 crossterm::event::KeyCode::Home => {
385 self.filter_input.cursor_start();
386 }
387 crossterm::event::KeyCode::End => {
388 self.filter_input.cursor_end();
389 }
390 _ => {}
391 }
392 }
393 return None;
394 }
395
396 if let Some(key_msg) = msg.downcast_ref::<KeyMsg>() {
397 if self.keymap.cursor_up.matches(key_msg) {
398 if self.cursor > 0 {
399 self.cursor -= 1;
400 // Synchronize viewport after cursor movement to keep selection visible.
401 // This triggers smooth scrolling when the cursor moves outside the current view.
402 self.sync_viewport_with_cursor();
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 // Synchronize viewport to maintain cursor visibility during navigation.
408 // Enables smooth scrolling instead of discrete page transitions.
409 self.sync_viewport_with_cursor();
410 }
411 } else if self.keymap.go_to_start.matches(key_msg) {
412 self.cursor = 0;
413 // Adjust viewport to show the beginning of the list when jumping to start.
414 self.sync_viewport_with_cursor();
415 } else if self.keymap.go_to_end.matches(key_msg) {
416 self.cursor = self.len().saturating_sub(1);
417 // Adjust viewport to show the end of the list when jumping to last item.
418 self.sync_viewport_with_cursor();
419 } else if self.keymap.filter.matches(key_msg) {
420 self.filter_state = FilterState::Filtering;
421 // Return focus command to enable cursor blinking in filter input
422 return Some(self.filter_input.focus());
423 } else if self.keymap.clear_filter.matches(key_msg) {
424 self.filter_input.set_value("");
425 self.filter_state = FilterState::Unfiltered;
426 self.filtered_items.clear();
427 self.cursor = 0;
428 self.update_pagination();
429 } else if key_msg.key == crossterm::event::KeyCode::Enter {
430 // Handle item selection
431 if let Some(selected_item) = self.selected_item() {
432 // Get the original index for the delegate callback
433 let original_index = if self.filter_state == FilterState::Unfiltered {
434 self.cursor
435 } else if let Some(filtered_item) = self.filtered_items.get(self.cursor) {
436 filtered_item.index
437 } else {
438 return None;
439 };
440
441 // Call the delegate's on_select callback
442 if let Some(cmd) = self.delegate.on_select(original_index, selected_item) {
443 return Some(cmd);
444 }
445 }
446 }
447 }
448 None
449 }
450
451 /// Renders the complete list view as a formatted string.
452 ///
453 /// This method combines all visual components of the list into a single
454 /// string suitable for terminal display. The layout adapts based on the
455 /// current filtering state and available content.
456 ///
457 /// # Returns
458 ///
459 /// A formatted string containing the complete list UI with ANSI styling codes.
460 ///
461 /// # Layout Structure
462 ///
463 /// The view consists of three vertically stacked sections:
464 ///
465 /// 1. **Header**: Title or filter input (depending on state)
466 /// - Normal: "List Title" or "List Title (filtered: N)"
467 /// - Filtering: "Filter: > user_input"
468 ///
469 /// 2. **Items**: The main content area showing visible items
470 /// - Styled according to the current delegate
471 /// - Shows "No items" message when empty
472 /// - Viewport-based rendering for large lists
473 ///
474 /// 3. **Footer**: Status and help information
475 /// - Status: "X/Y items" format
476 /// - Help: Context-sensitive key bindings
477 ///
478 /// # Performance
479 ///
480 /// The view method only renders items currently visible in the viewport,
481 /// ensuring consistent performance regardless of total item count.
482 ///
483 /// # Examples
484 ///
485 /// ```
486 /// # use bubbletea_widgets::list::{Model, DefaultDelegate, DefaultItem};
487 /// # use bubbletea_rs::Model as BubbleTeaModel;
488 /// let list = Model::new(
489 /// vec![DefaultItem::new("Item 1", "Description")],
490 /// DefaultDelegate::new(),
491 /// 80, 24
492 /// );
493 ///
494 /// let output = list.view();
495 /// // Contains formatted list with title, items, and status bar
496 /// ```
497 fn view(&self) -> String {
498 let mut sections = Vec::new();
499
500 // Header: Title or filter input
501 let header = self.view_header();
502 if !header.is_empty() {
503 sections.push(header);
504 }
505
506 // Items: Main content area
507 let items = self.view_items();
508 if !items.is_empty() {
509 sections.push(items);
510 }
511
512 // Spinner: Loading indicator
513 if self.show_spinner {
514 let spinner_view = self.spinner.view();
515 if !spinner_view.is_empty() {
516 sections.push(spinner_view);
517 }
518 }
519
520 // Pagination: Page indicators
521 if self.show_pagination && !self.is_empty() && self.paginator.total_pages > 1 {
522 let pagination_view = self.paginator.view();
523 if !pagination_view.is_empty() {
524 let styled_pagination = self
525 .styles
526 .pagination_style
527 .clone()
528 .render(&pagination_view);
529 sections.push(styled_pagination);
530 }
531 }
532
533 // Footer: Status and help
534 let footer = self.view_footer();
535 if !footer.is_empty() {
536 sections.push(footer);
537 }
538
539 sections.join("\n")
540 }
541}