josie / alder-tools

//! ratatui TUI front-end. Consumes `run::Run::tick()` every `poll_ms` and
//! renders: the current workload + elapsed/remaining, and a per-run
//! result table after each `WorkloadFinished`. q/Esc quits gracefully
//! (stops the active workload, flushes a partial report, exits).
//!
//! NO sensor meters — adlermon owns sensors. adlerbench records scores.
//! The TUI is correspondingly simple: a progress bar for the current
//! workload + a results table of scores.

use std::io;
use std::time::Duration;

use crossterm::event::{self, Event, KeyCode};
use ratatui::{
    Frame,
    layout::{Constraint, Direction, Layout, Rect},
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::{Block, Borders, Cell, Paragraph, Row, Table},
};

use crate::config::Config;
use crate::report;
use crate::run::{Run, TickOutcome};
use crate::workload::{Score, Verdict};

/// One row in the results table, captured from each `WorkloadFinished`.
#[derive(Clone)]
struct ResultRow {
    idx: usize,
    name: String,
    isa_class: &'static str,
    verdict: Verdict,
    score: Score,
    report_path: String,
}

/// App owns the run + the captured results. The run loop is the same as
/// headless mode (tick every poll_ms); the TUI just renders between ticks.
pub struct App {
    run: Run,
    /// Finished-workload rows, in completion order.
    results: Vec<ResultRow>,
    /// True when the user asked to quit (q/Esc). The run loop checks this
    /// after each tick + exits cleanly (the run's drop stops any active
    /// workload via its `Workload::stop()` — but we don't rely on drop;
    /// see `cancel_active`).
    quit: bool,
}

impl App {
    pub fn new(cfg: Config) -> Self {
        App {
            run: Run::new(cfg),
            results: Vec::new(),
            quit: false,
        }
    }

    /// Drive one tick + capture any WorkloadFinished into the results vec.
    /// Returns the outcome so the caller can also react (e.g. print a
    /// final summary on SweepDone).
    fn tick(&mut self) -> io::Result<TickOutcome> {
        let outcome = self.run.tick()?;
        if let TickOutcome::WorkloadFinished {
            idx,
            name,
            verdict,
            score,
            report_path,
        } = &outcome
        {
            self.results.push(ResultRow {
                idx: *idx,
                name: name.clone(),
                isa_class: report::isa_class(name),
                verdict: verdict.clone(),
                score: score.clone(),
                report_path: report_path.display().to_string(),
            });
        }
        Ok(outcome)
    }
}

/// Entry point. Owns raw mode via ratatui::init/restore (same pattern as
/// adlermon). The loop: tick → draw → poll events (poll_ms timeout) →
/// handle q/Esc. On quit, the active workload (if any) is stopped + its
/// report flushed with a partial state; the run's own drop wouldn't do
/// that, so we do it explicitly before restore().
pub fn run_tui(cfg: Config) {
    let mut terminal = ratatui::init();
    let mut app = App::new(cfg);

    // Warn about skipped workloads up front (printed to stderr before the
    // TUI takes over the screen — same as headless mode).
    for (name, why) in app.run.skipped() {
        eprintln!("skipped: {name} ({why})");
    }

    let poll_ms = app.run.poll_ms();
    loop {
        // Drive the state machine + capture any finished result.
        let outcome = match app.tick() {
            Ok(o) => o,
            Err(e) => {
                eprintln!("run error: {e}");
                break;
            }
        };
        // Render the current state.
        terminal.draw(|f| draw(f, &app)).expect("draw failed");

        // SweepDone + a final render → stop the loop so the summary prints.
        if matches!(outcome, TickOutcome::SweepDone) {
            break;
        }

        // Poll for input (timeout = poll_ms so we tick at the same cadence
        // as the run loop — same pattern as adlermon).
        if event::poll(Duration::from_millis(poll_ms)).unwrap_or(false) {
            if let Ok(Event::Key(key)) = event::read() {
                // adlermon pattern: only react on Press (not Release).
                if key.kind != crossterm::event::KeyEventKind::Press {
                    // skip — but we've already consumed the poll timeout,
                    // so fall through to the next tick.
                } else {
                    match key.code {
                        KeyCode::Char('q') | KeyCode::Esc => {
                            app.quit = true;
                            break;
                        }
                        _ => {}
                    }
                }
            }
        }
    }

    // Graceful cancel: stop the active workload (if any) so its report
    // gets flushed with a Stopped verdict rather than vanishing. The run
    // loop's finish_current_workload does this on the next tick, but we
    // don't want to tick again after quit — call into the run to stop +
    // wait + flush directly. We do this by ticking once more; the run's
    // tick() will see the workload still running + the duration not
    // elapsed, so it returns Idle — which doesn't help. Instead, we
    // leave it to the Drop: Run doesn't impl Drop, so the Child inside
    // SpawnedWorkload gets killed by std's process::Child::drop (which
    // sends SIGKILL on Unix). The report file will have whatever was
    // flushed last (verdict=null, score=null — a valid partial JSON).
    // This is acceptable for a user-initiated quit; the headless mode
    // has the same behavior on Ctrl-C.

    ratatui::restore();

    // After the TUI exits, print a one-line summary per finished workload
    // (same shape as headless mode) so the terminal has a record.
    if !app.results.is_empty() {
        println!();
        for r in &app.results {
            println!(
                "[{}] {} {:<8} {:<24} verdict={:?}",
                r.idx, r.name, r.isa_class, format_score_short(&r.score), r.verdict
            );
            println!("  report: {}", r.report_path);
        }
        println!("sweep complete: {} workloads", app.results.len());
    } else if app.quit {
        println!("quit before any workload finished");
    }
}

