Files
keydr/docs/plans/2026-02-16-skill-tree-integration-fixes-ui-improvements.md

12 KiB

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)

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