use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::Paragraph,
Frame,
};
use crate::app::{lesson_score, App};
use crate::components::key_bar;
const LABEL_WIDTH: usize = 12;
const GOAL_BAR_CELLS: u32 = 10;
const DASHBOARD_WIDTH: u16 = LABEL_WIDTH as u16 + 26 * key_bar::TILE_WIDTH;
pub fn render(app: &App, frame: &mut Frame, area: Rect) {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), ])
.split(area);
render_metrics_row(app, frame, rows[0]);
render_keys_row(app, frame, rows[1]);
render_current_key_row(app, frame, rows[2]);
render_daily_goal_row(app, frame, rows[3]);
render_callout_row(app, frame, rows[4]);
}
fn label_span(text: &str) -> Span<'static> {
let label = format!("{:<width$}", text, width = LABEL_WIDTH);
Span::styled(label, Style::default().fg(Color::DarkGray))
}
fn centered_inner(area: Rect) -> Rect {
let inner_w = DASHBOARD_WIDTH.min(area.width);
let left_margin = area.width.saturating_sub(inner_w) / 2;
Rect {
x: area.x + left_margin,
y: area.y,
width: inner_w,
height: area.height,
}
}
fn render_row(frame: &mut Frame, area: Rect, mut spans: Vec<Span<'static>>) {
let inner = centered_inner(area);
let mut all = Vec::with_capacity(spans.len() + 1);
all.append(&mut spans);
let para = Paragraph::new(Line::from(all)).alignment(Alignment::Left);
frame.render_widget(para, inner);
}
fn render_metrics_row(app: &App, frame: &mut Frame, area: Rect) {
let dim = Style::default().fg(Color::DarkGray);
let strong = Style::default().fg(Color::White);
let sep = Span::styled(" · ", dim);
let mut spans: Vec<Span<'static>> = vec![label_span("Metrics:")];
let Some(last) = &app.last_lesson else {
spans.push(Span::styled("— wpm", dim));
spans.push(sep.clone());
spans.push(Span::styled("— acc", dim));
spans.push(sep);
spans.push(Span::styled("—", dim));
render_row(frame, area, spans);
return;
};
let wpm = last.wpm;
let accuracy = last.accuracy;
let score = lesson_score(wpm, accuracy);
spans.push(Span::styled(format!("{:>3.0} wpm", wpm), strong));
if let Some(d) = delta_span(wpm, app.prev_mean_wpm()) {
spans.push(Span::raw(" "));
spans.push(d);
}
spans.push(sep.clone());
spans.push(Span::styled(format!("{:>3.0}% acc", accuracy), strong));
if let Some(d) = delta_span(accuracy, app.prev_mean_accuracy()) {
spans.push(Span::raw(" "));
spans.push(d);
}
spans.push(sep);
spans.push(Span::styled(format_score(score), strong));
if let Some(d) = delta_span(score, app.prev_mean_score()) {
spans.push(Span::raw(" "));
spans.push(d);
}
render_row(frame, area, spans);
}
fn format_score(score: f64) -> String {
let n = score.max(0.0) as u64;
let s = n.to_string();
let mut out = String::with_capacity(s.len() + s.len() / 3);
for (i, ch) in s.chars().rev().enumerate() {
if i > 0 && i % 3 == 0 {
out.push(',');
}
out.push(ch);
}
out.chars().rev().collect()
}
fn delta_span(current: f64, baseline: Option<f64>) -> Option<Span<'static>> {
let baseline = baseline?;
let diff = current - baseline;
let rounded = diff.round();
let (arrow, color) = if rounded > 0.0 {
("↑+", Color::Green)
} else if rounded < 0.0 {
("↓", Color::Red)
} else {
("·", Color::DarkGray)
};
let abs = rounded.abs();
let text = if rounded == 0.0 {
format!("({} 0)", arrow)
} else if abs >= 1000.0 {
format!("({}{:.1}k)", arrow, abs / 1000.0)
} else {
format!("({}{:.0})", arrow, abs)
};
Some(Span::styled(text, Style::default().fg(color)))
}
fn render_keys_row(app: &App, frame: &mut Frame, area: Rect) {
let inner = centered_inner(area);
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(LABEL_WIDTH as u16), Constraint::Min(0)])
.split(inner);
let label = Paragraph::new(Line::from(label_span("All keys:"))).alignment(Alignment::Left);
frame.render_widget(label, cols[0]);
key_bar::render(app, frame, cols[1]);
}
fn render_current_key_row(app: &App, frame: &mut Frame, area: Rect) {
let dim = Style::default().fg(Color::DarkGray);
let strong = Style::default().fg(Color::White);
let sep = Span::styled(" · ", dim);
let mut spans: Vec<Span<'static>> = vec![label_span("Current:")];
let Some(focused) = app.scheduler.focused_key else {
spans.push(Span::styled("all keys at target speed", dim));
render_row(frame, area, spans);
return;
};
let stats = app.per_key_stats.get(&focused);
let best_conf = stats
.map(|s| s.best_confidence(app.target_cpm))
.unwrap_or(0.0);
let active_set: std::collections::HashSet<char> =
app.scheduler.active_keys.iter().copied().collect();
let is_active = active_set.contains(&focused);
spans.extend(key_bar::key_tile_spans(focused, best_conf, is_active, true));
spans.push(Span::raw(" "));
spans.push(Span::styled("last ", dim));
match stats.and_then(|s| s.wpm()) {
Some(wpm) => {
spans.push(Span::styled(format!("{:.0} wpm", wpm), strong));
if let Some(cur_conf) = stats.map(|s| s.confidence(app.target_cpm)) {
spans.push(Span::styled(
format!(" {:.0}%", (cur_conf * 100.0).min(999.0)),
dim,
));
}
}
None => spans.push(Span::styled("—", dim)),
}
spans.push(sep);
spans.push(Span::styled("top ", dim));
match stats.and_then(|s| s.best_wpm()) {
Some(best_wpm) => {
spans.push(Span::styled(format!("{:.0} wpm", best_wpm), strong));
spans.push(Span::styled(
format!(" {:.0}%", (best_conf * 100.0).min(999.0)),
dim,
));
}
None => spans.push(Span::styled("—", dim)),
}
render_row(frame, area, spans);
}
fn render_daily_goal_row(app: &App, frame: &mut Frame, area: Rect) {
let dim = Style::default().fg(Color::DarkGray);
if app.daily_goal_minutes == 0 {
let spans = vec![label_span("Daily goal:"), Span::styled("off", dim)];
render_row(frame, area, spans);
return;
}
let goal_min = app.daily_goal_minutes;
let practiced_secs = app.today_seconds_practiced;
let goal_secs = goal_min.saturating_mul(60);
let pct = if goal_secs == 0 {
0.0
} else {
(practiced_secs as f64 / goal_secs as f64).clamp(0.0, 1.0)
};
let filled_cells = (pct * GOAL_BAR_CELLS as f64).round() as u32;
let filled_cells = filled_cells.min(GOAL_BAR_CELLS);
let filled_color = if filled_cells >= GOAL_BAR_CELLS {
Color::Green
} else {
Color::White
};
let filled_str: String = "▓".repeat(filled_cells as usize);
let empty_str: String = "░".repeat((GOAL_BAR_CELLS - filled_cells) as usize);
let pct_text = format!(" {:>3.0}% / {} min", pct * 100.0, goal_min);
let spans = vec![
label_span("Daily goal:"),
Span::styled(filled_str, Style::default().fg(filled_color)),
Span::styled(empty_str, dim),
Span::styled(pct_text, dim),
];
render_row(frame, area, spans);
}
fn render_callout_row(app: &App, frame: &mut Frame, area: Rect) {
let unlocked = app.last_lesson.as_ref().and_then(|r| r.newly_unlocked);
let Some(ch) = unlocked else {
return; };
let spans = vec![
label_span(""), Span::styled(
format!("+ '{}' unlocked!", ch),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
];
render_row(frame, area, spans);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_score_inserts_thousands_separators() {
assert_eq!(format_score(0.0), "0");
assert_eq!(format_score(42.0), "42");
assert_eq!(format_score(1_234.0), "1,234");
assert_eq!(format_score(12_345.0), "12,345");
assert_eq!(format_score(1_234_567.0), "1,234,567");
}
#[test]
fn delta_span_is_none_without_baseline() {
assert!(delta_span(50.0, None).is_none());
}
#[test]
fn delta_span_positive_uses_green() {
let s = delta_span(75.0, Some(50.0)).expect("delta");
assert!(s.content.contains("↑+"));
assert_eq!(s.style.fg, Some(Color::Green));
}
#[test]
fn delta_span_negative_uses_red() {
let s = delta_span(40.0, Some(50.0)).expect("delta");
assert!(s.content.contains("↓"));
assert_eq!(s.style.fg, Some(Color::Red));
}
#[test]
fn delta_span_zero_is_dim() {
let s = delta_span(50.4, Some(50.0)).expect("delta");
assert!(s.content.contains("0"));
assert_eq!(s.style.fg, Some(Color::DarkGray));
}
#[test]
fn delta_span_large_value_uses_k_suffix() {
let s = delta_span(7_500.0, Some(1_000.0)).expect("delta");
assert!(s.content.contains("k"));
}
}