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}