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.
270 lines
5.4 KiB
TypeScript
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;
|
|
}
|