use std::{
io::{self, Write},
time::Duration,
};
use prettier_bytes::ByteFormatter;
use crate::{ProgressSnapshot, ProgressStackSnapshot, ProgressType};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Theme {
pub bar_filled: char,
pub bar_empty: char,
pub spinner_frames: &'static [char],
}
impl Default for Theme {
fn default() -> Self {
Self::modern()
}
}
impl Theme {
#[must_use]
pub const fn ascii() -> Self {
Self {
bar_filled: '#',
bar_empty: '-',
spinner_frames: &['|', '/', '-', '\\'],
}
}
#[must_use]
pub const fn modern() -> Self {
Self {
bar_filled: '█',
bar_empty: '░',
spinner_frames: &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
}
}
}
struct FormattedMetrics {
elapsed: String,
pos: String,
total: String,
rate: String,
eta: String,
}
pub struct TerminalFrontend<W> {
writer: W,
last_lines: usize,
width: usize,
theme: Theme,
spinner_tick: usize,
byte_formatter: Option<ByteFormatter>,
}
impl<W: Write> TerminalFrontend<W> {
pub const fn new(writer: W) -> Self {
Self {
writer,
last_lines: 0,
width: 40,
theme: Theme {
bar_filled: '█',
bar_empty: '░',
spinner_frames: &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
},
spinner_tick: 0,
byte_formatter: None,
}
}
#[must_use]
pub const fn with_theme(mut self, theme: Theme) -> Self {
self.theme = theme;
self
}
#[must_use]
pub const fn with_width(mut self, width: usize) -> Self {
self.width = width;
self
}
#[must_use]
pub const fn with_byte_formatting(mut self, formatter: ByteFormatter) -> Self {
self.byte_formatter = Some(formatter);
self
}
fn move_cursor_up(&mut self, n: usize) -> io::Result<()> {
if n > 0 {
write!(self.writer, "\x1b[{n}A")?;
}
Ok(())
}
fn format_line(&self, snapshot: &ProgressSnapshot) -> String {
let metrics = self.format_metrics(snapshot);
match snapshot.kind {
ProgressType::Bar => self.format_bar(snapshot, &metrics),
ProgressType::Spinner => self.format_spinner(snapshot, &metrics),
}
}
fn format_metrics(&self, snapshot: &ProgressSnapshot) -> FormattedMetrics {
let elapsed = snapshot
.elapsed
.map_or_else(|| "--:--".to_string(), format_duration);
let (pos, total) = self.byte_formatter.as_ref().map_or_else(
|| (snapshot.position.to_string(), snapshot.total.to_string()),
|bf| {
(
bf.format(snapshot.position).to_string(),
bf.format(snapshot.total).to_string(),
)
},
);
let rate_val = snapshot.throughput();
let rate = if rate_val > 0.0 {
self.byte_formatter.as_ref().map_or_else(
|| format!("{rate_val:.1}/s"),
|bf| format!("{}/s", bf.format(rate_val as u64)),
)
} else if self.byte_formatter.is_some() {
"--.- B/s".to_string()
} else {
"--.-/s".to_string()
};
let eta = snapshot.eta().map_or_else(String::new, |eta_val| {
format!(" | ETA {}", format_duration(eta_val))
});
FormattedMetrics {
elapsed,
pos,
total,
rate,
eta,
}
}
fn format_bar(&self, snapshot: &ProgressSnapshot, metrics: &FormattedMetrics) -> String {
use std::fmt::Write as _;
#[allow(clippy::cast_precision_loss)]
let percent = if snapshot.total == 0 {
0.0
} else {
(snapshot.position as f64 / snapshot.total as f64) * 100.0
};
let percent = percent.clamp(0.0, 100.0);
#[allow(clippy::cast_precision_loss)]
let filled_float = (percent / 100.0) * (self.width as f64);
let filled = if filled_float.is_nan() || filled_float.is_infinite() || filled_float < 0.0 {
0
} else {
filled_float as usize
}
.min(self.width);
let empty = self.width.saturating_sub(filled);
let filled_str = self.theme.bar_filled.to_string().repeat(filled);
let empty_str = self.theme.bar_empty.to_string().repeat(empty);
let status = if snapshot.finished {
if snapshot.error.is_some() {
"✖"
} else {
"✔"
}
} else {
""
};
let mut info = String::new();
if !snapshot.name.is_empty() {
info.push_str(&snapshot.name);
}
if !snapshot.item.is_empty() {
if !info.is_empty() {
info.push(' ');
}
let _ = write!(info, "[{}]", snapshot.item);
}
if let Some(err) = &snapshot.error {
if !info.is_empty() {
info.push(' ');
}
let _ = write!(info, "ERROR: {err}");
}
format!(
"{status}{}[{filled_str}{empty_str}] {percent:>5.1}% ({}/{}) | {}{} | {} | {info}",
if status.is_empty() { "" } else { " " },
metrics.pos,
metrics.total,
metrics.elapsed,
metrics.eta,
metrics.rate,
)
}
fn format_spinner(&self, snapshot: &ProgressSnapshot, metrics: &FormattedMetrics) -> String {
use std::fmt::Write as _;
let frame = if snapshot.finished {
if snapshot.error.is_some() {
'✖'
} else {
'✔'
}
} else if self.theme.spinner_frames.is_empty() {
' '
} else {
self.theme.spinner_frames[self.spinner_tick % self.theme.spinner_frames.len()]
};
let name_prefix = if snapshot.name.is_empty() {
String::new()
} else {
format!("{} ", snapshot.name)
};
let mut info = String::new();
if !snapshot.item.is_empty() {
let _ = write!(info, " [{}]", snapshot.item);
}
if let Some(err) = &snapshot.error {
let _ = write!(info, " ERROR: {err}");
}
let items_label = if self.byte_formatter.is_some() {
""
} else {
" items"
};
format!(
"{frame} {name_prefix}{}{items_label} | {} | {}{info}",
metrics.pos, metrics.elapsed, metrics.rate
)
}
}
impl<W: Write> super::Frontend for TerminalFrontend<W> {
fn render(&mut self, snapshot: &ProgressSnapshot) -> io::Result<()> {
self.move_cursor_up(self.last_lines)?;
let line = self.format_line(snapshot);
writeln!(self.writer, "\x1b[2K\r{line}")?;
self.spinner_tick = self.spinner_tick.wrapping_add(1);
self.last_lines = 1;
self.writer.flush()?;
Ok(())
}
fn render_stack(&mut self, stack: &ProgressStackSnapshot) -> io::Result<()> {
self.move_cursor_up(self.last_lines)?;
for snapshot in &stack.0 {
let line = self.format_line(snapshot);
writeln!(self.writer, "\x1b[2K\r{line}")?;
}
if self.last_lines > stack.0.len() {
let diff = self.last_lines - stack.0.len();
for _ in 0..diff {
writeln!(self.writer, "\x1b[2K\r")?;
}
self.move_cursor_up(diff)?;
}
self.spinner_tick = self.spinner_tick.wrapping_add(1);
self.last_lines = stack.0.len();
self.writer.flush()?;
Ok(())
}
fn clear(&mut self) -> io::Result<()> {
self.move_cursor_up(self.last_lines)?;
for _ in 0..self.last_lines {
writeln!(self.writer, "\x1b[2K\r")?;
}
self.move_cursor_up(self.last_lines)?;
self.last_lines = 0;
self.writer.flush()?;
Ok(())
}
fn finish(&mut self) -> io::Result<()> {
self.last_lines = 0;
self.writer.flush()?;
Ok(())
}
}
fn format_duration(d: Duration) -> String {
let secs = d.as_secs();
if secs >= 3600 {
format!(
"{:02}:{:02}:{:02}",
secs / 3600,
(secs % 3600) / 60,
secs % 60
)
} else {
format!("{:02}:{:02}", secs / 60, secs % 60)
}
}
#[cfg(test)]
mod tests {
use std::thread;
use compact_str::CompactString;
use super::*;
use crate::{ProgressBuilder, ProgressStack, ProgressType, frontends::Frontend};
#[test]
fn test_format_duration() {
assert_eq!(format_duration(Duration::from_secs(45)), "00:45");
assert_eq!(format_duration(Duration::from_secs(125)), "02:05");
assert_eq!(format_duration(Duration::from_secs(3665)), "01:01:05");
}
#[test]
fn test_terminal_frontend_rendering() {
let mut buf = Vec::new();
{
let mut frontend = TerminalFrontend::new(&mut buf).with_theme(Theme::ascii());
let snap = ProgressSnapshot {
name: CompactString::new("Test"),
kind: ProgressType::Bar,
position: 50,
total: 100,
..Default::default()
};
frontend.render(&snap).unwrap();
}
let out = String::from_utf8(buf).unwrap();
assert!(out.contains("[####################--------------------]"));
assert!(out.contains("50.0%"));
assert!(out.contains("\x1b[2K\r")); }
#[test]
#[ignore = "Visual test that writes to stderr and sleeps"]
fn test_real_terminal_output() {
let stack = ProgressStack::new();
let bar = ProgressBuilder::new_bar("Downloading", 100u64)
.with_start_time_now()
.build();
stack.push(bar.clone());
let spinner = ProgressBuilder::new_spinner("Processing")
.with_start_time_now()
.build();
stack.push(spinner.clone());
let worker = thread::spawn(move || {
for i in 0..=100 {
bar.set_pos(i);
bar.set_item(format!("chunk_{i}.bin"));
spinner.bump();
spinner.set_item(format!("tasks: {i}"));
thread::sleep(Duration::from_millis(30));
}
bar.finish_with_item("Complete!");
spinner.finish_with_item("Done!");
});
let mut frontend = TerminalFrontend::new(std::io::stderr());
while !stack.is_all_finished() {
let snapshot = stack.snapshot();
frontend.render_stack(&snapshot).unwrap();
thread::sleep(Duration::from_millis(33));
}
let final_snapshot = stack.snapshot();
frontend.render_stack(&final_snapshot).unwrap();
frontend.finish().unwrap();
worker.join().unwrap();
}
}