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                // Create border-free versions of selected styles for character highlighting
573                // to prevent pipe character insertion between segments
574                let selected_title_no_border = s
575                    .selected_title
576                    .clone()
577                    .border_top(false)
578                    .border_right(false)
579                    .border_bottom(false)
580                    .border_left(false);
581                let selected_desc_no_border = s
582                    .selected_desc
583                    .clone()
584                    .border_top(false)
585                    .border_right(false)
586                    .border_bottom(false)
587                    .border_left(false);
588
589                let highlight_style = selected_title_no_border
590                    .clone()
591                    .inherit(s.filter_match.clone());
592                title_out = apply_character_highlighting(
593                    &title,
594                    match_indices,
595                    &highlight_style,
596                    &selected_title_no_border,
597                );
598                if !desc.is_empty() {
599                    let desc_highlight_style = selected_desc_no_border
600                        .clone()
601                        .inherit(s.filter_match.clone());
602                    desc_out = apply_character_highlighting(
603                        &desc,
604                        match_indices,
605                        &desc_highlight_style,
606                        &selected_desc_no_border,
607                    );
608                }
609
610                // For highlighted items, we need to apply the border separately
611                // since the highlighting already applied the appropriate colors
612                let border_only_style = Style::new()
613                    .border_style(normal_border())
614                    .border_top(false)
615                    .border_right(false)
616                    .border_bottom(false)
617                    .border_left(true)
618                    .border_left_foreground(AdaptiveColor {
619                        Light: "#F793FF",
620                        Dark: "#AD58B4",
621                    })
622                    .padding(0, 0, 0, 1);
623
624                title_out = border_only_style.render(&title_out);
625                if !desc.is_empty() {
626                    desc_out = border_only_style.render(&desc_out);
627                }
628            } else {
629                title_out = s.selected_title.clone().render(&title_out);
630                desc_out = s.selected_desc.clone().render(&desc_out);
631            }
632        } else {
633            // Apply highlighting for normal (unselected) items
634            if let Some(match_indices) = matches {
635                let highlight_style = s.normal_title.clone().inherit(s.filter_match.clone());
636                title_out = apply_character_highlighting(
637                    &title,
638                    match_indices,
639                    &highlight_style,
640                    &s.normal_title,
641                );
642                if !desc.is_empty() {
643                    let desc_highlight_style =
644                        s.normal_desc.clone().inherit(s.filter_match.clone());
645                    desc_out = apply_character_highlighting(
646                        &desc,
647                        match_indices,
648                        &desc_highlight_style,
649                        &s.normal_desc,
650                    );
651                }
652            } else {
653                title_out = s.normal_title.clone().render(&title_out);
654                desc_out = s.normal_desc.clone().render(&desc_out);
655            }
656        }
657
658        if self.show_description && !desc_out.is_empty() {
659            format!("{}\n{}", title_out, desc_out)
660        } else {
661            title_out
662        }
663    }
664    /// Returns the height in lines that each item occupies.
665    ///
666    /// The height depends on whether descriptions are enabled:
667    /// - With descriptions: Returns the configured height (default 2 lines)
668    /// - Without descriptions: Always returns 1 line
669    ///
670    /// This height is used by the list component for layout calculations,
671    /// viewport sizing, and scroll positioning.
672    ///
673    /// # Returns
674    ///
675    /// The number of terminal lines each item will occupy when rendered.
676    fn height(&self) -> usize {
677        if self.show_description {
678            self.height
679        } else {
680            1
681        }
682    }
683    /// Returns the number of blank lines between items.
684    ///
685    /// This spacing is added between each item in the list to improve
686    /// readability and visual separation. The default spacing is 1 line.
687    ///
688    /// # Returns
689    ///
690    /// The number of blank lines to insert between rendered items.
691    fn spacing(&self) -> usize {
692        self.spacing
693    }
694    /// Handles update messages for the delegate.
695    ///
696    /// The default delegate implementation does not require any message handling,
697    /// so this method always returns `None`. Override this method in custom
698    /// delegates that need to respond to keyboard input, timer events, or
699    /// other application messages.
700    ///
701    /// # Arguments
702    ///
703    /// * `_msg` - The message to handle (unused in default implementation)
704    /// * `_m` - Mutable reference to the list model (unused in default implementation)
705    ///
706    /// # Returns
707    ///
708    /// Always returns `None` as the default delegate requires no update commands.
709    fn update(&self, _msg: &Msg, _m: &mut Model<I>) -> Option<Cmd> {
710        None
711    }
712}