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}