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}