tui_dispatch_components/
select_list.rs

1//! Scrollable selection list component
2
3use crossterm::event::KeyCode;
4use ratatui::{
5    layout::Rect,
6    style::Style,
7    text::{Line, Span},
8    widgets::{Block, List, ListItem, ListState, ScrollbarOrientation, ScrollbarState},
9    Frame,
10};
11use tui_dispatch_core::{Component, EventKind};
12
13use crate::style::{BaseStyle, ComponentStyle, Padding, ScrollbarStyle, SelectionStyle};
14
15/// Unified styling for SelectList
16#[derive(Debug, Clone)]
17pub struct SelectListStyle {
18    /// Shared base style
19    pub base: BaseStyle,
20    /// Selection indication styling
21    pub selection: SelectionStyle,
22    /// Scrollbar styling
23    pub scrollbar: ScrollbarStyle,
24}
25
26impl Default for SelectListStyle {
27    fn default() -> Self {
28        Self {
29            base: BaseStyle {
30                fg: Some(ratatui::style::Color::Reset),
31                ..Default::default()
32            },
33            selection: SelectionStyle::default(),
34            scrollbar: ScrollbarStyle::default(),
35        }
36    }
37}
38
39impl SelectListStyle {
40    /// Create a style with no border
41    pub fn borderless() -> Self {
42        let mut style = Self::default();
43        style.base.border = None;
44        style
45    }
46
47    /// Create a minimal style (no border, no padding)
48    pub fn minimal() -> Self {
49        let mut style = Self::default();
50        style.base.border = None;
51        style.base.padding = Padding::default();
52        style
53    }
54}
55
56impl ComponentStyle for SelectListStyle {
57    fn base(&self) -> &BaseStyle {
58        &self.base
59    }
60}
61
62/// Behavior configuration for SelectList
63#[derive(Debug, Clone)]
64pub struct SelectListBehavior {
65    /// Show scrollbar when content exceeds viewport
66    pub show_scrollbar: bool,
67    /// Wrap navigation from last to first item (and vice versa)
68    pub wrap_navigation: bool,
69}
70
71impl Default for SelectListBehavior {
72    fn default() -> Self {
73        Self {
74            show_scrollbar: true,
75            wrap_navigation: false,
76        }
77    }
78}
79
80/// Props for SelectList component
81pub struct SelectListProps<'a, T, A> {
82    /// Items to render
83    pub items: &'a [T],
84    /// Total count (may differ from items.len() for virtual lists)
85    pub count: usize,
86    /// Currently selected index
87    pub selected: usize,
88    /// Whether this component has focus
89    pub is_focused: bool,
90    /// Unified styling
91    pub style: SelectListStyle,
92    /// Behavior configuration
93    pub behavior: SelectListBehavior,
94    /// Callback to create action when selection changes
95    pub on_select: fn(usize) -> A,
96    /// Render a single item into a Line
97    pub render_item: &'a dyn Fn(&T) -> Line<'static>,
98}
99
100impl<'a, T, A> SelectListProps<'a, T, A> {
101    /// Create props with sensible defaults
102    ///
103    /// Sets `count` to `items.len()`, `is_focused` to `true`, and uses default style/behavior.
104    pub fn new(
105        items: &'a [T],
106        selected: usize,
107        on_select: fn(usize) -> A,
108        render_item: &'a dyn Fn(&T) -> Line<'static>,
109    ) -> Self {
110        Self {
111            items,
112            count: items.len(),
113            selected,
114            is_focused: true,
115            style: SelectListStyle::default(),
116            behavior: SelectListBehavior::default(),
117            on_select,
118            render_item,
119        }
120    }
121}
122
123/// A scrollable selection list with keyboard navigation
124///
125/// Handles j/k/up/down for navigation and enter for selection.
126/// Generic over item type `T` - provide a `render_item` callback to convert to Lines.
127#[derive(Default)]
128pub struct SelectList {
129    /// Scroll offset for viewport
130    scroll_offset: usize,
131}
132
133impl SelectList {
134    /// Create a new SelectList
135    pub fn new() -> Self {
136        Self::default()
137    }
138
139    /// Ensure the selected index is visible within the viewport
140    fn ensure_visible(&mut self, selected: usize, viewport_height: usize) {
141        if viewport_height == 0 {
142            return;
143        }
144
145        if selected < self.scroll_offset {
146            self.scroll_offset = selected;
147        } else if selected >= self.scroll_offset + viewport_height {
148            self.scroll_offset = selected.saturating_sub(viewport_height - 1);
149        }
150    }
151}
152
153impl<A> Component<A> for SelectList {
154    type Props<'a> = SelectListProps<'a, Line<'static>, A>;
155
156    fn handle_event(
157        &mut self,
158        event: &EventKind,
159        props: Self::Props<'_>,
160    ) -> impl IntoIterator<Item = A> {
161        if !props.is_focused || props.count == 0 {
162            return None;
163        }
164
165        let len = props.count;
166
167        match event {
168            EventKind::Key(key) => match key.code {
169                // Navigate down
170                KeyCode::Char('j') | KeyCode::Down => {
171                    let new_idx = if props.behavior.wrap_navigation && props.selected == len - 1 {
172                        0
173                    } else {
174                        (props.selected + 1).min(len.saturating_sub(1))
175                    };
176                    if new_idx != props.selected {
177                        Some((props.on_select)(new_idx))
178                    } else {
179                        None
180                    }
181                }
182                // Navigate up
183                KeyCode::Char('k') | KeyCode::Up => {
184                    let new_idx = if props.behavior.wrap_navigation && props.selected == 0 {
185                        len.saturating_sub(1)
186                    } else {
187                        props.selected.saturating_sub(1)
188                    };
189                    if new_idx != props.selected {
190                        Some((props.on_select)(new_idx))
191                    } else {
192                        None
193                    }
194                }
195                // Jump to top
196                KeyCode::Char('g') | KeyCode::Home => {
197                    if props.selected != 0 {
198                        Some((props.on_select)(0))
199                    } else {
200                        None
201                    }
202                }
203                // Jump to bottom
204                KeyCode::Char('G') | KeyCode::End => {
205                    let last = len.saturating_sub(1);
206                    if props.selected != last {
207                        Some((props.on_select)(last))
208                    } else {
209                        None
210                    }
211                }
212                // Select current (re-emit for confirmation actions)
213                KeyCode::Enter => Some((props.on_select)(props.selected)),
214                _ => None,
215            },
216            _ => None,
217        }
218    }
219
220    fn render(&mut self, frame: &mut Frame, area: Rect, props: Self::Props<'_>) {
221        let style = &props.style;
222
223        // Fill background if specified
224        if let Some(bg) = style.base.bg {
225            for y in area.y..area.y.saturating_add(area.height) {
226                for x in area.x..area.x.saturating_add(area.width) {
227                    frame.buffer_mut()[(x, y)].set_bg(bg);
228                    frame.buffer_mut()[(x, y)].set_symbol(" ");
229                }
230            }
231        }
232
233        // Apply padding
234        let content_area = Rect {
235            x: area.x + style.base.padding.left,
236            y: area.y + style.base.padding.top,
237            width: area.width.saturating_sub(style.base.padding.horizontal()),
238            height: area.height.saturating_sub(style.base.padding.vertical()),
239        };
240
241        let mut inner_area = content_area;
242        if let Some(border) = &style.base.border {
243            let block = Block::default()
244                .borders(border.borders)
245                .border_style(border.style_for_focus(props.is_focused));
246            inner_area = block.inner(content_area);
247            frame.render_widget(block, content_area);
248        }
249
250        let viewport_height = inner_area.height as usize;
251        let render_selected = props.selected.min(props.items.len().saturating_sub(1));
252
253        // Ensure selected item is visible
254        if !props.items.is_empty() && viewport_height > 0 {
255            self.ensure_visible(render_selected, viewport_height);
256        }
257
258        if viewport_height > 0 {
259            let max_offset = props.count.saturating_sub(viewport_height);
260            self.scroll_offset = self.scroll_offset.min(max_offset);
261        }
262
263        let show_scrollbar = props.behavior.show_scrollbar
264            && viewport_height > 0
265            && props.count > viewport_height
266            && inner_area.width > 1;
267        let mut list_area = inner_area;
268        let scrollbar_area = if show_scrollbar {
269            let scrollbar_area = Rect {
270                x: inner_area.x + inner_area.width.saturating_sub(1),
271                width: 1,
272                ..inner_area
273            };
274            list_area.width = list_area.width.saturating_sub(1);
275            Some(scrollbar_area)
276        } else {
277            None
278        };
279
280        // Build list items with optional selection styling
281        let items: Vec<ListItem> = props
282            .items
283            .iter()
284            .enumerate()
285            .map(|(i, item)| {
286                let is_selected = i == render_selected;
287                let line = (props.render_item)(item);
288
289                // Apply selection styling unless disabled
290                if style.selection.disabled {
291                    ListItem::new(line)
292                } else {
293                    // Build the line with optional marker
294                    let display_line = if let Some(marker) = style.selection.marker {
295                        let prefix = if is_selected {
296                            marker
297                        } else {
298                            &"  "[..marker.len().min(2)]
299                        };
300                        let mut spans = vec![Span::raw(prefix)];
301                        spans.extend(line.spans.iter().cloned());
302                        Line::from(spans)
303                    } else {
304                        line
305                    };
306
307                    // Apply selection style (or base fg color for non-selected)
308                    let item_style = if is_selected {
309                        style.selection.style.unwrap_or_default()
310                    } else {
311                        let mut s = Style::default();
312                        if let Some(fg) = style.base.fg {
313                            s = s.fg(fg);
314                        }
315                        s
316                    };
317
318                    ListItem::new(display_line).style(item_style)
319                }
320            })
321            .collect();
322
323        // Create the list widget
324        let highlight_style = if style.selection.disabled {
325            Style::default()
326        } else {
327            style.selection.style.unwrap_or_default()
328        };
329        let list = List::new(items).highlight_style(highlight_style);
330
331        // Use ListState to handle scroll offset
332        let selected = if props.items.is_empty() {
333            None
334        } else {
335            Some(render_selected)
336        };
337        let mut state = ListState::default().with_selected(selected);
338        *state.offset_mut() = self.scroll_offset;
339
340        frame.render_stateful_widget(list, list_area, &mut state);
341
342        if let Some(scrollbar_area) = scrollbar_area {
343            let scrollbar = style.scrollbar.build(ScrollbarOrientation::VerticalRight);
344            let scrollbar_len = props
345                .count
346                .saturating_sub(viewport_height)
347                .saturating_add(1);
348            let mut scrollbar_state = ScrollbarState::new(scrollbar_len)
349                .position(self.scroll_offset)
350                .viewport_content_length(viewport_height.max(1));
351            frame.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
352        }
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use tui_dispatch_core::testing::{key, RenderHarness};
360
361    #[derive(Debug, Clone, PartialEq)]
362    enum TestAction {
363        Select(usize),
364    }
365
366    fn make_items() -> Vec<Line<'static>> {
367        vec![
368            Line::raw("Item 0"),
369            Line::raw("Item 1"),
370            Line::raw("Item 2"),
371        ]
372    }
373
374    fn render_item(item: &Line<'static>) -> Line<'static> {
375        item.clone()
376    }
377
378    #[test]
379    fn test_navigate_down() {
380        let mut list = SelectList::new();
381        let items = make_items();
382        let props = SelectListProps {
383            items: &items,
384            count: items.len(),
385            selected: 0,
386            is_focused: true,
387            style: SelectListStyle::default(),
388            behavior: SelectListBehavior::default(),
389            on_select: TestAction::Select,
390            render_item: &render_item,
391        };
392
393        let actions: Vec<_> = list
394            .handle_event(&EventKind::Key(key("j")), props)
395            .into_iter()
396            .collect();
397
398        assert_eq!(actions, vec![TestAction::Select(1)]);
399    }
400
401    #[test]
402    fn test_navigate_up() {
403        let mut list = SelectList::new();
404        let items = make_items();
405        let props = SelectListProps {
406            items: &items,
407            count: items.len(),
408            selected: 2,
409            is_focused: true,
410            style: SelectListStyle::default(),
411            behavior: SelectListBehavior::default(),
412            on_select: TestAction::Select,
413            render_item: &render_item,
414        };
415
416        let actions: Vec<_> = list
417            .handle_event(&EventKind::Key(key("k")), props)
418            .into_iter()
419            .collect();
420
421        assert_eq!(actions, vec![TestAction::Select(1)]);
422    }
423
424    #[test]
425    fn test_navigate_at_bounds() {
426        let mut list = SelectList::new();
427        let items = make_items();
428
429        // At top, going up should not emit
430        let props = SelectListProps {
431            items: &items,
432            count: items.len(),
433            selected: 0,
434            is_focused: true,
435            style: SelectListStyle::default(),
436            behavior: SelectListBehavior::default(),
437            on_select: TestAction::Select,
438            render_item: &render_item,
439        };
440        let actions: Vec<_> = list
441            .handle_event(&EventKind::Key(key("k")), props)
442            .into_iter()
443            .collect();
444        assert!(actions.is_empty());
445
446        // At bottom, going down should not emit
447        let props = SelectListProps {
448            items: &items,
449            count: items.len(),
450            selected: 2,
451            is_focused: true,
452            style: SelectListStyle::default(),
453            behavior: SelectListBehavior::default(),
454            on_select: TestAction::Select,
455            render_item: &render_item,
456        };
457        let actions: Vec<_> = list
458            .handle_event(&EventKind::Key(key("j")), props)
459            .into_iter()
460            .collect();
461        assert!(actions.is_empty());
462    }
463
464    #[test]
465    fn test_wrap_navigation() {
466        let mut list = SelectList::new();
467        let items = make_items();
468
469        // At top with wrap, going up should go to bottom
470        let props = SelectListProps {
471            items: &items,
472            count: items.len(),
473            selected: 0,
474            is_focused: true,
475            style: SelectListStyle::default(),
476            behavior: SelectListBehavior {
477                wrap_navigation: true,
478                ..Default::default()
479            },
480            on_select: TestAction::Select,
481            render_item: &render_item,
482        };
483        let actions: Vec<_> = list
484            .handle_event(&EventKind::Key(key("k")), props)
485            .into_iter()
486            .collect();
487        assert_eq!(actions, vec![TestAction::Select(2)]);
488
489        // At bottom with wrap, going down should go to top
490        let props = SelectListProps {
491            items: &items,
492            count: items.len(),
493            selected: 2,
494            is_focused: true,
495            style: SelectListStyle::default(),
496            behavior: SelectListBehavior {
497                wrap_navigation: true,
498                ..Default::default()
499            },
500            on_select: TestAction::Select,
501            render_item: &render_item,
502        };
503        let actions: Vec<_> = list
504            .handle_event(&EventKind::Key(key("j")), props)
505            .into_iter()
506            .collect();
507        assert_eq!(actions, vec![TestAction::Select(0)]);
508    }
509
510    #[test]
511    fn test_unfocused_ignores_events() {
512        let mut list = SelectList::new();
513        let items = make_items();
514        let props = SelectListProps {
515            items: &items,
516            count: items.len(),
517            selected: 0,
518            is_focused: false,
519            style: SelectListStyle::default(),
520            behavior: SelectListBehavior::default(),
521            on_select: TestAction::Select,
522            render_item: &render_item,
523        };
524
525        let actions: Vec<_> = list
526            .handle_event(&EventKind::Key(key("j")), props)
527            .into_iter()
528            .collect();
529
530        assert!(actions.is_empty());
531    }
532
533    #[test]
534    fn test_enter_selects_current() {
535        let mut list = SelectList::new();
536        let items = make_items();
537        let props = SelectListProps {
538            items: &items,
539            count: items.len(),
540            selected: 1,
541            is_focused: true,
542            style: SelectListStyle::default(),
543            behavior: SelectListBehavior::default(),
544            on_select: TestAction::Select,
545            render_item: &render_item,
546        };
547
548        let actions: Vec<_> = list
549            .handle_event(&EventKind::Key(key("enter")), props)
550            .into_iter()
551            .collect();
552
553        assert_eq!(actions, vec![TestAction::Select(1)]);
554    }
555
556    #[test]
557    fn test_render() {
558        let mut render = RenderHarness::new(30, 10);
559        let mut list = SelectList::new();
560        let items = make_items();
561
562        let output = render.render_to_string_plain(|frame| {
563            let props = SelectListProps {
564                items: &items,
565                count: items.len(),
566                selected: 1,
567                is_focused: true,
568                style: SelectListStyle::default(),
569                behavior: SelectListBehavior::default(),
570                on_select: |_| (),
571                render_item: &render_item,
572            };
573            list.render(frame, frame.area(), props);
574        });
575
576        assert!(output.contains("Item 0"));
577        assert!(output.contains("Item 1"));
578        assert!(output.contains("Item 2"));
579    }
580
581    #[test]
582    fn test_render_without_selection_styling() {
583        let mut render = RenderHarness::new(30, 10);
584        let mut list = SelectList::new();
585        let items = make_items();
586
587        let output = render.render_to_string_plain(|frame| {
588            let props = SelectListProps {
589                items: &items,
590                count: items.len(),
591                selected: 1,
592                is_focused: true,
593                style: SelectListStyle {
594                    selection: SelectionStyle::disabled(),
595                    ..Default::default()
596                },
597                behavior: SelectListBehavior::default(),
598                on_select: |_| (),
599                render_item: &render_item,
600            };
601            list.render(frame, frame.area(), props);
602        });
603
604        // Should render items without markers
605        assert!(output.contains("Item 0"));
606        assert!(output.contains("Item 1"));
607        assert!(output.contains("Item 2"));
608        // Should not contain selection marker
609        assert!(!output.contains(">"));
610    }
611}