intelli_shell/widgets/
list.rs

1use std::{borrow::Cow, collections::HashSet};
2
3use ratatui::{
4    buffer::Buffer,
5    layout::{Rect, Size},
6    prelude::StatefulWidget,
7    style::{Style, Styled},
8    widgets::{Block, Borders, Widget},
9};
10use tui_widget_list::{ListBuilder, ListState, ListView, ScrollAxis};
11use unicode_width::UnicodeWidthStr;
12
13use crate::config::Theme;
14
15/// A trait for types that can be rendered inside a [`CustomList`]
16pub trait CustomListItem {
17    type Widget<'w>: Widget + 'w
18    where
19        Self: 'w;
20
21    /// Converts the item into a ratatui widget and its size
22    fn as_widget<'a>(
23        &'a self,
24        theme: &Theme,
25        inline: bool,
26        is_highlighted: bool,
27        is_discarded: bool,
28    ) -> (Self::Widget<'a>, Size);
29}
30
31/// Defines how a highlight symbol is rendered next to a list item
32#[derive(Clone, Copy, PartialEq, Eq, Debug)]
33pub enum HighlightSymbolMode {
34    /// Appears only on the first line of the selected item
35    First,
36    /// Repeats on each line of the selected item
37    Repeat,
38    /// Appears only on the last line of the selected item
39    Last,
40}
41
42/// A widget that displays a customizable list of items
43pub struct CustomList<'a, T: CustomListItem> {
44    /// Application theme
45    theme: Theme,
46    /// Whether the TUI is rendered inline or not
47    inline: bool,
48    /// An optional `Block` to surround the list
49    block: Option<Block<'a>>,
50    /// The scroll axis
51    axis: ScrollAxis,
52    /// The vector of items to be displayed in the list
53    items: Vec<T>,
54    /// Whether the list is focused or not
55    focus: bool,
56    /// The state of the list, managing selection and scrolling
57    state: ListState,
58    /// The indices of the items marked as discarded
59    discarded_indices: HashSet<usize>,
60    /// An optional symbol string to display in front of the selected item
61    highlight_symbol: Option<String>,
62    /// Determines how the `highlight_symbol` is rendered
63    highlight_symbol_mode: HighlightSymbolMode,
64    /// The `Style` to apply to the `highlight_symbol`
65    highlight_symbol_style: Style,
66    /// The `Style` to apply to the `highlight_symbol` when focused
67    highlight_symbol_style_focused: Style,
68}
69
70impl<'a, T: CustomListItem> CustomList<'a, T> {
71    /// Creates a new [`CustomList`]
72    pub fn new(theme: Theme, inline: bool, items: Vec<T>) -> Self {
73        let mut state = ListState::default();
74        if !items.is_empty() {
75            state.select(Some(0));
76        }
77        Self {
78            block: (!inline).then(|| Block::default().borders(Borders::ALL).style(theme.primary)),
79            axis: ScrollAxis::Vertical,
80            items,
81            focus: true,
82            state,
83            discarded_indices: HashSet::new(),
84            highlight_symbol_style: theme.primary.into(),
85            highlight_symbol_style_focused: theme.highlight_primary_full().into(),
86            highlight_symbol: Some(theme.highlight_symbol.clone()).filter(|s| !s.trim().is_empty()),
87            highlight_symbol_mode: HighlightSymbolMode::Last,
88            theme,
89            inline,
90        }
91    }
92
93    /// Sets the scroll axis to horizontal
94    pub fn horizontal(mut self) -> Self {
95        self.axis = ScrollAxis::Horizontal;
96        self
97    }
98
99    /// Sets the scroll axis to vertical (the default)
100    pub fn vertical(mut self) -> Self {
101        self.axis = ScrollAxis::Vertical;
102        self
103    }
104
105    /// Sets the title for the list
106    pub fn title(mut self, title: impl Into<Cow<'a, str>>) -> Self {
107        self.set_title(title);
108        self
109    }
110
111    /// Sets the symbol to be displayed before the selected item
112    pub fn highlight_symbol(mut self, highlight_symbol: String) -> Self {
113        self.highlight_symbol = Some(highlight_symbol).filter(|s| !s.is_empty());
114        self
115    }
116
117    /// Sets the rendering mode for the highlight symbol
118    pub fn highlight_symbol_mode(mut self, highlight_symbol_mode: HighlightSymbolMode) -> Self {
119        self.highlight_symbol_mode = highlight_symbol_mode;
120        self
121    }
122
123    /// Sets the style for the highlight symbol
124    pub fn highlight_symbol_style(mut self, highlight_symbol_style: Style) -> Self {
125        self.highlight_symbol_style = highlight_symbol_style;
126        self
127    }
128
129    /// Sets the style for the highlight symbol when focused
130    pub fn highlight_symbol_style_focused(mut self, highlight_symbol_style_focused: Style) -> Self {
131        self.highlight_symbol_style_focused = highlight_symbol_style_focused;
132        self
133    }
134
135    /// Updates the title of the list
136    pub fn set_title(&mut self, new_title: impl Into<Cow<'a, str>>) {
137        if let Some(ref mut block) = self.block {
138            *block = Block::default()
139                .borders(Borders::ALL)
140                .style(Styled::style(block))
141                .title(new_title.into());
142        }
143    }
144
145    /// Sets the focus state of the list
146    pub fn set_focus(&mut self, focus: bool) {
147        self.focus = focus;
148    }
149
150    /// Returns whether the text area is currently focused or not
151    pub fn is_focused(&self) -> bool {
152        self.focus
153    }
154
155    /// Returns the number of items in this list
156    pub fn len(&self) -> usize {
157        self.items.len()
158    }
159
160    /// Returns `true` if the list contains no items
161    #[must_use]
162    pub fn is_empty(&self) -> bool {
163        self.items.is_empty()
164    }
165
166    /// Returns a reference to the inner items of the list
167    pub fn items(&self) -> &Vec<T> {
168        &self.items
169    }
170
171    /// Returns a mutable reference to the inner items of the list
172    pub fn items_mut(&mut self) -> &mut Vec<T> {
173        &mut self.items
174    }
175
176    /// Returns an iterator over the references to items that have not been discarded
177    pub fn non_discarded_items(&self) -> impl Iterator<Item = &T> {
178        self.items
179            .iter()
180            .enumerate()
181            .filter_map(|(index, item)| if self.is_discarded(index) { None } else { Some(item) })
182    }
183
184    /// Checks if an item at a given index is discarded
185    pub fn is_discarded(&self, index: usize) -> bool {
186        self.discarded_indices.contains(&index)
187    }
188
189    /// Updates the items displayed in this list.
190    ///
191    /// The discarded state will always be reset.
192    pub fn update_items(&mut self, items: Vec<T>, keep_selection: bool) {
193        self.items = items;
194        self.discarded_indices.clear();
195
196        if keep_selection {
197            if self.items.is_empty() {
198                self.state.select(None);
199            } else if let Some(selected) = self.state.selected {
200                if selected > self.items.len() - 1 {
201                    self.state.select(Some(self.items.len() - 1));
202                }
203            } else {
204                self.state.select(Some(0));
205            }
206        } else {
207            self.state = ListState::default();
208            if !self.items.is_empty() {
209                self.state.select(Some(0));
210            }
211        }
212    }
213
214    /// Selects the next item in the list, wrapping around to the beginning if at the end
215    pub fn select_next(&mut self) {
216        if self.focus
217            && let Some(selected) = self.state.selected
218        {
219            if self.items.is_empty() {
220                self.state.select(None);
221            } else {
222                let i = if selected >= self.items.len() - 1 {
223                    0
224                } else {
225                    selected + 1
226                };
227                self.state.select(Some(i));
228            }
229        }
230    }
231
232    /// Selects the previous item in the list, wrapping around to the end if at the beginning
233    pub fn select_prev(&mut self) {
234        if self.focus
235            && let Some(selected) = self.state.selected
236        {
237            if self.items.is_empty() {
238                self.state.select(None);
239            } else {
240                let i = if selected == 0 {
241                    self.items.len() - 1
242                } else {
243                    selected - 1
244                };
245                self.state.select(Some(i));
246            }
247        }
248    }
249
250    /// Selects the first item in the list
251    pub fn select_first(&mut self) {
252        if self.focus && !self.items.is_empty() {
253            self.state.select(Some(0));
254        }
255    }
256
257    /// Selects the last item in the list
258    pub fn select_last(&mut self) {
259        if self.focus && !self.items.is_empty() {
260            let i = self.items.len() - 1;
261            self.state.select(Some(i));
262        }
263    }
264
265    /// Selects the first item that matches the given predicate
266    pub fn select_matching<F>(&mut self, predicate: F) -> bool
267    where
268        F: FnMut(&T) -> bool,
269    {
270        if !self.items.is_empty()
271            && let Some(index) = self.items.iter().position(predicate)
272        {
273            self.state.select(Some(index));
274            true
275        } else {
276            false
277        }
278    }
279
280    /// Selects the given index
281    pub fn select(&mut self, index: usize) {
282        if self.focus && index < self.items.len() {
283            self.state.select(Some(index));
284        }
285    }
286
287    /// Returns the index of the currently selected item
288    pub fn selected_index(&self) -> Option<usize> {
289        self.state.selected
290    }
291
292    /// Returns a mutable reference to the currently selected item and its index
293    pub fn selected_mut(&mut self) -> Option<&mut T> {
294        if let Some(selected) = self.state.selected {
295            self.items.get_mut(selected)
296        } else {
297            None
298        }
299    }
300
301    /// Returns a reference to the currently selected item and its index
302    pub fn selected(&self) -> Option<&T> {
303        if let Some(selected) = self.state.selected {
304            self.items.get(selected)
305        } else {
306            None
307        }
308    }
309
310    /// Returns a reference to the currently selected item and its index
311    pub fn selected_with_index(&self) -> Option<(usize, &T)> {
312        if let Some(selected) = self.state.selected {
313            self.items.get(selected).map(|i| (selected, i))
314        } else {
315            None
316        }
317    }
318
319    /// Deletes the currently selected item from the list and returns it (along with its index)
320    pub fn delete_selected(&mut self) -> Option<(usize, T)> {
321        if self.focus {
322            let selected = self.state.selected?;
323            let deleted = self.items.remove(selected);
324
325            // Update discarded indices
326            self.discarded_indices = self
327                .discarded_indices
328                .iter()
329                .filter_map(|&idx| {
330                    if idx < selected {
331                        // Indices before the deleted one are unaffected
332                        Some(idx)
333                    } else if idx > selected {
334                        // Indices after the deleted one must be decremented
335                        Some(idx - 1)
336                    } else {
337                        // The deleted index itself is removed from the set
338                        None
339                    }
340                })
341                .collect();
342
343            if self.items.is_empty() {
344                self.state.select(None);
345            } else if selected >= self.items.len() {
346                self.state.select(Some(self.items.len() - 1));
347            }
348
349            Some((selected, deleted))
350        } else {
351            None
352        }
353    }
354
355    /// Toggles the discarded state of the currently selected item
356    pub fn toggle_discard_selected(&mut self) {
357        if let Some(selected) = self.state.selected
358            && !self.discarded_indices.remove(&selected)
359        {
360            self.discarded_indices.insert(selected);
361        }
362    }
363
364    /// Toggles the discarded state for all items
365    pub fn toggle_discard_all(&mut self) {
366        if self.items.is_empty() {
367            return;
368        }
369        // If all items are already discarded
370        if self.discarded_indices.len() == self.items.len() {
371            // Clear the set
372            self.discarded_indices.clear();
373        } else {
374            // Otherwise, discard all
375            self.discarded_indices.extend(0..self.items.len());
376        }
377    }
378}
379
380impl<'a, T: CustomListItem> Widget for &mut CustomList<'a, T> {
381    fn render(self, area: Rect, buf: &mut Buffer)
382    where
383        Self: Sized,
384    {
385        if let Some(ref highlight_symbol) = self.highlight_symbol {
386            // Render items with the highlight symbol
387            render_list_view(
388                ListBuilder::new(|ctx| {
389                    let is_highlighted = self.focus && ctx.is_selected;
390                    let is_discarded = self.discarded_indices.contains(&ctx.index);
391                    // Get the base widget and its height from the item
392                    let (item_widget, item_size) =
393                        self.items[ctx.index].as_widget(&self.theme, self.inline, is_highlighted, is_discarded);
394
395                    // Wrap the widget with a symbol
396                    let item = SymbolAndWidget {
397                        content: item_widget,
398                        content_height: item_size.height,
399                        symbol: if ctx.is_selected { highlight_symbol.as_str() } else { "" },
400                        symbol_width: highlight_symbol.width() as u16,
401                        symbol_mode: self.highlight_symbol_mode,
402                        symbol_style: if self.focus {
403                            self.highlight_symbol_style_focused
404                        } else {
405                            self.highlight_symbol_style
406                        },
407                    };
408
409                    let main_axis_size = match ctx.scroll_axis {
410                        ScrollAxis::Vertical => item_size.height,
411                        ScrollAxis::Horizontal => item_size.width + 1,
412                    };
413
414                    (item, main_axis_size)
415                }),
416                self.axis,
417                self.block.is_none(),
418                self.items.len(),
419                self.block.clone(),
420                &mut self.state,
421                area,
422                buf,
423            );
424        } else {
425            // No highlight symbol, render items directly
426            render_list_view(
427                ListBuilder::new(|ctx| {
428                    let is_highlighted = ctx.is_selected;
429                    let is_discarded = self.discarded_indices.contains(&ctx.index);
430                    let (item_widget, item_size) =
431                        self.items[ctx.index].as_widget(&self.theme, self.inline, is_highlighted, is_discarded);
432                    let main_axis_size = match ctx.scroll_axis {
433                        ScrollAxis::Vertical => item_size.height,
434                        ScrollAxis::Horizontal => item_size.width + 1,
435                    };
436                    (item_widget, main_axis_size)
437                }),
438                self.axis,
439                self.block.is_none(),
440                self.items.len(),
441                self.block.clone(),
442                &mut self.state,
443                area,
444                buf,
445            );
446        }
447    }
448}
449
450/// Internal helper function to render a list view using a generic builder
451#[allow(clippy::too_many_arguments)]
452fn render_list_view<'a, W: Widget>(
453    builder: ListBuilder<'a, W>,
454    axis: ScrollAxis,
455    inline: bool,
456    item_count: usize,
457    block: Option<Block<'a>>,
458    state: &mut ListState,
459    area: Rect,
460    buf: &mut Buffer,
461) {
462    let mut view = ListView::new(builder, item_count)
463        .scroll_axis(axis)
464        .infinite_scrolling(false)
465        .scroll_padding(1 + (!inline as u16));
466    if let Some(block) = block {
467        view = view.block(block);
468    }
469    view.render(area, buf, state)
470}
471
472/// Internal helper widget to render an item prefixed with a highlight symbol
473struct SymbolAndWidget<'a, W: Widget> {
474    /// The widget to be rendered as the main content next to the symbol
475    content: W,
476    /// Height of the `content` widget in rows
477    content_height: u16,
478    /// The actual symbol string to render (e.g., " > ", " ✔ ").
479    symbol: &'a str,
480    /// The pre-calculated width of the `symbol` string
481    symbol_width: u16,
482    /// Specifies how the `symbol` should be rendered relative to the `content`
483    symbol_mode: HighlightSymbolMode,
484    /// The `Style` to be applied to the `symbol` when rendering.
485    symbol_style: Style,
486}
487
488impl<'a, W: Widget> Widget for SymbolAndWidget<'a, W> {
489    fn render(self, area: Rect, buf: &mut Buffer) {
490        let mut content_area = area;
491        let mut symbol_area = Rect::default();
492
493        // Calculate the area for the symbol string
494        if self.symbol_width > 0 && area.width > 0 {
495            symbol_area = Rect {
496                x: area.x,
497                y: area.y,
498                width: self.symbol_width.min(area.width),
499                height: area.height,
500            };
501
502            // Adjust content area to be to the right of the symbol area
503            content_area.x = area.x.saturating_add(symbol_area.width);
504            content_area.width = area.width.saturating_sub(symbol_area.width);
505        }
506
507        // Render the actual item content widget
508        if content_area.width > 0 && content_area.height > 0 {
509            self.content.render(content_area, buf);
510        }
511
512        // Render the highlight symbol
513        if !self.symbol.is_empty() && symbol_area.width > 0 && symbol_area.height > 0 {
514            // Fill the entire symbol_area with the background style
515            if let Some(bg_color) = self.symbol_style.bg {
516                for y_coord in symbol_area.top()..symbol_area.bottom() {
517                    for x_coord in symbol_area.left()..symbol_area.right() {
518                        if let Some(cell) = buf.cell_mut((x_coord, y_coord)) {
519                            cell.set_bg(bg_color);
520                        }
521                    }
522                }
523            }
524            // Render the symbol
525            match self.symbol_mode {
526                HighlightSymbolMode::First => {
527                    // Render on the first line of the symbol's allocated area
528                    buf.set_stringn(
529                        symbol_area.x,
530                        symbol_area.y,
531                        self.symbol,
532                        symbol_area.width as usize,
533                        self.symbol_style,
534                    );
535                }
536                HighlightSymbolMode::Repeat => {
537                    // Repeat for each line of the content, up to content_height or available symbol area height
538                    for i in 0..self.content_height {
539                        let y_pos = symbol_area.y + i;
540                        // Ensure we are within the bounds of the symbol_area
541                        if y_pos < symbol_area.bottom() && i < symbol_area.height {
542                            buf.set_stringn(
543                                symbol_area.x,
544                                y_pos,
545                                self.symbol,
546                                symbol_area.width as usize,
547                                self.symbol_style,
548                            );
549                        } else {
550                            // Stop if we go beyond the symbol area's height
551                            break;
552                        }
553                    }
554                }
555                HighlightSymbolMode::Last => {
556                    // Render on the last line occupied by the content, if space permits
557                    if self.content_height > 0 {
558                        let y_pos = symbol_area.y + self.content_height - 1;
559                        // Ensure the calculated y_pos is within the symbol_area
560                        if y_pos < symbol_area.bottom() && (self.content_height - 1) < symbol_area.height {
561                            buf.set_stringn(
562                                symbol_area.x,
563                                y_pos,
564                                self.symbol,
565                                symbol_area.width as usize,
566                                self.symbol_style,
567                            );
568                        }
569                    }
570                }
571            }
572        }
573    }
574}