use std::fmt::Write as _;
use std::io::Write;
use std::io::{self};
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use std::time::Duration;
use std::time::Instant;
use gix::progress::Count;
use gix::progress::NestedProgress;
use gix::progress::Progress;
use gix::progress::StepShared;
use gix::progress::Unit;
#[derive(Clone)]
pub struct InlineProgress {
name: String,
state: Arc<State>,
}
struct State {
last_draw: std::sync::Mutex<Option<Instant>>,
current: StepShared,
max: AtomicUsize,
has_max: AtomicBool,
finished: AtomicBool,
}
impl InlineProgress {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
state: Arc::new(State {
last_draw: std::sync::Mutex::new(None),
current: Arc::new(AtomicUsize::new(0)),
max: AtomicUsize::new(0),
has_max: AtomicBool::new(false),
finished: AtomicBool::new(false),
}),
}
}
#[expect(
clippy::unwrap_used,
reason = "Mutex poisoning indicates a panic elsewhere; propagating is correct"
)]
fn draw(&self) {
let now = Instant::now();
{
let mut last = self.state.last_draw.lock().unwrap();
if let Some(last_time) = *last
&& now.duration_since(last_time) < Duration::from_millis(50)
&& self.state.has_max.load(Ordering::Relaxed)
{
return;
}
*last = Some(now);
}
let current = self.state.current.load(Ordering::Relaxed);
let has_max = self.state.has_max.load(Ordering::Relaxed);
let max = self.state.max.load(Ordering::Relaxed);
let mut line = String::new();
line.push_str(" ");
line.push_str(&self.name);
line.push_str(": ");
if has_max && max > 0 {
let pct = (current as f32 / max as f32) * 100.0;
let _ = write!(line, "{current}/{max} ({pct:.1}%)");
} else {
let _ = write!(line, "{current}");
}
print!("\r{line}");
let _ = io::stdout().flush();
}
}
impl Count for InlineProgress {
fn set(&self, step: usize) {
self.state.current.store(step, Ordering::Relaxed);
self.draw();
}
fn step(&self) -> usize {
self.state.current.load(Ordering::Relaxed)
}
fn inc_by(&self, step: usize) {
self.state.current.fetch_add(step, Ordering::Relaxed);
self.draw();
}
fn counter(&self) -> gix::progress::StepShared {
Arc::clone(&self.state.current)
}
}
impl Progress for InlineProgress {
fn init(&mut self, max: Option<usize>, _unit: Option<Unit>) {
if let Some(m) = max {
self.state.max.store(m, Ordering::Relaxed);
self.state.has_max.store(true, Ordering::Relaxed);
} else {
self.state.has_max.store(false, Ordering::Relaxed);
}
self.state.current.store(0, Ordering::Relaxed);
self.state.finished.store(false, Ordering::Relaxed);
self.draw();
}
fn set_name(&mut self, _name: String) {
}
fn name(&self) -> Option<String> {
Some(self.name.clone())
}
fn id(&self) -> gix::progress::Id {
[0u8; 4]
}
fn message(&self, _level: gix::progress::MessageLevel, _message: String) {
}
}
impl NestedProgress for InlineProgress {
type SubProgress = Self;
fn add_child(&mut self, name: impl Into<String>) -> Self::SubProgress {
if !self.state.finished.load(Ordering::Relaxed) {
println!();
}
Self::new(name)
}
fn add_child_with_id(
&mut self,
name: impl Into<String>,
_id: gix::progress::Id,
) -> Self::SubProgress {
self.add_child(name)
}
}
impl Drop for InlineProgress {
#[expect(
clippy::unwrap_used,
reason = "Mutex poisoning indicates a panic elsewhere; propagating is correct"
)]
fn drop(&mut self) {
if !self.state.finished.swap(true, Ordering::Relaxed) {
if self.state.last_draw.lock().unwrap().is_some() {
println!();
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn init_and_inc() {
let mut p = InlineProgress::new("test");
p.init(Some(100), None);
p.inc_by(1);
p.inc_by(9);
p.set(25);
}
#[test]
fn nested_children() {
let mut p = InlineProgress::new("root");
let mut c1 = p.add_child("child-1");
c1.init(Some(10), None);
c1.inc_by(3);
}
#[test]
fn no_max_progress() {
let mut p = InlineProgress::new("bytes");
p.init(None, None);
p.inc_by(100);
p.inc_by(200);
}
#[test]
fn counter_is_shared() {
use std::sync::atomic::Ordering;
let p = InlineProgress::new("t");
let c = p.counter();
c.fetch_add(5, Ordering::Relaxed);
assert_eq!(p.step(), 5);
}
}