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