Skip to main content

ratatui_interact/components/
list_picker.rs

1//! List picker widget
2//!
3//! A scrollable list with selection cursor for picking from a set of items.
4//!
5//! # Example
6//!
7//! ```rust
8//! use ratatui_interact::components::{ListPicker, ListPickerState, ListPickerStyle};
9//! use ratatui::text::Line;
10//! use ratatui::layout::Rect;
11//!
12//! // Create items
13//! let items = vec!["Option 1", "Option 2", "Option 3"];
14//!
15//! // Create state
16//! let mut state = ListPickerState::new(items.len());
17//!
18//! // Create picker
19//! let picker = ListPicker::new(&items, &state)
20//!     .title("Select an option")
21//!     .render_item(|item, idx, selected| {
22//!         let text = if selected {
23//!             format!("▶ {}", item)
24//!         } else {
25//!             format!("  {}", item)
26//!         };
27//!         vec![Line::from(text)]
28//!     });
29//! ```
30
31use ratatui::{
32    buffer::Buffer,
33    layout::Rect,
34    style::{Color, Modifier, Style},
35    text::{Line, Span},
36    widgets::{Block, Borders, Paragraph, Widget, Wrap},
37};
38
39/// State for the list picker widget
40#[derive(Debug, Clone, Default)]
41pub struct ListPickerState {
42    /// Currently selected index
43    pub selected_index: usize,
44    /// Scroll offset
45    pub scroll: u16,
46    /// Total number of items
47    pub total_items: usize,
48}
49
50impl ListPickerState {
51    /// Create a new list picker state with the given number of items
52    pub fn new(total_items: usize) -> Self {
53        Self {
54            selected_index: 0,
55            scroll: 0,
56            total_items,
57        }
58    }
59
60    /// Move selection up
61    pub fn select_prev(&mut self) {
62        if self.selected_index > 0 {
63            self.selected_index -= 1;
64        }
65    }
66
67    /// Move selection down
68    pub fn select_next(&mut self) {
69        if self.selected_index + 1 < self.total_items {
70            self.selected_index += 1;
71        }
72    }
73
74    /// Select a specific index
75    pub fn select(&mut self, index: usize) {
76        if index < self.total_items {
77            self.selected_index = index;
78        }
79    }
80
81    /// Move selection to first item
82    pub fn select_first(&mut self) {
83        self.selected_index = 0;
84    }
85
86    /// Move selection to last item
87    pub fn select_last(&mut self) {
88        if self.total_items > 0 {
89            self.selected_index = self.total_items - 1;
90        }
91    }
92
93    /// Ensure selected item is visible in viewport
94    pub fn ensure_visible(&mut self, viewport_height: usize) {
95        if viewport_height == 0 {
96            return;
97        }
98
99        if self.selected_index < self.scroll as usize {
100            self.scroll = self.selected_index as u16;
101        } else if self.selected_index >= self.scroll as usize + viewport_height {
102            self.scroll = (self.selected_index - viewport_height + 1) as u16;
103        }
104    }
105
106    /// Update total items count
107    pub fn set_total(&mut self, total: usize) {
108        self.total_items = total;
109        if self.selected_index >= total && total > 0 {
110            self.selected_index = total - 1;
111        }
112    }
113}
114
115/// Style configuration for list picker
116#[derive(Debug, Clone)]
117pub struct ListPickerStyle {
118    /// Style for selected items
119    pub selected_style: Style,
120    /// Style for normal items
121    pub normal_style: Style,
122    /// Style for the selection indicator
123    pub indicator_style: Style,
124    /// Border style
125    pub border_style: Style,
126    /// Selection indicator character(s)
127    pub indicator: &'static str,
128    /// Empty indicator (same width as indicator)
129    pub indicator_empty: &'static str,
130    /// Whether to show borders
131    pub bordered: bool,
132}
133
134impl Default for ListPickerStyle {
135    fn default() -> Self {
136        Self {
137            selected_style: Style::default()
138                .fg(Color::Yellow)
139                .add_modifier(Modifier::BOLD),
140            normal_style: Style::default().fg(Color::White),
141            indicator_style: Style::default().fg(Color::Yellow),
142            border_style: Style::default().fg(Color::Cyan),
143            indicator: "▶ ",
144            indicator_empty: "  ",
145            bordered: true,
146        }
147    }
148}
149
150impl From<&crate::theme::Theme> for ListPickerStyle {
151    fn from(theme: &crate::theme::Theme) -> Self {
152        let p = &theme.palette;
153        Self {
154            selected_style: Style::default().fg(p.primary).add_modifier(Modifier::BOLD),
155            normal_style: Style::default().fg(p.text),
156            indicator_style: Style::default().fg(p.primary),
157            border_style: Style::default().fg(p.border_accent),
158            indicator: "▶ ",
159            indicator_empty: "  ",
160            bordered: true,
161        }
162    }
163}
164
165impl ListPickerStyle {
166    /// Style with arrow indicator
167    pub fn arrow() -> Self {
168        Self::default()
169    }
170
171    /// Style with bracket indicator
172    pub fn bracket() -> Self {
173        Self {
174            indicator: "> ",
175            indicator_empty: "  ",
176            ..Default::default()
177        }
178    }
179
180    /// Style with checkbox indicator
181    pub fn checkbox() -> Self {
182        Self {
183            indicator: "[x] ",
184            indicator_empty: "[ ] ",
185            selected_style: Style::default().fg(Color::Green),
186            ..Default::default()
187        }
188    }
189
190    /// Set whether to show borders
191    pub fn bordered(mut self, bordered: bool) -> Self {
192        self.bordered = bordered;
193        self
194    }
195}
196
197/// Default render function type
198type DefaultRenderFn<T> = fn(&T, usize, bool) -> Vec<Line<'static>>;
199
200/// List picker widget
201pub struct ListPicker<'a, T, F = DefaultRenderFn<T>>
202where
203    F: Fn(&T, usize, bool) -> Vec<Line<'static>>,
204{
205    items: &'a [T],
206    state: &'a ListPickerState,
207    style: ListPickerStyle,
208    title: Option<&'a str>,
209    footer: Option<Vec<Line<'static>>>,
210    render_fn: F,
211}
212
213impl<'a, T: std::fmt::Display> ListPicker<'a, T, DefaultRenderFn<T>> {
214    /// Create a new list picker with default rendering
215    pub fn new(items: &'a [T], state: &'a ListPickerState) -> Self {
216        Self {
217            items,
218            state,
219            style: ListPickerStyle::default(),
220            title: None,
221            footer: None,
222            render_fn: |item, _idx, _selected| vec![Line::from(item.to_string())],
223        }
224    }
225}
226
227impl<'a, T, F> ListPicker<'a, T, F>
228where
229    F: Fn(&T, usize, bool) -> Vec<Line<'static>>,
230{
231    /// Set the render function for items
232    ///
233    /// The function receives: item reference, index, is_selected
234    /// Returns a Vec of Lines (to support multi-line items)
235    pub fn render_item<G>(self, render_fn: G) -> ListPicker<'a, T, G>
236    where
237        G: Fn(&T, usize, bool) -> Vec<Line<'static>>,
238    {
239        ListPicker {
240            items: self.items,
241            state: self.state,
242            style: self.style,
243            title: self.title,
244            footer: self.footer,
245            render_fn,
246        }
247    }
248
249    /// Set the title
250    pub fn title(mut self, title: &'a str) -> Self {
251        self.title = Some(title);
252        self
253    }
254
255    /// Set the footer lines
256    pub fn footer(mut self, footer: Vec<Line<'static>>) -> Self {
257        self.footer = Some(footer);
258        self
259    }
260
261    /// Set the style
262    pub fn style(mut self, style: ListPickerStyle) -> Self {
263        self.style = style;
264        self
265    }
266
267    /// Apply a theme to this list picker.
268    pub fn theme(self, theme: &crate::theme::Theme) -> Self {
269        self.style(ListPickerStyle::from(theme))
270    }
271
272    /// Build the lines for rendering
273    fn build_lines(&self, _area: Rect, inner_height: u16) -> Vec<Line<'static>> {
274        let mut lines = Vec::new();
275
276        // Header
277        if let Some(title) = self.title {
278            lines.push(Line::from(vec![Span::styled(
279                title.to_string(),
280                Style::default()
281                    .fg(Color::Cyan)
282                    .add_modifier(Modifier::BOLD),
283            )]));
284            lines.push(Line::from("")); // Empty line after title
285        }
286
287        // Calculate available height for items
288        let header_lines = if self.title.is_some() { 2 } else { 0 };
289        let footer_lines = self.footer.as_ref().map(|f| f.len()).unwrap_or(0);
290        let available_height = inner_height as usize - header_lines - footer_lines;
291
292        // Items
293        if self.items.is_empty() {
294            lines.push(Line::from(vec![Span::styled(
295                "No items",
296                Style::default().fg(Color::Gray),
297            )]));
298        } else {
299            let scroll = self.state.scroll as usize;
300            for (idx, item) in self
301                .items
302                .iter()
303                .enumerate()
304                .skip(scroll)
305                .take(available_height)
306            {
307                let is_selected = idx == self.state.selected_index;
308                let indicator = if is_selected {
309                    self.style.indicator
310                } else {
311                    self.style.indicator_empty
312                };
313
314                let item_style = if is_selected {
315                    self.style.selected_style
316                } else {
317                    self.style.normal_style
318                };
319
320                let item_lines = (self.render_fn)(item, idx, is_selected);
321                for (line_idx, line) in item_lines.into_iter().enumerate() {
322                    let mut spans = Vec::new();
323
324                    // Only show indicator on first line of item
325                    if line_idx == 0 {
326                        spans.push(Span::styled(
327                            indicator.to_string(),
328                            self.style.indicator_style,
329                        ));
330                    } else {
331                        // Indent continuation lines
332                        spans.push(Span::raw(" ".repeat(self.style.indicator.len())));
333                    }
334
335                    // Add the line content with appropriate style
336                    for span in line.spans {
337                        spans.push(Span::styled(span.content.to_string(), item_style));
338                    }
339
340                    lines.push(Line::from(spans));
341                }
342            }
343        }
344
345        // Footer
346        if let Some(footer) = &self.footer {
347            for line in footer {
348                lines.push(line.clone());
349            }
350        }
351
352        lines
353    }
354}
355
356impl<'a, T, F> Widget for ListPicker<'a, T, F>
357where
358    F: Fn(&T, usize, bool) -> Vec<Line<'static>>,
359{
360    fn render(self, area: Rect, buf: &mut Buffer) {
361        let block = if self.style.bordered {
362            Some(
363                Block::default()
364                    .borders(Borders::ALL)
365                    .border_style(self.style.border_style),
366            )
367        } else {
368            None
369        };
370
371        let inner = if let Some(ref block) = block {
372            block.inner(area)
373        } else {
374            area
375        };
376
377        if let Some(block) = block {
378            block.render(area, buf);
379        }
380
381        let lines = self.build_lines(area, inner.height);
382        let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false });
383        paragraph.render(inner, buf);
384    }
385}
386
387/// Helper function to create a simple footer with key hints
388pub fn key_hints_footer(hints: &[(&str, &str)]) -> Vec<Line<'static>> {
389    let mut spans = Vec::new();
390    for (idx, (key, desc)) in hints.iter().enumerate() {
391        if idx > 0 {
392            spans.push(Span::raw(" | "));
393        }
394        spans.push(Span::styled(
395            key.to_string(),
396            Style::default().fg(Color::Green),
397        ));
398        spans.push(Span::raw(format!(": {}", desc)));
399    }
400    vec![Line::from(""), Line::from(spans)]
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    #[test]
408    fn test_state_new() {
409        let state = ListPickerState::new(10);
410        assert_eq!(state.selected_index, 0);
411        assert_eq!(state.scroll, 0);
412        assert_eq!(state.total_items, 10);
413    }
414
415    #[test]
416    fn test_state_navigation() {
417        let mut state = ListPickerState::new(5);
418        assert_eq!(state.selected_index, 0);
419
420        state.select_next();
421        assert_eq!(state.selected_index, 1);
422
423        state.select_prev();
424        assert_eq!(state.selected_index, 0);
425
426        state.select_prev(); // Should not go below 0
427        assert_eq!(state.selected_index, 0);
428
429        state.select_last();
430        assert_eq!(state.selected_index, 4);
431
432        state.select_next(); // Should not go above total
433        assert_eq!(state.selected_index, 4);
434    }
435
436    #[test]
437    fn test_select_first_and_last() {
438        let mut state = ListPickerState::new(10);
439        state.selected_index = 5;
440
441        state.select_first();
442        assert_eq!(state.selected_index, 0);
443
444        state.select_last();
445        assert_eq!(state.selected_index, 9);
446    }
447
448    #[test]
449    fn test_select_specific_index() {
450        let mut state = ListPickerState::new(10);
451
452        state.select(5);
453        assert_eq!(state.selected_index, 5);
454
455        // Should clamp to valid range
456        state.select(100);
457        assert_eq!(state.selected_index, 5); // Unchanged because out of range
458    }
459
460    #[test]
461    fn test_ensure_visible() {
462        let mut state = ListPickerState::new(20);
463        state.selected_index = 15;
464        state.ensure_visible(10);
465        assert!(state.scroll >= 6); // 15 - 10 + 1 = 6
466    }
467
468    #[test]
469    fn test_ensure_visible_scroll_up() {
470        let mut state = ListPickerState::new(20);
471        state.scroll = 10;
472        state.selected_index = 5;
473        state.ensure_visible(10);
474        assert_eq!(state.scroll, 5);
475    }
476
477    #[test]
478    fn test_ensure_visible_zero_viewport() {
479        let mut state = ListPickerState::new(20);
480        state.selected_index = 10;
481        state.scroll = 5;
482        state.ensure_visible(0);
483        // Should not change scroll on zero viewport
484        assert_eq!(state.scroll, 5);
485    }
486
487    #[test]
488    fn test_set_total() {
489        let mut state = ListPickerState::new(10);
490        state.selected_index = 8;
491
492        // Reduce total, selected should be clamped
493        state.set_total(5);
494        assert_eq!(state.total_items, 5);
495        assert_eq!(state.selected_index, 4);
496
497        // Increase total
498        state.set_total(20);
499        assert_eq!(state.total_items, 20);
500        assert_eq!(state.selected_index, 4); // Unchanged
501    }
502
503    #[test]
504    fn test_empty_list() {
505        let mut state = ListPickerState::new(0);
506        state.select_next();
507        assert_eq!(state.selected_index, 0);
508        state.select_last();
509        assert_eq!(state.selected_index, 0);
510    }
511
512    #[test]
513    fn test_list_picker_render() {
514        let items = vec!["Item 1", "Item 2", "Item 3"];
515        let state = ListPickerState::new(items.len());
516        let picker = ListPicker::new(&items, &state).title("Test");
517
518        let mut buf = Buffer::empty(Rect::new(0, 0, 40, 10));
519        picker.render(Rect::new(0, 0, 40, 10), &mut buf);
520        // Just verify it doesn't panic
521    }
522
523    #[test]
524    fn test_list_picker_with_custom_render() {
525        let items = vec!["A", "B", "C"];
526        let state = ListPickerState::new(items.len());
527        let picker = ListPicker::new(&items, &state).render_item(|item, idx, selected| {
528            let prefix = if selected { "> " } else { "  " };
529            vec![Line::from(format!("{}{}. {}", prefix, idx + 1, item))]
530        });
531
532        let mut buf = Buffer::empty(Rect::new(0, 0, 40, 10));
533        picker.render(Rect::new(0, 0, 40, 10), &mut buf);
534    }
535
536    #[test]
537    fn test_list_picker_styles() {
538        let arrow = ListPickerStyle::arrow();
539        assert_eq!(arrow.indicator, "▶ ");
540
541        let bracket = ListPickerStyle::bracket();
542        assert_eq!(bracket.indicator, "> ");
543
544        let checkbox = ListPickerStyle::checkbox();
545        assert_eq!(checkbox.indicator, "[x] ");
546        assert_eq!(checkbox.indicator_empty, "[ ] ");
547    }
548
549    #[test]
550    fn test_list_picker_style_bordered() {
551        let style = ListPickerStyle::default().bordered(false);
552        assert!(!style.bordered);
553
554        let style = ListPickerStyle::default().bordered(true);
555        assert!(style.bordered);
556    }
557
558    #[test]
559    fn test_key_hints_footer() {
560        let footer = key_hints_footer(&[("↑↓", "Navigate"), ("Enter", "Select")]);
561        assert_eq!(footer.len(), 2);
562    }
563
564    #[test]
565    fn test_key_hints_footer_empty() {
566        let footer = key_hints_footer(&[]);
567        assert_eq!(footer.len(), 2); // Empty line + spans line
568    }
569}