Skip to main content

aria2_core/
ui.rs

1use std::io::{self, Write};
2
3/// Single-task progress bar with speed estimation.
4///
5/// Renders a visual progress indicator: `[=====>     ] 45% (12.3MiB/s) ETA: 02:15`
6///
7/// Automatically detects terminal width (via `crossterm`) and adapts
8/// the bar width accordingly. Falls back to simple text on non-TTY output.
9///
10/// # Example
11///
12/// ```rust,no_run
13/// use aria2_core::ui::ProgressBar;
14///
15/// let mut pb = ProgressBar::new(1024 * 1024); // 1 MiB total
16/// pb.update(512 * 1024);
17/// pb.render(true); // force render
18/// ```
19pub struct ProgressBar {
20    total: u64,
21    current: u64,
22    width: usize,
23    pub speed: u64,
24    start_time: std::time::Instant,
25}
26
27impl ProgressBar {
28    pub fn new(total: u64) -> Self {
29        let width = terminal_width().clamp(20, 60);
30        Self {
31            total,
32            current: 0,
33            width,
34            speed: 0,
35            start_time: std::time::Instant::now(),
36        }
37    }
38
39    pub fn update(&mut self, current: u64) {
40        self.current = current.min(self.total);
41        let elapsed = self.start_time.elapsed().as_secs_f64();
42        if elapsed > 0.0 {
43            self.speed = (self.current as f64 / elapsed) as u64;
44        }
45    }
46
47    pub fn finish(&mut self) {
48        self.current = self.total;
49        self.render(true);
50        println!();
51    }
52
53    pub fn render(&self, force: bool) {
54        if !force && !is_tty() {
55            return;
56        }
57        let percent = if self.total > 0 {
58            (self.current as f64 / self.total as f64 * 100.0).min(100.0)
59        } else {
60            0.0
61        };
62
63        let filled = (percent / 100.0 * self.width as f64) as usize;
64        let empty = self.width.saturating_sub(filled);
65
66        let bar: String = "=".repeat(filled) + &" ".repeat(empty);
67
68        let speed_str = format_speed(self.speed);
69        let downloaded = format_size(self.current);
70        let total_str = format_size(self.total);
71        let eta = self.eta();
72
73        print!(
74            "\r[{}] {:.0}% ({}/{}) {} ETA: {}",
75            bar, percent, downloaded, total_str, speed_str, eta
76        );
77        let _ = io::stdout().flush();
78    }
79
80    pub fn render_summary(&self) -> String {
81        format!(
82            "{}% ({}/{}) {}",
83            if self.total > 0 {
84                (self.current as f64 / self.total as f64 * 100.0) as i32
85            } else {
86                0
87            },
88            format_size(self.current),
89            format_size(self.total),
90            format_speed(self.speed)
91        )
92    }
93
94    fn eta(&self) -> String {
95        if self.speed == 0 || self.current >= self.total {
96            return "--:--".to_string();
97        }
98        let remaining = self.total.saturating_sub(self.current);
99        let secs = remaining as f64 / self.speed as f64;
100        format_duration(secs)
101    }
102
103    pub fn set_total(&mut self, total: u64) {
104        self.total = total;
105    }
106    pub fn current(&self) -> u64 {
107        self.current
108    }
109    pub fn total(&self) -> u64 {
110        self.total
111    }
112    pub fn is_complete(&self) -> bool {
113        self.current >= self.total
114    }
115}
116
117/// Multi-task progress display showing all active downloads at once.
118///
119/// Format: `[#1  45%] [#2  78%] [#3  12%] Total: 12.3MiB/s`
120///
121/// Each bar is an independent `ProgressBar` with its own label.
122pub struct MultiProgress {
123    bars: Vec<ProgressBar>,
124    labels: Vec<String>,
125    total_speed: u64,
126}
127
128impl MultiProgress {
129    pub fn new() -> Self {
130        Self {
131            bars: Vec::new(),
132            labels: Vec::new(),
133            total_speed: 0,
134        }
135    }
136
137    pub fn add(&mut self, label: impl Into<String>, total: u64) -> usize {
138        let idx = self.bars.len();
139        self.labels.push(label.into());
140        self.bars.push(ProgressBar::new(total));
141        idx
142    }
143
144    pub fn update(&mut self, idx: usize, current: u64) {
145        if idx < self.bars.len() {
146            self.bars[idx].update(current);
147            self.total_speed = self.bars.iter().map(|b| b.speed).sum();
148        }
149    }
150
151    pub fn render(&self, force: bool) {
152        if !force && !is_tty() {
153            return;
154        }
155        for (i, (bar, label)) in self.bars.iter().zip(self.labels.iter()).enumerate() {
156            print!("[#{} ", i + 1);
157            print!("{}", label);
158            print!("] ");
159            bar.render(force);
160            println!();
161        }
162        println!("Total: {}", format_speed(self.total_speed));
163    }
164
165    pub fn finish_all(&mut self) {
166        for bar in &mut self.bars {
167            bar.finish();
168        }
169    }
170
171    pub fn len(&self) -> usize {
172        self.bars.len()
173    }
174    pub fn is_empty(&self) -> bool {
175        self.bars.is_empty()
176    }
177}
178
179impl Default for MultiProgress {
180    fn default() -> Self {
181        Self::new()
182    }
183}
184
185/// Status panel for download output with quiet mode support.
186///
187/// Controls what gets printed to the console during downloads:
188/// - Progress updates (throttled to avoid flicker)
189/// - Completion/error messages
190/// - Summary statistics
191///
192/// When `quiet` is `true`, all output is suppressed.
193pub struct StatusPanel {
194    quiet: bool,
195    last_update: std::time::Instant,
196    update_interval_ms: u64,
197}
198
199impl StatusPanel {
200    pub fn new(quiet: bool) -> Self {
201        Self {
202            quiet,
203            last_update: std::time::Instant::now(),
204            update_interval_ms: 500,
205        }
206    }
207
208    pub fn should_update(&self) -> bool {
209        if self.quiet {
210            return false;
211        }
212        self.last_update.elapsed().as_millis() as u64 >= self.update_interval_ms
213    }
214
215    pub fn touch(&mut self) {
216        self.last_update = std::time::Instant::now();
217    }
218
219    pub fn print_download_status(&self, gid: u64, status: &str, progress: &str) {
220        if self.quiet {
221            return;
222        }
223        println!("[#{} {}] {}", gid, status, progress);
224    }
225
226    pub fn print_complete(&self, gid: u64, filename: &str, size: &str) {
227        if self.quiet {
228            return;
229        }
230        use colored::Colorize;
231        println!(
232            "[#{} {}] {} - {} ({})",
233            gid,
234            "DONE".green().bold(),
235            filename.green(),
236            size.white(),
237            format_size_str(size)
238        );
239    }
240
241    pub fn print_error(&self, gid: u64, error: &str) {
242        use colored::Colorize;
243        eprintln!("[#{} {}] {}", gid, "ERR".red().bold(), error.red());
244    }
245
246    pub fn print_summary(&self, total_files: u64, total_size: u64, elapsed_secs: f64) {
247        use colored::Colorize;
248        if self.quiet {
249            return;
250        }
251        println!();
252        println!("{}", "Download summary:".yellow());
253        println!("  Total files:   {}", total_files.to_string().white());
254        println!("  Total size:     {}", format_size(total_size).white());
255        println!(
256            "  Total time:     {}",
257            format_duration(elapsed_secs).white()
258        );
259        println!(
260            "  Average speed:   {}/s",
261            format_size((total_size as f64 / elapsed_secs.max(1.0)) as u64).white()
262        );
263    }
264}
265
266pub fn terminal_width() -> usize {
267    crossterm::terminal::size()
268        .map(|(w, _)| w as usize)
269        .unwrap_or(80)
270        .saturating_sub(10)
271}
272
273pub fn is_tty() -> bool {
274    atty::is(atty::Stream::Stdout)
275}
276
277fn format_size(bytes: u64) -> String {
278    const UNITS: &[&str] = &["B", "KiB", "MiB", "GiB", "TiB"];
279    let mut size = bytes as f64;
280    let mut unit_idx = 0;
281    while size >= 1024.0 && unit_idx < UNITS.len() - 1 {
282        size /= 1024.0;
283        unit_idx += 1;
284    }
285    if unit_idx == 0 {
286        format!("{}{}", bytes, UNITS[0])
287    } else {
288        format!("{:.1} {}", size, UNITS[unit_idx])
289    }
290}
291
292pub fn format_size_str(s: &str) -> String {
293    if let Ok(bytes) = s.parse::<u64>() {
294        format_size(bytes)
295    } else {
296        s.to_string()
297    }
298}
299
300fn format_speed(bytes_per_sec: u64) -> String {
301    if bytes_per_sec == 0 {
302        return "0 B/s".to_string();
303    }
304    format!("{}/s", format_size(bytes_per_sec))
305}
306
307pub fn format_duration(secs: f64) -> String {
308    if secs.is_nan() || secs < 0.0 {
309        return "--:--".to_string();
310    }
311    let total_secs = secs as u64;
312    let hours = total_secs / 3600;
313    let mins = (total_secs % 3600) / 60;
314    let secs_rem = total_secs % 60;
315    if hours > 0 {
316        format!("{:02}:{:02}:{:02}", hours, mins, secs_rem)
317    } else {
318        format!("{:02}:{:02}", mins, secs_rem)
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn test_progress_bar_creation() {
328        let pb = ProgressBar::new(1024);
329        assert_eq!(pb.total(), 1024);
330        assert_eq!(pb.current(), 0);
331        assert!(!pb.is_complete());
332    }
333
334    #[test]
335    fn test_progress_bar_update() {
336        let mut pb = ProgressBar::new(100);
337        pb.update(50);
338        assert_eq!(pb.current(), 50);
339        assert!(!pb.is_complete());
340
341        pb.update(100);
342        assert!(pb.is_complete());
343    }
344
345    #[test]
346    fn test_progress_bar_finish() {
347        let mut pb = ProgressBar::new(200);
348        pb.finish();
349        assert!(pb.is_complete());
350        assert_eq!(pb.current(), 200);
351    }
352
353    #[test]
354    fn test_progress_bar_render_summary() {
355        let mut pb = ProgressBar::new(1000);
356        pb.update(500);
357        let summary = pb.render_summary();
358        assert!(summary.contains("50"));
359    }
360
361    #[test]
362    fn test_multi_progress_add_and_update() {
363        let mut mp = MultiProgress::new();
364        mp.add("file1.zip".to_string(), 1000);
365        mp.add("file2.iso".to_string(), 2000);
366        assert_eq!(mp.len(), 2);
367        mp.update(0, 500);
368        mp.update(1, 1500);
369        assert_eq!(mp.bars[0].current(), 500);
370        assert_eq!(mp.bars[1].current(), 1500);
371    }
372
373    #[test]
374    fn test_multi_progress_default() {
375        let mp = MultiProgress::default();
376        assert!(mp.is_empty());
377    }
378
379    #[test]
380    fn test_format_size_bytes() {
381        assert_eq!(format_size(0), "0B");
382        assert_eq!(format_size(512), "512B");
383        assert_eq!(format_size(1024), "1.0 KiB");
384        assert_eq!(format_size(1536), "1.5 KiB");
385        assert_eq!(format_size(1048576), "1.0 MiB");
386        assert_eq!(format_size(1073741824), "1.0 GiB");
387    }
388
389    #[test]
390    fn test_format_speed() {
391        assert_eq!(format_speed(0), "0 B/s");
392        assert!(format_speed(1024).contains("KiB/s"));
393        assert!(format_speed(1048576).contains("MiB/s"));
394    }
395
396    #[test]
397    fn test_format_duration() {
398        assert_eq!(format_duration(0.0), "00:00");
399        assert_eq!(format_duration(65.0), "01:05");
400        assert_eq!(format_duration(3661.0), "01:01:01");
401        assert_eq!(format_duration(-1.0), "--:--");
402    }
403
404    #[test]
405    fn test_terminal_width_positive() {
406        let w = terminal_width();
407        assert!(w > 0);
408    }
409
410    #[test]
411    fn test_status_panel_quiet_mode() {
412        let panel = StatusPanel::new(true);
413        assert!(!panel.should_update());
414    }
415
416    #[test]
417    fn test_status_panel_verbose_mode() {
418        let panel = StatusPanel::new(false);
419        assert!(
420            !panel.should_update(),
421            "immediately after creation, interval not yet elapsed"
422        );
423    }
424
425    #[test]
426    fn test_set_total_updates_total() {
427        let mut pb = ProgressBar::new(100);
428        pb.set_total(999);
429        assert_eq!(pb.total(), 999);
430    }
431}