modcli/output/
progress.rs

1use crate::output::hook;
2use crossterm::style::{Color, Stylize};
3use std::io::{stdout, Write};
4use std::thread;
5use std::time::{Duration, Instant};
6
7/// Customizable style for the progress bar
8#[derive(Clone)]
9pub struct ProgressStyle {
10    pub fill: char,
11    pub start_cap: char,
12    pub end_cap: char,
13    pub done_label: &'static str,
14    pub show_percent: bool,
15    pub color: Option<Color>,
16}
17
18impl Default for MultiProgress {
19    fn default() -> Self {
20        Self::new()
21    }
22}
23
24// --- MultiProgress ---
25
26/// Minimal multi-progress manager that stacks multiple ProgressBar lines.
27/// Rendering uses ANSI cursor movement; works in most modern terminals.
28pub struct MultiProgress {
29    bars: Vec<ProgressBar>,
30}
31
32impl MultiProgress {
33    pub fn new() -> Self {
34        Self { bars: Vec::new() }
35    }
36
37    /// Add a bar and return its index for later updates.
38    pub fn add_bar(&mut self, label: &str, total_steps: usize, style: ProgressStyle) -> usize {
39        let mut bar = ProgressBar::new(total_steps, style);
40        bar.set_label(label);
41        if bar.start_time.is_none() {
42            bar.start_time = Some(Instant::now());
43        }
44        self.bars.push(bar);
45        self.bars.len() - 1
46    }
47
48    pub fn get_bar_mut(&mut self, idx: usize) -> Option<&mut ProgressBar> {
49        self.bars.get_mut(idx)
50    }
51
52    pub fn tick(&mut self, idx: usize) {
53        if let Some(b) = self.bars.get_mut(idx) {
54            b.tick();
55        }
56    }
57    pub fn set_progress(&mut self, idx: usize, value: usize) {
58        if let Some(b) = self.bars.get_mut(idx) {
59            b.set_progress(value);
60        }
61    }
62    pub fn set_bytes_processed(&mut self, idx: usize, bytes: u64) {
63        if let Some(b) = self.bars.get_mut(idx) {
64            b.set_bytes_processed(bytes);
65        }
66    }
67
68    /// Redraw all bars stacked. This moves the cursor up N lines then re-renders.
69    pub fn refresh(&mut self) {
70        let n = self.bars.len();
71        if n == 0 {
72            return;
73        }
74        // Move cursor up n lines (except for first draw)
75        print!("\x1B[{n}A"); // ANSI: CUU n
76        for b in &self.bars {
77            b.render();
78            println!();
79        }
80        let _ = stdout().flush();
81    }
82
83    /// Finish all bars and print their done labels on separate lines.
84    pub fn finish(&mut self) {
85        for b in &self.bars {
86            // Ensure bar is fully rendered at 100%
87            b.render();
88            println!(" {}", b.style.done_label);
89        }
90        let _ = stdout().flush();
91    }
92}
93
94impl Default for ProgressStyle {
95    fn default() -> Self {
96        Self {
97            fill: '#',
98            start_cap: '[',
99            end_cap: ']',
100            done_label: "Done!",
101            show_percent: true,
102            color: None,
103        }
104    }
105}
106
107// --- Helpers ---
108
109fn human_bytes_per_sec(bps: f64) -> String {
110    let abs = bps.abs();
111    const K: f64 = 1024.0;
112    let (value, unit) = if abs >= K * K * K {
113        (bps / (K * K * K), "GiB/s")
114    } else if abs >= K * K {
115        (bps / (K * K), "MiB/s")
116    } else if abs >= K {
117        (bps / K, "KiB/s")
118    } else {
119        (bps, "B/s")
120    };
121    if value.abs() >= 100.0 {
122        format!("{value:>4.0} {unit}")
123    } else {
124        format!("{value:>4.1} {unit}")
125    }
126}
127
128fn human_duration(d: Duration) -> String {
129    let mut secs = d.as_secs();
130    let h = secs / 3600;
131    secs %= 3600;
132    let m = secs / 60;
133    let s = secs % 60;
134    if h > 0 {
135        format!("{h:02}:{m:02}:{s:02}")
136    } else {
137        format!("{m:02}:{s:02}")
138    }
139}
140
141/// Struct-based progress bar
142pub struct ProgressBar {
143    pub total_steps: usize,
144    pub current: usize,
145    pub label: Option<String>,
146    pub style: ProgressStyle,
147    // Byte-based tracking (optional)
148    pub total_bytes: Option<u64>,
149    pub bytes_processed: u64,
150    // Timing
151    start_time: Option<Instant>,
152    last_tick: Option<Instant>,
153    // Control
154    paused: bool,
155}
156
157impl ProgressBar {
158    pub fn new(total_steps: usize, style: ProgressStyle) -> Self {
159        Self {
160            total_steps,
161            current: 0,
162            label: None,
163            style,
164            total_bytes: None,
165            bytes_processed: 0,
166            start_time: None,
167            last_tick: None,
168            paused: false,
169        }
170    }
171
172    pub fn set_label(&mut self, label: &str) {
173        self.label = Some(label.to_string());
174    }
175
176    pub fn set_progress(&mut self, value: usize) {
177        self.current = value.min(self.total_steps);
178        if self.start_time.is_none() {
179            self.start_time = Some(Instant::now());
180        }
181        self.last_tick = Some(Instant::now());
182        self.render();
183    }
184
185    pub fn tick(&mut self) {
186        if self.paused {
187            return;
188        }
189        self.current += 1;
190        if self.current > self.total_steps {
191            self.current = self.total_steps;
192        }
193        if self.start_time.is_none() {
194            self.start_time = Some(Instant::now());
195        }
196        self.last_tick = Some(Instant::now());
197        self.render();
198    }
199
200    /// Set total bytes (enables byte-based progress). You may update processed bytes independently.
201    pub fn set_bytes_total(&mut self, total: u64) {
202        self.total_bytes = Some(total);
203    }
204
205    /// Set processed bytes; will render percent, rate, and ETA when total is known.
206    pub fn set_bytes_processed(&mut self, processed: u64) {
207        self.bytes_processed = processed;
208        if self.start_time.is_none() {
209            self.start_time = Some(Instant::now());
210        }
211        self.last_tick = Some(Instant::now());
212        self.render();
213    }
214
215    /// Convenience to set both total and processed bytes in one call.
216    pub fn set_bytes(&mut self, total: u64, processed: u64) {
217        self.total_bytes = Some(total);
218        self.set_bytes_processed(processed);
219    }
220
221    /// Pause updates by tick(); direct set_ calls will still render.
222    pub fn pause(&mut self) {
223        self.paused = true;
224    }
225    /// Resume updates.
226    pub fn resume(&mut self) {
227        self.paused = false;
228        self.last_tick = Some(Instant::now());
229    }
230
231    pub fn start_auto(&mut self, duration_ms: u64) {
232        let interval = duration_ms / self.total_steps.max(1) as u64;
233        for _ in 0..self.total_steps {
234            self.tick();
235            thread::sleep(Duration::from_millis(interval));
236        }
237        println!(" {}", self.style.done_label);
238    }
239
240    fn render(&self) {
241        let mut percent_val: usize = self.current * 100 / self.total_steps.max(1);
242
243        // In bytes mode, compute percent from bytes
244        if let Some(total) = self.total_bytes {
245            if total > 0 {
246                percent_val = ((self.bytes_processed.saturating_mul(100)) / total.max(1)) as usize;
247            }
248        }
249
250        let percent = if self.style.show_percent {
251            format!(" {:>3}%", percent_val.min(100))
252        } else {
253            String::new()
254        };
255
256        // Determine visual fill based on either steps or bytes percent
257        let fill_from_percent =
258            |pct: usize, width: usize| -> usize { ((pct.min(100) * width) / 100).min(width) };
259
260        let (fill_count, empty_count) = if self.total_bytes.is_some() {
261            let fill = fill_from_percent(percent_val, self.total_steps);
262            (fill, self.total_steps - fill)
263        } else {
264            (self.current, self.total_steps - self.current)
265        };
266
267        let mut bar = format!(
268            "{}{}{}{}",
269            self.style.start_cap,
270            self.style.fill.to_string().repeat(fill_count),
271            " ".repeat(empty_count),
272            self.style.end_cap
273        );
274
275        if let Some(color) = self.style.color {
276            bar = bar.with(color).to_string();
277        }
278        print!("\r");
279
280        if let Some(ref label) = self.label {
281            print!("{label} {bar}");
282        } else {
283            print!("{bar}");
284        }
285
286        // Bytes-specific tail: rate and ETA
287        if let Some(total) = self.total_bytes {
288            let elapsed = self.start_time.map(|t| t.elapsed()).unwrap_or_default();
289            let rate_bps = if elapsed.as_secs_f64() > 0.0 {
290                self.bytes_processed as f64 / elapsed.as_secs_f64()
291            } else {
292                0.0
293            };
294            let remaining = total.saturating_sub(self.bytes_processed);
295            let eta_secs = if rate_bps > 0.0 {
296                (remaining as f64 / rate_bps).round() as u64
297            } else {
298                0
299            };
300
301            let rate_str = human_bytes_per_sec(rate_bps);
302            let eta_str = human_duration(Duration::from_secs(eta_secs));
303            print!("{percent}  {rate_str}  ETA {eta_str}");
304        } else {
305            print!("{percent}");
306        }
307
308        if let Err(e) = stdout().flush() {
309            hook::warn(&format!("flush failed: {e}"));
310        }
311    }
312}
313
314// Procedural-style one-liners
315
316pub fn show_progress_bar(label: &str, total_steps: usize, duration_ms: u64) {
317    let mut bar = ProgressBar::new(total_steps, ProgressStyle::default());
318    bar.set_label(label);
319    bar.start_auto(duration_ms);
320}
321
322pub fn show_percent_progress(label: &str, percent: usize) {
323    let clamped = percent.clamp(0, 100);
324    print!("\r{label}: {clamped:>3}% complete");
325    if let Err(e) = stdout().flush() {
326        hook::warn(&format!("flush failed: {e}"));
327    }
328}
329
330pub fn show_spinner(label: &str, cycles: usize, delay_ms: u64) {
331    let spinner = ['|', '/', '-', '\\'];
332    let mut stdout = stdout();
333    print!("{label} ");
334
335    for i in 0..cycles {
336        let frame = spinner[i % spinner.len()];
337        print!("\r{label} {frame}");
338        if let Err(e) = stdout.flush() {
339            hook::warn(&format!("flush failed: {e}"));
340        }
341        thread::sleep(Duration::from_millis(delay_ms));
342    }
343
344    println!("{label} βœ“");
345}
346
347/// Emoji spinner demo using moon phases. Compatible with most modern terminals.
348pub fn show_emoji_spinner(label: &str, cycles: usize, delay_ms: u64) {
349    const FRAMES: [&str; 8] = ["πŸŒ‘", "πŸŒ’", "πŸŒ“", "πŸŒ”", "πŸŒ•", "πŸŒ–", "πŸŒ—", "🌘"];
350    let mut stdout = stdout();
351    print!("{label} ");
352
353    for i in 0..cycles {
354        let frame = FRAMES[i % FRAMES.len()];
355        print!("\r{label} {frame}");
356        if let Err(e) = stdout.flush() {
357            hook::warn(&format!("flush failed: {e}"));
358        }
359        thread::sleep(Duration::from_millis(delay_ms));
360    }
361
362    println!("{label} βœ…");
363}