Skip to main content

nd_300/speedtest/
display.rs

1use crate::config::BoxChars;
2use crate::render::table::ReportBuilder;
3use crate::VERSION;
4
5use indicatif::{ProgressBar, ProgressStyle};
6use owo_colors::OwoColorize;
7
8use super::{format_bytes, format_mbps, SpeedTestResult};
9
10/// Manages live CLI progress display for the SpeedQX binary.
11pub struct SpeedQXDisplay {
12    use_ascii: bool,
13    use_colors: bool,
14    json_mode: bool,
15}
16
17impl SpeedQXDisplay {
18    pub fn new(use_ascii: bool, use_colors: bool, json_mode: bool) -> Self {
19        Self {
20            use_ascii,
21            use_colors,
22            json_mode,
23        }
24    }
25
26    /// Print the SpeedQX header banner.
27    pub fn print_header(&self) {
28        if self.json_mode {
29            return;
30        }
31        println!();
32        if self.use_colors {
33            println!(
34                "  {} - Internet Speed Test",
35                format!("SpeedQX v{}", VERSION).cyan().bold()
36            );
37            println!("  {}", "QubeTX Developer Tools".dimmed());
38        } else {
39            println!("  SpeedQX v{} - Internet Speed Test", VERSION);
40            println!("  QubeTX Developer Tools");
41        }
42        println!();
43    }
44
45    /// Create an indicatif spinner for a phase step.
46    pub fn create_spinner(&self, step: u32, total: u32, msg: &str) -> ProgressBar {
47        if self.json_mode {
48            return ProgressBar::hidden();
49        }
50        let pb = ProgressBar::new_spinner();
51        let template = format!("  [{{spinner}}] [{}/{}] {{msg}}", step, total);
52        pb.set_style(
53            ProgressStyle::default_spinner()
54                .template(&template)
55                .unwrap_or_else(|_| ProgressStyle::default_spinner()),
56        );
57        pb.set_message(msg.to_string());
58        pb.enable_steady_tick(std::time::Duration::from_millis(80));
59        pb
60    }
61
62    /// Create an indicatif progress bar for download/upload phases.
63    pub fn create_progress_bar(&self, step: u32, total: u32, msg: &str) -> ProgressBar {
64        if self.json_mode {
65            return ProgressBar::hidden();
66        }
67        let pb = ProgressBar::new(100);
68        let template = format!("  [{{bar:20.cyan/dim}}] [{}/{}] {{msg}}", step, total);
69        pb.set_style(
70            ProgressStyle::default_bar()
71                .template(&template)
72                .unwrap_or_else(|_| ProgressStyle::default_bar())
73                .progress_chars("##-"),
74        );
75        pb.set_message(msg.to_string());
76        pb
77    }
78
79    /// Print a persistent completion line for a finished step.
80    pub fn finish_step(&self, step: u32, total: u32, msg: &str) {
81        if self.json_mode {
82            return;
83        }
84        let check = if self.use_ascii { "[OK]" } else { "\u{2713}" };
85        if self.use_colors {
86            println!("  [{}/{}] {} {}", step, total, check.green(), msg,);
87        } else {
88            println!("  [{}/{}] {} {}", step, total, check, msg);
89        }
90    }
91}
92
93/// Render the final results table for SpeedQX output.
94pub fn render_results(result: &SpeedTestResult, use_ascii: bool, use_colors: bool) -> String {
95    let chars = if use_ascii {
96        BoxChars::ascii()
97    } else {
98        BoxChars::unicode()
99    };
100
101    let label_width = 14;
102    let data_width = 27;
103
104    let single_provider = result.providers.len() == 1;
105
106    if single_provider {
107        return render_single_provider(result, chars, label_width, data_width, use_colors);
108    }
109
110    render_multi_provider(result, chars, label_width, data_width, use_colors)
111}
112
113fn render_multi_provider(
114    result: &SpeedTestResult,
115    chars: BoxChars,
116    label_width: usize,
117    data_width: usize,
118    _use_colors: bool,
119) -> String {
120    let mut builder = ReportBuilder::new(label_width, data_width, chars);
121
122    // Top border + title
123    builder = builder.full_top_border().span_row(&format!(
124        "  {:^width$}",
125        "SPEEDQX RESULTS",
126        width = label_width + data_width + 3
127    ));
128
129    builder = builder.section_header("Averaged Results");
130
131    // Ping
132    if let Some(ping) = result.ping_ms {
133        builder = builder.row("Ping", &format!("{:.1} ms", ping));
134    }
135
136    // Jitter
137    if let Some(jitter) = result.jitter_ms {
138        builder = builder.row("Jitter", &format!("{:.1} ms", jitter));
139    }
140
141    // Download / Upload
142    builder = builder.row(
143        "Download",
144        &format!("{} (avg)", format_mbps(result.download_mbps)),
145    );
146    builder = builder.row(
147        "Upload",
148        &format!("{} (avg)", format_mbps(result.upload_mbps)),
149    );
150
151    // Packet loss
152    if let Some(loss) = result.packet_loss_pct {
153        builder = builder.row("Packet Loss", &format!("{}%", loss));
154    }
155
156    // Duration
157    builder = builder.row("Duration", &format!("{:.1}s", result.duration_s));
158
159    // Stability metrics
160    if let Some(ref stability) = result.stability {
161        let dl_label = if stability.download_stable {
162            "Stable"
163        } else {
164            "Variable"
165        };
166        let ul_label = if stability.upload_stable {
167            "Stable"
168        } else {
169            "Variable"
170        };
171        builder = builder.row(
172            "Stability",
173            &format!(
174                "DL: {} (CV {:.0}%) / UL: {} (CV {:.0}%)",
175                dl_label,
176                stability.download_cv * 100.0,
177                ul_label,
178                stability.upload_cv * 100.0,
179            ),
180        );
181    }
182
183    // Provider divergence warning
184    if let Some(ref div) = result.provider_divergence {
185        if div.significant {
186            builder = builder.row(
187                "Divergence",
188                &format!(
189                    "DL {:.0}% / UL {:.0}% (significant)",
190                    div.download * 100.0,
191                    div.upload * 100.0,
192                ),
193            );
194        }
195    }
196
197    // Per-provider sections
198    for provider in &result.providers {
199        builder = render_provider_section(builder, provider);
200    }
201
202    let mut output = builder.finish();
203    output.push('\n');
204    output
205}
206
207fn render_single_provider(
208    result: &SpeedTestResult,
209    chars: BoxChars,
210    label_width: usize,
211    data_width: usize,
212    _use_colors: bool,
213) -> String {
214    let mut builder = ReportBuilder::new(label_width, data_width, chars);
215
216    builder = builder.full_top_border().span_row(&format!(
217        "  {:^width$}",
218        "SPEEDQX RESULTS",
219        width = label_width + data_width + 3
220    ));
221
222    if let Some(provider) = result.providers.first() {
223        builder = builder.section_header(&provider.provider);
224
225        // Server
226        builder = builder.row("Server", &provider.server);
227
228        // Location
229        if let Some(ref loc) = provider.location {
230            builder = builder.row("Location", loc);
231        }
232
233        // Ping
234        if let Some(ping) = provider.ping_ms {
235            builder = builder.row("Ping", &format!("{:.1} ms", ping));
236        }
237
238        // Jitter
239        if let Some(jitter) = provider.jitter_ms {
240            builder = builder.row("Jitter", &format!("{:.1} ms", jitter));
241        }
242
243        // Download / Upload
244        if let Some(dl) = provider.download_mbps {
245            builder = builder.row("Download", &format_mbps(dl));
246        }
247        if let Some(ul) = provider.upload_mbps {
248            builder = builder.row("Upload", &format_mbps(ul));
249        }
250
251        // Data transferred
252        builder = builder.row("DL Data", &format_bytes(provider.download_bytes));
253        builder = builder.row("UL Data", &format_bytes(provider.upload_bytes));
254
255        // Packet loss
256        if let Some(loss) = provider.packet_loss_pct {
257            builder = builder.row("Packet Loss", &format!("{}%", loss));
258        }
259
260        // Duration
261        builder = builder.row("Duration", &format!("{:.1}s", result.duration_s));
262    }
263
264    let mut output = builder.finish();
265    output.push('\n');
266    output
267}
268
269fn render_provider_section(
270    builder: ReportBuilder,
271    provider: &super::ProviderResult,
272) -> ReportBuilder {
273    let mut b = builder.section_header(&provider.provider);
274
275    // Error case
276    if let Some(ref err) = provider.error {
277        b = b.row("Error", err);
278        return b;
279    }
280
281    // Server
282    b = b.row("Server", &provider.server);
283
284    // Location
285    if let Some(ref loc) = provider.location {
286        b = b.row("Location", loc);
287    }
288
289    // Ping
290    if let Some(ping) = provider.ping_ms {
291        b = b.row("Ping", &format!("{:.1} ms", ping));
292    }
293
294    // Jitter
295    if let Some(jitter) = provider.jitter_ms {
296        b = b.row("Jitter", &format!("{:.1} ms", jitter));
297    }
298
299    // Download / Upload
300    if let Some(dl) = provider.download_mbps {
301        b = b.row("Download", &format_mbps(dl));
302    }
303    if let Some(ul) = provider.upload_mbps {
304        b = b.row("Upload", &format_mbps(ul));
305    }
306
307    // Data transferred
308    b = b.row("DL Data", &format_bytes(provider.download_bytes));
309    b = b.row("UL Data", &format_bytes(provider.upload_bytes));
310
311    b
312}