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
10pub 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 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 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 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 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
93pub 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 agreement_label(a: &super::AgreementInfo) -> String {
115 let band = match a.band {
116 super::statistics::AgreementBand::High => "High",
117 super::statistics::AgreementBand::Moderate => "Moderate",
118 super::statistics::AgreementBand::Low => "Low",
119 super::statistics::AgreementBand::VeryLow => "Very low",
120 super::statistics::AgreementBand::Insufficient => "Insufficient",
121 };
122 match a.i2 {
123 Some(i2) => format!("{} (I\u{b2} {:.0}%)", band, i2 * 100.0),
124 None => band.to_string(),
125 }
126}
127
128fn ci_margin(ci: &Option<super::statistics::CiBounds>) -> String {
130 ci.as_ref()
131 .map(|c| (c.upper - c.lower) / 2.0)
132 .filter(|m| m.is_finite() && *m > 0.0)
133 .map(|m| format!(" \u{b1}{}", format_mbps(m)))
134 .unwrap_or_default()
135}
136
137fn render_multi_provider(
138 result: &SpeedTestResult,
139 chars: BoxChars,
140 label_width: usize,
141 data_width: usize,
142 use_colors: bool,
143) -> String {
144 let mut builder = ReportBuilder::new(label_width, data_width, chars);
145
146 builder = builder.full_top_border().span_row(&format!(
148 " {:^width$}",
149 "SPEEDQX RESULTS",
150 width = label_width + data_width + 3
151 ));
152
153 builder = builder.section_header("Capacity");
155
156 builder = builder.row(
157 "Download",
158 &format!(
159 "{}{}",
160 format_mbps(result.download_mbps),
161 ci_margin(&result.capacity.download_ci)
162 ),
163 );
164 builder = builder.row(
165 "Upload",
166 &format!(
167 "{}{}",
168 format_mbps(result.upload_mbps),
169 ci_margin(&result.capacity.upload_ci)
170 ),
171 );
172
173 if let Some(ping) = result.ping_ms {
174 builder = builder.row("Ping (min-RTT)", &format!("{:.1} ms", ping));
175 }
176 if let Some(jitter) = result.jitter_ms {
177 builder = builder.row("Jitter (PDV)", &format!("{:.1} ms", jitter));
178 }
179 if let Some(bb) = &result.bufferbloat {
180 builder = builder.row(
181 "Bufferbloat",
182 &format!("{} ({:.0} ms)", bb.grade.as_str(), bb.delta_ms),
183 );
184 }
185 if let Some(rpm) = result.rpm {
186 builder = builder.row("Responsiveness", &format!("{:.0} RPM", rpm));
187 }
188 if let Some(loss) = result.packet_loss_pct {
189 builder = builder.row("Packet Loss", &format!("{:.0}%", loss));
190 }
191
192 builder = builder.section_header("Quality");
194
195 builder = builder.row(
196 "Consensus DL",
197 &format!(
198 "{}{}",
199 format_mbps(result.consensus.download),
200 ci_margin(&result.consensus.download_ci)
201 ),
202 );
203 builder = builder.row(
204 "Consensus UL",
205 &format!(
206 "{}{}",
207 format_mbps(result.consensus.upload),
208 ci_margin(&result.consensus.upload_ci)
209 ),
210 );
211 builder = builder.row("Agreement (DL)", &agreement_label(&result.agreement));
212 if let Some(ref ua) = result.upload_agreement {
213 builder = builder.row("Agreement (UL)", &agreement_label(ua));
214 }
215
216 if let Some(ref stability) = result.stability {
217 let dl_label = if stability.download_stable {
218 "Stable"
219 } else {
220 "Variable"
221 };
222 let ul_label = if stability.upload_stable {
223 "Stable"
224 } else {
225 "Variable"
226 };
227 builder = builder.row(
228 "Stability",
229 &format!(
230 "DL: {} (CV {:.0}%) / UL: {} (CV {:.0}%)",
231 dl_label,
232 stability.download_cv * 100.0,
233 ul_label,
234 stability.upload_cv * 100.0,
235 ),
236 );
237 }
238
239 if !result.merge_exclusions.is_empty() {
241 let list = result
242 .merge_exclusions
243 .iter()
244 .map(|e| {
245 format!(
246 "{} {} ({} sample{})",
247 e.provider,
248 if e.direction == "download" {
249 "DL"
250 } else {
251 "UL"
252 },
253 e.samples,
254 if e.samples == 1 { "" } else { "s" },
255 )
256 })
257 .collect::<Vec<_>>()
258 .join(", ");
259 builder = builder.row("Excluded", &list);
260 }
261
262 builder = builder.row("Duration", &format!("{:.1}s", result.duration_s));
263
264 for provider in &result.providers {
266 builder = render_provider_section(builder, provider);
267 }
268
269 let mut output = builder.finish();
270
271 let footer = format!(" SQX methodology {}", result.methodology_version);
273 if use_colors {
274 output.push_str(&format!("{}\n", owo_colors::OwoColorize::dimmed(&footer)));
275 } else {
276 output.push_str(&footer);
277 output.push('\n');
278 }
279 output.push('\n');
280 output
281}
282
283fn render_single_provider(
284 result: &SpeedTestResult,
285 chars: BoxChars,
286 label_width: usize,
287 data_width: usize,
288 _use_colors: bool,
289) -> String {
290 let mut builder = ReportBuilder::new(label_width, data_width, chars);
291
292 builder = builder.full_top_border().span_row(&format!(
293 " {:^width$}",
294 "SPEEDQX RESULTS",
295 width = label_width + data_width + 3
296 ));
297
298 if let Some(provider) = result.providers.first() {
299 builder = builder.section_header(&provider.provider);
300
301 builder = builder.row("Server", &provider.server);
303
304 if let Some(ref loc) = provider.location {
306 builder = builder.row("Location", loc);
307 }
308
309 if let Some(ping) = provider.ping_ms {
311 builder = builder.row("Ping", &format!("{:.1} ms", ping));
312 }
313
314 if let Some(jitter) = provider.jitter_ms {
316 builder = builder.row("Jitter", &format!("{:.1} ms", jitter));
317 }
318
319 if let Some(dl) = provider.download_mbps {
321 builder = builder.row("Download", &format_mbps(dl));
322 }
323 if let Some(ul) = provider.upload_mbps {
324 builder = builder.row("Upload", &format_mbps(ul));
325 }
326
327 builder = builder.row("DL Data", &format_bytes(provider.download_bytes));
329 builder = builder.row("UL Data", &format_bytes(provider.upload_bytes));
330
331 if let Some(loss) = provider.packet_loss_pct {
333 builder = builder.row("Packet Loss", &format!("{}%", loss));
334 }
335
336 builder = builder.row("Duration", &format!("{:.1}s", result.duration_s));
338 }
339
340 let mut output = builder.finish();
341 output.push('\n');
342 output
343}
344
345fn render_provider_section(
346 builder: ReportBuilder,
347 provider: &super::ProviderResult,
348) -> ReportBuilder {
349 let mut b = builder.section_header(&provider.provider);
350
351 if provider.availability == super::ProviderAvailability::UnavailablePlatform {
353 b = b.row("Status", "unavailable (platform)");
354 return b;
355 }
356
357 if let Some(ref err) = provider.error {
359 b = b.row("Error", err);
360 return b;
361 }
362
363 b = b.row("Server", &provider.server);
365
366 if let Some(ref loc) = provider.location {
368 b = b.row("Location", loc);
369 }
370
371 if let Some(ping) = provider.ping_ms {
373 b = b.row("Ping", &format!("{:.1} ms", ping));
374 }
375
376 if let Some(jitter) = provider.jitter_ms {
378 b = b.row("Jitter", &format!("{:.1} ms", jitter));
379 }
380
381 if let Some(dl) = provider.download_mbps {
383 b = b.row("Download", &format_mbps(dl));
384 }
385 if let Some(ul) = provider.upload_mbps {
386 b = b.row("Upload", &format_mbps(ul));
387 }
388
389 b = b.row("DL Data", &format_bytes(provider.download_bytes));
391 b = b.row("UL Data", &format_bytes(provider.upload_bytes));
392
393 b
394}