typetui 0.1.0

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

fn center_rect(parent: Rect, width: u16, height: u16) -> Rect {
    let x = parent.x + (parent.width.saturating_sub(width)) / 2;
    let y = parent.y + (parent.height.saturating_sub(height)) / 2;
    Rect::new(x, y, width.min(parent.width), height.min(parent.height))
}

use crate::typing::{CharStatus, TypingTest};
use crate::ui::theme::Theme;

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

    render_header(f, test, theme, chunks[0]);
    render_typing_area(f, test, theme, chunks[1]);
    render_stats(f, test, theme, chunks[2]);
    render_help(f, theme, chunks[3]);
}

fn render_header(f: &mut Frame, test: &TypingTest, theme: &Theme, area: Rect) {
    let mode_str = test.mode.as_str();
    let remaining = test.remaining_secs();
    let progress = format!("{:.0}%", test.progress_pct());

    let header_text = vec![
        Line::from(vec![
            Span::styled(" mode: ", theme.style_muted()),
            Span::styled(mode_str, theme.style_accent()),
            Span::styled(" | ", theme.style_muted()),
            Span::styled(" language: ", theme.style_muted()),
            Span::styled(&test.language, theme.style_accent()),
            Span::styled(" | ", theme.style_muted()),
            Span::styled(" remaining: ", theme.style_muted()),
            Span::styled(
                format!("{}s", remaining),
                if remaining <= 10 {
                    theme.style(theme.error).add_modifier(Modifier::BOLD)
                } else {
                    theme.style_accent()
                },
            ),
            Span::styled(" | ", theme.style_muted()),
            Span::styled(" progress: ", theme.style_muted()),
            Span::styled(&progress, theme.style_accent()),
        ]),
    ];

    let header = Paragraph::new(header_text).alignment(Alignment::Center);

    f.render_widget(header, area);
}

fn render_typing_area(f: &mut Frame, test: &TypingTest, theme: &Theme, area: Rect) {
    let char_status = test.current_char_status();
    let mut lines = vec![];
    let mut current_line = vec![];
    let mut cursor_line = 0;

    for (i, (c, status)) in char_status.iter().enumerate() {
        let style = match status {
            CharStatus::Untyped => theme.style(theme.text_untyped),
            CharStatus::Correct => theme.style(theme.text_correct),
            CharStatus::Incorrect => theme
                .style(theme.text_incorrect)
                .add_modifier(Modifier::BOLD),
        };

        let is_cursor = i == test.cursor_position && test.is_active && !test.is_finished;

        let style = if is_cursor {
            style
                .add_modifier(Modifier::REVERSED)
                .add_modifier(Modifier::UNDERLINED)
        } else {
            style
        };

        if is_cursor {
            cursor_line = lines.len();
        }

        if *c == '\n' {
            let newline_color = match status {
                CharStatus::Untyped => theme.newline,
                CharStatus::Correct => theme.newline_typed,
                CharStatus::Incorrect => theme.newline_typed,
            };
            let mut newline_style = theme.style(newline_color).add_modifier(Modifier::BOLD);
            if is_cursor {
                newline_style = newline_style
                    .add_modifier(Modifier::REVERSED)
                    .add_modifier(Modifier::UNDERLINED);
            }
            current_line.push(Span::styled("", newline_style));
            lines.push(Line::from(current_line));
            current_line = vec![];
        } else {
            current_line.push(Span::styled(c.to_string(), style));
        }
    }

    if !current_line.is_empty() {
        lines.push(Line::from(current_line));
    }

    // Calculate viewport to show N lines with cursor approximately centered
    let viewport_height = area.height as usize;
    let total_lines = lines.len();

    let (start_line, end_line) = if total_lines <= viewport_height {
        // All lines fit, show everything
        (0, total_lines)
    } else {
        // Calculate viewport to center cursor line
        let viewport_half = viewport_height / 2;

        let start = if cursor_line <= viewport_half {
            // Cursor is near the top, start from beginning
            0
        } else if cursor_line + viewport_half >= total_lines {
            // Cursor is near the end, show last N lines
            total_lines.saturating_sub(viewport_height)
        } else {
            // Cursor is in the middle, center it
            cursor_line.saturating_sub(viewport_half)
        };

        let end = (start + viewport_height).min(total_lines);
        (start, end)
    };

    let visible_lines: Vec<Line> = lines.into_iter()
        .skip(start_line)
        .take(end_line - start_line)
        .collect();

    let para_width = visible_lines.iter().map(|l| l.width()).max().unwrap_or(0) as u16;
    let para_height = visible_lines.len() as u16;
    let para_area = center_rect(area, para_width.min(area.width), para_height.min(area.height));

    let typing_para = Paragraph::new(visible_lines).alignment(Alignment::Left);

    f.render_widget(typing_para, para_area);
}

fn render_stats(f: &mut Frame, test: &TypingTest, theme: &Theme, area: Rect) {
    let stats_chunks = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([
            Constraint::Length(12),
            Constraint::Length(12),
            Constraint::Length(12),
            Constraint::Length(12),
        ])
        .margin((area.width.saturating_sub(48)) / 2)
        .split(area);

    render_stat_box(
        f,
        "wpm",
        &format!("{:.0}", test.wpm()),
        theme.style(theme.success),
        theme,
        stats_chunks[0],
    );
    render_stat_box(
        f,
        "raw",
        &format!("{:.0}", test.raw_wpm()),
        theme.style(theme.accent),
        theme,
        stats_chunks[1],
    );
    render_stat_box(
        f,
        "accuracy",
        &format!("{:.1}%", test.accuracy()),
        theme.style(theme.warning),
        theme,
        stats_chunks[2],
    );
    render_stat_box(
        f,
        "characters",
        &format!("{}/{}", test.cursor_position, test.target_text.len()),
        theme.style(theme.muted),
        theme,
        stats_chunks[3],
    );
}

fn render_stat_box(
    f: &mut Frame,
    label: &str,
    value: &str,
    value_style: Style,
    theme: &Theme,
    area: Rect,
) {
    let text = vec![
        Line::from(Span::styled(value, value_style.add_modifier(Modifier::BOLD))),
        Line::from(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 = Paragraph::new(Line::from(vec![
        Span::styled("type the text • ", theme.style_muted()),
        Span::styled("backspace", theme.style_accent()),
        Span::styled(" correct • ", theme.style_muted()),
        Span::styled("tab", 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()),
    ]))
    .alignment(Alignment::Center);

    f.render_widget(help, area);
}