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