Files
openchamber/packages/ui/src/lib/ghostty/keyCodes.ts
T
Bohdan Triapitsyn 39fa8c1917 feat(terminal): replace ghostty-web with an in-repo libghostty-vt adapter
The terminal ran on the ghostty-web npm package plus a hand-written patch,
and every rendering bug (recycled rows, duplicated reflow fragments, prompt
artifacts) had to be worked around from outside. The emulator now is the
official libghostty-vt C ABI compiled to WebAssembly, driven by a browser
adapter ported from T3 Code (MIT, notice in LICENSE-T3CODE) and owned in
packages/ui/src/lib/ghostty. The artifact is reproducible with
scripts/build-libghostty-wasm.sh, including a workaround for Zig 0.15.2 on
macOS 27 SDKs.

On top of the port: one WASM instance per page with every tab kept mounted
and hidden tabs paused; history replayed at the PTY size it was drawn for;
shells spawned only after the first fitted grid so zsh never prints the
PROMPT_SP marker; box drawing, block elements and Powerline arrows drawn
procedurally to the exact cell so TUI borders and block logos have no gaps
between rows; a software-rasterized canvas so Gecko renders every tab's text
with the same smoothing; the symbols-only Nerd Font bundled instead of a CDN
fetch; touch selection and scrolling driven through the surface API; a copy
button in the tab strip for touch hosts; localized aria labels.

Testing: bun tests run the real WASM (reflow, palette, replay isolation,
recycled rows, box glyph geometry); viewport and view tests use a surface
double; verified in Chromium and Zen (windowed and headless) for crisp text,
new tabs, panel reopen, resize and box glyph rendering; package type-check,
oxlint/eslint on new files, web build.
2026-09-07 12:18:22 +03:00

270 lines
5.4 KiB
TypeScript

