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();
}
}
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"),
);
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_disabled_bar_is_inert() {
let mut bar = Bar { live: None };
bar.update(Progress::Visited {
count: 1,
dir: Path::new("x"),
});
bar.finish();
bar.finish();
}
}