Skip to main content

yarli_cli/dashboard/
widgets.rs

1//! Custom TUI widgets for the dashboard (Section 32).
2//!
3//! `CollapsiblePanel` — panels that expand, collapse to header-only, or hide entirely (~200 LOC).
4
5use ratatui::buffer::Buffer;
6use ratatui::layout::Rect;
7use ratatui::style::{Color, Modifier, Style};
8use ratatui::text::{Line, Span};
9use ratatui::widgets::{Block, Borders, Paragraph, Widget};
10
11use super::state::PanelState;
12use crate::stream::style::Tier;
13
14/// A panel that can be expanded, collapsed (header-only), or hidden.
15///
16/// In expanded state, renders a bordered block with title and content.
17/// In collapsed state, renders only the title bar (1 line).
18/// In hidden state, renders nothing (0 height).
19pub struct CollapsiblePanel<'a> {
20    title: &'a str,
21    content: Vec<Line<'a>>,
22    state: PanelState,
23    focused: bool,
24    scroll_offset: u16,
25    shortcut: Option<char>,
26    borderless: bool,
27}
28
29impl<'a> CollapsiblePanel<'a> {
30    pub fn new(title: &'a str, state: PanelState) -> Self {
31        Self {
32            title,
33            content: Vec::new(),
34            state,
35            focused: false,
36            scroll_offset: 0,
37            shortcut: None,
38            borderless: false,
39        }
40    }
41
42    pub fn content(mut self, content: Vec<Line<'a>>) -> Self {
43        self.content = content;
44        self
45    }
46
47    pub fn focused(mut self, focused: bool) -> Self {
48        self.focused = focused;
49        self
50    }
51
52    pub fn scroll_offset(mut self, offset: u16) -> Self {
53        self.scroll_offset = offset;
54        self
55    }
56
57    pub fn shortcut(mut self, shortcut: Option<char>) -> Self {
58        self.shortcut = shortcut;
59        self
60    }
61
62    /// Set borderless mode (copy mode — strips borders to maximize text area).
63    pub fn borderless(mut self, borderless: bool) -> Self {
64        self.borderless = borderless;
65        self
66    }
67
68    /// The minimum height this panel needs in its current state.
69    pub fn min_height(&self) -> u16 {
70        match self.state {
71            PanelState::Hidden => 0,
72            PanelState::Collapsed => 1,
73            PanelState::Expanded if self.borderless => 1, // no borders, just content
74            PanelState::Expanded => 3, // top border + 1 line content + bottom border
75        }
76    }
77
78    /// Build the block with appropriate styling for focus state.
79    fn build_block(&self) -> Block<'a> {
80        let border_style = if self.focused {
81            Style::default()
82                .fg(Color::Cyan)
83                .add_modifier(Modifier::BOLD)
84        } else {
85            Tier::Background.style()
86        };
87
88        let state_indicator = match self.state {
89            PanelState::Expanded => "[-]",
90            PanelState::Collapsed => "[+]",
91            PanelState::Hidden => "",
92        };
93
94        let shortcut_str = self.shortcut.map(|c| format!(" {c}:")).unwrap_or_default();
95
96        let title = format!("{shortcut_str}{} {state_indicator}", self.title);
97
98        Block::default()
99            .borders(Borders::ALL)
100            .border_style(border_style)
101            .title(Span::styled(title, border_style))
102    }
103}
104
105impl Widget for CollapsiblePanel<'_> {
106    fn render(self, area: Rect, buf: &mut Buffer) {
107        match self.state {
108            PanelState::Hidden => {
109                // Render nothing.
110            }
111            PanelState::Collapsed => {
112                // Render just a single-line header.
113                if area.height == 0 {
114                    return;
115                }
116                let block = self.build_block();
117                let header_area = Rect {
118                    height: 1.min(area.height),
119                    ..area
120                };
121                // Render as a single line: "▶ Title [+]"
122                let border_style = if self.focused {
123                    Style::default()
124                        .fg(Color::Cyan)
125                        .add_modifier(Modifier::BOLD)
126                } else {
127                    Tier::Background.style()
128                };
129                let shortcut_str = self.shortcut.map(|c| format!("{c}:")).unwrap_or_default();
130                let line = Line::from(vec![
131                    Span::styled("▶ ", border_style),
132                    Span::styled(format!("{shortcut_str}{} [+]", self.title), border_style),
133                ]);
134                let _ = block; // not used in collapsed mode
135                Paragraph::new(line).render(header_area, buf);
136            }
137            PanelState::Expanded if self.borderless => {
138                // Copy mode: render content directly without borders.
139                let visible_lines: Vec<Line<'_>> = self
140                    .content
141                    .into_iter()
142                    .skip(self.scroll_offset as usize)
143                    .take(area.height as usize)
144                    .collect();
145
146                Paragraph::new(visible_lines).render(area, buf);
147            }
148            PanelState::Expanded => {
149                let block = self.build_block();
150                let inner = block.inner(area);
151                block.render(area, buf);
152
153                // Apply scroll offset.
154                let visible_lines: Vec<Line<'_>> = self
155                    .content
156                    .into_iter()
157                    .skip(self.scroll_offset as usize)
158                    .take(inner.height as usize)
159                    .collect();
160
161                Paragraph::new(visible_lines).render(inner, buf);
162            }
163        }
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn min_height_by_state() {
173        let panel = CollapsiblePanel::new("Test", PanelState::Expanded);
174        assert_eq!(panel.min_height(), 3);
175
176        let panel = CollapsiblePanel::new("Test", PanelState::Collapsed);
177        assert_eq!(panel.min_height(), 1);
178
179        let panel = CollapsiblePanel::new("Test", PanelState::Hidden);
180        assert_eq!(panel.min_height(), 0);
181    }
182
183    #[test]
184    fn collapsed_renders_in_one_line() {
185        let panel = CollapsiblePanel::new("Tasks", PanelState::Collapsed).shortcut(Some('1'));
186        let area = Rect::new(0, 0, 40, 1);
187        let mut buf = Buffer::empty(area);
188        panel.render(area, &mut buf);
189
190        // Should have rendered something in the first line.
191        let line: String = (0..40)
192            .map(|x| {
193                buf.cell((x, 0))
194                    .unwrap()
195                    .symbol()
196                    .chars()
197                    .next()
198                    .unwrap_or(' ')
199            })
200            .collect();
201        assert!(line.contains("Tasks"));
202    }
203
204    #[test]
205    fn hidden_renders_nothing() {
206        let panel = CollapsiblePanel::new("Tasks", PanelState::Hidden);
207        let area = Rect::new(0, 0, 40, 5);
208        let mut buf = Buffer::empty(area);
209        // Fill with markers to detect changes.
210        for y in 0..5 {
211            for x in 0..40 {
212                buf.cell_mut((x, y)).unwrap().set_char('X');
213            }
214        }
215        panel.render(area, &mut buf);
216        // All cells should still be 'X' (nothing rendered).
217        for y in 0..5 {
218            for x in 0..40 {
219                assert_eq!(buf.cell((x, y)).unwrap().symbol(), "X");
220            }
221        }
222    }
223
224    #[test]
225    fn expanded_renders_block_with_content() {
226        let content = vec![Line::from("Hello"), Line::from("World")];
227        let panel = CollapsiblePanel::new("Test", PanelState::Expanded)
228            .content(content)
229            .focused(true);
230        let area = Rect::new(0, 0, 40, 5);
231        let mut buf = Buffer::empty(area);
232        panel.render(area, &mut buf);
233
234        // Check that content appears (inside the borders).
235        let line: String = (0..40)
236            .map(|x| {
237                buf.cell((x, 1))
238                    .unwrap()
239                    .symbol()
240                    .chars()
241                    .next()
242                    .unwrap_or(' ')
243            })
244            .collect();
245        assert!(line.contains("Hello"));
246    }
247
248    #[test]
249    fn borderless_min_height() {
250        let panel = CollapsiblePanel::new("Test", PanelState::Expanded).borderless(true);
251        assert_eq!(panel.min_height(), 1); // no borders
252    }
253
254    #[test]
255    fn borderless_renders_without_borders() {
256        let content = vec![Line::from("Hello"), Line::from("World")];
257        let panel = CollapsiblePanel::new("Test", PanelState::Expanded)
258            .content(content)
259            .borderless(true);
260        let area = Rect::new(0, 0, 40, 5);
261        let mut buf = Buffer::empty(area);
262        panel.render(area, &mut buf);
263
264        // First line should contain content directly (no border).
265        let line: String = (0..40)
266            .map(|x| {
267                buf.cell((x, 0))
268                    .unwrap()
269                    .symbol()
270                    .chars()
271                    .next()
272                    .unwrap_or(' ')
273            })
274            .collect();
275        assert!(line.contains("Hello"));
276    }
277
278    #[test]
279    fn scroll_offset_skips_lines() {
280        let content = vec![
281            Line::from("Line 0"),
282            Line::from("Line 1"),
283            Line::from("Line 2"),
284        ];
285        let panel = CollapsiblePanel::new("Test", PanelState::Expanded)
286            .content(content)
287            .scroll_offset(1);
288        let area = Rect::new(0, 0, 40, 5);
289        let mut buf = Buffer::empty(area);
290        panel.render(area, &mut buf);
291
292        // First visible line should be "Line 1" (skipped "Line 0").
293        let line: String = (0..40)
294            .map(|x| {
295                buf.cell((x, 1))
296                    .unwrap()
297                    .symbol()
298                    .chars()
299                    .next()
300                    .unwrap_or(' ')
301            })
302            .collect();
303        assert!(line.contains("Line 1"));
304    }
305}