1use 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
14pub 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 pub fn borderless(mut self, borderless: bool) -> Self {
64 self.borderless = borderless;
65 self
66 }
67
68 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, PanelState::Expanded => 3, }
76 }
77
78 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 }
111 PanelState::Collapsed => {
112 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 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; Paragraph::new(line).render(header_area, buf);
136 }
137 PanelState::Expanded if self.borderless => {
138 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 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 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 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 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 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); }
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 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 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}