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        // SAFETY: tests in this module run serially via #[serial]; no concurrent set_var calls.
330        #[allow(unsafe_code)]
331        unsafe {
332            std::env::set_var("NO_COLOR", "1");
333        }
334    }
335
336    fn unset_no_color() {
337        // SAFETY: tests in this module run serially via #[serial]; no concurrent remove_var calls.
338        #[allow(unsafe_code)]
339        unsafe {
340            std::env::remove_var("NO_COLOR");
341        }
342    }
343
344    #[test]
345    fn test_no_color_default() {
346        let _ = terminal::no_color();
347    }
348
349    #[test]
350    fn test_create_spinner() {
351        let pb = create_spinner("Testing...");
352        assert!(!pb.is_finished());
353        pb.finish_and_clear();
354    }
355
356    #[test]
357    fn test_finish_ok() {
358        let pb = create_spinner("Testing...");
359        finish_ok(&pb, "Done", crate::theme::Theme::Dark);
360        assert!(pb.is_finished());
361    }
362
363    #[test]
364    fn test_speed_progress_new() {
365        let sp = Tracker::new("Download");
366        assert!(!sp.done.load(Ordering::Relaxed));
367        sp.bar.finish_and_clear();
368    }
369
370    #[test]
371    fn test_speed_progress_update() {
372        let sp = Tracker::new("Download");
373        sp.update(150.5, 0.5, 1024 * 1024);
374        assert_eq!(sp.bar.position(), 50);
375        sp.bar.finish_and_clear();
376    }
377
378    #[test]
379    fn test_speed_progress_with_sparkline() {
380        let sp = Tracker::new("Download");
381        for i in 1..=20 {
382            sp.update(50.0 + i as f64 * 2.0, i as f64 / 20.0, 1024 * 1024);
383        }
384        let samples = sp.speed_samples.lock().unwrap();
385        assert_eq!(samples.len(), 20);
386        let sparkline = render_sparkline(&samples);
387        assert!(!sparkline.is_empty());
388        assert_eq!(sparkline.chars().count(), SPARKLINE_LEN);
389        sp.bar.finish_and_clear();
390    }
391
392    #[test]
393    fn test_speed_progress_nc() {
394        set_no_color();
395        let sp = Tracker::new("Upload");
396        sp.update(50.0, 0.25, 512 * 1024);
397        assert_eq!(sp.bar.position(), 25);
398        sp.finish(50.0, 1024 * 1024, crate::theme::Theme::Dark);
399        assert!(sp.done.load(Ordering::Relaxed));
400        unset_no_color();
401    }
402
403    #[test]
404    fn test_speed_trend_up() {
405        let samples = vec![10.0, 20.0, 30.0, 40.0, 50.0, 60.0];
406        assert_eq!(speed_trend(&samples), "↑");
407    }
408
409    #[test]
410    fn test_speed_trend_down() {
411        let samples = vec![60.0, 50.0, 40.0, 30.0, 20.0, 10.0];
412        assert_eq!(speed_trend(&samples), "↓");
413    }
414
415    #[test]
416    fn test_speed_trend_stable() {
417        let samples = vec![50.0, 51.0, 49.0, 50.0, 51.0, 50.0];
418        assert_eq!(speed_trend(&samples), "→");
419    }
420
421    #[test]
422    fn test_speed_trend_few_samples() {
423        assert_eq!(speed_trend(&[10.0, 20.0]), "→");
424    }
425
426    #[test]
427    fn test_render_sparkline_basic() {
428        let samples = vec![10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0];
429        let sparkline = render_sparkline(&samples);
430        assert_eq!(sparkline.chars().count(), SPARKLINE_LEN);
431    }
432
433    #[test]
434    fn test_render_sparkline_flat() {
435        let samples = vec![50.0; 10];
436        let sparkline = render_sparkline(&samples);
437        assert_eq!(sparkline.chars().count(), SPARKLINE_LEN);
438        let chars: Vec<char> = sparkline.chars().collect();
439        assert!(chars.windows(2).all(|w| w[0] == w[1]));
440    }
441
442    #[test]
443    fn test_render_sparkline_empty() {
444        let sparkline = render_sparkline(&[]);
445        assert!(sparkline.is_empty());
446    }
447
448    #[test]
449    fn test_render_sparkline_single() {
450        let sparkline = render_sparkline(&[42.0]);
451        assert!(sparkline.is_empty());
452    }
453
454    #[test]
455    fn test_speed_color_good() {
456        let colored = speed_color(150.0, crate::theme::Theme::Dark);
457        assert!(colored.contains("150.0"));
458    }
459
460    #[test]
461    fn test_speed_color_warn() {
462        let colored = speed_color(10.0, crate::theme::Theme::Dark);
463        assert!(colored.contains("10.0"));
464    }
465
466    #[test]
467    fn test_speed_color_bad() {
468        let colored = speed_color(2.0, crate::theme::Theme::Dark);
469        assert!(colored.contains("2.0"));
470    }
471
472    #[test]
473    fn test_adaptive_bar_width() {
474        let w = adaptive_bar_width();
475        assert!(w >= MIN_BAR_WIDTH);
476        assert!(w <= 50);
477    }
478
479    #[test]
480    #[serial]
481    fn test_no_color_env_set() {
482        set_no_color();
483        assert!(terminal::no_color());
484        unset_no_color();
485    }
486
487    #[test]
488    #[serial]
489    fn test_create_spinner_nc() {
490        set_no_color();
491        let pb = create_spinner("Testing...");
492        assert!(!pb.is_finished());
493        pb.finish_and_clear();
494        unset_no_color();
495    }
496
497    #[test]
498    #[serial]
499    fn test_finish_ok_nc() {
500        set_no_color();
501        let pb = create_spinner("Testing...");
502        finish_ok(&pb, "Done", crate::theme::Theme::Dark);
503        assert!(pb.is_finished());
504        unset_no_color();
505    }
506
507    #[test]
508    #[serial]
509    fn test_reveal_grade_nc() {
510        set_no_color();
511        reveal_grade("Overall", "A", "A", true);
512        unset_no_color();
513    }
514
515    #[test]
516    #[serial]
517    fn test_reveal_scan_complete_nc() {
518        set_no_color();
519        reveal_scan_complete(42, "B+", "B+", true, crate::theme::Theme::Dark);
520        unset_no_color();
521    }
522
523    #[test]
524    fn test_reveal_pause() {
525        reveal_pause();
526    }
527}