//! ratatui TUI front-end. Three modes:
//! - **Picker** (home screen): choose Stability / Bench / Custom / q to quit.
//! - **Editor** (custom preset): toggle workloads, edit duration/cores/
//! cooldown/loop count, reorder, then Start to launch the sweep.
//! - **Sweep**: consume `run::Run::tick()` every `poll_ms`, render the
//! current workload + progress + a results table. q/Esc quits.
//!
//! NO sensor meters — aldermon owns sensors. alderbench records 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, List, ListItem, Paragraph, Row, Table},
};
use crate::config::{Config, WorkloadSpec};
use crate::presets;
use crate::report;
use crate::run::{Run, TickOutcome};
use crate::workload::{Score, Verdict};
/// The three top-level modes the TUI cycles through.
enum Mode {
/// Picker home screen — choose a preset or custom.
Picker(PickerState),
/// Custom preset editor — tune the workload list + params before launch.
Editor(EditorState),
/// Sweep running — drives Run::tick() + renders progress + results.
Sweep(App),
/// Sweep done — final render sits until the user presses q.
Done(App),
}
/// Picker screen state: which row is highlighted (0..N).
struct PickerState {
cursor: usize,
}
/// Custom editor state. Holds a mutable Config the user edits before
/// launching the sweep. The workload list is the central piece — each
/// row is a WorkloadSpec the user can toggle, edit, or reorder.
struct EditorState {
cfg: Config,
/// Which row is highlighted (index into cfg.workloads).
cursor: usize,
/// Which field of the highlighted row is selected for editing.
/// 0 = name (toggle), 1 = duration, 2 = cores. The global fields
/// (cooldown, loop count) are rows below the workload list.
field: EditorField,
/// True while editing a numeric field (digit keys append, Enter
/// commits, Esc cancels).
editing: Option<String>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum EditorField {
Name,
Duration,
Cores,
Cooldown,
LoopCount,
}
impl EditorField {
/// Render order (top to bottom in the editor).
const PER_ROW: [Self; 3] = [Self::Name, Self::Duration, Self::Cores];
const GLOBALS: [Self; 2] = [Self::Cooldown, Self::LoopCount];
}
// ---------------------------------------------------------------------------
// Picker
// ---------------------------------------------------------------------------
const PICKER_ROWS: &[(&str, &str)] = &[
("Stability", "stress-ng + y-cruncher SSE/AVX2, 5 min each, looped 1 hr"),
("Bench", "7z + y-cruncher SSE/AVX2, 60 s each, single pass"),
("Core-cycle", "single-core per-core stability, 16 cores x 2 passes"),
("Custom", "tune the workload list + params yourself"),
];
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
/// Entry point. Owns raw mode via ratatui::init/restore (same pattern as
/// aldermon). The mode loop: draw → poll events (poll_ms timeout) →
/// dispatch by mode. q/Esc at the picker quits; during the sweep it
/// cancels back to the picker (if the sweep was launched from there) or
/// quits (if launched from --preset/--tui directly).
pub fn run_tui(cfg: Config) {
let mut terminal = ratatui::init();
let mut mode = Mode::Picker(PickerState { cursor: 0 });
// The cfg arg is the fallback for "Custom" (starts from whatever was
// loaded from the conf file). Presets replace it wholesale.
let default_cfg = cfg;
loop {
// Drive the sweep tick BEFORE drawing + polling, so the run
// advances at poll_ms cadence even when no key is pressed.
if let Mode::Sweep(app) = &mut mode {
let outcome = match app.tick() {
Ok(o) => o,
Err(e) => {
eprintln!("run error: {e}");
break;
}
};
// While a workload is RUNNING (not cooldown), don't re-draw
// every tick — the render loop steals CPU from the workload,
// which on single-core runs extends the wall time enough to
// cause false Stopped verdicts. The user can see CPU load via
// aldermon. We only re-draw on state transitions (workload
// started / finished / sweep done) + during cooldown (when
// the CPU is idle anyway).
let should_draw = match &outcome {
TickOutcome::WorkloadStarted { .. } => true,
TickOutcome::WorkloadFinished { .. } => true,
TickOutcome::SweepDone => true,
TickOutcome::Idle => {
// Draw during cooldown (CPU is idle) but NOT during
// running (CPU is busy with the workload).
app.run.is_cooldown() || app.run.is_done()
}
};
let is_done = matches!(outcome, TickOutcome::SweepDone);
// Compute the sleep duration BEFORE we potentially move `app`
// out (for the Done transition). This borrows app, but the
// borrow ends when sleep_secs is computed (a u64, no ref).
let sleep_secs = if !should_draw {
compute_sleep_secs(app)
} else {
0
};
if is_done {
let finished = std::mem::replace(app, App::empty());
mode = Mode::Done(finished);
}
if !should_draw {
// While the workload is running, sleep for the ENTIRE
// estimated duration + a generous margin. One long sleep
// means the TUI process is genuinely idle (not scheduling)
// while the workload runs — no CPU contention.
if event::poll(Duration::from_secs(sleep_secs)).unwrap_or(false) {
if let Ok(Event::Key(key)) = event::read() {
if key.kind == crossterm::event::KeyEventKind::Press
&& (key.code == KeyCode::Char('q') || key.code == KeyCode::Esc)
{
break;
}
}
}
continue;
}
}
terminal.draw(|f| draw(f, &mode)).expect("draw failed");
let poll_ms = match &mode {
Mode::Sweep(app) | Mode::Done(app) => app.run.poll_ms(),
_ => 100, // picker + editor don't need fast refresh
};
if !event::poll(Duration::from_millis(poll_ms)).unwrap_or(false) {
continue;
}
let event = event::read();
let Ok(Event::Key(key)) = event else {
continue;
};
if key.kind != crossterm::event::KeyEventKind::Press {
continue;
}
match &mut mode {
Mode::Picker(state) => {
match key.code {
KeyCode::Up | KeyCode::Char('k') => state.cursor = state.cursor.saturating_sub(1),
KeyCode::Down | KeyCode::Char('j') => {
state.cursor = (state.cursor + 1).min(PICKER_ROWS.len() - 1)
}
KeyCode::Enter => {
match state.cursor {
0 => mode = Mode::Sweep(App::new(presets::stability())),
1 => mode = Mode::Sweep(App::new(presets::bench())),
2 => mode = Mode::Sweep(App::new(presets::stability_core_cycle(60))),
3 => mode = Mode::Editor(EditorState::from_cfg(default_cfg.clone())),
_ => {}
}
}
KeyCode::Char('q') | KeyCode::Esc => break,
_ => {}
}
}
Mode::Editor(state) => {
match editor_key(state, key.code) {
EditorAction::Start => mode = Mode::Sweep(App::new(state.cfg.clone())),
EditorAction::Cancel => mode = Mode::Picker(PickerState { cursor: 3 }),
EditorAction::None => {}
}
}
Mode::Sweep(app) => {
// The tick already ran at the top of the loop (before
// draw + poll) so the sweep advances even without input.
// Here we only handle key events.
let _ = app; // borrow to keep the match arm exhaustive
match key.code {
KeyCode::Char('q') | KeyCode::Esc => break,
_ => {}
}
}
Mode::Done(app) => match key.code {
KeyCode::Char('q') | KeyCode::Esc | KeyCode::Enter => {
// Print the summary + exit.
print_summary(app);
break;
}
_ => {}
},
}
}
ratatui::restore();
// If we exited from Sweep/Done, print the post-TUI summary.
if let Mode::Done(app) = &mode {
print_summary(app);
} else if let Mode::Sweep(app) = &mode {
print_summary(app);
}
}
fn print_summary(app: &App) {
if app.results.is_empty() {
if app.quit {
println!("quit before any workload finished");
}
return;
}
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());
}
// ---------------------------------------------------------------------------
// Editor
// ---------------------------------------------------------------------------
#[derive(PartialEq, Eq)]
enum EditorAction {
None,
Start,
Cancel,
}
impl EditorState {
fn from_cfg(cfg: Config) -> Self {
EditorState {
cfg,
cursor: 0,
field: EditorField::Name,
editing: None,
}
}
}
/// Handle one key in the editor. Returns whether to start the sweep,
/// cancel back to the picker, or do nothing.
fn editor_key(state: &mut EditorState, key: KeyCode) -> EditorAction {
// If we're editing a numeric field, digit keys append, Enter commits,
// Esc cancels, Backspace pops.
if let Some(buf) = &mut state.editing {
match key {
KeyCode::Enter => {
let raw = std::mem::take(&mut state.editing).unwrap_or_default();
apply_editor_edit(state, &raw);
}
KeyCode::Esc => {
state.editing = None;
}
KeyCode::Backspace => {
buf.pop();
}
KeyCode::Char(c) if c.is_ascii_digit() => {
buf.push(c);
}
_ => {}
}
return EditorAction::None;
}
// Not editing — navigate fields + rows.
match key {
KeyCode::Up | KeyCode::Char('k') => {
editor_move(state, -1);
}
KeyCode::Down | KeyCode::Char('j') => {
editor_move(state, 1);
}
KeyCode::Left | KeyCode::Right => {
// Cycle field within the current row (only for workload rows).
if state.cursor < state.cfg.workloads.len() {
let idx = EditorField::PER_ROW.iter().position(|f| *f == state.field).unwrap_or(0);
let dir = if matches!(key, KeyCode::Left) { -1i32 } else { 1 };
let n = EditorField::PER_ROW.len() as i32;
let next = ((idx as i32 + dir).rem_euclid(n)) as usize;
state.field = EditorField::PER_ROW[next];
}
}
KeyCode::Enter => {
// Enter on a workload row's Name field toggles inclusion (cycles
// through available workload names). On Duration/Cores/Cooldown/
// LoopCount it starts numeric editing. On a "Start" pseudo-row
// (cursor == workloads.len() + globals.len()) it launches.
let total_rows = state.cfg.workloads.len() + EditorField::GLOBALS.len() + 1; // +Start
if state.cursor == total_rows - 1 {
return EditorAction::Start;
}
match state.field {
EditorField::Name => {
// Toggle to the next available workload name.
if let Some(w) = state.cfg.workloads.get_mut(state.cursor) {
let idx = presets::AVAILABLE_WORKLOADS
.iter()
.position(|n| *n == w.name)
.unwrap_or(0);
let next = (idx + 1) % presets::AVAILABLE_WORKLOADS.len();
w.name = presets::AVAILABLE_WORKLOADS[next].to_string();
}
}
EditorField::Duration | EditorField::Cores | EditorField::Cooldown | EditorField::LoopCount => {
let current = editor_current_value(state);
state.editing = Some(current);
}
}
}
// 'd' deletes the current workload row (for the custom editor's
// "remove" action). Bound to a letter so it doesn't collide with
// numeric editing.
KeyCode::Char('d') if state.cursor < state.cfg.workloads.len() => {
state.cfg.workloads.remove(state.cursor);
if state.cursor >= state.cfg.workloads.len() && !state.cfg.workloads.is_empty() {
state.cursor = state.cfg.workloads.len() - 1;
}
}
// 'a' adds a new workload row at the end (defaulting to the first
// available name).
KeyCode::Char('a') => {
state.cfg.workloads.push(WorkloadSpec {
name: presets::AVAILABLE_WORKLOADS[0].to_string(),
duration_secs: 60,
cores: "0-15".to_string(),
});
state.cursor = state.cfg.workloads.len() - 1;
state.field = EditorField::Name;
}
// Move the current workload row up/down (reorder). Uses `>`/`<`
// (intuitive for ordering) so j/k stay navigation-only.
KeyCode::Char('<') if state.cursor < state.cfg.workloads.len() && state.cursor > 0 => {
state.cfg.workloads.swap(state.cursor, state.cursor - 1);
state.cursor -= 1;
}
KeyCode::Char('>') if state.cursor < state.cfg.workloads.len()
&& state.cursor + 1 < state.cfg.workloads.len() =>
{
state.cfg.workloads.swap(state.cursor, state.cursor + 1);
state.cursor += 1;
}
KeyCode::Esc => return EditorAction::Cancel,
_ => {}
}
EditorAction::None
}
/// Move the editor cursor by `dir` rows (clamped). Fields reset to Name
/// when entering a workload row, or to the appropriate global field when
/// entering a global row.
fn editor_move(state: &mut EditorState, dir: i32) {
let total = state.cfg.workloads.len() + EditorField::GLOBALS.len() + 1; // +Start
let next = (state.cursor as i32 + dir).clamp(0, (total as i32) - 1) as usize;
state.cursor = next;
// Reset field to the first valid one for the new row.
let workloads_n = state.cfg.workloads.len();
if next < workloads_n {
state.field = EditorField::Name;
} else if next < workloads_n + EditorField::GLOBALS.len() {
state.field = EditorField::GLOBALS[next - workloads_n];
} else {
// Start row — no field.
state.field = EditorField::Name;
}
}
/// The current field's value as a string (for the edit buffer's initial
/// content when the user presses Enter to edit).
fn editor_current_value(state: &EditorState) -> String {
match state.field {
EditorField::Name => state
.cfg
.workloads
.get(state.cursor)
.map(|w| w.name.clone())
.unwrap_or_default(),
EditorField::Duration => state
.cfg
.workloads
.get(state.cursor)
.map(|w| w.duration_secs.to_string())
.unwrap_or_default(),
EditorField::Cores => state
.cfg
.workloads
.get(state.cursor)
.map(|w| w.cores.clone())
.unwrap_or_default(),
EditorField::Cooldown => state.cfg.cool_secs.to_string(),
EditorField::LoopCount => state.cfg.loop_count.to_string(),
}
}
/// Apply a committed edit buffer to the current field.
fn apply_editor_edit(state: &mut EditorState, raw: &str) {
let raw = raw.trim();
match state.field {
EditorField::Name => {
if let Some(w) = state.cfg.workloads.get_mut(state.cursor) {
w.name = raw.to_string();
}
}
EditorField::Duration => {
if let Some(w) = state.cfg.workloads.get_mut(state.cursor) {
if let Ok(v) = raw.parse::<u64>() {
w.duration_secs = v.max(1);
}
}
}
EditorField::Cores => {
if let Some(w) = state.cfg.workloads.get_mut(state.cursor) {
// Accept a cores range like "0-11" or a single number "3".
// Don't validate beyond non-empty — the workload impls
// handle bad ranges gracefully.
if !raw.is_empty() {
w.cores = raw.to_string();
}
}
}
EditorField::Cooldown => {
if let Ok(v) = raw.parse::<u64>() {
state.cfg.cool_secs = v;
}
}
EditorField::LoopCount => {
if let Ok(v) = raw.parse::<u32>() {
state.cfg.loop_count = v;
}
}
}
state.editing = None;
}
// ---------------------------------------------------------------------------
// Sweep (App — preserved from the prior session's ui.rs)
// ---------------------------------------------------------------------------
/// 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,
results: Vec<ResultRow>,
quit: bool,
}
impl App {
pub fn new(cfg: Config) -> Self {
App {
run: Run::new(cfg),
results: Vec::new(),
quit: false,
}
}
/// An empty App for the Done state (no run, no results) — used as a
/// placeholder when we need to move the real App out of a Mode.
pub fn empty() -> Self {
App {
run: Run::new(Config::default()),
results: Vec::new(),
quit: true,
}
}
/// Drive one tick + capture any WorkloadFinished into the results vec.
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)
}
}
// ---------------------------------------------------------------------------
// Draw dispatch
// ---------------------------------------------------------------------------
fn draw(f: &mut Frame, mode: &Mode) {
match mode {
Mode::Picker(state) => draw_picker(f, state),
Mode::Editor(state) => draw_editor(f, state),
Mode::Sweep(app) => draw_sweep(f, app),
Mode::Done(app) => draw_sweep(f, app),
}
}
fn draw_picker(f: &mut Frame, state: &PickerState) {
let area = f.area();
let items: Vec<ListItem> = PICKER_ROWS
.iter()
.enumerate()
.map(|(i, (name, desc))| {
let cursor = if i == state.cursor { ">" } else { " " };
let style = if i == state.cursor {
Style::default().add_modifier(Modifier::BOLD).fg(Color::Cyan)
} else {
Style::default()
};
ListItem::new(Line::from(vec![
Span::styled(format!("{cursor} "), style),
Span::styled(format!("{name:<14}"), style),
Span::raw(" "),
Span::styled(*desc, Style::default().fg(Color::DarkGray)),
]))
})
.collect();
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray))
.title(Span::styled(" alderbench — choose a sweep ", Style::default().fg(Color::Cyan))),
);
f.render_widget(list, area);
// Footer with keybinds.
let footer = Line::from(vec![
Span::styled(
"↑↓ select Enter start q quit",
Style::default().fg(Color::DarkGray),
),
]);
let footer_area = Rect { y: area.bottom().saturating_sub(1), height: 1, ..area };
f.render_widget(Paragraph::new(footer), footer_area);
}
fn draw_editor(f: &mut Frame, state: &EditorState) {
let area = f.area();
// Vertical: header(1) + workload list(flex) + globals(3) + start(1) + footer(1).
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Min(5),
Constraint::Length(3),
Constraint::Length(1),
Constraint::Length(1),
])
.split(area);
// Header.
f.render_widget(
Paragraph::new(Line::from(Span::styled(
"alderbench — custom sweep editor",
Style::default().add_modifier(Modifier::BOLD),
))),
chunks[0],
);
// Workload list (each row: name | duration | cores).
let rows: Vec<Row> = state
.cfg
.workloads
.iter()
.enumerate()
.map(|(i, w)| {
let cursor = if i == state.cursor { ">" } else { " " };
let row_style = if i == state.cursor {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default()
};
// Highlight the active field within the row.
let cell = |field: EditorField, text: String| -> Cell {
if i == state.cursor && state.field == field {
let editing = state.editing.as_deref().unwrap_or(&text);
Cell::from(Span::styled(
format!("[{editing}]"),
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
))
} else {
Cell::from(Span::styled(text, row_style))
}
};
Row::new(vec![
Cell::from(Span::styled(format!("{cursor} "), row_style)),
cell(EditorField::Name, w.name.clone()),
cell(EditorField::Duration, format!("{}s", w.duration_secs)),
cell(EditorField::Cores, w.cores.clone()),
])
})
.collect();
let header = Row::new(vec![
Cell::from(Span::styled(" ", Style::default())),
Cell::from(Span::styled("workload", Style::default().fg(Color::Cyan))),
Cell::from(Span::styled("dur", Style::default().fg(Color::Cyan))),
Cell::from(Span::styled("cores", Style::default().fg(Color::Cyan))),
]);
let table = Table::new(
rows,
[
Constraint::Length(2),
Constraint::Min(18),
Constraint::Length(8),
Constraint::Length(10),
],
)
.header(header)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray))
.title(Span::styled(" workloads ", Style::default().fg(Color::Cyan))),
);
f.render_widget(table, chunks[1]);
// Globals (cooldown + loop count).
let global_cell = |field: EditorField, label: &str, value: String| -> Line {
let cursor = if state.cursor == state.cfg.workloads.len() + EditorField::GLOBALS.iter().position(|f| *f == field).unwrap() {
">"
} else {
" "
};
let val_span = if state.cursor >= state.cfg.workloads.len()
&& state.field == field
{
let editing = state.editing.as_deref().unwrap_or(&value);
Span::styled(format!("[{editing}]"), Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
} else {
Span::raw(value)
};
Line::from(vec![
Span::raw(format!("{cursor} ")),
Span::styled(format!("{label:<12}"), Style::default().fg(Color::Cyan)),
val_span,
])
};
let globals = [global_cell(EditorField::Cooldown, "cooldown", format!("{}s", state.cfg.cool_secs)),
global_cell(EditorField::LoopCount, "loop count", state.cfg.loop_count.to_string())];
for (i, line) in globals.iter().enumerate() {
f.render_widget(
Paragraph::new(line.clone()),
Rect { x: chunks[2].x, y: chunks[2].y + i as u16, width: chunks[2].width, height: 1 },
);
}
// Start row.
let start_cursor = if state.cursor == state.cfg.workloads.len() + EditorField::GLOBALS.len() {
">"
} else {
" "
};
let start_style = if state.cursor == state.cfg.workloads.len() + EditorField::GLOBALS.len() {
Style::default().add_modifier(Modifier::BOLD).fg(Color::Green)
} else {
Style::default().fg(Color::DarkGray)
};
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(format!("{start_cursor} "), start_style),
Span::styled("Start sweep", start_style),
])),
chunks[3],
);
// Footer with keybinds.
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
"j/k row ←→ field Enter edit/toggle a add d del </> reorder Esc back",
Style::default().fg(Color::DarkGray),
),
])),
chunks[4],
);
}
fn draw_sweep(f: &mut Frame, app: &App) {
let area = f.area();
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 loop_idx = app.run.loop_index();
let loop_total = app.run.loop_count();
let loop_str = if loop_total == 0 {
format!("loop {loop_idx}/∞")
} else {
format!("loop {loop_idx}/{loop_total}")
};
let title = if app.run.is_done() {
format!("alderbench — sweep complete: {} workloads", app.results.len())
} else if current == 0 {
format!("alderbench — starting (0/{total}) {loop_str}")
} else {
format!("alderbench — workload {current}/{total} {loop_str}")
};
f.render_widget(
Paragraph::new(Line::from(Span::styled(
title,
Style::default().add_modifier(Modifier::BOLD),
))),
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 inner = block.inner(area);
f.render_widget(block, area);
let lines = if app.run.is_cooldown() {
// Cooldown: show progress (CPU is idle, the TUI can render freely).
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
};
[
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!("elapsed {:>5.1}s remaining {:>5.1}s", elapsed, remaining),
Style::default().fg(Color::DarkGray),
),
]),
progress_bar(progress, inner.width),
]
} else {
// Running: the duration is a display-only estimate ("this will
// probably take X seconds"). No progress bar — the workload runs
// until it self-exits or the user quits. The user can see CPU
// load via aldermon.
[
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", elapsed),
Style::default(),
),
]),
Line::from(Span::styled(
"running... (watch CPU load in aldermon, press q to cancel)",
Style::default().fg(Color::DarkGray),
)),
]
};
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%`.
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);
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;
}
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);
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) {
f.render_widget(
Paragraph::new(Line::from(Span::styled(
"q: quit Esc: quit",
Style::default().fg(Color::DarkGray),
))),
area,
);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Compute the sleep duration for the "workload running, no re-draw"
/// path. Sleeps for the remaining estimated time + a 5s margin, so the
/// TUI process is genuinely idle (not scheduling) while the workload
/// runs. After the sleep, the next tick will detect the workload has
/// exited (or still running, in which case we sleep again).
fn compute_sleep_secs(app: &mut App) -> u64 {
let elapsed = app.run.elapsed_secs();
let duration = app.run.current_spec().map(|s| s.duration_secs as f64).unwrap_or(60.0);
let remaining = (duration - elapsed).max(1.0);
remaining as u64 + 5
}
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())
}
}
}
}
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),
}
}