1use std::collections::HashMap;
4
5use ratatui::buffer::Buffer;
6use ratatui::layout::Rect;
7use ratatui::style::{Color, Modifier, Style};
8use ratatui::text::{Line, Span};
9use ratatui::widgets::Widget;
10
11use crate::colors;
12use crate::sidebar::state::WindowState;
13use crate::tmux::WindowInfo;
14
15struct LegendEntry {
19 key: &'static str,
20 label: &'static str,
21}
22
23const LEGEND: &[LegendEntry] = &[
24 LegendEntry {
25 key: "\u{2318} + j",
26 label: "claude",
27 },
28 LegendEntry {
29 key: "\u{2318} + m",
30 label: "terminal",
31 },
32 LegendEntry {
33 key: "\u{2318} + p",
34 label: "sessions",
35 },
36 LegendEntry {
37 key: "\u{2318} + ;",
38 label: "detach",
39 },
40];
41
42pub struct SidebarWidget<'a> {
43 pub windows: &'a [WindowInfo],
44 pub states: &'a HashMap<u32, WindowState>,
45 pub selected: usize,
46 pub tick: u64,
47 pub context: Option<&'a str>,
49 pub context_loading: bool,
51 pub context_error: Option<&'a str>,
53}
54
55impl Widget for SidebarWidget<'_> {
58 fn render(self, area: Rect, buf: &mut Buffer) {
59 let window_count = self.windows.len();
60
61 let plural = if window_count == 1 { "" } else { "s" };
63 let header = Line::from(vec![
64 Span::raw(" "),
65 Span::styled(
66 format!("{window_count} session{plural}"),
67 Style::default().fg(colors::OVERLAY),
68 ),
69 Span::styled(" \u{00b7} ", Style::default().fg(colors::SURFACE)),
70 Span::styled("\u{2191}\u{2193}", Style::default().fg(colors::BLUE)),
71 Span::styled(" navigate", Style::default().fg(colors::OVERLAY)),
72 ]);
73 if area.height > 0 {
74 buf.set_line(area.x, area.y, &header, area.width);
75 }
76
77 if area.height > 1 {
79 let sep_row = area.y + 1;
80 for x in area.x..area.x + area.width {
81 buf.cell_mut((x, sep_row))
82 .map(|cell| cell.set_char('\u{2500}').set_fg(colors::SURFACE));
83 }
84 }
85
86 let body_start = area.y + 2;
88 let right_col = area.width.saturating_sub(15);
89
90 let context_height = if let Some(context) = self.context {
92 let max_width = (area.width as usize).saturating_sub(3);
93 let text_lines = wrap_text(context, max_width, 3).len() as u16;
94 1 + text_lines } else if self.context_loading || self.context_error.is_some() {
96 2 } else {
98 0
99 };
100
101 let body_bottom = area.y + area.height;
103 let session_bottom = body_bottom.saturating_sub(context_height);
104 for (i, win) in self.windows.iter().enumerate() {
105 let y = body_start + i as u16;
106 if y >= session_bottom {
107 break;
108 }
109
110 let state = self
111 .states
112 .get(&win.index)
113 .copied()
114 .unwrap_or(WindowState::Fresh);
115 let is_selected = i == self.selected;
116
117 let (bullet, name_style) = if is_selected {
118 (
119 Span::styled("\u{276f}", Style::default().fg(Color::White)),
120 Style::default().fg(Color::White),
121 )
122 } else {
123 (Span::raw(" "), Style::default().fg(colors::OVERLAY))
124 };
125
126 let mut spans = vec![
127 Span::raw(" "),
128 bullet,
129 Span::raw(" "),
130 Span::styled(&win.name, name_style),
131 ];
132
133 let status = status_text(state);
134 if matches!(state, WindowState::Working) {
135 spans.push(status_span(state, self.tick));
137 } else if !status.is_empty() {
138 let name_width = 3 + win.name.len(); let status_width = status.chars().count() + 2; let pad = (right_col as usize).saturating_sub(name_width + status_width);
142 spans.push(Span::raw(" ".repeat(pad)));
143 spans.push(status_span(state, self.tick));
144 }
145
146 let line = Line::from(spans);
147 buf.set_line(area.x, y, &line, right_col);
148 }
149
150 for (i, entry) in LEGEND.iter().enumerate() {
152 let ly = body_start + i as u16;
153 if ly >= area.y + area.height {
154 break;
155 }
156 let legend_line = Line::from(vec![
157 Span::styled(entry.key, Style::default().fg(colors::BLUE)),
158 Span::raw(" "),
159 Span::styled(entry.label, Style::default().fg(colors::OVERLAY)),
160 ]);
161 buf.set_line(area.x + right_col, ly, &legend_line, area.width - right_col);
162 }
163
164 if context_height > 0 {
166 let context_start = body_bottom.saturating_sub(context_height);
167 if context_start >= body_start {
168 if let Some(context) = self.context {
169 render_context_block(buf, area, context_start, right_col, context);
170 } else if self.context_loading {
171 render_loading_block(buf, area, context_start);
172 } else if let Some(error) = self.context_error {
173 render_error_block(buf, area, context_start, error);
174 }
175 }
176 }
177 }
178}
179
180const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
183
184fn status_text(state: WindowState) -> &'static str {
185 match state {
186 WindowState::Working => "",
187 WindowState::Asking => "waiting\u{2026}",
188 WindowState::Waiting => "approve\u{2026}",
189 WindowState::Idle => "your turn",
190 WindowState::Done => "",
191 WindowState::Fresh => "",
192 }
193}
194
195fn status_span(state: WindowState, tick: u64) -> Span<'static> {
196 match state {
197 WindowState::Working => {
198 let frame = SPINNER[tick as usize % SPINNER.len()];
199 Span::styled(format!(" {frame}"), Style::default().fg(colors::LAVENDER))
200 }
201 WindowState::Idle => Span::styled(status_text(state), Style::default().fg(colors::GREEN)),
202 WindowState::Waiting => Span::styled(
203 status_text(state),
204 Style::default()
205 .fg(colors::PEACH)
206 .add_modifier(Modifier::ITALIC),
207 ),
208 _ => Span::styled(
209 status_text(state),
210 Style::default()
211 .fg(colors::OVERLAY)
212 .add_modifier(Modifier::ITALIC),
213 ),
214 }
215}
216
217fn render_context_block(buf: &mut Buffer, area: Rect, mut y: u16, _right_col: u16, text: &str) {
219 if y < area.y + area.height {
221 for x in (area.x + 1)..area.x + area.width.saturating_sub(1) {
222 buf.cell_mut((x, y))
223 .map(|cell| cell.set_char('\u{2500}').set_fg(colors::SURFACE));
224 }
225 y += 1;
226 }
227
228 let max_width = (area.width as usize).saturating_sub(3);
230 let lines = wrap_text(text, max_width, 3);
231 for line_text in &lines {
232 if y >= area.y + area.height {
233 break;
234 }
235 let line = Line::from(vec![
236 Span::raw(" "),
237 Span::styled(line_text.clone(), Style::default().fg(colors::OVERLAY)),
238 ]);
239 buf.set_line(area.x, y, &line, area.width);
240 y += 1;
241 }
242}
243
244fn render_loading_block(buf: &mut Buffer, area: Rect, mut y: u16) {
246 if y < area.y + area.height {
248 for x in (area.x + 1)..area.x + area.width.saturating_sub(1) {
249 buf.cell_mut((x, y))
250 .map(|cell| cell.set_char('\u{2500}').set_fg(colors::SURFACE));
251 }
252 y += 1;
253 }
254
255 if y < area.y + area.height {
257 let line = Line::from(vec![
258 Span::raw(" "),
259 Span::styled(
260 "loading\u{2026}",
261 Style::default()
262 .fg(colors::OVERLAY)
263 .add_modifier(Modifier::ITALIC),
264 ),
265 ]);
266 buf.set_line(area.x, y, &line, area.width);
267 }
268}
269
270fn render_error_block(buf: &mut Buffer, area: Rect, mut y: u16, message: &str) {
272 if y < area.y + area.height {
274 for x in (area.x + 1)..area.x + area.width.saturating_sub(1) {
275 buf.cell_mut((x, y))
276 .map(|cell| cell.set_char('\u{2500}').set_fg(colors::SURFACE));
277 }
278 y += 1;
279 }
280
281 if y < area.y + area.height {
283 let line = Line::from(vec![
284 Span::raw(" "),
285 Span::styled(
286 message.to_string(),
287 Style::default()
288 .fg(colors::SURFACE)
289 .add_modifier(Modifier::ITALIC),
290 ),
291 ]);
292 buf.set_line(area.x, y, &line, area.width);
293 }
294}
295
296fn wrap_text(text: &str, max_width: usize, max_lines: usize) -> Vec<String> {
298 if max_width == 0 || max_lines == 0 {
299 return Vec::new();
300 }
301
302 let mut lines = Vec::new();
303 let mut current = String::new();
304
305 for word in text.split_whitespace() {
306 let word_width = word.chars().count();
307 if current.is_empty() {
308 if word_width > max_width {
309 let truncated: String = word.chars().take(max_width.saturating_sub(1)).collect();
310 current = format!("{truncated}\u{2026}");
311 lines.push(current);
312 current = String::new();
313 if lines.len() >= max_lines {
314 break;
315 }
316 continue;
317 }
318 current = word.to_string();
319 } else if current.chars().count() + 1 + word_width <= max_width {
320 current.push(' ');
321 current.push_str(word);
322 } else {
323 lines.push(current);
324 if lines.len() >= max_lines {
325 current = String::new();
326 break;
327 }
328 current = word.to_string();
329 }
330 }
331
332 if !current.is_empty() && lines.len() < max_lines {
333 lines.push(current);
334 }
335
336 lines
337}
338
339#[cfg(test)]
342mod tests {
343 use super::*;
344
345 #[test]
346 fn test_wrap_short_text() {
347 let lines = wrap_text("hello world", 20, 3);
348 assert_eq!(lines, vec!["hello world"]);
349 }
350
351 #[test]
352 fn test_wrap_long_text() {
353 let lines = wrap_text("Adding OAuth login flow with Google provider", 25, 3);
354 assert_eq!(
355 lines,
356 vec!["Adding OAuth login flow", "with Google provider"]
357 );
358 }
359
360 #[test]
361 fn test_wrap_max_lines() {
362 let lines = wrap_text("one two three four five six seven eight", 10, 2);
363 assert_eq!(lines.len(), 2);
364 }
365
366 #[test]
367 fn test_wrap_empty() {
368 let lines = wrap_text("", 20, 3);
369 assert!(lines.is_empty());
370 }
371
372 #[test]
373 fn test_wrap_zero_width() {
374 let lines = wrap_text("hello", 0, 3);
375 assert!(lines.is_empty());
376 }
377
378 use crate::tmux::WindowInfo;
385
386 fn test_win(index: u32, name: &str) -> WindowInfo {
387 WindowInfo {
388 index,
389 name: name.to_string(),
390 is_active: false,
391 pane_path: format!("/project/{name}"),
392 }
393 }
394
395 fn buf_lines(buf: &Buffer, area: Rect) -> Vec<String> {
397 (area.y..area.y + area.height)
398 .map(|y| {
399 (area.x..area.x + area.width)
400 .map(|x| buf.cell((x, y)).map(|c| c.symbol()).unwrap_or(" "))
401 .collect::<String>()
402 })
403 .collect()
404 }
405
406 fn buf_contains(buf: &Buffer, area: Rect, needle: &str) -> bool {
408 buf_lines(buf, area)
409 .iter()
410 .any(|line| line.contains(needle))
411 }
412
413 #[test]
414 fn test_render_no_context() {
415 let area = Rect::new(0, 0, 40, 10);
416 let mut buf = Buffer::empty(area);
417
418 let windows = vec![test_win(1, "my-session")];
419 let states: HashMap<u32, WindowState> = [(1, WindowState::Idle)].into_iter().collect();
420
421 let widget = SidebarWidget {
422 windows: &windows,
423 states: &states,
424 selected: 0,
425 tick: 0,
426 context: None,
427 context_loading: false,
428 context_error: None,
429 };
430 widget.render(area, &mut buf);
431
432 assert!(
434 buf_contains(&buf, area, "my-session"),
435 "should show session name"
436 );
437 assert!(
438 !buf_contains(&buf, area, "loading"),
439 "should not show loading"
440 );
441 }
442
443 #[test]
444 fn test_render_loading_state() {
445 let area = Rect::new(0, 0, 40, 10);
446 let mut buf = Buffer::empty(area);
447
448 let windows = vec![test_win(1, "my-session")];
449 let states: HashMap<u32, WindowState> = [(1, WindowState::Idle)].into_iter().collect();
450
451 let widget = SidebarWidget {
452 windows: &windows,
453 states: &states,
454 selected: 0,
455 tick: 0,
456 context: None,
457 context_loading: true,
458 context_error: None,
459 };
460 widget.render(area, &mut buf);
461
462 assert!(
464 buf_contains(&buf, area, "my-session"),
465 "should show session name"
466 );
467 assert!(
468 buf_contains(&buf, area, "loading\u{2026}"),
469 "should show loading indicator"
470 );
471 }
472
473 #[test]
474 fn test_render_context_text() {
475 let area = Rect::new(0, 0, 40, 10);
476 let mut buf = Buffer::empty(area);
477
478 let windows = vec![test_win(1, "my-session")];
479 let states: HashMap<u32, WindowState> = [(1, WindowState::Idle)].into_iter().collect();
480 let context_text = "Fixing auth bug in login flow";
481
482 let widget = SidebarWidget {
483 windows: &windows,
484 states: &states,
485 selected: 0,
486 tick: 0,
487 context: Some(context_text),
488 context_loading: false,
489 context_error: None,
490 };
491 widget.render(area, &mut buf);
492
493 assert!(
495 buf_contains(&buf, area, "my-session"),
496 "should show session name"
497 );
498 assert!(
499 buf_contains(&buf, area, "Fixing auth bug"),
500 "should show context text"
501 );
502 assert!(
503 !buf_contains(&buf, area, "loading"),
504 "should not show loading when context is available"
505 );
506 }
507
508 #[test]
509 fn test_render_context_not_swallowed_in_small_pane() {
510 let area = Rect::new(0, 0, 40, 5);
512 let mut buf = Buffer::empty(area);
513
514 let windows = vec![test_win(1, "sess")];
515 let states: HashMap<u32, WindowState> = [(1, WindowState::Idle)].into_iter().collect();
516
517 let widget = SidebarWidget {
518 windows: &windows,
519 states: &states,
520 selected: 0,
521 tick: 0,
522 context: None,
523 context_loading: true,
524 context_error: None,
525 };
526 widget.render(area, &mut buf);
527
528 assert!(
530 buf_contains(&buf, area, "loading\u{2026}"),
531 "loading should render in 5-row pane: {:?}",
532 buf_lines(&buf, area)
533 );
534 }
535
536 #[test]
537 fn test_render_context_swallowed_in_tiny_pane() {
538 let area = Rect::new(0, 0, 40, 3);
541 let mut buf = Buffer::empty(area);
542
543 let windows = vec![test_win(1, "sess")];
544 let states: HashMap<u32, WindowState> = [(1, WindowState::Idle)].into_iter().collect();
545
546 let widget = SidebarWidget {
547 windows: &windows,
548 states: &states,
549 selected: 0,
550 tick: 0,
551 context: None,
552 context_loading: true,
553 context_error: None,
554 };
555 widget.render(area, &mut buf);
556
557 assert!(
560 !buf_contains(&buf, area, "loading\u{2026}"),
561 "loading should NOT render in 3-row pane (no space)"
562 );
563 }
564}