Skip to main content

photon_ui/components/
select_list.rs

1use crossterm::event::KeyCode;
2
3use crate::{
4    Component,
5    Event,
6    Focusable,
7    InputResult,
8    RenderError,
9    Rendered,
10    theme::{
11        Palette,
12        Style,
13        Theme,
14        stylize,
15    },
16};
17
18/// A scrollable list of selectable items with keyboard navigation.
19///
20/// Renders items with a `> ` prefix on the selected row. Supports vertical
21/// scrolling when the item count exceeds `max_visible`.
22pub struct SelectList {
23    items: Vec<String>,
24    selected: usize,
25    max_visible: usize,
26    scroll: usize,
27    focused: bool,
28}
29
30impl SelectList {
31    /// Create a new list with the given items and maximum visible rows.
32    pub fn new(items: Vec<String>, max_visible: usize) -> Self {
33        Self {
34            items,
35            selected: 0,
36            max_visible,
37            scroll: 0,
38            focused: false,
39        }
40    }
41
42    /// Index of the currently selected item.
43    pub fn selected(&self) -> usize {
44        self.selected
45    }
46
47    /// Set the selected item index (clamped to valid range).
48    pub fn set_selected(&mut self, index: usize) {
49        self.selected = index.min(self.items.len().saturating_sub(1));
50        self.scroll = self
51            .selected
52            .saturating_sub(self.max_visible.saturating_sub(1));
53    }
54
55    /// The currently selected item text, if any.
56    pub fn selected_item(&self) -> Option<&str> {
57        self.items.get(self.selected).map(|s| s.as_str())
58    }
59}
60
61impl Focusable for SelectList {
62    fn focused(&self) -> bool {
63        self.focused
64    }
65
66    fn set_focused(&mut self, focused: bool) {
67        self.focused = focused;
68    }
69}
70
71impl Component for SelectList {
72    fn render(&self, width: u16) -> Result<Rendered, RenderError> {
73        let theme = Theme::current();
74        let accent_style = Style::new().fg(theme.accent()).bold();
75        let primary_style = Style::new().fg(theme.text_primary());
76        let dim_style = Style::new().fg(theme.text_secondary());
77
78        let mut lines = Vec::new();
79        let visible_end = (self.scroll + self.max_visible).min(self.items.len());
80        for i in self.scroll..visible_end {
81            let is_selected = i == self.selected;
82            let style = if is_selected && self.focused {
83                &accent_style
84            } else if is_selected {
85                &primary_style
86            } else {
87                &dim_style
88            };
89
90            let prefix = if is_selected { "> " } else { "  " };
91            let line = stylize(&format!("{}{}", prefix, self.items[i]), style);
92            lines.push(crate::utils::truncate_to_width(&line, width, "…"));
93        }
94        Ok(Rendered {
95            lines,
96            cursor: None,
97            images: Vec::new(),
98        })
99    }
100
101    fn handle_input(&mut self, event: &Event) -> InputResult {
102        use crossterm::event::KeyModifiers;
103        if let Event::Key(key) = event {
104            match key.code {
105                | KeyCode::Down => {
106                    if self.selected + 1 < self.items.len() {
107                        self.selected += 1;
108                        if self.selected >= self.scroll + self.max_visible {
109                            self.scroll += 1;
110                        }
111                    }
112                    InputResult::Handled
113                },
114                | KeyCode::Up => {
115                    if self.selected > 0 {
116                        self.selected -= 1;
117                        if self.selected < self.scroll {
118                            self.scroll = self.scroll.saturating_sub(1);
119                        }
120                    }
121                    InputResult::Handled
122                },
123                | KeyCode::Char('j') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
124                    if self.selected + 1 < self.items.len() {
125                        self.selected += 1;
126                        if self.selected >= self.scroll + self.max_visible {
127                            self.scroll += 1;
128                        }
129                    }
130                    InputResult::Handled
131                },
132                | KeyCode::Char('k') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
133                    if self.selected > 0 {
134                        self.selected -= 1;
135                        if self.selected < self.scroll {
136                            self.scroll = self.scroll.saturating_sub(1);
137                        }
138                    }
139                    InputResult::Handled
140                },
141                | KeyCode::Enter => InputResult::RequestRender,
142                | _ => InputResult::Ignored,
143            }
144        } else {
145            InputResult::Ignored
146        }
147    }
148
149    fn as_focusable(&self) -> Option<&dyn Focusable> {
150        Some(self)
151    }
152
153    fn as_focusable_mut(&mut self) -> Option<&mut dyn Focusable> {
154        Some(self)
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use crossterm::event::KeyCode;
161
162    use super::*;
163
164    #[test]
165    fn select_list_renders() {
166        let list = SelectList::new(vec!["a".into(), "b".into()], 10);
167        let r = list.render(10).unwrap();
168        assert_eq!(r.lines.len(), 2);
169        assert!(r.lines[0].contains("> "));
170    }
171
172    #[test]
173    fn select_list_navigation() {
174        let mut list = SelectList::new(vec!["a".into(), "b".into(), "c".into()], 2);
175        list.set_focused(true);
176        list.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
177            KeyCode::Down,
178            crossterm::event::KeyModifiers::empty(),
179        )));
180        assert_eq!(list.selected(), 1);
181        list.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
182            KeyCode::Down,
183            crossterm::event::KeyModifiers::empty(),
184        )));
185        assert_eq!(list.selected(), 2);
186        assert_eq!(list.scroll, 1);
187    }
188
189    #[test]
190    fn select_list_scroll_up() {
191        let mut list = SelectList::new(vec!["a".into(), "b".into(), "c".into()], 2);
192        list.set_focused(true);
193        list.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
194            KeyCode::Down,
195            crossterm::event::KeyModifiers::empty(),
196        )));
197        list.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
198            KeyCode::Down,
199            crossterm::event::KeyModifiers::empty(),
200        )));
201        assert_eq!(list.scroll, 1);
202        list.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
203            KeyCode::Up,
204            crossterm::event::KeyModifiers::empty(),
205        )));
206        // selected=1, scroll=1, item is still visible so scroll stays
207        assert_eq!(list.scroll, 1);
208        list.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
209            KeyCode::Up,
210            crossterm::event::KeyModifiers::empty(),
211        )));
212        assert_eq!(list.scroll, 0);
213    }
214
215    #[test]
216    fn select_list_j_k_navigation() {
217        let mut list = SelectList::new(vec!["a".into(), "b".into(), "c".into()], 2);
218        list.set_focused(true);
219        list.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
220            KeyCode::Char('j'),
221            crossterm::event::KeyModifiers::empty(),
222        )));
223        assert_eq!(list.selected(), 1);
224        list.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
225            KeyCode::Char('k'),
226            crossterm::event::KeyModifiers::empty(),
227        )));
228        assert_eq!(list.selected(), 0);
229    }
230
231    #[test]
232    fn select_list_j_scrolls() {
233        let mut list = SelectList::new(vec!["a".into(), "b".into(), "c".into()], 2);
234        list.set_focused(true);
235        list.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
236            KeyCode::Char('j'),
237            crossterm::event::KeyModifiers::empty(),
238        )));
239        list.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
240            KeyCode::Char('j'),
241            crossterm::event::KeyModifiers::empty(),
242        )));
243        assert_eq!(list.selected(), 2);
244        assert_eq!(list.scroll, 1);
245    }
246}