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

@@ -0,0 +1,357 @@
# Keydr Improvement Plan
## Context
The keydr typing tutor app needs six improvements to bring it closer to the quality of keybr.com and typr. Currently the app starts at a menu screen, doesn't properly count corrected errors, has a confusing keyboard visualization, lacks responsive layout, can't delete sessions, and has basic statistics views.
---
## 1. Start in Adaptive Drill by Default
**Files:** `src/app.rs`
**Implementation:** Change `App::new()` to use a `let mut app = Self { ... }; app.start_lesson(); app` pattern. The struct literal currently at `src/app.rs:99-120` sets `screen: AppScreen::Menu` — change this to construct `Self`, then call `start_lesson()` which sets `screen = AppScreen::Lesson` and generates text. The menu remains accessible via ESC from lesson/result screens (unchanged).
---
## 2. Fix Error Tracking for Backspaced Corrections
**Files:** `src/session/lesson.rs`, `src/session/input.rs`, `src/session/result.rs`, `src/ui/components/stats_sidebar.rs`
**Problem:** When a user types wrong, backspaces, then types correctly, keydr pops `CharStatus::Incorrect` from the input vector and replaces with `CharStatus::Correct`. Final accuracy shows 0 errors. keybr.com counts this as an error (see `packages/keybr-textinput/lib/textinput.ts``typo` flag persists through backspace corrections, and `stats.ts:42-49` counts all steps with `typo: true`).
**Implementation — keybr-style step-based tracking:**
### Two separate tracking systems:
**A. Live display counters (existing, unchanged):**
- `input: Vec<CharStatus>` continues to track current visible state (grows on type, shrinks on backspace)
- `incorrect_count()` and `correct_count()` show current snapshot for the sidebar display
- `accuracy()` on `LessonState` continues using `input.len()` as denominator — only reflects currently-visible chars
**B. Persistent typo tracking (new, for final results):**
- Add `typo_flags: HashSet<usize>` to `LessonState` — tracks positions where ANY incorrect key was ever pressed
**Process flow:**
1. `process_char()` — when `!correct`: insert `lesson.cursor` into `typo_flags`. Push to `input` as before.
2. `process_backspace()` — pop from `input`, decrement cursor. Do NOT remove from `typo_flags`.
3. When the lesson completes (all positions filled with correct/incorrect chars), `LessonResult::from_lesson()` builds the final result using `typo_flags` to determine error count:
- `incorrect = typo_flags.len()` (positions where any error ever occurred)
- `accuracy = (total_chars - typo_flags.len()) / total_chars * 100`
- This avoids the denominator mismatch since we always use `target.len()` as the denominator
**Sidebar display during lesson:**
- Show "Errors: X" using `typo_flags.len()` (accumulated errors, never decreases)
- Live accuracy: count `typo_flags` entries that are `< cursor` (i.e., only count typos at positions already typed past), then: `((cursor - typos_before_cursor).max(0) as f64 / cursor as f64 * 100.0).clamp(0.0, 100.0)` where cursor > 0. This handles the backspace case correctly — if cursor retreats behind a typo'd position, that typo doesn't count in the live denominator.
### Unit tests:
- Type "abc" correctly → typo_flags empty, accuracy 100%
- Type wrong char at pos 0, backspace, type correct → typo_flags = {0}, accuracy < 100%
- Type wrong char, continue without backspace → typo_flags = {pos}, also in input as Incorrect
- Multiple errors at same position (wrong, backspace, wrong again, backspace, correct) → typo_flags = {pos}, counts as 1 error
---
## 3. Fix Keyboard Visualization
**Files:** `src/ui/components/keyboard_diagram.rs`, `src/main.rs`, `src/app.rs`, `src/event.rs`
**Problem:** All key colors shift constantly with no meaning. User expects pressed keys to light up.
**How keybr.com does it:**
- Uses physical key codes (W3C `event.code` like `KeyA`, `KeyQ`) for tracking depressed keys
- `Controller.tsx:99-107`: `onKeyDown` adds to `depressedKeys`, `onKeyUp` removes
- `KeyboardPresenter.tsx:36-39`: passes `depressedKeys` array and `suffixKeys` (next expected) to keyboard UI
- `KeyLayer.tsx`: pre-computes 8 states per key (depressed × toggled × showColors), selects based on current state
**Implementation — crossterm supports key Press/Release events:**
**Scope decision:** We track depressed state for **printable character keys only** (`KeyCode::Char(ch)`). This is intentional non-parity with keybr.com's physical-key-ID model — keybr runs in a browser with W3C key codes, but keydr's keyboard diagram only shows letter keys. Modifier keys (Shift, Ctrl, Alt) are not shown on the diagram and don't need depressed tracking. Characters are lowercased for matching against the diagram.
crossterm 0.28 provides `KeyEventKind::Press`, `KeyEventKind::Release`, and `KeyEventKind::Repeat` via `KeyEvent.kind`. However, terminal key-release support is inconsistent across terminals. We use a **hybrid approach**: track via Release events when available, with a 150ms timed fallback.
1. **Enable enhanced key events** (`src/main.rs`):
- Call `crossterm::event::PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::REPORT_EVENT_TYPES)` on startup (enables Release events on supported terminals)
- Pop the flags on cleanup
2. **Track depressed keys** (`src/app.rs`):
- Add `depressed_keys: HashSet<char>` field (stores lowercase chars)
- Add `last_key_time: Option<Instant>` for fallback clearing
- On `KeyEventKind::Press` with `KeyCode::Char(ch)`: insert `ch.to_ascii_lowercase()` into `depressed_keys`, set `last_key_time`
- On `KeyEventKind::Release` with `KeyCode::Char(ch)`: remove `ch.to_ascii_lowercase()` from `depressed_keys`
- On tick: if `last_key_time` > 150ms ago and no Release was received, clear `depressed_keys` (fallback for terminals without Release support)
3. **Update event handling** (`src/main.rs` `handle_key`):
- Check `key.kind` — only process typing logic on `KeyEventKind::Press`
- On `KeyEventKind::Release`: call `app.depressed_keys.remove(&ch.to_ascii_lowercase())`
- Filter out `KeyEventKind::Repeat` to avoid double-counting (or treat same as Press for depressed tracking)
4. **Update KeyboardDiagram** (`src/ui/components/keyboard_diagram.rs`):
- Accept `depressed_keys: &HashSet<char>` (all lowercase)
- Rendering priority order: **depressed** (bright/inverted style) > **next_expected** (accent bg) > **focused** (yellow bg) > **unlocked** (finger zone color) > **locked** (dim)
- Depressed style: bold white text on brighter version of the finger color
5. **Investigate "constantly shifting colors" bug:**
- Current code at `main.rs:356-359` passes `lesson.target.get(lesson.cursor)` as `next_char` — this correctly changes on each keystroke
- Verify the finger_color mapping is stable (it uses static match arms — should be fine)
- Most likely the "shifting" perception is the `next_key` highlight moving to adjacent keys as user types — this is correct behavior. The depressed-key highlight will make the interaction much clearer.
### Unit tests:
- Verify `depressed_keys` set grows on Press and shrinks on Release
- Verify fallback clearing works after 150ms timeout
---
## 4. Responsive UI for Small Terminals
**Files:** `src/ui/layout.rs`, `src/main.rs`, `src/ui/components/keyboard_diagram.rs`, `src/ui/components/stats_sidebar.rs`, `src/ui/components/typing_area.rs`
**How typr handles it (from `clones/typr/lua/typr/stats/init.lua:15-17`):**
- Base width: `state.w = 80` columns
- Responsive threshold: `vim.o.columns > ((2 * state.w) + 10)` = `> 170` cols → horizontal stats layout
- Below 170 cols → vertical tabbed stats layout
- Drill view: fixed 80-col centered window, doesn't have a sidebar concept
- Window height adapts: `large_screen and state.h or vim.o.lines - 7`
**Implementation — tiered layout for keydr:**
### Drill View Layout Tiers (based on `area` from `AppLayout::new()`):
**Wide (≥100 cols):** Current layout — typing area (70%) + sidebar (30%) side-by-side, keyboard + progress bar below typing area
**Medium (60-99 cols):**
- Typing area takes full width (no sidebar)
- Compact stats in header bar: `WPM: XX | Acc: XX% | Errors: X`
- Keyboard diagram below typing area (compressed 3-char keys `[x]` instead of `[ x ]`)
- Progress bar below keyboard
**Narrow (<60 cols):**
- Typing area full width
- Stats in header bar only
- No keyboard diagram
- No progress bar
**Short (<20 rows):**
- No keyboard diagram (regardless of width)
- No progress bar
- Typing area + single-line header + single-line footer
### Stats View Layout Tiers:
**Wide (>170 cols):** Side-by-side panels (matching typr threshold: `(2 * 80) + 10`)
**Normal (≤170 cols):** Tabbed view (current behavior, improved styling per item 6)
### Implementation:
1. Modify `AppLayout::new()` to accept area and return different constraint sets based on dimensions
2. Add `LayoutTier` enum: `{ Wide, Medium, Narrow }` computed from `area.width` and `area.height`
3. `render_lesson()` checks tier to decide which components to render
4. `KeyboardDiagram` gets a `compact: bool` flag for 3-char key mode
5. Verify `TypingArea` wraps properly at narrow widths (current implementation should handle this via Ratatui's `Paragraph` wrapping)
---
## 5. Delete Sessions from History
**Files:** `src/app.rs`, `src/main.rs`, `src/ui/components/stats_dashboard.rs`
**Implementation — complete recalculation scope:**
### State machine for history tab interaction:
```
Normal browsing → [j/k/Up/Down] → Move selection cursor
Normal browsing → [x/Delete] → Show confirmation dialog
Confirmation dialog → [y] → Delete session, recalculate, return to Normal
Confirmation dialog → [n/ESC] → Cancel, return to Normal
Normal browsing → [Tab/d/h/k/1/2/3] → Switch tabs (existing behavior)
```
### App state additions (`src/app.rs`):
- `history_selected: usize` — selected row index in history view (0 = most recent)
- `history_confirm_delete: bool` — whether confirmation dialog is showing
### Key bindings — full precedence table for `handle_stats_key`:
**When `history_confirm_delete == true` (confirmation dialog active):**
- `y` → call `delete_session()`, set `history_confirm_delete = false`
- `n` / `ESC` → set `history_confirm_delete = false` (cancel)
- All other keys ignored
**When `stats_tab == 1` (history tab, no dialog):**
- `j` / `Down` → increment `history_selected` (clamp to history length)
- `k` / `Up` → decrement `history_selected` (clamp to 0)
- `x` / `Delete` → set `history_confirm_delete = true`
- `d` / `1` → switch to Dashboard tab (`stats_tab = 0`)
- `h` / `2` → switch to History tab (no-op, already there)
- `3` → switch to Keystrokes tab (`stats_tab = 2`). Note: `k` is NOT a Keystrokes tab shortcut when on history tab — it navigates rows instead.
- `Tab` / `BackTab` → cycle tabs
- `ESC` / `q` → back to menu
**When on other tabs (stats_tab == 0 or 2):**
- Existing behavior unchanged: `d`/`1`, `h`/`2`, `k`/`3` switch tabs, `Tab`/`BackTab` cycle, `ESC`/`q` back to menu
### Delete logic (`src/app.rs` `delete_session()`):
Full recalculation via **chronological replay** to make it "as if the session never happened":
1. **Remove the lesson** from `self.lesson_history` at the correct index (history tab shows reverse order, so actual index = `len - 1 - history_selected`)
2. **Chronological state replay** — reset and rebuild from scratch, oldest→newest:
```
// 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 {
// Update key stats (same as finish_lesson does)
for kt in &result.per_key_times {
if kt.correct {
self.key_stats.update_key(kt.key, kt.time_ms);
}
}
// Update letter unlock
self.letter_unlock.update(&self.key_stats);
// Compute score using current unlock state (matches runtime)
let complexity = compute_complexity(self.letter_unlock.unlocked_count());
let score = compute_score(result, complexity);
self.profile.total_score += score;
self.profile.total_lessons += 1;
// Rebuild streak tracking (same logic as finish_lesson)
let day = result.timestamp.format("%Y-%m-%d").to_string();
// ... streak logic identical to App::finish_lesson
}
self.profile.unlocked_letters = self.letter_unlock.included.clone();
```
This exactly reproduces the runtime scoring path (`src/app.rs:186-218`, `src/engine/scoring.rs:3-7`), including complexity that depends on unlock state at each point in progression.
3. **Persist:** Call `self.save_data()` to write all three files (profile, key_stats, lesson_history)
4. **Adjust selection:** Clamp `history_selected` to new valid range
**Implementation note:** Extract the replay logic into a reusable `rebuild_from_history(&mut self)` method on `App`, since it could also be useful for data recovery.
### Rendering (`stats_dashboard.rs`):
- Selected row gets `bg(colors.accent_dim())` highlight background (existing theme color `accent_dim` = `#45475a`, a subtle dark surface color)
- Confirmation dialog: centered overlay box with border: `"Delete session #X? (y/n)"`
### Unit tests:
- Delete last session → history shrinks by 1, total_lessons decremented
- Delete session → key_stats rebuilt without that session's key times
- Delete all sessions → profile reset to defaults, key_stats empty
- Delete session with only practice day → streak recalculated correctly
---
## 6. Improved Statistics Display (Full Typr-Style Overhaul)
**Files:** `src/ui/components/stats_dashboard.rs`, new `src/ui/components/activity_heatmap.rs`
**Data sources (all derivable from existing persisted data):**
- `lesson_history: Vec<LessonResult>` — has `wpm`, `cpm`, `accuracy`, `correct`, `incorrect`, `total_chars`, `elapsed_secs`, `timestamp`, `per_key_times`
- `key_stats: KeyStatsStore` — has per-key `filtered_time_ms`, `best_time_ms`, `confidence`, `sample_count`, `recent_times`
- No schema migration needed — all new visualizations derive from existing fields
### Dashboard Tab Improvements:
**Summary stats as bordered table:**
```
┌─────────────────────────────────────────────┐
│ Lessons: 42 Avg WPM: 65 Best WPM: 82 │
│ Accuracy: 94.2% Total time: 2h 15m │
└─────────────────────────────────────────────┘
```
**Progress bars** using `` filled / dim `` empty:
- WPM progress: `avg_wpm / target_wpm` (green ≥ goal, accent < goal)
- Accuracy progress: (green ≥ 95%, yellow ≥ 85%, red < 85%)
- Level progress to next level
**WPM bar graph** (last 20 sessions) using `▁▂▃▄▅▆▇█` block characters, replacing the Braille line chart. Color-coded: green above goal, red below.
**Keep accuracy trend chart** (Braille line chart works well for this).
### History Tab Improvements:
**Bordered table:**
```
┌────┬──────┬──────┬───────┬───────┬────────────┐
│ # │ WPM │ Raw │ Acc% │ Time │ Date │
├────┼──────┼──────┼───────┼───────┼────────────┤
│ 42 │ 68 │ 72 │ 96.2% │ 45.2s │ 02/14 10:30│
│ 41 │ 63 │ 67 │ 93.1% │ 52.1s │ 02/14 09:15│
└────┴──────┴──────┴───────┴───────┴────────────┘
```
- Selected row highlighted with distinct background
- WPM goal indicator per row: small inline bar or color indicator
**Character speed distribution** (below table): dot/bar graph of all 26 letters (from typr's history view), using per-key `filtered_time_ms` data already available in `key_stats`.
### Keystrokes Tab Improvements:
**Activity heatmap** (new widget in `src/ui/components/activity_heatmap.rs`):
- 7-month calendar grid grouped by week
- Each day cell: `` or `` colored by session count (0 = dim, 1-5 = light green, 6-15 = medium, 16+ = bright)
- Data source: group `lesson_history` by `timestamp.date()`, count per day
- Month labels along top, day-of-week labels on left (M/W/F or all 7)
- Toggle between first/last 6 months (optional, if space allows)
**Key accuracy heatmap:** show accuracy percentage text on each key, not just color. E.g., `[a 97%]` or use color intensity.
**Top 3 worst keys:** highlighted badges showing the keys with lowest accuracy, matching typr's approach.
**Char times analysis:** Slowest 5 / Fastest 5 keys with times (already exists, clean up formatting with box borders).
### Shared visual improvements:
- Unicode box-drawing borders (`┌─┬─┐`, ``, `└─┴─┘`) via Ratatui's `Block::bordered()` with custom border set
- Bar graphs using `▁▂▃▄▅▆▇█` block characters
- Consistent 2-char padding inside bordered sections
- Color gradients for intensity (heatmap, speed distribution)
---
## File Summary
| File | Changes |
|------|---------|
| `src/app.rs` | Start in lesson mode, add `depressed_keys: HashSet<char>`, `last_key_time`, `history_selected`, `history_confirm_delete`, `delete_session()` with chronological replay via `rebuild_from_history()` |
| `src/main.rs` | Enable keyboard enhancement flags, handle Press/Release events, update `render_lesson` for responsive tiers, update `handle_stats_key` for history selection/deletion state machine |
| `src/event.rs` | Filter key events by kind (pass all events, let main.rs handle kind) |
| `src/session/input.rs` | Add `typo_flags` tracking — insert on incorrect, preserve through backspace |
| `src/session/lesson.rs` | Add `typo_flags: HashSet<usize>`, `typo_count()` method. Keep `accuracy()`/`incorrect_count()` for live display. |
| `src/session/result.rs` | Use `typo_flags.len()` for final `incorrect` count and accuracy |
| `src/ui/layout.rs` | Add `LayoutTier` enum, compute from area dimensions, return different constraint sets |
| `src/ui/components/keyboard_diagram.rs` | Accept `depressed_keys: &HashSet<char>`, render depressed state, add compact mode |
| `src/ui/components/stats_dashboard.rs` | Full overhaul: bordered tables, bar graphs, progress bars, row selection, delete confirmation overlay, character speed distribution |
| `src/ui/components/activity_heatmap.rs` | New: 7-month activity calendar heatmap widget |
| `src/ui/components/stats_sidebar.rs` | Compact single-line mode for medium terminals |
| `src/ui/components/typing_area.rs` | Verify wrapping at narrow widths |
---
## Verification
### Manual Testing:
1. **Start in drill:** Launch app → immediately in Adaptive typing lesson, no menu
2. **Error tracking:** Type wrong char, backspace, type correct char → accuracy < 100%, error count ≥ 1. Type wrong at same pos twice, backspace twice, type correct → still only 1 error for that position.
3. **Keyboard:** Type characters → pressed key visually highlights. Next expected key highlighted. Releasing key clears highlight (or after 150ms fallback).
4. **Responsive:** Resize terminal to 50×15, 80×25, 120×40, 200×50 → layout adapts, no panics, no overlapping text
5. **Delete sessions:** Stats → History → select row → press `x` → confirm dialog → press `y` → session gone, all stats recalculated. Verify key_stats and letter_unlock are consistent.
6. **Statistics:** Visual inspection of bordered tables, bar graphs, activity heatmap, progress bars
### Automated Tests:
- `session/lesson.rs`: typo_flags behavior (wrong→backspace→correct counts as error, multiple errors at same pos = 1 typo)
- `session/input.rs`: process_char sets typo_flags, process_backspace preserves them
- `app.rs`: delete_session recalculates total_lessons, total_score, key_stats, letter_unlock, streak fields
- `engine/key_stats.rs`: verify rebuild from scratch produces same results as incremental updates (within EMA tolerance)

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