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()
298 .iter()
299 .map(ratatui::buffer::Cell::symbol)
300 .collect()
301 }
302
303 #[test]
306 fn empty_entries_shows_placeholder() {
307 let mut buf = create_buffer(40, 5);
308 let area = Rect::new(0, 0, 40, 5);
309
310 let entries: Vec<OutputLine> = vec![];
311 let pane = ScrollablePane::new(&entries, 0);
312 pane.render(area, &mut buf);
313
314 let text = buffer_text(&buf);
315 assert!(text.contains("No output yet"));
316 }
317
318 #[test]
319 fn custom_empty_text() {
320 let mut buf = create_buffer(40, 5);
321 let area = Rect::new(0, 0, 40, 5);
322
323 let entries: Vec<OutputLine> = vec![];
324 let pane = ScrollablePane::new(&entries, 0).with_empty_text("Waiting for logs...");
325 pane.render(area, &mut buf);
326
327 let text = buffer_text(&buf);
328 assert!(text.contains("Waiting for logs..."));
329 }
330
331 #[test]
332 fn renders_output_lines() {
333 let mut buf = create_buffer(60, 6);
334 let area = Rect::new(0, 0, 60, 6);
335
336 let entries = vec![
337 OutputLine {
338 text: "stdout line one".to_string(),
339 is_stderr: false,
340 },
341 OutputLine {
342 text: "stderr warning".to_string(),
343 is_stderr: true,
344 },
345 ];
346
347 let pane = ScrollablePane::new(&entries, 0).with_title("Output");
348 pane.render(area, &mut buf);
349
350 let text = buffer_text(&buf);
351 assert!(text.contains("stdout line one"));
352 assert!(text.contains("stderr warning"));
353 assert!(text.contains("Output"));
354 }
355
356 #[test]
357 fn truncates_long_lines() {
358 let mut buf = create_buffer(20, 4);
360 let area = Rect::new(0, 0, 20, 4);
361
362 let entries = vec![OutputLine {
363 text: "A very long line that should be truncated with ellipsis".to_string(),
364 is_stderr: false,
365 }];
366
367 let pane = ScrollablePane::new(&entries, 0);
368 pane.render(area, &mut buf);
369
370 let text = buffer_text(&buf);
371 assert!(text.contains("..."));
372 }
373
374 #[test]
375 fn scroll_offset_clamps_past_end() {
376 let mut buf = create_buffer(40, 5);
377 let area = Rect::new(0, 0, 40, 5);
378
379 let entries: Vec<OutputLine> = (0..3)
380 .map(|i| OutputLine {
381 text: format!("Line {i}"),
382 is_stderr: false,
383 })
384 .collect();
385
386 let pane = ScrollablePane::new(&entries, 100);
388 pane.render(area, &mut buf);
389
390 let text = buffer_text(&buf);
391 assert!(text.contains("Line"));
393 }
394
395 #[test]
396 fn scroll_percentage_shown_when_scrollable() {
397 let mut buf = create_buffer(40, 5);
398 let area = Rect::new(0, 0, 40, 5);
399
400 let entries: Vec<OutputLine> = (0..10)
402 .map(|i| OutputLine {
403 text: format!("Line {i}"),
404 is_stderr: false,
405 })
406 .collect();
407
408 let pane = ScrollablePane::new(&entries, 0);
409 pane.render(area, &mut buf);
410
411 let text = buffer_text(&buf);
412 assert!(text.contains('%'));
413 }
414
415 #[test]
416 fn no_scroll_badge_when_all_visible() {
417 let mut buf = create_buffer(40, 10);
418 let area = Rect::new(0, 0, 40, 10);
419
420 let entries = vec![
422 OutputLine {
423 text: "one".to_string(),
424 is_stderr: false,
425 },
426 OutputLine {
427 text: "two".to_string(),
428 is_stderr: false,
429 },
430 ];
431
432 let pane = ScrollablePane::new(&entries, 0);
433 pane.render(area, &mut buf);
434
435 let text = buffer_text(&buf);
436 assert!(!text.contains('%'));
437 }
438
439 #[test]
442 fn log_entry_prefix_rendered() {
443 let mut buf = create_buffer(60, 6);
444 let area = Rect::new(0, 0, 60, 6);
445
446 let entries = vec![
447 LogEntry {
448 level: LogLevel::Info,
449 message: "Startup complete".to_string(),
450 },
451 LogEntry {
452 level: LogLevel::Warn,
453 message: "Overlay unavailable".to_string(),
454 },
455 LogEntry {
456 level: LogLevel::Error,
457 message: "Container crashed".to_string(),
458 },
459 ];
460
461 let pane = ScrollablePane::new(&entries, 0).with_title("Logs");
462 pane.render(area, &mut buf);
463
464 let text = buffer_text(&buf);
465 assert!(text.contains("[INFO]"));
466 assert!(text.contains("[WARN]"));
467 assert!(text.contains("[ERROR]"));
468 assert!(text.contains("Startup complete"));
469 }
470
471 #[test]
472 fn log_entry_scroll_shows_percentage() {
473 let mut buf = create_buffer(60, 5);
474 let area = Rect::new(0, 0, 60, 5);
475
476 let entries: Vec<LogEntry> = (0..20)
477 .map(|i| LogEntry {
478 level: LogLevel::Info,
479 message: format!("Log line {i}"),
480 })
481 .collect();
482
483 let pane = ScrollablePane::new(&entries, 5).with_title("Logs");
484 pane.render(area, &mut buf);
485
486 let text = buffer_text(&buf);
487 assert!(text.contains('%'));
488 }
489
490 #[test]
491 fn zero_height_does_not_panic() {
492 let mut buf = create_buffer(40, 0);
493 let area = Rect::new(0, 0, 40, 0);
494
495 let entries = vec![OutputLine {
496 text: "hello".to_string(),
497 is_stderr: false,
498 }];
499
500 let pane = ScrollablePane::new(&entries, 0);
501 pane.render(area, &mut buf);
502 }
503
504 #[test]
505 fn zero_width_does_not_panic() {
506 let mut buf = create_buffer(0, 5);
507 let area = Rect::new(0, 0, 0, 5);
508
509 let entries = vec![OutputLine {
510 text: "hello".to_string(),
511 is_stderr: false,
512 }];
513
514 let pane = ScrollablePane::new(&entries, 0);
515 pane.render(area, &mut buf);
516 }
517
518 #[test]
519 fn builder_methods_chain() {
520 let entries: Vec<OutputLine> = vec![];
521 let pane = ScrollablePane::new(&entries, 0)
522 .with_title("Test")
523 .with_border_style(Style::default().fg(Color::Blue))
524 .with_empty_text("Nothing here");
525
526 assert_eq!(pane.title, Some("Test"));
527 assert_eq!(pane.empty_text, "Nothing here");
528 }
529
530 #[test]
531 fn output_line_stderr_vs_stdout_styles() {
532 let stdout = OutputLine {
533 text: "ok".to_string(),
534 is_stderr: false,
535 };
536 let stderr = OutputLine {
537 text: "err".to_string(),
538 is_stderr: true,
539 };
540
541 assert_eq!(stdout.text_style().fg, Some(color::TEXT));
542 assert_eq!(stderr.text_style().fg, Some(color::WARNING));
543 }
544
545 #[test]
546 fn log_level_styles_differ() {
547 let info = LogEntry {
548 level: LogLevel::Info,
549 message: String::new(),
550 };
551 let warn = LogEntry {
552 level: LogLevel::Warn,
553 message: String::new(),
554 };
555 let error = LogEntry {
556 level: LogLevel::Error,
557 message: String::new(),
558 };
559
560 assert_eq!(info.text_style().fg, Some(color::INACTIVE));
561 assert_eq!(warn.text_style().fg, Some(color::WARNING));
562 assert_eq!(error.text_style().fg, Some(color::ERROR));
563 }
564}