o7 0.1.0

O7 workflow DSL runner
Documentation
//! TUI Rendering — ratatui-based UI for the O7 workflow runner.
//!
//! Draws the header, step list, detail panel, and footer. Uses
//! colored status badges and braille spinner animation.

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;

/// Main draw function called each tick.
pub fn draw(frame: &mut Frame, app: &App) {
    let size = frame.area();

    // Top-level layout: header (3 lines), body (fill), footer (1 line).
    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]);

    // Body: split view for Q&A, inspecting, or full-width step list.
    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]);

    // Command palette overlay (drawn last so it's on top).
    if app.mode == AppMode::CommandPalette {
        draw_command_palette(frame, &app.palette);
    }
}

/// Draw the header bar with workflow name, status badge, and elapsed time.
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);
}

/// Draw the step list.
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 { "  " };

            // Show [?] indicator for in-progress while-loop steps when Q&A is active.
            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();

    // Calculate offset so the selected item is always visible.
    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);
}

/// Draw the detail panel showing information about the selected step.
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(""),
        ];

        // Show related events for this step.
        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);
    }
}

/// Draw the footer with keybinding hints.
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)
}

/// Check if an engine event's path matches a step path.
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,
    }
}

/// Format elapsed time as M:SS or H:MM:SS.
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}");
        // Spinner should return a braille frame.
        assert_eq!(StepVisualStatus::InProgress.icon(0), SPINNER_FRAMES[0]);
    }
}