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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user