Implement six major improvements to typing tutor

1. Start in Adaptive Drill by default: App launches directly into a
   typing lesson instead of the menu screen.

2. Fix error tracking for backspaced corrections: Add typo_flags
   HashSet to LessonState that persists error positions through
   backspace. Errors at a position are counted even if corrected,
   matching keybr.com behavior. Multiple errors at the same position
   count as one.

3. Fix keyboard visualization with depressed keys: Enable crossterm
   keyboard enhancement flags for key Release events. Track depressed
   keys in a HashSet with 150ms fallback clearing. Depressed keys
   render with bright/bold styling at highest priority. Add compact
   keyboard mode for medium-width terminals.

4. Responsive UI for small terminals: Add LayoutTier enum (Wide >=100,
   Medium 60-99, Narrow <60 cols). Medium hides sidebar and shows
   compact stats header and compact keyboard. Narrow hides keyboard
   and progress bar entirely. Short terminals (<20 rows) also hide
   keyboard/progress.

5. Delete sessions from history: Add j/k row navigation in history
   tab, x/Delete to initiate deletion with y/n confirmation dialog.
   Full chronological replay rebuilds key_stats, letter_unlock,
   profile scoring, and streak tracking. Only adaptive sessions update
   key_stats/letter_unlock during rebuild. LessonResult now persists
   lesson_mode for correct replay gating.

6. Improved statistics display: Bordered summary table on dashboard,
   WPM bar graph using block characters (green above goal, red below),
   accuracy Braille trend chart, bordered history table with WPM goal
   indicators and selected-row highlighting, character speed
   distribution with time labels, keyboard accuracy heatmap with
   percentage text per key, worst accuracy keys panel, new 7-month
   activity calendar heatmap widget with theme-derived intensity
   colors, side-by-side panel layout for terminals >170 cols wide.

Also: ignore KeyEventKind::Repeat for typing input, clamp history
selection to visible 20-row range, and suppress dead_code warnings
on now-unused WpmChart.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 00:20:25 +00:00
parent c78a8a90a3
commit a0e8f3cafb
13 changed files with 1385 additions and 271 deletions

View File

