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
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//!
9//! ## Note
10//!
11//! Terminal environment detection ([`crate::terminal::no_color`], [`crate::terminal::no_emoji`], [`crate::terminal::no_animation`])
12//! has been moved to the [`crate::terminal`] module.
13
14use crate::common;
15use crate::terminal;
16use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
17use owo_colors::OwoColorize;
18use std::sync::Arc;
19use std::sync::atomic::{AtomicBool, Ordering};
20use std::time::Duration;
21
22/// A progress tracker for download/upload tests.
23/// Updates a single shared progress bar with live speed.
24pub struct Tracker {
25    bar: ProgressBar,
26    done: Arc<AtomicBool>,
27}
28
29impl Tracker {
30    /// Create a new progress tracker for a test phase.
31    /// `label` is something like "Download" or "Upload".
32    #[must_use]
33    pub fn new(label: &str) -> Self {
34        Self::with_target(label, ProgressDrawTarget::stderr_with_hz(10))
35    }
36
37    /// Create with a custom draw target (use `ProgressDrawTarget::hidden()` for silent mode).
38    ///
39    /// # Panics
40    ///
41    /// Panics if the progress bar template string is invalid (should never happen).
42    #[must_use]
43    pub fn with_target(label: &str, target: ProgressDrawTarget) -> Self {
44        let done = Arc::new(AtomicBool::new(false));
45        let bar = ProgressBar::with_draw_target(Some(100), target);
46
47        let style = ProgressStyle::with_template(
48            "  {prefix} {bar:40.cyan/blue} {percent:>3}%  {elapsed_precise} | {msg}",
49        )
50        .unwrap()
51        .progress_chars("█░─");
52
53        bar.set_style(style);
54        bar.set_prefix(if terminal::no_color() {
55            format!("{:<10}", format!("{}:", label))
56        } else {
57            format!("{:<10}", format!("{label}:").dimmed())
58        });
59        bar.set_message("starting...");
60        bar.set_position(0);
61
62        Self { bar, done }
63    }
64
65    /// Update the live speed and data display.
66    /// `speed_mbps` is the current speed in Mb/s (or MB/s if bytes mode).
67    /// `progress` is 0.0 to 1.0.
68    /// `bytes` is total bytes transferred so far.
69    pub fn update(&self, speed_mbps: f64, progress: f64, bytes: u64) {
70        let speed_str = if speed_mbps < 1000.0 {
71            format!("{speed_mbps:.1} Mb/s")
72        } else {
73            format!("{:.2} Gb/s", speed_mbps / 1000.0)
74        };
75
76        let data_str = common::format_data_size(bytes);
77
78        let msg = if terminal::no_color() {
79            format!("{data_str} @ {speed_str}")
80        } else {
81            format!("{} @ {}", data_str.white(), speed_str.cyan())
82        };
83
84        self.bar.set_message(msg);
85        // Safe: progress is 0.0..1.0, *100 → 0..100, fits u64.
86        let pct = (progress * 100.0).clamp(0.0, u64::MAX as f64) as u64;
87        self.bar.set_position(pct.min(100));
88    }
89
90    /// Mark the test as complete and display final speed.
91    pub fn finish(&self, final_speed_mbps: f64, total_bytes: u64) {
92        let speed_str = if final_speed_mbps < 1000.0 {
93            format!("{final_speed_mbps:.2} Mb/s")
94        } else {
95            format!("{:.2} Gb/s", final_speed_mbps / 1000.0)
96        };
97
98        let data_str = common::format_data_size(total_bytes);
99
100        self.bar.set_position(100);
101        let msg = if terminal::no_color() {
102            format!("DONE ({data_str} total @ {speed_str})")
103        } else {
104            format!(
105                "{} ({} total @ {})",
106                "DONE".green().bold(),
107                data_str.dimmed(),
108                speed_str.green()
109            )
110        };
111        self.bar.finish_with_message(msg);
112        self.done.store(true, Ordering::Relaxed);
113    }
114}
115
116/// Simple spinner for non-speed phases (server fetch, ping).
117///
118/// # Panics
119///
120/// Panics if the spinner template string is invalid (should never happen).
121#[must_use]
122pub fn create_spinner(message: &str) -> ProgressBar {
123    let pb = ProgressBar::with_draw_target(None, ProgressDrawTarget::stderr_with_hz(10));
124    pb.set_style(
125        ProgressStyle::with_template("  {spinner} {msg}")
126            .unwrap()
127            .tick_strings(&["·", "o", "O", "o"]),
128    );
129    pb.set_message(message.to_string());
130    pb.enable_steady_tick(std::time::Duration::from_millis(120));
131    pb
132}
133
134/// Finish a simple spinner with a checkmark.
135pub fn finish_ok(pb: &ProgressBar, message: &str) {
136    if terminal::no_color() {
137        pb.finish_with_message(format!("  {message}"));
138    } else {
139        pb.finish_with_message(format!("  {} {}", "✓".green(), message));
140    }
141}
142
143// ── Grade Reveal Animation (Intentional Friction) ────────────────────────────
144
145/// Animate a grade reveal with a brief "computing" pause followed by the final grade.
146/// Creates intentional friction — the user anticipates the result before it appears.
147///
148/// # Arguments
149/// * `label` — The metric being graded (e.g., "Overall", "Latency")
150/// * `grade_str` — The final grade string (already colorized if needed)
151/// * `grade_plain` — The plain grade string for no-color mode
152/// * `nc` — No-color mode flag
153pub fn reveal_grade(label: &str, grade_str: &str, grade_plain: &str, nc: bool) {
154    if nc {
155        // Brief pause for friction, then show result
156        std::thread::sleep(Duration::from_millis(300));
157        eprintln!("  {} → {grade_plain}", label.dimmed());
158    } else {
159        // Show a brief "computing" spinner
160        let spinner = create_spinner(&format!("Computing {label}..."));
161        std::thread::sleep(Duration::from_millis(400));
162        spinner.finish_and_clear();
163        eprintln!("  {label} → {grade_str}");
164    }
165}
166
167/// Animate a scan completion summary before revealing results.
168/// Shows total samples collected and overall grade.
169///
170/// # Arguments
171/// * `sample_count` — Number of samples collected
172/// * `grade_badge` — Colorized grade badge
173/// * `grade_plain` — Plain grade text for no-color mode
174/// * `nc` — No-color mode flag
175pub fn reveal_scan_complete(sample_count: usize, grade_badge: &str, grade_plain: &str, nc: bool) {
176    if terminal::no_animation() {
177        // Skip all animation for users who prefer reduced motion
178        eprintln!("  SCAN COMPLETE ✓ Scanned {sample_count} samples → {grade_plain}");
179    } else if nc {
180        std::thread::sleep(Duration::from_millis(100));
181        eprintln!(
182            "  {} ✓ Scanned {sample_count} samples → Grade: {grade_plain}",
183            "SCAN COMPLETE".bold()
184        );
185    } else {
186        // Brief pause for dramatic effect
187        std::thread::sleep(Duration::from_millis(100));
188        eprintln!(
189            "  {} {} Scanned {} samples → {}",
190            "SCAN COMPLETE".cyan().bold(),
191            "✓".green(),
192            sample_count.to_string().white().bold(),
193            grade_badge,
194        );
195    }
196}
197
198/// Brief pause between section reveals for visual breathing room.
199pub fn reveal_pause() {
200    if terminal::no_animation() {
201        return;
202    }
203    std::thread::sleep(Duration::from_millis(40));
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use serial_test::serial;
210
211    /// Set `NO_COLOR` env var for testing.
212    ///
213    /// SAFETY: Tests run single-threaded under `#[serial]`, so there is no
214    /// concurrent read/write race on the environment variable. The var is
215    /// removed in `unset_no_color()` after each test to avoid leaking state.
216    fn set_no_color() {
217        #[allow(unsafe_code)]
218        unsafe {
219            std::env::set_var("NO_COLOR", "1");
220        }
221    }
222
223    /// Remove `NO_COLOR` env var after testing.
224    ///
225    /// SAFETY: Same rationale as `set_no_color()` — serial test execution
226    /// guarantees no concurrent env access.
227    fn unset_no_color() {
228        #[allow(unsafe_code)]
229        unsafe {
230            std::env::remove_var("NO_COLOR");
231        }
232    }
233
234    #[test]
235    fn test_no_color_default() {
236        // Note: This may return true if NO_COLOR is set by another test.
237        // We just verify the function doesn't panic.
238        let _ = terminal::no_color();
239    }
240
241    #[test]
242    fn test_create_spinner() {
243        let pb = create_spinner("Testing...");
244        assert!(!pb.is_finished());
245        pb.finish_and_clear();
246    }
247
248    #[test]
249    fn test_finish_ok() {
250        let pb = create_spinner("Testing...");
251        finish_ok(&pb, "Done");
252        assert!(pb.is_finished());
253    }
254
255    #[test]
256    fn test_speed_progress_new() {
257        let sp = Tracker::new("Download");
258        assert!(!sp.done.load(Ordering::Relaxed));
259        sp.bar.finish_and_clear();
260    }
261
262    #[test]
263    fn test_speed_progress_update() {
264        let sp = Tracker::new("Download");
265        sp.update(150.5, 0.5, 1024 * 1024);
266        assert_eq!(sp.bar.position(), 50);
267        sp.bar.finish_and_clear();
268    }
269
270    #[test]
271    fn test_speed_progress_nc() {
272        set_no_color();
273        let sp = Tracker::new("Upload");
274        sp.update(50.0, 0.25, 512 * 1024);
275        assert_eq!(sp.bar.position(), 25);
276        sp.finish(50.0, 1024 * 1024);
277        assert!(sp.done.load(Ordering::Relaxed));
278        unset_no_color();
279    }
280
281    #[test]
282    #[serial]
283    fn test_no_color_env_set() {
284        set_no_color();
285        assert!(terminal::no_color());
286        unset_no_color();
287    }
288
289    #[test]
290    #[serial]
291    fn test_create_spinner_nc() {
292        set_no_color();
293        let pb = create_spinner("Testing...");
294        assert!(!pb.is_finished());
295        pb.finish_and_clear();
296        unset_no_color();
297    }
298
299    #[test]
300    #[serial]
301    fn test_finish_ok_nc() {
302        set_no_color();
303        let pb = create_spinner("Testing...");
304        finish_ok(&pb, "Done");
305        assert!(pb.is_finished());
306        unset_no_color();
307    }
308
309    #[test]
310    #[serial]
311    fn test_reveal_grade_nc() {
312        set_no_color();
313        reveal_grade("Overall", "A", "A", true);
314        unset_no_color();
315    }
316
317    #[test]
318    #[serial]
319    fn test_reveal_scan_complete_nc() {
320        set_no_color();
321        reveal_scan_complete(42, "B+", "B+", true);
322        unset_no_color();
323    }
324
325    #[test]
326    fn test_reveal_pause() {
327        // Just verify it doesn't panic
328        reveal_pause();
329    }
330}