use crate::cli::style;
use afterburner_wasi::bundle::BundleProgress;
use kovan_channel::flavors::unbounded::{Receiver, Sender, channel};
use std::io::Write;
use std::sync::OnceLock;
use std::thread::JoinHandle;
use std::time::Duration;
enum Ev {
Begin { label: String, total: Option<u64> },
Bytes(u64),
Assembling(String),
Finish,
}
struct CliBundleProgress {
tx: Option<Sender<Ev>>,
}
pub fn install() {
static RENDERER: OnceLock<Option<JoinHandle<()>>> = OnceLock::new();
if !style::animations_enabled() {
return;
}
RENDERER.get_or_init(|| {
let (tx, rx) = channel();
let handle = std::thread::Builder::new()
.name("burn-bundle-progress".to_string())
.spawn(move || Renderer { rx }.run())
.ok();
afterburner_wasi::bundle::set_progress_reporter(Box::new(CliBundleProgress {
tx: Some(tx),
}));
handle
});
}
impl CliBundleProgress {
fn emit(&self, ev: Ev) {
if let Some(tx) = &self.tx {
tx.send(ev);
}
}
}
impl BundleProgress for CliBundleProgress {
fn begin(&self, label: &str, total: Option<u64>) {
self.emit(Ev::Begin {
label: label.to_string(),
total,
});
}
fn bytes(&self, downloaded: u64) {
self.emit(Ev::Bytes(downloaded));
}
fn assembling(&self, label: &str) {
self.emit(Ev::Assembling(label.to_string()));
}
fn finish(&self) {
self.emit(Ev::Finish);
}
}
struct Renderer {
rx: Receiver<Ev>,
}
#[derive(Default)]
struct LineState {
label: String,
total: Option<u64>,
downloaded: u64,
assembling: bool,
active: bool,
}
impl Renderer {
fn run(self) {
use crossterm::{cursor, execute, terminal};
let mut err = std::io::stderr();
let mut state = LineState::default();
let mut frame = 0usize;
let mut hidden_cursor = false;
loop {
let mut closed = false;
loop {
match self.rx.try_recv() {
Some(Ev::Begin { label, total }) => {
state = LineState {
label,
total,
downloaded: 0,
assembling: false,
active: true,
};
if !hidden_cursor {
let _ = execute!(err, cursor::Hide);
hidden_cursor = true;
}
}
Some(Ev::Bytes(n)) => state.downloaded = n,
Some(Ev::Assembling(label)) => {
state.label = label;
state.assembling = true;
}
Some(Ev::Finish) => {
let _ = execute!(
err,
cursor::MoveToColumn(0),
terminal::Clear(terminal::ClearType::CurrentLine)
);
let _ = err.flush();
state.active = false;
}
None => {
break;
}
}
}
if self.rx.is_disconnected() {
closed = true;
}
if state.active {
paint(&mut err, &state, frame);
frame = frame.wrapping_add(1);
}
if closed {
break;
}
std::thread::sleep(Duration::from_millis(80));
}
if hidden_cursor {
let _ = execute!(err, cursor::Show);
let _ = err.flush();
}
}
}
fn paint(err: &mut impl Write, state: &LineState, frame: usize) {
use crossterm::{cursor, execute, terminal};
let cols = match terminal::size() {
Ok((c, _)) if c > 0 => c as usize,
_ => 80,
};
let bar_w = 24usize;
let lead = if state.assembling {
style::spinner_frame(frame)
} else {
let ratio = match state.total {
Some(t) if t > 0 => state.downloaded as f32 / t as f32,
_ => ((frame % 40) as f32 / 40.0).min(0.95),
};
style::flame_bar(ratio, bar_w, -(frame as f32) * 0.06)
};
let detail = if state.assembling {
String::new()
} else {
match state.total {
Some(t) if t > 0 => format!(" {}", style::muted(&human_pair(state.downloaded, t))),
_ => format!(" {}", style::muted(&human_bytes(state.downloaded))),
}
};
let fixed = 1 + 1 + bar_w + 2 + detail.len() + 1;
let label = truncate(&state.label, cols.saturating_sub(fixed));
let line = format!("{lead} {}{detail}", style::value(&label));
let _ = execute!(
err,
cursor::MoveToColumn(0),
terminal::Clear(terminal::ClearType::CurrentLine)
);
let _ = write!(err, "{line}");
let _ = err.flush();
}
fn human_pair(downloaded: u64, total: u64) -> String {
let (dv, du) = scale(downloaded);
let (tv, tu) = scale(total);
if du == tu {
format!("{dv:.1} / {tv:.1} {tu}")
} else {
format!("{dv:.1} {du} / {tv:.1} {tu}")
}
}
fn human_bytes(n: u64) -> String {
let (v, u) = scale(n);
format!("{v:.1} {u}")
}
fn scale(n: u64) -> (f64, &'static str) {
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
let mut v = n as f64;
let mut i = 0;
while v >= 1024.0 && i + 1 < UNITS.len() {
v /= 1024.0;
i += 1;
}
(v, UNITS[i])
}
fn truncate(s: &str, max: usize) -> String {
if max == 0 {
return String::new();
}
let n = s.chars().count();
if n <= max {
return s.to_string();
}
if max == 1 {
return "…".to_string();
}
let kept: String = s.chars().take(max - 1).collect();
format!("{kept}…")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scale_picks_binary_units() {
assert_eq!(scale(512), (512.0, "B"));
assert_eq!(scale(2048).1, "KiB");
assert_eq!(scale(5 * 1024 * 1024).1, "MiB");
assert_eq!(scale(3 * 1024 * 1024 * 1024).1, "GiB");
}
#[test]
fn human_pair_collapses_matching_units() {
let s = human_pair(12 * 1024 * 1024, 31 * 1024 * 1024);
assert_eq!(s, "12.0 / 31.0 MiB");
}
#[test]
fn truncate_bounds_width_and_adds_ellipsis() {
assert_eq!(truncate("Fetching Python runtime", 8), "Fetchin…");
assert_eq!(truncate("short", 20), "short");
assert_eq!(truncate("x", 0), "");
}
}