use crate::tui::state::{OutputMode, TuiState};
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Widget, Wrap},
};
pub struct OutputPanelWidget<'a> {
state: &'a TuiState,
}
impl<'a> OutputPanelWidget<'a> {
#[must_use]
pub const fn new(state: &'a TuiState) -> Self {
Self { state }
}
fn render_task_header(task_name: &str, status_symbol: &str, color: Color) -> Line<'static> {
let display_name = Self::extract_display_name(task_name);
let header_text = format!("─── {status_symbol} {display_name} ");
let padding = "─".repeat(40);
Line::from(vec![
Span::styled(
header_text,
Style::default().fg(color).add_modifier(Modifier::BOLD),
),
Span::styled(padding, Style::default().fg(Color::DarkGray)),
])
}
fn extract_display_name(task_name: &str) -> &str {
let parts: Vec<&str> = task_name.split(':').collect();
if parts.len() >= 3 {
parts[2]
} else if parts.len() == 2 {
parts[1]
} else {
task_name
}
}
fn parse_task_path(task_name: &str) -> String {
let parts: Vec<&str> = task_name.split(':').collect();
if parts.len() >= 3 {
parts[2].to_string()
} else if parts.len() == 2 {
parts[1].to_string()
} else {
task_name.to_string()
}
}
fn task_matches_filter(task_name: &str, filter: &str) -> bool {
if filter.starts_with("::group::") {
let group_path = filter.strip_prefix("::group::").unwrap_or("");
let task_path = Self::parse_task_path(task_name);
task_path.starts_with(group_path)
} else {
task_name == filter
}
}
fn get_visible_tasks(&self) -> Vec<&str> {
match &self.state.selected_task {
None => {
self.state.tasks.keys().map(String::as_str).collect()
}
Some(filter) => {
self.state
.tasks
.keys()
.filter(|name| Self::task_matches_filter(name, filter))
.map(String::as_str)
.collect()
}
}
}
}
impl Widget for OutputPanelWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let title = match &self.state.selected_task {
None => " Output: All Tasks ".to_string(),
Some(filter) if filter.starts_with("::group::") => {
let group = filter.strip_prefix("::group::").unwrap_or("?");
format!(" Output: {group} ")
}
Some(task) => {
let display = Self::extract_display_name(task);
format!(" Output: {display} ")
}
};
let block = Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(Style::default().fg(Color::Cyan));
let inner = block.inner(area);
block.render(area, buf);
let mut lines: Vec<Line> = Vec::new();
let visible_tasks = self.get_visible_tasks();
let mut sorted_tasks: Vec<&str> = visible_tasks;
sorted_tasks.sort_unstable();
for task_name in sorted_tasks {
if let Some(output) = self.state.outputs.get(task_name) {
if output.combined.is_empty() {
continue;
}
let task_info = self.state.tasks.get(task_name);
let (symbol, color) = task_info.map_or(("?", Color::DarkGray), |t| {
(t.status.symbol(), t.status.color())
});
lines.push(Self::render_task_header(task_name, symbol, color));
for output_line in &output.combined {
if output_line.is_stderr {
lines.push(Line::from(vec![
Span::styled("! ", Style::default().fg(Color::Red)),
Span::raw(output_line.content.clone()),
]));
} else {
lines.push(Line::from(vec![Span::raw(output_line.content.clone())]));
}
}
lines.push(Line::from("")); }
}
if lines.is_empty() {
lines.push(Line::from(vec![Span::styled(
"No output yet...",
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::DIM),
)]));
}
let visible_height = inner.height as usize;
let total_lines = lines.len();
let effective_scroll = match self.state.output_mode {
OutputMode::All => total_lines.saturating_sub(visible_height),
OutputMode::Selected => self
.state
.output_scroll
.min(total_lines.saturating_sub(visible_height)),
};
let visible_lines: Vec<Line> = lines
.into_iter()
.skip(effective_scroll)
.take(visible_height)
.collect();
let paragraph = Paragraph::new(visible_lines).wrap(Wrap { trim: false });
paragraph.render(inner, buf);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_output_panel_widget_new() {
let state = TuiState::new();
let widget = OutputPanelWidget::new(&state);
assert!(std::ptr::eq(widget.state, &raw const state));
}
#[test]
fn test_render_task_header() {
let line = OutputPanelWidget::render_task_header("test_task", "✓", Color::Green);
assert!(!line.spans.is_empty());
}
}