WaylandKeybinds: reliable Discord global keybinds on GNOME Wayland
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.
This commit is contained in:
commit
a3a9149184
10 files changed
+1514
No files matched your search
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* 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;
|
||||
}
|
||||
Reference in new issue
Block a user