Skip to main content

photon_ui/components/
tabs.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 horizontal tab bar with keyboard navigation.
18///
19/// Renders as a horizontal tab bar. The active tab is highlighted with the
20/// theme's accent color and bold; inactive tabs use the secondary text color.
21/// When focused, a `│` prefix is shown.
22pub struct Tabs {
23    items: Vec<String>,
24    active: usize,
25    focused: bool,
26}
27
28impl Tabs {
29    /// Create a new tab bar with the given items.
30    pub fn new(items: Vec<impl Into<String>>) -> Self {
31        Self {
32            items: items.into_iter().map(Into::into).collect(),
33            active: 0,
34            focused: false,
35        }
36    }
37
38    /// Index of the currently active tab.
39    pub fn active(&self) -> usize {
40        self.active
41    }
42
43    /// Set the active tab index (clamped to valid range).
44    pub fn set_active(&mut self, index: usize) {
45        self.active = index.min(self.items.len().saturating_sub(1));
46    }
47}
48
49impl Focusable for Tabs {
50    fn focused(&self) -> bool {
51        self.focused
52    }
53
54    fn set_focused(&mut self, focused: bool) {
55        self.focused = focused;
56    }
57}
58
59impl Component for Tabs {
60    fn render(&self, _width: u16) -> Result<Rendered, RenderError> {
61        let theme = Theme::palette();
62        let accent_style = Style::new().fg(theme.accent()).bold();
63        let inactive_style = Style::new().fg(theme.text_muted());
64
65        let mut line = String::new();
66        if self.focused {
67            line.push('│');
68            line.push(' ');
69        }
70
71        for (i, item) in self.items.iter().enumerate() {
72            if i == self.active {
73                let text = format!(" [{}] ", item);
74                line.push_str(&stylize(&text, &accent_style));
75            } else {
76                let text = format!("  {}  ", item);
77                line.push_str(&stylize(&text, &inactive_style));
78            }
79        }
80
81        Ok(Rendered {
82            lines: vec![line],
83            cursor: None,
84            images: Vec::new(),
85        })
86    }
87
88    fn handle_input(&mut self, event: &Event) -> InputResult {
89        use crossterm::event::KeyModifiers;
90        if let Event::Key(key) = event {
91            match key.code {
92                | KeyCode::Right => {
93                    if self.active + 1 < self.items.len() {
94                        self.active += 1;
95                    }
96                    InputResult::Handled
97                },
98                | KeyCode::Left => {
99                    if self.active > 0 {
100                        self.active -= 1;
101                    }
102                    InputResult::Handled
103                },
104                | KeyCode::Char('l') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
105                    if self.active + 1 < self.items.len() {
106                        self.active += 1;
107                    }
108                    InputResult::Handled
109                },
110                | KeyCode::Char('h') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
111                    if self.active > 0 {
112                        self.active -= 1;
113                    }
114                    InputResult::Handled
115                },
116                | _ => InputResult::Ignored,
117            }
118        } else {
119            InputResult::Ignored
120        }
121    }
122
123    fn as_focusable(&self) -> Option<&dyn Focusable> {
124        Some(self)
125    }
126
127    fn as_focusable_mut(&mut self) -> Option<&mut dyn Focusable> {
128        Some(self)
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use crossterm::event::KeyCode;
135
136    use super::*;
137
138    #[test]
139    fn tabs_new() {
140        let tabs = Tabs::new(vec!["a", "b", "c"]);
141        assert_eq!(tabs.active(), 0);
142        assert_eq!(tabs.items.len(), 3);
143    }
144
145    #[test]
146    fn tabs_set_active() {
147        let mut tabs = Tabs::new(vec!["a", "b", "c"]);
148        tabs.set_active(1);
149        assert_eq!(tabs.active(), 1);
150        tabs.set_active(10);
151        assert_eq!(tabs.active(), 2);
152    }
153
154    #[test]
155    fn tabs_focusable() {
156        let mut tabs = Tabs::new(vec!["a", "b"]);
157        assert!(!tabs.focused());
158        tabs.set_focused(true);
159        assert!(tabs.focused());
160    }
161
162    #[test]
163    fn tabs_render_unfocused() {
164        Theme::with(Theme::Light, || {
165            let tabs = Tabs::new(vec!["Tab 1", "Tab 2"]);
166            let rendered = tabs.render(80).unwrap();
167            assert_eq!(rendered.lines.len(), 1);
168            assert!(!rendered.lines[0].starts_with('│'));
169        });
170    }
171
172    #[test]
173    fn tabs_render_focused() {
174        Theme::with(Theme::Light, || {
175            let mut tabs = Tabs::new(vec!["Tab 1", "Tab 2"]);
176            tabs.set_focused(true);
177            let rendered = tabs.render(80).unwrap();
178            assert!(rendered.lines[0].starts_with('│'));
179        });
180    }
181
182    #[test]
183    fn tabs_handle_input_right() {
184        let mut tabs = Tabs::new(vec!["a", "b", "c"]);
185        tabs.set_focused(true);
186        let result = tabs.handle_input(&Event::Key(KeyCode::Right.into()));
187        assert_eq!(result, InputResult::Handled);
188        assert_eq!(tabs.active(), 1);
189    }
190
191    #[test]
192    fn tabs_handle_input_left() {
193        let mut tabs = Tabs::new(vec!["a", "b", "c"]);
194        tabs.set_focused(true);
195        tabs.set_active(2);
196        let result = tabs.handle_input(&Event::Key(KeyCode::Left.into()));
197        assert_eq!(result, InputResult::Handled);
198        assert_eq!(tabs.active(), 1);
199    }
200
201    #[test]
202    fn tabs_handle_input_h_l() {
203        let mut tabs = Tabs::new(vec!["a", "b", "c"]);
204        tabs.set_focused(true);
205        tabs.set_active(1);
206        let result = tabs.handle_input(&Event::Key(KeyCode::Char('h').into()));
207        assert_eq!(result, InputResult::Handled);
208        assert_eq!(tabs.active(), 0);
209
210        let result = tabs.handle_input(&Event::Key(KeyCode::Char('l').into()));
211        assert_eq!(result, InputResult::Handled);
212        assert_eq!(tabs.active(), 1);
213    }
214
215    #[test]
216    fn tabs_handle_input_clamps() {
217        let mut tabs = Tabs::new(vec!["a", "b"]);
218        tabs.set_focused(true);
219        tabs.set_active(1);
220        let result = tabs.handle_input(&Event::Key(KeyCode::Right.into()));
221        assert_eq!(result, InputResult::Handled);
222        assert_eq!(tabs.active(), 1); // clamped
223
224        tabs.set_active(0);
225        let result = tabs.handle_input(&Event::Key(KeyCode::Left.into()));
226        assert_eq!(result, InputResult::Handled);
227        assert_eq!(tabs.active(), 0); // clamped
228    }
229
230    #[test]
231    fn tabs_handle_input_ignores_unmapped_keys() {
232        let mut tabs = Tabs::new(vec!["a", "b"]);
233        let result = tabs.handle_input(&Event::Key(KeyCode::Char('x').into()));
234        assert_eq!(result, InputResult::Ignored);
235    }
236
237    #[test]
238    fn tabs_handle_input_ignores_non_key_events() {
239        let mut tabs = Tabs::new(vec!["a", "b"]);
240        let result = tabs.handle_input(&Event::Resize(80, 24));
241        assert_eq!(result, InputResult::Ignored);
242    }
243
244    #[test]
245    fn tabs_focusable_trait_objects() {
246        let mut tabs = Tabs::new(vec!["a"]);
247        tabs.set_focused(true);
248        assert!(tabs.as_focusable().is_some());
249        assert!(tabs.as_focusable_mut().is_some());
250    }
251}