mod app;
mod log_pane;
mod screens;
mod sparsity;
mod theme;
use std::path::PathBuf;
use std::sync::mpsc::channel;
use std::time::{Duration, Instant};
use anyhow::Context;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use app::{App, BatchJob, BatchProgress, Screen, SynthField, WorkerEvent};
use log_pane::LogBuf;
use powerio_matrix::pipeline::{MatrixKind, Pipeline, RhsKind};
use powerio_matrix::synth::Topology;
#[derive(Debug, Default)]
pub struct TuiOptions {
pub data_dir: Option<PathBuf>,
pub out_dir: Option<PathBuf>,
}
pub fn run(opts: TuiOptions) -> anyhow::Result<()> {
let log = LogBuf::default();
let _ = install_tui_tracing(log.clone());
let data_dir = opts
.data_dir
.or_else(|| std::env::current_dir().ok().map(|p| p.join("tests/data")))
.unwrap_or_else(|| PathBuf::from("."));
let out_dir = opts
.out_dir
.or_else(|| std::env::current_dir().ok().map(|p| p.join("out")))
.unwrap_or_else(|| PathBuf::from("./out"));
let mut app = App::new(data_dir, out_dir, log.clone());
app.refresh_cases();
app.parse_selected();
tracing::info!(
cases = app.cases.len(),
data = %app.data_dir.display(),
out = %app.out_dir.display(),
"TUI ready",
);
ratatui::run(|terminal| -> std::io::Result<()> {
let tick_rate = Duration::from_millis(120);
let mut last_tick = Instant::now();
loop {
terminal.draw(|f| screens::draw(f, &app))?;
let timeout = tick_rate
.checked_sub(last_tick.elapsed())
.unwrap_or_default();
if event::poll(timeout)? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
if let Err(e) = handle_key(&mut app, key) {
tracing::error!(error = %e, "key handler error");
app.set_status(format!("error: {e}"));
}
}
}
}
if last_tick.elapsed() >= tick_rate {
last_tick = Instant::now();
app.drain_worker();
if let Some((_, when)) = app.status {
if when.elapsed() > Duration::from_secs(4) {
app.status = None;
}
}
}
if app.should_quit {
break Ok(());
}
}
})
.context("TUI run failed")
}
fn install_tui_tracing(buf: LogBuf) -> Result<(), tracing::dispatcher::SetGlobalDefaultError> {
use tracing_subscriber::EnvFilter;
use tracing_subscriber::fmt;
let subscriber = fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
)
.with_writer(buf)
.with_ansi(false)
.without_time()
.finish();
tracing::subscriber::set_global_default(subscriber)
}
fn handle_key(app: &mut App, key: KeyEvent) -> anyhow::Result<()> {
if matches!(key.code, KeyCode::Char('q'))
|| (matches!(key.code, KeyCode::Char('c')) && key.modifiers.contains(KeyModifiers::CONTROL))
{
app.should_quit = true;
return Ok(());
}
if matches!(key.code, KeyCode::Char('?')) {
app.previous_screen = app.screen;
app.screen = Screen::Help;
return Ok(());
}
match app.screen {
Screen::Browse => handle_browse(app, key),
Screen::Inspect => return handle_inspect(app, key),
Screen::Batch => handle_batch(app, key),
Screen::Synth => handle_synth(app, key),
Screen::Help => handle_help(app, key),
}
Ok(())
}
fn handle_browse(app: &mut App, key: KeyEvent) {
match key.code {
KeyCode::Up if app.selected > 0 => {
app.selected -= 1;
app.parse_selected();
}
KeyCode::Down if app.selected + 1 < app.cases.len() => {
app.selected += 1;
app.parse_selected();
}
KeyCode::Char(' ') if !app.cases.is_empty() && !app.multi_selected.insert(app.selected) => {
app.multi_selected.remove(&app.selected);
}
KeyCode::Char('R') | KeyCode::F(5) => {
app.refresh_cases();
app.parse_selected();
app.set_status("rescanned");
}
KeyCode::Enter => {
if let Err(e) = app.open_inspect() {
app.set_status(format!("inspect failed: {e}"));
}
}
KeyCode::Char('b') => {
app.previous_screen = Screen::Browse;
app.screen = Screen::Batch;
}
KeyCode::Char('g') => {
app.previous_screen = Screen::Browse;
app.screen = Screen::Synth;
}
_ => {}
}
}
fn handle_inspect(app: &mut App, key: KeyEvent) -> anyhow::Result<()> {
match key.code {
KeyCode::Esc => {
app.screen = Screen::Browse;
app.inspect = None;
}
KeyCode::Tab => app.cycle_matrix_kind(true),
KeyCode::BackTab => app.cycle_matrix_kind(false),
KeyCode::Char('s') => {
app.scheme = match app.scheme {
powerio_matrix::matrix::Scheme::Bx => powerio_matrix::matrix::Scheme::Xb,
_ => powerio_matrix::matrix::Scheme::Bx,
};
if let Some(state) = app.inspect.take() {
let case = state.case;
let prev_kind = state.kind;
match app.build_inspect(case) {
Ok(mut new_state) => {
new_state.kind = prev_kind;
new_state.kind_idx = MatrixKind::ALL
.iter()
.position(|k| *k == prev_kind)
.unwrap_or(0);
app.inspect = Some(new_state);
app.set_status(format!("scheme = {:?}", app.scheme));
}
Err(e) => app.set_status(format!("rebuild failed: {e}")),
}
}
}
KeyCode::Char('e') => {
export_inspect(app)?;
}
_ => {}
}
Ok(())
}
fn export_inspect(app: &mut App) -> anyhow::Result<()> {
let Some(state) = &app.inspect else {
return Ok(());
};
let pipeline = Pipeline {
matrices: vec![state.kind],
options: powerio_matrix::matrix::BuildOptions {
scheme: app.scheme,
..Default::default()
},
rhs: app.rhs,
rng_seed: 0x00C0_FFEE,
source_file: None,
};
let outputs = pipeline.run(&state.case, &app.out_dir)?;
tracing::info!(
files = outputs.files.len(),
out = %app.out_dir.display(),
case = %outputs.case_name,
"inspect export"
);
app.set_status(format!(
"wrote {} files for {} → {}",
outputs.files.len(),
outputs.case_name,
app.out_dir.display()
));
Ok(())
}
fn handle_batch(app: &mut App, key: KeyEvent) {
match key.code {
KeyCode::Esc => {
app.screen = app.previous_screen;
}
KeyCode::Char('m') => {
cycle_matrices(app);
}
KeyCode::Char('r') => {
app.rhs = match app.rhs {
RhsKind::None => RhsKind::Random,
RhsKind::Random => RhsKind::Injection,
RhsKind::Injection => RhsKind::None,
};
app.set_status(format!("rhs = {:?}", app.rhs));
}
KeyCode::Char('e') => {
spawn_batch(app);
}
_ => {}
}
}
fn cycle_matrices(app: &mut App) {
let presets: &[&[MatrixKind]] = &[
&[MatrixKind::BPrime],
&[MatrixKind::BPrime, MatrixKind::BDoublePrime],
&[
MatrixKind::BPrime,
MatrixKind::BDoublePrime,
MatrixKind::YbusB,
],
&[
MatrixKind::BPrime,
MatrixKind::BDoublePrime,
MatrixKind::YbusG,
MatrixKind::YbusB,
],
MatrixKind::ALL,
];
let cur_idx = presets
.iter()
.position(|p| {
p.len() == app.matrices_to_export.len()
&& p.iter().zip(&app.matrices_to_export).all(|(a, b)| a == b)
})
.unwrap_or(0);
let next = presets[(cur_idx + 1) % presets.len()];
app.matrices_to_export = next.to_vec();
app.set_status(format!(
"matrices = {}",
next.iter().map(|k| k.slug()).collect::<Vec<_>>().join(",")
));
}
fn spawn_batch(app: &mut App) {
let targets = app.batch_targets();
if targets.is_empty() {
app.set_status("nothing to export");
return;
}
let paths: Vec<PathBuf> = targets
.iter()
.filter_map(|i| app.cases.get(*i).map(|c| c.path.clone()))
.collect();
app.batch = paths
.iter()
.map(|p| BatchJob {
case_name: p
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("?")
.to_string(),
progress: BatchProgress::Pending,
})
.collect();
let pipeline = Pipeline {
matrices: app.matrices_to_export.clone(),
options: powerio_matrix::matrix::BuildOptions {
scheme: app.scheme,
..Default::default()
},
rhs: app.rhs,
rng_seed: 0x00C0_FFEE,
source_file: None,
};
let out_dir = app.out_dir.clone();
let (tx, rx) = channel();
app.worker_rx = Some(rx);
let log = app.log.clone();
std::thread::spawn(move || {
for (i, path) in paths.iter().enumerate() {
let _ = tx.send(WorkerEvent::Progress {
case_idx: i,
progress: BatchProgress::Running(0.05),
});
let parsed = match powerio_matrix::parse_matpower_file(path) {
Ok(p) => p,
Err(e) => {
let _ = tx.send(WorkerEvent::Progress {
case_idx: i,
progress: BatchProgress::Failed(format!("parse: {e}")),
});
log.push(format!("ERROR parse {}: {e}", path.display()));
continue;
}
};
let _ = tx.send(WorkerEvent::Progress {
case_idx: i,
progress: BatchProgress::Running(0.4),
});
let mut p = pipeline.clone();
p.source_file = Some(path.clone());
match p.run(&parsed, &out_dir) {
Ok(out) => {
log.push(format!(
"INFO exported {} ({} files)",
out.case_name,
out.files.len()
));
let _ = tx.send(WorkerEvent::Progress {
case_idx: i,
progress: BatchProgress::Done {
files: out.files.len(),
},
});
}
Err(e) => {
log.push(format!("ERROR export {}: {e}", path.display()));
let _ = tx.send(WorkerEvent::Progress {
case_idx: i,
progress: BatchProgress::Failed(e.to_string()),
});
}
}
}
let _ = tx.send(WorkerEvent::AllDone);
});
app.set_status(format!("running {} jobs", app.batch.len()));
}
fn handle_synth(app: &mut App, key: KeyEvent) {
match key.code {
KeyCode::Esc => {
app.screen = app.previous_screen;
}
KeyCode::Up => app.synth.field = app.synth.field.prev(),
KeyCode::Down => app.synth.field = app.synth.field.next(),
KeyCode::Left => synth_tweak(app, false),
KeyCode::Right => synth_tweak(app, true),
KeyCode::Char('g') => {
let case = powerio_matrix::synth::generate(&app.synth.spec);
app.synth.generated = Some(case);
app.set_status("regenerated synthetic case");
}
KeyCode::Char('e') => {
if let Some(case) = &app.synth.generated {
let pipeline = Pipeline {
matrices: app.matrices_to_export.clone(),
options: powerio_matrix::matrix::BuildOptions {
scheme: app.scheme,
..Default::default()
},
rhs: app.rhs,
rng_seed: app.synth.spec.seed,
source_file: None,
};
match pipeline.run(case, &app.out_dir) {
Ok(out) => {
app.set_status(format!(
"wrote {} files for {} → {}",
out.files.len(),
out.case_name,
app.out_dir.display()
));
}
Err(e) => app.set_status(format!("export failed: {e}")),
}
} else {
app.set_status("press [g] to generate first");
}
}
_ => {}
}
}
fn synth_tweak(app: &mut App, increase: bool) {
let s = &mut app.synth.spec;
let direction = if increase { 1.0 } else { -1.0 };
match app.synth.field {
SynthField::Topology => {
#[allow(clippy::match_same_arms)]
let next = match (s.topology, increase) {
(Topology::Tree, true) => Topology::Lattice2D,
(Topology::Lattice2D, true) => Topology::PegaseLike,
(Topology::PegaseLike, true) => Topology::Tree,
(Topology::Tree, false) => Topology::PegaseLike,
(Topology::Lattice2D, false) => Topology::Tree,
(Topology::PegaseLike, false) => Topology::Lattice2D,
};
s.topology = next;
}
SynthField::N => {
let step = (s.n as f64 * 0.25).round() as usize;
s.n = if increase {
s.n.saturating_add(step.max(8))
} else {
s.n.saturating_sub(step.max(8)).max(2)
};
}
SynthField::ROverX => {
s.r_over_x = (s.r_over_x + direction * 0.05).clamp(0.0, 5.0);
}
SynthField::MeanX => {
let factor: f64 = if increase { 1.2 } else { 1.0 / 1.2 };
s.mean_x = (s.mean_x * factor).clamp(1e-4, 1.0);
}
SynthField::Seed => {
s.seed = s.seed.wrapping_add(if increase { 1 } else { u64::MAX });
}
}
}
fn handle_help(app: &mut App, key: KeyEvent) {
if matches!(key.code, KeyCode::Esc | KeyCode::Char('?')) {
app.screen = app.previous_screen;
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::buffer::Buffer;
fn make_app() -> App {
let log = LogBuf::default();
App::new(
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/data"),
std::env::temp_dir().join("powerio-tui-test"),
log,
)
}
fn buffer_text(buf: &Buffer) -> String {
let area = buf.area();
let mut out = String::new();
for y in 0..area.height {
for x in 0..area.width {
out.push_str(buf[(area.x + x, area.y + y)].symbol());
}
out.push('\n');
}
out
}
#[test]
fn smoke_browse_renders() {
let mut app = make_app();
app.refresh_cases();
let backend = TestBackend::new(120, 32);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| screens::draw(f, &app)).unwrap();
let s = buffer_text(terminal.backend().buffer());
assert!(
s.contains("Browse"),
"expected screen header to contain Browse:\n{s}"
);
assert!(s.contains("Cases"), "expected case panel title:\n{s}");
}
#[test]
fn inspect_render_after_open() {
let mut app = make_app();
app.refresh_cases();
if !app.cases.is_empty() {
app.selected = 0;
app.open_inspect().unwrap();
let backend = TestBackend::new(160, 40);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| screens::draw(f, &app)).unwrap();
let s = buffer_text(terminal.backend().buffer());
assert!(s.contains("Inspect"), "missing Inspect header:\n{s}");
assert!(s.contains("Stats"), "missing Stats panel:\n{s}");
assert!(s.contains("Sparsity"), "missing Sparsity panel:\n{s}");
}
}
#[test]
fn matrix_kind_cycles() {
let mut app = make_app();
app.refresh_cases();
if !app.cases.is_empty() {
app.selected = 0;
app.open_inspect().unwrap();
let initial = app.inspect.as_ref().unwrap().kind;
app.cycle_matrix_kind(true);
let next = app.inspect.as_ref().unwrap().kind;
assert_ne!(initial, next);
}
}
}