bubbletea_widgets/list/defaultitem.rs
1//! Default item implementation and delegate for list components.
2//!
3//! This module provides the standard item type and delegate implementation for the list component.
4//! The `DefaultItem` is a simple item with a title and description, while `DefaultDelegate` handles
5//! the rendering and interaction logic for these items.
6//!
7//! ## Default Item Structure
8//!
9//! The `DefaultItem` represents a basic list item with:
10//! - A title (main text)
11//! - A description (secondary text, optional display)
12//!
13//! ## Default Delegate
14//!
15//! The `DefaultDelegate` handles:
16//! - Rendering items with different visual states (normal, selected, dimmed)
17//! - Managing item height and spacing
18//! - Filtering and match highlighting (when implemented)
19//!
20//! ## Styling
21//!
22//! The `DefaultItemStyles` provides comprehensive styling options:
23//! - Normal state styles for title and description
24//! - Selected state styles with borders and highlighting
25//! - Dimmed state styles for filtered-out items
26//! - Filter match highlighting styles
27//!
28//! ## Example
29//!
30//! ```rust
31//! use bubbletea_widgets::list::{DefaultItem, DefaultDelegate};
32//!
33//! let item = DefaultItem::new("Task 1", "Complete the documentation");
34//! let delegate = DefaultDelegate::new();
35//! ```
36
37use super::{Item, ItemDelegate, Model};
38use bubbletea_rs::{Cmd, Msg};
39use lipgloss_extras::prelude::*;
40
41/// Applies segment-based highlighting to a string based on match indices.
42///
43/// This function takes a string and a vector of character indices that should be highlighted,
44/// then applies the given styles to create highlighted and non-highlighted segments.
45/// Unlike character-level highlighting, this groups consecutive match indices into contiguous
46/// segments to avoid ANSI escape sequence insertion between individual characters.
47///
48/// # Arguments
49/// * `text` - The text to apply highlighting to
50/// * `matches` - Vector of character indices that should be highlighted
51/// * `highlight_style` - Style to apply to matched segments
52/// * `normal_style` - Style to apply to non-matched segments
53///
54/// # Returns
55/// A styled string with highlighting applied to contiguous segments
56pub(super) fn apply_character_highlighting(
57 text: &str,
58 matches: &[usize],
59 highlight_style: &Style,
60 normal_style: &Style,
61) -> String {
62 if matches.is_empty() {
63 return normal_style.render(text);
64 }
65
66 let chars: Vec<char> = text.chars().collect();
67 let mut result = String::new();
68
69 // Sort match indices and remove duplicates
70 let mut sorted_matches = matches.to_vec();
71 sorted_matches.sort_unstable();
72 sorted_matches.dedup();
73
74 // Filter out invalid indices
75 let valid_matches: Vec<usize> = sorted_matches
76 .into_iter()
77 .filter(|&idx| idx < chars.len())
78 .collect();
79
80 if valid_matches.is_empty() {
81 return normal_style.render(text);
82 }
83
84 // Group consecutive indices into segments
85 let mut segments: Vec<(usize, usize, bool)> = Vec::new(); // (start, end, is_highlighted)
86 let mut current_pos = 0;
87 let mut i = 0;
88
89 while i < valid_matches.len() {
90 let match_start = valid_matches[i];
91
92 // Add normal segment before this match if needed
93 if current_pos < match_start {
94 segments.push((current_pos, match_start, false));
95 }
96
97 // Find the end of consecutive matches
98 let mut match_end = match_start + 1;
99 while i + 1 < valid_matches.len() && valid_matches[i + 1] == valid_matches[i] + 1 {
100 i += 1;
101 match_end = valid_matches[i] + 1;
102 }
103
104 // Add highlighted segment
105 segments.push((match_start, match_end, true));
106 current_pos = match_end;
107 i += 1;
108 }
109
110 // Add final normal segment if needed
111 if current_pos < chars.len() {
112 segments.push((current_pos, chars.len(), false));
113 }
114
115 // Render each segment with appropriate styling
116 for (start, end, is_highlighted) in segments {
117 let segment: String = chars[start..end].iter().collect();
118 if !segment.is_empty() {
119 if is_highlighted {
120 result.push_str(&highlight_style.render(&segment));
121 } else {
122 result.push_str(&normal_style.render(&segment));
123 }
124 }
125 }
126
127 result
128}
129
130/// Styling configuration for default list items in various visual states.
131///
132/// This struct provides comprehensive styling options for rendering list items
133/// in different states: normal, selected, and dimmed. Each state can have
134/// different styles for both the title and description text.
135///
136/// The styling system uses adaptive colors that automatically adjust to the
137/// terminal's light or dark theme, ensuring optimal readability in any environment.
138///
139/// # Visual States
140///
141/// - **Normal**: Default appearance for unselected items
142/// - **Selected**: Highlighted appearance with left border for the current selection
143/// - **Dimmed**: Faded appearance used during filtering when filter input is empty
144///
145/// # Theme Adaptation
146///
147/// All colors use `AdaptiveColor` which automatically switches between light and
148/// dark variants based on the terminal's background color detection.
149///
150/// # Examples
151///
152/// ```rust
153/// use bubbletea_widgets::list::DefaultItemStyles;
154/// use lipgloss_extras::prelude::*;
155///
156/// // Use default styles with adaptive colors
157/// let styles = DefaultItemStyles::default();
158///
159/// // Customize specific styles
160/// let mut custom_styles = DefaultItemStyles::default();
161/// custom_styles.normal_title = Style::new()
162/// .foreground(AdaptiveColor { Light: "#333333", Dark: "#FFFFFF" })
163/// .bold(true);
164/// ```
165#[derive(Debug, Clone)]
166pub struct DefaultItemStyles {
167 /// Title style in normal (unselected) state.
168 pub normal_title: Style,
169 /// Description style in normal (unselected) state.
170 pub normal_desc: Style,
171 /// Title style when the item is selected.
172 pub selected_title: Style,
173 /// Description style when the item is selected.
174 pub selected_desc: Style,
175 /// Title style when the item is dimmed (e.g., during filtering).
176 pub dimmed_title: Style,
177 /// Description style when the item is dimmed.
178 pub dimmed_desc: Style,
179 /// Style used to highlight filter matches.
180 pub filter_match: Style,
181}
182
183impl Default for DefaultItemStyles {
184 /// Creates default styling that matches the Go bubbles library appearance.
185 ///
186 /// The default styles provide a clean, professional appearance with:
187 /// - Adaptive colors that work in both light and dark terminals
188 /// - Left border highlighting for selected items
189 /// - Consistent padding and typography
190 /// - Subtle dimming for filtered states
191 ///
192 /// # Theme Colors
193 ///
194 /// - **Normal text**: Dark text on light backgrounds, light text on dark backgrounds
195 /// - **Selected items**: Purple accent with left border
196 /// - **Dimmed items**: Muted colors during filtering
197 ///
198 /// # Examples
199 ///
200 /// ```rust
201 /// use bubbletea_widgets::list::DefaultItemStyles;
202 ///
203 /// let styles = DefaultItemStyles::default();
204 /// // Styles are now ready to use with adaptive colors
205 /// ```
206 fn default() -> Self {
207 let normal_title = Style::new()
208 .foreground(AdaptiveColor {
209 Light: "#1a1a1a",
210 Dark: "#dddddd",
211 })
212 .padding(0, 0, 0, 2);
213 let normal_desc = Style::new()
214 .foreground(AdaptiveColor {
215 Light: "#A49FA5",
216 Dark: "#777777",
217 })
218 .padding(0, 0, 0, 2);
219 let selected_title = Style::new()
220 .border_style(normal_border())
221 .border_top(false)
222 .border_right(false)
223 .border_bottom(false)
224 .border_left(true)
225 .border_left_foreground(AdaptiveColor {
226 Light: "#F793FF",
227 Dark: "#AD58B4",
228 })
229 .foreground(AdaptiveColor {
230 Light: "#EE6FF8",
231 Dark: "#EE6FF8",
232 })
233 .padding(0, 0, 0, 1);
234 let selected_desc = selected_title.clone().foreground(AdaptiveColor {
235 Light: "#F793FF",
236 Dark: "#AD58B4",
237 });
238 let dimmed_title = Style::new()
239 .foreground(AdaptiveColor {
240 Light: "#A49FA5",
241 Dark: "#777777",
242 })
243 .padding(0, 0, 0, 2);
244 let dimmed_desc = Style::new()
245 .foreground(AdaptiveColor {
246 Light: "#C2B8C2",
247 Dark: "#4D4D4D",
248 })
249 .padding(0, 0, 0, 2);
250 let filter_match = Style::new().underline(true);
251 Self {
252 normal_title,
253 normal_desc,
254 selected_title,
255 selected_desc,
256 dimmed_title,
257 dimmed_desc,
258 filter_match,
259 }
260 }
261}
262
263/// A simple list item with title and description text.
264///
265/// This struct represents a basic list item that can be used with the `DefaultDelegate`
266/// for rendering in list components. It provides a straightforward implementation of the
267/// `Item` trait with built-in support for filtering and display formatting.
268///
269/// # Structure
270///
271/// Each `DefaultItem` contains:
272/// - A **title**: The primary text displayed prominently
273/// - A **description**: Secondary text shown below the title (when enabled)
274///
275/// Both fields are always present but the description display can be controlled
276/// by the delegate's `show_description` setting.
277///
278/// # Usage
279///
280/// `DefaultItem` is designed to work seamlessly with `DefaultDelegate` and provides
281/// sensible defaults for most list use cases. For more complex item types with
282/// custom data, implement the `Item` trait directly.
283///
284/// # Examples
285///
286/// ```rust
287/// use bubbletea_widgets::list::DefaultItem;
288///
289/// // Create a simple item
290/// let item = DefaultItem::new("Task 1", "Complete the documentation");
291/// println!("{}", item); // Displays: "Task 1"
292///
293/// // Create items for a todo list
294/// let todos = vec![
295/// DefaultItem::new("Buy groceries", "Milk, bread, eggs"),
296/// DefaultItem::new("Write code", "Implement the new feature"),
297/// DefaultItem::new("Review PRs", "Check team submissions"),
298/// ];
299/// ```
300#[derive(Debug, Clone)]
301pub struct DefaultItem {
302 /// Main item text.
303 pub title: String,
304 /// Secondary item text (optional display).
305 pub desc: String,
306}
307
308impl DefaultItem {
309 /// Creates a new default item with the specified title and description.
310 ///
311 /// This constructor creates a new `DefaultItem` with the provided title and description
312 /// text. Both parameters are converted to owned `String` values for storage.
313 ///
314 /// # Arguments
315 ///
316 /// * `title` - The primary text to display for this item. This will be shown
317 /// prominently and is used for filtering operations.
318 /// * `desc` - The secondary descriptive text. This provides additional context
319 /// and is displayed below the title when `show_description` is enabled.
320 ///
321 /// # Returns
322 ///
323 /// A new `DefaultItem` instance with the specified title and description.
324 ///
325 /// # Examples
326 ///
327 /// ```rust
328 /// use bubbletea_widgets::list::DefaultItem;
329 ///
330 /// // Create a task item
331 /// let task = DefaultItem::new("Review code", "Check the pull request from yesterday");
332 ///
333 /// // Create a menu item
334 /// let menu_item = DefaultItem::new("Settings", "Configure application preferences");
335 ///
336 /// // Create an item with empty description
337 /// let simple_item = DefaultItem::new("Simple Item", "");
338 /// ```
339 pub fn new(title: &str, desc: &str) -> Self {
340 Self {
341 title: title.to_string(),
342 desc: desc.to_string(),
343 }
344 }
345}
346
347impl std::fmt::Display for DefaultItem {
348 /// Formats the item for display, showing only the title.
349 ///
350 /// This implementation provides a string representation of the item
351 /// using only the title field. The description is not included in
352 /// the display output, following the pattern where descriptions are
353 /// shown separately in list rendering.
354 ///
355 /// # Examples
356 ///
357 /// ```rust
358 /// use bubbletea_widgets::list::DefaultItem;
359 ///
360 /// let item = DefaultItem::new("My Task", "This is a description");
361 /// assert_eq!(format!("{}", item), "My Task");
362 /// ```
363 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
364 write!(f, "{}", self.title)
365 }
366}
367
368impl Item for DefaultItem {
369 /// Returns the text used for filtering this item.
370 ///
371 /// This implementation returns the item's title, which means that
372 /// filtering operations will search and match against the title text.
373 /// The description is not included in filtering to keep the search
374 /// focused on the primary item identifier.
375 ///
376 /// # Returns
377 ///
378 /// A clone of the item's title string that will be used for fuzzy
379 /// matching during filtering operations.
380 ///
381 /// # Examples
382 ///
383 /// ```rust
384 /// use bubbletea_widgets::list::{DefaultItem, Item};
385 ///
386 /// let item = DefaultItem::new("Buy groceries", "Milk, bread, eggs");
387 /// assert_eq!(item.filter_value(), "Buy groceries");
388 ///
389 /// // The filter will match against "Buy groceries", not the description
390 /// ```
391 fn filter_value(&self) -> String {
392 self.title.clone()
393 }
394}
395
396/// A delegate for rendering `DefaultItem` instances in list components.
397///
398/// This delegate provides the standard rendering logic for `DefaultItem` objects,
399/// handling different visual states, filtering highlights, and layout options.
400/// It implements the `ItemDelegate` trait to integrate seamlessly with the list
401/// component system.
402///
403/// # Features
404///
405/// - **Adaptive styling**: Automatically adjusts colors for light/dark terminals
406/// - **State rendering**: Handles normal, selected, and dimmed visual states
407/// - **Filter highlighting**: Character-level highlighting of search matches
408/// - **Flexible layout**: Configurable description display and item spacing
409/// - **Responsive design**: Adjusts rendering based on available width
410///
411/// # Configuration
412///
413/// The delegate can be customized through its public fields:
414/// - `show_description`: Controls whether descriptions are rendered below titles
415/// - `styles`: Complete styling configuration for all visual states
416///
417/// # Usage with List
418///
419/// The delegate is designed to work with the `Model<DefaultItem>` list component
420/// and handles all the rendering complexity automatically.
421///
422/// # Examples
423///
424/// ```rust
425/// use bubbletea_widgets::list::{DefaultDelegate, DefaultItem, Model};
426///
427/// // Create a delegate with default settings
428/// let delegate = DefaultDelegate::new();
429///
430/// // Customize the delegate
431/// let mut custom_delegate = DefaultDelegate::new();
432/// custom_delegate.show_description = false; // Hide descriptions
433///
434/// // Use with a list
435/// let items = vec![
436/// DefaultItem::new("Task 1", "First task description"),
437/// DefaultItem::new("Task 2", "Second task description"),
438/// ];
439/// let list = Model::new(items, delegate, 80, 24);
440/// ```
441#[derive(Debug, Clone)]
442pub struct DefaultDelegate {
443 /// Whether to show the description beneath the title.
444 pub show_description: bool,
445 /// Styling used for different visual states.
446 pub styles: DefaultItemStyles,
447 height: usize,
448 spacing: usize,
449}
450
451impl Default for DefaultDelegate {
452 /// Creates a new delegate with default configuration.
453 ///
454 /// The default delegate is configured with:
455 /// - Description display enabled
456 /// - Standard adaptive styling
457 /// - Height of 2 lines (title + description)
458 /// - 1 line spacing between items
459 ///
460 /// This configuration provides a standard list appearance that matches
461 /// the Go bubbles library defaults.
462 ///
463 /// # Examples
464 ///
465 /// ```rust
466 /// use bubbletea_widgets::list::DefaultDelegate;
467 ///
468 /// let delegate = DefaultDelegate::default();
469 /// assert_eq!(delegate.show_description, true);
470 /// ```
471 fn default() -> Self {
472 Self {
473 show_description: true,
474 styles: Default::default(),
475 height: 2,
476 spacing: 1,
477 }
478 }
479}
480impl DefaultDelegate {
481 /// Creates a new delegate with default styles and layout.
482 ///
483 /// This is equivalent to `DefaultDelegate::default()` and provides a convenient
484 /// constructor for creating a new delegate with standard settings.
485 ///
486 /// # Returns
487 ///
488 /// A new `DefaultDelegate` configured with default settings suitable for
489 /// most list use cases.
490 ///
491 /// # Examples
492 ///
493 /// ```rust
494 /// use bubbletea_widgets::list::{DefaultDelegate, DefaultItem, Model};
495 ///
496 /// let delegate = DefaultDelegate::new();
497 /// let items = vec![DefaultItem::new("Item 1", "Description 1")];
498 /// let list = Model::new(items, delegate, 80, 24);
499 /// ```
500 pub fn new() -> Self {
501 Self::default()
502 }
503}
504
505impl<I: Item + 'static> ItemDelegate<I> for DefaultDelegate {
506 /// Renders an item as a styled string for display in the list.
507 ///
508 /// This method handles the complete rendering pipeline for list items, including:
509 /// - State detection (normal, selected, dimmed)
510 /// - Filter highlighting with character-level precision
511 /// - Style application based on current state
512 /// - Layout formatting (single-line or with description)
513 ///
514 /// The rendering adapts to the current list state, applying different styles
515 /// for selected items, dimmed items during filtering, and highlighting
516 /// characters that match the current filter.
517 ///
518 /// # Arguments
519 ///
520 /// * `m` - The list model containing state information
521 /// * `index` - The index of this item in the list
522 /// * `item` - The item to render
523 ///
524 /// # Returns
525 ///
526 /// A formatted string with ANSI styling codes that represents the visual
527 /// appearance of the item. Returns an empty string if the list width is 0.
528 ///
529 /// # Visual States
530 ///
531 /// - **Normal**: Standard appearance for unselected items
532 /// - **Selected**: Highlighted with left border and accent colors
533 /// - **Dimmed**: Faded appearance when filtering with empty input
534 /// - **Filtered**: Normal or selected appearance with character highlights
535 fn render(&self, m: &Model<I>, index: usize, item: &I) -> String {
536 let title = item.to_string();
537 let desc = if let Some(di) = (item as &dyn std::any::Any).downcast_ref::<DefaultItem>() {
538 di.desc.clone()
539 } else {
540 String::new()
541 };
542
543 if m.width == 0 {
544 return String::new();
545 }
546
547 let s = &self.styles;
548 let is_selected = index == m.cursor;
549 let empty_filter =
550 m.filter_state == super::FilterState::Filtering && m.filter_input.value().is_empty();
551 let is_filtered = matches!(
552 m.filter_state,
553 super::FilterState::Filtering | super::FilterState::FilterApplied
554 );
555
556 // Get filter matches for this item if filtering is active
557 let matches = if is_filtered && index < m.filtered_items.len() {
558 Some(&m.filtered_items[index].matches)
559 } else {
560 None
561 };
562
563 let mut title_out = title.clone();
564 let mut desc_out = desc.clone();
565
566 if empty_filter {
567 title_out = s.dimmed_title.clone().render(&title_out);
568 desc_out = s.dimmed_desc.clone().render(&desc_out);
569 } else if is_selected && m.filter_state != super::FilterState::Filtering {
570 // Apply highlighting for selected items
571 if let Some(match_indices) = matches {
572 let highlight_style = s.selected_title.clone().inherit(s.filter_match.clone());
573 title_out = apply_character_highlighting(
574 &title,
575 match_indices,
576 &highlight_style,
577 &s.selected_title,
578 );
579 if !desc.is_empty() {
580 let desc_highlight_style =
581 s.selected_desc.clone().inherit(s.filter_match.clone());
582 desc_out = apply_character_highlighting(
583 &desc,
584 match_indices,
585 &desc_highlight_style,
586 &s.selected_desc,
587 );
588 }
589 } else {
590 title_out = s.selected_title.clone().render(&title_out);
591 desc_out = s.selected_desc.clone().render(&desc_out);
592 }
593 } else {
594 // Apply highlighting for normal (unselected) items
595 if let Some(match_indices) = matches {
596 let highlight_style = s.normal_title.clone().inherit(s.filter_match.clone());
597 title_out = apply_character_highlighting(
598 &title,
599 match_indices,
600 &highlight_style,
601 &s.normal_title,
602 );
603 if !desc.is_empty() {
604 let desc_highlight_style =
605 s.normal_desc.clone().inherit(s.filter_match.clone());
606 desc_out = apply_character_highlighting(
607 &desc,
608 match_indices,
609 &desc_highlight_style,
610 &s.normal_desc,
611 );
612 }
613 } else {
614 title_out = s.normal_title.clone().render(&title_out);
615 desc_out = s.normal_desc.clone().render(&desc_out);
616 }
617 }
618
619 if self.show_description && !desc_out.is_empty() {
620 format!("{}\n{}", title_out, desc_out)
621 } else {
622 title_out
623 }
624 }
625 /// Returns the height in lines that each item occupies.
626 ///
627 /// The height depends on whether descriptions are enabled:
628 /// - With descriptions: Returns the configured height (default 2 lines)
629 /// - Without descriptions: Always returns 1 line
630 ///
631 /// This height is used by the list component for layout calculations,
632 /// viewport sizing, and scroll positioning.
633 ///
634 /// # Returns
635 ///
636 /// The number of terminal lines each item will occupy when rendered.
637 fn height(&self) -> usize {
638 if self.show_description {
639 self.height
640 } else {
641 1
642 }
643 }
644 /// Returns the number of blank lines between items.
645 ///
646 /// This spacing is added between each item in the list to improve
647 /// readability and visual separation. The default spacing is 1 line.
648 ///
649 /// # Returns
650 ///
651 /// The number of blank lines to insert between rendered items.
652 fn spacing(&self) -> usize {
653 self.spacing
654 }
655 /// Handles update messages for the delegate.
656 ///
657 /// The default delegate implementation does not require any message handling,
658 /// so this method always returns `None`. Override this method in custom
659 /// delegates that need to respond to keyboard input, timer events, or
660 /// other application messages.
661 ///
662 /// # Arguments
663 ///
664 /// * `_msg` - The message to handle (unused in default implementation)
665 /// * `_m` - Mutable reference to the list model (unused in default implementation)
666 ///
667 /// # Returns
668 ///
669 /// Always returns `None` as the default delegate requires no update commands.
670 fn update(&self, _msg: &Msg, _m: &mut Model<I>) -> Option<Cmd> {
671 None
672 }
673}