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:
357
docs/plans/2026-02-14-improvement-2.md
Normal file
357
docs/plans/2026-02-14-improvement-2.md
Normal 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)
|
||||||
104
src/app.rs
104
src/app.rs
@@ -1,3 +1,6 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
use rand::rngs::SmallRng;
|
use rand::rngs::SmallRng;
|
||||||
use rand::SeedableRng;
|
use rand::SeedableRng;
|
||||||
|
|
||||||
@@ -37,6 +40,16 @@ pub enum LessonMode {
|
|||||||
Passage,
|
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 struct App {
|
||||||
pub screen: AppScreen,
|
pub screen: AppScreen,
|
||||||
pub lesson_mode: LessonMode,
|
pub lesson_mode: LessonMode,
|
||||||
@@ -54,6 +67,10 @@ pub struct App {
|
|||||||
pub should_quit: bool,
|
pub should_quit: bool,
|
||||||
pub settings_selected: usize,
|
pub settings_selected: usize,
|
||||||
pub stats_tab: 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,
|
rng: SmallRng,
|
||||||
transition_table: TransitionTable,
|
transition_table: TransitionTable,
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
@@ -96,7 +113,7 @@ impl App {
|
|||||||
let dictionary = Dictionary::load();
|
let dictionary = Dictionary::load();
|
||||||
let transition_table = TransitionTable::build_from_words(&dictionary.words_list());
|
let transition_table = TransitionTable::build_from_words(&dictionary.words_list());
|
||||||
|
|
||||||
Self {
|
let mut app = Self {
|
||||||
screen: AppScreen::Menu,
|
screen: AppScreen::Menu,
|
||||||
lesson_mode: LessonMode::Adaptive,
|
lesson_mode: LessonMode::Adaptive,
|
||||||
lesson: None,
|
lesson: None,
|
||||||
@@ -113,10 +130,16 @@ impl App {
|
|||||||
should_quit: false,
|
should_quit: false,
|
||||||
settings_selected: 0,
|
settings_selected: 0,
|
||||||
stats_tab: 0,
|
stats_tab: 0,
|
||||||
|
depressed_keys: HashSet::new(),
|
||||||
|
last_key_time: None,
|
||||||
|
history_selected: 0,
|
||||||
|
history_confirm_delete: false,
|
||||||
rng: SmallRng::from_entropy(),
|
rng: SmallRng::from_entropy(),
|
||||||
transition_table,
|
transition_table,
|
||||||
dictionary,
|
dictionary,
|
||||||
}
|
};
|
||||||
|
app.start_lesson();
|
||||||
|
app
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn start_lesson(&mut self) {
|
pub fn start_lesson(&mut self) {
|
||||||
@@ -181,7 +204,7 @@ impl App {
|
|||||||
|
|
||||||
fn finish_lesson(&mut self) {
|
fn finish_lesson(&mut self) {
|
||||||
if let Some(ref lesson) = self.lesson {
|
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 {
|
if self.lesson_mode == LessonMode::Adaptive {
|
||||||
for kt in &result.per_key_times {
|
for kt in &result.per_key_times {
|
||||||
@@ -255,9 +278,84 @@ impl App {
|
|||||||
|
|
||||||
pub fn go_to_stats(&mut self) {
|
pub fn go_to_stats(&mut self) {
|
||||||
self.stats_tab = 0;
|
self.stats_tab = 0;
|
||||||
|
self.history_selected = 0;
|
||||||
|
self.history_confirm_delete = false;
|
||||||
self.screen = AppScreen::StatsDashboard;
|
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) {
|
pub fn go_to_settings(&mut self) {
|
||||||
self.settings_selected = 0;
|
self.settings_selected = 0;
|
||||||
self.screen = AppScreen::Settings;
|
self.screen = AppScreen::Settings;
|
||||||
|
|||||||
160
src/main.rs
160
src/main.rs
@@ -9,11 +9,14 @@ mod store;
|
|||||||
mod ui;
|
mod ui;
|
||||||
|
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::time::Duration;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
use crossterm::event::{
|
||||||
|
KeyCode, KeyEvent, KeyEventKind, KeyModifiers, KeyboardEnhancementFlags,
|
||||||
|
PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
|
||||||
|
};
|
||||||
use crossterm::execute;
|
use crossterm::execute;
|
||||||
use crossterm::terminal::{
|
use crossterm::terminal::{
|
||||||
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
|
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
|
||||||
@@ -68,6 +71,14 @@ fn main() -> Result<()> {
|
|||||||
enable_raw_mode()?;
|
enable_raw_mode()?;
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
execute!(stdout, EnterAlternateScreen)?;
|
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 backend = CrosstermBackend::new(stdout);
|
||||||
let mut terminal = Terminal::new(backend)?;
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
@@ -75,6 +86,9 @@ fn main() -> Result<()> {
|
|||||||
|
|
||||||
let result = run_app(&mut terminal, &mut app, &events);
|
let result = run_app(&mut terminal, &mut app, &events);
|
||||||
|
|
||||||
|
if keyboard_enhanced {
|
||||||
|
let _ = execute!(io::stdout(), PopKeyboardEnhancementFlags);
|
||||||
|
}
|
||||||
disable_raw_mode()?;
|
disable_raw_mode()?;
|
||||||
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
||||||
terminal.show_cursor()?;
|
terminal.show_cursor()?;
|
||||||
@@ -96,7 +110,16 @@ fn run_app(
|
|||||||
|
|
||||||
match events.next()? {
|
match events.next()? {
|
||||||
AppEvent::Key(key) => handle_key(app, key),
|
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(_, _) => {}
|
AppEvent::Resize(_, _) => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +130,25 @@ fn run_app(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn handle_key(app: &mut App, key: KeyEvent) {
|
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') {
|
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
|
||||||
app.should_quit = true;
|
app.should_quit = true;
|
||||||
return;
|
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);
|
let has_progress = app.lesson.as_ref().is_some_and(|l| l.cursor > 0);
|
||||||
if has_progress {
|
if has_progress {
|
||||||
if let Some(ref lesson) = app.lesson {
|
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.last_result = Some(result);
|
||||||
}
|
}
|
||||||
app.screen = AppScreen::LessonResult;
|
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) {
|
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 {
|
match key.code {
|
||||||
KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(),
|
KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(),
|
||||||
KeyCode::Char('d') | KeyCode::Char('1') => app.stats_tab = 0,
|
KeyCode::Char('d') | KeyCode::Char('1') => app.stats_tab = 0,
|
||||||
@@ -304,12 +392,32 @@ fn render_lesson(frame: &mut ratatui::Frame, app: &App) {
|
|||||||
|
|
||||||
if let Some(ref lesson) = app.lesson {
|
if let Some(ref lesson) = app.lesson {
|
||||||
let app_layout = AppLayout::new(area);
|
let app_layout = AppLayout::new(area);
|
||||||
|
let tier = app_layout.tier;
|
||||||
|
|
||||||
let mode_name = match app.lesson_mode {
|
let mode_name = match app.lesson_mode {
|
||||||
LessonMode::Adaptive => "Adaptive",
|
LessonMode::Adaptive => "Adaptive",
|
||||||
LessonMode::Code => "Code",
|
LessonMode::Code => "Code",
|
||||||
LessonMode::Passage => "Passage",
|
LessonMode::Passage => "Passage",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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),
|
||||||
|
)))
|
||||||
|
.style(Style::default().bg(colors.header_bg()));
|
||||||
|
frame.render_widget(header, app_layout.header);
|
||||||
|
} else {
|
||||||
let header_title = format!(" {mode_name} Practice ");
|
let header_title = format!(" {mode_name} Practice ");
|
||||||
let focus_text = if let Some(focused) = app.letter_unlock.focused {
|
let focus_text = if let Some(focused) = app.letter_unlock.focused {
|
||||||
format!(" | Focus: '{focused}'")
|
format!(" | Focus: '{focused}'")
|
||||||
@@ -333,40 +441,56 @@ fn render_lesson(frame: &mut ratatui::Frame, app: &App) {
|
|||||||
]))
|
]))
|
||||||
.style(Style::default().bg(colors.header_bg()));
|
.style(Style::default().bg(colors.header_bg()));
|
||||||
frame.render_widget(header, app_layout.header);
|
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()
|
let main_layout = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([
|
.constraints(constraints)
|
||||||
Constraint::Min(5),
|
|
||||||
Constraint::Length(3),
|
|
||||||
Constraint::Length(5),
|
|
||||||
])
|
|
||||||
.split(app_layout.main);
|
.split(app_layout.main);
|
||||||
|
|
||||||
let typing = TypingArea::new(lesson, app.theme);
|
let typing = TypingArea::new(lesson, app.theme);
|
||||||
frame.render_widget(typing, main_layout[0]);
|
frame.render_widget(typing, main_layout[0]);
|
||||||
|
|
||||||
|
let mut idx = 1;
|
||||||
|
if show_progress {
|
||||||
let progress = ProgressBar::new(
|
let progress = ProgressBar::new(
|
||||||
"Letter Progress",
|
"Letter Progress",
|
||||||
app.letter_unlock.progress(),
|
app.letter_unlock.progress(),
|
||||||
app.theme,
|
app.theme,
|
||||||
);
|
);
|
||||||
frame.render_widget(progress, main_layout[1]);
|
frame.render_widget(progress, main_layout[idx]);
|
||||||
|
idx += 1;
|
||||||
|
}
|
||||||
|
|
||||||
let next_char = lesson
|
if show_kbd {
|
||||||
.target
|
let next_char = lesson.target.get(lesson.cursor).copied();
|
||||||
.get(lesson.cursor)
|
|
||||||
.copied();
|
|
||||||
let kbd = KeyboardDiagram::new(
|
let kbd = KeyboardDiagram::new(
|
||||||
app.letter_unlock.focused,
|
app.letter_unlock.focused,
|
||||||
next_char,
|
next_char,
|
||||||
&app.letter_unlock.included,
|
&app.letter_unlock.included,
|
||||||
|
&app.depressed_keys,
|
||||||
app.theme,
|
app.theme,
|
||||||
);
|
)
|
||||||
frame.render_widget(kbd, main_layout[2]);
|
.compact(tier.compact_keyboard());
|
||||||
|
frame.render_widget(kbd, main_layout[idx]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(sidebar_area) = app_layout.sidebar {
|
||||||
let sidebar = StatsSidebar::new(lesson, app.theme);
|
let sidebar = StatsSidebar::new(lesson, app.theme);
|
||||||
frame.render_widget(sidebar, app_layout.sidebar);
|
frame.render_widget(sidebar, sidebar_area);
|
||||||
|
}
|
||||||
|
|
||||||
let footer = Paragraph::new(Line::from(Span::styled(
|
let footer = Paragraph::new(Line::from(Span::styled(
|
||||||
" [ESC] End lesson [Backspace] Delete ",
|
" [ESC] End lesson [Backspace] Delete ",
|
||||||
@@ -394,6 +518,8 @@ fn render_stats(frame: &mut ratatui::Frame, app: &App) {
|
|||||||
app.stats_tab,
|
app.stats_tab,
|
||||||
app.config.target_wpm,
|
app.config.target_wpm,
|
||||||
app.theme,
|
app.theme,
|
||||||
|
app.history_selected,
|
||||||
|
app.history_confirm_delete,
|
||||||
);
|
);
|
||||||
frame.render_widget(dashboard, area);
|
frame.render_widget(dashboard, area);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ pub fn process_char(lesson: &mut LessonState, ch: char) -> Option<KeystrokeEvent
|
|||||||
lesson.input.push(CharStatus::Correct);
|
lesson.input.push(CharStatus::Correct);
|
||||||
} else {
|
} else {
|
||||||
lesson.input.push(CharStatus::Incorrect(ch));
|
lesson.input.push(CharStatus::Incorrect(ch));
|
||||||
|
lesson.typo_flags.insert(lesson.cursor);
|
||||||
}
|
}
|
||||||
lesson.cursor += 1;
|
lesson.cursor += 1;
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use crate::session::input::CharStatus;
|
use crate::session::input::CharStatus;
|
||||||
@@ -8,6 +9,7 @@ pub struct LessonState {
|
|||||||
pub cursor: usize,
|
pub cursor: usize,
|
||||||
pub started_at: Option<Instant>,
|
pub started_at: Option<Instant>,
|
||||||
pub finished_at: Option<Instant>,
|
pub finished_at: Option<Instant>,
|
||||||
|
pub typo_flags: HashSet<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LessonState {
|
impl LessonState {
|
||||||
@@ -18,6 +20,7 @@ impl LessonState {
|
|||||||
cursor: 0,
|
cursor: 0,
|
||||||
started_at: None,
|
started_at: None,
|
||||||
finished_at: None,
|
finished_at: None,
|
||||||
|
typo_flags: HashSet::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,13 +43,6 @@ impl LessonState {
|
|||||||
.count()
|
.count()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn incorrect_count(&self) -> usize {
|
|
||||||
self.input
|
|
||||||
.iter()
|
|
||||||
.filter(|s| matches!(s, CharStatus::Incorrect(_)))
|
|
||||||
.count()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn wpm(&self) -> f64 {
|
pub fn wpm(&self) -> f64 {
|
||||||
let elapsed = self.elapsed_secs();
|
let elapsed = self.elapsed_secs();
|
||||||
if elapsed < 0.1 {
|
if elapsed < 0.1 {
|
||||||
@@ -56,12 +52,20 @@ impl LessonState {
|
|||||||
(chars / 5.0) / (elapsed / 60.0)
|
(chars / 5.0) / (elapsed / 60.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn typo_count(&self) -> usize {
|
||||||
|
self.typo_flags.len()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn accuracy(&self) -> f64 {
|
pub fn accuracy(&self) -> f64 {
|
||||||
let total = self.input.len();
|
if self.cursor == 0 {
|
||||||
if total == 0 {
|
|
||||||
return 100.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 {
|
pub fn cpm(&self) -> f64 {
|
||||||
@@ -83,6 +87,7 @@ impl LessonState {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::session::input;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_new_lesson() {
|
fn test_new_lesson() {
|
||||||
@@ -105,4 +110,52 @@ mod tests {
|
|||||||
assert!(lesson.is_complete());
|
assert!(lesson.is_complete());
|
||||||
assert_eq!(lesson.progress(), 0.0);
|
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 elapsed_secs: f64,
|
||||||
pub timestamp: DateTime<Utc>,
|
pub timestamp: DateTime<Utc>,
|
||||||
pub per_key_times: Vec<KeyTime>,
|
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)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
@@ -25,7 +31,7 @@ pub struct KeyTime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl LessonResult {
|
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
|
let per_key_times: Vec<KeyTime> = events
|
||||||
.windows(2)
|
.windows(2)
|
||||||
.map(|pair| {
|
.map(|pair| {
|
||||||
@@ -38,16 +44,25 @@ impl LessonResult {
|
|||||||
})
|
})
|
||||||
.collect();
|
.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 {
|
Self {
|
||||||
wpm: lesson.wpm(),
|
wpm: lesson.wpm(),
|
||||||
cpm: lesson.cpm(),
|
cpm: lesson.cpm(),
|
||||||
accuracy: lesson.accuracy(),
|
accuracy,
|
||||||
correct: lesson.correct_count(),
|
correct: total_chars - typo_count,
|
||||||
incorrect: lesson.incorrect_count(),
|
incorrect: typo_count,
|
||||||
total_chars: lesson.target.len(),
|
total_chars,
|
||||||
elapsed_secs: lesson.elapsed_secs(),
|
elapsed_secs: lesson.elapsed_secs(),
|
||||||
timestamp: Utc::now(),
|
timestamp: Utc::now(),
|
||||||
per_key_times,
|
per_key_times,
|
||||||
|
lesson_mode: lesson_mode.to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
152
src/ui/components/activity_heatmap.rs
Normal file
152
src/ui/components/activity_heatmap.rs
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,11 +6,13 @@ use ratatui::widgets::{Axis, Block, Chart, Dataset, GraphType, Widget};
|
|||||||
|
|
||||||
use crate::ui::theme::Theme;
|
use crate::ui::theme::Theme;
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub struct WpmChart<'a> {
|
pub struct WpmChart<'a> {
|
||||||
pub data: &'a [(f64, f64)],
|
pub data: &'a [(f64, f64)],
|
||||||
pub theme: &'a Theme,
|
pub theme: &'a Theme,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
impl<'a> WpmChart<'a> {
|
impl<'a> WpmChart<'a> {
|
||||||
pub fn new(data: &'a [(f64, f64)], theme: &'a Theme) -> Self {
|
pub fn new(data: &'a [(f64, f64)], theme: &'a Theme) -> Self {
|
||||||
Self { data, theme }
|
Self { data, theme }
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use ratatui::buffer::Buffer;
|
use ratatui::buffer::Buffer;
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
use ratatui::style::{Color, Style};
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
use ratatui::widgets::{Block, Widget};
|
use ratatui::widgets::{Block, Widget};
|
||||||
|
|
||||||
use crate::keyboard::finger::{self, Finger, Hand};
|
use crate::keyboard::finger::{self, Finger, Hand};
|
||||||
@@ -10,7 +12,9 @@ pub struct KeyboardDiagram<'a> {
|
|||||||
pub focused_key: Option<char>,
|
pub focused_key: Option<char>,
|
||||||
pub next_key: Option<char>,
|
pub next_key: Option<char>,
|
||||||
pub unlocked_keys: &'a [char],
|
pub unlocked_keys: &'a [char],
|
||||||
|
pub depressed_keys: &'a HashSet<char>,
|
||||||
pub theme: &'a Theme,
|
pub theme: &'a Theme,
|
||||||
|
pub compact: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> KeyboardDiagram<'a> {
|
impl<'a> KeyboardDiagram<'a> {
|
||||||
@@ -18,15 +22,23 @@ impl<'a> KeyboardDiagram<'a> {
|
|||||||
focused_key: Option<char>,
|
focused_key: Option<char>,
|
||||||
next_key: Option<char>,
|
next_key: Option<char>,
|
||||||
unlocked_keys: &'a [char],
|
unlocked_keys: &'a [char],
|
||||||
|
depressed_keys: &'a HashSet<char>,
|
||||||
theme: &'a Theme,
|
theme: &'a Theme,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
focused_key,
|
focused_key,
|
||||||
next_key,
|
next_key,
|
||||||
unlocked_keys,
|
unlocked_keys,
|
||||||
|
depressed_keys,
|
||||||
theme,
|
theme,
|
||||||
|
compact: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn compact(mut self, compact: bool) -> Self {
|
||||||
|
self.compact = compact;
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROWS: &[&[char]] = &[
|
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<'_> {
|
impl Widget for KeyboardDiagram<'_> {
|
||||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
let colors = &self.theme.colors;
|
let colors = &self.theme.colors;
|
||||||
@@ -61,12 +84,18 @@ impl Widget for KeyboardDiagram<'_> {
|
|||||||
let inner = block.inner(area);
|
let inner = block.inner(area);
|
||||||
block.render(area, buf);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let key_width: u16 = 5;
|
let offsets: &[u16] = if self.compact {
|
||||||
let offsets: &[u16] = &[1, 3, 5];
|
&[0, 1, 3]
|
||||||
|
} else {
|
||||||
|
&[1, 3, 5]
|
||||||
|
};
|
||||||
|
|
||||||
for (row_idx, row) in ROWS.iter().enumerate() {
|
for (row_idx, row) in ROWS.iter().enumerate() {
|
||||||
let y = inner.y + row_idx as u16;
|
let y = inner.y + row_idx as u16;
|
||||||
@@ -82,11 +111,23 @@ impl Widget for KeyboardDiagram<'_> {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let is_depressed = self.depressed_keys.contains(&key);
|
||||||
let is_unlocked = self.unlocked_keys.contains(&key);
|
let is_unlocked = self.unlocked_keys.contains(&key);
|
||||||
let is_focused = self.focused_key == Some(key);
|
let is_focused = self.focused_key == Some(key);
|
||||||
let is_next = self.next_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()
|
Style::default()
|
||||||
.fg(colors.bg())
|
.fg(colors.bg())
|
||||||
.bg(colors.accent())
|
.bg(colors.accent())
|
||||||
@@ -104,7 +145,11 @@ impl Widget for KeyboardDiagram<'_> {
|
|||||||
.bg(colors.bg())
|
.bg(colors.bg())
|
||||||
};
|
};
|
||||||
|
|
||||||
let display = format!("[ {key} ]");
|
let display = if self.compact {
|
||||||
|
format!("[{key}]")
|
||||||
|
} else {
|
||||||
|
format!("[ {key} ]")
|
||||||
|
};
|
||||||
buf.set_string(x, y, &display, style);
|
buf.set_string(x, y, &display, style);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod activity_heatmap;
|
||||||
pub mod chart;
|
pub mod chart;
|
||||||
pub mod dashboard;
|
pub mod dashboard;
|
||||||
pub mod keyboard_diagram;
|
pub mod keyboard_diagram;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use ratatui::widgets::{Block, Paragraph, Widget};
|
|||||||
|
|
||||||
use crate::engine::key_stats::KeyStatsStore;
|
use crate::engine::key_stats::KeyStatsStore;
|
||||||
use crate::session::result::LessonResult;
|
use crate::session::result::LessonResult;
|
||||||
use crate::ui::components::chart::WpmChart;
|
use crate::ui::components::activity_heatmap::ActivityHeatmap;
|
||||||
use crate::ui::theme::Theme;
|
use crate::ui::theme::Theme;
|
||||||
|
|
||||||
pub struct StatsDashboard<'a> {
|
pub struct StatsDashboard<'a> {
|
||||||
@@ -15,6 +15,8 @@ pub struct StatsDashboard<'a> {
|
|||||||
pub active_tab: usize,
|
pub active_tab: usize,
|
||||||
pub target_wpm: u32,
|
pub target_wpm: u32,
|
||||||
pub theme: &'a Theme,
|
pub theme: &'a Theme,
|
||||||
|
pub history_selected: usize,
|
||||||
|
pub history_confirm_delete: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> StatsDashboard<'a> {
|
impl<'a> StatsDashboard<'a> {
|
||||||
@@ -24,6 +26,8 @@ impl<'a> StatsDashboard<'a> {
|
|||||||
active_tab: usize,
|
active_tab: usize,
|
||||||
target_wpm: u32,
|
target_wpm: u32,
|
||||||
theme: &'a Theme,
|
theme: &'a Theme,
|
||||||
|
history_selected: usize,
|
||||||
|
history_confirm_delete: bool,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
history,
|
history,
|
||||||
@@ -31,6 +35,8 @@ impl<'a> StatsDashboard<'a> {
|
|||||||
active_tab,
|
active_tab,
|
||||||
target_wpm,
|
target_wpm,
|
||||||
theme,
|
theme,
|
||||||
|
history_selected,
|
||||||
|
history_confirm_delete,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,37 +91,88 @@ impl Widget for StatsDashboard<'_> {
|
|||||||
.collect();
|
.collect();
|
||||||
Paragraph::new(Line::from(tab_spans)).render(layout[0], buf);
|
Paragraph::new(Line::from(tab_spans)).render(layout[0], buf);
|
||||||
|
|
||||||
// Tab content
|
// Tab content — wide mode shows two panels side by side
|
||||||
match self.active_tab {
|
let is_wide = area.width > 170;
|
||||||
0 => self.render_dashboard_tab(layout[1], buf),
|
if is_wide {
|
||||||
1 => self.render_history_tab(layout[1], buf),
|
let panels = Layout::default()
|
||||||
2 => self.render_keystrokes_tab(layout[1], buf),
|
.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
|
// 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(
|
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()),
|
Style::default().fg(colors.accent()),
|
||||||
)));
|
)));
|
||||||
footer.render(layout[2], buf);
|
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<'_> {
|
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) {
|
fn render_dashboard_tab(&self, area: Rect, buf: &mut Buffer) {
|
||||||
let colors = &self.theme.colors;
|
let colors = &self.theme.colors;
|
||||||
|
|
||||||
let layout = Layout::default()
|
let layout = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([
|
.constraints([
|
||||||
Constraint::Length(4),
|
Constraint::Length(6), // summary stats bordered box
|
||||||
Constraint::Length(3),
|
Constraint::Length(3), // progress bars
|
||||||
Constraint::Min(8),
|
Constraint::Min(8), // charts
|
||||||
])
|
])
|
||||||
.split(area);
|
.split(area);
|
||||||
|
|
||||||
// Summary stats
|
// Summary stats as bordered table
|
||||||
let avg_wpm =
|
let avg_wpm =
|
||||||
self.history.iter().map(|r| r.wpm).sum::<f64>() / self.history.len() as f64;
|
self.history.iter().map(|r| r.wpm).sum::<f64>() / self.history.len() as f64;
|
||||||
let best_wpm = self
|
let best_wpm = self
|
||||||
@@ -133,19 +190,29 @@ impl StatsDashboard<'_> {
|
|||||||
let avg_acc_str = format!("{avg_accuracy:.1}%");
|
let avg_acc_str = format!("{avg_accuracy:.1}%");
|
||||||
let time_str = format_duration(total_time);
|
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![
|
let summary = vec![
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
Span::styled(" Lessons: ", Style::default().fg(colors.fg())),
|
Span::styled(" Lessons: ", Style::default().fg(colors.fg())),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
&*total_str,
|
&*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: ", Style::default().fg(colors.fg())),
|
||||||
Span::styled(&*avg_wpm_str, Style::default().fg(colors.accent())),
|
Span::styled(&*avg_wpm_str, Style::default().fg(colors.accent())),
|
||||||
Span::styled(" Best WPM: ", Style::default().fg(colors.fg())),
|
Span::styled(" Best WPM: ", Style::default().fg(colors.fg())),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
&*best_wpm_str,
|
&*best_wpm_str,
|
||||||
Style::default().fg(colors.success()).add_modifier(Modifier::BOLD),
|
Style::default()
|
||||||
|
.fg(colors.success())
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
@@ -164,30 +231,108 @@ impl StatsDashboard<'_> {
|
|||||||
Span::styled(&*time_str, Style::default().fg(colors.text_pending())),
|
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
|
// Progress bars
|
||||||
self.render_progress_bars(layout[1], buf);
|
self.render_progress_bars(layout[1], buf);
|
||||||
|
|
||||||
// Charts
|
// Charts: WPM bar graph + accuracy trend
|
||||||
let chart_layout = Layout::default()
|
let chart_layout = Layout::default()
|
||||||
.direction(Direction::Horizontal)
|
.direction(Direction::Horizontal)
|
||||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||||
.split(layout[2]);
|
.split(layout[2]);
|
||||||
|
|
||||||
// WPM chart
|
self.render_wpm_bar_graph(chart_layout[0], buf);
|
||||||
let wpm_data: Vec<(f64, f64)> = self
|
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
|
.history
|
||||||
.iter()
|
.iter()
|
||||||
.rev()
|
.rev()
|
||||||
.take(50)
|
.take(20)
|
||||||
.enumerate()
|
.map(|r| r.wpm)
|
||||||
.map(|(i, r)| (i as f64, r.wpm))
|
.collect::<Vec<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.rev()
|
||||||
.collect();
|
.collect();
|
||||||
WpmChart::new(&wpm_data, self.theme).render(chart_layout[0], buf);
|
|
||||||
|
|
||||||
// Accuracy chart
|
if recent.is_empty() {
|
||||||
let acc_data: Vec<(f64, f64)> = self
|
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
|
.history
|
||||||
.iter()
|
.iter()
|
||||||
.rev()
|
.rev()
|
||||||
@@ -195,7 +340,43 @@ impl StatsDashboard<'_> {
|
|||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, r)| (i as f64, r.accuracy))
|
.map(|(i, r)| (i as f64, r.accuracy))
|
||||||
.collect();
|
.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) {
|
fn render_progress_bars(&self, area: Rect, buf: &mut Buffer) {
|
||||||
@@ -217,8 +398,20 @@ impl StatsDashboard<'_> {
|
|||||||
|
|
||||||
// WPM progress
|
// WPM progress
|
||||||
let wpm_pct = (avg_wpm / self.target_wpm as f64 * 100.0).min(100.0);
|
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);
|
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
|
// Accuracy progress
|
||||||
let acc_pct = avg_accuracy.min(100.0);
|
let acc_pct = avg_accuracy.min(100.0);
|
||||||
@@ -230,16 +423,32 @@ impl StatsDashboard<'_> {
|
|||||||
} else {
|
} else {
|
||||||
colors.error()
|
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
|
// Level progress
|
||||||
let total_score: f64 = self.history.iter().map(|r| r.wpm * r.accuracy / 100.0).sum();
|
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 level = ((total_score / 100.0).sqrt() as u32).max(1);
|
||||||
let next_level_score = ((level + 1) as f64).powi(2) * 100.0;
|
let next_level_score = ((level + 1) as f64).powi(2) * 100.0;
|
||||||
let current_level_score = (level 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);
|
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) {
|
fn render_history_tab(&self, area: Rect, buf: &mut Buffer) {
|
||||||
@@ -247,26 +456,30 @@ impl StatsDashboard<'_> {
|
|||||||
|
|
||||||
let layout = Layout::default()
|
let layout = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([
|
.constraints([Constraint::Min(10), Constraint::Length(8)])
|
||||||
Constraint::Min(10),
|
|
||||||
Constraint::Length(8),
|
|
||||||
])
|
|
||||||
.split(area);
|
.split(area);
|
||||||
|
|
||||||
// Recent tests table
|
// Recent tests bordered table
|
||||||
let header = Line::from(vec![
|
let table_block = Block::bordered()
|
||||||
Span::styled(
|
.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 header = Line::from(vec![Span::styled(
|
||||||
" # WPM Raw Acc% Time Date",
|
" # WPM Raw Acc% Time Date",
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(colors.accent())
|
.fg(colors.accent())
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
),
|
)]);
|
||||||
]);
|
|
||||||
|
|
||||||
let mut lines = vec![header, Line::from(Span::styled(
|
let mut lines = vec![
|
||||||
|
header,
|
||||||
|
Line::from(Span::styled(
|
||||||
" ─────────────────────────────────────────────",
|
" ─────────────────────────────────────────────",
|
||||||
Style::default().fg(colors.border()),
|
Style::default().fg(colors.border()),
|
||||||
))];
|
)),
|
||||||
|
];
|
||||||
|
|
||||||
let recent: Vec<&LessonResult> = self.history.iter().rev().take(20).collect();
|
let recent: Vec<&LessonResult> = self.history.iter().rev().take(20).collect();
|
||||||
let total = self.history.len();
|
let total = self.history.len();
|
||||||
@@ -281,7 +494,17 @@ impl StatsDashboard<'_> {
|
|||||||
let wpm_str = format!("{:>6.0}", result.wpm);
|
let wpm_str = format!("{:>6.0}", result.wpm);
|
||||||
let raw_str = format!("{:>6.0}", raw_wpm);
|
let raw_str = format!("{:>6.0}", raw_wpm);
|
||||||
let acc_str = format!("{:>6.1}%", result.accuracy);
|
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 {
|
let acc_color = if result.accuracy >= 95.0 {
|
||||||
colors.success()
|
colors.success()
|
||||||
@@ -291,12 +514,19 @@ impl StatsDashboard<'_> {
|
|||||||
colors.error()
|
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);
|
self.render_per_key_speed(layout[1], buf);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,7 +534,7 @@ impl StatsDashboard<'_> {
|
|||||||
let colors = &self.theme.colors;
|
let colors = &self.theme.colors;
|
||||||
|
|
||||||
let block = Block::bordered()
|
let block = Block::bordered()
|
||||||
.title(" Per-Key Average Speed (ms) ")
|
.title(" Character Speed Distribution ")
|
||||||
.border_style(Style::default().fg(colors.border()));
|
.border_style(Style::default().fg(colors.border()));
|
||||||
let inner = block.inner(area);
|
let inner = block.inner(area);
|
||||||
block.render(area, buf);
|
block.render(area, buf);
|
||||||
@@ -321,9 +551,7 @@ impl StatsDashboard<'_> {
|
|||||||
.fold(0.0f64, f64::max)
|
.fold(0.0f64, f64::max)
|
||||||
.max(1.0);
|
.max(1.0);
|
||||||
|
|
||||||
// Render bar chart: letter label on row 0, bar on row 1
|
let bar_chars = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
||||||
let bar_width = (inner.width as usize).min(52) / 26;
|
|
||||||
let bar_width = bar_width.max(1) as u16;
|
|
||||||
|
|
||||||
for (i, &ch) in letters.iter().enumerate() {
|
for (i, &ch) in letters.iter().enumerate() {
|
||||||
let x = inner.x + (i as u16 * 2).min(inner.width.saturating_sub(1));
|
let x = inner.x + (i as u16 * 2).min(inner.width.saturating_sub(1));
|
||||||
@@ -350,19 +578,11 @@ impl StatsDashboard<'_> {
|
|||||||
// Letter label
|
// Letter label
|
||||||
buf.set_string(x, inner.y, &ch.to_string(), Style::default().fg(color));
|
buf.set_string(x, inner.y, &ch.to_string(), Style::default().fg(color));
|
||||||
|
|
||||||
// Simple bar indicator
|
// Bar indicator
|
||||||
if inner.height >= 2 {
|
if inner.height >= 2 {
|
||||||
let bar_char = if time > 0.0 {
|
let bar_char = if time > 0.0 {
|
||||||
match (ratio * 8.0) as u8 {
|
let idx = ((ratio * 7.0).round() as usize).min(7);
|
||||||
0 => '▁',
|
bar_chars[idx]
|
||||||
1 => '▂',
|
|
||||||
2 => '▃',
|
|
||||||
3 => '▄',
|
|
||||||
4 => '▅',
|
|
||||||
5 => '▆',
|
|
||||||
6 => '▇',
|
|
||||||
_ => '█',
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
' '
|
' '
|
||||||
};
|
};
|
||||||
@@ -373,25 +593,41 @@ impl StatsDashboard<'_> {
|
|||||||
Style::default().fg(color),
|
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) {
|
fn render_keystrokes_tab(&self, area: Rect, buf: &mut Buffer) {
|
||||||
let layout = Layout::default()
|
let layout = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([
|
.constraints([
|
||||||
Constraint::Length(7),
|
Constraint::Length(12), // Activity heatmap
|
||||||
Constraint::Min(5),
|
Constraint::Length(7), // Keyboard accuracy heatmap
|
||||||
Constraint::Length(6),
|
Constraint::Min(5), // Slowest/Fastest/Stats
|
||||||
|
Constraint::Length(5), // Overall stats
|
||||||
])
|
])
|
||||||
.split(area);
|
.split(area);
|
||||||
|
|
||||||
// Keyboard accuracy heatmap
|
// Activity heatmap
|
||||||
self.render_keyboard_heatmap(layout[0], buf);
|
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()
|
let key_layout = Layout::default()
|
||||||
.direction(Direction::Horizontal)
|
.direction(Direction::Horizontal)
|
||||||
.constraints([
|
.constraints([
|
||||||
@@ -399,14 +635,14 @@ impl StatsDashboard<'_> {
|
|||||||
Constraint::Percentage(34),
|
Constraint::Percentage(34),
|
||||||
Constraint::Percentage(33),
|
Constraint::Percentage(33),
|
||||||
])
|
])
|
||||||
.split(layout[1]);
|
.split(layout[2]);
|
||||||
|
|
||||||
self.render_slowest_keys(key_layout[0], buf);
|
self.render_slowest_keys(key_layout[0], buf);
|
||||||
self.render_fastest_keys(key_layout[1], 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
|
// Overall stats
|
||||||
self.render_overall_stats(layout[2], buf);
|
self.render_overall_stats(layout[3], buf);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_keyboard_heatmap(&self, area: Rect, buf: &mut Buffer) {
|
fn render_keyboard_heatmap(&self, area: Rect, buf: &mut Buffer) {
|
||||||
@@ -418,7 +654,7 @@ impl StatsDashboard<'_> {
|
|||||||
let inner = block.inner(area);
|
let inner = block.inner(area);
|
||||||
block.render(area, buf);
|
block.render(area, buf);
|
||||||
|
|
||||||
if inner.height < 3 || inner.width < 40 {
|
if inner.height < 3 || inner.width < 50 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,7 +664,7 @@ impl StatsDashboard<'_> {
|
|||||||
&['z', 'x', 'c', 'v', 'b', 'n', 'm'],
|
&['z', 'x', 'c', 'v', 'b', 'n', 'm'],
|
||||||
];
|
];
|
||||||
let offsets: &[u16] = &[1, 3, 5];
|
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() {
|
for (row_idx, row) in rows.iter().enumerate() {
|
||||||
let y = inner.y + row_idx as u16;
|
let y = inner.y + row_idx as u16;
|
||||||
@@ -440,23 +676,33 @@ impl StatsDashboard<'_> {
|
|||||||
|
|
||||||
for (col_idx, &key) in row.iter().enumerate() {
|
for (col_idx, &key) in row.iter().enumerate() {
|
||||||
let x = inner.x + offset + col_idx as u16 * key_width;
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
let accuracy = self.get_key_accuracy(key);
|
let accuracy = self.get_key_accuracy(key);
|
||||||
let color = if accuracy >= 100.0 {
|
let (fg_color, bg_color) = if accuracy <= 0.0 {
|
||||||
colors.text_pending()
|
(colors.text_pending(), colors.bg())
|
||||||
|
} else if accuracy >= 98.0 {
|
||||||
|
(colors.success(), colors.bg())
|
||||||
} else if accuracy >= 90.0 {
|
} else if accuracy >= 90.0 {
|
||||||
colors.warning()
|
(colors.warning(), colors.bg())
|
||||||
} else if accuracy > 0.0 {
|
|
||||||
colors.error()
|
|
||||||
} else {
|
} else {
|
||||||
colors.text_pending()
|
(colors.error(), colors.bg())
|
||||||
};
|
};
|
||||||
|
|
||||||
let display = format!("[{key}]");
|
let display = if accuracy > 0.0 {
|
||||||
buf.set_string(x, y, &display, Style::default().fg(color).bg(colors.bg()));
|
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 colors = &self.theme.colors;
|
||||||
|
|
||||||
let block = Block::bordered()
|
let block = Block::bordered()
|
||||||
.title(" Key Stats ")
|
.title(" Worst Accuracy ")
|
||||||
.border_style(Style::default().fg(colors.border()));
|
.border_style(Style::default().fg(colors.border()));
|
||||||
let inner = block.inner(area);
|
let inner = block.inner(area);
|
||||||
block.render(area, buf);
|
block.render(area, buf);
|
||||||
|
|
||||||
let mut total_correct = 0usize;
|
// Compute accuracy for each key
|
||||||
let mut total_incorrect = 0usize;
|
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 result in self.history {
|
||||||
total_correct += result.correct;
|
for kt in &result.per_key_times {
|
||||||
total_incorrect += result.incorrect;
|
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();
|
||||||
|
|
||||||
|
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;
|
for (i, (ch, acc, _total)) in key_accuracies.iter().take(5).enumerate() {
|
||||||
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() {
|
|
||||||
let y = inner.y + i as u16;
|
let y = inner.y + i as u16;
|
||||||
if y >= inner.y + inner.height {
|
if y >= inner.y + inner.height {
|
||||||
break;
|
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,9 +868,8 @@ impl StatsDashboard<'_> {
|
|||||||
let total_incorrect: usize = self.history.iter().map(|r| r.incorrect).sum();
|
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 total_time: f64 = self.history.iter().map(|r| r.elapsed_secs).sum();
|
||||||
|
|
||||||
let lines = vec![
|
let lines = vec![Line::from(vec![
|
||||||
Line::from(vec![
|
Span::styled(" Characters: ", Style::default().fg(colors.fg())),
|
||||||
Span::styled(" Characters typed: ", Style::default().fg(colors.fg())),
|
|
||||||
Span::styled(
|
Span::styled(
|
||||||
format!("{total_chars}"),
|
format!("{total_chars}"),
|
||||||
Style::default().fg(colors.accent()),
|
Style::default().fg(colors.accent()),
|
||||||
@@ -623,13 +888,12 @@ impl StatsDashboard<'_> {
|
|||||||
colors.success()
|
colors.success()
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
Span::styled(" Total time: ", Style::default().fg(colors.fg())),
|
Span::styled(" Time: ", Style::default().fg(colors.fg())),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
format_duration(total_time),
|
format_duration(total_time),
|
||||||
Style::default().fg(colors.text_pending()),
|
Style::default().fg(colors.text_pending()),
|
||||||
),
|
),
|
||||||
]),
|
])];
|
||||||
];
|
|
||||||
|
|
||||||
Paragraph::new(lines).render(inner, buf);
|
Paragraph::new(lines).render(inner, buf);
|
||||||
}
|
}
|
||||||
@@ -655,7 +919,7 @@ fn render_text_bar(
|
|||||||
Style::default().fg(fill_color),
|
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 bar_width = (area.width as usize).saturating_sub(4);
|
||||||
let filled = (ratio * bar_width as f64) as usize;
|
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 {
|
fn format_duration(secs: f64) -> String {
|
||||||
let total = secs as u64;
|
let total = secs as u64;
|
||||||
let hours = total / 3600;
|
let hours = total / 3600;
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ impl Widget for StatsSidebar<'_> {
|
|||||||
let accuracy = self.lesson.accuracy();
|
let accuracy = self.lesson.accuracy();
|
||||||
let progress = self.lesson.progress() * 100.0;
|
let progress = self.lesson.progress() * 100.0;
|
||||||
let correct = self.lesson.correct_count();
|
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 elapsed = self.lesson.elapsed_secs();
|
||||||
|
|
||||||
let wpm_str = format!("{wpm:.0}");
|
let wpm_str = format!("{wpm:.0}");
|
||||||
|
|||||||
@@ -1,14 +1,52 @@
|
|||||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
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 struct AppLayout {
|
||||||
pub header: Rect,
|
pub header: Rect,
|
||||||
pub main: Rect,
|
pub main: Rect,
|
||||||
pub sidebar: Rect,
|
pub sidebar: Option<Rect>,
|
||||||
pub footer: Rect,
|
pub footer: Rect,
|
||||||
|
pub tier: LayoutTier,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppLayout {
|
impl AppLayout {
|
||||||
pub fn new(area: Rect) -> Self {
|
pub fn new(area: Rect) -> Self {
|
||||||
|
let tier = LayoutTier::from_area(area);
|
||||||
|
|
||||||
let vertical = Layout::default()
|
let vertical = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([
|
.constraints([
|
||||||
@@ -18,6 +56,7 @@ impl AppLayout {
|
|||||||
])
|
])
|
||||||
.split(area);
|
.split(area);
|
||||||
|
|
||||||
|
if tier.show_sidebar() {
|
||||||
let horizontal = Layout::default()
|
let horizontal = Layout::default()
|
||||||
.direction(Direction::Horizontal)
|
.direction(Direction::Horizontal)
|
||||||
.constraints([Constraint::Percentage(70), Constraint::Percentage(30)])
|
.constraints([Constraint::Percentage(70), Constraint::Percentage(30)])
|
||||||
@@ -26,8 +65,18 @@ impl AppLayout {
|
|||||||
Self {
|
Self {
|
||||||
header: vertical[0],
|
header: vertical[0],
|
||||||
main: horizontal[0],
|
main: horizontal[0],
|
||||||
sidebar: horizontal[1],
|
sidebar: Some(horizontal[1]),
|
||||||
footer: vertical[2],
|
footer: vertical[2],
|
||||||
|
tier,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Self {
|
||||||
|
header: vertical[0],
|
||||||
|
main: vertical[1],
|
||||||
|
sidebar: None,
|
||||||
|
footer: vertical[2],
|
||||||
|
tier,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user