implement global shortcuts with electron api

This commit is contained in:
Vendicated
2026-04-04 23:10:11 +02:00
parent c32e16e740
commit d179b621a7
12 changed files with 454 additions and 27 deletions
@@ -0,0 +1,47 @@
.vcd-shortcuts-group {
display: grid;
gap: 0.25em 1em;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto auto;
align-items: center;
}
.vcd-shortcuts-label:first-child {
grid-column: 1 / 2;
}
.vcd-shortcuts-label:nth-child(2) {
grid-column: 2 / 5;
}
.vcd-shortcuts-recorder {
display: flex;
background-color: var(--input-background-default);
border: 1px solid;
border-color: var(--input-border-default);
border-radius: var(--radius-sm);
width: 100%;
min-height: calc(var(--select-option-height) - 2px);
padding: 0.25em;
}
.vcd-shortcuts-record-input {
background-color: transparent;
border: none;
flex: 1 1 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-strong);
font-weight: 600;
&:disabled {
color: var(--text-muted);
font-weight: 400;
}
}
.vcd-shortcuts-record-button {
flex: 0 0 auto;
white-space: nowrap;
min-width: max-content;
}
@@ -0,0 +1,149 @@
/*
* Vesktop, a desktop app aiming to give you a snappier Discord Experience
* Copyright (c) 2026 Vendicated and Vesktop contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./ShortcutSettings.css";
import { BaseText, Button, DeleteIcon, Switch } from "@vencord/types/components";
import { classNameFactory, identity } from "@vencord/types/utils";
import { React, Select, useState } from "@vencord/types/webpack/common";
import { Settings } from "renderer/settings";
import { recordKeybind } from "./recordKeybind";
const cl = classNameFactory("vcd-shortcuts-");
const ShortcutActions = {
unassigned: "Unassigned",
toggleMute: "Toggle Mute",
toggleDeafen: "Toggle Deafen",
toggleStreamerMode: "Toggle Streamer Mode",
toggleCamera: "Toggle Camera",
toggleScreenShare: "Toggle Screen Share",
disconnectFromVoiceChannel: "Disconnect from Voice Channel"
} as const;
export type ShortcutAction = keyof typeof ShortcutActions;
export interface KeyBind {
id: string;
enabled: boolean;
action: ShortcutAction;
key: string;
}
function KeyBindGroup({
keyBind,
setKeyBind,
deleteKeyBind
}: {
keyBind: KeyBind;
setKeyBind(keyBind: KeyBind): void;
deleteKeyBind(): void;
}) {
const [isRecording, setIsRecording] = useState(false);
const [abortController, setAbortController] = useState<AbortController | null>(null);
const actionId = React.useId();
const keyId = React.useId();
return (
<div className={cl("group")}>
<BaseText size="md" weight="medium" className={cl("label")} id={actionId}>
Action
</BaseText>
<BaseText size="md" weight="medium" className={cl("label")} id={keyId}>
Keybind
</BaseText>
{/* @ts-expect-error incorrect type for aria-labelledby */}
<Select
aria-labelledby={actionId}
options={Object.entries(ShortcutActions).map(([action, label]) => ({ label, value: action }))}
isSelected={o => o === keyBind.action}
select={o => setKeyBind({ ...keyBind, action: o })}
serialize={identity}
/>
<div className={cl("recorder")} aria-labelledby={keyId}>
<input
className={cl("record-input")}
type="text"
value={keyBind.key || "No Keybind Set"}
readOnly
disabled={!keyBind.key}
/>
<Button
className="record-button"
variant="secondary"
size="small"
onClick={async () => {
if (isRecording) {
abortController?.abort();
setIsRecording(false);
setAbortController(null);
} else {
const controller = new AbortController();
setAbortController(controller);
setIsRecording(true);
const key = await recordKeybind(controller.signal).catch(() => null);
setIsRecording(false);
setAbortController(null);
if (key) setKeyBind({ ...keyBind, key });
}
}}
>
{isRecording ? "Cancel Recording" : "Record"}
</Button>
</div>
<Button size="iconOnly" variant="secondary" aria-label="Delete Keybind" onClick={deleteKeyBind}>
<DeleteIcon />
</Button>
<Switch checked={keyBind.enabled} onChange={v => setKeyBind({ ...keyBind, enabled: v })} />
</div>
);
}
export function KeyBindSettings() {
const [keyBinds, setKeyBinds] = useState<KeyBind[]>(Settings.store.keyBinds ?? []);
return (
<div>
{keyBinds.map((keyBind, index) => (
<KeyBindGroup
key={keyBind.id}
keyBind={keyBind}
setKeyBind={newKeyBind => {
const newKeyBinds = [...keyBinds];
newKeyBinds[index] = newKeyBind;
setKeyBinds(newKeyBinds);
Settings.store.keyBinds = newKeyBinds;
}}
deleteKeyBind={() => {
const newKeyBinds = keyBinds.filter((_, i) => i !== index);
setKeyBinds(newKeyBinds);
Settings.store.keyBinds = newKeyBinds;
}}
/>
))}
<Button
onClick={() => {
setKeyBinds(binds => [
...binds,
{
action: "unassigned",
key: "",
enabled: true,
id: crypto.randomUUID()
}
]);
}}
>
Add a Keybind
</Button>
</div>
);
}
@@ -0,0 +1,34 @@
/*
* Vesktop, a desktop app aiming to give you a snappier Discord Experience
* Copyright (c) 2026 Vendicated and Vesktop contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Logger } from "@vencord/types/utils";
import { ShortcutAction } from "./ShortcutSettings";
const logger = new Logger("VesktopKeyBindHandler");
export function handleKeyBind(keyBind: ShortcutAction) {
logger.debug(`Handling key bind action: ${keyBind}`);
switch (keyBind) {
case "toggleMute":
break;
case "toggleDeafen":
break;
case "toggleStreamerMode":
break;
case "toggleCamera":
break;
case "toggleScreenShare":
break;
case "disconnectFromVoiceChannel":
break;
case "unassigned":
break;
default:
logger.warn(`Unknown key bind action: ${keyBind}`);
}
}
@@ -0,0 +1,125 @@
/*
* Vesktop, a desktop app aiming to give you a snappier Discord Experience
* Copyright (c) 2026 Vendicated and Vesktop contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
const MODIFIER_KEYS = new Set(["Control", "Shift", "Alt", "Meta", "Dead"]);
const VALID_PUNCTUATION = new Set(')!@#$%^&*(;:=<,_-.>?/~`{][|\\}"');
// Codes whose accelerator name matches the code value exactly
const PASSTHROUGH_CODES = new Set([
"Space",
"Tab",
"Backspace",
"Delete",
"Insert",
"Home",
"End",
"PageUp",
"PageDown",
"PrintScreen",
"Escape"
]);
// Codes that need an explicit rename
const CODE_MAP: Record<string, string> = {
Enter: "Return",
NumpadEnter: "Return",
NumpadDecimal: "numdec",
NumpadAdd: "numadd",
NumpadSubtract: "numsub",
NumpadMultiply: "nummult",
NumpadDivide: "numdiv",
CapsLock: "Capslock",
NumLock: "Numlock",
ScrollLock: "Scrolllock",
ArrowUp: "Up",
ArrowDown: "Down",
ArrowLeft: "Left",
ArrowRight: "Right"
};
const MEDIA_KEY_MAP: Record<string, string> = {
AudioVolumeUp: "VolumeUp",
AudioVolumeDown: "VolumeDown",
AudioVolumeMute: "VolumeMute",
MediaTrackNext: "MediaNextTrack",
MediaTrackPrevious: "MediaPreviousTrack",
MediaStop: "MediaStop",
MediaPlayPause: "MediaPlayPause"
};
function codeToAcceleratorKey(event: KeyboardEvent): string | null {
const { code, key } = event;
// Letters: KeyA -> A
const letterMatch = /^Key([A-Z])$/.exec(code);
if (letterMatch) return letterMatch[1];
// Digits: Digit0 -> 0
const digitMatch = /^Digit(\d)$/.exec(code);
if (digitMatch) return digitMatch[1];
// Function keys: F1-F24
if (/^F([1-9]|1\d|2[0-4])$/.test(code)) return code;
// Numpad digits: Numpad0 -> num0
const numpadDigitMatch = /^Numpad(\d)$/.exec(code);
if (numpadDigitMatch) return "num" + numpadDigitMatch[1];
if (PASSTHROUGH_CODES.has(code)) return code;
if (code in CODE_MAP) return CODE_MAP[code];
// Media / audio keys (identified by event.key)
if (key in MEDIA_KEY_MAP) return MEDIA_KEY_MAP[key];
// Punctuation keys
if (key === "+") return "Plus";
if (VALID_PUNCTUATION.has(key)) return key;
return null;
}
export async function recordKeybind(signal?: AbortSignal): Promise<string> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new DOMException("Recording aborted", "AbortError"));
return;
}
function cleanup() {
document.removeEventListener("keydown", onKeyDown);
signal?.removeEventListener("abort", onAbort);
}
function onAbort() {
cleanup();
reject(new DOMException("Recording aborted", "AbortError"));
}
function onKeyDown(event: KeyboardEvent) {
event.preventDefault();
if (MODIFIER_KEYS.has(event.key)) return;
const key = codeToAcceleratorKey(event);
if (key === null) return;
cleanup();
const parts: string[] = [];
if (event.ctrlKey) parts.push("Ctrl");
if (event.shiftKey) parts.push("Shift");
if (event.altKey) parts.push("Alt");
if (event.metaKey) parts.push("Super");
parts.push(key);
resolve(parts.join("+"));
}
signal?.addEventListener("abort", onAbort, { once: true });
document.addEventListener("keydown", onKeyDown);
});
}