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

@@ -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(),
}
}
}