Skip to main content

photon_ui/components/
sidebar.rs

1use crossterm::event::KeyCode;
2
3use crate::{
4    Component,
5    Event,
6    Focusable,
7    InputResult,
8    RenderError,
9    Rendered,
10    theme::{
11        ColorMode,
12        Style,
13        Theme,
14        stylize,
15    },
16};
17
18/// A single item in a sidebar.
19pub struct SidebarItem {
20    label: String,
21    icon: Option<String>,
22}
23
24impl SidebarItem {
25    /// Create a new sidebar item with the given label.
26    pub fn new(label: impl Into<String>) -> Self {
27        Self {
28            label: label.into(),
29            icon: None,
30        }
31    }
32
33    /// Set an optional icon for this item.
34    pub fn icon(mut self, icon: impl Into<String>) -> Self {
35        self.icon = Some(icon.into());
36        self
37    }
38}
39
40/// A vertical navigation sidebar with selectable items.
41///
42/// Renders items vertically with a `> ` prefix on the selected row when
43/// focused. Supports an optional left border and keyboard navigation.
44pub struct Sidebar {
45    items: Vec<SidebarItem>,
46    selected: usize,
47    focused: bool,
48    show_border: bool,
49}
50
51impl Sidebar {
52    /// Create a new sidebar with the given items.
53    pub fn new(items: Vec<SidebarItem>) -> Self {
54        Self {
55            items,
56            selected: 0,
57            focused: false,
58            show_border: true,
59        }
60    }
61
62    /// Index of the currently selected item.
63    pub fn selected(&self) -> usize {
64        self.selected
65    }
66
67    /// Set the selected item index (clamped to valid range).
68    pub fn set_selected(&mut self, index: usize) {
69        self.selected = index.min(self.items.len().saturating_sub(1));
70    }
71
72    /// Hide the left border.
73    pub fn hide_border(mut self) -> Self {
74        self.show_border = false;
75        self
76    }
77}
78
79impl Focusable for Sidebar {
80    fn focused(&self) -> bool {
81        self.focused
82    }
83
84    fn set_focused(&mut self, focused: bool) {
85        self.focused = focused;
86    }
87}
88
89impl Component for Sidebar {
90    fn render(&self, width: u16) -> Result<Rendered, RenderError> {
91        let theme = Theme::palette();
92        let mut lines = Vec::new();
93
94        let content_width = if self.show_border {
95            width.saturating_sub(1)
96        } else {
97            width
98        };
99
100        for (i, item) in self.items.iter().enumerate() {
101            let is_selected = i == self.selected && self.focused;
102            let style = if is_selected {
103                Style::new().fg(theme.accent()).bold()
104            } else {
105                Style::new().fg(theme.text_muted())
106            };
107
108            let prefix = if is_selected { "> " } else { "  " };
109            let icon = item
110                .icon
111                .as_ref()
112                .map(|s| format!("{} ", s))
113                .unwrap_or_default();
114            let line = format!("{}{}{}", prefix, icon, item.label);
115            let line = crate::utils::truncate_to_width(&line, content_width, "…");
116            lines.push(stylize(&line, &style));
117        }
118
119        if self.show_border && width > 0 {
120            let border_style = Style::new().fg(theme.border());
121            let mode = ColorMode::detect();
122            let border_prefix = border_style.prefix(mode);
123            let border_suffix = border_style.suffix();
124            let border = format!("{}{}{}", border_prefix, '▐', border_suffix);
125
126            for line in &mut lines {
127                let mut new_line = border.clone();
128                new_line.push_str(line);
129                *line = new_line;
130            }
131        }
132
133        Ok(Rendered {
134            lines,
135            cursor: None,
136            images: Vec::new(),
137        })
138    }
139
140    fn handle_input(&mut self, event: &Event) -> InputResult {
141        use crossterm::event::KeyModifiers;
142        if let Event::Key(key) = event {
143            match key.code {
144                | KeyCode::Down => {
145                    if self.selected + 1 < self.items.len() {
146                        self.selected += 1;
147                    }
148                    InputResult::Handled
149                },
150                | KeyCode::Up => {
151                    if self.selected > 0 {
152                        self.selected -= 1;
153                    }
154                    InputResult::Handled
155                },
156                | KeyCode::Char('j') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
157                    if self.selected + 1 < self.items.len() {
158                        self.selected += 1;
159                    }
160                    InputResult::Handled
161                },
162                | KeyCode::Char('k') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
163                    if self.selected > 0 {
164                        self.selected -= 1;
165                    }
166                    InputResult::Handled
167                },
168                // Allow Tab / BackTab to propagate so TUI can cycle focus.
169                | KeyCode::Tab | KeyCode::BackTab => InputResult::Ignored,
170                // When focused, consume all other keys to prevent fallthrough
171                // to sibling components (e.g. Tabs reacting to Left/Right).
172                | _ => {
173                    if self.focused {
174                        InputResult::Handled
175                    } else {
176                        InputResult::Ignored
177                    }
178                },
179            }
180        } else {
181            InputResult::Ignored
182        }
183    }
184
185    fn as_focusable(&self) -> Option<&dyn Focusable> {
186        Some(self)
187    }
188
189    fn as_focusable_mut(&mut self) -> Option<&mut dyn Focusable> {
190        Some(self)
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use crossterm::event::KeyCode;
197
198    use super::*;
199
200    #[test]
201    fn sidebar_new() {
202        let sidebar = Sidebar::new(vec![SidebarItem::new("A"), SidebarItem::new("B")]);
203        assert_eq!(sidebar.selected(), 0);
204        assert!(!sidebar.focused());
205    }
206
207    #[test]
208    fn sidebar_set_selected() {
209        let mut sidebar = Sidebar::new(vec![SidebarItem::new("A"), SidebarItem::new("B")]);
210        sidebar.set_selected(1);
211        assert_eq!(sidebar.selected(), 1);
212        sidebar.set_selected(10);
213        assert_eq!(sidebar.selected(), 1);
214    }
215
216    #[test]
217    fn sidebar_focusable() {
218        let mut sidebar = Sidebar::new(vec![SidebarItem::new("A")]);
219        assert!(!sidebar.focused());
220        sidebar.set_focused(true);
221        assert!(sidebar.focused());
222    }
223
224    #[test]
225    fn sidebar_hide_border() {
226        let sidebar = Sidebar::new(vec![SidebarItem::new("A")]).hide_border();
227        assert!(!sidebar.show_border);
228    }
229
230    #[test]
231    fn sidebar_renders_items() {
232        Theme::with(Theme::Light, || {
233            let sidebar = Sidebar::new(vec![SidebarItem::new("A"), SidebarItem::new("B")]);
234            let rendered = sidebar.render(10).unwrap();
235            assert_eq!(rendered.lines.len(), 2);
236        });
237    }
238
239    #[test]
240    fn sidebar_selected_shows_prefix() {
241        Theme::with(Theme::Light, || {
242            let mut sidebar = Sidebar::new(vec![SidebarItem::new("A"), SidebarItem::new("B")]);
243            sidebar.set_focused(true);
244            let rendered = sidebar.render(10).unwrap();
245            assert!(rendered.lines[0].contains("> "));
246            assert!(rendered.lines[1].contains("  "));
247        });
248    }
249
250    #[test]
251    fn sidebar_icon_renders() {
252        Theme::with(Theme::Light, || {
253            let sidebar = Sidebar::new(vec![SidebarItem::new("Home").icon("🏠")]);
254            let rendered = sidebar.render(20).unwrap();
255            assert!(rendered.lines[0].contains("🏠"));
256            assert!(rendered.lines[0].contains("Home"));
257        });
258    }
259
260    #[test]
261    fn sidebar_keyboard_navigation() {
262        let mut sidebar = Sidebar::new(vec![
263            SidebarItem::new("A"),
264            SidebarItem::new("B"),
265            SidebarItem::new("C"),
266        ]);
267        sidebar.set_focused(true);
268
269        sidebar.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
270            KeyCode::Down,
271            crossterm::event::KeyModifiers::empty(),
272        )));
273        assert_eq!(sidebar.selected(), 1);
274
275        sidebar.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
276            KeyCode::Down,
277            crossterm::event::KeyModifiers::empty(),
278        )));
279        assert_eq!(sidebar.selected(), 2);
280
281        sidebar.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
282            KeyCode::Down,
283            crossterm::event::KeyModifiers::empty(),
284        )));
285        assert_eq!(sidebar.selected(), 2); // clamped
286    }
287
288    #[test]
289    fn sidebar_j_k_navigation() {
290        let mut sidebar = Sidebar::new(vec![
291            SidebarItem::new("A"),
292            SidebarItem::new("B"),
293            SidebarItem::new("C"),
294        ]);
295        sidebar.set_focused(true);
296
297        sidebar.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
298            KeyCode::Char('j'),
299            crossterm::event::KeyModifiers::empty(),
300        )));
301        assert_eq!(sidebar.selected(), 1);
302
303        sidebar.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
304            KeyCode::Char('k'),
305            crossterm::event::KeyModifiers::empty(),
306        )));
307        assert_eq!(sidebar.selected(), 0);
308    }
309
310    #[test]
311    fn sidebar_clamps_up() {
312        let mut sidebar = Sidebar::new(vec![SidebarItem::new("A"), SidebarItem::new("B")]);
313        sidebar.set_focused(true);
314
315        sidebar.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
316            KeyCode::Up,
317            crossterm::event::KeyModifiers::empty(),
318        )));
319        assert_eq!(sidebar.selected(), 0);
320    }
321
322    #[test]
323    fn sidebar_border_present_by_default() {
324        Theme::with(Theme::Light, || {
325            let sidebar = Sidebar::new(vec![SidebarItem::new("A")]);
326            let rendered = sidebar.render(10).unwrap();
327            assert!(rendered.lines[0].contains('▐'));
328        });
329    }
330
331    #[test]
332    fn sidebar_hide_border_no_border() {
333        Theme::with(Theme::Light, || {
334            let sidebar = Sidebar::new(vec![SidebarItem::new("A")]).hide_border();
335            let rendered = sidebar.render(10).unwrap();
336            assert!(!rendered.lines[0].contains('▐'));
337        });
338    }
339
340    /// Regression: when focused, unhandled keys must be consumed (Handled) so
341    /// they don't fall through to sibling components.
342    #[test]
343    fn sidebar_focused_consumes_unhandled_keys() {
344        let mut sidebar = Sidebar::new(vec![SidebarItem::new("A"), SidebarItem::new("B")]);
345        sidebar.set_focused(true);
346
347        let left = Event::Key(crossterm::event::KeyEvent::new(
348            KeyCode::Left,
349            crossterm::event::KeyModifiers::empty(),
350        ));
351        assert_eq!(sidebar.handle_input(&left), InputResult::Handled);
352
353        let right = Event::Key(crossterm::event::KeyEvent::new(
354            KeyCode::Right,
355            crossterm::event::KeyModifiers::empty(),
356        ));
357        assert_eq!(sidebar.handle_input(&right), InputResult::Handled);
358    }
359
360    /// Tab must propagate (Ignored) so TUI can cycle focus.
361    #[test]
362    fn sidebar_tab_propagates_for_focus_cycle() {
363        let mut sidebar = Sidebar::new(vec![SidebarItem::new("A")]);
364        sidebar.set_focused(true);
365
366        let tab = Event::Key(crossterm::event::KeyEvent::new(
367            KeyCode::Tab,
368            crossterm::event::KeyModifiers::empty(),
369        ));
370        assert_eq!(sidebar.handle_input(&tab), InputResult::Ignored);
371    }
372}