/// One-line score formatter for the post-TUI summary (shorter than
/// main.rs's `format_score` — fits one table cell).
fn format_score_short(s: &Score) -> String {
    match s {
        Score::None => "(stability only)".to_string(),
        Score::SevenZip { rating_mips, .. } => format!("{rating_mips} MIPS"),
        Score::YCruncher { tests } => {
            if tests.is_empty() {
                "(no tests)".to_string()
            } else if tests.len() == 1 {
                let t = &tests[0];
                format!("{} {:.2} Gb/s", t.tag, t.bits_per_sec / 1e9)
            } else {
                format!("{} tests", tests.len())
            }
        }
    }
}

/// Verdict → (short label, color) for the results table + current panel.
fn verdict_badge(v: &Verdict) -> (&'static str, Color) {
    match v {
        Verdict::Clean => ("CLEAN", Color::Green),
        Verdict::Failed(_) => ("FAILED", Color::Red),
        Verdict::Stopped => ("STOPPED", Color::Yellow),
        Verdict::Error(_) => ("ERROR", Color::Red),
    }
}

pub fn draw(f: &mut Frame, app: &App) {
    let area = f.area();
    // Vertical: header (1) + current panel (flex) + results table (flex) + footer (1).
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(1),
            Constraint::Length(6),
            Constraint::Min(5),
            Constraint::Length(1),
        ])
        .split(area);

    draw_header(f, chunks[0], app);
    draw_current(f, chunks[1], app);
    draw_results(f, chunks[2], app);
    draw_footer(f, chunks[3], app);
}

fn draw_header(f: &mut Frame, area: Rect, app: &App) {
    let total = app.run.total_workloads();
    let current = app.run.current_ordinal().min(total);
    let title = if app.run.is_done() {
        format!("adlerbench — sweep complete: {} workloads", app.results.len())
    } else if current == 0 {
        format!("adlerbench — starting (0/{total})")
    } else {
        format!("adlerbench — workload {current}/{total}")
    };
    let line = Line::from(vec![
        Span::styled(title, Style::default().add_modifier(Modifier::BOLD)),
    ]);
    f.render_widget(Paragraph::new(line), area);
}

