Skip to main content

tui_dispatch_components/
select_list.rs

1//! Scrollable selection list component
2
3use std::marker::PhantomData;
4use std::rc::Rc;
5
6use crossterm::event::KeyCode;
7use ratatui::{
8    layout::Rect,
9    style::Style,
10    text::{Line, Span},
11    widgets::{Block, List, ListItem, ListState, ScrollbarOrientation, ScrollbarState},
12    Frame,
13};
14use tui_dispatch_core::{Component, EventKind, HandlerResponse};
15
16use crate::commands;
17use crate::style::{BaseStyle, ComponentStyle, Padding, ScrollbarStyle, SelectionStyle};
18use crate::{ComponentDebugEntry, ComponentDebugState, ComponentInput, InteractiveComponent};
19
20/// Unified styling for SelectList
21#[derive(Debug, Clone)]
22pub struct SelectListStyle {
23    /// Shared base style
24    pub base: BaseStyle,
25    /// Selection indication styling
26    pub selection: SelectionStyle,
27    /// Scrollbar styling
28    pub scrollbar: ScrollbarStyle,
29}
30
31impl Default for SelectListStyle {
32    fn default() -> Self {
33        Self {
34            base: BaseStyle {
35                fg: Some(ratatui::style::Color::Reset),
36                ..Default::default()
37            },
38            selection: SelectionStyle::default(),
39            scrollbar: ScrollbarStyle::default(),
40        }
41    }
42}
43
44impl SelectListStyle {
45    /// Create a style with no border
46    pub fn borderless() -> Self {
47        let mut style = Self::default();
48        style.base.border = None;
49        style
50    }
51
52    /// Create a minimal style (no border, no padding)
53    pub fn minimal() -> Self {
54        let mut style = Self::default();
55        style.base.border = None;
56        style.base.padding = Padding::default();
57        style
58    }
59}
60
61impl ComponentStyle for SelectListStyle {
62    fn base(&self) -> &BaseStyle {
63        &self.base
64    }
65}
66
67/// Behavior configuration for SelectList
68#[derive(Debug, Clone)]
69pub struct SelectListBehavior {
70    /// Show scrollbar when content exceeds viewport
71    pub show_scrollbar: bool,
72    /// Wrap navigation from last to first item (and vice versa)
73    pub wrap_navigation: bool,
74}
75
76impl Default for SelectListBehavior {
77    fn default() -> Self {
78        Self {
79            show_scrollbar: true,
80            wrap_navigation: false,
81        }
82    }
83}
84
85/// Callback to create an action when the selected index changes.
86pub type SelectListCallback<A> = Rc<dyn Fn(usize) -> A>;
87
88/// Props for SelectList component
89#[derive(Clone)]
90pub struct SelectListProps<'a, T, A> {
91    /// Items to render
92    pub items: &'a [T],
93    /// Total count (may differ from items.len() for virtual lists)
94    pub count: usize,
95    /// Currently selected index
96    pub selected: usize,
97    /// Whether this component has focus
98    pub is_focused: bool,
99    /// Unified styling
100    pub style: SelectListStyle,
101    /// Behavior configuration
102    pub behavior: SelectListBehavior,
103    /// Callback to create action when selection changes
104    pub on_select: SelectListCallback<A>,
105    /// Render a single item into a Line
106    pub render_item: &'a dyn Fn(&T) -> Line<'static>,
107}
108
109/// Render-only props for SelectList
110pub struct SelectListRenderProps<'a, T> {
111    /// Items to render
112    pub items: &'a [T],
113    /// Total count (may differ from items.len() for virtual lists)
114    pub count: usize,
115    /// Currently selected index
116    pub selected: usize,
117    /// Whether this component has focus
118    pub is_focused: bool,
119    /// Unified styling
120    pub style: SelectListStyle,
121    /// Behavior configuration
122    pub behavior: SelectListBehavior,
123    /// Render a single item into a Line
124    pub render_item: &'a dyn Fn(&T) -> Line<'static>,
125}
126
127impl<'a, T, A> SelectListProps<'a, T, A> {
128    /// Create props with sensible defaults
129    ///
130    /// Sets `count` to `items.len()`, `is_focused` to `true`, and uses default style/behavior.
131    pub fn new(
132        items: &'a [T],
133        selected: usize,
134        on_select: SelectListCallback<A>,
135        render_item: &'a dyn Fn(&T) -> Line<'static>,
136    ) -> Self {
137        Self {
138            items,
139            count: items.len(),
140            selected,
141            is_focused: true,
142            style: SelectListStyle::default(),
143            behavior: SelectListBehavior::default(),
144            on_select,
145            render_item,
146        }
147    }
148}
149
150/// A scrollable selection list with keyboard navigation
151///
152/// Handles j/k/up/down for navigation and enter for selection.
153/// Generic over item type `T` - provide a `render_item` callback to convert to Lines.
154pub struct SelectList<Item = Line<'static>> {
155    /// Scroll offset for viewport
156    scroll_offset: usize,
157    _marker: PhantomData<fn() -> Item>,
158}
159
160impl<Item> Default for SelectList<Item> {
161    fn default() -> Self {
162        Self {
163            scroll_offset: 0,
164            _marker: PhantomData,
165        }
166    }
167}
168
169impl<Item> SelectList<Item> {
170    /// Create a new SelectList
171    pub fn new() -> Self {
172        Self::default()
173    }
174
175    /// Render the widget without requiring selection callbacks.
176    pub fn render_widget(
177        &mut self,
178        frame: &mut Frame,
179        area: Rect,
180        props: SelectListRenderProps<'_, Item>,
181    ) {
182        self.render_with(frame, area, props);
183    }
184
185    /// Ensure the selected index is visible within the viewport
186    fn ensure_visible(&mut self, selected: usize, viewport_height: usize) {
187        if viewport_height == 0 {
188            return;
189        }
190
191        if selected < self.scroll_offset {
192            self.scroll_offset = selected;
193        } else if selected >= self.scroll_offset + viewport_height {
194            self.scroll_offset = selected.saturating_sub(viewport_height - 1);
195        }
196    }
197
198    fn next_index(&self, selected: usize, len: usize, wrap_navigation: bool) -> usize {
199        if wrap_navigation && selected == len.saturating_sub(1) {
200            0
201        } else {
202            (selected + 1).min(len.saturating_sub(1))
203        }
204    }
205
206    fn prev_index(&self, selected: usize, len: usize, wrap_navigation: bool) -> usize {
207        if wrap_navigation && selected == 0 {
208            len.saturating_sub(1)
209        } else {
210            selected.saturating_sub(1)
211        }
212    }
213
214    fn select_action<A>(
215        &self,
216        selected: usize,
217        next: usize,
218        on_select: &dyn Fn(usize) -> A,
219    ) -> Option<A> {
220        (next != selected).then(|| on_select(next))
221    }
222
223    fn handle_navigation<A>(
224        &mut self,
225        command: NavigationCommand,
226        props: &SelectListProps<'_, Item, A>,
227    ) -> Option<A> {
228        if !props.is_focused || props.count == 0 {
229            return None;
230        }
231
232        let len = props.count;
233
234        match command {
235            NavigationCommand::Next => self.select_action(
236                props.selected,
237                self.next_index(props.selected, len, props.behavior.wrap_navigation),
238                props.on_select.as_ref(),
239            ),
240            NavigationCommand::Prev => self.select_action(
241                props.selected,
242                self.prev_index(props.selected, len, props.behavior.wrap_navigation),
243                props.on_select.as_ref(),
244            ),
245            NavigationCommand::First => {
246                self.select_action(props.selected, 0, props.on_select.as_ref())
247            }
248            NavigationCommand::Last => self.select_action(
249                props.selected,
250                len.saturating_sub(1),
251                props.on_select.as_ref(),
252            ),
253            NavigationCommand::Select => Some((props.on_select.as_ref())(props.selected)),
254        }
255    }
256
257    fn render_with(
258        &mut self,
259        frame: &mut Frame,
260        area: Rect,
261        props: SelectListRenderProps<'_, Item>,
262    ) {
263        let style = &props.style;
264
265        // Fill background if specified
266        if let Some(bg) = style.base.bg {
267            for y in area.y..area.y.saturating_add(area.height) {
268                for x in area.x..area.x.saturating_add(area.width) {
269                    frame.buffer_mut()[(x, y)].set_bg(bg);
270                    frame.buffer_mut()[(x, y)].set_symbol(" ");
271                }
272            }
273        }
274
275        // Apply padding
276        let content_area = Rect {
277            x: area.x + style.base.padding.left,
278            y: area.y + style.base.padding.top,
279            width: area.width.saturating_sub(style.base.padding.horizontal()),
280            height: area.height.saturating_sub(style.base.padding.vertical()),
281        };
282
283        let mut inner_area = content_area;
284        if let Some(border) = &style.base.border {
285            let block = Block::default()
286                .borders(border.borders)
287                .border_style(border.style_for_focus(props.is_focused));
288            inner_area = block.inner(content_area);
289            frame.render_widget(block, content_area);
290        }
291
292        let viewport_height = inner_area.height as usize;
293        let render_selected = props.selected.min(props.items.len().saturating_sub(1));
294
295        // Ensure selected item is visible
296        if !props.items.is_empty() && viewport_height > 0 {
297            self.ensure_visible(render_selected, viewport_height);
298        }
299
300        if viewport_height > 0 {
301            let max_offset = props.count.saturating_sub(viewport_height);
302            self.scroll_offset = self.scroll_offset.min(max_offset);
303        }
304
305        let show_scrollbar = props.behavior.show_scrollbar
306            && viewport_height > 0
307            && props.count > viewport_height
308            && inner_area.width > 1;
309        let mut list_area = inner_area;
310        let scrollbar_area = if show_scrollbar {
311            let scrollbar_area = Rect {
312                x: inner_area.x + inner_area.width.saturating_sub(1),
313                width: 1,
314                ..inner_area
315            };
316            list_area.width = list_area.width.saturating_sub(1);
317            Some(scrollbar_area)
318        } else {
319            None
320        };
321
322        // Build list items with optional selection styling
323        let items: Vec<ListItem> = props
324            .items
325            .iter()
326            .enumerate()
327            .map(|(i, item)| {
328                let is_selected = i == render_selected;
329                let line = (props.render_item)(item);
330
331                // Apply selection styling unless disabled
332                if style.selection.disabled {
333                    ListItem::new(line)
334                } else {
335                    // Build the line with optional marker
336                    let display_line = if let Some(marker) = style.selection.marker {
337                        let prefix = if is_selected {
338                            marker
339                        } else {
340                            &"  "[..marker.len().min(2)]
341                        };
342                        let mut spans = vec![Span::raw(prefix)];
343                        spans.extend(line.spans.iter().cloned());
344                        Line::from(spans)
345                    } else {
346                        line
347                    };
348
349                    // Apply selection style (or base fg color for non-selected)
350                    let item_style = if is_selected {
351                        style.selection.style.unwrap_or_default()
352                    } else {
353                        let mut s = Style::default();
354                        if let Some(fg) = style.base.fg {
355                            s = s.fg(fg);
356                        }
357                        s
358                    };
359
360                    ListItem::new(display_line).style(item_style)
361                }
362            })
363            .collect();
364
365        // Create the list widget
366        let highlight_style = if style.selection.disabled {
367            Style::default()
368        } else {
369            style.selection.style.unwrap_or_default()
370        };
371        let list = List::new(items).highlight_style(highlight_style);
372
373        // Use ListState to handle scroll offset
374        let selected = if props.items.is_empty() {
375            None
376        } else {
377            Some(render_selected)
378        };
379        let mut state = ListState::default().with_selected(selected);
380        *state.offset_mut() = self.scroll_offset;
381
382        frame.render_stateful_widget(list, list_area, &mut state);
383
384        if let Some(scrollbar_area) = scrollbar_area {
385            let scrollbar = style.scrollbar.build(ScrollbarOrientation::VerticalRight);
386            let scrollbar_len = props
387                .count
388                .saturating_sub(viewport_height)
389                .saturating_add(1);
390            let mut scrollbar_state = ScrollbarState::new(scrollbar_len)
391                .position(self.scroll_offset)
392                .viewport_content_length(viewport_height.max(1));
393            frame.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
394        }
395    }
396}
397
398#[derive(Clone, Copy)]
399enum NavigationCommand {
400    Next,
401    Prev,
402    First,
403    Last,
404    Select,
405}
406
407impl<Item, A> Component<A> for SelectList<Item> {
408    type Props<'a>
409        = SelectListProps<'a, Item, A>
410    where
411        Item: 'a;
412
413    fn handle_event(
414        &mut self,
415        event: &EventKind,
416        props: Self::Props<'_>,
417    ) -> impl IntoIterator<Item = A> {
418        if !props.is_focused {
419            return None;
420        }
421
422        match event {
423            EventKind::Key(key) => match key.code {
424                KeyCode::Char('j') | KeyCode::Down => {
425                    self.handle_navigation(NavigationCommand::Next, &props)
426                }
427                KeyCode::Char('k') | KeyCode::Up => {
428                    self.handle_navigation(NavigationCommand::Prev, &props)
429                }
430                KeyCode::Char('g') | KeyCode::Home => {
431                    self.handle_navigation(NavigationCommand::First, &props)
432                }
433                KeyCode::Char('G') | KeyCode::End => {
434                    self.handle_navigation(NavigationCommand::Last, &props)
435                }
436                KeyCode::Enter => self.handle_navigation(NavigationCommand::Select, &props),
437                _ => None,
438            },
439            _ => None,
440        }
441    }
442
443    fn render(&mut self, frame: &mut Frame, area: Rect, props: Self::Props<'_>) {
444        self.render_with(
445            frame,
446            area,
447            SelectListRenderProps {
448                items: props.items,
449                count: props.count,
450                selected: props.selected,
451                is_focused: props.is_focused,
452                style: props.style,
453                behavior: props.behavior,
454                render_item: props.render_item,
455            },
456        );
457    }
458}
459
460impl<Item> ComponentDebugState for SelectList<Item> {
461    fn debug_state(&self) -> Vec<ComponentDebugEntry> {
462        vec![ComponentDebugEntry::new(
463            "scroll_offset",
464            self.scroll_offset.to_string(),
465        )]
466    }
467}
468
469impl<Item, A, Ctx> InteractiveComponent<A, Ctx> for SelectList<Item> {
470    type Props<'a>
471        = SelectListProps<'a, Item, A>
472    where
473        Item: 'a;
474
475    fn update(
476        &mut self,
477        input: ComponentInput<'_, Ctx>,
478        props: Self::Props<'_>,
479    ) -> HandlerResponse<A> {
480        if !props.is_focused {
481            return HandlerResponse::ignored();
482        }
483
484        let action = match input {
485            ComponentInput::Command { name, .. } => match name {
486                commands::NEXT | commands::DOWN => {
487                    self.handle_navigation(NavigationCommand::Next, &props)
488                }
489                commands::PREV | commands::UP => {
490                    self.handle_navigation(NavigationCommand::Prev, &props)
491                }
492                commands::FIRST | commands::HOME => {
493                    self.handle_navigation(NavigationCommand::First, &props)
494                }
495                commands::LAST | commands::END => {
496                    self.handle_navigation(NavigationCommand::Last, &props)
497                }
498                commands::SELECT | commands::CONFIRM => {
499                    self.handle_navigation(NavigationCommand::Select, &props)
500                }
501                _ => None,
502            },
503            ComponentInput::Key(key) => match key.code {
504                KeyCode::Char('j') | KeyCode::Down => {
505                    self.handle_navigation(NavigationCommand::Next, &props)
506                }
507                KeyCode::Char('k') | KeyCode::Up => {
508                    self.handle_navigation(NavigationCommand::Prev, &props)
509                }
510                KeyCode::Char('g') | KeyCode::Home => {
511                    self.handle_navigation(NavigationCommand::First, &props)
512                }
513                KeyCode::Char('G') | KeyCode::End => {
514                    self.handle_navigation(NavigationCommand::Last, &props)
515                }
516                KeyCode::Enter => self.handle_navigation(NavigationCommand::Select, &props),
517                _ => None,
518            },
519            _ => None,
520        };
521
522        match action {
523            Some(action) => HandlerResponse::action(action),
524            None => HandlerResponse::ignored(),
525        }
526    }
527
528    fn render(&mut self, frame: &mut Frame, area: Rect, props: Self::Props<'_>) {
529        <Self as Component<A>>::render(self, frame, area, props);
530    }
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536    use tui_dispatch_core::testing::{key, RenderHarness};
537
538    #[derive(Debug, Clone, PartialEq)]
539    enum TestAction {
540        Select(usize),
541    }
542
543    fn make_items() -> Vec<Line<'static>> {
544        vec![
545            Line::raw("Item 0"),
546            Line::raw("Item 1"),
547            Line::raw("Item 2"),
548        ]
549    }
550
551    fn render_item(item: &Line<'static>) -> Line<'static> {
552        item.clone()
553    }
554
555    #[test]
556    fn test_navigate_down() {
557        let mut list = SelectList::new();
558        let items = make_items();
559        let props = SelectListProps {
560            items: &items,
561            count: items.len(),
562            selected: 0,
563            is_focused: true,
564            style: SelectListStyle::default(),
565            behavior: SelectListBehavior::default(),
566            on_select: Rc::new(TestAction::Select),
567            render_item: &render_item,
568        };
569
570        let actions: Vec<_> = list
571            .handle_event(&EventKind::Key(key("j")), props)
572            .into_iter()
573            .collect();
574
575        assert_eq!(actions, vec![TestAction::Select(1)]);
576    }
577
578    #[test]
579    fn test_navigate_up() {
580        let mut list = SelectList::new();
581        let items = make_items();
582        let props = SelectListProps {
583            items: &items,
584            count: items.len(),
585            selected: 2,
586            is_focused: true,
587            style: SelectListStyle::default(),
588            behavior: SelectListBehavior::default(),
589            on_select: Rc::new(TestAction::Select),
590            render_item: &render_item,
591        };
592
593        let actions: Vec<_> = list
594            .handle_event(&EventKind::Key(key("k")), props)
595            .into_iter()
596            .collect();
597
598        assert_eq!(actions, vec![TestAction::Select(1)]);
599    }
600
601    #[test]
602    fn test_navigate_at_bounds() {
603        let mut list = SelectList::new();
604        let items = make_items();
605
606        // At top, going up should not emit
607        let props = SelectListProps {
608            items: &items,
609            count: items.len(),
610            selected: 0,
611            is_focused: true,
612            style: SelectListStyle::default(),
613            behavior: SelectListBehavior::default(),
614            on_select: Rc::new(TestAction::Select),
615            render_item: &render_item,
616        };
617        let actions: Vec<_> = list
618            .handle_event(&EventKind::Key(key("k")), props)
619            .into_iter()
620            .collect();
621        assert!(actions.is_empty());
622
623        // At bottom, going down should not emit
624        let props = SelectListProps {
625            items: &items,
626            count: items.len(),
627            selected: 2,
628            is_focused: true,
629            style: SelectListStyle::default(),
630            behavior: SelectListBehavior::default(),
631            on_select: Rc::new(TestAction::Select),
632            render_item: &render_item,
633        };
634        let actions: Vec<_> = list
635            .handle_event(&EventKind::Key(key("j")), props)
636            .into_iter()
637            .collect();
638        assert!(actions.is_empty());
639    }
640
641    #[test]
642    fn test_wrap_navigation() {
643        let mut list = SelectList::new();
644        let items = make_items();
645
646        // At top with wrap, going up should go to bottom
647        let props = SelectListProps {
648            items: &items,
649            count: items.len(),
650            selected: 0,
651            is_focused: true,
652            style: SelectListStyle::default(),
653            behavior: SelectListBehavior {
654                wrap_navigation: true,
655                ..Default::default()
656            },
657            on_select: Rc::new(TestAction::Select),
658            render_item: &render_item,
659        };
660        let actions: Vec<_> = list
661            .handle_event(&EventKind::Key(key("k")), props)
662            .into_iter()
663            .collect();
664        assert_eq!(actions, vec![TestAction::Select(2)]);
665
666        // At bottom with wrap, going down should go to top
667        let props = SelectListProps {
668            items: &items,
669            count: items.len(),
670            selected: 2,
671            is_focused: true,
672            style: SelectListStyle::default(),
673            behavior: SelectListBehavior {
674                wrap_navigation: true,
675                ..Default::default()
676            },
677            on_select: Rc::new(TestAction::Select),
678            render_item: &render_item,
679        };
680        let actions: Vec<_> = list
681            .handle_event(&EventKind::Key(key("j")), props)
682            .into_iter()
683            .collect();
684        assert_eq!(actions, vec![TestAction::Select(0)]);
685    }
686
687    #[test]
688    fn test_unfocused_ignores_events() {
689        let mut list = SelectList::new();
690        let items = make_items();
691        let props = SelectListProps {
692            items: &items,
693            count: items.len(),
694            selected: 0,
695            is_focused: false,
696            style: SelectListStyle::default(),
697            behavior: SelectListBehavior::default(),
698            on_select: Rc::new(TestAction::Select),
699            render_item: &render_item,
700        };
701
702        let actions: Vec<_> = list
703            .handle_event(&EventKind::Key(key("j")), props)
704            .into_iter()
705            .collect();
706
707        assert!(actions.is_empty());
708    }
709
710    #[test]
711    fn test_unfocused_ignores_commands() {
712        let mut list = SelectList::new();
713        let items = make_items();
714        let props = SelectListProps {
715            items: &items,
716            count: items.len(),
717            selected: 0,
718            is_focused: false,
719            style: SelectListStyle::default(),
720            behavior: SelectListBehavior::default(),
721            on_select: Rc::new(TestAction::Select),
722            render_item: &render_item,
723        };
724
725        let response = <SelectList as InteractiveComponent<TestAction, ()>>::update(
726            &mut list,
727            ComponentInput::Command {
728                name: "next",
729                ctx: (),
730            },
731            props,
732        );
733
734        assert!(response.actions.is_empty());
735        assert!(!response.consumed);
736        assert!(!response.needs_render);
737    }
738
739    #[test]
740    fn test_enter_selects_current() {
741        let mut list = SelectList::new();
742        let items = make_items();
743        let props = SelectListProps {
744            items: &items,
745            count: items.len(),
746            selected: 1,
747            is_focused: true,
748            style: SelectListStyle::default(),
749            behavior: SelectListBehavior::default(),
750            on_select: Rc::new(TestAction::Select),
751            render_item: &render_item,
752        };
753
754        let actions: Vec<_> = list
755            .handle_event(&EventKind::Key(key("enter")), props)
756            .into_iter()
757            .collect();
758
759        assert_eq!(actions, vec![TestAction::Select(1)]);
760    }
761
762    #[test]
763    fn test_render() {
764        let mut render = RenderHarness::new(30, 10);
765        let mut list = SelectList::new();
766        let items = make_items();
767
768        let output = render.render_to_string_plain(|frame| {
769            let props = SelectListProps {
770                items: &items,
771                count: items.len(),
772                selected: 1,
773                is_focused: true,
774                style: SelectListStyle::default(),
775                behavior: SelectListBehavior::default(),
776                on_select: Rc::new(|_| ()),
777                render_item: &render_item,
778            };
779            <SelectList as Component<()>>::render(&mut list, frame, frame.area(), props);
780        });
781
782        assert!(output.contains("Item 0"));
783        assert!(output.contains("Item 1"));
784        assert!(output.contains("Item 2"));
785    }
786
787    #[test]
788    fn test_render_without_selection_styling() {
789        let mut render = RenderHarness::new(30, 10);
790        let mut list = SelectList::new();
791        let items = make_items();
792
793        let output = render.render_to_string_plain(|frame| {
794            let props = SelectListProps {
795                items: &items,
796                count: items.len(),
797                selected: 1,
798                is_focused: true,
799                style: SelectListStyle {
800                    selection: SelectionStyle::disabled(),
801                    ..Default::default()
802                },
803                behavior: SelectListBehavior::default(),
804                on_select: Rc::new(|_| ()),
805                render_item: &render_item,
806            };
807            <SelectList as Component<()>>::render(&mut list, frame, frame.area(), props);
808        });
809
810        // Should render items without markers
811        assert!(output.contains("Item 0"));
812        assert!(output.contains("Item 1"));
813        assert!(output.contains("Item 2"));
814        // Should not contain selection marker
815        assert!(!output.contains(">"));
816    }
817}