diff --git a/apps/stage-tamagotchi/src/shared/eventa/index.ts b/apps/stage-tamagotchi/src/shared/eventa/index.ts index 386599820..6850b9a34 100644 --- a/apps/stage-tamagotchi/src/shared/eventa/index.ts +++ b/apps/stage-tamagotchi/src/shared/eventa/index.ts @@ -1,5 +1,9 @@ import type { Locale } from '@intlify/core' import type { ServerOptions } from '@proj-airi/server-runtime/server' +import type { + ShortcutBinding, + ShortcutRegistrationResult, +} from '@proj-airi/stage-shared/global-shortcut' import type { ServerChannelQrPayload } from '@proj-airi/stage-shared/server-channel-qr' import type { ThreeHitTestReadTracePayload, @@ -315,6 +319,34 @@ export const electronGodotStageGetStatus = defineInvokeEventa('eventa:invoke:electron:godot-stage:apply-scene-input') export const electronGodotStageStatusChanged = defineEventa('eventa:event:electron:godot-stage:status-changed') +// Global shortcut -> + +/** + * Phase of a shortcut trigger event. + * + * - `down` — key-combination pressed + * - `up` — key-combination released; only emitted by drivers that set + * `ok: true` for bindings with `receiveKeyUps: true` + */ +export type ElectronShortcutTriggerPhase = 'down' | 'up' + +/** + * Payload broadcast to all subscribed windows when a registered shortcut + * fires. Renderer composables filter by `id` to dispatch local handlers. + */ +export interface ElectronShortcutTriggerPayload { + id: string + phase: ElectronShortcutTriggerPhase +} + +export const electronShortcutRegister = defineInvokeEventa('eventa:invoke:electron:shortcut:register') +export const electronShortcutUnregister = defineInvokeEventa('eventa:invoke:electron:shortcut:unregister') +export const electronShortcutUnregisterAll = defineInvokeEventa('eventa:invoke:electron:shortcut:unregister-all') +export const electronShortcutList = defineInvokeEventa('eventa:invoke:electron:shortcut:list') +export const electronShortcutTriggered = defineEventa('eventa:event:electron:shortcut:triggered') + +// <- Global shortcut + export type StageThreeRuntimeTraceEnvelope = | { type: 'three-render-info', payload: ThreeSceneRenderInfoTracePayload } | { type: 'three-hit-test-read', payload: ThreeHitTestReadTracePayload } diff --git a/cspell.config.yaml b/cspell.config.yaml index 05dd6d990..663500f2c 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -28,6 +28,7 @@ words: - autoplay - awilix - Ayaka + - Backquote - baichuan - baiducloud - bailian @@ -49,11 +50,13 @@ words: - cjkfonts - clippy - clustr + - cmdorctrl - codesign - collectblock - colorjs - cometapi - Comfortaa + - commandorcontrol - composables - cooldown - coreml @@ -285,6 +288,7 @@ words: - softprops - sonarjs - sonner + - Spacebar - specta - splitpanes - splt diff --git a/packages/stage-shared/package.json b/packages/stage-shared/package.json index 923758029..366cc6670 100644 --- a/packages/stage-shared/package.json +++ b/packages/stage-shared/package.json @@ -18,6 +18,7 @@ ".": "./src/index.ts", "./auth": "./src/auth/index.ts", "./beat-sync": "./src/beat-sync/index.ts", + "./global-shortcut": "./src/global-shortcut/index.ts", "./server-channel-qr": "./src/server-channel-qr.ts", "./electron-renderer": "./src/electron-renderer.d.ts", "./composables": "./src/composables/index.ts", diff --git a/packages/stage-shared/src/global-shortcut/accelerators.test.ts b/packages/stage-shared/src/global-shortcut/accelerators.test.ts new file mode 100644 index 000000000..f0b291e9c --- /dev/null +++ b/packages/stage-shared/src/global-shortcut/accelerators.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it } from 'vitest' + +import { + formatAccelerator, + formatElectronAccelerator, + isValidAccelerator, + KEY_NAMES, + parseAccelerator, +} from './accelerators' + +describe('parseAccelerator', () => { + it('parses a single key with no modifiers', () => { + // @example "Escape" — bare key, valid on its own + expect(parseAccelerator('Escape')).toEqual({ modifiers: [], key: 'Escape' }) + }) + + it('parses a typical accelerator with modifiers', () => { + // @example "Mod+Shift+K" — common Cmd/Ctrl shortcut + expect(parseAccelerator('Mod+Shift+K')) + .toEqual({ modifiers: ['cmd-or-ctrl', 'shift'], key: 'KeyK' }) + }) + + it('treats Mod, CmdOrCtrl, and CommandOrControl as the same modifier', () => { + const a = parseAccelerator('Mod+K') + const b = parseAccelerator('CmdOrCtrl+K') + const c = parseAccelerator('CommandOrControl+K') + expect(a).toEqual(b) + expect(b).toEqual(c) + expect(a.modifiers).toEqual(['cmd-or-ctrl']) + }) + + it('treats Cmd and Command as the same modifier', () => { + expect(parseAccelerator('Cmd+K')).toEqual(parseAccelerator('Command+K')) + expect(parseAccelerator('Cmd+K').modifiers).toEqual(['cmd']) + }) + + it('treats Ctrl and Control as the same modifier', () => { + expect(parseAccelerator('Ctrl+K')).toEqual(parseAccelerator('Control+K')) + expect(parseAccelerator('Ctrl+K').modifiers).toEqual(['ctrl']) + }) + + it('treats Alt and Option as the same modifier', () => { + expect(parseAccelerator('Alt+F12')).toEqual(parseAccelerator('Option+F12')) + expect(parseAccelerator('Alt+F12').modifiers).toEqual(['alt']) + }) + + it('is case-insensitive on modifier tokens', () => { + // @example "mod+shift+k" parses identically to "Mod+Shift+K" + expect(parseAccelerator('mod+shift+k')) + .toEqual(parseAccelerator('Mod+Shift+K')) + }) + + it('expands single-letter shorthand to W3C key code', () => { + // @example "K" -> "KeyK" + expect(parseAccelerator('K').key).toBe('KeyK') + expect(parseAccelerator('a').key).toBe('KeyA') + }) + + it('expands single-digit shorthand to W3C key code', () => { + // @example "1" -> "Digit1" + expect(parseAccelerator('1').key).toBe('Digit1') + expect(parseAccelerator('Mod+9').key).toBe('Digit9') + }) + + it('accepts canonical W3C key codes verbatim', () => { + expect(parseAccelerator('KeyK').key).toBe('KeyK') + expect(parseAccelerator('Digit1').key).toBe('Digit1') + expect(parseAccelerator('F12').key).toBe('F12') + expect(parseAccelerator('ArrowUp').key).toBe('ArrowUp') + }) + + it('expands key shorthand aliases to canonical names', () => { + // @example "Up" -> "ArrowUp", "Esc" -> "Escape" + expect(parseAccelerator('Up').key).toBe('ArrowUp') + expect(parseAccelerator('Esc').key).toBe('Escape') + expect(parseAccelerator('Return').key).toBe('Enter') + }) + + it('tolerates whitespace around tokens', () => { + expect(parseAccelerator(' Mod + Shift + K ')) + .toEqual(parseAccelerator('Mod+Shift+K')) + }) + + it('throws on empty input', () => { + expect(() => parseAccelerator('')).toThrow(/empty string/) + expect(() => parseAccelerator(' ')).toThrow(/empty string/) + }) + + it('throws on empty token (trailing or leading +)', () => { + expect(() => parseAccelerator('Mod+')).toThrow(/empty token/) + expect(() => parseAccelerator('+K')).toThrow(/empty token/) + expect(() => parseAccelerator('Mod++K')).toThrow(/empty token/) + }) + + it('throws when the input has no key token', () => { + expect(() => parseAccelerator('Mod+Shift')).toThrow(/no key token/) + }) + + it('throws when the input has multiple non-modifier tokens', () => { + expect(() => parseAccelerator('Mod+K+L')).toThrow(/multiple non-modifier keys/) + }) + + it('throws on duplicate modifier (alias-aware)', () => { + // @example "Mod+CmdOrCtrl+K" — both alias to "cmd-or-ctrl" + expect(() => parseAccelerator('Mod+CmdOrCtrl+K')) + .toThrow(/duplicate modifier/) + expect(() => parseAccelerator('Shift+Shift+K')) + .toThrow(/duplicate modifier/) + }) + + it('throws on unknown key token', () => { + // @example "Mod+Foo" — "Foo" is not a known key + expect(() => parseAccelerator('Mod+Foo')) + .toThrow(/unknown key/) + }) +}) + +describe('isValidAccelerator', () => { + it('returns true for well-formed accelerators', () => { + expect(isValidAccelerator('Mod+Shift+K')).toBe(true) + expect(isValidAccelerator(' CmdOrCtrl + K ')).toBe(true) + expect(isValidAccelerator('Escape')).toBe(true) + }) + + it('returns false for malformed input without throwing', () => { + expect(isValidAccelerator('')).toBe(false) + expect(isValidAccelerator('Mod+')).toBe(false) + expect(isValidAccelerator('Mod+Shift')).toBe(false) + expect(isValidAccelerator('Mod+K+L')).toBe(false) + expect(isValidAccelerator('Mod+Foo')).toBe(false) + }) +}) + +describe('formatAccelerator', () => { + it('emits canonical IR with modifier ordering normalized', () => { + // @example modifiers given in author order ['shift', 'cmd-or-ctrl'] + // serialize as 'Mod+Shift+KeyK', not 'Shift+Mod+KeyK' + expect(formatAccelerator({ modifiers: ['shift', 'cmd-or-ctrl'], key: 'KeyK' })) + .toBe('Mod+Shift+KeyK') + }) + + it('emits a bare key when there are no modifiers', () => { + expect(formatAccelerator({ modifiers: [], key: 'Escape' })).toBe('Escape') + }) + + it('orders modifiers as cmd-or-ctrl, cmd, ctrl, alt, shift, super', () => { + expect(formatAccelerator({ + modifiers: ['super', 'shift', 'alt', 'ctrl', 'cmd', 'cmd-or-ctrl'], + key: 'KeyK', + })).toBe('Mod+Cmd+Ctrl+Alt+Shift+Super+KeyK') + }) +}) + +describe('formatElectronAccelerator', () => { + it('rewrites cmd-or-ctrl to CmdOrCtrl', () => { + expect(formatElectronAccelerator({ modifiers: ['cmd-or-ctrl', 'shift'], key: 'KeyK' })) + .toBe('CmdOrCtrl+Shift+K') + }) + + it('strips the Key prefix from letter keys', () => { + // @example "KeyK" -> "K" + expect(formatElectronAccelerator({ modifiers: ['alt'], key: 'KeyA' })) + .toBe('Alt+A') + }) + + it('strips the Digit prefix from digit keys', () => { + // @example "Digit1" -> "1" + expect(formatElectronAccelerator({ modifiers: ['cmd-or-ctrl'], key: 'Digit1' })) + .toBe('CmdOrCtrl+1') + }) + + it('rewrites arrow keys to Electron short names', () => { + expect(formatElectronAccelerator({ modifiers: [], key: 'ArrowUp' })).toBe('Up') + expect(formatElectronAccelerator({ modifiers: [], key: 'ArrowDown' })).toBe('Down') + }) + + it('rewrites Escape to Esc', () => { + expect(formatElectronAccelerator({ modifiers: [], key: 'Escape' })).toBe('Esc') + }) + + it('rewrites punctuation keys to literal characters', () => { + expect(formatElectronAccelerator({ modifiers: [], key: 'Equal' })).toBe('=') + expect(formatElectronAccelerator({ modifiers: ['shift'], key: 'Slash' })).toBe('Shift+/') + }) + + it('passes function and named keys through unchanged', () => { + expect(formatElectronAccelerator({ modifiers: ['alt'], key: 'F12' })).toBe('Alt+F12') + expect(formatElectronAccelerator({ modifiers: [], key: 'Space' })).toBe('Space') + }) +}) + +describe('round-trip parse -> format', () => { + it('is idempotent on canonical IR strings', () => { + for (const input of [ + 'Mod+Shift+KeyK', + 'Alt+F12', + 'Escape', + 'Mod+Alt+ArrowUp', + ]) { + expect(formatAccelerator(parseAccelerator(input))).toBe(input) + } + }) + + it('normalizes non-canonical inputs to canonical IR', () => { + // @example "Shift+Mod+k" normalizes to "Mod+Shift+KeyK" + expect(formatAccelerator(parseAccelerator('Shift+Mod+k'))).toBe('Mod+Shift+KeyK') + expect(formatAccelerator(parseAccelerator(' CmdOrCtrl + Shift + KeyK '))) + .toBe('Mod+Shift+KeyK') + }) +}) + +describe('constant KEY_NAMES', () => { + it('includes a representative subset of W3C key codes', () => { + expect(KEY_NAMES.has('KeyA')).toBe(true) + expect(KEY_NAMES.has('KeyZ')).toBe(true) + expect(KEY_NAMES.has('Digit0')).toBe(true) + expect(KEY_NAMES.has('F1')).toBe(true) + expect(KEY_NAMES.has('F24')).toBe(true) + expect(KEY_NAMES.has('ArrowUp')).toBe(true) + expect(KEY_NAMES.has('Escape')).toBe(true) + expect(KEY_NAMES.has('Space')).toBe(true) + }) + + it('does not include shorthand aliases', () => { + // KEY_NAMES is the canonical set; shorthand resolves through the + // parser, not the exported set. + expect(KEY_NAMES.has('K')).toBe(false) + expect(KEY_NAMES.has('Up')).toBe(false) + expect(KEY_NAMES.has('Esc')).toBe(false) + }) +}) diff --git a/packages/stage-shared/src/global-shortcut/accelerators.ts b/packages/stage-shared/src/global-shortcut/accelerators.ts new file mode 100644 index 000000000..0f729227c --- /dev/null +++ b/packages/stage-shared/src/global-shortcut/accelerators.ts @@ -0,0 +1,434 @@ +import type { ShortcutAccelerator, ShortcutKey, ShortcutModifier } from './types' + +/** + * Accelerator parsing, validation, and serialization. + * + * The canonical form is the structured `ShortcutAccelerator`. Strings + * are an ergonomic input/output format only. + * + * Two output flavours are provided: + * - `formatAccelerator` — canonical IR (`"Mod+Shift+KeyK"`), + * round-trips losslessly through + * `parseAccelerator`. + * - `formatElectronAccelerator` — Electron's accelerator string + * (`"CmdOrCtrl+Shift+K"`), suitable for + * passing directly to + * `globalShortcut.register`. + * + * Future C# drivers consume the structured value and never call into + * these utilities; they exist only at the renderer/config-author edges. + */ + +const LETTER_KEYS: ReadonlySet = new Set([ + 'KeyA', + 'KeyB', + 'KeyC', + 'KeyD', + 'KeyE', + 'KeyF', + 'KeyG', + 'KeyH', + 'KeyI', + 'KeyJ', + 'KeyK', + 'KeyL', + 'KeyM', + 'KeyN', + 'KeyO', + 'KeyP', + 'KeyQ', + 'KeyR', + 'KeyS', + 'KeyT', + 'KeyU', + 'KeyV', + 'KeyW', + 'KeyX', + 'KeyY', + 'KeyZ', +]) + +const DIGIT_KEYS: ReadonlySet = new Set([ + 'Digit0', + 'Digit1', + 'Digit2', + 'Digit3', + 'Digit4', + 'Digit5', + 'Digit6', + 'Digit7', + 'Digit8', + 'Digit9', +]) + +const FUNCTION_KEYS: ReadonlySet = 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', +]) + +const NAMED_KEYS: ReadonlySet = new Set([ + 'Space', + 'Tab', + 'Enter', + 'Escape', + 'Backspace', + 'Delete', + 'Insert', + 'ArrowUp', + 'ArrowDown', + 'ArrowLeft', + 'ArrowRight', + 'Home', + 'End', + 'PageUp', + 'PageDown', + 'Backquote', + 'Minus', + 'Equal', + 'BracketLeft', + 'BracketRight', + 'Backslash', + 'Semicolon', + 'Quote', + 'Comma', + 'Period', + 'Slash', +]) + +/** + * Full set of accepted W3C `KeyboardEvent.code` key names. Drivers map + * each value to the native key code understood by their platform API. + */ +export const KEY_NAMES: ReadonlySet = new Set([ + ...LETTER_KEYS, + ...DIGIT_KEYS, + ...FUNCTION_KEYS, + ...NAMED_KEYS, +]) + +/** + * Shorthand-to-canonical key alias map used by the parser. + * + * Lets authors write `Up` instead of `ArrowUp`, `Esc` instead of + * `Escape`, etc. Single letters and digits are handled by inline + * regex fallbacks rather than entries here, so `K` becomes `KeyK` + * and `1` becomes `Digit1` without lookup. + */ +const KEY_ALIASES: ReadonlyMap = new Map([ + ['Up', 'ArrowUp'], + ['Down', 'ArrowDown'], + ['Left', 'ArrowLeft'], + ['Right', 'ArrowRight'], + ['Esc', 'Escape'], + ['Return', 'Enter'], + ['Spacebar', 'Space'], +]) + +/** + * Modifier alias map (case-insensitive lookup; keys are lowercase). + * + * Accepts the union of names used by Electron, Tauri, and informal + * style so authors can paste from existing docs without translation. + */ +const MODIFIER_ALIASES: ReadonlyMap = new Map([ + ['mod', 'cmd-or-ctrl'], + ['cmdorctrl', 'cmd-or-ctrl'], + ['commandorcontrol', 'cmd-or-ctrl'], + ['cmd', 'cmd'], + ['command', 'cmd'], + ['ctrl', 'ctrl'], + ['control', 'ctrl'], + ['alt', 'alt'], + ['option', 'alt'], + ['shift', 'shift'], + ['super', 'super'], +]) + +/** + * Canonical modifier order used when serializing back to a string. + * Two structurally identical accelerators always serialize to the + * same string regardless of the order in which the author wrote + * modifiers. + */ +const MODIFIER_CANONICAL_ORDER: readonly ShortcutModifier[] = [ + 'cmd-or-ctrl', + 'cmd', + 'ctrl', + 'alt', + 'shift', + 'super', +] + +/** + * Title-case modifier tokens used by `formatAccelerator` (canonical IR + * output). Mirrors Tauri/Electron casing so output is recognizable. + */ +const MODIFIER_TO_IR_TOKEN: Readonly> = { + 'cmd-or-ctrl': 'Mod', + 'cmd': 'Cmd', + 'ctrl': 'Ctrl', + 'alt': 'Alt', + 'shift': 'Shift', + 'super': 'Super', +} + +/** + * Tokens used by `formatElectronAccelerator`. `cmd-or-ctrl` becomes + * `CmdOrCtrl`; everything else passes through unchanged. + */ +const MODIFIER_TO_ELECTRON_TOKEN: Readonly> = { + 'cmd-or-ctrl': 'CmdOrCtrl', + 'cmd': 'Cmd', + 'ctrl': 'Ctrl', + 'alt': 'Alt', + 'shift': 'Shift', + 'super': 'Super', +} + +/** + * Per-key overrides used when serializing for Electron. Electron's + * accelerator format expects literal characters or short names for + * many keys (`Up`, `Esc`, `=`, etc.) rather than the W3C codes. + * + * Letter and digit keys are handled by stripping the `Key`/`Digit` + * prefix in `toElectronKey`, so they are not listed here. + */ +const ELECTRON_KEY_OVERRIDES: Readonly> = { + ArrowUp: 'Up', + ArrowDown: 'Down', + ArrowLeft: 'Left', + ArrowRight: 'Right', + Escape: 'Esc', + Backquote: '`', + Minus: '-', + Equal: '=', + BracketLeft: '[', + BracketRight: ']', + Backslash: '\\', + Semicolon: ';', + Quote: '\'', + Comma: ',', + Period: '.', + Slash: '/', +} + +const SINGLE_LETTER_RE = /^[A-Z]$/i +const SINGLE_DIGIT_RE = /^\d$/ + +/** + * Normalizes a raw key token to a canonical `ShortcutKey`. + * + * Before: + * - `"K"` / `"k"` / `"KeyK"` / `"Up"` / `"Esc"` + * + * After: + * - `"KeyK"` / `"KeyK"` / `"KeyK"` / `"ArrowUp"` / `"Escape"` + */ +function normalizeKeyToken(token: string): ShortcutKey { + if (KEY_NAMES.has(token)) + return token + + const aliased = KEY_ALIASES.get(token) + if (aliased !== undefined) + return aliased + + if (SINGLE_LETTER_RE.test(token)) { + const candidate = `Key${token.toUpperCase()}` + if (LETTER_KEYS.has(candidate)) + return candidate + } + + if (SINGLE_DIGIT_RE.test(token)) { + const candidate = `Digit${token}` + if (DIGIT_KEYS.has(candidate)) + return candidate + } + + throw new Error(`Invalid accelerator: unknown key "${token}"`) +} + +/** + * Returns the canonical modifier for a token, or `undefined` if the + * token is not a modifier (i.e. probably a key). + */ +function lookupModifierToken(token: string): ShortcutModifier | undefined { + return MODIFIER_ALIASES.get(token.toLowerCase()) +} + +/** + * Parses a string accelerator into its canonical structured form. + * + * Use when: + * - Accepting an accelerator from author code, settings UI, or config + * file + * - Validating user input + * + * Expects: + * - `input` is a `+`-joined token string. Whitespace around tokens is + * tolerated and ignored. Modifier tokens are case-insensitive; key + * tokens are matched against `KEY_NAMES` and a small alias map, with + * single letters and digits accepted as shorthand. + * + * Returns: + * - A `ShortcutAccelerator` with modifiers in input order and a + * canonical key + * + * Throws: + * - `Error` when the input is empty, has empty tokens, names an + * unknown key, repeats a modifier, or contains multiple non-modifier + * tokens + * + * @example + * parseAccelerator('Mod+Shift+K') + * // => { modifiers: ['cmd-or-ctrl', 'shift'], key: 'KeyK' } + * + * @example + * parseAccelerator(' CmdOrCtrl + Shift + KeyK ') + * // => same as above; whitespace tolerated + */ +export function parseAccelerator(input: string): ShortcutAccelerator { + const trimmed = input.trim() + if (trimmed.length === 0) + throw new Error('Invalid accelerator: empty string') + + const rawTokens = trimmed.split('+') + const modifiers: ShortcutModifier[] = [] + let key: ShortcutKey | undefined + + for (const raw of rawTokens) { + const token = raw.trim() + if (token.length === 0) + throw new Error(`Invalid accelerator "${input}": empty token`) + + const modifier = lookupModifierToken(token) + if (modifier !== undefined) { + if (modifiers.includes(modifier)) + throw new Error(`Invalid accelerator "${input}": duplicate modifier "${modifier}"`) + modifiers.push(modifier) + continue + } + + if (key !== undefined) + throw new Error(`Invalid accelerator "${input}": multiple non-modifier keys ("${key}", "${token}")`) + key = normalizeKeyToken(token) + } + + if (key === undefined) + throw new Error(`Invalid accelerator "${input}": no key token`) + + return { modifiers, key } +} + +/** + * Tests whether `input` is a well-formed accelerator string. + * + * Use when: + * - Gating user input without needing the parsed result + * + * Returns: + * - `true` when `parseAccelerator` would succeed, `false` otherwise + */ +export function isValidAccelerator(input: string): boolean { + try { + parseAccelerator(input) + return true + } + catch { + return false + } +} + +/** + * Returns the modifiers of `acc` sorted into canonical order. + */ +function canonicalModifiers(acc: ShortcutAccelerator): ShortcutModifier[] { + return MODIFIER_CANONICAL_ORDER.filter(m => acc.modifiers.includes(m)) +} + +/** + * Serializes a structured accelerator back to canonical IR string + * form. + * + * Use when: + * - Displaying a binding in settings UI + * - Round-tripping a binding through a string representation + * + * Returns: + * - A `+`-joined string with modifiers in canonical order followed by + * the key. The output round-trips losslessly through + * `parseAccelerator`. + * + * @example + * formatAccelerator({ modifiers: ['shift', 'cmd-or-ctrl'], key: 'KeyK' }) + * // => 'Mod+Shift+KeyK' + */ +export function formatAccelerator(acc: ShortcutAccelerator): string { + const tokens = canonicalModifiers(acc).map(m => MODIFIER_TO_IR_TOKEN[m]) + tokens.push(acc.key) + return tokens.join('+') +} + +/** + * Translates a canonical W3C key name to Electron's accelerator key + * token. + * + * Before: + * - `"KeyK"` / `"Digit1"` / `"ArrowUp"` / `"Equal"` / `"F12"` + * + * After: + * - `"K"` / `"1"` / `"Up"` / `"="` / `"F12"` + */ +function toElectronKey(key: ShortcutKey): string { + if (LETTER_KEYS.has(key)) + return key.slice(3) + if (DIGIT_KEYS.has(key)) + return key.slice(5) + if (key in ELECTRON_KEY_OVERRIDES) + return ELECTRON_KEY_OVERRIDES[key] + return key +} + +/** + * Serializes a structured accelerator to Electron's accelerator string + * format, suitable for `globalShortcut.register`. + * + * Use when: + * - Calling Electron's `globalShortcut` API from the main-process + * driver + * + * Returns: + * - An Electron-format string with modifiers in canonical order + * (`CmdOrCtrl`, `Cmd`, `Ctrl`, `Alt`, `Shift`, `Super`) followed by + * Electron's key spelling + * + * @example + * formatElectronAccelerator({ modifiers: ['cmd-or-ctrl', 'shift'], key: 'KeyK' }) + * // => 'CmdOrCtrl+Shift+K' + */ +export function formatElectronAccelerator(acc: ShortcutAccelerator): string { + const tokens = canonicalModifiers(acc).map(m => MODIFIER_TO_ELECTRON_TOKEN[m]) + tokens.push(toElectronKey(acc.key)) + return tokens.join('+') +} diff --git a/packages/stage-shared/src/global-shortcut/index.ts b/packages/stage-shared/src/global-shortcut/index.ts new file mode 100644 index 000000000..29c4680bc --- /dev/null +++ b/packages/stage-shared/src/global-shortcut/index.ts @@ -0,0 +1,2 @@ +export * from './accelerators' +export * from './types' diff --git a/packages/stage-shared/src/global-shortcut/types.ts b/packages/stage-shared/src/global-shortcut/types.ts new file mode 100644 index 000000000..5ae3e1dd2 --- /dev/null +++ b/packages/stage-shared/src/global-shortcut/types.ts @@ -0,0 +1,106 @@ +/** + * Modifier key understood by accelerator parsing and serialization. + * + * - `cmd-or-ctrl` — platform meta key. Resolves to Cmd on macOS, + * Ctrl on Windows/Linux at the driver boundary. + * - `cmd` — literal Command key. + * - `ctrl` — literal Control key. + * - `alt` — Alt / Option. + * - `shift` — Shift. + * - `super` — Super / Win / Meta key. + */ +export type ShortcutModifier + = | 'cmd-or-ctrl' + | 'cmd' + | 'ctrl' + | 'alt' + | 'shift' + | 'super' + +/** + * Key identifier following the W3C `KeyboardEvent.code` convention. + * Layout-independent; refers to physical key position. + * + * Examples: `"KeyK"`, `"Digit1"`, `"F12"`, `"ArrowUp"`, `"Space"`, + * `"Escape"`. The accepted set is enumerated by `KEY_NAMES` in + * `./accelerators`. + */ +export type ShortcutKey = string + +/** + * A keyboard combination: modifiers plus a single key. + * + * Compare two accelerators structurally; modifier array order is not + * significant. Use `formatAccelerator` for a stable canonical string. + */ +export interface ShortcutAccelerator { + modifiers: ShortcutModifier[] + key: ShortcutKey +} + +/** + * When a shortcut is active. + * + * - `'global'` — fires regardless of which app or window is focused. + * - (More will be added if needed) + */ +export type ShortcutScope = 'global' + +/** + * A registered shortcut entry. + * + * `id` is the stable handle used by (un)registration, and trigger + * events; rebinding the accelerator must not change it. + */ +export interface ShortcutBinding { + /** Stable identifier, e.g. `"toggle-main-window"`. */ + id: string + /** Keyboard combination that triggers this shortcut. */ + accelerator: ShortcutAccelerator + /** When the shortcut is active. */ + scope: ShortcutScope + /** + * Whether the driver should also emit key-release events. + * + * Drivers that cannot deliver release events refuse the + * registration with `{ ok: false, reason: 'unsupported' }`. + * + * @default false + */ + receiveKeyUps?: boolean + /** Human-readable description, surfaced in settings UI. */ + description?: string +} + +/** + * Outcome of a registration request. + * + * `ok: true` means the binding is live. `ok: false` carries `reason`. + * `actualAccelerator` is populated when the host had to substitute the + * requested accelerator (e.g. user choice via a Wayland portal dialog). + */ +export interface ShortcutRegistrationResult { + id: string + ok: boolean + /** + * The accelerator the host actually bound. Absent when the request + * was honoured verbatim. + */ + actualAccelerator?: ShortcutAccelerator + /** + * Failure reason. Known values: `'conflict'`, `'denied'`, + * `'unsupported'`. Drivers may emit other strings; treat unknown + * values as opaque. + */ + reason?: 'conflict' | 'denied' | 'unsupported' | string +} + +/** + * In-memory shortcut config. Bump `version` on any breaking schema + * change; consumers refuse newer versions rather than silently + * dropping fields. + */ +export interface ShortcutConfig { + version: 1 + bindings: ShortcutBinding[] +}