Skip to content
Merged
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
32 changes: 30 additions & 2 deletions crates/voxctrl-config/src/migrate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,13 @@ pub(crate) fn migrate_cloned_voices_dir() {
/// could never fire. The unshifted and shifted characters of a US layout are
/// both mapped, since Shift changes the character typed on the same key.
///
/// - Keys whose DOM name differs from evdev's were saved under the DOM name
/// (`KEY_PRINTSCREEN`, `KEY_AUDIOVOLUMEMUTE`, `KEY_NUMPADENTER`).
/// - The TTS stop key recorder saved letters without the underscore (`KEYV`).
///
/// Numpad digits were saved as the top-row digit (`KEY_1`), which is a real
/// key name, so those cannot be told apart and are left alone.
/// key name, so those cannot be told apart and are left alone. So is `KEY_*`,
/// which could be the keypad `*` or Shift+8.
pub fn canonical_key_name(name: &str) -> Option<&'static str> {
Some(match name {
"KEY_ESCAPE" => "KEY_ESC",
Expand All @@ -75,10 +80,33 @@ pub fn canonical_key_name(name: &str) -> Option<&'static str> {
"KEY_[" | "KEY_{" => "KEY_LEFTBRACE",
"KEY_]" | "KEY_}" => "KEY_RIGHTBRACE",
"KEY_\\" | "KEY_|" => "KEY_BACKSLASH",
_ => return None,
"KEY_PRINTSCREEN" => "KEY_SYSRQ",
"KEY_CONTEXTMENU" => "KEY_COMPOSE",
"KEY_NUMPADENTER" => "KEY_KPENTER",
"KEY_AUDIOVOLUMEMUTE" => "KEY_MUTE",
"KEY_AUDIOVOLUMEDOWN" => "KEY_VOLUMEDOWN",
"KEY_AUDIOVOLUMEUP" => "KEY_VOLUMEUP",
"KEY_MEDIATRACKNEXT" => "KEY_NEXTSONG",
"KEY_MEDIATRACKPREVIOUS" => "KEY_PREVIOUSSONG",
"KEY_MEDIASTOP" => "KEY_STOPCD",
"KEY_MEDIAPLAYPAUSE" => "KEY_PLAYPAUSE",
_ => return legacy_letter(name),
})
}

