Skip to main content

cove_cli/sidebar/
ui.rs

1// ── ratatui rendering for sidebar ──
2
3use 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
15// ── Types ──
16
17/// Legend entry for keyboard shortcuts.
18struct 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    /// Context description for the selected session (if available).
48    pub context: Option<&'a str>,
49    /// Whether context is currently being generated for the selected session.
50    pub context_loading: bool,
51}
52
53// ── Public API ──
54
55impl Widget for SidebarWidget<'_> {
56    fn render(self, area: Rect, buf: &mut Buffer) {
57        let window_count = self.windows.len();
58
59        // ── Header ──
60        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        // ── Separator ──
76        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        // ── Body ──
85        let body_start = area.y + 2;
86        let right_col = area.width.saturating_sub(15);
87
88        // Pre-calculate context block height so we can reserve space at the bottom
89        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 // dashed separator + text lines
93        } else if self.context_loading {
94            2 // dashed separator + "loading…"
95        } else {
96            0
97        };
98
99        // Left column: sessions
100        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                // Spinner renders inline right after the name
134                spans.push(status_span(state, self.tick));
135            } else if !status.is_empty() {
136                // Right-align status text against the legend column
137                let name_width = 3 + win.name.len(); // " · " or " ❯ " prefix + name
138                let status_width = status.chars().count() + 2; // 2 spaces before status
139                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        // Right column: legend (independent positioning)
149        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        // Context block pinned to the bottom of the panel
163        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
176// ── Helpers ──
177
178const 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
213/// Render the dashed separator + context text below the selected session.
214fn render_context_block(buf: &mut Buffer, area: Rect, mut y: u16, _right_col: u16, text: &str) {
215    // Dashed separator (full width with 1-char padding on each side)
216    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    // Word-wrapped context (max 3 lines)
225    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
240/// Render the dashed separator + "loading…" indicator.
241fn render_loading_block(buf: &mut Buffer, area: Rect, mut y: u16) {
242    // Dashed separator (full width with 1-char padding on each side)
243    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    // Loading indicator
252    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
266/// Word-wrap text to fit within `max_width`, returning at most `max_lines` lines.
267fn 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// ── Tests ──
310
311#[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    // ── Render output validation tests ──
349    //
350    // These tests render the SidebarWidget to a ratatui Buffer and assert
351    // that the actual output contains the expected text. This validates
352    // the full rendering pipeline — layout math, height guards, and content.
353
354    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    /// Extract all text from a ratatui Buffer as a vector of strings (one per row).
366    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    /// Check if any line in the buffer contains the given substring.
377    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        // Should show session name but no context or loading
402        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        // Should show both session name and loading indicator
431        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        // Should show session name and context text
461        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        // Minimum viable pane: header + separator + 1 session + separator + loading = 5 rows
478        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        // Loading should still render even in a small pane
495        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        // Pane too small: header(1) + sep(1) = body_start at row 2, height 3 → body has 1 row
505        // Context needs 2 rows (sep + loading), context_start = 3-2=1, body_start=2 → guard fails
506        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        // In a 3-row pane, context guard correctly prevents rendering
523        // (there's no space for it — this is expected behavior)
524        assert!(
525            !buf_contains(&buf, area, "loading\u{2026}"),
526            "loading should NOT render in 3-row pane (no space)"
527        );
528    }
529}