typetui 0.2.1

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

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, test, 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!("{remaining}s"),
            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);
}

const MAX_LINE_WIDTH: usize = 80;

#[allow(clippy::too_many_lines)]
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;
    let mut current_line_width: usize = 0;

    for (i, (c, status)) in char_status.iter().enumerate() {
        // Get syntax color if in code mode and available
        let syntax_color = if test.mode == crate::content::Mode::Code {
            test.syntax_colors.get(i).copied().flatten()
        } else {
            None
        };

        // Check if this is whitespace that needs visibility
        let is_whitespace = c.is_whitespace() && *c != '\n';

        let style = match status {
            CharStatus::Untyped => {
                // For code mode with syntax highlighting: dim the syntax color
                // For text mode or unsupported languages: use default untyped color
                // Whitespace always uses visible color (not dimmed) to preserve indentation
                match syntax_color {
                    Some(color) if !is_whitespace => theme.style(theme.dim_color(color)),
                    Some(color) => theme.style(color), // Don't dim whitespace
                    None => theme.style(theme.text_untyped),
                }
            }
            CharStatus::Correct => {
                // For code mode with syntax highlighting: use syntax color with bold
                // to make it more visible against dimmed untyped text
                // For text mode: use default correct color
                match syntax_color {
                    Some(color) => theme.style(color).add_modifier(Modifier::BOLD),
                    None => 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 | 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![];
            current_line_width = 0;
        } else {
            // For text mode, wrap at MAX_LINE_WIDTH with intelligent word breaking
            if test.mode == crate::content::Mode::Text {
                let char_width = c.width().unwrap_or(1);

                // Check if we need to wrap
                if current_line_width + char_width > MAX_LINE_WIDTH {
                    // Try to break at word boundary (space)
                    if *c == ' ' {
                        // End current line, skip the space on the new line
                        lines.push(Line::from(current_line));
                        current_line = vec![];
                        current_line_width = 0;
                    } else {
                        // Look for a word boundary in the current line
                        let mut split_pos = current_line.len();

                        // Find the last space in the current line
                        for (idx, span) in current_line.iter().enumerate().rev() {
                            if span.content == " " {
                                split_pos = idx;
                                break;
                            }
                        }

                        // If we found a space and it's not at the start, split there
                        if split_pos > 0 && split_pos < current_line.len() {
                            // Characters after the split point form the start of new line
                            let new_line_spans: Vec<Span> = current_line
                                .split_off(split_pos + 1) // +1 to exclude the space itself
                                .into_iter()
                                .collect();

                            // Push the completed line
                            lines.push(Line::from(current_line));

                            // Start new line with remaining spans
                            current_line = new_line_spans;
                            current_line_width =
                                current_line.iter().map(|s| s.content.width()).sum();
                        }

                        // Add the current character
                        current_line.push(Span::styled(c.to_string(), style));
                        current_line_width += char_width;
                    }
                } else {
                    current_line.push(Span::styled(c.to_string(), style));
                    current_line_width += char_width;
                }
            } else {
                // Code mode: no wrapping, respect original newlines
                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(ratatui::prelude::Line::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, test: &TypingTest, theme: &Theme, area: Rect) {
    let help_chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Length(1), Constraint::Length(1)])
        .split(area);

    // First row: typing controls
    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("esc", theme.style_accent()),
        Span::styled(" quit", theme.style_muted()),
    ]))
    .alignment(Alignment::Center);

    f.render_widget(help, help_chunks[0]);

    // Second row: config buttons
    let config_line = vec![
        Span::styled("ctrl+o ", theme.style_accent()),
        Span::styled(format!("{}", test.mode.as_str()), theme.style_muted()),
        Span::styled("ctrl+l ", theme.style_accent()),
        Span::styled(format!("{}", test.language), theme.style_muted()),
        Span::styled("ctrl+d ", theme.style_accent()),
        Span::styled(format!("{}s • ", test.duration_secs), theme.style_muted()),
        Span::styled("ctrl+t ", theme.style_accent()),
        Span::styled("theme • ", theme.style_muted()),
        Span::styled("ctrl+p ", theme.style_accent()),
        Span::styled("profile", theme.style_muted()),
    ];

    let config_help = Paragraph::new(Line::from(config_line)).alignment(Alignment::Center);
    f.render_widget(config_help, help_chunks[1]);
}