typetui 0.2.0

A terminal-based typing test.
Documentation
use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::Modifier;
use ratatui::symbols;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Axis, Chart, Dataset, GraphType, Paragraph};

use crate::stats::TestResult;
use crate::ui::theme::Theme;

#[derive(Debug, Clone)]
pub struct ResultsState;

impl Default for ResultsState {
    fn default() -> Self {
        Self
    }
}

pub fn render(
    f: &mut Frame,
    result: &TestResult,
    _state: &ResultsState,
    theme: &Theme,
    area: Rect,
) {
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .margin(2)
        .constraints([
            Constraint::Length(3),
            Constraint::Min(5),
            Constraint::Length(3),
        ])
        .split(area);

    render_title(f, result, theme, chunks[0]);
    render_main_results(f, result, theme, chunks[1]);
    render_help(f, theme, chunks[2]);
}

/// Calculate appropriate Y-axis bounds and step for the chart
fn calculate_y_bounds(max_value: f64) -> (f64, f64, f64) {
    let max_rounded = max_value.ceil();
    let step = if max_rounded <= 30.0 {
        5.0
    } else if max_rounded <= 60.0 {
        10.0
    } else if max_rounded <= 120.0 {
        20.0
    } else {
        50.0
    };
    let max_bound = ((max_rounded / step).ceil() * step).max(step);
    (0.0, max_bound, step)
}

fn render_title(f: &mut Frame, result: &TestResult, theme: &Theme, area: Rect) {
    let title = Paragraph::new(vec![
        Line::from(Span::styled(
            " test complete! ",
            theme.style_accent().add_modifier(Modifier::BOLD),
        )),
        Line::from(Span::styled(
            format!(
                "{} mode • {}{}s",
                result.mode.to_lowercase(),
                result.language,
                result.duration
            ),
            theme.style_muted(),
        )),
    ])
    .alignment(Alignment::Center);

    f.render_widget(title, area);
}

fn render_main_results(f: &mut Frame, result: &TestResult, theme: &Theme, area: Rect) {
    // Vertical layout: stats grid on top, shorter chart below with max height
    let main_layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(6), // Stats grid (2 rows of 3)
            Constraint::Max(20),   // Chart - max 20 lines
            Constraint::Min(0),    // Any extra space goes here
        ])
        .split(area);

    render_stats_grid(f, result, theme, main_layout[0]);
    render_chart(f, result, theme, main_layout[1]);
}

fn render_stats_grid(f: &mut Frame, result: &TestResult, theme: &Theme, area: Rect) {
    // 2 rows x 3 columns grid
    let rows = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
        .split(area);

    // Top row: wpm | accuracy | chars
    let top_row = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([
            Constraint::Percentage(33),
            Constraint::Percentage(34),
            Constraint::Percentage(33),
        ])
        .split(rows[0]);

    let accuracy_style = if result.accuracy >= 95.0 {
        theme.style(theme.success)
    } else if result.accuracy >= 85.0 {
        theme.style(theme.warning)
    } else {
        theme.style(theme.error)
    };

    render_big_stat(
        f,
        "wpm",
        &format!("{:.0}", result.wpm),
        theme.style(theme.success),
        theme,
        top_row[0],
    );
    render_big_stat(
        f,
        "accuracy",
        &format!("{:.1}%", result.accuracy),
        accuracy_style,
        theme,
        top_row[1],
    );
    render_big_stat(
        f,
        "chars",
        &format!("{}", result.total_chars),
        theme.style(theme.muted),
        theme,
        top_row[2],
    );

    // Bottom row: raw wpm | errors | duration
    let bottom_row = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([
            Constraint::Percentage(33),
            Constraint::Percentage(34),
            Constraint::Percentage(33),
        ])
        .split(rows[1]);

    render_big_stat(
        f,
        "raw wpm",
        &format!("{:.0}", result.raw_wpm),
        theme.style(theme.accent),
        theme,
        bottom_row[0],
    );
    render_big_stat(
        f,
        "errors",
        &format!("{}", result.errors),
        theme.style(theme.error),
        theme,
        bottom_row[1],
    );
    render_big_stat(
        f,
        "duration",
        &format!("{}s", result.duration),
        theme.style(theme.muted),
        theme,
        bottom_row[2],
    );
}

