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//! ## Filter Highlighting Architecture
8//!
9//! This module implements a sophisticated filter highlighting system that avoids common ANSI
10//! rendering issues. The key insight is that lipgloss styles with padding/borders cause spacing
11//! problems when applied to individual text segments.
12//!
13//! ### The Problem
14//! When highlighting individual characters in filtered text, naively applying styles with padding
15//! results in extra spaces between segments:
16//! - Input: "Nutella" with 'N' highlighted
17//! - Broken: "│ N utella" (space between N and utella)
18//! - Correct: "│ Nutella" (seamless text)
19//!
20//! ### The Solution
21//! 1. **Segment-Based Highlighting**: Group consecutive match indices to minimize ANSI sequences
22//! 2. **Clean Styles**: Use styles without padding/borders for `apply_character_highlighting`
23//! 3. **Manual Layout**: Apply borders and padding manually after highlighting is complete
24//!
25//! ### Visual States
26//! - **Selected Items**: Left border (│) + 1 space + highlighted text
27//! - **Unselected Items**: 2 spaces + highlighted text (no border)
28//! - **Dimmed Items**: Faded colors when filter input is empty
29//!
30//! This approach ensures perfect text rendering while maintaining the visual hierarchy.
31//!
32//! ## Default Item Structure
33//!
34//! The `DefaultItem` represents a basic list item with:
35//! - A title (main text)
36//! - A description (secondary text, optional display)
37//!
38//! ## Default Delegate
39//!
40//! The `DefaultDelegate` handles:
41//! - Rendering items with different visual states (normal, selected, dimmed)
42//! - Managing item height and spacing
43//! - Complex filter highlighting with seamless text rendering
44//!
45//! ## Styling
46//!
47//! The `DefaultItemStyles` provides comprehensive styling options:
48//! - Normal state styles for title and description
49//! - Selected state styles with borders and highlighting
50//! - Dimmed state styles for filtered-out items
51//! - Filter match highlighting styles
52//!
53//! ## Example
54//!
55//! ```rust
56//! use bubbletea_widgets::list::{DefaultItem, DefaultDelegate};
57//!
58//! let item = DefaultItem::new("Task 1", "Complete the documentation");
59//! let delegate = DefaultDelegate::new();
60//! ```
61
62use super::{Item, ItemDelegate, Model};
63use bubbletea_rs::{Cmd, Msg};
64use lipgloss_extras::prelude::*;
65
66/// Applies segment-based highlighting to a string based on match indices.
67///
68/// This function is the core of the filter highlighting system. It takes a string and character
69/// indices that should be highlighted, then applies styles to create highlighted and non-highlighted
70/// segments. The key insight is to group consecutive match indices into contiguous segments rather
71/// than styling individual characters.
72///
73/// ## Why Segment-Based Highlighting?
74///
75/// Character-by-character highlighting would create excessive ANSI escape sequences:
76/// - "Nutella" with matches [0,1,2] would become: `[style]N[reset][style]u[reset][style]t[reset]ella`
77/// - Segment-based approach creates: `[style]Nut[reset]ella` (much more efficient)
78///
79/// ## Critical Implementation Detail
80///
81/// The styles passed to this function MUST NOT contain padding or borders, as lipgloss will
82/// apply padding to each individual segment, causing extra spaces between segments. For example:
83/// - ❌ With padding: "N utella" (space inserted between segments)
84/// - ✅ Without padding: "Nutella" (segments render seamlessly)
85///
86/// Border and padding should be applied manually AFTER this function returns.
87///
88/// # Arguments
89/// * `text` - The text to apply highlighting to
90/// * `matches` - Vector of character indices that should be highlighted (from fuzzy matching)
91/// * `highlight_style` - Style to apply to matched segments (should have NO padding/borders)
92/// * `normal_style` - Style to apply to non-matched segments (should have NO padding/borders)
93///
94/// # Returns
95/// A styled string with highlighting applied to contiguous segments, ready for border/padding application
96pub(super) fn apply_character_highlighting(
97 text: &str,
98 matches: &[usize],
99 highlight_style: &Style,
100 normal_style: &Style,
101) -> String {
102 if matches.is_empty() {
103 return normal_style.render(text);
104 }
105
106 let chars: Vec<char> = text.chars().collect();
107 let mut result = String::new();
108
109 // Sort match indices and remove duplicates
110 let mut sorted_matches = matches.to_vec();
111 sorted_matches.sort_unstable();
112 sorted_matches.dedup();
113
114 // Filter out invalid indices
115 let valid_matches: Vec<usize> = sorted_matches
116 .into_iter()
117 .filter(|&idx| idx < chars.len())
118 .collect();
119
120 if valid_matches.is_empty() {
121 return normal_style.render(text);
122 }
123
124 // Group consecutive indices into segments
125 let mut segments: Vec<(usize, usize, bool)> = Vec::new(); // (start, end, is_highlighted)
126 let mut current_pos = 0;
127 let mut i = 0;
128
129 while i < valid_matches.len() {
130 let match_start = valid_matches[i];
131
132 // Add normal segment before this match if needed
133 if current_pos < match_start {
134 segments.push((current_pos, match_start, false));
135 }
136
137 // Find the end of consecutive matches
138 let mut match_end = match_start + 1;
139 while i + 1 < valid_matches.len() && valid_matches[i + 1] == valid_matches[i] + 1 {
140 i += 1;
141 match_end = valid_matches[i] + 1;
142 }
143
144 // Add highlighted segment
145 segments.push((match_start, match_end, true));
146 current_pos = match_end;
147 i += 1;
148 }
149
150 // Add final normal segment if needed
151 if current_pos < chars.len() {
152 segments.push((current_pos, chars.len(), false));
153 }
154
155 // Render each segment with appropriate styling
156 for (start, end, is_highlighted) in segments {
157 let segment: String = chars[start..end].iter().collect();
158 if !segment.is_empty() {
159 if is_highlighted {
160 result.push_str(&highlight_style.render(&segment));
161 } else {
162 result.push_str(&normal_style.render(&segment));
163 }
164 }
165 }
166
167 result
168}
169
170/// Styling configuration for default list items in various visual states.
171///
172/// This struct provides comprehensive styling options for rendering list items
173/// in different states: normal, selected, and dimmed. Each state can have
174/// different styles for both the title and description text.
175///
176/// The styling system uses adaptive colors that automatically adjust to the
177/// terminal's light or dark theme, ensuring optimal readability in any environment.
178///
179/// # Visual States
180///
181/// - **Normal**: Default appearance for unselected items
182/// - **Selected**: Highlighted appearance with left border for the current selection
183/// - **Dimmed**: Faded appearance used during filtering when filter input is empty
184///
185/// # Theme Adaptation
186///
187/// All colors use `AdaptiveColor` which automatically switches between light and
188/// dark variants based on the terminal's background color detection.
189///
190/// # Examples
191///
192/// ```rust
193/// use bubbletea_widgets::list::DefaultItemStyles;
194/// use lipgloss_extras::prelude::*;
195///
196/// // Use default styles with adaptive colors
197/// let styles = DefaultItemStyles::default();
198///
199/// // Customize specific styles
200/// let mut custom_styles = DefaultItemStyles::default();
201/// custom_styles.normal_title = Style::new()
202/// .foreground(AdaptiveColor { Light: "#333333", Dark: "#FFFFFF" })
203/// .bold(true);
204/// ```
205#[derive(Debug, Clone)]
206pub struct DefaultItemStyles {
207 /// Title style in normal (unselected) state.
208 pub normal_title: Style,
209 /// Description style in normal (unselected) state.
210 pub normal_desc: Style,
211 /// Title style when the item is selected.
212 pub selected_title: Style,
213 /// Description style when the item is selected.
214 pub selected_desc: Style,
215 /// Title style when the item is dimmed (e.g., during filtering).
216 pub dimmed_title: Style,
217 /// Description style when the item is dimmed.
218 pub dimmed_desc: Style,
219 /// Style used to highlight filter matches.
220 pub filter_match: Style,
221}
222
223impl Default for DefaultItemStyles {
224 /// Creates default styling that matches the Go bubbles library appearance.
225 ///
226 /// The default styles provide a clean, professional appearance with:
227 /// - Adaptive colors that work in both light and dark terminals
228 /// - Left border highlighting for selected items
229 /// - Consistent padding and typography
230 /// - Subtle dimming for filtered states
231 ///
232 /// # Theme Colors
233 ///
234 /// - **Normal text**: Dark text on light backgrounds, light text on dark backgrounds
235 /// - **Selected items**: Purple accent with left border
236 /// - **Dimmed items**: Muted colors during filtering
237 ///
238 /// # Examples
239 ///
240 /// ```rust
241 /// use bubbletea_widgets::list::DefaultItemStyles;
242 ///
243 /// let styles = DefaultItemStyles::default();
244 /// // Styles are now ready to use with adaptive colors
245 /// ```
246 fn default() -> Self {
247 let normal_title = Style::new()
248 .foreground(AdaptiveColor {
249 Light: "#1a1a1a",
250 Dark: "#dddddd",
251 })
252 .padding(0, 0, 0, 2);
253 let normal_desc = Style::new()
254 .foreground(AdaptiveColor {
255 Light: "#A49FA5",
256 Dark: "#777777",
257 })
258 .padding(0, 0, 0, 2);
259 let selected_title = Style::new()
260 .border_style(normal_border())
261 .border_top(false)
262 .border_right(false)
263 .border_bottom(false)
264 .border_left(true)
265 .border_left_foreground(AdaptiveColor {
266 Light: "#F793FF",
267 Dark: "#AD58B4",
268 })
269 .foreground(AdaptiveColor {
270 Light: "#EE6FF8",
271 Dark: "#EE6FF8",
272 })
273 .padding(0, 0, 0, 1);
274 let selected_desc = selected_title.clone().foreground(AdaptiveColor {
275 Light: "#F793FF",
276 Dark: "#AD58B4",
277 });
278 let dimmed_title = Style::new()
279 .foreground(AdaptiveColor {
280 Light: "#A49FA5",
281 Dark: "#777777",
282 })
283 .padding(0, 0, 0, 2);
284 let dimmed_desc = Style::new()
285 .foreground(AdaptiveColor {
286 Light: "#C2B8C2",
287 Dark: "#4D4D4D",
288 })
289 .padding(0, 0, 0, 2);
290 let filter_match = Style::new().underline(true);
291 Self {
292 normal_title,
293 normal_desc,
294 selected_title,
295 selected_desc,
296 dimmed_title,
297 dimmed_desc,
298 filter_match,
299 }
300 }
301}
302
303/// A simple list item with title and description text.
304///
305/// This struct represents a basic list item that can be used with the `DefaultDelegate`
306/// for rendering in list components. It provides a straightforward implementation of the
307/// `Item` trait with built-in support for filtering and display formatting.
308///
309/// # Structure
310///
311/// Each `DefaultItem` contains:
312/// - A **title**: The primary text displayed prominently
313/// - A **description**: Secondary text shown below the title (when enabled)
314///
315/// Both fields are always present but the description display can be controlled
316/// by the delegate's `show_description` setting.
317///
318/// # Usage
319///
320/// `DefaultItem` is designed to work seamlessly with `DefaultDelegate` and provides
321/// sensible defaults for most list use cases. For more complex item types with
322/// custom data, implement the `Item` trait directly.
323///
324/// # Examples
325///
326/// ```rust
327/// use bubbletea_widgets::list::DefaultItem;
328///
329/// // Create a simple item
330/// let item = DefaultItem::new("Task 1", "Complete the documentation");
331/// println!("{}", item); // Displays: "Task 1"
332///
333/// // Create items for a todo list
334/// let todos = vec![
335/// DefaultItem::new("Buy groceries", "Milk, bread, eggs"),
336/// DefaultItem::new("Write code", "Implement the new feature"),
337/// DefaultItem::new("Review PRs", "Check team submissions"),
338/// ];
339/// ```
340#[derive(Debug, Clone)]
341pub struct DefaultItem {
342 /// Main item text.
343 pub title: String,
344 /// Secondary item text (optional display).
345 pub desc: String,
346}
347
348impl DefaultItem {
349 /// Creates a new default item with the specified title and description.
350 ///
351 /// This constructor creates a new `DefaultItem` with the provided title and description
352 /// text. Both parameters are converted to owned `String` values for storage.
353 ///
354 /// # Arguments
355 ///
356 /// * `title` - The primary text to display for this item. This will be shown
357 /// prominently and is used for filtering operations.
358 /// * `desc` - The secondary descriptive text. This provides additional context
359 /// and is displayed below the title when `show_description` is enabled.
360 ///
361 /// # Returns
362 ///
363 /// A new `DefaultItem` instance with the specified title and description.
364 ///
365 /// # Examples
366 ///
367 /// ```rust
368 /// use bubbletea_widgets::list::DefaultItem;
369 ///
370 /// // Create a task item
371 /// let task = DefaultItem::new("Review code", "Check the pull request from yesterday");
372 ///
373 /// // Create a menu item
374 /// let menu_item = DefaultItem::new("Settings", "Configure application preferences");
375 ///
376 /// // Create an item with empty description
377 /// let simple_item = DefaultItem::new("Simple Item", "");
378 /// ```
379 pub fn new(title: &str, desc: &str) -> Self {
380 Self {
381 title: title.to_string(),
382 desc: desc.to_string(),
383 }
384 }
385}
386
387impl std::fmt::Display for DefaultItem {
388 /// Formats the item for display, showing only the title.
389 ///
390 /// This implementation provides a string representation of the item
391 /// using only the title field. The description is not included in
392 /// the display output, following the pattern where descriptions are
393 /// shown separately in list rendering.
394 ///
395 /// # Examples
396 ///
397 /// ```rust
398 /// use bubbletea_widgets::list::DefaultItem;
399 ///
400 /// let item = DefaultItem::new("My Task", "This is a description");
401 /// assert_eq!(format!("{}", item), "My Task");
402 /// ```
403 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
404 write!(f, "{}", self.title)
405 }
406}
407
408impl Item for DefaultItem {
409 /// Returns the text used for filtering this item.
410 ///
411 /// This implementation returns the item's title, which means that
412 /// filtering operations will search and match against the title text.
413 /// The description is not included in filtering to keep the search
414 /// focused on the primary item identifier.
415 ///
416 /// # Returns
417 ///
418 /// A clone of the item's title string that will be used for fuzzy
419 /// matching during filtering operations.
420 ///
421 /// # Examples
422 ///
423 /// ```rust
424 /// use bubbletea_widgets::list::{DefaultItem, Item};
425 ///
426 /// let item = DefaultItem::new("Buy groceries", "Milk, bread, eggs");
427 /// assert_eq!(item.filter_value(), "Buy groceries");
428 ///
429 /// // The filter will match against "Buy groceries", not the description
430 /// ```
431 fn filter_value(&self) -> String {
432 self.title.clone()
433 }
434}
435
436/// A delegate for rendering `DefaultItem` instances in list components.
437///
438/// This delegate provides the standard rendering logic for `DefaultItem` objects,
439/// handling different visual states, filtering highlights, and layout options.
440/// It implements the `ItemDelegate` trait to integrate seamlessly with the list
441/// component system.
442///
443/// # Features
444///
445/// - **Adaptive styling**: Automatically adjusts colors for light/dark terminals
446/// - **State rendering**: Handles normal, selected, and dimmed visual states
447/// - **Filter highlighting**: Character-level highlighting of search matches
448/// - **Flexible layout**: Configurable description display and item spacing
449/// - **Responsive design**: Adjusts rendering based on available width
450///
451/// # Configuration
452///
453/// The delegate can be customized through its public fields:
454/// - `show_description`: Controls whether descriptions are rendered below titles
455/// - `styles`: Complete styling configuration for all visual states
456///
457/// # Usage with List
458///
459/// The delegate is designed to work with the `Model<DefaultItem>` list component
460/// and handles all the rendering complexity automatically.
461///
462/// # Examples
463///
464/// ```rust
465/// use bubbletea_widgets::list::{DefaultDelegate, DefaultItem, Model};
466///
467/// // Create a delegate with default settings
468/// let delegate = DefaultDelegate::new();
469///
470/// // Customize the delegate
471/// let mut custom_delegate = DefaultDelegate::new();
472/// custom_delegate.show_description = false; // Hide descriptions
473///
474/// // Use with a list
475/// let items = vec![
476/// DefaultItem::new("Task 1", "First task description"),
477/// DefaultItem::new("Task 2", "Second task description"),
478/// ];
479/// let list = Model::new(items, delegate, 80, 24);
480/// ```
481#[derive(Debug, Clone)]
482pub struct DefaultDelegate {
483 /// Whether to show the description beneath the title.
484 pub show_description: bool,
485 /// Styling used for different visual states.
486 pub styles: DefaultItemStyles,
487 height: usize,
488 spacing: usize,
489}
490
491impl Default for DefaultDelegate {
492 /// Creates a new delegate with default configuration.
493 ///
494 /// The default delegate is configured with:
495 /// - Description display enabled
496 /// - Standard adaptive styling
497 /// - Height of 2 lines (title + description)
498 /// - 1 line spacing between items
499 ///
500 /// This configuration provides a standard list appearance that matches
501 /// the Go bubbles library defaults.
502 ///
503 /// # Examples
504 ///
505 /// ```rust
506 /// use bubbletea_widgets::list::DefaultDelegate;
507 ///
508 /// let delegate = DefaultDelegate::default();
509 /// assert_eq!(delegate.show_description, true);
510 /// ```
511 fn default() -> Self {
512 Self {
513 show_description: true,
514 styles: Default::default(),
515 height: 2,
516 spacing: 1,
517 }
518 }
519}
520impl DefaultDelegate {
521 /// Creates a new delegate with default styles and layout.
522 ///
523 /// This is equivalent to `DefaultDelegate::default()` and provides a convenient
524 /// constructor for creating a new delegate with standard settings.
525 ///
526 /// # Returns
527 ///
528 /// A new `DefaultDelegate` configured with default settings suitable for
529 /// most list use cases.
530 ///
531 /// # Examples
532 ///
533 /// ```rust
534 /// use bubbletea_widgets::list::{DefaultDelegate, DefaultItem, Model};
535 ///
536 /// let delegate = DefaultDelegate::new();
537 /// let items = vec![DefaultItem::new("Item 1", "Description 1")];
538 /// let list = Model::new(items, delegate, 80, 24);
539 /// ```
540 pub fn new() -> Self {
541 Self::default()
542 }
543}
544
545impl<I: Item + 'static> ItemDelegate<I> for DefaultDelegate {
546 /// Renders an item as a styled string for display in the list.
547 ///
548 /// This method implements the complete rendering pipeline for list items, with special
549 /// handling for filter highlighting that avoids common ANSI spacing issues.
550 ///
551 /// ## Rendering Pipeline
552 ///
553 /// 1. **State Detection**: Determine if item is selected, dimmed, or has filter matches
554 /// 2. **Filter Highlighting**: Apply character-level highlighting using segment-based approach
555 /// 3. **Style Application**: Apply colors, borders, and padding based on item state
556 /// 4. **Layout Formatting**: Combine title and description if enabled
557 ///
558 /// ## Filter Highlighting Implementation
559 ///
560 /// The filter highlighting system is complex due to lipgloss rendering behavior:
561 ///
562 /// ### Problem
563 /// Lipgloss styles with padding add spaces when applied to individual text segments.
564 /// If we pass styles with padding directly to `apply_character_highlighting`, we get:
565 /// - Input: "Nutella" with matches \[0\] (highlighting 'N')
566 /// - Expected: "│ Nutella"
567 /// - Actual: "│ N utella" (extra space between 'N' and 'utella')
568 ///
569 /// ### Solution
570 /// 1. Create "base" styles WITHOUT padding/borders for segment highlighting
571 /// 2. Apply highlighting using these clean styles via `apply_character_highlighting`
572 /// 3. Manually apply border and padding AFTER highlighting is complete
573 ///
574 /// ### Selected vs Unselected Items
575 /// - **Selected**: Left border (│) + 1 space padding + colored text
576 /// - **Unselected**: No border + 2 spaces padding + normal text color
577 ///
578 /// This approach ensures seamless text rendering while preserving visual hierarchy.
579 ///
580 /// # Arguments
581 ///
582 /// * `m` - The list model containing state and filter information
583 /// * `index` - The index of this item in the current list view
584 /// * `item` - The item to render
585 ///
586 /// # Returns
587 ///
588 /// A formatted string with ANSI styling codes. Returns empty string if list width is 0.
589 ///
590 /// # Visual States
591 ///
592 /// - **Normal**: Standard appearance with left padding
593 /// - **Selected**: Highlighted with left border and accent colors
594 /// - **Dimmed**: Faded appearance when filter input is empty
595 /// - **Filtered**: Normal or selected appearance with character-level match highlighting
596 ///
597 /// ## CRITICAL DEVELOPER NOTES - List Component Bug Fixes
598 ///
599 /// This render method is part of comprehensive fixes for list component issues.
600 /// **READ THIS** before modifying anything related to index handling!
601 ///
602 /// ### Index Parameter Semantics (VERY IMPORTANT!)
603 /// The `index` parameter represents the **original item index** in the full items list,
604 /// NOT a viewport-relative or filtered-relative position. This design is crucial for:
605 ///
606 /// 1. **Cursor Highlighting**: `index == m.cursor` comparison works correctly
607 /// 2. **Filter Highlighting**: We can find matches by searching filtered_items
608 /// 3. **Viewport Scrolling**: Highlighting persists across viewport changes
609 ///
610 /// ### Fixed Bug Context
611 /// Previous issues that were resolved:
612 /// - **Cursor highlighting loss**: Caused by passing viewport-relative indices
613 /// - **Filter input accumulation**: Fixed by proper textinput event forwarding
614 /// - **Viewport page jumping**: Fixed by smooth scrolling implementation
615 ///
616 /// ### System Integration
617 /// This method works with other fixes in `mod.rs`:
618 /// - `sync_viewport_with_cursor()`: Provides smooth viewport scrolling
619 /// - `view_items()`: Passes original indices instead of viewport-relative ones
620 /// - Filter input handlers: Ensure proper character accumulation
621 ///
622 /// ⚠️ **WARNING**: If you modify index handling here, ensure consistency with `view_items()`!
623 fn render(&self, m: &Model<I>, index: usize, item: &I) -> String {
624 let title = item.to_string();
625 let desc = if let Some(di) = (item as &dyn std::any::Any).downcast_ref::<DefaultItem>() {
626 di.desc.clone()
627 } else {
628 String::new()
629 };
630
631 if m.width == 0 {
632 return String::new();
633 }
634
635 let s = &self.styles;
636 let is_selected = index == m.cursor;
637
638 // Check if we're in the special "empty filter" dimmed state
639 // This happens when user has pressed '/' to filter but hasn't typed anything yet
640 let empty_filter =
641 m.filter_state == super::FilterState::Filtering && m.filter_input.value().is_empty();
642
643 // Determine if any kind of filtering is active (typing or applied)
644 let is_filtered = matches!(
645 m.filter_state,
646 super::FilterState::Filtering | super::FilterState::FilterApplied
647 );
648
649 // FILTER HIGHLIGHTING FIX: Extract character match indices from the fuzzy search results
650 // These indices tell us which characters in the text should be highlighted
651 //
652 // CRITICAL CHANGE: Find matches for this item by searching the filtered_items by original index
653 //
654 // Previous approach: Used `index < m.filtered_items.len()` and accessed `m.filtered_items[index]`
655 // Problem: This assumed `index` was the filtered item position, but after viewport scrolling fixes,
656 // the `index` parameter now represents the original item index (for cursor highlighting).
657 //
658 // New approach: Search filtered_items to find the FilteredItem whose `index` field matches
659 // the original item index, then extract the matches from that FilteredItem.
660 //
661 // This ensures filter highlighting works correctly even when:
662 // 1. Items are scrolled in/out of viewport
663 // 2. Cursor highlighting uses original indices
664 // 3. Filter matches need to be found by original item position
665 let matches = if is_filtered {
666 m.filtered_items
667 .iter()
668 .find(|fi| fi.index == index) // Find FilteredItem with matching original index
669 .map(|fi| &fi.matches) // Extract the character match indices
670 } else {
671 None
672 };
673
674 let mut title_out = title.clone();
675 let mut desc_out = desc.clone();
676
677 // RENDERING BRANCH 1: Empty Filter State (Dimmed)
678 // When user presses '/' but hasn't typed anything, show all items in dimmed colors
679 if empty_filter {
680 title_out = s.dimmed_title.render(&title_out);
681 desc_out = s.dimmed_desc.render(&desc_out);
682 // RENDERING BRANCH 2: Selected Item (with potential highlighting)
683 // Selected items get a left border and accent colors, plus highlighting if filtered
684 } else if is_selected && m.filter_state != super::FilterState::Filtering {
685 if let Some(match_indices) = matches {
686 // SELECTED ITEM WITH FILTER HIGHLIGHTING
687 //
688 // Problem: The default selected_title style has padding(0,0,0,1) and a border.
689 // If we pass this directly to apply_character_highlighting, lipgloss applies
690 // the padding to each text segment individually, creating spaces between them:
691 // "Nutella" with 'N' highlighted becomes "│ N utella" instead of "│ Nutella"
692 //
693 // Solution: Use clean styles without padding/borders for highlighting, then
694 // manually add the border and padding afterward.
695
696 // Step 1: Create clean base styles (colors only, no padding/borders)
697 let selected_base_style = Style::new().foreground(AdaptiveColor {
698 Light: "#EE6FF8", // Selected item text color (matching Go)
699 Dark: "#EE6FF8",
700 });
701 let selected_desc_base_style = Style::new().foreground(AdaptiveColor {
702 Light: "#F793FF", // Selected description color (matching Go)
703 Dark: "#AD58B4",
704 });
705
706 // Step 2: Apply character-level highlighting using clean styles
707 let highlight_style = selected_base_style.clone().inherit(s.filter_match.clone());
708 title_out = apply_character_highlighting(
709 &title,
710 match_indices,
711 &highlight_style, // Highlighted segments: selected color + underline/bold
712 &selected_base_style, // Normal segments: just selected color
713 );
714 if !desc.is_empty() {
715 let desc_highlight_style = selected_desc_base_style
716 .clone()
717 .inherit(s.filter_match.clone());
718 desc_out = apply_character_highlighting(
719 &desc,
720 match_indices,
721 &desc_highlight_style,
722 &selected_desc_base_style,
723 );
724 }
725
726 // Step 3: Manually add border and padding to avoid lipgloss segment spacing
727 // This ensures "│ Nutella" instead of "│ N utella"
728 let border_char = "│";
729 let padding = " "; // 1 space after border for selected items
730 title_out = format!(
731 "{}{}{}",
732 Style::new()
733 .foreground(AdaptiveColor {
734 Light: "#F793FF", // Border color (matching Go)
735 Dark: "#AD58B4",
736 })
737 .render(border_char), // Colored border character
738 padding, // Manual spacing (no styling needed)
739 title_out // Pre-highlighted text
740 );
741 if !desc.is_empty() {
742 desc_out = format!(
743 "{}{}{}",
744 Style::new()
745 .foreground(AdaptiveColor {
746 Light: "#F793FF", // Border color (matching Go)
747 Dark: "#AD58B4",
748 })
749 .render(border_char),
750 padding,
751 desc_out
752 );
753 }
754 } else {
755 // SELECTED ITEM WITHOUT FILTER HIGHLIGHTING
756 // No filter matches, so use the standard selected styles (with border and padding)
757 title_out = s.selected_title.render(&title_out);
758 desc_out = s.selected_desc.render(&desc_out);
759 }
760 } else {
761 // RENDERING BRANCH 3: Unselected/Normal Item (with potential highlighting)
762 // Unselected items have no border, just left padding for alignment
763 if let Some(match_indices) = matches {
764 // UNSELECTED ITEM WITH FILTER HIGHLIGHTING
765 //
766 // Same issue as selected items: normal_title style has padding(0,0,0,2).
767 // Applying this to individual segments creates: "Li n ux" instead of "Linux"
768 //
769 // Solution: Use the same approach - clean styles for highlighting,
770 // then manual padding.
771
772 // Step 1: Create clean base styles (colors only, no padding)
773 let normal_base_style = Style::new().foreground(AdaptiveColor {
774 Light: "#1a1a1a", // Normal item text color (dark on light, light on dark)
775 Dark: "#dddddd",
776 });
777 let normal_desc_base_style = Style::new().foreground(AdaptiveColor {
778 Light: "#A49FA5", // Normal description color (muted)
779 Dark: "#777777",
780 });
781
782 // Step 2: Apply character-level highlighting using clean styles
783 let highlight_style = normal_base_style.clone().inherit(s.filter_match.clone());
784 title_out = apply_character_highlighting(
785 &title,
786 match_indices,
787 &highlight_style, // Highlighted segments: normal color + underline/bold
788 &normal_base_style, // Normal segments: just normal color
789 );
790 if !desc.is_empty() {
791 let desc_highlight_style = normal_desc_base_style
792 .clone()
793 .inherit(s.filter_match.clone());
794 desc_out = apply_character_highlighting(
795 &desc,
796 match_indices,
797 &desc_highlight_style,
798 &normal_desc_base_style,
799 );
800 }
801
802 // Step 3: Apply padding manually (no border for unselected items)
803 // This ensures " Linux" instead of " Li n ux"
804 let padding = " "; // 2 spaces for normal items (to align with selected items' border+space)
805 title_out = format!("{}{}", padding, title_out);
806 if !desc.is_empty() {
807 desc_out = format!("{}{}", padding, desc_out);
808 }
809 } else {
810 // UNSELECTED ITEM WITHOUT FILTER HIGHLIGHTING
811 // No filter matches, so use the standard normal styles (with padding)
812 title_out = s.normal_title.render(&title_out);
813 desc_out = s.normal_desc.render(&desc_out);
814 }
815 }
816
817 // FINAL LAYOUT: Combine title and description based on delegate settings
818 // At this point, title_out and desc_out are fully styled with proper spacing:
819 // - Selected items: "│ Nutella" (border + space + text)
820 // - Unselected items: " Linux" (2 spaces + text)
821 // - Filter highlighting is seamlessly integrated without spacing artifacts
822 if self.show_description && !desc_out.is_empty() {
823 format!("{}\n{}", title_out, desc_out)
824 } else {
825 title_out
826 }
827 }
828 /// Returns the height in lines that each item occupies.
829 ///
830 /// The height depends on whether descriptions are enabled:
831 /// - With descriptions: Returns the configured height (default 2 lines)
832 /// - Without descriptions: Always returns 1 line
833 ///
834 /// This height is used by the list component for layout calculations,
835 /// viewport sizing, and scroll positioning.
836 ///
837 /// # Returns
838 ///
839 /// The number of terminal lines each item will occupy when rendered.
840 fn height(&self) -> usize {
841 if self.show_description {
842 self.height
843 } else {
844 1
845 }
846 }
847 /// Returns the number of blank lines between items.
848 ///
849 /// This spacing is added between each item in the list to improve
850 /// readability and visual separation. The default spacing is 1 line.
851 ///
852 /// # Returns
853 ///
854 /// The number of blank lines to insert between rendered items.
855 fn spacing(&self) -> usize {
856 self.spacing
857 }
858 /// Handles update messages for the delegate.
859 ///
860 /// The default delegate implementation does not require any message handling,
861 /// so this method always returns `None`. Override this method in custom
862 /// delegates that need to respond to keyboard input, timer events, or
863 /// other application messages.
864 ///
865 /// # Arguments
866 ///
867 /// * `_msg` - The message to handle (unused in default implementation)
868 /// * `_m` - Mutable reference to the list model (unused in default implementation)
869 ///
870 /// # Returns
871 ///
872 /// Always returns `None` as the default delegate requires no update commands.
873 fn update(&self, _msg: &Msg, _m: &mut Model<I>) -> Option<Cmd> {
874 None
875 }
876}