Skip to main content

netspeed_cli/
progress.rs

1//! Terminal progress bars and spinners for test feedback.
2//!
3//! This module provides user interface components for test progress:
4//! - [`Tracker`] — Progress bar with real-time speed display and sparkline
5//! - Spinners for individual test phases (server discovery, ping, etc.)
6//! - Colorized finish messages with test results
7//! - Grade reveal animation for intentional friction
8
9use crate::common;
10use crate::terminal;
11use crate::theme::Colors;
12use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
13use owo_colors::OwoColorize;
14use std::sync::atomic::{AtomicBool, Ordering};
15use std::sync::{Arc, Mutex};
16use std::time::Duration;
17
18const SPARKLINE_CHARS: &[char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
19const SPARKLINE_LEN: usize = 12;
20const MIN_BAR_WIDTH: usize = 20;
21
22fn adaptive_bar_width() -> usize {
23    let term_w = common::get_terminal_width().unwrap_or(100) as usize;
24    let available = term_w.saturating_sub(52);
25    available.clamp(MIN_BAR_WIDTH, 50)
26}
27
28fn speed_color(speed_mbps: f64, theme: crate::theme::Theme) -> String {
29    if speed_mbps >= 100.0 {
30        Colors::good(&format!("{speed_mbps:.1} Mb/s"), theme)
31    } else if speed_mbps >= 25.0 {
32        Colors::info(&format!("{speed_mbps:.1} Mb/s"), theme)
33    } else if speed_mbps >= 5.0 {
34        Colors::warn(&format!("{speed_mbps:.1} Mb/s"), theme)
35    } else {
36        Colors::bad(&format!("{speed_mbps:.1} Mb/s"), theme)
37    }
38}
39
40fn speed_trend(samples: &[f64]) -> &'static str {
41    if samples.len() < 6 {
42        return "→";
43    }
44    let n = samples.len();
45    let recent_count = 2;
46    let older_count = 3;
47    let recent_avg: f64 =
48        samples[n - recent_count..].iter().copied().sum::<f64>() / recent_count as f64;
49    let older_avg: f64 = samples[n - recent_count - older_count..n - recent_count]
50        .iter()
51        .copied()
52        .sum::<f64>()
53        / older_count as f64;
54    let ratio = recent_avg / older_avg.max(0.01);
55    if ratio > 1.05 {
56        "↑"
57    } else if ratio < 0.95 {
58        "↓"
59    } else {
60        "→"
61    }
62}
63
64fn render_sparkline(samples: &[f64]) -> String {
65    if samples.len() < 2 {
66        return String::new();
67    }
68    let min = samples.iter().cloned().reduce(f64::min).unwrap_or(0.0);
69    let max = samples.iter().cloned().reduce(f64::max).unwrap_or(1.0);
70    let range = max - min;
71    if range < 0.001 {
72        return SPARKLINE_CHARS[4].to_string().repeat(SPARKLINE_LEN);
73    }
74    let step = if samples.len() > SPARKLINE_LEN {
75        samples.len() / SPARKLINE_LEN
76    } else {
77        1
78    };
79    let sampled: Vec<f64> = (0..SPARKLINE_LEN)
80        .map(|i| {
81            let idx = ((i * step) + (step / 2)).min(samples.len() - 1);
82            samples[idx]
83        })
84        .collect();
85    sampled
86        .iter()
87        .map(|s| {
88            let norm = ((s - min) / range).clamp(0.0, 1.0);
89            let idx = (norm * (SPARKLINE_CHARS.len() - 1) as f64).round() as usize;
90            SPARKLINE_CHARS[idx.clamp(0, SPARKLINE_CHARS.len() - 1)]
91        })
92        .collect()
93}
94
95/// A progress tracker for download/upload tests.
96/// Updates a single shared progress bar with live speed, sparkline, and trend.
97pub struct Tracker {
98    bar: ProgressBar,
99    done: Arc<AtomicBool>,
100    speed_samples: Mutex<Vec<f64>>,
101}
102
103// SAFETY: Tracker is only used from a single async task (download/upload loop)
104// with shared reference through Arc. The internal Mutex protects speed_samples.
105unsafe impl Send for Tracker {}
106unsafe impl Sync for Tracker {}
107
108impl Tracker {
109    #[must_use]
110    pub fn new(label: &str) -> Self {
111        Self::with_target(label, ProgressDrawTarget::stderr_with_hz(10))
112    }
113
114    #[must_use]
115    pub fn new_animated(label: &str) -> Self {
116        if terminal::no_animation() {
117            return Self::new(label);
118        }
119        Self::with_target_animated(label, ProgressDrawTarget::stderr_with_hz(10))
120    }
121
122    #[must_use]
123    pub fn with_target(label: &str, target: ProgressDrawTarget) -> Self {
124        let done = Arc::new(AtomicBool::new(false));
125        let bar = ProgressBar::with_draw_target(Some(100), target);
126        let bw = adaptive_bar_width();
127
128        let tmpl = format!(
129            "  {{prefix}} {{bar:{bw}.cyan/blue}} {{percent:>3}}%  {{elapsed_precise}} | {{msg}}"
130        );
131        let style = ProgressStyle::with_template(&tmpl)
132            .unwrap()
133            .progress_chars("━░─");
134
135        bar.set_style(style);
136        let arrow = if label.starts_with('D') {
137            "↓ "
138        } else if label.starts_with('U') {
139            "↑ "
140        } else {
141            "  "
142        };
143        bar.set_prefix(if terminal::no_color() {
144            format!("{:<12}", format!("{arrow}{label}:"))
145        } else {
146            format!("{:<12}", format!("{arrow}{label}:").dimmed())
147        });
148        bar.set_message("starting...");
149        bar.set_position(0);
150
151        Self {
152            bar,
153            done,
154            speed_samples: Mutex::new(Vec::new()),
155        }
156    }
157
158    fn with_target_animated(label: &str, target: ProgressDrawTarget) -> Self {
159        let done = Arc::new(AtomicBool::new(false));
160        let bar = ProgressBar::with_draw_target(Some(100), target);
161        let bw = adaptive_bar_width();
162
163        let tmpl = format!(
164            "  {{prefix}} {{spinner}} {{bar:{bw}.cyan/blue}} {{percent:>3}}%  {{elapsed_precise}} | {{msg}}"
165        );
166        let style = ProgressStyle::with_template(&tmpl)
167            .unwrap()
168            .progress_chars("━░─")
169            .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", "⠏"]);
170
171        bar.set_style(style);
172        let arrow = if label.starts_with('D') {
173            "↓ "
174        } else if label.starts_with('U') {
175            "↑ "
176        } else {
177            "  "
178        };
179        bar.set_prefix(if terminal::no_color() {
180            format!("{:<12}", format!("{arrow}{label}:"))
181        } else {
182            format!("{:<12}", format!("{arrow}{label}:").dimmed())
183        });
184        bar.set_message("starting...");
185        bar.set_position(0);
186        bar.enable_steady_tick(Duration::from_millis(100));
187
188        Self {
189            bar,
190            done,
191            speed_samples: Mutex::new(Vec::new()),
192        }
193    }
194
195    pub fn update(&self, speed_mbps: f64, progress: f64, bytes: u64) {
196        {
197            let mut samples = self.speed_samples.lock().unwrap();
198            samples.push(speed_mbps);
199            if samples.len() > 60 {
200                let drain = samples.len() - 60;
201                samples.drain(..drain);
202            }
203        }
204
205        let data_str = common::format_data_size(bytes);
206
207        let msg = if terminal::no_color() {
208            let speed_str = format!("{speed_mbps:.1} Mb/s");
209            format!("{data_str} @ {speed_str}")
210        } else {
211            let speed_colored = speed_color(speed_mbps, crate::theme::Theme::Dark);
212            let samples = self.speed_samples.lock().unwrap();
213            let sparkline = render_sparkline(&samples);
214            let trend = speed_trend(&samples);
215            if sparkline.is_empty() {
216                format!("{} @ {}", data_str.white(), speed_colored)
217            } else {
218                format!(
219                    "{} {} {} @ {}",
220                    data_str.white(),
221                    sparkline.dimmed(),
222                    trend,
223                    speed_colored
224                )
225            }
226        };
227
228        self.bar.set_message(msg);
229        let pct = (progress * 100.0).clamp(0.0, u64::MAX as f64) as u64;
230        self.bar.set_position(pct.min(100));
231    }
232
233    pub fn finish(&self, final_speed_mbps: f64, total_bytes: u64, theme: crate::theme::Theme) {
234        let speed_str = if final_speed_mbps < 1000.0 {
235            format!("{final_speed_mbps:.2} Mb/s")
236        } else {
237            format!("{:.2} Gb/s", final_speed_mbps / 1000.0)
238        };
239
240        let data_str = common::format_data_size(total_bytes);
241
242        self.bar.set_position(100);
243        let msg = if terminal::no_color() {
244            format!("DONE ({data_str} total @ {speed_str})")
245        } else {
246            format!(
247                "{} ({} total @ {})",
248                Colors::good("DONE", theme),
249                data_str.dimmed(),
250                Colors::good(&speed_str, theme)
251            )
252        };
253        self.bar.finish_with_message(msg);
254        self.done.store(true, Ordering::Relaxed);
255    }
256}
257
258#[must_use]
259pub fn create_spinner(message: &str) -> ProgressBar {
260    let pb = ProgressBar::with_draw_target(None, ProgressDrawTarget::stderr_with_hz(10));
261    pb.set_style(
262        ProgressStyle::with_template("  {spinner} {msg}")
263            .unwrap()
264            .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", "⠏"]),
265    );
266    pb.set_message(message.to_string());
267    pb.enable_steady_tick(std::time::Duration::from_millis(120));
268    pb
269}
270
271pub fn finish_ok(pb: &ProgressBar, message: &str, theme: crate::theme::Theme) {
272    if terminal::no_color() {
273        pb.finish_with_message(format!("  {message}"));
274    } else {
275        pb.finish_with_message(format!("  {} {}", Colors::good("◉", theme), message));
276    }
277}
278
279// ── Grade Reveal Animation (Intentional Friction) ────────────────────────────
280
281pub fn reveal_grade(label: &str, grade_str: &str, grade_plain: &str, nc: bool) {
282    if nc {
283        std::thread::sleep(Duration::from_millis(300));
284        eprintln!("  {label} → {grade_plain}");
285    } else {
286        let spinner = create_spinner(&format!("Computing {label}..."));
287        std::thread::sleep(Duration::from_millis(400));
288        spinner.finish_and_clear();
289        eprintln!("  {label} → {grade_str}");
290    }
291}
292
293pub fn reveal_scan_complete(
294    sample_count: usize,
295    grade_badge: &str,
296    grade_plain: &str,
297    nc: bool,
298    theme: crate::theme::Theme,
299) {
300    if terminal::no_animation() {
301        eprintln!("  ◉ Scanned {sample_count} samples  {grade_plain}");
302    } else if nc {
303        std::thread::sleep(Duration::from_millis(100));
304        eprintln!("  ◉ Scanned {sample_count} samples  Grade: {grade_plain}");
305    } else {
306        std::thread::sleep(Duration::from_millis(100));
307        eprintln!(
308            "  {}  Scanned {} samples  {}",
309            Colors::good("◉", theme),
310            Colors::bold(&sample_count.to_string(), theme),
311            grade_badge,
312        );
313    }
314}
315
316pub fn reveal_pause() {
317    if terminal::no_animation() {
318        return;
319    }
320    std::thread::sleep(Duration::from_millis(40));
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use serial_test::serial;
327
328    fn set_no_color() {
329        #[allow(unsafe_code)]
330        unsafe {
331            std::env::set_var("NO_COLOR", "1");
332        }
333    }
334
335    fn unset_no_color() {
336        #[allow(unsafe_code)]
337        unsafe {
338            std::env::remove_var("NO_COLOR");
339        }
340    }
341
342    #[test]
343    fn test_no_color_default() {
344        let _ = terminal::no_color();
345    }
346
347    #[test]
348    fn test_create_spinner() {
349        let pb = create_spinner("Testing...");
350        assert!(!pb.is_finished());
351        pb.finish_and_clear();
352    }
353
354    #[test]
355    fn test_finish_ok() {
356        let pb = create_spinner("Testing...");
357        finish_ok(&pb, "Done", crate::theme::Theme::Dark);
358        assert!(pb.is_finished());
359    }
360
361    #[test]
362    fn test_speed_progress_new() {
363        let sp = Tracker::new("Download");
364        assert!(!sp.done.load(Ordering::Relaxed));
365        sp.bar.finish_and_clear();
366    }
367
368    #[test]
369    fn test_speed_progress_update() {
370        let sp = Tracker::new("Download");
371        sp.update(150.5, 0.5, 1024 * 1024);
372        assert_eq!(sp.bar.position(), 50);
373        sp.bar.finish_and_clear();
374    }
375
376    #[test]
377    fn test_speed_progress_with_sparkline() {
378        let sp = Tracker::new("Download");
379        for i in 1..=20 {
380            sp.update(50.0 + i as f64 * 2.0, i as f64 / 20.0, 1024 * 1024);
381        }
382        let samples = sp.speed_samples.lock().unwrap();
383        assert_eq!(samples.len(), 20);
384        let sparkline = render_sparkline(&samples);
385        assert!(!sparkline.is_empty());
386        assert_eq!(sparkline.chars().count(), SPARKLINE_LEN);
387        sp.bar.finish_and_clear();
388    }
389
390    #[test]
391    fn test_speed_progress_nc() {
392        set_no_color();
393        let sp = Tracker::new("Upload");
394        sp.update(50.0, 0.25, 512 * 1024);
395        assert_eq!(sp.bar.position(), 25);
396        sp.finish(50.0, 1024 * 1024, crate::theme::Theme::Dark);
397        assert!(sp.done.load(Ordering::Relaxed));
398        unset_no_color();
399    }
400
401    #[test]
402    fn test_speed_trend_up() {
403        let samples = vec![10.0, 20.0, 30.0, 40.0, 50.0, 60.0];
404        assert_eq!(speed_trend(&samples), "↑");
405    }
406
407    #[test]
408    fn test_speed_trend_down() {
409        let samples = vec![60.0, 50.0, 40.0, 30.0, 20.0, 10.0];
410        assert_eq!(speed_trend(&samples), "↓");
411    }
412
413    #[test]
414    fn test_speed_trend_stable() {
415        let samples = vec![50.0, 51.0, 49.0, 50.0, 51.0, 50.0];
416        assert_eq!(speed_trend(&samples), "→");
417    }
418
419    #[test]
420    fn test_speed_trend_few_samples() {
421        assert_eq!(speed_trend(&[10.0, 20.0]), "→");
422    }
423
424    #[test]
425    fn test_render_sparkline_basic() {
426        let samples = vec![10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0];
427        let sparkline = render_sparkline(&samples);
428        assert_eq!(sparkline.chars().count(), SPARKLINE_LEN);
429    }
430
431    #[test]
432    fn test_render_sparkline_flat() {
433        let samples = vec![50.0; 10];
434        let sparkline = render_sparkline(&samples);
435        assert_eq!(sparkline.chars().count(), SPARKLINE_LEN);
436        let chars: Vec<char> = sparkline.chars().collect();
437        assert!(chars.windows(2).all(|w| w[0] == w[1]));
438    }
439
440    #[test]
441    fn test_render_sparkline_empty() {
442        let sparkline = render_sparkline(&[]);
443        assert!(sparkline.is_empty());
444    }
445
446    #[test]
447    fn test_render_sparkline_single() {
448        let sparkline = render_sparkline(&[42.0]);
449        assert!(sparkline.is_empty());
450    }
451
452    #[test]
453    fn test_speed_color_good() {
454        let colored = speed_color(150.0, crate::theme::Theme::Dark);
455        assert!(colored.contains("150.0"));
456    }
457
458    #[test]
459    fn test_speed_color_warn() {
460        let colored = speed_color(10.0, crate::theme::Theme::Dark);
461        assert!(colored.contains("10.0"));
462    }
463
464    #[test]
465    fn test_speed_color_bad() {
466        let colored = speed_color(2.0, crate::theme::Theme::Dark);
467        assert!(colored.contains("2.0"));
468    }
469
470    #[test]
471    fn test_adaptive_bar_width() {
472        let w = adaptive_bar_width();
473        assert!(w >= MIN_BAR_WIDTH);
474        assert!(w <= 50);
475    }
476
477    #[test]
478    #[serial]
479    fn test_no_color_env_set() {
480        set_no_color();
481        assert!(terminal::no_color());
482        unset_no_color();
483    }
484
485    #[test]
486    #[serial]
487    fn test_create_spinner_nc() {
488        set_no_color();
489        let pb = create_spinner("Testing...");
490        assert!(!pb.is_finished());
491        pb.finish_and_clear();
492        unset_no_color();
493    }
494
495    #[test]
496    #[serial]
497    fn test_finish_ok_nc() {
498        set_no_color();
499        let pb = create_spinner("Testing...");
500        finish_ok(&pb, "Done", crate::theme::Theme::Dark);
501        assert!(pb.is_finished());
502        unset_no_color();
503    }
504
505    #[test]
506    #[serial]
507    fn test_reveal_grade_nc() {
508        set_no_color();
509        reveal_grade("Overall", "A", "A", true);
510        unset_no_color();
511    }
512
513    #[test]
514    #[serial]
515    fn test_reveal_scan_complete_nc() {
516        set_no_color();
517        reveal_scan_complete(42, "B+", "B+", true, crate::theme::Theme::Dark);
518        unset_no_color();
519    }
520
521    #[test]
522    fn test_reveal_pause() {
523        reveal_pause();
524    }
525}