use anyhow::Result;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
use std::io;
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
use crate::config::Config;
use crate::network::{
connection::ConnectionInfo, dns::DnsResult, interfaces::NetworkInterface, traceroute::TraceHop,
PingResult, SpeedResult, SpeedSample, SpeedTestResult,
};
use crate::storage::history;
use crate::ui::{self, Theme, ThemeName};
use crate::utils::calculate_gauge_scale;
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub enum TestProgress {
Phase(TestPhase),
ConnectionInfo(ConnectionInfo),
PingResult(PingResult),
SpeedUpdate { speed_mbps: f64, is_download: bool },
DownloadComplete(SpeedResult),
UploadComplete(SpeedResult),
TestComplete(Box<SpeedTestResult>),
Error(String),
}
const TICK_RATE: Duration = Duration::from_millis(50);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum View {
Main,
History,
Diagnostics,
Settings,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TestPhase {
Idle,
Connecting,
Ping,
Download,
Upload,
Complete,
}
pub struct App {
pub should_quit: bool,
pub current_view: View,
pub theme_name: ThemeName,
pub current_provider: String,
pub is_connected: bool,
pub show_provider_popup: bool,
pub provider_selected_index: usize,
pub is_testing: bool,
pub test_phase: TestPhase,
pub test_progress: f64,
pub test_duration: u64,
pub current_download_speed: f64,
pub current_upload_speed: f64,
pub gauge_max_scale: f64,
pub speed_samples: Vec<SpeedSample>,
pub results: SpeedTestResult,
pub history: Vec<SpeedTestResult>,
pub history_selected_index: usize,
pub connection_info: Option<ConnectionInfo>,
pub dns_result: Option<DnsResult>,
pub interfaces: Vec<NetworkInterface>,
#[allow(dead_code)]
pub traceroute_hops: Vec<TraceHop>,
pub show_help_popup: bool,
pub show_results_popup: bool,
pub show_theme_popup: bool,
pub theme_selected_index: usize,
pub tick: u64,
}
impl App {
pub fn new() -> Result<Self> {
let config = Config::load()?;
let history = history::load_history().unwrap_or_default();
let theme_name = config.theme.name.parse().unwrap_or(ThemeName::Dark);
Ok(Self {
should_quit: false,
current_view: View::Main,
theme_name,
current_provider: config.general.default_provider,
is_connected: true,
show_provider_popup: false,
provider_selected_index: 0,
is_testing: false,
test_phase: TestPhase::Idle,
test_progress: 0.0,
test_duration: config.general.test_duration_seconds,
current_download_speed: 0.0,
current_upload_speed: 0.0,
gauge_max_scale: 100.0,
speed_samples: Vec::new(),
results: SpeedTestResult::default(),
history,
history_selected_index: 0,
connection_info: None,
dns_result: None,
interfaces: Vec::new(),
traceroute_hops: Vec::new(),
show_help_popup: false,
show_results_popup: false,
show_theme_popup: false,
theme_selected_index: 0,
tick: 0,
})
}
pub fn get_theme(&self) -> Theme {
Theme::from_name(self.theme_name)
}
fn handle_key(&mut self, key: KeyCode, modifiers: KeyModifiers) -> Option<AppAction> {
if modifiers.contains(KeyModifiers::CONTROL) && key == KeyCode::Char('c') {
self.should_quit = true;
return None;
}
if self.show_help_popup {
self.show_help_popup = false;
return None;
}
if self.show_results_popup {
self.show_results_popup = false;
return None;
}
if self.show_provider_popup {
return self.handle_provider_popup_key(key);
}
if self.show_theme_popup {
return self.handle_theme_popup_key(key);
}
if self.is_testing {
if key == KeyCode::Esc {
return Some(AppAction::CancelTest);
}
return None;
}
match key {
KeyCode::Char('q') | KeyCode::Esc => {
if self.current_view != View::Main {
self.current_view = View::Main;
} else {
self.should_quit = true;
}
}
KeyCode::Char('s') => {
if self.current_view == View::Main {
return Some(AppAction::StartTest);
}
}
KeyCode::Enter => match self.current_view {
View::Main => return Some(AppAction::StartTest),
View::History => {
if !self.history.is_empty() {
self.show_results_popup = true;
}
}
_ => {}
},
KeyCode::Char('p') => {
self.show_provider_popup = true;
}
KeyCode::Char('h') => {
self.current_view = View::History;
}
KeyCode::Char('d') => {
self.current_view = View::Diagnostics;
return Some(AppAction::LoadDiagnostics);
}
KeyCode::Char('e') => {
return Some(AppAction::ExportHistory);
}
KeyCode::Char('t') => {
self.show_theme_popup = true;
self.theme_selected_index = match self.theme_name {
ThemeName::Dark => 0,
ThemeName::Light => 1,
ThemeName::Ocean => 2,
ThemeName::Neon => 3,
};
}
KeyCode::Char('c') => {
self.current_view = View::Settings;
}
KeyCode::Char('?') => {
self.show_help_popup = true;
}
KeyCode::Up | KeyCode::Char('k') => {
if self.current_view == View::History && self.history_selected_index > 0 {
self.history_selected_index -= 1;
}
}
KeyCode::Down | KeyCode::Char('j') => {
if self.current_view == View::History {
let max_index = self.history.len().min(50).saturating_sub(1);
if self.history_selected_index < max_index {
self.history_selected_index += 1;
}
}
}
_ => {}
}
None
}
fn handle_provider_popup_key(&mut self, key: KeyCode) -> Option<AppAction> {
let providers = ["cloudflare"];
match key {
KeyCode::Esc => {
self.show_provider_popup = false;
}
KeyCode::Up | KeyCode::Char('k') => {
if self.provider_selected_index > 0 {
self.provider_selected_index -= 1;
}
}
KeyCode::Down | KeyCode::Char('j') => {
if self.provider_selected_index < providers.len().saturating_sub(1) {
self.provider_selected_index += 1;
}
}
KeyCode::Enter => {
self.current_provider = providers[self.provider_selected_index].to_string();
self.show_provider_popup = false;
}
_ => {}
}
None
}
fn handle_theme_popup_key(&mut self, key: KeyCode) -> Option<AppAction> {
let themes = [
ThemeName::Dark,
ThemeName::Light,
ThemeName::Ocean,
ThemeName::Neon,
];
match key {
KeyCode::Esc => {
self.show_theme_popup = false;
}
KeyCode::Up | KeyCode::Char('k') => {
if self.theme_selected_index > 0 {
self.theme_selected_index -= 1;
}
}
KeyCode::Down | KeyCode::Char('j') => {
if self.theme_selected_index < themes.len() - 1 {
self.theme_selected_index += 1;
}
}
KeyCode::Enter => {
self.theme_name = themes[self.theme_selected_index];
self.show_theme_popup = false;
}
_ => {}
}
None
}
pub fn update_speed(&mut self, speed_mbps: f64, is_download: bool) {
if is_download {
self.current_download_speed = speed_mbps;
} else {
self.current_upload_speed = speed_mbps;
}
let current_speed = if is_download {
self.current_download_speed
} else {
self.current_upload_speed
};
let new_scale = calculate_gauge_scale(current_speed);
if new_scale > self.gauge_max_scale {
self.gauge_max_scale = new_scale;
}
let timestamp_ms = self.speed_samples.len() as u64 * 250;
self.speed_samples.push(SpeedSample {
timestamp_ms,
speed_mbps: current_speed,
});
}
pub fn reset_for_test(&mut self) {
self.is_testing = true;
self.test_phase = TestPhase::Connecting;
self.test_progress = 0.0;
self.current_download_speed = 0.0;
self.current_upload_speed = 0.0;
self.gauge_max_scale = 100.0;
self.speed_samples.clear();
self.results = SpeedTestResult::default();
}
pub fn finish_test(&mut self) {
self.is_testing = false;
self.test_phase = TestPhase::Complete;
self.show_results_popup = true;
if let Err(e) = history::add_to_history(self.results.clone()) {
tracing::error!("Failed to save to history: {}", e);
}
self.history = history::load_history().unwrap_or_default();
}
}
impl Default for App {
fn default() -> Self {
Self::new().expect("Failed to create app")
}
}
pub enum AppAction {
StartTest,
CancelTest,
ExportHistory,
LoadDiagnostics,
}
type TestHandle = tokio::task::JoinHandle<()>;
pub async fn run_tui() -> Result<()> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut app = App::new()?;
let (action_tx, mut action_rx) = mpsc::unbounded_channel::<AppAction>();
let (progress_tx, mut progress_rx) = mpsc::unbounded_channel::<TestProgress>();
let mut test_handle: Option<TestHandle> = None;
let mut last_tick = Instant::now();
loop {
terminal.draw(|f| ui::render(f, &app))?;
let timeout = TICK_RATE.saturating_sub(last_tick.elapsed());
if crossterm::event::poll(timeout)? {
if let Event::Key(key) = event::read()? {
if let Some(action) = app.handle_key(key.code, key.modifiers) {
action_tx.send(action)?;
}
}
}
while let Ok(progress) = progress_rx.try_recv() {
match progress {
TestProgress::Phase(phase) => {
app.test_phase = phase;
}
TestProgress::ConnectionInfo(info) => {
app.results.connection_info = Some(info.clone());
app.connection_info = Some(info);
}
TestProgress::PingResult(ping) => {
app.results.ping = Some(ping);
}
TestProgress::SpeedUpdate {
speed_mbps,
is_download,
} => {
app.update_speed(speed_mbps, is_download);
}
TestProgress::DownloadComplete(result) => {
app.current_download_speed = result.speed_mbps;
app.results.download = Some(result);
app.speed_samples.clear();
}
TestProgress::UploadComplete(result) => {
app.current_upload_speed = result.speed_mbps;
app.results.upload = Some(result);
}
TestProgress::TestComplete(mut result) => {
if result.ping.is_none() {
result.ping = app.results.ping.take();
}
if result.connection_info.is_none() {
result.connection_info = app.results.connection_info.take();
}
app.results = *result;
app.finish_test();
test_handle = None;
}
TestProgress::Error(err) => {
tracing::error!("Speed test error: {}", err);
app.is_testing = false;
app.test_phase = TestPhase::Idle;
test_handle = None;
}
}
}
while let Ok(action) = action_rx.try_recv() {
match action {
AppAction::StartTest => {
if test_handle.is_none() {
app.reset_for_test();
let tx = progress_tx.clone();
let provider = app.current_provider.clone();
let duration = app.test_duration;
test_handle = Some(tokio::spawn(async move {
if let Err(e) = run_speed_test_background(tx, provider, duration).await
{
tracing::error!("Speed test failed: {}", e);
}
}));
}
}
AppAction::CancelTest => {
if let Some(handle) = test_handle.take() {
handle.abort();
}
app.is_testing = false;
app.test_phase = TestPhase::Idle;
}
AppAction::ExportHistory => {
if let Ok(path) = crate::storage::export::export_to_csv() {
tracing::info!("Exported history to: {}", path.display());
}
}
AppAction::LoadDiagnostics => {
load_diagnostics(&mut app).await;
}
}
}
if last_tick.elapsed() >= TICK_RATE {
app.tick = app.tick.wrapping_add(1);
last_tick = Instant::now();
}
if app.should_quit {
if let Some(handle) = test_handle.take() {
handle.abort();
}
break;
}
}
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
Ok(())
}
async fn run_speed_test_background(
tx: mpsc::UnboundedSender<TestProgress>,
provider_name: String,
test_duration: u64,
) -> Result<()> {
use crate::network::{download, ping, providers, upload};
let provider = providers::get_provider(&provider_name)?;
let _ = tx.send(TestProgress::Phase(TestPhase::Connecting));
if let Ok(info) = crate::network::connection::get_connection_info().await {
let _ = tx.send(TestProgress::ConnectionInfo(info));
}
let _ = tx.send(TestProgress::Phase(TestPhase::Ping));
if let Ok(ping_result) = ping::measure_ping(&provider.get_ping_url()).await {
let _ = tx.send(TestProgress::PingResult(ping_result));
}
let _ = tx.send(TestProgress::Phase(TestPhase::Download));
let tx_download = tx.clone();
let download_result = download::measure_download(
&provider.get_download_url(),
test_duration,
move |progress| {
let _ = tx_download.send(TestProgress::SpeedUpdate {
speed_mbps: progress.current_speed_mbps,
is_download: true,
});
},
)
.await?;
let _ = tx.send(TestProgress::DownloadComplete(download_result.clone()));
let _ = tx.send(TestProgress::Phase(TestPhase::Upload));
let tx_upload = tx.clone();
let upload_result =
upload::measure_upload(&provider.get_upload_url(), test_duration, move |progress| {
let _ = tx_upload.send(TestProgress::SpeedUpdate {
speed_mbps: progress.current_speed_mbps,
is_download: false,
});
})
.await?;
let _ = tx.send(TestProgress::UploadComplete(upload_result.clone()));
let result = SpeedTestResult {
provider: provider_name,
timestamp: chrono::Utc::now(),
server: None,
connection_info: None,
ping: None,
download: Some(download_result),
upload: Some(upload_result),
};
let _ = tx.send(TestProgress::TestComplete(Box::new(result)));
Ok(())
}
async fn load_diagnostics(app: &mut App) {
if app.connection_info.is_none() {
if let Ok(info) = crate::network::connection::get_connection_info().await {
app.connection_info = Some(info);
}
}
if app.interfaces.is_empty() {
if let Ok(interfaces) = crate::network::interfaces::list_interfaces() {
app.interfaces = interfaces;
}
}
if app.dns_result.is_none() {
if let Ok(dns) = crate::network::dns::measure_dns("google.com").await {
app.dns_result = Some(dns);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_app_creation() {
let theme = ThemeName::Dark;
assert_eq!(theme.as_str(), "dark");
}
#[test]
fn test_theme_cycling() {
let mut theme = ThemeName::Dark;
theme = theme.next();
assert_eq!(theme, ThemeName::Light);
}
}