use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Paragraph};
use crate::palette::color;
pub trait PaneEntry {
fn display_text(&self) -> &str;
fn prefix(&self) -> Option<(&str, Style)> {
None
}
fn text_style(&self) -> Style {
Style::default().fg(color::TEXT)
}
}
pub struct ScrollablePane<'a, T: PaneEntry> {
pub entries: &'a [T],
pub scroll_offset: usize,
pub title: Option<&'a str>,
pub border_style: Style,
pub empty_text: &'a str,
}
impl<'a, T: PaneEntry> ScrollablePane<'a, T> {
pub fn new(entries: &'a [T], scroll_offset: usize) -> Self {
Self {
entries,
scroll_offset,
title: None,
border_style: Style::default().fg(color::INACTIVE),
empty_text: "No output yet",
}
}
pub fn with_title(mut self, title: &'a str) -> Self {
self.title = Some(title);
self
}
pub fn with_border_style(mut self, style: Style) -> Self {
self.border_style = style;
self
}
pub fn with_empty_text(mut self, text: &'a str) -> Self {
self.empty_text = text;
self
}
}
impl<T: PaneEntry> Widget for ScrollablePane<'_, T> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {
return;
}
let mut block = Block::default()
.borders(Borders::ALL)
.border_style(self.border_style);
if let Some(title) = self.title {
block = block.title(format!(" {} ", title));
}
let inner = block.inner(area);
block.render(area, buf);
if inner.height == 0 || inner.width == 0 {
return;
}
if self.entries.is_empty() {
Paragraph::new(self.empty_text)
.style(
Style::default()
.fg(color::INACTIVE)
.add_modifier(Modifier::ITALIC),
)
.render(inner, buf);
return;
}
let visible_count = inner.height as usize;
let total = self.entries.len();
let start = self.scroll_offset.min(total.saturating_sub(visible_count));
let end = (start + visible_count).min(total);
for (display_idx, idx) in (start..end).enumerate() {
if display_idx >= visible_count {
break;
}
let entry = &self.entries[idx];
let y = inner.y + display_idx as u16;
let mut x = inner.x;
let mut remaining_width = inner.width as usize;
if let Some((prefix_text, prefix_style)) = entry.prefix() {
let pw = prefix_text.len().min(remaining_width);
buf.set_string(x, y, &prefix_text[..pw], prefix_style);
x += pw as u16;
remaining_width = remaining_width.saturating_sub(pw);
}
let text = entry.display_text();
if remaining_width == 0 {
continue;
}
let display = if text.len() > remaining_width {
if remaining_width >= 4 {
format!("{}...", &text[..remaining_width.saturating_sub(3)])
} else {
text[..remaining_width].to_string()
}
} else {
text.to_string()
};
buf.set_string(x, y, &display, entry.text_style());
}
if total > visible_count {
let percent = if total == 0 {
100
} else {
((end as f64 / total as f64) * 100.0) as usize
};
let indicator = format!(" {}% ", percent);
let ind_len = indicator.len() as u16;
let badge_x = inner.x + inner.width.saturating_sub(ind_len + 1);
let badge_y = inner.y + inner.height.saturating_sub(1);
if badge_x >= inner.x && badge_y >= inner.y {
buf.set_string(
badge_x,
badge_y,
&indicator,
Style::default()
.fg(color::SCROLL_BADGE_FG)
.bg(color::SCROLL_BADGE_BG),
);
}
}
}
}
#[derive(Debug, Clone)]
pub struct OutputLine {
pub text: String,
pub is_stderr: bool,
}
impl PaneEntry for OutputLine {
fn display_text(&self) -> &str {
&self.text
}
fn text_style(&self) -> Style {
if self.is_stderr {
Style::default().fg(color::WARNING)
} else {
Style::default().fg(color::TEXT)
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LogLevel {
Info,
Warn,
Error,
}
#[derive(Debug, Clone)]
pub struct LogEntry {
pub level: LogLevel,
pub message: String,
}
impl PaneEntry for LogEntry {
fn display_text(&self) -> &str {
&self.message
}
fn prefix(&self) -> Option<(&str, Style)> {
match self.level {
LogLevel::Info => Some((
"[INFO] ",
Style::default()
.fg(color::INACTIVE)
.add_modifier(Modifier::BOLD),
)),
LogLevel::Warn => Some((
"[WARN] ",
Style::default()
.fg(color::WARNING)
.add_modifier(Modifier::BOLD),
)),
LogLevel::Error => Some((
"[ERROR] ",
Style::default()
.fg(color::ERROR)
.add_modifier(Modifier::BOLD),
)),
}
}
fn text_style(&self) -> Style {
match self.level {
LogLevel::Info => Style::default().fg(color::INACTIVE),
LogLevel::Warn => Style::default().fg(color::WARNING),
LogLevel::Error => Style::default().fg(color::ERROR),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_buffer(width: u16, height: u16) -> Buffer {
Buffer::empty(Rect::new(0, 0, width, height))
}
fn buffer_text(buf: &Buffer) -> String {
buf.content().iter().map(|c| c.symbol()).collect()
}
#[test]
fn empty_entries_shows_placeholder() {
let mut buf = create_buffer(40, 5);
let area = Rect::new(0, 0, 40, 5);
let entries: Vec<OutputLine> = vec![];
let pane = ScrollablePane::new(&entries, 0);
pane.render(area, &mut buf);
let text = buffer_text(&buf);
assert!(text.contains("No output yet"));
}
#[test]
fn custom_empty_text() {
let mut buf = create_buffer(40, 5);
let area = Rect::new(0, 0, 40, 5);
let entries: Vec<OutputLine> = vec![];
let pane = ScrollablePane::new(&entries, 0).with_empty_text("Waiting for logs...");
pane.render(area, &mut buf);
let text = buffer_text(&buf);
assert!(text.contains("Waiting for logs..."));
}
#[test]
fn renders_output_lines() {
let mut buf = create_buffer(60, 6);
let area = Rect::new(0, 0, 60, 6);
let entries = vec![
OutputLine {
text: "stdout line one".to_string(),
is_stderr: false,
},
OutputLine {
text: "stderr warning".to_string(),
is_stderr: true,
},
];
let pane = ScrollablePane::new(&entries, 0).with_title("Output");
pane.render(area, &mut buf);
let text = buffer_text(&buf);
assert!(text.contains("stdout line one"));
assert!(text.contains("stderr warning"));
assert!(text.contains("Output"));
}
#[test]
fn truncates_long_lines() {
let mut buf = create_buffer(20, 4);
let area = Rect::new(0, 0, 20, 4);
let entries = vec![OutputLine {
text: "A very long line that should be truncated with ellipsis".to_string(),
is_stderr: false,
}];
let pane = ScrollablePane::new(&entries, 0);
pane.render(area, &mut buf);
let text = buffer_text(&buf);
assert!(text.contains("..."));
}
#[test]
fn scroll_offset_clamps_past_end() {
let mut buf = create_buffer(40, 5);
let area = Rect::new(0, 0, 40, 5);
let entries: Vec<OutputLine> = (0..3)
.map(|i| OutputLine {
text: format!("Line {}", i),
is_stderr: false,
})
.collect();
let pane = ScrollablePane::new(&entries, 100);
pane.render(area, &mut buf);
let text = buffer_text(&buf);
assert!(text.contains("Line"));
}
#[test]
fn scroll_percentage_shown_when_scrollable() {
let mut buf = create_buffer(40, 5);
let area = Rect::new(0, 0, 40, 5);
let entries: Vec<OutputLine> = (0..10)
.map(|i| OutputLine {
text: format!("Line {}", i),
is_stderr: false,
})
.collect();
let pane = ScrollablePane::new(&entries, 0);
pane.render(area, &mut buf);
let text = buffer_text(&buf);
assert!(text.contains('%'));
}
#[test]
fn no_scroll_badge_when_all_visible() {
let mut buf = create_buffer(40, 10);
let area = Rect::new(0, 0, 40, 10);
let entries = vec![
OutputLine {
text: "one".to_string(),
is_stderr: false,
},
OutputLine {
text: "two".to_string(),
is_stderr: false,
},
];
let pane = ScrollablePane::new(&entries, 0);
pane.render(area, &mut buf);
let text = buffer_text(&buf);
assert!(!text.contains('%'));
}
#[test]
fn log_entry_prefix_rendered() {
let mut buf = create_buffer(60, 6);
let area = Rect::new(0, 0, 60, 6);
let entries = vec![
LogEntry {
level: LogLevel::Info,
message: "Startup complete".to_string(),
},
LogEntry {
level: LogLevel::Warn,
message: "Overlay unavailable".to_string(),
},
LogEntry {
level: LogLevel::Error,
message: "Container crashed".to_string(),
},
];
let pane = ScrollablePane::new(&entries, 0).with_title("Logs");
pane.render(area, &mut buf);
let text = buffer_text(&buf);
assert!(text.contains("[INFO]"));
assert!(text.contains("[WARN]"));
assert!(text.contains("[ERROR]"));
assert!(text.contains("Startup complete"));
}
#[test]
fn log_entry_scroll_shows_percentage() {
let mut buf = create_buffer(60, 5);
let area = Rect::new(0, 0, 60, 5);
let entries: Vec<LogEntry> = (0..20)
.map(|i| LogEntry {
level: LogLevel::Info,
message: format!("Log line {}", i),
})
.collect();
let pane = ScrollablePane::new(&entries, 5).with_title("Logs");
pane.render(area, &mut buf);
let text = buffer_text(&buf);
assert!(text.contains('%'));
}
#[test]
fn zero_height_does_not_panic() {
let mut buf = create_buffer(40, 0);
let area = Rect::new(0, 0, 40, 0);
let entries = vec![OutputLine {
text: "hello".to_string(),
is_stderr: false,
}];
let pane = ScrollablePane::new(&entries, 0);
pane.render(area, &mut buf);
}
#[test]
fn zero_width_does_not_panic() {
let mut buf = create_buffer(0, 5);
let area = Rect::new(0, 0, 0, 5);
let entries = vec![OutputLine {
text: "hello".to_string(),
is_stderr: false,
}];
let pane = ScrollablePane::new(&entries, 0);
pane.render(area, &mut buf);
}
#[test]
fn builder_methods_chain() {
let entries: Vec<OutputLine> = vec![];
let pane = ScrollablePane::new(&entries, 0)
.with_title("Test")
.with_border_style(Style::default().fg(Color::Blue))
.with_empty_text("Nothing here");
assert_eq!(pane.title, Some("Test"));
assert_eq!(pane.empty_text, "Nothing here");
}
#[test]
fn output_line_stderr_vs_stdout_styles() {
let stdout = OutputLine {
text: "ok".to_string(),
is_stderr: false,
};
let stderr = OutputLine {
text: "err".to_string(),
is_stderr: true,
};
assert_eq!(stdout.text_style().fg, Some(color::TEXT));
assert_eq!(stderr.text_style().fg, Some(color::WARNING));
}
#[test]
fn log_level_styles_differ() {
let info = LogEntry {
level: LogLevel::Info,
message: String::new(),
};
let warn = LogEntry {
level: LogLevel::Warn,
message: String::new(),
};
let error = LogEntry {
level: LogLevel::Error,
message: String::new(),
};
assert_eq!(info.text_style().fg, Some(color::INACTIVE));
assert_eq!(warn.text_style().fg, Some(color::WARNING));
assert_eq!(error.text_style().fg, Some(color::ERROR));
}
}