use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap};
use super::app::{App, AppMode};
use super::command_palette::draw_command_palette;
use super::qa_ui;
pub fn draw(frame: &mut Frame, app: &App) {
let size = frame.area();
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(3),
Constraint::Min(2),
])
.split(size);
draw_header(frame, app, main_chunks[0]);
if app.qa_panel_visible() {
let body_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(main_chunks[1]);
draw_step_list(frame, app, body_chunks[0]);
qa_ui::draw_question_panel(frame, app, body_chunks[1]);
} else if app.mode == AppMode::Inspecting {
let body_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(main_chunks[1]);
draw_step_list(frame, app, body_chunks[0]);
draw_detail_panel(frame, app, body_chunks[1]);
} else if app.harness_log_visible() {
let body_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(main_chunks[1]);
draw_step_list(frame, app, body_chunks[0]);
draw_harness_log_panel(frame, app, body_chunks[1]);
} else {
draw_step_list(frame, app, main_chunks[1]);
}
draw_footer(frame, app, main_chunks[2]);
if app.mode == AppMode::CommandPalette {
draw_command_palette(frame, &app.palette);
}
}
fn draw_header(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let status_color = match app.mode {
AppMode::Running => Color::Green,
AppMode::Paused => Color::Yellow,
AppMode::Completed => Color::Cyan,
AppMode::Failed => Color::Red,
AppMode::Inspecting => Color::Blue,
AppMode::CommandPalette => Color::Magenta,
AppMode::QuestionInput => Color::Magenta,
};
let elapsed = format_elapsed(app.elapsed_secs);
let header_line = Line::from(vec![
Span::styled(
" o7 ",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
Span::raw("| "),
Span::styled(
&app.workflow_name,
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
format!(" {} ", app.status_text()),
Style::default()
.fg(Color::Black)
.bg(status_color)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(elapsed, Style::default().fg(Color::DarkGray)),
]);
let block = Block::default()
.borders(Borders::BOTTOM)
.border_style(Style::default().fg(Color::DarkGray));
let paragraph = Paragraph::new(header_line).block(block);
frame.render_widget(paragraph, area);
}
fn draw_step_list(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let block = Block::default()
.title(" Steps ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray));
let inner = block.inner(area);
frame.render_widget(block, area);
if app.steps.is_empty() {
let empty = Paragraph::new(" No steps").style(Style::default().fg(Color::DarkGray));
frame.render_widget(empty, inner);
return;
}
let items: Vec<ListItem> = app
.steps
.iter()
.enumerate()
.map(|(i, step)| {
let is_selected = i == app.selected_index;
let indent = " ".repeat(step.depth);
let icon = step.status.icon(app.spinner_frame);
let name_style = if is_selected {
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(step.status.color())
};
let selector = if is_selected { "> " } else { " " };
let qa_indicator = if app.qa_panel_visible()
&& step.status == super::app::StepVisualStatus::InProgress
&& (step.name.starts_with("while ") || step.name.starts_with("while-not "))
{
" [?]"
} else {
""
};
let line = Line::from(vec![
Span::styled(selector.to_string(), Style::default().fg(Color::Cyan)),
Span::raw(indent),
Span::styled(
format!("{} ", icon),
Style::default().fg(step.status.color()),
),
Span::styled(step.name.clone(), name_style),
Span::styled(qa_indicator, Style::default().fg(Color::Magenta)),
]);
ListItem::new(line)
})
.collect();
let visible_height = inner.height as usize;
let offset = if app.selected_index >= visible_height {
app.selected_index - visible_height + 1
} else {
0
};
let visible_items: Vec<ListItem> = items.into_iter().skip(offset).collect();
let list = List::new(visible_items);
frame.render_widget(list, inner);
}
fn draw_detail_panel(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let block = Block::default()
.title(" Detail ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray));
let inner = block.inner(area);
frame.render_widget(block, area);
if let Some(step) = app.steps.get(app.selected_index) {
let mut lines = vec![
Line::from(vec![
Span::styled("Name: ", Style::default().fg(Color::DarkGray)),
Span::styled(&step.name, Style::default().fg(Color::White)),
]),
Line::from(vec![
Span::styled("Path: ", Style::default().fg(Color::DarkGray)),
Span::styled(step.path.join(" > "), Style::default().fg(Color::White)),
]),
Line::from(vec![
Span::styled("Status: ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("{:?}", step.status),
Style::default().fg(step.status.color()),
),
]),
Line::from(""),
];
let step_path = &step.path;
let related_events: Vec<&crate::engine::types::ExecutionEvent> = app
.events
.iter()
.filter(|e| event_matches_path(e, step_path))
.collect();
if !related_events.is_empty() {
lines.push(Line::from(Span::styled(
"Events:",
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)));
for event in related_events {
lines.push(Line::from(Span::styled(
format!(" {}", event.event_type()),
Style::default().fg(Color::White),
)));
}
}
let paragraph = Paragraph::new(lines).wrap(Wrap { trim: true });
frame.render_widget(paragraph, inner);
}
}
fn draw_harness_log_panel(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let block = Block::default()
.title(" Harness Log ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray));
let inner = block.inner(area);
frame.render_widget(block, area);
let events: Vec<&crate::state::types::HarnessEvent> = app
.harness_events
.iter()
.rev()
.take(12)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
let mut lines = Vec::new();
for event in events {
let summary = if let Some(parsed) = &event.parsed {
if let Some(kind) = parsed.get("type").and_then(|value| value.as_str()) {
kind.to_string()
} else {
parsed.to_string()
}
} else {
event.raw.clone()
};
let path = event
.step_path
.as_ref()
.map(|parts| parts.join(" > "))
.unwrap_or_else(|| "(unknown)".to_string());
lines.push(Line::from(vec![
Span::styled(
format!("[{:?}/{:?}] ", event.stream, event.kind).to_lowercase(),
Style::default().fg(Color::DarkGray),
),
Span::styled(path, Style::default().fg(Color::White)),
]));
lines.push(Line::from(Span::styled(
format!(" {}", summary),
Style::default().fg(Color::Gray),
)));
}
let paragraph = Paragraph::new(lines).wrap(Wrap { trim: true });
frame.render_widget(paragraph, inner);
}
fn draw_footer(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let hints = match app.mode {
AppMode::Running => vec![
("q", "Quit"),
("p", "Pause"),
("v", "Detail"),
("^P", "Commands"),
("j/k", "Navigate"),
],
AppMode::Paused => vec![
("q", "Quit"),
("v", "Detail"),
("^P", "Resume/Reset"),
("j/k", "Navigate"),
],
AppMode::Completed | AppMode::Failed => {
vec![("q", "Quit"), ("v", "Detail"), ("j/k", "Navigate")]
}
AppMode::Inspecting => vec![("Esc/v", "Close"), ("q", "Quit")],
AppMode::CommandPalette => vec![
("Esc", "Close"),
("Up/Down", "Navigate"),
("Enter", "Select"),
("Type", "Filter"),
],
AppMode::QuestionInput => vec![("Right panel", "Answer questions"), ("Esc", "Close Q&A")],
};
let footer = Paragraph::new(render_hint_line(&hints)).wrap(Wrap { trim: false });
frame.render_widget(footer, area);
}
fn render_hint_line(hints: &[(&str, &str)]) -> Line<'static> {
let mut spans = Vec::new();
for (idx, (key, label)) in hints.iter().enumerate() {
if idx > 0 {
spans.push(Span::styled(" | ", Style::default().fg(Color::DarkGray)));
}
spans.push(Span::styled(
key.to_string(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::raw(": "));
spans.push(Span::styled(
label.to_string(),
Style::default().fg(Color::Gray),
));
}
Line::from(spans)
}
fn event_matches_path(event: &crate::engine::types::ExecutionEvent, step_path: &[String]) -> bool {
match event {
crate::engine::types::ExecutionEvent::StepStarted { stepPath, .. }
| crate::engine::types::ExecutionEvent::StepCompleted { stepPath, .. }
| crate::engine::types::ExecutionEvent::StepFailed { stepPath, .. }
| crate::engine::types::ExecutionEvent::SafeBoundary { stepPath, .. } => {
stepPath == step_path
}
crate::engine::types::ExecutionEvent::BranchStarted { branchPath, .. }
| crate::engine::types::ExecutionEvent::BranchCompleted { branchPath, .. }
| crate::engine::types::ExecutionEvent::BranchFailed { branchPath, .. } => {
branchPath == step_path
}
_ => false,
}
}
fn format_elapsed(secs: u64) -> String {
let hours = secs / 3600;
let minutes = (secs % 3600) / 60;
let seconds = secs % 60;
if hours > 0 {
format!("{hours}:{minutes:02}:{seconds:02}")
} else {
format!("{minutes}:{seconds:02}")
}
}
#[cfg(test)]
mod tests {
use super::super::app::StepVisualStatus;
use super::*;
#[test]
fn test_format_elapsed() {
assert_eq!(format_elapsed(0), "0:00");
assert_eq!(format_elapsed(59), "0:59");
assert_eq!(format_elapsed(60), "1:00");
assert_eq!(format_elapsed(3661), "1:01:01");
}
#[test]
fn test_status_icons() {
use super::super::app::SPINNER_FRAMES;
assert_eq!(StepVisualStatus::Pending.icon(0), "\u{25CB}");
assert_eq!(StepVisualStatus::Completed.icon(0), "\u{2713}");
assert_eq!(StepVisualStatus::Failed.icon(0), "\u{2717}");
assert_eq!(StepVisualStatus::InProgress.icon(0), SPINNER_FRAMES[0]);
}
}