typetui 0.1.0

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

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

pub fn render(f: &mut Frame, theme: &Theme, area: Rect) {
    let results = Storage::load_results().unwrap_or_default();

    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .margin(2)
        .constraints([
            Constraint::Length(3),
            Constraint::Length(8),
            Constraint::Length(8),
            Constraint::Min(0),
            Constraint::Length(3),
        ])
        .split(area);

    render_title(f, &results, theme, chunks[0]);
    render_personal_bests(f, theme, chunks[1]);
    render_recent_history(f, &results, theme, chunks[2]);
    render_activity_heatmap(f, &results, theme, chunks[3]);
    render_help(f, theme, chunks[4]);
}

fn render_title(f: &mut Frame, results: &[TestResult], theme: &Theme, area: Rect) {
    let total_tests = results.len();
    let total_time: u64 = results.iter().map(|r| r.duration).sum();
    let total_chars: u32 = results.iter().map(|r| r.total_chars).sum();

    let title = Paragraph::new(vec![
        Line::from(Span::styled(
            " profile dashboard ",
            theme.style_accent().add_modifier(Modifier::BOLD),
        )),
        Line::from(vec![
            Span::styled(format!("{} tests ", total_tests), theme.style_muted()),
            Span::styled("", theme.style_muted()),
            Span::styled(format!(" {}m typed ", total_time / 60), theme.style_muted()),
            Span::styled("", theme.style_muted()),
            Span::styled(format!(" {} chars ", total_chars), theme.style_muted()),
        ]),
    ])
    .alignment(Alignment::Center);

    f.render_widget(title, area);
}

fn render_personal_bests(f: &mut Frame, theme: &Theme, area: Rect) {
    let bests = Storage::personal_bests().unwrap_or_default();

    let mut lines = vec![
        Line::from(vec![Span::styled(
            " personal bests ",
            theme.style_accent(),
        )]),
        Line::from(""),
    ];

    if bests.is_empty() {
        lines.push(Line::from(vec![Span::styled(
            "no completed tests yet. start typing!",
            theme.style_muted(),
        )]));
    } else {
        let mut sorted_bests = bests;
        sorted_bests.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));

        for (i, (key, wpm)) in sorted_bests.iter().take(6).enumerate() {
            let parts: Vec<&str> = key.split('-').collect();
            let mode = parts.first().copied().unwrap_or("?");
            let duration = parts.get(1).copied().unwrap_or("?");

            lines.push(Line::from(vec![
                Span::styled(format!("{}. ", i + 1), theme.style_muted()),
                Span::styled(format!("{:.0} wpm", wpm), theme.style(theme.success)),
                Span::styled("", theme.style_muted()),
                Span::styled(format!("{} mode", mode), theme.style(theme.accent)),
                Span::styled("", theme.style_muted()),
                Span::styled(format!("{}s", duration), theme.style(theme.accent)),
            ]));
        }
    }

    let para = Paragraph::new(lines).wrap(Wrap { trim: true });
    f.render_widget(para, area);
}

fn render_recent_history(f: &mut Frame, results: &[TestResult], theme: &Theme, area: Rect) {
    let recent: Vec<_> = results.iter().rev().take(5).collect();

    let mut lines = vec![
        Line::from(vec![Span::styled(
            " recent history ",
            theme.style_accent(),
        )]),
        Line::from(""),
    ];

    if recent.is_empty() {
        lines.push(Line::from(vec![Span::styled(
            "no history yet. complete your first test!",
            theme.style_muted(),
        )]));
    } else {
        for result in recent {
            let timestamp = result
                .timestamp
                .split('T')
                .next()
                .unwrap_or("?")
                .to_string();
            let wpm_style = if result.wpm >= 80.0 {
                theme.style(theme.success)
            } else if result.wpm >= 50.0 {
                theme.style(theme.accent)
            } else {
                theme.style(theme.warning)
            };

            lines.push(Line::from(vec![
                Span::styled(format!("{} ", timestamp), theme.style_muted()),
                Span::styled("", theme.style(theme.border)),
                Span::styled(format!(" {:.0} wpm", result.wpm), wpm_style),
                Span::styled("", theme.style_muted()),
                Span::styled(
                    format!(" {:.1}% acc", result.accuracy),
                    theme.style(theme.accent),
                ),
                Span::styled("", theme.style_muted()),
                Span::styled(format!(" {}s", result.duration), theme.style(theme.accent)),
            ]));
        }
    }

    let para = Paragraph::new(lines).wrap(Wrap { trim: true });
    f.render_widget(para, area);
}

fn render_activity_heatmap(
    f: &mut Frame,
    _results: &[TestResult],
    theme: &Theme,
    area: Rect,
) {
    let by_day = Storage::results_by_day().unwrap_or_default();

    let mut days: Vec<_> = by_day.iter().collect();
    days.sort_by(|a, b| a.0.cmp(b.0));
    days.reverse();

    let mut lines = vec![
        Line::from(vec![Span::styled(
            " activity (tests per day) ",
            theme.style_accent(),
        )]),
        Line::from(""),
    ];

    if days.is_empty() {
        lines.push(Line::from(vec![Span::styled(
            "no activity recorded yet.",
            theme.style_muted(),
        )]));
    } else {
        let max_count = days.iter().map(|(_, v)| v.len()).max().unwrap_or(1) as f64;

        for (day, results) in days.iter().take(10) {
            let count = results.len();
            let intensity = (count as f64 / max_count).min(1.0);

            // Create a visual bar
            let bar_len = (intensity * 20.0) as usize;
            let bar = "".repeat(bar_len);

            let bar_style = if intensity > 0.8 {
                theme.style(theme.success)
            } else if intensity > 0.5 {
                theme.style(theme.accent)
            } else if intensity > 0.2 {
                theme.style(theme.warning)
            } else {
                theme.style(theme.muted)
            };

            lines.push(Line::from(vec![
                Span::styled(format!("{} ", day), theme.style_muted()),
                Span::styled(bar, bar_style),
                Span::styled(format!(" {}", count), theme.style(theme.foreground)),
            ]));
        }
    }

    let para = Paragraph::new(lines).wrap(Wrap { trim: true });
    f.render_widget(para, area);
}

fn render_help(f: &mut Frame, theme: &Theme, area: Rect) {
    let help = Paragraph::new(Line::from(vec![
        Span::styled("ctrl+l", theme.style_accent()),
        Span::styled(" lang • ", theme.style_muted()),
        Span::styled("ctrl+m", theme.style_accent()),
        Span::styled(" mode • ", theme.style_muted()),
        Span::styled("ctrl+d", theme.style_accent()),
        Span::styled(" duration • ", theme.style_muted()),
        Span::styled("ctrl+t", theme.style_accent()),
        Span::styled(" theme • ", theme.style_muted()),
        Span::styled("tab", theme.style_accent()),
        Span::styled(" test • ", theme.style_muted()),
        Span::styled("esc", theme.style_accent()),
        Span::styled(" menu", theme.style_muted()),
    ]))
    .alignment(Alignment::Center);

    f.render_widget(help, area);
}