Initial Rust CLI/TUI Rebuild (Seeded Sim + CSV Export)

This commit is contained in:
markmental 2026-02-10 16:44:07 -05:00
commit dae34cce41
16 changed files with 2739 additions and 0 deletions

83
src/ui/dashboard.rs Normal file
View file

@ -0,0 +1,83 @@
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
use crate::app::App;
use crate::instance::SimStatus;
use crate::ui::widgets::status_badge;
pub fn render(f: &mut Frame<'_>, area: Rect, app: &App) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
.split(area);
let mut items: Vec<ListItem> = Vec::new();
if app.instances.is_empty() {
items.push(ListItem::new(
"No instances yet. Press n, l, or o to create one.",
));
} else {
for (idx, inst) in app.instances.iter().enumerate() {
let marker = if idx == app.selected { ">" } else { " " };
let line = format!(
"{} sim-{} [{}] {} | {}",
marker,
inst.id,
inst.sim_type.as_str(),
status_badge(&inst.status),
inst.progress_text()
);
let mut item = ListItem::new(line);
if idx == app.selected {
item = item.style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
}
items.push(item);
}
}
let list = List::new(items).block(Block::default().title("Instances").borders(Borders::ALL));
f.render_widget(list, chunks[0]);
let detail_text = if let Some(inst) = app.selected_instance() {
let status = match &inst.status {
SimStatus::Pending => "pending",
SimStatus::Running { .. } => "running",
SimStatus::Completed => "completed",
};
format!(
"ID: sim-{}\nType: {}\nStatus: {}\nSeed: {}\nTeams:\n- {}\n- {}{}\n\nOutcome:\n{}\n\nTip: Press Enter or v to open live detail view.",
inst.id,
inst.sim_type.as_str(),
status,
inst.seed,
inst.teams.first().cloned().unwrap_or_default(),
inst.teams.get(1).cloned().unwrap_or_default(),
if inst.teams.len() > 2 {
format!(
"\n- {}\n- {}",
inst.teams.get(2).cloned().unwrap_or_default(),
inst.teams.get(3).cloned().unwrap_or_default()
)
} else {
String::new()
},
inst.outcome_summary()
)
} else {
"No selection".to_string()
};
let details = Paragraph::new(detail_text)
.block(
Block::default()
.title("Selected Instance")
.borders(Borders::ALL),
)
.wrap(ratatui::widgets::Wrap { trim: true });
f.render_widget(details, chunks[1]);
}

109
src/ui/detail.rs Normal file
View file

@ -0,0 +1,109 @@
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Tabs};
use crate::app::App;
use crate::instance::SimStatus;
pub fn render(f: &mut Frame<'_>, area: Rect, app: &App) {
let Some(inst) = app.selected_instance() else {
let empty = Paragraph::new("No selected instance")
.block(Block::default().title("Detail").borders(Borders::ALL));
f.render_widget(empty, area);
return;
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(8),
Constraint::Length(8),
])
.split(area);
let score = Paragraph::new(inst.scoreboard.clone())
.block(
Block::default()
.title(format!("sim-{} Scoreboard", inst.id))
.borders(Borders::ALL),
)
.style(Style::default().fg(Color::White).bg(Color::Black));
f.render_widget(score, chunks[0]);
let middle = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
.split(chunks[1]);
let logs: Vec<ListItem> = inst
.logs
.iter()
.rev()
.take((middle[0].height as usize).saturating_sub(2))
.rev()
.map(|line| ListItem::new(line.clone()))
.collect();
let log_widget =
List::new(logs).block(Block::default().title("Live Log").borders(Borders::ALL));
f.render_widget(log_widget, middle[0]);
let mut right_lines: Vec<ListItem> = Vec::new();
let status = match &inst.status {
SimStatus::Pending => "pending",
SimStatus::Running { .. } => "running",
SimStatus::Completed => "completed",
};
right_lines.push(ListItem::new(format!("Status: {}", status)));
right_lines.push(ListItem::new(format!("Seed: {}", inst.seed)));
right_lines.push(ListItem::new(format!("Mode: {}", inst.sim_type.as_str())));
right_lines.push(ListItem::new(""));
right_lines.push(ListItem::new("Teams:"));
for t in &inst.teams {
right_lines.push(ListItem::new(format!("- {}", t)));
}
let side = List::new(right_lines).block(
Block::default()
.title("Instance Info")
.borders(Borders::ALL),
);
f.render_widget(side, middle[1]);
let bottom = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(34),
Constraint::Percentage(33),
Constraint::Percentage(33),
])
.split(chunks[2]);
let stats = List::new(
inst.stats_lines
.iter()
.map(|s| ListItem::new(s.clone()))
.collect::<Vec<_>>(),
)
.block(Block::default().title("Stats").borders(Borders::ALL));
let comp = List::new(
inst.competition_lines
.iter()
.map(|s| ListItem::new(s.clone()))
.collect::<Vec<_>>(),
)
.block(Block::default().title("Competition").borders(Borders::ALL));
let hist = List::new(
inst.history_lines
.iter()
.map(|s| ListItem::new(s.clone()))
.collect::<Vec<_>>(),
)
.block(Block::default().title("History").borders(Borders::ALL));
f.render_widget(stats, bottom[0]);
f.render_widget(comp, bottom[1]);
f.render_widget(hist, bottom[2]);
let tabs = Tabs::new(vec!["Dashboard", "Detail"]).select(1);
let _ = tabs;
}

41
src/ui/mod.rs Normal file
View file

@ -0,0 +1,41 @@
pub mod dashboard;
pub mod detail;
pub mod modal;
pub mod widgets;
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Paragraph};
use crate::app::App;
pub fn draw(f: &mut Frame<'_>, app: &App) {
let areas = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(10),
Constraint::Length(2),
])
.split(f.area());
let header = Paragraph::new("MentalNet SoccerCloud | n=single l=league4 o=knockout4 s=start c=clone d=delete e=export v=detail q=quit")
.block(Block::default().title("Dashboard").borders(Borders::ALL))
.style(Style::default().fg(Color::Cyan));
f.render_widget(header, areas[0]);
if app.show_detail {
detail::render(f, areas[1], app);
} else {
dashboard::render(f, areas[1], app);
}
let footer = Paragraph::new(format!(
"{} | speed={} (1/2/4/0)",
app.status_line,
app.speed.label()
))
.block(Block::default().borders(Borders::ALL).title("Status"))
.style(Style::default().fg(Color::Green));
f.render_widget(footer, areas[2]);
}

2
src/ui/modal.rs Normal file
View file

@ -0,0 +1,2 @@
// Placeholder module for future interactive creation modal.
// Current MVP uses keyboard shortcuts for fast instance creation.

9
src/ui/widgets.rs Normal file
View file

@ -0,0 +1,9 @@
use crate::instance::SimStatus;
pub fn status_badge(status: &SimStatus) -> &'static str {
match status {
SimStatus::Pending => "PENDING",
SimStatus::Running { .. } => "RUNNING",
SimStatus::Completed => "COMPLETED",
}
}