Skip to main content

intelli_shell/widgets/
list.rs

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