//! 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, /// 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 { 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); }