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::{self, style::Style, Color};
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 for the default list item in various states.
105#[derive(Debug, Clone)]
106pub struct DefaultItemStyles {
107    /// Title style in normal (unselected) state.
108    pub normal_title: Style,
109    /// Description style in normal (unselected) state.
110    pub normal_desc: Style,
111    /// Title style when the item is selected.
112    pub selected_title: Style,
113    /// Description style when the item is selected.
114    pub selected_desc: Style,
115    /// Title style when the item is dimmed (e.g., during filtering).
116    pub dimmed_title: Style,
117    /// Description style when the item is dimmed.
118    pub dimmed_desc: Style,
119    /// Style used to highlight filter matches.
120    pub filter_match: Style,
121}
122
123impl Default for DefaultItemStyles {
124    fn default() -> Self {
125        let normal_title = Style::new()
126            .foreground(Color::from("#dddddd"))
127            .padding(0, 0, 0, 2);
128        let normal_desc = normal_title.clone().foreground(Color::from("#777777"));
129        let selected_title = Style::new()
130            .border_style(lipgloss::normal_border())
131            .border_left(true)
132            .border_left_foreground(Color::from("#AD58B4"))
133            .foreground(Color::from("#EE6FF8"))
134            .padding(0, 0, 0, 1);
135        let selected_desc = selected_title.clone().foreground(Color::from("#AD58B4"));
136        let dimmed_title = Style::new()
137            .foreground(Color::from("#777777"))
138            .padding(0, 0, 0, 2);
139        let dimmed_desc = dimmed_title.clone().foreground(Color::from("#4D4D4D"));
140        let filter_match = Style::new().underline(true);
141        Self {
142            normal_title,
143            normal_desc,
144            selected_title,
145            selected_desc,
146            dimmed_title,
147            dimmed_desc,
148            filter_match,
149        }
150    }
151}
152
153/// Simple item with a title and optional description.
154#[derive(Debug, Clone)]
155pub struct DefaultItem {
156    /// Main item text.
157    pub title: String,
158    /// Secondary item text (optional display).
159    pub desc: String,
160}
161
162impl DefaultItem {
163    /// Creates a new default item with title and description.
164    pub fn new(title: &str, desc: &str) -> Self {
165        Self {
166            title: title.to_string(),
167            desc: desc.to_string(),
168        }
169    }
170}
171
172impl std::fmt::Display for DefaultItem {
173    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174        write!(f, "{}", self.title)
175    }
176}
177
178impl Item for DefaultItem {
179    fn filter_value(&self) -> String {
180        self.title.clone()
181    }
182}
183
184/// Delegate that renders `DefaultItem` instances.
185#[derive(Debug, Clone)]
186pub struct DefaultDelegate {
187    /// Whether to show the description beneath the title.
188    pub show_description: bool,
189    /// Styling used for different visual states.
190    pub styles: DefaultItemStyles,
191    height: usize,
192    spacing: usize,
193}
194
195impl Default for DefaultDelegate {
196    fn default() -> Self {
197        Self {
198            show_description: true,
199            styles: Default::default(),
200            height: 2,
201            spacing: 1,
202        }
203    }
204}
205impl DefaultDelegate {
206    /// Creates a new delegate with default styles and layout.
207    pub fn new() -> Self {
208        Self::default()
209    }
210}
211
212impl<I: Item + 'static> ItemDelegate<I> for DefaultDelegate {
213    fn render(&self, m: &Model<I>, index: usize, item: &I) -> String {
214        let title = item.to_string();
215        let desc = if let Some(di) = (item as &dyn std::any::Any).downcast_ref::<DefaultItem>() {
216            di.desc.clone()
217        } else {
218            String::new()
219        };
220
221        if m.width == 0 {
222            return String::new();
223        }
224
225        let s = &self.styles;
226        let is_selected = index == m.cursor;
227        let empty_filter =
228            m.filter_state == super::FilterState::Filtering && m.filter_input.value().is_empty();
229        let is_filtered = matches!(
230            m.filter_state,
231            super::FilterState::Filtering | super::FilterState::FilterApplied
232        );
233
234        // Get filter matches for this item if filtering is active
235        let matches = if is_filtered && index < m.filtered_items.len() {
236            Some(&m.filtered_items[index].matches)
237        } else {
238            None
239        };
240
241        let mut title_out = title.clone();
242        let mut desc_out = desc.clone();
243
244        if empty_filter {
245            title_out = s.dimmed_title.clone().render(&title_out);
246            desc_out = s.dimmed_desc.clone().render(&desc_out);
247        } else if is_selected && m.filter_state != super::FilterState::Filtering {
248            // Apply highlighting for selected items
249            if let Some(match_indices) = matches {
250                let highlight_style = s.selected_title.clone().inherit(s.filter_match.clone());
251                title_out = apply_character_highlighting(
252                    &title,
253                    match_indices,
254                    &highlight_style,
255                    &s.selected_title,
256                );
257                if !desc.is_empty() {
258                    let desc_highlight_style =
259                        s.selected_desc.clone().inherit(s.filter_match.clone());
260                    desc_out = apply_character_highlighting(
261                        &desc,
262                        match_indices,
263                        &desc_highlight_style,
264                        &s.selected_desc,
265                    );
266                }
267            } else {
268                title_out = s.selected_title.clone().render(&title_out);
269                desc_out = s.selected_desc.clone().render(&desc_out);
270            }
271        } else {
272            // Apply highlighting for normal (unselected) items
273            if let Some(match_indices) = matches {
274                let highlight_style = s.normal_title.clone().inherit(s.filter_match.clone());
275                title_out = apply_character_highlighting(
276                    &title,
277                    match_indices,
278                    &highlight_style,
279                    &s.normal_title,
280                );
281                if !desc.is_empty() {
282                    let desc_highlight_style =
283                        s.normal_desc.clone().inherit(s.filter_match.clone());
284                    desc_out = apply_character_highlighting(
285                        &desc,
286                        match_indices,
287                        &desc_highlight_style,
288                        &s.normal_desc,
289                    );
290                }
291            } else {
292                title_out = s.normal_title.clone().render(&title_out);
293                desc_out = s.normal_desc.clone().render(&desc_out);
294            }
295        }
296
297        if self.show_description && !desc_out.is_empty() {
298            format!("{}\n{}", title_out, desc_out)
299        } else {
300            title_out
301        }
302    }
303    fn height(&self) -> usize {
304        if self.show_description {
305            self.height
306        } else {
307            1
308        }
309    }
310    fn spacing(&self) -> usize {
311        self.spacing
312    }
313    fn update(&self, _msg: &Msg, _m: &mut Model<I>) -> Option<Cmd> {
314        None
315    }
316}