Compare commits
9 Commits
c78a8a90a3
...
4e39e99732
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e39e99732 | |||
| d0605f8426 | |||
| 2d63cffb33 | |||
| a61ed77ed6 | |||
| edd2f7e6b5 | |||
| 6d6815af02 | |||
| 13550505c1 | |||
| a51adafeb0 | |||
| a0e8f3cafb |
23
assets/themes/farout.toml
Normal file
23
assets/themes/farout.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name = "farout"
|
||||||
|
|
||||||
|
[colors]
|
||||||
|
bg = "#0f0908"
|
||||||
|
fg = "#E0CCAE"
|
||||||
|
text_correct = "#a4896f"
|
||||||
|
text_incorrect = "#bf472c"
|
||||||
|
text_incorrect_bg = "#392D2B"
|
||||||
|
text_pending = "#A67458"
|
||||||
|
text_cursor_bg = "#0f0908"
|
||||||
|
text_cursor_fg = "#f2a766"
|
||||||
|
focused_key = "#f2a766"
|
||||||
|
accent = "#d47d49"
|
||||||
|
accent_dim = "#392D2B"
|
||||||
|
border = "#392D2B"
|
||||||
|
border_focused = "#d47d49"
|
||||||
|
header_bg = "#392D2B"
|
||||||
|
header_fg = "#E0CCAE"
|
||||||
|
bar_filled = "#a67458"
|
||||||
|
bar_empty = "#392D2B"
|
||||||
|
error = "#bf472c"
|
||||||
|
warning = "#f2a766"
|
||||||
|
success = "#a4896f"
|
||||||
23
assets/themes/gruvbox-darkest.toml
Normal file
23
assets/themes/gruvbox-darkest.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name = "gruvbox-darkest"
|
||||||
|
|
||||||
|
[colors]
|
||||||
|
bg = "#121212"
|
||||||
|
fg = "#ebdbb2"
|
||||||
|
text_correct = "#b8bb26"
|
||||||
|
text_incorrect = "#fb4934"
|
||||||
|
text_incorrect_bg = "#462726"
|
||||||
|
text_pending = "#a89984"
|
||||||
|
text_cursor_bg = "#fabd2f"
|
||||||
|
text_cursor_fg = "#121212"
|
||||||
|
focused_key = "#fabd2f"
|
||||||
|
accent = "#83a598"
|
||||||
|
accent_dim = "#3c3836"
|
||||||
|
border = "#504945"
|
||||||
|
border_focused = "#83a598"
|
||||||
|
header_bg = "#3c3836"
|
||||||
|
header_fg = "#ebdbb2"
|
||||||
|
bar_filled = "#83a598"
|
||||||
|
bar_empty = "#3c3836"
|
||||||
|
error = "#fb4934"
|
||||||
|
warning = "#fabd2f"
|
||||||
|
success = "#b8bb26"
|
||||||
23
assets/themes/kanagawa-dragon.toml
Normal file
23
assets/themes/kanagawa-dragon.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name = "kanagawa-dragon"
|
||||||
|
|
||||||
|
[colors]
|
||||||
|
bg = "#181616"
|
||||||
|
fg = "#c5c9c5"
|
||||||
|
text_correct = "#8a9a7b"
|
||||||
|
text_incorrect = "#c4746e"
|
||||||
|
text_incorrect_bg = "#43242B"
|
||||||
|
text_pending = "#a6a69c"
|
||||||
|
text_cursor_bg = "#2d4f67"
|
||||||
|
text_cursor_fg = "#c8c093"
|
||||||
|
focused_key = "#c4b28a"
|
||||||
|
accent = "#8ba4b0"
|
||||||
|
accent_dim = "#282727"
|
||||||
|
border = "#625e5a"
|
||||||
|
border_focused = "#8ba4b0"
|
||||||
|
header_bg = "#282727"
|
||||||
|
header_fg = "#c5c9c5"
|
||||||
|
bar_filled = "#8ea4a2"
|
||||||
|
bar_empty = "#282727"
|
||||||
|
error = "#c4746e"
|
||||||
|
warning = "#c4b28a"
|
||||||
|
success = "#8a9a7b"
|
||||||
23
assets/themes/kanagawa-lotus.toml
Normal file
23
assets/themes/kanagawa-lotus.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name = "kanagawa-lotus"
|
||||||
|
|
||||||
|
[colors]
|
||||||
|
bg = "#f2ecbc"
|
||||||
|
fg = "#545464"
|
||||||
|
text_correct = "#6f894e"
|
||||||
|
text_incorrect = "#c84053"
|
||||||
|
text_incorrect_bg = "#d9a594"
|
||||||
|
text_pending = "#8a8980"
|
||||||
|
text_cursor_bg = "#5d57a3"
|
||||||
|
text_cursor_fg = "#f2ecbc"
|
||||||
|
focused_key = "#77713f"
|
||||||
|
accent = "#4d699b"
|
||||||
|
accent_dim = "#e7dba0"
|
||||||
|
border = "#a5a37d"
|
||||||
|
border_focused = "#4d699b"
|
||||||
|
header_bg = "#e7dba0"
|
||||||
|
header_fg = "#545464"
|
||||||
|
bar_filled = "#597b75"
|
||||||
|
bar_empty = "#d9d0a3"
|
||||||
|
error = "#c84053"
|
||||||
|
warning = "#77713f"
|
||||||
|
success = "#6f894e"
|
||||||
23
assets/themes/kanagawa-wave.toml
Normal file
23
assets/themes/kanagawa-wave.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name = "kanagawa-wave"
|
||||||
|
|
||||||
|
[colors]
|
||||||
|
bg = "#1f1f28"
|
||||||
|
fg = "#dcd7ba"
|
||||||
|
text_correct = "#76946a"
|
||||||
|
text_incorrect = "#c34043"
|
||||||
|
text_incorrect_bg = "#43242B"
|
||||||
|
text_pending = "#727169"
|
||||||
|
text_cursor_bg = "#2d4f67"
|
||||||
|
text_cursor_fg = "#c8c093"
|
||||||
|
focused_key = "#c0a36e"
|
||||||
|
accent = "#7e9cd8"
|
||||||
|
accent_dim = "#2A2A37"
|
||||||
|
border = "#54546D"
|
||||||
|
border_focused = "#7e9cd8"
|
||||||
|
header_bg = "#2A2A37"
|
||||||
|
header_fg = "#dcd7ba"
|
||||||
|
bar_filled = "#7e9cd8"
|
||||||
|
bar_empty = "#2A2A37"
|
||||||
|
error = "#c34043"
|
||||||
|
warning = "#c0a36e"
|
||||||
|
success = "#76946a"
|
||||||
23
assets/themes/terminal-default.toml
Normal file
23
assets/themes/terminal-default.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name = "terminal-default"
|
||||||
|
|
||||||
|
[colors]
|
||||||
|
bg = "reset"
|
||||||
|
fg = "reset"
|
||||||
|
text_correct = "green"
|
||||||
|
text_incorrect = "red"
|
||||||
|
text_incorrect_bg = "reset"
|
||||||
|
text_pending = "darkgray"
|
||||||
|
text_cursor_bg = "reset"
|
||||||
|
text_cursor_fg = "reset"
|
||||||
|
focused_key = "yellow"
|
||||||
|
accent = "blue"
|
||||||
|
accent_dim = "darkgray"
|
||||||
|
border = "darkgray"
|
||||||
|
border_focused = "blue"
|
||||||
|
header_bg = "reset"
|
||||||
|
header_fg = "reset"
|
||||||
|
bar_filled = "blue"
|
||||||
|
bar_empty = "darkgray"
|
||||||
|
error = "red"
|
||||||
|
warning = "yellow"
|
||||||
|
success = "green"
|
||||||
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)
|
||||||
507
docs/plans/2026-02-15-skill-tree-progression-system.md
Normal file
507
docs/plans/2026-02-15-skill-tree-progression-system.md
Normal file
@@ -0,0 +1,507 @@
|
|||||||
|
# Skill Tree Progression System & Whitespace Support
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
keydr currently tracks only a-z lowercase letters in its adaptive unlock system. Since keydr aims to be a coding-focused typing tutor, it must also train capitals, numbers, punctuation, whitespace (tabs/newlines), and code-specific symbols. The current flat a-z progression needs to be replaced with a branching skill tree that lets players choose their training path after mastering lowercase letters. Additionally, code drills currently strip newlines into spaces, making them unrealistic for real-world code practice.
|
||||||
|
|
||||||
|
## Skill Tree Structure
|
||||||
|
|
||||||
|
The tree is flat: a-z is the root, and all other branches are direct siblings at the same level. Once a-z is complete, all branches unlock simultaneously and the user can choose any order.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐
|
||||||
|
│ a-z Lowercase │ (ROOT - everyone starts here)
|
||||||
|
│ 26 keys, freq │
|
||||||
|
│ order unlock │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
┌─────────┬──────────┼──────────┬──────────┐
|
||||||
|
▼ ▼ ▼ ▼ ▼
|
||||||
|
┌─────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌──────────┐
|
||||||
|
│Capitals │ │Numbers │ │ Prose │ │White- │ │ Code │
|
||||||
|
│ A-Z │ │ 0-9 │ │ Punct. │ │ space │ │ Symbols │
|
||||||
|
│ 3 lvls │ │ 2 lvls │ │ 3 lvls │ │ 2 lvls │ │ 4 lvls │
|
||||||
|
└─────────┘ └────────┘ └────────┘ └────────┘ └──────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- **a-z Lowercase** (root): Always available from start
|
||||||
|
- **All other branches**: Require a-z complete (all 26 lowercase letters confident). Once a-z is done, all 5 branches unlock simultaneously. User freely chooses which to pursue.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Branch Status State Machine
|
||||||
|
|
||||||
|
Each branch has an explicit status:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum BranchStatus {
|
||||||
|
Locked, // Prerequisites not met
|
||||||
|
Available, // Prerequisites met, user hasn't started
|
||||||
|
InProgress, // User has begun drilling this branch
|
||||||
|
Complete, // All levels in branch are done
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Transitions:**
|
||||||
|
- `Locked → Available`: When a-z branch reaches `Complete`
|
||||||
|
- `Available → InProgress`: **Only** when user explicitly launches a branch drill from the skill tree (start-on-select model). The global adaptive drill does NOT auto-start branches.
|
||||||
|
- `InProgress → Complete`: When all keys in all levels of the branch reach confidence >= 1.0
|
||||||
|
|
||||||
|
**Multiple branches active**: Yes. The user can have multiple branches `InProgress` simultaneously. Each tracks its own current level independently.
|
||||||
|
|
||||||
|
**Global adaptive scope**: Only includes keys from `InProgress` and `Complete` branches. `Available` branches are not included — the user must visit the skill tree to start them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Detailed Level Breakdown
|
||||||
|
|
||||||
|
### Branch: a-z Lowercase (Root)
|
||||||
|
|
||||||
|
Uses existing frequency-order system. Starts with 6 keys, unlocks one at a time when all current keys reach confidence >= 1.0. Branch is "complete" when all 26 letters are confident.
|
||||||
|
|
||||||
|
Order: `e t a o i n s h r d l c u m w f g y p b v k j x q z`
|
||||||
|
|
||||||
|
Total keys: **26**
|
||||||
|
|
||||||
|
### Branch: Capital Letters (3 levels)
|
||||||
|
|
||||||
|
- **Level 1 — Common Sentence Capitals** (8 keys): `T I A S W H B M`
|
||||||
|
- **Level 2 — Name Capitals** (10 keys): `J D R C E N P L F G`
|
||||||
|
- **Level 3 — Remaining Capitals** (8 keys): `O U K V Y X Q Z`
|
||||||
|
|
||||||
|
Total keys: **26**
|
||||||
|
|
||||||
|
Text generation rules:
|
||||||
|
- First word of each "sentence" (after `.` `?` `!` or at drill start) gets capitalized
|
||||||
|
- ~10-15% of words get capitalized as proper-noun-like words
|
||||||
|
- Focused capital letter is boosted (40% chance to appear in word starts)
|
||||||
|
|
||||||
|
### Branch: Numbers (2 levels)
|
||||||
|
|
||||||
|
- **Level 1 — Common Digits** (5 keys): `1 2 3 4 5`
|
||||||
|
- **Level 2 — All Digits** (5 keys): `0 6 7 8 9`
|
||||||
|
|
||||||
|
Total keys: **10**
|
||||||
|
|
||||||
|
Text generation rules:
|
||||||
|
- ~15% of words replaced with number expressions using only unlocked digits
|
||||||
|
- Patterns: counts ("3 items"), years ("2024"), IDs ("room 42"), measurements ("7 miles")
|
||||||
|
|
||||||
|
### Branch: Prose Punctuation (3 levels)
|
||||||
|
|
||||||
|
- **Level 1 — Essential** (3 keys): `. , '`
|
||||||
|
- **Level 2 — Common** (4 keys): `; : " -`
|
||||||
|
- **Level 3 — Expressive** (4 keys): `? ! ( )`
|
||||||
|
|
||||||
|
Total keys: **11**
|
||||||
|
|
||||||
|
Text generation rules follow natural prose patterns:
|
||||||
|
- `.` ends sentences (every 5-15 words), `,` separates clauses
|
||||||
|
- `'` in contractions (don't, it's, we'll)
|
||||||
|
- `"` wrapping quoted phrases, `;` between clauses, `:` before lists
|
||||||
|
- `-` in compound words (well-known), `?` for questions, `!` for exclamations
|
||||||
|
- `( )` for parenthetical asides
|
||||||
|
|
||||||
|
### Branch: Whitespace (2 levels)
|
||||||
|
|
||||||
|
- **Level 1 — Enter/Return** (1 key): `\n`
|
||||||
|
- **Level 2 — Tab/Indent** (1 key): `\t`
|
||||||
|
|
||||||
|
Total keys: **2**
|
||||||
|
|
||||||
|
Text generation rules:
|
||||||
|
- Line breaks at sentence boundaries (every ~60-80 chars)
|
||||||
|
- Tabs for indentation in code-like structures
|
||||||
|
- Once unlocked, **default adaptive drills automatically become multi-line**
|
||||||
|
|
||||||
|
### Branch: Code Symbols (4 levels)
|
||||||
|
|
||||||
|
- **Level 1 — Arithmetic & Assignment** (5 keys): `= + * /` and `-` (shared with Prose Punct L2)
|
||||||
|
- **Level 2 — Grouping** (6 keys): `{ } [ ] < >`
|
||||||
|
- **Level 3 — Logic & Reference** (5 keys): `& | ^ ~` and `!` (shared with Prose Punct L3)
|
||||||
|
- **Level 4 — Special** (7 keys): `` @ # $ % _ \ ` ``
|
||||||
|
|
||||||
|
Total keys: **23** (21 unique + 2 shared with Prose Punctuation)
|
||||||
|
|
||||||
|
Text generation rules:
|
||||||
|
- L1: Prose with simple expressions (`x = a + b`, `total = price * qty`)
|
||||||
|
- L2: Code-pattern templates (`if (x) { return y; }`, `arr[0]`)
|
||||||
|
- L3: Bitwise/logical patterns (`a & b`, `!flag`, `*ptr`)
|
||||||
|
- L4: Language-specific patterns (`@decorator`, `#include`, `snake_case`)
|
||||||
|
|
||||||
|
**Grand total**: 98 keys across branches, **96 unique keys** (after deducting 2 shared: `-` and `!`). `TOTAL_UNIQUE_KEYS` is derived at startup by collecting all keys from all branch definitions into a `HashSet` and taking `len()`. Stored as a field on `SkillTree` for use in scoring and UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shared Keys Between Branches
|
||||||
|
|
||||||
|
Two keys appear in multiple branches:
|
||||||
|
- `-` appears in Prose Punctuation L2 and Code Symbols L1
|
||||||
|
- `!` appears in Prose Punctuation L3 and Code Symbols L3
|
||||||
|
|
||||||
|
**Rule**: Confidence is tracked once per character in `KeyStatsStore` (keyed by `char`). If a user masters `-` in Prose Punctuation, it is automatically confident in Code Symbols too. When checking level completion, the branch reads the single confidence value for that char. This is idempotent — no special handling needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Focused Key Policy
|
||||||
|
|
||||||
|
### Global Adaptive Drill (from menu)
|
||||||
|
|
||||||
|
1. Collect all keys from all `InProgress` branches (current level's keys only) plus all `Complete` branch keys
|
||||||
|
2. Find the key with the **lowest confidence < 1.0** across this entire set
|
||||||
|
3. If all keys are confident, no focused key (maintenance mode)
|
||||||
|
4. Boost the focused key in text generation (40% probability)
|
||||||
|
|
||||||
|
### Branch-Specific Drill (from skill tree)
|
||||||
|
|
||||||
|
1. Collect keys from the selected branch including **all prior completed levels** (as background reinforcement) plus the **current level's keys**, plus all a-z keys
|
||||||
|
2. Find the key with the **lowest confidence < 1.0** within the **current level keys only** (prior level keys are reinforcement, not focus targets)
|
||||||
|
3. If all current level keys are confident, advance the level and focus on the weakest new key
|
||||||
|
4. Boost the focused key in text generation (40% probability)
|
||||||
|
5. Prior-level keys always appear in generated text for reinforcement but are never the focused key
|
||||||
|
|
||||||
|
### Branches with Zero Progress
|
||||||
|
|
||||||
|
When a branch is `Available` but user hasn't started it yet:
|
||||||
|
- Launching a drill from that branch transitions it to `InProgress` at level 1
|
||||||
|
- The focused key is the weakest among level 1's keys (likely all at 0.0 confidence, so pick the first in definition order)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scoring
|
||||||
|
|
||||||
|
Current formula: `complexity = unlocked_count / 26`
|
||||||
|
|
||||||
|
**New formula**: `complexity = total_unlocked_keys / TOTAL_UNIQUE_KEYS`
|
||||||
|
|
||||||
|
Where `TOTAL_UNIQUE_KEYS = 96` is computed from branch definitions (deduplicated across shared keys). This scales naturally — the more branches the user has unlocked, the higher the complexity multiplier.
|
||||||
|
|
||||||
|
Level formula remains: `level = floor(sqrt(total_score / 100))`.
|
||||||
|
|
||||||
|
Menu header changes from `"X/26 letters"` to `"X/96 keys"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Skill Tree UI
|
||||||
|
|
||||||
|
### New Screen: `AppScreen::SkillTree`
|
||||||
|
|
||||||
|
Accessible from menu via `[t] Skill Tree`. Renders **vertically** as a scrollable list.
|
||||||
|
|
||||||
|
```
|
||||||
|
╔══════════════════════════════════════════════════════════════════╗
|
||||||
|
║ SKILL TREE ║
|
||||||
|
╠══════════════════════════════════════════════════════════════════╣
|
||||||
|
║ ║
|
||||||
|
║ ★ Lowercase a-z COMPLETE 26/26 ║
|
||||||
|
║ ████████████████████████████████████████ Level 26/26 ║
|
||||||
|
║ ║
|
||||||
|
║ ── Branches (unlocked after a-z) ────────────────────────── ║
|
||||||
|
║ ║
|
||||||
|
║ ► Capitals A-Z Lvl 2/3 18/26 keys ║
|
||||||
|
║ ████████████████████░░░░░░░░░░░░ 69% ║
|
||||||
|
║ ║
|
||||||
|
║ Numbers 0-9 Lvl 0/2 0/10 keys ║
|
||||||
|
║ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0% ║
|
||||||
|
║ ║
|
||||||
|
║ Prose Punctuation Lvl 1/3 3/11 keys ║
|
||||||
|
║ ██████████░░░░░░░░░░░░░░░░░░░░░ 27% ║
|
||||||
|
║ ║
|
||||||
|
║ Whitespace Lvl 0/2 0/2 keys ║
|
||||||
|
║ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0% ║
|
||||||
|
║ ║
|
||||||
|
║ Code Symbols Lvl 0/4 0/23 keys ║
|
||||||
|
║ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0% ║
|
||||||
|
║ ║
|
||||||
|
╠══════════════════════════════════════════════════════════════════╣
|
||||||
|
║ ► Capitals A-Z Level 2/3 ║
|
||||||
|
║ L1: T I A S W H B M (complete) ║
|
||||||
|
║ L2: J [D] R C E N P L F G (in progress, focused: D) ║
|
||||||
|
║ L3: O U K V Y X Q Z (locked) ║
|
||||||
|
║ Avg Confidence: ████████░░ 82% ║
|
||||||
|
║ ║
|
||||||
|
║ [Enter] Start Drill [↑↓/jk] Navigate [q] Back ║
|
||||||
|
╚══════════════════════════════════════════════════════════════════╝
|
||||||
|
```
|
||||||
|
|
||||||
|
**Layout:**
|
||||||
|
- **Top section**: Vertical list of all branches with status prefix, level, key count, progress bar
|
||||||
|
- **Bottom section**: Detail panel showing per-level key breakdown, confidence bars, focused key
|
||||||
|
- **Footer**: Controls
|
||||||
|
|
||||||
|
**Node states (prefix):**
|
||||||
|
- Locked: grayed out, no prefix, not selectable
|
||||||
|
- Available: normal color, no prefix
|
||||||
|
- In Progress `►`: accent color
|
||||||
|
- Complete `★`: gold/green
|
||||||
|
|
||||||
|
**Navigation:** `↑↓` / `j/k` move selection. `Enter` launches branch drill. `q` returns to menu.
|
||||||
|
|
||||||
|
**Keyboard diagram**: For non-printable keys (`Enter`, `Tab`), show them as labeled keys on the keyboard diagram in their standard positions. No special handling needed — they're physical keys with fixed positions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code & Passage Drill Changes (Unranked Modes)
|
||||||
|
|
||||||
|
Code and Passage drills remain as separate menu options.
|
||||||
|
|
||||||
|
1. **Unranked tagging**: Add `ranked: bool` to `DrillResult` with `#[serde(default = "default_true")]` for backward compat
|
||||||
|
2. **Derive ranked from DrillContext**: At drill start, set `ranked = (drill_mode == Adaptive)`. Code/Passage → `ranked = false`.
|
||||||
|
3. **No progression**: `finish_drill()` gates skill tree updates on `result.ranked`
|
||||||
|
4. **History replay**: `rebuild_from_history()` uses `result.ranked` as the gate. No legacy fallback — since we reset on schema change (WIP policy), old history without `ranked` field won't exist.
|
||||||
|
5. **Visual indicators**:
|
||||||
|
- Drill header: "Code Drill (Unranked)" / "Passage Drill (Unranked)" in dimmed/muted color
|
||||||
|
- Result screen: "Unranked — does not count toward skill tree"
|
||||||
|
- Stats dashboard history: unranked rows shown with muted styling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Whitespace Handling
|
||||||
|
|
||||||
|
### Tokenized Render Model (`typing_area.rs`)
|
||||||
|
|
||||||
|
Replace direct char→span rendering with a `RenderToken` approach to handle one-to-many char-to-cell mapping:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
struct RenderToken {
|
||||||
|
target_idx: usize, // Index into DrillState.target
|
||||||
|
display: String, // What to show (e.g., "↵", "→···", "a")
|
||||||
|
style: Style, // Computed style (correct/incorrect/cursor/pending)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Display mapper:**
|
||||||
|
- `\n` → visible `↵` marker token + hard line break (new `Line` in paragraph)
|
||||||
|
- `\t` → visible `→` marker + padding `·` tokens to next 4-char tab stop
|
||||||
|
- All other chars → single token with char as display
|
||||||
|
|
||||||
|
**Cursor/style mapping:** Maintain a `Vec<(usize, usize)>` mapping from `target_idx` to first display cell position. When highlighting cursor or errors, look up the target index to find which display tokens to style.
|
||||||
|
|
||||||
|
**Multi-line rendering:** Change from single `Line` to `Vec<Line>`. Split on newline tokens. Each line is a separate `Line` in the `Paragraph`.
|
||||||
|
|
||||||
|
### Input Pipeline (`main.rs` + `session/input.rs`)
|
||||||
|
|
||||||
|
Current flow: `main.rs` matches `KeyCode::Char(ch)` → `app.type_char(ch)`. Enter/Tab are currently consumed by other handlers (menu nav, etc.).
|
||||||
|
|
||||||
|
**Changes in `main.rs`:**
|
||||||
|
- When `screen == Drill` and drill is active:
|
||||||
|
- `KeyCode::Enter` → `app.type_char('\n')` **unconditionally** (correctness decided by `process_char()`)
|
||||||
|
- `KeyCode::Tab` → `app.type_char('\t')` **unconditionally** (correctness decided by `process_char()`)
|
||||||
|
- `KeyCode::BackTab` (Shift+Tab) → ignore (no action)
|
||||||
|
- These must be checked **before** the existing Esc/Enter handlers for drill screen
|
||||||
|
- If Enter/Tab is typed when not expected, it registers as an error on the current char — same as typing any wrong key
|
||||||
|
|
||||||
|
**No changes to `session/input.rs`**: `process_char()` already compares `ch == expected` generically. It will work with `'\n'` and `'\t'` as-is.
|
||||||
|
|
||||||
|
### Code Drill Updates (`generator/code_syntax.rs`)
|
||||||
|
|
||||||
|
- Embedded snippets change from single-line `&str` to multi-line string literals with preserved indentation
|
||||||
|
- `extract_code_snippets()`: preserve original newlines and leading whitespace instead of `split_whitespace().join(" ")`
|
||||||
|
- `generate()`: join snippets with `\n\n` instead of `" "`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Model Changes
|
||||||
|
|
||||||
|
### Persistence Policy (WIP stage)
|
||||||
|
|
||||||
|
**No backward compatibility migration.** On schema mismatch, reset persisted files to defaults. Bump schema version to 2. Add a note in changelog that local progress is intentionally reset for this version. This avoids over-engineering migration logic during early development.
|
||||||
|
|
||||||
|
### `ProfileData` (schema v2)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ProfileData {
|
||||||
|
pub schema_version: u32, // 2
|
||||||
|
pub skill_tree: SkillTreeProgress, // Replaces unlocked_letters
|
||||||
|
pub total_score: f64,
|
||||||
|
pub total_drills: u32,
|
||||||
|
pub streak_days: u32,
|
||||||
|
pub best_streak: u32,
|
||||||
|
pub last_practice_date: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct SkillTreeProgress {
|
||||||
|
pub branches: HashMap<String, BranchProgress>, // String keys for stable JSON
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct BranchProgress {
|
||||||
|
pub status: BranchStatus,
|
||||||
|
pub current_level: usize, // 0-indexed into branch's levels array
|
||||||
|
// current_level = 0 means working on first level (plan's "Level 1")
|
||||||
|
// current_level = levels.len() only when status == Complete
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Indexing invariant**: `current_level` is always 0-indexed into `BranchDefinition.levels`. When the plan says "Level 1", "Level 2", etc. in human-readable text, that maps to `current_level = 0`, `current_level = 1`, etc. in code. A branch with `current_level = 0` and `status = InProgress` is actively working on its first level.
|
||||||
|
|
||||||
|
**HashMap uses `String` keys** (e.g., `"lowercase"`, `"capitals"`, `"numbers"`, etc.) for stable JSON serialization. `BranchId` enum has `to_key() -> &'static str` and `from_key()` methods.
|
||||||
|
|
||||||
|
### `DrillResult` Addition
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub ranked: bool,
|
||||||
|
```
|
||||||
|
|
||||||
|
### `KeyStatsStore`
|
||||||
|
|
||||||
|
No structural change. Already `HashMap<char, KeyStat>` — works for any char.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Skill Tree Definition (Source of Truth)
|
||||||
|
|
||||||
|
Hard-coded static definition in `src/engine/skill_tree.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct BranchDefinition {
|
||||||
|
pub id: BranchId,
|
||||||
|
pub name: &'static str,
|
||||||
|
pub levels: Vec<LevelDefinition>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LevelDefinition {
|
||||||
|
pub name: &'static str,
|
||||||
|
pub keys: Vec<char>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
All branch/level/key definitions are `const`/`static` arrays. No data-driven manifest needed at this stage. The `SkillTree` struct holds:
|
||||||
|
- The static definition (reference)
|
||||||
|
- The persisted `SkillTreeProgress` (mutable state)
|
||||||
|
- Methods: `unlocked_keys(scope)`, `focused_key(scope, &KeyStatsStore)`, `update(&KeyStatsStore)`, `branch_status(id)`, `all_branches()`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1: Skill Tree Core & Data Model
|
||||||
|
|
||||||
|
**Goal**: Replace `LetterUnlock` with `SkillTree`, update persistence.
|
||||||
|
|
||||||
|
1. Create `src/engine/skill_tree.rs`:
|
||||||
|
- `BranchId` enum (`Lowercase, Capitals, Numbers, ProsePunctuation, Whitespace, CodeSymbols`)
|
||||||
|
- `BranchStatus` enum (`Locked, Available, InProgress, Complete`)
|
||||||
|
- `BranchDefinition`, `LevelDefinition` structs
|
||||||
|
- Static branch definitions with all keys per level
|
||||||
|
- `SkillTree` struct with `update()`, `unlocked_keys()`, `focused_key()`, `branch_status()`
|
||||||
|
2. Update `src/store/schema.rs`: new `ProfileData` with `SkillTreeProgress`, schema v2, reset on mismatch
|
||||||
|
3. Add `ranked: bool` to `DrillResult` in `src/session/result.rs`
|
||||||
|
4. Update `src/app.rs`: replace `letter_unlock: LetterUnlock` with `skill_tree: SkillTree`, update `finish_drill()` to gate on `ranked`, update `rebuild_from_history()`, update scoring complexity formula
|
||||||
|
5. Delete/replace `src/engine/letter_unlock.rs`
|
||||||
|
|
||||||
|
**Key files**: `src/engine/skill_tree.rs` (new), `src/engine/letter_unlock.rs` (delete), `src/store/schema.rs`, `src/session/result.rs`, `src/app.rs`
|
||||||
|
|
||||||
|
**Tests**:
|
||||||
|
- Skill tree status transitions (Locked → Available → InProgress → Complete)
|
||||||
|
- Shared key confidence propagation
|
||||||
|
- Focused key selection (global vs branch scope)
|
||||||
|
- Level completion and advancement
|
||||||
|
- Schema reset on version mismatch
|
||||||
|
|
||||||
|
**Acceptance criteria**: `cargo build` passes, `cargo test` passes, existing adaptive drills work with skill tree (a-z only), scoring uses new formula.
|
||||||
|
|
||||||
|
### Phase 2: Whitespace Input & Rendering
|
||||||
|
|
||||||
|
**Goal**: Support Enter/Tab in typing drills with proper display.
|
||||||
|
|
||||||
|
1. Update `src/ui/components/typing_area.rs`: tokenized render model with `RenderToken`, multi-line support, visible `↵` and `→` markers
|
||||||
|
2. Update `src/main.rs`: route `KeyCode::Enter` → `'\n'` and `KeyCode::Tab` → `'\t'` when in drill mode, ignore `BackTab`
|
||||||
|
3. Update `src/generator/code_syntax.rs`: preserve newlines/indentation in snippets, change embedded snippets to multi-line, fix `extract_code_snippets()` to preserve whitespace
|
||||||
|
4. Optionally update `src/generator/passage.rs` with multi-line passage variants
|
||||||
|
|
||||||
|
**Key files**: `src/ui/components/typing_area.rs`, `src/main.rs`, `src/generator/code_syntax.rs`
|
||||||
|
|
||||||
|
**Tests**:
|
||||||
|
- RenderToken generation for strings with `\n` and `\t`
|
||||||
|
- Cursor position mapping with expanded tokens
|
||||||
|
- Enter/Tab input processing (reuse existing `process_char()` — just verify `'\n'` and `'\t'` work)
|
||||||
|
|
||||||
|
**Acceptance criteria**: Code drills display multi-line with visible whitespace markers, Enter/Tab advance the cursor correctly, backspace works across line boundaries.
|
||||||
|
|
||||||
|
### Phase 3: Text Generation for Capitals & Punctuation
|
||||||
|
|
||||||
|
**Goal**: Generate drill text that naturally incorporates capitals and punctuation.
|
||||||
|
|
||||||
|
1. Create `src/generator/capitalize.rs`: post-processing pass that capitalizes sentence starts and occasional words, using only unlocked capital letters
|
||||||
|
2. Create `src/generator/punctuate.rs`: post-processing pass that inserts periods, commas, apostrophes, etc. at natural positions, using only unlocked punctuation
|
||||||
|
3. Update `src/generator/phonetic.rs` or `src/app.rs` `generate_text()`: apply capitalize/punctuate passes when those branches are active
|
||||||
|
4. Update `src/engine/filter.rs` `CharFilter`: add awareness of which char types are allowed (lowercase, uppercase, punctuation, etc.)
|
||||||
|
|
||||||
|
**Key files**: `src/generator/capitalize.rs` (new), `src/generator/punctuate.rs` (new), `src/generator/phonetic.rs`, `src/app.rs`, `src/engine/filter.rs`
|
||||||
|
|
||||||
|
**Acceptance criteria**: Adaptive drills with Capitals branch active produce properly capitalized text. Drills with Prose Punctuation active have natural punctuation placement.
|
||||||
|
|
||||||
|
### Phase 4: Text Generation for Numbers & Code Symbols
|
||||||
|
|
||||||
|
**Goal**: Generate drill text with numbers and code symbol patterns.
|
||||||
|
|
||||||
|
1. Create `src/generator/numbers.rs`: injects number expressions into prose using only unlocked digits
|
||||||
|
2. Create `src/generator/code_patterns.rs`: code-pattern templates for Code Symbols branch drills (expressions, brackets, operators)
|
||||||
|
3. Update `src/app.rs` `generate_text()`: apply number/code passes based on active branches
|
||||||
|
4. For whitespace branch: when active, insert `\n` at sentence boundaries in generated text
|
||||||
|
|
||||||
|
**Key files**: `src/generator/numbers.rs` (new), `src/generator/code_patterns.rs` (new), `src/app.rs`
|
||||||
|
|
||||||
|
**Acceptance criteria**: Number expressions use only unlocked digits. Code symbol drills produce recognizable code-like patterns. Whitespace branch generates multi-line output.
|
||||||
|
|
||||||
|
### Phase 5: Skill Tree UI
|
||||||
|
|
||||||
|
**Goal**: Navigable skill tree screen with branch detail and drill launch.
|
||||||
|
|
||||||
|
1. Add `AppScreen::SkillTree` to `src/app.rs`
|
||||||
|
2. Create `src/ui/components/skill_tree.rs`: vertical branch list + detail panel widget
|
||||||
|
3. Update `src/main.rs`: handle key events for skill tree screen (navigation, drill launch)
|
||||||
|
4. Update `src/ui/components/menu.rs`: add `[t] Skill Tree` option
|
||||||
|
5. Update menu header: show `"X/96 keys"` instead of `"X/26 letters"`
|
||||||
|
6. Add `DrillMode::BranchDrill(BranchId)` or similar to track drill origin for branch-specific focus
|
||||||
|
|
||||||
|
**Key files**: `src/ui/components/skill_tree.rs` (new), `src/app.rs`, `src/main.rs`, `src/ui/components/menu.rs`
|
||||||
|
|
||||||
|
**Acceptance criteria**: Can navigate to skill tree from menu, see all branches with correct status, launch a branch-specific drill, return to menu.
|
||||||
|
|
||||||
|
### Phase 6: Unranked Mode Polish
|
||||||
|
|
||||||
|
**Goal**: Clearly distinguish ranked vs unranked drills in UI.
|
||||||
|
|
||||||
|
1. Update drill header in `src/main.rs`: show "(Unranked)" for Code/Passage modes
|
||||||
|
2. Update `src/ui/components/dashboard.rs` result screen: note "does not count toward skill tree"
|
||||||
|
3. Update `src/ui/components/stats_dashboard.rs`: muted styling for unranked history rows
|
||||||
|
4. Verify `rebuild_from_history()` correctly uses `ranked` field to gate skill tree updates
|
||||||
|
|
||||||
|
**Key files**: `src/main.rs`, `src/ui/components/dashboard.rs`, `src/ui/components/stats_dashboard.rs`, `src/app.rs`
|
||||||
|
|
||||||
|
**Acceptance criteria**: Code/Passage drills clearly marked unranked. Stats history shows visual distinction. Ranked drills advance skill tree, unranked don't.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
### Automated Tests
|
||||||
|
|
||||||
|
- **Skill tree transitions**: `Locked → Available → InProgress → Complete` for each branch
|
||||||
|
- **Shared keys**: Mastering `!` in Prose Punct → confident in Code Symbols too
|
||||||
|
- **Focused key**: Global scope selects weakest across all active branches; branch scope selects within branch
|
||||||
|
- **Level advancement**: Completing all keys in a level auto-advances to next
|
||||||
|
- **Ranked/unranked**: Only ranked drills update skill tree in `rebuild_from_history()`
|
||||||
|
- **Whitespace tokens**: RenderToken expansion for `\n` and `\t` produces correct display strings and index mapping
|
||||||
|
- **Input routing**: `'\n'` and `'\t'` correctly processed as typed characters
|
||||||
|
|
||||||
|
### Manual Testing
|
||||||
|
|
||||||
|
1. Launch app → a-z trunk works as before
|
||||||
|
2. Complete a-z (or edit profile to simulate) → all 5 branches show as Available
|
||||||
|
3. Navigate skill tree → select Capitals → launch drill → see capitalized text
|
||||||
|
4. Complete Capitals L1 → L2 keys appear in drills
|
||||||
|
5. Launch default adaptive with multiple branches active → text mixes all unlocked keys
|
||||||
|
6. Launch Code/Passage drill → header shows "(Unranked)", no skill tree progress
|
||||||
|
7. Start Whitespace branch → default adaptive becomes multi-line
|
||||||
|
8. Type Enter/Tab in code drills → cursor advances correctly, errors tracked
|
||||||
|
9. Quit and relaunch → progress preserved
|
||||||
|
10. Delete `~/.local/share/keydr/` → app resets cleanly to fresh state
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
# Skill Tree Integration Fixes & UI Improvements
|
||||||
|
|
||||||
|
## Context
|
||||||
|
After adding a skill tree progression system, several parts of the app weren't fully integrated. This plan addresses 7 issues: progress bar confusion, broken skill tree bars, missing selectability, duplicate displays, incomplete keyboard visualization, code drill formatting issues, and a missing menu shortcut.
|
||||||
|
|
||||||
|
## Architecture Foundations
|
||||||
|
|
||||||
|
### A. Layout-Driven Keyboard Model
|
||||||
|
**Files:** `src/keyboard/layout.rs`, new `src/keyboard/model.rs`
|
||||||
|
|
||||||
|
The existing `KeyboardLayout` in `layout.rs` only stores `Vec<Vec<char>>` (base layer). We need a shared model used by both drill and stats keyboards.
|
||||||
|
|
||||||
|
Create `src/keyboard/model.rs`:
|
||||||
|
- `PhysicalKey { base: char, shifted: char }` - represents one physical key with both layers
|
||||||
|
- `KeyboardModel { rows: Vec<Vec<PhysicalKey>> }` - full keyboard definition
|
||||||
|
- Factory methods: `KeyboardModel::qwerty()`, `::dvorak()`, `::colemak()` - each returns the full layout
|
||||||
|
- Helper: `base_to_shifted(ch) -> Option<char>` and `shifted_to_base(ch) -> Option<char>` derived from the model
|
||||||
|
- Helper: `physical_key_for(ch) -> Option<&PhysicalKey>` - lookup by either base or shifted char
|
||||||
|
|
||||||
|
The QWERTY model:
|
||||||
|
```
|
||||||
|
Row 0 (number): (`~) (1!) (2@) (3#) (4$) (5%) (6^) (7&) (8*) (9() (0)) (-_) (=+)
|
||||||
|
Row 1 (top): (qQ) (wW) (eE) (rR) (tT) (yY) (uU) (iI) (oO) (pP) ([{) (]}) (\|)
|
||||||
|
Row 2 (home): (aA) (sS) (dD) (fF) (gG) (hH) (jJ) (kK) (lL) (;:) ('")
|
||||||
|
Row 3 (bottom): (zZ) (xX) (cC) (vV) (bB) (nN) (mM) (,<) (.>) (/?)
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `KeyboardLayout` to use `KeyboardModel` internally (or replace it).
|
||||||
|
|
||||||
|
Replace `qwerty_finger(ch)` with a layout-aware API:
|
||||||
|
- `KeyboardModel::finger_for(&self, key: &PhysicalKey) -> FingerAssignment` - each layout defines finger assignments per physical key position (row, col)
|
||||||
|
- For shifted chars, callers first resolve to physical key via `physical_key_for(ch)`, then look up finger
|
||||||
|
- This eliminates the QWERTY-only char match and works for Dvorak/Colemak
|
||||||
|
|
||||||
|
Load the active layout from `config.keyboard_layout` and pass it through to all keyboard rendering.
|
||||||
|
|
||||||
|
### B. Dual Progress Metrics
|
||||||
|
**File:** `src/engine/skill_tree.rs`
|
||||||
|
|
||||||
|
Add `branch_unlocked_count(id: BranchId) -> usize` method:
|
||||||
|
- Lowercase: delegates to `lowercase_unlocked_count()`
|
||||||
|
- Others: sums `keys.len()` for levels `0..=current_level` when InProgress; all keys when Complete; 0 otherwise
|
||||||
|
|
||||||
|
All UI uses two metrics per branch:
|
||||||
|
- **Unlocked**: `branch_unlocked_count(id)` / `branch_total_keys(id)` - how far through the branch
|
||||||
|
- **Mastered**: `branch_confident_keys(id, stats)` / `branch_total_keys(id)` - how many keys at confidence >= 1.0
|
||||||
|
|
||||||
|
### C. Code Language Config
|
||||||
|
**File:** `src/config.rs`
|
||||||
|
|
||||||
|
Replace the implicit `code_languages: Vec<String>` usage with a clearer model:
|
||||||
|
- Add `code_language: String` field (single language: "rust", "python", "javascript", "go", "all")
|
||||||
|
- Keep `code_languages` for backwards compat but derive from `code_language`
|
||||||
|
- Settings cycling and code generation both read `code_language`
|
||||||
|
- "all" picks a random language per drill in `generate_text()`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Changes (in order)
|
||||||
|
|
||||||
|
### 1. Fix missing `[c] Settings` shortcut in menu footer
|
||||||
|
**File:** `src/main.rs` (`render_menu` function)
|
||||||
|
- Change footer string to: `" [1-3] Start [t] Skill Tree [s] Stats [c] Settings [q] Quit "`
|
||||||
|
- Verify no other footers are missing hints by checking all `render_*` functions
|
||||||
|
|
||||||
|
### 2. Fix duplicate fraction display on Lowercase branch
|
||||||
|
**File:** `src/ui/components/skill_tree.rs` (`render_branch_list`)
|
||||||
|
- Currently shows `"6/26 0/26 keys"` because status_text and confident/total are concatenated
|
||||||
|
- Change to single display: `"6/26 unlocked"` when no mastered keys, or `"6/26 unlocked (3 mastered)"` when some exist
|
||||||
|
- Apply same pattern to all branches: `"Lvl 1/3 5/10 unlocked (2 mastered)"`
|
||||||
|
|
||||||
|
### 3. Make Lowercase a-z selectable in skill tree
|
||||||
|
**Files:** `src/ui/components/skill_tree.rs`, `src/main.rs` (`handle_skill_tree_key`)
|
||||||
|
|
||||||
|
- Add `BranchId::Lowercase` to `selectable_branches()` at index 0
|
||||||
|
- Merge the separate root Lowercase rendering (currently in `render_branch_list` lines 113-170) into the main branch loop
|
||||||
|
- Apply selection highlighting to Lowercase using same `is_selected` logic as other branches
|
||||||
|
- Keep "Branches (unlocked after a-z)" separator after Lowercase (index 0) and before Capitals (index 1)
|
||||||
|
- Detail panel for Lowercase: show progressive unlock state `"Unlocked 6/26 letters"` instead of `"Level 1/1"`. Show each unlocked key with its confidence, locked keys dimmed
|
||||||
|
- Enter on InProgress Lowercase starts branch drill (existing `start_branch_drill` handles this)
|
||||||
|
- Update `branch_list_height` calculation to account for the merged layout
|
||||||
|
|
||||||
|
### 4. Fix skill tree progress bars - combined unlocked/mastered bar
|
||||||
|
**Files:** `src/engine/skill_tree.rs`, `src/ui/components/skill_tree.rs`
|
||||||
|
|
||||||
|
- Add `branch_unlocked_count()` method (see Architecture B above)
|
||||||
|
- Change progress bars to a **combined dual-metric bar**: the bar is divided into three segments:
|
||||||
|
- Filled (accent color): mastered keys (confidence >= 1.0)
|
||||||
|
- Filled (dimmer color): unlocked but not yet mastered
|
||||||
|
- Empty (background): locked keys
|
||||||
|
- This works because mastered <= unlocked <= total always holds
|
||||||
|
- Update `progress_bar_str` to accept two ratios and render with two fill colors
|
||||||
|
- **Rounding rule**: compute cell counts from raw counts (not ratios) to avoid rounding violations:
|
||||||
|
- `mastered_cells = (mastered * width / total)` (floor)
|
||||||
|
- `unlocked_cells = (unlocked * width / total).max(mastered_cells)` (floor, clamped)
|
||||||
|
- `empty_cells = width - unlocked_cells`
|
||||||
|
- This guarantees `mastered_cells <= unlocked_cells <= width` with no overlap
|
||||||
|
- Text label shows: `"6/26 unlocked, 3 mastered"`
|
||||||
|
|
||||||
|
### 5. Add per-key mastery display in skill tree detail panel (phase 2 if time allows)
|
||||||
|
**File:** `src/ui/components/skill_tree.rs` (`render_detail_panel`)
|
||||||
|
|
||||||
|
- In the detail view for the selected branch, show a mini progress bar per key
|
||||||
|
- Each key shows: `char [====----] 75%` where the bar represents confidence (0-100%)
|
||||||
|
- Keys already at confidence >= 1.0 show as fully filled with success color
|
||||||
|
- Keys not yet unlocked show dimmed with "locked" label
|
||||||
|
- Focused key is highlighted (existing logic already identifies it)
|
||||||
|
- Layout: keys in their level groups, each on its own line with the mini bar
|
||||||
|
- Note: This adds UI complexity. Implement after core issues (1-4, 6-8) are stable.
|
||||||
|
|
||||||
|
### 6. Replace drill screen progress bar with per-branch progress
|
||||||
|
**Files:** `src/main.rs` (`render_drill`), new `src/ui/components/branch_progress_list.rs`
|
||||||
|
|
||||||
|
Create a new `BranchProgressList` widget (not stretching the existing `ProgressBar`):
|
||||||
|
- Shows one compact line per active branch (InProgress or Complete), plus an overall line
|
||||||
|
- Each line: `" ▶ Lowercase [████░░░░] 6/26"`
|
||||||
|
- Uses the combined dual-metric bar from Issue 4 (mastered vs unlocked segments)
|
||||||
|
- Active drill branch (from `app.drill_scope`) is highlighted with accent color and `▶` prefix
|
||||||
|
- Other branches use dimmer color and `·` prefix
|
||||||
|
|
||||||
|
Layout budgeting by `LayoutTier` (unbordered, plain lines to maximize density):
|
||||||
|
- **Wide** (height >= 25): show all active branches (InProgress/Complete). `Constraint::Length(active_count.min(6) as u16 + 1)` (+1 for "Overall" line)
|
||||||
|
- **Wide** (height 20-24): show active drill branch + overall only. `Constraint::Length(2)`
|
||||||
|
- **Medium**: show active drill branch only. `Constraint::Length(1)`
|
||||||
|
- **Narrow**: hide progress (current behavior)
|
||||||
|
|
||||||
|
### 7. Full keyboard visualization
|
||||||
|
**Files:** `src/keyboard/model.rs` (new), `src/keyboard/layout.rs` (update), `src/ui/components/keyboard_diagram.rs`, `src/ui/components/stats_dashboard.rs`, `src/main.rs`, `src/app.rs`
|
||||||
|
|
||||||
|
#### 7a. Build KeyboardModel (Architecture A above)
|
||||||
|
|
||||||
|
#### 7b. Drill keyboard
|
||||||
|
- `KeyboardDiagram` takes `&KeyboardModel` instead of hardcoded `ROWS`
|
||||||
|
- Add `shift_held: bool` field
|
||||||
|
- **Shift state handling**: Primary source is `key.modifiers.contains(KeyModifiers::SHIFT)` checked on every Press event. Set `app.shift_held = true` when modifier present, `false` when absent. Additionally, on tick (100ms), if `shift_held` is true and no key event has been received in 200ms, clear it as a fallback. This means: shifted display appears when a shifted key is pressed, and naturally clears on the next unshifted keypress or after timeout. Acceptance: brief flicker (1-2 frames) on quick shift+key combos is acceptable; sustained wrong state is not.
|
||||||
|
- When `shift_held`, display `physical_key.shifted` for each key; otherwise `physical_key.base`
|
||||||
|
- Full mode: 4 rows (number, top, home, bottom) + visual-only labels for Tab/Backspace/Shift/Enter at row edges
|
||||||
|
- Compact mode: 3 rows letters only (current behavior, but driven from `KeyboardModel`)
|
||||||
|
- Height: `Constraint::Length(7)` for full (4 rows + 2 border + label), `Constraint::Length(5)` for compact
|
||||||
|
- Replace `finger_color(ch)` with layout-aware `finger_for(model, physical_key) -> FingerAssignment` that works for any layout (see 7a)
|
||||||
|
- `is_unlocked` check: map the displayed char against `unlocked_keys` list
|
||||||
|
|
||||||
|
#### 7c. Stats keyboard heatmap
|
||||||
|
- Two sub-rows per physical row: top = shifted layer (dimmer styling), bottom = base layer
|
||||||
|
- Each cell shows char + accuracy % (existing format)
|
||||||
|
- Height: `Constraint::Length(12)` (4 physical rows x 2 sub-rows + 2 borders + header)
|
||||||
|
- Load from `KeyboardModel` based on `config.keyboard_layout`
|
||||||
|
- Accuracy lookup: use existing `get_key_accuracy(char)` for each layer independently
|
||||||
|
- **Width fallback**: if terminal width < 70, collapse to base layer only (hide shifted sub-rows). Existing min-width guard pattern from `render_keyboard_heatmap` (width < 50 => skip) is preserved.
|
||||||
|
|
||||||
|
### 8. Code drill improvements
|
||||||
|
**Files:** `src/generator/code_syntax.rs`, `src/app.rs`, `src/main.rs`, `src/config.rs`
|
||||||
|
|
||||||
|
#### 8a. Multi-line embedded snippets
|
||||||
|
- Reformat all snippets in `rust_snippets()`, `python_snippets()`, `javascript_snippets()`, `go_snippets()` to be multi-line with realistic formatting
|
||||||
|
- Go: use `\t` for indentation (gofmt convention)
|
||||||
|
- Rust/Python/JavaScript: use 4 spaces
|
||||||
|
- Keep Tab key input as literal `\t` (do NOT convert to spaces) - this is needed for whitespace branch progression and the typing area already renders tabs properly
|
||||||
|
- Add basic validation for fetched snippets: require at least one newline and reject snippets that are all on one line (filter in `extract_code_snippets`)
|
||||||
|
|
||||||
|
#### 8b. Language selection screen
|
||||||
|
- Add `AppScreen::CodeLanguageSelect` to `AppScreen` enum
|
||||||
|
- Add `code_language_selected: usize` to `App`
|
||||||
|
- Screen flow: Menu `'2'` or Enter on "Code Drill" -> `CodeLanguageSelect` -> select language -> start drill
|
||||||
|
- ESC from language select returns to Menu
|
||||||
|
- Direct hotkeys in language select: `1`=Rust, `2`=Python, `3`=JavaScript, `4`=Go, `5`=All
|
||||||
|
- Enter confirms selection
|
||||||
|
- Arrow keys / j/k navigate
|
||||||
|
- Default selection: whichever language matches current `config.code_language`
|
||||||
|
- On confirm: update `config.code_language`, save config, set `drill_mode = Code`, start drill
|
||||||
|
- Render: centered bordered box with language list, highlighting selected item, showing `(current)` next to the default
|
||||||
|
|
||||||
|
#### 8c. Config changes
|
||||||
|
- Add `code_language: String` field to Config with default "rust"
|
||||||
|
- Settings screen language cycling updates `code_language`
|
||||||
|
- `generate_text` for Code mode reads `code_language` (if "all", picks random)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- `cargo build` -- no compilation errors
|
||||||
|
- `cargo test` -- existing tests pass; add tests for:
|
||||||
|
- `branch_unlocked_count` returns correct values for each branch state
|
||||||
|
- `KeyboardModel::qwerty()` covers all skill tree chars
|
||||||
|
- Selection bounds don't panic with Lowercase in `selectable_branches`
|
||||||
|
- Manual testing checklist:
|
||||||
|
- Menu footer shows `[c] Settings`
|
||||||
|
- Skill tree: Lowercase is selectable with arrow keys, Enter starts drill
|
||||||
|
- Skill tree: single fraction display, no duplicate numbers
|
||||||
|
- Skill tree: progress bars show dual unlocked/mastered segments
|
||||||
|
- Skill tree detail: per-key mastery bars shown
|
||||||
|
- Drill: branch progress bars visible, active branch highlighted
|
||||||
|
- Drill keyboard: full layout visible, keys shift on Shift press
|
||||||
|
- Stats keyboard: both layers shown
|
||||||
|
- Code drill: language selection appears, snippets have proper newlines/indentation
|
||||||
|
- Non-adaptive drills: ESC still shows partial result correctly
|
||||||
|
- Dvorak/Colemak: keyboard renders correctly when layout config changed
|
||||||
757
docs/plans/2026-02-17-code-drill-feature-parity-plan.md
Normal file
757
docs/plans/2026-02-17-code-drill-feature-parity-plan.md
Normal file
@@ -0,0 +1,757 @@
|
|||||||
|
# Code Drill Feature Parity Plan
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The code drill feature is significantly less developed than the passage drill. The passage drill has a full onboarding flow, lazy downloads with progress bars, configurable network/cache settings, and rich content from Project Gutenberg. The code drill only has 4 hardcoded languages with ~20-30 built-in snippets each, a basic language selection screen, and a partially-implemented synchronous GitHub fetch that blocks the UI thread. There's also a completely dead `github_code.rs` file that's never used.
|
||||||
|
|
||||||
|
This plan is split into three delivery phases:
|
||||||
|
1. **Phase 1**: Feature parity with passage drill (onboarding, downloads, progress bar, config)
|
||||||
|
2. **Phase 2**: Language expansion and extraction improvements
|
||||||
|
3. **Phase 3**: Custom repo support
|
||||||
|
|
||||||
|
## Current Code Drill Analysis
|
||||||
|
|
||||||
|
### What exists:
|
||||||
|
- **`generator/code_syntax.rs`**: `CodeSyntaxGenerator` with built-in snippets for 4 languages (rust, python, javascript, go), a `try_fetch_code()` that synchronously fetches from hardcoded GitHub URLs (blocking UI), `extract_code_snippets()` for parsing functions from source
|
||||||
|
- **`generator/code_patterns.rs`**: Post-processor that inserts code-like expressions into adaptive drill text (unrelated to code drill mode)
|
||||||
|
- **`generator/github_code.rs`**: **Dead code** - `GitHubCodeGenerator` struct with `#[allow(dead_code)]`, never referenced outside its own file
|
||||||
|
- **Config**: Only `code_language: String` - no download/network/onboarding settings
|
||||||
|
- **Screens**: `CodeLanguageSelect` only - no intro, no download progress
|
||||||
|
- **Languages**: rust, python, javascript, go, "all"
|
||||||
|
|
||||||
|
### What passage drill has that code drill doesn't:
|
||||||
|
- Onboarding intro screen (`PassageIntro`) with config for downloads/dir/limits
|
||||||
|
- `passage_onboarding_done` flag (shows intro only on first use)
|
||||||
|
- `passage_downloads_enabled` toggle
|
||||||
|
- `passage_download_dir` configurable path
|
||||||
|
- `passage_paragraphs_per_book` content limit
|
||||||
|
- Lazy download: on drill start, downloads one book if not cached
|
||||||
|
- Background download thread with atomic progress reporting
|
||||||
|
- Download progress screen (`PassageDownloadProgress`) with byte-level progress bar
|
||||||
|
- Fallback to built-in content when downloads off
|
||||||
|
|
||||||
|
### Built-in snippet whitespace review:
|
||||||
|
- **Rust**: 4-space indent - idiomatic
|
||||||
|
- **Python**: 4-space indent - idiomatic
|
||||||
|
- **JavaScript**: 4-space indent - idiomatic
|
||||||
|
- **Go**: `\t` tab indent - idiomatic
|
||||||
|
|
||||||
|
All whitespace is correct. The escaped string format (`\n`, `\t`, `\"`) is hard to read. Converting to raw strings (`r#"..."#`) improves maintainability.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Feature Parity with Passage Drill
|
||||||
|
|
||||||
|
Goal: Give code drill the same onboarding, download, caching, and config infrastructure as passage drill. Keep the existing 4 languages. No language expansion yet.
|
||||||
|
|
||||||
|
### Step 1.1: Delete dead code
|
||||||
|
|
||||||
|
- Delete `src/generator/github_code.rs` entirely
|
||||||
|
- Remove `pub mod github_code;` from `src/generator/mod.rs`
|
||||||
|
|
||||||
|
### Step 1.2: Convert built-in snippets to raw strings
|
||||||
|
|
||||||
|
**File**: `src/generator/code_syntax.rs`
|
||||||
|
|
||||||
|
Convert all 4 language snippet arrays from escaped strings to `r#"..."#` raw strings. Example:
|
||||||
|
|
||||||
|
Before: `"fn main() {\n println!(\"hello\");\n}"`
|
||||||
|
After:
|
||||||
|
```rust
|
||||||
|
r#"fn main() {
|
||||||
|
println!("hello");
|
||||||
|
}"#
|
||||||
|
```
|
||||||
|
|
||||||
|
Go snippets: `\t` becomes actual tab characters inside raw strings (correct for Go).
|
||||||
|
|
||||||
|
Keep all existing snippets at their current count (~20-30 per language). Do NOT reduce them -- since downloads default to off, these are the primary content source for new users.
|
||||||
|
|
||||||
|
Validation: run `cargo test` after conversion. Add a focused test that asserts a sample snippet's char content matches expectations (catches any accidental whitespace changes).
|
||||||
|
|
||||||
|
### Step 1.3: Add config fields for code drill
|
||||||
|
|
||||||
|
**File**: `src/config.rs`
|
||||||
|
|
||||||
|
Add fields mirroring passage drill config:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[serde(default = "default_code_downloads_enabled")]
|
||||||
|
pub code_downloads_enabled: bool, // default: false
|
||||||
|
#[serde(default = "default_code_download_dir")]
|
||||||
|
pub code_download_dir: String, // default: dirs::data_dir()/keydr/code/
|
||||||
|
#[serde(default = "default_code_snippets_per_repo")]
|
||||||
|
pub code_snippets_per_repo: usize, // default: 50
|
||||||
|
#[serde(default = "default_code_onboarding_done")]
|
||||||
|
pub code_onboarding_done: bool, // default: false
|
||||||
|
```
|
||||||
|
|
||||||
|
`code_download_dir` default uses `dirs::data_dir()` (same pattern as `default_passage_download_dir`) for cross-platform portability.
|
||||||
|
|
||||||
|
`code_snippets_per_repo` is a **download-time extraction cap**: when fetching from a repo, extract at most this many snippets and write them to cache. The generator reads whatever is in the cache without re-filtering.
|
||||||
|
|
||||||
|
Update `Default` impl. Add `default_*` functions.
|
||||||
|
|
||||||
|
**Config normalization**: After deserialization in `App::new()` (not `Config::load()`, to avoid coupling config to generator internals), validate `code_language` against `code_language_options()`. If invalid (e.g., old/renamed key), reset to `"rust"`.
|
||||||
|
|
||||||
|
**Old cache migration**: The old `DiskCache("code_cache")` entries (in `~/.local/share/keydr/code_cache/`) are simply ignored. They used a different key format (`{lang}_snippets`) and location. No migration or cleanup needed -- they'll be naturally superseded by the new cache in `code_download_dir`.
|
||||||
|
|
||||||
|
### Step 1.4: Define language data structures
|
||||||
|
|
||||||
|
**File**: `src/generator/code_syntax.rs`
|
||||||
|
|
||||||
|
Add structures for the language registry. Phase 1 only populates the 4 existing languages + "all":
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct CodeLanguage {
|
||||||
|
pub key: &'static str, // filesystem-safe identifier (e.g. "rust", "bash")
|
||||||
|
pub display_name: &'static str, // UI label (e.g. "Rust", "Shell/Bash")
|
||||||
|
pub extensions: &'static [&'static str], // e.g. &[".rs"], &[".py", ".pyi"]
|
||||||
|
pub repos: &'static [CodeRepo],
|
||||||
|
pub has_builtin: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CodeRepo {
|
||||||
|
pub key: &'static str, // filesystem-safe identifier for cache naming
|
||||||
|
pub urls: &'static [&'static str], // raw.githubusercontent.com file URLs to fetch
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const CODE_LANGUAGES: &[CodeLanguage] = &[
|
||||||
|
CodeLanguage {
|
||||||
|
key: "rust",
|
||||||
|
display_name: "Rust",
|
||||||
|
extensions: &[".rs"],
|
||||||
|
repos: &[
|
||||||
|
CodeRepo {
|
||||||
|
key: "tokio",
|
||||||
|
urls: &[
|
||||||
|
"https://raw.githubusercontent.com/tokio-rs/tokio/master/tokio/src/sync/mutex.rs",
|
||||||
|
"https://raw.githubusercontent.com/tokio-rs/tokio/master/tokio/src/net/tcp/stream.rs",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
CodeRepo {
|
||||||
|
key: "serde",
|
||||||
|
urls: &[
|
||||||
|
"https://raw.githubusercontent.com/serde-rs/serde/master/serde/src/ser/mod.rs",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
has_builtin: true,
|
||||||
|
},
|
||||||
|
// ... python, javascript, go with similar structure
|
||||||
|
// Move existing hardcoded URLs from try_fetch_code() into these repo definitions
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
Helper functions:
|
||||||
|
```rust
|
||||||
|
pub fn code_language_options() -> Vec<(&'static str, String)>
|
||||||
|
// Returns [("rust", "Rust"), ("python", "Python"), ..., ("all", "All (random)")]
|
||||||
|
|
||||||
|
pub fn language_by_key(key: &str) -> Option<&'static CodeLanguage>
|
||||||
|
|
||||||
|
pub fn is_language_cached(cache_dir: &str, key: &str) -> bool
|
||||||
|
// Checks if any {key}_*.txt files exist in cache_dir AND have non-empty content (>0 bytes)
|
||||||
|
// Uses direct filesystem scanning (NOT DiskCache -- DiskCache has no list/glob API)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 1.5: Generalize download job struct
|
||||||
|
|
||||||
|
**File**: `src/app.rs`
|
||||||
|
|
||||||
|
Rename `PassageDownloadJob` to `DownloadJob`. It's already generic (just `Arc<AtomicU64>`, `Arc<AtomicBool>`, and a thread handle). Update all passage references to use the renamed type. No behavior change.
|
||||||
|
|
||||||
|
### Step 1.6: Add code drill app state
|
||||||
|
|
||||||
|
**File**: `src/app.rs`
|
||||||
|
|
||||||
|
Add `CodeDownloadCompleteAction` enum (parallels `PassageDownloadCompleteAction`):
|
||||||
|
```rust
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum CodeDownloadCompleteAction {
|
||||||
|
StartCodeDrill,
|
||||||
|
ReturnToSettings,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add screen variants:
|
||||||
|
```rust
|
||||||
|
CodeIntro, // Onboarding screen for code drill
|
||||||
|
CodeDownloadProgress, // Download progress for code files
|
||||||
|
```
|
||||||
|
|
||||||
|
Add app fields:
|
||||||
|
```rust
|
||||||
|
pub code_intro_selected: usize,
|
||||||
|
pub code_intro_downloads_enabled: bool,
|
||||||
|
pub code_intro_download_dir: String,
|
||||||
|
pub code_intro_snippets_per_repo: usize,
|
||||||
|
pub code_intro_downloading: bool,
|
||||||
|
pub code_intro_download_total: usize,
|
||||||
|
pub code_intro_downloaded: usize,
|
||||||
|
pub code_intro_current_repo: String,
|
||||||
|
pub code_intro_download_bytes: u64,
|
||||||
|
pub code_intro_download_bytes_total: u64,
|
||||||
|
pub code_download_queue: Vec<usize>, // repo indices within current language's repos array
|
||||||
|
pub code_drill_language_override: Option<String>,
|
||||||
|
pub code_download_action: CodeDownloadCompleteAction,
|
||||||
|
code_download_job: Option<DownloadJob>,
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 1.7: Remove blocking fetch from generator
|
||||||
|
|
||||||
|
**File**: `src/generator/code_syntax.rs`
|
||||||
|
|
||||||
|
Remove `try_fetch_code()` from `CodeSyntaxGenerator`. All network I/O moves to the app layer with background threads.
|
||||||
|
|
||||||
|
Update constructor:
|
||||||
|
```rust
|
||||||
|
pub fn new(rng: SmallRng, language: &str, cache_dir: &str) -> Self
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `load_cached_snippets()`: scan `cache_dir` for files matching `{language}_*.txt`, read each, split on `---SNIPPET---` delimiter. This replaces the `DiskCache("code_cache")` approach with direct filesystem reads (since `DiskCache` has no listing/glob API and the cache dir is now user-configurable).
|
||||||
|
|
||||||
|
### Step 1.8: Add download function
|
||||||
|
|
||||||
|
**File**: `src/generator/code_syntax.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn download_code_repo_to_cache_with_progress<F>(
|
||||||
|
cache_dir: &str,
|
||||||
|
language_key: &str,
|
||||||
|
repo: &CodeRepo,
|
||||||
|
snippets_limit: usize,
|
||||||
|
on_progress: F,
|
||||||
|
) -> bool
|
||||||
|
where
|
||||||
|
F: FnMut(u64, Option<u64>),
|
||||||
|
```
|
||||||
|
|
||||||
|
This function:
|
||||||
|
1. Creates `cache_dir` if needed (`fs::create_dir_all`)
|
||||||
|
2. Fetches each URL in `repo.urls` using `fetch_url_bytes_with_progress` (already exists in `cache.rs`)
|
||||||
|
3. Runs `extract_code_snippets()` on each fetched file
|
||||||
|
4. Combines all snippets, truncates to `snippets_limit`
|
||||||
|
5. Writes to `{cache_dir}/{language_key}_{repo.key}.txt` with `---SNIPPET---` delimiter
|
||||||
|
6. Returns `true` on success
|
||||||
|
|
||||||
|
**Error handling**: If any individual URL fails (404, timeout, network error), skip it and continue with others. If zero snippets extracted from all URLs, return `false`. The app layer treats `false` as "skip this repo, continue queue" (same as passage drill's failure behavior).
|
||||||
|
|
||||||
|
### Step 1.9: Implement code drill flow methods
|
||||||
|
|
||||||
|
**File**: `src/app.rs`
|
||||||
|
|
||||||
|
**`go_to_code_intro()`**: Initialize intro screen state (downloads toggle, dir, snippets limit from config). Set `code_download_action = CodeDownloadCompleteAction::StartCodeDrill`. Set screen to `CodeIntro`.
|
||||||
|
|
||||||
|
**`start_code_drill()`**: Lazy download logic with explicit language resolution:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn start_code_drill(&mut self) {
|
||||||
|
// Step 1: Resolve concrete language (never download with "all" selected)
|
||||||
|
if self.code_drill_language_override.is_none() {
|
||||||
|
let chosen = if self.config.code_language == "all" {
|
||||||
|
// Pick from languages with built-in OR cached content only
|
||||||
|
// Never pick a network-only language that isn't cached
|
||||||
|
let available = languages_with_content(&self.config.code_download_dir);
|
||||||
|
if available.is_empty() {
|
||||||
|
"rust".to_string() // ultimate fallback
|
||||||
|
} else {
|
||||||
|
let idx = self.rng.gen_range(0..available.len());
|
||||||
|
available[idx].to_string()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.config.code_language.clone()
|
||||||
|
};
|
||||||
|
self.code_drill_language_override = Some(chosen);
|
||||||
|
}
|
||||||
|
|
||||||
|
let chosen = self.code_drill_language_override.clone().unwrap();
|
||||||
|
|
||||||
|
// Step 2: Check if we need to download
|
||||||
|
if self.config.code_downloads_enabled
|
||||||
|
&& !is_language_cached(&self.config.code_download_dir, &chosen)
|
||||||
|
{
|
||||||
|
if let Some(lang) = language_by_key(&chosen) {
|
||||||
|
if !lang.repos.is_empty() {
|
||||||
|
// Pick one random repo to download
|
||||||
|
let repo_idx = self.rng.gen_range(0..lang.repos.len());
|
||||||
|
self.code_download_queue = vec![repo_idx];
|
||||||
|
self.code_intro_download_total = 1;
|
||||||
|
self.code_intro_downloaded = 0;
|
||||||
|
self.code_intro_downloading = true;
|
||||||
|
self.code_intro_current_repo = format!("{}", lang.repos[repo_idx].key);
|
||||||
|
self.code_download_action = CodeDownloadCompleteAction::StartCodeDrill;
|
||||||
|
self.code_download_job = None;
|
||||||
|
self.screen = AppScreen::CodeDownloadProgress;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Language has no repos or unknown: fall through to built-in
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: If language has no built-in AND no cache AND downloads off → fallback
|
||||||
|
if !is_language_cached(&self.config.code_download_dir, &chosen) {
|
||||||
|
if let Some(lang) = language_by_key(&chosen) {
|
||||||
|
if !lang.has_builtin {
|
||||||
|
// Network-only language with no cache: fall back to "rust"
|
||||||
|
self.code_drill_language_override = Some("rust".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Start the drill
|
||||||
|
self.drill_mode = DrillMode::Code;
|
||||||
|
self.drill_scope = DrillScope::Global;
|
||||||
|
self.start_drill();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Key behavior: `"all"` only selects from `languages_with_content()` (built-in OR cached). This prevents the dead-end loop of repeatedly picking uncached network-only languages and forcing download screens. In Phase 2, once network-only languages get cached via manual download, they are automatically included in `"all"` selection.
|
||||||
|
|
||||||
|
**`languages_with_content(cache_dir: &str) -> Vec<&'static str>`**: Returns language keys that have either `has_builtin: true` or non-empty cache files in `cache_dir`.
|
||||||
|
|
||||||
|
**`process_code_download_tick()`**, **`spawn_code_download_job()`**: Same pattern as passage equivalents, using `download_code_repo_to_cache_with_progress` and `DownloadJob`.
|
||||||
|
|
||||||
|
**`start_code_downloads_from_settings()`**: Mirror `start_passage_downloads_from_settings()` with `CodeDownloadCompleteAction::ReturnToSettings`.
|
||||||
|
|
||||||
|
### Step 1.10: Update code language select flow
|
||||||
|
|
||||||
|
**File**: `src/main.rs`
|
||||||
|
|
||||||
|
Update `handle_code_language_key()` and `render_code_language_select()`:
|
||||||
|
- Still shows the same 4+1 languages for now (Phase 2 expands this)
|
||||||
|
- Wire Enter to `confirm_code_language_and_continue()`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn confirm_code_language_and_continue(app: &mut App, langs: &[&str]) {
|
||||||
|
if app.code_language_selected >= langs.len() { return; }
|
||||||
|
app.config.code_language = langs[app.code_language_selected].to_string();
|
||||||
|
let _ = app.config.save();
|
||||||
|
if app.config.code_onboarding_done {
|
||||||
|
app.start_code_drill();
|
||||||
|
} else {
|
||||||
|
app.go_to_code_intro();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 1.11: Add event handlers and renderers
|
||||||
|
|
||||||
|
**File**: `src/main.rs`
|
||||||
|
|
||||||
|
Add to screen dispatch in `handle_key()` and `render()`:
|
||||||
|
|
||||||
|
**`handle_code_intro_key()`**: Same field navigation as `handle_passage_intro_key()` but operates on `code_intro_*` fields. 4 fields:
|
||||||
|
1. Enable network downloads (toggle)
|
||||||
|
2. Download directory (editable text)
|
||||||
|
3. Snippets per repo (numeric, adjustable)
|
||||||
|
4. Start code drill (confirm button)
|
||||||
|
|
||||||
|
On confirm: save config fields, set `code_onboarding_done = true`, call `start_code_drill()`.
|
||||||
|
|
||||||
|
**`handle_code_download_progress_key()`**: Esc/q to cancel. On cancel:
|
||||||
|
1. Clear `code_download_queue`
|
||||||
|
2. Set `code_intro_downloading = false`
|
||||||
|
3. If a `code_download_job` is in-flight, detach it (set to `None` without joining -- the thread will finish and write to cache, which is harmless; the `Arc` atomics keep the thread safe)
|
||||||
|
4. Reset `code_drill_language_override` to `None`
|
||||||
|
5. Go to menu
|
||||||
|
|
||||||
|
This matches the existing passage download cancel behavior (passage also does not join/abort in-flight threads on Esc).
|
||||||
|
|
||||||
|
**`render_code_intro()`**: Mirror `render_passage_intro()` layout. Title: "Code Downloads Setup". Explanatory text: "Configure code source settings before your first code drill." / "Downloads are lazy: code is fetched only when first needed."
|
||||||
|
|
||||||
|
**`render_code_download_progress()`**: Mirror `render_passage_download_progress()`. Title: "Downloading Code Source". Show repo name, byte progress bar.
|
||||||
|
|
||||||
|
Update tick handler:
|
||||||
|
```rust
|
||||||
|
if (app.screen == AppScreen::CodeIntro
|
||||||
|
|| app.screen == AppScreen::CodeDownloadProgress)
|
||||||
|
&& app.code_intro_downloading
|
||||||
|
{
|
||||||
|
app.process_code_download_tick();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 1.12: Update generate_text for Code mode
|
||||||
|
|
||||||
|
**File**: `src/app.rs`
|
||||||
|
|
||||||
|
Update `DrillMode::Code` in `generate_text()`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
DrillMode::Code => {
|
||||||
|
let filter = CharFilter::new(('a'..='z').collect());
|
||||||
|
let lang = self.code_drill_language_override
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| self.config.code_language.clone());
|
||||||
|
let rng = SmallRng::from_rng(&mut self.rng).unwrap();
|
||||||
|
let mut generator = CodeSyntaxGenerator::new(
|
||||||
|
rng, &lang, &self.config.code_download_dir,
|
||||||
|
);
|
||||||
|
self.code_drill_language_override = None;
|
||||||
|
let text = generator.generate(&filter, None, word_count);
|
||||||
|
(text, Some(generator.last_source().to_string()))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 1.13: Settings integration
|
||||||
|
|
||||||
|
**Files**: `src/main.rs`, `src/app.rs`
|
||||||
|
|
||||||
|
Add settings rows after existing code language field (index 3):
|
||||||
|
- Index 4: Code Downloads: On/Off
|
||||||
|
- Index 5: Code Download Dir: editable path
|
||||||
|
- Index 6: Code Snippets per Repo: numeric
|
||||||
|
- Index 7: Download Code Now: action button
|
||||||
|
|
||||||
|
Shift existing passage settings indices up by 4. Update `settings_cycle_forward`/`settings_cycle_backward` and max `settings_selected` bound.
|
||||||
|
|
||||||
|
**"Download Code Now" behavior**: Downloads all uncached curated repos for the currently selected `code_language` only. If `code_language == "all"`, downloads all uncached repos for all curated languages. Does NOT include custom repos. Mirrors passage behavior where "Download Passages Now" downloads all uncached books.
|
||||||
|
|
||||||
|
**`start_code_downloads()`**: Queues all uncached repos for the currently selected language. Used by intro screen "confirm" flow when downloads are enabled.
|
||||||
|
|
||||||
|
### Phase 1 Verification
|
||||||
|
|
||||||
|
1. `cargo build` -- compiles
|
||||||
|
2. `cargo test` -- all existing tests pass, plus new tests:
|
||||||
|
- `test_languages_with_content_includes_builtin` -- verifies built-in languages appear in `languages_with_content()` even with empty cache dir
|
||||||
|
- `test_languages_with_content_excludes_uncached_network_only` -- verifies network-only languages without cache are not returned
|
||||||
|
- `test_config_serde_defaults` -- verifies new config fields deserialize with correct defaults from empty/old configs
|
||||||
|
- `test_raw_string_snippets_preserved` -- spot-check that raw string conversion didn't alter snippet content
|
||||||
|
3. `cargo build --no-default-features` -- compiles, network features gated
|
||||||
|
4. Manual tests:
|
||||||
|
- Menu → Code Drill → language select → first time shows CodeIntro
|
||||||
|
- CodeIntro with downloads off → confirms → starts drill with built-in snippets
|
||||||
|
- CodeIntro with downloads on → confirms → shows CodeDownloadProgress → downloads repo → starts drill with downloaded content
|
||||||
|
- Subsequent code drills skip onboarding
|
||||||
|
- "all" language mode only picks from languages with content (never triggers download)
|
||||||
|
- Settings shows code drill fields, values persist on restart
|
||||||
|
- Passage drill flow completely unchanged
|
||||||
|
- Esc during download progress → returns to menu, no crash
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Language Expansion and Extraction Improvements
|
||||||
|
|
||||||
|
Goal: Add 8 more built-in languages and ~18 network-only languages, improve snippet extraction.
|
||||||
|
|
||||||
|
### Step 2.1: Add 8 built-in language snippet sets
|
||||||
|
|
||||||
|
**File**: `src/generator/code_syntax.rs`
|
||||||
|
|
||||||
|
Add ~10-15 raw-string snippets each for: **typescript, java, c, cpp, ruby, swift, bash, lua**
|
||||||
|
|
||||||
|
Language keys: `typescript`/`ts`, `java`, `c`, `cpp`, `ruby`, `swift`, `bash` (display: "Shell/Bash"), `lua`
|
||||||
|
|
||||||
|
All with idiomatic whitespace:
|
||||||
|
- TypeScript: 4-space indent
|
||||||
|
- Java: 4-space indent
|
||||||
|
- C: 4-space indent
|
||||||
|
- C++: 4-space indent
|
||||||
|
- Ruby: 2-space indent
|
||||||
|
- Swift: 4-space indent
|
||||||
|
- Bash: 2-space indent (common convention)
|
||||||
|
- Lua: 2-space indent
|
||||||
|
|
||||||
|
Update `get_snippets()` match to include all 12 languages.
|
||||||
|
|
||||||
|
### Step 2.2: Expand language registry to ~30 languages
|
||||||
|
|
||||||
|
**File**: `src/generator/code_syntax.rs`
|
||||||
|
|
||||||
|
Add ~18 network-only entries to `CODE_LANGUAGES` with curated repos:
|
||||||
|
|
||||||
|
kotlin, scala, haskell, elixir, clojure, perl, php, r, dart, zig, nim, ocaml, erlang, julia, objective-c, groovy, csharp, fsharp
|
||||||
|
|
||||||
|
Each gets 2-3 repos with specific raw.githubusercontent.com file URLs. **Exclude SQL and CSS** -- their syntax is too different from procedural code for function-level extraction to work well.
|
||||||
|
|
||||||
|
This is a significant data curation subtask: for each language, identify 2-3 well-known repos with permissive licenses (MIT/Apache/BSD), select 2-5 representative source files per repo with functions/methods to extract.
|
||||||
|
|
||||||
|
**Acceptance threshold**: Each language must yield at least 10 extractable snippets from its curated repos (verified by running `extract_code_snippets` against fetched files). Languages that fall below this threshold should be dropped from the registry rather than shipped with poor content.
|
||||||
|
|
||||||
|
### Step 2.3: Improve snippet extraction
|
||||||
|
|
||||||
|
**File**: `src/generator/code_syntax.rs`
|
||||||
|
|
||||||
|
Add a `func_start_patterns` field to `CodeLanguage`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct CodeLanguage {
|
||||||
|
// ... existing fields ...
|
||||||
|
pub block_style: BlockStyle,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum BlockStyle {
|
||||||
|
Braces(&'static [&'static str]), // fn/def/func patterns, brace-delimited (C, Java, Go, etc.)
|
||||||
|
Indentation(&'static [&'static str]), // def/class patterns, indentation-delimited (Python)
|
||||||
|
EndDelimited(&'static [&'static str]), // def/class patterns, closed by `end` keyword (Ruby, Lua, Elixir)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `extract_code_snippets()` to accept `BlockStyle`:
|
||||||
|
- `Braces`: current behavior with configurable start patterns (C, Java, Go, JS, etc.)
|
||||||
|
- `Indentation`: track indent level changes to find block boundaries (Python only)
|
||||||
|
- `EndDelimited`: scan for matching `end` keyword at same indent level to close blocks (Ruby, Lua, Elixir)
|
||||||
|
|
||||||
|
Language-specific patterns:
|
||||||
|
- Java: `["public ", "private ", "protected ", "static ", "class ", "interface "]`
|
||||||
|
- Ruby: `["def ", "class ", "module "]` (EndDelimited style -- uses `end` keyword to close blocks)
|
||||||
|
- C/C++: `["int ", "void ", "char ", "float ", "double ", "struct ", "class ", "template"]`
|
||||||
|
- Swift: `["func ", "class ", "struct ", "enum ", "protocol "]`
|
||||||
|
- Bash: `["function ", "() {"]` (Braces style, simple)
|
||||||
|
- etc.
|
||||||
|
|
||||||
|
### Step 2.4: Make language select scrollable
|
||||||
|
|
||||||
|
**File**: `src/main.rs`
|
||||||
|
|
||||||
|
With 30+ languages, the selection screen needs scrolling. Add `code_language_scroll: usize` to `App`. Show a viewport of ~15 items. Add keybindings:
|
||||||
|
- Up/Down: navigate
|
||||||
|
- PageUp/PageDown: jump 10 items
|
||||||
|
- Home/End or `g`/`G`: jump to top/bottom
|
||||||
|
- `/`: type-to-filter (optional, nice-to-have)
|
||||||
|
|
||||||
|
Mark each language as "(built-in)" or "(download required)" in the list.
|
||||||
|
|
||||||
|
### Phase 2 Verification
|
||||||
|
|
||||||
|
1. `cargo build && cargo test`
|
||||||
|
2. Manual: verify all 12 built-in languages produce readable snippets with correct indentation
|
||||||
|
3. Manual: select a network-only language → triggers download → produces good snippets
|
||||||
|
4. Manual: scrollable language list works, indicators are accurate
|
||||||
|
5. Verify each built-in language's snippet whitespace is idiomatic
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Custom Repo Support
|
||||||
|
|
||||||
|
Goal: Let users specify their own GitHub repos to train on.
|
||||||
|
|
||||||
|
### Step 3.1: Design custom repo fetch strategy
|
||||||
|
|
||||||
|
Custom repos require solving problems that curated repos don't have:
|
||||||
|
- **Branch discovery**: Use GitHub API `GET /repos/{owner}/{repo}` to find `default_branch`. Requires `User-Agent` header (GitHub rejects requests without it; use `"keydr/{version}"`). Optionally support a `GITHUB_TOKEN` env var for authenticated requests (raises rate limit from 60 to 5000 req/hour).
|
||||||
|
- **File discovery**: Use GitHub API `GET /repos/{owner}/{repo}/git/trees/{branch}?recursive=1` to list all files, filter by language extensions. Same `User-Agent` and optional auth headers. If the response has `"truncated": true` (repos with >100k files), reject with a user-facing error: "Repository is too large for automatic file discovery. Please use a smaller repo or fork with fewer files."
|
||||||
|
- **Rate limiting**: Cache the tree response to disk. On 403/429 responses, show error: "GitHub API rate limit reached. Try again later or set GITHUB_TOKEN env var for higher limits."
|
||||||
|
- **File selection**: From matching files, randomly select 3-5 files to download via raw.githubusercontent.com (no API needed for file content)
|
||||||
|
- **Language detection**: Match file extensions against `CodeLanguage.extensions` field. If ambiguous or no match, prompt user.
|
||||||
|
- **All API requests**: Set `Accept: application/vnd.github.v3+json` header, timeout 10s.
|
||||||
|
|
||||||
|
### Step 3.2: Add config field and validation
|
||||||
|
|
||||||
|
**File**: `src/config.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[serde(default)]
|
||||||
|
pub code_custom_repos: Vec<String>, // Format: "owner/repo" or "owner/repo@language"
|
||||||
|
```
|
||||||
|
|
||||||
|
Parse function:
|
||||||
|
```rust
|
||||||
|
pub fn parse_custom_repo(input: &str) -> Option<CustomRepo> {
|
||||||
|
// Accepts: "owner/repo", "owner/repo@language", "https://github.com/owner/repo"
|
||||||
|
// Validates: owner and repo contain only valid GitHub chars
|
||||||
|
// Returns None on invalid input
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3.3: Settings UI for custom repos
|
||||||
|
|
||||||
|
Add a settings section showing current custom repos as a scrollable list. Keybindings:
|
||||||
|
- `a`: add new repo (enters text input mode)
|
||||||
|
- `d`/`x`: delete selected repo
|
||||||
|
- Up/Down: navigate list
|
||||||
|
|
||||||
|
### Step 3.4: Code language select "Add custom repo" option
|
||||||
|
|
||||||
|
At the bottom of the language select list, add an "[ + Add custom repo ]" option. Selecting it enters a text input mode for `owner/repo`. On confirm:
|
||||||
|
1. Validate format
|
||||||
|
2. Add to `code_custom_repos` config
|
||||||
|
3. Auto-detect language from repo (via API tree listing file extensions)
|
||||||
|
4. If language ambiguous, show a small picker
|
||||||
|
5. Queue download of that repo
|
||||||
|
|
||||||
|
### Step 3.5: Integrate custom repos into download flow
|
||||||
|
|
||||||
|
When `start_code_drill()` runs for a language, include matching custom repos in the download candidates alongside curated repos.
|
||||||
|
|
||||||
|
### Phase 3 Verification
|
||||||
|
|
||||||
|
1. Add a custom repo → appears in settings list
|
||||||
|
2. Start drill → custom repo snippets appear
|
||||||
|
3. Invalid repo format → shows error, doesn't save
|
||||||
|
4. GitHub rate limit → shows informative error
|
||||||
|
5. Remove custom repo → removed from config and future drills
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical Files Summary
|
||||||
|
|
||||||
|
| File | Phase | Changes |
|
||||||
|
|------|-------|---------|
|
||||||
|
| `src/generator/github_code.rs` | 1 | Delete |
|
||||||
|
| `src/generator/mod.rs` | 1 | Remove github_code module |
|
||||||
|
| `src/generator/code_syntax.rs` | 1, 2 | Raw strings, new constructor, remove blocking fetch, language registry, download fn, new snippet sets, improved extraction |
|
||||||
|
| `src/config.rs` | 1, 3 | New code drill config fields, validation |
|
||||||
|
| `src/app.rs` | 1 | DownloadJob rename, new screens/state/flow methods, CodeDownloadCompleteAction |
|
||||||
|
| `src/main.rs` | 1, 2 | New handlers/renderers, updated settings, scrollable language list |
|
||||||
|
| `src/generator/cache.rs` | 1 | No changes (reuse existing `fetch_url_bytes_with_progress`) |
|
||||||
|
|
||||||
|
## Existing Code to Reuse
|
||||||
|
|
||||||
|
- `generator::cache::fetch_url_bytes_with_progress` -- already handles progress callbacks, used for passage downloads
|
||||||
|
- `generator::cache::DiskCache` -- NOT reused for code cache (no listing API); use direct `fs::read_dir` + `fs::read_to_string` instead
|
||||||
|
- `PassageDownloadJob` pattern (atomics + thread) -- generalized into `DownloadJob`
|
||||||
|
- `passage::extract_paragraphs` pattern -- referenced for extraction design but not directly reused
|
||||||
|
- `passage::download_book_to_cache_with_progress` -- structural template for `download_code_repo_to_cache_with_progress`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2.5: Improve Snippet Extraction Quality
|
||||||
|
|
||||||
|
### Context
|
||||||
|
|
||||||
|
After Phase 2, the verification test (`test_verify_repo_urls`) shows many languages producing far fewer than 100 snippets. Root causes:
|
||||||
|
1. **Per-file cap of 50** in `extract_code_snippets()` (line 1869) limits output even from large source files
|
||||||
|
2. **Keyword-only matching** — extraction only starts when a line begins with a recognized keyword (e.g. `fn `, `def `, `class `). Many valid code blocks (anonymous functions, method chains, match arms, closures, etc.) are missed.
|
||||||
|
3. **Narrow keyword lists** — some languages are missing patterns for common constructs (e.g. `macro_rules!` in Rust, `@interface` in Objective-C)
|
||||||
|
4. **`code_snippets_per_repo` default of 50** caps total output per download
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
|
||||||
|
Get every language to produce 100+ snippets from its curated repos, without sacrificing snippet quality. Do this by:
|
||||||
|
1. Widening keyword patterns to capture more language constructs
|
||||||
|
2. Adding a structural fallback that extracts well-formed code blocks by structure when keywords alone don't find enough
|
||||||
|
3. Raising the per-file and per-repo snippet caps
|
||||||
|
|
||||||
|
### Step 2.5.1: Raise snippet caps
|
||||||
|
|
||||||
|
**File**: `src/generator/code_syntax.rs`
|
||||||
|
|
||||||
|
Change `snippets.truncate(50)` → `snippets.truncate(200)` in `extract_code_snippets()`.
|
||||||
|
|
||||||
|
**File**: `src/config.rs`
|
||||||
|
|
||||||
|
Change `default_code_snippets_per_repo()` → `200`.
|
||||||
|
|
||||||
|
### Step 2.5.2: Widen keyword patterns
|
||||||
|
|
||||||
|
**File**: `src/generator/code_syntax.rs`
|
||||||
|
|
||||||
|
Add missing start patterns to existing languages. These are patterns that should have been there from the start — they represent common, well-defined constructs that produce good typing drill snippets:
|
||||||
|
|
||||||
|
| Language | Add patterns |
|
||||||
|
|----------|-------------|
|
||||||
|
| Rust | `"macro_rules! "`, `"mod "`, `"const "`, `"static "`, `"type "` |
|
||||||
|
| Python | `"async def "` is already there. Add `"@"` (decorators start blocks) |
|
||||||
|
| JavaScript | `"class "`, `"const "`, `"let "`, `"export "` |
|
||||||
|
| Go | No changes needed (already has `"func "`, `"type "`) |
|
||||||
|
| TypeScript | `"class "`, `"const "`, `"let "`, `"export "`, `"interface "` |
|
||||||
|
| Java | `"abstract "`, `"final "`, `"@"` (annotations start blocks) |
|
||||||
|
| C | `"typedef "`, `"#define "`, `"enum "` |
|
||||||
|
| C++ | `"namespace "`, `"typedef "`, `"#define "`, `"enum "`, `"constexpr "`, `"auto "` |
|
||||||
|
| Ruby | Add `"attr_"`, `"scope "`, `"describe "`, `"it "` |
|
||||||
|
| Swift | `"var "`, `"let "`, `"init("`, `"deinit "`, `"extension "`, `"typealias "` |
|
||||||
|
| Bash | `"if "`, `"for "`, `"while "`, `"case "` |
|
||||||
|
| Kotlin | `"override fun "` already there. Add `"val "`, `"var "`, `"enum "`, `"annotation "`, `"typealias "` |
|
||||||
|
| Scala | `"val "`, `"var "`, `"type "`, `"implicit "`, `"given "`, `"extension "` |
|
||||||
|
| PHP | `"class "`, `"interface "`, `"trait "`, `"enum "` |
|
||||||
|
| Dart | Add `"Widget "`, `"get "`, `"set "`, `"enum "`, `"typedef "`, `"extension "` |
|
||||||
|
| Elixir | `"defmacro "`, `"defstruct"`, `"defprotocol "`, `"defimpl "` |
|
||||||
|
| Zig | `"test "`, `"var "` |
|
||||||
|
| Haskell | Already broad. No changes. |
|
||||||
|
| Objective-C | `"@interface "`, `"@implementation "`, `"@protocol "`, `"typedef "` |
|
||||||
|
| Others | Review on a case-by-case basis during implementation |
|
||||||
|
|
||||||
|
### Step 2.5.3: Add structural fallback extraction
|
||||||
|
|
||||||
|
**File**: `src/generator/code_syntax.rs`
|
||||||
|
|
||||||
|
When keyword-based extraction yields fewer than 20 snippets from a file, run a second pass that extracts code blocks purely by structure. This captures anonymous functions, nested blocks, and other constructs that don't start with recognized keywords.
|
||||||
|
|
||||||
|
#### Design
|
||||||
|
|
||||||
|
Add a `structural_fallback: bool` field to each `BlockStyle` variant:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum BlockStyle {
|
||||||
|
Braces {
|
||||||
|
patterns: &'static [&'static str],
|
||||||
|
structural_fallback: bool,
|
||||||
|
},
|
||||||
|
Indentation {
|
||||||
|
patterns: &'static [&'static str],
|
||||||
|
structural_fallback: bool,
|
||||||
|
},
|
||||||
|
EndDelimited {
|
||||||
|
patterns: &'static [&'static str],
|
||||||
|
structural_fallback: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `structural_fallback: true` for all languages. This can be disabled per-language if it produces poor results.
|
||||||
|
|
||||||
|
Update `extract_code_snippets()`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn extract_code_snippets(source: &str, block_style: &BlockStyle) -> Vec<String> {
|
||||||
|
let mut snippets = keyword_extract(source, block_style);
|
||||||
|
|
||||||
|
if snippets.len() < 20 && has_structural_fallback(block_style) {
|
||||||
|
let structural = structural_extract(source, block_style);
|
||||||
|
// Add structural snippets that don't overlap with keyword ones
|
||||||
|
for s in structural {
|
||||||
|
if !snippets.contains(&s) {
|
||||||
|
snippets.push(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
snippets.truncate(200);
|
||||||
|
snippets
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Structural extraction for Braces languages
|
||||||
|
|
||||||
|
`structural_extract_braces(source)`:
|
||||||
|
1. Scan for lines containing `{` where brace depth transitions from 0→1 or 1→2
|
||||||
|
2. Capture from that line until depth returns to its starting level
|
||||||
|
3. Apply the same quality filters: 3-30 lines, 20+ non-whitespace chars, ≤800 bytes
|
||||||
|
4. Skip noise blocks: reject snippets where first non-blank line is only `{`, or where the block is just imports/use statements
|
||||||
|
|
||||||
|
#### Structural extraction for Indentation languages
|
||||||
|
|
||||||
|
`structural_extract_indent(source)`:
|
||||||
|
1. Scan for non-blank lines at indentation level 0 (top-level) that are followed by indented lines
|
||||||
|
2. Capture the top-level line + all subsequent lines with greater indentation
|
||||||
|
3. Apply same quality filters
|
||||||
|
4. Skip noise: reject if all body lines are `import`/`from`/`use`/`#include` statements
|
||||||
|
|
||||||
|
#### Structural extraction for EndDelimited languages
|
||||||
|
|
||||||
|
`structural_extract_end(source)`:
|
||||||
|
1. Scan for lines at top-level indentation followed by indented body ending with `end`
|
||||||
|
2. Same quality filters and noise rejection
|
||||||
|
|
||||||
|
#### Noise filtering
|
||||||
|
|
||||||
|
A snippet is "noise" and should be rejected if:
|
||||||
|
- First meaningful line (after stripping comments) is just `{` or `}`
|
||||||
|
- Body consists entirely of `import`, `use`, `from`, `require`, `include`, or blank lines
|
||||||
|
- It's a single-statement block (only 1 non-blank body line after the opening)
|
||||||
|
|
||||||
|
### Step 2.5.4: Add more source URLs for low-count languages
|
||||||
|
|
||||||
|
After implementing the extraction improvements, re-run `test_verify_repo_urls` to identify languages still under 100 snippets. For those, add 1-2 more source file URLs from the same or new repos to increase raw material.
|
||||||
|
|
||||||
|
This step is intentionally deferred until after extraction improvements, since better extraction may push many languages over the 100 threshold without needing more URLs.
|
||||||
|
|
||||||
|
### Phase 2.5 Verification
|
||||||
|
|
||||||
|
1. `cargo test` — all existing tests pass
|
||||||
|
2. Run `cargo test test_verify_repo_urls -- --ignored --nocapture` — verify all 30 languages produce 50+ snippets (ideally 100+)
|
||||||
|
3. Spot-check structural fallback snippets for 3-4 languages — verify they contain real code, not just import blocks or noise
|
||||||
|
4. `cargo build --no-default-features` — compiles without network features
|
||||||
|
5. Verify no change to built-in snippet behavior (built-in snippets don't go through extraction)
|
||||||
1185
src/app.rs
1185
src/app.rs
File diff suppressed because it is too large
Load Diff
171
src/config.rs
171
src/config.rs
@@ -12,27 +12,82 @@ pub struct Config {
|
|||||||
pub theme: String,
|
pub theme: String,
|
||||||
#[serde(default = "default_keyboard_layout")]
|
#[serde(default = "default_keyboard_layout")]
|
||||||
pub keyboard_layout: String,
|
pub keyboard_layout: String,
|
||||||
#[serde(default = "default_code_languages")]
|
|
||||||
pub code_languages: Vec<String>,
|
|
||||||
#[serde(default = "default_word_count")]
|
#[serde(default = "default_word_count")]
|
||||||
pub word_count: usize,
|
pub word_count: usize,
|
||||||
|
#[serde(default = "default_code_language")]
|
||||||
|
pub code_language: String,
|
||||||
|
#[serde(default = "default_passage_book")]
|
||||||
|
pub passage_book: String,
|
||||||
|
#[serde(default = "default_passage_downloads_enabled")]
|
||||||
|
pub passage_downloads_enabled: bool,
|
||||||
|
#[serde(default = "default_passage_download_dir")]
|
||||||
|
pub passage_download_dir: String,
|
||||||
|
#[serde(default = "default_passage_paragraphs_per_book")]
|
||||||
|
pub passage_paragraphs_per_book: usize,
|
||||||
|
#[serde(default = "default_passage_onboarding_done")]
|
||||||
|
pub passage_onboarding_done: bool,
|
||||||
|
#[serde(default = "default_code_downloads_enabled")]
|
||||||
|
pub code_downloads_enabled: bool,
|
||||||
|
#[serde(default = "default_code_download_dir")]
|
||||||
|
pub code_download_dir: String,
|
||||||
|
#[serde(default = "default_code_snippets_per_repo")]
|
||||||
|
pub code_snippets_per_repo: usize,
|
||||||
|
#[serde(default = "default_code_onboarding_done")]
|
||||||
|
pub code_onboarding_done: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_target_wpm() -> u32 {
|
fn default_target_wpm() -> u32 {
|
||||||
35
|
35
|
||||||
}
|
}
|
||||||
fn default_theme() -> String {
|
fn default_theme() -> String {
|
||||||
"catppuccin-mocha".to_string()
|
"terminal-default".to_string()
|
||||||
}
|
}
|
||||||
fn default_keyboard_layout() -> String {
|
fn default_keyboard_layout() -> String {
|
||||||
"qwerty".to_string()
|
"qwerty".to_string()
|
||||||
}
|
}
|
||||||
fn default_code_languages() -> Vec<String> {
|
|
||||||
vec!["rust".to_string()]
|
|
||||||
}
|
|
||||||
fn default_word_count() -> usize {
|
fn default_word_count() -> usize {
|
||||||
20
|
20
|
||||||
}
|
}
|
||||||
|
fn default_code_language() -> String {
|
||||||
|
"rust".to_string()
|
||||||
|
}
|
||||||
|
fn default_passage_book() -> String {
|
||||||
|
"all".to_string()
|
||||||
|
}
|
||||||
|
fn default_passage_downloads_enabled() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
fn default_passage_download_dir() -> String {
|
||||||
|
dirs::data_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join("keydr")
|
||||||
|
.join("passages")
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
fn default_passage_paragraphs_per_book() -> usize {
|
||||||
|
100
|
||||||
|
}
|
||||||
|
fn default_passage_onboarding_done() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
fn default_code_downloads_enabled() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
fn default_code_download_dir() -> String {
|
||||||
|
dirs::data_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join("keydr")
|
||||||
|
.join("code")
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
fn default_code_snippets_per_repo() -> usize {
|
||||||
|
200
|
||||||
|
}
|
||||||
|
fn default_code_onboarding_done() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for Config {
|
impl Default for Config {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
@@ -40,8 +95,17 @@ impl Default for Config {
|
|||||||
target_wpm: default_target_wpm(),
|
target_wpm: default_target_wpm(),
|
||||||
theme: default_theme(),
|
theme: default_theme(),
|
||||||
keyboard_layout: default_keyboard_layout(),
|
keyboard_layout: default_keyboard_layout(),
|
||||||
code_languages: default_code_languages(),
|
|
||||||
word_count: default_word_count(),
|
word_count: default_word_count(),
|
||||||
|
code_language: default_code_language(),
|
||||||
|
passage_book: default_passage_book(),
|
||||||
|
passage_downloads_enabled: default_passage_downloads_enabled(),
|
||||||
|
passage_download_dir: default_passage_download_dir(),
|
||||||
|
passage_paragraphs_per_book: default_passage_paragraphs_per_book(),
|
||||||
|
passage_onboarding_done: default_passage_onboarding_done(),
|
||||||
|
code_downloads_enabled: default_code_downloads_enabled(),
|
||||||
|
code_download_dir: default_code_download_dir(),
|
||||||
|
code_snippets_per_repo: default_code_snippets_per_repo(),
|
||||||
|
code_onboarding_done: default_code_onboarding_done(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,4 +143,97 @@ impl Config {
|
|||||||
pub fn target_cpm(&self) -> f64 {
|
pub fn target_cpm(&self) -> f64 {
|
||||||
self.target_wpm as f64 * 5.0
|
self.target_wpm as f64 * 5.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Validate `code_language` against known options, resetting to default if invalid.
|
||||||
|
/// Call after deserialization to handle stale/renamed keys from old configs.
|
||||||
|
pub fn normalize_code_language(&mut self, valid_keys: &[&str]) {
|
||||||
|
// Backwards compatibility: old "shell" key is now "bash".
|
||||||
|
if self.code_language == "shell" {
|
||||||
|
self.code_language = "bash".to_string();
|
||||||
|
}
|
||||||
|
if !valid_keys.contains(&self.code_language.as_str()) {
|
||||||
|
self.code_language = default_code_language();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_serde_defaults_from_empty() {
|
||||||
|
// Simulates loading an old config file with no code drill fields
|
||||||
|
let config: Config = toml::from_str("").unwrap();
|
||||||
|
assert_eq!(config.code_downloads_enabled, false);
|
||||||
|
assert_eq!(config.code_snippets_per_repo, 200);
|
||||||
|
assert_eq!(config.code_onboarding_done, false);
|
||||||
|
assert!(!config.code_download_dir.is_empty());
|
||||||
|
assert!(config.code_download_dir.contains("code"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_serde_defaults_from_old_fields_only() {
|
||||||
|
// Simulates a config file that only has pre-existing fields
|
||||||
|
let toml_str = r#"
|
||||||
|
target_wpm = 60
|
||||||
|
theme = "monokai"
|
||||||
|
code_language = "go"
|
||||||
|
"#;
|
||||||
|
let config: Config = toml::from_str(toml_str).unwrap();
|
||||||
|
assert_eq!(config.target_wpm, 60);
|
||||||
|
assert_eq!(config.theme, "monokai");
|
||||||
|
assert_eq!(config.code_language, "go");
|
||||||
|
// New fields should have defaults
|
||||||
|
assert_eq!(config.code_downloads_enabled, false);
|
||||||
|
assert_eq!(config.code_snippets_per_repo, 200);
|
||||||
|
assert_eq!(config.code_onboarding_done, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_serde_roundtrip() {
|
||||||
|
let config = Config::default();
|
||||||
|
let serialized = toml::to_string_pretty(&config).unwrap();
|
||||||
|
let deserialized: Config = toml::from_str(&serialized).unwrap();
|
||||||
|
assert_eq!(config.code_downloads_enabled, deserialized.code_downloads_enabled);
|
||||||
|
assert_eq!(config.code_download_dir, deserialized.code_download_dir);
|
||||||
|
assert_eq!(config.code_snippets_per_repo, deserialized.code_snippets_per_repo);
|
||||||
|
assert_eq!(config.code_onboarding_done, deserialized.code_onboarding_done);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_code_language_valid_key_unchanged() {
|
||||||
|
let mut config = Config::default();
|
||||||
|
config.code_language = "python".to_string();
|
||||||
|
let valid_keys = vec!["rust", "python", "javascript", "go", "all"];
|
||||||
|
config.normalize_code_language(&valid_keys);
|
||||||
|
assert_eq!(config.code_language, "python");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_code_language_invalid_key_resets() {
|
||||||
|
let mut config = Config::default();
|
||||||
|
config.code_language = "haskell".to_string();
|
||||||
|
let valid_keys = vec!["rust", "python", "javascript", "go", "all"];
|
||||||
|
config.normalize_code_language(&valid_keys);
|
||||||
|
assert_eq!(config.code_language, "rust");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_code_language_empty_string_resets() {
|
||||||
|
let mut config = Config::default();
|
||||||
|
config.code_language = String::new();
|
||||||
|
let valid_keys = vec!["rust", "python", "javascript", "go", "all"];
|
||||||
|
config.normalize_code_language(&valid_keys);
|
||||||
|
assert_eq!(config.code_language, "rust");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_code_language_shell_maps_to_bash() {
|
||||||
|
let mut config = Config::default();
|
||||||
|
config.code_language = "shell".to_string();
|
||||||
|
let valid_keys = vec!["rust", "python", "javascript", "go", "bash", "all"];
|
||||||
|
config.normalize_code_language(&valid_keys);
|
||||||
|
assert_eq!(config.code_language, "bash");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ impl CharFilter {
|
|||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn filter_text(&self, text: &str) -> String {
|
pub fn filter_text(&self, text: &str) -> String {
|
||||||
text.chars()
|
text.chars().filter(|&ch| self.is_allowed(ch)).collect()
|
||||||
.filter(|&ch| self.is_allowed(ch))
|
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,8 +48,7 @@ impl KeyStatsStore {
|
|||||||
if stat.sample_count == 1 {
|
if stat.sample_count == 1 {
|
||||||
stat.filtered_time_ms = time_ms;
|
stat.filtered_time_ms = time_ms;
|
||||||
} else {
|
} else {
|
||||||
stat.filtered_time_ms =
|
stat.filtered_time_ms = EMA_ALPHA * time_ms + (1.0 - EMA_ALPHA) * stat.filtered_time_ms;
|
||||||
EMA_ALPHA * time_ms + (1.0 - EMA_ALPHA) * stat.filtered_time_ms;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stat.best_time_ms = stat.best_time_ms.min(stat.filtered_time_ms);
|
stat.best_time_ms = stat.best_time_ms.min(stat.filtered_time_ms);
|
||||||
@@ -64,10 +63,7 @@ impl KeyStatsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_confidence(&self, key: char) -> f64 {
|
pub fn get_confidence(&self, key: char) -> f64 {
|
||||||
self.stats
|
self.stats.get(&key).map(|s| s.confidence).unwrap_or(0.0)
|
||||||
.get(&key)
|
|
||||||
.map(|s| s.confidence)
|
|
||||||
.unwrap_or(0.0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
@@ -104,7 +100,10 @@ mod tests {
|
|||||||
let conf = store.get_confidence('t');
|
let conf = store.get_confidence('t');
|
||||||
// At 175 CPM target, target_time = 60000/175 = 342.8ms
|
// At 175 CPM target, target_time = 60000/175 = 342.8ms
|
||||||
// With 200ms typing time, confidence = 342.8/200 = 1.71
|
// With 200ms typing time, confidence = 342.8/200 = 1.71
|
||||||
assert!(conf > 1.0, "confidence should be > 1.0 for fast typing, got {conf}");
|
assert!(
|
||||||
|
conf > 1.0,
|
||||||
|
"confidence should be > 1.0 for fast typing, got {conf}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -115,6 +114,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
let conf = store.get_confidence('a');
|
let conf = store.get_confidence('a');
|
||||||
// target_time = 342.8ms, typing at 1000ms -> conf = 342.8/1000 = 0.34
|
// target_time = 342.8ms, typing at 1000ms -> conf = 342.8/1000 = 0.34
|
||||||
assert!(conf < 1.0, "confidence should be < 1.0 for slow typing, got {conf}");
|
assert!(
|
||||||
|
conf < 1.0,
|
||||||
|
"confidence should be < 1.0 for slow typing, got {conf}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,151 +0,0 @@
|
|||||||
use crate::engine::key_stats::KeyStatsStore;
|
|
||||||
|
|
||||||
pub const FREQUENCY_ORDER: &[char] = &[
|
|
||||||
'e', 't', 'a', 'o', 'i', 'n', 's', 'h', 'r', 'd', 'l', 'c', 'u', 'm', 'w', 'f', 'g', 'y',
|
|
||||||
'p', 'b', 'v', 'k', 'j', 'x', 'q', 'z',
|
|
||||||
];
|
|
||||||
|
|
||||||
const MIN_LETTERS: usize = 6;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct LetterUnlock {
|
|
||||||
pub included: Vec<char>,
|
|
||||||
pub focused: Option<char>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LetterUnlock {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
let included = FREQUENCY_ORDER[..MIN_LETTERS].to_vec();
|
|
||||||
Self {
|
|
||||||
included,
|
|
||||||
focused: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_included(included: Vec<char>) -> Self {
|
|
||||||
let mut lu = Self {
|
|
||||||
included,
|
|
||||||
focused: None,
|
|
||||||
};
|
|
||||||
lu.focused = None;
|
|
||||||
lu
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update(&mut self, stats: &KeyStatsStore) {
|
|
||||||
let all_confident = self
|
|
||||||
.included
|
|
||||||
.iter()
|
|
||||||
.all(|&ch| stats.get_confidence(ch) >= 1.0);
|
|
||||||
|
|
||||||
if all_confident {
|
|
||||||
for &letter in FREQUENCY_ORDER {
|
|
||||||
if !self.included.contains(&letter) {
|
|
||||||
self.included.push(letter);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
while self.included.len() < MIN_LETTERS {
|
|
||||||
for &letter in FREQUENCY_ORDER {
|
|
||||||
if !self.included.contains(&letter) {
|
|
||||||
self.included.push(letter);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.focused = self
|
|
||||||
.included
|
|
||||||
.iter()
|
|
||||||
.filter(|&&ch| stats.get_confidence(ch) < 1.0)
|
|
||||||
.min_by(|&&a, &&b| {
|
|
||||||
stats
|
|
||||||
.get_confidence(a)
|
|
||||||
.partial_cmp(&stats.get_confidence(b))
|
|
||||||
.unwrap_or(std::cmp::Ordering::Equal)
|
|
||||||
})
|
|
||||||
.copied();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn is_unlocked(&self, ch: char) -> bool {
|
|
||||||
self.included.contains(&ch)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn unlocked_count(&self) -> usize {
|
|
||||||
self.included.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn total_letters(&self) -> usize {
|
|
||||||
FREQUENCY_ORDER.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn progress(&self) -> f64 {
|
|
||||||
self.unlocked_count() as f64 / self.total_letters() as f64
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for LetterUnlock {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::engine::key_stats::KeyStatsStore;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_initial_unlock_has_min_letters() {
|
|
||||||
let lu = LetterUnlock::new();
|
|
||||||
assert_eq!(lu.unlocked_count(), 6);
|
|
||||||
assert_eq!(&lu.included, &['e', 't', 'a', 'o', 'i', 'n']);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_no_unlock_without_confidence() {
|
|
||||||
let mut lu = LetterUnlock::new();
|
|
||||||
let stats = KeyStatsStore::default();
|
|
||||||
lu.update(&stats);
|
|
||||||
assert_eq!(lu.unlocked_count(), 6);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_unlock_when_all_confident() {
|
|
||||||
let mut lu = LetterUnlock::new();
|
|
||||||
let mut stats = KeyStatsStore::default();
|
|
||||||
// Make all included keys confident by typing fast
|
|
||||||
for &ch in &['e', 't', 'a', 'o', 'i', 'n'] {
|
|
||||||
for _ in 0..50 {
|
|
||||||
stats.update_key(ch, 200.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lu.update(&stats);
|
|
||||||
assert_eq!(lu.unlocked_count(), 7);
|
|
||||||
assert!(lu.included.contains(&'s'));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_focused_key_is_weakest() {
|
|
||||||
let mut lu = LetterUnlock::new();
|
|
||||||
let mut stats = KeyStatsStore::default();
|
|
||||||
// Make most keys confident except 'o'
|
|
||||||
for &ch in &['e', 't', 'a', 'i', 'n'] {
|
|
||||||
for _ in 0..50 {
|
|
||||||
stats.update_key(ch, 200.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stats.update_key('o', 1000.0); // slow on 'o'
|
|
||||||
lu.update(&stats);
|
|
||||||
assert_eq!(lu.focused, Some('o'));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_progress_ratio() {
|
|
||||||
let lu = LetterUnlock::new();
|
|
||||||
let expected = 6.0 / 26.0;
|
|
||||||
assert!((lu.progress() - expected).abs() < 0.001);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
pub mod filter;
|
pub mod filter;
|
||||||
pub mod key_stats;
|
pub mod key_stats;
|
||||||
pub mod learning_rate;
|
pub mod learning_rate;
|
||||||
pub mod letter_unlock;
|
|
||||||
pub mod scoring;
|
pub mod scoring;
|
||||||
|
pub mod skill_tree;
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
use crate::session::result::LessonResult;
|
use crate::session::result::DrillResult;
|
||||||
|
|
||||||
pub fn compute_score(result: &LessonResult, complexity: f64) -> f64 {
|
pub fn compute_score(result: &DrillResult, complexity: f64) -> f64 {
|
||||||
let speed = result.cpm;
|
let speed = result.cpm;
|
||||||
let errors = result.incorrect as f64;
|
let errors = result.incorrect as f64;
|
||||||
let length = result.total_chars as f64;
|
let length = result.total_chars as f64;
|
||||||
(speed * complexity) / (errors + 1.0) * (length / 50.0)
|
(speed * complexity) / (errors + 1.0) * (length / 50.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn compute_complexity(unlocked_count: usize) -> f64 {
|
#[allow(dead_code)]
|
||||||
(unlocked_count as f64 / 26.0).max(0.1)
|
pub fn compute_complexity(unlocked_count: usize, total_keys: usize) -> f64 {
|
||||||
|
(unlocked_count as f64 / total_keys as f64).max(0.1)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn level_from_score(total_score: f64) -> u32 {
|
pub fn level_from_score(total_score: f64) -> u32 {
|
||||||
@@ -38,8 +39,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_complexity_scales_with_letters() {
|
fn test_complexity_scales_with_keys() {
|
||||||
assert!(compute_complexity(26) > compute_complexity(6));
|
assert!(compute_complexity(96, 96) > compute_complexity(6, 96));
|
||||||
assert!((compute_complexity(26) - 1.0).abs() < 0.001);
|
assert!((compute_complexity(96, 96) - 1.0).abs() < 0.001);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
971
src/engine/skill_tree.rs
Normal file
971
src/engine/skill_tree.rs
Normal file
@@ -0,0 +1,971 @@
|
|||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::engine::key_stats::KeyStatsStore;
|
||||||
|
|
||||||
|
// --- Branch ID ---
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||||
|
pub enum BranchId {
|
||||||
|
Lowercase,
|
||||||
|
Capitals,
|
||||||
|
Numbers,
|
||||||
|
ProsePunctuation,
|
||||||
|
Whitespace,
|
||||||
|
CodeSymbols,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BranchId {
|
||||||
|
pub fn to_key(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
BranchId::Lowercase => "lowercase",
|
||||||
|
BranchId::Capitals => "capitals",
|
||||||
|
BranchId::Numbers => "numbers",
|
||||||
|
BranchId::ProsePunctuation => "prose_punctuation",
|
||||||
|
BranchId::Whitespace => "whitespace",
|
||||||
|
BranchId::CodeSymbols => "code_symbols",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn from_key(key: &str) -> Option<Self> {
|
||||||
|
match key {
|
||||||
|
"lowercase" => Some(BranchId::Lowercase),
|
||||||
|
"capitals" => Some(BranchId::Capitals),
|
||||||
|
"numbers" => Some(BranchId::Numbers),
|
||||||
|
"prose_punctuation" => Some(BranchId::ProsePunctuation),
|
||||||
|
"whitespace" => Some(BranchId::Whitespace),
|
||||||
|
"code_symbols" => Some(BranchId::CodeSymbols),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn all() -> &'static [BranchId] {
|
||||||
|
&[
|
||||||
|
BranchId::Lowercase,
|
||||||
|
BranchId::Capitals,
|
||||||
|
BranchId::Numbers,
|
||||||
|
BranchId::ProsePunctuation,
|
||||||
|
BranchId::Whitespace,
|
||||||
|
BranchId::CodeSymbols,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Branch Status ---
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum BranchStatus {
|
||||||
|
Locked,
|
||||||
|
Available,
|
||||||
|
InProgress,
|
||||||
|
Complete,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Static Definitions ---
|
||||||
|
|
||||||
|
pub struct LevelDefinition {
|
||||||
|
pub name: &'static str,
|
||||||
|
pub keys: &'static [char],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct BranchDefinition {
|
||||||
|
pub id: BranchId,
|
||||||
|
pub name: &'static str,
|
||||||
|
pub levels: &'static [LevelDefinition],
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOWERCASE_LEVELS: &[LevelDefinition] = &[LevelDefinition {
|
||||||
|
name: "Frequency Order",
|
||||||
|
keys: &[
|
||||||
|
'e', 't', 'a', 'o', 'i', 'n', 's', 'h', 'r', 'd', 'l', 'c', 'u', 'm', 'w', 'f', 'g', 'y',
|
||||||
|
'p', 'b', 'v', 'k', 'j', 'x', 'q', 'z',
|
||||||
|
],
|
||||||
|
}];
|
||||||
|
|
||||||
|
const CAPITALS_LEVELS: &[LevelDefinition] = &[
|
||||||
|
LevelDefinition {
|
||||||
|
name: "Common Sentence Capitals",
|
||||||
|
keys: &['T', 'I', 'A', 'S', 'W', 'H', 'B', 'M'],
|
||||||
|
},
|
||||||
|
LevelDefinition {
|
||||||
|
name: "Name Capitals",
|
||||||
|
keys: &['J', 'D', 'R', 'C', 'E', 'N', 'P', 'L', 'F', 'G'],
|
||||||
|
},
|
||||||
|
LevelDefinition {
|
||||||
|
name: "Remaining Capitals",
|
||||||
|
keys: &['O', 'U', 'K', 'V', 'Y', 'X', 'Q', 'Z'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const NUMBERS_LEVELS: &[LevelDefinition] = &[
|
||||||
|
LevelDefinition {
|
||||||
|
name: "Common Digits",
|
||||||
|
keys: &['1', '2', '3', '4', '5'],
|
||||||
|
},
|
||||||
|
LevelDefinition {
|
||||||
|
name: "All Digits",
|
||||||
|
keys: &['0', '6', '7', '8', '9'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const PROSE_PUNCTUATION_LEVELS: &[LevelDefinition] = &[
|
||||||
|
LevelDefinition {
|
||||||
|
name: "Essential",
|
||||||
|
keys: &['.', ',', '\''],
|
||||||
|
},
|
||||||
|
LevelDefinition {
|
||||||
|
name: "Common",
|
||||||
|
keys: &[';', ':', '"', '-'],
|
||||||
|
},
|
||||||
|
LevelDefinition {
|
||||||
|
name: "Expressive",
|
||||||
|
keys: &['?', '!', '(', ')'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const WHITESPACE_LEVELS: &[LevelDefinition] = &[
|
||||||
|
LevelDefinition {
|
||||||
|
name: "Enter/Return",
|
||||||
|
keys: &['\n'],
|
||||||
|
},
|
||||||
|
LevelDefinition {
|
||||||
|
name: "Tab/Indent",
|
||||||
|
keys: &['\t'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const CODE_SYMBOLS_LEVELS: &[LevelDefinition] = &[
|
||||||
|
LevelDefinition {
|
||||||
|
name: "Arithmetic & Assignment",
|
||||||
|
keys: &['=', '+', '*', '/', '-'],
|
||||||
|
},
|
||||||
|
LevelDefinition {
|
||||||
|
name: "Grouping",
|
||||||
|
keys: &['{', '}', '[', ']', '<', '>'],
|
||||||
|
},
|
||||||
|
LevelDefinition {
|
||||||
|
name: "Logic & Reference",
|
||||||
|
keys: &['&', '|', '^', '~', '!'],
|
||||||
|
},
|
||||||
|
LevelDefinition {
|
||||||
|
name: "Special",
|
||||||
|
keys: &['@', '#', '$', '%', '_', '\\', '`'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
pub const ALL_BRANCHES: &[BranchDefinition] = &[
|
||||||
|
BranchDefinition {
|
||||||
|
id: BranchId::Lowercase,
|
||||||
|
name: "Lowercase a-z",
|
||||||
|
levels: LOWERCASE_LEVELS,
|
||||||
|
},
|
||||||
|
BranchDefinition {
|
||||||
|
id: BranchId::Capitals,
|
||||||
|
name: "Capitals A-Z",
|
||||||
|
levels: CAPITALS_LEVELS,
|
||||||
|
},
|
||||||
|
BranchDefinition {
|
||||||
|
id: BranchId::Numbers,
|
||||||
|
name: "Numbers 0-9",
|
||||||
|
levels: NUMBERS_LEVELS,
|
||||||
|
},
|
||||||
|
BranchDefinition {
|
||||||
|
id: BranchId::ProsePunctuation,
|
||||||
|
name: "Prose Punctuation",
|
||||||
|
levels: PROSE_PUNCTUATION_LEVELS,
|
||||||
|
},
|
||||||
|
BranchDefinition {
|
||||||
|
id: BranchId::Whitespace,
|
||||||
|
name: "Whitespace",
|
||||||
|
levels: WHITESPACE_LEVELS,
|
||||||
|
},
|
||||||
|
BranchDefinition {
|
||||||
|
id: BranchId::CodeSymbols,
|
||||||
|
name: "Code Symbols",
|
||||||
|
levels: CODE_SYMBOLS_LEVELS,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn get_branch_definition(id: BranchId) -> &'static BranchDefinition {
|
||||||
|
ALL_BRANCHES
|
||||||
|
.iter()
|
||||||
|
.find(|b| b.id == id)
|
||||||
|
.expect("branch definition not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Persisted Progress ---
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct BranchProgress {
|
||||||
|
pub status: BranchStatus,
|
||||||
|
pub current_level: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for BranchProgress {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
status: BranchStatus::Locked,
|
||||||
|
current_level: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct SkillTreeProgress {
|
||||||
|
pub branches: HashMap<String, BranchProgress>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SkillTreeProgress {
|
||||||
|
fn default() -> Self {
|
||||||
|
let mut branches = HashMap::new();
|
||||||
|
// Lowercase starts as InProgress; everything else Locked
|
||||||
|
branches.insert(
|
||||||
|
BranchId::Lowercase.to_key().to_string(),
|
||||||
|
BranchProgress {
|
||||||
|
status: BranchStatus::InProgress,
|
||||||
|
current_level: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
for &id in &[
|
||||||
|
BranchId::Capitals,
|
||||||
|
BranchId::Numbers,
|
||||||
|
BranchId::ProsePunctuation,
|
||||||
|
BranchId::Whitespace,
|
||||||
|
BranchId::CodeSymbols,
|
||||||
|
] {
|
||||||
|
branches.insert(id.to_key().to_string(), BranchProgress::default());
|
||||||
|
}
|
||||||
|
Self { branches }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Skill Tree Engine ---
|
||||||
|
|
||||||
|
/// The scope for key collection and focus selection.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum DrillScope {
|
||||||
|
/// Global adaptive: all InProgress + Complete branches
|
||||||
|
Global,
|
||||||
|
/// Branch-specific drill: specific branch + a-z background
|
||||||
|
Branch(BranchId),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SkillTree {
|
||||||
|
pub progress: SkillTreeProgress,
|
||||||
|
pub total_unique_keys: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of lowercase letters to start with before unlocking one-at-a-time
|
||||||
|
const LOWERCASE_MIN_KEYS: usize = 6;
|
||||||
|
|
||||||
|
impl SkillTree {
|
||||||
|
pub fn new(progress: SkillTreeProgress) -> Self {
|
||||||
|
let total_unique_keys = Self::compute_total_unique_keys();
|
||||||
|
Self {
|
||||||
|
progress,
|
||||||
|
total_unique_keys,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_total_unique_keys() -> usize {
|
||||||
|
let mut all_keys: HashSet<char> = HashSet::new();
|
||||||
|
for branch in ALL_BRANCHES {
|
||||||
|
for level in branch.levels {
|
||||||
|
for &key in level.keys {
|
||||||
|
all_keys.insert(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
all_keys.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn branch_status(&self, id: BranchId) -> &BranchStatus {
|
||||||
|
self.progress
|
||||||
|
.branches
|
||||||
|
.get(id.to_key())
|
||||||
|
.map(|bp| &bp.status)
|
||||||
|
.unwrap_or(&BranchStatus::Locked)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn branch_progress(&self, id: BranchId) -> &BranchProgress {
|
||||||
|
static DEFAULT: BranchProgress = BranchProgress {
|
||||||
|
status: BranchStatus::Locked,
|
||||||
|
current_level: 0,
|
||||||
|
};
|
||||||
|
self.progress.branches.get(id.to_key()).unwrap_or(&DEFAULT)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn branch_progress_mut(&mut self, id: BranchId) -> &mut BranchProgress {
|
||||||
|
self.progress
|
||||||
|
.branches
|
||||||
|
.entry(id.to_key().to_string())
|
||||||
|
.or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a branch (transition Available -> InProgress).
|
||||||
|
pub fn start_branch(&mut self, id: BranchId) {
|
||||||
|
let bp = self.branch_progress_mut(id);
|
||||||
|
if bp.status == BranchStatus::Available {
|
||||||
|
bp.status = BranchStatus::InProgress;
|
||||||
|
bp.current_level = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collect all unlocked keys for the given scope.
|
||||||
|
pub fn unlocked_keys(&self, scope: DrillScope) -> Vec<char> {
|
||||||
|
match scope {
|
||||||
|
DrillScope::Global => self.global_unlocked_keys(),
|
||||||
|
DrillScope::Branch(id) => self.branch_unlocked_keys(id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn global_unlocked_keys(&self) -> Vec<char> {
|
||||||
|
let mut keys = Vec::new();
|
||||||
|
for branch_def in ALL_BRANCHES {
|
||||||
|
let bp = self.branch_progress(branch_def.id);
|
||||||
|
match bp.status {
|
||||||
|
BranchStatus::InProgress => {
|
||||||
|
// For lowercase, use the progressive unlock system
|
||||||
|
if branch_def.id == BranchId::Lowercase {
|
||||||
|
keys.extend(self.lowercase_unlocked_keys());
|
||||||
|
} else {
|
||||||
|
// Include current level's keys + all prior levels
|
||||||
|
for (i, level) in branch_def.levels.iter().enumerate() {
|
||||||
|
if i <= bp.current_level {
|
||||||
|
keys.extend_from_slice(level.keys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BranchStatus::Complete => {
|
||||||
|
for level in branch_def.levels {
|
||||||
|
keys.extend_from_slice(level.keys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keys
|
||||||
|
}
|
||||||
|
|
||||||
|
fn branch_unlocked_keys(&self, id: BranchId) -> Vec<char> {
|
||||||
|
let mut keys = Vec::new();
|
||||||
|
|
||||||
|
// Always include a-z background keys
|
||||||
|
if id != BranchId::Lowercase {
|
||||||
|
let lowercase_def = get_branch_definition(BranchId::Lowercase);
|
||||||
|
let lowercase_bp = self.branch_progress(BranchId::Lowercase);
|
||||||
|
match lowercase_bp.status {
|
||||||
|
BranchStatus::InProgress => keys.extend(self.lowercase_unlocked_keys()),
|
||||||
|
BranchStatus::Complete => {
|
||||||
|
for level in lowercase_def.levels {
|
||||||
|
keys.extend_from_slice(level.keys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include keys from the target branch
|
||||||
|
let branch_def = get_branch_definition(id);
|
||||||
|
let bp = self.branch_progress(id);
|
||||||
|
if id == BranchId::Lowercase {
|
||||||
|
keys.extend(self.lowercase_unlocked_keys());
|
||||||
|
} else {
|
||||||
|
match bp.status {
|
||||||
|
BranchStatus::InProgress => {
|
||||||
|
for (i, level) in branch_def.levels.iter().enumerate() {
|
||||||
|
if i <= bp.current_level {
|
||||||
|
keys.extend_from_slice(level.keys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BranchStatus::Complete => {
|
||||||
|
for level in branch_def.levels {
|
||||||
|
keys.extend_from_slice(level.keys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keys
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the progressively-unlocked lowercase keys (mirrors old LetterUnlock logic).
|
||||||
|
fn lowercase_unlocked_keys(&self) -> Vec<char> {
|
||||||
|
let def = get_branch_definition(BranchId::Lowercase);
|
||||||
|
let bp = self.branch_progress(BranchId::Lowercase);
|
||||||
|
let all_keys = def.levels[0].keys;
|
||||||
|
|
||||||
|
match bp.status {
|
||||||
|
BranchStatus::Complete => all_keys.to_vec(),
|
||||||
|
BranchStatus::InProgress => {
|
||||||
|
// current_level represents number of keys unlocked beyond LOWERCASE_MIN_KEYS
|
||||||
|
let count = (LOWERCASE_MIN_KEYS + bp.current_level).min(all_keys.len());
|
||||||
|
all_keys[..count].to_vec()
|
||||||
|
}
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of unlocked lowercase letters (for display).
|
||||||
|
pub fn lowercase_unlocked_count(&self) -> usize {
|
||||||
|
self.lowercase_unlocked_keys().len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the focused (weakest) key for the given scope.
|
||||||
|
pub fn focused_key(&self, scope: DrillScope, stats: &KeyStatsStore) -> Option<char> {
|
||||||
|
match scope {
|
||||||
|
DrillScope::Global => self.global_focused_key(stats),
|
||||||
|
DrillScope::Branch(id) => self.branch_focused_key(id, stats),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn global_focused_key(&self, stats: &KeyStatsStore) -> Option<char> {
|
||||||
|
// Collect keys from all InProgress branches (current level only) + complete branches
|
||||||
|
let mut focus_candidates = Vec::new();
|
||||||
|
for branch_def in ALL_BRANCHES {
|
||||||
|
let bp = self.branch_progress(branch_def.id);
|
||||||
|
match bp.status {
|
||||||
|
BranchStatus::InProgress => {
|
||||||
|
if branch_def.id == BranchId::Lowercase {
|
||||||
|
focus_candidates.extend(self.lowercase_unlocked_keys());
|
||||||
|
} else if bp.current_level < branch_def.levels.len() {
|
||||||
|
// Only current level keys are focus candidates
|
||||||
|
focus_candidates
|
||||||
|
.extend_from_slice(branch_def.levels[bp.current_level].keys);
|
||||||
|
// Plus prior level keys for reinforcement
|
||||||
|
for i in 0..bp.current_level {
|
||||||
|
focus_candidates.extend_from_slice(branch_def.levels[i].keys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BranchStatus::Complete => {
|
||||||
|
for level in branch_def.levels {
|
||||||
|
focus_candidates.extend_from_slice(level.keys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Self::weakest_key(&focus_candidates, stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn branch_focused_key(&self, id: BranchId, stats: &KeyStatsStore) -> Option<char> {
|
||||||
|
let branch_def = get_branch_definition(id);
|
||||||
|
let bp = self.branch_progress(id);
|
||||||
|
|
||||||
|
if id == BranchId::Lowercase {
|
||||||
|
return Self::weakest_key(&self.lowercase_unlocked_keys(), stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
match bp.status {
|
||||||
|
BranchStatus::InProgress if bp.current_level < branch_def.levels.len() => {
|
||||||
|
// Focus only within current level's keys
|
||||||
|
let current_keys = branch_def.levels[bp.current_level].keys;
|
||||||
|
Self::weakest_key(¤t_keys.to_vec(), stats)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn weakest_key(keys: &[char], stats: &KeyStatsStore) -> Option<char> {
|
||||||
|
keys.iter()
|
||||||
|
.filter(|&&ch| stats.get_confidence(ch) < 1.0)
|
||||||
|
.min_by(|&&a, &&b| {
|
||||||
|
stats
|
||||||
|
.get_confidence(a)
|
||||||
|
.partial_cmp(&stats.get_confidence(b))
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
})
|
||||||
|
.copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update skill tree progress based on current key stats.
|
||||||
|
/// Call after updating KeyStatsStore.
|
||||||
|
pub fn update(&mut self, stats: &KeyStatsStore) {
|
||||||
|
// Update lowercase branch (progressive unlock)
|
||||||
|
self.update_lowercase(stats);
|
||||||
|
|
||||||
|
// Check if lowercase is complete -> unlock other branches
|
||||||
|
if *self.branch_status(BranchId::Lowercase) == BranchStatus::Complete {
|
||||||
|
for &id in &[
|
||||||
|
BranchId::Capitals,
|
||||||
|
BranchId::Numbers,
|
||||||
|
BranchId::ProsePunctuation,
|
||||||
|
BranchId::Whitespace,
|
||||||
|
BranchId::CodeSymbols,
|
||||||
|
] {
|
||||||
|
let bp = self.branch_progress_mut(id);
|
||||||
|
if bp.status == BranchStatus::Locked {
|
||||||
|
bp.status = BranchStatus::Available;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update InProgress branches (non-lowercase)
|
||||||
|
for branch_def in ALL_BRANCHES {
|
||||||
|
if branch_def.id == BranchId::Lowercase {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let bp = self.branch_progress(branch_def.id).clone();
|
||||||
|
if bp.status != BranchStatus::InProgress {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
self.update_branch_level(branch_def, stats);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_lowercase(&mut self, stats: &KeyStatsStore) {
|
||||||
|
let bp = self.branch_progress(BranchId::Lowercase).clone();
|
||||||
|
if bp.status != BranchStatus::InProgress {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let all_keys = get_branch_definition(BranchId::Lowercase).levels[0].keys;
|
||||||
|
let current_count = LOWERCASE_MIN_KEYS + bp.current_level;
|
||||||
|
|
||||||
|
if current_count >= all_keys.len() {
|
||||||
|
// All 26 keys unlocked, check if all confident
|
||||||
|
let all_confident = all_keys.iter().all(|&ch| stats.get_confidence(ch) >= 1.0);
|
||||||
|
if all_confident {
|
||||||
|
let bp_mut = self.branch_progress_mut(BranchId::Lowercase);
|
||||||
|
bp_mut.status = BranchStatus::Complete;
|
||||||
|
bp_mut.current_level = all_keys.len() - LOWERCASE_MIN_KEYS;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all current keys are confident -> unlock next
|
||||||
|
let current_keys = &all_keys[..current_count];
|
||||||
|
let all_confident = current_keys
|
||||||
|
.iter()
|
||||||
|
.all(|&ch| stats.get_confidence(ch) >= 1.0);
|
||||||
|
|
||||||
|
if all_confident {
|
||||||
|
let bp_mut = self.branch_progress_mut(BranchId::Lowercase);
|
||||||
|
bp_mut.current_level += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_branch_level(&mut self, branch_def: &BranchDefinition, stats: &KeyStatsStore) {
|
||||||
|
let bp = self.branch_progress(branch_def.id).clone();
|
||||||
|
if bp.current_level >= branch_def.levels.len() {
|
||||||
|
// Already past last level, mark complete
|
||||||
|
let bp_mut = self.branch_progress_mut(branch_def.id);
|
||||||
|
bp_mut.status = BranchStatus::Complete;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all keys in current level are confident
|
||||||
|
let current_level_keys = branch_def.levels[bp.current_level].keys;
|
||||||
|
let all_confident = current_level_keys
|
||||||
|
.iter()
|
||||||
|
.all(|&ch| stats.get_confidence(ch) >= 1.0);
|
||||||
|
|
||||||
|
if all_confident {
|
||||||
|
let bp_mut = self.branch_progress_mut(branch_def.id);
|
||||||
|
bp_mut.current_level += 1;
|
||||||
|
if bp_mut.current_level >= branch_def.levels.len() {
|
||||||
|
bp_mut.status = BranchStatus::Complete;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total number of unlocked unique keys across all branches.
|
||||||
|
pub fn total_unlocked_count(&self) -> usize {
|
||||||
|
let mut keys: HashSet<char> = HashSet::new();
|
||||||
|
for branch_def in ALL_BRANCHES {
|
||||||
|
let bp = self.branch_progress(branch_def.id);
|
||||||
|
match bp.status {
|
||||||
|
BranchStatus::InProgress => {
|
||||||
|
if branch_def.id == BranchId::Lowercase {
|
||||||
|
for key in self.lowercase_unlocked_keys() {
|
||||||
|
keys.insert(key);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (i, level) in branch_def.levels.iter().enumerate() {
|
||||||
|
if i <= bp.current_level {
|
||||||
|
for &key in level.keys {
|
||||||
|
keys.insert(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BranchStatus::Complete => {
|
||||||
|
for level in branch_def.levels {
|
||||||
|
for &key in level.keys {
|
||||||
|
keys.insert(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keys.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complexity for scoring: total_unlocked / total_unique
|
||||||
|
pub fn complexity(&self) -> f64 {
|
||||||
|
(self.total_unlocked_count() as f64 / self.total_unique_keys as f64).max(0.1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all branch definitions with their current progress (for UI).
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn all_branches_with_progress(&self) -> Vec<(&'static BranchDefinition, &BranchProgress)> {
|
||||||
|
ALL_BRANCHES
|
||||||
|
.iter()
|
||||||
|
.map(|def| (def, self.branch_progress(def.id)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of unlocked keys in a branch.
|
||||||
|
pub fn branch_unlocked_count(&self, id: BranchId) -> usize {
|
||||||
|
let def = get_branch_definition(id);
|
||||||
|
let bp = self.branch_progress(id);
|
||||||
|
match bp.status {
|
||||||
|
BranchStatus::Complete => def.levels.iter().map(|l| l.keys.len()).sum(),
|
||||||
|
BranchStatus::InProgress => {
|
||||||
|
if id == BranchId::Lowercase {
|
||||||
|
self.lowercase_unlocked_count()
|
||||||
|
} else {
|
||||||
|
def.levels
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(i, _)| *i <= bp.current_level)
|
||||||
|
.map(|(_, l)| l.keys.len())
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total keys defined in a branch (across all levels).
|
||||||
|
pub fn branch_total_keys(id: BranchId) -> usize {
|
||||||
|
let def = get_branch_definition(id);
|
||||||
|
def.levels.iter().map(|l| l.keys.len()).sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count of unique confident keys across all branches.
|
||||||
|
pub fn total_confident_keys(&self, stats: &KeyStatsStore) -> usize {
|
||||||
|
let mut keys: HashSet<char> = HashSet::new();
|
||||||
|
for branch_def in ALL_BRANCHES {
|
||||||
|
for level in branch_def.levels {
|
||||||
|
for &ch in level.keys {
|
||||||
|
if stats.get_confidence(ch) >= 1.0 {
|
||||||
|
keys.insert(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keys.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count of confident keys in a branch.
|
||||||
|
pub fn branch_confident_keys(&self, id: BranchId, stats: &KeyStatsStore) -> usize {
|
||||||
|
let def = get_branch_definition(id);
|
||||||
|
def.levels
|
||||||
|
.iter()
|
||||||
|
.flat_map(|l| l.keys.iter())
|
||||||
|
.filter(|&&ch| stats.get_confidence(ch) >= 1.0)
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SkillTree {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new(SkillTreeProgress::default())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn make_stats_confident(stats: &mut KeyStatsStore, keys: &[char]) {
|
||||||
|
for &ch in keys {
|
||||||
|
for _ in 0..50 {
|
||||||
|
stats.update_key(ch, 200.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_initial_state() {
|
||||||
|
let tree = SkillTree::default();
|
||||||
|
assert_eq!(
|
||||||
|
*tree.branch_status(BranchId::Lowercase),
|
||||||
|
BranchStatus::InProgress
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
*tree.branch_status(BranchId::Capitals),
|
||||||
|
BranchStatus::Locked
|
||||||
|
);
|
||||||
|
assert_eq!(*tree.branch_status(BranchId::Numbers), BranchStatus::Locked);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_total_unique_keys() {
|
||||||
|
let tree = SkillTree::default();
|
||||||
|
assert_eq!(tree.total_unique_keys, 96);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_initial_lowercase_unlocked() {
|
||||||
|
let tree = SkillTree::default();
|
||||||
|
let keys = tree.unlocked_keys(DrillScope::Global);
|
||||||
|
assert_eq!(keys.len(), LOWERCASE_MIN_KEYS);
|
||||||
|
assert_eq!(&keys[..6], &['e', 't', 'a', 'o', 'i', 'n']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_lowercase_progressive_unlock() {
|
||||||
|
let mut tree = SkillTree::default();
|
||||||
|
let mut stats = KeyStatsStore::default();
|
||||||
|
|
||||||
|
// Make initial 6 keys confident
|
||||||
|
make_stats_confident(&mut stats, &['e', 't', 'a', 'o', 'i', 'n']);
|
||||||
|
tree.update(&stats);
|
||||||
|
|
||||||
|
// Should unlock 7th key ('s')
|
||||||
|
let keys = tree.unlocked_keys(DrillScope::Global);
|
||||||
|
assert_eq!(keys.len(), 7);
|
||||||
|
assert!(keys.contains(&'s'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_lowercase_completion_unlocks_branches() {
|
||||||
|
let mut tree = SkillTree::default();
|
||||||
|
let mut stats = KeyStatsStore::default();
|
||||||
|
|
||||||
|
// Make all 26 lowercase keys confident
|
||||||
|
let all_lowercase = get_branch_definition(BranchId::Lowercase).levels[0].keys;
|
||||||
|
make_stats_confident(&mut stats, all_lowercase);
|
||||||
|
|
||||||
|
// Need to repeatedly update as each unlock requires all current keys confident
|
||||||
|
for _ in 0..30 {
|
||||||
|
tree.update(&stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
*tree.branch_status(BranchId::Lowercase),
|
||||||
|
BranchStatus::Complete
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
*tree.branch_status(BranchId::Capitals),
|
||||||
|
BranchStatus::Available
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
*tree.branch_status(BranchId::Numbers),
|
||||||
|
BranchStatus::Available
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
*tree.branch_status(BranchId::ProsePunctuation),
|
||||||
|
BranchStatus::Available
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
*tree.branch_status(BranchId::Whitespace),
|
||||||
|
BranchStatus::Available
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
*tree.branch_status(BranchId::CodeSymbols),
|
||||||
|
BranchStatus::Available
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_start_branch() {
|
||||||
|
let mut tree = SkillTree::default();
|
||||||
|
// Force capitals to Available
|
||||||
|
tree.branch_progress_mut(BranchId::Capitals).status = BranchStatus::Available;
|
||||||
|
|
||||||
|
tree.start_branch(BranchId::Capitals);
|
||||||
|
assert_eq!(
|
||||||
|
*tree.branch_status(BranchId::Capitals),
|
||||||
|
BranchStatus::InProgress
|
||||||
|
);
|
||||||
|
assert_eq!(tree.branch_progress(BranchId::Capitals).current_level, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_branch_level_advancement() {
|
||||||
|
let mut tree = SkillTree::default();
|
||||||
|
let mut stats = KeyStatsStore::default();
|
||||||
|
|
||||||
|
// Set capitals to InProgress at level 0
|
||||||
|
let bp = tree.branch_progress_mut(BranchId::Capitals);
|
||||||
|
bp.status = BranchStatus::InProgress;
|
||||||
|
bp.current_level = 0;
|
||||||
|
|
||||||
|
// Make level 1 capitals confident: T I A S W H B M
|
||||||
|
make_stats_confident(&mut stats, &['T', 'I', 'A', 'S', 'W', 'H', 'B', 'M']);
|
||||||
|
tree.update(&stats);
|
||||||
|
|
||||||
|
assert_eq!(tree.branch_progress(BranchId::Capitals).current_level, 1);
|
||||||
|
assert_eq!(
|
||||||
|
*tree.branch_status(BranchId::Capitals),
|
||||||
|
BranchStatus::InProgress
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_branch_completion() {
|
||||||
|
let mut tree = SkillTree::default();
|
||||||
|
let mut stats = KeyStatsStore::default();
|
||||||
|
|
||||||
|
let bp = tree.branch_progress_mut(BranchId::Capitals);
|
||||||
|
bp.status = BranchStatus::InProgress;
|
||||||
|
bp.current_level = 0;
|
||||||
|
|
||||||
|
// Make all capital letter levels confident
|
||||||
|
let all_caps: Vec<char> = ('A'..='Z').collect();
|
||||||
|
make_stats_confident(&mut stats, &all_caps);
|
||||||
|
|
||||||
|
// Update multiple times for level advancement
|
||||||
|
for _ in 0..5 {
|
||||||
|
tree.update(&stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
*tree.branch_status(BranchId::Capitals),
|
||||||
|
BranchStatus::Complete
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_shared_key_confidence() {
|
||||||
|
let _tree = SkillTree::default();
|
||||||
|
let mut stats = KeyStatsStore::default();
|
||||||
|
|
||||||
|
// '-' is shared between ProsePunctuation L2 and CodeSymbols L1
|
||||||
|
// Master it once
|
||||||
|
make_stats_confident(&mut stats, &['-']);
|
||||||
|
|
||||||
|
// Both branches should see it as confident
|
||||||
|
assert!(stats.get_confidence('-') >= 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_focused_key_global() {
|
||||||
|
let tree = SkillTree::default();
|
||||||
|
let stats = KeyStatsStore::default();
|
||||||
|
|
||||||
|
// All keys at 0 confidence, focused should be first in order
|
||||||
|
let focused = tree.focused_key(DrillScope::Global, &stats);
|
||||||
|
assert!(focused.is_some());
|
||||||
|
// Should be one of the initial 6 lowercase keys
|
||||||
|
assert!(
|
||||||
|
['e', 't', 'a', 'o', 'i', 'n'].contains(&focused.unwrap()),
|
||||||
|
"focused: {:?}",
|
||||||
|
focused
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_focused_key_branch() {
|
||||||
|
let mut tree = SkillTree::default();
|
||||||
|
let stats = KeyStatsStore::default();
|
||||||
|
|
||||||
|
let bp = tree.branch_progress_mut(BranchId::Capitals);
|
||||||
|
bp.status = BranchStatus::InProgress;
|
||||||
|
bp.current_level = 0;
|
||||||
|
|
||||||
|
let focused = tree.focused_key(DrillScope::Branch(BranchId::Capitals), &stats);
|
||||||
|
assert!(focused.is_some());
|
||||||
|
// Should be one of level 1 capitals
|
||||||
|
assert!(
|
||||||
|
['T', 'I', 'A', 'S', 'W', 'H', 'B', 'M'].contains(&focused.unwrap()),
|
||||||
|
"focused: {:?}",
|
||||||
|
focused
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_complexity_scales() {
|
||||||
|
let tree = SkillTree::default();
|
||||||
|
let initial_complexity = tree.complexity();
|
||||||
|
assert!(initial_complexity > 0.0);
|
||||||
|
assert!(initial_complexity < 1.0);
|
||||||
|
|
||||||
|
// Full unlock should give complexity ~1.0
|
||||||
|
let mut full_tree = SkillTree::default();
|
||||||
|
for id in BranchId::all() {
|
||||||
|
let bp = full_tree.branch_progress_mut(*id);
|
||||||
|
bp.status = BranchStatus::Complete;
|
||||||
|
}
|
||||||
|
let full_complexity = full_tree.complexity();
|
||||||
|
assert!((full_complexity - 1.0).abs() < 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_branch_keys_for_drill() {
|
||||||
|
let mut tree = SkillTree::default();
|
||||||
|
|
||||||
|
// Set lowercase complete, capitals in progress at level 1
|
||||||
|
tree.branch_progress_mut(BranchId::Lowercase).status = BranchStatus::Complete;
|
||||||
|
let bp = tree.branch_progress_mut(BranchId::Capitals);
|
||||||
|
bp.status = BranchStatus::InProgress;
|
||||||
|
bp.current_level = 1;
|
||||||
|
|
||||||
|
let keys = tree.unlocked_keys(DrillScope::Branch(BranchId::Capitals));
|
||||||
|
// Should include all 26 lowercase + Capitals L1 (8) + Capitals L2 (10)
|
||||||
|
assert!(keys.contains(&'e')); // lowercase background
|
||||||
|
assert!(keys.contains(&'T')); // Capitals L1
|
||||||
|
assert!(keys.contains(&'J')); // Capitals L2 (current level)
|
||||||
|
assert!(!keys.contains(&'O')); // Capitals L3 (locked)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_branch_unlocked_count() {
|
||||||
|
let tree = SkillTree::default();
|
||||||
|
// Lowercase starts InProgress with LOWERCASE_MIN_KEYS
|
||||||
|
assert_eq!(
|
||||||
|
tree.branch_unlocked_count(BranchId::Lowercase),
|
||||||
|
LOWERCASE_MIN_KEYS
|
||||||
|
);
|
||||||
|
|
||||||
|
// Locked branches return 0
|
||||||
|
assert_eq!(tree.branch_unlocked_count(BranchId::Capitals), 0);
|
||||||
|
assert_eq!(tree.branch_unlocked_count(BranchId::Numbers), 0);
|
||||||
|
|
||||||
|
// InProgress non-lowercase branch
|
||||||
|
let mut tree2 = SkillTree::default();
|
||||||
|
let bp = tree2.branch_progress_mut(BranchId::Capitals);
|
||||||
|
bp.status = BranchStatus::InProgress;
|
||||||
|
bp.current_level = 1;
|
||||||
|
// Level 0 (8 keys) + Level 1 (10 keys)
|
||||||
|
assert_eq!(tree2.branch_unlocked_count(BranchId::Capitals), 18);
|
||||||
|
|
||||||
|
// Complete branch returns all keys
|
||||||
|
let mut tree3 = SkillTree::default();
|
||||||
|
tree3.branch_progress_mut(BranchId::Numbers).status = BranchStatus::Complete;
|
||||||
|
assert_eq!(tree3.branch_unlocked_count(BranchId::Numbers), 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_selectable_branches_bounds() {
|
||||||
|
use crate::ui::components::skill_tree::selectable_branches;
|
||||||
|
|
||||||
|
let branches = selectable_branches();
|
||||||
|
assert!(!branches.is_empty());
|
||||||
|
assert_eq!(branches[0], BranchId::Lowercase);
|
||||||
|
|
||||||
|
let tree = SkillTree::default();
|
||||||
|
// Accessing branch_progress for every selectable branch should not panic
|
||||||
|
for &branch_id in &branches {
|
||||||
|
let _ = tree.branch_progress(branch_id);
|
||||||
|
let _ = SkillTree::branch_total_keys(branch_id);
|
||||||
|
let _ = tree.branch_unlocked_count(branch_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selection at 0 and at max index should be valid
|
||||||
|
assert!(0 < branches.len());
|
||||||
|
assert!(branches.len() - 1 < branches.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,8 @@ impl EventHandler {
|
|||||||
let (tx, rx) = mpsc::channel();
|
let (tx, rx) = mpsc::channel();
|
||||||
let _tx = tx.clone();
|
let _tx = tx.clone();
|
||||||
|
|
||||||
thread::spawn(move || loop {
|
thread::spawn(move || {
|
||||||
|
loop {
|
||||||
if event::poll(tick_rate).unwrap_or(false) {
|
if event::poll(tick_rate).unwrap_or(false) {
|
||||||
match event::read() {
|
match event::read() {
|
||||||
Ok(Event::Key(key)) => {
|
Ok(Event::Key(key)) => {
|
||||||
@@ -38,6 +39,7 @@ impl EventHandler {
|
|||||||
} else if tx.send(AppEvent::Tick).is_err() {
|
} else if tx.send(AppEvent::Tick).is_err() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Self { rx, _tx }
|
Self { rx, _tx }
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
|
#[cfg(feature = "network")]
|
||||||
|
use std::io::Read;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub struct DiskCache {
|
pub struct DiskCache {
|
||||||
base_dir: PathBuf,
|
base_dir: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
impl DiskCache {
|
impl DiskCache {
|
||||||
pub fn new(subdir: &str) -> Option<Self> {
|
pub fn new(subdir: &str) -> Option<Self> {
|
||||||
let base = dirs::data_dir()?.join("keydr").join(subdir);
|
let base = dirs::data_dir()?.join("keydr").join(subdir);
|
||||||
@@ -24,11 +28,18 @@ impl DiskCache {
|
|||||||
|
|
||||||
fn sanitize_key(key: &str) -> String {
|
fn sanitize_key(key: &str) -> String {
|
||||||
key.chars()
|
key.chars()
|
||||||
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' })
|
.map(|c| {
|
||||||
|
if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
'_'
|
||||||
|
}
|
||||||
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
#[cfg(feature = "network")]
|
#[cfg(feature = "network")]
|
||||||
pub fn fetch_url(url: &str) -> Option<String> {
|
pub fn fetch_url(url: &str) -> Option<String> {
|
||||||
let client = reqwest::blocking::Client::builder()
|
let client = reqwest::blocking::Client::builder()
|
||||||
@@ -43,7 +54,48 @@ pub fn fetch_url(url: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
#[cfg(not(feature = "network"))]
|
#[cfg(not(feature = "network"))]
|
||||||
pub fn fetch_url(_url: &str) -> Option<String> {
|
pub fn fetch_url(_url: &str) -> Option<String> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "network")]
|
||||||
|
pub fn fetch_url_bytes_with_progress<F>(url: &str, mut on_progress: F) -> Option<Vec<u8>>
|
||||||
|
where
|
||||||
|
F: FnMut(u64, Option<u64>),
|
||||||
|
{
|
||||||
|
let client = reqwest::blocking::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.build()
|
||||||
|
.ok()?;
|
||||||
|
let mut response = client.get(url).send().ok()?;
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let total = response.content_length();
|
||||||
|
let mut out: Vec<u8> = Vec::new();
|
||||||
|
let mut buf = [0u8; 16 * 1024];
|
||||||
|
let mut downloaded = 0u64;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let n = response.read(&mut buf).ok()?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
out.extend_from_slice(&buf[..n]);
|
||||||
|
downloaded = downloaded.saturating_add(n as u64);
|
||||||
|
on_progress(downloaded, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "network"))]
|
||||||
|
pub fn fetch_url_bytes_with_progress<F>(_url: &str, _on_progress: F) -> Option<Vec<u8>>
|
||||||
|
where
|
||||||
|
F: FnMut(u64, Option<u64>),
|
||||||
|
{
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|||||||
128
src/generator/capitalize.rs
Normal file
128
src/generator/capitalize.rs
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
use rand::Rng;
|
||||||
|
use rand::rngs::SmallRng;
|
||||||
|
|
||||||
|
/// Post-processing pass that capitalizes words in generated text.
|
||||||
|
/// Only capitalizes using letters from `unlocked_capitals`.
|
||||||
|
pub fn apply_capitalization(
|
||||||
|
text: &str,
|
||||||
|
unlocked_capitals: &[char],
|
||||||
|
focused: Option<char>,
|
||||||
|
rng: &mut SmallRng,
|
||||||
|
) -> String {
|
||||||
|
if unlocked_capitals.is_empty() {
|
||||||
|
return text.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If focused key is an uppercase letter, boost its probability
|
||||||
|
let focused_upper = focused.filter(|ch| ch.is_ascii_uppercase());
|
||||||
|
|
||||||
|
let mut result = String::with_capacity(text.len());
|
||||||
|
let mut at_sentence_start = true;
|
||||||
|
|
||||||
|
for (i, ch) in text.chars().enumerate() {
|
||||||
|
if at_sentence_start && ch.is_ascii_lowercase() {
|
||||||
|
let upper = ch.to_ascii_uppercase();
|
||||||
|
if unlocked_capitals.contains(&upper) {
|
||||||
|
result.push(upper);
|
||||||
|
at_sentence_start = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// After period/question/exclamation + space, next word starts a sentence
|
||||||
|
if ch == ' ' && i > 0 {
|
||||||
|
let prev = text.as_bytes().get(i - 1).map(|&b| b as char);
|
||||||
|
if matches!(prev, Some('.' | '?' | '!')) {
|
||||||
|
at_sentence_start = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capitalize word starts: boosted for focused key, ~12% for others
|
||||||
|
if ch.is_ascii_lowercase() && !at_sentence_start {
|
||||||
|
let is_word_start =
|
||||||
|
i == 0 || text.as_bytes().get(i - 1).map(|&b| b as char) == Some(' ');
|
||||||
|
if is_word_start {
|
||||||
|
let upper = ch.to_ascii_uppercase();
|
||||||
|
if unlocked_capitals.contains(&upper) {
|
||||||
|
let prob = if focused_upper == Some(upper) {
|
||||||
|
0.40
|
||||||
|
} else {
|
||||||
|
0.12
|
||||||
|
};
|
||||||
|
if rng.gen_bool(prob) {
|
||||||
|
result.push(upper);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ch != '.' && ch != '?' && ch != '!' {
|
||||||
|
at_sentence_start = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use rand::SeedableRng;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_caps_when_empty() {
|
||||||
|
let mut rng = SmallRng::seed_from_u64(42);
|
||||||
|
let result = apply_capitalization("hello world", &[], None, &mut rng);
|
||||||
|
assert_eq!(result, "hello world");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_capitalizes_first_word() {
|
||||||
|
let mut rng = SmallRng::seed_from_u64(42);
|
||||||
|
let result = apply_capitalization("hello world", &['H', 'W'], None, &mut rng);
|
||||||
|
assert!(result.starts_with('H'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_only_capitalizes_unlocked() {
|
||||||
|
let mut rng = SmallRng::seed_from_u64(42);
|
||||||
|
// Only 'W' is unlocked, not 'H'
|
||||||
|
let result = apply_capitalization("hello world", &['W'], None, &mut rng);
|
||||||
|
assert!(result.starts_with('h')); // 'H' not unlocked
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_after_period() {
|
||||||
|
let mut rng = SmallRng::seed_from_u64(42);
|
||||||
|
let result = apply_capitalization("one. two", &['O', 'T'], None, &mut rng);
|
||||||
|
assert!(result.starts_with('O'));
|
||||||
|
assert!(result.contains("Two") || result.contains("two"));
|
||||||
|
// At minimum, first word should be capitalized
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_focused_capital_boosted() {
|
||||||
|
// With focused 'W', W capitalization should happen more often
|
||||||
|
let caps = &['H', 'W'];
|
||||||
|
let mut focused_count = 0;
|
||||||
|
let mut unfocused_count = 0;
|
||||||
|
// Run many trials to check statistical boosting
|
||||||
|
for seed in 0..200 {
|
||||||
|
let mut rng = SmallRng::seed_from_u64(seed);
|
||||||
|
let text = "hello world wide web wonder what where who will work";
|
||||||
|
let result = apply_capitalization(text, caps, Some('W'), &mut rng);
|
||||||
|
// Count W capitalizations (skip first word which is always capitalized if 'H' is available)
|
||||||
|
focused_count += result.matches('W').count();
|
||||||
|
let mut rng2 = SmallRng::seed_from_u64(seed);
|
||||||
|
let result2 = apply_capitalization(text, caps, None, &mut rng2);
|
||||||
|
unfocused_count += result2.matches('W').count();
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
focused_count > unfocused_count,
|
||||||
|
"Focused W count ({focused_count}) should exceed unfocused ({unfocused_count})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
256
src/generator/code_patterns.rs
Normal file
256
src/generator/code_patterns.rs
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
use rand::Rng;
|
||||||
|
use rand::rngs::SmallRng;
|
||||||
|
|
||||||
|
/// Post-processing pass that inserts code-like expressions into text.
|
||||||
|
/// Only uses symbols from `unlocked_symbols`.
|
||||||
|
pub fn apply_code_symbols(
|
||||||
|
text: &str,
|
||||||
|
unlocked_symbols: &[char],
|
||||||
|
focused: Option<char>,
|
||||||
|
rng: &mut SmallRng,
|
||||||
|
) -> String {
|
||||||
|
if unlocked_symbols.is_empty() {
|
||||||
|
return text.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If focused key is a code symbol, boost insertion probability
|
||||||
|
let focused_symbol = focused.filter(|ch| unlocked_symbols.contains(ch));
|
||||||
|
let base_prob = if focused_symbol.is_some() { 0.35 } else { 0.20 };
|
||||||
|
|
||||||
|
let words: Vec<&str> = text.split(' ').collect();
|
||||||
|
let mut result = Vec::new();
|
||||||
|
|
||||||
|
for word in &words {
|
||||||
|
if rng.gen_bool(base_prob) {
|
||||||
|
let expr = generate_code_expr(word, unlocked_symbols, focused_symbol, rng);
|
||||||
|
result.push(expr);
|
||||||
|
} else {
|
||||||
|
result.push(word.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_code_expr(
|
||||||
|
word: &str,
|
||||||
|
symbols: &[char],
|
||||||
|
focused_symbol: Option<char>,
|
||||||
|
rng: &mut SmallRng,
|
||||||
|
) -> String {
|
||||||
|
// Categorize available symbols
|
||||||
|
let has = |ch: char| symbols.contains(&ch);
|
||||||
|
|
||||||
|
// Try various patterns based on available symbols
|
||||||
|
let mut patterns: Vec<Box<dyn Fn(&mut SmallRng) -> String>> = Vec::new();
|
||||||
|
// Track which patterns use the focused symbol for priority selection
|
||||||
|
let mut focused_patterns: Vec<usize> = Vec::new();
|
||||||
|
|
||||||
|
// Arithmetic & Assignment patterns
|
||||||
|
if has('=') {
|
||||||
|
let w = word.to_string();
|
||||||
|
let idx = patterns.len();
|
||||||
|
patterns.push(Box::new(move |_| format!("{w} = val")));
|
||||||
|
if focused_symbol == Some('=') {
|
||||||
|
focused_patterns.push(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if has('+') {
|
||||||
|
let w = word.to_string();
|
||||||
|
let idx = patterns.len();
|
||||||
|
patterns.push(Box::new(move |_| format!("{w} + num")));
|
||||||
|
if focused_symbol == Some('+') {
|
||||||
|
focused_patterns.push(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if has('*') {
|
||||||
|
let w = word.to_string();
|
||||||
|
let idx = patterns.len();
|
||||||
|
patterns.push(Box::new(move |_| format!("{w} * cnt")));
|
||||||
|
if focused_symbol == Some('*') {
|
||||||
|
focused_patterns.push(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if has('/') {
|
||||||
|
let w = word.to_string();
|
||||||
|
let idx = patterns.len();
|
||||||
|
patterns.push(Box::new(move |_| format!("{w} / max")));
|
||||||
|
if focused_symbol == Some('/') {
|
||||||
|
focused_patterns.push(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if has('-') {
|
||||||
|
let w = word.to_string();
|
||||||
|
let idx = patterns.len();
|
||||||
|
patterns.push(Box::new(move |_| format!("{w} - one")));
|
||||||
|
if focused_symbol == Some('-') {
|
||||||
|
focused_patterns.push(idx);
|
||||||
|
}
|
||||||
|
let w = word.to_string();
|
||||||
|
let idx = patterns.len();
|
||||||
|
patterns.push(Box::new(move |_| format!("-{w}")));
|
||||||
|
if focused_symbol == Some('-') {
|
||||||
|
focused_patterns.push(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if has('=') && has('+') {
|
||||||
|
let w = word.to_string();
|
||||||
|
patterns.push(Box::new(move |_| format!("{w} += one")));
|
||||||
|
}
|
||||||
|
if has('=') && has('-') {
|
||||||
|
let w = word.to_string();
|
||||||
|
let idx = patterns.len();
|
||||||
|
patterns.push(Box::new(move |_| format!("{w} -= one")));
|
||||||
|
if focused_symbol == Some('-') {
|
||||||
|
focused_patterns.push(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if has('=') && has('=') {
|
||||||
|
let w = word.to_string();
|
||||||
|
patterns.push(Box::new(move |_| format!("{w} == nil")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grouping patterns
|
||||||
|
if has('{') && has('}') {
|
||||||
|
let w = word.to_string();
|
||||||
|
let idx = patterns.len();
|
||||||
|
patterns.push(Box::new(move |_| format!("{{ {w} }}")));
|
||||||
|
if matches!(focused_symbol, Some('{') | Some('}')) {
|
||||||
|
focused_patterns.push(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if has('[') && has(']') {
|
||||||
|
let w = word.to_string();
|
||||||
|
let idx = patterns.len();
|
||||||
|
patterns.push(Box::new(move |_| format!("{w}[idx]")));
|
||||||
|
if matches!(focused_symbol, Some('[') | Some(']')) {
|
||||||
|
focused_patterns.push(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if has('<') && has('>') {
|
||||||
|
let w = word.to_string();
|
||||||
|
let idx = patterns.len();
|
||||||
|
patterns.push(Box::new(move |_| format!("Vec<{w}>")));
|
||||||
|
if matches!(focused_symbol, Some('<') | Some('>')) {
|
||||||
|
focused_patterns.push(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logic patterns
|
||||||
|
if has('&') {
|
||||||
|
let w = word.to_string();
|
||||||
|
let idx = patterns.len();
|
||||||
|
patterns.push(Box::new(move |_| format!("&{w}")));
|
||||||
|
if focused_symbol == Some('&') {
|
||||||
|
focused_patterns.push(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if has('|') {
|
||||||
|
let w = word.to_string();
|
||||||
|
let idx = patterns.len();
|
||||||
|
patterns.push(Box::new(move |_| format!("{w} | nil")));
|
||||||
|
if focused_symbol == Some('|') {
|
||||||
|
focused_patterns.push(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if has('!') {
|
||||||
|
let w = word.to_string();
|
||||||
|
let idx = patterns.len();
|
||||||
|
patterns.push(Box::new(move |_| format!("!{w}")));
|
||||||
|
if focused_symbol == Some('!') {
|
||||||
|
focused_patterns.push(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special patterns
|
||||||
|
if has('@') {
|
||||||
|
let w = word.to_string();
|
||||||
|
let idx = patterns.len();
|
||||||
|
patterns.push(Box::new(move |_| format!("@{w}")));
|
||||||
|
if focused_symbol == Some('@') {
|
||||||
|
focused_patterns.push(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if has('#') {
|
||||||
|
let w = word.to_string();
|
||||||
|
let idx = patterns.len();
|
||||||
|
patterns.push(Box::new(move |_| format!("#{w}")));
|
||||||
|
if focused_symbol == Some('#') {
|
||||||
|
focused_patterns.push(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if has('_') {
|
||||||
|
let w = word.to_string();
|
||||||
|
let idx = patterns.len();
|
||||||
|
patterns.push(Box::new(move |_| format!("{w}_val")));
|
||||||
|
if focused_symbol == Some('_') {
|
||||||
|
focused_patterns.push(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if has('$') {
|
||||||
|
let w = word.to_string();
|
||||||
|
let idx = patterns.len();
|
||||||
|
patterns.push(Box::new(move |_| format!("${w}")));
|
||||||
|
if focused_symbol == Some('$') {
|
||||||
|
focused_patterns.push(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if has('\\') {
|
||||||
|
let w = word.to_string();
|
||||||
|
let idx = patterns.len();
|
||||||
|
patterns.push(Box::new(move |_| format!("\\{w}")));
|
||||||
|
if focused_symbol == Some('\\') {
|
||||||
|
focused_patterns.push(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if patterns.is_empty() {
|
||||||
|
return word.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 50% chance to prefer a pattern that uses the focused symbol
|
||||||
|
let idx = if !focused_patterns.is_empty() && rng.gen_bool(0.50) {
|
||||||
|
focused_patterns[rng.gen_range(0..focused_patterns.len())]
|
||||||
|
} else {
|
||||||
|
rng.gen_range(0..patterns.len())
|
||||||
|
};
|
||||||
|
patterns[idx](rng)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use rand::SeedableRng;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_symbols_when_empty() {
|
||||||
|
let mut rng = SmallRng::seed_from_u64(42);
|
||||||
|
let result = apply_code_symbols("hello world", &[], None, &mut rng);
|
||||||
|
assert_eq!(result, "hello world");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_uses_only_unlocked_symbols() {
|
||||||
|
let mut rng = SmallRng::seed_from_u64(42);
|
||||||
|
let symbols = ['=', '+'];
|
||||||
|
let text = "a b c d e f g h i j";
|
||||||
|
let result = apply_code_symbols(text, &symbols, None, &mut rng);
|
||||||
|
for ch in result.chars() {
|
||||||
|
if !ch.is_alphanumeric() && ch != ' ' {
|
||||||
|
assert!(
|
||||||
|
symbols.contains(&ch),
|
||||||
|
"Unexpected symbol '{ch}' in: {result}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_dash_patterns_generated() {
|
||||||
|
let mut rng = SmallRng::seed_from_u64(42);
|
||||||
|
let symbols = ['-', '='];
|
||||||
|
let text = "a b c d e f g h i j k l m n o p q r s t";
|
||||||
|
let result = apply_code_symbols(text, &symbols, None, &mut rng);
|
||||||
|
assert!(result.contains('-'), "Expected dash in: {result}");
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -23,11 +23,7 @@ impl Dictionary {
|
|||||||
self.words.clone()
|
self.words.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn find_matching(
|
pub fn find_matching(&self, filter: &CharFilter, focused: Option<char>) -> Vec<&str> {
|
||||||
&self,
|
|
||||||
filter: &CharFilter,
|
|
||||||
focused: Option<char>,
|
|
||||||
) -> Vec<&str> {
|
|
||||||
let mut matching: Vec<&str> = self
|
let mut matching: Vec<&str> = self
|
||||||
.words
|
.words
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
use crate::engine::filter::CharFilter;
|
|
||||||
use crate::generator::TextGenerator;
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct GitHubCodeGenerator {
|
|
||||||
cached_snippets: Vec<String>,
|
|
||||||
current_idx: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GitHubCodeGenerator {
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
cached_snippets: Vec::new(),
|
|
||||||
current_idx: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for GitHubCodeGenerator {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TextGenerator for GitHubCodeGenerator {
|
|
||||||
fn generate(
|
|
||||||
&mut self,
|
|
||||||
_filter: &CharFilter,
|
|
||||||
_focused: Option<char>,
|
|
||||||
_word_count: usize,
|
|
||||||
) -> String {
|
|
||||||
if self.cached_snippets.is_empty() {
|
|
||||||
return "// GitHub code fetching not yet configured. Use settings to add a repository."
|
|
||||||
.to_string();
|
|
||||||
}
|
|
||||||
let snippet = self.cached_snippets[self.current_idx % self.cached_snippets.len()].clone();
|
|
||||||
self.current_idx += 1;
|
|
||||||
snippet
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
pub mod cache;
|
pub mod cache;
|
||||||
|
pub mod capitalize;
|
||||||
|
pub mod code_patterns;
|
||||||
pub mod code_syntax;
|
pub mod code_syntax;
|
||||||
pub mod dictionary;
|
pub mod dictionary;
|
||||||
pub mod github_code;
|
pub mod numbers;
|
||||||
pub mod passage;
|
pub mod passage;
|
||||||
pub mod phonetic;
|
pub mod phonetic;
|
||||||
|
pub mod punctuate;
|
||||||
pub mod transition_table;
|
pub mod transition_table;
|
||||||
|
|
||||||
use crate::engine::filter::CharFilter;
|
use crate::engine::filter::CharFilter;
|
||||||
|
|||||||
135
src/generator/numbers.rs
Normal file
135
src/generator/numbers.rs
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
use rand::Rng;
|
||||||
|
use rand::rngs::SmallRng;
|
||||||
|
|
||||||
|
/// Post-processing pass that inserts number expressions into text.
|
||||||
|
/// Only uses digits from `unlocked_digits`.
|
||||||
|
pub fn apply_numbers(
|
||||||
|
text: &str,
|
||||||
|
unlocked_digits: &[char],
|
||||||
|
has_dot: bool,
|
||||||
|
focused: Option<char>,
|
||||||
|
rng: &mut SmallRng,
|
||||||
|
) -> String {
|
||||||
|
if unlocked_digits.is_empty() {
|
||||||
|
return text.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If focused key is a digit, boost number insertion probability
|
||||||
|
let focused_digit = focused.filter(|ch| ch.is_ascii_digit());
|
||||||
|
let base_prob = if focused_digit.is_some() { 0.30 } else { 0.15 };
|
||||||
|
|
||||||
|
let words: Vec<&str> = text.split(' ').collect();
|
||||||
|
let mut result = Vec::new();
|
||||||
|
|
||||||
|
for word in &words {
|
||||||
|
if rng.gen_bool(base_prob) {
|
||||||
|
let expr = generate_number_expr(unlocked_digits, has_dot, focused_digit, rng);
|
||||||
|
result.push(expr);
|
||||||
|
} else {
|
||||||
|
result.push(word.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_number_expr(
|
||||||
|
digits: &[char],
|
||||||
|
has_dot: bool,
|
||||||
|
focused_digit: Option<char>,
|
||||||
|
rng: &mut SmallRng,
|
||||||
|
) -> String {
|
||||||
|
// Determine how many patterns are available (version pattern needs dot)
|
||||||
|
let max_pattern = if has_dot { 5 } else { 4 };
|
||||||
|
let pattern = rng.gen_range(0..max_pattern);
|
||||||
|
let num = match pattern {
|
||||||
|
0 => {
|
||||||
|
// Simple count: "3" or "42"
|
||||||
|
random_number(digits, 1, 3, focused_digit, rng)
|
||||||
|
}
|
||||||
|
1 => {
|
||||||
|
// Measurement: "7 miles" or "42 items"
|
||||||
|
let num = random_number(digits, 1, 2, focused_digit, rng);
|
||||||
|
let units = ["items", "miles", "days", "lines", "times", "parts"];
|
||||||
|
let unit = units[rng.gen_range(0..units.len())];
|
||||||
|
return format!("{num} {unit}");
|
||||||
|
}
|
||||||
|
2 => {
|
||||||
|
// Year-like: "2024"
|
||||||
|
random_number(digits, 4, 4, focused_digit, rng)
|
||||||
|
}
|
||||||
|
3 => {
|
||||||
|
// ID: "room 42" or "page 7"
|
||||||
|
let prefixes = ["room", "page", "step", "item", "line", "port"];
|
||||||
|
let prefix = prefixes[rng.gen_range(0..prefixes.len())];
|
||||||
|
let num = random_number(digits, 1, 3, focused_digit, rng);
|
||||||
|
return format!("{prefix} {num}");
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Version-like: "3.14" or "2.0" (only when dot is available)
|
||||||
|
let major = random_number(digits, 1, 1, focused_digit, rng);
|
||||||
|
let minor = random_number(digits, 1, 2, focused_digit, rng);
|
||||||
|
return format!("{major}.{minor}");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
num
|
||||||
|
}
|
||||||
|
|
||||||
|
fn random_number(
|
||||||
|
digits: &[char],
|
||||||
|
min_len: usize,
|
||||||
|
max_len: usize,
|
||||||
|
focused_digit: Option<char>,
|
||||||
|
rng: &mut SmallRng,
|
||||||
|
) -> String {
|
||||||
|
let len = rng.gen_range(min_len..=max_len);
|
||||||
|
(0..len)
|
||||||
|
.map(|_| {
|
||||||
|
// 40% chance to use the focused digit if it's a digit
|
||||||
|
if let Some(fd) = focused_digit {
|
||||||
|
if rng.gen_bool(0.40) {
|
||||||
|
return fd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
digits[rng.gen_range(0..digits.len())]
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use rand::SeedableRng;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_numbers_when_empty() {
|
||||||
|
let mut rng = SmallRng::seed_from_u64(42);
|
||||||
|
let result = apply_numbers("hello world", &[], false, None, &mut rng);
|
||||||
|
assert_eq!(result, "hello world");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_numbers_use_only_unlocked_digits() {
|
||||||
|
let mut rng = SmallRng::seed_from_u64(42);
|
||||||
|
let digits = ['1', '2', '3'];
|
||||||
|
let text = "a b c d e f g h i j k l m n o p q r s t";
|
||||||
|
let result = apply_numbers(text, &digits, false, None, &mut rng);
|
||||||
|
for ch in result.chars() {
|
||||||
|
if ch.is_ascii_digit() {
|
||||||
|
assert!(digits.contains(&ch), "Unexpected digit {ch} in: {result}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_dot_without_punctuation() {
|
||||||
|
let mut rng = SmallRng::seed_from_u64(42);
|
||||||
|
let digits = ['1', '2', '3', '4', '5'];
|
||||||
|
let text = "a b c d e f g h i j k l m n o p q r s t";
|
||||||
|
let result = apply_numbers(text, &digits, false, None, &mut rng);
|
||||||
|
assert!(
|
||||||
|
!result.contains('.'),
|
||||||
|
"Should not contain dot when has_dot=false: {result}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
use rand::rngs::SmallRng;
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
|
use rand::rngs::SmallRng;
|
||||||
|
|
||||||
use crate::engine::filter::CharFilter;
|
use crate::engine::filter::CharFilter;
|
||||||
use crate::generator::cache::{DiskCache, fetch_url};
|
|
||||||
use crate::generator::TextGenerator;
|
use crate::generator::TextGenerator;
|
||||||
|
use crate::generator::cache::fetch_url_bytes_with_progress;
|
||||||
|
|
||||||
const PASSAGES: &[&str] = &[
|
const PASSAGES: &[&str] = &[
|
||||||
// Classic literature & speeches
|
|
||||||
"the quick brown fox jumps over the lazy dog and then runs across the field while the sun sets behind the distant hills",
|
"the quick brown fox jumps over the lazy dog and then runs across the field while the sun sets behind the distant hills",
|
||||||
"it was the best of times it was the worst of times it was the age of wisdom it was the age of foolishness",
|
"it was the best of times it was the worst of times it was the age of wisdom it was the age of foolishness",
|
||||||
"in the beginning there was nothing but darkness and then the light appeared slowly spreading across the vast empty space",
|
"in the beginning there was nothing but darkness and then the light appeared slowly spreading across the vast empty space",
|
||||||
@@ -17,138 +19,157 @@ const PASSAGES: &[&str] = &[
|
|||||||
"all that glitters is not gold and not all those who wander are lost for the old that is strong does not wither",
|
"all that glitters is not gold and not all those who wander are lost for the old that is strong does not wither",
|
||||||
"the river flowed quietly through the green valley and the mountains rose high on either side covered with trees and snow",
|
"the river flowed quietly through the green valley and the mountains rose high on either side covered with trees and snow",
|
||||||
"a long time ago in a land far away there lived a wise king who ruled his people with kindness and justice",
|
"a long time ago in a land far away there lived a wise king who ruled his people with kindness and justice",
|
||||||
"the rain fell steadily on the roof making a soft drumming sound that filled the room and made everything feel calm",
|
|
||||||
"she opened the door and stepped outside into the cool morning air breathing deeply as the first light of dawn appeared",
|
|
||||||
"he picked up the book and began to read turning the pages slowly as the story drew him deeper and deeper into its world",
|
|
||||||
"the stars shone brightly in the clear night sky and the moon cast a silver light over the sleeping town below",
|
|
||||||
"they gathered around the fire telling stories and laughing while the wind howled outside and the snow piled up against the door",
|
|
||||||
// Pride and Prejudice
|
|
||||||
"it is a truth universally acknowledged that a single man in possession of a good fortune must be in want of a wife",
|
|
||||||
"there is a stubbornness about me that never can bear to be frightened at the will of others my courage always rises at every attempt to intimidate me",
|
|
||||||
"i could easily forgive his pride if he had not mortified mine but vanity not love has been my folly",
|
|
||||||
// Alice in Wonderland
|
|
||||||
"alice was beginning to get very tired of sitting by her sister on the bank and of having nothing to do",
|
|
||||||
"who in the world am i that is the great puzzle she said as she looked around the strange room with wonder",
|
|
||||||
"but i dont want to go among mad people alice remarked oh you cant help that said the cat were all mad here",
|
|
||||||
// Great Gatsby
|
|
||||||
"in my younger and more vulnerable years my father gave me some advice that i have been turning over in my mind ever since",
|
|
||||||
"so we beat on boats against the current borne back ceaselessly into the past dreaming of that green light",
|
|
||||||
// Sherlock Holmes
|
|
||||||
"when you have eliminated the impossible whatever remains however improbable must be the truth my dear watson",
|
|
||||||
"the world is full of obvious things which nobody by any chance ever observes but which are perfectly visible",
|
|
||||||
// Moby Dick
|
|
||||||
"call me ishmael some years ago having little or no money in my purse and nothing particular to interest me on shore",
|
|
||||||
"it is not down on any map because true places never are and the voyage was long and the sea was deep",
|
|
||||||
// 1984
|
|
||||||
"it was a bright cold day in april and the clocks were striking thirteen winston smith his chin nuzzled into his breast",
|
|
||||||
"who controls the past controls the future and who controls the present controls the past said the voice from the screen",
|
|
||||||
// Walden
|
|
||||||
"i went to the woods because i wished to live deliberately to front only the essential facts of life",
|
|
||||||
"the mass of men lead lives of quiet desperation and go to the grave with the song still in them",
|
|
||||||
// Science & philosophy
|
|
||||||
"the only way to do great work is to love what you do and if you have not found it yet keep looking and do not settle",
|
|
||||||
"imagination is more important than knowledge for while knowledge defines all we currently know imagination points to what we might discover",
|
|
||||||
"the important thing is not to stop questioning for curiosity has its own reason for existing in this wonderful universe",
|
|
||||||
"we are all in the gutter but some of us are looking at the stars and dreaming of worlds beyond our own",
|
|
||||||
"the greatest glory in living lies not in never falling but in rising every time we fall and trying once more",
|
|
||||||
// Nature & observation
|
|
||||||
"the autumn wind scattered golden leaves across the garden as the last rays of sunlight painted the clouds in shades of orange and pink",
|
|
||||||
"deep in the forest where the ancient trees stood tall and silent a small stream wound its way through moss covered stones",
|
|
||||||
"the ocean stretched endlessly before them its surface catching the light of the setting sun in a thousand shimmering reflections",
|
|
||||||
"morning mist hung low over the meadow as the first birds began their chorus and dew drops sparkled like diamonds on every blade of grass",
|
|
||||||
"the mountain peak stood above the clouds its snow covered summit glowing pink and gold in the light of the early morning sun",
|
|
||||||
// Everyday wisdom
|
|
||||||
"the best time to plant a tree was twenty years ago and the second best time is now so do not wait any longer to begin",
|
|
||||||
"a journey of a thousand miles begins with a single step and every great achievement started with the decision to try",
|
|
||||||
"the more that you read the more things you will know and the more that you learn the more places you will go",
|
|
||||||
"in three words i can sum up everything i have learned about life it goes on and so must we with hope",
|
|
||||||
"happiness is not something ready made it comes from your own actions and your choices shape the life you live",
|
|
||||||
"do not go where the path may lead but go instead where there is no path and leave a trail for others to follow",
|
|
||||||
"success is not final failure is not fatal it is the courage to continue that counts in the end",
|
|
||||||
"be yourself because everyone else is already taken and the world needs what only you can bring to it",
|
|
||||||
"life is what happens when you are busy making other plans so enjoy the journey along the way",
|
|
||||||
"the secret of getting ahead is getting started and the secret of getting started is breaking your tasks into small steps",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Gutenberg book IDs for popular public domain works
|
pub struct GutenbergBook {
|
||||||
const GUTENBERG_IDS: &[(u32, &str)] = &[
|
pub id: u32,
|
||||||
(1342, "pride_and_prejudice"),
|
pub key: &'static str,
|
||||||
(11, "alice_in_wonderland"),
|
pub title: &'static str,
|
||||||
(1661, "sherlock_holmes"),
|
}
|
||||||
(84, "frankenstein"),
|
|
||||||
(1952, "yellow_wallpaper"),
|
pub const GUTENBERG_BOOKS: &[GutenbergBook] = &[
|
||||||
(2701, "moby_dick"),
|
GutenbergBook {
|
||||||
(74, "tom_sawyer"),
|
id: 1342,
|
||||||
(345, "dracula"),
|
key: "pride_prejudice",
|
||||||
(1232, "prince"),
|
title: "Pride and Prejudice",
|
||||||
(76, "huckleberry_finn"),
|
},
|
||||||
(5200, "metamorphosis"),
|
GutenbergBook {
|
||||||
(2542, "aesop_fables"),
|
id: 11,
|
||||||
(174, "dorian_gray"),
|
key: "alice_wonderland",
|
||||||
(98, "tale_two_cities"),
|
title: "Alice in Wonderland",
|
||||||
(1080, "modest_proposal"),
|
},
|
||||||
(219, "heart_of_darkness"),
|
GutenbergBook {
|
||||||
(4300, "ulysses"),
|
id: 1661,
|
||||||
(28054, "brothers_karamazov"),
|
key: "sherlock_holmes",
|
||||||
(2554, "crime_and_punishment"),
|
title: "Sherlock Holmes",
|
||||||
(55, "oz"),
|
},
|
||||||
|
GutenbergBook {
|
||||||
|
id: 84,
|
||||||
|
key: "frankenstein",
|
||||||
|
title: "Frankenstein",
|
||||||
|
},
|
||||||
|
GutenbergBook {
|
||||||
|
id: 2701,
|
||||||
|
key: "moby_dick",
|
||||||
|
title: "Moby Dick",
|
||||||
|
},
|
||||||
|
GutenbergBook {
|
||||||
|
id: 98,
|
||||||
|
key: "tale_two_cities",
|
||||||
|
title: "A Tale of Two Cities",
|
||||||
|
},
|
||||||
|
GutenbergBook {
|
||||||
|
id: 2554,
|
||||||
|
key: "crime_punishment",
|
||||||
|
title: "Crime and Punishment",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
pub fn passage_options() -> Vec<(&'static str, String)> {
|
||||||
|
let mut out = vec![
|
||||||
|
("all", "All (Built-in + all books)".to_string()),
|
||||||
|
("builtin", "Built-in passages only".to_string()),
|
||||||
|
];
|
||||||
|
for book in GUTENBERG_BOOKS {
|
||||||
|
out.push((book.key, format!("Book: {}", book.title)));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_valid_passage_book(book: &str) -> bool {
|
||||||
|
book == "all" || book == "builtin" || GUTENBERG_BOOKS.iter().any(|b| b.key == book)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn uncached_books(cache_dir: &str) -> Vec<&'static GutenbergBook> {
|
||||||
|
GUTENBERG_BOOKS
|
||||||
|
.iter()
|
||||||
|
.filter(|book| !cache_file(cache_dir, book.key).exists())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn book_by_key(key: &str) -> Option<&'static GutenbergBook> {
|
||||||
|
GUTENBERG_BOOKS.iter().find(|b| b.key == key)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_book_cached(cache_dir: &str, key: &str) -> bool {
|
||||||
|
cache_file(cache_dir, key).exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn download_book_to_cache_with_progress<F>(
|
||||||
|
cache_dir: &str,
|
||||||
|
book: &GutenbergBook,
|
||||||
|
mut on_progress: F,
|
||||||
|
) -> bool
|
||||||
|
where
|
||||||
|
F: FnMut(u64, Option<u64>),
|
||||||
|
{
|
||||||
|
let _ = fs::create_dir_all(cache_dir);
|
||||||
|
let url = format!(
|
||||||
|
"https://www.gutenberg.org/cache/epub/{}/pg{}.txt",
|
||||||
|
book.id, book.id
|
||||||
|
);
|
||||||
|
if let Some(bytes) = fetch_url_bytes_with_progress(&url, |downloaded, total| {
|
||||||
|
on_progress(downloaded, total);
|
||||||
|
}) {
|
||||||
|
return fs::write(cache_file(cache_dir, book.key), bytes).is_ok();
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cache_file(cache_dir: &str, key: &str) -> PathBuf {
|
||||||
|
PathBuf::from(cache_dir).join(format!("{key}.txt"))
|
||||||
|
}
|
||||||
|
|
||||||
pub struct PassageGenerator {
|
pub struct PassageGenerator {
|
||||||
current_idx: usize,
|
fetched_passages: Vec<(String, String)>,
|
||||||
fetched_passages: Vec<String>,
|
|
||||||
rng: SmallRng,
|
rng: SmallRng,
|
||||||
|
selection: String,
|
||||||
|
cache_dir: String,
|
||||||
|
paragraph_limit: usize,
|
||||||
|
_downloads_enabled: bool,
|
||||||
|
last_source: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PassageGenerator {
|
impl PassageGenerator {
|
||||||
pub fn new(rng: SmallRng) -> Self {
|
pub fn new(
|
||||||
|
rng: SmallRng,
|
||||||
|
selection: &str,
|
||||||
|
cache_dir: &str,
|
||||||
|
paragraph_limit: usize,
|
||||||
|
downloads_enabled: bool,
|
||||||
|
) -> Self {
|
||||||
|
let selected = if is_valid_passage_book(selection) {
|
||||||
|
selection.to_string()
|
||||||
|
} else {
|
||||||
|
"all".to_string()
|
||||||
|
};
|
||||||
let mut generator = Self {
|
let mut generator = Self {
|
||||||
current_idx: 0,
|
|
||||||
fetched_passages: Vec::new(),
|
fetched_passages: Vec::new(),
|
||||||
rng,
|
rng,
|
||||||
|
selection: selected,
|
||||||
|
cache_dir: cache_dir.to_string(),
|
||||||
|
paragraph_limit,
|
||||||
|
_downloads_enabled: downloads_enabled,
|
||||||
|
last_source: "Built-in passage library".to_string(),
|
||||||
};
|
};
|
||||||
generator.load_cached_passages();
|
generator.load_cached_passages();
|
||||||
generator
|
generator
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn last_source(&self) -> &str {
|
||||||
|
&self.last_source
|
||||||
|
}
|
||||||
|
|
||||||
fn load_cached_passages(&mut self) {
|
fn load_cached_passages(&mut self) {
|
||||||
if let Some(cache) = DiskCache::new("passages") {
|
let _ = fs::create_dir_all(&self.cache_dir);
|
||||||
for &(_, name) in GUTENBERG_IDS {
|
for book in relevant_books(&self.selection) {
|
||||||
if let Some(content) = cache.get(name) {
|
if let Ok(content) = fs::read_to_string(cache_file(&self.cache_dir, book.key)) {
|
||||||
let paragraphs = extract_paragraphs(&content);
|
for para in extract_paragraphs(&content, self.paragraph_limit) {
|
||||||
self.fetched_passages.extend(paragraphs);
|
self.fetched_passages.push((para, book.title.to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn try_fetch_gutenberg(&mut self) {
|
|
||||||
let cache = match DiskCache::new("passages") {
|
|
||||||
Some(c) => c,
|
|
||||||
None => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Pick a random book that we haven't cached yet
|
|
||||||
let uncached: Vec<(u32, &str)> = GUTENBERG_IDS
|
|
||||||
.iter()
|
|
||||||
.filter(|(_, name)| cache.get(name).is_none())
|
|
||||||
.copied()
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if uncached.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let idx = self.rng.gen_range(0..uncached.len());
|
|
||||||
let (book_id, name) = uncached[idx];
|
|
||||||
let url = format!("https://www.gutenberg.org/cache/epub/{book_id}/pg{book_id}.txt");
|
|
||||||
|
|
||||||
if let Some(content) = fetch_url(&url) {
|
|
||||||
cache.put(name, &content);
|
|
||||||
let paragraphs = extract_paragraphs(&content);
|
|
||||||
self.fetched_passages.extend(paragraphs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TextGenerator for PassageGenerator {
|
impl TextGenerator for PassageGenerator {
|
||||||
@@ -156,38 +177,48 @@ impl TextGenerator for PassageGenerator {
|
|||||||
&mut self,
|
&mut self,
|
||||||
_filter: &CharFilter,
|
_filter: &CharFilter,
|
||||||
_focused: Option<char>,
|
_focused: Option<char>,
|
||||||
_word_count: usize,
|
word_count: usize,
|
||||||
) -> String {
|
) -> String {
|
||||||
// Try to fetch a new Gutenberg book in the background (first few calls)
|
let use_builtin = self.selection == "all" || self.selection == "builtin";
|
||||||
if self.fetched_passages.len() < 50 && self.current_idx < 3 {
|
let total = (if use_builtin { PASSAGES.len() } else { 0 }) + self.fetched_passages.len();
|
||||||
self.try_fetch_gutenberg();
|
|
||||||
|
if total == 0 {
|
||||||
|
let idx = self.rng.gen_range(0..PASSAGES.len());
|
||||||
|
self.last_source = "Built-in passage library (fallback)".to_string();
|
||||||
|
return normalize_keyboard_text(PASSAGES[idx]);
|
||||||
|
}
|
||||||
|
let idx = self.rng.gen_range(0..total);
|
||||||
|
if use_builtin && idx < PASSAGES.len() {
|
||||||
|
self.last_source = "Built-in passage library".to_string();
|
||||||
|
return fit_to_word_target(&normalize_keyboard_text(PASSAGES[idx]), word_count);
|
||||||
}
|
}
|
||||||
|
|
||||||
let total_passages = PASSAGES.len() + self.fetched_passages.len();
|
let fetched_idx = if use_builtin {
|
||||||
|
idx - PASSAGES.len()
|
||||||
if total_passages == 0 {
|
|
||||||
self.current_idx += 1;
|
|
||||||
return PASSAGES[0].to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mix embedded and fetched passages
|
|
||||||
let idx = self.current_idx % total_passages;
|
|
||||||
self.current_idx += 1;
|
|
||||||
|
|
||||||
if idx < PASSAGES.len() {
|
|
||||||
PASSAGES[idx].to_string()
|
|
||||||
} else {
|
} else {
|
||||||
let fetched_idx = idx - PASSAGES.len();
|
idx
|
||||||
self.fetched_passages[fetched_idx % self.fetched_passages.len()].clone()
|
};
|
||||||
}
|
let (text, source) = &self.fetched_passages[fetched_idx % self.fetched_passages.len()];
|
||||||
|
self.last_source = format!("Project Gutenberg ({source})");
|
||||||
|
fit_to_word_target(text, word_count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract readable paragraphs from Gutenberg text, skipping header/footer
|
fn relevant_books(selection: &str) -> Vec<&'static GutenbergBook> {
|
||||||
fn extract_paragraphs(text: &str) -> Vec<String> {
|
if selection == "all" || selection == "builtin" {
|
||||||
let mut paragraphs = Vec::new();
|
return GUTENBERG_BOOKS.iter().collect();
|
||||||
|
}
|
||||||
|
GUTENBERG_BOOKS
|
||||||
|
.iter()
|
||||||
|
.filter(|book| book.key == selection)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
// Find the start of actual content (after Gutenberg header)
|
fn extract_paragraphs(text: &str, limit: usize) -> Vec<String> {
|
||||||
|
const MIN_WORDS: usize = 12;
|
||||||
|
const MAX_WORDS: usize = 42;
|
||||||
|
|
||||||
|
let mut paragraphs = Vec::new();
|
||||||
let start_markers = ["*** START OF", "***START OF"];
|
let start_markers = ["*** START OF", "***START OF"];
|
||||||
let end_markers = ["*** END OF", "***END OF"];
|
let end_markers = ["*** END OF", "***END OF"];
|
||||||
|
|
||||||
@@ -195,50 +226,150 @@ fn extract_paragraphs(text: &str) -> Vec<String> {
|
|||||||
.iter()
|
.iter()
|
||||||
.filter_map(|marker| text.find(marker))
|
.filter_map(|marker| text.find(marker))
|
||||||
.min()
|
.min()
|
||||||
.map(|pos| {
|
.map(|pos| text[pos..].find('\n').map(|nl| pos + nl + 1).unwrap_or(pos))
|
||||||
// Find the end of the header line
|
|
||||||
text[pos..].find('\n').map(|nl| pos + nl + 1).unwrap_or(pos)
|
|
||||||
})
|
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
let content_end = end_markers
|
let content_end = end_markers
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|marker| text.find(marker))
|
.filter_map(|marker| text.find(marker))
|
||||||
.min()
|
.min()
|
||||||
.unwrap_or(text.len());
|
.unwrap_or(text.len());
|
||||||
|
let normalized = normalize_keyboard_text(
|
||||||
|
&text[content_start..content_end]
|
||||||
|
.replace("\r\n", "\n")
|
||||||
|
.replace('\r', "\n"),
|
||||||
|
);
|
||||||
|
|
||||||
let content = &text[content_start..content_end];
|
for para in normalized.split("\n\n") {
|
||||||
|
let raw = para.trim_matches('\n');
|
||||||
|
if raw.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Split into paragraphs (double newline separated)
|
let has_letters = raw.chars().any(|c| c.is_alphabetic());
|
||||||
for para in content.split("\r\n\r\n").chain(content.split("\n\n")) {
|
let has_only_supported_controls = raw
|
||||||
let cleaned: String = para
|
|
||||||
.lines()
|
|
||||||
.map(|l| l.trim())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(" ")
|
|
||||||
.chars()
|
.chars()
|
||||||
.filter(|c| c.is_ascii_alphanumeric() || c.is_ascii_whitespace() || c.is_ascii_punctuation())
|
.all(|c| !c.is_control() || c == '\n' || c == '\t');
|
||||||
.collect::<String>()
|
let word_count = raw.split_whitespace().count();
|
||||||
.to_lowercase();
|
if !has_letters || !has_only_supported_controls || word_count < MIN_WORDS {
|
||||||
|
continue;
|
||||||
let word_count = cleaned.split_whitespace().count();
|
|
||||||
if word_count >= 15 && word_count <= 60 {
|
|
||||||
// Keep only the alpha/space portions for typing
|
|
||||||
let typing_text: String = cleaned
|
|
||||||
.chars()
|
|
||||||
.filter(|c| c.is_ascii_lowercase() || *c == ' ')
|
|
||||||
.collect::<String>()
|
|
||||||
.split_whitespace()
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(" ");
|
|
||||||
|
|
||||||
if typing_text.split_whitespace().count() >= 10 {
|
|
||||||
paragraphs.push(typing_text);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if word_count <= MAX_WORDS {
|
||||||
|
paragraphs.push(raw.to_string());
|
||||||
|
} else {
|
||||||
|
paragraphs.extend(split_into_sentence_chunks(raw, MIN_WORDS, MAX_WORDS));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Take at most 100 paragraphs per book
|
if limit > 0 {
|
||||||
paragraphs.truncate(100);
|
paragraphs.truncate(limit);
|
||||||
|
}
|
||||||
paragraphs
|
paragraphs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_keyboard_text(text: &str) -> String {
|
||||||
|
text.chars()
|
||||||
|
.map(|c| match c {
|
||||||
|
'\u{2018}' | '\u{2019}' | '\u{201B}' | '\u{2032}' => '\'',
|
||||||
|
'\u{201C}' | '\u{201D}' | '\u{201F}' | '\u{2033}' => '"',
|
||||||
|
'\u{2013}' | '\u{2014}' | '\u{2015}' | '\u{2212}' => '-',
|
||||||
|
'\u{2026}' => '.',
|
||||||
|
'\u{00A0}' => ' ',
|
||||||
|
_ => c,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fit_to_word_target(text: &str, target_words: usize) -> String {
|
||||||
|
if target_words == 0 {
|
||||||
|
return text.to_string();
|
||||||
|
}
|
||||||
|
let words: Vec<&str> = text.split_whitespace().collect();
|
||||||
|
if words.is_empty() {
|
||||||
|
return text.to_string();
|
||||||
|
}
|
||||||
|
// Keep passages slightly longer than target at most.
|
||||||
|
let keep = target_words.saturating_mul(6) / 5;
|
||||||
|
if words.len() <= keep.max(1) {
|
||||||
|
return text.to_string();
|
||||||
|
}
|
||||||
|
words[..keep.max(1)].join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split_into_sentence_chunks(text: &str, min_words: usize, max_words: usize) -> Vec<String> {
|
||||||
|
let mut sentences: Vec<String> = Vec::new();
|
||||||
|
let mut start = 0usize;
|
||||||
|
for (idx, ch) in text.char_indices() {
|
||||||
|
if matches!(ch, '.' | '!' | '?') {
|
||||||
|
let end = idx + ch.len_utf8();
|
||||||
|
let s = text[start..end].trim();
|
||||||
|
if !s.is_empty() {
|
||||||
|
sentences.push(s.to_string());
|
||||||
|
}
|
||||||
|
start = end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let tail = text[start..].trim();
|
||||||
|
if !tail.is_empty() {
|
||||||
|
sentences.push(tail.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut chunks: Vec<String> = Vec::new();
|
||||||
|
let mut current = String::new();
|
||||||
|
let mut current_words = 0usize;
|
||||||
|
|
||||||
|
for sentence in sentences {
|
||||||
|
let w = sentence.split_whitespace().count();
|
||||||
|
if w == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if w > max_words {
|
||||||
|
if current_words >= min_words {
|
||||||
|
chunks.push(current.trim().to_string());
|
||||||
|
}
|
||||||
|
current.clear();
|
||||||
|
current_words = 0;
|
||||||
|
chunks.extend(split_long_by_words(&sentence, min_words, max_words));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if current_words == 0 {
|
||||||
|
current = sentence;
|
||||||
|
current_words = w;
|
||||||
|
} else if current_words + w <= max_words {
|
||||||
|
current.push(' ');
|
||||||
|
current.push_str(&sentence);
|
||||||
|
current_words += w;
|
||||||
|
} else {
|
||||||
|
if current_words >= min_words {
|
||||||
|
chunks.push(current.trim().to_string());
|
||||||
|
}
|
||||||
|
current = sentence;
|
||||||
|
current_words = w;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if current_words >= min_words {
|
||||||
|
chunks.push(current.trim().to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split_long_by_words(sentence: &str, min_words: usize, max_words: usize) -> Vec<String> {
|
||||||
|
let words: Vec<&str> = sentence.split_whitespace().collect();
|
||||||
|
let mut out: Vec<String> = Vec::new();
|
||||||
|
let mut i = 0usize;
|
||||||
|
while i < words.len() {
|
||||||
|
let end = (i + max_words).min(words.len());
|
||||||
|
let chunk = words[i..end].join(" ");
|
||||||
|
if chunk.split_whitespace().count() >= min_words {
|
||||||
|
out.push(chunk);
|
||||||
|
} else if let Some(last) = out.last_mut() {
|
||||||
|
last.push(' ');
|
||||||
|
last.push_str(&chunk);
|
||||||
|
}
|
||||||
|
i = end;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
use rand::rngs::SmallRng;
|
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
|
use rand::rngs::SmallRng;
|
||||||
|
|
||||||
use crate::engine::filter::CharFilter;
|
use crate::engine::filter::CharFilter;
|
||||||
|
use crate::generator::TextGenerator;
|
||||||
use crate::generator::dictionary::Dictionary;
|
use crate::generator::dictionary::Dictionary;
|
||||||
use crate::generator::transition_table::TransitionTable;
|
use crate::generator::transition_table::TransitionTable;
|
||||||
use crate::generator::TextGenerator;
|
|
||||||
|
|
||||||
const MIN_WORD_LEN: usize = 3;
|
const MIN_WORD_LEN: usize = 3;
|
||||||
const MAX_WORD_LEN: usize = 10;
|
const MAX_WORD_LEN: usize = 10;
|
||||||
@@ -149,7 +149,8 @@ impl PhoneticGenerator {
|
|||||||
if space_weight > 0.0 {
|
if space_weight > 0.0 {
|
||||||
let boost = 1.3f64.powi(word.len() as i32 - MIN_WORD_LEN as i32);
|
let boost = 1.3f64.powi(word.len() as i32 - MIN_WORD_LEN as i32);
|
||||||
let total: f64 = probs.iter().map(|(_, w)| w).sum();
|
let total: f64 = probs.iter().map(|(_, w)| w).sum();
|
||||||
let space_prob = (space_weight * boost) / (total + space_weight * (boost - 1.0));
|
let space_prob =
|
||||||
|
(space_weight * boost) / (total + space_weight * (boost - 1.0));
|
||||||
if self.rng.gen_bool(space_prob.min(0.85)) {
|
if self.rng.gen_bool(space_prob.min(0.85)) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -164,11 +165,8 @@ impl PhoneticGenerator {
|
|||||||
|
|
||||||
// Get next character from transition table
|
// Get next character from transition table
|
||||||
if let Some(probs) = self.table.segment(&prefix) {
|
if let Some(probs) = self.table.segment(&prefix) {
|
||||||
let non_space: Vec<(char, f64)> = probs
|
let non_space: Vec<(char, f64)> =
|
||||||
.iter()
|
probs.iter().filter(|(ch, _)| *ch != ' ').copied().collect();
|
||||||
.filter(|(ch, _)| *ch != ' ')
|
|
||||||
.copied()
|
|
||||||
.collect();
|
|
||||||
if let Some(next) = Self::pick_weighted_from(&mut self.rng, &non_space, filter) {
|
if let Some(next) = Self::pick_weighted_from(&mut self.rng, &non_space, filter) {
|
||||||
word.push(next);
|
word.push(next);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
263
src/generator/punctuate.rs
Normal file
263
src/generator/punctuate.rs
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
use rand::Rng;
|
||||||
|
use rand::rngs::SmallRng;
|
||||||
|
|
||||||
|
/// Post-processing pass that inserts punctuation into generated text.
|
||||||
|
/// Only uses punctuation chars from `unlocked_punct`.
|
||||||
|
pub fn apply_punctuation(
|
||||||
|
text: &str,
|
||||||
|
unlocked_punct: &[char],
|
||||||
|
focused: Option<char>,
|
||||||
|
rng: &mut SmallRng,
|
||||||
|
) -> String {
|
||||||
|
if unlocked_punct.is_empty() {
|
||||||
|
return text.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If focused key is a punctuation char in our set, boost its insertion probability
|
||||||
|
let focused_punct = focused.filter(|ch| unlocked_punct.contains(ch));
|
||||||
|
|
||||||
|
let words: Vec<&str> = text.split(' ').collect();
|
||||||
|
if words.is_empty() {
|
||||||
|
return text.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let has_period = unlocked_punct.contains(&'.');
|
||||||
|
let has_comma = unlocked_punct.contains(&',');
|
||||||
|
let has_apostrophe = unlocked_punct.contains(&'\'');
|
||||||
|
let has_semicolon = unlocked_punct.contains(&';');
|
||||||
|
let has_colon = unlocked_punct.contains(&':');
|
||||||
|
let has_quote = unlocked_punct.contains(&'"');
|
||||||
|
let has_dash = unlocked_punct.contains(&'-');
|
||||||
|
let has_question = unlocked_punct.contains(&'?');
|
||||||
|
let has_exclaim = unlocked_punct.contains(&'!');
|
||||||
|
let has_open_paren = unlocked_punct.contains(&'(');
|
||||||
|
let has_close_paren = unlocked_punct.contains(&')');
|
||||||
|
|
||||||
|
let mut result = Vec::new();
|
||||||
|
let mut words_since_period = 0;
|
||||||
|
let mut words_since_comma = 0;
|
||||||
|
|
||||||
|
for (i, word) in words.iter().enumerate() {
|
||||||
|
let mut w = word.to_string();
|
||||||
|
|
||||||
|
// Contractions (~8% of words, boosted if apostrophe is focused)
|
||||||
|
let apostrophe_prob = if focused_punct == Some('\'') {
|
||||||
|
0.30
|
||||||
|
} else {
|
||||||
|
0.08
|
||||||
|
};
|
||||||
|
if has_apostrophe && w.len() >= 3 && rng.gen_bool(apostrophe_prob) {
|
||||||
|
w = make_contraction(&w, rng);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compound words with dash (~5% of words, boosted if dash is focused)
|
||||||
|
let dash_prob = if focused_punct == Some('-') {
|
||||||
|
0.25
|
||||||
|
} else {
|
||||||
|
0.05
|
||||||
|
};
|
||||||
|
if has_dash && i + 1 < words.len() && rng.gen_bool(dash_prob) {
|
||||||
|
w.push('-');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sentence ending punctuation
|
||||||
|
words_since_period += 1;
|
||||||
|
let end_sentence =
|
||||||
|
words_since_period >= 8 && rng.gen_bool(0.15) || words_since_period >= 12;
|
||||||
|
|
||||||
|
if end_sentence && i < words.len() - 1 {
|
||||||
|
let q_prob = if focused_punct == Some('?') {
|
||||||
|
0.40
|
||||||
|
} else {
|
||||||
|
0.15
|
||||||
|
};
|
||||||
|
let excl_prob = if focused_punct == Some('!') {
|
||||||
|
0.40
|
||||||
|
} else {
|
||||||
|
0.10
|
||||||
|
};
|
||||||
|
if has_question && rng.gen_bool(q_prob) {
|
||||||
|
w.push('?');
|
||||||
|
} else if has_exclaim && rng.gen_bool(excl_prob) {
|
||||||
|
w.push('!');
|
||||||
|
} else if has_period {
|
||||||
|
w.push('.');
|
||||||
|
}
|
||||||
|
words_since_period = 0;
|
||||||
|
words_since_comma = 0;
|
||||||
|
} else {
|
||||||
|
// Comma after clause (~every 4-6 words)
|
||||||
|
words_since_comma += 1;
|
||||||
|
let comma_prob = if focused_punct == Some(',') {
|
||||||
|
0.40
|
||||||
|
} else {
|
||||||
|
0.20
|
||||||
|
};
|
||||||
|
if has_comma
|
||||||
|
&& words_since_comma >= 4
|
||||||
|
&& rng.gen_bool(comma_prob)
|
||||||
|
&& i < words.len() - 1
|
||||||
|
{
|
||||||
|
w.push(',');
|
||||||
|
words_since_comma = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Semicolon between clauses (rare, boosted if focused)
|
||||||
|
let semi_prob = if focused_punct == Some(';') {
|
||||||
|
0.25
|
||||||
|
} else {
|
||||||
|
0.05
|
||||||
|
};
|
||||||
|
if has_semicolon
|
||||||
|
&& words_since_comma >= 5
|
||||||
|
&& rng.gen_bool(semi_prob)
|
||||||
|
&& i < words.len() - 1
|
||||||
|
{
|
||||||
|
w.push(';');
|
||||||
|
words_since_comma = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Colon before list-like content (rare, boosted if focused)
|
||||||
|
let colon_prob = if focused_punct == Some(':') {
|
||||||
|
0.20
|
||||||
|
} else {
|
||||||
|
0.03
|
||||||
|
};
|
||||||
|
if has_colon && rng.gen_bool(colon_prob) && i < words.len() - 1 {
|
||||||
|
w.push(':');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quoted phrases (~5% chance to start a quote, boosted if focused)
|
||||||
|
let quote_prob = if focused_punct == Some('"') {
|
||||||
|
0.20
|
||||||
|
} else {
|
||||||
|
0.04
|
||||||
|
};
|
||||||
|
if has_quote && rng.gen_bool(quote_prob) && i + 2 < words.len() {
|
||||||
|
w = format!("\"{w}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parenthetical asides (rare, boosted if focused)
|
||||||
|
let paren_prob = if matches!(focused_punct, Some('(' | ')')) {
|
||||||
|
0.15
|
||||||
|
} else {
|
||||||
|
0.03
|
||||||
|
};
|
||||||
|
if has_open_paren && has_close_paren && rng.gen_bool(paren_prob) && i + 2 < words.len() {
|
||||||
|
w = format!("({w}");
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(w);
|
||||||
|
}
|
||||||
|
|
||||||
|
// End with period if we have it
|
||||||
|
if has_period {
|
||||||
|
if let Some(last) = result.last_mut() {
|
||||||
|
let last_char = last.chars().last();
|
||||||
|
if !matches!(last_char, Some('.' | '?' | '!' | '"' | ')')) {
|
||||||
|
last.push('.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close any open quotes/parens
|
||||||
|
let mut open_quotes = 0i32;
|
||||||
|
let mut open_parens = 0i32;
|
||||||
|
for w in &result {
|
||||||
|
for ch in w.chars() {
|
||||||
|
if ch == '"' {
|
||||||
|
open_quotes += 1;
|
||||||
|
}
|
||||||
|
if ch == '(' {
|
||||||
|
open_parens += 1;
|
||||||
|
}
|
||||||
|
if ch == ')' {
|
||||||
|
open_parens -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(last) = result.last_mut() {
|
||||||
|
if open_quotes % 2 != 0 && has_quote {
|
||||||
|
// Remove trailing period to put quote after
|
||||||
|
let had_period = last.ends_with('.');
|
||||||
|
if had_period {
|
||||||
|
last.pop();
|
||||||
|
}
|
||||||
|
last.push('"');
|
||||||
|
if had_period {
|
||||||
|
last.push('.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if open_parens > 0 && has_close_paren {
|
||||||
|
let had_period = last.ends_with('.');
|
||||||
|
if had_period {
|
||||||
|
last.pop();
|
||||||
|
}
|
||||||
|
last.push(')');
|
||||||
|
if had_period {
|
||||||
|
last.push('.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_contraction(word: &str, rng: &mut SmallRng) -> String {
|
||||||
|
// Simple contractions based on common patterns
|
||||||
|
let contractions: &[(&str, &str)] = &[
|
||||||
|
("not", "n't"),
|
||||||
|
("will", "'ll"),
|
||||||
|
("would", "'d"),
|
||||||
|
("have", "'ve"),
|
||||||
|
("are", "'re"),
|
||||||
|
("is", "'s"),
|
||||||
|
];
|
||||||
|
|
||||||
|
for &(base, suffix) in contractions {
|
||||||
|
if word == base {
|
||||||
|
// For "not" -> "don't", "can't", etc. - just return the contraction form
|
||||||
|
return format!("{word}{suffix}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic: ~chance to add 's
|
||||||
|
if rng.gen_bool(0.5) {
|
||||||
|
format!("{word}'s")
|
||||||
|
} else {
|
||||||
|
word.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use rand::SeedableRng;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_punct_when_empty() {
|
||||||
|
let mut rng = SmallRng::seed_from_u64(42);
|
||||||
|
let result = apply_punctuation("hello world", &[], None, &mut rng);
|
||||||
|
assert_eq!(result, "hello world");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_adds_period_at_end() {
|
||||||
|
let mut rng = SmallRng::seed_from_u64(42);
|
||||||
|
let text = "one two three four five six seven eight nine ten";
|
||||||
|
let result = apply_punctuation(text, &['.'], None, &mut rng);
|
||||||
|
assert!(result.ends_with('.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_period_appears_mid_text() {
|
||||||
|
let mut rng = SmallRng::seed_from_u64(42);
|
||||||
|
let words: Vec<&str> = (0..20).map(|_| "word").collect();
|
||||||
|
let text = words.join(" ");
|
||||||
|
let result = apply_punctuation(&text, &['.', ','], None, &mut rng);
|
||||||
|
// Should have at least one period somewhere in the middle
|
||||||
|
let period_count = result.chars().filter(|&c| c == '.').count();
|
||||||
|
assert!(period_count >= 1, "Expected periods in: {result}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -108,24 +108,94 @@ impl TransitionTable {
|
|||||||
let mut table = Self::new(4);
|
let mut table = Self::new(4);
|
||||||
|
|
||||||
let common_patterns: &[(&str, f64)] = &[
|
let common_patterns: &[(&str, f64)] = &[
|
||||||
("the", 10.0), ("and", 8.0), ("ing", 7.0), ("tion", 6.0), ("ent", 5.0),
|
("the", 10.0),
|
||||||
("ion", 5.0), ("her", 4.0), ("for", 4.0), ("are", 4.0), ("his", 4.0),
|
("and", 8.0),
|
||||||
("hat", 3.0), ("tha", 3.0), ("ere", 3.0), ("ate", 3.0), ("ith", 3.0),
|
("ing", 7.0),
|
||||||
("ver", 3.0), ("all", 3.0), ("not", 3.0), ("ess", 3.0), ("est", 3.0),
|
("tion", 6.0),
|
||||||
("rea", 3.0), ("sta", 3.0), ("ted", 3.0), ("com", 3.0), ("con", 3.0),
|
("ent", 5.0),
|
||||||
("oun", 2.5), ("pro", 2.5), ("oth", 2.5), ("igh", 2.5), ("ore", 2.5),
|
("ion", 5.0),
|
||||||
("our", 2.5), ("ine", 2.5), ("ove", 2.5), ("ome", 2.5), ("use", 2.5),
|
("her", 4.0),
|
||||||
("ble", 2.0), ("ful", 2.0), ("ous", 2.0), ("str", 2.0), ("tri", 2.0),
|
("for", 4.0),
|
||||||
("ght", 2.0), ("whi", 2.0), ("who", 2.0), ("hen", 2.0), ("ter", 2.0),
|
("are", 4.0),
|
||||||
("man", 2.0), ("men", 2.0), ("ner", 2.0), ("per", 2.0), ("pre", 2.0),
|
("his", 4.0),
|
||||||
("ran", 2.0), ("lin", 2.0), ("kin", 2.0), ("din", 2.0), ("sin", 2.0),
|
("hat", 3.0),
|
||||||
("out", 2.0), ("ind", 2.0), ("ber", 2.0), ("der", 2.0),
|
("tha", 3.0),
|
||||||
("end", 2.0), ("hin", 2.0), ("old", 2.0), ("ear", 2.0), ("ain", 2.0),
|
("ere", 3.0),
|
||||||
("ant", 2.0), ("urn", 2.0), ("ell", 2.0), ("ill", 2.0), ("ade", 2.0),
|
("ate", 3.0),
|
||||||
("ong", 2.0), ("ung", 2.0), ("ast", 2.0), ("ist", 2.0),
|
("ith", 3.0),
|
||||||
("ust", 2.0), ("ost", 2.0), ("ard", 2.0), ("ord", 2.0), ("art", 2.0),
|
("ver", 3.0),
|
||||||
("ort", 2.0), ("ect", 2.0), ("act", 2.0), ("ack", 2.0), ("ick", 2.0),
|
("all", 3.0),
|
||||||
("ock", 2.0), ("uck", 2.0), ("ash", 2.0), ("ish", 2.0), ("ush", 2.0),
|
("not", 3.0),
|
||||||
|
("ess", 3.0),
|
||||||
|
("est", 3.0),
|
||||||
|
("rea", 3.0),
|
||||||
|
("sta", 3.0),
|
||||||
|
("ted", 3.0),
|
||||||
|
("com", 3.0),
|
||||||
|
("con", 3.0),
|
||||||
|
("oun", 2.5),
|
||||||
|
("pro", 2.5),
|
||||||
|
("oth", 2.5),
|
||||||
|
("igh", 2.5),
|
||||||
|
("ore", 2.5),
|
||||||
|
("our", 2.5),
|
||||||
|
("ine", 2.5),
|
||||||
|
("ove", 2.5),
|
||||||
|
("ome", 2.5),
|
||||||
|
("use", 2.5),
|
||||||
|
("ble", 2.0),
|
||||||
|
("ful", 2.0),
|
||||||
|
("ous", 2.0),
|
||||||
|
("str", 2.0),
|
||||||
|
("tri", 2.0),
|
||||||
|
("ght", 2.0),
|
||||||
|
("whi", 2.0),
|
||||||
|
("who", 2.0),
|
||||||
|
("hen", 2.0),
|
||||||
|
("ter", 2.0),
|
||||||
|
("man", 2.0),
|
||||||
|
("men", 2.0),
|
||||||
|
("ner", 2.0),
|
||||||
|
("per", 2.0),
|
||||||
|
("pre", 2.0),
|
||||||
|
("ran", 2.0),
|
||||||
|
("lin", 2.0),
|
||||||
|
("kin", 2.0),
|
||||||
|
("din", 2.0),
|
||||||
|
("sin", 2.0),
|
||||||
|
("out", 2.0),
|
||||||
|
("ind", 2.0),
|
||||||
|
("ber", 2.0),
|
||||||
|
("der", 2.0),
|
||||||
|
("end", 2.0),
|
||||||
|
("hin", 2.0),
|
||||||
|
("old", 2.0),
|
||||||
|
("ear", 2.0),
|
||||||
|
("ain", 2.0),
|
||||||
|
("ant", 2.0),
|
||||||
|
("urn", 2.0),
|
||||||
|
("ell", 2.0),
|
||||||
|
("ill", 2.0),
|
||||||
|
("ade", 2.0),
|
||||||
|
("ong", 2.0),
|
||||||
|
("ung", 2.0),
|
||||||
|
("ast", 2.0),
|
||||||
|
("ist", 2.0),
|
||||||
|
("ust", 2.0),
|
||||||
|
("ost", 2.0),
|
||||||
|
("ard", 2.0),
|
||||||
|
("ord", 2.0),
|
||||||
|
("art", 2.0),
|
||||||
|
("ort", 2.0),
|
||||||
|
("ect", 2.0),
|
||||||
|
("act", 2.0),
|
||||||
|
("ack", 2.0),
|
||||||
|
("ick", 2.0),
|
||||||
|
("ock", 2.0),
|
||||||
|
("uck", 2.0),
|
||||||
|
("ash", 2.0),
|
||||||
|
("ish", 2.0),
|
||||||
|
("ush", 2.0),
|
||||||
];
|
];
|
||||||
|
|
||||||
for &(pattern, weight) in common_patterns {
|
for &(pattern, weight) in common_patterns {
|
||||||
@@ -142,8 +212,8 @@ impl TransitionTable {
|
|||||||
|
|
||||||
let vowels = ['a', 'e', 'i', 'o', 'u'];
|
let vowels = ['a', 'e', 'i', 'o', 'u'];
|
||||||
let consonants = [
|
let consonants = [
|
||||||
'b', 'c', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'm', 'n', 'p', 'r', 's', 't', 'v',
|
'b', 'c', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'm', 'n', 'p', 'r', 's', 't', 'v', 'w',
|
||||||
'w', 'x', 'y', 'z',
|
'x', 'y', 'z',
|
||||||
];
|
];
|
||||||
|
|
||||||
for &c in &consonants {
|
for &c in &consonants {
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
pub mod finger;
|
pub mod finger;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
|
pub mod model;
|
||||||
|
|||||||
819
src/keyboard/model.rs
Normal file
819
src/keyboard/model.rs
Normal file
@@ -0,0 +1,819 @@
|
|||||||
|
use crate::keyboard::finger::{Finger, FingerAssignment, Hand};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct PhysicalKey {
|
||||||
|
pub base: char,
|
||||||
|
pub shifted: char,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct KeyboardModel {
|
||||||
|
pub rows: Vec<Vec<PhysicalKey>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeyboardModel {
|
||||||
|
pub fn qwerty() -> Self {
|
||||||
|
Self {
|
||||||
|
rows: vec![
|
||||||
|
vec![
|
||||||
|
PhysicalKey {
|
||||||
|
base: '`',
|
||||||
|
shifted: '~',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '1',
|
||||||
|
shifted: '!',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '2',
|
||||||
|
shifted: '@',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '3',
|
||||||
|
shifted: '#',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '4',
|
||||||
|
shifted: '$',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '5',
|
||||||
|
shifted: '%',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '6',
|
||||||
|
shifted: '^',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '7',
|
||||||
|
shifted: '&',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '8',
|
||||||
|
shifted: '*',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '9',
|
||||||
|
shifted: '(',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '0',
|
||||||
|
shifted: ')',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '-',
|
||||||
|
shifted: '_',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '=',
|
||||||
|
shifted: '+',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
vec![
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'q',
|
||||||
|
shifted: 'Q',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'w',
|
||||||
|
shifted: 'W',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'e',
|
||||||
|
shifted: 'E',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'r',
|
||||||
|
shifted: 'R',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 't',
|
||||||
|
shifted: 'T',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'y',
|
||||||
|
shifted: 'Y',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'u',
|
||||||
|
shifted: 'U',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'i',
|
||||||
|
shifted: 'I',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'o',
|
||||||
|
shifted: 'O',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'p',
|
||||||
|
shifted: 'P',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '[',
|
||||||
|
shifted: '{',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: ']',
|
||||||
|
shifted: '}',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '\\',
|
||||||
|
shifted: '|',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
vec![
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'a',
|
||||||
|
shifted: 'A',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 's',
|
||||||
|
shifted: 'S',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'd',
|
||||||
|
shifted: 'D',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'f',
|
||||||
|
shifted: 'F',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'g',
|
||||||
|
shifted: 'G',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'h',
|
||||||
|
shifted: 'H',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'j',
|
||||||
|
shifted: 'J',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'k',
|
||||||
|
shifted: 'K',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'l',
|
||||||
|
shifted: 'L',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: ';',
|
||||||
|
shifted: ':',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '\'',
|
||||||
|
shifted: '"',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
vec![
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'z',
|
||||||
|
shifted: 'Z',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'x',
|
||||||
|
shifted: 'X',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'c',
|
||||||
|
shifted: 'C',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'v',
|
||||||
|
shifted: 'V',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'b',
|
||||||
|
shifted: 'B',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'n',
|
||||||
|
shifted: 'N',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'm',
|
||||||
|
shifted: 'M',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: ',',
|
||||||
|
shifted: '<',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '.',
|
||||||
|
shifted: '>',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '/',
|
||||||
|
shifted: '?',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dvorak() -> Self {
|
||||||
|
Self {
|
||||||
|
rows: vec![
|
||||||
|
vec![
|
||||||
|
PhysicalKey {
|
||||||
|
base: '`',
|
||||||
|
shifted: '~',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '1',
|
||||||
|
shifted: '!',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '2',
|
||||||
|
shifted: '@',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '3',
|
||||||
|
shifted: '#',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '4',
|
||||||
|
shifted: '$',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '5',
|
||||||
|
shifted: '%',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '6',
|
||||||
|
shifted: '^',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '7',
|
||||||
|
shifted: '&',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '8',
|
||||||
|
shifted: '*',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '9',
|
||||||
|
shifted: '(',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '0',
|
||||||
|
shifted: ')',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '[',
|
||||||
|
shifted: '{',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: ']',
|
||||||
|
shifted: '}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
vec![
|
||||||
|
PhysicalKey {
|
||||||
|
base: '\'',
|
||||||
|
shifted: '"',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: ',',
|
||||||
|
shifted: '<',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '.',
|
||||||
|
shifted: '>',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'p',
|
||||||
|
shifted: 'P',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'y',
|
||||||
|
shifted: 'Y',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'f',
|
||||||
|
shifted: 'F',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'g',
|
||||||
|
shifted: 'G',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'c',
|
||||||
|
shifted: 'C',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'r',
|
||||||
|
shifted: 'R',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'l',
|
||||||
|
shifted: 'L',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '/',
|
||||||
|
shifted: '?',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '=',
|
||||||
|
shifted: '+',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '\\',
|
||||||
|
shifted: '|',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
vec![
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'a',
|
||||||
|
shifted: 'A',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'o',
|
||||||
|
shifted: 'O',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'e',
|
||||||
|
shifted: 'E',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'u',
|
||||||
|
shifted: 'U',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'i',
|
||||||
|
shifted: 'I',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'd',
|
||||||
|
shifted: 'D',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'h',
|
||||||
|
shifted: 'H',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 't',
|
||||||
|
shifted: 'T',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'n',
|
||||||
|
shifted: 'N',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 's',
|
||||||
|
shifted: 'S',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '-',
|
||||||
|
shifted: '_',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
vec![
|
||||||
|
PhysicalKey {
|
||||||
|
base: ';',
|
||||||
|
shifted: ':',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'q',
|
||||||
|
shifted: 'Q',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'j',
|
||||||
|
shifted: 'J',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'k',
|
||||||
|
shifted: 'K',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'x',
|
||||||
|
shifted: 'X',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'b',
|
||||||
|
shifted: 'B',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'm',
|
||||||
|
shifted: 'M',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'w',
|
||||||
|
shifted: 'W',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'v',
|
||||||
|
shifted: 'V',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'z',
|
||||||
|
shifted: 'Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn colemak() -> Self {
|
||||||
|
Self {
|
||||||
|
rows: vec![
|
||||||
|
vec![
|
||||||
|
PhysicalKey {
|
||||||
|
base: '`',
|
||||||
|
shifted: '~',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '1',
|
||||||
|
shifted: '!',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '2',
|
||||||
|
shifted: '@',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '3',
|
||||||
|
shifted: '#',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '4',
|
||||||
|
shifted: '$',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '5',
|
||||||
|
shifted: '%',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '6',
|
||||||
|
shifted: '^',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '7',
|
||||||
|
shifted: '&',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '8',
|
||||||
|
shifted: '*',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '9',
|
||||||
|
shifted: '(',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '0',
|
||||||
|
shifted: ')',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '-',
|
||||||
|
shifted: '_',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '=',
|
||||||
|
shifted: '+',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
vec![
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'q',
|
||||||
|
shifted: 'Q',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'w',
|
||||||
|
shifted: 'W',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'f',
|
||||||
|
shifted: 'F',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'p',
|
||||||
|
shifted: 'P',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'g',
|
||||||
|
shifted: 'G',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'j',
|
||||||
|
shifted: 'J',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'l',
|
||||||
|
shifted: 'L',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'u',
|
||||||
|
shifted: 'U',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'y',
|
||||||
|
shifted: 'Y',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: ';',
|
||||||
|
shifted: ':',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '[',
|
||||||
|
shifted: '{',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: ']',
|
||||||
|
shifted: '}',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '\\',
|
||||||
|
shifted: '|',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
vec![
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'a',
|
||||||
|
shifted: 'A',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'r',
|
||||||
|
shifted: 'R',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 's',
|
||||||
|
shifted: 'S',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 't',
|
||||||
|
shifted: 'T',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'd',
|
||||||
|
shifted: 'D',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'h',
|
||||||
|
shifted: 'H',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'n',
|
||||||
|
shifted: 'N',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'e',
|
||||||
|
shifted: 'E',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'i',
|
||||||
|
shifted: 'I',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'o',
|
||||||
|
shifted: 'O',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '\'',
|
||||||
|
shifted: '"',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
vec![
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'z',
|
||||||
|
shifted: 'Z',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'x',
|
||||||
|
shifted: 'X',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'c',
|
||||||
|
shifted: 'C',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'v',
|
||||||
|
shifted: 'V',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'b',
|
||||||
|
shifted: 'B',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'k',
|
||||||
|
shifted: 'K',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: 'm',
|
||||||
|
shifted: 'M',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: ',',
|
||||||
|
shifted: '<',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '.',
|
||||||
|
shifted: '>',
|
||||||
|
},
|
||||||
|
PhysicalKey {
|
||||||
|
base: '/',
|
||||||
|
shifted: '?',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_name(name: &str) -> Self {
|
||||||
|
match name {
|
||||||
|
"dvorak" => Self::dvorak(),
|
||||||
|
"colemak" => Self::colemak(),
|
||||||
|
_ => Self::qwerty(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Given a base character, return its shifted counterpart.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn base_to_shifted(&self, ch: char) -> Option<char> {
|
||||||
|
self.physical_key_for(ch)
|
||||||
|
.filter(|pk| pk.base == ch)
|
||||||
|
.map(|pk| pk.shifted)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Given a shifted character, return its base counterpart.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn shifted_to_base(&self, ch: char) -> Option<char> {
|
||||||
|
self.physical_key_for(ch)
|
||||||
|
.filter(|pk| pk.shifted == ch)
|
||||||
|
.map(|pk| pk.base)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn physical_key_for(&self, ch: char) -> Option<&PhysicalKey> {
|
||||||
|
self.find_key_position(ch).map(|(r, c)| &self.rows[r][c])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_key_position(&self, ch: char) -> Option<(usize, usize)> {
|
||||||
|
for (row_idx, row) in self.rows.iter().enumerate() {
|
||||||
|
for (col_idx, key) in row.iter().enumerate() {
|
||||||
|
if key.base == ch || key.shifted == ch {
|
||||||
|
return Some((row_idx, col_idx));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the finger assignment for a physical key by its row/col position.
|
||||||
|
/// Uses QWERTY-style finger assignments based on column position.
|
||||||
|
pub fn finger_for_position(&self, row: usize, col: usize) -> FingerAssignment {
|
||||||
|
// Map column to finger based on standard touch-typing
|
||||||
|
// Row 0 (number row) has 13 keys, rows 1-3 have varying counts
|
||||||
|
// We use column position relative to the keyboard
|
||||||
|
let total_cols = self.rows[row].len();
|
||||||
|
|
||||||
|
// For the number row and top row (13 keys each in QWERTY)
|
||||||
|
// left pinky: cols 0-1, left ring: col 2, left middle: col 3,
|
||||||
|
// left index: cols 4-5, right index: cols 6-7,
|
||||||
|
// right middle: col 8, right ring: col 9, right pinky: cols 10+
|
||||||
|
match row {
|
||||||
|
0 => {
|
||||||
|
// Number row
|
||||||
|
match col {
|
||||||
|
0 | 1 => FingerAssignment::new(Hand::Left, Finger::Pinky),
|
||||||
|
2 => FingerAssignment::new(Hand::Left, Finger::Ring),
|
||||||
|
3 => FingerAssignment::new(Hand::Left, Finger::Middle),
|
||||||
|
4 | 5 => FingerAssignment::new(Hand::Left, Finger::Index),
|
||||||
|
6 | 7 => FingerAssignment::new(Hand::Right, Finger::Index),
|
||||||
|
8 => FingerAssignment::new(Hand::Right, Finger::Middle),
|
||||||
|
9 => FingerAssignment::new(Hand::Right, Finger::Ring),
|
||||||
|
_ => FingerAssignment::new(Hand::Right, Finger::Pinky),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
1 => {
|
||||||
|
// Top row (q-row in QWERTY)
|
||||||
|
match col {
|
||||||
|
0 => FingerAssignment::new(Hand::Left, Finger::Pinky),
|
||||||
|
1 => FingerAssignment::new(Hand::Left, Finger::Ring),
|
||||||
|
2 => FingerAssignment::new(Hand::Left, Finger::Middle),
|
||||||
|
3 | 4 => FingerAssignment::new(Hand::Left, Finger::Index),
|
||||||
|
5 | 6 => FingerAssignment::new(Hand::Right, Finger::Index),
|
||||||
|
7 => FingerAssignment::new(Hand::Right, Finger::Middle),
|
||||||
|
8 => FingerAssignment::new(Hand::Right, Finger::Ring),
|
||||||
|
_ => FingerAssignment::new(Hand::Right, Finger::Pinky),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
2 => {
|
||||||
|
// Home row
|
||||||
|
match col {
|
||||||
|
0 => FingerAssignment::new(Hand::Left, Finger::Pinky),
|
||||||
|
1 => FingerAssignment::new(Hand::Left, Finger::Ring),
|
||||||
|
2 => FingerAssignment::new(Hand::Left, Finger::Middle),
|
||||||
|
3 | 4 => FingerAssignment::new(Hand::Left, Finger::Index),
|
||||||
|
5 | 6 => FingerAssignment::new(Hand::Right, Finger::Index),
|
||||||
|
7 => FingerAssignment::new(Hand::Right, Finger::Middle),
|
||||||
|
8 => FingerAssignment::new(Hand::Right, Finger::Ring),
|
||||||
|
_ => FingerAssignment::new(Hand::Right, Finger::Pinky),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
3 => {
|
||||||
|
// Bottom row
|
||||||
|
let _ = total_cols;
|
||||||
|
match col {
|
||||||
|
0 => FingerAssignment::new(Hand::Left, Finger::Pinky),
|
||||||
|
1 => FingerAssignment::new(Hand::Left, Finger::Ring),
|
||||||
|
2 => FingerAssignment::new(Hand::Left, Finger::Middle),
|
||||||
|
3 | 4 => FingerAssignment::new(Hand::Left, Finger::Index),
|
||||||
|
5 | 6 => FingerAssignment::new(Hand::Right, Finger::Index),
|
||||||
|
7 => FingerAssignment::new(Hand::Right, Finger::Middle),
|
||||||
|
8 => FingerAssignment::new(Hand::Right, Finger::Ring),
|
||||||
|
_ => FingerAssignment::new(Hand::Right, Finger::Pinky),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => FingerAssignment::new(Hand::Right, Finger::Index),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get finger assignment for a character, looking it up in the model.
|
||||||
|
pub fn finger_for_char(&self, ch: char) -> FingerAssignment {
|
||||||
|
if let Some((row_idx, col_idx)) = self.find_key_position(ch) {
|
||||||
|
self.finger_for_position(row_idx, col_idx)
|
||||||
|
} else {
|
||||||
|
FingerAssignment::new(Hand::Right, Finger::Index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Letter-only rows (rows 1-3) for compact keyboard display.
|
||||||
|
pub fn letter_rows(&self) -> &[Vec<PhysicalKey>] {
|
||||||
|
if self.rows.len() > 1 {
|
||||||
|
&self.rows[1..]
|
||||||
|
} else {
|
||||||
|
&self.rows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_qwerty_covers_all_skill_tree_chars() {
|
||||||
|
let model = KeyboardModel::qwerty();
|
||||||
|
|
||||||
|
// All chars used in skill tree branches
|
||||||
|
let skill_tree_chars: Vec<char> = vec![
|
||||||
|
// Lowercase
|
||||||
|
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q',
|
||||||
|
'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', // Capitals
|
||||||
|
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q',
|
||||||
|
'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', // Numbers
|
||||||
|
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', // Prose punctuation
|
||||||
|
'.', ',', '\'', ';', ':', '"', '-', '?', '!', '(', ')', // Code symbols
|
||||||
|
'=', '+', '*', '/', '{', '}', '[', ']', '<', '>', '&', '|', '^', '~', '@', '#', '$',
|
||||||
|
'%', '_', '\\', '`',
|
||||||
|
];
|
||||||
|
|
||||||
|
for ch in &skill_tree_chars {
|
||||||
|
assert!(
|
||||||
|
model.physical_key_for(*ch).is_some(),
|
||||||
|
"KeyboardModel::qwerty() missing char: {:?}",
|
||||||
|
ch
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_base_to_shifted_and_back() {
|
||||||
|
let model = KeyboardModel::qwerty();
|
||||||
|
|
||||||
|
assert_eq!(model.base_to_shifted('a'), Some('A'));
|
||||||
|
assert_eq!(model.base_to_shifted('1'), Some('!'));
|
||||||
|
assert_eq!(model.base_to_shifted('['), Some('{'));
|
||||||
|
assert_eq!(model.shifted_to_base('A'), Some('a'));
|
||||||
|
assert_eq!(model.shifted_to_base('!'), Some('1'));
|
||||||
|
assert_eq!(model.shifted_to_base('{'), Some('['));
|
||||||
|
|
||||||
|
// base_to_shifted on a shifted char returns None
|
||||||
|
assert_eq!(model.base_to_shifted('A'), None);
|
||||||
|
// shifted_to_base on a base char returns None
|
||||||
|
assert_eq!(model.shifted_to_base('a'), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_qwerty_has_four_rows() {
|
||||||
|
let model = KeyboardModel::qwerty();
|
||||||
|
assert_eq!(model.rows.len(), 4);
|
||||||
|
assert_eq!(model.rows[0].len(), 13); // number row
|
||||||
|
assert_eq!(model.rows[1].len(), 13); // top row
|
||||||
|
assert_eq!(model.rows[2].len(), 11); // home row
|
||||||
|
assert_eq!(model.rows[3].len(), 10); // bottom row
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_finger_for_char_works_for_all_chars() {
|
||||||
|
let model = KeyboardModel::qwerty();
|
||||||
|
// Just verify it doesn't panic for various chars
|
||||||
|
let _ = model.finger_for_char('a');
|
||||||
|
let _ = model.finger_for_char('A');
|
||||||
|
let _ = model.finger_for_char('1');
|
||||||
|
let _ = model.finger_for_char('!');
|
||||||
|
let _ = model.finger_for_char('{');
|
||||||
|
}
|
||||||
|
}
|
||||||
1811
src/main.rs
1811
src/main.rs
File diff suppressed because it is too large
Load Diff
282
src/session/drill.rs
Normal file
282
src/session/drill.rs
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use crate::session::input::CharStatus;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct SyntheticSpan {
|
||||||
|
pub start: usize,
|
||||||
|
pub end: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DrillState {
|
||||||
|
pub target: Vec<char>,
|
||||||
|
pub input: Vec<CharStatus>,
|
||||||
|
pub cursor: usize,
|
||||||
|
pub started_at: Option<Instant>,
|
||||||
|
pub finished_at: Option<Instant>,
|
||||||
|
pub typo_flags: HashSet<usize>,
|
||||||
|
pub synthetic_spans: Vec<SyntheticSpan>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DrillState {
|
||||||
|
pub fn new(text: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
target: text.chars().collect(),
|
||||||
|
input: Vec::new(),
|
||||||
|
cursor: 0,
|
||||||
|
started_at: None,
|
||||||
|
finished_at: None,
|
||||||
|
typo_flags: HashSet::new(),
|
||||||
|
synthetic_spans: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_complete(&self) -> bool {
|
||||||
|
self.cursor >= self.target.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn elapsed_secs(&self) -> f64 {
|
||||||
|
match (self.started_at, self.finished_at) {
|
||||||
|
(Some(start), Some(end)) => end.duration_since(start).as_secs_f64(),
|
||||||
|
(Some(start), None) => start.elapsed().as_secs_f64(),
|
||||||
|
_ => 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn correct_count(&self) -> usize {
|
||||||
|
self.input
|
||||||
|
.iter()
|
||||||
|
.filter(|s| matches!(s, CharStatus::Correct))
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn wpm(&self) -> f64 {
|
||||||
|
let elapsed = self.elapsed_secs();
|
||||||
|
if elapsed < 0.1 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let chars = self.correct_count() as f64;
|
||||||
|
(chars / 5.0) / (elapsed / 60.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn typo_count(&self) -> usize {
|
||||||
|
self.typo_flags.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn accuracy(&self) -> f64 {
|
||||||
|
if self.cursor == 0 {
|
||||||
|
return 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 {
|
||||||
|
let elapsed = self.elapsed_secs();
|
||||||
|
if elapsed < 0.1 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
self.correct_count() as f64 / (elapsed / 60.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn progress(&self) -> f64 {
|
||||||
|
if self.target.is_empty() {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
self.cursor as f64 / self.target.len() as f64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::session::input;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_new_drill() {
|
||||||
|
let drill = DrillState::new("hello");
|
||||||
|
assert_eq!(drill.target.len(), 5);
|
||||||
|
assert_eq!(drill.cursor, 0);
|
||||||
|
assert!(!drill.is_complete());
|
||||||
|
assert_eq!(drill.progress(), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_accuracy_starts_at_100() {
|
||||||
|
let drill = DrillState::new("test");
|
||||||
|
assert_eq!(drill.accuracy(), 100.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_drill_progress() {
|
||||||
|
let drill = DrillState::new("");
|
||||||
|
assert!(drill.is_complete());
|
||||||
|
assert_eq!(drill.progress(), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_correct_typing_no_typos() {
|
||||||
|
let mut drill = DrillState::new("abc");
|
||||||
|
input::process_char(&mut drill, 'a');
|
||||||
|
input::process_char(&mut drill, 'b');
|
||||||
|
input::process_char(&mut drill, 'c');
|
||||||
|
assert!(drill.typo_flags.is_empty());
|
||||||
|
assert_eq!(drill.accuracy(), 100.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wrong_then_backspace_then_correct_counts_as_error() {
|
||||||
|
let mut drill = DrillState::new("abc");
|
||||||
|
// Type wrong at pos 0
|
||||||
|
input::process_char(&mut drill, 'x');
|
||||||
|
assert!(drill.typo_flags.contains(&0));
|
||||||
|
// Backspace
|
||||||
|
input::process_backspace(&mut drill);
|
||||||
|
// Typo flag persists
|
||||||
|
assert!(drill.typo_flags.contains(&0));
|
||||||
|
// Type correct
|
||||||
|
input::process_char(&mut drill, 'a');
|
||||||
|
assert!(drill.typo_flags.contains(&0));
|
||||||
|
assert_eq!(drill.typo_count(), 1);
|
||||||
|
assert!(drill.accuracy() < 100.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_multiple_errors_same_position_counts_as_one() {
|
||||||
|
let mut drill = DrillState::new("abc");
|
||||||
|
// Wrong, backspace, wrong again, backspace, correct
|
||||||
|
input::process_char(&mut drill, 'x');
|
||||||
|
input::process_backspace(&mut drill);
|
||||||
|
input::process_char(&mut drill, 'y');
|
||||||
|
input::process_backspace(&mut drill);
|
||||||
|
input::process_char(&mut drill, 'a');
|
||||||
|
assert_eq!(drill.typo_count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wrong_char_without_backspace() {
|
||||||
|
let mut drill = DrillState::new("abc");
|
||||||
|
input::process_char(&mut drill, 'x'); // wrong at pos 0
|
||||||
|
input::process_char(&mut drill, 'b'); // correct at pos 1
|
||||||
|
assert_eq!(drill.typo_count(), 1);
|
||||||
|
assert!(drill.typo_flags.contains(&0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wrong_enter_skips_line_and_backspace_collapses() {
|
||||||
|
let mut drill = DrillState::new("abcd\nef");
|
||||||
|
input::process_char(&mut drill, 'a');
|
||||||
|
assert_eq!(drill.cursor, 1);
|
||||||
|
|
||||||
|
// Wrong newline while expecting 'b' should skip to next line start.
|
||||||
|
input::process_char(&mut drill, '\n');
|
||||||
|
assert_eq!(drill.cursor, 5); // index after '\n'
|
||||||
|
assert!(drill.typo_count() >= 4);
|
||||||
|
for pos in 1..5 {
|
||||||
|
assert!(drill.typo_flags.contains(&pos));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backspacing at jump boundary collapses span to a single typo.
|
||||||
|
input::process_backspace(&mut drill);
|
||||||
|
assert_eq!(drill.cursor, 1);
|
||||||
|
assert_eq!(drill.typo_count(), 1);
|
||||||
|
assert!(drill.typo_flags.contains(&1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wrong_tab_skips_tab_width_and_backspace_collapses() {
|
||||||
|
let mut drill = DrillState::new("abcdef");
|
||||||
|
input::process_char(&mut drill, 'a');
|
||||||
|
assert_eq!(drill.cursor, 1);
|
||||||
|
|
||||||
|
// Tab jumps 4 chars (or to end of line).
|
||||||
|
input::process_char(&mut drill, '\t');
|
||||||
|
assert_eq!(drill.cursor, 5);
|
||||||
|
for pos in 1..5 {
|
||||||
|
assert!(drill.typo_flags.contains(&pos));
|
||||||
|
}
|
||||||
|
|
||||||
|
input::process_backspace(&mut drill);
|
||||||
|
assert_eq!(drill.cursor, 1);
|
||||||
|
assert_eq!(drill.typo_count(), 1);
|
||||||
|
assert!(drill.typo_flags.contains(&1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wrong_tab_near_line_end_clamps_to_end_of_line() {
|
||||||
|
let mut drill = DrillState::new("ab\ncd");
|
||||||
|
input::process_char(&mut drill, 'a');
|
||||||
|
input::process_char(&mut drill, 'b');
|
||||||
|
// At newline position, a wrong tab should consume just this line remainder.
|
||||||
|
input::process_char(&mut drill, '\t');
|
||||||
|
assert_eq!(drill.cursor, 3);
|
||||||
|
assert_eq!(drill.typo_count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tab_counts_as_four_spaces() {
|
||||||
|
let mut drill = DrillState::new(" pass");
|
||||||
|
let start = drill.cursor;
|
||||||
|
input::process_char(&mut drill, '\t');
|
||||||
|
assert_eq!(drill.cursor, start + 4);
|
||||||
|
assert_eq!(drill.typo_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tab_counts_as_two_spaces() {
|
||||||
|
let mut drill = DrillState::new(" echo");
|
||||||
|
let start = drill.cursor;
|
||||||
|
input::process_char(&mut drill, '\t');
|
||||||
|
assert_eq!(drill.cursor, start + 2);
|
||||||
|
assert_eq!(drill.typo_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tab_not_accepted_for_non_four_space_prefix() {
|
||||||
|
let mut drill = DrillState::new("abc def");
|
||||||
|
for ch in "abc".chars() {
|
||||||
|
input::process_char(&mut drill, ch);
|
||||||
|
}
|
||||||
|
let start = drill.cursor;
|
||||||
|
input::process_char(&mut drill, '\t');
|
||||||
|
// Falls back to synthetic incorrect span behavior.
|
||||||
|
assert!(drill.cursor > start);
|
||||||
|
assert!(drill.typo_count() >= 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_correct_enter_auto_indents_next_line() {
|
||||||
|
let mut drill = DrillState::new("if x:\n pass");
|
||||||
|
for ch in "if x:".chars() {
|
||||||
|
input::process_char(&mut drill, ch);
|
||||||
|
}
|
||||||
|
// Correct newline should also consume the 4-space indent.
|
||||||
|
input::process_char(&mut drill, '\n');
|
||||||
|
let expected_cursor = "if x:\n ".chars().count();
|
||||||
|
assert_eq!(drill.cursor, expected_cursor);
|
||||||
|
assert_eq!(drill.typo_count(), 0);
|
||||||
|
assert_eq!(drill.accuracy(), 100.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_nested_synthetic_spans_collapse_to_single_error() {
|
||||||
|
let mut drill = DrillState::new("abcd\nefgh");
|
||||||
|
input::process_char(&mut drill, 'a');
|
||||||
|
input::process_char(&mut drill, '\n');
|
||||||
|
let after_newline = drill.cursor;
|
||||||
|
input::process_char(&mut drill, '\t');
|
||||||
|
assert!(drill.typo_count() > 1);
|
||||||
|
|
||||||
|
input::process_backspace(&mut drill); // collapse tab span
|
||||||
|
assert_eq!(drill.cursor, after_newline);
|
||||||
|
input::process_backspace(&mut drill); // collapse newline span
|
||||||
|
assert_eq!(drill.cursor, 1);
|
||||||
|
assert_eq!(drill.typo_count(), 1);
|
||||||
|
assert!(drill.typo_flags.contains(&1));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use crate::session::lesson::LessonState;
|
use crate::session::drill::{DrillState, SyntheticSpan};
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub enum CharStatus {
|
pub enum CharStatus {
|
||||||
@@ -17,17 +17,23 @@ pub struct KeystrokeEvent {
|
|||||||
pub correct: bool,
|
pub correct: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn process_char(lesson: &mut LessonState, ch: char) -> Option<KeystrokeEvent> {
|
pub fn process_char(drill: &mut DrillState, ch: char) -> Option<KeystrokeEvent> {
|
||||||
if lesson.is_complete() {
|
if drill.is_complete() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
if lesson.started_at.is_none() {
|
if drill.started_at.is_none() {
|
||||||
lesson.started_at = Some(Instant::now());
|
drill.started_at = Some(Instant::now());
|
||||||
}
|
}
|
||||||
|
|
||||||
let expected = lesson.target[lesson.cursor];
|
let expected = drill.target[drill.cursor];
|
||||||
let correct = ch == expected;
|
let tab_indent_len = if ch == '\t' {
|
||||||
|
tab_indent_completion_len(drill)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let tab_as_indent = tab_indent_len > 0;
|
||||||
|
let correct = ch == expected || tab_as_indent;
|
||||||
|
|
||||||
let event = KeystrokeEvent {
|
let event = KeystrokeEvent {
|
||||||
expected,
|
expected,
|
||||||
@@ -36,23 +42,176 @@ pub fn process_char(lesson: &mut LessonState, ch: char) -> Option<KeystrokeEvent
|
|||||||
correct,
|
correct,
|
||||||
};
|
};
|
||||||
|
|
||||||
if correct {
|
if tab_as_indent {
|
||||||
lesson.input.push(CharStatus::Correct);
|
apply_tab_indent(drill, tab_indent_len);
|
||||||
} else {
|
} else if correct {
|
||||||
lesson.input.push(CharStatus::Incorrect(ch));
|
drill.input.push(CharStatus::Correct);
|
||||||
|
drill.cursor += 1;
|
||||||
|
// IDE-like behavior: when Enter is correctly typed, auto-consume
|
||||||
|
// indentation whitespace on the next line.
|
||||||
|
if ch == '\n' {
|
||||||
|
apply_auto_indent_after_newline(drill);
|
||||||
|
}
|
||||||
|
} else if ch == '\n' {
|
||||||
|
apply_newline_span(drill, ch);
|
||||||
|
} else if ch == '\t' {
|
||||||
|
apply_tab_span(drill, ch);
|
||||||
|
} else {
|
||||||
|
drill.input.push(CharStatus::Incorrect(ch));
|
||||||
|
drill.typo_flags.insert(drill.cursor);
|
||||||
|
drill.cursor += 1;
|
||||||
}
|
}
|
||||||
lesson.cursor += 1;
|
|
||||||
|
|
||||||
if lesson.is_complete() {
|
if drill.is_complete() {
|
||||||
lesson.finished_at = Some(Instant::now());
|
drill.finished_at = Some(Instant::now());
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(event)
|
Some(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn process_backspace(lesson: &mut LessonState) {
|
fn tab_indent_completion_len(drill: &DrillState) -> usize {
|
||||||
if lesson.cursor > 0 {
|
if drill.cursor >= drill.target.len() {
|
||||||
lesson.cursor -= 1;
|
return 0;
|
||||||
lesson.input.pop();
|
}
|
||||||
|
|
||||||
|
// Only treat Tab as indentation if cursor is in leading whitespace
|
||||||
|
// for the current line.
|
||||||
|
let line_start = drill.target[..drill.cursor]
|
||||||
|
.iter()
|
||||||
|
.rposition(|&c| c == '\n')
|
||||||
|
.map(|idx| idx + 1)
|
||||||
|
.unwrap_or(0);
|
||||||
|
if drill.target[line_start..drill.cursor]
|
||||||
|
.iter()
|
||||||
|
.any(|&c| c != ' ' && c != '\t')
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let line_end = drill.target[drill.cursor..]
|
||||||
|
.iter()
|
||||||
|
.position(|&c| c == '\n')
|
||||||
|
.map(|offset| drill.cursor + offset)
|
||||||
|
.unwrap_or(drill.target.len());
|
||||||
|
|
||||||
|
let mut end = drill.cursor;
|
||||||
|
while end < line_end {
|
||||||
|
let c = drill.target[end];
|
||||||
|
if c == ' ' || c == '\t' {
|
||||||
|
end += 1;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
end.saturating_sub(drill.cursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_tab_indent(drill: &mut DrillState, len: usize) {
|
||||||
|
for _ in 0..len {
|
||||||
|
drill.input.push(CharStatus::Correct);
|
||||||
|
}
|
||||||
|
drill.cursor = drill.cursor.saturating_add(len);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_auto_indent_after_newline(drill: &mut DrillState) {
|
||||||
|
while drill.cursor < drill.target.len() {
|
||||||
|
let c = drill.target[drill.cursor];
|
||||||
|
if c == ' ' || c == '\t' {
|
||||||
|
drill.input.push(CharStatus::Correct);
|
||||||
|
drill.cursor += 1;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn process_backspace(drill: &mut DrillState) {
|
||||||
|
if drill.cursor == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(span) = drill
|
||||||
|
.synthetic_spans
|
||||||
|
.last()
|
||||||
|
.copied()
|
||||||
|
.filter(|s| s.end == drill.cursor)
|
||||||
|
{
|
||||||
|
let span_len = span.end.saturating_sub(span.start);
|
||||||
|
if span_len > 0 {
|
||||||
|
let has_chained_prev = drill
|
||||||
|
.synthetic_spans
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.nth(1)
|
||||||
|
.is_some_and(|prev| prev.end == span.start);
|
||||||
|
let new_len = drill.input.len().saturating_sub(span_len);
|
||||||
|
drill.input.truncate(new_len);
|
||||||
|
drill.cursor = span.start;
|
||||||
|
for pos in span.start..span.end {
|
||||||
|
drill.typo_flags.remove(&pos);
|
||||||
|
}
|
||||||
|
if !has_chained_prev {
|
||||||
|
drill.typo_flags.insert(span.start);
|
||||||
|
}
|
||||||
|
drill.synthetic_spans.pop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drill.cursor -= 1;
|
||||||
|
drill.input.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_newline_span(drill: &mut DrillState, typed: char) {
|
||||||
|
let start = drill.cursor;
|
||||||
|
let line_end = drill.target[start..]
|
||||||
|
.iter()
|
||||||
|
.position(|&c| c == '\n')
|
||||||
|
.map(|offset| start + offset + 1)
|
||||||
|
.unwrap_or(drill.target.len());
|
||||||
|
let end = line_end.max(start + 1).min(drill.target.len());
|
||||||
|
apply_synthetic_span(drill, start, end, typed, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_tab_span(drill: &mut DrillState, typed: char) {
|
||||||
|
let start = drill.cursor;
|
||||||
|
let line_end = drill.target[start..]
|
||||||
|
.iter()
|
||||||
|
.position(|&c| c == '\n')
|
||||||
|
.map(|offset| start + offset)
|
||||||
|
.unwrap_or(drill.target.len());
|
||||||
|
let mut end = (start + 4).min(line_end);
|
||||||
|
if end <= start {
|
||||||
|
end = (start + 1).min(drill.target.len());
|
||||||
|
}
|
||||||
|
let first_actual = drill.target.get(start).copied();
|
||||||
|
apply_synthetic_span(drill, start, end, typed, first_actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_synthetic_span(
|
||||||
|
drill: &mut DrillState,
|
||||||
|
start: usize,
|
||||||
|
end: usize,
|
||||||
|
typed: char,
|
||||||
|
first_actual: Option<char>,
|
||||||
|
) {
|
||||||
|
if start >= end || start >= drill.target.len() {
|
||||||
|
drill.input.push(CharStatus::Incorrect(typed));
|
||||||
|
drill.typo_flags.insert(drill.cursor);
|
||||||
|
drill.cursor += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for idx in start..end {
|
||||||
|
let actual = if idx == start {
|
||||||
|
first_actual.unwrap_or(typed)
|
||||||
|
} else {
|
||||||
|
drill.target[idx]
|
||||||
|
};
|
||||||
|
drill.input.push(CharStatus::Incorrect(actual));
|
||||||
|
drill.typo_flags.insert(idx);
|
||||||
|
}
|
||||||
|
drill.cursor = end;
|
||||||
|
drill.synthetic_spans.push(SyntheticSpan { start, end });
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
use crate::session::input::CharStatus;
|
|
||||||
|
|
||||||
pub struct LessonState {
|
|
||||||
pub target: Vec<char>,
|
|
||||||
pub input: Vec<CharStatus>,
|
|
||||||
pub cursor: usize,
|
|
||||||
pub started_at: Option<Instant>,
|
|
||||||
pub finished_at: Option<Instant>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LessonState {
|
|
||||||
pub fn new(text: &str) -> Self {
|
|
||||||
Self {
|
|
||||||
target: text.chars().collect(),
|
|
||||||
input: Vec::new(),
|
|
||||||
cursor: 0,
|
|
||||||
started_at: None,
|
|
||||||
finished_at: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_complete(&self) -> bool {
|
|
||||||
self.cursor >= self.target.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn elapsed_secs(&self) -> f64 {
|
|
||||||
match (self.started_at, self.finished_at) {
|
|
||||||
(Some(start), Some(end)) => end.duration_since(start).as_secs_f64(),
|
|
||||||
(Some(start), None) => start.elapsed().as_secs_f64(),
|
|
||||||
_ => 0.0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn correct_count(&self) -> usize {
|
|
||||||
self.input
|
|
||||||
.iter()
|
|
||||||
.filter(|s| matches!(s, CharStatus::Correct))
|
|
||||||
.count()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn incorrect_count(&self) -> usize {
|
|
||||||
self.input
|
|
||||||
.iter()
|
|
||||||
.filter(|s| matches!(s, CharStatus::Incorrect(_)))
|
|
||||||
.count()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn wpm(&self) -> f64 {
|
|
||||||
let elapsed = self.elapsed_secs();
|
|
||||||
if elapsed < 0.1 {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
let chars = self.correct_count() as f64;
|
|
||||||
(chars / 5.0) / (elapsed / 60.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn accuracy(&self) -> f64 {
|
|
||||||
let total = self.input.len();
|
|
||||||
if total == 0 {
|
|
||||||
return 100.0;
|
|
||||||
}
|
|
||||||
(self.correct_count() as f64 / total as f64) * 100.0
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn cpm(&self) -> f64 {
|
|
||||||
let elapsed = self.elapsed_secs();
|
|
||||||
if elapsed < 0.1 {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
self.correct_count() as f64 / (elapsed / 60.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn progress(&self) -> f64 {
|
|
||||||
if self.target.is_empty() {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
self.cursor as f64 / self.target.len() as f64
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_new_lesson() {
|
|
||||||
let lesson = LessonState::new("hello");
|
|
||||||
assert_eq!(lesson.target.len(), 5);
|
|
||||||
assert_eq!(lesson.cursor, 0);
|
|
||||||
assert!(!lesson.is_complete());
|
|
||||||
assert_eq!(lesson.progress(), 0.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_accuracy_starts_at_100() {
|
|
||||||
let lesson = LessonState::new("test");
|
|
||||||
assert_eq!(lesson.accuracy(), 100.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_empty_lesson_progress() {
|
|
||||||
let lesson = LessonState::new("");
|
|
||||||
assert!(lesson.is_complete());
|
|
||||||
assert_eq!(lesson.progress(), 0.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
|
pub mod drill;
|
||||||
pub mod input;
|
pub mod input;
|
||||||
pub mod lesson;
|
|
||||||
pub mod result;
|
pub mod result;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::session::drill::DrillState;
|
||||||
use crate::session::input::KeystrokeEvent;
|
use crate::session::input::KeystrokeEvent;
|
||||||
use crate::session::lesson::LessonState;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct LessonResult {
|
pub struct DrillResult {
|
||||||
pub wpm: f64,
|
pub wpm: f64,
|
||||||
pub cpm: f64,
|
pub cpm: f64,
|
||||||
pub accuracy: f64,
|
pub accuracy: f64,
|
||||||
@@ -15,6 +15,26 @@ 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_drill_mode", alias = "lesson_mode")]
|
||||||
|
pub drill_mode: String,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub ranked: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub partial: bool,
|
||||||
|
#[serde(default = "default_completion_percent")]
|
||||||
|
pub completion_percent: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_drill_mode() -> String {
|
||||||
|
"adaptive".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_completion_percent() -> f64 {
|
||||||
|
100.0
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
@@ -24,8 +44,14 @@ pub struct KeyTime {
|
|||||||
pub correct: bool,
|
pub correct: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LessonResult {
|
impl DrillResult {
|
||||||
pub fn from_lesson(lesson: &LessonState, events: &[KeystrokeEvent]) -> Self {
|
pub fn from_drill(
|
||||||
|
drill: &DrillState,
|
||||||
|
events: &[KeystrokeEvent],
|
||||||
|
drill_mode: &str,
|
||||||
|
ranked: bool,
|
||||||
|
partial: bool,
|
||||||
|
) -> 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 +64,28 @@ impl LessonResult {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
let total_chars = drill.target.len();
|
||||||
|
let typo_count = drill.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: drill.wpm(),
|
||||||
cpm: lesson.cpm(),
|
cpm: drill.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: drill.elapsed_secs(),
|
||||||
timestamp: Utc::now(),
|
timestamp: Utc::now(),
|
||||||
per_key_times,
|
per_key_times,
|
||||||
|
drill_mode: drill_mode.to_string(),
|
||||||
|
ranked,
|
||||||
|
partial,
|
||||||
|
completion_percent: (drill.progress() * 100.0).clamp(0.0, 100.0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ use std::io::Write;
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use serde::{de::DeserializeOwned, Serialize};
|
use serde::{Serialize, de::DeserializeOwned};
|
||||||
|
|
||||||
use crate::store::schema::{KeyStatsData, LessonHistoryData, ProfileData};
|
use crate::store::schema::{DrillHistoryData, KeyStatsData, ProfileData};
|
||||||
|
|
||||||
pub struct JsonStore {
|
pub struct JsonStore {
|
||||||
base_dir: PathBuf,
|
base_dir: PathBuf,
|
||||||
@@ -49,8 +49,17 @@ impl JsonStore {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_profile(&self) -> ProfileData {
|
/// Load and deserialize profile. Returns None if file exists but
|
||||||
self.load("profile.json")
|
/// cannot be parsed (schema mismatch / corruption).
|
||||||
|
pub fn load_profile(&self) -> Option<ProfileData> {
|
||||||
|
let path = self.file_path("profile.json");
|
||||||
|
if path.exists() {
|
||||||
|
let content = fs::read_to_string(&path).ok()?;
|
||||||
|
serde_json::from_str(&content).ok()
|
||||||
|
} else {
|
||||||
|
// No file yet — return fresh default (not a schema mismatch)
|
||||||
|
Some(ProfileData::default())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save_profile(&self, data: &ProfileData) -> Result<()> {
|
pub fn save_profile(&self, data: &ProfileData) -> Result<()> {
|
||||||
@@ -65,11 +74,11 @@ impl JsonStore {
|
|||||||
self.save("key_stats.json", data)
|
self.save("key_stats.json", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_lesson_history(&self) -> LessonHistoryData {
|
pub fn load_drill_history(&self) -> DrillHistoryData {
|
||||||
self.load("lesson_history.json")
|
self.load("lesson_history.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save_lesson_history(&self, data: &LessonHistoryData) -> Result<()> {
|
pub fn save_drill_history(&self, data: &DrillHistoryData) -> Result<()> {
|
||||||
self.save("lesson_history.json", data)
|
self.save("lesson_history.json", data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::engine::key_stats::KeyStatsStore;
|
use crate::engine::key_stats::KeyStatsStore;
|
||||||
use crate::session::result::LessonResult;
|
use crate::engine::skill_tree::SkillTreeProgress;
|
||||||
|
use crate::session::result::DrillResult;
|
||||||
|
|
||||||
const SCHEMA_VERSION: u32 = 1;
|
const SCHEMA_VERSION: u32 = 2;
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct ProfileData {
|
pub struct ProfileData {
|
||||||
pub schema_version: u32,
|
pub schema_version: u32,
|
||||||
pub unlocked_letters: Vec<char>,
|
pub skill_tree: SkillTreeProgress,
|
||||||
pub total_score: f64,
|
pub total_score: f64,
|
||||||
pub total_lessons: u32,
|
#[serde(alias = "total_lessons")]
|
||||||
|
pub total_drills: u32,
|
||||||
pub streak_days: u32,
|
pub streak_days: u32,
|
||||||
pub best_streak: u32,
|
pub best_streak: u32,
|
||||||
pub last_practice_date: Option<String>,
|
pub last_practice_date: Option<String>,
|
||||||
@@ -20,9 +22,9 @@ impl Default for ProfileData {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
schema_version: SCHEMA_VERSION,
|
schema_version: SCHEMA_VERSION,
|
||||||
unlocked_letters: Vec::new(),
|
skill_tree: SkillTreeProgress::default(),
|
||||||
total_score: 0.0,
|
total_score: 0.0,
|
||||||
total_lessons: 0,
|
total_drills: 0,
|
||||||
streak_days: 0,
|
streak_days: 0,
|
||||||
best_streak: 0,
|
best_streak: 0,
|
||||||
last_practice_date: None,
|
last_practice_date: None,
|
||||||
@@ -30,6 +32,13 @@ impl Default for ProfileData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ProfileData {
|
||||||
|
/// Check if loaded data has a stale schema version and needs reset.
|
||||||
|
pub fn needs_reset(&self) -> bool {
|
||||||
|
self.schema_version != SCHEMA_VERSION
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct KeyStatsData {
|
pub struct KeyStatsData {
|
||||||
pub schema_version: u32,
|
pub schema_version: u32,
|
||||||
@@ -46,16 +55,17 @@ impl Default for KeyStatsData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct LessonHistoryData {
|
pub struct DrillHistoryData {
|
||||||
pub schema_version: u32,
|
pub schema_version: u32,
|
||||||
pub lessons: Vec<LessonResult>,
|
#[serde(alias = "lessons")]
|
||||||
|
pub drills: Vec<DrillResult>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for LessonHistoryData {
|
impl Default for DrillHistoryData {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
schema_version: SCHEMA_VERSION,
|
schema_version: SCHEMA_VERSION,
|
||||||
lessons: Vec::new(),
|
drills: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
161
src/ui/components/activity_heatmap.rs
Normal file
161
src/ui/components/activity_heatmap.rs
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use chrono::{Datelike, NaiveDate, Utc};
|
||||||
|
use ratatui::buffer::Buffer;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Widget};
|
||||||
|
|
||||||
|
use crate::session::result::DrillResult;
|
||||||
|
use crate::ui::theme::Theme;
|
||||||
|
|
||||||
|
pub struct ActivityHeatmap<'a> {
|
||||||
|
history: &'a [DrillResult],
|
||||||
|
theme: &'a Theme,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> ActivityHeatmap<'a> {
|
||||||
|
pub fn new(history: &'a [DrillResult], 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(Line::from(Span::styled(
|
||||||
|
" Daily Activity (Sessions per Day) ",
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.accent())
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)))
|
||||||
|
.border_style(Style::default().fg(colors.accent()));
|
||||||
|
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.iter().filter(|r| !r.partial) {
|
||||||
|
let date = result.timestamp.date_naive();
|
||||||
|
*day_counts.entry(date).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let today = Utc::now().date_naive();
|
||||||
|
let end_date = today;
|
||||||
|
// 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 = end_date - 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 <= end_date {
|
||||||
|
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 > end_date {
|
||||||
|
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 color = intensity_cell_bg(count, colors);
|
||||||
|
// Fill both columns so low-activity cells render as blocks instead of glyphs.
|
||||||
|
// This avoids cursor-like artifacts in some terminal fonts.
|
||||||
|
buf.set_string(x, y, " ", Style::default().bg(color).fg(colors.bg()));
|
||||||
|
}
|
||||||
|
|
||||||
|
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_bg(count: usize, colors: &crate::ui::theme::ThemeColors) -> Color {
|
||||||
|
let success = colors.success();
|
||||||
|
match count {
|
||||||
|
0 => scale_color(colors.accent_dim(), 0.35),
|
||||||
|
1..=2 => scale_color(success, 0.35),
|
||||||
|
3..=5 => scale_color(success, 0.6),
|
||||||
|
6..=15 => scale_color(success, 0.8),
|
||||||
|
_ => success,
|
||||||
|
}
|
||||||
|
}
|
||||||
145
src/ui/components/branch_progress_list.rs
Normal file
145
src/ui/components/branch_progress_list.rs
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
use ratatui::buffer::Buffer;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::style::Style;
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Paragraph, Widget};
|
||||||
|
|
||||||
|
use crate::engine::skill_tree::{BranchId, DrillScope, SkillTree, get_branch_definition};
|
||||||
|
use crate::ui::theme::Theme;
|
||||||
|
|
||||||
|
pub struct BranchProgressList<'a> {
|
||||||
|
pub skill_tree: &'a SkillTree,
|
||||||
|
pub key_stats: &'a crate::engine::key_stats::KeyStatsStore,
|
||||||
|
pub drill_scope: DrillScope,
|
||||||
|
pub active_branches: &'a [BranchId],
|
||||||
|
pub theme: &'a Theme,
|
||||||
|
pub height: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Widget for BranchProgressList<'_> {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let colors = &self.theme.colors;
|
||||||
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
|
|
||||||
|
let drill_branch = match self.drill_scope {
|
||||||
|
DrillScope::Branch(id) => Some(id),
|
||||||
|
DrillScope::Global => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let show_all = self.height > 2;
|
||||||
|
|
||||||
|
if show_all {
|
||||||
|
for &branch_id in self.active_branches {
|
||||||
|
if lines.len() as u16 >= self.height.saturating_sub(1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let def = get_branch_definition(branch_id);
|
||||||
|
let total = SkillTree::branch_total_keys(branch_id);
|
||||||
|
let unlocked = self.skill_tree.branch_unlocked_count(branch_id);
|
||||||
|
let mastered = self
|
||||||
|
.skill_tree
|
||||||
|
.branch_confident_keys(branch_id, self.key_stats);
|
||||||
|
let is_active = drill_branch == Some(branch_id);
|
||||||
|
let prefix = if is_active {
|
||||||
|
" \u{25b6} "
|
||||||
|
} else {
|
||||||
|
" \u{00b7} "
|
||||||
|
};
|
||||||
|
let (m_bar, u_bar, e_bar) = compact_dual_bar_parts(mastered, unlocked, total, 12);
|
||||||
|
let name = format!("{:<14}", def.name);
|
||||||
|
let label_color = if is_active {
|
||||||
|
colors.accent()
|
||||||
|
} else {
|
||||||
|
colors.text_pending()
|
||||||
|
};
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(prefix, Style::default().fg(label_color)),
|
||||||
|
Span::styled(name, Style::default().fg(label_color)),
|
||||||
|
Span::styled(m_bar, Style::default().fg(colors.text_correct())),
|
||||||
|
Span::styled(u_bar, Style::default().fg(colors.accent())),
|
||||||
|
Span::styled(e_bar, Style::default().fg(colors.text_pending())),
|
||||||
|
Span::styled(
|
||||||
|
format!(" {unlocked}/{total}"),
|
||||||
|
Style::default().fg(colors.text_pending()),
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
} else if let Some(branch_id) = drill_branch {
|
||||||
|
let def = get_branch_definition(branch_id);
|
||||||
|
let total = SkillTree::branch_total_keys(branch_id);
|
||||||
|
let unlocked = self.skill_tree.branch_unlocked_count(branch_id);
|
||||||
|
let mastered = self
|
||||||
|
.skill_tree
|
||||||
|
.branch_confident_keys(branch_id, self.key_stats);
|
||||||
|
let (m_bar, u_bar, e_bar) = compact_dual_bar_parts(mastered, unlocked, total, 12);
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
format!(" \u{25b6} {:<14}", def.name),
|
||||||
|
Style::default().fg(colors.accent()),
|
||||||
|
),
|
||||||
|
Span::styled(m_bar, Style::default().fg(colors.text_correct())),
|
||||||
|
Span::styled(u_bar, Style::default().fg(colors.accent())),
|
||||||
|
Span::styled(e_bar, Style::default().fg(colors.text_pending())),
|
||||||
|
Span::styled(
|
||||||
|
format!(" {unlocked}/{total}"),
|
||||||
|
Style::default().fg(colors.text_pending()),
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overall line
|
||||||
|
if lines.len() < self.height as usize {
|
||||||
|
let total = self.skill_tree.total_unique_keys;
|
||||||
|
let unlocked = self.skill_tree.total_unlocked_count();
|
||||||
|
let mastered = self.skill_tree.total_confident_keys(self.key_stats);
|
||||||
|
let left_pad = if area.width >= 90 {
|
||||||
|
3
|
||||||
|
} else if area.width >= 70 {
|
||||||
|
2
|
||||||
|
} else if area.width >= 55 {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let right_pad = if area.width >= 75 { 2 } else { 0 };
|
||||||
|
let label = format!("{}Overall Key Progress ", " ".repeat(left_pad));
|
||||||
|
let suffix = format!(
|
||||||
|
" {unlocked}/{total} unlocked ({mastered} mastered){}",
|
||||||
|
" ".repeat(right_pad)
|
||||||
|
);
|
||||||
|
let reserved = label.len() + suffix.len();
|
||||||
|
let bar_width = (area.width as usize).saturating_sub(reserved).max(6);
|
||||||
|
let (m_bar, u_bar, e_bar) =
|
||||||
|
compact_dual_bar_parts(mastered, unlocked, total, bar_width);
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(label, Style::default().fg(colors.fg())),
|
||||||
|
Span::styled(m_bar, Style::default().fg(colors.text_correct())),
|
||||||
|
Span::styled(u_bar, Style::default().fg(colors.accent())),
|
||||||
|
Span::styled(e_bar, Style::default().fg(colors.text_pending())),
|
||||||
|
Span::styled(suffix, Style::default().fg(colors.text_pending())),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(lines);
|
||||||
|
paragraph.render(area, buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compact_dual_bar_parts(
|
||||||
|
mastered: usize,
|
||||||
|
unlocked: usize,
|
||||||
|
total: usize,
|
||||||
|
width: usize,
|
||||||
|
) -> (String, String, String) {
|
||||||
|
if total == 0 {
|
||||||
|
return (String::new(), String::new(), "\u{2591}".repeat(width));
|
||||||
|
}
|
||||||
|
let mastered_cells = mastered * width / total;
|
||||||
|
let unlocked_cells = (unlocked * width / total).max(mastered_cells);
|
||||||
|
let empty_cells = width - unlocked_cells;
|
||||||
|
(
|
||||||
|
"\u{2588}".repeat(mastered_cells),
|
||||||
|
"\u{2593}".repeat(unlocked_cells - mastered_cells),
|
||||||
|
"\u{2591}".repeat(empty_cells),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -51,7 +53,7 @@ impl Widget for WpmChart<'_> {
|
|||||||
)
|
)
|
||||||
.x_axis(
|
.x_axis(
|
||||||
Axis::default()
|
Axis::default()
|
||||||
.title("Lesson")
|
.title("Drill #")
|
||||||
.style(Style::default().fg(colors.text_pending()))
|
.style(Style::default().fg(colors.text_pending()))
|
||||||
.bounds([0.0, max_x]),
|
.bounds([0.0, max_x]),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,16 +4,16 @@ use ratatui::style::{Modifier, Style};
|
|||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, Paragraph, Widget};
|
use ratatui::widgets::{Block, Paragraph, Widget};
|
||||||
|
|
||||||
use crate::session::result::LessonResult;
|
use crate::session::result::DrillResult;
|
||||||
use crate::ui::theme::Theme;
|
use crate::ui::theme::Theme;
|
||||||
|
|
||||||
pub struct Dashboard<'a> {
|
pub struct Dashboard<'a> {
|
||||||
pub result: &'a LessonResult,
|
pub result: &'a DrillResult,
|
||||||
pub theme: &'a Theme,
|
pub theme: &'a Theme,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Dashboard<'a> {
|
impl<'a> Dashboard<'a> {
|
||||||
pub fn new(result: &'a LessonResult, theme: &'a Theme) -> Self {
|
pub fn new(result: &'a DrillResult, theme: &'a Theme) -> Self {
|
||||||
Self { result, theme }
|
Self { result, theme }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -23,7 +23,7 @@ impl Widget for Dashboard<'_> {
|
|||||||
let colors = &self.theme.colors;
|
let colors = &self.theme.colors;
|
||||||
|
|
||||||
let block = Block::bordered()
|
let block = Block::bordered()
|
||||||
.title(" Lesson Complete ")
|
.title(" Drill Complete ")
|
||||||
.border_style(Style::default().fg(colors.accent()))
|
.border_style(Style::default().fg(colors.accent()))
|
||||||
.style(Style::default().bg(colors.bg()));
|
.style(Style::default().bg(colors.bg()));
|
||||||
let inner = block.inner(area);
|
let inner = block.inner(area);
|
||||||
@@ -42,13 +42,19 @@ impl Widget for Dashboard<'_> {
|
|||||||
])
|
])
|
||||||
.split(inner);
|
.split(inner);
|
||||||
|
|
||||||
let title = Paragraph::new(Line::from(Span::styled(
|
let mut title_spans = vec![Span::styled(
|
||||||
"Results",
|
"Results",
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(colors.accent())
|
.fg(colors.accent())
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
)))
|
)];
|
||||||
.alignment(Alignment::Center);
|
if !self.result.ranked {
|
||||||
|
title_spans.push(Span::styled(
|
||||||
|
" (Unranked \u{2014} does not count toward skill tree)",
|
||||||
|
Style::default().fg(colors.text_pending()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let title = Paragraph::new(Line::from(title_spans)).alignment(Alignment::Center);
|
||||||
title.render(layout[0], buf);
|
title.render(layout[0], buf);
|
||||||
|
|
||||||
let wpm_text = format!("{:.0} WPM", self.result.wpm);
|
let wpm_text = format!("{:.0} WPM", self.result.wpm);
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
|
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::{Finger, Hand};
|
||||||
|
use crate::keyboard::model::KeyboardModel;
|
||||||
use crate::ui::theme::Theme;
|
use crate::ui::theme::Theme;
|
||||||
|
|
||||||
pub struct KeyboardDiagram<'a> {
|
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,
|
||||||
|
pub model: &'a KeyboardModel,
|
||||||
|
pub shift_held: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> KeyboardDiagram<'a> {
|
impl<'a> KeyboardDiagram<'a> {
|
||||||
@@ -18,25 +25,35 @@ 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,
|
||||||
|
model: &'a KeyboardModel,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
focused_key,
|
focused_key,
|
||||||
next_key,
|
next_key,
|
||||||
unlocked_keys,
|
unlocked_keys,
|
||||||
|
depressed_keys,
|
||||||
theme,
|
theme,
|
||||||
|
compact: false,
|
||||||
|
model,
|
||||||
|
shift_held: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn compact(mut self, compact: bool) -> Self {
|
||||||
|
self.compact = compact;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn shift_held(mut self, shift_held: bool) -> Self {
|
||||||
|
self.shift_held = shift_held;
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROWS: &[&[char]] = &[
|
fn finger_color(model: &KeyboardModel, ch: char) -> Color {
|
||||||
&['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'],
|
let assignment = model.finger_for_char(ch);
|
||||||
&['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l'],
|
|
||||||
&['z', 'x', 'c', 'v', 'b', 'n', 'm'],
|
|
||||||
];
|
|
||||||
|
|
||||||
fn finger_color(ch: char) -> Color {
|
|
||||||
let assignment = finger::qwerty_finger(ch);
|
|
||||||
match (assignment.hand, assignment.finger) {
|
match (assignment.hand, assignment.finger) {
|
||||||
(Hand::Left, Finger::Pinky) => Color::Rgb(180, 100, 100),
|
(Hand::Left, Finger::Pinky) => Color::Rgb(180, 100, 100),
|
||||||
(Hand::Left, Finger::Ring) => Color::Rgb(180, 140, 80),
|
(Hand::Left, Finger::Ring) => Color::Rgb(180, 140, 80),
|
||||||
@@ -50,6 +67,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,14 +89,19 @@ 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 {
|
if self.compact {
|
||||||
|
// Compact mode: letter rows only (rows 1-3 of the model)
|
||||||
|
let letter_rows = self.model.letter_rows();
|
||||||
|
let key_width: u16 = 3;
|
||||||
|
let min_width: u16 = 21;
|
||||||
|
|
||||||
|
if inner.height < 3 || inner.width < min_width {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let key_width: u16 = 5;
|
let offsets: &[u16] = &[0, 1, 3];
|
||||||
let offsets: &[u16] = &[1, 3, 5];
|
|
||||||
|
|
||||||
for (row_idx, row) in ROWS.iter().enumerate() {
|
for (row_idx, row) in letter_rows.iter().enumerate() {
|
||||||
let y = inner.y + row_idx as u16;
|
let y = inner.y + row_idx as u16;
|
||||||
if y >= inner.y + inner.height {
|
if y >= inner.y + inner.height {
|
||||||
break;
|
break;
|
||||||
@@ -76,37 +109,214 @@ impl Widget for KeyboardDiagram<'_> {
|
|||||||
|
|
||||||
let offset = offsets.get(row_idx).copied().unwrap_or(0);
|
let offset = offsets.get(row_idx).copied().unwrap_or(0);
|
||||||
|
|
||||||
for (col_idx, &key) in row.iter().enumerate() {
|
for (col_idx, physical_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 + key_width > inner.x + inner.width {
|
if x + key_width > inner.x + inner.width {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
let is_unlocked = self.unlocked_keys.contains(&key);
|
let display_char = if self.shift_held {
|
||||||
let is_focused = self.focused_key == Some(key);
|
physical_key.shifted
|
||||||
let is_next = self.next_key == Some(key);
|
|
||||||
|
|
||||||
let style = if is_next {
|
|
||||||
Style::default()
|
|
||||||
.fg(colors.bg())
|
|
||||||
.bg(colors.accent())
|
|
||||||
} else if is_focused {
|
|
||||||
Style::default()
|
|
||||||
.fg(colors.bg())
|
|
||||||
.bg(colors.focused_key())
|
|
||||||
} else if is_unlocked {
|
|
||||||
Style::default()
|
|
||||||
.fg(colors.fg())
|
|
||||||
.bg(finger_color(key))
|
|
||||||
} else {
|
} else {
|
||||||
Style::default()
|
physical_key.base
|
||||||
.fg(colors.text_pending())
|
|
||||||
.bg(colors.bg())
|
|
||||||
};
|
};
|
||||||
|
let base_char = physical_key.base;
|
||||||
|
|
||||||
let display = format!("[ {key} ]");
|
let is_depressed = self.depressed_keys.contains(&base_char);
|
||||||
|
let is_unlocked = self.unlocked_keys.contains(&display_char)
|
||||||
|
|| self.unlocked_keys.contains(&base_char);
|
||||||
|
let is_focused = self.focused_key == Some(display_char)
|
||||||
|
|| self.focused_key == Some(base_char);
|
||||||
|
let is_next =
|
||||||
|
self.next_key == Some(display_char) || self.next_key == Some(base_char);
|
||||||
|
|
||||||
|
let style = key_style(
|
||||||
|
is_depressed,
|
||||||
|
is_next,
|
||||||
|
is_focused,
|
||||||
|
is_unlocked,
|
||||||
|
base_char,
|
||||||
|
self.model,
|
||||||
|
colors,
|
||||||
|
);
|
||||||
|
|
||||||
|
let display = format!("[{display_char}]");
|
||||||
buf.set_string(x, y, &display, style);
|
buf.set_string(x, y, &display, style);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Full mode: all 4 rows
|
||||||
|
let key_width: u16 = 5;
|
||||||
|
let min_width: u16 = 69;
|
||||||
|
|
||||||
|
if inner.height < 4 || inner.width < min_width {
|
||||||
|
// Fallback to compact-style if too narrow for full
|
||||||
|
let letter_rows = self.model.letter_rows();
|
||||||
|
let key_width: u16 = 5;
|
||||||
|
let offsets: &[u16] = &[1, 3, 5];
|
||||||
|
|
||||||
|
if inner.height < 3 || inner.width < 30 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (row_idx, row) in letter_rows.iter().enumerate() {
|
||||||
|
let y = inner.y + row_idx as u16;
|
||||||
|
if y >= inner.y + inner.height {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let offset = offsets.get(row_idx).copied().unwrap_or(0);
|
||||||
|
|
||||||
|
for (col_idx, physical_key) in row.iter().enumerate() {
|
||||||
|
let x = inner.x + offset + col_idx as u16 * key_width;
|
||||||
|
if x + key_width > inner.x + inner.width {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let display_char = if self.shift_held {
|
||||||
|
physical_key.shifted
|
||||||
|
} else {
|
||||||
|
physical_key.base
|
||||||
|
};
|
||||||
|
let base_char = physical_key.base;
|
||||||
|
|
||||||
|
let is_depressed = self.depressed_keys.contains(&base_char);
|
||||||
|
let is_unlocked = self.unlocked_keys.contains(&display_char)
|
||||||
|
|| self.unlocked_keys.contains(&base_char);
|
||||||
|
let is_focused = self.focused_key == Some(display_char)
|
||||||
|
|| self.focused_key == Some(base_char);
|
||||||
|
let is_next =
|
||||||
|
self.next_key == Some(display_char) || self.next_key == Some(base_char);
|
||||||
|
|
||||||
|
let style = key_style(
|
||||||
|
is_depressed,
|
||||||
|
is_next,
|
||||||
|
is_focused,
|
||||||
|
is_unlocked,
|
||||||
|
base_char,
|
||||||
|
self.model,
|
||||||
|
colors,
|
||||||
|
);
|
||||||
|
|
||||||
|
let display = format!("[ {display_char} ]");
|
||||||
|
buf.set_string(x, y, &display, style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row offsets for full layout (staggered keyboard)
|
||||||
|
let offsets: &[u16] = &[0, 2, 3, 4];
|
||||||
|
|
||||||
|
for (row_idx, row) in self.model.rows.iter().enumerate() {
|
||||||
|
let y = inner.y + row_idx as u16;
|
||||||
|
if y >= inner.y + inner.height {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let offset = offsets.get(row_idx).copied().unwrap_or(0);
|
||||||
|
|
||||||
|
for (col_idx, physical_key) in row.iter().enumerate() {
|
||||||
|
let x = inner.x + offset + col_idx as u16 * key_width;
|
||||||
|
if x + key_width > inner.x + inner.width {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let display_char = if self.shift_held {
|
||||||
|
physical_key.shifted
|
||||||
|
} else {
|
||||||
|
physical_key.base
|
||||||
|
};
|
||||||
|
let base_char = physical_key.base;
|
||||||
|
|
||||||
|
let is_depressed = self.depressed_keys.contains(&base_char);
|
||||||
|
let is_unlocked = self.unlocked_keys.contains(&display_char)
|
||||||
|
|| self.unlocked_keys.contains(&base_char);
|
||||||
|
let is_focused = self.focused_key == Some(display_char)
|
||||||
|
|| self.focused_key == Some(base_char);
|
||||||
|
let is_next =
|
||||||
|
self.next_key == Some(display_char) || self.next_key == Some(base_char);
|
||||||
|
|
||||||
|
let style = key_style(
|
||||||
|
is_depressed,
|
||||||
|
is_next,
|
||||||
|
is_focused,
|
||||||
|
is_unlocked,
|
||||||
|
base_char,
|
||||||
|
self.model,
|
||||||
|
colors,
|
||||||
|
);
|
||||||
|
|
||||||
|
let display = format!("[ {display_char} ]");
|
||||||
|
buf.set_string(x, y, &display, style);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modifier labels at row edges (visual only)
|
||||||
|
let label_style = Style::default().fg(colors.text_pending());
|
||||||
|
let after_x = inner.x + offset + row.len() as u16 * key_width + 1;
|
||||||
|
match row_idx {
|
||||||
|
0 => {
|
||||||
|
// Backspace after number row
|
||||||
|
if after_x + 4 <= inner.x + inner.width {
|
||||||
|
buf.set_string(after_x, y, "Bksp", label_style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
1 => {
|
||||||
|
// Tab before top row, backslash already in row
|
||||||
|
if offset >= 3 {
|
||||||
|
buf.set_string(inner.x, y, "Tab", label_style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
2 => {
|
||||||
|
// Enter after home row
|
||||||
|
if after_x + 5 <= inner.x + inner.width {
|
||||||
|
buf.set_string(after_x, y, "Enter", label_style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
3 => {
|
||||||
|
// Shift before and after bottom row
|
||||||
|
if offset >= 5 {
|
||||||
|
buf.set_string(inner.x, y, "Shft", label_style);
|
||||||
|
}
|
||||||
|
if after_x + 4 <= inner.x + inner.width {
|
||||||
|
buf.set_string(after_x, y, "Shft", label_style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key_style(
|
||||||
|
is_depressed: bool,
|
||||||
|
is_next: bool,
|
||||||
|
is_focused: bool,
|
||||||
|
is_unlocked: bool,
|
||||||
|
base_char: char,
|
||||||
|
model: &KeyboardModel,
|
||||||
|
colors: &crate::ui::theme::ThemeColors,
|
||||||
|
) -> Style {
|
||||||
|
if is_depressed {
|
||||||
|
let bg = if is_unlocked {
|
||||||
|
brighten_color(finger_color(model, base_char))
|
||||||
|
} else {
|
||||||
|
brighten_color(colors.accent_dim())
|
||||||
|
};
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::White)
|
||||||
|
.bg(bg)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else if is_next {
|
||||||
|
Style::default().fg(colors.bg()).bg(colors.accent())
|
||||||
|
} else if is_focused {
|
||||||
|
Style::default().fg(colors.bg()).bg(colors.focused_key())
|
||||||
|
} else if is_unlocked {
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.fg())
|
||||||
|
.bg(finger_color(model, base_char))
|
||||||
|
} else {
|
||||||
|
Style::default().fg(colors.text_pending()).bg(colors.bg())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,19 +24,24 @@ impl<'a> Menu<'a> {
|
|||||||
items: vec![
|
items: vec![
|
||||||
MenuItem {
|
MenuItem {
|
||||||
key: "1".to_string(),
|
key: "1".to_string(),
|
||||||
label: "Adaptive Practice".to_string(),
|
label: "Adaptive Drill".to_string(),
|
||||||
description: "Phonetic words with adaptive letter unlocking".to_string(),
|
description: "Phonetic words with adaptive letter unlocking".to_string(),
|
||||||
},
|
},
|
||||||
MenuItem {
|
MenuItem {
|
||||||
key: "2".to_string(),
|
key: "2".to_string(),
|
||||||
label: "Code Practice".to_string(),
|
label: "Code Drill".to_string(),
|
||||||
description: "Practice typing code syntax".to_string(),
|
description: "Practice typing code syntax".to_string(),
|
||||||
},
|
},
|
||||||
MenuItem {
|
MenuItem {
|
||||||
key: "3".to_string(),
|
key: "3".to_string(),
|
||||||
label: "Passage Mode".to_string(),
|
label: "Passage Drill".to_string(),
|
||||||
description: "Type passages from books".to_string(),
|
description: "Type passages from books".to_string(),
|
||||||
},
|
},
|
||||||
|
MenuItem {
|
||||||
|
key: "t".to_string(),
|
||||||
|
label: "Skill Tree".to_string(),
|
||||||
|
description: "View progression branches and launch drills".to_string(),
|
||||||
|
},
|
||||||
MenuItem {
|
MenuItem {
|
||||||
key: "s".to_string(),
|
key: "s".to_string(),
|
||||||
label: "Statistics".to_string(),
|
label: "Statistics".to_string(),
|
||||||
@@ -112,13 +117,29 @@ impl Widget for &Menu<'_> {
|
|||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
)
|
)
|
||||||
.split(layout[2]);
|
.split(layout[2]);
|
||||||
|
let key_width = self
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.map(|item| item.key.len())
|
||||||
|
.max()
|
||||||
|
.unwrap_or(1);
|
||||||
|
|
||||||
for (i, item) in self.items.iter().enumerate() {
|
for (i, item) in self.items.iter().enumerate() {
|
||||||
let is_selected = i == self.selected;
|
let is_selected = i == self.selected;
|
||||||
let indicator = if is_selected { ">" } else { " " };
|
let indicator = if is_selected { ">" } else { " " };
|
||||||
|
|
||||||
let label_text = format!(" {indicator} [{key}] {label}", key = item.key, label = item.label);
|
let label_text = format!(
|
||||||
let desc_text = format!(" {}", item.description);
|
" {indicator} [{key:<key_width$}] {label}",
|
||||||
|
key = item.key,
|
||||||
|
key_width = key_width,
|
||||||
|
label = item.label
|
||||||
|
);
|
||||||
|
let desc_text = format!(
|
||||||
|
" {:indent$}{}",
|
||||||
|
"",
|
||||||
|
item.description,
|
||||||
|
indent = key_width + 4
|
||||||
|
);
|
||||||
|
|
||||||
let lines = vec![
|
let lines = vec![
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
pub mod activity_heatmap;
|
||||||
|
pub mod branch_progress_list;
|
||||||
pub mod chart;
|
pub mod chart;
|
||||||
pub mod dashboard;
|
pub mod dashboard;
|
||||||
pub mod keyboard_diagram;
|
pub mod keyboard_diagram;
|
||||||
pub mod menu;
|
pub mod menu;
|
||||||
pub mod progress_bar;
|
pub mod skill_tree;
|
||||||
pub mod stats_dashboard;
|
pub mod stats_dashboard;
|
||||||
pub mod stats_sidebar;
|
pub mod stats_sidebar;
|
||||||
pub mod typing_area;
|
pub mod typing_area;
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
use ratatui::buffer::Buffer;
|
|
||||||
use ratatui::layout::Rect;
|
|
||||||
use ratatui::style::Style;
|
|
||||||
use ratatui::widgets::{Block, Widget};
|
|
||||||
|
|
||||||
use crate::ui::theme::Theme;
|
|
||||||
|
|
||||||
pub struct ProgressBar<'a> {
|
|
||||||
pub label: String,
|
|
||||||
pub ratio: f64,
|
|
||||||
pub theme: &'a Theme,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> ProgressBar<'a> {
|
|
||||||
pub fn new(label: &str, ratio: f64, theme: &'a Theme) -> Self {
|
|
||||||
Self {
|
|
||||||
label: label.to_string(),
|
|
||||||
ratio: ratio.clamp(0.0, 1.0),
|
|
||||||
theme,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Widget for ProgressBar<'_> {
|
|
||||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
|
||||||
let colors = &self.theme.colors;
|
|
||||||
|
|
||||||
let block = Block::bordered()
|
|
||||||
.title(format!(" {} ", self.label))
|
|
||||||
.border_style(Style::default().fg(colors.border()));
|
|
||||||
let inner = block.inner(area);
|
|
||||||
block.render(area, buf);
|
|
||||||
|
|
||||||
if inner.width == 0 || inner.height == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let filled_width = (self.ratio * inner.width as f64) as u16;
|
|
||||||
let label = format!("{:.0}%", self.ratio * 100.0);
|
|
||||||
|
|
||||||
for x in inner.x..inner.x + inner.width {
|
|
||||||
let style = if x < inner.x + filled_width {
|
|
||||||
Style::default().fg(colors.bg()).bg(colors.bar_filled())
|
|
||||||
} else {
|
|
||||||
Style::default().fg(colors.fg()).bg(colors.bar_empty())
|
|
||||||
};
|
|
||||||
buf[(x, inner.y)].set_style(style);
|
|
||||||
}
|
|
||||||
|
|
||||||
let label_x = inner.x + (inner.width.saturating_sub(label.len() as u16)) / 2;
|
|
||||||
buf.set_string(label_x, inner.y, &label, Style::default().fg(colors.fg()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
481
src/ui/components/skill_tree.rs
Normal file
481
src/ui/components/skill_tree.rs
Normal file
@@ -0,0 +1,481 @@
|
|||||||
|
use ratatui::buffer::Buffer;
|
||||||
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||||
|
use ratatui::style::{Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Paragraph, Widget, Wrap};
|
||||||
|
|
||||||
|
use crate::engine::key_stats::KeyStatsStore;
|
||||||
|
use crate::engine::skill_tree::{
|
||||||
|
BranchId, BranchStatus, DrillScope, SkillTree as SkillTreeEngine, get_branch_definition,
|
||||||
|
};
|
||||||
|
use crate::ui::theme::Theme;
|
||||||
|
|
||||||
|
pub struct SkillTreeWidget<'a> {
|
||||||
|
skill_tree: &'a SkillTreeEngine,
|
||||||
|
key_stats: &'a KeyStatsStore,
|
||||||
|
selected: usize,
|
||||||
|
detail_scroll: usize,
|
||||||
|
theme: &'a Theme,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> SkillTreeWidget<'a> {
|
||||||
|
pub fn new(
|
||||||
|
skill_tree: &'a SkillTreeEngine,
|
||||||
|
key_stats: &'a KeyStatsStore,
|
||||||
|
selected: usize,
|
||||||
|
detail_scroll: usize,
|
||||||
|
theme: &'a Theme,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
skill_tree,
|
||||||
|
key_stats,
|
||||||
|
selected,
|
||||||
|
detail_scroll,
|
||||||
|
theme,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the list of selectable branch IDs (Lowercase first, then other branches).
|
||||||
|
pub fn selectable_branches() -> Vec<BranchId> {
|
||||||
|
vec![
|
||||||
|
BranchId::Lowercase,
|
||||||
|
BranchId::Capitals,
|
||||||
|
BranchId::Numbers,
|
||||||
|
BranchId::ProsePunctuation,
|
||||||
|
BranchId::Whitespace,
|
||||||
|
BranchId::CodeSymbols,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detail_line_count(branch_id: BranchId) -> usize {
|
||||||
|
let def = get_branch_definition(branch_id);
|
||||||
|
// 1 line branch header + for each level: 1 line level header + 1 line per key
|
||||||
|
1 + def
|
||||||
|
.levels
|
||||||
|
.iter()
|
||||||
|
.map(|level| 1 + level.keys.len())
|
||||||
|
.sum::<usize>()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Widget for SkillTreeWidget<'_> {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let colors = &self.theme.colors;
|
||||||
|
|
||||||
|
let block = Block::bordered()
|
||||||
|
.title(" Skill Tree ")
|
||||||
|
.border_style(Style::default().fg(colors.accent()))
|
||||||
|
.style(Style::default().bg(colors.bg()));
|
||||||
|
let inner = block.inner(area);
|
||||||
|
block.render(area, buf);
|
||||||
|
|
||||||
|
// Layout: branch list, separator, detail panel, footer (adaptive height)
|
||||||
|
let branches = selectable_branches();
|
||||||
|
let branch_list_height = branches.len() as u16 * 2 + 1; // all branches * 2 lines + separator after Lowercase
|
||||||
|
let (footer_hints, footer_notice) = if self.selected < branches.len() {
|
||||||
|
let bp = self.skill_tree.branch_progress(branches[self.selected]);
|
||||||
|
if *self.skill_tree.branch_status(branches[self.selected]) == BranchStatus::Locked {
|
||||||
|
(
|
||||||
|
vec![
|
||||||
|
"[↑↓/jk] Navigate",
|
||||||
|
"[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll",
|
||||||
|
"[q] Back",
|
||||||
|
],
|
||||||
|
Some("Complete a-z to unlock branches"),
|
||||||
|
)
|
||||||
|
} else if bp.status == BranchStatus::Available || bp.status == BranchStatus::InProgress
|
||||||
|
{
|
||||||
|
(
|
||||||
|
vec![
|
||||||
|
"[Enter] Start Drill",
|
||||||
|
"[↑↓/jk] Navigate",
|
||||||
|
"[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll",
|
||||||
|
"[q] Back",
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
vec![
|
||||||
|
"[↑↓/jk] Navigate",
|
||||||
|
"[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll",
|
||||||
|
"[q] Back",
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
vec![
|
||||||
|
"[↑↓/jk] Navigate",
|
||||||
|
"[PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll",
|
||||||
|
"[q] Back",
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let hint_lines = pack_hint_lines(&footer_hints, inner.width as usize);
|
||||||
|
let notice_lines = footer_notice
|
||||||
|
.map(|text| wrapped_line_count(text, inner.width as usize))
|
||||||
|
.unwrap_or(0);
|
||||||
|
let show_notice =
|
||||||
|
footer_notice.is_some() && (inner.height as usize >= hint_lines.len() + notice_lines + 8);
|
||||||
|
let footer_needed = hint_lines.len() + if show_notice { notice_lines } else { 0 } + 1;
|
||||||
|
let footer_height = footer_needed
|
||||||
|
.min(inner.height.saturating_sub(5) as usize)
|
||||||
|
.max(1) as u16;
|
||||||
|
|
||||||
|
let layout = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(
|
||||||
|
branch_list_height.min(inner.height.saturating_sub(footer_height + 4)),
|
||||||
|
),
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Min(4),
|
||||||
|
Constraint::Length(footer_height),
|
||||||
|
])
|
||||||
|
.split(inner);
|
||||||
|
|
||||||
|
// --- Branch list ---
|
||||||
|
self.render_branch_list(layout[0], buf, &branches);
|
||||||
|
|
||||||
|
// --- Separator ---
|
||||||
|
let sep = Paragraph::new(Line::from(Span::styled(
|
||||||
|
"\u{2500}".repeat(layout[1].width as usize),
|
||||||
|
Style::default().fg(colors.border()),
|
||||||
|
)));
|
||||||
|
sep.render(layout[1], buf);
|
||||||
|
|
||||||
|
// --- Detail panel for selected branch ---
|
||||||
|
self.render_detail_panel(layout[2], buf, &branches);
|
||||||
|
|
||||||
|
// --- Footer ---
|
||||||
|
let mut footer_lines: Vec<Line> = Vec::new();
|
||||||
|
if show_notice {
|
||||||
|
if let Some(notice) = footer_notice {
|
||||||
|
footer_lines.push(Line::from(Span::styled(
|
||||||
|
format!(" {notice}"),
|
||||||
|
Style::default().fg(colors.text_pending()),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
footer_lines.extend(hint_lines.into_iter().map(|line| {
|
||||||
|
Line::from(Span::styled(line, Style::default().fg(colors.text_pending())))
|
||||||
|
}));
|
||||||
|
let footer = Paragraph::new(footer_lines).wrap(Wrap { trim: false });
|
||||||
|
footer.render(layout[3], buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SkillTreeWidget<'_> {
|
||||||
|
fn render_branch_list(&self, area: Rect, buf: &mut Buffer, branches: &[BranchId]) {
|
||||||
|
let colors = &self.theme.colors;
|
||||||
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
|
|
||||||
|
for (i, &branch_id) in branches.iter().enumerate() {
|
||||||
|
let bp = self.skill_tree.branch_progress(branch_id);
|
||||||
|
let def = get_branch_definition(branch_id);
|
||||||
|
let total_keys = def.levels.iter().map(|l| l.keys.len()).sum::<usize>();
|
||||||
|
let confident_keys = self
|
||||||
|
.skill_tree
|
||||||
|
.branch_confident_keys(branch_id, self.key_stats);
|
||||||
|
let is_selected = i == self.selected;
|
||||||
|
|
||||||
|
let (prefix, style) = match bp.status {
|
||||||
|
BranchStatus::Complete => (
|
||||||
|
"\u{2605} ",
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.text_correct())
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
BranchStatus::InProgress => (
|
||||||
|
"\u{25b6} ",
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.accent())
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
BranchStatus::Available => (" ", Style::default().fg(colors.fg())),
|
||||||
|
BranchStatus::Locked => (" ", Style::default().fg(colors.text_pending())),
|
||||||
|
};
|
||||||
|
|
||||||
|
let unlocked = self.skill_tree.branch_unlocked_count(branch_id);
|
||||||
|
let mastered_text = if confident_keys > 0 {
|
||||||
|
format!(" ({confident_keys} mastered)")
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
let status_text = match bp.status {
|
||||||
|
BranchStatus::Complete => {
|
||||||
|
format!("{unlocked}/{total_keys} unlocked{mastered_text}")
|
||||||
|
}
|
||||||
|
BranchStatus::InProgress => {
|
||||||
|
if branch_id == BranchId::Lowercase {
|
||||||
|
format!("{unlocked}/{total_keys} unlocked{mastered_text}")
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"Lvl {}/{} {unlocked}/{total_keys} unlocked{mastered_text}",
|
||||||
|
bp.current_level + 1,
|
||||||
|
def.levels.len()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BranchStatus::Available => format!("0/{total_keys} unlocked"),
|
||||||
|
BranchStatus::Locked => format!("Locked 0/{total_keys}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let sel_indicator = if is_selected { "> " } else { " " };
|
||||||
|
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(format!("{sel_indicator}{prefix}{}", def.name), style),
|
||||||
|
Span::styled(
|
||||||
|
format!(" {status_text}"),
|
||||||
|
Style::default().fg(colors.text_pending()),
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
|
||||||
|
let (mastered_bar, unlocked_bar, empty_bar) =
|
||||||
|
dual_progress_bar_parts(confident_keys, unlocked, total_keys, 30);
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(" ", style),
|
||||||
|
Span::styled(mastered_bar, Style::default().fg(colors.text_correct())),
|
||||||
|
Span::styled(unlocked_bar, Style::default().fg(colors.accent())),
|
||||||
|
Span::styled(empty_bar, Style::default().fg(colors.text_pending())),
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Add separator after Lowercase (index 0)
|
||||||
|
if branch_id == BranchId::Lowercase {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
" \u{2500}\u{2500} Branches (unlocked after a-z) \u{2500}\u{2500}",
|
||||||
|
Style::default().fg(colors.border()),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(lines);
|
||||||
|
paragraph.render(area, buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_detail_panel(&self, area: Rect, buf: &mut Buffer, branches: &[BranchId]) {
|
||||||
|
let colors = &self.theme.colors;
|
||||||
|
|
||||||
|
if self.selected >= branches.len() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let branch_id = branches[self.selected];
|
||||||
|
let bp = self.skill_tree.branch_progress(branch_id);
|
||||||
|
let def = get_branch_definition(branch_id);
|
||||||
|
|
||||||
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
|
|
||||||
|
// Branch title with level info
|
||||||
|
let level_text = if branch_id == BranchId::Lowercase {
|
||||||
|
let unlocked = self.skill_tree.branch_unlocked_count(BranchId::Lowercase);
|
||||||
|
let total = SkillTreeEngine::branch_total_keys(BranchId::Lowercase);
|
||||||
|
format!("Unlocked {unlocked}/{total} letters")
|
||||||
|
} else {
|
||||||
|
match bp.status {
|
||||||
|
BranchStatus::InProgress => {
|
||||||
|
format!("Level {}/{}", bp.current_level + 1, def.levels.len())
|
||||||
|
}
|
||||||
|
BranchStatus::Complete => {
|
||||||
|
format!("Level {}/{}", def.levels.len(), def.levels.len())
|
||||||
|
}
|
||||||
|
_ => format!("Level 0/{}", def.levels.len()),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
format!(" {}", def.name),
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.accent())
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::styled(
|
||||||
|
format!(" {level_text}"),
|
||||||
|
Style::default().fg(colors.text_pending()),
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Per-level key breakdown with per-key mastery bars
|
||||||
|
let focused = self
|
||||||
|
.skill_tree
|
||||||
|
.focused_key(DrillScope::Branch(branch_id), self.key_stats);
|
||||||
|
|
||||||
|
// For Lowercase, determine which keys are unlocked
|
||||||
|
let lowercase_unlocked_keys: Vec<char> = if branch_id == BranchId::Lowercase {
|
||||||
|
self.skill_tree
|
||||||
|
.unlocked_keys(DrillScope::Branch(BranchId::Lowercase))
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
for (level_idx, level) in def.levels.iter().enumerate() {
|
||||||
|
let level_status =
|
||||||
|
if bp.status == BranchStatus::Complete || level_idx < bp.current_level {
|
||||||
|
"complete"
|
||||||
|
} else if bp.status == BranchStatus::InProgress && level_idx == bp.current_level {
|
||||||
|
"in progress"
|
||||||
|
} else {
|
||||||
|
"locked"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Level header
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
format!(" L{}: {} ({level_status})", level_idx + 1, level.name),
|
||||||
|
Style::default().fg(colors.fg()),
|
||||||
|
)));
|
||||||
|
|
||||||
|
// Per-key mastery bars
|
||||||
|
for &key in level.keys {
|
||||||
|
let is_focused = focused == Some(key);
|
||||||
|
let confidence = self.key_stats.get_confidence(key).min(1.0);
|
||||||
|
let is_confident = confidence >= 1.0;
|
||||||
|
|
||||||
|
// For Lowercase, check if this specific key is unlocked
|
||||||
|
let is_locked = if branch_id == BranchId::Lowercase {
|
||||||
|
!lowercase_unlocked_keys.contains(&key)
|
||||||
|
} else {
|
||||||
|
level_status == "locked"
|
||||||
|
};
|
||||||
|
|
||||||
|
let display = if key == '\n' {
|
||||||
|
"\\n".to_string()
|
||||||
|
} else if key == '\t' {
|
||||||
|
"\\t".to_string()
|
||||||
|
} else {
|
||||||
|
format!(" {key}")
|
||||||
|
};
|
||||||
|
|
||||||
|
if is_locked {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
format!(" {display} "),
|
||||||
|
Style::default().fg(colors.text_pending()),
|
||||||
|
),
|
||||||
|
Span::styled("locked", Style::default().fg(colors.text_pending())),
|
||||||
|
]));
|
||||||
|
} else {
|
||||||
|
let bar_width = 10;
|
||||||
|
let filled = (confidence * bar_width as f64).round() as usize;
|
||||||
|
let empty = bar_width - filled;
|
||||||
|
let bar = format!("{}{}", "\u{2588}".repeat(filled), "\u{2591}".repeat(empty));
|
||||||
|
let pct_str = format!("{:>3.0}%", confidence * 100.0);
|
||||||
|
let focus_label = if is_focused { " in focus" } else { "" };
|
||||||
|
|
||||||
|
let key_style = if is_focused {
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.bg())
|
||||||
|
.bg(colors.focused_key())
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else if is_confident {
|
||||||
|
Style::default().fg(colors.text_correct())
|
||||||
|
} else {
|
||||||
|
Style::default().fg(colors.fg())
|
||||||
|
};
|
||||||
|
|
||||||
|
let bar_color = if is_confident {
|
||||||
|
colors.text_correct()
|
||||||
|
} else {
|
||||||
|
colors.accent()
|
||||||
|
};
|
||||||
|
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(format!(" {display} "), key_style),
|
||||||
|
Span::styled(bar, Style::default().fg(bar_color)),
|
||||||
|
Span::styled(
|
||||||
|
format!(" {pct_str}"),
|
||||||
|
Style::default().fg(colors.text_pending()),
|
||||||
|
),
|
||||||
|
Span::styled(
|
||||||
|
focus_label,
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.focused_key())
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let visible_height = area.height as usize;
|
||||||
|
if visible_height == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let max_scroll = lines.len().saturating_sub(visible_height);
|
||||||
|
let scroll = self.detail_scroll.min(max_scroll);
|
||||||
|
let visible_lines: Vec<Line> = lines
|
||||||
|
.into_iter()
|
||||||
|
.skip(scroll)
|
||||||
|
.take(visible_height)
|
||||||
|
.collect();
|
||||||
|
let paragraph = Paragraph::new(visible_lines);
|
||||||
|
paragraph.render(area, buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dual_progress_bar_parts(
|
||||||
|
mastered: usize,
|
||||||
|
unlocked: usize,
|
||||||
|
total: usize,
|
||||||
|
width: usize,
|
||||||
|
) -> (String, String, String) {
|
||||||
|
if total == 0 {
|
||||||
|
return (String::new(), String::new(), "\u{2591}".repeat(width));
|
||||||
|
}
|
||||||
|
let mastered_cells = mastered * width / total;
|
||||||
|
let unlocked_cells = (unlocked * width / total).max(mastered_cells);
|
||||||
|
let empty_cells = width - unlocked_cells;
|
||||||
|
(
|
||||||
|
"\u{2588}".repeat(mastered_cells),
|
||||||
|
"\u{2593}".repeat(unlocked_cells - mastered_cells),
|
||||||
|
"\u{2591}".repeat(empty_cells),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wrapped_line_count(text: &str, width: usize) -> usize {
|
||||||
|
if width == 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let chars = text.chars().count().max(1);
|
||||||
|
chars.div_ceil(width)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pack_hint_lines(hints: &[&str], width: usize) -> Vec<String> {
|
||||||
|
if width == 0 || hints.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let prefix = " ";
|
||||||
|
let separator = " ";
|
||||||
|
let mut out: Vec<String> = Vec::new();
|
||||||
|
let mut current = prefix.to_string();
|
||||||
|
let mut has_hint = false;
|
||||||
|
|
||||||
|
for hint in hints {
|
||||||
|
if hint.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let candidate = if has_hint {
|
||||||
|
format!("{current}{separator}{hint}")
|
||||||
|
} else {
|
||||||
|
format!("{current}{hint}")
|
||||||
|
};
|
||||||
|
if candidate.chars().count() <= width {
|
||||||
|
current = candidate;
|
||||||
|
has_hint = true;
|
||||||
|
} else {
|
||||||
|
if has_hint {
|
||||||
|
out.push(current);
|
||||||
|
}
|
||||||
|
current = format!("{prefix}{hint}");
|
||||||
|
has_hint = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if has_hint {
|
||||||
|
out.push(current);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,44 @@
|
|||||||
use ratatui::buffer::Buffer;
|
use ratatui::buffer::Buffer;
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||||
use ratatui::style::Style;
|
use ratatui::style::Style;
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, Paragraph, Widget};
|
use ratatui::widgets::{Block, Paragraph, Widget};
|
||||||
|
|
||||||
use crate::session::lesson::LessonState;
|
use crate::session::drill::DrillState;
|
||||||
|
use crate::session::result::DrillResult;
|
||||||
use crate::ui::theme::Theme;
|
use crate::ui::theme::Theme;
|
||||||
|
|
||||||
pub struct StatsSidebar<'a> {
|
pub struct StatsSidebar<'a> {
|
||||||
lesson: &'a LessonState,
|
drill: &'a DrillState,
|
||||||
|
last_result: Option<&'a DrillResult>,
|
||||||
|
history: &'a [DrillResult],
|
||||||
theme: &'a Theme,
|
theme: &'a Theme,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> StatsSidebar<'a> {
|
impl<'a> StatsSidebar<'a> {
|
||||||
pub fn new(lesson: &'a LessonState, theme: &'a Theme) -> Self {
|
pub fn new(
|
||||||
Self { lesson, theme }
|
drill: &'a DrillState,
|
||||||
|
last_result: Option<&'a DrillResult>,
|
||||||
|
history: &'a [DrillResult],
|
||||||
|
theme: &'a Theme,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
drill,
|
||||||
|
last_result,
|
||||||
|
history,
|
||||||
|
theme,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a delta value with arrow indicator
|
||||||
|
fn format_delta(delta: f64, suffix: &str) -> String {
|
||||||
|
if delta > 0.0 {
|
||||||
|
format!("\u{2191}+{:.1}{suffix}", delta)
|
||||||
|
} else if delta < 0.0 {
|
||||||
|
format!("\u{2193}{:.1}{suffix}", delta)
|
||||||
|
} else {
|
||||||
|
format!("={suffix}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,12 +46,29 @@ impl Widget for StatsSidebar<'_> {
|
|||||||
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;
|
||||||
|
|
||||||
let wpm = self.lesson.wpm();
|
let has_last = self.last_result.is_some();
|
||||||
let accuracy = self.lesson.accuracy();
|
|
||||||
let progress = self.lesson.progress() * 100.0;
|
// Split sidebar into current stats and last drill sections
|
||||||
let correct = self.lesson.correct_count();
|
let sections = if has_last {
|
||||||
let incorrect = self.lesson.incorrect_count();
|
Layout::default()
|
||||||
let elapsed = self.lesson.elapsed_secs();
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([Constraint::Min(10), Constraint::Min(12)])
|
||||||
|
.split(area)
|
||||||
|
} else {
|
||||||
|
Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([Constraint::Min(10), Constraint::Length(0)])
|
||||||
|
.split(area)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Current drill stats
|
||||||
|
{
|
||||||
|
let wpm = self.drill.wpm();
|
||||||
|
let accuracy = self.drill.accuracy();
|
||||||
|
let progress = self.drill.progress() * 100.0;
|
||||||
|
let correct = self.drill.correct_count();
|
||||||
|
let incorrect = self.drill.typo_count();
|
||||||
|
let elapsed = self.drill.elapsed_secs();
|
||||||
|
|
||||||
let wpm_str = format!("{wpm:.0}");
|
let wpm_str = format!("{wpm:.0}");
|
||||||
let acc_str = format!("{accuracy:.1}%");
|
let acc_str = format!("{accuracy:.1}%");
|
||||||
@@ -39,13 +80,13 @@ impl Widget for StatsSidebar<'_> {
|
|||||||
let lines = vec![
|
let lines = vec![
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
Span::styled("WPM: ", Style::default().fg(colors.fg())),
|
Span::styled("WPM: ", Style::default().fg(colors.fg())),
|
||||||
Span::styled(&*wpm_str, Style::default().fg(colors.accent())),
|
Span::styled(wpm_str, Style::default().fg(colors.accent())),
|
||||||
]),
|
]),
|
||||||
Line::from(""),
|
Line::from(""),
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
Span::styled("Accuracy: ", Style::default().fg(colors.fg())),
|
Span::styled("Accuracy: ", Style::default().fg(colors.fg())),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
&*acc_str,
|
acc_str,
|
||||||
Style::default().fg(if accuracy >= 95.0 {
|
Style::default().fg(if accuracy >= 95.0 {
|
||||||
colors.success()
|
colors.success()
|
||||||
} else if accuracy >= 85.0 {
|
} else if accuracy >= 85.0 {
|
||||||
@@ -58,21 +99,21 @@ impl Widget for StatsSidebar<'_> {
|
|||||||
Line::from(""),
|
Line::from(""),
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
Span::styled("Progress: ", Style::default().fg(colors.fg())),
|
Span::styled("Progress: ", Style::default().fg(colors.fg())),
|
||||||
Span::styled(&*prog_str, Style::default().fg(colors.accent())),
|
Span::styled(prog_str, Style::default().fg(colors.accent())),
|
||||||
]),
|
]),
|
||||||
Line::from(""),
|
Line::from(""),
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
Span::styled("Correct: ", Style::default().fg(colors.fg())),
|
Span::styled("Correct: ", Style::default().fg(colors.fg())),
|
||||||
Span::styled(&*correct_str, Style::default().fg(colors.success())),
|
Span::styled(correct_str, Style::default().fg(colors.success())),
|
||||||
]),
|
]),
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
Span::styled("Errors: ", Style::default().fg(colors.fg())),
|
Span::styled("Errors: ", Style::default().fg(colors.fg())),
|
||||||
Span::styled(&*incorrect_str, Style::default().fg(colors.error())),
|
Span::styled(incorrect_str, Style::default().fg(colors.error())),
|
||||||
]),
|
]),
|
||||||
Line::from(""),
|
Line::from(""),
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
Span::styled("Time: ", Style::default().fg(colors.fg())),
|
Span::styled("Time: ", Style::default().fg(colors.fg())),
|
||||||
Span::styled(&*elapsed_str, Style::default().fg(colors.fg())),
|
Span::styled(elapsed_str, Style::default().fg(colors.fg())),
|
||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -82,6 +123,100 @@ impl Widget for StatsSidebar<'_> {
|
|||||||
.style(Style::default().bg(colors.bg()));
|
.style(Style::default().bg(colors.bg()));
|
||||||
|
|
||||||
let paragraph = Paragraph::new(lines).block(block);
|
let paragraph = Paragraph::new(lines).block(block);
|
||||||
paragraph.render(area, buf);
|
paragraph.render(sections[0], buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last drill stats with session impact deltas
|
||||||
|
if let Some(last) = self.last_result {
|
||||||
|
let wpm_str = format!("{:.0}", last.wpm);
|
||||||
|
let acc_str = format!("{:.1}%", last.accuracy);
|
||||||
|
let time_str = format!("{:.1}s", last.elapsed_secs);
|
||||||
|
let errors_str = format!("{}", last.incorrect);
|
||||||
|
|
||||||
|
// Compute deltas: compare last drill to the average of all prior drills
|
||||||
|
// (excluding the last one which is the current result)
|
||||||
|
let prior_count = self.history.len().saturating_sub(1);
|
||||||
|
let (wpm_delta, acc_delta) = if prior_count > 0 {
|
||||||
|
let prior = &self.history[..prior_count];
|
||||||
|
let avg_wpm = prior.iter().map(|r| r.wpm).sum::<f64>() / prior.len() as f64;
|
||||||
|
let avg_acc = prior.iter().map(|r| r.accuracy).sum::<f64>() / prior.len() as f64;
|
||||||
|
(last.wpm - avg_wpm, last.accuracy - avg_acc)
|
||||||
|
} else {
|
||||||
|
(0.0, 0.0)
|
||||||
|
};
|
||||||
|
|
||||||
|
let wpm_delta_str = format_delta(wpm_delta, "");
|
||||||
|
let acc_delta_str = format_delta(acc_delta, "%");
|
||||||
|
|
||||||
|
let wpm_delta_color = if wpm_delta > 0.0 {
|
||||||
|
colors.success()
|
||||||
|
} else if wpm_delta < 0.0 {
|
||||||
|
colors.error()
|
||||||
|
} else {
|
||||||
|
colors.text_pending()
|
||||||
|
};
|
||||||
|
|
||||||
|
let acc_delta_color = if acc_delta > 0.0 {
|
||||||
|
colors.success()
|
||||||
|
} else if acc_delta < 0.0 {
|
||||||
|
colors.error()
|
||||||
|
} else {
|
||||||
|
colors.text_pending()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut lines = vec![Line::from(vec![
|
||||||
|
Span::styled("WPM: ", Style::default().fg(colors.fg())),
|
||||||
|
Span::styled(wpm_str, Style::default().fg(colors.accent())),
|
||||||
|
])];
|
||||||
|
|
||||||
|
if prior_count > 0 {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(" vs avg: ", Style::default().fg(colors.text_pending())),
|
||||||
|
Span::styled(wpm_delta_str, Style::default().fg(wpm_delta_color)),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled("Accuracy: ", Style::default().fg(colors.fg())),
|
||||||
|
Span::styled(
|
||||||
|
acc_str,
|
||||||
|
Style::default().fg(if last.accuracy >= 95.0 {
|
||||||
|
colors.success()
|
||||||
|
} else if last.accuracy >= 85.0 {
|
||||||
|
colors.warning()
|
||||||
|
} else {
|
||||||
|
colors.error()
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
|
||||||
|
if prior_count > 0 {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(" vs avg: ", Style::default().fg(colors.text_pending())),
|
||||||
|
Span::styled(acc_delta_str, Style::default().fg(acc_delta_color)),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled("Errors: ", Style::default().fg(colors.fg())),
|
||||||
|
Span::styled(errors_str, Style::default().fg(colors.error())),
|
||||||
|
]));
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled("Time: ", Style::default().fg(colors.fg())),
|
||||||
|
Span::styled(time_str, Style::default().fg(colors.fg())),
|
||||||
|
]));
|
||||||
|
|
||||||
|
let block = Block::bordered()
|
||||||
|
.title(" Last Drill ")
|
||||||
|
.border_style(Style::default().fg(colors.border()))
|
||||||
|
.style(Style::default().bg(colors.bg()));
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(lines).block(block);
|
||||||
|
paragraph.render(sections[1], buf);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,58 +4,200 @@ use ratatui::style::{Modifier, Style};
|
|||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, Paragraph, Widget, Wrap};
|
use ratatui::widgets::{Block, Paragraph, Widget, Wrap};
|
||||||
|
|
||||||
|
use crate::session::drill::DrillState;
|
||||||
use crate::session::input::CharStatus;
|
use crate::session::input::CharStatus;
|
||||||
use crate::session::lesson::LessonState;
|
|
||||||
use crate::ui::theme::Theme;
|
use crate::ui::theme::Theme;
|
||||||
|
|
||||||
pub struct TypingArea<'a> {
|
pub struct TypingArea<'a> {
|
||||||
lesson: &'a LessonState,
|
drill: &'a DrillState,
|
||||||
theme: &'a Theme,
|
theme: &'a Theme,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> TypingArea<'a> {
|
impl<'a> TypingArea<'a> {
|
||||||
pub fn new(lesson: &'a LessonState, theme: &'a Theme) -> Self {
|
pub fn new(drill: &'a DrillState, theme: &'a Theme) -> Self {
|
||||||
Self { lesson, theme }
|
Self { drill, theme }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A render token maps a single target character to its display representation.
|
||||||
|
struct RenderToken {
|
||||||
|
target_idx: usize,
|
||||||
|
display: String,
|
||||||
|
is_line_break: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expand target chars into render tokens, handling whitespace display.
|
||||||
|
fn build_render_tokens(target: &[char]) -> Vec<RenderToken> {
|
||||||
|
let mut tokens = Vec::new();
|
||||||
|
let mut col = 0usize;
|
||||||
|
|
||||||
|
for (i, &ch) in target.iter().enumerate() {
|
||||||
|
match ch {
|
||||||
|
'\n' => {
|
||||||
|
tokens.push(RenderToken {
|
||||||
|
target_idx: i,
|
||||||
|
display: "\u{21b5}".to_string(), // ↵
|
||||||
|
is_line_break: true,
|
||||||
|
});
|
||||||
|
col = 0;
|
||||||
|
}
|
||||||
|
'\t' => {
|
||||||
|
let tab_width = 4 - (col % 4);
|
||||||
|
let mut display = String::from("\u{2192}"); // →
|
||||||
|
for _ in 1..tab_width {
|
||||||
|
display.push('\u{00b7}'); // ·
|
||||||
|
}
|
||||||
|
tokens.push(RenderToken {
|
||||||
|
target_idx: i,
|
||||||
|
display,
|
||||||
|
is_line_break: false,
|
||||||
|
});
|
||||||
|
col += tab_width;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
tokens.push(RenderToken {
|
||||||
|
target_idx: i,
|
||||||
|
display: ch.to_string(),
|
||||||
|
is_line_break: false,
|
||||||
|
});
|
||||||
|
col += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens
|
||||||
|
}
|
||||||
|
|
||||||
impl Widget for TypingArea<'_> {
|
impl Widget for TypingArea<'_> {
|
||||||
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;
|
||||||
let mut spans: Vec<Span> = Vec::new();
|
let tokens = build_render_tokens(&self.drill.target);
|
||||||
|
|
||||||
for (i, &target_ch) in self.lesson.target.iter().enumerate() {
|
// Group tokens into lines, splitting on line_break tokens
|
||||||
if i < self.lesson.cursor {
|
let mut lines: Vec<Vec<Span>> = vec![Vec::new()];
|
||||||
let style = match &self.lesson.input[i] {
|
|
||||||
|
for token in &tokens {
|
||||||
|
let idx = token.target_idx;
|
||||||
|
let target_ch = self.drill.target[idx];
|
||||||
|
|
||||||
|
let style = if idx < self.drill.cursor {
|
||||||
|
match &self.drill.input[idx] {
|
||||||
CharStatus::Correct => Style::default().fg(colors.text_correct()),
|
CharStatus::Correct => Style::default().fg(colors.text_correct()),
|
||||||
CharStatus::Incorrect(_) => Style::default()
|
CharStatus::Incorrect(_) => Style::default()
|
||||||
.fg(colors.text_incorrect())
|
.fg(colors.text_incorrect())
|
||||||
.bg(colors.text_incorrect_bg())
|
.bg(colors.text_incorrect_bg())
|
||||||
.add_modifier(Modifier::UNDERLINED),
|
.add_modifier(Modifier::UNDERLINED),
|
||||||
};
|
}
|
||||||
let display = match &self.lesson.input[i] {
|
} else if idx == self.drill.cursor {
|
||||||
CharStatus::Incorrect(actual) => *actual,
|
Style::default()
|
||||||
_ => target_ch,
|
|
||||||
};
|
|
||||||
spans.push(Span::styled(display.to_string(), style));
|
|
||||||
} else if i == self.lesson.cursor {
|
|
||||||
let style = Style::default()
|
|
||||||
.fg(colors.text_cursor_fg())
|
.fg(colors.text_cursor_fg())
|
||||||
.bg(colors.text_cursor_bg());
|
.bg(colors.text_cursor_bg())
|
||||||
spans.push(Span::styled(target_ch.to_string(), style));
|
.add_modifier(Modifier::REVERSED | Modifier::BOLD)
|
||||||
} else {
|
} else {
|
||||||
let style = Style::default().fg(colors.text_pending());
|
Style::default().fg(colors.text_pending())
|
||||||
spans.push(Span::styled(target_ch.to_string(), style));
|
};
|
||||||
|
|
||||||
|
// For incorrect chars, show the actual typed char for regular chars,
|
||||||
|
// but always show the token display for whitespace markers
|
||||||
|
let display = if idx < self.drill.cursor {
|
||||||
|
if let CharStatus::Incorrect(actual) = &self.drill.input[idx] {
|
||||||
|
if target_ch == '\n' || target_ch == '\t' {
|
||||||
|
// Show the whitespace marker even when incorrect
|
||||||
|
token.display.clone()
|
||||||
|
} else {
|
||||||
|
actual.to_string()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
token.display.clone()
|
||||||
|
}
|
||||||
|
} else if idx == self.drill.cursor && target_ch == ' ' {
|
||||||
|
"\u{00b7}".to_string()
|
||||||
|
} else {
|
||||||
|
token.display.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
lines.last_mut().unwrap().push(Span::styled(display, style));
|
||||||
|
|
||||||
|
if token.is_line_break {
|
||||||
|
lines.push(Vec::new());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let line = Line::from(spans);
|
// Keep cursor visible at end-of-input as an insertion marker.
|
||||||
|
if self.drill.cursor >= self.drill.target.len() {
|
||||||
|
lines.last_mut().unwrap().push(Span::styled(
|
||||||
|
"\u{258f}",
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.accent())
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let ratatui_lines: Vec<Line> = lines.into_iter().map(Line::from).collect();
|
||||||
|
|
||||||
let block = Block::bordered()
|
let block = Block::bordered()
|
||||||
.border_style(Style::default().fg(colors.border()))
|
.border_style(Style::default().fg(colors.border()))
|
||||||
.style(Style::default().bg(colors.bg()));
|
.style(Style::default().bg(colors.bg()));
|
||||||
|
|
||||||
let paragraph = Paragraph::new(line).block(block).wrap(Wrap { trim: false });
|
let paragraph = Paragraph::new(ratatui_lines)
|
||||||
|
.block(block)
|
||||||
|
.wrap(Wrap { trim: false });
|
||||||
|
|
||||||
paragraph.render(area, buf);
|
paragraph.render(area, buf);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_tokens_basic() {
|
||||||
|
let target: Vec<char> = "abc".chars().collect();
|
||||||
|
let tokens = build_render_tokens(&target);
|
||||||
|
assert_eq!(tokens.len(), 3);
|
||||||
|
assert_eq!(tokens[0].display, "a");
|
||||||
|
assert_eq!(tokens[1].display, "b");
|
||||||
|
assert_eq!(tokens[2].display, "c");
|
||||||
|
assert!(!tokens[0].is_line_break);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_tokens_newline() {
|
||||||
|
let target: Vec<char> = "a\nb".chars().collect();
|
||||||
|
let tokens = build_render_tokens(&target);
|
||||||
|
assert_eq!(tokens.len(), 3);
|
||||||
|
assert_eq!(tokens[1].display, "\u{21b5}"); // ↵
|
||||||
|
assert!(tokens[1].is_line_break);
|
||||||
|
assert_eq!(tokens[1].target_idx, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_tokens_tab() {
|
||||||
|
let target: Vec<char> = "\tx".chars().collect();
|
||||||
|
let tokens = build_render_tokens(&target);
|
||||||
|
assert_eq!(tokens.len(), 2);
|
||||||
|
// Tab at col 0: width = 4 - (0 % 4) = 4 => "→···"
|
||||||
|
assert_eq!(tokens[0].display, "\u{2192}\u{00b7}\u{00b7}\u{00b7}");
|
||||||
|
assert!(!tokens[0].is_line_break);
|
||||||
|
assert_eq!(tokens[0].target_idx, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_tokens_tab_alignment() {
|
||||||
|
// "ab\t" -> col 2, tab_width = 4 - (2 % 4) = 2 => "→·"
|
||||||
|
let target: Vec<char> = "ab\t".chars().collect();
|
||||||
|
let tokens = build_render_tokens(&target);
|
||||||
|
assert_eq!(tokens[2].display, "\u{2192}\u{00b7}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_tokens_newline_resets_column() {
|
||||||
|
// "\n\tx" -> after newline, col resets to 0, tab_width = 4
|
||||||
|
let target: Vec<char> = "\n\tx".chars().collect();
|
||||||
|
let tokens = build_render_tokens(&target);
|
||||||
|
assert_eq!(tokens.len(), 3);
|
||||||
|
assert!(tokens[0].is_line_break);
|
||||||
|
assert_eq!(tokens[1].display, "\u{2192}\u{00b7}\u{00b7}\u{00b7}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,28 +65,34 @@ 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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
|
pub fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
|
||||||
let vertical = Layout::default()
|
const MIN_POPUP_WIDTH: u16 = 72;
|
||||||
.direction(Direction::Vertical)
|
const MIN_POPUP_HEIGHT: u16 = 18;
|
||||||
.constraints([
|
|
||||||
Constraint::Percentage((100 - percent_y) / 2),
|
|
||||||
Constraint::Percentage(percent_y),
|
|
||||||
Constraint::Percentage((100 - percent_y) / 2),
|
|
||||||
])
|
|
||||||
.split(area);
|
|
||||||
|
|
||||||
Layout::default()
|
let requested_w = area.width.saturating_mul(percent_x.min(100)) / 100;
|
||||||
.direction(Direction::Horizontal)
|
let requested_h = area.height.saturating_mul(percent_y.min(100)) / 100;
|
||||||
.constraints([
|
|
||||||
Constraint::Percentage((100 - percent_x) / 2),
|
let target_w = requested_w.max(MIN_POPUP_WIDTH).min(area.width);
|
||||||
Constraint::Percentage(percent_x),
|
let target_h = requested_h.max(MIN_POPUP_HEIGHT).min(area.height);
|
||||||
Constraint::Percentage((100 - percent_x) / 2),
|
|
||||||
])
|
let left = area.x.saturating_add((area.width.saturating_sub(target_w)) / 2);
|
||||||
.split(vertical[1])[1]
|
let top = area.y.saturating_add((area.height.saturating_sub(target_h)) / 2);
|
||||||
|
|
||||||
|
Rect::new(left, top, target_w, target_h)
|
||||||
}
|
}
|
||||||
|
|||||||
131
src/ui/theme.rs
131
src/ui/theme.rs
@@ -8,6 +8,8 @@ use serde::{Deserialize, Serialize};
|
|||||||
#[folder = "assets/themes/"]
|
#[folder = "assets/themes/"]
|
||||||
struct ThemeAssets;
|
struct ThemeAssets;
|
||||||
|
|
||||||
|
const TERMINAL_DEFAULT_THEME: &str = "terminal-default";
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct Theme {
|
pub struct Theme {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -42,7 +44,10 @@ impl Theme {
|
|||||||
pub fn load(name: &str) -> Option<Self> {
|
pub fn load(name: &str) -> Option<Self> {
|
||||||
// Try user themes dir
|
// Try user themes dir
|
||||||
if let Some(config_dir) = dirs::config_dir() {
|
if let Some(config_dir) = dirs::config_dir() {
|
||||||
let user_theme_path = config_dir.join("keydr").join("themes").join(format!("{name}.toml"));
|
let user_theme_path = config_dir
|
||||||
|
.join("keydr")
|
||||||
|
.join("themes")
|
||||||
|
.join(format!("{name}.toml"));
|
||||||
if let Ok(content) = fs::read_to_string(&user_theme_path) {
|
if let Ok(content) = fs::read_to_string(&user_theme_path) {
|
||||||
if let Ok(theme) = toml::from_str::<Theme>(&content) {
|
if let Ok(theme) = toml::from_str::<Theme>(&content) {
|
||||||
return Some(theme);
|
return Some(theme);
|
||||||
@@ -64,17 +69,20 @@ impl Theme {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn available_themes() -> Vec<String> {
|
pub fn available_themes() -> Vec<String> {
|
||||||
ThemeAssets::iter()
|
let mut themes: Vec<String> = ThemeAssets::iter()
|
||||||
.filter_map(|f| {
|
.filter_map(|f| f.strip_suffix(".toml").map(|n| n.to_string()))
|
||||||
f.strip_suffix(".toml").map(|n| n.to_string())
|
.collect();
|
||||||
})
|
themes.sort_unstable();
|
||||||
.collect()
|
if let Some(idx) = themes.iter().position(|t| t == TERMINAL_DEFAULT_THEME) {
|
||||||
|
themes.swap(0, idx);
|
||||||
|
}
|
||||||
|
themes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Theme {
|
impl Default for Theme {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::load("catppuccin-mocha").unwrap_or_else(|| Self {
|
Self::load(TERMINAL_DEFAULT_THEME).unwrap_or_else(|| Self {
|
||||||
name: "default".to_string(),
|
name: "default".to_string(),
|
||||||
colors: ThemeColors::default(),
|
colors: ThemeColors::default(),
|
||||||
})
|
})
|
||||||
@@ -109,9 +117,10 @@ impl Default for ThemeColors {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ThemeColors {
|
impl ThemeColors {
|
||||||
pub fn parse_color(hex: &str) -> Color {
|
pub fn parse_color(value: &str) -> Color {
|
||||||
let hex = hex.trim_start_matches('#');
|
let value = value.trim();
|
||||||
if hex.len() == 6 {
|
let hex = value.trim_start_matches('#');
|
||||||
|
if hex.len() == 6 && value.starts_with('#') {
|
||||||
if let (Ok(r), Ok(g), Ok(b)) = (
|
if let (Ok(r), Ok(g), Ok(b)) = (
|
||||||
u8::from_str_radix(&hex[0..2], 16),
|
u8::from_str_radix(&hex[0..2], 16),
|
||||||
u8::from_str_radix(&hex[2..4], 16),
|
u8::from_str_radix(&hex[2..4], 16),
|
||||||
@@ -120,27 +129,87 @@ impl ThemeColors {
|
|||||||
return Color::Rgb(r, g, b);
|
return Color::Rgb(r, g, b);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Color::White
|
|
||||||
|
match value.to_ascii_lowercase().as_str() {
|
||||||
|
"reset" | "default" | "none" => Color::Reset,
|
||||||
|
"black" => Color::Black,
|
||||||
|
"red" => Color::Red,
|
||||||
|
"green" => Color::Green,
|
||||||
|
"yellow" => Color::Yellow,
|
||||||
|
"blue" => Color::Blue,
|
||||||
|
"magenta" => Color::Magenta,
|
||||||
|
"cyan" => Color::Cyan,
|
||||||
|
"gray" | "grey" => Color::Gray,
|
||||||
|
"darkgray" | "darkgrey" => Color::DarkGray,
|
||||||
|
"lightred" | "brightred" => Color::LightRed,
|
||||||
|
"lightgreen" | "brightgreen" => Color::LightGreen,
|
||||||
|
"lightyellow" | "brightyellow" => Color::LightYellow,
|
||||||
|
"lightblue" | "brightblue" => Color::LightBlue,
|
||||||
|
"lightmagenta" | "brightmagenta" => Color::LightMagenta,
|
||||||
|
"lightcyan" | "brightcyan" => Color::LightCyan,
|
||||||
|
"white" | "lightwhite" | "brightwhite" => Color::White,
|
||||||
|
_ => Color::White,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn bg(&self) -> Color { Self::parse_color(&self.bg) }
|
pub fn bg(&self) -> Color {
|
||||||
pub fn fg(&self) -> Color { Self::parse_color(&self.fg) }
|
Self::parse_color(&self.bg)
|
||||||
pub fn text_correct(&self) -> Color { Self::parse_color(&self.text_correct) }
|
}
|
||||||
pub fn text_incorrect(&self) -> Color { Self::parse_color(&self.text_incorrect) }
|
pub fn fg(&self) -> Color {
|
||||||
pub fn text_incorrect_bg(&self) -> Color { Self::parse_color(&self.text_incorrect_bg) }
|
Self::parse_color(&self.fg)
|
||||||
pub fn text_pending(&self) -> Color { Self::parse_color(&self.text_pending) }
|
}
|
||||||
pub fn text_cursor_bg(&self) -> Color { Self::parse_color(&self.text_cursor_bg) }
|
pub fn text_correct(&self) -> Color {
|
||||||
pub fn text_cursor_fg(&self) -> Color { Self::parse_color(&self.text_cursor_fg) }
|
Self::parse_color(&self.text_correct)
|
||||||
pub fn focused_key(&self) -> Color { Self::parse_color(&self.focused_key) }
|
}
|
||||||
pub fn accent(&self) -> Color { Self::parse_color(&self.accent) }
|
pub fn text_incorrect(&self) -> Color {
|
||||||
pub fn accent_dim(&self) -> Color { Self::parse_color(&self.accent_dim) }
|
Self::parse_color(&self.text_incorrect)
|
||||||
pub fn border(&self) -> Color { Self::parse_color(&self.border) }
|
}
|
||||||
pub fn border_focused(&self) -> Color { Self::parse_color(&self.border_focused) }
|
pub fn text_incorrect_bg(&self) -> Color {
|
||||||
pub fn header_bg(&self) -> Color { Self::parse_color(&self.header_bg) }
|
Self::parse_color(&self.text_incorrect_bg)
|
||||||
pub fn header_fg(&self) -> Color { Self::parse_color(&self.header_fg) }
|
}
|
||||||
pub fn bar_filled(&self) -> Color { Self::parse_color(&self.bar_filled) }
|
pub fn text_pending(&self) -> Color {
|
||||||
pub fn bar_empty(&self) -> Color { Self::parse_color(&self.bar_empty) }
|
Self::parse_color(&self.text_pending)
|
||||||
pub fn error(&self) -> Color { Self::parse_color(&self.error) }
|
}
|
||||||
pub fn warning(&self) -> Color { Self::parse_color(&self.warning) }
|
pub fn text_cursor_bg(&self) -> Color {
|
||||||
pub fn success(&self) -> Color { Self::parse_color(&self.success) }
|
Self::parse_color(&self.text_cursor_bg)
|
||||||
|
}
|
||||||
|
pub fn text_cursor_fg(&self) -> Color {
|
||||||
|
Self::parse_color(&self.text_cursor_fg)
|
||||||
|
}
|
||||||
|
pub fn focused_key(&self) -> Color {
|
||||||
|
Self::parse_color(&self.focused_key)
|
||||||
|
}
|
||||||
|
pub fn accent(&self) -> Color {
|
||||||
|
Self::parse_color(&self.accent)
|
||||||
|
}
|
||||||
|
pub fn accent_dim(&self) -> Color {
|
||||||
|
Self::parse_color(&self.accent_dim)
|
||||||
|
}
|
||||||
|
pub fn border(&self) -> Color {
|
||||||
|
Self::parse_color(&self.border)
|
||||||
|
}
|
||||||
|
pub fn border_focused(&self) -> Color {
|
||||||
|
Self::parse_color(&self.border_focused)
|
||||||
|
}
|
||||||
|
pub fn header_bg(&self) -> Color {
|
||||||
|
Self::parse_color(&self.header_bg)
|
||||||
|
}
|
||||||
|
pub fn header_fg(&self) -> Color {
|
||||||
|
Self::parse_color(&self.header_fg)
|
||||||
|
}
|
||||||
|
pub fn bar_filled(&self) -> Color {
|
||||||
|
Self::parse_color(&self.bar_filled)
|
||||||
|
}
|
||||||
|
pub fn bar_empty(&self) -> Color {
|
||||||
|
Self::parse_color(&self.bar_empty)
|
||||||
|
}
|
||||||
|
pub fn error(&self) -> Color {
|
||||||
|
Self::parse_color(&self.error)
|
||||||
|
}
|
||||||
|
pub fn warning(&self) -> Color {
|
||||||
|
Self::parse_color(&self.warning)
|
||||||
|
}
|
||||||
|
pub fn success(&self) -> Color {
|
||||||
|
Self::parse_color(&self.success)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user