intelli_shell/widgets/
list.rs

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