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() {
let syntax_color = if test.mode == crate::content::Mode::Code {
test.syntax_colors.get(i).copied().flatten()
} else {
None
};
let is_whitespace = c.is_whitespace() && *c != '\n';
let style = match status {
CharStatus::Untyped => {
match syntax_color {
Some(color) if !is_whitespace => theme.style(theme.dim_color(color)),
Some(color) => theme.style(color), None => theme.style(theme.text_untyped),
}
}
CharStatus::Correct => {
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 {
if test.mode == crate::content::Mode::Text {
let char_width = c.width().unwrap_or(1);
if current_line_width + char_width > MAX_LINE_WIDTH {
if *c == ' ' {
lines.push(Line::from(current_line));
current_line = vec![];
current_line_width = 0;
} else {
let mut split_pos = current_line.len();
for (idx, span) in current_line.iter().enumerate().rev() {
if span.content == " " {
split_pos = idx;
break;
}
}
if split_pos > 0 && split_pos < current_line.len() {
let new_line_spans: Vec<Span> = current_line
.split_off(split_pos + 1) .into_iter()
.collect();
lines.push(Line::from(current_line));
current_line = new_line_spans;
current_line_width =
current_line.iter().map(|s| s.content.width()).sum();
}
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 {
current_line.push(Span::styled(c.to_string(), style));
}
}
}
if !current_line.is_empty() {
lines.push(Line::from(current_line));
}
let viewport_height = area.height as usize;
let total_lines = lines.len();
let (start_line, end_line) = if total_lines <= viewport_height {
(0, total_lines)
} else {
let viewport_half = viewport_height / 2;
let start = if cursor_line <= viewport_half {
0
} else if cursor_line + viewport_half >= total_lines {
total_lines.saturating_sub(viewport_height)
} else {
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);
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]);
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]);
}