Skip to content

Commit 1680deb

Browse files
authored
chore: implement xtermjs API (#54)
1 parent 5e035a2 commit 1680deb

11 files changed

Lines changed: 1127 additions & 114 deletions

demo/colors-demo.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ <h2>Color Demonstrations</h2>
163163
// Initialization
164164
// =========================================================================
165165

166-
async function init() {
166+
function init() {
167167
try {
168168
// Create terminal with dark theme
169169
term = new Terminal({
@@ -201,7 +201,7 @@ <h2>Color Demonstrations</h2>
201201

202202
// Open terminal
203203
const container = document.getElementById('terminal-container');
204-
await term.open(container);
204+
term.open(container);
205205
fitAddon.fit();
206206

207207
// Handle resize

demo/index.html

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@
138138
let ws;
139139
let fitAddon;
140140

141-
async function initTerminal() {
141+
function initTerminal() {
142142
term = new Terminal({
143143
cursorBlink: true,
144144
fontSize: 14,
@@ -153,16 +153,21 @@
153153
fitAddon = new FitAddon();
154154
term.loadAddon(fitAddon);
155155

156-
await term.open(document.getElementById('terminal-container'));
156+
term.open(document.getElementById('terminal-container'));
157157
fitAddon.fit();
158158

159159
// Handle window resize
160160
window.addEventListener('resize', () => {
161161
fitAddon.fit();
162162
});
163163

164-
// Connect to PTY server
165-
connectWebSocket();
164+
// Handle terminal resize - MUST be registered before terminal becomes ready!
165+
term.onResize((size) => {
166+
if (ws && ws.readyState === WebSocket.OPEN) {
167+
// Send resize as control sequence (server expects this format)
168+
ws.send(JSON.stringify({ type: 'resize', cols: size.cols, rows: size.rows }));
169+
}
170+
});
166171

167172
// Handle user input
168173
term.onData((data) => {
@@ -176,6 +181,14 @@
176181
term.onScroll((ydisp) => {
177182
console.log('Scroll position:', ydisp);
178183
});
184+
185+
// Connect to PTY server AFTER terminal is ready
186+
// This ensures term.cols/rows have been updated by FitAddon
187+
// since the PTY server doesn't support dynamic resize
188+
term.onReady(() => {
189+
console.log('[Demo] Terminal ready, connecting with size:', term.cols, 'x', term.rows);
190+
connectWebSocket();
191+
});
179192
}
180193

181194
function connectWebSocket() {
@@ -210,14 +223,6 @@
210223
}
211224
}, 3000);
212225
};
213-
214-
// Handle terminal resize
215-
term.onResize((size) => {
216-
if (ws && ws.readyState === WebSocket.OPEN) {
217-
// Send resize as control sequence (server expects this format)
218-
ws.send(JSON.stringify({ type: 'resize', cols: size.cols, rows: size.rows }));
219-
}
220-
});
221226
}
222227

223228
function updateConnectionStatus(connected) {

demo/scrollbar-test.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ <h2>Scrollbar Test</h2>
4747
const fitAddon = new FitAddon();
4848
term.loadAddon(fitAddon);
4949

50-
await term.open(document.getElementById('terminal'));
50+
term.open(document.getElementById('terminal'));
5151
fitAddon.fit();
5252

5353
// Write lots of lines to create scrollback

lib/addons/fit.test.ts

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,3 +161,167 @@ describe('FitAddon', () => {
161161
expect(resizeCallCount).toBe(0); // Still 0 because no element
162162
});
163163
});
164+
165+
// ==========================================================================
166+
// onReady Auto-Retry Tests
167+
// ==========================================================================
168+
169+
describe('onReady Auto-Retry', () => {
170+
let addon: FitAddon;
171+
172+
beforeEach(() => {
173+
addon = new FitAddon();
174+
});
175+
176+
afterEach(() => {
177+
addon.dispose();
178+
});
179+
180+
test('subscribes to onReady during activation', () => {
181+
let subscribed = false;
182+
183+
const mockTerminal = {
184+
cols: 80,
185+
rows: 24,
186+
onReady: (listener: () => void) => {
187+
subscribed = true;
188+
return { dispose: () => {} };
189+
},
190+
};
191+
192+
addon.activate(mockTerminal as any);
193+
expect(subscribed).toBe(true);
194+
});
195+
196+
test('calls fit() when onReady fires', () => {
197+
let readyCallback: (() => void) | null = null;
198+
let fitCallCount = 0;
199+
200+
// Create a mock element with computed dimensions
201+
const mockElement = document.createElement('div');
202+
Object.defineProperty(mockElement, 'clientWidth', { value: 800, configurable: true });
203+
Object.defineProperty(mockElement, 'clientHeight', { value: 400, configurable: true });
204+
205+
const mockTerminal = {
206+
cols: 80,
207+
rows: 24,
208+
element: mockElement,
209+
renderer: {
210+
getMetrics: () => ({ width: 9, height: 16, baseline: 12 }),
211+
},
212+
resize: (cols: number, rows: number) => {
213+
fitCallCount++;
214+
mockTerminal.cols = cols;
215+
mockTerminal.rows = rows;
216+
},
217+
onReady: (listener: () => void) => {
218+
readyCallback = listener;
219+
return { dispose: () => {} };
220+
},
221+
};
222+
223+
addon.activate(mockTerminal as any);
224+
225+
// Before ready, fit() may not resize (depending on implementation)
226+
const initialFitCount = fitCallCount;
227+
228+
// Simulate terminal becoming ready
229+
if (readyCallback) {
230+
readyCallback();
231+
}
232+
233+
// fit() should have been called via onReady handler
234+
expect(fitCallCount).toBeGreaterThan(initialFitCount);
235+
});
236+
237+
test('disposes onReady subscription on dispose()', () => {
238+
let disposed = false;
239+
240+
const mockTerminal = {
241+
cols: 80,
242+
rows: 24,
243+
onReady: (listener: () => void) => {
244+
return {
245+
dispose: () => {
246+
disposed = true;
247+
},
248+
};
249+
},
250+
};
251+
252+
addon.activate(mockTerminal as any);
253+
expect(disposed).toBe(false);
254+
255+
addon.dispose();
256+
expect(disposed).toBe(true);
257+
});
258+
259+
test('handles terminal without onReady gracefully', () => {
260+
const terminalWithoutReady = {
261+
cols: 80,
262+
rows: 24,
263+
resize: () => {},
264+
};
265+
266+
expect(() => addon.activate(terminalWithoutReady as any)).not.toThrow();
267+
expect(() => addon.fit()).not.toThrow();
268+
expect(() => addon.dispose()).not.toThrow();
269+
});
270+
271+
test('fit() calculates correct dimensions from container', () => {
272+
// Create a mock element with known dimensions
273+
// FitAddon subtracts 15px for scrollbar, so we need to account for that
274+
const mockElement = document.createElement('div');
275+
Object.defineProperty(mockElement, 'clientWidth', { value: 900, configurable: true });
276+
Object.defineProperty(mockElement, 'clientHeight', { value: 480, configurable: true });
277+
278+
let resizedCols = 0;
279+
let resizedRows = 0;
280+
281+
const mockTerminal = {
282+
cols: 80,
283+
rows: 24,
284+
element: mockElement,
285+
renderer: {
286+
// 9px wide chars, 16px tall
287+
getMetrics: () => ({ width: 9, height: 16, baseline: 12 }),
288+
},
289+
resize: (cols: number, rows: number) => {
290+
resizedCols = cols;
291+
resizedRows = rows;
292+
mockTerminal.cols = cols;
293+
mockTerminal.rows = rows;
294+
},
295+
};
296+
297+
addon.activate(mockTerminal as any);
298+
addon.fit();
299+
300+
// Expected: (900 - 15 scrollbar) / 9 = 98 cols, 480 / 16 = 30 rows
301+
expect(resizedCols).toBe(98);
302+
expect(resizedRows).toBe(30);
303+
});
304+
305+
test('proposeDimensions returns correct values', () => {
306+
// FitAddon subtracts 15px for scrollbar width
307+
const mockElement = document.createElement('div');
308+
Object.defineProperty(mockElement, 'clientWidth', { value: 720, configurable: true });
309+
Object.defineProperty(mockElement, 'clientHeight', { value: 384, configurable: true });
310+
311+
const mockTerminal = {
312+
cols: 80,
313+
rows: 24,
314+
element: mockElement,
315+
renderer: {
316+
getMetrics: () => ({ width: 8, height: 16, baseline: 12 }),
317+
},
318+
resize: () => {},
319+
};
320+
321+
addon.activate(mockTerminal as any);
322+
const dims = addon.proposeDimensions();
323+
324+
// Expected: (720 - 15 scrollbar) / 8 = 88 cols, 384 / 16 = 24 rows
325+
expect(dims).toEqual({ cols: 88, rows: 24 });
326+
});
327+
});

lib/addons/fit.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,25 @@ export class FitAddon implements ITerminalAddon {
4444
private _lastCols?: number;
4545
private _lastRows?: number;
4646
private _isResizing: boolean = false;
47+
private _pendingFit: boolean = false;
48+
private _readyDisposable?: { dispose: () => void };
4749

4850
/**
4951
* Activate the addon (called by Terminal.loadAddon)
5052
*/
5153
public activate(terminal: ITerminalCore): void {
5254
this._terminal = terminal;
55+
56+
// Subscribe to onReady event if available (xterm.js compatibility)
57+
const terminalWithEvents = terminal as any;
58+
if (terminalWithEvents.onReady && typeof terminalWithEvents.onReady === 'function') {
59+
this._readyDisposable = terminalWithEvents.onReady(() => {
60+
// Terminal is ready - always call fit when ready
61+
// This handles the case where fit() was called before terminal was ready
62+
this._pendingFit = false;
63+
this.fit();
64+
});
65+
}
5366
}
5467

5568
/**
@@ -68,6 +81,12 @@ export class FitAddon implements ITerminalAddon {
6881
this._resizeDebounceTimer = undefined;
6982
}
7083

84+
// Dispose onReady subscription
85+
if (this._readyDisposable) {
86+
this._readyDisposable.dispose();
87+
this._readyDisposable = undefined;
88+
}
89+
7190
// Clear stored dimensions
7291
this._lastCols = undefined;
7392
this._lastRows = undefined;
@@ -89,9 +108,18 @@ export class FitAddon implements ITerminalAddon {
89108

90109
const dims = this.proposeDimensions();
91110
if (!dims || !this._terminal) {
111+
// Check if terminal exists but renderer isn't ready yet
112+
const terminal = this._terminal as any;
113+
if (this._terminal && terminal.element && !terminal.renderer) {
114+
// Mark fit as pending - will be called from onReady handler
115+
this._pendingFit = true;
116+
}
92117
return;
93118
}
94119

120+
// Clear pending flag if we get here
121+
this._pendingFit = false;
122+
95123
// Access terminal to check current dimensions
96124
const terminal = this._terminal as any;
97125
const currentCols = terminal.cols;

lib/buffer.test.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@
55
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
66
import { Terminal } from './terminal';
77

8+
/**
9+
* Helper to open terminal and wait for WASM to be ready.
10+
*/
11+
async function openAndWaitForReady(term: Terminal, container: HTMLElement): Promise<void> {
12+
term.open(container);
13+
await new Promise<void>((resolve) => term.onReady(resolve));
14+
}
15+
816
describe('Buffer API', () => {
917
let term: Terminal | null = null;
1018
let container: HTMLElement | null = null;
@@ -15,7 +23,7 @@ describe('Buffer API', () => {
1523
container = document.createElement('div');
1624
document.body.appendChild(container);
1725
term = new Terminal({ cols: 80, rows: 24 });
18-
await term.open(container);
26+
await openAndWaitForReady(term, container);
1927
}
2028
});
2129

lib/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export type {
1717
IEvent,
1818
IBufferRange,
1919
IKeyEvent,
20+
IUnicodeVersionProvider,
2021
} from './interfaces';
2122

2223
// Ghostty WASM components (for advanced usage)

lib/interfaces.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,13 @@ export interface IKeyEvent {
8383
domEvent: KeyboardEvent;
8484
}
8585

86+
/**
87+
* Unicode version provider (xterm.js compatibility)
88+
*/
89+
export interface IUnicodeVersionProvider {
90+
readonly activeVersion: string;
91+
}
92+
8693
// ============================================================================
8794
// Buffer API Interfaces (xterm.js compatibility)
8895
// ============================================================================

0 commit comments

Comments
 (0)