use crate::config::BoxChars;
use crate::render::table::ReportBuilder;
use crate::VERSION;
use indicatif::{ProgressBar, ProgressStyle};
use owo_colors::OwoColorize;
use super::{format_bytes, format_mbps, SpeedTestResult};
pub struct SpeedQXDisplay {
use_ascii: bool,
use_colors: bool,
json_mode: bool,
}
impl SpeedQXDisplay {
pub fn new(use_ascii: bool, use_colors: bool, json_mode: bool) -> Self {
Self {
use_ascii,
use_colors,
json_mode,
}
}
pub fn print_header(&self) {
if self.json_mode {
return;
}
println!();
if self.use_colors {
println!(
" {} - Internet Speed Test",
format!("SpeedQX v{}", VERSION).cyan().bold()
);
println!(" {}", "QubeTX Developer Tools".dimmed());
} else {
println!(" SpeedQX v{} - Internet Speed Test", VERSION);
println!(" QubeTX Developer Tools");
}
println!();
}
pub fn create_spinner(&self, step: u32, total: u32, msg: &str) -> ProgressBar {
if self.json_mode {
return ProgressBar::hidden();
}
let pb = ProgressBar::new_spinner();
let template = format!(" [{{spinner}}] [{}/{}] {{msg}}", step, total);
pb.set_style(
ProgressStyle::default_spinner()
.template(&template)
.unwrap_or_else(|_| ProgressStyle::default_spinner()),
);
pb.set_message(msg.to_string());
pb.enable_steady_tick(std::time::Duration::from_millis(80));
pb
}
pub fn create_progress_bar(&self, step: u32, total: u32, msg: &str) -> ProgressBar {
if self.json_mode {
return ProgressBar::hidden();
}
let pb = ProgressBar::new(100);
let template = format!(" [{{bar:20.cyan/dim}}] [{}/{}] {{msg}}", step, total);
pb.set_style(
ProgressStyle::default_bar()
.template(&template)
.unwrap_or_else(|_| ProgressStyle::default_bar())
.progress_chars("##-"),
);
pb.set_message(msg.to_string());
pb
}
pub fn finish_step(&self, step: u32, total: u32, msg: &str) {
if self.json_mode {
return;
}
let check = if self.use_ascii { "[OK]" } else { "\u{2713}" };
if self.use_colors {
println!(" [{}/{}] {} {}", step, total, check.green(), msg,);
} else {
println!(" [{}/{}] {} {}", step, total, check, msg);
}
}
}
pub fn render_results(result: &SpeedTestResult, use_ascii: bool, use_colors: bool) -> String {
let chars = if use_ascii {
BoxChars::ascii()
} else {
BoxChars::unicode()
};
let label_width = 14;
let data_width = 27;
let single_provider = result.providers.len() == 1;
if single_provider {
return render_single_provider(result, chars, label_width, data_width, use_colors);
}
render_multi_provider(result, chars, label_width, data_width, use_colors)
}
fn agreement_label(a: &super::AgreementInfo) -> String {
let band = match a.band {
super::statistics::AgreementBand::High => "High",
super::statistics::AgreementBand::Moderate => "Moderate",
super::statistics::AgreementBand::Low => "Low",
super::statistics::AgreementBand::VeryLow => "Very low",
super::statistics::AgreementBand::Insufficient => "Insufficient",
};
match a.i2 {
Some(i2) if !matches!(a.band, super::statistics::AgreementBand::Insufficient) => {
format!("{} (I\u{b2} {:.0}%)", band, i2 * 100.0)
}
_ => band.to_string(),
}
}
fn ci_margin(ci: &Option<super::statistics::CiBounds>) -> String {
ci.as_ref()
.map(|c| (c.upper - c.lower) / 2.0)
.filter(|m| m.is_finite() && *m > 0.0)
.map(|m| format!(" \u{b1}{}", format_mbps(m)))
.unwrap_or_default()
}
fn render_multi_provider(
result: &SpeedTestResult,
chars: BoxChars,
label_width: usize,
data_width: usize,
use_colors: bool,
) -> String {
let mut builder = ReportBuilder::new(label_width, data_width, chars);
builder = builder.full_top_border().span_row(&format!(
" {:^width$}",
"SPEEDQX RESULTS",
width = label_width + data_width + 3
));
builder = builder.section_header("Capacity");
builder = builder.row(
"Download",
&format!(
"{}{}",
format_mbps(result.download_mbps),
ci_margin(&result.capacity.download_ci)
),
);
builder = builder.row(
"Upload",
&format!(
"{}{}",
format_mbps(result.upload_mbps),
ci_margin(&result.capacity.upload_ci)
),
);
if let Some(ping) = result.ping_ms {
builder = builder.row("Ping (min-RTT)", &format!("{:.1} ms", ping));
}
if let Some(jitter) = result.jitter_ms {
builder = builder.row("Jitter (PDV)", &format!("{:.1} ms", jitter));
}
if let Some(bb) = &result.bufferbloat {
builder = builder.row(
"Bufferbloat",
&format!("{} ({:.0} ms)", bb.grade.as_str(), bb.delta_ms),
);
}
if let Some(rpm) = result.rpm {
builder = builder.row("Responsiveness", &format!("{:.0} RPM", rpm));
}
if let Some(loss) = result.packet_loss_pct {
builder = builder.row("Packet Loss", &format!("{:.0}%", loss));
}
builder = builder.section_header("Quality");
builder = builder.row(
"Consensus DL",
&format!(
"{}{}",
format_mbps(result.consensus.download),
ci_margin(&result.consensus.download_ci)
),
);
builder = builder.row(
"Consensus UL",
&format!(
"{}{}",
format_mbps(result.consensus.upload),
ci_margin(&result.consensus.upload_ci)
),
);
builder = builder.row("Agreement (DL)", &agreement_label(&result.agreement));
if let Some(ref ua) = result.upload_agreement {
builder = builder.row("Agreement (UL)", &agreement_label(ua));
}
if let Some(ref stability) = result.stability {
let dl_label = if stability.download_stable {
"Stable"
} else {
"Variable"
};
let ul_label = if stability.upload_stable {
"Stable"
} else {
"Variable"
};
builder = builder.row(
"Stability",
&format!(
"DL: {} (CV {:.0}%) / UL: {} (CV {:.0}%)",
dl_label,
stability.download_cv * 100.0,
ul_label,
stability.upload_cv * 100.0,
),
);
}
if !result.merge_exclusions.is_empty() {
let list = result
.merge_exclusions
.iter()
.map(|e| {
format!(
"{} {} ({} sample{})",
e.provider,
if e.direction == "download" {
"DL"
} else {
"UL"
},
e.samples,
if e.samples == 1 { "" } else { "s" },
)
})
.collect::<Vec<_>>()
.join(", ");
builder = builder.row("Excluded", &list);
}
builder = builder.row("Duration", &format!("{:.1}s", result.duration_s));
for provider in &result.providers {
builder = render_provider_section(builder, provider);
}
let mut output = builder.finish();
let footer = format!(" SQX methodology {}", result.methodology_version);
if use_colors {
output.push_str(&format!("{}\n", owo_colors::OwoColorize::dimmed(&footer)));
} else {
output.push_str(&footer);
output.push('\n');
}
output.push('\n');
output
}
fn render_single_provider(
result: &SpeedTestResult,
chars: BoxChars,
label_width: usize,
data_width: usize,
_use_colors: bool,
) -> String {
let mut builder = ReportBuilder::new(label_width, data_width, chars);
builder = builder.full_top_border().span_row(&format!(
" {:^width$}",
"SPEEDQX RESULTS",
width = label_width + data_width + 3
));
if let Some(provider) = result.providers.first() {
builder = builder.section_header(&provider.provider);
builder = builder.row("Server", &provider.server);
if let Some(ref loc) = provider.location {
builder = builder.row("Location", loc);
}
if let Some(ping) = provider.ping_ms {
builder = builder.row("Ping", &format!("{:.1} ms", ping));
}
if let Some(jitter) = provider.jitter_ms {
builder = builder.row("Jitter", &format!("{:.1} ms", jitter));
}
if let Some(dl) = provider.download_mbps {
builder = builder.row("Download", &format_mbps(dl));
}
if let Some(ul) = provider.upload_mbps {
builder = builder.row("Upload", &format_mbps(ul));
}
builder = builder.row("DL Data", &format_bytes(provider.download_bytes));
builder = builder.row("UL Data", &format_bytes(provider.upload_bytes));
if let Some(loss) = provider.packet_loss_pct {
builder = builder.row("Packet Loss", &format!("{}%", loss));
}
builder = builder.row("Duration", &format!("{:.1}s", result.duration_s));
}
let mut output = builder.finish();
output.push('\n');
output
}
fn render_provider_section(
builder: ReportBuilder,
provider: &super::ProviderResult,
) -> ReportBuilder {
let mut b = builder.section_header(&provider.provider);
if provider.availability == super::ProviderAvailability::UnavailablePlatform {
b = b.row("Status", "unavailable (platform)");
return b;
}
if let Some(ref err) = provider.error {
b = b.row("Error", err);
return b;
}
b = b.row("Server", &provider.server);
if let Some(ref loc) = provider.location {
b = b.row("Location", loc);
}
if let Some(ping) = provider.ping_ms {
b = b.row("Ping", &format!("{:.1} ms", ping));
}
if let Some(jitter) = provider.jitter_ms {
b = b.row("Jitter", &format!("{:.1} ms", jitter));
}
if let Some(dl) = provider.download_mbps {
b = b.row("Download", &format_mbps(dl));
}
if let Some(ul) = provider.upload_mbps {
b = b.row("Upload", &format_mbps(ul));
}
b = b.row("DL Data", &format_bytes(provider.download_bytes));
b = b.row("UL Data", &format_bytes(provider.upload_bytes));
b
}