fix(hotkey): track modifier state manually so Super+key records on KDE Wayland

webkit2gtk under KDE Wayland does not set e.metaKey on subsequent events
after the compositor intercepts Super — but the Super keydown itself is
still delivered with e.key === "Super". Track pressed modifiers from
raw keydown/keyup and OR with the webkit booleans so combos like Super+E
(which OpenWhispr already registers successfully via evdev) can now be
captured in Kon's recorder.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-19 21:49:08 +01:00
parent 39c967c33b
commit 5e09ab9bd3

View File

@@ -48,9 +48,43 @@
return e.key.toUpperCase(); return e.key.toUpperCase();
} }
// Manual modifier-state tracker. webkit2gtk on KDE Wayland does not
// populate e.metaKey when the compositor intercepts Super — the Super
// keydown arrives as e.key === "Super" with e.metaKey still false on the
// next event. We track modifier state from raw keydown/keyup and OR it
// with the webkit booleans when building the combo so Super+letter can
// be recorded.
let manualMods = {
ctrl: false,
shift: false,
alt: false,
meta: false,
};
function updateManualMods(e: KeyboardEvent, down: boolean) {
switch (e.key) {
case "Control":
manualMods.ctrl = down;
break;
case "Shift":
manualMods.shift = down;
break;
case "Alt":
manualMods.alt = down;
break;
case "Meta":
case "Super":
case "OS":
case "Hyper":
manualMods.meta = down;
break;
}
}
function handleKeyDown(e: KeyboardEvent) { function handleKeyDown(e: KeyboardEvent) {
// Capture-phase listener; guard still belts-and-braces. // Capture-phase listener; guard still belts-and-braces.
if (!recording) return; if (!recording) return;
updateManualMods(e, true);
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -63,10 +97,10 @@
if (modifierKeys.has(e.key)) return; if (modifierKeys.has(e.key)) return;
const parts: string[] = []; const parts: string[] = [];
if (e.ctrlKey) parts.push("Ctrl"); if (e.ctrlKey || manualMods.ctrl) parts.push("Ctrl");
if (e.shiftKey) parts.push("Shift"); if (e.shiftKey || manualMods.shift) parts.push("Shift");
if (e.altKey) parts.push("Alt"); if (e.altKey || manualMods.alt) parts.push("Alt");
if (e.metaKey) parts.push("Super"); if (e.metaKey || manualMods.meta) parts.push("Super");
// Must have at least one modifier // Must have at least one modifier
if (parts.length === 0) { if (parts.length === 0) {
@@ -82,16 +116,23 @@
setTimeout(() => { captured = false; }, 1500); setTimeout(() => { captured = false; }, 1500);
} }
// Register the listener only while recording, at the capture phase so no function handleKeyUp(e: KeyboardEvent) {
// descendant handler (or the parent layout's svelte:window keydown) can if (!recording) return;
// swallow the event first. Button-level onkeydown would require the updateManualMods(e, false);
// button to hold keyboard focus after a click, which webkit2gtk on Linux }
// does not guarantee.
// Register capture-phase keydown + keyup listeners only while recording.
// Document-level capture so no descendant (or the parent layout's
// svelte:window keydown) can swallow the event first.
$effect(() => { $effect(() => {
if (!recording) return; if (!recording) return;
const handler = handleKeyDown; manualMods = { ctrl: false, shift: false, alt: false, meta: false };
document.addEventListener("keydown", handler, { capture: true }); document.addEventListener("keydown", handleKeyDown, { capture: true });
return () => document.removeEventListener("keydown", handler, { capture: true }); document.addEventListener("keyup", handleKeyUp, { capture: true });
return () => {
document.removeEventListener("keydown", handleKeyDown, { capture: true });
document.removeEventListener("keyup", handleKeyUp, { capture: true });
};
}); });
function startRecording() { function startRecording() {
@@ -100,9 +141,22 @@
} }
let chips = $derived(settings.globalHotkey.split("+")); let chips = $derived(settings.globalHotkey.split("+"));
let ariaLabel = $derived(
recording
? "Press a new global hotkey. Press Escape to cancel."
: `Record hotkey. Current shortcut: ${settings.globalHotkey}`,
);
let srStatus = $derived(
recording
? "Listening for a new shortcut. Press Escape to cancel."
: captured
? `Shortcut saved as ${settings.globalHotkey}.`
: `Current shortcut: ${settings.globalHotkey}. Click to change.`,
);
</script> </script>
<button <button
type="button"
class="inline-flex items-center gap-1.5 rounded-xl px-4 py-2.5 class="inline-flex items-center gap-1.5 rounded-xl px-4 py-2.5
{recording {recording
? 'bg-bg-input border-accent shadow-[0_0_0_3px_rgba(232,168,124,0.1)]' ? 'bg-bg-input border-accent shadow-[0_0_0_3px_rgba(232,168,124,0.1)]'
@@ -111,14 +165,16 @@
: 'bg-bg-input border-border hover:border-border'} : 'bg-bg-input border-border hover:border-border'}
border transition-all" border transition-all"
onclick={startRecording} onclick={startRecording}
aria-label="Record hotkey" aria-label={ariaLabel}
aria-pressed={recording}
> >
<span class="sr-only" aria-live="polite">{srStatus}</span>
{#if recording} {#if recording}
<span class="text-[11px] text-text-tertiary italic">Hold Ctrl/Alt/Super + key...</span> <span class="text-[11px] text-text-tertiary italic">Hold Ctrl/Alt/Super + key...</span>
{:else} {:else}
{#each chips as chip, i} {#each chips as chip, i}
{#if i > 0} {#if i > 0}
<span class="text-[10px] text-text-tertiary">+</span> <span class="text-[10px] text-text-tertiary" aria-hidden="true">+</span>
{/if} {/if}
<span class="px-2 py-0.5 rounded-md bg-bg-elevated border border-border text-[11px] font-medium text-text <span class="px-2 py-0.5 rounded-md bg-bg-elevated border border-border text-[11px] font-medium text-text
shadow-[inset_0_1px_2px_rgba(0,0,0,0.3)]">{chip}</span> shadow-[inset_0_1px_2px_rgba(0,0,0,0.3)]">{chip}</span>