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