use std::io::{self, Write};
use std::time::{Duration, Instant};
use crossterm::{cursor, queue, terminal};
use unicode_width::UnicodeWidthChar;
use crate::context::Context;
use crate::output::Palette;
const BRAILLE: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const PLAIN: [&str; 4] = ["|", "/", "-", "\\"];
const REDRAW_EVERY: Duration = Duration::from_millis(80);
const HOLD_BEFORE_FIRST_PAINT: Duration = Duration::from_millis(150);
const FALLBACK_COLUMNS: usize = 80;
#[derive(Debug)]
pub(crate) struct Progress {
line: Option<Line>,
}
#[derive(Debug)]
struct Line {
label: &'static str,
total: Option<usize>,
done: usize,
note: String,
frame: usize,
started: Instant,
last_paint: Option<Instant>,
painted: bool,
palette: Palette,
frames: &'static [&'static str],
columns: usize,
}
impl Progress {
pub(crate) const fn hidden() -> Self {
Self { line: None }
}
pub(crate) fn start(label: &'static str, total: Option<usize>, context: &Context) -> Self {
if !shows(context) {
return Self::hidden();
}
Self {
line: Some(Line {
label,
total,
done: 0,
note: String::new(),
frame: 0,
started: Instant::now(),
last_paint: None,
painted: false,
palette: Palette::new(context.color.stderr),
frames: if context.wide_glyphs {
&BRAILLE
} else {
&PLAIN
},
columns: match context.terminal.width {
Some(width) if width > 0 => usize::from(width),
_ => FALLBACK_COLUMNS,
},
}),
}
}
pub(crate) fn tick(&mut self, done: usize, note: &str) {
let Some(line) = self.line.as_mut() else {
return;
};
line.done = done;
if line.note != note {
line.note.clear();
line.note.push_str(note);
}
let now = Instant::now();
if now.duration_since(line.started) < HOLD_BEFORE_FIRST_PAINT {
return;
}
if line
.last_paint
.is_some_and(|last| now.duration_since(last) < REDRAW_EVERY)
{
return;
}
line.last_paint = Some(now);
line.frame = line.frame.wrapping_add(1);
let _ = line.paint();
}
pub(crate) fn suspend<T>(&mut self, write: impl FnOnce() -> T) -> T {
if let Some(line) = self.line.as_mut() {
let _ = line.erase();
}
let outcome = write();
if let Some(line) = self.line.as_mut()
&& line.painted
{
line.last_paint = Some(Instant::now());
let _ = line.paint();
}
outcome
}
pub(crate) fn finish(&mut self) {
if let Some(mut line) = self.line.take() {
let _ = line.erase();
}
}
}
impl Drop for Progress {
fn drop(&mut self) {
self.finish();
}
}
impl Line {
fn compose(&self, seconds: u64) -> String {
let spinner = self
.frames
.get(self.frame % self.frames.len())
.copied()
.unwrap_or("");
let mut text = format!("{spinner} {}", self.label);
if let Some(total) = self.total {
text.push_str(&format!(" {} of {total}", self.done));
}
if !self.note.is_empty() {
text.push_str(&format!(" {}", self.note));
}
if seconds >= 1 {
text.push_str(&format!(" {seconds}s"));
}
text
}
fn paint(&mut self) -> io::Result<()> {
let text = self.compose(self.started.elapsed().as_secs());
let body = fit(&text, self.columns.saturating_sub(1));
let styled = self.palette.dim(&body);
let mut stderr = io::stderr();
queue!(
stderr,
cursor::MoveToColumn(0),
terminal::Clear(terminal::ClearType::CurrentLine)
)?;
write!(stderr, "{styled}")?;
stderr.flush()?;
self.painted = true;
Ok(())
}
fn erase(&mut self) -> io::Result<()> {
if !self.painted {
return Ok(());
}
let mut stderr = io::stderr();
queue!(
stderr,
cursor::MoveToColumn(0),
terminal::Clear(terminal::ClearType::CurrentLine)
)?;
stderr.flush()?;
self.painted = false;
Ok(())
}
}
fn shows(context: &Context) -> bool {
context.terminal.stderr_is_tty
&& !context.terminal.is_ci
&& !context.quiet_progress
&& context.decorate
}
fn fit(text: &str, columns: usize) -> String {
if columns == 0 {
return String::new();
}
let mut used = 0;
let mut kept = String::new();
for glyph in text.chars() {
let width = glyph.width().unwrap_or(0);
if used + width > columns {
return kept;
}
used += width;
kept.push(glyph);
}
kept
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::ColorArg;
use crate::context::{ColorPolicy, TerminalInfo};
fn context(stderr_is_tty: bool, is_ci: bool, quiet_progress: bool) -> Context {
Context {
color: ColorPolicy::resolve(ColorArg::Never),
terminal: TerminalInfo {
stdin_is_tty: true,
stdout_is_tty: true,
stderr_is_tty,
width: Some(80),
height: Some(24),
is_ci,
},
quiet_progress,
decorate: true,
wide_glyphs: true,
}
}
#[test]
fn a_redirected_stderr_never_receives_a_progress_line() {
assert!(
!shows(&context(false, false, false)),
"a progress line painted into a file is corruption, not progress"
);
}
#[test]
fn continuous_integration_gets_no_spinner() {
assert!(
!shows(&context(true, true, false)),
"a build log must not collect thousands of redraw frames"
);
}
#[test]
fn a_machine_readable_or_unattended_run_stays_silent() {
assert!(!shows(&context(true, false, true)));
}
#[test]
fn a_person_at_a_terminal_sees_the_line() {
assert!(shows(&context(true, false, false)));
}
#[test]
fn a_hidden_progress_paints_nothing_and_tolerates_every_call() {
let mut progress = Progress::hidden();
progress.tick(5, "");
let carried = progress.suspend(|| 42);
progress.finish();
assert_eq!(carried, 42, "suspend passes the closure's value back");
}
#[test]
fn nothing_is_painted_before_the_wait_is_real() {
let mut progress = Progress {
line: Some(Line {
label: "checking",
total: Some(10),
done: 0,
note: String::new(),
frame: 0,
started: Instant::now(),
last_paint: None,
painted: false,
palette: Palette::new(false),
frames: &PLAIN,
columns: 80,
}),
};
progress.tick(1, "");
let painted = progress.line.as_ref().is_some_and(|line| line.painted);
assert!(!painted, "a run that finishes in a blink must not flash");
}
fn line(note: &str, total: Option<usize>, done: usize) -> Line {
Line {
label: "checking",
total,
done,
note: note.to_owned(),
frame: 1,
started: Instant::now(),
last_paint: None,
painted: false,
palette: Palette::new(false),
frames: &PLAIN,
columns: 80,
}
}
#[test]
fn the_line_names_what_the_run_is_working_on() {
let shown = line("example.io", Some(40), 12).compose(3);
assert!(
shown.contains("12 of 40"),
"the count is still there: {shown}"
);
assert!(
shown.contains("example.io"),
"a count alone never shows the run is moving through real work: {shown}"
);
assert!(shown.contains("3s"), "the elapsed time survives: {shown}");
}
#[test]
fn a_stage_with_nothing_to_name_leaves_no_gap_behind() {
let shown = line("", Some(40), 12).compose(0);
assert!(!shown.contains(" "), "no empty slot is left: {shown:?}");
assert!(!shown.ends_with(' '), "and no trailing space: {shown:?}");
}
#[test]
fn a_stage_with_no_total_still_names_its_work() {
let shown = line("example.io", None, 0).compose(0);
assert!(!shown.contains(" of "), "nothing to count: {shown}");
assert!(shown.contains("example.io"), "but still named: {shown}");
}
#[test]
fn a_stage_with_nothing_to_name_still_paints_a_clean_line() {
let mut progress = Progress::hidden();
progress.tick(3, "");
progress.tick(4, "example.com");
progress.finish();
}
#[test]
fn the_line_is_cut_to_the_terminal_width() {
assert_eq!(fit("checking 1 of 2", 8), "checking");
assert_eq!(fit("checking", 0), "");
assert_eq!(fit("short", 40), "short");
}
#[test]
fn a_wide_glyph_is_never_cut_in_half() {
let text = "⠋ 世界";
let kept = fit(text, 4);
assert!(
kept.chars().all(|glyph| text.contains(glyph)),
"truncation keeps whole characters"
);
let width: usize = kept.chars().map(|g| g.width().unwrap_or(0)).sum();
assert!(
width <= 4,
"a wide glyph that would overflow is dropped whole"
);
}
}