Files
Lumotia/src/lib/components/HotkeyRecorder.svelte
Jake 681a9b26dc
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
agent: lumotia-rebrand — frontend strings (svelte + i18n + design-system)
Phase 8 of the rebrand cascade. Every rendered string is now Lumotia;
no Magnotia surface visible in the UI.

Sweep replaced \bmagnotia\b -> lumotia and \bMagnotia\b -> Lumotia
across all .svelte / .ts / .js / .css / .html / .json (excluding
package-lock.json which regenerates, target/, build/, node_modules/).

Surfaces touched:
- src/app.css — design-token comment header and .magnotia-rh-* CSS
  resize-handle class selectors (also the consuming elements in
  components/ResizeHandles.svelte and src/routes/*/+layout.svelte).
- src/lib/i18n/locales/{en,de,es}.json — brand name in translations.
- src/lib/i18n/index.ts — header comment.
- src/lib/Sidebar.svelte and most pages under src/lib/pages/ +
  src/lib/components/ — title bars, document titles, default
  filenames (lumotia-YYYY-MM-DD.* etc), toast strings, error
  messages, dialog headers.
- src/routes/+layout.svelte, +page.svelte, viewer/, float/, preview/.
- src/app.html page <title>.
- src/lib/utils/settingsMigrations.ts — fallback toast copy.
- src/design-system/{colors_and_type.css,SKILL.md,README.md,
  ui_kits/{Sidebar.jsx,index.html}} — design-tokens, doc strings,
  preview wordmark in the kit.
- package.json — name + description.

NOT touched (deferred / immutable):
- package-lock.json — regenerates on next npm install.
- The two migration-call sites in stores reference the legacy magnotia
  keys deliberately; restored after the sweep clobbered them.
- docs/, README.md, HANDOVER.md — Phase 9 scope.

npm run check: 0 errors / 0 warnings (3958 files).
cargo test --workspace: 339 pass / 0 fail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:29:37 +01:00

218 lines
7.4 KiB
Svelte

<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
// treated as modifiers, not as the trigger key.
const modifierKeys = new Set([
"Control", "Shift", "Alt", "Meta",
"Super", "OS", "Hyper",
]);
// Map KeyboardEvent.code (physical key, layout-independent, shift-independent)
// to the unshifted name the lumotia-hotkey evdev parser understands. Browser
// e.key returns "+" for Shift+Equal — parser wants "=". Same for | → \, etc.
const codeToParserKey: Record<string, string> = {
Equal: "=", Minus: "-",
BracketLeft: "[", BracketRight: "]",
Backslash: "\\", IntlBackslash: "\\",
Semicolon: ";", Quote: "'",
Comma: ",", Period: ".", Slash: "/",
Backquote: "`",
Space: "Space",
// Numpad keys intentionally NOT mapped here. They are physically distinct
// evdev codes from their main-keyboard counterparts; mapping them would
// silently bind the wrong key. Let resolveTriggerKey fall through so the
// backend parser rejects and the toast surfaces the failure.
};
function resolveTriggerKey(e: KeyboardEvent): string {
// Letters and digits: e.key is reliable (always A-Z / 0-9 for those codes).
if (/^Key[A-Z]$/.test(e.code)) return e.code.slice(3); // KeyA → A
if (/^Digit[0-9]$/.test(e.code)) return e.code.slice(5); // Digit1 → 1
if (/^Numpad[0-9]$/.test(e.code)) return e.code.slice(6); // Numpad1 → 1
if (/^F\d{1,2}$/.test(e.code)) return e.code; // F1..F12
// Shifted punctuation / numpad operators: use the physical-key mapping.
if (codeToParserKey[e.code]) return codeToParserKey[e.code];
// Named keys (Escape, Enter, Tab, arrows, etc.): e.key is the right choice
// — but only if it's multi-char (single chars here are usually dead-keys
// or IME output we shouldn't save).
if (e.key.length > 1) return e.key;
// Fallback: uppercase single char. Better than nothing.
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) {
// Capture-phase listener; guard still belts-and-braces.
if (!recording) return;
updateManualMods(e, true);
e.preventDefault();
e.stopPropagation();
if (e.key === "Escape") {
recording = false;
return;
}
// Wait for a non-modifier key
if (modifierKeys.has(e.key)) return;
const parts: string[] = [];
if (e.ctrlKey || manualMods.ctrl) parts.push("Ctrl");
if (e.shiftKey || manualMods.shift) parts.push("Shift");
if (e.altKey || manualMods.alt) parts.push("Alt");
if (e.metaKey || manualMods.meta) parts.push("Super");
// Must have at least one modifier
if (parts.length === 0) {
recording = false;
return;
}
parts.push(resolveTriggerKey(e));
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;
setTimeout(() => { captured = false; }, 1500);
}
function handleKeyUp(e: KeyboardEvent) {
if (!recording) return;
updateManualMods(e, false);
}
// 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(() => {
if (!recording) return;
manualMods = { ctrl: false, shift: false, alt: false, meta: false };
document.addEventListener("keydown", handleKeyDown, { capture: true });
document.addEventListener("keyup", handleKeyUp, { capture: true });
return () => {
document.removeEventListener("keydown", handleKeyDown, { capture: true });
document.removeEventListener("keyup", handleKeyUp, { capture: true });
};
});
function startRecording() {
recording = true;
captured = false;
rejectionReason = null;
}
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>
<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(214,132,80,0.1)]'
: captured
? 'bg-success/10 border-success/40'
: 'bg-bg-input border-border hover:border-border'}
border transition-colors"
onclick={startRecording}
aria-label={ariaLabel}
aria-pressed={recording}
>
<span class="sr-only" aria-live="polite">{srStatus}</span>
{#if recording}
<span class="text-[12px] text-text-secondary italic">Hold Ctrl/Alt/Super + key. Esc to cancel.</span>
{:else}
{#each chips as chip, i}
{#if i > 0}
<span class="text-[12px] text-text-secondary" aria-hidden="true">+</span>
{/if}
<span class="px-2 py-0.5 rounded-md bg-bg-elevated border border-border text-[12px] 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-[12px] text-warning" role="alert">
Couldn't bind that combo. {rejectionReason}.
</span>
{/if}
</div>