// Adapted from T3 Code's libghostty-vt browser adapter (MIT, T3 Tools Inc.).
// See LICENSE-T3CODE in this directory.
// This order mirrors GhosttyKey in ghostty/vt/key/event.h. The values are
// intentionally derived from the official W3C-aligned enum instead of
// maintaining a second keyboard protocol.
const ghosttyKeyboardCodes = [
'Unidentified',
'Backquote',
'Backslash',
'BracketLeft',
'BracketRight',
'Comma',
'Digit0',
'Digit1',
'Digit2',
'Digit3',
'Digit4',
'Digit5',
'Digit6',
'Digit7',
'Digit8',
'Digit9',
'Equal',
'IntlBackslash',
'IntlRo',
'IntlYen',
'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',
'Minus',
'Period',
'Quote',
'Semicolon',
'Slash',
'AltLeft',
'AltRight',
'Backspace',
'CapsLock',
'ContextMenu',
'ControlLeft',
'ControlRight',
'Enter',
'MetaLeft',
'MetaRight',
'ShiftLeft',
'ShiftRight',
'Space',
'Tab',
'Convert',
'KanaMode',
'NonConvert',
'Delete',
'End',
'Help',
'Home',
'Insert',
'PageDown',
'PageUp',
'ArrowDown',
'ArrowLeft',
'ArrowRight',
'ArrowUp',
'NumLock',
'Numpad0',
'Numpad1',
'Numpad2',
'Numpad3',
'Numpad4',
'Numpad5',
'Numpad6',
'Numpad7',
'Numpad8',
'Numpad9',
'NumpadAdd',
'NumpadBackspace',
'NumpadClear',
'NumpadClearEntry',
'NumpadComma',
'NumpadDecimal',
'NumpadDivide',
'NumpadEnter',
'NumpadEqual',
'NumpadMemoryAdd',
'NumpadMemoryClear',
'NumpadMemoryRecall',
'NumpadMemoryStore',
'NumpadMemorySubtract',
'NumpadMultiply',
'NumpadParenLeft',
'NumpadParenRight',
'NumpadSubtract',
'NumpadSeparator',
'NumpadArrowUp',
'NumpadArrowDown',
'NumpadArrowRight',
'NumpadArrowLeft',
'NumpadBegin',
'NumpadHome',
'NumpadEnd',
'NumpadInsert',
'NumpadDelete',
'NumpadPageUp',
'NumpadPageDown',
'Escape',
'F1',
'F2',
'F3',
'F4',
'F5',
'F6',
'F7',
'F8',
'F9',
'F10',
'F11',
'F12',
'F13',
'F14',
'F15',
'F16',
'F17',
'F18',
'F19',
'F20',
'F21',
'F22',
'F23',
'F24',
'F25',
'Fn',
'FnLock',
'PrintScreen',
'ScrollLock',
'Pause',
'BrowserBack',
'BrowserFavorites',
'BrowserForward',
'BrowserHome',
'BrowserRefresh',
'BrowserSearch',
'BrowserStop',
'Eject',
'LaunchApp1',
'LaunchApp2',
'LaunchMail',
'MediaPlayPause',
'MediaSelect',
'MediaStop',
'MediaTrackNext',
'MediaTrackPrevious',
'Power',
'Sleep',
'AudioVolumeDown',
'AudioVolumeMute',
'AudioVolumeUp',
'WakeUp',
'Copy',
'Cut',
'Paste',
] as const;
const codeToGhosttyKey = new Map<string, number>(
ghosttyKeyboardCodes.map((code, index) => [code, index]),
);
export function ghosttyKeyForCode(code: string): number {
return codeToGhosttyKey.get(code) ?? 0;
}
export interface GhosttyKeyboardLayoutMap {
get(code: string): string | undefined;
}
const shiftedToUnshiftedCharacter = new Map<string, string>([
['!', '1'],
['@', '2'],
['#', '3'],
['$', '4'],
['%', '5'],
['^', '6'],
['&', '7'],
['*', '8'],
['(', '9'],
[')', '0'],
['~', '`'],
['_', '-'],
['+', '='],
['{', '['],
['}', ']'],
['|', '\\'],
[':', ';'],
['"', "'"],
['<', ','],
['>', '.'],
['?', '/'],
]);
let keyboardLayoutMapPromise: Promise<GhosttyKeyboardLayoutMap | undefined> | undefined;
export function loadGhosttyKeyboardLayoutMap(): Promise<GhosttyKeyboardLayoutMap | undefined> {
if (keyboardLayoutMapPromise) return keyboardLayoutMapPromise;
// SAFETY: navigator.keyboard is Chromium-only and absent from lib.dom; the
// optional field keeps every other engine on the undefined branch.
const keyboard = (
globalThis.navigator as Navigator & {
readonly keyboard?: { getLayoutMap(): Promise<GhosttyKeyboardLayoutMap> };
} | undefined
)?.keyboard;
const promise = keyboard?.getLayoutMap().catch(() => undefined) ?? Promise.resolve(undefined);
keyboardLayoutMapPromise = promise;
return promise;
}
// Browsers do not expose consumed modifiers; treat Shift as consumed for
// unchorded character input.
export function ghosttyConsumedMods(
event: Pick<KeyboardEvent, 'altKey' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey'>,
): number {
if (!event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) return 0;
return [...event.key].length === 1 ? 1 : 0;
}
export function ghosttyUnshiftedCodepoint(
event: Pick<KeyboardEvent, 'code' | 'key' | 'shiftKey'>,
layoutMap?: GhosttyKeyboardLayoutMap,
): number {
if ([...event.key].length !== 1) return 0;
const layoutCharacter = layoutMap?.get(event.code);
if (layoutCharacter && [...layoutCharacter].length === 1) {
return layoutCharacter.codePointAt(0) ?? 0;
}
if (/^[A-Z]$/u.test(event.key)) return event.key.charCodeAt(0) + 32;
if (event.shiftKey) {
const unshiftedCharacter = shiftedToUnshiftedCharacter.get(event.key);
if (unshiftedCharacter) return unshiftedCharacter.codePointAt(0) ?? 0;
const lowercase = event.key.toLowerCase();
if (lowercase !== event.key && [...lowercase].length === 1) {
return lowercase.codePointAt(0) ?? 0;
}
// Without layout data the unshifted form of a shifted key is unknowable;
// reporting the shifted character as unshifted corrupts Kitty alternate keys.
return 0;
}
return event.key.codePointAt(0) ?? 0;
}