speedtest-tui 0.1.1

A terminal-based network speed test tool with real-time gauges and graphs
use ratatui::{
    prelude::*,
    widgets::canvas::{Canvas, Line as CanvasLine, Points},
    Frame,
};
use std::f64::consts::PI;

use super::Theme;

pub struct GaugeWidget<'a> {
    current_speed: f64,
    max_scale: f64,
    label: &'a str,
    theme: Option<&'a Theme>,
}

impl<'a> GaugeWidget<'a> {
    pub fn new(current_speed: f64, max_scale: f64) -> Self {
        Self {
            current_speed,
            max_scale,
            label: "",
            theme: None,
        }
    }

    pub fn with_label(mut self, label: &'a str) -> Self {
        self.label = label;
        self
    }

    pub fn with_theme(mut self, theme: &'a Theme) -> Self {
        self.theme = Some(theme);
        self
    }

    pub fn render(self, frame: &mut Frame, area: Rect) {
        let default_theme = Theme::default();
        let theme = self.theme.unwrap_or(&default_theme);

        let canvas = Canvas::default()
            .x_bounds([-1.2, 1.2])
            .y_bounds([-0.3, 1.1])
            .background_color(theme.background)
            .paint(|ctx| {
                let segments = 50;
                let start_angle = PI;
                let end_angle = 0.0;

                for i in 0..segments {
                    let t1 = i as f64 / segments as f64;
                    let t2 = (i + 1) as f64 / segments as f64;

                    let angle1 = start_angle + (end_angle - start_angle) * t1;
                    let angle2 = start_angle + (end_angle - start_angle) * t2;

                    ctx.draw(&CanvasLine {
                        x1: angle1.cos(),
                        y1: angle1.sin(),
                        x2: angle2.cos(),
                        y2: angle2.sin(),
                        color: theme.speed_color(t1),
                    });
                }

                let tick_scales = calculate_tick_scales(self.max_scale);
                for (value, _) in &tick_scales {
                    let normalized = *value / self.max_scale;
                    let angle = start_angle + (end_angle - start_angle) * normalized;

                    ctx.draw(&CanvasLine {
                        x1: angle.cos() * 0.85,
                        y1: angle.sin() * 0.85,
                        x2: angle.cos() * 0.95,
                        y2: angle.sin() * 0.95,
                        color: theme.foreground,
                    });
                }

                let normalized_speed = (self.current_speed / self.max_scale).clamp(0.0, 1.0);
                let needle_angle = start_angle + (end_angle - start_angle) * normalized_speed;

                ctx.draw(&CanvasLine {
                    x1: 0.0,
                    y1: 0.0,
                    x2: needle_angle.cos() * 0.75,
                    y2: needle_angle.sin() * 0.75,
                    color: theme.primary,
                });

                ctx.draw(&Points {
                    coords: &[(0.0, 0.0)],
                    color: theme.accent,
                });
            });

        frame.render_widget(canvas, area);

        let speed_text = format!("{:.1}", self.current_speed);
        let center_x = area.x + area.width / 2;
        let center_y = area.y + area.height / 2 + 2;

        if center_y < area.y + area.height && center_x >= area.x {
            let speed_span =
                Span::styled(&speed_text, Style::default().fg(theme.foreground).bold());
            let speed_widget =
                ratatui::widgets::Paragraph::new(speed_span).alignment(Alignment::Center);

            let speed_area = Rect {
                x: area.x,
                y: center_y.saturating_sub(1),
                width: area.width,
                height: 1,
            };
            frame.render_widget(speed_widget, speed_area);

            let unit_span = Span::styled("Mbps", Style::default().fg(theme.muted));
            let unit_widget =
                ratatui::widgets::Paragraph::new(unit_span).alignment(Alignment::Center);

            let unit_area = Rect {
                x: area.x,
                y: center_y,
                width: area.width,
                height: 1,
            };
            frame.render_widget(unit_widget, unit_area);

            if !self.label.is_empty() {
                let label_span = Span::styled(self.label, Style::default().fg(theme.secondary));
                let label_widget =
                    ratatui::widgets::Paragraph::new(label_span).alignment(Alignment::Center);

                let label_area = Rect {
                    x: area.x,
                    y: center_y + 1,
                    width: area.width,
                    height: 1,
                };
                frame.render_widget(label_widget, label_area);
            }
        }

        let tick_scales = calculate_tick_scales(self.max_scale);
        render_scale_labels(frame, area, &tick_scales, theme);
    }
}

fn calculate_tick_scales(max_scale: f64) -> Vec<(f64, String)> {
    let scales = if max_scale <= 10.0 {
        vec![0.0, 2.5, 5.0, 7.5, 10.0]
    } else if max_scale <= 50.0 {
        vec![0.0, 10.0, 20.0, 30.0, 40.0, 50.0]
    } else if max_scale <= 100.0 {
        vec![0.0, 25.0, 50.0, 75.0, 100.0]
    } else if max_scale <= 250.0 {
        vec![0.0, 50.0, 100.0, 150.0, 200.0, 250.0]
    } else if max_scale <= 500.0 {
        vec![0.0, 100.0, 200.0, 300.0, 400.0, 500.0]
    } else if max_scale <= 1000.0 {
        vec![0.0, 250.0, 500.0, 750.0, 1000.0]
    } else {
        vec![
            0.0,
            max_scale / 4.0,
            max_scale / 2.0,
            max_scale * 3.0 / 4.0,
            max_scale,
        ]
    };

    scales
        .into_iter()
        .filter(|&v| v <= max_scale)
        .map(|v| {
            let label = if v >= 1000.0 {
                format!("{:.0}G", v / 1000.0)
            } else {
                format!("{:.0}", v)
            };
            (v, label)
        })
        .collect()
}

fn render_scale_labels(frame: &mut Frame, area: Rect, scales: &[(f64, String)], theme: &Theme) {
    if area.height < 3 || area.width < 10 {
        return;
    }

    // The gauge arc spans x=-1.0 to x=1.0 in canvas coordinates [-1.2, 1.2],
    // which maps to ~8% and ~92% of screen width
    let left_pos = (area.width as f32 * 0.08) as u16;
    let right_pos = (area.width as f32 * 0.92) as u16;

    let zero_area = Rect {
        x: area.x + left_pos.saturating_sub(2),
        y: area.y + area.height - 2,
        width: 4,
        height: 1,
    };
    let zero = ratatui::widgets::Paragraph::new("0")
        .style(Style::default().fg(theme.muted))
        .alignment(Alignment::Center);
    frame.render_widget(zero, zero_area);

    if let Some((_, label)) = scales.last() {
        let label_width = label.len() as u16 + 1;
        let max_area = Rect {
            x: area.x + right_pos.saturating_sub(label_width / 2),
            y: area.y + area.height - 2,
            width: label_width,
            height: 1,
        };
        let max = ratatui::widgets::Paragraph::new(label.as_str())
            .style(Style::default().fg(theme.muted))
            .alignment(Alignment::Center);
        frame.render_widget(max, max_area);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_tick_scales() {
        let scales = calculate_tick_scales(100.0);
        assert!(!scales.is_empty());
        assert_eq!(scales[0].0, 0.0);
    }

    #[test]
    fn test_gauge_widget() {
        let gauge = GaugeWidget::new(50.0, 100.0).with_label("TEST");

        assert_eq!(gauge.current_speed, 50.0);
        assert_eq!(gauge.max_scale, 100.0);
        assert_eq!(gauge.label, "TEST");
    }
}