@aurigax-ai/ghostty-web 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/GHOSTTY_COMMIT +1 -0
- package/LICENSE +22 -0
- package/README.md +90 -2
- package/ghostty-vt.wasm +0 -0
- package/lib/abi.ts +227 -0
- package/lib/addons/fit.ts +242 -0
- package/lib/buffer.ts +402 -0
- package/lib/event-emitter.ts +27 -0
- package/lib/ghostty.ts +1135 -0
- package/lib/index.ts +110 -0
- package/lib/input-handler.ts +1104 -0
- package/lib/interfaces.ts +218 -0
- package/lib/link-detector.ts +215 -0
- package/lib/marker.ts +55 -0
- package/lib/providers/osc8-link-provider.ts +144 -0
- package/lib/providers/url-regex-provider.ts +150 -0
- package/lib/renderer.ts +958 -0
- package/lib/selection-manager.ts +1052 -0
- package/lib/terminal.ts +2026 -0
- package/lib/types.ts +648 -0
- package/package.json +70 -4
package/lib/terminal.ts
ADDED
|
@@ -0,0 +1,2026 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal - Main terminal emulator class
|
|
3
|
+
*
|
|
4
|
+
* Provides an xterm.js-compatible API wrapping Ghostty's WASM terminal emulator.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* ```typescript
|
|
8
|
+
* import { init, Terminal } from 'ghostty-web';
|
|
9
|
+
*
|
|
10
|
+
* await init();
|
|
11
|
+
* const term = new Terminal();
|
|
12
|
+
* term.open(document.getElementById('container'));
|
|
13
|
+
* term.write('Hello, World!\n');
|
|
14
|
+
* term.onData(data => console.log('User typed:', data));
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { BufferNamespace } from './buffer';
|
|
19
|
+
import { EventEmitter } from './event-emitter';
|
|
20
|
+
import {
|
|
21
|
+
type DesktopNotificationEvent,
|
|
22
|
+
DirtyState,
|
|
23
|
+
type Ghostty,
|
|
24
|
+
type GhosttyCell,
|
|
25
|
+
type GhosttyTerminal,
|
|
26
|
+
type GhosttyTerminalConfig,
|
|
27
|
+
type MouseTrackingMode,
|
|
28
|
+
type SemanticPromptEvent,
|
|
29
|
+
} from './ghostty';
|
|
30
|
+
import { getGhostty } from './index';
|
|
31
|
+
import { InputHandler, type MouseTrackingConfig } from './input-handler';
|
|
32
|
+
import type {
|
|
33
|
+
IBufferNamespace,
|
|
34
|
+
IBufferRange,
|
|
35
|
+
IDisposable,
|
|
36
|
+
IEvent,
|
|
37
|
+
IKeyEvent,
|
|
38
|
+
ITerminalAddon,
|
|
39
|
+
ITerminalCore,
|
|
40
|
+
ITerminalOptions,
|
|
41
|
+
IUnicodeVersionProvider,
|
|
42
|
+
} from './interfaces';
|
|
43
|
+
import { LinkDetector } from './link-detector';
|
|
44
|
+
import { type IMarker, Marker } from './marker';
|
|
45
|
+
import { OSC8LinkProvider } from './providers/osc8-link-provider';
|
|
46
|
+
import { UrlRegexProvider } from './providers/url-regex-provider';
|
|
47
|
+
import { CanvasRenderer } from './renderer';
|
|
48
|
+
import { SelectionManager } from './selection-manager';
|
|
49
|
+
import type { ILink, ILinkProvider } from './types';
|
|
50
|
+
|
|
51
|
+
// ============================================================================
|
|
52
|
+
// Terminal Class
|
|
53
|
+
// ============================================================================
|
|
54
|
+
|
|
55
|
+
export class Terminal implements ITerminalCore {
|
|
56
|
+
// Public properties (xterm.js compatibility)
|
|
57
|
+
public cols: number;
|
|
58
|
+
public rows: number;
|
|
59
|
+
public element?: HTMLElement;
|
|
60
|
+
public textarea?: HTMLTextAreaElement;
|
|
61
|
+
|
|
62
|
+
// Buffer API (xterm.js compatibility)
|
|
63
|
+
public readonly buffer: IBufferNamespace;
|
|
64
|
+
|
|
65
|
+
// Unicode API (xterm.js compatibility)
|
|
66
|
+
public readonly unicode: IUnicodeVersionProvider = {
|
|
67
|
+
get activeVersion(): string {
|
|
68
|
+
return '15.1'; // Ghostty supports Unicode 15.1
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Options (public for xterm.js compatibility)
|
|
73
|
+
public readonly options!: Required<ITerminalOptions>;
|
|
74
|
+
|
|
75
|
+
// Components (created on open())
|
|
76
|
+
private ghostty?: Ghostty;
|
|
77
|
+
public wasmTerm?: GhosttyTerminal; // Made public for link providers
|
|
78
|
+
public renderer?: CanvasRenderer; // Made public for FitAddon
|
|
79
|
+
private inputHandler?: InputHandler;
|
|
80
|
+
private selectionManager?: SelectionManager;
|
|
81
|
+
private canvas?: HTMLCanvasElement;
|
|
82
|
+
|
|
83
|
+
// Link detection system
|
|
84
|
+
private linkDetector?: LinkDetector;
|
|
85
|
+
private currentHoveredLink?: ILink;
|
|
86
|
+
private mouseMoveThrottleTimeout?: number;
|
|
87
|
+
private pendingMouseMove?: MouseEvent;
|
|
88
|
+
|
|
89
|
+
// Event emitters
|
|
90
|
+
private dataEmitter = new EventEmitter<string>();
|
|
91
|
+
private resizeEmitter = new EventEmitter<{ cols: number; rows: number }>();
|
|
92
|
+
private bellEmitter = new EventEmitter<void>();
|
|
93
|
+
private selectionChangeEmitter = new EventEmitter<void>();
|
|
94
|
+
private keyEmitter = new EventEmitter<IKeyEvent>();
|
|
95
|
+
private titleChangeEmitter = new EventEmitter<string>();
|
|
96
|
+
private scrollEmitter = new EventEmitter<number>();
|
|
97
|
+
private renderEmitter = new EventEmitter<{ start: number; end: number }>();
|
|
98
|
+
private cursorMoveEmitter = new EventEmitter<void>();
|
|
99
|
+
// Public event accessors (xterm.js compatibility)
|
|
100
|
+
public readonly onData: IEvent<string> = this.dataEmitter.event;
|
|
101
|
+
public readonly onResize: IEvent<{ cols: number; rows: number }> = this.resizeEmitter.event;
|
|
102
|
+
public readonly onBell: IEvent<void> = this.bellEmitter.event;
|
|
103
|
+
public readonly onSelectionChange: IEvent<void> = this.selectionChangeEmitter.event;
|
|
104
|
+
public readonly onKey: IEvent<IKeyEvent> = this.keyEmitter.event;
|
|
105
|
+
public readonly onTitleChange: IEvent<string> = this.titleChangeEmitter.event;
|
|
106
|
+
public readonly onScroll: IEvent<number> = this.scrollEmitter.event;
|
|
107
|
+
public readonly onRender: IEvent<{ start: number; end: number }> = this.renderEmitter.event;
|
|
108
|
+
public readonly onCursorMove: IEvent<void> = this.cursorMoveEmitter.event;
|
|
109
|
+
|
|
110
|
+
private pwdEmitter = new EventEmitter<string>();
|
|
111
|
+
private semanticPromptEmitter = new EventEmitter<SemanticPromptEvent>();
|
|
112
|
+
private desktopNotificationEmitter = new EventEmitter<DesktopNotificationEvent>();
|
|
113
|
+
private unknownOscEmitter = new EventEmitter<string>();
|
|
114
|
+
|
|
115
|
+
/** OSC 7 working directory as the shell sent it (raw file:// URI with its host). */
|
|
116
|
+
public readonly onPwdChange: IEvent<string> = this.pwdEmitter.event;
|
|
117
|
+
/** OSC 133 prompt marks; buffer.active cursor reads inside the handler are exact. */
|
|
118
|
+
public readonly onSemanticPrompt: IEvent<SemanticPromptEvent> = this.semanticPromptEmitter.event;
|
|
119
|
+
/** OSC 9 / OSC 777 desktop notifications. */
|
|
120
|
+
public readonly onDesktopNotification: IEvent<DesktopNotificationEvent> =
|
|
121
|
+
this.desktopNotificationEmitter.event;
|
|
122
|
+
/** OSCs libghostty-vt does not implement, as their content (e.g. `633;E;ls`). */
|
|
123
|
+
public readonly onUnknownOsc: IEvent<string> = this.unknownOscEmitter.event;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Whether replies to the program's queries (DSR, DA, mode and color reports)
|
|
127
|
+
* are sent back as data. A host that runs the shell behind a multiplexer which
|
|
128
|
+
* answers them itself turns this off.
|
|
129
|
+
*/
|
|
130
|
+
public answerQueries = true;
|
|
131
|
+
|
|
132
|
+
private engineSubscriptions: IDisposable[] = [];
|
|
133
|
+
private clipboardHandler: ((text: string) => boolean) | null = null;
|
|
134
|
+
private markers = new Set<Marker>();
|
|
135
|
+
private lastAlternate = false;
|
|
136
|
+
private idleFrames = 0;
|
|
137
|
+
private static readonly IDLE_FRAMES_BEFORE_SLEEP = 30;
|
|
138
|
+
|
|
139
|
+
// Lifecycle state
|
|
140
|
+
private isOpen = false;
|
|
141
|
+
private isDisposed = false;
|
|
142
|
+
private animationFrameId?: number;
|
|
143
|
+
private writeQueue: Uint8Array[] = [];
|
|
144
|
+
private awaitingEcho = false;
|
|
145
|
+
|
|
146
|
+
// Addons
|
|
147
|
+
private addons: ITerminalAddon[] = [];
|
|
148
|
+
|
|
149
|
+
// Phase 1: Custom event handlers
|
|
150
|
+
private customKeyEventHandler?: (event: KeyboardEvent) => boolean;
|
|
151
|
+
|
|
152
|
+
// Phase 1: Title tracking
|
|
153
|
+
private currentTitle: string = '';
|
|
154
|
+
|
|
155
|
+
// Phase 2: Viewport and scrolling state
|
|
156
|
+
public viewportY: number = 0; // Top line of viewport in scrollback buffer (0 = at bottom, can be fractional during smooth scroll)
|
|
157
|
+
private targetViewportY: number = 0; // Target viewport position for smooth scrolling
|
|
158
|
+
private scrollAnimationStartTime?: number;
|
|
159
|
+
private scrollAnimationFrame?: number;
|
|
160
|
+
private customWheelEventHandler?: (event: WheelEvent) => boolean;
|
|
161
|
+
private lastCursorY: number = 0; // Track cursor position for onCursorMove
|
|
162
|
+
|
|
163
|
+
// Scrollbar interaction state
|
|
164
|
+
private isDraggingScrollbar: boolean = false;
|
|
165
|
+
private scrollbarDragStart: number | null = null;
|
|
166
|
+
private scrollbarDragStartViewportY: number = 0;
|
|
167
|
+
|
|
168
|
+
// Scrollbar visibility/auto-hide state
|
|
169
|
+
private scrollbarVisible: boolean = false;
|
|
170
|
+
private scrollbarOpacity: number = 0;
|
|
171
|
+
private scrollbarHideTimeout?: number;
|
|
172
|
+
private readonly SCROLLBAR_HIDE_DELAY_MS = 1500; // Hide after 1.5 seconds
|
|
173
|
+
private readonly SCROLLBAR_FADE_DURATION_MS = 200; // 200ms fade animation
|
|
174
|
+
|
|
175
|
+
constructor(options: ITerminalOptions = {}) {
|
|
176
|
+
// Use provided Ghostty instance (for test isolation) or get module-level instance
|
|
177
|
+
this.ghostty = options.ghostty ?? getGhostty();
|
|
178
|
+
|
|
179
|
+
// Create base options object with all defaults (excluding ghostty)
|
|
180
|
+
const baseOptions = {
|
|
181
|
+
cols: options.cols ?? 80,
|
|
182
|
+
rows: options.rows ?? 24,
|
|
183
|
+
cursorBlink: options.cursorBlink ?? false,
|
|
184
|
+
cursorStyle: options.cursorStyle ?? 'block',
|
|
185
|
+
theme: options.theme ?? {},
|
|
186
|
+
scrollback: options.scrollback ?? 10000,
|
|
187
|
+
fontSize: options.fontSize ?? 15,
|
|
188
|
+
fontFamily: options.fontFamily ?? 'monospace',
|
|
189
|
+
allowTransparency: options.allowTransparency ?? false,
|
|
190
|
+
convertEol: options.convertEol ?? false,
|
|
191
|
+
disableStdin: options.disableStdin ?? false,
|
|
192
|
+
smoothScrollDuration: options.smoothScrollDuration ?? 100, // Default: 100ms smooth scroll
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// Wrap in Proxy to intercept runtime changes (xterm.js compatibility)
|
|
196
|
+
(this.options as any) = new Proxy(baseOptions, {
|
|
197
|
+
set: (target: any, prop: string, value: any) => {
|
|
198
|
+
const oldValue = target[prop];
|
|
199
|
+
target[prop] = value;
|
|
200
|
+
|
|
201
|
+
// Apply runtime changes if terminal is open
|
|
202
|
+
if (this.isOpen) {
|
|
203
|
+
this.handleOptionChange(prop, value, oldValue);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return true;
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
this.cols = this.options.cols;
|
|
211
|
+
this.rows = this.options.rows;
|
|
212
|
+
|
|
213
|
+
// Initialize buffer API
|
|
214
|
+
this.buffer = new BufferNamespace(this);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ==========================================================================
|
|
218
|
+
// Option Change Handling (for mutable options)
|
|
219
|
+
// ==========================================================================
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Handle runtime option changes (called when options are modified after terminal is open)
|
|
223
|
+
* This enables xterm.js compatibility where options can be changed at runtime
|
|
224
|
+
*/
|
|
225
|
+
private handleOptionChange(key: string, newValue: any, oldValue: any): void {
|
|
226
|
+
this.wake();
|
|
227
|
+
if (newValue === oldValue) return;
|
|
228
|
+
|
|
229
|
+
switch (key) {
|
|
230
|
+
case 'disableStdin':
|
|
231
|
+
// Input handler already checks this.options.disableStdin dynamically
|
|
232
|
+
// No action needed
|
|
233
|
+
break;
|
|
234
|
+
|
|
235
|
+
case 'cursorBlink':
|
|
236
|
+
case 'cursorStyle':
|
|
237
|
+
if (this.renderer) {
|
|
238
|
+
this.renderer.setCursorStyle(this.options.cursorStyle);
|
|
239
|
+
this.renderer.setCursorBlink(this.options.cursorBlink);
|
|
240
|
+
}
|
|
241
|
+
break;
|
|
242
|
+
|
|
243
|
+
case 'theme':
|
|
244
|
+
if (this.renderer) {
|
|
245
|
+
console.warn('ghostty-web: theme changes after open() are not yet fully supported');
|
|
246
|
+
}
|
|
247
|
+
break;
|
|
248
|
+
|
|
249
|
+
case 'fontSize':
|
|
250
|
+
if (this.renderer) {
|
|
251
|
+
this.renderer.setFontSize(this.options.fontSize);
|
|
252
|
+
this.handleFontChange();
|
|
253
|
+
}
|
|
254
|
+
break;
|
|
255
|
+
|
|
256
|
+
case 'fontFamily':
|
|
257
|
+
if (this.renderer) {
|
|
258
|
+
this.renderer.setFontFamily(this.options.fontFamily);
|
|
259
|
+
this.handleFontChange();
|
|
260
|
+
}
|
|
261
|
+
break;
|
|
262
|
+
|
|
263
|
+
case 'cols':
|
|
264
|
+
case 'rows':
|
|
265
|
+
// Redirect to resize method
|
|
266
|
+
this.resize(this.options.cols, this.options.rows);
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Handle font changes (fontSize or fontFamily)
|
|
273
|
+
* Updates canvas size to match new font metrics and forces a full re-render
|
|
274
|
+
*/
|
|
275
|
+
private handleFontChange(): void {
|
|
276
|
+
if (!this.renderer || !this.wasmTerm || !this.canvas) return;
|
|
277
|
+
|
|
278
|
+
// Clear any active selection since pixel positions have changed
|
|
279
|
+
if (this.selectionManager) {
|
|
280
|
+
this.selectionManager.clearSelection();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Resize canvas to match new font metrics
|
|
284
|
+
this.renderer.resize(this.cols, this.rows);
|
|
285
|
+
|
|
286
|
+
// Update canvas element dimensions to match renderer
|
|
287
|
+
const metrics = this.renderer.getMetrics();
|
|
288
|
+
this.canvas.width = metrics.width * this.cols;
|
|
289
|
+
this.canvas.height = metrics.height * this.rows;
|
|
290
|
+
this.canvas.style.width = `${metrics.width * this.cols}px`;
|
|
291
|
+
this.canvas.style.height = `${metrics.height * this.rows}px`;
|
|
292
|
+
|
|
293
|
+
// Force full re-render with new font
|
|
294
|
+
this.renderer.render(this.wasmTerm, true, this.viewportY, this);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Parse a CSS color string to 0xRRGGBB format.
|
|
299
|
+
* Returns 0 if the color is undefined or invalid.
|
|
300
|
+
*/
|
|
301
|
+
private parseColorToHex(color?: string): number {
|
|
302
|
+
if (!color) return 0;
|
|
303
|
+
|
|
304
|
+
// Handle hex colors (#RGB, #RRGGBB)
|
|
305
|
+
if (color.startsWith('#')) {
|
|
306
|
+
let hex = color.slice(1);
|
|
307
|
+
if (hex.length === 3) {
|
|
308
|
+
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
|
|
309
|
+
}
|
|
310
|
+
const value = Number.parseInt(hex, 16);
|
|
311
|
+
return Number.isNaN(value) ? 0 : value;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Handle rgb(r, g, b) format
|
|
315
|
+
const match = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
|
316
|
+
if (match) {
|
|
317
|
+
const r = Number.parseInt(match[1], 10);
|
|
318
|
+
const g = Number.parseInt(match[2], 10);
|
|
319
|
+
const b = Number.parseInt(match[3], 10);
|
|
320
|
+
return (r << 16) | (g << 8) | b;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return 0;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Convert terminal options to WASM terminal config.
|
|
328
|
+
*/
|
|
329
|
+
private buildWasmConfig(): GhosttyTerminalConfig | undefined {
|
|
330
|
+
const theme = this.options.theme;
|
|
331
|
+
const scrollback = this.options.scrollback;
|
|
332
|
+
|
|
333
|
+
// If no theme and default scrollback, use defaults
|
|
334
|
+
if (!theme && scrollback === 10000) {
|
|
335
|
+
return undefined;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Build palette array from theme colors
|
|
339
|
+
// Order: black, red, green, yellow, blue, magenta, cyan, white,
|
|
340
|
+
// brightBlack, brightRed, brightGreen, brightYellow, brightBlue, brightMagenta, brightCyan, brightWhite
|
|
341
|
+
const palette: number[] = [
|
|
342
|
+
this.parseColorToHex(theme?.black),
|
|
343
|
+
this.parseColorToHex(theme?.red),
|
|
344
|
+
this.parseColorToHex(theme?.green),
|
|
345
|
+
this.parseColorToHex(theme?.yellow),
|
|
346
|
+
this.parseColorToHex(theme?.blue),
|
|
347
|
+
this.parseColorToHex(theme?.magenta),
|
|
348
|
+
this.parseColorToHex(theme?.cyan),
|
|
349
|
+
this.parseColorToHex(theme?.white),
|
|
350
|
+
this.parseColorToHex(theme?.brightBlack),
|
|
351
|
+
this.parseColorToHex(theme?.brightRed),
|
|
352
|
+
this.parseColorToHex(theme?.brightGreen),
|
|
353
|
+
this.parseColorToHex(theme?.brightYellow),
|
|
354
|
+
this.parseColorToHex(theme?.brightBlue),
|
|
355
|
+
this.parseColorToHex(theme?.brightMagenta),
|
|
356
|
+
this.parseColorToHex(theme?.brightCyan),
|
|
357
|
+
this.parseColorToHex(theme?.brightWhite),
|
|
358
|
+
];
|
|
359
|
+
|
|
360
|
+
return {
|
|
361
|
+
scrollbackLimit: scrollback,
|
|
362
|
+
fgColor: this.parseColorToHex(theme?.foreground),
|
|
363
|
+
bgColor: this.parseColorToHex(theme?.background),
|
|
364
|
+
cursorColor: this.parseColorToHex(theme?.cursor),
|
|
365
|
+
palette,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ==========================================================================
|
|
370
|
+
// Lifecycle Methods
|
|
371
|
+
// ==========================================================================
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Open terminal in a parent element
|
|
375
|
+
*
|
|
376
|
+
* Initializes all components and starts rendering.
|
|
377
|
+
* Requires a pre-loaded Ghostty instance passed to the constructor.
|
|
378
|
+
*/
|
|
379
|
+
open(parent: HTMLElement): void {
|
|
380
|
+
if (this.isOpen) {
|
|
381
|
+
throw new Error('Terminal is already open');
|
|
382
|
+
}
|
|
383
|
+
if (this.isDisposed) {
|
|
384
|
+
throw new Error('Terminal has been disposed');
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Store parent element
|
|
388
|
+
this.element = parent;
|
|
389
|
+
this.isOpen = true;
|
|
390
|
+
|
|
391
|
+
try {
|
|
392
|
+
// Make parent focusable if it isn't already
|
|
393
|
+
if (!parent.hasAttribute('tabindex')) {
|
|
394
|
+
parent.setAttribute('tabindex', '0');
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Mark as contenteditable so browser extensions (Vimium, etc.) recognize
|
|
398
|
+
// this as an input element and don't intercept keyboard events.
|
|
399
|
+
parent.setAttribute('contenteditable', 'true');
|
|
400
|
+
// Prevent actual content editing - we handle input ourselves
|
|
401
|
+
parent.addEventListener('beforeinput', (e) => {
|
|
402
|
+
if (e.target === parent) {
|
|
403
|
+
e.preventDefault();
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// Add accessibility attributes for screen readers and extensions
|
|
408
|
+
parent.setAttribute('role', 'textbox');
|
|
409
|
+
parent.setAttribute('aria-label', 'Terminal input');
|
|
410
|
+
parent.setAttribute('aria-multiline', 'true');
|
|
411
|
+
|
|
412
|
+
// Create WASM terminal with current dimensions and config
|
|
413
|
+
const config = this.buildWasmConfig();
|
|
414
|
+
this.wasmTerm = this.ghostty!.createTerminal(this.cols, this.rows, config);
|
|
415
|
+
this.wireEngine();
|
|
416
|
+
|
|
417
|
+
// Create canvas element
|
|
418
|
+
this.canvas = document.createElement('canvas');
|
|
419
|
+
this.canvas.style.display = 'block';
|
|
420
|
+
this.canvas.style.cursor = 'text';
|
|
421
|
+
|
|
422
|
+
parent.appendChild(this.canvas);
|
|
423
|
+
|
|
424
|
+
// Create hidden textarea for keyboard input (must be inside parent for event bubbling)
|
|
425
|
+
this.textarea = document.createElement('textarea');
|
|
426
|
+
this.textarea.setAttribute('autocorrect', 'off');
|
|
427
|
+
this.textarea.setAttribute('autocapitalize', 'off');
|
|
428
|
+
this.textarea.setAttribute('spellcheck', 'false');
|
|
429
|
+
this.textarea.setAttribute('tabindex', '0'); // Allow focus for mobile keyboard
|
|
430
|
+
this.textarea.setAttribute('aria-label', 'Terminal input');
|
|
431
|
+
// Use clip-path to completely hide the textarea and its caret
|
|
432
|
+
this.textarea.style.position = 'absolute';
|
|
433
|
+
this.textarea.style.left = '0';
|
|
434
|
+
this.textarea.style.top = '0';
|
|
435
|
+
this.textarea.style.width = '1px';
|
|
436
|
+
this.textarea.style.height = '1px';
|
|
437
|
+
this.textarea.style.padding = '0';
|
|
438
|
+
this.textarea.style.border = 'none';
|
|
439
|
+
this.textarea.style.margin = '0';
|
|
440
|
+
this.textarea.style.opacity = '0';
|
|
441
|
+
this.textarea.style.clipPath = 'inset(50%)'; // Clip everything including caret
|
|
442
|
+
this.textarea.style.overflow = 'hidden';
|
|
443
|
+
this.textarea.style.whiteSpace = 'nowrap';
|
|
444
|
+
this.textarea.style.resize = 'none';
|
|
445
|
+
parent.appendChild(this.textarea);
|
|
446
|
+
|
|
447
|
+
// Focus textarea on interaction - preventDefault before focus
|
|
448
|
+
const textarea = this.textarea;
|
|
449
|
+
// Desktop: mousedown
|
|
450
|
+
this.canvas.addEventListener('mousedown', (ev) => {
|
|
451
|
+
ev.preventDefault();
|
|
452
|
+
textarea.focus();
|
|
453
|
+
});
|
|
454
|
+
// Mobile: touchend with preventDefault to suppress iOS caret
|
|
455
|
+
this.canvas.addEventListener('touchend', (ev) => {
|
|
456
|
+
ev.preventDefault();
|
|
457
|
+
textarea.focus();
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
// Create renderer
|
|
461
|
+
this.renderer = new CanvasRenderer(this.canvas, {
|
|
462
|
+
fontSize: this.options.fontSize,
|
|
463
|
+
fontFamily: this.options.fontFamily,
|
|
464
|
+
cursorStyle: this.options.cursorStyle,
|
|
465
|
+
cursorBlink: this.options.cursorBlink,
|
|
466
|
+
theme: this.options.theme,
|
|
467
|
+
});
|
|
468
|
+
this.renderer.onNeedsFrame = () => this.wake();
|
|
469
|
+
|
|
470
|
+
// Size canvas to terminal dimensions (use renderer.resize for proper DPI scaling)
|
|
471
|
+
this.renderer.resize(this.cols, this.rows);
|
|
472
|
+
|
|
473
|
+
// Create mouse tracking configuration
|
|
474
|
+
const canvas = this.canvas;
|
|
475
|
+
const renderer = this.renderer;
|
|
476
|
+
const wasmTerm = this.wasmTerm;
|
|
477
|
+
const mouseConfig: MouseTrackingConfig = {
|
|
478
|
+
hasMouseTracking: () => wasmTerm?.hasMouseTracking() ?? false,
|
|
479
|
+
hasSgrMouseMode: () => wasmTerm?.getMode(1006, false) ?? true, // SGR extended mode
|
|
480
|
+
getCellDimensions: () => ({
|
|
481
|
+
width: renderer.charWidth,
|
|
482
|
+
height: renderer.charHeight,
|
|
483
|
+
}),
|
|
484
|
+
getCanvasOffset: () => {
|
|
485
|
+
const rect = canvas.getBoundingClientRect();
|
|
486
|
+
return { left: rect.left, top: rect.top };
|
|
487
|
+
},
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
// Create input handler
|
|
491
|
+
this.inputHandler = new InputHandler(
|
|
492
|
+
this.ghostty!,
|
|
493
|
+
parent,
|
|
494
|
+
(data: string) => {
|
|
495
|
+
// Check if stdin is disabled
|
|
496
|
+
if (this.options.disableStdin) {
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
// Clear selection when user types
|
|
500
|
+
this.selectionManager?.clearSelection();
|
|
501
|
+
this.awaitingEcho = true;
|
|
502
|
+
// Input handler fires data events
|
|
503
|
+
this.dataEmitter.fire(data);
|
|
504
|
+
},
|
|
505
|
+
() => {
|
|
506
|
+
// Input handler can also fire bell
|
|
507
|
+
this.bellEmitter.fire();
|
|
508
|
+
},
|
|
509
|
+
(keyEvent: IKeyEvent) => {
|
|
510
|
+
// Forward key events
|
|
511
|
+
this.keyEmitter.fire(keyEvent);
|
|
512
|
+
},
|
|
513
|
+
this.customKeyEventHandler,
|
|
514
|
+
(mode: number) => {
|
|
515
|
+
// Query terminal mode state (e.g., mode 1 for application cursor mode)
|
|
516
|
+
return this.wasmTerm?.getMode(mode, false) ?? false;
|
|
517
|
+
},
|
|
518
|
+
() => {
|
|
519
|
+
// Handle Cmd+C copy - returns true if there was a selection to copy
|
|
520
|
+
return this.copySelection();
|
|
521
|
+
},
|
|
522
|
+
this.textarea,
|
|
523
|
+
mouseConfig
|
|
524
|
+
);
|
|
525
|
+
|
|
526
|
+
// Create selection manager (pass textarea for context menu positioning)
|
|
527
|
+
this.selectionManager = new SelectionManager(
|
|
528
|
+
this,
|
|
529
|
+
this.renderer,
|
|
530
|
+
this.wasmTerm,
|
|
531
|
+
this.textarea
|
|
532
|
+
);
|
|
533
|
+
|
|
534
|
+
// Connect selection manager to renderer
|
|
535
|
+
this.renderer.setSelectionManager(this.selectionManager);
|
|
536
|
+
|
|
537
|
+
// Forward selection change events
|
|
538
|
+
this.selectionManager.onSelectionChange(() => {
|
|
539
|
+
this.selectionChangeEmitter.fire();
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
// Initialize link detection system
|
|
543
|
+
this.linkDetector = new LinkDetector(this);
|
|
544
|
+
|
|
545
|
+
// Register link providers
|
|
546
|
+
// OSC8 first (explicit hyperlinks take precedence)
|
|
547
|
+
this.linkDetector.registerProvider(new OSC8LinkProvider(this));
|
|
548
|
+
// URL regex second (fallback for plain text URLs)
|
|
549
|
+
this.linkDetector.registerProvider(new UrlRegexProvider(this));
|
|
550
|
+
|
|
551
|
+
// Setup mouse event handling for links and scrollbar
|
|
552
|
+
// Use capture phase to intercept scrollbar clicks before SelectionManager
|
|
553
|
+
parent.addEventListener('mousedown', this.handleMouseDown, { capture: true });
|
|
554
|
+
parent.addEventListener('mousemove', this.handleMouseMove);
|
|
555
|
+
parent.addEventListener('mouseleave', this.handleMouseLeave);
|
|
556
|
+
parent.addEventListener('click', this.handleClick);
|
|
557
|
+
|
|
558
|
+
// Setup document-level mouseup for scrollbar drag (so drag works even outside canvas)
|
|
559
|
+
document.addEventListener('mouseup', this.handleMouseUp);
|
|
560
|
+
|
|
561
|
+
// Setup wheel event handling for scrolling (Phase 2)
|
|
562
|
+
// Use capture phase to ensure we get the event before browser scrolling
|
|
563
|
+
parent.addEventListener('wheel', this.handleWheel, { passive: false, capture: true });
|
|
564
|
+
|
|
565
|
+
// Render initial blank screen (force full redraw)
|
|
566
|
+
this.renderer.render(this.wasmTerm, true, this.viewportY, this, this.scrollbarOpacity);
|
|
567
|
+
|
|
568
|
+
// Start render loop
|
|
569
|
+
this.startRenderLoop();
|
|
570
|
+
|
|
571
|
+
// Focus input (auto-focus so user can start typing immediately)
|
|
572
|
+
this.focus();
|
|
573
|
+
} catch (error) {
|
|
574
|
+
// Clean up on error
|
|
575
|
+
this.isOpen = false;
|
|
576
|
+
this.cleanupComponents();
|
|
577
|
+
throw new Error(`Failed to open terminal: ${error}`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Write data to terminal
|
|
583
|
+
*/
|
|
584
|
+
write(data: string | Uint8Array, callback?: () => void): void {
|
|
585
|
+
this.assertOpen();
|
|
586
|
+
|
|
587
|
+
// Handle convertEol option
|
|
588
|
+
if (this.options.convertEol && typeof data === 'string') {
|
|
589
|
+
data = data.replace(/\n/g, '\r\n');
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
this.writeInternal(data, callback);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Internal write implementation (extracted from write())
|
|
597
|
+
*/
|
|
598
|
+
private writeInternal(data: string | Uint8Array, callback?: () => void): void {
|
|
599
|
+
// Note: We intentionally do NOT clear selection on write - most modern terminals
|
|
600
|
+
// preserve selection when new data arrives. Selection is cleared by user actions
|
|
601
|
+
// like clicking or typing, not by incoming data.
|
|
602
|
+
|
|
603
|
+
// Write directly to WASM terminal (handles VT parsing internally)
|
|
604
|
+
this.wasmTerm!.write(data);
|
|
605
|
+
|
|
606
|
+
// Process any responses generated by the terminal (e.g., DSR cursor position)
|
|
607
|
+
// These need to be sent back to the PTY via onData
|
|
608
|
+
this.processTerminalResponses();
|
|
609
|
+
|
|
610
|
+
// Invalidate link cache (content changed)
|
|
611
|
+
this.linkDetector?.invalidateCache();
|
|
612
|
+
|
|
613
|
+
// Phase 2: Auto-scroll to bottom on new output (xterm.js behavior)
|
|
614
|
+
if (this.viewportY !== 0) {
|
|
615
|
+
this.scrollToBottom();
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
this.afterWrite();
|
|
619
|
+
|
|
620
|
+
// Call callback if provided
|
|
621
|
+
if (callback) {
|
|
622
|
+
// Queue callback after next render
|
|
623
|
+
requestAnimationFrame(callback);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (this.awaitingEcho) {
|
|
627
|
+
this.awaitingEcho = false;
|
|
628
|
+
if (this.renderer && this.wasmTerm) {
|
|
629
|
+
this.renderer.render(this.wasmTerm, false, this.viewportY, this, this.scrollbarOpacity);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Render will happen on next animation frame
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Write data with newline
|
|
638
|
+
*/
|
|
639
|
+
writeln(data: string | Uint8Array, callback?: () => void): void {
|
|
640
|
+
if (typeof data === 'string') {
|
|
641
|
+
this.write(data + '\r\n', callback);
|
|
642
|
+
} else {
|
|
643
|
+
// Append \r\n to Uint8Array
|
|
644
|
+
const newData = new Uint8Array(data.length + 2);
|
|
645
|
+
newData.set(data);
|
|
646
|
+
newData[data.length] = 0x0d; // \r
|
|
647
|
+
newData[data.length + 1] = 0x0a; // \n
|
|
648
|
+
this.write(newData, callback);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Paste text into terminal (triggers bracketed paste if supported)
|
|
654
|
+
*/
|
|
655
|
+
paste(data: string): void {
|
|
656
|
+
this.assertOpen();
|
|
657
|
+
|
|
658
|
+
// Don't paste if stdin is disabled
|
|
659
|
+
if (this.options.disableStdin) {
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
this.awaitingEcho = true;
|
|
664
|
+
|
|
665
|
+
// Check if terminal has bracketed paste mode enabled
|
|
666
|
+
if (this.wasmTerm!.hasBracketedPaste()) {
|
|
667
|
+
// Wrap with bracketed paste sequences (DEC mode 2004)
|
|
668
|
+
this.dataEmitter.fire('\x1b[200~' + data + '\x1b[201~');
|
|
669
|
+
} else {
|
|
670
|
+
// Send data directly
|
|
671
|
+
this.dataEmitter.fire(data);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Input data into terminal (as if typed by user)
|
|
677
|
+
*
|
|
678
|
+
* @param data - Data to input
|
|
679
|
+
* @param wasUserInput - If true, triggers onData event (default: false for compat with some apps)
|
|
680
|
+
*/
|
|
681
|
+
input(data: string, wasUserInput: boolean = false): void {
|
|
682
|
+
this.assertOpen();
|
|
683
|
+
|
|
684
|
+
// Don't input if stdin is disabled
|
|
685
|
+
if (this.options.disableStdin) {
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
if (wasUserInput) {
|
|
690
|
+
this.awaitingEcho = true;
|
|
691
|
+
// Trigger onData event as if user typed it
|
|
692
|
+
this.dataEmitter.fire(data);
|
|
693
|
+
} else {
|
|
694
|
+
// Just write to terminal without triggering onData
|
|
695
|
+
this.write(data);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Resize terminal
|
|
701
|
+
*/
|
|
702
|
+
resize(cols: number, rows: number): void {
|
|
703
|
+
this.assertOpen();
|
|
704
|
+
|
|
705
|
+
if (cols === this.cols && rows === this.rows) {
|
|
706
|
+
return; // No change
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Cancel render loop before resize to prevent accessing detached TypedArray
|
|
710
|
+
// views while WASM reallocates buffers. We restart it after resize completes.
|
|
711
|
+
// This avoids the background-tab regression of using an isResizing flag
|
|
712
|
+
// cleared via requestAnimationFrame (rAF is throttled/paused in background tabs).
|
|
713
|
+
this.cancelRenderLoop();
|
|
714
|
+
|
|
715
|
+
try {
|
|
716
|
+
// Update dimensions
|
|
717
|
+
this.cols = cols;
|
|
718
|
+
this.rows = rows;
|
|
719
|
+
|
|
720
|
+
// Resize WASM terminal (may reallocate buffers, invalidating TypedArray views)
|
|
721
|
+
this.wasmTerm!.resize(cols, rows);
|
|
722
|
+
|
|
723
|
+
// Resize renderer
|
|
724
|
+
this.renderer!.resize(cols, rows);
|
|
725
|
+
|
|
726
|
+
// Update canvas dimensions
|
|
727
|
+
const metrics = this.renderer!.getMetrics();
|
|
728
|
+
this.canvas!.width = metrics.width * cols;
|
|
729
|
+
this.canvas!.height = metrics.height * rows;
|
|
730
|
+
this.canvas!.style.width = `${metrics.width * cols}px`;
|
|
731
|
+
this.canvas!.style.height = `${metrics.height * rows}px`;
|
|
732
|
+
|
|
733
|
+
// Fire resize event
|
|
734
|
+
this.resizeEmitter.fire({ cols, rows });
|
|
735
|
+
|
|
736
|
+
// Force full render
|
|
737
|
+
this.renderer!.render(this.wasmTerm!, true, this.viewportY, this);
|
|
738
|
+
} catch (e) {
|
|
739
|
+
console.error('Terminal resize failed:', e);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// Flush any writes that were queued during resize, then restart render loop
|
|
743
|
+
this.flushWriteQueue();
|
|
744
|
+
this.wake();
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Clear terminal screen
|
|
749
|
+
*/
|
|
750
|
+
clear(): void {
|
|
751
|
+
this.assertOpen();
|
|
752
|
+
// Send ANSI clear screen and cursor home sequences
|
|
753
|
+
this.wasmTerm!.write('\x1b[2J\x1b[H');
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Reset terminal state
|
|
758
|
+
*/
|
|
759
|
+
reset(): void {
|
|
760
|
+
this.assertOpen();
|
|
761
|
+
|
|
762
|
+
// Free old WASM terminal and create new one
|
|
763
|
+
if (this.wasmTerm) {
|
|
764
|
+
this.wasmTerm.free();
|
|
765
|
+
}
|
|
766
|
+
const config = this.buildWasmConfig();
|
|
767
|
+
this.disposeMarkers();
|
|
768
|
+
this.wasmTerm = this.ghostty!.createTerminal(this.cols, this.rows, config);
|
|
769
|
+
this.wireEngine();
|
|
770
|
+
this.wake();
|
|
771
|
+
|
|
772
|
+
// Clear renderer
|
|
773
|
+
this.renderer!.clear();
|
|
774
|
+
|
|
775
|
+
// Reset title
|
|
776
|
+
this.currentTitle = '';
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Focus terminal input
|
|
781
|
+
*/
|
|
782
|
+
focus(): void {
|
|
783
|
+
this.wake();
|
|
784
|
+
if (this.isOpen && this.element) {
|
|
785
|
+
// Focus immediately for immediate keyboard/wheel event handling
|
|
786
|
+
this.element.focus();
|
|
787
|
+
|
|
788
|
+
// Also schedule a delayed focus as backup to ensure it sticks
|
|
789
|
+
// (some browsers may need this if DOM isn't fully settled)
|
|
790
|
+
setTimeout(() => {
|
|
791
|
+
this.element?.focus();
|
|
792
|
+
}, 0);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Blur terminal (remove focus)
|
|
798
|
+
*/
|
|
799
|
+
blur(): void {
|
|
800
|
+
this.wake();
|
|
801
|
+
if (this.isOpen && this.element) {
|
|
802
|
+
this.element.blur();
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Load an addon
|
|
808
|
+
*/
|
|
809
|
+
loadAddon(addon: ITerminalAddon): void {
|
|
810
|
+
addon.activate(this);
|
|
811
|
+
this.addons.push(addon);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// ==========================================================================
|
|
815
|
+
// Selection API (xterm.js compatible)
|
|
816
|
+
// ==========================================================================
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* Get the selected text as a string
|
|
820
|
+
*/
|
|
821
|
+
public getSelection(): string {
|
|
822
|
+
return this.selectionManager?.getSelection() || '';
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Check if there's an active selection
|
|
827
|
+
*/
|
|
828
|
+
public hasSelection(): boolean {
|
|
829
|
+
return this.selectionManager?.hasSelection() || false;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Clear the current selection
|
|
834
|
+
*/
|
|
835
|
+
public clearSelection(): void {
|
|
836
|
+
this.selectionManager?.clearSelection();
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* Copy the current selection to clipboard
|
|
841
|
+
* @returns true if there was text to copy, false otherwise
|
|
842
|
+
*/
|
|
843
|
+
public copySelection(): boolean {
|
|
844
|
+
return this.selectionManager?.copySelection() || false;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Select all text in the terminal
|
|
849
|
+
*/
|
|
850
|
+
public selectAll(): void {
|
|
851
|
+
this.selectionManager?.selectAll();
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Select text at specific column and row with length
|
|
856
|
+
*/
|
|
857
|
+
public select(column: number, row: number, length: number): void {
|
|
858
|
+
this.selectionManager?.select(column, row, length);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* Select entire lines from start to end
|
|
863
|
+
*/
|
|
864
|
+
public selectLines(start: number, end: number): void {
|
|
865
|
+
this.selectionManager?.selectLines(start, end);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Get selection position as buffer range
|
|
870
|
+
*/
|
|
871
|
+
/**
|
|
872
|
+
* Get the current viewport Y position.
|
|
873
|
+
*
|
|
874
|
+
* This is the number of lines scrolled back from the bottom of the
|
|
875
|
+
* scrollback buffer. It may be fractional during smooth scrolling.
|
|
876
|
+
*/
|
|
877
|
+
public getViewportY(): number {
|
|
878
|
+
return this.viewportY;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
public getSelectionPosition(): IBufferRange | undefined {
|
|
882
|
+
return this.selectionManager?.getSelectionPosition();
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// ==========================================================================
|
|
886
|
+
// Phase 1: Custom Event Handlers
|
|
887
|
+
// ==========================================================================
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Attach a custom keyboard event handler
|
|
891
|
+
* Returns true to prevent default handling
|
|
892
|
+
*/
|
|
893
|
+
public attachCustomKeyEventHandler(
|
|
894
|
+
customKeyEventHandler: (event: KeyboardEvent) => boolean
|
|
895
|
+
): void {
|
|
896
|
+
this.customKeyEventHandler = customKeyEventHandler;
|
|
897
|
+
// Update input handler if already created
|
|
898
|
+
if (this.inputHandler) {
|
|
899
|
+
this.inputHandler.setCustomKeyEventHandler(customKeyEventHandler);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* Attach a custom wheel event handler (Phase 2)
|
|
905
|
+
* Returns true to prevent default handling
|
|
906
|
+
*/
|
|
907
|
+
public attachCustomWheelEventHandler(
|
|
908
|
+
customWheelEventHandler?: (event: WheelEvent) => boolean
|
|
909
|
+
): void {
|
|
910
|
+
this.customWheelEventHandler = customWheelEventHandler;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// ==========================================================================
|
|
914
|
+
// Link Detection Methods
|
|
915
|
+
// ==========================================================================
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* Register a custom link provider
|
|
919
|
+
* Multiple providers can be registered to detect different types of links
|
|
920
|
+
*
|
|
921
|
+
* @example
|
|
922
|
+
* ```typescript
|
|
923
|
+
* term.registerLinkProvider({
|
|
924
|
+
* provideLinks(y, callback) {
|
|
925
|
+
* // Detect URLs, file paths, etc.
|
|
926
|
+
* callback(detectedLinks);
|
|
927
|
+
* }
|
|
928
|
+
* });
|
|
929
|
+
* ```
|
|
930
|
+
*/
|
|
931
|
+
public registerLinkProvider(provider: ILinkProvider): void {
|
|
932
|
+
if (!this.linkDetector) {
|
|
933
|
+
throw new Error('Terminal must be opened before registering link providers');
|
|
934
|
+
}
|
|
935
|
+
this.linkDetector.registerProvider(provider);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// ==========================================================================
|
|
939
|
+
// Phase 2: Scrolling Methods
|
|
940
|
+
// ==========================================================================
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* Scroll viewport by a number of lines
|
|
944
|
+
* @param amount Number of lines to scroll (positive = down, negative = up)
|
|
945
|
+
*/
|
|
946
|
+
public scrollLines(amount: number): void {
|
|
947
|
+
this.wake();
|
|
948
|
+
if (!this.wasmTerm) {
|
|
949
|
+
throw new Error('Terminal not open');
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
const scrollbackLength = this.getScrollbackLength();
|
|
953
|
+
const maxScroll = scrollbackLength;
|
|
954
|
+
|
|
955
|
+
// Calculate new viewport position
|
|
956
|
+
// viewportY = 0 means at bottom (no scroll)
|
|
957
|
+
// viewportY > 0 means scrolled up into history
|
|
958
|
+
// amount < 0 (scroll up) should INCREASE viewportY
|
|
959
|
+
// amount > 0 (scroll down) should DECREASE viewportY
|
|
960
|
+
// So we SUBTRACT amount (negative amount becomes positive change)
|
|
961
|
+
const newViewportY = Math.max(0, Math.min(maxScroll, this.viewportY - amount));
|
|
962
|
+
|
|
963
|
+
if (newViewportY !== this.viewportY) {
|
|
964
|
+
this.viewportY = newViewportY;
|
|
965
|
+
this.fireScroll();
|
|
966
|
+
|
|
967
|
+
// Show scrollbar when scrolling (with auto-hide)
|
|
968
|
+
if (scrollbackLength > 0) {
|
|
969
|
+
this.showScrollbar();
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* Scroll viewport by a number of pages
|
|
976
|
+
* @param amount Number of pages to scroll (positive = down, negative = up)
|
|
977
|
+
*/
|
|
978
|
+
public scrollPages(amount: number): void {
|
|
979
|
+
this.scrollLines(amount * this.rows);
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* Scroll viewport to the top of the scrollback buffer
|
|
984
|
+
*/
|
|
985
|
+
public scrollToTop(): void {
|
|
986
|
+
this.wake();
|
|
987
|
+
const scrollbackLength = this.getScrollbackLength();
|
|
988
|
+
if (scrollbackLength > 0 && this.viewportY !== scrollbackLength) {
|
|
989
|
+
this.viewportY = scrollbackLength;
|
|
990
|
+
this.fireScroll();
|
|
991
|
+
this.showScrollbar();
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* Scroll viewport to the bottom (current output)
|
|
997
|
+
*/
|
|
998
|
+
public scrollToBottom(): void {
|
|
999
|
+
this.wake();
|
|
1000
|
+
if (this.viewportY !== 0) {
|
|
1001
|
+
this.viewportY = 0;
|
|
1002
|
+
this.fireScroll();
|
|
1003
|
+
// Show scrollbar briefly when scrolling to bottom
|
|
1004
|
+
if (this.getScrollbackLength() > 0) {
|
|
1005
|
+
this.showScrollbar();
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/**
|
|
1011
|
+
* Scroll viewport to a specific line in the buffer
|
|
1012
|
+
* @param line Line number (0 = top of scrollback, scrollbackLength = bottom)
|
|
1013
|
+
*/
|
|
1014
|
+
/** Scrolls so that absolute buffer line `line` (0 = oldest scrollback line) is at the top, like xterm.js. */
|
|
1015
|
+
public scrollToLine(line: number): void {
|
|
1016
|
+
this.wake();
|
|
1017
|
+
const scrollbackLength = this.getScrollbackLength();
|
|
1018
|
+
const top = Math.max(0, Math.min(scrollbackLength, Math.round(line)));
|
|
1019
|
+
const newViewportY = scrollbackLength - top;
|
|
1020
|
+
|
|
1021
|
+
if (newViewportY !== this.viewportY) {
|
|
1022
|
+
this.viewportY = newViewportY;
|
|
1023
|
+
this.fireScroll();
|
|
1024
|
+
|
|
1025
|
+
// Show scrollbar when scrolling to specific line
|
|
1026
|
+
if (scrollbackLength > 0) {
|
|
1027
|
+
this.showScrollbar();
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/**
|
|
1033
|
+
* Smoothly scroll to a target viewport position
|
|
1034
|
+
* @param targetY Target viewport Y position (in lines, can be fractional)
|
|
1035
|
+
*/
|
|
1036
|
+
private smoothScrollTo(targetY: number): void {
|
|
1037
|
+
if (!this.wasmTerm) return;
|
|
1038
|
+
|
|
1039
|
+
const scrollbackLength = this.getScrollbackLength();
|
|
1040
|
+
const maxScroll = scrollbackLength;
|
|
1041
|
+
|
|
1042
|
+
// Clamp target to valid range
|
|
1043
|
+
const newTarget = Math.max(0, Math.min(maxScroll, targetY));
|
|
1044
|
+
|
|
1045
|
+
// If smooth scrolling is disabled (duration = 0), jump immediately
|
|
1046
|
+
const duration = this.options.smoothScrollDuration ?? 100;
|
|
1047
|
+
if (duration === 0) {
|
|
1048
|
+
this.viewportY = newTarget;
|
|
1049
|
+
this.targetViewportY = newTarget;
|
|
1050
|
+
this.fireScroll();
|
|
1051
|
+
|
|
1052
|
+
if (scrollbackLength > 0) {
|
|
1053
|
+
this.showScrollbar();
|
|
1054
|
+
}
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// Update target (accumulate if animation running)
|
|
1059
|
+
this.targetViewportY = newTarget;
|
|
1060
|
+
|
|
1061
|
+
// If animation is already running, don't restart it
|
|
1062
|
+
// Just let it continue toward the updated target
|
|
1063
|
+
// This prevents choppy restarts during continuous scrolling
|
|
1064
|
+
if (this.scrollAnimationFrame) {
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// Start new animation
|
|
1069
|
+
this.scrollAnimationStartTime = Date.now();
|
|
1070
|
+
this.animateScroll();
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* Animation loop for smooth scrolling
|
|
1075
|
+
* Uses asymptotic approach - moves a fraction of remaining distance each frame
|
|
1076
|
+
*/
|
|
1077
|
+
private animateScroll = (): void => {
|
|
1078
|
+
this.wake();
|
|
1079
|
+
if (!this.wasmTerm || this.scrollAnimationStartTime === undefined) {
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
const duration = this.options.smoothScrollDuration ?? 100;
|
|
1084
|
+
|
|
1085
|
+
// Calculate distance to target
|
|
1086
|
+
const distance = this.targetViewportY - this.viewportY;
|
|
1087
|
+
const absDistance = Math.abs(distance);
|
|
1088
|
+
|
|
1089
|
+
// If very close, snap to target
|
|
1090
|
+
if (absDistance < 0.01) {
|
|
1091
|
+
this.viewportY = this.targetViewportY;
|
|
1092
|
+
this.fireScroll();
|
|
1093
|
+
|
|
1094
|
+
const scrollbackLength = this.getScrollbackLength();
|
|
1095
|
+
if (scrollbackLength > 0) {
|
|
1096
|
+
this.showScrollbar();
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// Animation complete
|
|
1100
|
+
this.scrollAnimationFrame = undefined;
|
|
1101
|
+
this.scrollAnimationStartTime = undefined;
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// Move a fraction of the remaining distance
|
|
1106
|
+
// At 60fps, move ~1/6 of distance per frame for ~100ms total duration
|
|
1107
|
+
// This creates smooth deceleration toward target
|
|
1108
|
+
const framesForDuration = (duration / 1000) * 60; // Convert ms to frame count
|
|
1109
|
+
const moveRatio = 1 - (1 / framesForDuration) ** 2; // Ease-out
|
|
1110
|
+
this.viewportY += distance * moveRatio;
|
|
1111
|
+
|
|
1112
|
+
// Fire scroll event
|
|
1113
|
+
this.fireScroll();
|
|
1114
|
+
|
|
1115
|
+
// Show scrollbar during animation
|
|
1116
|
+
const scrollbackLength = this.getScrollbackLength();
|
|
1117
|
+
if (scrollbackLength > 0) {
|
|
1118
|
+
this.showScrollbar();
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// Continue animation
|
|
1122
|
+
this.scrollAnimationFrame = requestAnimationFrame(this.animateScroll);
|
|
1123
|
+
};
|
|
1124
|
+
|
|
1125
|
+
// ==========================================================================
|
|
1126
|
+
// Lifecycle
|
|
1127
|
+
// ==========================================================================
|
|
1128
|
+
|
|
1129
|
+
/**
|
|
1130
|
+
* Dispose terminal and clean up resources
|
|
1131
|
+
*/
|
|
1132
|
+
dispose(): void {
|
|
1133
|
+
if (this.isDisposed) {
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
this.isDisposed = true;
|
|
1138
|
+
this.isOpen = false;
|
|
1139
|
+
|
|
1140
|
+
// Stop render loop and clear write queue
|
|
1141
|
+
this.cancelRenderLoop();
|
|
1142
|
+
this.writeQueue.length = 0;
|
|
1143
|
+
|
|
1144
|
+
// Stop smooth scroll animation
|
|
1145
|
+
if (this.scrollAnimationFrame) {
|
|
1146
|
+
cancelAnimationFrame(this.scrollAnimationFrame);
|
|
1147
|
+
this.scrollAnimationFrame = undefined;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// Clear mouse move throttle timeout
|
|
1151
|
+
if (this.mouseMoveThrottleTimeout) {
|
|
1152
|
+
clearTimeout(this.mouseMoveThrottleTimeout);
|
|
1153
|
+
this.mouseMoveThrottleTimeout = undefined;
|
|
1154
|
+
}
|
|
1155
|
+
this.pendingMouseMove = undefined;
|
|
1156
|
+
|
|
1157
|
+
// Dispose addons
|
|
1158
|
+
for (const addon of this.addons) {
|
|
1159
|
+
addon.dispose();
|
|
1160
|
+
}
|
|
1161
|
+
this.addons = [];
|
|
1162
|
+
|
|
1163
|
+
// Clean up components
|
|
1164
|
+
this.cleanupComponents();
|
|
1165
|
+
|
|
1166
|
+
// Dispose event emitters
|
|
1167
|
+
this.dataEmitter.dispose();
|
|
1168
|
+
this.resizeEmitter.dispose();
|
|
1169
|
+
this.bellEmitter.dispose();
|
|
1170
|
+
this.selectionChangeEmitter.dispose();
|
|
1171
|
+
this.keyEmitter.dispose();
|
|
1172
|
+
this.titleChangeEmitter.dispose();
|
|
1173
|
+
this.scrollEmitter.dispose();
|
|
1174
|
+
this.renderEmitter.dispose();
|
|
1175
|
+
this.cursorMoveEmitter.dispose();
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// ==========================================================================
|
|
1179
|
+
// Private Methods
|
|
1180
|
+
// ==========================================================================
|
|
1181
|
+
|
|
1182
|
+
/**
|
|
1183
|
+
* Cancel the render loop
|
|
1184
|
+
*/
|
|
1185
|
+
private cancelRenderLoop(): void {
|
|
1186
|
+
if (this.animationFrameId) {
|
|
1187
|
+
cancelAnimationFrame(this.animationFrameId);
|
|
1188
|
+
this.animationFrameId = undefined;
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/**
|
|
1193
|
+
* Flush any writes that were queued during resize
|
|
1194
|
+
*/
|
|
1195
|
+
private flushWriteQueue(): void {
|
|
1196
|
+
while (this.writeQueue.length > 0) {
|
|
1197
|
+
const data = this.writeQueue.shift()!;
|
|
1198
|
+
this.wasmTerm!.write(data);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
/**
|
|
1203
|
+
* Start the render loop
|
|
1204
|
+
*/
|
|
1205
|
+
private startRenderLoop(): void {
|
|
1206
|
+
if (this.animationFrameId) return;
|
|
1207
|
+
const loop = () => {
|
|
1208
|
+
this.animationFrameId = undefined;
|
|
1209
|
+
if (this.isDisposed || !this.isOpen) return;
|
|
1210
|
+
const drew = this.wasmTerm!.update() !== DirtyState.NONE;
|
|
1211
|
+
this.renderer!.render(this.wasmTerm!, false, this.viewportY, this, this.scrollbarOpacity);
|
|
1212
|
+
if (drew) this.renderEmitter.fire({ start: 0, end: this.rows - 1 });
|
|
1213
|
+
const cursor = this.wasmTerm!.getCursor();
|
|
1214
|
+
if (cursor.y !== this.lastCursorY) {
|
|
1215
|
+
this.lastCursorY = cursor.y;
|
|
1216
|
+
this.cursorMoveEmitter.fire();
|
|
1217
|
+
}
|
|
1218
|
+
if (this.idleFrames++ < Terminal.IDLE_FRAMES_BEFORE_SLEEP) {
|
|
1219
|
+
this.animationFrameId = requestAnimationFrame(loop);
|
|
1220
|
+
}
|
|
1221
|
+
};
|
|
1222
|
+
this.animationFrameId = requestAnimationFrame(loop);
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/**
|
|
1226
|
+
* Get a line from native WASM scrollback buffer
|
|
1227
|
+
* Implements IScrollbackProvider
|
|
1228
|
+
*/
|
|
1229
|
+
public getScrollbackLine(offset: number): GhosttyCell[] | null {
|
|
1230
|
+
if (!this.wasmTerm) return null;
|
|
1231
|
+
return this.wasmTerm.getScrollbackLine(offset);
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
/**
|
|
1235
|
+
* Get scrollback length from native WASM
|
|
1236
|
+
* Implements IScrollbackProvider
|
|
1237
|
+
*/
|
|
1238
|
+
public getScrollbackLength(): number {
|
|
1239
|
+
if (!this.wasmTerm) return 0;
|
|
1240
|
+
return this.wasmTerm.getScrollbackLength();
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
/**
|
|
1244
|
+
* Clean up components (called on dispose or error)
|
|
1245
|
+
*/
|
|
1246
|
+
private cleanupComponents(): void {
|
|
1247
|
+
// Dispose selection manager
|
|
1248
|
+
if (this.selectionManager) {
|
|
1249
|
+
this.selectionManager.dispose();
|
|
1250
|
+
this.selectionManager = undefined;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
// Dispose input handler
|
|
1254
|
+
if (this.inputHandler) {
|
|
1255
|
+
this.inputHandler.dispose();
|
|
1256
|
+
this.inputHandler = undefined;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// Dispose renderer
|
|
1260
|
+
if (this.renderer) {
|
|
1261
|
+
this.renderer.dispose();
|
|
1262
|
+
this.renderer = undefined;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// Remove canvas from DOM
|
|
1266
|
+
if (this.canvas && this.canvas.parentNode) {
|
|
1267
|
+
this.canvas.parentNode.removeChild(this.canvas);
|
|
1268
|
+
this.canvas = undefined;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
// Remove textarea from DOM
|
|
1272
|
+
if (this.textarea && this.textarea.parentNode) {
|
|
1273
|
+
this.textarea.parentNode.removeChild(this.textarea);
|
|
1274
|
+
this.textarea = undefined;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// Remove event listeners
|
|
1278
|
+
if (this.element) {
|
|
1279
|
+
this.element.removeEventListener('wheel', this.handleWheel);
|
|
1280
|
+
this.element.removeEventListener('mousedown', this.handleMouseDown, { capture: true });
|
|
1281
|
+
this.element.removeEventListener('mousemove', this.handleMouseMove);
|
|
1282
|
+
this.element.removeEventListener('mouseleave', this.handleMouseLeave);
|
|
1283
|
+
this.element.removeEventListener('click', this.handleClick);
|
|
1284
|
+
|
|
1285
|
+
// Remove contenteditable and accessibility attributes added in open()
|
|
1286
|
+
this.element.removeAttribute('contenteditable');
|
|
1287
|
+
this.element.removeAttribute('role');
|
|
1288
|
+
this.element.removeAttribute('aria-label');
|
|
1289
|
+
this.element.removeAttribute('aria-multiline');
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
// Remove document-level listeners (only if opened)
|
|
1293
|
+
if (this.isOpen && typeof document !== 'undefined') {
|
|
1294
|
+
document.removeEventListener('mouseup', this.handleMouseUp);
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
// Clean up scrollbar timers
|
|
1298
|
+
if (this.scrollbarHideTimeout) {
|
|
1299
|
+
window.clearTimeout(this.scrollbarHideTimeout);
|
|
1300
|
+
this.scrollbarHideTimeout = undefined;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
// Dispose link detector
|
|
1304
|
+
if (this.linkDetector) {
|
|
1305
|
+
this.linkDetector.dispose();
|
|
1306
|
+
this.linkDetector = undefined;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// Free WASM terminal
|
|
1310
|
+
if (this.wasmTerm) {
|
|
1311
|
+
this.wasmTerm.free();
|
|
1312
|
+
this.wasmTerm = undefined;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
// Clear references
|
|
1316
|
+
this.ghostty = undefined;
|
|
1317
|
+
this.element = undefined;
|
|
1318
|
+
this.textarea = undefined;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
/**
|
|
1322
|
+
* Assert terminal is open (throw if not)
|
|
1323
|
+
*/
|
|
1324
|
+
private assertOpen(): void {
|
|
1325
|
+
if (this.isDisposed) {
|
|
1326
|
+
throw new Error('Terminal has been disposed');
|
|
1327
|
+
}
|
|
1328
|
+
if (!this.isOpen) {
|
|
1329
|
+
throw new Error('Terminal must be opened before use. Call terminal.open(parent) first.');
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
/**
|
|
1334
|
+
* Handle mouse move for link hover detection and scrollbar dragging
|
|
1335
|
+
* Throttled to avoid blocking scroll events (except when dragging scrollbar)
|
|
1336
|
+
*/
|
|
1337
|
+
private handleMouseMove = (e: MouseEvent): void => {
|
|
1338
|
+
this.wake();
|
|
1339
|
+
if (!this.canvas || !this.renderer || !this.wasmTerm) return;
|
|
1340
|
+
|
|
1341
|
+
// If dragging scrollbar, handle immediately without throttling
|
|
1342
|
+
if (this.isDraggingScrollbar) {
|
|
1343
|
+
this.processScrollbarDrag(e);
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
if (!this.linkDetector) return;
|
|
1348
|
+
|
|
1349
|
+
// Throttle to ~60fps (16ms) to avoid blocking scroll/other events
|
|
1350
|
+
if (this.mouseMoveThrottleTimeout) {
|
|
1351
|
+
this.pendingMouseMove = e;
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
this.processMouseMove(e);
|
|
1356
|
+
|
|
1357
|
+
this.mouseMoveThrottleTimeout = window.setTimeout(() => {
|
|
1358
|
+
this.mouseMoveThrottleTimeout = undefined;
|
|
1359
|
+
if (this.pendingMouseMove) {
|
|
1360
|
+
const pending = this.pendingMouseMove;
|
|
1361
|
+
this.pendingMouseMove = undefined;
|
|
1362
|
+
this.processMouseMove(pending);
|
|
1363
|
+
}
|
|
1364
|
+
}, 16);
|
|
1365
|
+
};
|
|
1366
|
+
|
|
1367
|
+
/**
|
|
1368
|
+
* Process mouse move for link detection (internal, called by throttled handler)
|
|
1369
|
+
*/
|
|
1370
|
+
private processMouseMove(e: MouseEvent): void {
|
|
1371
|
+
if (!this.canvas || !this.renderer || !this.linkDetector || !this.wasmTerm) return;
|
|
1372
|
+
|
|
1373
|
+
// Convert mouse coordinates to terminal cell position
|
|
1374
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
1375
|
+
const x = Math.floor((e.clientX - rect.left) / this.renderer.charWidth);
|
|
1376
|
+
const y = Math.floor((e.clientY - rect.top) / this.renderer.charHeight);
|
|
1377
|
+
|
|
1378
|
+
// Get hyperlink_id directly from the cell at this position
|
|
1379
|
+
// Must account for viewportY (scrollback position)
|
|
1380
|
+
const viewportRow = y; // Row in the viewport (0 to rows-1)
|
|
1381
|
+
let hyperlinkId = 0;
|
|
1382
|
+
|
|
1383
|
+
// When scrolled, fetch from scrollback or screen based on position
|
|
1384
|
+
// NOTE: viewportY may be fractional during smooth scrolling. The renderer
|
|
1385
|
+
// uses Math.floor(viewportY) when mapping viewport rows to scrollback vs
|
|
1386
|
+
// screen; we mirror that logic here so link hit-testing matches what the
|
|
1387
|
+
// user sees on screen.
|
|
1388
|
+
let line: GhosttyCell[] | null = null;
|
|
1389
|
+
const rawViewportY = this.getViewportY();
|
|
1390
|
+
const viewportY = Math.max(0, Math.floor(rawViewportY));
|
|
1391
|
+
if (viewportY > 0) {
|
|
1392
|
+
const scrollbackLength = this.wasmTerm.getScrollbackLength();
|
|
1393
|
+
if (viewportRow < viewportY) {
|
|
1394
|
+
// Mouse is over scrollback content
|
|
1395
|
+
const scrollbackOffset = scrollbackLength - viewportY + viewportRow;
|
|
1396
|
+
line = this.wasmTerm.getScrollbackLine(scrollbackOffset);
|
|
1397
|
+
} else {
|
|
1398
|
+
// Mouse is over screen content (bottom part of viewport)
|
|
1399
|
+
const screenRow = viewportRow - viewportY;
|
|
1400
|
+
line = this.wasmTerm.getLine(screenRow);
|
|
1401
|
+
}
|
|
1402
|
+
} else {
|
|
1403
|
+
// At bottom - just use screen buffer
|
|
1404
|
+
line = this.wasmTerm.getLine(viewportRow);
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
if (line && x >= 0 && x < line.length) {
|
|
1408
|
+
hyperlinkId = line[x].hyperlink_id;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
// Update renderer for underline rendering
|
|
1412
|
+
const previousHyperlinkId = (this.renderer as any).hoveredHyperlinkId || 0;
|
|
1413
|
+
if (hyperlinkId !== previousHyperlinkId) {
|
|
1414
|
+
this.renderer.setHoveredHyperlinkId(hyperlinkId);
|
|
1415
|
+
|
|
1416
|
+
// The 60fps render loop will pick up the change automatically
|
|
1417
|
+
// No need to force a render - this keeps performance smooth
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
// Check if there's a link at this position (for click handling and cursor)
|
|
1421
|
+
// Buffer API expects absolute buffer coordinates (including scrollback)
|
|
1422
|
+
// When scrolled, we need to adjust the buffer row based on viewportY
|
|
1423
|
+
const scrollbackLength = this.wasmTerm.getScrollbackLength();
|
|
1424
|
+
let bufferRow: number;
|
|
1425
|
+
|
|
1426
|
+
// Use floored viewportY for buffer mapping (must match renderer & selection)
|
|
1427
|
+
const rawViewportYForBuffer = this.getViewportY();
|
|
1428
|
+
const viewportYForBuffer = Math.max(0, Math.floor(rawViewportYForBuffer));
|
|
1429
|
+
|
|
1430
|
+
if (viewportYForBuffer > 0) {
|
|
1431
|
+
// When scrolled, the buffer row depends on where in the viewport we are
|
|
1432
|
+
if (viewportRow < viewportYForBuffer) {
|
|
1433
|
+
// Mouse is over scrollback content
|
|
1434
|
+
bufferRow = scrollbackLength - viewportYForBuffer + viewportRow;
|
|
1435
|
+
} else {
|
|
1436
|
+
// Mouse is over screen content (bottom part of viewport)
|
|
1437
|
+
const screenRow = viewportRow - viewportYForBuffer;
|
|
1438
|
+
bufferRow = scrollbackLength + screenRow;
|
|
1439
|
+
}
|
|
1440
|
+
} else {
|
|
1441
|
+
// At bottom - buffer row is scrollback + screen row
|
|
1442
|
+
bufferRow = scrollbackLength + viewportRow;
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// Make async call non-blocking - don't await
|
|
1446
|
+
this.linkDetector
|
|
1447
|
+
.getLinkAt(x, bufferRow)
|
|
1448
|
+
.then((link) => {
|
|
1449
|
+
// Update hover state for cursor changes and click handling
|
|
1450
|
+
if (link !== this.currentHoveredLink) {
|
|
1451
|
+
// Notify old link we're leaving
|
|
1452
|
+
this.currentHoveredLink?.hover?.(false);
|
|
1453
|
+
|
|
1454
|
+
// Update current link
|
|
1455
|
+
this.currentHoveredLink = link;
|
|
1456
|
+
|
|
1457
|
+
// Notify new link we're entering
|
|
1458
|
+
link?.hover?.(true);
|
|
1459
|
+
|
|
1460
|
+
// Update cursor style on both container and canvas
|
|
1461
|
+
const cursorStyle = link ? 'pointer' : 'text';
|
|
1462
|
+
if (this.element) {
|
|
1463
|
+
this.element.style.cursor = cursorStyle;
|
|
1464
|
+
}
|
|
1465
|
+
if (this.canvas) {
|
|
1466
|
+
this.canvas.style.cursor = cursorStyle;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
// Update renderer for underline (for regex URLs without hyperlink_id)
|
|
1470
|
+
if (this.renderer) {
|
|
1471
|
+
if (link) {
|
|
1472
|
+
// Convert buffer coordinates to viewport coordinates
|
|
1473
|
+
const scrollbackLength = this.wasmTerm?.getScrollbackLength() || 0;
|
|
1474
|
+
|
|
1475
|
+
// Calculate viewport Y for start and end positions
|
|
1476
|
+
// Use floored viewportY so overlay rows match renderer & selection
|
|
1477
|
+
const rawViewportYForLinks = this.getViewportY();
|
|
1478
|
+
const viewportYForLinks = Math.max(0, Math.floor(rawViewportYForLinks));
|
|
1479
|
+
const startViewportY = link.range.start.y - scrollbackLength + viewportYForLinks;
|
|
1480
|
+
const endViewportY = link.range.end.y - scrollbackLength + viewportYForLinks;
|
|
1481
|
+
|
|
1482
|
+
// Only show underline if link is visible in viewport
|
|
1483
|
+
if (startViewportY < this.rows && endViewportY >= 0) {
|
|
1484
|
+
this.renderer.setHoveredLinkRange({
|
|
1485
|
+
startX: link.range.start.x,
|
|
1486
|
+
startY: Math.max(0, startViewportY),
|
|
1487
|
+
endX: link.range.end.x,
|
|
1488
|
+
endY: Math.min(this.rows - 1, endViewportY),
|
|
1489
|
+
});
|
|
1490
|
+
} else {
|
|
1491
|
+
this.renderer.setHoveredLinkRange(null);
|
|
1492
|
+
}
|
|
1493
|
+
} else {
|
|
1494
|
+
this.renderer.setHoveredLinkRange(null);
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
})
|
|
1499
|
+
.catch((err) => {
|
|
1500
|
+
console.warn('Link detection error:', err);
|
|
1501
|
+
});
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
/**
|
|
1505
|
+
* Handle mouse leave to clear link hover
|
|
1506
|
+
*/
|
|
1507
|
+
private handleMouseLeave = (): void => {
|
|
1508
|
+
this.wake();
|
|
1509
|
+
// Clear hyperlink underline
|
|
1510
|
+
if (this.renderer && this.wasmTerm) {
|
|
1511
|
+
const previousHyperlinkId = (this.renderer as any).hoveredHyperlinkId || 0;
|
|
1512
|
+
if (previousHyperlinkId > 0) {
|
|
1513
|
+
this.renderer.setHoveredHyperlinkId(0);
|
|
1514
|
+
|
|
1515
|
+
// The 60fps render loop will pick up the change automatically
|
|
1516
|
+
}
|
|
1517
|
+
// Clear regex link underline
|
|
1518
|
+
this.renderer.setHoveredLinkRange(null);
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
if (this.currentHoveredLink) {
|
|
1522
|
+
// Notify link we're leaving
|
|
1523
|
+
this.currentHoveredLink.hover?.(false);
|
|
1524
|
+
|
|
1525
|
+
// Clear hovered link
|
|
1526
|
+
this.currentHoveredLink = undefined;
|
|
1527
|
+
|
|
1528
|
+
// Reset cursor
|
|
1529
|
+
if (this.element) {
|
|
1530
|
+
this.element.style.cursor = 'text';
|
|
1531
|
+
if (this.canvas) {
|
|
1532
|
+
this.canvas.style.cursor = 'text';
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
};
|
|
1537
|
+
|
|
1538
|
+
/**
|
|
1539
|
+
* Handle mouse click for link activation
|
|
1540
|
+
*/
|
|
1541
|
+
private handleClick = async (e: MouseEvent): Promise<void> => {
|
|
1542
|
+
this.wake();
|
|
1543
|
+
// For more reliable clicking, detect the link at click time
|
|
1544
|
+
// rather than relying on cached hover state (avoids async races)
|
|
1545
|
+
if (!this.canvas || !this.renderer || !this.linkDetector || !this.wasmTerm) return;
|
|
1546
|
+
|
|
1547
|
+
// Get click position
|
|
1548
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
1549
|
+
const x = Math.floor((e.clientX - rect.left) / this.renderer.charWidth);
|
|
1550
|
+
const y = Math.floor((e.clientY - rect.top) / this.renderer.charHeight);
|
|
1551
|
+
|
|
1552
|
+
// Calculate buffer row (same logic as processMouseMove)
|
|
1553
|
+
const viewportRow = y;
|
|
1554
|
+
const scrollbackLength = this.wasmTerm.getScrollbackLength();
|
|
1555
|
+
let bufferRow: number;
|
|
1556
|
+
|
|
1557
|
+
// Use floored viewportY for buffer mapping (must match renderer & selection)
|
|
1558
|
+
const rawViewportYForClick = this.getViewportY();
|
|
1559
|
+
const viewportYForClick = Math.max(0, Math.floor(rawViewportYForClick));
|
|
1560
|
+
|
|
1561
|
+
if (viewportYForClick > 0) {
|
|
1562
|
+
if (viewportRow < viewportYForClick) {
|
|
1563
|
+
bufferRow = scrollbackLength - viewportYForClick + viewportRow;
|
|
1564
|
+
} else {
|
|
1565
|
+
const screenRow = viewportRow - viewportYForClick;
|
|
1566
|
+
bufferRow = scrollbackLength + screenRow;
|
|
1567
|
+
}
|
|
1568
|
+
} else {
|
|
1569
|
+
bufferRow = scrollbackLength + viewportRow;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
// Get the link at this position
|
|
1573
|
+
const link = await this.linkDetector.getLinkAt(x, bufferRow);
|
|
1574
|
+
|
|
1575
|
+
if (link) {
|
|
1576
|
+
// Activate link
|
|
1577
|
+
link.activate(e);
|
|
1578
|
+
|
|
1579
|
+
// Prevent default action if modifier key held
|
|
1580
|
+
if (e.ctrlKey || e.metaKey) {
|
|
1581
|
+
e.preventDefault();
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
};
|
|
1585
|
+
|
|
1586
|
+
/**
|
|
1587
|
+
* Handle wheel events for scrolling (Phase 2)
|
|
1588
|
+
*/
|
|
1589
|
+
private handleWheel = (e: WheelEvent): void => {
|
|
1590
|
+
this.wake();
|
|
1591
|
+
// Always prevent default browser scrolling
|
|
1592
|
+
e.preventDefault();
|
|
1593
|
+
e.stopPropagation();
|
|
1594
|
+
|
|
1595
|
+
// Allow custom handler to override
|
|
1596
|
+
if (this.customWheelEventHandler && this.customWheelEventHandler(e)) {
|
|
1597
|
+
return;
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
// Check if in alternate screen mode (vim, less, htop, etc.)
|
|
1601
|
+
const isAltScreen = this.wasmTerm?.isAlternateScreen() ?? false;
|
|
1602
|
+
|
|
1603
|
+
if (isAltScreen) {
|
|
1604
|
+
// Alternate screen: send arrow keys to the application
|
|
1605
|
+
// Applications like vim handle scrolling internally
|
|
1606
|
+
// Standard: ~3 arrow presses per wheel "click"
|
|
1607
|
+
const direction = e.deltaY > 0 ? 'down' : 'up';
|
|
1608
|
+
const count = Math.min(Math.abs(Math.round(e.deltaY / 33)), 5); // Cap at 5
|
|
1609
|
+
|
|
1610
|
+
for (let i = 0; i < count; i++) {
|
|
1611
|
+
if (direction === 'up') {
|
|
1612
|
+
this.dataEmitter.fire('\x1B[A'); // Up arrow
|
|
1613
|
+
} else {
|
|
1614
|
+
this.dataEmitter.fire('\x1B[B'); // Down arrow
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
} else {
|
|
1618
|
+
// Normal screen: scroll viewport through history with smooth scrolling
|
|
1619
|
+
// Handle different deltaMode values for better trackpad/mouse support
|
|
1620
|
+
let deltaLines: number;
|
|
1621
|
+
|
|
1622
|
+
if (e.deltaMode === WheelEvent.DOM_DELTA_PIXEL) {
|
|
1623
|
+
// Pixel mode (trackpads): convert pixels to lines
|
|
1624
|
+
// Use actual line height from renderer for accurate conversion
|
|
1625
|
+
const lineHeight = this.renderer?.getMetrics()?.height ?? 20;
|
|
1626
|
+
deltaLines = e.deltaY / lineHeight;
|
|
1627
|
+
} else if (e.deltaMode === WheelEvent.DOM_DELTA_LINE) {
|
|
1628
|
+
// Line mode (some mice): use directly
|
|
1629
|
+
deltaLines = e.deltaY;
|
|
1630
|
+
} else if (e.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
|
|
1631
|
+
// Page mode (rare): convert pages to lines
|
|
1632
|
+
deltaLines = e.deltaY * this.rows;
|
|
1633
|
+
} else {
|
|
1634
|
+
// Fallback: assume pixel mode with legacy divisor
|
|
1635
|
+
deltaLines = e.deltaY / 33;
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
// Use smooth scrolling for any amount (no rounding needed)
|
|
1639
|
+
if (deltaLines !== 0) {
|
|
1640
|
+
// Calculate target position
|
|
1641
|
+
// deltaY > 0 = scroll down (decrease viewportY)
|
|
1642
|
+
// deltaY < 0 = scroll up (increase viewportY)
|
|
1643
|
+
const targetY = this.viewportY - deltaLines;
|
|
1644
|
+
this.smoothScrollTo(targetY);
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
};
|
|
1648
|
+
|
|
1649
|
+
/**
|
|
1650
|
+
* Handle mouse down for scrollbar interaction
|
|
1651
|
+
*/
|
|
1652
|
+
private handleMouseDown = (e: MouseEvent): void => {
|
|
1653
|
+
this.wake();
|
|
1654
|
+
if (!this.canvas || !this.renderer || !this.wasmTerm) return;
|
|
1655
|
+
|
|
1656
|
+
const scrollbackLength = this.wasmTerm.getScrollbackLength();
|
|
1657
|
+
if (scrollbackLength === 0) return; // No scrollbar if no scrollback
|
|
1658
|
+
|
|
1659
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
1660
|
+
const mouseX = e.clientX - rect.left;
|
|
1661
|
+
const mouseY = e.clientY - rect.top;
|
|
1662
|
+
|
|
1663
|
+
// Calculate scrollbar dimensions (match renderer's logic)
|
|
1664
|
+
// Use rect dimensions which are already in CSS pixels
|
|
1665
|
+
const canvasWidth = rect.width;
|
|
1666
|
+
const canvasHeight = rect.height;
|
|
1667
|
+
const scrollbarWidth = 8;
|
|
1668
|
+
const scrollbarX = canvasWidth - scrollbarWidth - 4;
|
|
1669
|
+
const scrollbarPadding = 4;
|
|
1670
|
+
|
|
1671
|
+
// Check if click is in scrollbar area
|
|
1672
|
+
if (mouseX >= scrollbarX && mouseX <= scrollbarX + scrollbarWidth) {
|
|
1673
|
+
// Prevent default and stop propagation to prevent text selection
|
|
1674
|
+
e.preventDefault();
|
|
1675
|
+
e.stopPropagation();
|
|
1676
|
+
e.stopImmediatePropagation(); // Stop SelectionManager from seeing this event
|
|
1677
|
+
|
|
1678
|
+
// Calculate scrollbar thumb position and size
|
|
1679
|
+
const scrollbarTrackHeight = canvasHeight - scrollbarPadding * 2;
|
|
1680
|
+
const visibleRows = this.rows;
|
|
1681
|
+
const totalLines = scrollbackLength + visibleRows;
|
|
1682
|
+
const thumbHeight = Math.max(20, (visibleRows / totalLines) * scrollbarTrackHeight);
|
|
1683
|
+
const scrollPosition = this.viewportY / scrollbackLength;
|
|
1684
|
+
const thumbY = scrollbarPadding + (scrollbarTrackHeight - thumbHeight) * (1 - scrollPosition);
|
|
1685
|
+
|
|
1686
|
+
// Check if click is on thumb
|
|
1687
|
+
if (mouseY >= thumbY && mouseY <= thumbY + thumbHeight) {
|
|
1688
|
+
// Start dragging thumb
|
|
1689
|
+
this.isDraggingScrollbar = true;
|
|
1690
|
+
this.scrollbarDragStart = mouseY;
|
|
1691
|
+
this.scrollbarDragStartViewportY = this.viewportY;
|
|
1692
|
+
|
|
1693
|
+
// Prevent text selection during drag
|
|
1694
|
+
if (this.canvas) {
|
|
1695
|
+
this.canvas.style.userSelect = 'none';
|
|
1696
|
+
this.canvas.style.webkitUserSelect = 'none';
|
|
1697
|
+
}
|
|
1698
|
+
} else {
|
|
1699
|
+
// Click on track - jump to position
|
|
1700
|
+
const relativeY = mouseY - scrollbarPadding;
|
|
1701
|
+
const scrollFraction = 1 - relativeY / scrollbarTrackHeight; // Inverted: top = 1, bottom = 0
|
|
1702
|
+
const targetViewportY = Math.round(scrollFraction * scrollbackLength);
|
|
1703
|
+
this.scrollToLine(Math.max(0, Math.min(scrollbackLength, targetViewportY)));
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
};
|
|
1707
|
+
|
|
1708
|
+
/**
|
|
1709
|
+
* Handle mouse up for scrollbar drag
|
|
1710
|
+
*/
|
|
1711
|
+
private handleMouseUp = (): void => {
|
|
1712
|
+
this.wake();
|
|
1713
|
+
if (this.isDraggingScrollbar) {
|
|
1714
|
+
this.isDraggingScrollbar = false;
|
|
1715
|
+
this.scrollbarDragStart = null;
|
|
1716
|
+
|
|
1717
|
+
// Restore text selection
|
|
1718
|
+
if (this.canvas) {
|
|
1719
|
+
this.canvas.style.userSelect = '';
|
|
1720
|
+
this.canvas.style.webkitUserSelect = '';
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
// Schedule auto-hide after drag ends
|
|
1724
|
+
if (this.scrollbarVisible && this.getScrollbackLength() > 0) {
|
|
1725
|
+
this.showScrollbar(); // Reset the hide timer
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
};
|
|
1729
|
+
|
|
1730
|
+
/**
|
|
1731
|
+
* Process scrollbar drag movement
|
|
1732
|
+
*/
|
|
1733
|
+
private processScrollbarDrag(e: MouseEvent): void {
|
|
1734
|
+
if (!this.canvas || !this.renderer || !this.wasmTerm || this.scrollbarDragStart === null)
|
|
1735
|
+
return;
|
|
1736
|
+
|
|
1737
|
+
const scrollbackLength = this.wasmTerm.getScrollbackLength();
|
|
1738
|
+
if (scrollbackLength === 0) return;
|
|
1739
|
+
|
|
1740
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
1741
|
+
const mouseY = e.clientY - rect.top;
|
|
1742
|
+
|
|
1743
|
+
// Calculate how much the mouse moved
|
|
1744
|
+
const deltaY = mouseY - this.scrollbarDragStart;
|
|
1745
|
+
|
|
1746
|
+
// Convert mouse delta to viewport delta
|
|
1747
|
+
// Use rect height which is already in CSS pixels
|
|
1748
|
+
const canvasHeight = rect.height;
|
|
1749
|
+
const scrollbarPadding = 4;
|
|
1750
|
+
const scrollbarTrackHeight = canvasHeight - scrollbarPadding * 2;
|
|
1751
|
+
const visibleRows = this.rows;
|
|
1752
|
+
const totalLines = scrollbackLength + visibleRows;
|
|
1753
|
+
const thumbHeight = Math.max(20, (visibleRows / totalLines) * scrollbarTrackHeight);
|
|
1754
|
+
|
|
1755
|
+
// Calculate scroll fraction from thumb movement
|
|
1756
|
+
// Note: thumb moves in opposite direction to viewport (thumb down = scroll down = viewportY decreases)
|
|
1757
|
+
const scrollFraction = -deltaY / (scrollbarTrackHeight - thumbHeight);
|
|
1758
|
+
const viewportDelta = Math.round(scrollFraction * scrollbackLength);
|
|
1759
|
+
|
|
1760
|
+
const newViewportY = this.scrollbarDragStartViewportY + viewportDelta;
|
|
1761
|
+
this.scrollToLine(Math.max(0, Math.min(scrollbackLength, newViewportY)));
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
/**
|
|
1765
|
+
* Show scrollbar with fade-in and schedule auto-hide
|
|
1766
|
+
*/
|
|
1767
|
+
private showScrollbar(): void {
|
|
1768
|
+
this.wake();
|
|
1769
|
+
// Clear any existing hide timeout
|
|
1770
|
+
if (this.scrollbarHideTimeout) {
|
|
1771
|
+
window.clearTimeout(this.scrollbarHideTimeout);
|
|
1772
|
+
this.scrollbarHideTimeout = undefined;
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
// If not visible, start fade-in
|
|
1776
|
+
if (!this.scrollbarVisible) {
|
|
1777
|
+
this.scrollbarVisible = true;
|
|
1778
|
+
this.scrollbarOpacity = 0;
|
|
1779
|
+
this.fadeInScrollbar();
|
|
1780
|
+
} else {
|
|
1781
|
+
// Already visible, just ensure it's fully opaque
|
|
1782
|
+
this.scrollbarOpacity = 1;
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
// Schedule auto-hide (unless dragging)
|
|
1786
|
+
if (!this.isDraggingScrollbar) {
|
|
1787
|
+
this.scrollbarHideTimeout = window.setTimeout(() => {
|
|
1788
|
+
this.hideScrollbar();
|
|
1789
|
+
}, this.SCROLLBAR_HIDE_DELAY_MS);
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
/**
|
|
1794
|
+
* Hide scrollbar with fade-out
|
|
1795
|
+
*/
|
|
1796
|
+
private hideScrollbar(): void {
|
|
1797
|
+
this.wake();
|
|
1798
|
+
if (this.scrollbarHideTimeout) {
|
|
1799
|
+
window.clearTimeout(this.scrollbarHideTimeout);
|
|
1800
|
+
this.scrollbarHideTimeout = undefined;
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
if (this.scrollbarVisible) {
|
|
1804
|
+
this.fadeOutScrollbar();
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
/**
|
|
1809
|
+
* Fade in scrollbar
|
|
1810
|
+
*/
|
|
1811
|
+
private fadeInScrollbar(): void {
|
|
1812
|
+
this.wake();
|
|
1813
|
+
const startTime = Date.now();
|
|
1814
|
+
const animate = () => {
|
|
1815
|
+
const elapsed = Date.now() - startTime;
|
|
1816
|
+
const progress = Math.min(elapsed / this.SCROLLBAR_FADE_DURATION_MS, 1);
|
|
1817
|
+
this.scrollbarOpacity = progress;
|
|
1818
|
+
|
|
1819
|
+
// Trigger render to show updated opacity
|
|
1820
|
+
if (this.renderer && this.wasmTerm) {
|
|
1821
|
+
this.renderer.render(this.wasmTerm, false, this.viewportY, this, this.scrollbarOpacity);
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
if (progress < 1) {
|
|
1825
|
+
requestAnimationFrame(animate);
|
|
1826
|
+
}
|
|
1827
|
+
};
|
|
1828
|
+
animate();
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
/**
|
|
1832
|
+
* Fade out scrollbar
|
|
1833
|
+
*/
|
|
1834
|
+
private fadeOutScrollbar(): void {
|
|
1835
|
+
this.wake();
|
|
1836
|
+
const startTime = Date.now();
|
|
1837
|
+
const startOpacity = this.scrollbarOpacity;
|
|
1838
|
+
const animate = () => {
|
|
1839
|
+
const elapsed = Date.now() - startTime;
|
|
1840
|
+
const progress = Math.min(elapsed / this.SCROLLBAR_FADE_DURATION_MS, 1);
|
|
1841
|
+
this.scrollbarOpacity = startOpacity * (1 - progress);
|
|
1842
|
+
|
|
1843
|
+
// Trigger render to show updated opacity
|
|
1844
|
+
if (this.renderer && this.wasmTerm) {
|
|
1845
|
+
this.renderer.render(this.wasmTerm, false, this.viewportY, this, this.scrollbarOpacity);
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
if (progress < 1) {
|
|
1849
|
+
requestAnimationFrame(animate);
|
|
1850
|
+
} else {
|
|
1851
|
+
this.scrollbarVisible = false;
|
|
1852
|
+
this.scrollbarOpacity = 0;
|
|
1853
|
+
// Final render to clear scrollbar completely
|
|
1854
|
+
if (this.renderer && this.wasmTerm) {
|
|
1855
|
+
this.renderer.render(this.wasmTerm, false, this.viewportY, this, 0);
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
};
|
|
1859
|
+
animate();
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
/**
|
|
1863
|
+
* Process any pending terminal responses and emit them via onData.
|
|
1864
|
+
*
|
|
1865
|
+
* This handles escape sequences that require the terminal to send a response
|
|
1866
|
+
* back to the PTY, such as:
|
|
1867
|
+
* - DSR 6 (cursor position): Shell sends \x1b[6n, terminal responds with \x1b[row;colR
|
|
1868
|
+
* - DSR 5 (operating status): Shell sends \x1b[5n, terminal responds with \x1b[0n
|
|
1869
|
+
*
|
|
1870
|
+
* Without this, shells like nushell that rely on cursor position queries
|
|
1871
|
+
* will hang waiting for a response that never comes.
|
|
1872
|
+
*
|
|
1873
|
+
* Note: We loop to read all pending responses, not just one. This is important
|
|
1874
|
+
* when multiple queries are processed in a single write() call (e.g., when
|
|
1875
|
+
* buffered data is written all at once during terminal initialization).
|
|
1876
|
+
*/
|
|
1877
|
+
private processTerminalResponses(): void {
|
|
1878
|
+
if (!this.wasmTerm) return;
|
|
1879
|
+
|
|
1880
|
+
// Read all pending responses from the WASM terminal
|
|
1881
|
+
// Multiple responses can be queued if a single write() contained multiple queries
|
|
1882
|
+
while (true) {
|
|
1883
|
+
const response = this.wasmTerm.readResponse();
|
|
1884
|
+
if (response === null) break;
|
|
1885
|
+
if (!this.answerQueries) continue;
|
|
1886
|
+
// Send response back to the PTY via onData
|
|
1887
|
+
// This is the same path as user keyboard input
|
|
1888
|
+
this.dataEmitter.fire(response);
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
// ============================================================================
|
|
1893
|
+
// Host hooks: markers, clipboard, modes
|
|
1894
|
+
// ============================================================================
|
|
1895
|
+
|
|
1896
|
+
/**
|
|
1897
|
+
* Adds a marker on the cursor's line plus `cursorYOffset`, like xterm.js.
|
|
1898
|
+
* Returns undefined when that line is outside the active area.
|
|
1899
|
+
*/
|
|
1900
|
+
public registerMarker(cursorYOffset: number = 0): IMarker | undefined {
|
|
1901
|
+
if (!this.wasmTerm) return undefined;
|
|
1902
|
+
const y = this.wasmTerm.cursorPosition().y + cursorYOffset;
|
|
1903
|
+
if (y < 0 || y >= this.rows) return undefined;
|
|
1904
|
+
const row = this.wasmTerm.trackRow(y);
|
|
1905
|
+
if (!row) return undefined;
|
|
1906
|
+
const marker = new Marker(row);
|
|
1907
|
+
this.markers.add(marker);
|
|
1908
|
+
marker.onDispose(() => this.markers.delete(marker));
|
|
1909
|
+
return marker;
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
/**
|
|
1913
|
+
* Decides OSC 52 clipboard writes synchronously (true allows the write).
|
|
1914
|
+
* Unset, every write is denied; clipboard reads are never answered.
|
|
1915
|
+
*/
|
|
1916
|
+
public set clipboardWriteHandler(handler: ((text: string) => boolean) | null) {
|
|
1917
|
+
this.clipboardHandler = handler;
|
|
1918
|
+
if (this.wasmTerm) this.wasmTerm.clipboardWriteHandler = handler;
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
public get clipboardWriteHandler(): ((text: string) => boolean) | null {
|
|
1922
|
+
return this.clipboardHandler;
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
/** xterm.js-compatible terminal modes. */
|
|
1926
|
+
public get modes(): {
|
|
1927
|
+
mouseTrackingMode: MouseTrackingMode;
|
|
1928
|
+
bracketedPasteMode: boolean;
|
|
1929
|
+
applicationCursorKeysMode: boolean;
|
|
1930
|
+
sendFocusMode: boolean;
|
|
1931
|
+
} {
|
|
1932
|
+
return {
|
|
1933
|
+
mouseTrackingMode: this.wasmTerm?.mouseTrackingMode() ?? 'none',
|
|
1934
|
+
bracketedPasteMode: this.wasmTerm?.hasBracketedPaste() ?? false,
|
|
1935
|
+
applicationCursorKeysMode: this.wasmTerm?.getMode(1, false) ?? false,
|
|
1936
|
+
sendFocusMode: this.wasmTerm?.hasFocusEvents() ?? false,
|
|
1937
|
+
};
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
/** Keeps the render loop running for a while; called whenever something may need drawing. */
|
|
1941
|
+
public wake(): void {
|
|
1942
|
+
this.idleFrames = 0;
|
|
1943
|
+
if (this.isOpen && !this.isDisposed) this.startRenderLoop();
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
private wireEngine(): void {
|
|
1947
|
+
for (const sub of this.engineSubscriptions) sub.dispose();
|
|
1948
|
+
const engine = this.wasmTerm!;
|
|
1949
|
+
engine.clipboardWriteHandler = this.clipboardHandler;
|
|
1950
|
+
this.lastAlternate = engine.isAlternateScreen();
|
|
1951
|
+
this.engineSubscriptions = [
|
|
1952
|
+
engine.onBell(() => this.bellEmitter.fire()),
|
|
1953
|
+
engine.onTitleChange((title) => {
|
|
1954
|
+
if (title === this.currentTitle) return;
|
|
1955
|
+
this.currentTitle = title;
|
|
1956
|
+
this.titleChangeEmitter.fire(title);
|
|
1957
|
+
}),
|
|
1958
|
+
engine.onPwdChange((pwd) => this.pwdEmitter.fire(pwd)),
|
|
1959
|
+
engine.onSemanticPrompt((event) => this.semanticPromptEmitter.fire(event)),
|
|
1960
|
+
engine.onDesktopNotification((n) => this.desktopNotificationEmitter.fire(n)),
|
|
1961
|
+
engine.onUnknownOsc((content) => this.unknownOscEmitter.fire(content)),
|
|
1962
|
+
];
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
private afterWrite(): void {
|
|
1966
|
+
const engine = this.wasmTerm;
|
|
1967
|
+
if (!engine) return;
|
|
1968
|
+
const alternate = engine.isAlternateScreen();
|
|
1969
|
+
if (alternate !== this.lastAlternate) {
|
|
1970
|
+
this.lastAlternate = alternate;
|
|
1971
|
+
(this.buffer as BufferNamespace)._fireBufferChange(this.buffer.active);
|
|
1972
|
+
}
|
|
1973
|
+
for (const marker of [...this.markers]) marker.refresh();
|
|
1974
|
+
this.wake();
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
/** Fires onScroll with the absolute line at the top of the viewport, as xterm.js does. */
|
|
1978
|
+
private fireScroll(): void {
|
|
1979
|
+
this.scrollEmitter.fire(this.buffer.active.viewportY);
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
private disposeMarkers(): void {
|
|
1983
|
+
for (const marker of [...this.markers]) marker.dispose();
|
|
1984
|
+
this.markers.clear();
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
// ============================================================================
|
|
1988
|
+
// Terminal Modes
|
|
1989
|
+
// ============================================================================
|
|
1990
|
+
|
|
1991
|
+
/**
|
|
1992
|
+
* Query terminal mode state
|
|
1993
|
+
*
|
|
1994
|
+
* @param mode Mode number (e.g., 2004 for bracketed paste)
|
|
1995
|
+
* @param isAnsi True for ANSI modes, false for DEC modes (default: false)
|
|
1996
|
+
* @returns true if mode is enabled
|
|
1997
|
+
*/
|
|
1998
|
+
public getMode(mode: number, isAnsi: boolean = false): boolean {
|
|
1999
|
+
this.assertOpen();
|
|
2000
|
+
return this.wasmTerm!.getMode(mode, isAnsi);
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
/**
|
|
2004
|
+
* Check if bracketed paste mode is enabled
|
|
2005
|
+
*/
|
|
2006
|
+
public hasBracketedPaste(): boolean {
|
|
2007
|
+
this.assertOpen();
|
|
2008
|
+
return this.wasmTerm!.hasBracketedPaste();
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
/**
|
|
2012
|
+
* Check if focus event reporting is enabled
|
|
2013
|
+
*/
|
|
2014
|
+
public hasFocusEvents(): boolean {
|
|
2015
|
+
this.assertOpen();
|
|
2016
|
+
return this.wasmTerm!.hasFocusEvents();
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
/**
|
|
2020
|
+
* Check if mouse tracking is enabled
|
|
2021
|
+
*/
|
|
2022
|
+
public hasMouseTracking(): boolean {
|
|
2023
|
+
this.assertOpen();
|
|
2024
|
+
return this.wasmTerm!.hasMouseTracking();
|
|
2025
|
+
}
|
|
2026
|
+
}
|