use std::io::{IsTerminal, Write};
use std::path::Path;
use std::time::{Duration, Instant};
use crate::scan::Progress;
const FRAMES: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
const REPAINT: Duration = Duration::from_millis(80);
const FALLBACK_WIDTH: usize = 80;
pub struct Bar {
live: Option<Live>,
}
struct Live {
started: Instant,
painted: Instant,
dirs: usize,
repos: usize,
current: String,
}
impl Bar {
pub fn start(root: &Path, depth: usize) -> Bar {
if !watching() {
return Bar { live: None };
}
eprintln!(
" scanning {} · depth {} …",
amont_runtime::ui::sanitize_path(root),
depth
);
let now = Instant::now();
let mut bar = Bar {
live: Some(Live {
started: now,
painted: now - REPAINT,
dirs: 0,
repos: 0,
current: String::new(),
}),
};
bar.paint();
bar
}
pub fn update(&mut self, p: Progress) {
let Some(live) = self.live.as_mut() else {
return;
};
match p {
Progress::Visited { count, dir } => {
live.dirs = count;
live.current = dir.display().to_string();
}
Progress::Found(r) => {
live.repos += 1;
live.current = r.path.display().to_string();
}
}
if live.painted.elapsed() >= REPAINT {
self.paint();
}
}
pub fn finish(&mut self) {
if self.live.take().is_some() {
let mut err = std::io::stderr();
let _ = write!(err, "\r\u{1b}[K");
let _ = err.flush();
}
}
fn paint(&mut self) {
let Some(live) = self.live.as_mut() else {
return;
};
live.painted = Instant::now();
let secs = live.started.elapsed().as_secs_f64();
let frame = FRAMES[((secs * 10.0) as usize) % FRAMES.len()];
let s = line(frame, live.dirs, live.repos, secs, &live.current, width());
let mut err = std::io::stderr();
let _ = write!(err, "\r\u{1b}[K{s}");
let _ = err.flush();
}
}
impl Drop for Bar {
fn drop(&mut self) {
self.finish();
}
}
pub struct Steps {
live: Option<StepsLive>,
}
struct StepsLive {
verb: &'static str,
started: Instant,
painted: Instant,
done: usize,
total: usize,
current: String,
}
impl Steps {
pub fn start(verb: &'static str, total: usize) -> Steps {
if total == 0 || !watching() {
return Steps { live: None };
}
let now = Instant::now();
let mut steps = Steps {
live: Some(StepsLive {
verb,
started: now,
painted: now - REPAINT,
done: 0,
total,
current: String::new(),
}),
};
steps.paint();
steps
}
pub fn step(&mut self, current: &Path) {
let Some(live) = self.live.as_mut() else {
return;
};
live.done += 1;
live.current = current.display().to_string();
if live.painted.elapsed() >= REPAINT {
self.paint();
}
}
pub fn is_live(&self) -> bool {
self.live.is_some()
}
pub fn interrupt(&mut self, line: &str) {
if self.live.is_some() {
let mut err = std::io::stderr();
let _ = write!(err, "\r\u{1b}[K");
let _ = err.flush();
}
println!("{line}");
let _ = std::io::stdout().flush();
self.paint();
}
pub fn finish(&mut self) {
if self.live.take().is_some() {
let mut err = std::io::stderr();
let _ = write!(err, "\r\u{1b}[K");
let _ = err.flush();
}
}
fn paint(&mut self) {
let Some(live) = self.live.as_mut() else {
return;
};
live.painted = Instant::now();
let secs = live.started.elapsed().as_secs_f64();
let frame = FRAMES[((secs * 10.0) as usize) % FRAMES.len()];
let s = step_line(
frame,
live.verb,
live.done,
live.total,
secs,
&live.current,
width(),
);
let mut err = std::io::stderr();
let _ = write!(err, "\r\u{1b}[K{s}");
let _ = err.flush();
}
}
impl Drop for Steps {
fn drop(&mut self) {
self.finish();
}
}
fn step_line(
frame: char,
verb: &str,
done: usize,
total: usize,
secs: f64,
current: &str,
width: usize,
) -> String {
if width == 0 {
return String::new();
}
let head = format!(" {frame} {verb} {done}/{total} · {secs:.1}s ");
fit(head, current, width)
}
fn watching() -> bool {
std::io::stderr().is_terminal() && std::env::var("TERM").map(|t| t != "dumb").unwrap_or(true)
}
fn width() -> usize {
match crossterm::terminal::size() {
Ok((cols, _)) if cols > 0 => cols as usize,
_ => FALLBACK_WIDTH,
}
}
fn plural(n: usize, one: &'static str, many: &'static str) -> &'static str {
if n == 1 {
one
} else {
many
}
}
fn line(frame: char, dirs: usize, repos: usize, secs: f64, current: &str, width: usize) -> String {
if width == 0 {
return String::new();
}
let head = format!(
" {frame} {dirs} {} · {repos} {} · {secs:.1}s ",
plural(dirs, "dir", "dirs"),
plural(repos, "repo", "repos"),
);
fit(head, current, width)
}
fn fit(head: String, current: &str, width: usize) -> String {
let head_len = head.chars().count();
if head_len >= width {
return head.chars().take(width).collect();
}
let current = amont_runtime::ui::sanitize(current);
let room = width - head_len;
let len = current.chars().count();
if len <= room {
return format!("{head}{current}");
}
let tail: String = current.chars().skip(len - room.saturating_sub(1)).collect();
format!("{head}…{tail}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_frame_never_exceeds_the_width() {
let long = "Perso/some/deeply/nested/group/project/with-a-long-name/sub";
for width in [0, 1, 5, 20, 39, 40, 41, 80, 200] {
for (dirs, repos) in [(0, 0), (1240, 84), (999_999, 12_345)] {
let s = line('⠋', dirs, repos, 12.75, long, width);
assert!(
s.chars().count() <= width,
"width {width}: {:?} is {} chars",
s,
s.chars().count()
);
}
}
}
#[test]
fn the_counts_outrank_the_path() {
let s = line('⠋', 1240, 84, 3.25, "some/repo", 34);
assert!(s.contains("1240 dirs"), "{s:?}");
assert!(s.contains("84 repos"), "{s:?}");
assert!(s.contains("3.2s"), "{s:?}");
assert!(!s.contains("some/repo"), "the path should have gone: {s:?}");
}
#[test]
fn one_of_something_is_singular() {
let s = line('⠋', 1, 1, 0.1, "", 80);
assert!(s.contains("1 dir ·"), "{s:?}");
assert!(s.contains("1 repo ·"), "{s:?}");
let s = line('⠋', 0, 2, 0.1, "", 80);
assert!(s.contains("0 dirs ·"), "{s:?}");
assert!(s.contains("2 repos ·"), "{s:?}");
}
#[test]
fn a_long_path_keeps_its_tail() {
let s = line('⠋', 1, 1, 1.0, "a/very/long/path/to/the-repo", 40);
assert!(s.ends_with("the-repo"), "{s:?}");
assert!(s.contains('…'), "the cut must be visible: {s:?}");
assert!(s.chars().count() <= 40, "{s:?}");
}
#[test]
fn a_short_path_is_shown_whole() {
let s = line('⠋', 3, 1, 0.5, "Perso/amont", 80);
assert!(s.ends_with("Perso/amont"), "{s:?}");
assert!(!s.contains('…'), "nothing was cut: {s:?}");
}
#[test]
fn a_hostile_directory_name_cannot_reach_the_terminal() {
let s = line('⠋', 1, 1, 1.0, "evil\u{1b}[2Jname\rhere", 80);
assert!(!s.contains('\u{1b}'), "an escape survived: {s:?}");
assert!(!s.contains('\r'), "a carriage return survived: {s:?}");
assert!(s.contains("\\x1b"), "{s:?}");
assert!(s.contains("\\x0d"), "{s:?}");
}
#[test]
fn escaping_happens_before_the_width_is_enforced() {
let hostile = "\u{1b}\u{1b}\u{1b}\u{1b}\u{1b}\u{1b}\u{1b}\u{1b}\u{1b}\u{1b}";
for width in [20, 30, 44, 60] {
let s = line('⠋', 1, 1, 1.0, hostile, width);
assert!(s.chars().count() <= width, "width {width}: {s:?}");
assert!(!s.contains('\u{1b}'), "{s:?}");
}
}
#[test]
fn a_counted_frame_shows_its_denominator() {
let s = step_line('⠋', "planning", 42, 185, 1.25, "Perso/some/repo", 80);
assert!(s.contains("planning 42/185"), "{s:?}");
assert!(s.contains("1.2s"), "{s:?}");
assert!(s.ends_with("Perso/some/repo"), "{s:?}");
}
#[test]
fn a_counted_frame_never_exceeds_the_width() {
let long = "Perso/some/deeply/nested/group/project/with-a-long-name/sub";
for width in [0, 1, 5, 20, 40, 80, 200] {
let s = step_line('⠋', "planning", 184, 185, 12.75, long, width);
assert!(
s.chars().count() <= width,
"width {width}: {:?} is {} chars",
s,
s.chars().count()
);
}
}
#[test]
fn a_hostile_path_cannot_reach_a_counted_frame() {
let s = step_line('⠋', "planning", 1, 2, 1.0, "evil\u{1b}[2Jname\rhere", 80);
assert!(!s.contains('\u{1b}'), "{s:?}");
assert!(!s.contains('\r'), "{s:?}");
assert!(s.contains("\\x1b"), "escaped, not dropped: {s:?}");
}
#[test]
fn an_interrupt_without_a_frame_is_just_a_line() {
let mut steps = Steps { live: None };
steps.interrupt("kept line");
steps.finish();
}
#[test]
fn an_empty_or_unwatched_phase_is_inert() {
let mut steps = Steps { live: None };
steps.step(Path::new("x"));
steps.finish();
steps.finish();
assert!(Steps::start("planning", 0).live.is_none());
}
#[test]
fn a_disabled_bar_is_inert() {
let mut bar = Bar { live: None };
bar.update(Progress::Visited {
count: 1,
dir: Path::new("x"),
});
bar.finish();
bar.finish();
}
}