feat(stage-tamagotchi,stage-shared): essentials for global shortcut
Some checks are pending
CI / Lint (push) Waiting to run
CI / Build Test (stage-tamagotchi) (push) Waiting to run
CI / Build Test (stage-tamagotchi-godot) (push) Waiting to run
CI / Build Test (stage-web) (push) Waiting to run
CI / Build Test (ui-loading-screens) (push) Waiting to run
CI / Build Test (ui-transitions) (push) Waiting to run
CI / Type Check (push) Waiting to run
CI / Check Provenance (push) Waiting to run
Cloudflare Workers / Deploy - stage-web (push) Waiting to run

This commit is contained in:
Makito 2026-04-25 21:30:10 +09:00
parent f292ab9b99
commit 3566e8b4a8
No known key found for this signature in database
GPG key ID: FF813827E1D9D2CD
7 changed files with 810 additions and 0 deletions

View file

@ -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<ElectronGodotStage
export const electronGodotStageApplySceneInput = defineInvokeEventa<void, ElectronGodotStageSceneInputPayload>('eventa:invoke:electron:godot-stage:apply-scene-input')
export const electronGodotStageStatusChanged = defineEventa<ElectronGodotStageStatus>('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<ShortcutRegistrationResult, ShortcutBinding>('eventa:invoke:electron:shortcut:register')
export const electronShortcutUnregister = defineInvokeEventa<void, { id: string }>('eventa:invoke:electron:shortcut:unregister')
export const electronShortcutUnregisterAll = defineInvokeEventa<void>('eventa:invoke:electron:shortcut:unregister-all')
export const electronShortcutList = defineInvokeEventa<ShortcutBinding[]>('eventa:invoke:electron:shortcut:list')
export const electronShortcutTriggered = defineEventa<ElectronShortcutTriggerPayload>('eventa:event:electron:shortcut:triggered')
// <- Global shortcut
export type StageThreeRuntimeTraceEnvelope
= | { type: 'three-render-info', payload: ThreeSceneRenderInfoTracePayload }
| { type: 'three-hit-test-read', payload: ThreeHitTestReadTracePayload }

View file

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

View file

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

View file

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

View file

@ -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<ShortcutKey> = 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<ShortcutKey> = new Set([
'Digit0',
'Digit1',
'Digit2',
'Digit3',
'Digit4',
'Digit5',
'Digit6',
'Digit7',
'Digit8',
'Digit9',
])
const FUNCTION_KEYS: ReadonlySet<ShortcutKey> = 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<ShortcutKey> = 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<ShortcutKey> = new Set<ShortcutKey>([
...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<string, ShortcutKey> = 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<string, ShortcutModifier> = new Map<string, ShortcutModifier>([
['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<Record<ShortcutModifier, string>> = {
'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<Record<ShortcutModifier, string>> = {
'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<Record<string, string>> = {
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('+')
}

View file

@ -0,0 +1,2 @@
export * from './accelerators'
export * from './types'

View file

@ -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[]
}