Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 87 additions & 8 deletions crates/voxctrl-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<base>/voxctrl/pocket-tts-voices` to `<base>/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
Expand Down Expand Up @@ -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}");
Expand Down Expand Up @@ -1099,6 +1152,32 @@ pub fn validate(cfg: &AppConfig) -> Vec<String> {

#[cfg(test)]
mod tests {
#[test]
fn legacy_key_names_are_rewritten_to_evdev_names() {
use super::migrate_legacy_key_names;
let mut keys: Vec<String> = ["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<String> = ["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();
Expand Down
11 changes: 7 additions & 4 deletions crates/voxctrl-hotkeys/src/win_keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 7 additions & 1 deletion crates/voxctrl-routing/src/loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
22 changes: 22 additions & 0 deletions crates/voxctrl-routing/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
}
27 changes: 1 addition & 26 deletions src/lib/Settings/HotkeysTab.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { mapBrowserKeyToEvdev } from "../keys";
import type { OutputTarget, HotkeyBinding } from "./routing-types";
import { config } from "../../stores/config";
import TargetEditorModal from "./TargetEditorModal.svelte";
Expand Down Expand Up @@ -448,32 +449,6 @@
}

// --- Keyboard Event Capture / Recorder ---
function mapBrowserKeyToEvdev(key: string, code: string): string {
const codeUpper = code.toUpperCase();
if (key === "Control") return "KEY_LEFTCTRL";
if (key === "Alt") return "KEY_LEFTALT";
if (key === "Shift") return "KEY_LEFTSHIFT";
if (key === "Meta" || key === "OS" || key === "Super") return "KEY_LEFTMETA";

if (codeUpper === "SPACE") return "KEY_SPACE";
if (codeUpper === "ENTER") return "KEY_ENTER";
if (codeUpper === "ESCAPE" || codeUpper === "ESC") return "KEY_ESC";
if (codeUpper === "TAB") return "KEY_TAB";
if (codeUpper === "BACKSPACE") return "KEY_BACKSPACE";
if (codeUpper === "DELETE") return "KEY_DELETE";

if (/^KEY[A-Z]$/.test(codeUpper)) {
return `KEY_${codeUpper.slice(3)}`;
}
if (codeUpper.startsWith("KEY")) return codeUpper;
if (codeUpper.startsWith("DIGIT")) return `KEY_${codeUpper.replace("DIGIT", "")}`;
if (codeUpper.startsWith("ARROW")) return `KEY_${codeUpper.replace("ARROW", "")}`;
if (codeUpper.startsWith("F") && codeUpper.length > 1) return `KEY_${codeUpper}`;

if (key.length === 1) return `KEY_${key.toUpperCase()}`;
return `KEY_${codeUpper}`;
}

let currentlyPressedKeys = $state<string[]>([]);
// The keys the binding had when the edit modal was opened. Used to detect
// a no-op re-record so we don't show stale rejection state when the user
Expand Down
21 changes: 1 addition & 20 deletions src/lib/Settings/TtsTab.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import type { AppConfig } from "../../stores/config";
import { config, configDirty, saveConfig } from "../../stores/config";
import { invoke } from "@tauri-apps/api/core";
import { mapBrowserKeyToEvdev } from "../keys";
import { listen } from "@tauri-apps/api/event";
import { onMount, onDestroy } from "svelte";
import CustomSelect from "./CustomSelect.svelte";
Expand Down Expand Up @@ -703,26 +704,6 @@
let isRecordingStopKey = $state(false);
let currentlyPressedStopKeys = $state<string[]>([]);

function mapBrowserKeyToEvdev(key: string, code: string): string {
const codeUpper = code.toUpperCase();
if (key === "Control") return "KEY_LEFTCTRL";
if (key === "Alt") return "KEY_LEFTALT";
if (key === "Shift") return "KEY_LEFTSHIFT";
if (key === "Meta" || key === "OS" || key === "Super") return "KEY_LEFTMETA";
if (codeUpper === "SPACE") return "KEY_SPACE";
if (codeUpper === "ENTER") return "KEY_ENTER";
if (codeUpper === "ESCAPE" || codeUpper === "ESC") return "KEY_ESC";
if (codeUpper === "TAB") return "KEY_TAB";
if (codeUpper === "BACKSPACE") return "KEY_BACKSPACE";
if (codeUpper === "DELETE") return "KEY_DELETE";
if (codeUpper.startsWith("KEY")) return codeUpper;
if (codeUpper.startsWith("DIGIT")) return `KEY_${codeUpper.replace("DIGIT", "")}`;
if (codeUpper.startsWith("ARROW")) return `KEY_${codeUpper.replace("ARROW", "")}`;
if (codeUpper.startsWith("F") && codeUpper.length > 1) return `KEY_${codeUpper}`;
if (key.length === 1) return `KEY_${key.toUpperCase()}`;
return `KEY_${codeUpper}`;
}

function handleStopKeyDown(e: KeyboardEvent) {
if (!isRecordingStopKey) return;
e.preventDefault();
Expand Down
33 changes: 3 additions & 30 deletions src/lib/Wizard/wizard-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,36 +350,9 @@ export function waveBars(count: number): { d: string; dl: string }[] {

// ── Key naming ───────────────────────────────────────────────────────────────

/**
* Map a browser key event onto the evdev name VoxCtrl stores in bindings.toml.
*
* Kept identical to the recorder in Settings → Hotkeys: a combination captured
* in the wizard has to be the same combination when the user later opens that
* tab, or the binding they made here would appear to have changed by itself.
*/
export function mapBrowserKeyToEvdev(key: string, code: string): string {
const codeUpper = code.toUpperCase();
if (key === "Control") return "KEY_LEFTCTRL";
if (key === "Alt") return "KEY_LEFTALT";
if (key === "Shift") return "KEY_LEFTSHIFT";
if (key === "Meta" || key === "OS" || key === "Super") return "KEY_LEFTMETA";

if (codeUpper === "SPACE") return "KEY_SPACE";
if (codeUpper === "ENTER") return "KEY_ENTER";
if (codeUpper === "ESCAPE" || codeUpper === "ESC") return "KEY_ESC";
if (codeUpper === "TAB") return "KEY_TAB";
if (codeUpper === "BACKSPACE") return "KEY_BACKSPACE";
if (codeUpper === "DELETE") return "KEY_DELETE";

if (/^KEY[A-Z]$/.test(codeUpper)) return `KEY_${codeUpper.slice(3)}`;
if (codeUpper.startsWith("KEY")) return codeUpper;
if (codeUpper.startsWith("DIGIT")) return `KEY_${codeUpper.replace("DIGIT", "")}`;
if (codeUpper.startsWith("ARROW")) return `KEY_${codeUpper.replace("ARROW", "")}`;
if (codeUpper.startsWith("F") && codeUpper.length > 1) return `KEY_${codeUpper}`;

if (key.length === 1) return `KEY_${key.toUpperCase()}`;
return `KEY_${codeUpper}`;
}
// One mapping for every recorder, so a combination captured here is the same
// combination when the user later opens Settings → Hotkeys.
export { mapBrowserKeyToEvdev } from "../keys";

const KEYCAP_LABELS: Record<string, string> = {
KEY_LEFTCTRL: "Ctrl",
Expand Down
Loading
Loading