Fix kitty protocol, caps lock, code source desc

This commit is contained in:
2026-02-22 16:51:34 -05:00
parent 9d59c265dd
commit f8bcad247b
5 changed files with 408 additions and 55 deletions

View File

@@ -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<String>,
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<String> = 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

View File

@@ -1,3 +1,4 @@
use crate::keyboard::display::{BACKSPACE, ENTER, SPACE, TAB};
use crate::keyboard::finger::{Finger, FingerAssignment, Hand};
#[derive(Clone, Debug)]
@@ -732,12 +733,20 @@ impl KeyboardModel {
/// Get finger assignment for a character, looking it up in the model.
pub fn finger_for_char(&self, ch: char) -> FingerAssignment {
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)
}
}
}
}
/// Letter-only rows (rows 1-3) for compact keyboard display.
pub fn letter_rows(&self) -> &[Vec<PhysicalKey>] {
@@ -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)
);
}
}

View File

@@ -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()
{
if last.elapsed() > Duration::from_millis(150) {
if !app.depressed_keys.is_empty() {
app.depressed_keys.clear();
app.last_key_time = None;
}
// Clear shift_held after 200ms as fallback
if last.elapsed() > Duration::from_millis(200) && app.shift_held {
if app.shift_held {
app.shift_held = false;
}
app.last_key_time = None;
}
}
}
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<String> = 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<String> = 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 {

View File

@@ -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

View File

@@ -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()