@@ -1,3 +1,6 @@
use std::collections::HashSet;
use std::time::Instant;
use rand::rngs::SmallRng;
use rand::SeedableRng;
@@ -37,6 +40,16 @@ pub enum LessonMode {
Passage,
}
impl LessonMode {
pub fn as_str(self) -> &'static str {
match self {
LessonMode::Adaptive => "adaptive",
LessonMode::Code => "code",
LessonMode::Passage => "passage",
}
}
}
pub struct App {
pub screen: AppScreen,
pub lesson_mode: LessonMode,
@@ -54,6 +67,10 @@ pub struct App {
pub should_quit: bool,
pub settings_selected: usize,
pub stats_tab: usize,
pub depressed_keys: HashSet<char>,
pub last_key_time: Option<Instant>,
pub history_selected: usize,
pub history_confirm_delete: bool,
rng: SmallRng,
transition_table: TransitionTable,
#[allow(dead_code)]
@@ -96,7 +113,7 @@ impl App {
let dictionary = Dictionary::load();
let transition_table = TransitionTable::build_from_words(&dictionary.words_list());
Self {
let mut app = Self {
screen: AppScreen::Menu,
lesson_mode: LessonMode::Adaptive,
lesson: None,
@@ -113,10 +130,16 @@ impl App {
should_quit: false,
settings_selected: 0,
stats_tab: 0,
depressed_keys: HashSet::new(),
last_key_time: None,
history_selected: 0,
history_confirm_delete: false,
rng: SmallRng::from_entropy(),
transition_table,
dictionary,
}
};
app.start_lesson();
app
}
pub fn start_lesson(&mut self) {
@@ -181,7 +204,7 @@ impl App {
fn finish_lesson(&mut self) {
if let Some(ref lesson) = self.lesson {
let result = LessonResult::from_lesson(lesson, &self.lesson_events);
let result = LessonResult::from_lesson(lesson, &self.lesson_events, self.lesson_mode.as_str());
if self.lesson_mode == LessonMode::Adaptive {
for kt in &result.per_key_times {
@@ -255,9 +278,84 @@ impl App {
pub fn go_to_stats(&mut self) {
self.stats_tab = 0;
self.history_selected = 0;
self.history_confirm_delete = false;
self.screen = AppScreen::StatsDashboard;
}
pub fn delete_session(&mut self) {
if self.lesson_history.is_empty() {
return;
}
// History tab shows reverse order, so convert display index to actual index
let actual_idx = self.lesson_history.len() - 1 - self.history_selected;
self.lesson_history.remove(actual_idx);
self.rebuild_from_history();
self.save_data();
// Clamp selection to visible range (max 20 visible rows)
if !self.lesson_history.is_empty() {
let max_visible = self.lesson_history.len().min(20) - 1;
self.history_selected = self.history_selected.min(max_visible);
} else {
self.history_selected = 0;
}
}
pub fn rebuild_from_history(&mut self) {
// Reset all derived state
self.key_stats = KeyStatsStore::default();
self.key_stats.target_cpm = self.config.target_cpm();
self.letter_unlock = LetterUnlock::new();
self.profile.total_score = 0.0;
self.profile.total_lessons = 0;
self.profile.streak_days = 0;
self.profile.best_streak = 0;
self.profile.last_practice_date = None;
// Replay each remaining session oldest→newest
for result in &self.lesson_history {
// Only update adaptive progression for adaptive sessions
if result.lesson_mode == "adaptive" {
for kt in &result.per_key_times {
if kt.correct {
self.key_stats.update_key(kt.key, kt.time_ms);
}
}
self.letter_unlock.update(&self.key_stats);
}
// Compute score
let complexity = scoring::compute_complexity(self.letter_unlock.unlocked_count());
let score = scoring::compute_score(result, complexity);
self.profile.total_score += score;
self.profile.total_lessons += 1;
// Rebuild streak tracking
let day = result.timestamp.format("%Y-%m-%d").to_string();
if self.profile.last_practice_date.as_deref() != Some(&day) {
if let Some(ref last) = self.profile.last_practice_date {
let result_date = result.timestamp.date_naive();
let last_date =
chrono::NaiveDate::parse_from_str(last, "%Y-%m-%d").unwrap_or(result_date);
let diff = result_date.signed_duration_since(last_date).num_days();
if diff == 1 {
self.profile.streak_days += 1;
} else {
self.profile.streak_days = 1;
}
} else {
self.profile.streak_days = 1;
}
self.profile.best_streak =
self.profile.best_streak.max(self.profile.streak_days);
self.profile.last_practice_date = Some(day);
}
}
self.profile.unlocked_letters = self.letter_unlock.included.clone();
}
pub fn go_to_settings(&mut self) {
self.settings_selected = 0;
self.screen = AppScreen::Settings;

View File

@@ -9,11 +9,14 @@ mod store;
mod ui;
use std::io;
use std::time::Duration;
use std::time::{Duration, Instant};
use anyhow::Result;
use clap::Parser;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crossterm::event::{
KeyCode, KeyEvent, KeyEventKind, KeyModifiers, KeyboardEnhancementFlags,
PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
@@ -68,6 +71,14 @@ fn main() -> Result<()> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
// Try to enable keyboard enhancement for Release event support
let keyboard_enhanced = execute!(
io::stdout(),
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::REPORT_EVENT_TYPES)
)
.is_ok();
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
@@ -75,6 +86,9 @@ fn main() -> Result<()> {
let result = run_app(&mut terminal, &mut app, &events);
if keyboard_enhanced {
let _ = execute!(io::stdout(), PopKeyboardEnhancementFlags);
}
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
@@ -96,7 +110,16 @@ fn run_app(
match events.next()? {
AppEvent::Key(key) => handle_key(app, key),
AppEvent::Tick => {}
AppEvent::Tick => {
// Fallback: clear depressed keys after 150ms if no Release event received
if let Some(last) = app.last_key_time {
if last.elapsed() > Duration::from_millis(150) && !app.depressed_keys.is_empty()
{
app.depressed_keys.clear();
app.last_key_time = None;
}
}
}
AppEvent::Resize(_, _) => {}
}
@@ -107,6 +130,25 @@ fn run_app(
}
fn handle_key(app: &mut App, key: KeyEvent) {
// Track depressed keys for keyboard diagram
match (&key.code, key.kind) {
(KeyCode::Char(ch), KeyEventKind::Press) => {
app.depressed_keys.insert(ch.to_ascii_lowercase());
app.last_key_time = Some(Instant::now());
}
(KeyCode::Char(ch), KeyEventKind::Release) => {
app.depressed_keys.remove(&ch.to_ascii_lowercase());
return; // Don't process Release events as input
}
(_, KeyEventKind::Release) => return,
_ => {}
}
// Only process Press events — ignore Repeat to avoid inflating input
if key.kind != KeyEventKind::Press {
return;
}
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
app.should_quit = true;
return;
@@ -167,7 +209,7 @@ fn handle_lesson_key(app: &mut App, key: KeyEvent) {
let has_progress = app.lesson.as_ref().is_some_and(|l| l.cursor > 0);
if has_progress {
if let Some(ref lesson) = app.lesson {
let result = LessonResult::from_lesson(lesson, &app.lesson_events);
let result = LessonResult::from_lesson(lesson, &app.lesson_events, app.lesson_mode.as_str());
app.last_result = Some(result);
}
app.screen = AppScreen::LessonResult;
@@ -191,6 +233,52 @@ fn handle_result_key(app: &mut App, key: KeyEvent) {
}
fn handle_stats_key(app: &mut App, key: KeyEvent) {
// Confirmation dialog takes priority
if app.history_confirm_delete {
match key.code {
KeyCode::Char('y') => {
app.delete_session();
app.history_confirm_delete = false;
}
KeyCode::Char('n') | KeyCode::Esc => {
app.history_confirm_delete = false;
}
_ => {}
}
return;
}
// History tab has row navigation
if app.stats_tab == 1 {
match key.code {
KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(),
KeyCode::Char('j') | KeyCode::Down => {
if !app.lesson_history.is_empty() {
let max_visible = app.lesson_history.len().min(20) - 1;
app.history_selected =
(app.history_selected + 1).min(max_visible);
}
}
KeyCode::Char('k') | KeyCode::Up => {
app.history_selected = app.history_selected.saturating_sub(1);
}
KeyCode::Char('x') | KeyCode::Delete => {
if !app.lesson_history.is_empty() {
app.history_confirm_delete = true;
}
}
KeyCode::Char('d') | KeyCode::Char('1') => app.stats_tab = 0,
KeyCode::Char('h') | KeyCode::Char('2') => {} // already on history
KeyCode::Char('3') => app.stats_tab = 2,
KeyCode::Tab => app.stats_tab = (app.stats_tab + 1) % 3,
KeyCode::BackTab => {
app.stats_tab = if app.stats_tab == 0 { 2 } else { app.stats_tab - 1 }
}
_ => {}
}
return;
}
match key.code {
KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(),
KeyCode::Char('d') | KeyCode::Char('1') => app.stats_tab = 0,
@@ -304,69 +392,105 @@ fn render_lesson(frame: &mut ratatui::Frame, app: &App) {
if let Some(ref lesson) = app.lesson {
let app_layout = AppLayout::new(area);
let tier = app_layout.tier;
let mode_name = match app.lesson_mode {
LessonMode::Adaptive => "Adaptive",
LessonMode::Code => "Code",
LessonMode::Passage => "Passage",
};
let header_title = format!(" {mode_name} Practice ");
let focus_text = if let Some(focused) = app.letter_unlock.focused {
format!(" | Focus: '{focused}'")
} else {
String::new()
};
let header = Paragraph::new(Line::from(vec![
Span::styled(
&*header_title,
// For medium/narrow: show compact stats in header
if !tier.show_sidebar() {
let wpm = lesson.wpm();
let accuracy = lesson.accuracy();
let errors = lesson.typo_count();
let header_text = format!(
" {mode_name} | WPM: {wpm:.0} | Acc: {accuracy:.1}% | Errors: {errors}"
);
let header = Paragraph::new(Line::from(Span::styled(
&*header_text,
Style::default()
.fg(colors.header_fg())
.bg(colors.header_bg())
.add_modifier(Modifier::BOLD),
),
Span::styled(
&*focus_text,
Style::default()
.fg(colors.focused_key())
.bg(colors.header_bg()),
),
]))
.style(Style::default().bg(colors.header_bg()));
frame.render_widget(header, app_layout.header);
)))
.style(Style::default().bg(colors.header_bg()));
frame.render_widget(header, app_layout.header);
} else {
let header_title = format!(" {mode_name} Practice ");
let focus_text = if let Some(focused) = app.letter_unlock.focused {
format!(" | Focus: '{focused}'")
} else {
String::new()
};
let header = Paragraph::new(Line::from(vec![
Span::styled(
&*header_title,
Style::default()
.fg(colors.header_fg())
.bg(colors.header_bg())
.add_modifier(Modifier::BOLD),
),
Span::styled(
&*focus_text,
Style::default()
.fg(colors.focused_key())
.bg(colors.header_bg()),
),
]))
.style(Style::default().bg(colors.header_bg()));
frame.render_widget(header, app_layout.header);
}
// Build main area constraints based on tier
let show_kbd = tier.show_keyboard(area.height);
let show_progress = tier.show_progress_bar(area.height);
let mut constraints: Vec<Constraint> = vec![Constraint::Min(5)];
if show_progress {
constraints.push(Constraint::Length(3));
}
if show_kbd {
constraints.push(Constraint::Length(5));
}
let main_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(5),
Constraint::Length(3),
Constraint::Length(5),
])
.constraints(constraints)
.split(app_layout.main);
let typing = TypingArea::new(lesson, app.theme);
frame.render_widget(typing, main_layout[0]);
let progress = ProgressBar::new(
"Letter Progress",
app.letter_unlock.progress(),
app.theme,
);
frame.render_widget(progress, main_layout[1]);
let mut idx = 1;
if show_progress {
let progress = ProgressBar::new(
"Letter Progress",
app.letter_unlock.progress(),
app.theme,
);
frame.render_widget(progress, main_layout[idx]);
idx += 1;
}
let next_char = lesson
.target
.get(lesson.cursor)
.copied();
let kbd = KeyboardDiagram::new(
app.letter_unlock.focused,
next_char,
&app.letter_unlock.included,
app.theme,
);
frame.render_widget(kbd, main_layout[2]);
if show_kbd {
let next_char = lesson.target.get(lesson.cursor).copied();
let kbd = KeyboardDiagram::new(
app.letter_unlock.focused,
next_char,
&app.letter_unlock.included,
&app.depressed_keys,
app.theme,
)
.compact(tier.compact_keyboard());
frame.render_widget(kbd, main_layout[idx]);
}
let sidebar = StatsSidebar::new(lesson, app.theme);
frame.render_widget(sidebar, app_layout.sidebar);
if let Some(sidebar_area) = app_layout.sidebar {
let sidebar = StatsSidebar::new(lesson, app.theme);
frame.render_widget(sidebar, sidebar_area);
}
let footer = Paragraph::new(Line::from(Span::styled(
" [ESC] End lesson [Backspace] Delete ",
@@ -394,6 +518,8 @@ fn render_stats(frame: &mut ratatui::Frame, app: &App) {
app.stats_tab,
app.config.target_wpm,
app.theme,
app.history_selected,
app.history_confirm_delete,
);
frame.render_widget(dashboard, area);
}

View File

@@ -40,6 +40,7 @@ pub fn process_char(lesson: &mut LessonState, ch: char) -> Option<KeystrokeEvent
lesson.input.push(CharStatus::Correct);
} else {
lesson.input.push(CharStatus::Incorrect(ch));
lesson.typo_flags.insert(lesson.cursor);
}
lesson.cursor += 1;

View File

@@ -1,3 +1,4 @@
use std::collections::HashSet;
use std::time::Instant;
use crate::session::input::CharStatus;
@@ -8,6 +9,7 @@ pub struct LessonState {
pub cursor: usize,
pub started_at: Option<Instant>,
pub finished_at: Option<Instant>,
pub typo_flags: HashSet<usize>,
}
impl LessonState {
@@ -18,6 +20,7 @@ impl LessonState {
cursor: 0,
started_at: None,
finished_at: None,
typo_flags: HashSet::new(),
}
}
@@ -40,13 +43,6 @@ impl LessonState {
.count()
}
pub fn incorrect_count(&self) -> usize {
self.input
.iter()
.filter(|s| matches!(s, CharStatus::Incorrect(_)))
.count()
}
pub fn wpm(&self) -> f64 {
let elapsed = self.elapsed_secs();
if elapsed < 0.1 {
@@ -56,12 +52,20 @@ impl LessonState {
(chars / 5.0) / (elapsed / 60.0)
}
pub fn typo_count(&self) -> usize {
self.typo_flags.len()
}
pub fn accuracy(&self) -> f64 {
let total = self.input.len();
if total == 0 {
if self.cursor == 0 {
return 100.0;
}
(self.correct_count() as f64 / total as f64) * 100.0
let typos_before_cursor = self
.typo_flags
.iter()
.filter(|&&pos| pos < self.cursor)
.count();
((self.cursor - typos_before_cursor) as f64 / self.cursor as f64 * 100.0).clamp(0.0, 100.0)
}
pub fn cpm(&self) -> f64 {
@@ -83,6 +87,7 @@ impl LessonState {
#[cfg(test)]
mod tests {
use super::*;
use crate::session::input;
#[test]
fn test_new_lesson() {
@@ -105,4 +110,52 @@ mod tests {
assert!(lesson.is_complete());
assert_eq!(lesson.progress(), 0.0);
}
#[test]
fn test_correct_typing_no_typos() {
let mut lesson = LessonState::new("abc");
input::process_char(&mut lesson, 'a');
input::process_char(&mut lesson, 'b');
input::process_char(&mut lesson, 'c');
assert!(lesson.typo_flags.is_empty());
assert_eq!(lesson.accuracy(), 100.0);
}
#[test]
fn test_wrong_then_backspace_then_correct_counts_as_error() {
let mut lesson = LessonState::new("abc");
// Type wrong at pos 0
input::process_char(&mut lesson, 'x');
assert!(lesson.typo_flags.contains(&0));
// Backspace
input::process_backspace(&mut lesson);
// Typo flag persists
assert!(lesson.typo_flags.contains(&0));
// Type correct
input::process_char(&mut lesson, 'a');
assert!(lesson.typo_flags.contains(&0));
assert_eq!(lesson.typo_count(), 1);
assert!(lesson.accuracy() < 100.0);
}
#[test]
fn test_multiple_errors_same_position_counts_as_one() {
let mut lesson = LessonState::new("abc");
// Wrong, backspace, wrong again, backspace, correct
input::process_char(&mut lesson, 'x');
input::process_backspace(&mut lesson);
input::process_char(&mut lesson, 'y');
input::process_backspace(&mut lesson);
input::process_char(&mut lesson, 'a');
assert_eq!(lesson.typo_count(), 1);
}
#[test]
fn test_wrong_char_without_backspace() {
let mut lesson = LessonState::new("abc");
input::process_char(&mut lesson, 'x'); // wrong at pos 0
input::process_char(&mut lesson, 'b'); // correct at pos 1
assert_eq!(lesson.typo_count(), 1);
assert!(lesson.typo_flags.contains(&0));
}
}

View File

@@ -15,6 +15,12 @@ pub struct LessonResult {
pub elapsed_secs: f64,
pub timestamp: DateTime<Utc>,
pub per_key_times: Vec<KeyTime>,
#[serde(default = "default_lesson_mode")]
pub lesson_mode: String,
}
fn default_lesson_mode() -> String {
"adaptive".to_string()
}
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -25,7 +31,7 @@ pub struct KeyTime {
}
impl LessonResult {
pub fn from_lesson(lesson: &LessonState, events: &[KeystrokeEvent]) -> Self {
pub fn from_lesson(lesson: &LessonState, events: &[KeystrokeEvent], lesson_mode: &str) -> Self {
let per_key_times: Vec<KeyTime> = events
.windows(2)
.map(|pair| {
@@ -38,16 +44,25 @@ impl LessonResult {
})
.collect();
let total_chars = lesson.target.len();
let typo_count = lesson.typo_flags.len();
let accuracy = if total_chars > 0 {
((total_chars - typo_count) as f64 / total_chars as f64 * 100.0).clamp(0.0, 100.0)
} else {
100.0
};
Self {
wpm: lesson.wpm(),
cpm: lesson.cpm(),
accuracy: lesson.accuracy(),
correct: lesson.correct_count(),
incorrect: lesson.incorrect_count(),
total_chars: lesson.target.len(),
accuracy,
correct: total_chars - typo_count,
incorrect: typo_count,
total_chars,
elapsed_secs: lesson.elapsed_secs(),
timestamp: Utc::now(),
per_key_times,
lesson_mode: lesson_mode.to_string(),
}
}
}

View File

@@ -0,0 +1,152 @@
use std::collections::HashMap;
use chrono::{Datelike, NaiveDate, Utc};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
use ratatui::widgets::{Block, Widget};
use crate::session::result::LessonResult;
use crate::ui::theme::Theme;
pub struct ActivityHeatmap<'a> {
history: &'a [LessonResult],
theme: &'a Theme,
}
impl<'a> ActivityHeatmap<'a> {
pub fn new(history: &'a [LessonResult], theme: &'a Theme) -> Self {
Self { history, theme }
}
}
impl Widget for ActivityHeatmap<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let block = Block::bordered()
.title(" Activity ")
.border_style(Style::default().fg(colors.border()));
let inner = block.inner(area);
block.render(area, buf);
if inner.height < 9 || inner.width < 30 {
return;
}
// Count sessions per day
let mut day_counts: HashMap<NaiveDate, usize> = HashMap::new();
for result in self.history {
let date = result.timestamp.date_naive();
*day_counts.entry(date).or_insert(0) += 1;
}
let today = Utc::now().date_naive();
// Show ~26 weeks (half a year)
let weeks_to_show = ((inner.width as usize).saturating_sub(3)) / 2;
let weeks_to_show = weeks_to_show.min(26);
let start_date = today - chrono::Duration::weeks(weeks_to_show as i64);
// Align to Monday
let start_date = start_date
- chrono::Duration::days(start_date.weekday().num_days_from_monday() as i64);
// Day-of-week labels
let day_labels = ["M", " ", "W", " ", "F", " ", "S"];
for (row, label) in day_labels.iter().enumerate() {
let y = inner.y + 1 + row as u16;
if y < inner.y + inner.height {
buf.set_string(
inner.x,
y,
label,
Style::default().fg(colors.text_pending()),
);
}
}
// Render weeks as columns
let mut current_date = start_date;
let mut col = 0u16;
// Month labels
let mut last_month = 0u32;
while current_date <= today {
let x = inner.x + 2 + col * 2;
if x + 1 >= inner.x + inner.width {
break;
}
// Month label on first row
let month = current_date.month();
if month != last_month {
let month_name = match month {
1 => "Jan",
2 => "Feb",
3 => "Mar",
4 => "Apr",
5 => "May",
6 => "Jun",
7 => "Jul",
8 => "Aug",
9 => "Sep",
10 => "Oct",
11 => "Nov",
12 => "Dec",
_ => "",
};
// Only show if we have space (3 chars)
if x + 3 <= inner.x + inner.width {
buf.set_string(
x,
inner.y,
month_name,
Style::default().fg(colors.text_pending()),
);
}
last_month = month;
}
// Render 7 days in this week column
for day_offset in 0..7u16 {
let date = current_date + chrono::Duration::days(day_offset as i64);
if date > today {
break;
}
let y = inner.y + 1 + day_offset;
if y >= inner.y + inner.height {
break;
}
let count = day_counts.get(&date).copied().unwrap_or(0);
let (ch, color) = intensity_cell(count, colors);
buf.set_string(x, y, &ch.to_string(), Style::default().fg(color));
}
current_date += chrono::Duration::weeks(1);
col += 1;
}
}
}
fn scale_color(base: Color, factor: f64) -> Color {
match base {
Color::Rgb(r, g, b) => Color::Rgb(
(r as f64 * factor).min(255.0) as u8,
(g as f64 * factor).min(255.0) as u8,
(b as f64 * factor).min(255.0) as u8,
),
other => other,
}
}
fn intensity_cell(count: usize, colors: &crate::ui::theme::ThemeColors) -> (char, Color) {
let success = colors.success();
match count {
0 => ('·', colors.accent_dim()),
1..=2 => ('▪', scale_color(success, 0.4)),
3..=5 => ('▪', scale_color(success, 0.65)),
6..=15 => ('█', scale_color(success, 0.85)),
_ => ('█', success),
}
}

View File

@@ -6,11 +6,13 @@ use ratatui::widgets::{Axis, Block, Chart, Dataset, GraphType, Widget};
use crate::ui::theme::Theme;
#[allow(dead_code)]
pub struct WpmChart<'a> {
pub data: &'a [(f64, f64)],
pub theme: &'a Theme,
}
#[allow(dead_code)]
impl<'a> WpmChart<'a> {
pub fn new(data: &'a [(f64, f64)], theme: &'a Theme) -> Self {
Self { data, theme }

View File

@@ -1,6 +1,8 @@
use std::collections::HashSet;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::{Block, Widget};
use crate::keyboard::finger::{self, Finger, Hand};
@@ -10,7 +12,9 @@ pub struct KeyboardDiagram<'a> {
pub focused_key: Option<char>,
pub next_key: Option<char>,
pub unlocked_keys: &'a [char],
pub depressed_keys: &'a HashSet<char>,
pub theme: &'a Theme,
pub compact: bool,
}
impl<'a> KeyboardDiagram<'a> {
@@ -18,15 +22,23 @@ impl<'a> KeyboardDiagram<'a> {
focused_key: Option<char>,
next_key: Option<char>,
unlocked_keys: &'a [char],
depressed_keys: &'a HashSet<char>,
theme: &'a Theme,
) -> Self {
Self {
focused_key,
next_key,
unlocked_keys,
depressed_keys,
theme,
compact: false,
}
}
pub fn compact(mut self, compact: bool) -> Self {
self.compact = compact;
self
}
}
const ROWS: &[&[char]] = &[
@@ -50,6 +62,17 @@ fn finger_color(ch: char) -> Color {
}
}
fn brighten_color(color: Color) -> Color {
match color {
Color::Rgb(r, g, b) => Color::Rgb(
r.saturating_add(60),
g.saturating_add(60),
b.saturating_add(60),
),
other => other,
}
}
impl Widget for KeyboardDiagram<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
@@ -61,12 +84,18 @@ impl Widget for KeyboardDiagram<'_> {
let inner = block.inner(area);
block.render(area, buf);
if inner.height < 3 || inner.width < 30 {
let key_width: u16 = if self.compact { 3 } else { 5 };
let min_width: u16 = if self.compact { 21 } else { 30 };
if inner.height < 3 || inner.width < min_width {
return;
}
let key_width: u16 = 5;
let offsets: &[u16] = &[1, 3, 5];
let offsets: &[u16] = if self.compact {
&[0, 1, 3]
} else {
&[1, 3, 5]
};
for (row_idx, row) in ROWS.iter().enumerate() {
let y = inner.y + row_idx as u16;
@@ -82,11 +111,23 @@ impl Widget for KeyboardDiagram<'_> {
break;
}
let is_depressed = self.depressed_keys.contains(&key);
let is_unlocked = self.unlocked_keys.contains(&key);
let is_focused = self.focused_key == Some(key);
let is_next = self.next_key == Some(key);
let style = if is_next {
// Priority: depressed > next_expected > focused > unlocked > locked
let style = if is_depressed {
let bg = if is_unlocked {
brighten_color(finger_color(key))
} else {
brighten_color(colors.accent_dim())
};
Style::default()
.fg(Color::White)
.bg(bg)
.add_modifier(Modifier::BOLD)
} else if is_next {
Style::default()
.fg(colors.bg())
.bg(colors.accent())
@@ -104,7 +145,11 @@ impl Widget for KeyboardDiagram<'_> {
.bg(colors.bg())
};
let display = format!("[ {key} ]");
let display = if self.compact {
format!("[{key}]")
} else {
format!("[ {key} ]")
};
buf.set_string(x, y, &display, style);
}
}

View File

@@ -1,3 +1,4 @@
pub mod activity_heatmap;
pub mod chart;
pub mod dashboard;
pub mod keyboard_diagram;

View File

@@ -6,7 +6,7 @@ use ratatui::widgets::{Block, Paragraph, Widget};
use crate::engine::key_stats::KeyStatsStore;
use crate::session::result::LessonResult;
use crate::ui::components::chart::WpmChart;
use crate::ui::components::activity_heatmap::ActivityHeatmap;
use crate::ui::theme::Theme;
pub struct StatsDashboard<'a> {
@@ -15,6 +15,8 @@ pub struct StatsDashboard<'a> {
pub active_tab: usize,
pub target_wpm: u32,
pub theme: &'a Theme,
pub history_selected: usize,
pub history_confirm_delete: bool,
}
impl<'a> StatsDashboard<'a> {
@@ -24,6 +26,8 @@ impl<'a> StatsDashboard<'a> {
active_tab: usize,
target_wpm: u32,
theme: &'a Theme,
history_selected: usize,
history_confirm_delete: bool,
) -> Self {
Self {
history,
@@ -31,6 +35,8 @@ impl<'a> StatsDashboard<'a> {
active_tab,
target_wpm,
theme,
history_selected,
history_confirm_delete,
}
}
}
@@ -85,37 +91,88 @@ impl Widget for StatsDashboard<'_> {
.collect();
Paragraph::new(Line::from(tab_spans)).render(layout[0], buf);
// Tab content
match self.active_tab {
0 => self.render_dashboard_tab(layout[1], buf),
1 => self.render_history_tab(layout[1], buf),
2 => self.render_keystrokes_tab(layout[1], buf),
_ => {}
// Tab content — wide mode shows two panels side by side
let is_wide = area.width > 170;
if is_wide {
let panels = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(layout[1]);
// Left panel: active tab, Right panel: next tab
let left_tab = self.active_tab;
let right_tab = (self.active_tab + 1) % 3;
self.render_tab(left_tab, panels[0], buf);
self.render_tab(right_tab, panels[1], buf);
} else {
self.render_tab(self.active_tab, layout[1], buf);
}
// Footer
let footer_text = if self.active_tab == 1 {
" [ESC] Back [Tab] Next tab [j/k] Navigate [x] Delete"
} else {
" [ESC] Back [Tab] Next tab [D/H/K] Switch tab"
};
let footer = Paragraph::new(Line::from(Span::styled(
" [ESC] Back [Tab] Next tab [D/H/K] Switch tab",
footer_text,
Style::default().fg(colors.accent()),
)));
footer.render(layout[2], buf);
// Confirmation dialog overlay
if self.history_confirm_delete && self.active_tab == 1 {
let dialog_width = 34u16;
let dialog_height = 5u16;
let dialog_x = area.x + area.width.saturating_sub(dialog_width) / 2;
let dialog_y = area.y + area.height.saturating_sub(dialog_height) / 2;
let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
let idx = self.history.len().saturating_sub(self.history_selected);
let dialog_text = format!("Delete session #{idx}? (y/n)");
let dialog = Paragraph::new(vec![
Line::from(""),
Line::from(Span::styled(
format!(" {dialog_text} "),
Style::default().fg(colors.fg()),
)),
])
.block(
Block::bordered()
.title(" Confirm ")
.border_style(Style::default().fg(colors.error()))
.style(Style::default().bg(colors.bg())),
);
dialog.render(dialog_area, buf);
}
}
}
impl StatsDashboard<'_> {
fn render_tab(&self, tab: usize, area: Rect, buf: &mut Buffer) {
match tab {
0 => self.render_dashboard_tab(area, buf),
1 => self.render_history_tab(area, buf),
2 => self.render_keystrokes_tab(area, buf),
_ => {}
}
}
fn render_dashboard_tab(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(4),
Constraint::Length(3),
Constraint::Min(8),
Constraint::Length(6), // summary stats bordered box
Constraint::Length(3), // progress bars
Constraint::Min(8), // charts
])
.split(area);
// Summary stats
// Summary stats as bordered table
let avg_wpm =
self.history.iter().map(|r| r.wpm).sum::<f64>() / self.history.len() as f64;
let best_wpm = self
@@ -133,19 +190,29 @@ impl StatsDashboard<'_> {
let avg_acc_str = format!("{avg_accuracy:.1}%");
let time_str = format_duration(total_time);
let summary_block = Block::bordered()
.title(" Summary ")
.border_style(Style::default().fg(colors.border()));
let summary_inner = summary_block.inner(layout[0]);
summary_block.render(layout[0], buf);
let summary = vec![
Line::from(vec![
Span::styled(" Lessons: ", Style::default().fg(colors.fg())),
Span::styled(
&*total_str,
Style::default().fg(colors.accent()).add_modifier(Modifier::BOLD),
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
),
Span::styled(" Avg WPM: ", Style::default().fg(colors.fg())),
Span::styled(&*avg_wpm_str, Style::default().fg(colors.accent())),
Span::styled(" Best WPM: ", Style::default().fg(colors.fg())),
Span::styled(
&*best_wpm_str,
Style::default().fg(colors.success()).add_modifier(Modifier::BOLD),
Style::default()
.fg(colors.success())
.add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
@@ -164,30 +231,108 @@ impl StatsDashboard<'_> {
Span::styled(&*time_str, Style::default().fg(colors.text_pending())),
]),
];
Paragraph::new(summary).render(layout[0], buf);
Paragraph::new(summary).render(summary_inner, buf);
// Progress bars
self.render_progress_bars(layout[1], buf);
// Charts
// Charts: WPM bar graph + accuracy trend
let chart_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(layout[2]);
// WPM chart
let wpm_data: Vec<(f64, f64)> = self
self.render_wpm_bar_graph(chart_layout[0], buf);
self.render_accuracy_chart(chart_layout[1], buf);
}
fn render_wpm_bar_graph(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let block = Block::bordered()
.title(" WPM (Last 20) ")
.border_style(Style::default().fg(colors.border()));
let inner = block.inner(area);
block.render(area, buf);
if inner.width < 10 || inner.height < 3 {
return;
}
let recent: Vec<f64> = self
.history
.iter()
.rev()
.take(50)
.enumerate()
.map(|(i, r)| (i as f64, r.wpm))
.take(20)
.map(|r| r.wpm)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
WpmChart::new(&wpm_data, self.theme).render(chart_layout[0], buf);
// Accuracy chart
let acc_data: Vec<(f64, f64)> = self
if recent.is_empty() {
return;
}
let max_wpm = recent.iter().fold(0.0f64, |a, &b| a.max(b)).max(10.0);
let target = self.target_wpm as f64;
let bar_count = (inner.width as usize).min(recent.len());
let bar_spacing = if bar_count > 0 {
inner.width / bar_count as u16
} else {
return;
};
let bar_chars = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
// Render each bar as a column
let start_idx = recent.len().saturating_sub(bar_count);
for (i, &wpm) in recent[start_idx..].iter().enumerate() {
let x = inner.x + i as u16 * bar_spacing;
if x >= inner.x + inner.width {
break;
}
let ratio = (wpm / max_wpm).clamp(0.0, 1.0);
let bar_height = (ratio * (inner.height as f64 - 1.0)).round() as usize;
let color = if wpm >= target {
colors.success()
} else {
colors.error()
};
// Draw bar from bottom up
for row in 0..inner.height.saturating_sub(1) {
let y = inner.y + inner.height - 1 - row;
let row_idx = row as usize;
if row_idx < bar_height {
let ch = if row_idx + 1 == bar_height {
// Top of bar - use fractional char
let frac = (ratio * (inner.height as f64 - 1.0)) - bar_height as f64 + 1.0;
let idx = ((frac * 7.0).round() as usize).min(7);
bar_chars[idx]
} else {
'█'
};
buf.set_string(x, y, &ch.to_string(), Style::default().fg(color));
}
}
// WPM label on top row
if bar_spacing >= 3 {
let label = format!("{wpm:.0}");
buf.set_string(x, inner.y, &label, Style::default().fg(colors.text_pending()));
}
}
}
fn render_accuracy_chart(&self, area: Rect, buf: &mut Buffer) {
use ratatui::symbols;
use ratatui::widgets::{Axis, Chart, Dataset, GraphType};
let colors = &self.theme.colors;
let data: Vec<(f64, f64)> = self
.history
.iter()
.rev()
@@ -195,7 +340,43 @@ impl StatsDashboard<'_> {
.enumerate()
.map(|(i, r)| (i as f64, r.accuracy))
.collect();
render_accuracy_chart(&acc_data, self.theme, chart_layout[1], buf);
if data.is_empty() {
let block = Block::bordered()
.title(" Accuracy Trend ")
.border_style(Style::default().fg(colors.border()));
block.render(area, buf);
return;
}
let max_x = data.last().map(|(x, _)| *x).unwrap_or(1.0);
let dataset = Dataset::default()
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(colors.success()))
.data(&data);
let chart = Chart::new(vec![dataset])
.block(
Block::bordered()
.title(" Accuracy Trend ")
.border_style(Style::default().fg(colors.border())),
)
.x_axis(
Axis::default()
.title("Lesson")
.style(Style::default().fg(colors.text_pending()))
.bounds([0.0, max_x]),
)
.y_axis(
Axis::default()
.title("%")
.style(Style::default().fg(colors.text_pending()))
.bounds([80.0, 100.0]),
);
chart.render(area, buf);
}
fn render_progress_bars(&self, area: Rect, buf: &mut Buffer) {
@@ -217,8 +398,20 @@ impl StatsDashboard<'_> {
// WPM progress
let wpm_pct = (avg_wpm / self.target_wpm as f64 * 100.0).min(100.0);
let wpm_color = if wpm_pct >= 100.0 {
colors.success()
} else {
colors.accent()
};
let wpm_label = format!(" WPM: {avg_wpm:.0}/{} ({wpm_pct:.0}%)", self.target_wpm);
render_text_bar(&wpm_label, wpm_pct / 100.0, colors.accent(), colors.bar_empty(), layout[0], buf);
render_text_bar(
&wpm_label,
wpm_pct / 100.0,
wpm_color,
colors.bar_empty(),
layout[0],
buf,
);
// Accuracy progress
let acc_pct = avg_accuracy.min(100.0);
@@ -230,16 +423,32 @@ impl StatsDashboard<'_> {
} else {
colors.error()
};
render_text_bar(&acc_label, acc_pct / 100.0, acc_color, colors.bar_empty(), layout[1], buf);
render_text_bar(
&acc_label,
acc_pct / 100.0,
acc_color,
colors.bar_empty(),
layout[1],
buf,
);
// Level progress
let total_score: f64 = self.history.iter().map(|r| r.wpm * r.accuracy / 100.0).sum();
let level = ((total_score / 100.0).sqrt() as u32).max(1);
let next_level_score = ((level + 1) as f64).powi(2) * 100.0;
let current_level_score = (level as f64).powi(2) * 100.0;
let level_pct = ((total_score - current_level_score) / (next_level_score - current_level_score)).clamp(0.0, 1.0);
let level_pct = ((total_score - current_level_score)
/ (next_level_score - current_level_score))
.clamp(0.0, 1.0);
let level_label = format!(" Lvl {level} ({:.0}%)", level_pct * 100.0);
render_text_bar(&level_label, level_pct, colors.focused_key(), colors.bar_empty(), layout[2], buf);
render_text_bar(
&level_label,
level_pct,
colors.focused_key(),
colors.bar_empty(),
layout[2],
buf,
);
}
fn render_history_tab(&self, area: Rect, buf: &mut Buffer) {
@@ -247,26 +456,30 @@ impl StatsDashboard<'_> {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(10),
Constraint::Length(8),
])
.constraints([Constraint::Min(10), Constraint::Length(8)])
.split(area);
// Recent tests table
let header = Line::from(vec![
Span::styled(
" # WPM Raw Acc% Time Date",
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
),
]);
// Recent tests bordered table
let table_block = Block::bordered()
.title(" Recent Sessions ")
.border_style(Style::default().fg(colors.border()));
let table_inner = table_block.inner(layout[0]);
table_block.render(layout[0], buf);
let mut lines = vec![header, Line::from(Span::styled(
" ─────────────────────────────────────────────",
Style::default().fg(colors.border()),
))];
let header = Line::from(vec![Span::styled(
" # WPM Raw Acc% Time Date",
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
)]);
let mut lines = vec![
header,
Line::from(Span::styled(
" ─────────────────────────────────────────────",
Style::default().fg(colors.border()),
)),
];
let recent: Vec<&LessonResult> = self.history.iter().rev().take(20).collect();
let total = self.history.len();
@@ -281,7 +494,17 @@ impl StatsDashboard<'_> {
let wpm_str = format!("{:>6.0}", result.wpm);
let raw_str = format!("{:>6.0}", raw_wpm);
let acc_str = format!("{:>6.1}%", result.accuracy);
let row = format!(" {idx_str} {wpm_str} {raw_str} {acc_str} {time_str:>6} {date_str}");
// WPM indicator
let wpm_indicator = if result.wpm >= self.target_wpm as f64 {
"+"
} else {
" "
};
let row = format!(
" {wpm_indicator}{idx_str} {wpm_str} {raw_str} {acc_str} {time_str:>6} {date_str}"
);
let acc_color = if result.accuracy >= 95.0 {
colors.success()
@@ -291,12 +514,19 @@ impl StatsDashboard<'_> {
colors.error()
};
lines.push(Line::from(Span::styled(row, Style::default().fg(acc_color))));
let is_selected = i == self.history_selected;
let style = if is_selected {
Style::default().fg(acc_color).bg(colors.accent_dim())
} else {
Style::default().fg(acc_color)
};
lines.push(Line::from(Span::styled(row, style)));
}
Paragraph::new(lines).render(layout[0], buf);
Paragraph::new(lines).render(table_inner, buf);
// Per-key speed
// Per-key speed distribution
self.render_per_key_speed(layout[1], buf);
}
@@ -304,7 +534,7 @@ impl StatsDashboard<'_> {
let colors = &self.theme.colors;
let block = Block::bordered()
.title(" Per-Key Average Speed (ms) ")
.title(" Character Speed Distribution ")
.border_style(Style::default().fg(colors.border()));
let inner = block.inner(area);
block.render(area, buf);
@@ -321,9 +551,7 @@ impl StatsDashboard<'_> {
.fold(0.0f64, f64::max)
.max(1.0);
// Render bar chart: letter label on row 0, bar on row 1
let bar_width = (inner.width as usize).min(52) / 26;
let bar_width = bar_width.max(1) as u16;
let bar_chars = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
for (i, &ch) in letters.iter().enumerate() {
let x = inner.x + (i as u16 * 2).min(inner.width.saturating_sub(1));
@@ -350,19 +578,11 @@ impl StatsDashboard<'_> {
// Letter label
buf.set_string(x, inner.y, &ch.to_string(), Style::default().fg(color));
// Simple bar indicator
// Bar indicator
if inner.height >= 2 {
let bar_char = if time > 0.0 {
match (ratio * 8.0) as u8 {
0 => '▁',
1 => '▂',
2 => '▃',
3 => '▄',
4 => '▅',
5 => '▆',
6 => '▇',
_ => '█',
}
let idx = ((ratio * 7.0).round() as usize).min(7);
bar_chars[idx]
} else {
' '
};
@@ -373,25 +593,41 @@ impl StatsDashboard<'_> {
Style::default().fg(color),
);
}
}
let _ = bar_width;
// Time label on row 3
if inner.height >= 3 && time > 0.0 {
let time_label = format!("{time:.0}");
if x + time_label.len() as u16 <= inner.x + inner.width {
buf.set_string(
x,
inner.y + 2,
&time_label,
Style::default().fg(colors.text_pending()),
);
}
}
}
}
fn render_keystrokes_tab(&self, area: Rect, buf: &mut Buffer) {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(7),
Constraint::Min(5),
Constraint::Length(6),
Constraint::Length(12), // Activity heatmap
Constraint::Length(7), // Keyboard accuracy heatmap
Constraint::Min(5), // Slowest/Fastest/Stats
Constraint::Length(5), // Overall stats
])
.split(area);
// Keyboard accuracy heatmap
self.render_keyboard_heatmap(layout[0], buf);
// Activity heatmap
let heatmap = ActivityHeatmap::new(self.history, self.theme);
heatmap.render(layout[0], buf);
// Slowest/Fastest keys
// Keyboard accuracy heatmap with percentages
self.render_keyboard_heatmap(layout[1], buf);
// Slowest/Fastest/Worst keys
let key_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([
@@ -399,14 +635,14 @@ impl StatsDashboard<'_> {
Constraint::Percentage(34),
Constraint::Percentage(33),
])
.split(layout[1]);
.split(layout[2]);
self.render_slowest_keys(key_layout[0], buf);
self.render_fastest_keys(key_layout[1], buf);
self.render_char_stats(key_layout[2], buf);
self.render_worst_accuracy_keys(key_layout[2], buf);
// Word/Character stats summary
self.render_overall_stats(layout[2], buf);
// Overall stats
self.render_overall_stats(layout[3], buf);
}
fn render_keyboard_heatmap(&self, area: Rect, buf: &mut Buffer) {
@@ -418,7 +654,7 @@ impl StatsDashboard<'_> {
let inner = block.inner(area);
block.render(area, buf);
if inner.height < 3 || inner.width < 40 {
if inner.height < 3 || inner.width < 50 {
return;
}
@@ -428,7 +664,7 @@ impl StatsDashboard<'_> {
&['z', 'x', 'c', 'v', 'b', 'n', 'm'],
];
let offsets: &[u16] = &[1, 3, 5];
let key_width: u16 = 4;
let key_width: u16 = 5; // wider to fit accuracy %
for (row_idx, row) in rows.iter().enumerate() {
let y = inner.y + row_idx as u16;
@@ -440,23 +676,33 @@ impl StatsDashboard<'_> {
for (col_idx, &key) in row.iter().enumerate() {
let x = inner.x + offset + col_idx as u16 * key_width;
if x + 3 > inner.x + inner.width {
if x + key_width > inner.x + inner.width {
break;
}
let accuracy = self.get_key_accuracy(key);
let color = if accuracy >= 100.0 {
colors.text_pending()
let (fg_color, bg_color) = if accuracy <= 0.0 {
(colors.text_pending(), colors.bg())
} else if accuracy >= 98.0 {
(colors.success(), colors.bg())
} else if accuracy >= 90.0 {
colors.warning()
} else if accuracy > 0.0 {
colors.error()
(colors.warning(), colors.bg())
} else {
colors.text_pending()
(colors.error(), colors.bg())
};
let display = format!("[{key}]");
buf.set_string(x, y, &display, Style::default().fg(color).bg(colors.bg()));
let display = if accuracy > 0.0 {
let pct = accuracy.round() as u32;
format!("{key}{pct:>3}")
} else {
format!("{key} ")
};
buf.set_string(
x,
y,
&display,
Style::default().fg(fg_color).bg(bg_color),
);
}
}
}
@@ -548,43 +794,63 @@ impl StatsDashboard<'_> {
}
}
fn render_char_stats(&self, area: Rect, buf: &mut Buffer) {
fn render_worst_accuracy_keys(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let block = Block::bordered()
.title(" Key Stats ")
.title(" Worst Accuracy ")
.border_style(Style::default().fg(colors.border()));
let inner = block.inner(area);
block.render(area, buf);
let mut total_correct = 0usize;
let mut total_incorrect = 0usize;
// Compute accuracy for each key
let mut key_accuracies: Vec<(char, f64, usize)> = ('a'..='z')
.filter_map(|ch| {
let mut correct = 0usize;
let mut total = 0usize;
for result in self.history {
for kt in &result.per_key_times {
if kt.key == ch {
total += 1;
if kt.correct {
correct += 1;
}
}
}
}
if total >= 5 {
let acc = correct as f64 / total as f64 * 100.0;
Some((ch, acc, total))
} else {
None
}
})
.collect();
for result in self.history {
total_correct += result.correct;
total_incorrect += result.incorrect;
key_accuracies.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
if key_accuracies.is_empty() {
buf.set_string(
inner.x,
inner.y,
" Not enough data",
Style::default().fg(colors.text_pending()),
);
return;
}
let total = total_correct + total_incorrect;
let overall_acc = if total > 0 {
total_correct as f64 / total as f64 * 100.0
} else {
0.0
};
let lines = [
format!(" Total: {total}"),
format!(" Correct: {total_correct}"),
format!(" Wrong: {total_incorrect}"),
format!(" Acc: {overall_acc:.1}%"),
];
for (i, line) in lines.iter().enumerate() {
for (i, (ch, acc, _total)) in key_accuracies.iter().take(5).enumerate() {
let y = inner.y + i as u16;
if y >= inner.y + inner.height {
break;
}
buf.set_string(inner.x, y, line, Style::default().fg(colors.fg()));
let badge = format!(" '{ch}' {acc:.1}%");
let color = if *acc >= 95.0 {
colors.warning()
} else {
colors.error()
};
buf.set_string(inner.x, y, &badge, Style::default().fg(color));
}
}
@@ -602,34 +868,32 @@ impl StatsDashboard<'_> {
let total_incorrect: usize = self.history.iter().map(|r| r.incorrect).sum();
let total_time: f64 = self.history.iter().map(|r| r.elapsed_secs).sum();
let lines = vec![
Line::from(vec![
Span::styled(" Characters typed: ", Style::default().fg(colors.fg())),
Span::styled(
format!("{total_chars}"),
Style::default().fg(colors.accent()),
),
Span::styled(" Correct: ", Style::default().fg(colors.fg())),
Span::styled(
format!("{total_correct}"),
Style::default().fg(colors.success()),
),
Span::styled(" Errors: ", Style::default().fg(colors.fg())),
Span::styled(
format!("{total_incorrect}"),
Style::default().fg(if total_incorrect > 0 {
colors.error()
} else {
colors.success()
}),
),
Span::styled(" Total time: ", Style::default().fg(colors.fg())),
Span::styled(
format_duration(total_time),
Style::default().fg(colors.text_pending()),
),
]),
];
let lines = vec![Line::from(vec![
Span::styled(" Characters: ", Style::default().fg(colors.fg())),
Span::styled(
format!("{total_chars}"),
Style::default().fg(colors.accent()),
),
Span::styled(" Correct: ", Style::default().fg(colors.fg())),
Span::styled(
format!("{total_correct}"),
Style::default().fg(colors.success()),
),
Span::styled(" Errors: ", Style::default().fg(colors.fg())),
Span::styled(
format!("{total_incorrect}"),
Style::default().fg(if total_incorrect > 0 {
colors.error()
} else {
colors.success()
}),
),
Span::styled(" Time: ", Style::default().fg(colors.fg())),
Span::styled(
format_duration(total_time),
Style::default().fg(colors.text_pending()),
),
])];
Paragraph::new(lines).render(inner, buf);
}
@@ -655,7 +919,7 @@ fn render_text_bar(
Style::default().fg(fill_color),
);
// Bar on second line
// Bar on second line using ┃ filled / dim ┃ empty
let bar_width = (area.width as usize).saturating_sub(4);
let filled = (ratio * bar_width as f64) as usize;
@@ -676,55 +940,6 @@ fn render_text_bar(
}
}
fn render_accuracy_chart(
data: &[(f64, f64)],
theme: &Theme,
area: Rect,
buf: &mut Buffer,
) {
use ratatui::symbols;
use ratatui::widgets::{Axis, Chart, Dataset, GraphType};
let colors = &theme.colors;
if data.is_empty() {
let block = Block::bordered()
.title(" Accuracy Over Time ")
.border_style(Style::default().fg(colors.border()));
block.render(area, buf);
return;
}
let max_x = data.last().map(|(x, _)| *x).unwrap_or(1.0);
let dataset = Dataset::default()
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(colors.success()))
.data(data);
let chart = Chart::new(vec![dataset])
.block(
Block::bordered()
.title(" Accuracy Over Time ")
.border_style(Style::default().fg(colors.border())),
)
.x_axis(
Axis::default()
.title("Lesson")
.style(Style::default().fg(colors.text_pending()))
.bounds([0.0, max_x]),
)
.y_axis(
Axis::default()
.title("%")
.style(Style::default().fg(colors.text_pending()))
.bounds([80.0, 100.0]),
);
chart.render(area, buf);
}
fn format_duration(secs: f64) -> String {
let total = secs as u64;
let hours = total / 3600;

View File

@@ -26,7 +26,7 @@ impl Widget for StatsSidebar<'_> {
let accuracy = self.lesson.accuracy();
let progress = self.lesson.progress() * 100.0;
let correct = self.lesson.correct_count();
let incorrect = self.lesson.incorrect_count();
let incorrect = self.lesson.typo_count();
let elapsed = self.lesson.elapsed_secs();
let wpm_str = format!("{wpm:.0}");

View File

@@ -1,14 +1,52 @@
use ratatui::layout::{Constraint, Direction, Layout, Rect};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LayoutTier {
Wide, // ≥100 cols: typing area + sidebar, keyboard, progress bar
Medium, // 60-99 cols: full-width typing, compact stats header, compact keyboard
Narrow, // <60 cols: full-width typing, stats header only
}
impl LayoutTier {
pub fn from_area(area: Rect) -> Self {
if area.width >= 100 {
LayoutTier::Wide
} else if area.width >= 60 {
LayoutTier::Medium
} else {
LayoutTier::Narrow
}
}
pub fn show_keyboard(&self, height: u16) -> bool {
height >= 20 && *self != LayoutTier::Narrow
}
pub fn show_progress_bar(&self, height: u16) -> bool {
height >= 20 && *self != LayoutTier::Narrow
}
pub fn show_sidebar(&self) -> bool {
*self == LayoutTier::Wide
}
pub fn compact_keyboard(&self) -> bool {
*self == LayoutTier::Medium
}
}
pub struct AppLayout {
pub header: Rect,
pub main: Rect,
pub sidebar: Rect,
pub sidebar: Option<Rect>,
pub footer: Rect,
pub tier: LayoutTier,
}
impl AppLayout {
pub fn new(area: Rect) -> Self {
let tier = LayoutTier::from_area(area);
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
@@ -18,16 +56,27 @@ impl AppLayout {
])
.split(area);
let horizontal = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(70), Constraint::Percentage(30)])
.split(vertical[1]);
if tier.show_sidebar() {
let horizontal = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(70), Constraint::Percentage(30)])
.split(vertical[1]);
Self {
header: vertical[0],
main: horizontal[0],
sidebar: horizontal[1],
footer: vertical[2],
Self {
header: vertical[0],
main: horizontal[0],
sidebar: Some(horizontal[1]),
footer: vertical[2],
tier,
}
} else {
Self {
header: vertical[0],
main: vertical[1],
sidebar: None,
footer: vertical[2],
tier,
}
}
}
}