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,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);
}
}