//! 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()` + render the current //! workload + progress + a results table. While a workload runs the //! loop heartbeat-sleeps (≤15s, ~6s past the estimate) instead of //! ticking at poll_ms — keeps CPU off single-core workloads while the //! panel stays live. 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::{ layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Cell, List, ListItem, Paragraph, Row, Table}, Frame, }; 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, } #[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. // Session 6 killed the 2s poll cadence for this reason (it // stretched a 10s single-core y-cruncher run past 60s). The // compromise below: sleep on a heartbeat (compute_sleep_secs, // capped at ~15s), draw once per wake. The panel's elapsed // stays live + overrun states become visible, while the TUI // spends ~0% CPU (one diff-draw per 15s). let should_draw = match &outcome { TickOutcome::WorkloadStarted { .. } => true, TickOutcome::WorkloadFinished { .. } => true, TickOutcome::SweepDone => true, TickOutcome::Idle => 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 { // Heartbeat sleep. Wake on timeout (then fall through to // the draw below so elapsed updates) or on a key (q/Esc // cancels; anything else also falls through to a draw). 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; } } } } } 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 Cores it cycles the // preset ranges (0-15 / 0-11 / 12-15). On Duration/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::Cores => { // Cycle through the preset core ranges instead of // free-text editing. Unknown current values (e.g. a // hand-edited conf range) restart at the first option. if let Some(w) = state.cfg.workloads.get_mut(state.cursor) { w.cores = match presets::CORES_OPTIONS.iter().position(|c| *c == w.cores) { Some(idx) => { presets::CORES_OPTIONS[(idx + 1) % presets::CORES_OPTIONS.len()] } None => presets::CORES_OPTIONS[0], } .to_string(); } } EditorField::Duration | 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::() { 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::() { state.cfg.cool_secs = v; } } EditorField::LoopCount => { if let Ok(v) = raw.parse::() { 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, 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 { 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 = 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 = 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 }; vec![ 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. Past the estimate the // label goes yellow + says so (the 7z-overrun "is it hung?" // UAT complaint): the sweep is not stalled, the workload just // runs to the end of its fixed passes. The user can see CPU load // via aldermon. let overrun = elapsed >= spec.duration_secs as f64 + 2.0; let mut lines = vec![ 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( if overrun { format!( "past ~{}s estimate — running to completion (self-terminating; q cancels)", spec.duration_secs ) } else { "running... (watch CPU load in aldermon, press q to cancel)".to_string() }, Style::default().fg(if overrun { Color::Yellow } else { Color::DarkGray }), )), ]; if overrun { lines.push(Line::from(Span::styled( "7z runs fixed benchmark passes — overrunning the estimate is normal.", Style::default().fg(Color::DarkGray), ))); } lines }; 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 heartbeat sleep for the "workload running, don't poll at /// 250ms" path: remaining estimate + 5s margin, capped at 15s so the /// panel's elapsed refreshes even on long runs (session 6's freeze /// complaint), and settling at ~6s once the estimate is overrun (so /// self-exiting workloads like 7z get detected within a short window /// after their final pass). The 15s cap keeps the TUI at ~0% CPU — the /// failure mode that forced the original one-long-sleep design was the /// 2s poll cadence stealing CPU from single-core runs. 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).min(15) } 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()) } } Score::C2c { pairs } => { let ns: Vec = pairs.iter().map(|p| p.ns_per_hop).collect(); ns_range(&ns) } Score::Dram { levels } => { let ns: Vec = levels.iter().map(|l| l.ns_per_access).collect(); ns_range(&ns) } } } fn ns_range(ns: &[f64]) -> String { if ns.is_empty() { "(no samples)".to_string() } else { let lo = ns.iter().cloned().fold(f64::INFINITY, f64::min); let hi = ns.iter().cloned().fold(f64::NEG_INFINITY, f64::max); format!("{lo:.0}-{hi:.0} ns") } } 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), } } #[cfg(test)] mod tests { use super::*; fn editor_at_cores() -> EditorState { let mut state = EditorState::from_cfg(Config::default()); state.cursor = 0; state.field = EditorField::Cores; state } #[test] fn enter_on_cores_cycles_options_not_free_text() { let mut state = editor_at_cores(); assert_eq!(state.cfg.workloads[0].cores, "0-15"); editor_key(&mut state, KeyCode::Enter); assert_eq!(state.cfg.workloads[0].cores, "0-11"); assert!(state.editing.is_none()); editor_key(&mut state, KeyCode::Enter); assert_eq!(state.cfg.workloads[0].cores, "12-15"); editor_key(&mut state, KeyCode::Enter); assert_eq!(state.cfg.workloads[0].cores, "0-15"); } #[test] fn enter_on_cores_unknown_value_restarts_at_first() { let mut state = editor_at_cores(); state.cfg.workloads[0].cores = "3,7".to_string(); editor_key(&mut state, KeyCode::Enter); assert_eq!(state.cfg.workloads[0].cores, "0-15"); } }