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
18pub struct SidebarItem {
20 label: String,
21 icon: Option<String>,
22}
23
24impl SidebarItem {
25 pub fn new(label: impl Into<String>) -> Self {
27 Self {
28 label: label.into(),
29 icon: None,
30 }
31 }
32
33 pub fn icon(mut self, icon: impl Into<String>) -> Self {
35 self.icon = Some(icon.into());
36 self
37 }
38}
39
40pub struct Sidebar {
45 items: Vec<SidebarItem>,
46 selected: usize,
47 focused: bool,
48 show_border: bool,
49}
50
51impl Sidebar {
52 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 pub fn selected(&self) -> usize {
64 self.selected
65 }
66
67 pub fn set_selected(&mut self, index: usize) {
69 self.selected = index.min(self.items.len().saturating_sub(1));
70 }
71
72 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 | KeyCode::Tab | KeyCode::BackTab => InputResult::Ignored,
170 | _ => {
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); }
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 #[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 #[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}