feat(B.1 #5): per-OS hotkey capability matrix with inline rejection copy
Adds src/lib/utils/hotkeyValidity.ts with validateHotkey(combo, os)
and wires it into HotkeyRecorder.svelte. Rules:
- X11/Wayland/Linux: reject single-key combos unless the trigger
is F13..F24 (the conventional global-shortcut escape hatch).
- Windows: reject combos whose only modifier is a right-hand
variant (RCtrl/RAlt) — matches Handy #966's failure mode where
RegisterHotKey silently ignores them.
- macOS: reject Fn-only combos and bare-key combos (non-function
keys).
- unknown OS: pass through — better to ship a flawed combo than
reject one we can't validate.
When validation fails, the recorder leaves the previous known-good
hotkey intact and surfaces an inline warning-coloured sentence
explaining why, with actionable copy ("add a modifier", "use the
left-hand equivalent", etc.). The save button disappears because
settings.globalHotkey is never written to — no state drift.
Matches Handy #917 / #1019 / #966 / #956, brief item #5.
Co-authored-by: jars <jakejars@users.noreply.github.com>
This commit is contained in:
@@ -1,8 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { settings, saveSettings } from "$lib/stores/page.svelte.js";
|
||||
import { osInfo } from "$lib/utils/osInfo";
|
||||
import { validateHotkey, type HotkeyOs } from "$lib/utils/hotkeyValidity";
|
||||
|
||||
let recording = $state(false);
|
||||
let captured = $state(false);
|
||||
let rejectionReason = $state<string | null>(null);
|
||||
|
||||
function currentOs(): HotkeyOs {
|
||||
const info = osInfo();
|
||||
if (!info) return "unknown";
|
||||
const name = (info.os || "").toLowerCase();
|
||||
if (name.startsWith("mac")) return "macos";
|
||||
if (name.startsWith("win")) return "windows";
|
||||
if (name.startsWith("linux")) return "linux";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
// Linux webkit2gtk fires e.key === "Super" for the Windows/Super key;
|
||||
// Firefox/older Chrome may fire "OS" or "Hyper". All of these should be
|
||||
@@ -109,7 +122,20 @@
|
||||
}
|
||||
|
||||
parts.push(resolveTriggerKey(e));
|
||||
settings.globalHotkey = parts.join("+");
|
||||
const candidate = parts.join("+");
|
||||
|
||||
// Per-OS capability matrix — reject combos the backend can't
|
||||
// reliably bind before they land in settings.globalHotkey (brief
|
||||
// item #5). Leaves the previous, known-good hotkey intact.
|
||||
const check = validateHotkey(candidate, currentOs());
|
||||
if (!check.valid) {
|
||||
rejectionReason = check.reason;
|
||||
recording = false;
|
||||
return;
|
||||
}
|
||||
|
||||
rejectionReason = null;
|
||||
settings.globalHotkey = candidate;
|
||||
saveSettings();
|
||||
recording = false;
|
||||
captured = true;
|
||||
@@ -138,6 +164,7 @@
|
||||
function startRecording() {
|
||||
recording = true;
|
||||
captured = false;
|
||||
rejectionReason = null;
|
||||
}
|
||||
|
||||
let chips = $derived(settings.globalHotkey.split("+"));
|
||||
@@ -155,29 +182,36 @@
|
||||
);
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 rounded-xl px-4 py-2.5
|
||||
{recording
|
||||
? 'bg-bg-input border-accent shadow-[0_0_0_3px_rgba(232,168,124,0.1)]'
|
||||
: captured
|
||||
? 'bg-bg-input border-border animate-pulse-warm'
|
||||
: 'bg-bg-input border-border hover:border-border'}
|
||||
border transition-all"
|
||||
onclick={startRecording}
|
||||
aria-label={ariaLabel}
|
||||
aria-pressed={recording}
|
||||
>
|
||||
<span class="sr-only" aria-live="polite">{srStatus}</span>
|
||||
{#if recording}
|
||||
<span class="text-[11px] text-text-tertiary italic">Hold Ctrl/Alt/Super + key...</span>
|
||||
{:else}
|
||||
{#each chips as chip, i}
|
||||
{#if i > 0}
|
||||
<span class="text-[10px] text-text-tertiary" aria-hidden="true">+</span>
|
||||
{/if}
|
||||
<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>
|
||||
{/each}
|
||||
<div class="inline-flex flex-col items-start gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 rounded-xl px-4 py-2.5
|
||||
{recording
|
||||
? 'bg-bg-input border-accent shadow-[0_0_0_3px_rgba(232,168,124,0.1)]'
|
||||
: captured
|
||||
? 'bg-bg-input border-border animate-pulse-warm'
|
||||
: 'bg-bg-input border-border hover:border-border'}
|
||||
border transition-all"
|
||||
onclick={startRecording}
|
||||
aria-label={ariaLabel}
|
||||
aria-pressed={recording}
|
||||
>
|
||||
<span class="sr-only" aria-live="polite">{srStatus}</span>
|
||||
{#if recording}
|
||||
<span class="text-[11px] text-text-tertiary italic">Hold Ctrl/Alt/Super + key...</span>
|
||||
{:else}
|
||||
{#each chips as chip, i}
|
||||
{#if i > 0}
|
||||
<span class="text-[10px] text-text-tertiary" aria-hidden="true">+</span>
|
||||
{/if}
|
||||
<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>
|
||||
{/each}
|
||||
{/if}
|
||||
</button>
|
||||
{#if rejectionReason}
|
||||
<span class="text-[11px] text-warning" role="alert">
|
||||
Couldn't bind that combo — {rejectionReason}.
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user