diff --git a/src/main.rs b/src/main.rs
index 843aac6..31f1c81 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,6 +1,7 @@
mod config;
mod report;
mod run;
+mod ui;
mod workload;
fn main() {
@@ -16,10 +17,14 @@ fn main() {
headless_run(cfg);
return;
}
- println!("adlerbench — {} workloads configured", cfg.workloads.len());
- for w in &cfg.workloads {
- println!(" {} {}s cores={}", w.name, w.duration_secs, w.cores);
+ if args.iter().any(|a| a == "--tui") {
+ ui::run_tui(cfg);
+ return;
}
+ // Default mode = TUI (the real front-end). Args without a dedicated
+ // mode fall through to the TUI so `adlerbench` with no args launches
+ // the sweep with a live display.
+ ui::run_tui(cfg);
}
/// `--headless` mode: drive the run loop without a TUI. Prints one line
diff --git a/src/run.rs b/src/run.rs
index d4e1251..27606c9 100644
--- a/src/run.rs
+++ b/src/run.rs
@@ -117,6 +117,70 @@ impl Run {
self.workloads.iter().filter(|w| w.is_some()).count()
}
+ /// 1-indexed ordinal of the workload currently running or about to
+ /// start (skipped entries excluded). 0 before the sweep begins. For
+ /// the TUI header ("workload N of M").
+ pub fn current_ordinal(&self) -> usize {
+ // Count Some entries up to + including `current`.
+ self.workloads
+ .iter()
+ .take(self.current + 1)
+ .filter(|w| w.is_some())
+ .count()
+ }
+
+ /// The current workload's spec (name, duration, cores), or None if
+ /// the sweep hasn't started / is done. For the TUI's current-panel.
+ pub fn current_spec(&self) -> Option<&crate::config::WorkloadSpec> {
+ let w = self.workloads.get(self.current)?;
+ if w.is_some() {
+ self.cfg.workloads.get(self.current)
+ } else {
+ None
+ }
+ }
+
+ /// Seconds elapsed in the current state (Cooldown or Running), as
+ /// f64 for the TUI's progress display.
+ pub fn elapsed_secs(&self) -> f64 {
+ self.state_start.elapsed().as_secs_f64()
+ }
+
+ /// Remaining seconds in the current state. During Running this is
+ /// `duration - elapsed` (clamped at 0). During Cooldown it's
+ /// `remaining - elapsed` (clamped at 0). None when Done.
+ pub fn remaining_secs(&self) -> Option<f64> {
+ match &self.state {
+ State::Cooldown { remaining } => {
+ let dur = Duration::from_secs(*remaining);
+ Some((dur - self.state_start.elapsed()).as_secs_f64().max(0.0))
+ }
+ State::Running { duration_secs } => {
+ let dur = Duration::from_secs(*duration_secs);
+ Some((dur - self.state_start.elapsed()).as_secs_f64().max(0.0))
+ }
+ State::Done => None,
+ }
+ }
+
+ /// True when the sweep is over (all workloads done). For the TUI to
+ /// know when to stop ticking.
+ pub fn is_done(&self) -> bool {
+ matches!(self.state, State::Done)
+ }
+
+ /// The configured poll period, ms. The TUI uses this for its event
+ /// poll timeout so the run + render stay in lockstep.
+ pub fn poll_ms(&self) -> u64 {
+ self.cfg.poll_ms
+ }
+
+ /// True when currently cooling down between workloads (for the TUI
+ /// to render a "cooldown" state vs a "running" state).
+ pub fn is_cooldown(&self) -> bool {
+ matches!(self.state, State::Cooldown { .. })
+ }
+
/// One tick of the state machine. Call every `poll_ms`. Polls the
/// workload's `is_running()` to detect completion + advances state.
pub fn tick(&mut self) -> io::Result<TickOutcome> {
diff --git a/src/ui.rs b/src/ui.rs
new file mode 100644
index 0000000..a425e43
--- /dev/null
+++ b/src/ui.rs
@@ -0,0 +1,389 @@
+//! 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);
+}
\ No newline at end of file
Add TUI (ui.rs) + wire --tui as default mode
- src/ui.rs: ratatui front-end consuming run::Run::tick() every poll_ms. Renders: header (workload N/M), current panel (name + isa + state + params + elapsed/remaining + progress bar), results table (idx, workload, isa, verdict, score per finished run), footer (q/Esc quit). NO sensor meters — adlermon owns sensors; adlerbench records scores. Post-TUI summary prints one line per finished workload (same shape as --headless) so the terminal has a record. - src/run.rs: added read accessors for the TUI — current_ordinal(), current_spec(), elapsed_secs(), remaining_secs(), is_done(), is_cooldown(), poll_ms(). The TUI borrows Run immutably each render. - src/main.rs: --tui (explicit) + default mode both launch ui::run_tui. --smoke-workload + --headless still work for scripting/CI. - Patterns ported from adlermon's ui.rs: ratatui::init/restore own raw mode; event::poll with poll_ms timeout; KeyEventKind::Press filter (ignore Release). The sensor hero panel / graphs / settings pane are NOT ported — adlerbench has no sensors + no settings to edit. - Verified live via forkpty + TIOCSWINSZ 30x100 python recipe (adlermon memory): stress-ng-cpu 3s → CLEAN (verdict clean, score null) → 7z cooldown state renders → 7z running → q quits cleanly. Report file landed with verdict=clean. 43/43 tests; clippy clean.
8db8144d1b6922886a81345a2e26b0b878d8bdea
josie <administrator@josie-c.com> · 2026-09-01T21:27 ·
browse files at this commit
parents:
369b7d3