use std::io::{IsTerminal, Write, stderr};
use std::thread::{JoinHandle, spawn};
use std::time::{Duration, Instant};
use flume::RecvTimeoutError;
use crate::format::truncate;
pub enum DisplayMessage {
Start(usize, usize), Calibrate(usize),
Idle(usize),
Counters(Vec<String>),
}
#[derive(Clone, Copy)]
enum WorkerState {
Idle,
Running(usize),
Calibrating,
}
const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏', ' '];
const FRAME: Duration = Duration::from_millis(500);
const MIN_REDRAW: Duration = Duration::from_millis(50);
fn apply(msg: DisplayMessage, worker_state: &mut [WorkerState], counter_msgs: &mut Vec<String>) {
match msg {
DisplayMessage::Start(w, idx) => worker_state[w] = WorkerState::Running(idx),
DisplayMessage::Calibrate(w) => worker_state[w] = WorkerState::Calibrating,
DisplayMessage::Idle(w) => worker_state[w] = WorkerState::Idle,
DisplayMessage::Counters(lines) => *counter_msgs = lines,
}
}
fn term_size() -> (usize, usize) {
match rustix::termios::tcgetwinsize(stderr()) {
Ok(ws) if ws.ws_col > 0 && ws.ws_row > 0 => (ws.ws_row as usize, ws.ws_col as usize),
_ => (24, 80),
}
}
pub fn term_width() -> usize {
term_size().1
}
fn fit(line: &str, cols: usize) -> String {
let budget = cols.saturating_sub(1);
let mut out = String::with_capacity(line.len());
let mut visible = 0;
let mut in_escape = false;
let mut had_escape = false;
for c in line.chars() {
if in_escape {
out.push(c);
in_escape = c != 'm';
} else if c == '\x1b' {
in_escape = true;
had_escape = true;
out.push(c);
} else if visible < budget {
out.push(c);
visible += 1;
} else {
break;
}
}
if had_escape {
out.push_str("\x1b[0m");
}
out
}
pub fn spawn_display(
jobs: usize,
command_labels: Vec<String>,
rx: flume::Receiver<DisplayMessage>,
disabled: bool,
) -> JoinHandle<()> {
spawn(move || {
if disabled || !stderr().is_terminal() {
while rx.recv().is_ok() {}
return;
}
let mut worker_state = vec![WorkerState::Idle; jobs];
let mut counter_msgs: Vec<String> = command_labels
.iter()
.map(|label| format!("{label}: pending"))
.collect();
let n_lines = worker_state.len() + counter_msgs.len();
let mut frame = 0usize;
let mut last_anim = Instant::now();
let draw = |frame: usize, worker_state: &[WorkerState], counter_msgs: &[String]| {
let (rows, cols) = term_size();
let glyph = SPINNER[frame % SPINNER.len()];
let mut lines: Vec<String> = Vec::with_capacity(n_lines);
for (w, state) in worker_state.iter().enumerate() {
let prefix = format!(" {glyph} worker {w}: ");
let line = match state {
WorkerState::Running(idx) => {
let budget = cols.saturating_sub(1 + prefix.chars().count());
format!("{prefix}{}", truncate(&command_labels[*idx], budget))
}
WorkerState::Calibrating => format!("{prefix}<calibration>"),
WorkerState::Idle => format!("{prefix}idle"),
};
lines.push(fit(&line, cols));
}
for msg in counter_msgs {
lines.push(fit(&format!(" {msg}"), cols));
}
let visible = lines.len().min(rows.saturating_sub(1)).max(1);
let mut buf = String::from("\x1b[?25l\r\x1b[J");
for line in &lines[lines.len() - visible..] {
buf.push_str(line);
buf.push('\n');
}
buf.push_str(&format!("\x1b[{visible}A"));
let mut out = stderr().lock();
let _ = out.write_all(buf.as_bytes());
let _ = out.flush();
};
let mut last_draw = Instant::now();
let mut dirty = false;
draw(frame, &worker_state, &counter_msgs);
loop {
let timeout = if dirty {
MIN_REDRAW.saturating_sub(last_draw.elapsed())
} else {
FRAME
};
match rx.recv_timeout(timeout) {
Ok(msg) => {
apply(msg, &mut worker_state, &mut counter_msgs);
while let Ok(msg) = rx.try_recv() {
apply(msg, &mut worker_state, &mut counter_msgs);
}
dirty = true;
}
Err(RecvTimeoutError::Disconnected) => {
let mut out = stderr().lock();
let _ = out.write_all(b"\r\x1b[J\x1b[?25h");
let _ = out.flush();
break;
}
Err(RecvTimeoutError::Timeout) => {}
}
if last_anim.elapsed() >= FRAME {
frame = frame.wrapping_add(1);
last_anim = Instant::now();
}
if last_draw.elapsed() >= MIN_REDRAW {
last_draw = Instant::now();
dirty = false;
draw(frame, &worker_state, &counter_msgs);
}
}
})
}