1use ratatui::prelude::*;
8use ratatui::widgets::{Block, Borders, Paragraph};
9
10use crate::palette::color;
11
12pub trait PaneEntry {
18 fn display_text(&self) -> &str;
20
21 fn prefix(&self) -> Option<(&str, Style)> {
25 None
26 }
27
28 fn text_style(&self) -> Style {
30 Style::default().fg(color::TEXT)
31 }
32}
33
34pub struct ScrollablePane<'a, T: PaneEntry> {
41 pub entries: &'a [T],
43 pub scroll_offset: usize,
45 pub title: Option<&'a str>,
47 pub border_style: Style,
49 pub empty_text: &'a str,
51}
52
53impl<'a, T: PaneEntry> ScrollablePane<'a, T> {
54 pub fn new(entries: &'a [T], scroll_offset: usize) -> Self {
56 Self {
57 entries,
58 scroll_offset,
59 title: None,
60 border_style: Style::default().fg(color::INACTIVE),
61 empty_text: "No output yet",
62 }
63 }
64
65 #[must_use]
67 pub fn with_title(mut self, title: &'a str) -> Self {
68 self.title = Some(title);
69 self
70 }
71
72 #[must_use]
74 pub fn with_border_style(mut self, style: Style) -> Self {
75 self.border_style = style;
76 self
77 }
78
79 #[must_use]
81 pub fn with_empty_text(mut self, text: &'a str) -> Self {
82 self.empty_text = text;
83 self
84 }
85}
86
87impl<T: PaneEntry> Widget for ScrollablePane<'_, T> {
88 #[allow(
89 clippy::cast_possible_truncation,
90 clippy::cast_precision_loss,
91 clippy::cast_sign_loss
92 )]
93 fn render(self, area: Rect, buf: &mut Buffer) {
94 if area.height == 0 || area.width == 0 {
95 return;
96 }
97
98 let mut block = Block::default()
100 .borders(Borders::ALL)
101 .border_style(self.border_style);
102
103 if let Some(title) = self.title {
104 block = block.title(format!(" {title} "));
105 }
106
107 let inner = block.inner(area);
108 block.render(area, buf);
109
110 if inner.height == 0 || inner.width == 0 {
111 return;
112 }
113
114 if self.entries.is_empty() {
116 Paragraph::new(self.empty_text)
117 .style(
118 Style::default()
119 .fg(color::INACTIVE)
120 .add_modifier(Modifier::ITALIC),
121 )
122 .render(inner, buf);
123 return;
124 }
125
126 let visible_count = inner.height as usize;
127 let total = self.entries.len();
128
129 let start = self.scroll_offset.min(total.saturating_sub(visible_count));
131 let end = (start + visible_count).min(total);
132
133 for (display_idx, idx) in (start..end).enumerate() {
135 if display_idx >= visible_count {
136 break;
137 }
138
139 let entry = &self.entries[idx];
140 let y = inner.y + display_idx as u16;
141 let mut x = inner.x;
142 let mut remaining_width = inner.width as usize;
143
144 if let Some((prefix_text, prefix_style)) = entry.prefix() {
146 let pw = prefix_text.len().min(remaining_width);
147 buf.set_string(x, y, &prefix_text[..pw], prefix_style);
148 x += pw as u16;
149 remaining_width = remaining_width.saturating_sub(pw);
150 }
151
152 let text = entry.display_text();
154 if remaining_width == 0 {
155 continue;
156 }
157
158 let display = if text.len() > remaining_width {
159 if remaining_width >= 4 {
161 format!("{}...", &text[..remaining_width.saturating_sub(3)])
162 } else {
163 text[..remaining_width].to_string()
164 }
165 } else {
166 text.to_string()
167 };
168
169 buf.set_string(x, y, &display, entry.text_style());
170 }
171
172 if total > visible_count {
174 let percent = if total == 0 {
175 100
176 } else {
177 ((end as f64 / total as f64) * 100.0) as usize
178 };
179 let indicator = format!(" {percent}% ");
180 let ind_len = indicator.len() as u16;
181
182 let badge_x = inner.x + inner.width.saturating_sub(ind_len + 1);
183 let badge_y = inner.y + inner.height.saturating_sub(1);
184
185 if badge_x >= inner.x && badge_y >= inner.y {
186 buf.set_string(
187 badge_x,
188 badge_y,
189 &indicator,
190 Style::default()
191 .fg(color::SCROLL_BADGE_FG)
192 .bg(color::SCROLL_BADGE_BG),
193 );
194 }
195 }
196 }
197}
198
199#[derive(Debug, Clone)]
205pub struct OutputLine {
206 pub text: String,
208 pub is_stderr: bool,
210}
211
212impl PaneEntry for OutputLine {
213 fn display_text(&self) -> &str {
214 &self.text
215 }
216
217 fn text_style(&self) -> Style {
218 if self.is_stderr {
219 Style::default().fg(color::WARNING)
220 } else {
221 Style::default().fg(color::TEXT)
222 }
223 }
224}
225
226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232pub enum LogLevel {
233 Info,
234 Warn,
235 Error,
236}
237
238#[derive(Debug, Clone)]
240pub struct LogEntry {
241 pub level: LogLevel,
243 pub message: String,
245}
246
247impl PaneEntry for LogEntry {
248 fn display_text(&self) -> &str {
249 &self.message
250 }
251
252 fn prefix(&self) -> Option<(&str, Style)> {
253 match self.level {
254 LogLevel::Info => Some((
255 "[INFO] ",
256 Style::default()
257 .fg(color::INACTIVE)
258 .add_modifier(Modifier::BOLD),
259 )),
260 LogLevel::Warn => Some((
261 "[WARN] ",
262 Style::default()
263 .fg(color::WARNING)
264 .add_modifier(Modifier::BOLD),
265 )),
266 LogLevel::Error => Some((
267 "[ERROR] ",
268 Style::default()
269 .fg(color::ERROR)
270 .add_modifier(Modifier::BOLD),
271 )),
272 }
273 }
274
275 fn text_style(&self) -> Style {
276 match self.level {
277 LogLevel::Info => Style::default().fg(color::INACTIVE),
278 LogLevel::Warn => Style::default().fg(color::WARNING),
279 LogLevel::Error => Style::default().fg(color::ERROR),
280 }
281 }
282}
283
284#[cfg(test)]
289mod tests {
290 use super::*;
291
292 fn create_buffer(width: u16, height: u16) -> Buffer {
293 Buffer::empty(Rect::new(0, 0, width, height))
294 }
295
296 fn buffer_text(buf: &Buffer) -> String {
297 buf.content().iter().map(|c| c.symbol()).collect()
298 }
299
300 #[test]
303 fn empty_entries_shows_placeholder() {
304 let mut buf = create_buffer(40, 5);
305 let area = Rect::new(0, 0, 40, 5);
306
307 let entries: Vec<OutputLine> = vec![];
308 let pane = ScrollablePane::new(&entries, 0);
309 pane.render(area, &mut buf);
310
311 let text = buffer_text(&buf);
312 assert!(text.contains("No output yet"));
313 }
314
315 #[test]
316 fn custom_empty_text() {
317 let mut buf = create_buffer(40, 5);
318 let area = Rect::new(0, 0, 40, 5);
319
320 let entries: Vec<OutputLine> = vec![];
321 let pane = ScrollablePane::new(&entries, 0).with_empty_text("Waiting for logs...");
322 pane.render(area, &mut buf);
323
324 let text = buffer_text(&buf);
325 assert!(text.contains("Waiting for logs..."));
326 }
327
328 #[test]
329 fn renders_output_lines() {
330 let mut buf = create_buffer(60, 6);
331 let area = Rect::new(0, 0, 60, 6);
332
333 let entries = vec![
334 OutputLine {
335 text: "stdout line one".to_string(),
336 is_stderr: false,
337 },
338 OutputLine {
339 text: "stderr warning".to_string(),
340 is_stderr: true,
341 },
342 ];
343
344 let pane = ScrollablePane::new(&entries, 0).with_title("Output");
345 pane.render(area, &mut buf);
346
347 let text = buffer_text(&buf);
348 assert!(text.contains("stdout line one"));
349 assert!(text.contains("stderr warning"));
350 assert!(text.contains("Output"));
351 }
352
353 #[test]
354 fn truncates_long_lines() {
355 let mut buf = create_buffer(20, 4);
357 let area = Rect::new(0, 0, 20, 4);
358
359 let entries = vec![OutputLine {
360 text: "A very long line that should be truncated with ellipsis".to_string(),
361 is_stderr: false,
362 }];
363
364 let pane = ScrollablePane::new(&entries, 0);
365 pane.render(area, &mut buf);
366
367 let text = buffer_text(&buf);
368 assert!(text.contains("..."));
369 }
370
371 #[test]
372 fn scroll_offset_clamps_past_end() {
373 let mut buf = create_buffer(40, 5);
374 let area = Rect::new(0, 0, 40, 5);
375
376 let entries: Vec<OutputLine> = (0..3)
377 .map(|i| OutputLine {
378 text: format!("Line {}", i),
379 is_stderr: false,
380 })
381 .collect();
382
383 let pane = ScrollablePane::new(&entries, 100);
385 pane.render(area, &mut buf);
386
387 let text = buffer_text(&buf);
388 assert!(text.contains("Line"));
390 }
391
392 #[test]
393 fn scroll_percentage_shown_when_scrollable() {
394 let mut buf = create_buffer(40, 5);
395 let area = Rect::new(0, 0, 40, 5);
396
397 let entries: Vec<OutputLine> = (0..10)
399 .map(|i| OutputLine {
400 text: format!("Line {}", i),
401 is_stderr: false,
402 })
403 .collect();
404
405 let pane = ScrollablePane::new(&entries, 0);
406 pane.render(area, &mut buf);
407
408 let text = buffer_text(&buf);
409 assert!(text.contains('%'));
410 }
411
412 #[test]
413 fn no_scroll_badge_when_all_visible() {
414 let mut buf = create_buffer(40, 10);
415 let area = Rect::new(0, 0, 40, 10);
416
417 let entries = vec![
419 OutputLine {
420 text: "one".to_string(),
421 is_stderr: false,
422 },
423 OutputLine {
424 text: "two".to_string(),
425 is_stderr: false,
426 },
427 ];
428
429 let pane = ScrollablePane::new(&entries, 0);
430 pane.render(area, &mut buf);
431
432 let text = buffer_text(&buf);
433 assert!(!text.contains('%'));
434 }
435
436 #[test]
439 fn log_entry_prefix_rendered() {
440 let mut buf = create_buffer(60, 6);
441 let area = Rect::new(0, 0, 60, 6);
442
443 let entries = vec![
444 LogEntry {
445 level: LogLevel::Info,
446 message: "Startup complete".to_string(),
447 },
448 LogEntry {
449 level: LogLevel::Warn,
450 message: "Overlay unavailable".to_string(),
451 },
452 LogEntry {
453 level: LogLevel::Error,
454 message: "Container crashed".to_string(),
455 },
456 ];
457
458 let pane = ScrollablePane::new(&entries, 0).with_title("Logs");
459 pane.render(area, &mut buf);
460
461 let text = buffer_text(&buf);
462 assert!(text.contains("[INFO]"));
463 assert!(text.contains("[WARN]"));
464 assert!(text.contains("[ERROR]"));
465 assert!(text.contains("Startup complete"));
466 }
467
468 #[test]
469 fn log_entry_scroll_shows_percentage() {
470 let mut buf = create_buffer(60, 5);
471 let area = Rect::new(0, 0, 60, 5);
472
473 let entries: Vec<LogEntry> = (0..20)
474 .map(|i| LogEntry {
475 level: LogLevel::Info,
476 message: format!("Log line {}", i),
477 })
478 .collect();
479
480 let pane = ScrollablePane::new(&entries, 5).with_title("Logs");
481 pane.render(area, &mut buf);
482
483 let text = buffer_text(&buf);
484 assert!(text.contains('%'));
485 }
486
487 #[test]
488 fn zero_height_does_not_panic() {
489 let mut buf = create_buffer(40, 0);
490 let area = Rect::new(0, 0, 40, 0);
491
492 let entries = vec![OutputLine {
493 text: "hello".to_string(),
494 is_stderr: false,
495 }];
496
497 let pane = ScrollablePane::new(&entries, 0);
498 pane.render(area, &mut buf);
499 }
500
501 #[test]
502 fn zero_width_does_not_panic() {
503 let mut buf = create_buffer(0, 5);
504 let area = Rect::new(0, 0, 0, 5);
505
506 let entries = vec![OutputLine {
507 text: "hello".to_string(),
508 is_stderr: false,
509 }];
510
511 let pane = ScrollablePane::new(&entries, 0);
512 pane.render(area, &mut buf);
513 }
514
515 #[test]
516 fn builder_methods_chain() {
517 let entries: Vec<OutputLine> = vec![];
518 let pane = ScrollablePane::new(&entries, 0)
519 .with_title("Test")
520 .with_border_style(Style::default().fg(Color::Blue))
521 .with_empty_text("Nothing here");
522
523 assert_eq!(pane.title, Some("Test"));
524 assert_eq!(pane.empty_text, "Nothing here");
525 }
526
527 #[test]
528 fn output_line_stderr_vs_stdout_styles() {
529 let stdout = OutputLine {
530 text: "ok".to_string(),
531 is_stderr: false,
532 };
533 let stderr = OutputLine {
534 text: "err".to_string(),
535 is_stderr: true,
536 };
537
538 assert_eq!(stdout.text_style().fg, Some(color::TEXT));
539 assert_eq!(stderr.text_style().fg, Some(color::WARNING));
540 }
541
542 #[test]
543 fn log_level_styles_differ() {
544 let info = LogEntry {
545 level: LogLevel::Info,
546 message: String::new(),
547 };
548 let warn = LogEntry {
549 level: LogLevel::Warn,
550 message: String::new(),
551 };
552 let error = LogEntry {
553 level: LogLevel::Error,
554 message: String::new(),
555 };
556
557 assert_eq!(info.text_style().fg, Some(color::INACTIVE));
558 assert_eq!(warn.text_style().fg, Some(color::WARNING));
559 assert_eq!(error.text_style().fg, Some(color::ERROR));
560 }
561}