Vencord plugin for the Discord desktop app. Toggles fire on key press, push-to-talk infers releases GNOME drops (re-registration probe), and GNOME's trigger descriptions are matched correctly. Includes basis-path tests, a patch checker against the live Discord bundle, the design spec and the README assets.
127 lines
4.1 KiB
TypeScript
127 lines
4.1 KiB
TypeScript
/*
|
|
* Vencord, a Discord client mod
|
|
* Copyright (c) 2026 Evandro
|
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
*/
|
|
|
|
export interface KeyEventOptions {
|
|
keydown?: boolean;
|
|
keyup?: boolean;
|
|
portal?: boolean;
|
|
}
|
|
|
|
export interface PortalState {
|
|
bound: boolean;
|
|
matchesTrigger: boolean;
|
|
triggerDescription: string;
|
|
}
|
|
|
|
export type DispatchMode = "passthrough" | "pressAsRelease" | "hold";
|
|
|
|
export type Callback = (isDown: boolean) => void;
|
|
|
|
export interface Scheduler {
|
|
now(): number;
|
|
setTimeout(fn: () => void, ms: number): unknown;
|
|
clearTimeout(handle: unknown): void;
|
|
}
|
|
|
|
export interface Dispatcher {
|
|
dispatch: Callback;
|
|
cancel(): void;
|
|
}
|
|
|
|
// GNOME repeats a held shortcut after 500 ms and then every 30 ms (org.gnome.desktop.peripherals.keyboard)
|
|
export const FIRST_PROBE_MS = 1000;
|
|
export const PROBE_WINDOW_MS = 300;
|
|
export const ECHO_MS = 150;
|
|
|
|
const MODIFIERS: Record<string, string> = {
|
|
ctrl: "CTRL", control: "CTRL", primary: "CTRL",
|
|
shift: "SHIFT",
|
|
alt: "ALT", mod1: "ALT",
|
|
super: "LOGO", logo: "LOGO", meta: "LOGO", mod4: "LOGO"
|
|
};
|
|
|
|
export function planRegistration(options: KeyEventOptions, portalActive: boolean, representable: boolean): DispatchMode {
|
|
if (!portalActive || !representable || options.portal === false) return "passthrough";
|
|
if (options.keyup && options.keydown) return "hold";
|
|
// GNOME only delivers the release if the main key is let go before the modifiers, so act on the press
|
|
if (options.keyup) return "pressAsRelease";
|
|
return "passthrough";
|
|
}
|
|
|
|
export function adjustOptions<T extends KeyEventOptions>(options: T, mode: DispatchMode): T {
|
|
return mode === "pressAsRelease" ? { ...options, keydown: true, keyup: true } : options;
|
|
}
|
|
|
|
export function createDispatcher(callback: Callback, mode: DispatchMode, rearm: () => void, scheduler: Scheduler): Dispatcher {
|
|
if (mode === "passthrough") return { dispatch: callback, cancel() { } };
|
|
|
|
let held = false;
|
|
let repeated = false;
|
|
let rearmedAt = -Infinity;
|
|
let timer: unknown;
|
|
|
|
const end = () => {
|
|
held = false;
|
|
if (mode === "hold") callback(false);
|
|
};
|
|
// Discord's native module ignores presses until it sees a release, and GNOME drops the release when a
|
|
// modifier is let go first. Re-registering resets that state; a held combo then repeats within a few ms.
|
|
const probe = () => {
|
|
repeated = false;
|
|
rearm();
|
|
rearmedAt = scheduler.now();
|
|
timer = scheduler.setTimeout(() => repeated ? probe() : end(), PROBE_WINDOW_MS);
|
|
};
|
|
const start = () => {
|
|
held = true;
|
|
callback(mode === "hold");
|
|
timer = scheduler.setTimeout(probe, FIRST_PROBE_MS);
|
|
};
|
|
|
|
const dispatch: Callback = isDown => {
|
|
const echo = scheduler.now() - rearmedAt < ECHO_MS;
|
|
if (!held) {
|
|
if (isDown) start();
|
|
} else if (isDown) {
|
|
if (echo) repeated = true;
|
|
else { scheduler.clearTimeout(timer); start(); }
|
|
} else if (!echo) {
|
|
scheduler.clearTimeout(timer);
|
|
end();
|
|
}
|
|
};
|
|
|
|
return {
|
|
dispatch,
|
|
cancel() {
|
|
scheduler.clearTimeout(timer);
|
|
held = false;
|
|
}
|
|
};
|
|
}
|
|
|
|
export function normalizeTrigger(trigger: string): string {
|
|
const mods = Array.from(trigger.matchAll(/<([^>]+)>/g), m => m[1]);
|
|
let rest = trigger.slice(trigger.lastIndexOf(">") + 1);
|
|
if (mods.length === 0) {
|
|
const parts = rest.split("+");
|
|
rest = parts.pop()!;
|
|
mods.push(...parts);
|
|
}
|
|
|
|
const key = rest.trim().split(/\s+/).pop()!;
|
|
const canonical = mods.map(m => MODIFIERS[m.trim().toLowerCase()] ?? m.trim().toUpperCase());
|
|
return [...new Set(canonical)].sort().join("+") + "|" + key.toLowerCase();
|
|
}
|
|
|
|
export function fixPortalState<S extends PortalState>(state: S, expected: string | undefined): S {
|
|
if (!state.bound || state.matchesTrigger || !expected) return state;
|
|
// GNOME describes the trigger as "Press <Shift><Control>F12", which never equals "CTRL+SHIFT+F12" literally
|
|
return normalizeTrigger(expected) === normalizeTrigger(state.triggerDescription)
|
|
? { ...state, matchesTrigger: true }
|
|
: state;
|
|
}
|