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