josie / alder-tools

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

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