photon_ui/components/
tabs.rs1use 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
17pub struct Tabs {
23 items: Vec<String>,
24 active: usize,
25 focused: bool,
26}
27
28impl Tabs {
29 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 pub fn active(&self) -> usize {
40 self.active
41 }
42
43 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); 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); }
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}