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