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