diff --git a/crates/voxctrl-config/src/lib.rs b/crates/voxctrl-config/src/lib.rs index 06825fb..cd15d9c 100644 --- a/crates/voxctrl-config/src/lib.rs +++ b/crates/voxctrl-config/src/lib.rs @@ -796,6 +796,63 @@ fn migrate_hf_token(data: &mut AppConfig) -> bool { true } +/// The evdev name for a key name an older settings UI saved, or `None` when +/// `key` needs no change. +/// +/// The key recorder used to name keys it had no rule for after the character +/// they typed, so punctuation was saved as `KEY_.`, `KEY_;` and so on, and the +/// TTS stop key recorder saved letters as `KEYV`. No hotkey backend reports +/// those names, so a binding holding one could never fire. The character is +/// read as the US-layout key that types it (shifted or not), which is what the +/// recorder now names by position; on other layouts a few may land on a +/// neighbouring key, which is still better than a binding that is dead. +pub fn migrate_legacy_key_name(key: &str) -> Option<&'static str> { + Some(match key { + "KEY_ESCAPE" => "KEY_ESC", + "KEY_." | "KEY_>" => "KEY_DOT", + "KEY_," | "KEY_<" => "KEY_COMMA", + "KEY_/" | "KEY_?" => "KEY_SLASH", + "KEY_;" | "KEY_:" => "KEY_SEMICOLON", + "KEY_'" | "KEY_\"" => "KEY_APOSTROPHE", + "KEY_`" | "KEY_~" => "KEY_GRAVE", + "KEY_-" | "KEY__" => "KEY_MINUS", + "KEY_=" | "KEY_+" => "KEY_EQUAL", + "KEY_[" | "KEY_{" => "KEY_LEFTBRACE", + "KEY_]" | "KEY_}" => "KEY_RIGHTBRACE", + "KEY_\\" | "KEY_|" => "KEY_BACKSLASH", + "KEY_*" => "KEY_KPASTERISK", + "KEY_NUMPADENTER" => "KEY_KPENTER", + "KEY_PRINTSCREEN" => "KEY_SYSRQ", + "KEY_CONTEXTMENU" => "KEY_COMPOSE", + _ => return legacy_letter(key), + }) +} + +/// `KEYA` … `KEYZ`, as the old stop key recorder wrote letters. +fn legacy_letter(key: &str) -> Option<&'static str> { + const LETTERS: [&str; 26] = [ + "KEY_A", "KEY_B", "KEY_C", "KEY_D", "KEY_E", "KEY_F", "KEY_G", "KEY_H", "KEY_I", + "KEY_J", "KEY_K", "KEY_L", "KEY_M", "KEY_N", "KEY_O", "KEY_P", "KEY_Q", "KEY_R", + "KEY_S", "KEY_T", "KEY_U", "KEY_V", "KEY_W", "KEY_X", "KEY_Y", "KEY_Z", + ]; + match key.strip_prefix("KEY")?.as_bytes() { + [c @ b'A'..=b'Z'] => Some(LETTERS[usize::from(c - b'A')]), + _ => None, + } +} + +/// Rewrite every legacy key name in `keys`. Returns whether anything changed. +pub fn migrate_legacy_key_names(keys: &mut [String]) -> bool { + let mut changed = false; + for k in keys.iter_mut() { + if let Some(name) = migrate_legacy_key_name(k) { + *k = name.to_string(); + changed = true; + } + } + changed +} + /// Rename `/voxctrl/pocket-tts-voices` to `/voxctrl/cloned-tts-voices`, /// the shared clip folder's new name now that it is used by every /// voice-cloning TTS engine, not just Pocket-TTS. Returns whether a rename @@ -917,14 +974,10 @@ impl Config { } } - // Migrate legacy "KEY_ESCAPE" → "KEY_ESC" (evdev crate uses KEY_ESC as the - // canonical debug name via stringify!(KEY_ESC)). - let needs_escape_fix = data.tts.stop_key.iter().any(|k| k == "KEY_ESCAPE"); - if needs_escape_fix { - data.tts.stop_key = data.tts.stop_key - .into_iter() - .map(|k| if k == "KEY_ESCAPE" { "KEY_ESC".to_string() } else { k }) - .collect(); + // Migrate key names older builds saved that no backend reports: + // "KEY_ESCAPE" (the evdev crate's name is KEY_ESC), punctuation saved as + // the character it types ("KEY_."), and so on. + if migrate_legacy_key_names(&mut data.tts.stop_key) { let clean_config = Self { data: data.clone(), path: path.clone() }; if let Err(e) = clean_config.save() { tracing::error!("Failed to save migrated stop_key: {e}"); @@ -1099,6 +1152,32 @@ pub fn validate(cfg: &AppConfig) -> Vec { #[cfg(test)] mod tests { + #[test] + fn legacy_key_names_are_rewritten_to_evdev_names() { + use super::migrate_legacy_key_names; + let mut keys: Vec = ["KEY_LEFTCTRL", "KEY_.", "KEY_;", "KEY_\\", "KEY_ESCAPE", "KEYV"] + .iter() + .map(|s| s.to_string()) + .collect(); + assert!(migrate_legacy_key_names(&mut keys)); + assert_eq!( + keys, + ["KEY_LEFTCTRL", "KEY_DOT", "KEY_SEMICOLON", "KEY_BACKSLASH", "KEY_ESC", "KEY_V"] + ); + } + + #[test] + fn current_key_names_are_left_alone() { + use super::migrate_legacy_key_names; + let mut keys: Vec = ["KEY_LEFTMETA", "KEY_SPACE", "KEY_DOT", "KEY_KP1", "KEY_F5"] + .iter() + .map(|s| s.to_string()) + .collect(); + let before = keys.clone(); + assert!(!migrate_legacy_key_names(&mut keys)); + assert_eq!(keys, before); + } + #[test] fn a_name_on_path_is_found() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/voxctrl-hotkeys/src/win_keys.rs b/crates/voxctrl-hotkeys/src/win_keys.rs index 3fbcfed..fb40072 100644 --- a/crates/voxctrl-hotkeys/src/win_keys.rs +++ b/crates/voxctrl-hotkeys/src/win_keys.rs @@ -39,10 +39,13 @@ pub mod keymap { //! # Left and right //! //! Modifiers keep their side: right Ctrl is `KEY_RIGHTCTRL`, not `KEY_LEFTCTRL`. - //! That matches evdev and the X11 backend exactly. The settings UI collapses - //! both sides to the left name when recording, so a binding captured on right - //! Ctrl fires only from left Ctrl — surprising, but it is precisely what Linux - //! does today, and diverging here would make the platforms disagree. + //! That matches evdev and the X11 backend exactly, and the settings UI records + //! the side that was pressed (`src/lib/keys.ts`), so a binding captured on + //! right Ctrl fires from right Ctrl on every platform. + //! + //! `NAMES` is also the list the frontend's key recorder is tested against + //! (`tests/svelte/keys.test.ts` reads it from this file), so keep it a flat + //! array of string literals. /// Every key VoxCtrl can name, in a fixed order. The index into this table is /// the compact key id used by the hook's lock-free pressed/suppressed arrays. diff --git a/crates/voxctrl-routing/src/loader.rs b/crates/voxctrl-routing/src/loader.rs index 57eaa11..74b755a 100644 --- a/crates/voxctrl-routing/src/loader.rs +++ b/crates/voxctrl-routing/src/loader.rs @@ -324,7 +324,13 @@ fn target_to_raw(t: &OutputTarget) -> RawTarget { } } -fn raw_to_binding(r: RawBinding) -> HotkeyBinding { +fn raw_to_binding(mut r: RawBinding) -> HotkeyBinding { + // Older settings UIs saved some keys under names no backend reports + // (`KEY_.` for the period key). Repair them in memory; the next save of + // bindings.toml writes the corrected names back. + if voxctrl_config::migrate_legacy_key_names(&mut r.keys) { + tracing::info!(binding = %r.id, keys = ?r.keys, "Migrated legacy key names"); + } let gesture = match r.gesture.as_str() { "toggle" => GestureType::Toggle, "double_tap" => GestureType::DoubleTap, diff --git a/crates/voxctrl-routing/src/tests.rs b/crates/voxctrl-routing/src/tests.rs index 3d09293..1566d82 100644 --- a/crates/voxctrl-routing/src/tests.rs +++ b/crates/voxctrl-routing/src/tests.rs @@ -2349,3 +2349,25 @@ fn test_parse_voice_command_non_ascii_text_and_targets() { // must be skipped without slicing mid-character. assert!(parse_voice_command("Hey Vox, xÜberblick hallo", &targets).is_none()); } + +#[test] +fn bindings_saved_with_legacy_key_names_load_with_evdev_names() { + use crate::loader::load_bindings; + let temp_dir = tempfile::tempdir().unwrap(); + std::fs::write( + temp_dir.path().join("bindings.toml"), + r#" +format_version = "1.1" + +[[binding]] +id = "punct" +label = "Punctuation" +keys = ["KEY_LEFTCTRL", "KEY_."] +gesture = "hold" +target_id = "default" +"#, + ) + .unwrap(); + let loaded = load_bindings(temp_dir.path()).unwrap(); + assert_eq!(loaded[0].keys, vec!["KEY_LEFTCTRL", "KEY_DOT"]); +} diff --git a/src/lib/Settings/HotkeysTab.svelte b/src/lib/Settings/HotkeysTab.svelte index ee3c333..f5a95a8 100644 --- a/src/lib/Settings/HotkeysTab.svelte +++ b/src/lib/Settings/HotkeysTab.svelte @@ -1,6 +1,7 @@