Something went wrong. Try again.
Ghostty for the web with xterm.js API compatibility
Something went wrong. Try again.
22 kB · 844 lines
TypeScript
at smoothscroll
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845/** * Unit tests for InputHandler */
import { beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test';import { Ghostty } from './ghostty';import { InputHandler } from './input-handler';import { Key, KeyAction, Mods } from './types';
// Mock DOM types for testinginterface MockKeyboardEvent { code: string; key: string; ctrlKey: boolean; altKey: boolean; shiftKey: boolean; metaKey: boolean; repeat: boolean; preventDefault: () => void; stopPropagation: () => void;}
interface MockClipboardEvent { type: string; clipboardData: { getData: (format: string) => string; setData: (format: string, data: string) => void; } | null; preventDefault: () => void; stopPropagation: () => void;}
interface MockHTMLElement { addEventListener: (event: string, handler: (e: any) => void) => void; removeEventListener: (event: string, handler: (e: any) => void) => void;}
// Helper to create mock keyboard eventfunction createKeyEvent( code: string, key: string, modifiers: { ctrl?: boolean; alt?: boolean; shift?: boolean; meta?: boolean } = {}): MockKeyboardEvent { return { code, key, ctrlKey: modifiers.ctrl ?? false, altKey: modifiers.alt ?? false, shiftKey: modifiers.shift ?? false, metaKey: modifiers.meta ?? false, repeat: false, preventDefault: mock(() => {}), stopPropagation: mock(() => {}), };}
// Helper to create mock clipboard eventfunction createClipboardEvent(text: string | null): MockClipboardEvent { const data = new Map<string, string>(); if (text !== null) { data.set('text/plain', text); }
return { type: 'paste', clipboardData: text !== null ? { getData: (format: string) => data.get(format) || '', setData: (format: string, value: string) => { data.set(format, value); }, } : null, preventDefault: mock(() => {}), stopPropagation: mock(() => {}), };}
// Helper to create mock containerfunction createMockContainer(): MockHTMLElement & { _listeners: Map<string, ((e: any) => void)[]>; dispatchEvent: (event: any) => void;} { const listeners = new Map<string, ((e: any) => void)[]>();
return { _listeners: listeners, addEventListener(event: string, handler: (e: any) => void) { if (!listeners.has(event)) { listeners.set(event, []); } listeners.get(event)!.push(handler); }, removeEventListener(event: string, handler: (e: any) => void) { const handlers = listeners.get(event); if (handlers) { const index = handlers.indexOf(handler); if (index >= 0) { handlers.splice(index, 1); } } }, dispatchEvent(event: any) { const handlers = listeners.get(event.type) || []; for (const handler of handlers) { handler(event); } }, };}
// Helper to simulate key eventfunction simulateKey( container: ReturnType<typeof createMockContainer>, event: MockKeyboardEvent): void { const handlers = container._listeners.get('keydown') || []; for (const handler of handlers) { handler(event); }}
describe('InputHandler', () => { let ghostty: Ghostty; let container: ReturnType<typeof createMockContainer>; let dataReceived: string[]; let bellCalled: boolean;
beforeAll(async () => { // Load WASM once for all tests (expensive operation) // wasmPath is now optional - auto-detected ghostty = await Ghostty.load(); });
beforeEach(() => { // Create mock container for each test container = createMockContainer();
// Reset data tracking dataReceived = []; bellCalled = false; });
describe('Constructor and Lifecycle', () => { test('creates handler and attaches listeners', () => { const handler = new InputHandler( ghostty, container as any, (data) => dataReceived.push(data), () => { bellCalled = true; } );
expect(handler.isActive()).toBe(true); expect(container._listeners.has('keydown')).toBe(true); expect(container._listeners.get('keydown')!.length).toBe(1); });
test('dispose removes listeners and marks inactive', () => { const handler = new InputHandler( ghostty, container as any, (data) => dataReceived.push(data), () => { bellCalled = true; } );
handler.dispose();
expect(handler.isActive()).toBe(false); expect(container._listeners.get('keydown')!.length).toBe(0); });
test('dispose is idempotent', () => { const handler = new InputHandler( ghostty, container as any, (data) => dataReceived.push(data), () => { bellCalled = true; } );
handler.dispose(); handler.dispose(); // Second call should not throw
expect(handler.isActive()).toBe(false); }); });
describe('Printable Characters', () => { test('encodes lowercase letters', () => { const handler = new InputHandler( ghostty, container as any, (data) => dataReceived.push(data), () => { bellCalled = true; } );
simulateKey(container, createKeyEvent('KeyA', 'a')); expect(dataReceived).toEqual(['a']);
simulateKey(container, createKeyEvent('KeyZ', 'z')); expect(dataReceived).toEqual(['a', 'z']); });
test('encodes uppercase letters (with shift)', () => { const handler = new InputHandler( ghostty, container as any, (data) => dataReceived.push(data), () => { bellCalled = true; } );
simulateKey(container, createKeyEvent('KeyA', 'A', { shift: true })); expect(dataReceived).toEqual(['A']); });
test('encodes digits', () => { const handler = new InputHandler( ghostty, container as any, (data) => dataReceived.push(data), () => { bellCalled = true; } );
simulateKey(container, createKeyEvent('Digit0', '0')); simulateKey(container, createKeyEvent('Digit5', '5')); simulateKey(container, createKeyEvent('Digit9', '9'));
expect(dataReceived).toEqual(['0', '5', '9']); });
test('encodes punctuation', () => { const handler = new InputHandler( ghostty, container as any, (data) => dataReceived.push(data), () => { bellCalled = true; } );
simulateKey(container, createKeyEvent('Comma', ',')); simulateKey(container, createKeyEvent('Period', '.')); simulateKey(container, createKeyEvent('Slash', '/'));
expect(dataReceived).toEqual([',', '.', '/']); });
test('encodes space', () => { const handler = new InputHandler( ghostty, container as any, (data) => dataReceived.push(data), () => { bellCalled = true; } );
simulateKey(container, createKeyEvent('Space', ' ')); expect(dataReceived).toEqual([' ']); }); });
describe('Control Characters', () => { test('encodes Ctrl+A', () => { const handler = new InputHandler( ghostty, container as any, (data) => dataReceived.push(data), () => { bellCalled = true; } );
simulateKey(container, createKeyEvent('KeyA', 'a', { ctrl: true }));
expect(dataReceived.length).toBe(1); // Ctrl+A should produce 0x01 expect(dataReceived[0].charCodeAt(0)).toBe(0x01); });
test('encodes Ctrl+C', () => { const handler = new InputHandler( ghostty, container as any, (data) => dataReceived.push(data), () => { bellCalled = true; } );
simulateKey(container, createKeyEvent('KeyC', 'c', { ctrl: true }));
expect(dataReceived.length).toBe(1); // Ctrl+C should produce 0x03 expect(dataReceived[0].charCodeAt(0)).toBe(0x03); });
test('encodes Ctrl+D', () => { const handler = new InputHandler( ghostty, container as any, (data) => dataReceived.push(data), () => { bellCalled = true; } );
simulateKey(container, createKeyEvent('KeyD', 'd', { ctrl: true }));
expect(dataReceived.length).toBe(1); // Ctrl+D should produce 0x04 expect(dataReceived[0].charCodeAt(0)).toBe(0x04); });
test('encodes Ctrl+Z', () => { const handler = new InputHandler( ghostty, container as any, (data) => dataReceived.push(data), () => { bellCalled = true; } );
simulateKey(container, createKeyEvent('KeyZ', 'z', { ctrl: true }));
expect(dataReceived.length).toBe(1); // Ctrl+Z should produce 0x1A (26) expect(dataReceived[0].charCodeAt(0)).toBe(0x1a); });
test('Cmd+C allows copy (no data sent)', () => { const handler = new InputHandler( ghostty, container as any, (data) => dataReceived.push(data), () => { bellCalled = true; } );
simulateKey(container, createKeyEvent('KeyC', 'c', { meta: true }));
// Cmd+C should NOT send data - it should allow copy operation // SelectionManager handles the actual copying expect(dataReceived.length).toBe(0); }); });
describe('Special Keys', () => { test('encodes Enter', () => { const handler = new InputHandler( ghostty, container as any, (data) => dataReceived.push(data), () => { bellCalled = true; } );
simulateKey(container, createKeyEvent('Enter', 'Enter'));
expect(dataReceived.length).toBe(1); // Enter should produce \r (0x0D) expect(dataReceived[0]).toBe('\r'); });
test('encodes Tab', () => { const handler = new InputHandler( ghostty, container as any, (data) => dataReceived.push(data), () => { bellCalled = true; } );
simulateKey(container, createKeyEvent('Tab', 'Tab'));
expect(dataReceived.length).toBe(1); // Tab should produce \t (0x09) expect(dataReceived[0]).toBe('\t'); });
test('encodes Escape', () => { const handler = new InputHandler( ghostty, container as any, (data) => dataReceived.push(data), () => { bellCalled = true; } );
simulateKey(container, createKeyEvent('Escape', 'Escape'));
expect(dataReceived.length).toBe(1); // Escape should produce ESC (0x1B) expect(dataReceived[0].charCodeAt(0)).toBe(0x1b); });
test('encodes Backspace', () => { const handler = new InputHandler( ghostty, container as any, (data) => dataReceived.push(data), () => { bellCalled = true; } );
simulateKey(container, createKeyEvent('Backspace', 'Backspace'));
expect(dataReceived.length).toBe(1); // Backspace should produce 0x7F (DEL) or 0x08 (BS) const code = dataReceived[0].charCodeAt(0); expect(code === 0x7f || code === 0x08).toBe(true); }); });
describe('Arrow Keys', () => { test('encodes Up arrow', () => { const handler = new InputHandler( ghostty, container as any, (data) => dataReceived.push(data), () => { bellCalled = true; } );
simulateKey(container, createKeyEvent('ArrowUp', 'ArrowUp'));
expect(dataReceived.length).toBe(1); // Arrow keys produce ESC[A, ESC[B, ESC[C, ESC[D or ESCOA, ESCOB, ESCOC, ESCOD expect(dataReceived[0]).toMatch(/\x1b(\[A|OA)/); });
test('encodes Down arrow', () => { const handler = new InputHandler( ghostty, container as any, (data) => dataReceived.push(data), () => { bellCalled = true; } );
simulateKey(container, createKeyEvent('ArrowDown', 'ArrowDown'));
expect(dataReceived.length).toBe(1); expect(dataReceived[0]).toMatch(/\x1b(\[B|OB)/); });
test('encodes Left arrow', () => { const handler = new InputHandler( ghostty, container as any, (data) => dataReceived.push(data), () => { bellCalled = true; } );
simulateKey(container, createKeyEvent('ArrowLeft', 'ArrowLeft'));
expect(dataReceived.length).toBe(1); expect(dataReceived[0]).toMatch(/\x1b(\[D|OD)/); });
test('encodes Right arrow', () => { const handler = new InputHandler( ghostty, container as any, (data) => dataReceived.push(data), () => { bellCalled = true; } );
simulateKey(container, createKeyEvent('ArrowRight', 'ArrowRight'));
expect(dataReceived.length).toBe(1); expect(dataReceived[0]).toMatch(/\x1b(\[C|OC)/); }); });
describe('Function Keys', () => { test('encodes F1', () => { const handler = new InputHandler( ghostty, container as any, (data) => dataReceived.push(data), () => { bellCalled = true; } );
simulateKey(container, createKeyEvent('F1', 'F1'));
expect(dataReceived.length).toBe(1); // F1 produces ESC[11~ or ESCOP expect(dataReceived[0]).toMatch(/\x1b(\[11~|OP)/); });
test('encodes F12', () => { const handler = new InputHandler( ghostty, container as any, (data) => dataReceived.push(data), () => { bellCalled = true; } );
simulateKey(container, createKeyEvent('F12', 'F12'));
expect(dataReceived.length).toBe(1); // F12 produces ESC[24~ expect(dataReceived[0].includes('\x1b')).toBe(true); }); });
describe('Navigation Keys', () => { test('encodes Home', () => { const handler = new InputHandler( ghostty, container as any, (data) => dataReceived.push(data), () => { bellCalled = true; } );
simulateKey(container, createKeyEvent('Home', 'Home'));
expect(dataReceived.length).toBe(1); expect(dataReceived[0].includes('\x1b')).toBe(true); });
test('encodes End', () => { const handler = new InputHandler( ghostty, container as any, (data) => dataReceived.push(data), () => { bellCalled = true; } );
simulateKey(container, createKeyEvent('End', 'End'));
expect(dataReceived.length).toBe(1); expect(dataReceived[0].includes('\x1b')).toBe(true); });
test('encodes PageUp', () => { const handler = new InputHandler( ghostty, container as any, (data) => dataReceived.push(data), () => { bellCalled = true; } );
simulateKey(container, createKeyEvent('PageUp', 'PageUp'));
expect(dataReceived.length).toBe(1); expect(dataReceived[0].includes('\x1b')).toBe(true); });
test('encodes PageDown', () => { const handler = new InputHandler( ghostty, container as any, (data) => dataReceived.push(data), () => { bellCalled = true; } );
simulateKey(container, createKeyEvent('PageDown', 'PageDown'));
expect(dataReceived.length).toBe(1); expect(dataReceived[0].includes('\x1b')).toBe(true); });
test('encodes Delete', () => { const handler = new InputHandler( ghostty, container as any, (data) => dataReceived.push(data), () => { bellCalled = true; } );
simulateKey(container, createKeyEvent('Delete', 'Delete'));
expect(dataReceived.length).toBe(1); expect(dataReceived[0].includes('\x1b')).toBe(true); });
test('encodes Insert', () => { const handler = new InputHandler( ghostty, container as any, (data) => dataReceived.push(data), () => { bellCalled = true; } );
simulateKey(container, createKeyEvent('Insert', 'Insert'));
expect(dataReceived.length).toBe(1); expect(dataReceived[0].includes('\x1b')).toBe(true); }); });
describe('Event Prevention', () => { test('prevents default on printable characters', () => { const handler = new InputHandler( ghostty, container as any, (data) => dataReceived.push(data), () => { bellCalled = true; } );
const event = createKeyEvent('KeyA', 'a'); simulateKey(container, event);
expect(event.preventDefault).toHaveBeenCalled(); });
test('prevents default on special keys', () => { const handler = new InputHandler( ghostty, container as any, (data) => dataReceived.push(data), () => { bellCalled = true; } );
const event = createKeyEvent('Enter', 'Enter'); simulateKey(container, event);
expect(event.preventDefault).toHaveBeenCalled(); });
test('prevents default on Ctrl+keys', () => { const handler = new InputHandler( ghostty, container as any, (data) => dataReceived.push(data), () => { bellCalled = true; } );
const event = createKeyEvent('KeyC', 'c', { ctrl: true }); simulateKey(container, event);
expect(event.preventDefault).toHaveBeenCalled(); }); });
describe('Unknown Keys', () => { test('ignores unmapped keys', () => { const handler = new InputHandler( ghostty, container as any, (data) => dataReceived.push(data), () => { bellCalled = true; } );
// Simulate a key that's not in KEY_MAP simulateKey(container, createKeyEvent('Unknown', 'Unknown'));
// Should not crash or produce output expect(dataReceived.length).toBe(0); }); });
describe('Modifier Combinations', () => { test('handles Ctrl+Shift combinations', () => { const handler = new InputHandler( ghostty, container as any, (data) => dataReceived.push(data), () => { bellCalled = true; } );
simulateKey(container, createKeyEvent('KeyA', 'A', { ctrl: true, shift: true }));
expect(dataReceived.length).toBe(1); // Should still encode something expect(dataReceived[0].length).toBeGreaterThan(0); });
test('handles Alt combinations', () => { const handler = new InputHandler( ghostty, container as any, (data) => dataReceived.push(data), () => { bellCalled = true; } );
simulateKey(container, createKeyEvent('KeyA', 'a', { alt: true }));
expect(dataReceived.length).toBe(1); // Alt+A often produces ESC a or similar expect(dataReceived[0].length).toBeGreaterThan(0); }); });
describe('Clipboard Operations', () => { test('handles paste event', () => { const handler = new InputHandler( ghostty, container as any, (data) => dataReceived.push(data), () => { bellCalled = true; } );
const pasteText = 'Hello, World!'; const pasteEvent = createClipboardEvent(pasteText);
container.dispatchEvent(pasteEvent);
expect(dataReceived.length).toBe(1); expect(dataReceived[0]).toBe(pasteText); });
test('handles multi-line paste', () => { const handler = new InputHandler( ghostty, container as any, (data) => dataReceived.push(data), () => { bellCalled = true; } );
const pasteText = 'Line 1\nLine 2\nLine 3'; const pasteEvent = createClipboardEvent(pasteText);
container.dispatchEvent(pasteEvent);
expect(dataReceived.length).toBe(1); expect(dataReceived[0]).toBe(pasteText); });
test('ignores paste with no clipboard data', () => { const handler = new InputHandler( ghostty, container as any, (data) => dataReceived.push(data), () => { bellCalled = true; } );
const pasteEvent = createClipboardEvent(null);
container.dispatchEvent(pasteEvent);
expect(dataReceived.length).toBe(0); });
test('ignores paste with empty text', () => { const handler = new InputHandler( ghostty, container as any, (data) => dataReceived.push(data), () => { bellCalled = true; } );
const pasteEvent = createClipboardEvent('');
container.dispatchEvent(pasteEvent);
expect(dataReceived.length).toBe(0); });
test('allows Ctrl+V to trigger paste', () => { const handler = new InputHandler( ghostty, container as any, (data) => dataReceived.push(data), () => { bellCalled = true; } );
// Ctrl+V should NOT call onData callback (lets paste event handle it) simulateKey(container, createKeyEvent('KeyV', 'v', { ctrl: true }));
expect(dataReceived.length).toBe(0); });
test('allows Cmd+V to trigger paste', () => { const handler = new InputHandler( ghostty, container as any, (data) => dataReceived.push(data), () => { bellCalled = true; } );
// Cmd+V should NOT call onData callback (lets paste event handle it) simulateKey(container, createKeyEvent('KeyV', 'v', { meta: true }));
expect(dataReceived.length).toBe(0); }); });});