use crate::tui::state::{TaskStatus, TreeNodeType, TreeViewItem, TuiState};
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Widget},
};
pub struct TaskTreeWidget<'a> {
state: &'a TuiState,
}
impl<'a> TaskTreeWidget<'a> {
#[must_use]
pub const fn new(state: &'a TuiState) -> Self {
Self { state }
}
fn render_tree_prefix(item: &TreeViewItem) -> String {
let indent = " ".repeat(item.depth);
let icon = if item.has_children {
if item.is_expanded { "▼" } else { "▶" }
} else {
"─"
};
format!("{indent}{icon} ")
}
fn get_group_status(&self, group_path: &str) -> (TaskStatus, usize, usize) {
let mut running = 0;
let mut completed = 0;
let mut failed = 0;
let mut total = 0;
for (name, info) in &self.state.tasks {
let task_path = Self::parse_task_path(name).join(".");
if task_path.starts_with(group_path) || group_path.is_empty() {
total += 1;
match info.status {
TaskStatus::Running => running += 1,
TaskStatus::Completed | TaskStatus::Cached => completed += 1,
TaskStatus::Failed => failed += 1,
_ => {}
}
}
}
let status = if failed > 0 {
TaskStatus::Failed
} else if running > 0 {
TaskStatus::Running
} else if completed == total && total > 0 {
TaskStatus::Completed
} else {
TaskStatus::Pending
};
(status, completed, total)
}
fn parse_task_path(task_name: &str) -> Vec<&str> {
let parts: Vec<&str> = task_name.split(':').collect();
if parts.len() >= 3 {
parts[2].split('.').collect()
} else if parts.len() == 2 {
parts[1].split('.').collect()
} else {
vec![task_name]
}
}
fn render_tree_item(
&self,
item: &TreeViewItem,
is_cursor: bool,
is_selected: bool,
) -> Line<'static> {
let prefix = Self::render_tree_prefix(item);
let (status_symbol, status_color, suffix) = match &item.node_type {
TreeNodeType::All => {
let (status, completed, total) = self.get_group_status("");
let suffix = format!(" ({completed}/{total})");
(status.symbol(), status.color(), suffix)
}
TreeNodeType::Group(path) => {
let (status, completed, total) = self.get_group_status(path);
let suffix = format!(" ({completed}/{total})");
(status.symbol(), status.color(), suffix)
}
TreeNodeType::Task(name) => {
let task_info = self.state.tasks.get(name);
let (symbol, color) = task_info.map_or(("?", Color::DarkGray), |t| {
(t.status.symbol(), t.status.color())
});
let duration = task_info.and_then(|t| {
t.duration_ms.or_else(|| {
if t.status == TaskStatus::Running {
t.elapsed_ms()
} else {
None
}
})
});
let suffix = duration.map(|ms| format!(" ({ms}ms)")).unwrap_or_default();
(symbol, color, suffix)
}
};
let mut name_style = Style::default().fg(status_color);
if matches!(&item.node_type, TreeNodeType::Task(name) if self.state.tasks.get(name).map(|t| t.status) == Some(TaskStatus::Running))
{
name_style = name_style.add_modifier(Modifier::BOLD);
}
if is_cursor {
name_style = name_style.bg(Color::DarkGray);
}
if is_selected {
name_style = name_style.add_modifier(Modifier::REVERSED);
}
Line::from(vec![
Span::styled(prefix, Style::default().fg(Color::DarkGray)),
Span::styled(status_symbol.to_string(), Style::default().fg(status_color)),
Span::raw(" "),
Span::styled(
format!("{}{}", item.display_name.clone(), suffix),
name_style,
),
])
}
}
impl Widget for TaskTreeWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Tasks ")
.border_style(Style::default().fg(Color::Cyan));
let inner = block.inner(area);
block.render(area, buf);
let visible_height = inner.height as usize;
let cursor = self.state.cursor_position;
let scroll_offset = if cursor >= visible_height {
cursor - visible_height + 1
} else {
0
};
let mut lines = Vec::new();
for (idx, item) in self.state.flattened_tree.iter().enumerate() {
if idx < scroll_offset {
continue;
}
if lines.len() >= visible_height {
break;
}
let is_cursor = idx == cursor;
let is_selected = match &item.node_type {
TreeNodeType::All => {
self.state.selected_task.is_none()
&& self.state.output_mode == crate::tui::state::OutputMode::All
}
TreeNodeType::Task(name) => self.state.selected_task.as_deref() == Some(name),
TreeNodeType::Group(path) => {
self.state.selected_task.as_deref() == Some(&format!("::group::{path}"))
}
};
lines.push(self.render_tree_item(item, is_cursor, is_selected));
}
if lines.is_empty() {
lines.push(Line::from(vec![Span::styled(
"No tasks",
Style::default().fg(Color::DarkGray),
)]));
}
let paragraph = Paragraph::new(lines);
paragraph.render(inner, buf);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_task_tree_widget_new() {
let state = TuiState::new();
let widget = TaskTreeWidget::new(&state);
assert!(std::ptr::eq(widget.state, &raw const state));
}
#[test]
fn test_render_tree_prefix_collapsed() {
let item = TreeViewItem {
node_type: TreeNodeType::Group("test".to_string()),
display_name: "test".to_string(),
depth: 0,
is_expanded: false,
has_children: true,
};
let prefix = TaskTreeWidget::render_tree_prefix(&item);
assert_eq!(prefix, "▶ ");
}
#[test]
fn test_render_tree_prefix_expanded() {
let item = TreeViewItem {
node_type: TreeNodeType::Group("test".to_string()),
display_name: "test".to_string(),
depth: 0,
is_expanded: true,
has_children: true,
};
let prefix = TaskTreeWidget::render_tree_prefix(&item);
assert_eq!(prefix, "▼ ");
}
#[test]
fn test_render_tree_prefix_leaf() {
let item = TreeViewItem {
node_type: TreeNodeType::Task("test".to_string()),
display_name: "test".to_string(),
depth: 1,
is_expanded: false,
has_children: false,
};
let prefix = TaskTreeWidget::render_tree_prefix(&item);
assert_eq!(prefix, " ─ ");
}
#[test]
fn test_render_tree_prefix_nested() {
let item = TreeViewItem {
node_type: TreeNodeType::Group("test.nested".to_string()),
display_name: "nested".to_string(),
depth: 2,
is_expanded: true,
has_children: true,
};
let prefix = TaskTreeWidget::render_tree_prefix(&item);
assert_eq!(prefix, " ▼ ");
}
#[test]
fn test_parse_task_path() {
assert_eq!(
TaskTreeWidget::parse_task_path("task:cuenv:test.bdd"),
vec!["test", "bdd"]
);
assert_eq!(
TaskTreeWidget::parse_task_path("task:cuenv:build"),
vec!["build"]
);
assert_eq!(
TaskTreeWidget::parse_task_path("cuenv:test.unit"),
vec!["test", "unit"]
);
assert_eq!(TaskTreeWidget::parse_task_path("simple"), vec!["simple"]);
}
}