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:
Cursor Agent
2026-04-21 11:39:45 +00:00
committed by Jake
parent 8e5e034df1
commit df58d98adc
2 changed files with 209 additions and 26 deletions

View File

@@ -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>

View File

@@ -0,0 +1,149 @@
/**
* Per-OS hotkey capability matrix.
*
* Different global-hotkey backends refuse different combos. Rather
* than let the user save a combo the OS will silently ignore, we
* validate before save and surface a clear "why" string.
*
* Covered failures (brief item #5):
* - X11: single-key combos (no modifier) get swallowed by the user's
* editor / IRC client / whatever; they are useless as globals.
* Handy #917 / #1019.
* - Windows: right-hand modifiers (RCtrl, RAlt) don't always register
* through RegisterHotKey; the user gets a dead shortcut. Handy
* #966. We reject a combo whose only modifier is a right-hand one
* — if the user binds Ctrl+R we don't care whether left or right
* Ctrl fired it.
* - macOS: Fn-only combos require an NSEvent private API that
* RegisterHotKey-equivalents don't expose (Whispering #549 area).
* - Wayland (Linux): single-key combos are rejected even more
* aggressively than X11 because compositors vary on whether they
* forward a bare key to a global listener at all.
*
* All rules are pure — the UI calls `validateHotkey(combo, os)` and
* gates save on `result.valid`. No backend round-trip required.
*/
export type HotkeyOs = "windows" | "macos" | "linux" | "unknown";
export interface HotkeyValidity {
/** Whether this combo is safe to save. */
valid: boolean;
/**
* User-visible "why not" string when `valid` is false. Null when
* the combo is OK. Copy is written for inline display — no leading
* capitalisation assumption, no trailing period needed.
*/
reason: string | null;
}
export interface HotkeyParts {
modifiers: string[]; // e.g. ["Ctrl", "Shift"]
trigger: string; // e.g. "R" — may be empty if we're mid-record
}
/** Tokenise a `+`-joined hotkey the way the kon-hotkey parser does,
* but without demanding a fully-valid combo (we validate next). The
* last part is always the trigger key; everything before is a
* modifier. An empty/whitespace combo returns empty parts. */
export function parseHotkey(combo: string): HotkeyParts {
const trimmed = combo?.trim?.() ?? "";
if (!trimmed) return { modifiers: [], trigger: "" };
const tokens = trimmed.split("+").map((t) => t.trim()).filter(Boolean);
if (tokens.length === 0) return { modifiers: [], trigger: "" };
const trigger = tokens[tokens.length - 1];
const modifiers = tokens.slice(0, -1);
return { modifiers, trigger };
}
// Fn and function-key helpers.
const FUNCTION_KEYS = new Set([
"F1", "F2", "F3", "F4", "F5", "F6",
"F7", "F8", "F9", "F10", "F11", "F12",
"F13", "F14", "F15", "F16", "F17", "F18", "F19", "F20",
"F21", "F22", "F23", "F24",
]);
function normaliseModifier(mod: string): string {
const m = mod.trim().toLowerCase();
if (m === "cmd" || m === "meta" || m === "super" || m === "win") return "super";
if (m === "option") return "alt";
return m;
}
function isRightHandModifier(mod: string): boolean {
const m = mod.trim().toLowerCase();
return m === "rctrl" || m === "ralt" || m === "rshift" || m === "rsuper" || m === "rmeta";
}
function isFnModifier(mod: string): boolean {
return mod.trim().toLowerCase() === "fn";
}
/** Validate a combo for the given OS. `unknown` OS passes unchanged
* — we'd rather ship a flawed combo than reject one we can't check. */
export function validateHotkey(combo: string, os: HotkeyOs): HotkeyValidity {
const parts = parseHotkey(combo);
if (!parts.trigger) {
return {
valid: false,
reason: "pick a key — hotkeys need at least one non-modifier",
};
}
const hasModifier = parts.modifiers.length > 0;
const normalisedMods = parts.modifiers.map(normaliseModifier);
if (os === "linux" || os === "unknown") {
// X11 AND Wayland: single-key globals are a footgun everywhere on
// Linux. Function keys F13+ are the conventional escape hatch
// (they're not on anyone's keyboard, so they don't collide) —
// allow them single-key.
if (!hasModifier && !FUNCTION_KEYS.has(parts.trigger)) {
return {
valid: false,
reason:
"add Ctrl, Shift, Alt, or Super — single keys get eaten by whichever app is focused",
};
}
}
if (os === "windows") {
// Right-hand-only modifiers break RegisterHotKey in some layouts.
const onlyRightHand =
parts.modifiers.length > 0
&& parts.modifiers.every(isRightHandModifier);
if (onlyRightHand) {
return {
valid: false,
reason:
"right-hand-only modifiers (RCtrl / RAlt) don't always register as global shortcuts on Windows — use the left-hand equivalent",
};
}
if (!hasModifier) {
return {
valid: false,
reason: "Windows requires at least one modifier on a global hotkey",
};
}
}
if (os === "macos") {
if (normalisedMods.some(isFnModifier) && parts.modifiers.length === 1) {
return {
valid: false,
reason:
"Fn-only combos aren't available to Tauri global shortcuts on macOS — pair Fn with Cmd/Ctrl/Option, or pick a different trigger",
};
}
if (!hasModifier && !FUNCTION_KEYS.has(parts.trigger)) {
return {
valid: false,
reason:
"add Cmd, Ctrl, Option, or Shift — macOS requires a modifier on bare-key global shortcuts",
};
}
}
return { valid: true, reason: null };
}