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