netspd 0.1.1

A beautiful, modern, minimalistic network speed testing terminal application
Documentation
//! The asynchronous runtime loop wiring terminal, engine and renderer.

use std::sync::Arc;
use std::time::{Duration, Instant};

use anyhow::Result;
use crossterm::event::{Event, EventStream, KeyEventKind};
use futures_util::StreamExt;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;

use crate::engine::models::Server;
use crate::engine::{Engine, EngineEvent};
use crate::tui::renderer::Renderer;
use crate::tui::terminal::Tui;

use super::action::Command;
use super::controller;
use super::event::AppEvent;
use super::history;
use super::state::{AppState, Screen};

/// Minimum time the splash screen stays visible, so startup never flashes.
const MIN_SPLASH: Duration = Duration::from_millis(1200);

/// Capacity of the engine event channel; progress events are small and
/// frequent, so a modest buffer avoids backpressure on the engine.
const ENGINE_CHANNEL_CAPACITY: usize = 64;

/// How many alternative servers are tried automatically after a failure
/// before showing the error screen.
const MAX_FAILOVER: usize = 2;

/// Number of past results loaded for the trends screen.
const TRENDS_LIMIT: usize = 120;

/// The application: owns the engine, the state and the renderer, and runs
/// the event loop until the user quits.
pub struct App {
    engine: Arc<Engine>,
    state: AppState,
    renderer: Renderer,
    tick_rate: Duration,
    engine_rx: Option<mpsc::Receiver<EngineEvent>>,
    cancel: CancellationToken,
    failover_attempts: usize,
}

impl App {
    /// Assembles the application from its parts.
    #[must_use]
    pub fn new(engine: Engine, state: AppState, renderer: Renderer, tick_rate: Duration) -> Self {
        Self {
            engine: Arc::new(engine),
            state,
            renderer,
            tick_rate,
            engine_rx: None,
            cancel: CancellationToken::new(),
            failover_attempts: 0,
        }
    }

    /// Runs the event loop until quit, drawing at most one frame per tick.
    pub async fn run(mut self, tui: &mut Tui) -> Result<()> {
        let mut servers_rx = Some(self.spawn_discovery());
        let mut info_rx: Option<mpsc::Receiver<String>> = None;
        let mut input = EventStream::new();
        let mut ticker = tokio::time::interval(self.tick_rate);
        ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
        let mut last_frame = Instant::now();

        self.draw(tui, 0.0)?;

        loop {
            let event = tokio::select! {
                maybe = input.next() => match maybe {
                    Some(Ok(Event::Key(key))) if key.kind == KeyEventKind::Press => {
                        AppEvent::Key(key)
                    }
                    Some(Ok(Event::Resize(_, _))) => AppEvent::Resize,
                    Some(Ok(_)) => continue,
                    // Terminal input errored or closed; nothing sensible
                    // remains but to exit cleanly.
                    Some(Err(_)) | None => break,
                },
                _ = ticker.tick() => AppEvent::Tick,
                maybe = recv_or_pending(&mut self.engine_rx) => match maybe {
                    Some(engine_event) => AppEvent::Engine(engine_event),
                    None => {
                        self.engine_rx = None;
                        continue;
                    }
                },
                maybe = recv_or_pending(&mut servers_rx) => match maybe {
                    Some(result) => AppEvent::ServersLoaded(result),
                    None => {
                        servers_rx = None;
                        continue;
                    }
                },
                maybe = recv_or_pending(&mut info_rx) => match maybe {
                    Some(info) => AppEvent::ClientInfo(info),
                    None => {
                        info_rx = None;
                        continue;
                    }
                },
            };

            match event {
                AppEvent::Key(key) => {
                    let Some(action) = controller::map_key(self.state.screen, key) else {
                        continue;
                    };
                    match controller::handle(&mut self.state, action) {
                        Command::Quit => break,
                        Command::StartTest => {
                            self.failover_attempts = 0;
                            self.start_test();
                        }
                        Command::LoadTrends => {
                            self.state.trends = history::load_recent(TRENDS_LIMIT);
                        }
                        Command::None => {}
                    }
                }
                AppEvent::Resize => self.state.request_redraw(),
                AppEvent::Engine(engine_event) => {
                    if matches!(engine_event, EngineEvent::Failed { .. }) && self.try_failover() {
                        continue;
                    }
                    if let EngineEvent::Finished { report } = &engine_event {
                        self.failover_attempts = 0;
                        // Best-effort persistence; the UI never blocks on it.
                        history::record_report(report);
                    }
                    self.state.apply_engine_event(engine_event);
                }
                AppEvent::ServersLoaded(result) => {
                    self.state.apply_servers(result);
                    if info_rx.is_none() {
                        info_rx = self.spawn_info_fetch();
                    }
                }
                AppEvent::ClientInfo(info) => {
                    self.state.client_info = Some(info);
                    self.state.request_redraw();
                }
                AppEvent::Tick => {
                    self.state.on_tick();
                    self.maybe_leave_splash();
                    if self.state.take_redraw() {
                        let dt = last_frame.elapsed().as_secs_f64();
                        last_frame = Instant::now();
                        self.draw(tui, dt)?;
                    }
                }
            }
        }

        self.cancel.cancel();
        Ok(())
    }

