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