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}
52
53impl Widget for SidebarWidget<'_> {
56 fn render(self, area: Rect, buf: &mut Buffer) {
57 let window_count = self.windows.len();
58
59 let plural = if window_count == 1 { "" } else { "s" };
61 let header = Line::from(vec![
62 Span::raw(" "),
63 Span::styled(
64 format!("{window_count} session{plural}"),
65 Style::default().fg(colors::OVERLAY),
66 ),
67 Span::styled(" \u{00b7} ", Style::default().fg(colors::SURFACE)),
68 Span::styled("\u{2191}\u{2193}", Style::default().fg(colors::BLUE)),
69 Span::styled(" navigate", Style::default().fg(colors::OVERLAY)),
70 ]);
71 if area.height > 0 {
72 buf.set_line(area.x, area.y, &header, area.width);
73 }
74
75 if area.height > 1 {
77 let sep_row = area.y + 1;
78 for x in area.x..area.x + area.width {
79 buf.cell_mut((x, sep_row))
80 .map(|cell| cell.set_char('\u{2500}').set_fg(colors::SURFACE));
81 }
82 }
83
84 let body_start = area.y + 2;
86 let right_col = area.width.saturating_sub(15);
87
88 let context_height = if let Some(context) = self.context {
90 let max_width = (area.width as usize).saturating_sub(3);
91 let text_lines = wrap_text(context, max_width, 3).len() as u16;
92 1 + text_lines } else if self.context_loading {
94 2 } else {
96 0
97 };
98
99 let body_bottom = area.y + area.height;
101 let session_bottom = body_bottom.saturating_sub(context_height);
102 for (i, win) in self.windows.iter().enumerate() {
103 let y = body_start + i as u16;
104 if y >= session_bottom {
105 break;
106 }
107
108 let state = self
109 .states
110 .get(&win.index)
111 .copied()
112 .unwrap_or(WindowState::Fresh);
113 let is_selected = i == self.selected;
114
115 let (bullet, name_style) = if is_selected {
116 (
117 Span::styled("\u{276f}", Style::default().fg(Color::White)),
118 Style::default().fg(Color::White),
119 )
120 } else {
121 (Span::raw(" "), Style::default().fg(colors::OVERLAY))
122 };
123
124 let mut spans = vec![
125 Span::raw(" "),
126 bullet,
127 Span::raw(" "),
128 Span::styled(&win.name, name_style),
129 ];
130
131 let status = status_text(state);
132 if matches!(state, WindowState::Working) {
133 spans.push(status_span(state, self.tick));
135 } else if !status.is_empty() {
136 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);
140 spans.push(Span::raw(" ".repeat(pad)));
141 spans.push(status_span(state, self.tick));
142 }
143
144 let line = Line::from(spans);
145 buf.set_line(area.x, y, &line, right_col);
146 }
147
148 for (i, entry) in LEGEND.iter().enumerate() {
150 let ly = body_start + i as u16;
151 if ly >= area.y + area.height {
152 break;
153 }
154 let legend_line = Line::from(vec![
155 Span::styled(entry.key, Style::default().fg(colors::BLUE)),
156 Span::raw(" "),
157 Span::styled(entry.label, Style::default().fg(colors::OVERLAY)),
158 ]);
159 buf.set_line(area.x + right_col, ly, &legend_line, area.width - right_col);
160 }
161
162 if context_height > 0 {
164 let context_start = body_bottom.saturating_sub(context_height);
165 if context_start >= body_start {
166 if let Some(context) = self.context {
167 render_context_block(buf, area, context_start, right_col, context);
168 } else if self.context_loading {
169 render_loading_block(buf, area, context_start);
170 }
171 }
172 }
173 }
174}
175
176const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
179
180fn status_text(state: WindowState) -> &'static str {
181 match state {
182 WindowState::Working => "",
183 WindowState::Asking => "waiting\u{2026}",
184 WindowState::Waiting => "approve\u{2026}",
185 WindowState::Idle => "your turn",
186 WindowState::Done => "",
187 WindowState::Fresh => "",
188 }
189}
190
191fn status_span(state: WindowState, tick: u64) -> Span<'static> {
192 match state {
193 WindowState::Working => {
194 let frame = SPINNER[tick as usize % SPINNER.len()];
195 Span::styled(format!(" {frame}"), Style::default().fg(colors::LAVENDER))
196 }
197 WindowState::Idle => Span::styled(status_text(state), Style::default().fg(colors::GREEN)),
198 WindowState::Waiting => Span::styled(
199 status_text(state),
200 Style::default()
201 .fg(colors::PEACH)
202 .add_modifier(Modifier::ITALIC),
203 ),
204 _ => Span::styled(
205 status_text(state),
206 Style::default()
207 .fg(colors::OVERLAY)
208 .add_modifier(Modifier::ITALIC),
209 ),
210 }
211}
212
213fn render_context_block(buf: &mut Buffer, area: Rect, mut y: u16, _right_col: u16, text: &str) {
215 if y < area.y + area.height {
217 for x in (area.x + 1)..area.x + area.width.saturating_sub(1) {
218 buf.cell_mut((x, y))
219 .map(|cell| cell.set_char('\u{2500}').set_fg(colors::SURFACE));
220 }
221 y += 1;
222 }
223
224 let max_width = (area.width as usize).saturating_sub(3);
226 let lines = wrap_text(text, max_width, 3);
227 for line_text in &lines {
228 if y >= area.y + area.height {
229 break;
230 }
231 let line = Line::from(vec![
232 Span::raw(" "),
233 Span::styled(line_text.clone(), Style::default().fg(colors::OVERLAY)),
234 ]);
235 buf.set_line(area.x, y, &line, area.width);
236 y += 1;
237 }
238}
239
240fn render_loading_block(buf: &mut Buffer, area: Rect, mut y: u16) {
242 if y < area.y + area.height {
244 for x in (area.x + 1)..area.x + area.width.saturating_sub(1) {
245 buf.cell_mut((x, y))
246 .map(|cell| cell.set_char('\u{2500}').set_fg(colors::SURFACE));
247 }
248 y += 1;
249 }
250
251 if y < area.y + area.height {
253 let line = Line::from(vec![
254 Span::raw(" "),
255 Span::styled(
256 "loading\u{2026}",
257 Style::default()
258 .fg(colors::OVERLAY)
259 .add_modifier(Modifier::ITALIC),
260 ),
261 ]);
262 buf.set_line(area.x, y, &line, area.width);
263 }
264}
265
266fn wrap_text(text: &str, max_width: usize, max_lines: usize) -> Vec<String> {
268 if max_width == 0 || max_lines == 0 {
269 return Vec::new();
270 }
271
272 let mut lines = Vec::new();
273 let mut current = String::new();
274
275 for word in text.split_whitespace() {
276 let word_width = word.chars().count();
277 if current.is_empty() {
278 if word_width > max_width {
279 let truncated: String = word.chars().take(max_width.saturating_sub(1)).collect();
280 current = format!("{truncated}\u{2026}");
281 lines.push(current);
282 current = String::new();
283 if lines.len() >= max_lines {
284 break;
285 }
286 continue;
287 }
288 current = word.to_string();
289 } else if current.chars().count() + 1 + word_width <= max_width {
290 current.push(' ');
291 current.push_str(word);
292 } else {
293 lines.push(current);
294 if lines.len() >= max_lines {
295 current = String::new();
296 break;
297 }
298 current = word.to_string();
299 }
300 }
301
302 if !current.is_empty() && lines.len() < max_lines {
303 lines.push(current);
304 }
305
306 lines
307}
308
309#[cfg(test)]
312mod tests {
313 use super::*;
314
315 #[test]
316 fn test_wrap_short_text() {
317 let lines = wrap_text("hello world", 20, 3);
318 assert_eq!(lines, vec!["hello world"]);
319 }
320
321 #[test]
322 fn test_wrap_long_text() {
323 let lines = wrap_text("Adding OAuth login flow with Google provider", 25, 3);
324 assert_eq!(
325 lines,
326 vec!["Adding OAuth login flow", "with Google provider"]
327 );
328 }
329
330 #[test]
331 fn test_wrap_max_lines() {
332 let lines = wrap_text("one two three four five six seven eight", 10, 2);
333 assert_eq!(lines.len(), 2);
334 }
335
336 #[test]
337 fn test_wrap_empty() {
338 let lines = wrap_text("", 20, 3);
339 assert!(lines.is_empty());
340 }
341
342 #[test]
343 fn test_wrap_zero_width() {
344 let lines = wrap_text("hello", 0, 3);
345 assert!(lines.is_empty());
346 }
347
348 use crate::tmux::WindowInfo;
355
356 fn test_win(index: u32, name: &str) -> WindowInfo {
357 WindowInfo {
358 index,
359 name: name.to_string(),
360 is_active: false,
361 pane_path: format!("/project/{name}"),
362 }
363 }
364
365 fn buf_lines(buf: &Buffer, area: Rect) -> Vec<String> {
367 (area.y..area.y + area.height)
368 .map(|y| {
369 (area.x..area.x + area.width)
370 .map(|x| buf.cell((x, y)).map(|c| c.symbol()).unwrap_or(" "))
371 .collect::<String>()
372 })
373 .collect()
374 }
375
376 fn buf_contains(buf: &Buffer, area: Rect, needle: &str) -> bool {
378 buf_lines(buf, area)
379 .iter()
380 .any(|line| line.contains(needle))
381 }
382
383 #[test]
384 fn test_render_no_context() {
385 let area = Rect::new(0, 0, 40, 10);
386 let mut buf = Buffer::empty(area);
387
388 let windows = vec![test_win(1, "my-session")];
389 let states: HashMap<u32, WindowState> = [(1, WindowState::Idle)].into_iter().collect();
390
391 let widget = SidebarWidget {
392 windows: &windows,
393 states: &states,
394 selected: 0,
395 tick: 0,
396 context: None,
397 context_loading: false,
398 };
399 widget.render(area, &mut buf);
400
401 assert!(
403 buf_contains(&buf, area, "my-session"),
404 "should show session name"
405 );
406 assert!(
407 !buf_contains(&buf, area, "loading"),
408 "should not show loading"
409 );
410 }
411
412 #[test]
413 fn test_render_loading_state() {
414 let area = Rect::new(0, 0, 40, 10);
415 let mut buf = Buffer::empty(area);
416
417 let windows = vec![test_win(1, "my-session")];
418 let states: HashMap<u32, WindowState> = [(1, WindowState::Idle)].into_iter().collect();
419
420 let widget = SidebarWidget {
421 windows: &windows,
422 states: &states,
423 selected: 0,
424 tick: 0,
425 context: None,
426 context_loading: true,
427 };
428 widget.render(area, &mut buf);
429
430 assert!(
432 buf_contains(&buf, area, "my-session"),
433 "should show session name"
434 );
435 assert!(
436 buf_contains(&buf, area, "loading\u{2026}"),
437 "should show loading indicator"
438 );
439 }
440
441 #[test]
442 fn test_render_context_text() {
443 let area = Rect::new(0, 0, 40, 10);
444 let mut buf = Buffer::empty(area);
445
446 let windows = vec![test_win(1, "my-session")];
447 let states: HashMap<u32, WindowState> = [(1, WindowState::Idle)].into_iter().collect();
448 let context_text = "Fixing auth bug in login flow";
449
450 let widget = SidebarWidget {
451 windows: &windows,
452 states: &states,
453 selected: 0,
454 tick: 0,
455 context: Some(context_text),
456 context_loading: false,
457 };
458 widget.render(area, &mut buf);
459
460 assert!(
462 buf_contains(&buf, area, "my-session"),
463 "should show session name"
464 );
465 assert!(
466 buf_contains(&buf, area, "Fixing auth bug"),
467 "should show context text"
468 );
469 assert!(
470 !buf_contains(&buf, area, "loading"),
471 "should not show loading when context is available"
472 );
473 }
474
475 #[test]
476 fn test_render_context_not_swallowed_in_small_pane() {
477 let area = Rect::new(0, 0, 40, 5);
479 let mut buf = Buffer::empty(area);
480
481 let windows = vec![test_win(1, "sess")];
482 let states: HashMap<u32, WindowState> = [(1, WindowState::Idle)].into_iter().collect();
483
484 let widget = SidebarWidget {
485 windows: &windows,
486 states: &states,
487 selected: 0,
488 tick: 0,
489 context: None,
490 context_loading: true,
491 };
492 widget.render(area, &mut buf);
493
494 assert!(
496 buf_contains(&buf, area, "loading\u{2026}"),
497 "loading should render in 5-row pane: {:?}",
498 buf_lines(&buf, area)
499 );
500 }
501
502 #[test]
503 fn test_render_context_swallowed_in_tiny_pane() {
504 let area = Rect::new(0, 0, 40, 3);
507 let mut buf = Buffer::empty(area);
508
509 let windows = vec![test_win(1, "sess")];
510 let states: HashMap<u32, WindowState> = [(1, WindowState::Idle)].into_iter().collect();
511
512 let widget = SidebarWidget {
513 windows: &windows,
514 states: &states,
515 selected: 0,
516 tick: 0,
517 context: None,
518 context_loading: true,
519 };
520 widget.render(area, &mut buf);
521
522 assert!(
525 !buf_contains(&buf, area, "loading\u{2026}"),
526 "loading should NOT render in 3-row pane (no space)"
527 );
528 }
529}