    /// Starts server discovery in the background.
    fn spawn_discovery(&self) -> mpsc::Receiver<Result<Vec<Server>, String>> {
        let (tx, rx) = mpsc::channel(1);
        let engine = Arc::clone(&self.engine);
        tokio::spawn(async move {
            let result = engine.load_servers().await.map_err(|err| err.to_string());
            let _ = tx.send(result).await;
        });
        rx
    }

    /// Fetches the client's IP/ISP through the selected server, once
    /// discovery has produced one.
    fn spawn_info_fetch(&self) -> Option<mpsc::Receiver<String>> {
        let server = self.state.current_server().cloned()?;
        let (tx, rx) = mpsc::channel(1);
        let engine = Arc::clone(&self.engine);
        tokio::spawn(async move {
            if let Some(info) = engine.client_info(&server).await {
                let _ = tx.send(info).await;
            }
        });
        Some(rx)
    }

    /// Automatically starts the first test once the splash has been shown
    /// long enough and servers are available.
    fn maybe_leave_splash(&mut self) {
        let ready = self.state.screen == Screen::Splash
            && !self.state.servers_loading
            && !self.state.testing
            && !self.state.servers.is_empty()
            && self.state.splash_since.elapsed() >= MIN_SPLASH;
        if ready {
            self.start_test();
        }
    }

    /// Moves to the next-nearest server after a failure and restarts,
    /// up to [`MAX_FAILOVER`] times. Returns whether a retry started.
    ///
    /// Skipped when the user pinned a server with `--server`: an explicit
    /// choice should fail loudly, not wander.
    fn try_failover(&mut self) -> bool {
        let next = self.state.server_index + 1;
        let possible = self.state.preferred_server.is_none()
            && self.failover_attempts < MAX_FAILOVER
            && next < self.state.servers.len();
        if !possible {
            return false;
        }
        self.failover_attempts += 1;
        self.state.server_index = next;
        self.state.server_cursor = next;
        self.start_test();
        true
    }

    /// Cancels any running test and launches a new one against the
    /// selected server.
    fn start_test(&mut self) {
        let Some(server) = self.state.current_server().cloned() else {
            return;
        };
        self.cancel.cancel();
        self.cancel = CancellationToken::new();
        self.state.begin_test();

        let (tx, rx) = mpsc::channel(ENGINE_CHANNEL_CAPACITY);
        self.engine_rx = Some(rx);
        let engine = Arc::clone(&self.engine);
        let cancel = self.cancel.clone();
        tokio::spawn(async move {
            // Failures already reach the UI as EngineEvent::Failed.
            let _ = engine.run_test(&server, tx, cancel).await;
        });
    }

    /// Renders one frame.
    fn draw(&mut self, tui: &mut Tui, dt: f64) -> Result<()> {
        let renderer = &mut self.renderer;
        let state = &self.state;
        tui.draw(|frame| renderer.render(frame, state, dt))?;
        Ok(())
    }
}

/// Receives from an optional channel, parking forever when absent so it
/// can sit inside `tokio::select!` without busy-looping.
async fn recv_or_pending<T>(rx: &mut Option<mpsc::Receiver<T>>) -> Option<T> {
    match rx {
        Some(receiver) => receiver.recv().await,
        None => std::future::pending().await,
    }
}