diff --git a/src/generator/code_syntax.rs b/src/generator/code_syntax.rs index 8cafe8e..eb5dc99 100644 --- a/src/generator/code_syntax.rs +++ b/src/generator/code_syntax.rs @@ -785,7 +785,7 @@ pub fn build_code_download_queue(lang_key: &str, cache_dir: &str) -> Vec<(String pub struct CodeSyntaxGenerator { rng: SmallRng, language: String, - fetched_snippets: Vec, + fetched_snippets: Vec<(String, String)>, // (snippet, repo_key) last_source: String, } @@ -816,11 +816,17 @@ impl CodeSyntaxGenerator { let name = entry.file_name(); let name_str = name.to_string_lossy(); if name_str.starts_with(&prefix) && name_str.ends_with(".txt") { + // Extract repo key from filename: {language}_{repo}.txt + let repo_key = name_str + .strip_prefix(&prefix) + .and_then(|s| s.strip_suffix(".txt")) + .unwrap_or("unknown") + .to_string(); if let Ok(content) = fs::read_to_string(entry.path()) { - let snippets: Vec = content + let snippets: Vec<(String, String)> = content .split("\n---SNIPPET---\n") .filter(|s| !s.trim().is_empty()) - .map(|s| s.to_string()) + .map(|s| (s.to_string(), repo_key.clone())) .collect(); self.fetched_snippets.extend(snippets); } @@ -1660,7 +1666,7 @@ impl TextGenerator for CodeSyntaxGenerator { candidates.push((false, i)); } } - for (i, snippet) in self.fetched_snippets.iter().enumerate() { + for (i, (snippet, _)) in self.fetched_snippets.iter().enumerate() { if approx_token_count(snippet) >= min_units { candidates.push((true, i)); } @@ -1681,22 +1687,29 @@ impl TextGenerator for CodeSyntaxGenerator { let pick = self.rng.gen_range(0..candidates.len()); let (is_fetched, idx) = candidates[pick]; - let used_fetched = is_fetched; - let selected = if is_fetched { - self.fetched_snippets + let display_name = CODE_LANGUAGES + .iter() + .find(|l| l.key == self.language) + .map(|l| l.display_name) + .unwrap_or(&self.language); + + let (selected, repo_key) = if is_fetched { + let (snippet, repo) = self + .fetched_snippets .get(idx) - .map(|s| s.as_str()) - .unwrap_or_else(|| embedded[0]) + .map(|(s, r)| (s.as_str(), Some(r.as_str()))) + .unwrap_or((embedded[0], None)); + (snippet, repo) } else { - embedded.get(idx).copied().unwrap_or(embedded[0]) + (embedded.get(idx).copied().unwrap_or(embedded[0]), None) }; let text = fit_snippet_to_target(selected, target_words); - self.last_source = if used_fetched { - format!("GitHub source cache ({})", self.language) + self.last_source = if let Some(repo) = repo_key { + format!("{} \u{b7} {}", display_name, repo) } else { - format!("Built-in snippets ({})", self.language) + format!("{} \u{b7} built-in", display_name) }; text diff --git a/src/keyboard/model.rs b/src/keyboard/model.rs index ea5addd..338e08b 100644 --- a/src/keyboard/model.rs +++ b/src/keyboard/model.rs @@ -1,3 +1,4 @@ +use crate::keyboard::display::{BACKSPACE, ENTER, SPACE, TAB}; use crate::keyboard::finger::{Finger, FingerAssignment, Hand}; #[derive(Clone, Debug)] @@ -732,10 +733,18 @@ impl KeyboardModel { /// 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) + match ch { + // Built-in/non-grid keys in this app. + TAB => FingerAssignment::new(Hand::Left, Finger::Pinky), + ENTER | BACKSPACE => FingerAssignment::new(Hand::Right, Finger::Pinky), + SPACE => FingerAssignment::new(Hand::Right, Finger::Thumb), + _ => { + 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) + } + } } } @@ -816,4 +825,25 @@ mod tests { let _ = model.finger_for_char('!'); let _ = model.finger_for_char('{'); } + + #[test] + fn test_finger_for_meta_keys() { + let model = KeyboardModel::qwerty(); + assert_eq!( + model.finger_for_char(TAB), + FingerAssignment::new(Hand::Left, Finger::Pinky) + ); + assert_eq!( + model.finger_for_char(ENTER), + FingerAssignment::new(Hand::Right, Finger::Pinky) + ); + assert_eq!( + model.finger_for_char(BACKSPACE), + FingerAssignment::new(Hand::Right, Finger::Pinky) + ); + assert_eq!( + model.finger_for_char(SPACE), + FingerAssignment::new(Hand::Right, Finger::Thumb) + ); + } } diff --git a/src/main.rs b/src/main.rs index bc84cbb..5bfc0d1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -80,12 +80,22 @@ fn main() -> Result<()> { let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen)?; - // Try to enable keyboard enhancement for Release event support + // Request kitty keyboard protocol enhancements from the terminal. + // - DISAMBIGUATE_ESCAPE_CODES: CSI u sequences for unambiguous key IDs, + // enables CAPS_LOCK/NUM_LOCK state detection. + // - REPORT_EVENT_TYPES: key release and repeat events (for depressed-key + // tracking in the keyboard diagram). + // - REPORT_ALL_KEYS_AS_ESCAPE_CODES: standalone modifier key events + // (LeftShift, RightShift, etc.) so we can track shift state independently. + // Falls back gracefully in terminals that don't support the protocol (tmux, + // mosh, SSH, older emulators) — the execute! returns Err and we use + // timer-based fallbacks instead. let keyboard_enhanced = execute!( io::stdout(), PushKeyboardEnhancementFlags( KeyboardEnhancementFlags::REPORT_EVENT_TYPES - | KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES, + | KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES + | KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES, ) ) .is_ok(); @@ -135,17 +145,25 @@ fn run_app( { app.process_code_download_tick(); } - // Fallback: clear depressed keys after 150ms if no Release event received + // Fallback: clear depressed keys and shift state on a timer. + // Needed because not all terminals send Release events (e.g. + // WezTerm doesn't implement REPORT_EVENT_TYPES). Terminals that + // DO send Release events handle cleanup in handle_key instead, + // and the repeated Press events they send while a key is held + // keep resetting last_key_time so this fallback never fires. + // This causes a brief flicker (key clears, then re-appears when + // OS key repeat kicks in after ~300-500ms), but that's an + // acceptable tradeoff for responsive key press visualization. if let Some(last) = app.last_key_time { - if last.elapsed() > Duration::from_millis(150) && !app.depressed_keys.is_empty() - { - app.depressed_keys.clear(); + if last.elapsed() > Duration::from_millis(150) { + if !app.depressed_keys.is_empty() { + app.depressed_keys.clear(); + } + if app.shift_held { + app.shift_held = false; + } app.last_key_time = None; } - // Clear shift_held after 200ms as fallback - if last.elapsed() > Duration::from_millis(200) && app.shift_held { - app.shift_held = false; - } } } AppEvent::Resize(_, _) => {} @@ -159,15 +177,35 @@ fn run_app( fn handle_key(app: &mut App, key: KeyEvent) { // Track caps lock state via Kitty protocol metadata (KeyEventState::CAPS_LOCK). - // This only works in terminals with native Kitty keyboard protocol support - // (Kitty, WezTerm, foot, Ghostty). In tmux/mosh/SSH, the protocol is stripped - // and crossterm infers SHIFT from character case, making it impossible to - // distinguish Shift+a from CapsLock+a. - app.caps_lock = key.state.contains(KeyEventState::CAPS_LOCK); + // Only Modifier key events reliably report lock state in WezTerm; regular + // character events have empty state (0x0). So we only set caps_lock=true + // when CAPS_LOCK appears, and only clear it from Modifier events that + // reliably report state. + if key.state.contains(KeyEventState::CAPS_LOCK) { + app.caps_lock = true; + } else if matches!(key.code, KeyCode::Modifier(_) | KeyCode::CapsLock) { + // Modifier events and CapsLock key events reliably report lock state. + // If CAPS_LOCK isn't in state, caps lock was toggled off. + app.caps_lock = false; + } + + // Determine whether the physical Shift key is held. When caps lock is on, + // crossterm infers SHIFT from uppercase chars, so we need a heuristic: + // caps lock + shift inverts case, producing lowercase. So if caps lock is + // on and the char is uppercase, that's caps lock alone, not shift. + let infer_shift = |ch: char, mods: KeyModifiers, caps: bool| -> bool { + let has_shift = mods.contains(KeyModifiers::SHIFT); + if caps && ch.is_ascii_alphabetic() { + // Caps lock on: shift would invert to lowercase + has_shift && ch.is_ascii_lowercase() + } else { + has_shift + } + }; // Track depressed keys and shift state for keyboard diagram match (&key.code, key.kind) { - (KeyCode::Modifier(ModifierKeyCode::LeftShift | ModifierKeyCode::RightShift), KeyEventKind::Press) => { + (KeyCode::Modifier(ModifierKeyCode::LeftShift | ModifierKeyCode::RightShift), KeyEventKind::Press | KeyEventKind::Repeat) => { app.shift_held = true; app.last_key_time = Some(Instant::now()); return; // Don't dispatch bare shift presses to screen handlers @@ -179,7 +217,7 @@ fn handle_key(app: &mut App, key: KeyEvent) { (KeyCode::Char(ch), KeyEventKind::Press) => { app.depressed_keys.insert(ch.to_ascii_lowercase()); app.last_key_time = Some(Instant::now()); - app.shift_held = key.modifiers.contains(KeyModifiers::SHIFT); + app.shift_held = infer_shift(*ch, key.modifiers, app.caps_lock); } (KeyCode::Char(ch), KeyEventKind::Release) => { app.depressed_keys.remove(&ch.to_ascii_lowercase()); @@ -1778,6 +1816,260 @@ mod review_tests { let result = next_available_path(custom_path.to_str().unwrap()); assert!(result.ends_with("my-backup-2.json")); } + + // --- Keyboard state tracking tests --- + + /// Helper to build a KeyEvent with specific state flags. + fn key_event_with_state( + code: KeyCode, + modifiers: KeyModifiers, + kind: KeyEventKind, + state: KeyEventState, + ) -> KeyEvent { + KeyEvent { + code, + modifiers, + kind, + state, + } + } + + #[test] + fn caps_lock_set_from_state_flag() { + let mut app = App::new(); + assert!(!app.caps_lock); + + // Modifier event with CAPS_LOCK in state turns it on + handle_key( + &mut app, + key_event_with_state( + KeyCode::Modifier(ModifierKeyCode::LeftShift), + KeyModifiers::SHIFT, + KeyEventKind::Press, + KeyEventState::CAPS_LOCK, + ), + ); + assert!(app.caps_lock); + } + + #[test] + fn caps_lock_not_cleared_by_char_event_with_empty_state() { + let mut app = App::new(); + app.caps_lock = true; + app.screen = AppScreen::Drill; + app.drill = Some(crate::session::drill::DrillState::new("abc")); + + // Character event with empty state should NOT clear caps_lock + handle_key( + &mut app, + key_event_with_state( + KeyCode::Char('A'), + KeyModifiers::SHIFT, + KeyEventKind::Press, + KeyEventState::NONE, + ), + ); + assert!(app.caps_lock, "char event with empty state must not clear caps_lock"); + } + + #[test] + fn caps_lock_cleared_by_modifier_event_without_caps_flag() { + let mut app = App::new(); + app.caps_lock = true; + + // Modifier event WITHOUT CAPS_LOCK in state clears it + handle_key( + &mut app, + key_event_with_state( + KeyCode::Modifier(ModifierKeyCode::LeftShift), + KeyModifiers::SHIFT, + KeyEventKind::Press, + KeyEventState::NONE, + ), + ); + assert!(!app.caps_lock, "modifier event without CAPS_LOCK flag should clear caps_lock"); + } + + #[test] + fn caps_lock_on_uppercase_char_does_not_set_shift() { + let mut app = App::new(); + app.caps_lock = true; + app.screen = AppScreen::Drill; + app.drill = Some(crate::session::drill::DrillState::new("ABC")); + + // Caps lock on, typing 'A' — crossterm may report SHIFT modifier, + // but this is caps lock, not physical shift + handle_key( + &mut app, + key_event_with_state( + KeyCode::Char('A'), + KeyModifiers::SHIFT, + KeyEventKind::Press, + KeyEventState::NONE, + ), + ); + assert!(!app.shift_held, "uppercase char with caps lock should not set shift_held"); + } + + #[test] + fn caps_lock_on_lowercase_char_with_shift_sets_shift() { + let mut app = App::new(); + app.caps_lock = true; + app.screen = AppScreen::Drill; + app.drill = Some(crate::session::drill::DrillState::new("abc")); + + // Caps lock on + shift held produces lowercase: shift IS held + handle_key( + &mut app, + key_event_with_state( + KeyCode::Char('a'), + KeyModifiers::SHIFT, + KeyEventKind::Press, + KeyEventState::NONE, + ), + ); + assert!(app.shift_held, "lowercase char with caps+shift should set shift_held"); + } + + #[test] + fn caps_lock_off_uppercase_char_with_shift_sets_shift() { + let mut app = App::new(); + assert!(!app.caps_lock); + app.screen = AppScreen::Drill; + app.drill = Some(crate::session::drill::DrillState::new("ABC")); + + // Normal shift+a = 'A', caps lock off + handle_key( + &mut app, + key_event_with_state( + KeyCode::Char('A'), + KeyModifiers::SHIFT, + KeyEventKind::Press, + KeyEventState::NONE, + ), + ); + assert!(app.shift_held, "uppercase char without caps lock should set shift_held"); + } + + #[test] + fn caps_lock_off_lowercase_char_without_shift_clears_shift() { + let mut app = App::new(); + app.shift_held = true; + app.screen = AppScreen::Drill; + app.drill = Some(crate::session::drill::DrillState::new("abc")); + + // Normal lowercase typing, no shift + handle_key( + &mut app, + key_event_with_state( + KeyCode::Char('a'), + KeyModifiers::NONE, + KeyEventKind::Press, + KeyEventState::NONE, + ), + ); + assert!(!app.shift_held, "lowercase char without shift should clear shift_held"); + } + + #[test] + fn shift_modifier_press_sets_shift_held() { + let mut app = App::new(); + + handle_key( + &mut app, + key_event_with_state( + KeyCode::Modifier(ModifierKeyCode::LeftShift), + KeyModifiers::SHIFT, + KeyEventKind::Press, + KeyEventState::NONE, + ), + ); + assert!(app.shift_held); + } + + #[test] + fn shift_modifier_release_clears_shift_held() { + let mut app = App::new(); + app.shift_held = true; + + handle_key( + &mut app, + key_event_with_state( + KeyCode::Modifier(ModifierKeyCode::RightShift), + KeyModifiers::SHIFT, + KeyEventKind::Release, + KeyEventState::NONE, + ), + ); + assert!(!app.shift_held); + } + + #[test] + fn depressed_keys_tracks_char_press() { + let mut app = App::new(); + app.screen = AppScreen::Drill; + app.drill = Some(crate::session::drill::DrillState::new("abc")); + + handle_key(&mut app, KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); + assert!(app.depressed_keys.contains(&'a')); + } + + #[test] + fn depressed_keys_release_removes_char() { + let mut app = App::new(); + app.depressed_keys.insert('a'); + + handle_key( + &mut app, + key_event_with_state( + KeyCode::Char('a'), + KeyModifiers::NONE, + KeyEventKind::Release, + KeyEventState::NONE, + ), + ); + assert!(!app.depressed_keys.contains(&'a')); + } + + #[test] + fn caps_lock_cleared_by_capslock_key_without_caps_flag() { + let mut app = App::new(); + app.caps_lock = true; + + // Pressing CapsLock key to toggle off: event has KeyCode::CapsLock + // but state no longer contains CAPS_LOCK + handle_key( + &mut app, + key_event_with_state( + KeyCode::CapsLock, + KeyModifiers::NONE, + KeyEventKind::Press, + KeyEventState::NONE, + ), + ); + assert!(!app.caps_lock, "CapsLock key event without CAPS_LOCK state should clear caps_lock"); + } + + #[test] + fn caps_lock_non_alpha_char_with_shift_still_sets_shift() { + let mut app = App::new(); + app.caps_lock = true; + app.screen = AppScreen::Drill; + app.drill = Some(crate::session::drill::DrillState::new("!@#")); + + // Caps lock doesn't affect non-alpha chars like '!', so SHIFT + // modifier should be trusted as-is + handle_key( + &mut app, + key_event_with_state( + KeyCode::Char('!'), + KeyModifiers::SHIFT, + KeyEventKind::Press, + KeyEventState::NONE, + ), + ); + assert!(app.shift_held, "non-alpha char with shift should set shift_held regardless of caps"); + } } fn render_result(frame: &mut ratatui::Frame, app: &App) { @@ -3112,11 +3404,8 @@ fn render_keyboard_detail_panel(frame: &mut ratatui::Frame, app: &App, area: Rec "No data".to_string() }; - let (branch_name, level_name) = if let Some((branch, level, pos)) = find_key_branch(selected) { - (branch.name.to_string(), format!("{level} (key #{pos})")) - } else { - ("Unknown".to_string(), "Unknown".to_string()) - }; + let branch_info = find_key_branch(selected) + .map(|(branch, level, pos)| (branch.name.to_string(), format!("{level} (key #{pos})"))); // Ranked-only mastery display (same semantics as skill tree per-key progress) let ranked_conf = app.ranked_key_stats.get_confidence(selected).min(1.0); @@ -3138,12 +3427,15 @@ fn render_keyboard_detail_panel(frame: &mut ratatui::Frame, app: &App, area: Rec format!("Overall Accuracy: {}", fmt_acc(overall_acc)), ]; - let mut right_col: Vec = vec![ - format!("Branch: {branch_name}"), - format!("Level: {level_name}"), - format!("Unlocked: {}", if is_unlocked { "Yes" } else { "No" }), - format!("In Focus?: {}", if in_focus { "Yes" } else { "No" }), - ]; + let mut right_col: Vec = Vec::new(); + if let Some((branch_name, level_name)) = branch_info { + right_col.push(format!("Branch: {branch_name}")); + right_col.push(format!("Level: {level_name}")); + } else { + right_col.push("Built-in Key".to_string()); + } + right_col.push(format!("Unlocked: {}", if is_unlocked { "Yes" } else { "No" })); + right_col.push(format!("In Focus?: {}", if in_focus { "Yes" } else { "No" })); if is_unlocked { right_col.push(format!("Mastery: {mastery_text}")); } else { diff --git a/src/ui/components/keyboard_diagram.rs b/src/ui/components/keyboard_diagram.rs index 7d7f7dd..ffa2868 100644 --- a/src/ui/components/keyboard_diagram.rs +++ b/src/ui/components/keyboard_diagram.rs @@ -210,7 +210,13 @@ impl KeyboardDiagram<'_> { break; } - let display_char = if self.shift_held { + // Caps lock inverts shift for alpha keys only + let show_shifted = if physical_key.base.is_ascii_alphabetic() { + self.shift_held ^ self.caps_lock + } else { + self.shift_held + }; + let display_char = if show_shifted { physical_key.shifted } else { physical_key.base @@ -328,7 +334,13 @@ impl KeyboardDiagram<'_> { break; } - let display_char = if self.shift_held { + // Caps lock inverts shift for alpha keys only + let show_shifted = if physical_key.base.is_ascii_alphabetic() { + self.shift_held ^ self.caps_lock + } else { + self.shift_held + }; + let display_char = if show_shifted { physical_key.shifted } else { physical_key.base @@ -444,7 +456,13 @@ impl KeyboardDiagram<'_> { break; } - let display_char = if self.shift_held { + // Caps lock inverts shift for alpha keys only + let show_shifted = if physical_key.base.is_ascii_alphabetic() { + self.shift_held ^ self.caps_lock + } else { + self.shift_held + }; + let display_char = if show_shifted { physical_key.shifted } else { physical_key.base diff --git a/src/ui/components/stats_dashboard.rs b/src/ui/components/stats_dashboard.rs index bec9311..5abb1de 100644 --- a/src/ui/components/stats_dashboard.rs +++ b/src/ui/components/stats_dashboard.rs @@ -6,7 +6,7 @@ use ratatui::widgets::{Block, Clear, Paragraph, Widget}; use std::collections::{BTreeSet, HashMap}; use crate::engine::key_stats::KeyStatsStore; -use crate::keyboard::display::{self, BACKSPACE, ENTER, SPACE, TAB}; +use crate::keyboard::display::{self, BACKSPACE, ENTER, MODIFIER_SENTINELS, SPACE, TAB}; use crate::keyboard::model::KeyboardModel; use crate::session::result::DrillResult; use crate::ui::components::activity_heatmap::ActivityHeatmap; @@ -1052,9 +1052,9 @@ impl StatsDashboard<'_> { } // Include modifier/whitespace keys all_keys.insert(SPACE); - all_keys.insert(TAB); - all_keys.insert(ENTER); - all_keys.insert(BACKSPACE); + for &key in MODIFIER_SENTINELS { + all_keys.insert(key); + } let mut key_accuracies: Vec<(char, f64)> = all_keys .into_iter() @@ -1130,9 +1130,9 @@ impl StatsDashboard<'_> { } } all_keys.insert(SPACE); - all_keys.insert(TAB); - all_keys.insert(ENTER); - all_keys.insert(BACKSPACE); + for &key in MODIFIER_SENTINELS { + all_keys.insert(key); + } let mut key_accuracies: Vec<(char, f64)> = all_keys .into_iter()