use std::time::{Duration, Instant};
use ratatui::{
backend::{Backend, CrosstermBackend},
crossterm,
layout::{Constraint, Direction, Layout},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Paragraph},
Terminal,
};
use crate::sprites::WalkingCat;
use baztet_core::baztet_bep::BepOutput;
use baztet_core::exec::{ExecChild, ExecResult};
pub async fn run(child: ExecChild, title: &str) -> anyhow::Result<ExecResult> {
let mut terminal = TuiTerminal::enter()?;
let result = terminal.drive(child, title).await;
drop(terminal);
result
}
struct TuiTerminal {
terminal: Terminal<CrosstermBackend<std::io::Stdout>>,
}
impl TuiTerminal {
fn enter() -> anyhow::Result<Self> {
crossterm::terminal::enable_raw_mode()?;
crossterm::execute!(std::io::stdout(), crossterm::terminal::EnterAlternateScreen)?;
let terminal = match Terminal::new(CrosstermBackend::new(std::io::stdout())) {
Ok(t) => t,
Err(e) => {
restore();
return Err(e.into());
}
};
Ok(Self { terminal })
}
async fn drive(&mut self, child: ExecChild, title: &str) -> anyhow::Result<ExecResult> {
let run_started = Instant::now();
let bep_path = child.bep_path().to_owned();
let console = child.console_handle();
let mut waiter = Some(tokio::spawn(async move {
child.wait().await.map_err(anyhow::Error::from)
}));
let mut state = BepOutput::default();
let mut offset = 0usize;
let (tw, th) = crossterm::terminal::size().unwrap_or((80, 24));
let mut walker = WalkingCat::new(tw, th);
let outcome: anyhow::Result<ExecResult> = loop {
if waiter.as_ref().unwrap().is_finished() {
let result = match waiter.take().unwrap().await {
Ok(Ok(res)) => Ok(res),
Ok(Err(e)) => Err(e),
Err(e) => Err(e.into()),
};
if let Ok(res) = &result {
if let Some(bep) = &res.bep {
state = bep.clone();
}
}
let _ = draw(
&mut self.terminal,
&state,
walker.as_ref(),
&console,
title,
true,
run_started.elapsed(),
);
break result;
}
let key_ready = match crossterm::event::poll(Duration::from_millis(120)) {
Ok(ready) => ready,
Err(e) => break Err(e.into()),
};
if key_ready {
match crossterm::event::read() {
Ok(crossterm::event::Event::Key(k)) => {
let quit = k.kind == crossterm::event::KeyEventKind::Press
&& (k.code == crossterm::event::KeyCode::Char('q')
|| (k
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL)
&& k.code == crossterm::event::KeyCode::Char('c')));
if quit {
if waiter.as_ref().unwrap().is_finished() {
break match waiter.take().unwrap().await {
Ok(Ok(res)) => Ok(res),
Ok(Err(e)) => Err(e),
Err(e) => Err(e.into()),
};
}
waiter.as_ref().unwrap().abort();
break Ok(ExecResult {
code: 130,
bep: Some(state),
..Default::default()
});
}
}
Ok(crossterm::event::Event::Resize(w, h)) => {
walker = WalkingCat::new(w, h);
}
_ => {}
}
}
if let Some(g) = walker.as_mut() {
g.tick();
}
offset = ingest(&mut state, &bep_path, offset);
draw(
&mut self.terminal,
&state,
walker.as_ref(),
&console,
title,
false,
run_started.elapsed(),
)?;
};
if outcome.is_err() {
if let Some(h) = waiter.as_ref() {
h.abort();
}
}
outcome
}
}
impl Drop for TuiTerminal {
fn drop(&mut self) {
restore();
}
}
fn restore() {
let _ = crossterm::execute!(
std::io::stdout(),
crossterm::terminal::LeaveAlternateScreen,
crossterm::cursor::Show,
);
let _ = crossterm::terminal::disable_raw_mode();
}
fn draw<B>(
terminal: &mut Terminal<B>,
out: &BepOutput,
walker: Option<&WalkingCat>,
console: &baztet_core::exec::ConsoleCapture,
title: &str,
settled: bool,
elapsed: Duration,
) -> anyhow::Result<()>
where
B: Backend,
B::Error: std::error::Error + Send + Sync + 'static,
{
terminal.draw(|f| {
let sprite_h = walker.map(|g| g.size.dims().1).unwrap_or(0);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(2 + sprite_h)])
.split(f.area());
let mut rows: Vec<Line> = Vec::new();
for s in &out.test_summaries {
rows.push(Line::from(format!(" {:<48} {}", s.label, s.status)));
}
if rows.is_empty() {
rows.push(Line::from(Span::styled(
" waiting for the first verdicts…",
Style::new().dim(),
)));
}
let list = Paragraph::new(rows).block(
Block::bordered()
.title(format!(" baztet {title} — live "))
.title_style(Style::new().bold().fg(Color::Yellow))
.border_style(Style::new().fg(Color::DarkGray)),
);
f.render_widget(list, chunks[0]);
const MARKS: [&str; 3] = ["▸ ", "▹ ", "● "];
let mark = if settled {
MARKS[0]
} else {
MARKS[(elapsed.as_millis() / 400) as usize % MARKS.len()]
};
let progress = console.last_progress();
let (tw, _) = crossterm::terminal::size().unwrap_or((80, 24));
let max_progress = tw.saturating_sub(14) as usize;
let progress: String = progress.chars().take(max_progress).collect();
let mut status = vec![
Span::styled(mark, Style::new().bold().fg(Color::Yellow)),
Span::raw(progress),
];
if !settled {
status.push(Span::raw(format!(" · {:.0}s", elapsed.as_secs_f64())));
}
let mut strip: Vec<Line> = vec![Line::from(status)];
if let Some(walker) = walker {
for l in walker.frame_lines() {
strip.push(Line::from(Span::styled(
l.clone(),
Style::new().fg(Color::Rgb(0xE5, 0xC0, 0x6B)),
)));
}
} else {
strip.push(Line::from(""));
}
if settled {
strip.push(Line::from(Span::styled(
if out.target_status.values().any(|ok| !ok) {
" ~ grumpy ~"
} else {
" ~ meow ~"
},
Style::new().fg(if out.target_status.values().any(|ok| !ok) {
Color::Red
} else {
Color::Green
}),
)));
}
f.render_widget(Paragraph::new(strip), chunks[1]);
})?;
Ok(())
}
fn ingest(state: &mut BepOutput, path: &std::path::Path, offset: usize) -> usize {
use std::io::{Read, Seek, SeekFrom};
if let Ok(mut f) = std::fs::File::open(path) {
if f.seek(SeekFrom::Start(offset as u64)).is_ok() {
let mut buf = Vec::new();
if f.read_to_end(&mut buf).is_ok() {
if let Ok((delta, consumed)) = baztet_core::baztet_bep::parse_prefix(&buf, 0) {
state.extend(delta);
return offset + consumed;
}
}
}
}
offset
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::backend::TestBackend;
#[test]
fn dashboard_lists_verdicts_and_walks() {
let mut out = BepOutput::default();
out.test_summaries
.push(baztet_core::baztet_bep::TestSummary {
label: "//a:one".into(),
status: "PASSED".into(),
});
out.target_status.insert("//a:two".into(), true);
let backend = TestBackend::new(80, 20);
let mut terminal = Terminal::new(backend).unwrap();
let walker = WalkingCat::new(80, 20).unwrap();
draw(
&mut terminal,
&out,
Some(&walker),
&baztet_core::exec::ConsoleCapture::default(),
"test",
true,
Duration::from_secs(9),
)
.unwrap();
let text: String = terminal
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect();
assert!(text.contains("//a:one"), "verdict row missing: {text}");
assert!(text.contains("PASSED"), "verdict missing: {text}");
assert!(text.contains("meow"), "goodbye missing: {text}");
}
#[test]
fn sprite_size_falls_back_on_narrow_terminals() {
assert!(WalkingCat::new(20, 10).is_none());
assert!(WalkingCat::new(30, 15).is_some());
}
#[test]
fn ingest_reads_appended_frames() {
use std::io::Write;
use std::time::{SystemTime, UNIX_EPOCH};
let path = std::env::temp_dir().join(format!(
"baztet-ingest-{}.bin",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let mut f = std::fs::File::create(&path).unwrap();
let frame1 = baztet_core::baztet_bep::testing::framed_started(4242, "stub");
f.write_all(&frame1).unwrap();
f.sync_all().unwrap();
let mut state = BepOutput::default();
let off = ingest(&mut state, &path, 0);
assert_eq!(off, frame1.len());
assert_eq!(state.server_pid, Some(4242));
let frame2 = baztet_core::baztet_bep::testing::framed_started(5, "x");
f.write_all(&frame2).unwrap();
f.sync_all().unwrap();
let off2 = ingest(&mut state, &path, off);
assert_eq!(off2, frame1.len() + frame2.len());
assert_eq!(state.server_pid, Some(5));
f.write_all(&frame1[..3]).unwrap();
f.sync_all().unwrap();
let off3 = ingest(&mut state, &path, off2);
assert_eq!(off3, off2);
assert_eq!(state.server_pid, Some(5));
let _ = std::fs::remove_file(&path);
}
}