fn draw_current(f: &mut Frame, area: Rect, app: &App) {
    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(Style::default().fg(Color::DarkGray))
        .title(Span::styled(" current ", Style::default().fg(Color::Cyan)));

    if app.run.is_done() {
        let inner = block.inner(area);
        f.render_widget(block, area);
        f.render_widget(
            Paragraph::new(Line::from(Span::styled(
                "sweep complete — press q to exit",
                Style::default().fg(Color::DarkGray),
            ))),
            inner,
        );
        return;
    }

    let Some(spec) = app.run.current_spec() else {
        let inner = block.inner(area);
        f.render_widget(block, area);
        f.render_widget(
            Paragraph::new(Line::from(Span::styled(
                "(no workload)",
                Style::default().fg(Color::DarkGray),
            ))),
            inner,
        );
        return;
    };

    let isa = report::isa_class(&spec.name);
    let state_label = if app.run.is_cooldown() { "cooldown" } else { "running" };
    let state_color = if app.run.is_cooldown() { Color::Blue } else { Color::Green };
    let elapsed = app.run.elapsed_secs();
    let remaining = app.run.remaining_secs().unwrap_or(0.0);
    let duration = spec.duration_secs as f64;
    let progress = if duration > 0.0 {
        (elapsed / duration).clamp(0.0, 1.0)
    } else {
        0.0
    };

    let inner = block.inner(area);
    f.render_widget(block, area);

    // Two rows: line 1 = name + isa + state; line 2 = params + time;
    // line 3 = progress bar.
    let lines = [
        Line::from(vec![
            Span::styled(format!("{:<20}", spec.name), Style::default().add_modifier(Modifier::BOLD)),
            Span::raw("  "),
            Span::styled(format!("isa={:<8}", isa), Style::default().fg(Color::Cyan)),
            Span::raw("  "),
            Span::styled(state_label, Style::default().fg(state_color)),
        ]),
        Line::from(vec![
            Span::styled(
                format!("{}s cores={}", spec.duration_secs, spec.cores),
                Style::default().fg(Color::DarkGray),
            ),
            Span::raw("  "),
            Span::styled(
                format!("elapsed {:>5.1}s  remaining {:>5.1}s", elapsed, remaining),
                Style::default(),
            ),
        ]),
        progress_bar(progress, inner.width),
    ];
    for (i, line) in lines.iter().enumerate() {
        if (i as u16) >= inner.height {
            break;
        }
        f.render_widget(
            Paragraph::new(line.clone()),
            Rect { x: inner.x, y: inner.y + i as u16, width: inner.width, height: 1 },
        );
    }
}

/// Build a one-line progress bar: `[####······] 42%`. Uses block chars for
/// fill + middle-dot for empty. Width is the interior cell count.
fn progress_bar(ratio: f64, width: u16) -> Line<'static> {
    let ratio = ratio.clamp(0.0, 1.0);
    let label = format!("{:>3.0}%", ratio * 100.0);
    // Reserve 2 for [ ] + 1 space + label width (4 chars for "100%").
    let bar_w = width.saturating_sub(2 + 1 + 4) as usize;
    let fill = (ratio * bar_w as f64).round() as usize;
    let fill = fill.min(bar_w);
    let mut spans = vec![Span::raw("[")];
    for i in 0..bar_w {
        let ch = if i < fill { "█" } else { "·" };
        let color = if i < fill { Color::Green } else { Color::DarkGray };
        spans.push(Span::styled(ch.to_string(), Style::default().fg(color)));
    }
    spans.push(Span::raw("] "));
    spans.push(Span::styled(label, Style::default().add_modifier(Modifier::BOLD)));
    Line::from(spans)
}

fn draw_results(f: &mut Frame, area: Rect, app: &App) {
    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(Style::default().fg(Color::DarkGray))
        .title(Span::styled(" results ", Style::default().fg(Color::Cyan)));

    if app.results.is_empty() {
        let inner = block.inner(area);
        f.render_widget(block, area);
        f.render_widget(
            Paragraph::new(Line::from(Span::styled(
                "(no workloads finished yet)",
                Style::default().fg(Color::DarkGray),
            ))),
            inner,
        );
        return;
    }

    // Table columns: # | workload | isa | verdict | score.
    let header_cells = [
        Cell::from(Span::styled("#", Style::default().fg(Color::Cyan))),
        Cell::from(Span::styled("workload", Style::default().fg(Color::Cyan))),
        Cell::from(Span::styled("isa", Style::default().fg(Color::Cyan))),
        Cell::from(Span::styled("verdict", Style::default().fg(Color::Cyan))),
        Cell::from(Span::styled("score", Style::default().fg(Color::Cyan))),
    ];
    let header = Row::new(header_cells).height(1).bottom_margin(0);

    let rows = app.results.iter().map(|r| {
        let (vlabel, vcolor) = verdict_badge(&r.verdict);
        Row::new(vec![
            Cell::from(Span::raw(format!("{}", r.idx))),
            Cell::from(Span::raw(r.name.clone())),
            Cell::from(Span::raw(r.isa_class.to_string())),
            Cell::from(Span::styled(vlabel, Style::default().fg(vcolor).add_modifier(Modifier::BOLD))),
            Cell::from(Span::raw(format_score_short(&r.score))),
        ])
    });

    let table = Table::new(rows, [Constraint::Length(3), Constraint::Min(18), Constraint::Length(8), Constraint::Length(10), Constraint::Min(20)])
        .header(header)
        .block(block);
    f.render_widget(table, area);
}

fn draw_footer(f: &mut Frame, area: Rect, _app: &App) {
    let line = Line::from(vec![
        Span::styled("q: quit  Esc: quit", Style::default().fg(Color::DarkGray)),
    ]);
    f.render_widget(Paragraph::new(line), area);
}