Something went wrong. Try again.
Ghostty for the web with xterm.js API compatibility
Something went wrong. Try again.
11 kB · 425 lines
TypeScript
at task5
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426/** * InputHandler - Converts browser keyboard events to terminal input * * Handles: * - Keyboard event listening on a container element * - Mapping KeyboardEvent.code to USB HID Key codes * - Extracting modifier keys (Ctrl, Alt, Shift, Meta) * - Encoding keys using Ghostty's KeyEncoder * - Emitting data for Terminal to send to PTY * * Limitations: * - Does not handle IME/composition events (CJK input) - to be added later * - Captures all keyboard input (preventDefault on everything) */
import type { Ghostty } from './ghostty';import { KeyEncoder } from './ghostty';import { Key, KeyAction, Mods } from './types';
/** * Map KeyboardEvent.code values to USB HID Key enum values * Based on: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code */const KEY_MAP: Record<string, Key> = { // Letters 'KeyA': Key.A, 'KeyB': Key.B, 'KeyC': Key.C, 'KeyD': Key.D, 'KeyE': Key.E, 'KeyF': Key.F, 'KeyG': Key.G, 'KeyH': Key.H, 'KeyI': Key.I, 'KeyJ': Key.J, 'KeyK': Key.K, 'KeyL': Key.L, 'KeyM': Key.M, 'KeyN': Key.N, 'KeyO': Key.O, 'KeyP': Key.P, 'KeyQ': Key.Q, 'KeyR': Key.R, 'KeyS': Key.S, 'KeyT': Key.T, 'KeyU': Key.U, 'KeyV': Key.V, 'KeyW': Key.W, 'KeyX': Key.X, 'KeyY': Key.Y, 'KeyZ': Key.Z,
// Numbers 'Digit1': Key.ONE, 'Digit2': Key.TWO, 'Digit3': Key.THREE, 'Digit4': Key.FOUR, 'Digit5': Key.FIVE, 'Digit6': Key.SIX, 'Digit7': Key.SEVEN, 'Digit8': Key.EIGHT, 'Digit9': Key.NINE, 'Digit0': Key.ZERO,
// Special keys 'Enter': Key.ENTER, 'Escape': Key.ESCAPE, 'Backspace': Key.BACKSPACE, 'Tab': Key.TAB, 'Space': Key.SPACE,
// Punctuation 'Minus': Key.MINUS, 'Equal': Key.EQUAL, 'BracketLeft': Key.BRACKET_LEFT, 'BracketRight': Key.BRACKET_RIGHT, 'Backslash': Key.BACKSLASH, 'Semicolon': Key.SEMICOLON, 'Quote': Key.QUOTE, 'Backquote': Key.GRAVE, 'Comma': Key.COMMA, 'Period': Key.PERIOD, 'Slash': Key.SLASH,
// Function keys 'CapsLock': Key.CAPS_LOCK, 'F1': Key.F1, 'F2': Key.F2, 'F3': Key.F3, 'F4': Key.F4, 'F5': Key.F5, 'F6': Key.F6, 'F7': Key.F7, 'F8': Key.F8, 'F9': Key.F9, 'F10': Key.F10, 'F11': Key.F11, 'F12': Key.F12,
// Special function keys 'PrintScreen': Key.PRINT_SCREEN, 'ScrollLock': Key.SCROLL_LOCK, 'Pause': Key.PAUSE, 'Insert': Key.INSERT, 'Home': Key.HOME, 'PageUp': Key.PAGE_UP, 'Delete': Key.DELETE, 'End': Key.END, 'PageDown': Key.PAGE_DOWN,
// Arrow keys 'ArrowRight': Key.RIGHT, 'ArrowLeft': Key.LEFT, 'ArrowDown': Key.DOWN, 'ArrowUp': Key.UP,
// Keypad 'NumLock': Key.NUM_LOCK, 'NumpadDivide': Key.KP_DIVIDE, 'NumpadMultiply': Key.KP_MULTIPLY, 'NumpadSubtract': Key.KP_MINUS, 'NumpadAdd': Key.KP_PLUS, 'NumpadEnter': Key.KP_ENTER, 'Numpad1': Key.KP_1, 'Numpad2': Key.KP_2, 'Numpad3': Key.KP_3, 'Numpad4': Key.KP_4, 'Numpad5': Key.KP_5, 'Numpad6': Key.KP_6, 'Numpad7': Key.KP_7, 'Numpad8': Key.KP_8, 'Numpad9': Key.KP_9, 'Numpad0': Key.KP_0, 'NumpadDecimal': Key.KP_PERIOD,
// International 'IntlBackslash': Key.NON_US_BACKSLASH, 'ContextMenu': Key.APPLICATION,
// Additional function keys 'F13': Key.F13, 'F14': Key.F14, 'F15': Key.F15, 'F16': Key.F16, 'F17': Key.F17, 'F18': Key.F18, 'F19': Key.F19, 'F20': Key.F20, 'F21': Key.F21, 'F22': Key.F22, 'F23': Key.F23, 'F24': Key.F24,};
/** * InputHandler class * Attaches keyboard event listeners to a container and converts * keyboard events to terminal input data */export class InputHandler { private encoder: KeyEncoder; private container: HTMLElement; private onDataCallback: (data: string) => void; private onBellCallback: () => void; private keydownListener: ((e: KeyboardEvent) => void) | null = null; private keypressListener: ((e: KeyboardEvent) => void) | null = null; private isDisposed = false;
/** * Create a new InputHandler * @param ghostty - Ghostty instance (for creating KeyEncoder) * @param container - DOM element to attach listeners to * @param onData - Callback for terminal data (escape sequences to send to PTY) * @param onBell - Callback for bell/beep event */ constructor( ghostty: Ghostty, container: HTMLElement, onData: (data: string) => void, onBell: () => void ) { this.encoder = ghostty.createKeyEncoder(); this.container = container; this.onDataCallback = onData; this.onBellCallback = onBell;
// Attach event listeners this.attach(); }
/** * Attach keyboard event listeners to container */ private attach(): void { this.keydownListener = this.handleKeyDown.bind(this); this.container.addEventListener('keydown', this.keydownListener); }
/** * Map KeyboardEvent.code to USB HID Key enum value * @param code - KeyboardEvent.code value * @returns Key enum value or null if unmapped */ private mapKeyCode(code: string): Key | null { return KEY_MAP[code] ?? null; }
/** * Extract modifier flags from KeyboardEvent * @param event - KeyboardEvent * @returns Mods flags */ private extractModifiers(event: KeyboardEvent): Mods { let mods = Mods.NONE;
if (event.shiftKey) mods |= Mods.SHIFT; if (event.ctrlKey) mods |= Mods.CTRL; if (event.altKey) mods |= Mods.ALT; if (event.metaKey) mods |= Mods.SUPER;
// Note: CapsLock and NumLock are not in KeyboardEvent modifiers // They would need to be tracked separately if needed // For now, we don't set CAPSLOCK or NUMLOCK flags
return mods; }
/** * Check if this is a printable character with no special modifiers * @param event - KeyboardEvent * @returns true if printable character */ private isPrintableCharacter(event: KeyboardEvent): boolean { // If Ctrl or Alt is pressed (but not AltGr which is Ctrl+Alt on some keyboards) // then it's not a simple printable character if (event.ctrlKey && !event.altKey) return false; if (event.altKey && !event.ctrlKey) return false;
// If key produces a single printable character return event.key.length === 1; }
/** * Handle keydown event * @param event - KeyboardEvent */ private handleKeyDown(event: KeyboardEvent): void { if (this.isDisposed) return;
// For printable characters without modifiers, send the character directly // This handles: a-z, A-Z (with shift), 0-9, punctuation, etc. if (this.isPrintableCharacter(event)) { event.preventDefault(); this.onDataCallback(event.key); return; }
// Map the physical key code const key = this.mapKeyCode(event.code); if (key === null) { // Unknown key - ignore it return; }
// Extract modifiers const mods = this.extractModifiers(event);
// Handle simple special keys that produce standard sequences if (mods === Mods.NONE || mods === Mods.SHIFT) { let simpleOutput: string | null = null; switch (key) { case Key.ENTER: simpleOutput = '\r'; // Carriage return break; case Key.TAB: simpleOutput = '\t'; // Tab break; case Key.BACKSPACE: simpleOutput = '\x7F'; // DEL (most terminals use 0x7F for backspace) break; case Key.ESCAPE: simpleOutput = '\x1B'; // ESC break; // Arrow keys (CSI sequences) case Key.UP: simpleOutput = '\x1B[A'; break; case Key.DOWN: simpleOutput = '\x1B[B'; break; case Key.RIGHT: simpleOutput = '\x1B[C'; break; case Key.LEFT: simpleOutput = '\x1B[D'; break; // Navigation keys case Key.HOME: simpleOutput = '\x1B[H'; break; case Key.END: simpleOutput = '\x1B[F'; break; case Key.INSERT: simpleOutput = '\x1B[2~'; break; case Key.DELETE: simpleOutput = '\x1B[3~'; break; case Key.PAGE_UP: simpleOutput = '\x1B[5~'; break; case Key.PAGE_DOWN: simpleOutput = '\x1B[6~'; break; // Function keys case Key.F1: simpleOutput = '\x1BOP'; break; case Key.F2: simpleOutput = '\x1BOQ'; break; case Key.F3: simpleOutput = '\x1BOR'; break; case Key.F4: simpleOutput = '\x1BOS'; break; case Key.F5: simpleOutput = '\x1B[15~'; break; case Key.F6: simpleOutput = '\x1B[17~'; break; case Key.F7: simpleOutput = '\x1B[18~'; break; case Key.F8: simpleOutput = '\x1B[19~'; break; case Key.F9: simpleOutput = '\x1B[20~'; break; case Key.F10: simpleOutput = '\x1B[21~'; break; case Key.F11: simpleOutput = '\x1B[23~'; break; case Key.F12: simpleOutput = '\x1B[24~'; break; } if (simpleOutput !== null) { event.preventDefault(); this.onDataCallback(simpleOutput); return; } }
// Determine action (we only care about PRESS for now, not RELEASE or REPEAT) const action = KeyAction.PRESS;
// For non-printable keys or keys with modifiers, encode using Ghostty try { // For letter/number keys, even with modifiers, pass the base character // This helps the encoder produce correct control sequences (e.g., Ctrl+A = 0x01) // For special keys (Enter, Arrow keys, etc.), don't pass utf8 const utf8 = (event.key.length === 1 && event.key.charCodeAt(0) < 128) ? event.key.toLowerCase() // Use lowercase for consistency : undefined; const encoded = this.encoder.encode({ action, key, mods, utf8, });
// Convert Uint8Array to string const decoder = new TextDecoder(); const data = decoder.decode(encoded);
// Prevent default browser behavior event.preventDefault(); event.stopPropagation();
// Emit the data if (data.length > 0) { this.onDataCallback(data); } } catch (error) { // Encoding failed - log but don't crash console.warn('Failed to encode key:', event.code, error); } }
/** * Dispose the InputHandler and remove event listeners */ dispose(): void { if (this.isDisposed) return;
if (this.keydownListener) { this.container.removeEventListener('keydown', this.keydownListener); this.keydownListener = null; }
if (this.keypressListener) { this.container.removeEventListener('keypress', this.keypressListener); this.keypressListener = null; }
this.isDisposed = true; }
/** * Check if handler is disposed */ isActive(): boolean { return !this.isDisposed; }}