use std::io::{stderr, BufRead, BufReader, Read, Write};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
use console::{style, Style};
use crossterm::event::{
poll, read, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers,
KeyboardEnhancementFlags, MouseButton, MouseEventKind, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, supports_keyboard_enhancement, Clear, ClearType};
use crossterm::{cursor, queue, style::Print, terminal};
use super::clock::{clean_line, format_elapsed};
use super::live::{Model, Row, Status, Sty};
use super::signals::{isolate_child, register_child, terminate_all, unregister_child};
use super::TICK;
static TUI_ACTIVE: AtomicBool = AtomicBool::new(false);
static ENHANCED: AtomicBool = AtomicBool::new(false);
pub fn restore_terminal() {
if !TUI_ACTIVE.swap(false, Ordering::SeqCst) {
return;
}
let mut out = stderr();
if ENHANCED.swap(false, Ordering::SeqCst) {
let _ = queue!(out, PopKeyboardEnhancementFlags);
}
let _ = queue!(out, DisableMouseCapture, cursor::Show);
let _ = out.flush();
let _ = disable_raw_mode();
}
pub struct LiveUi {
attended: bool,
model: Arc<Mutex<Model>>,
starts: Arc<Mutex<Vec<Option<Instant>>>>,
stop: Arc<AtomicBool>,
top: Arc<AtomicUsize>,
render: Option<JoinHandle<()>>,
}
impl LiveUi {
pub fn new(attended: bool) -> LiveUi {
let model = Arc::new(Mutex::new(Model::new()));
let starts = Arc::new(Mutex::new(Vec::new()));
let stop = Arc::new(AtomicBool::new(false));
let top = Arc::new(AtomicUsize::new(0));
let render = if attended && enter_tui() {
install_panic_hook();
let (m, s, st, tp) = (Arc::clone(&model), Arc::clone(&starts), Arc::clone(&stop), Arc::clone(&top));
Some(thread::spawn(move || render_loop(m, s, st, tp)))
} else {
None
};
let attended = render.is_some();
LiveUi { attended, model, starts, stop, top, render }
}
pub fn proc(&self, label: impl Into<String>, tail: bool) -> Proc {
let label = label.into();
let i = {
let mut m = self.model.lock().unwrap();
m.add(label.clone())
};
self.starts.lock().unwrap().push(None);
Proc { i, label, attended: self.attended, tail, model: Arc::clone(&self.model), starts: Arc::clone(&self.starts) }
}
pub fn pin_board_to_top(&self) {
self.model.lock().unwrap().scroll_to_top();
}
pub fn finish(mut self) {
self.stop.store(true, Ordering::SeqCst);
if let Some(h) = self.render.take() {
let _ = h.join();
}
if self.attended {
let top = self.top.load(Ordering::SeqCst) as u16;
let mut out = stderr();
let _ = queue!(out, cursor::MoveTo(0, top), Clear(ClearType::FromCursorDown));
let _ = out.flush();
restore_terminal();
for line in summary_lines(&self.model.lock().unwrap()) {
eprintln!("{line}");
}
}
}
}
impl Drop for LiveUi {
fn drop(&mut self) {
self.stop.store(true, Ordering::SeqCst);
if let Some(h) = self.render.take() {
let _ = h.join();
}
restore_terminal();
}
}
pub struct Proc {
i: usize,
label: String,
attended: bool,
tail: bool,
model: Arc<Mutex<Model>>,
starts: Arc<Mutex<Vec<Option<Instant>>>>,
}
impl Proc {
pub fn start(&self) {
*self.starts.lock().unwrap().get_mut(self.i).unwrap() = Some(Instant::now());
self.model.lock().unwrap().set_status(self.i, Status::Running);
if !self.attended {
eprintln!("{} {}…", style("▶").cyan(), style(&self.label).bold());
}
}
pub fn note(&self, msg: &str) {
if self.attended {
self.model.lock().unwrap().set_note(self.i, Some(msg.to_string()));
}
}
pub fn run(&self, program: &str, args: &[String]) -> std::io::Result<(bool, Option<String>)> {
let (ok, _timed_out, last) = self.exec(program, args, None, None)?;
Ok((ok, last))
}
pub fn run_timed(
&self, program: &str, args: &[String], timeout: Option<Duration>,
) -> std::io::Result<(bool, bool, Option<String>)> {
self.exec(program, args, None, timeout)
}
pub fn run_with_stdin(
&self, program: &str, args: &[String], stdin: &[u8],
) -> std::io::Result<(bool, Option<String>)> {
let (ok, _timed_out, last) = self.exec(program, args, Some(stdin), None)?;
Ok((ok, last))
}
fn exec(
&self, program: &str, args: &[String], stdin: Option<&[u8]>, timeout: Option<Duration>,
) -> std::io::Result<(bool, bool, Option<String>)> {
let started = self.start_instant();
let mut command = Command::new(program);
command.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
command.stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() });
isolate_child(&mut command);
let mut child = command.spawn()?;
let pid = child.id();
register_child(pid);
let last = Arc::new(Mutex::new(None::<String>));
let mut pumps: Vec<JoinHandle<()>> = Vec::new();
if let Some(out) = child.stdout.take() {
pumps.push(self.pump(out, started, Arc::clone(&last)));
}
if let Some(err) = child.stderr.take() {
pumps.push(self.pump(err, started, Arc::clone(&last)));
}
if let Some(bytes) = stdin {
if let Some(mut si) = child.stdin.take() {
let _ = si.write_all(bytes);
}
}
let mut timed_out = false;
let status = match timeout {
None => child.wait()?,
Some(limit) => loop {
if let Some(s) = child.try_wait()? {
break s;
}
if started.elapsed() >= limit {
let _ = child.kill();
timed_out = true;
break child.wait()?;
}
thread::sleep(Duration::from_millis(100));
},
};
unregister_child(pid);
for p in pumps {
let _ = p.join();
}
let last = last.lock().unwrap().clone();
Ok((status.success(), timed_out, last))
}
pub fn finish_ok(&self, detail: Option<&str>) {
self.finish(Status::Ok, detail);
}
pub fn finish_fail(&self, detail: Option<&str>) {
self.finish(Status::Fail, detail);
}
fn finish(&self, status: Status, detail: Option<&str>) {
let elapsed = self.start_instant().elapsed().as_secs_f64();
{
let mut m = self.model.lock().unwrap();
m.set_elapsed(self.i, elapsed);
m.set_status(self.i, status);
m.set_detail(self.i, detail.filter(|d| !d.is_empty()).map(str::to_string));
}
if !self.attended {
eprintln!("{}", summary_line(&self.label, status, elapsed, detail));
}
}
fn start_instant(&self) -> Instant {
self.starts.lock().unwrap().get(self.i).copied().flatten().unwrap_or_else(Instant::now)
}
fn pump<R: Read + Send + 'static>(
&self, reader: R, started: Instant, last: Arc<Mutex<Option<String>>>,
) -> JoinHandle<()> {
let (i, attended, tail, model) = (self.i, self.attended, self.tail, Arc::clone(&self.model));
thread::spawn(move || {
for line in BufReader::new(reader).lines() {
let Ok(raw) = line else { break };
let cleaned = clean_line(&raw);
if cleaned.is_empty() {
continue;
}
let at = started.elapsed().as_secs_f64();
{
let mut m = model.lock().unwrap();
m.push_line(i, at, cleaned.clone());
if attended {
m.set_note(i, Some(cleaned.clone()));
}
}
if !attended && tail {
eprintln!(" {}", style(&cleaned).dim());
}
*last.lock().unwrap() = Some(cleaned);
}
})
}
}
fn enter_tui() -> bool {
if enable_raw_mode().is_err() {
return false;
}
let mut out = stderr();
if queue!(out, EnableMouseCapture, cursor::Hide).and_then(|_| out.flush()).is_err() {
let _ = disable_raw_mode();
return false;
}
if supports_keyboard_enhancement().unwrap_or(false)
&& queue!(out, PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES))
.and_then(|_| out.flush())
.is_ok()
{
ENHANCED.store(true, Ordering::SeqCst);
}
TUI_ACTIVE.store(true, Ordering::SeqCst);
true
}
fn install_panic_hook() {
static HOOKED: AtomicBool = AtomicBool::new(false);
if HOOKED.swap(true, Ordering::SeqCst) {
return;
}
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
restore_terminal();
prev(info);
}));
}
fn render_loop(
model: Arc<Mutex<Model>>, starts: Arc<Mutex<Vec<Option<Instant>>>>, stop: Arc<AtomicBool>, top_out: Arc<AtomicUsize>,
) {
let anchor = cursor::position().map(|(_, r)| r).unwrap_or(0);
let mut frame: u64 = 0;
let mut last_rows: Vec<Row> = Vec::new();
let mut board_top = anchor; while !stop.load(Ordering::SeqCst) {
if poll(TICK).unwrap_or(false) {
loop {
match read() {
Ok(ev) => {
if handle_event(ev, &model, &last_rows, board_top) {
return; }
}
Err(_) => break,
}
if !poll(Duration::from_millis(0)).unwrap_or(false) {
break;
}
}
}
tick_clocks(&model, &starts);
let (rows, top) = draw(&model, frame, anchor, board_top);
last_rows = rows;
board_top = top;
top_out.store(top as usize, Ordering::SeqCst);
frame = frame.wrapping_add(1);
}
}
fn tick_clocks(model: &Arc<Mutex<Model>>, starts: &Arc<Mutex<Vec<Option<Instant>>>>) {
let starts = starts.lock().unwrap();
let mut m = model.lock().unwrap();
for (i, p) in m.procs.iter_mut().enumerate() {
if p.status == Status::Running {
if let Some(Some(start)) = starts.get(i) {
p.elapsed = start.elapsed().as_secs_f64();
}
}
}
}
fn handle_event(ev: Event, model: &Arc<Mutex<Model>>, last_rows: &[Row], board_top: u16) -> bool {
let (w, h) = terminal::size().unwrap_or((80, 24));
let width = w as usize;
let page = (h as usize).saturating_sub(1).max(1); match ev {
Event::Mouse(me) => match me.kind {
MouseEventKind::Down(MouseButton::Left) => {
if me.row >= board_top {
let idx = (me.row - board_top) as usize;
if let Some(Some(p)) = last_rows.get(idx).map(|r| r.proc) {
model.lock().unwrap().toggle(p);
}
}
}
MouseEventKind::ScrollUp => model.lock().unwrap().scroll_by(-3, width, page),
MouseEventKind::ScrollDown => model.lock().unwrap().scroll_by(3, width, page),
_ => {}
},
Event::Key(ke) if ke.kind != KeyEventKind::Release => {
let ctrl = ke.modifiers.contains(KeyModifiers::CONTROL);
match ke.code {
KeyCode::Char('c') if ctrl => {
restore_terminal();
terminate_all();
std::process::exit(130);
}
KeyCode::Up => model.lock().unwrap().scroll_by(-1, width, page),
KeyCode::Down => model.lock().unwrap().scroll_by(1, width, page),
KeyCode::PageUp => model.lock().unwrap().scroll_by(-(page as isize), width, page),
KeyCode::PageDown => model.lock().unwrap().scroll_by(page as isize, width, page),
KeyCode::Home => model.lock().unwrap().scroll_to_top(),
KeyCode::End => model.lock().unwrap().scroll_to_bottom(),
KeyCode::Char(d) if d.is_ascii_digit() || d.is_ascii_alphabetic() => {
if let Some(idx) = super::live::proc_index_from_key(d) {
let mut m = model.lock().unwrap();
if idx < m.procs.len() {
m.toggle(idx);
}
}
}
KeyCode::Char('e') => model.lock().unwrap().set_all_expanded(true),
KeyCode::Char('c') => model.lock().unwrap().set_all_expanded(false),
_ => {}
}
}
_ => {} }
false
}
fn draw(model: &Arc<Mutex<Model>>, frame: u64, anchor: u16, prev_top: u16) -> (Vec<Row>, u16) {
let (w, h) = terminal::size().unwrap_or((80, 24));
let (width, screen_h) = (w as usize, h as usize);
let max_h = screen_h.saturating_sub(1).max(1); let rows = {
let m = model.lock().unwrap();
let height = m.total_rows(width).min(max_h).max(1);
m.view(width, height, frame).0
};
let board_top = (anchor as usize).min(screen_h.saturating_sub(rows.len())) as u16;
let mut out = stderr().lock();
let _ = queue!(out, cursor::MoveTo(0, board_top.min(prev_top)), Clear(ClearType::FromCursorDown));
for (r, row) in rows.iter().enumerate() {
let _ = queue!(out, cursor::MoveTo(0, board_top + r as u16), Print(render_row(row)));
}
let _ = out.flush();
(rows, board_top)
}
fn render_row(row: &Row) -> String {
row.segs.iter().map(|s| sty(s.sty).apply_to(&s.text).to_string()).collect()
}
fn sty(s: Sty) -> Style {
match s {
Sty::Plain => Style::new(),
Sty::Dim => Style::new().dim(),
Sty::Bold => Style::new().bold(),
Sty::Cyan => Style::new().cyan(),
Sty::Green => Style::new().green().bold(),
Sty::Red => Style::new().red().bold(),
}
}
fn summary_line(label: &str, status: Status, elapsed: f64, detail: Option<&str>) -> String {
let (glyph, ok) = match status {
Status::Fail => (style("✗").red().bold(), false),
_ => (style("✓").green().bold(), true),
};
let mut line = format!("{glyph} {} {}", style(label).bold(), style(format_elapsed(elapsed)).dim());
if let Some(d) = detail.filter(|d| !d.is_empty()) {
let d = if ok { style(d).dim() } else { style(d).red() };
line.push_str(&format!(" {d}"));
}
line
}
fn summary_lines(model: &Model) -> Vec<String> {
model.procs.iter().map(|p| summary_line(&p.label, p.status, p.elapsed, p.detail.as_deref())).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn summary_line_is_check_or_cross_with_detail() {
let ok = console::strip_ansi_codes(&summary_line("add", Status::Ok, 5.0, Some("2 + 3 = 5"))).into_owned();
assert_eq!(ok, "✓ add 5s 2 + 3 = 5");
let bad = console::strip_ansi_codes(&summary_line("multiply", Status::Fail, 0.0, Some("X required"))).into_owned();
assert_eq!(bad, "✗ multiply 0.0s X required");
let bare = console::strip_ansi_codes(&summary_line("build", Status::Ok, 4.0, None)).into_owned();
assert_eq!(bare, "✓ build 4s");
}
#[test]
fn summary_lists_every_proc_in_order() {
let mut m = Model::new();
let b = m.add("build");
m.set_status(b, Status::Ok);
m.set_elapsed(b, 4.0);
let s = m.add("add");
m.set_status(s, Status::Fail);
m.set_detail(s, Some("boom".into()));
let lines: Vec<String> = summary_lines(&m).iter().map(|l| console::strip_ansi_codes(l).into_owned()).collect();
assert_eq!(lines, vec!["✓ build 4s".to_string(), "✗ add 0.0s boom".to_string()]);
}
#[cfg(unix)]
#[test]
fn proc_pumps_timestamped_lines_into_the_model() {
let ui = LiveUi::new(false); let p = ui.proc("seq", false);
p.start();
let (ok, last) = p.run("seq", &["3".to_string()]).unwrap();
assert!(ok);
assert_eq!(last.as_deref(), Some("3"));
p.finish_ok(Some("done"));
let m = ui.model.lock().unwrap();
let texts: Vec<&str> = m.procs[0].lines.iter().map(|l| l.text.as_str()).collect();
assert_eq!(texts, ["1", "2", "3"]);
assert!(m.procs[0].lines.iter().all(|l| l.at >= 0.0));
assert_eq!(m.procs[0].status, Status::Ok);
}
#[cfg(unix)]
#[test]
fn proc_run_timed_kills_an_overrunning_child() {
let ui = LiveUi::new(false);
let p = ui.proc("sleep", false);
p.start();
let (ok, timed_out, _) = p.run_timed("sleep", &["5".to_string()], Some(Duration::from_millis(150))).unwrap();
assert!(timed_out && !ok, "the 5s sleep must be killed by the 150ms timeout");
}
}