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 ratatui::Frame;
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 {
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_footer(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let hints = match app.mode {
AppMode::Running => vec![
("q", "Quit"),
("p", "Pause"),
("v", "Inspect"),
("^P", "Commands"),
("j/k", "Navigate"),
],
AppMode::Paused => vec![
("q", "Quit"),
("v", "Inspect"),
("^P", "Resume/Reset"),
("j/k", "Navigate"),
],
AppMode::Completed | AppMode::Failed => vec![
("q", "Quit"),
("v", "Inspect"),
("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::*;
use super::super::app::StepVisualStatus;
#[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]);
}
}