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]);
}
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) {
let main_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(6), Constraint::Max(20), Constraint::Min(0), ])
.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) {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
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],
);
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() {
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;
}
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);
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();
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
};
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);
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();
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);
}