fn render_chart(f: &mut Frame, result: &TestResult, theme: &Theme, area: Rect) {
    if result.time_series.is_empty() {
        // No data available (legacy result or very short test)
        let msg = Paragraph::new(vec![
            Line::from(""),
            Line::from(Span::styled("no chart data available", theme.style_muted())),
        ])
        .alignment(Alignment::Center);
        f.render_widget(msg, area);
        return;
    }

    // Calculate bounds first to normalize accuracy
    let max_wpm = result
        .time_series
        .iter()
        .map(|s| s.wpm.max(s.raw_wpm))
        .fold(0.0, f64::max);
    let (wpm_min, wpm_max, wpm_step) = calculate_y_bounds(max_wpm);

    // Build data points from time series
    // Normalize accuracy (0-100) to WPM scale so it appears correctly on chart
    let accuracy_scale = wpm_max / 100.0;

    let wpm_data: Vec<(f64, f64)> = result
        .time_series
        .iter()
        .map(|s| (s.elapsed_secs as f64, s.wpm))
        .collect();
    let raw_wpm_data: Vec<(f64, f64)> = result
        .time_series
        .iter()
        .map(|s| (s.elapsed_secs as f64, s.raw_wpm))
        .collect();
    let accuracy_data: Vec<(f64, f64)> = result
        .time_series
        .iter()
        .map(|s| (s.elapsed_secs as f64, s.accuracy * accuracy_scale))
        .collect();

    // X-axis bounds (time)
    let max_time = result.duration as f64;
    let x_step = if max_time <= 10.0 {
        2.0
    } else if max_time <= 30.0 {
        5.0
    } else if max_time <= 60.0 {
        10.0
    } else {
        15.0
    };

    // Create datasets
    let wpm_dataset = Dataset::default()
        .name("wpm")
        .marker(symbols::Marker::Braille)
        .graph_type(GraphType::Line)
        .style(theme.style(theme.success))
        .data(&wpm_data);

    let raw_wpm_dataset = Dataset::default()
        .name("raw")
        .marker(symbols::Marker::Braille)
        .graph_type(GraphType::Line)
        .style(theme.style(theme.accent))
        .data(&raw_wpm_data);

    let accuracy_dataset = Dataset::default()
        .name("acc")
        .marker(symbols::Marker::Braille)
        .graph_type(GraphType::Line)
        .style(theme.style(theme.warning))
        .data(&accuracy_data);

    // X-axis labels
    let x_labels: Vec<Span> = (0..=(max_time / x_step).ceil() as usize)
        .map(|i| {
            let val = (i as f64) * x_step;
            if val > max_time {
                Span::styled(format!("{max_time:.0}"), theme.style_muted())
            } else {
                Span::styled(format!("{val:.0}"), theme.style_muted())
            }
        })
        .collect();

    // Y-axis labels showing WPM values only
    let wpm_labels: Vec<Span> = (0..=(wpm_max / wpm_step).ceil() as usize)
        .map(|i| {
            let wpm_val = (i as f64) * wpm_step;
            Span::styled(format!("{wpm_val:.0}"), theme.style_muted())
        })
        .collect();

    let chart = Chart::new(vec![wpm_dataset, raw_wpm_dataset, accuracy_dataset])
        .x_axis(
            Axis::default()
                .title(Span::styled("time (s)", theme.style_muted()))
                .style(theme.style_muted())
                .bounds([0.0, max_time])
                .labels(x_labels),
        )
        .y_axis(
            Axis::default()
                .style(theme.style_muted())
                .bounds([wpm_min, wpm_max])
                .labels(wpm_labels),
        )
        .hidden_legend_constraints((Constraint::Ratio(1, 4), Constraint::Ratio(1, 4)));

    f.render_widget(chart, area);
}

fn render_big_stat(
    f: &mut Frame,
    label: &str,
    value: &str,
    value_style: ratatui::style::Style,
    theme: &Theme,
    area: Rect,
) {
    let text = vec![
        Line::from(vec![Span::styled(
            value,
            value_style.add_modifier(Modifier::BOLD),
        )]),
        Line::from(vec![Span::styled(label, theme.style_muted())]),
    ];

    let para = Paragraph::new(text).alignment(Alignment::Center);
    f.render_widget(para, area);
}

fn render_help(f: &mut Frame, theme: &Theme, area: Rect) {
    let help_text = vec![
        Span::styled("tab/r", theme.style_accent()),
        Span::styled(" restart • ", theme.style_muted()),
        Span::styled("ctrl+p", theme.style_accent()),
        Span::styled(" profile • ", theme.style_muted()),
        Span::styled("esc", theme.style_accent()),
        Span::styled(" menu", theme.style_muted()),
    ];

    let help = Paragraph::new(Line::from(help_text)).alignment(Alignment::Center);
    f.render_widget(help, area);
}