use super::{finding_noun, secret_noun};
use crate::style::terminal_clear_line_prefix;
use std::io::Write;
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use std::time::Instant;
use crate::style::{
SEV_AMBER as C_AMBER, SEV_BOLD as C_BOLD, SEV_BRAND as C_BRAND, SEV_MUTED as C_MUTED,
SEV_RAIL as C_RAIL, SEV_RESET as C_RESET,
};
pub(crate) const FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
pub(crate) const BAR_WIDTH: usize = 22;
pub(crate) fn render_progress_bar(frac: f64, width: usize, color: bool) -> String {
const PARTIALS: [char; 8] = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉'];
let frac = frac.clamp(0.0, 1.0);
let eighths = (frac * width as f64 * 8.0).round() as usize;
let full = (eighths / 8).min(width);
let rem = eighths % 8;
let mut fill = "█".repeat(full);
let mut used = full;
if full < width && rem > 0 {
fill.push(PARTIALS[rem]);
used += 1;
}
let rail = "░".repeat(width.saturating_sub(used));
if color {
let mut s = String::new();
if !fill.is_empty() {
s.push_str(C_BRAND);
s.push_str(&fill);
}
if !rail.is_empty() {
s.push_str(C_RAIL);
s.push_str(&rail);
}
s.push_str(C_RESET);
s
} else {
format!("{fill}{rail}")
}
}
fn render_indeterminate_bar(phase: usize, width: usize, color: bool) -> String {
let band = 4usize;
let span = width + band;
let head = phase % span;
let mut cells = String::with_capacity(width * 4);
for i in 0..width {
let lit = head >= i && head < i + band;
if color {
cells.push_str(if lit { C_AMBER } else { C_RAIL });
}
cells.push(if lit { '█' } else { '░' });
}
if color {
cells.push_str(C_RESET);
}
cells
}
pub(crate) fn fmt_secs(s: f64) -> String {
if s < 59.95 {
format!("{s:.1}s")
} else {
let total = s.round() as u64;
let m = total / 60;
let r = total % 60;
format!("{m}m{r:02}s")
}
}
pub(crate) fn render_ticker_line(
scanned: usize,
total: usize,
findings: usize,
elapsed: f64,
frame: usize,
color: bool,
) -> String {
let (brand, amber, muted, rail, bold, reset) = if color {
(C_BRAND, C_AMBER, C_MUTED, C_RAIL, C_BOLD, C_RESET)
} else {
("", "", "", "", "", "")
};
let spin = FRAMES[frame % FRAMES.len()];
let noun = finding_noun(findings);
let find_seg = if findings > 0 {
format!("{bold}{amber}{findings}{reset} {muted}{noun}{reset}")
} else {
format!("{muted}0 {noun}{reset}")
};
if total == 0 {
let sweep = render_indeterminate_bar(frame, BAR_WIDTH, color);
format!(
"{brand}{spin}{reset} {bold}preparing{reset} {muted}·{reset} {sweep} {muted}·{reset} warming backend, discovering files {muted}·{reset} {find_seg} {muted}·{reset} {muted}{}{reset}",
fmt_secs(elapsed)
)
} else {
let shown = scanned.min(total);
let frac = shown as f64 / total as f64;
let pct = (frac * 100.0).floor() as u64;
let bar = render_progress_bar(frac, BAR_WIDTH, color);
let rate = if elapsed > 0.05 {
scanned as f64 / elapsed
} else {
0.0
};
let eta = if rate > 0.5 && shown < total {
format!(
" {muted}eta {}{reset}",
fmt_secs((total - shown) as f64 / rate)
)
} else {
String::new()
};
let label = if shown >= total {
"finalizing"
} else {
"scanning"
};
format!(
"{brand}{spin}{reset} {bold}{label}{reset} {rail}▕{reset}{bar}{rail}▏{reset} {bold}{pct:>3}%{reset} {muted}{shown}/{total}{reset} {muted}·{reset} {find_seg} {muted}·{reset} {muted}{rate:.0}/s{reset} {muted}·{reset} {muted}{}{reset}{eta}",
fmt_secs(elapsed)
)
}
}
pub(crate) fn render_verification_ticker_line(
total: usize,
elapsed: f64,
frame: usize,
color: bool,
) -> String {
let (brand, muted, bold, reset) = if color {
(C_BRAND, C_MUTED, C_BOLD, C_RESET)
} else {
("", "", "", "")
};
let spin = FRAMES[frame % FRAMES.len()];
let sweep = render_indeterminate_bar(frame, BAR_WIDTH, color);
let noun = secret_noun(total);
format!(
"{brand}{spin}{reset} {bold}verifying{reset} {muted}·{reset} {sweep} {muted}·{reset} checking {bold}{total}{reset} {noun} {muted}·{reset} {muted}{}{reset}",
fmt_secs(elapsed)
)
}
pub(crate) fn render_reporting_ticker_line(
total: usize,
elapsed: f64,
frame: usize,
color: bool,
) -> String {
let (brand, muted, bold, reset) = if color {
(C_BRAND, C_MUTED, C_BOLD, C_RESET)
} else {
("", "", "", "")
};
let spin = FRAMES[frame % FRAMES.len()];
let sweep = render_indeterminate_bar(frame, BAR_WIDTH, color);
let noun = finding_noun(total);
format!(
"{brand}{spin}{reset} {bold}reporting{reset} {muted}·{reset} {sweep} {muted}·{reset} writing {bold}{total}{reset} {noun} {muted}·{reset} {muted}{}{reset}",
fmt_secs(elapsed)
)
}
pub(crate) struct TickerGuard {
done: Arc<AtomicBool>,
handle: Option<std::thread::JoinHandle<()>>,
label: &'static str,
}
impl TickerGuard {
pub(crate) fn spawn<F>(label: &'static str, run: F) -> Self
where
F: FnOnce(Arc<AtomicBool>, Instant) + Send + 'static,
{
let done = Arc::new(AtomicBool::new(false));
let ticker_done = Arc::clone(&done);
let started = Instant::now();
let handle = std::thread::spawn(move || run(ticker_done, started));
Self {
done,
handle: Some(handle),
label,
}
}
pub(crate) fn stop(mut self) {
self.stop_inner();
}
fn stop_inner(&mut self) {
use std::sync::atomic::Ordering;
self.done.store(true, Ordering::Relaxed);
if let Some(handle) = self.handle.take() {
if handle.join().is_err() {
tracing::debug!(
ticker = self.label,
"progress thread panicked while shutting down"
);
}
}
}
}
impl Drop for TickerGuard {
fn drop(&mut self) {
self.stop_inner();
}
}
fn terminal_ticker_loop<F>(
done: Arc<AtomicBool>,
started: Instant,
redraw_error_label: &'static str,
mut render: F,
) where
F: FnMut(f64, usize, bool) -> String,
{
use std::io::IsTerminal;
use std::sync::atomic::Ordering;
use std::time::Duration;
if !std::io::stderr().is_terminal() {
return;
}
let color = !crate::style::no_color_requested();
let tick = Duration::from_millis(90);
let mut frame = 0usize;
loop {
let elapsed = started.elapsed().as_secs_f64();
let clear = terminal_clear_line_prefix(true);
let line = render(elapsed, frame, color);
let mut err = std::io::stderr().lock();
if let Err(error) = write!(err, "{clear}{line}") {
tracing::debug!(%error, ticker = redraw_error_label, "progress redraw write error");
}
let _ = err.flush(); drop(err);
if done.load(Ordering::Relaxed) {
break;
}
for _ in 0..9 {
if done.load(Ordering::Relaxed) {
break;
}
std::thread::sleep(tick / 9);
}
frame = frame.wrapping_add(1);
}
let mut err = std::io::stderr().lock();
let _ = write!(err, "{}", terminal_clear_line_prefix(true)); let _ = err.flush(); }
pub(crate) fn progress_ticker(done: Arc<AtomicBool>, started: Instant) {
terminal_ticker_loop(done, started, "scan", |elapsed, frame, color| {
let scanned = crate::SCANNED_CHUNKS.load(std::sync::atomic::Ordering::Relaxed);
let total = crate::TOTAL_CHUNKS.load(std::sync::atomic::Ordering::Relaxed);
let findings = crate::FINDINGS_COUNT.load(std::sync::atomic::Ordering::Relaxed);
render_ticker_line(scanned, total, findings, elapsed, frame, color)
});
}
#[cfg(feature = "verify")]
pub(crate) fn verification_ticker(done: Arc<AtomicBool>, started: Instant, total: usize) {
terminal_ticker_loop(done, started, "verification", |elapsed, frame, color| {
render_verification_ticker_line(total, elapsed, frame, color)
});
}
pub(crate) fn reporting_ticker(done: Arc<AtomicBool>, started: Instant, total: usize) {
terminal_ticker_loop(done, started, "reporting", |elapsed, frame, color| {
render_reporting_ticker_line(total, elapsed, frame, color)
});
}