Something went wrong. Try again.
Ghostty for the web with xterm.js API compatibility
Something went wrong. Try again.
33 kB · 1106 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107/** * 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 type { KeyEncoder } from './ghostty';import type { IKeyEvent } from './interfaces';import { Key, KeyAction, KeyEncoderOption, 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.INTL_BACKSLASH, ContextMenu: Key.CONTEXT_MENU,
// 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 *//** * Mouse tracking configuration */export interface MouseTrackingConfig { /** Check if any mouse tracking mode is enabled */ hasMouseTracking: () => boolean; /** Check if SGR extended mouse mode is enabled (mode 1006) */ hasSgrMouseMode: () => boolean; /** Get cell dimensions for pixel to cell conversion */ getCellDimensions: () => { width: number; height: number }; /** Get canvas/container offset for accurate position calculation */ getCanvasOffset: () => { left: number; top: number };}
export class InputHandler { private encoder: KeyEncoder; private container: HTMLElement; private inputElement?: HTMLElement; private onDataCallback: (data: string) => void; private onBellCallback: () => void; private onKeyCallback?: (keyEvent: IKeyEvent) => void; private customKeyEventHandler?: (event: KeyboardEvent) => boolean; private getModeCallback?: (mode: number) => boolean; private onCopyCallback?: () => boolean; private mouseConfig?: MouseTrackingConfig; private keydownListener: ((e: KeyboardEvent) => void) | null = null; private keypressListener: ((e: KeyboardEvent) => void) | null = null; private pasteListener: ((e: ClipboardEvent) => void) | null = null; private beforeInputListener: ((e: InputEvent) => void) | null = null; private compositionStartListener: ((e: CompositionEvent) => void) | null = null; private compositionUpdateListener: ((e: CompositionEvent) => void) | null = null; private compositionEndListener: ((e: CompositionEvent) => void) | null = null; private mousedownListener: ((e: MouseEvent) => void) | null = null; private mouseupListener: ((e: MouseEvent) => void) | null = null; private mousemoveListener: ((e: MouseEvent) => void) | null = null; private wheelListener: ((e: WheelEvent) => void) | null = null; private isComposing = false; private isDisposed = false; private mouseButtonsPressed = 0; // Track which buttons are pressed for motion reporting private lastKeyDownData: string | null = null; private lastKeyDownTime = 0; private lastPasteData: string | null = null; private lastPasteTime = 0; private lastPasteSource: 'paste' | 'beforeinput' | null = null; private lastCompositionData: string | null = null; private lastCompositionTime = 0; private lastBeforeInputData: string | null = null; private lastBeforeInputTime = 0; private static readonly BEFORE_INPUT_IGNORE_MS = 100;
/** * 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 * @param onKey - Optional callback for raw key events * @param customKeyEventHandler - Optional custom key event handler * @param getMode - Optional callback to query terminal mode state (for application cursor mode) * @param onCopy - Optional callback to handle copy (Cmd+C/Ctrl+C with selection) * @param inputElement - Optional input element for beforeinput events * @param mouseConfig - Optional mouse tracking configuration */ constructor( ghostty: Ghostty, container: HTMLElement, onData: (data: string) => void, onBell: () => void, onKey?: (keyEvent: IKeyEvent) => void, customKeyEventHandler?: (event: KeyboardEvent) => boolean, getMode?: (mode: number) => boolean, onCopy?: () => boolean, inputElement?: HTMLElement, mouseConfig?: MouseTrackingConfig ) { this.encoder = ghostty.createKeyEncoder(); this.container = container; this.inputElement = inputElement; this.onDataCallback = onData; this.onBellCallback = onBell; this.onKeyCallback = onKey; this.customKeyEventHandler = customKeyEventHandler; this.getModeCallback = getMode; this.onCopyCallback = onCopy; this.mouseConfig = mouseConfig;
// Attach event listeners this.attach(); }
/** * Set custom key event handler (for runtime updates) */ setCustomKeyEventHandler(handler: (event: KeyboardEvent) => boolean): void { this.customKeyEventHandler = handler; }
/** * Attach keyboard event listeners to container */ private attach(): void { // Make container focusable so it can receive keyboard events (browser only) if ( typeof this.container.hasAttribute === 'function' && typeof this.container.setAttribute === 'function' ) { if (!this.container.hasAttribute('tabindex')) { this.container.setAttribute('tabindex', '0'); }
// Add visual focus indication (only if style exists - for browser environments) if (this.container.style) { this.container.style.outline = 'none'; // Remove default outline } }
this.keydownListener = this.handleKeyDown.bind(this); this.container.addEventListener('keydown', this.keydownListener);
this.pasteListener = this.handlePaste.bind(this); this.container.addEventListener('paste', this.pasteListener); if (this.inputElement && this.inputElement !== this.container) { this.inputElement.addEventListener('paste', this.pasteListener); }
if (this.inputElement) { this.beforeInputListener = this.handleBeforeInput.bind(this); this.inputElement.addEventListener('beforeinput', this.beforeInputListener); }
this.compositionStartListener = this.handleCompositionStart.bind(this); this.container.addEventListener('compositionstart', this.compositionStartListener);
this.compositionUpdateListener = this.handleCompositionUpdate.bind(this); this.container.addEventListener('compositionupdate', this.compositionUpdateListener);
this.compositionEndListener = this.handleCompositionEnd.bind(this); this.container.addEventListener('compositionend', this.compositionEndListener);
// Mouse event listeners (for terminal mouse tracking) this.mousedownListener = this.handleMouseDown.bind(this); this.container.addEventListener('mousedown', this.mousedownListener);
this.mouseupListener = this.handleMouseUp.bind(this); this.container.addEventListener('mouseup', this.mouseupListener);
this.mousemoveListener = this.handleMouseMove.bind(this); this.container.addEventListener('mousemove', this.mousemoveListener);
this.wheelListener = this.handleWheel.bind(this); this.container.addEventListener('wheel', this.wheelListener, { passive: false }); }
/** * 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, Alt, or Meta (Cmd on Mac) is pressed, it's not a simple printable character // Exception: AltGr (Ctrl+Alt on some keyboards) can produce printable characters if (event.ctrlKey && !event.altKey) return false; if (event.altKey && !event.ctrlKey) return false; if (event.metaKey) return false; // Cmd key on Mac
// 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;
// Ignore keydown events during composition // Note: Some browsers send keyCode 229 for all keys during composition if (this.isComposing || event.isComposing || event.keyCode === 229) { return; }
// Emit onKey event first (before any processing) if (this.onKeyCallback) { this.onKeyCallback({ key: event.key, domEvent: event }); }
// Check custom key event handler if (this.customKeyEventHandler) { const handled = this.customKeyEventHandler(event); if (handled) { // Custom handler consumed the event event.preventDefault(); return; } }
// Allow Ctrl+V and Cmd+V to trigger paste event (don't preventDefault) if ((event.ctrlKey || event.metaKey) && event.code === 'KeyV') { // Let the browser's native paste event fire return; }
// Handle Cmd+C for copy (on Mac, Cmd+C should copy, not send interrupt) // Note: Ctrl+C on all platforms sends interrupt signal (0x03) if (event.metaKey && event.code === 'KeyC') { // Try to copy selection via callback // If there's a selection and copy succeeds, prevent default // If no selection, let it fall through (browser may have other text selected) if (this.onCopyCallback && this.onCopyCallback()) { event.preventDefault(); } 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); this.recordKeyDownData(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: if (mods === Mods.SHIFT) { simpleOutput = '\x1b[Z'; // Backtab } else { 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 are handled by the encoder (respects application cursor mode) // 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); this.recordKeyDownData(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 { // Sync encoder options with terminal mode state // Mode 1 (DECCKM) controls whether arrow keys send CSI or SS3 sequences if (this.getModeCallback) { const appCursorMode = this.getModeCallback(1); this.encoder.setOption(KeyEncoderOption.CURSOR_KEY_APPLICATION, appCursorMode); }
// 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); this.recordKeyDownData(data); } } catch (error) { // Encoding failed - log but don't crash console.warn('Failed to encode key:', event.code, error); } }
/** * Handle paste event from clipboard * @param event - ClipboardEvent */ private handlePaste(event: ClipboardEvent): void { if (this.isDisposed) return;
// Prevent default paste behavior event.preventDefault(); event.stopPropagation();
// Get clipboard data const clipboardData = event.clipboardData; if (!clipboardData) { console.warn('No clipboard data available'); return; }
// Get text from clipboard const text = clipboardData.getData('text/plain'); if (!text) { console.warn('No text in clipboard'); return; }
if (this.shouldIgnorePasteEvent(text, 'paste')) { return; }
this.emitPasteData(text); this.recordPasteData(text, 'paste'); }
/** * Handle beforeinput event (mobile/IME input) * @param event - InputEvent */ private handleBeforeInput(event: InputEvent): void { if (this.isDisposed) return;
if (this.isComposing || event.isComposing) { return; }
const inputType = event.inputType; const data = event.data ?? ''; let output: string | null = null;
switch (inputType) { case 'insertText': case 'insertReplacementText': output = data.length > 0 ? data.replace(/\n/g, '\r') : null; break; case 'insertLineBreak': case 'insertParagraph': output = '\r'; break; case 'deleteContentBackward': output = '\x7F'; break; case 'deleteContentForward': output = '\x1B[3~'; break; case 'insertFromPaste': if (!data) { return; } if (this.shouldIgnorePasteEvent(data, 'beforeinput')) { event.preventDefault(); event.stopPropagation(); return; } event.preventDefault(); event.stopPropagation(); this.emitPasteData(data); this.recordPasteData(data, 'beforeinput'); return; default: return; }
if (!output) { return; }
if (this.shouldIgnoreBeforeInput(output)) { event.preventDefault(); event.stopPropagation(); return; }
if (data && this.shouldIgnoreBeforeInputFromComposition(data)) { event.preventDefault(); event.stopPropagation(); return; }
event.preventDefault(); event.stopPropagation(); this.onDataCallback(output); if (data) { this.recordBeforeInputData(data); } }
/** * Handle compositionstart event */ private handleCompositionStart(_event: CompositionEvent): void { if (this.isDisposed) return; this.isComposing = true; }
/** * Handle compositionupdate event */ private handleCompositionUpdate(_event: CompositionEvent): void { if (this.isDisposed) return; // We could track the current composition string here if we wanted to // display it in a custom way, but for now we rely on the browser's // input method editor UI. }
/** * Handle compositionend event */ private handleCompositionEnd(event: CompositionEvent): void { if (this.isDisposed) return; this.isComposing = false;
const data = event.data; if (data && data.length > 0) { if (this.shouldIgnoreCompositionEnd(data)) { this.cleanupCompositionTextNodes(); return; } this.onDataCallback(data); this.recordCompositionData(data); }
this.cleanupCompositionTextNodes(); }
/** * Cleanup text nodes in container after composition */ private cleanupCompositionTextNodes(): void { // Cleanup text nodes in container (fix for duplicate text display) // When the container is contenteditable, the browser might insert text nodes // upon composition end. We need to remove them to prevent duplicate display. if (this.container && this.container.childNodes) { for (let i = this.container.childNodes.length - 1; i >= 0; i--) { const node = this.container.childNodes[i]; // Node.TEXT_NODE === 3 if (node.nodeType === 3) { this.container.removeChild(node); } } } }
// ========================================================================== // Mouse Event Handling (for terminal mouse tracking) // ==========================================================================
/** * Convert pixel coordinates to terminal cell coordinates */ private pixelToCell(event: MouseEvent): { col: number; row: number } | null { if (!this.mouseConfig) return null;
const dims = this.mouseConfig.getCellDimensions(); const offset = this.mouseConfig.getCanvasOffset();
if (dims.width <= 0 || dims.height <= 0) return null;
const x = event.clientX - offset.left; const y = event.clientY - offset.top;
// Convert to 1-based cell coordinates (terminal uses 1-based) const col = Math.floor(x / dims.width) + 1; const row = Math.floor(y / dims.height) + 1;
// Clamp to valid range (at least 1) return { col: Math.max(1, col), row: Math.max(1, row), }; }
/** * Get modifier flags for mouse event */ private getMouseModifiers(event: MouseEvent): number { let mods = 0; if (event.shiftKey) mods |= 4; if (event.metaKey) mods |= 8; // Meta (Cmd on Mac) if (event.ctrlKey) mods |= 16; return mods; }
/** * Encode mouse event as SGR sequence * SGR format: \x1b[<Btn;Col;RowM (press/motion) or \x1b[<Btn;Col;Rowm (release) */ private encodeMouseSGR( button: number, col: number, row: number, isRelease: boolean, modifiers: number ): string { const btn = button + modifiers; const suffix = isRelease ? 'm' : 'M'; return `\x1b[<${btn};${col};${row}${suffix}`; }
/** * Encode mouse event as X10/normal sequence (legacy format) * Format: \x1b[M<Btn+32><Col+32><Row+32> */ private encodeMouseX10(button: number, col: number, row: number, modifiers: number): string { // X10 format adds 32 to all values and encodes as characters // Button encoding: 0=left, 1=middle, 2=right, 3=release const btn = button + modifiers + 32; const colChar = String.fromCharCode(Math.min(col + 32, 255)); const rowChar = String.fromCharCode(Math.min(row + 32, 255)); return `\x1b[M${String.fromCharCode(btn)}${colChar}${rowChar}`; }
/** * Send mouse event to terminal */ private sendMouseEvent( button: number, col: number, row: number, isRelease: boolean, event: MouseEvent ): void { const modifiers = this.getMouseModifiers(event);
// Check if SGR extended mode is enabled (mode 1006) const useSGR = this.mouseConfig?.hasSgrMouseMode?.() ?? true;
let sequence: string; if (useSGR) { sequence = this.encodeMouseSGR(button, col, row, isRelease, modifiers); } else { // X10/normal mode doesn't support release events directly // Button 3 means release in X10 mode const x10Button = isRelease ? 3 : button; sequence = this.encodeMouseX10(x10Button, col, row, modifiers); }
this.onDataCallback(sequence); }
/** * Handle mousedown event */ private handleMouseDown(event: MouseEvent): void { if (this.isDisposed) return; if (!this.mouseConfig?.hasMouseTracking()) return;
const cell = this.pixelToCell(event); if (!cell) return;
// Map browser button to terminal button // event.button: 0=left, 1=middle, 2=right // Terminal: 0=left, 1=middle, 2=right const button = event.button;
// Track pressed buttons for motion events this.mouseButtonsPressed |= 1 << button;
this.sendMouseEvent(button, cell.col, cell.row, false, event);
// Don't prevent default - let SelectionManager handle selection // Only prevent if we actually handled the event // event.preventDefault(); }
/** * Handle mouseup event */ private handleMouseUp(event: MouseEvent): void { if (this.isDisposed) return; if (!this.mouseConfig?.hasMouseTracking()) return;
const cell = this.pixelToCell(event); if (!cell) return;
const button = event.button;
// Clear pressed button this.mouseButtonsPressed &= ~(1 << button);
this.sendMouseEvent(button, cell.col, cell.row, true, event); }
/** * Handle mousemove event */ private handleMouseMove(event: MouseEvent): void { if (this.isDisposed) return; if (!this.mouseConfig?.hasMouseTracking()) return;
// Check if button motion mode or any-event tracking is enabled // Mode 1002 = button motion, Mode 1003 = any motion const hasButtonMotion = this.getModeCallback?.(1002) ?? false; const hasAnyMotion = this.getModeCallback?.(1003) ?? false;
if (!hasButtonMotion && !hasAnyMotion) return;
// In button motion mode, only report if a button is pressed if (hasButtonMotion && !hasAnyMotion && this.mouseButtonsPressed === 0) return;
const cell = this.pixelToCell(event); if (!cell) return;
// Determine which button to report (or 32 for motion with no button) let button = 32; // Motion flag if (this.mouseButtonsPressed & 1) button += 0; // Left else if (this.mouseButtonsPressed & 2) button += 1; // Middle else if (this.mouseButtonsPressed & 4) button += 2; // Right
this.sendMouseEvent(button, cell.col, cell.row, false, event); }
/** * Handle wheel event (scroll) */ private handleWheel(event: WheelEvent): void { if (this.isDisposed) return; if (!this.mouseConfig?.hasMouseTracking()) return;
const cell = this.pixelToCell(event); if (!cell) return;
// Wheel events: button 64 = scroll up, button 65 = scroll down const button = event.deltaY < 0 ? 64 : 65;
this.sendMouseEvent(button, cell.col, cell.row, false, event);
// Prevent default scrolling when mouse tracking is active event.preventDefault(); }
/** * Emit paste data with bracketed paste support */ private emitPasteData(text: string): void { const hasBracketedPaste = this.getModeCallback?.(2004) ?? false;
if (hasBracketedPaste) { this.onDataCallback('\x1b[200~' + text + '\x1b[201~'); } else { this.onDataCallback(text); } }
/** * Record keydown data for beforeinput de-duplication */ private recordKeyDownData(data: string): void { this.lastKeyDownData = data; this.lastKeyDownTime = this.getNow(); }
/** * Record paste data for beforeinput de-duplication */ private recordPasteData(data: string, source: 'paste' | 'beforeinput'): void { this.lastPasteData = data; this.lastPasteTime = this.getNow(); this.lastPasteSource = source; }
/** * Check if beforeinput should be ignored due to a recent keydown */ private shouldIgnoreBeforeInput(data: string): boolean { if (!this.lastKeyDownData) { return false; } const now = this.getNow(); const isDuplicate = now - this.lastKeyDownTime < InputHandler.BEFORE_INPUT_IGNORE_MS && this.lastKeyDownData === data; this.lastKeyDownData = null; return isDuplicate; }
/** * Check if beforeinput text should be ignored due to a recent composition end */ private shouldIgnoreBeforeInputFromComposition(data: string): boolean { if (!this.lastCompositionData) { return false; } const now = this.getNow(); const isDuplicate = now - this.lastCompositionTime < InputHandler.BEFORE_INPUT_IGNORE_MS && this.lastCompositionData === data; if (isDuplicate) { this.lastCompositionData = null; } return isDuplicate; }
/** * Check if composition end should be ignored due to a recent beforeinput text */ private shouldIgnoreCompositionEnd(data: string): boolean { if (!this.lastBeforeInputData) { return false; } const now = this.getNow(); const isDuplicate = now - this.lastBeforeInputTime < InputHandler.BEFORE_INPUT_IGNORE_MS && this.lastBeforeInputData === data; if (isDuplicate) { this.lastBeforeInputData = null; } return isDuplicate; }
/** * Record beforeinput text for composition de-duplication */ private recordBeforeInputData(data: string): void { this.lastBeforeInputData = data; this.lastBeforeInputTime = this.getNow(); }
/** * Record composition end data for beforeinput de-duplication */ private recordCompositionData(data: string): void { this.lastCompositionData = data; this.lastCompositionTime = this.getNow(); }
/** * Check if paste should be ignored due to a recent paste event from another source */ private shouldIgnorePasteEvent(data: string, source: 'paste' | 'beforeinput'): boolean { if (!this.lastPasteData) { return false; } if (this.lastPasteSource === source) { return false; } const now = this.getNow(); const isDuplicate = now - this.lastPasteTime < InputHandler.BEFORE_INPUT_IGNORE_MS && this.lastPasteData === data; if (isDuplicate) { this.lastPasteData = null; this.lastPasteSource = null; } return isDuplicate; }
/** * Get current time in milliseconds */ private getNow(): number { return typeof performance !== 'undefined' && typeof performance.now === 'function' ? performance.now() : Date.now(); }
/** * 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; }
if (this.pasteListener) { this.container.removeEventListener('paste', this.pasteListener); if (this.inputElement && this.inputElement !== this.container) { this.inputElement.removeEventListener('paste', this.pasteListener); } this.pasteListener = null; }
if (this.beforeInputListener && this.inputElement) { this.inputElement.removeEventListener('beforeinput', this.beforeInputListener); this.beforeInputListener = null; }
if (this.compositionStartListener) { this.container.removeEventListener('compositionstart', this.compositionStartListener); this.compositionStartListener = null; }
if (this.compositionUpdateListener) { this.container.removeEventListener('compositionupdate', this.compositionUpdateListener); this.compositionUpdateListener = null; }
if (this.compositionEndListener) { this.container.removeEventListener('compositionend', this.compositionEndListener); this.compositionEndListener = null; }
if (this.mousedownListener) { this.container.removeEventListener('mousedown', this.mousedownListener); this.mousedownListener = null; }
if (this.mouseupListener) { this.container.removeEventListener('mouseup', this.mouseupListener); this.mouseupListener = null; }
if (this.mousemoveListener) { this.container.removeEventListener('mousemove', this.mousemoveListener); this.mousemoveListener = null; }
if (this.wheelListener) { this.container.removeEventListener('wheel', this.wheelListener); this.wheelListener = null; }
this.isDisposed = true; }
/** * Check if handler is disposed */ isActive(): boolean { return !this.isDisposed; }}