use std::io::{IsTerminal, Write};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
const SHOW_DELAY: Duration = Duration::from_millis(200);
const TICK: Duration = Duration::from_millis(80);
const UNICODE_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const ASCII_FRAMES: &[&str] = &["|", "/", "-", "\\"];
const CLEAR: &str = "\r\x1b[2K";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Setting {
Auto,
Always,
Never,
}
pub fn parse_setting(raw: Option<&str>) -> Setting {
match raw.map(|v| v.trim().to_ascii_lowercase()).as_deref() {
Some("always") => Setting::Always,
Some("never") => Setting::Never,
_ => Setting::Auto,
}
}
fn is_ci(raw: Option<&str>) -> bool {
raw.map(str::trim)
.is_some_and(|v| !v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false"))
}
#[derive(Debug, Clone, Copy)]
pub struct Inputs {
pub setting: Setting,
pub no_progress: bool,
pub json: bool,
pub stderr_tty: bool,
pub term_dumb: bool,
pub ci: bool,
}
pub fn decide(i: &Inputs) -> Option<Duration> {
if i.json || i.no_progress {
return None;
}
match i.setting {
Setting::Never => None,
Setting::Always => Some(Duration::ZERO),
Setting::Auto => (i.stderr_tty && !i.term_dumb && !i.ci).then_some(SHOW_DELAY),
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct Config {
pub delay: Duration,
pub unicode: bool,
pub color: bool,
pub width: Option<usize>,
}
struct Inner {
steps: Vec<(u64, String)>,
next_id: u64,
since: Option<Instant>,
drawn: bool,
held: usize,
frame: usize,
out: Box<dyn Write + Send>,
}
pub(crate) struct Progress {
cfg: Config,
hidden: AtomicBool,
on_screen: AtomicBool,
stopped: AtomicBool,
inner: Mutex<Inner>,
}
impl Progress {
pub(crate) fn new(cfg: Config, out: Box<dyn Write + Send>) -> Self {
Self {
cfg,
hidden: AtomicBool::new(false),
on_screen: AtomicBool::new(false),
stopped: AtomicBool::new(false),
inner: Mutex::new(Inner {
steps: Vec::new(),
next_id: 0,
since: None,
drawn: false,
held: 0,
frame: 0,
out,
}),
}
}
fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
self.inner.lock().unwrap_or_else(|e| e.into_inner())
}
pub(crate) fn step(&self, label: &str) -> Step<'_> {
let label = crate::sanitize::strip_ansi(label);
let mut g = self.lock();
let id = g.next_id;
g.next_id += 1;
g.steps.push((id, label));
if g.since.is_none() {
g.since = Some(Instant::now());
}
self.draw_if_ready(&mut g);
Step {
owner: Some(self),
id,
}
}
fn busy(&self) -> bool {
!self.lock().steps.is_empty()
}
fn relabel(&self, id: u64, label: &str) {
let label = crate::sanitize::strip_ansi(label);
let mut g = self.lock();
if let Some(slot) = g.steps.iter_mut().find(|(i, _)| *i == id) {
slot.1 = label;
}
if g.drawn || self.cfg.delay.is_zero() {
self.draw_if_ready(&mut g);
}
}
fn end(&self, id: u64) {
let mut g = self.lock();
g.steps.retain(|(i, _)| *i != id);
if g.steps.is_empty() {
g.since = None;
self.clear(&mut g);
} else if g.drawn {
self.draw_if_ready(&mut g);
}
}
pub(crate) fn suspend<R>(&self, f: impl FnOnce() -> R) -> R {
struct Resume<'a>(&'a Progress);
impl Drop for Resume<'_> {
fn drop(&mut self) {
let mut g = self.0.lock();
g.held -= 1;
if self.0.cfg.delay.is_zero() {
self.0.draw_if_ready(&mut g);
}
}
}
{
let mut g = self.lock();
g.held += 1;
self.clear(&mut g);
}
let _resume = Resume(self);
f()
}
pub(crate) fn set_hidden(&self, hidden: bool) {
self.hidden.store(hidden, Ordering::Relaxed);
if hidden {
self.clear(&mut self.lock());
}
}
fn is_hidden(&self) -> bool {
self.hidden.load(Ordering::Relaxed)
}
fn stop(&self) -> bool {
self.stopped.store(true, Ordering::SeqCst);
self.on_screen.swap(false, Ordering::SeqCst)
}
fn may_draw(&self) -> bool {
!self.is_hidden() && self.lock().held == 0
}
pub(crate) fn tick(&self) {
let mut g = self.lock();
g.frame = g.frame.wrapping_add(1);
self.draw_if_ready(&mut g);
}
fn draw_if_ready(&self, g: &mut Inner) {
let Some((_, label)) = g.steps.last() else {
return;
};
let due = g.since.is_some_and(|t| t.elapsed() >= self.cfg.delay);
if !due || g.held > 0 || self.is_hidden() || self.stopped.load(Ordering::SeqCst) {
return;
}
let line = format!("{CLEAR}{}", self.compose(label, g.frame));
let _ = g.out.write_all(line.as_bytes());
let _ = g.out.flush();
g.drawn = true;
self.on_screen.store(true, Ordering::SeqCst);
}
fn clear(&self, g: &mut Inner) {
if g.drawn {
let _ = write!(g.out, "{CLEAR}");
let _ = g.out.flush();
g.drawn = false;
self.on_screen.store(false, Ordering::SeqCst);
}
}
fn compose(&self, label: &str, frame: usize) -> String {
let frames = if self.cfg.unicode {
UNICODE_FRAMES
} else {
ASCII_FRAMES
};
let glyph = frames[frame % frames.len()];
let width = self.cfg.width.unwrap_or_else(stderr_width);
let label = truncate_to_width(label, width.saturating_sub(3));
if self.cfg.color {
format!("\x1b[36m{glyph}\x1b[0m {label}")
} else {
format!("{glyph} {label}")
}
}
}
pub struct Step<'a> {
owner: Option<&'a Progress>,
id: u64,
}
impl Step<'_> {
fn none() -> Step<'static> {
Step { owner: None, id: 0 }
}
pub fn set(&self, label: impl AsRef<str>) {
if let Some(p) = self.owner {
p.relabel(self.id, label.as_ref());
}
}
}
impl Drop for Step<'_> {
fn drop(&mut self) {
if let Some(p) = self.owner {
p.end(self.id);
}
}
}
fn truncate_to_width(s: &str, max: usize) -> String {
use unicode_width::UnicodeWidthChar;
if crate::render::visible_width(s) <= max {
return s.to_string();
}
let room = max.saturating_sub(3);
let mut out = String::new();
let mut used = 0;
for c in s.chars() {
let w = c.width().unwrap_or(0);
if used + w > room {
break;
}
out.push(c);
used += w;
}
out.push_str(&"..."[..max.min(3)]);
out
}
fn stderr_width() -> usize {
std::env::var("COLUMNS")
.ok()
.and_then(|v| v.trim().parse::<usize>().ok())
.or_else(|| columns_of(std::io::stderr()))
.unwrap_or(80)
.max(20)
}
#[cfg(unix)]
fn columns_of(fd: impl std::os::fd::AsFd) -> Option<usize> {
use std::os::fd::AsRawFd as _;
let mut ws: libc::winsize = unsafe { std::mem::zeroed() };
let rc = unsafe { libc::ioctl(fd.as_fd().as_raw_fd(), libc::TIOCGWINSZ, &mut ws) };
(rc == 0 && ws.ws_col > 0).then(|| usize::from(ws.ws_col))
}
#[cfg(not(unix))]
fn columns_of(_: std::io::Stderr) -> Option<usize> {
crossterm::terminal::size()
.ok()
.map(|(c, _)| usize::from(c))
}
static GLOBAL: OnceLock<Progress> = OnceLock::new();
pub fn init(json: bool, no_progress: bool, ascii: bool) {
let stderr_tty = std::io::stderr().is_terminal();
let inputs = Inputs {
setting: parse_setting(std::env::var("MIND_PROGRESS").ok().as_deref()),
no_progress,
json,
stderr_tty,
term_dumb: std::env::var("TERM").is_ok_and(|t| t == "dumb"),
ci: is_ci(std::env::var("CI").ok().as_deref()),
};
let Some(delay) = decide(&inputs) else {
return;
};
let cfg = Config {
delay,
unicode: !ascii && crate::render::detect_utf8_locale(),
color: !ascii && stderr_tty && std::env::var_os("NO_COLOR").is_none(),
width: (!stderr_tty).then(stderr_width),
};
if GLOBAL
.set(Progress::new(cfg, Box::new(std::io::stderr())))
.is_ok()
{
clear_on_interrupt();
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
if GLOBAL.get().is_some_and(Progress::stop) {
eprint!("{CLEAR}");
}
prev(info);
}));
std::thread::spawn(|| {
loop {
std::thread::sleep(TICK);
if let Some(p) = GLOBAL.get() {
p.tick();
}
}
});
}
}
#[cfg(unix)]
fn clear_on_interrupt() {
extern "C" fn on_signal(sig: libc::c_int) {
if GLOBAL.get().is_some_and(Progress::stop) {
unsafe {
libc::write(libc::STDERR_FILENO, CLEAR.as_ptr().cast(), CLEAR.len());
}
}
unsafe {
libc::signal(sig, libc::SIG_DFL);
libc::raise(sig);
}
}
for sig in [libc::SIGINT, libc::SIGTERM] {
unsafe {
let mut old: libc::sigaction = std::mem::zeroed();
if libc::sigaction(sig, std::ptr::null(), &mut old) != 0
|| old.sa_sigaction == libc::SIG_IGN
{
continue;
}
let mut new: libc::sigaction = std::mem::zeroed();
new.sa_sigaction = on_signal as extern "C" fn(libc::c_int) as libc::sighandler_t;
libc::sigemptyset(&mut new.sa_mask);
new.sa_flags = libc::SA_RESTART;
libc::sigaction(sig, &new, std::ptr::null_mut());
}
}
}
#[cfg(not(unix))]
fn clear_on_interrupt() {}
pub fn enabled() -> bool {
GLOBAL.get().is_some_and(Progress::may_draw)
}
pub fn step(label: impl AsRef<str>) -> Step<'static> {
match GLOBAL.get() {
Some(p) => p.step(label.as_ref()),
None => Step::none(),
}
}
pub fn step_if_idle(label: impl FnOnce() -> String) -> Step<'static> {
match GLOBAL.get() {
Some(p) if !p.busy() => p.step(&label()),
_ => Step::none(),
}
}
pub fn suspend<R>(f: impl FnOnce() -> R) -> R {
match GLOBAL.get() {
Some(p) => p.suspend(f),
None => f(),
}
}
pub fn set_hidden(hidden: bool) {
if let Some(p) = GLOBAL.get() {
p.set_hidden(hidden);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
#[derive(Clone, Default)]
struct Buf(Arc<Mutex<Vec<u8>>>);
impl Write for Buf {
fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(b);
Ok(b.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl Buf {
fn text(&self) -> String {
String::from_utf8(self.0.lock().unwrap().clone()).unwrap()
}
}
fn progress(delay: Duration, unicode: bool, width: usize) -> (Progress, Buf) {
let buf = Buf::default();
let cfg = Config {
delay,
unicode,
color: false,
width: Some(width),
};
(Progress::new(cfg, Box::new(buf.clone())), buf)
}
fn inputs() -> Inputs {
Inputs {
setting: Setting::Auto,
no_progress: false,
json: false,
stderr_tty: true,
term_dumb: false,
ci: false,
}
}
#[test]
fn parse_setting_reads_always_and_never_else_auto() {
assert_eq!(parse_setting(Some("always")), Setting::Always);
assert_eq!(parse_setting(Some(" NEVER ")), Setting::Never);
assert_eq!(parse_setting(Some("")), Setting::Auto);
assert_eq!(parse_setting(Some("sometimes")), Setting::Auto);
assert_eq!(parse_setting(None), Setting::Auto);
}
#[test]
fn auto_needs_a_terminal_and_no_dumb_term_or_ci() {
assert_eq!(decide(&inputs()), Some(SHOW_DELAY));
for off in [
Inputs {
stderr_tty: false,
..inputs()
},
Inputs {
term_dumb: true,
..inputs()
},
Inputs {
ci: true,
..inputs()
},
] {
assert_eq!(decide(&off), None, "{off:?}");
}
}
#[test]
fn json_flag_and_never_turn_it_off_even_over_always() {
for off in [
Inputs {
json: true,
setting: Setting::Always,
..inputs()
},
Inputs {
no_progress: true,
setting: Setting::Always,
..inputs()
},
Inputs {
setting: Setting::Never,
..inputs()
},
] {
assert_eq!(decide(&off), None, "{off:?}");
}
}
#[test]
fn always_draws_with_no_delay_off_a_terminal_and_in_ci() {
let i = Inputs {
setting: Setting::Always,
stderr_tty: false,
term_dumb: true,
ci: true,
..inputs()
};
assert_eq!(decide(&i), Some(Duration::ZERO));
}
#[test]
fn ci_value_reading() {
assert!(is_ci(Some("true")));
assert!(is_ci(Some("1")));
assert!(!is_ci(Some("")));
assert!(!is_ci(Some("0")));
assert!(!is_ci(Some("false")));
assert!(!is_ci(None));
}
#[test]
fn a_step_draws_its_label_and_clears_when_it_ends() {
let (p, buf) = progress(Duration::ZERO, false, 80);
{
let _s = p.step("cloning repo");
}
assert_eq!(buf.text(), format!("{CLEAR}| cloning repo{CLEAR}"));
}
#[test]
fn auto_delay_keeps_a_fast_step_off_the_screen() {
let (p, buf) = progress(Duration::from_secs(60), false, 80);
{
let _s = p.step("fast");
p.tick();
}
assert_eq!(
buf.text(),
"",
"a step that ends before the delay draws nothing"
);
}
#[test]
fn the_delay_runs_from_the_outermost_step_and_restarts_after_a_break() {
let delay = Duration::from_millis(50);
let (p, buf) = progress(delay, false, 80);
let outer = p.step("outer");
std::thread::sleep(delay * 2);
p.tick();
let inner = p.step("inner");
assert!(
buf.text().ends_with("inner"),
"a step nested in a drawn one shows at once: {:?}",
buf.text()
);
drop(inner);
drop(outer);
let after = buf.text();
drop(p.step("next"));
assert_eq!(buf.text(), after, "a new step starts the clock again");
}
#[test]
fn nested_step_shows_innermost_then_restores_outer() {
let (p, buf) = progress(Duration::ZERO, false, 80);
let outer = p.step("outer");
{
let _inner = p.step("inner");
}
drop(outer);
let t = buf.text();
let inner = t.find("inner").unwrap();
let back = t.rfind("outer").unwrap();
assert!(back > inner, "outer label redrawn after inner ends: {t:?}");
assert!(t.ends_with(CLEAR), "{t:?}");
}
#[test]
fn set_relabels_the_live_step() {
let (p, buf) = progress(Duration::ZERO, false, 80);
let s = p.step("syncing sources (0/2)");
s.set("syncing sources (1/2)");
drop(s);
assert!(
buf.text().contains("| syncing sources (1/2)"),
"{}",
buf.text()
);
}
#[test]
fn suspend_clears_before_the_closure_and_redraws_after() {
let (p, buf) = progress(Duration::ZERO, false, 80);
let _s = p.step("working");
let seen = p.suspend(|| buf.text());
assert!(
seen.ends_with(CLEAR),
"the line is cleared before the closure writes: {seen:?}"
);
assert!(buf.text().ends_with("| working"), "{:?}", buf.text());
}
#[test]
fn nothing_draws_while_suspended() {
let (p, buf) = progress(Duration::ZERO, false, 80);
p.suspend(|| {
let _s = p.step("inside");
p.tick();
});
assert_eq!(buf.text(), "");
}
#[test]
fn may_not_draw_while_suspended_or_hidden() {
let (p, _buf) = progress(Duration::ZERO, false, 80);
assert!(p.may_draw());
assert!(!p.suspend(|| p.may_draw()), "suspended: git may prompt");
assert!(p.may_draw());
p.set_hidden(true);
assert!(!p.may_draw());
}
#[test]
fn on_screen_follows_the_drawn_line() {
let (p, _buf) = progress(Duration::ZERO, false, 80);
let s = p.step("x");
assert!(p.on_screen.load(Ordering::SeqCst));
p.suspend(|| assert!(!p.on_screen.load(Ordering::SeqCst)));
assert!(p.on_screen.load(Ordering::SeqCst), "redrawn after suspend");
drop(s);
assert!(!p.on_screen.load(Ordering::SeqCst));
}
#[test]
fn stop_reports_a_drawn_line_once_and_ends_drawing() {
let (p, buf) = progress(Duration::ZERO, false, 80);
let s = p.step("working");
assert!(p.stop(), "a drawn line is reported for erasing");
assert!(!p.stop(), "and only once");
let at_stop = buf.text();
p.tick();
s.set("next");
p.set_hidden(false);
p.tick();
assert_eq!(buf.text(), at_stop, "nothing draws after a stop");
drop(s);
let (idle, _) = progress(Duration::ZERO, false, 80);
assert!(!idle.stop(), "nothing on screen, nothing to erase");
}
#[test]
fn hidden_clears_and_blocks_drawing_until_shown() {
let (p, buf) = progress(Duration::ZERO, false, 80);
let _s = p.step("working");
p.set_hidden(true);
let at_hide = buf.text();
assert!(at_hide.ends_with(CLEAR), "{at_hide:?}");
p.tick();
assert_eq!(buf.text(), at_hide, "no draw while hidden");
p.set_hidden(false);
p.tick();
assert!(buf.text().ends_with("working"), "{:?}", buf.text());
}
#[test]
fn label_is_sanitized_and_cut_to_the_width() {
let (p, buf) = progress(Duration::ZERO, false, 20);
drop(p.step("a\x1b[31mbcdefghijklmnopqrstuvwxyz\nnext"));
let t = buf.text();
let drawn = t.trim_start_matches(CLEAR).trim_end_matches(CLEAR);
assert!(!drawn.contains('\x1b') && !drawn.contains('\n'), "{t:?}");
assert_eq!(drawn, "| abcdefghijklmn...");
assert!(crate::render::visible_width(drawn) < 20);
}
#[test]
fn frames_follow_the_unicode_capability() {
let (p, buf) = progress(Duration::ZERO, true, 80);
drop(p.step("x"));
assert!(buf.text().contains("⠋ x"), "{:?}", buf.text());
let (p, buf) = progress(Duration::ZERO, false, 80);
let s = p.step("x");
p.tick();
drop(s);
assert!(buf.text().contains("/ x"), "ticks advance the ASCII frame");
}
#[cfg(unix)]
#[test]
fn the_width_is_read_from_the_terminal_the_fd_is_on() {
use std::os::fd::FromRawFd;
let mut ws: libc::winsize = unsafe { std::mem::zeroed() };
ws.ws_col = 123;
ws.ws_row = 40;
let (mut leader, mut follower) = (-1, -1);
let rc = unsafe {
libc::openpty(
&mut leader,
&mut follower,
std::ptr::null_mut(),
std::ptr::null_mut(),
&raw mut ws,
)
};
assert_eq!(rc, 0, "openpty");
let (leader, follower) = unsafe {
(
std::fs::File::from_raw_fd(leader),
std::fs::File::from_raw_fd(follower),
)
};
assert_eq!(columns_of(&follower), Some(123));
drop(leader);
let file = std::fs::File::open("Cargo.toml").unwrap();
assert_eq!(columns_of(&file), None, "not a terminal");
}
#[test]
fn truncate_handles_wide_chars_and_tiny_widths() {
assert_eq!(truncate_to_width("abc", 3), "abc");
assert_eq!(truncate_to_width("日本語テキスト", 7), "日本...");
assert_eq!(truncate_to_width("abcdef", 2), "..");
}
}