/// `KEYA` … `KEYZ`, as the old TTS stop key recorder wrote letters.
fn legacy_letter(name: &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 name.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` to its canonical spelling,
/// returning whether anything changed.
pub fn canonicalize_key_names(keys: &mut [String]) -> bool {
Expand Down
5 changes: 3 additions & 2 deletions crates/voxctrl-config/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,9 @@ impl Config {
migrated = true;
}

// Legacy key names in the stop key ("KEY_ESCAPE", or a punctuation key
// saved as e.g. "KEY_.") → the evdev names the backends report.
// Legacy key names in the stop key ("KEY_ESCAPE", a punctuation key
// saved as e.g. "KEY_.", a letter saved as "KEYV") → the evdev names
// the backends report. See `canonical_key_name`.
migrated |= canonicalize_key_names(&mut data.tts.stop_key);

// Migrate legacy default OpenAI timeout (8s) to the new default (30s) to prevent timeouts
Expand Down
18 changes: 18 additions & 0 deletions crates/voxctrl-config/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -590,3 +590,21 @@ fn legacy_key_names_become_the_names_the_backends_report() {
assert_eq!(keys, vec!["KEY_LEFTCTRL", "KEY_SLASH"]);
assert!(!canonicalize_key_names(&mut keys), "a second pass changes nothing");
}

#[test]
fn legacy_dom_names_and_underscoreless_letters_are_repaired() {
assert_eq!(canonical_key_name("KEYV"), Some("KEY_V"), "old TTS stop key letter");
assert_eq!(canonical_key_name("KEYA"), Some("KEY_A"));
assert_eq!(canonical_key_name("KEYZ"), Some("KEY_Z"));
assert_eq!(canonical_key_name("KEY_PRINTSCREEN"), Some("KEY_SYSRQ"));
assert_eq!(canonical_key_name("KEY_CONTEXTMENU"), Some("KEY_COMPOSE"));
assert_eq!(canonical_key_name("KEY_NUMPADENTER"), Some("KEY_KPENTER"));
assert_eq!(canonical_key_name("KEY_AUDIOVOLUMEMUTE"), Some("KEY_MUTE"));
assert_eq!(canonical_key_name("KEY_MEDIAPLAYPAUSE"), Some("KEY_PLAYPAUSE"));

// Real names that happen to start with KEY and one letter are untouched.
for name in ["KEY_A", "KEY_KP1", "KEY_F5", "KEYBOARD", "KEY"] {
assert_eq!(canonical_key_name(name), None, "{name}");
}
assert_eq!(canonical_key_name("KEY_*"), None, "keypad * or Shift+8: ambiguous");
}
37 changes: 37 additions & 0 deletions crates/voxctrl-hotkeys/src/trigger.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,20 @@ fn keysym_name(key: &str) -> Option<String> {
"LEFTBRACE" => "bracketleft".to_string(),
"RIGHTBRACE" => "bracketright".to_string(),
"CAPSLOCK" => "Caps_Lock".to_string(),
"NUMLOCK" => "Num_Lock".to_string(),
"SCROLLLOCK" => "Scroll_Lock".to_string(),
"PAUSE" => "Pause".to_string(),
"SYSRQ" => "Print".to_string(),
"COMPOSE" => "Menu".to_string(),
// The extra ISO key beside left Shift; `less` on a US-based layout.
"102ND" => "less".to_string(),
"MUTE" => "XF86AudioMute".to_string(),
"VOLUMEDOWN" => "XF86AudioLowerVolume".to_string(),
"VOLUMEUP" => "XF86AudioRaiseVolume".to_string(),
"NEXTSONG" => "XF86AudioNext".to_string(),
"PREVIOUSSONG" => "XF86AudioPrev".to_string(),
"STOPCD" => "XF86AudioStop".to_string(),
"PLAYPAUSE" => "XF86AudioPlay".to_string(),
"KPPLUS" => "KP_Add".to_string(),
"KPMINUS" => "KP_Subtract".to_string(),
"KPASTERISK" => "KP_Multiply".to_string(),
Expand Down Expand Up @@ -360,6 +374,29 @@ mod tests {
}
}

/// Every key the other backends can match must register through the portal
/// too, or a shortcut that works under evdev is refused in portal mode.
/// `keymap::NAMES` is also the vocabulary the Settings recorder is checked
/// against (`tests/svelte/keys.test.ts`), so this covers every key a user
/// can record.
#[test]
fn every_backend_key_translates_to_a_keysym() {
let untranslated: Vec<&str> = crate::win_keys::keymap::NAMES
.iter()
.copied()
.filter(|k| modifier_name(k).is_none() && keysym_name(k).is_none())
.collect();
assert_eq!(untranslated, Vec::<&str>::new());
for (key, sym) in [
("KEY_SYSRQ", "Print"),
("KEY_COMPOSE", "Menu"),
("KEY_MUTE", "XF86AudioMute"),
("KEY_PLAYPAUSE", "XF86AudioPlay"),
] {
assert_eq!(keysym_name(key).as_deref(), Some(sym), "{key}");
}
}

#[test]
fn an_unmappable_key_names_itself() {
let err = accelerator(&keys(&["KEY_FN_F1"])).unwrap_err();
Expand Down
108 changes: 54 additions & 54 deletions dist/assets/index-ywDg7bM0.js → dist/assets/index-lCQNFkWj.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
<meta property="og:title" content="VoxCtrl" />
<meta property="og:description" content="Smart voice command control panel and transcription tool powered by local AI." />
<meta property="og:type" content="website" />
<script type="module" crossorigin src="/assets/index-ywDg7bM0.js"></script>
<script type="module" crossorigin src="/assets/index-lCQNFkWj.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CAhZLQF6.css">
</head>
<body>
Expand Down
10 changes: 10 additions & 0 deletions src/lib/Wizard/wizard-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,16 @@ const KEYCAP_LABELS: Record<string, string> = {
KEY_KPDOT: "Num .",
KEY_KPENTER: "Num Enter",
KEY_KPEQUAL: "Num =",
KEY_102ND: "<",
KEY_SYSRQ: "PrtSc",
KEY_COMPOSE: "Menu",
KEY_MUTE: "Mute",
KEY_VOLUMEDOWN: "Vol -",
KEY_VOLUMEUP: "Vol +",
KEY_NEXTSONG: "Next",
KEY_PREVIOUSSONG: "Prev",
KEY_STOPCD: "Stop",
KEY_PLAYPAUSE: "Play/Pause",
};

/** Human label for an evdev key name, for keycaps and summaries. */
Expand Down
16 changes: 16 additions & 0 deletions src/lib/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,24 @@ const CODE_TO_EVDEV: Record<string, string> = {
NumpadDecimal: "KEY_KPDOT",
NumpadEnter: "KEY_KPENTER",
NumpadEqual: "KEY_KPEQUAL",

// Keys whose DOM name is not evdev's.
PrintScreen: "KEY_SYSRQ",
ContextMenu: "KEY_COMPOSE",

// Media keys, when the webview delivers them at all.
AudioVolumeMute: "KEY_MUTE",
AudioVolumeDown: "KEY_VOLUMEDOWN",
AudioVolumeUp: "KEY_VOLUMEUP",
MediaTrackNext: "KEY_NEXTSONG",
MediaTrackPrevious: "KEY_PREVIOUSSONG",
MediaStop: "KEY_STOPCD",
MediaPlayPause: "KEY_PLAYPAUSE",
};

/** The `code` values mapped through the table above, for tests. */
export const MAPPED_CODES: readonly string[] = Object.keys(CODE_TO_EVDEV);

/**
* Map a browser key event onto the evdev name VoxCtrl stores in bindings.toml.
*
Expand Down
85 changes: 85 additions & 0 deletions tests/svelte/keys.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, test, expect } from "vitest";
import { MAPPED_CODES, mapBrowserKeyToEvdev } from "../../src/lib/keys";
import { keycapLabel } from "../../src/lib/Wizard/wizard-data";

/**
* The backends' key vocabulary, read out of `keymap::NAMES` in the Rust source
* so the recorder and the backends cannot drift apart unnoticed. The Linux
* backends take their names from the evdev crate, and this table spells out
* the same names for Windows.
*/
function backendKeyNames(): Set<string> {
// Vitest runs from the repository root.
const path = resolve(process.cwd(), "crates/voxctrl-hotkeys/src/win_keys.rs");
const src = readFileSync(path, "utf8");
const table = /pub const NAMES: &\[&str\] = &\[([\s\S]*?)\];/.exec(src);
if (!table) throw new Error("keymap::NAMES not found in win_keys.rs");
return new Set([...table[1].matchAll(/"(KEY_[A-Z0-9_]+)"/g)].map((m) => m[1]));
}

/**
* Valid evdev names the Windows table has no scan code for. They work on
* Linux, which is where the keys exist in practice.
*/
const LINUX_ONLY = new Set(["KEY_KPEQUAL"]);

describe("mapBrowserKeyToEvdev against the backends' key table", () => {
const known = backendKeyNames();

test("the table was read", () => {
expect(known.size).toBeGreaterThan(90);
});

test("every key the recorder can name is one a backend reports", () => {
const codes = [
...MAPPED_CODES,
...[..."ABCDEFGHIJKLMNOPQRSTUVWXYZ"].map((c) => `Key${c}`),
...[..."0123456789"].map((d) => `Digit${d}`),
// F13–F24 are valid evdev names too, but the Windows table stops at F12.
...Array.from({ length: 12 }, (_, i) => `F${i + 1}`),
"Space",
"Enter",
"Escape",
"Tab",
"Backspace",
"Delete",
"Insert",
"Home",
"End",
"PageUp",
"PageDown",
"ArrowUp",
"ArrowDown",
"ArrowLeft",
"ArrowRight",
"CapsLock",
"NumLock",
"ScrollLock",
"Pause",
"PrintScreen",
"ContextMenu",
"AudioVolumeMute",
"MediaPlayPause",
];
const unknown = codes
.map((code) => [code, mapBrowserKeyToEvdev("", code)] as const)
.filter(([, name]) => !known.has(name) && !LINUX_ONLY.has(name));
expect(unknown).toEqual([]);
});

test("keys whose DOM name differs from evdev's use evdev's", () => {
expect(mapBrowserKeyToEvdev("PrintScreen", "PrintScreen")).toBe("KEY_SYSRQ");
expect(mapBrowserKeyToEvdev("ContextMenu", "ContextMenu")).toBe("KEY_COMPOSE");
expect(mapBrowserKeyToEvdev("AudioVolumeMute", "AudioVolumeMute")).toBe("KEY_MUTE");
expect(mapBrowserKeyToEvdev("MediaPlayPause", "MediaPlayPause")).toBe("KEY_PLAYPAUSE");
});

test("every mapped key has a readable keycap", () => {
for (const code of MAPPED_CODES) {
const label = keycapLabel(mapBrowserKeyToEvdev("", code));
expect(label, code).not.toMatch(/^(KEY_|KP|AUDIO|MEDIA)/);
}
});
});
Loading