@aurigax-ai/ghostty-web 0.0.0-stage → 0.2.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 +94 -2
- package/ghostty-vt.wasm +0 -0
- package/lib/abi.ts +227 -0
- package/lib/addons/fit.ts +242 -0
- package/lib/box-glyphs.ts +278 -0
- package/lib/buffer.ts +402 -0
- package/lib/event-emitter.ts +27 -0
- package/lib/ghostty.ts +1253 -0
- package/lib/index.ts +111 -0
- package/lib/input-handler.ts +1116 -0
- package/lib/interfaces.ts +224 -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 +820 -0
- package/lib/selection-manager.ts +1057 -0
- package/lib/terminal.ts +2054 -0
- package/lib/types.ts +648 -0
- package/lib/webgl-renderer.ts +870 -0
- package/package.json +70 -4
|
@@ -0,0 +1,1057 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Selection Manager - Handles text selection in the terminal
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Mouse drag selection
|
|
6
|
+
* - Double-click word selection
|
|
7
|
+
* - Text extraction from terminal buffer
|
|
8
|
+
* - Automatic clipboard copy
|
|
9
|
+
* - Visual selection highlighting (integrated into CanvasRenderer cell rendering)
|
|
10
|
+
* - Auto-scroll during drag selection
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { EventEmitter } from './event-emitter';
|
|
14
|
+
import type { GhosttyTerminal } from './ghostty';
|
|
15
|
+
import type { IEvent } from './interfaces';
|
|
16
|
+
import type { TerminalRenderer } from './renderer';
|
|
17
|
+
import type { Terminal } from './terminal';
|
|
18
|
+
import type { GhosttyCell } from './types';
|
|
19
|
+
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// Type Definitions
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
24
|
+
export interface SelectionCoordinates {
|
|
25
|
+
startCol: number;
|
|
26
|
+
startRow: number;
|
|
27
|
+
endCol: number;
|
|
28
|
+
endRow: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ============================================================================
|
|
32
|
+
// SelectionManager Class
|
|
33
|
+
// ============================================================================
|
|
34
|
+
|
|
35
|
+
export class SelectionManager {
|
|
36
|
+
private terminal: Terminal;
|
|
37
|
+
private renderer: TerminalRenderer;
|
|
38
|
+
private wasmTerm: GhosttyTerminal;
|
|
39
|
+
private textarea: HTMLTextAreaElement;
|
|
40
|
+
|
|
41
|
+
// Selection state - coordinates are in ABSOLUTE buffer space (viewportY + viewportRow)
|
|
42
|
+
// This ensures selection persists correctly when scrolling
|
|
43
|
+
private selectionStart: { col: number; absoluteRow: number } | null = null;
|
|
44
|
+
private selectionEnd: { col: number; absoluteRow: number } | null = null;
|
|
45
|
+
private isSelecting: boolean = false;
|
|
46
|
+
private mouseDownX: number = 0;
|
|
47
|
+
private mouseDownY: number = 0;
|
|
48
|
+
private dragThresholdMet: boolean = false;
|
|
49
|
+
private mouseDownTarget: EventTarget | null = null; // Track where mousedown occurred
|
|
50
|
+
|
|
51
|
+
// Track rows that need redraw for clearing old selection
|
|
52
|
+
// Using a Set prevents the overwrite bug where mousemove would clobber
|
|
53
|
+
// the rows marked by clearSelection()
|
|
54
|
+
private dirtySelectionRows: Set<number> = new Set();
|
|
55
|
+
|
|
56
|
+
// Event emitter
|
|
57
|
+
private selectionChangedEmitter = new EventEmitter<void>();
|
|
58
|
+
|
|
59
|
+
// Store bound event handlers for cleanup
|
|
60
|
+
private boundMouseUpHandler: ((e: MouseEvent) => void) | null = null;
|
|
61
|
+
private boundContextMenuHandler: ((e: MouseEvent) => void) | null = null;
|
|
62
|
+
private boundClickHandler: ((e: MouseEvent) => void) | null = null;
|
|
63
|
+
private boundDocumentMouseMoveHandler: ((e: MouseEvent) => void) | null = null;
|
|
64
|
+
|
|
65
|
+
// Auto-scroll state for drag selection
|
|
66
|
+
private autoScrollInterval: ReturnType<typeof setInterval> | null = null;
|
|
67
|
+
private autoScrollDirection: number = 0; // -1 = up, 0 = none, 1 = down
|
|
68
|
+
private static readonly AUTO_SCROLL_EDGE_SIZE = 30; // pixels from edge to trigger scroll
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Get current viewport Y position (how many lines scrolled into history)
|
|
72
|
+
*/
|
|
73
|
+
private getViewportY(): number {
|
|
74
|
+
const rawViewportY =
|
|
75
|
+
typeof (this.terminal as any).getViewportY === 'function'
|
|
76
|
+
? (this.terminal as any).getViewportY()
|
|
77
|
+
: (this.terminal as any).viewportY || 0;
|
|
78
|
+
return Math.max(0, Math.floor(rawViewportY));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Convert viewport row to absolute buffer row
|
|
83
|
+
* Absolute row is an index into combined buffer: scrollback (0 to len-1) + screen (len to len+rows-1)
|
|
84
|
+
*/
|
|
85
|
+
private viewportRowToAbsolute(viewportRow: number): number {
|
|
86
|
+
const scrollbackLength = this.wasmTerm.getScrollbackLength();
|
|
87
|
+
const viewportY = this.getViewportY();
|
|
88
|
+
return scrollbackLength + viewportRow - viewportY;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Convert absolute buffer row to viewport row (may be outside visible range)
|
|
93
|
+
*/
|
|
94
|
+
private absoluteRowToViewport(absoluteRow: number): number {
|
|
95
|
+
const scrollbackLength = this.wasmTerm.getScrollbackLength();
|
|
96
|
+
const viewportY = this.getViewportY();
|
|
97
|
+
return absoluteRow - scrollbackLength + viewportY;
|
|
98
|
+
}
|
|
99
|
+
private static readonly AUTO_SCROLL_SPEED = 3; // lines per interval
|
|
100
|
+
private static readonly AUTO_SCROLL_INTERVAL = 50; // ms between scroll steps
|
|
101
|
+
|
|
102
|
+
constructor(
|
|
103
|
+
terminal: Terminal,
|
|
104
|
+
renderer: TerminalRenderer,
|
|
105
|
+
wasmTerm: GhosttyTerminal,
|
|
106
|
+
textarea: HTMLTextAreaElement
|
|
107
|
+
) {
|
|
108
|
+
this.terminal = terminal;
|
|
109
|
+
this.renderer = renderer;
|
|
110
|
+
this.wasmTerm = wasmTerm;
|
|
111
|
+
this.textarea = textarea;
|
|
112
|
+
|
|
113
|
+
// Attach mouse event listeners
|
|
114
|
+
this.attachEventListeners();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Points the selection at a new renderer drawing the same canvas. */
|
|
118
|
+
setRenderer(renderer: TerminalRenderer): void {
|
|
119
|
+
this.renderer = renderer;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ==========================================================================
|
|
123
|
+
// Public API
|
|
124
|
+
// ==========================================================================
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Get the selected text as a string
|
|
128
|
+
*/
|
|
129
|
+
getSelection(): string {
|
|
130
|
+
if (!this.selectionStart || !this.selectionEnd) return '';
|
|
131
|
+
|
|
132
|
+
// Get absolute row coordinates (not clamped to viewport)
|
|
133
|
+
let { col: startCol, absoluteRow: startAbsRow } = this.selectionStart;
|
|
134
|
+
let { col: endCol, absoluteRow: endAbsRow } = this.selectionEnd;
|
|
135
|
+
|
|
136
|
+
// Swap if selection goes backwards
|
|
137
|
+
if (startAbsRow > endAbsRow || (startAbsRow === endAbsRow && startCol > endCol)) {
|
|
138
|
+
[startCol, endCol] = [endCol, startCol];
|
|
139
|
+
[startAbsRow, endAbsRow] = [endAbsRow, startAbsRow];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const scrollbackLength = this.wasmTerm.getScrollbackLength();
|
|
143
|
+
let text = '';
|
|
144
|
+
|
|
145
|
+
for (let absRow = startAbsRow; absRow <= endAbsRow; absRow++) {
|
|
146
|
+
// Fetch line based on absolute row position
|
|
147
|
+
// Absolute row < scrollbackLength means it's in scrollback
|
|
148
|
+
// Absolute row >= scrollbackLength means it's in the screen buffer
|
|
149
|
+
let line: GhosttyCell[] | null = null;
|
|
150
|
+
|
|
151
|
+
if (absRow < scrollbackLength) {
|
|
152
|
+
// Row is in scrollback
|
|
153
|
+
line = this.wasmTerm.getScrollbackLine(absRow);
|
|
154
|
+
} else {
|
|
155
|
+
// Row is in screen buffer
|
|
156
|
+
const screenRow = absRow - scrollbackLength;
|
|
157
|
+
line = this.wasmTerm.getLine(screenRow);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!line) continue;
|
|
161
|
+
|
|
162
|
+
// Track the last non-empty column for trimming trailing spaces
|
|
163
|
+
let lastNonEmpty = -1;
|
|
164
|
+
|
|
165
|
+
// Determine column range for this row
|
|
166
|
+
const colStart = absRow === startAbsRow ? startCol : 0;
|
|
167
|
+
const colEnd = absRow === endAbsRow ? endCol : line.length - 1;
|
|
168
|
+
|
|
169
|
+
// Build the line text
|
|
170
|
+
let lineText = '';
|
|
171
|
+
for (let col = colStart; col <= colEnd; col++) {
|
|
172
|
+
const cell = line[col];
|
|
173
|
+
if (cell && cell.codepoint !== 0) {
|
|
174
|
+
// Use grapheme lookup for cells with multi-codepoint characters
|
|
175
|
+
let char: string;
|
|
176
|
+
if (cell.grapheme_len > 0) {
|
|
177
|
+
// Row is in scrollback or screen - determine which and use appropriate method
|
|
178
|
+
if (absRow < scrollbackLength) {
|
|
179
|
+
char = this.wasmTerm.getScrollbackGraphemeString(absRow, col);
|
|
180
|
+
} else {
|
|
181
|
+
const screenRow = absRow - scrollbackLength;
|
|
182
|
+
char = this.wasmTerm.getGraphemeString(screenRow, col);
|
|
183
|
+
}
|
|
184
|
+
} else {
|
|
185
|
+
char = String.fromCodePoint(cell.codepoint);
|
|
186
|
+
}
|
|
187
|
+
lineText += char;
|
|
188
|
+
if (char.trim()) {
|
|
189
|
+
lastNonEmpty = lineText.length;
|
|
190
|
+
}
|
|
191
|
+
} else {
|
|
192
|
+
lineText += ' ';
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Trim trailing spaces from each line
|
|
197
|
+
if (lastNonEmpty >= 0) {
|
|
198
|
+
lineText = lineText.substring(0, lastNonEmpty);
|
|
199
|
+
} else {
|
|
200
|
+
lineText = '';
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
text += lineText;
|
|
204
|
+
|
|
205
|
+
// Add newline between rows (but not after the last row)
|
|
206
|
+
if (absRow < endAbsRow) {
|
|
207
|
+
text += '\n';
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return text;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Check if there's an active selection
|
|
216
|
+
*/
|
|
217
|
+
hasSelection(): boolean {
|
|
218
|
+
if (!this.selectionStart || !this.selectionEnd) return false;
|
|
219
|
+
|
|
220
|
+
// Don't report selection until drag threshold is met (prevents flash on click)
|
|
221
|
+
if (this.isSelecting && !this.dragThresholdMet) return false;
|
|
222
|
+
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Copy the current selection to clipboard
|
|
228
|
+
* @returns true if there was text to copy, false otherwise
|
|
229
|
+
*/
|
|
230
|
+
copySelection(): boolean {
|
|
231
|
+
if (!this.hasSelection()) return false;
|
|
232
|
+
|
|
233
|
+
const text = this.getSelection();
|
|
234
|
+
if (text) {
|
|
235
|
+
this.copyToClipboard(text);
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Clear the selection
|
|
243
|
+
*/
|
|
244
|
+
clearSelection(): void {
|
|
245
|
+
if (!this.hasSelection()) return;
|
|
246
|
+
|
|
247
|
+
// Mark current selection rows as dirty for redraw
|
|
248
|
+
const coords = this.normalizeSelection();
|
|
249
|
+
if (coords) {
|
|
250
|
+
for (let row = coords.startRow; row <= coords.endRow; row++) {
|
|
251
|
+
this.dirtySelectionRows.add(row);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
this.selectionStart = null;
|
|
256
|
+
this.selectionEnd = null;
|
|
257
|
+
this.isSelecting = false;
|
|
258
|
+
|
|
259
|
+
// Force redraw of previously selected lines to clear the overlay
|
|
260
|
+
this.requestRender();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Select all text in the terminal
|
|
265
|
+
*/
|
|
266
|
+
selectAll(): void {
|
|
267
|
+
const dims = this.wasmTerm.getDimensions();
|
|
268
|
+
const viewportY = this.getViewportY();
|
|
269
|
+
this.selectionStart = { col: 0, absoluteRow: viewportY };
|
|
270
|
+
this.selectionEnd = { col: dims.cols - 1, absoluteRow: viewportY + dims.rows - 1 };
|
|
271
|
+
this.requestRender();
|
|
272
|
+
this.selectionChangedEmitter.fire();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Select text at specific column and row with length
|
|
277
|
+
* xterm.js compatible API
|
|
278
|
+
*/
|
|
279
|
+
select(column: number, row: number, length: number): void {
|
|
280
|
+
// Clamp to valid ranges
|
|
281
|
+
const dims = this.wasmTerm.getDimensions();
|
|
282
|
+
row = Math.max(0, Math.min(row, dims.rows - 1));
|
|
283
|
+
column = Math.max(0, Math.min(column, dims.cols - 1));
|
|
284
|
+
|
|
285
|
+
// Calculate end position
|
|
286
|
+
let endRow = row;
|
|
287
|
+
let endCol = column + length - 1;
|
|
288
|
+
|
|
289
|
+
// Handle wrapping if selection extends past end of line
|
|
290
|
+
while (endCol >= dims.cols) {
|
|
291
|
+
endCol -= dims.cols;
|
|
292
|
+
endRow++;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Clamp end row
|
|
296
|
+
endRow = Math.min(endRow, dims.rows - 1);
|
|
297
|
+
|
|
298
|
+
// Convert viewport rows to absolute rows
|
|
299
|
+
const viewportY = this.getViewportY();
|
|
300
|
+
this.selectionStart = { col: column, absoluteRow: viewportY + row };
|
|
301
|
+
this.selectionEnd = { col: endCol, absoluteRow: viewportY + endRow };
|
|
302
|
+
this.requestRender();
|
|
303
|
+
this.selectionChangedEmitter.fire();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Select entire lines from start to end
|
|
308
|
+
* xterm.js compatible API
|
|
309
|
+
*/
|
|
310
|
+
selectLines(start: number, end: number): void {
|
|
311
|
+
const dims = this.wasmTerm.getDimensions();
|
|
312
|
+
|
|
313
|
+
// Clamp to valid row ranges
|
|
314
|
+
start = Math.max(0, Math.min(start, dims.rows - 1));
|
|
315
|
+
end = Math.max(0, Math.min(end, dims.rows - 1));
|
|
316
|
+
|
|
317
|
+
// Ensure start <= end
|
|
318
|
+
if (start > end) {
|
|
319
|
+
[start, end] = [end, start];
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Convert viewport rows to absolute rows
|
|
323
|
+
this.selectionStart = { col: 0, absoluteRow: this.viewportRowToAbsolute(start) };
|
|
324
|
+
this.selectionEnd = { col: dims.cols - 1, absoluteRow: this.viewportRowToAbsolute(end) };
|
|
325
|
+
this.requestRender();
|
|
326
|
+
this.selectionChangedEmitter.fire();
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Get selection position as buffer range
|
|
331
|
+
* xterm.js compatible API
|
|
332
|
+
*/
|
|
333
|
+
getSelectionPosition():
|
|
334
|
+
{ start: { x: number; y: number }; end: { x: number; y: number } } | undefined {
|
|
335
|
+
const coords = this.normalizeSelection();
|
|
336
|
+
if (!coords) return undefined;
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
start: { x: coords.startCol, y: coords.startRow },
|
|
340
|
+
end: { x: coords.endCol, y: coords.endRow },
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Deselect all text
|
|
346
|
+
* xterm.js compatible API
|
|
347
|
+
*/
|
|
348
|
+
deselect(): void {
|
|
349
|
+
this.clearSelection();
|
|
350
|
+
this.selectionChangedEmitter.fire();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Focus the terminal (make it receive keyboard input)
|
|
355
|
+
*/
|
|
356
|
+
focus(): void {
|
|
357
|
+
const canvas = this.renderer.getCanvas();
|
|
358
|
+
if (canvas.parentElement) {
|
|
359
|
+
canvas.parentElement.focus();
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Get current selection coordinates (for rendering)
|
|
365
|
+
*/
|
|
366
|
+
getSelectionCoords(): SelectionCoordinates | null {
|
|
367
|
+
return this.normalizeSelection();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Get dirty selection rows that need redraw (for clearing old highlight)
|
|
372
|
+
*/
|
|
373
|
+
getDirtySelectionRows(): Set<number> {
|
|
374
|
+
return this.dirtySelectionRows;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Clear the dirty selection rows tracking (after redraw)
|
|
379
|
+
*/
|
|
380
|
+
clearDirtySelectionRows(): void {
|
|
381
|
+
this.dirtySelectionRows.clear();
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Get selection change event accessor
|
|
386
|
+
*/
|
|
387
|
+
get onSelectionChange(): IEvent<void> {
|
|
388
|
+
return this.selectionChangedEmitter.event;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Cleanup resources
|
|
393
|
+
*/
|
|
394
|
+
dispose(): void {
|
|
395
|
+
this.selectionChangedEmitter.dispose();
|
|
396
|
+
|
|
397
|
+
// Stop auto-scroll if active
|
|
398
|
+
this.stopAutoScroll();
|
|
399
|
+
|
|
400
|
+
// Clean up document event listener
|
|
401
|
+
if (this.boundMouseUpHandler) {
|
|
402
|
+
document.removeEventListener('mouseup', this.boundMouseUpHandler);
|
|
403
|
+
this.boundMouseUpHandler = null;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Clean up document mousemove listener
|
|
407
|
+
if (this.boundDocumentMouseMoveHandler) {
|
|
408
|
+
document.removeEventListener('mousemove', this.boundDocumentMouseMoveHandler);
|
|
409
|
+
this.boundDocumentMouseMoveHandler = null;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Clean up context menu event listener
|
|
413
|
+
if (this.boundContextMenuHandler) {
|
|
414
|
+
const canvas = this.renderer.getCanvas();
|
|
415
|
+
canvas.removeEventListener('contextmenu', this.boundContextMenuHandler);
|
|
416
|
+
this.boundContextMenuHandler = null;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Clean up document click listener
|
|
420
|
+
if (this.boundClickHandler) {
|
|
421
|
+
document.removeEventListener('click', this.boundClickHandler);
|
|
422
|
+
this.boundClickHandler = null;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Canvas event listeners will be cleaned up when canvas is removed from DOM
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ==========================================================================
|
|
429
|
+
// Private Methods
|
|
430
|
+
// ==========================================================================
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Attach mouse event listeners to canvas
|
|
434
|
+
*/
|
|
435
|
+
private attachEventListeners(): void {
|
|
436
|
+
const canvas = this.renderer.getCanvas();
|
|
437
|
+
|
|
438
|
+
// Mouse down - start selection or clear existing
|
|
439
|
+
canvas.addEventListener('mousedown', (e: MouseEvent) => {
|
|
440
|
+
if (e.button === 0) {
|
|
441
|
+
// Left click only
|
|
442
|
+
|
|
443
|
+
// CRITICAL: Focus the terminal so it can receive keyboard input
|
|
444
|
+
// The canvas doesn't have tabindex, but the parent container does
|
|
445
|
+
if (canvas.parentElement) {
|
|
446
|
+
canvas.parentElement.focus();
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const cell = this.pixelToCell(e.offsetX, e.offsetY);
|
|
450
|
+
|
|
451
|
+
// Always clear previous selection on new click
|
|
452
|
+
const hadSelection = this.hasSelection();
|
|
453
|
+
if (hadSelection) {
|
|
454
|
+
this.clearSelection();
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Start new selection (convert to absolute coordinates)
|
|
458
|
+
const absoluteRow = this.viewportRowToAbsolute(cell.row);
|
|
459
|
+
this.selectionStart = { col: cell.col, absoluteRow };
|
|
460
|
+
this.selectionEnd = { col: cell.col, absoluteRow };
|
|
461
|
+
this.isSelecting = true;
|
|
462
|
+
this.mouseDownX = e.offsetX;
|
|
463
|
+
this.mouseDownY = e.offsetY;
|
|
464
|
+
this.dragThresholdMet = false;
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
// Mouse move on canvas - update selection
|
|
469
|
+
canvas.addEventListener('mousemove', (e: MouseEvent) => {
|
|
470
|
+
if (this.isSelecting) {
|
|
471
|
+
// Check if drag threshold has been met
|
|
472
|
+
if (!this.dragThresholdMet) {
|
|
473
|
+
const dx = e.offsetX - this.mouseDownX;
|
|
474
|
+
const dy = e.offsetY - this.mouseDownY;
|
|
475
|
+
// Use 50% of cell width as threshold to scale with font size
|
|
476
|
+
const threshold = this.renderer.getMetrics().width * 0.5;
|
|
477
|
+
if (dx * dx + dy * dy < threshold * threshold) {
|
|
478
|
+
return; // Below threshold, ignore
|
|
479
|
+
}
|
|
480
|
+
this.dragThresholdMet = true;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Mark current selection rows as dirty before updating
|
|
484
|
+
this.markCurrentSelectionDirty();
|
|
485
|
+
|
|
486
|
+
const cell = this.pixelToCell(e.offsetX, e.offsetY);
|
|
487
|
+
const absoluteRow = this.viewportRowToAbsolute(cell.row);
|
|
488
|
+
this.selectionEnd = { col: cell.col, absoluteRow };
|
|
489
|
+
this.requestRender();
|
|
490
|
+
|
|
491
|
+
// Check if near edges for auto-scroll
|
|
492
|
+
this.updateAutoScroll(e.offsetY, canvas.clientHeight);
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
// Mouse leave - check for auto-scroll when leaving canvas during drag
|
|
497
|
+
canvas.addEventListener('mouseleave', (e: MouseEvent) => {
|
|
498
|
+
if (this.isSelecting) {
|
|
499
|
+
// Determine scroll direction based on where mouse left
|
|
500
|
+
const rect = canvas.getBoundingClientRect();
|
|
501
|
+
if (e.clientY < rect.top) {
|
|
502
|
+
this.startAutoScroll(-1); // Scroll up
|
|
503
|
+
} else if (e.clientY > rect.bottom) {
|
|
504
|
+
this.startAutoScroll(1); // Scroll down
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
// Mouse enter - stop auto-scroll when mouse returns to canvas
|
|
510
|
+
canvas.addEventListener('mouseenter', () => {
|
|
511
|
+
if (this.isSelecting) {
|
|
512
|
+
this.stopAutoScroll();
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
// Document-level mousemove for tracking mouse position during drag outside canvas
|
|
517
|
+
this.boundDocumentMouseMoveHandler = (e: MouseEvent) => {
|
|
518
|
+
if (this.isSelecting) {
|
|
519
|
+
// Check drag threshold (same as canvas mousemove)
|
|
520
|
+
if (!this.dragThresholdMet) {
|
|
521
|
+
const dx = e.clientX - (canvas.getBoundingClientRect().left + this.mouseDownX);
|
|
522
|
+
const dy = e.clientY - (canvas.getBoundingClientRect().top + this.mouseDownY);
|
|
523
|
+
const threshold = this.renderer.getMetrics().width * 0.5;
|
|
524
|
+
if (dx * dx + dy * dy < threshold * threshold) {
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
this.dragThresholdMet = true;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const rect = canvas.getBoundingClientRect();
|
|
531
|
+
|
|
532
|
+
// Update selection based on clamped position
|
|
533
|
+
const clampedX = Math.max(rect.left, Math.min(e.clientX, rect.right));
|
|
534
|
+
const clampedY = Math.max(rect.top, Math.min(e.clientY, rect.bottom));
|
|
535
|
+
|
|
536
|
+
// Convert to canvas-relative coordinates
|
|
537
|
+
const offsetX = clampedX - rect.left;
|
|
538
|
+
const offsetY = clampedY - rect.top;
|
|
539
|
+
|
|
540
|
+
// Only update if mouse is outside the canvas
|
|
541
|
+
if (
|
|
542
|
+
e.clientX < rect.left ||
|
|
543
|
+
e.clientX > rect.right ||
|
|
544
|
+
e.clientY < rect.top ||
|
|
545
|
+
e.clientY > rect.bottom
|
|
546
|
+
) {
|
|
547
|
+
// Update auto-scroll direction based on mouse position
|
|
548
|
+
if (e.clientY < rect.top) {
|
|
549
|
+
this.startAutoScroll(-1);
|
|
550
|
+
} else if (e.clientY > rect.bottom) {
|
|
551
|
+
this.startAutoScroll(1);
|
|
552
|
+
} else {
|
|
553
|
+
this.stopAutoScroll();
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Only update selection position if NOT auto-scrolling
|
|
557
|
+
// During auto-scroll, the scroll handler extends the selection
|
|
558
|
+
if (this.autoScrollDirection === 0) {
|
|
559
|
+
// Mark current selection rows as dirty before updating
|
|
560
|
+
this.markCurrentSelectionDirty();
|
|
561
|
+
|
|
562
|
+
const cell = this.pixelToCell(offsetX, offsetY);
|
|
563
|
+
const absoluteRow = this.viewportRowToAbsolute(cell.row);
|
|
564
|
+
this.selectionEnd = { col: cell.col, absoluteRow };
|
|
565
|
+
this.requestRender();
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
};
|
|
570
|
+
document.addEventListener('mousemove', this.boundDocumentMouseMoveHandler);
|
|
571
|
+
|
|
572
|
+
// Track mousedown on document to know if a click started inside the canvas
|
|
573
|
+
document.addEventListener('mousedown', (e: MouseEvent) => {
|
|
574
|
+
this.mouseDownTarget = e.target;
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
// CRITICAL FIX: Listen for mouseup on DOCUMENT, not just canvas
|
|
578
|
+
// This catches mouseup events that happen outside the canvas (common during drag)
|
|
579
|
+
this.boundMouseUpHandler = () => {
|
|
580
|
+
if (this.isSelecting) {
|
|
581
|
+
this.isSelecting = false;
|
|
582
|
+
this.stopAutoScroll();
|
|
583
|
+
|
|
584
|
+
// Check if this was a click without drag (threshold never met).
|
|
585
|
+
if (!this.dragThresholdMet) {
|
|
586
|
+
this.clearSelection();
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (this.hasSelection()) {
|
|
591
|
+
const text = this.getSelection();
|
|
592
|
+
if (text) {
|
|
593
|
+
this.copyToClipboard(text);
|
|
594
|
+
this.selectionChangedEmitter.fire();
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
document.addEventListener('mouseup', this.boundMouseUpHandler);
|
|
600
|
+
|
|
601
|
+
// Handle click events for double-click (word) and triple-click (line) selection
|
|
602
|
+
// Use event.detail which browsers set to click count (1, 2, 3, etc.)
|
|
603
|
+
canvas.addEventListener('click', (e: MouseEvent) => {
|
|
604
|
+
// event.detail: 1 = single, 2 = double, 3 = triple click
|
|
605
|
+
if (e.detail === 2) {
|
|
606
|
+
// Double-click - select word
|
|
607
|
+
const cell = this.pixelToCell(e.offsetX, e.offsetY);
|
|
608
|
+
const word = this.getWordAtCell(cell.col, cell.row);
|
|
609
|
+
|
|
610
|
+
if (word) {
|
|
611
|
+
const absoluteRow = this.viewportRowToAbsolute(cell.row);
|
|
612
|
+
this.selectionStart = { col: word.startCol, absoluteRow };
|
|
613
|
+
this.selectionEnd = { col: word.endCol, absoluteRow };
|
|
614
|
+
this.requestRender();
|
|
615
|
+
|
|
616
|
+
const text = this.getSelection();
|
|
617
|
+
if (text) {
|
|
618
|
+
this.copyToClipboard(text);
|
|
619
|
+
this.selectionChangedEmitter.fire();
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
} else if (e.detail >= 3) {
|
|
623
|
+
// Triple-click (or more) - select line content (like native Ghostty)
|
|
624
|
+
const cell = this.pixelToCell(e.offsetX, e.offsetY);
|
|
625
|
+
const absoluteRow = this.viewportRowToAbsolute(cell.row);
|
|
626
|
+
|
|
627
|
+
// Find actual line length (exclude trailing empty cells)
|
|
628
|
+
// Use scrollback-aware line retrieval (like getSelection does)
|
|
629
|
+
const scrollbackLength = this.wasmTerm.getScrollbackLength();
|
|
630
|
+
let line: GhosttyCell[] | null = null;
|
|
631
|
+
if (absoluteRow < scrollbackLength) {
|
|
632
|
+
// Row is in scrollback
|
|
633
|
+
line = this.wasmTerm.getScrollbackLine(absoluteRow);
|
|
634
|
+
} else {
|
|
635
|
+
// Row is in screen buffer
|
|
636
|
+
const screenRow = absoluteRow - scrollbackLength;
|
|
637
|
+
line = this.wasmTerm.getLine(screenRow);
|
|
638
|
+
}
|
|
639
|
+
// Find last non-empty cell (-1 means empty line)
|
|
640
|
+
let endCol = -1;
|
|
641
|
+
if (line) {
|
|
642
|
+
for (let i = line.length - 1; i >= 0; i--) {
|
|
643
|
+
if (line[i] && line[i].codepoint !== 0 && line[i].codepoint !== 32) {
|
|
644
|
+
endCol = i;
|
|
645
|
+
break;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Only select if line has content (endCol >= 0)
|
|
651
|
+
if (endCol >= 0) {
|
|
652
|
+
// Select line content only (not trailing whitespace)
|
|
653
|
+
this.selectionStart = { col: 0, absoluteRow };
|
|
654
|
+
this.selectionEnd = { col: endCol, absoluteRow };
|
|
655
|
+
this.requestRender();
|
|
656
|
+
|
|
657
|
+
const text = this.getSelection();
|
|
658
|
+
if (text) {
|
|
659
|
+
this.copyToClipboard(text);
|
|
660
|
+
this.selectionChangedEmitter.fire();
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
// Right-click (context menu) - position textarea to show browser's native menu
|
|
667
|
+
// This allows Copy/Paste options to appear in the context menu
|
|
668
|
+
this.boundContextMenuHandler = (e: MouseEvent) => {
|
|
669
|
+
// Position textarea at mouse cursor
|
|
670
|
+
this.textarea.style.position = 'fixed';
|
|
671
|
+
this.textarea.style.left = `${e.clientX}px`;
|
|
672
|
+
this.textarea.style.top = `${e.clientY}px`;
|
|
673
|
+
this.textarea.style.width = '1px';
|
|
674
|
+
this.textarea.style.height = '1px';
|
|
675
|
+
this.textarea.style.zIndex = '1000';
|
|
676
|
+
this.textarea.style.opacity = '0';
|
|
677
|
+
|
|
678
|
+
// Enable pointer events temporarily so context menu targets the textarea
|
|
679
|
+
this.textarea.style.pointerEvents = 'auto';
|
|
680
|
+
|
|
681
|
+
// If there's a selection, populate textarea with it and select the text
|
|
682
|
+
if (this.hasSelection()) {
|
|
683
|
+
const text = this.getSelection();
|
|
684
|
+
this.textarea.value = text;
|
|
685
|
+
this.textarea.select();
|
|
686
|
+
this.textarea.setSelectionRange(0, text.length);
|
|
687
|
+
} else {
|
|
688
|
+
// No selection - clear textarea but still show menu (for paste)
|
|
689
|
+
this.textarea.value = '';
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Focus the textarea so the context menu appears on it
|
|
693
|
+
this.textarea.focus();
|
|
694
|
+
|
|
695
|
+
// After a short delay, restore the textarea to its hidden state
|
|
696
|
+
// This allows the context menu to appear first
|
|
697
|
+
setTimeout(() => {
|
|
698
|
+
// Listen for when the context menu closes (user clicks away or selects an option)
|
|
699
|
+
const resetTextarea = () => {
|
|
700
|
+
this.textarea.style.pointerEvents = 'none';
|
|
701
|
+
this.textarea.style.zIndex = '-10';
|
|
702
|
+
this.textarea.style.width = '0';
|
|
703
|
+
this.textarea.style.height = '0';
|
|
704
|
+
this.textarea.style.left = '0';
|
|
705
|
+
this.textarea.style.top = '0';
|
|
706
|
+
this.textarea.value = '';
|
|
707
|
+
|
|
708
|
+
// Remove the one-time listeners
|
|
709
|
+
document.removeEventListener('click', resetTextarea);
|
|
710
|
+
document.removeEventListener('contextmenu', resetTextarea);
|
|
711
|
+
this.textarea.removeEventListener('blur', resetTextarea);
|
|
712
|
+
};
|
|
713
|
+
|
|
714
|
+
// Reset on any of these events (menu closed)
|
|
715
|
+
document.addEventListener('click', resetTextarea, { once: true });
|
|
716
|
+
document.addEventListener('contextmenu', resetTextarea, { once: true });
|
|
717
|
+
this.textarea.addEventListener('blur', resetTextarea, { once: true });
|
|
718
|
+
}, 10);
|
|
719
|
+
|
|
720
|
+
// Don't prevent default - let browser show the context menu on the textarea
|
|
721
|
+
};
|
|
722
|
+
|
|
723
|
+
canvas.addEventListener('contextmenu', this.boundContextMenuHandler);
|
|
724
|
+
|
|
725
|
+
// Click outside canvas - clear selection
|
|
726
|
+
// This allows users to deselect by clicking anywhere outside the terminal
|
|
727
|
+
this.boundClickHandler = (e: MouseEvent) => {
|
|
728
|
+
// Don't clear selection if we're actively selecting
|
|
729
|
+
if (this.isSelecting) {
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// A click is only valid for clearing selection if BOTH mousedown and mouseup
|
|
734
|
+
// happened outside the canvas. If mousedown was inside (drag selection),
|
|
735
|
+
// don't clear even if mouseup/click is outside.
|
|
736
|
+
const mouseDownWasInCanvas =
|
|
737
|
+
this.mouseDownTarget && canvas.contains(this.mouseDownTarget as Node);
|
|
738
|
+
if (mouseDownWasInCanvas) {
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// Check if the click is outside the canvas
|
|
743
|
+
const target = e.target as Node;
|
|
744
|
+
if (!canvas.contains(target)) {
|
|
745
|
+
// Clicked outside the canvas - clear selection
|
|
746
|
+
if (this.hasSelection()) {
|
|
747
|
+
this.clearSelection();
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
};
|
|
751
|
+
|
|
752
|
+
document.addEventListener('click', this.boundClickHandler);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Mark current selection rows as dirty for redraw
|
|
757
|
+
*/
|
|
758
|
+
private markCurrentSelectionDirty(): void {
|
|
759
|
+
const coords = this.normalizeSelection();
|
|
760
|
+
if (coords) {
|
|
761
|
+
for (let row = coords.startRow; row <= coords.endRow; row++) {
|
|
762
|
+
this.dirtySelectionRows.add(row);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Update auto-scroll based on mouse Y position within canvas
|
|
769
|
+
*/
|
|
770
|
+
private updateAutoScroll(offsetY: number, canvasHeight: number): void {
|
|
771
|
+
const edgeSize = SelectionManager.AUTO_SCROLL_EDGE_SIZE;
|
|
772
|
+
|
|
773
|
+
if (offsetY < edgeSize) {
|
|
774
|
+
// Near top edge - scroll up
|
|
775
|
+
this.startAutoScroll(-1);
|
|
776
|
+
} else if (offsetY > canvasHeight - edgeSize) {
|
|
777
|
+
// Near bottom edge - scroll down
|
|
778
|
+
this.startAutoScroll(1);
|
|
779
|
+
} else {
|
|
780
|
+
// In middle - stop scrolling
|
|
781
|
+
this.stopAutoScroll();
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Start auto-scrolling in the given direction
|
|
787
|
+
*/
|
|
788
|
+
private startAutoScroll(direction: number): void {
|
|
789
|
+
// Don't restart if already scrolling in same direction
|
|
790
|
+
if (this.autoScrollInterval !== null && this.autoScrollDirection === direction) {
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// Stop any existing scroll
|
|
795
|
+
this.stopAutoScroll();
|
|
796
|
+
|
|
797
|
+
this.autoScrollDirection = direction;
|
|
798
|
+
|
|
799
|
+
// Start scrolling interval
|
|
800
|
+
this.autoScrollInterval = setInterval(() => {
|
|
801
|
+
if (!this.isSelecting) {
|
|
802
|
+
this.stopAutoScroll();
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// Scroll the terminal to reveal more content in the direction user is dragging
|
|
807
|
+
// autoScrollDirection: -1 = dragging up (wants to see history), 1 = dragging down (wants to see newer)
|
|
808
|
+
// scrollLines convention: negative = scroll up into history, positive = scroll down to newer
|
|
809
|
+
// So direction maps directly to scrollLines sign
|
|
810
|
+
const scrollAmount = SelectionManager.AUTO_SCROLL_SPEED * this.autoScrollDirection;
|
|
811
|
+
(this.terminal as any).scrollLines(scrollAmount);
|
|
812
|
+
|
|
813
|
+
// Extend selection in the scroll direction
|
|
814
|
+
// Key insight: we need to EXTEND the selection, not reset it to viewport edge
|
|
815
|
+
if (this.selectionEnd) {
|
|
816
|
+
const dims = this.wasmTerm.getDimensions();
|
|
817
|
+
if (this.autoScrollDirection < 0) {
|
|
818
|
+
// Scrolling up - extend selection upward (decrease absoluteRow)
|
|
819
|
+
// Set to top of viewport, but only if it extends the selection
|
|
820
|
+
const topAbsoluteRow = this.viewportRowToAbsolute(0);
|
|
821
|
+
if (topAbsoluteRow < this.selectionEnd.absoluteRow) {
|
|
822
|
+
this.selectionEnd = { col: 0, absoluteRow: topAbsoluteRow };
|
|
823
|
+
}
|
|
824
|
+
} else {
|
|
825
|
+
// Scrolling down - extend selection downward (increase absoluteRow)
|
|
826
|
+
// Set to bottom of viewport, but only if it extends the selection
|
|
827
|
+
const bottomAbsoluteRow = this.viewportRowToAbsolute(dims.rows - 1);
|
|
828
|
+
if (bottomAbsoluteRow > this.selectionEnd.absoluteRow) {
|
|
829
|
+
this.selectionEnd = { col: dims.cols - 1, absoluteRow: bottomAbsoluteRow };
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
this.requestRender();
|
|
835
|
+
}, SelectionManager.AUTO_SCROLL_INTERVAL);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Stop auto-scrolling
|
|
840
|
+
*/
|
|
841
|
+
private stopAutoScroll(): void {
|
|
842
|
+
if (this.autoScrollInterval !== null) {
|
|
843
|
+
clearInterval(this.autoScrollInterval);
|
|
844
|
+
this.autoScrollInterval = null;
|
|
845
|
+
}
|
|
846
|
+
this.autoScrollDirection = 0;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Convert pixel coordinates to terminal cell coordinates
|
|
851
|
+
*/
|
|
852
|
+
private pixelToCell(x: number, y: number): { col: number; row: number } {
|
|
853
|
+
const metrics = this.renderer.getMetrics();
|
|
854
|
+
|
|
855
|
+
const col = Math.floor(x / metrics.width);
|
|
856
|
+
const row = Math.floor(y / metrics.height);
|
|
857
|
+
|
|
858
|
+
// Clamp to terminal bounds
|
|
859
|
+
return {
|
|
860
|
+
col: Math.max(0, Math.min(col, this.terminal.cols - 1)),
|
|
861
|
+
row: Math.max(0, Math.min(row, this.terminal.rows - 1)),
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* Normalize selection coordinates (handle backward selection)
|
|
867
|
+
* Returns coordinates in VIEWPORT space for rendering, clamped to visible area
|
|
868
|
+
*/
|
|
869
|
+
private normalizeSelection(): SelectionCoordinates | null {
|
|
870
|
+
if (!this.selectionStart || !this.selectionEnd) return null;
|
|
871
|
+
|
|
872
|
+
let { col: startCol, absoluteRow: startAbsRow } = this.selectionStart;
|
|
873
|
+
let { col: endCol, absoluteRow: endAbsRow } = this.selectionEnd;
|
|
874
|
+
|
|
875
|
+
// Swap if selection goes backwards
|
|
876
|
+
if (startAbsRow > endAbsRow || (startAbsRow === endAbsRow && startCol > endCol)) {
|
|
877
|
+
[startCol, endCol] = [endCol, startCol];
|
|
878
|
+
[startAbsRow, endAbsRow] = [endAbsRow, startAbsRow];
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// Convert to viewport coordinates
|
|
882
|
+
let startRow = this.absoluteRowToViewport(startAbsRow);
|
|
883
|
+
let endRow = this.absoluteRowToViewport(endAbsRow);
|
|
884
|
+
|
|
885
|
+
// Clamp to visible viewport range
|
|
886
|
+
const dims = this.wasmTerm.getDimensions();
|
|
887
|
+
const maxRow = dims.rows - 1;
|
|
888
|
+
|
|
889
|
+
// If entire selection is outside viewport, return null
|
|
890
|
+
if (endRow < 0 || startRow > maxRow) {
|
|
891
|
+
return null;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// Clamp rows to visible range, adjusting columns for partial rows
|
|
895
|
+
if (startRow < 0) {
|
|
896
|
+
startRow = 0;
|
|
897
|
+
startCol = 0; // Selection starts from beginning of first visible row
|
|
898
|
+
}
|
|
899
|
+
if (endRow > maxRow) {
|
|
900
|
+
endRow = maxRow;
|
|
901
|
+
endCol = dims.cols - 1; // Selection extends to end of last visible row
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
return { startCol, startRow, endCol, endRow };
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Get word boundaries at a cell position
|
|
909
|
+
*/
|
|
910
|
+
private getWordAtCell(col: number, row: number): { startCol: number; endCol: number } | null {
|
|
911
|
+
const absoluteRow = this.viewportRowToAbsolute(row);
|
|
912
|
+
const scrollbackLength = this.wasmTerm.getScrollbackLength();
|
|
913
|
+
let line: GhosttyCell[] | null;
|
|
914
|
+
if (absoluteRow < scrollbackLength) {
|
|
915
|
+
line = this.wasmTerm.getScrollbackLine(absoluteRow);
|
|
916
|
+
} else {
|
|
917
|
+
const screenRow = absoluteRow - scrollbackLength;
|
|
918
|
+
line = this.wasmTerm.getLine(screenRow);
|
|
919
|
+
}
|
|
920
|
+
if (!line) return null;
|
|
921
|
+
|
|
922
|
+
// Word characters: letters, numbers, and common path/URL characters
|
|
923
|
+
// Matches native Ghostty behavior where double-click selects entire paths
|
|
924
|
+
// Includes: / (path sep), . (extensions), ~ (home), @ (emails), + (encodings)
|
|
925
|
+
const isWordChar = (cell: GhosttyCell) => {
|
|
926
|
+
if (!cell || cell.codepoint === 0) return false;
|
|
927
|
+
const char = String.fromCodePoint(cell.codepoint);
|
|
928
|
+
return /[\w\-./~@+]/.test(char);
|
|
929
|
+
};
|
|
930
|
+
|
|
931
|
+
// Only return if we're actually on a word character
|
|
932
|
+
if (!isWordChar(line[col])) return null;
|
|
933
|
+
|
|
934
|
+
// Find start of word
|
|
935
|
+
let startCol = col;
|
|
936
|
+
while (startCol > 0 && isWordChar(line[startCol - 1])) {
|
|
937
|
+
startCol--;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// Find end of word
|
|
941
|
+
let endCol = col;
|
|
942
|
+
while (endCol < line.length - 1 && isWordChar(line[endCol + 1])) {
|
|
943
|
+
endCol++;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
return { startCol, endCol };
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* Copy text to clipboard
|
|
951
|
+
*
|
|
952
|
+
* Strategy (modern APIs first):
|
|
953
|
+
* 1. Try ClipboardItem API (works in Safari and modern browsers)
|
|
954
|
+
* - Safari requires the ClipboardItem to be created synchronously within user gesture
|
|
955
|
+
* 2. Try navigator.clipboard.writeText (modern async API, may fail in Safari)
|
|
956
|
+
* 3. Fall back to execCommand (legacy, for older browsers)
|
|
957
|
+
*/
|
|
958
|
+
private copyToClipboard(text: string): void {
|
|
959
|
+
// First try: ClipboardItem API (modern, Safari-compatible)
|
|
960
|
+
// Safari allows this because we create the ClipboardItem synchronously
|
|
961
|
+
// within the user gesture, even though the write is async
|
|
962
|
+
if (navigator.clipboard && typeof ClipboardItem !== 'undefined') {
|
|
963
|
+
try {
|
|
964
|
+
const blob = new Blob([text], { type: 'text/plain' });
|
|
965
|
+
const clipboardItem = new ClipboardItem({
|
|
966
|
+
'text/plain': blob,
|
|
967
|
+
});
|
|
968
|
+
navigator.clipboard.write([clipboardItem]).catch((err) => {
|
|
969
|
+
console.warn('ClipboardItem write failed, trying writeText:', err);
|
|
970
|
+
// Try writeText as fallback
|
|
971
|
+
this.copyWithWriteText(text);
|
|
972
|
+
});
|
|
973
|
+
return;
|
|
974
|
+
} catch (err) {
|
|
975
|
+
// ClipboardItem not supported or failed, fall through
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// Second try: basic async writeText (works in Chrome, may fail in Safari)
|
|
980
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
981
|
+
navigator.clipboard.writeText(text).catch((err) => {
|
|
982
|
+
console.warn('Clipboard writeText failed, trying execCommand:', err);
|
|
983
|
+
// Fall back to execCommand
|
|
984
|
+
this.copyWithExecCommand(text);
|
|
985
|
+
});
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// Third try: legacy execCommand fallback
|
|
990
|
+
this.copyWithExecCommand(text);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/**
|
|
994
|
+
* Copy using navigator.clipboard.writeText
|
|
995
|
+
*/
|
|
996
|
+
private copyWithWriteText(text: string): void {
|
|
997
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
998
|
+
navigator.clipboard.writeText(text).catch((err) => {
|
|
999
|
+
console.warn('Clipboard writeText failed, trying execCommand:', err);
|
|
1000
|
+
this.copyWithExecCommand(text);
|
|
1001
|
+
});
|
|
1002
|
+
} else {
|
|
1003
|
+
this.copyWithExecCommand(text);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* Copy using legacy execCommand (fallback for older browsers)
|
|
1009
|
+
*/
|
|
1010
|
+
private copyWithExecCommand(text: string): void {
|
|
1011
|
+
const previouslyFocused = document.activeElement as HTMLElement;
|
|
1012
|
+
try {
|
|
1013
|
+
// Position textarea offscreen but in a way that allows selection
|
|
1014
|
+
const textarea = this.textarea;
|
|
1015
|
+
textarea.value = text;
|
|
1016
|
+
textarea.style.position = 'fixed';
|
|
1017
|
+
textarea.style.left = '-9999px';
|
|
1018
|
+
textarea.style.top = '0';
|
|
1019
|
+
textarea.style.width = '1px';
|
|
1020
|
+
textarea.style.height = '1px';
|
|
1021
|
+
textarea.style.opacity = '0';
|
|
1022
|
+
|
|
1023
|
+
// Select all text and copy
|
|
1024
|
+
textarea.focus();
|
|
1025
|
+
textarea.select();
|
|
1026
|
+
textarea.setSelectionRange(0, text.length);
|
|
1027
|
+
|
|
1028
|
+
const success = document.execCommand('copy');
|
|
1029
|
+
|
|
1030
|
+
// Restore focus
|
|
1031
|
+
if (previouslyFocused) {
|
|
1032
|
+
previouslyFocused.focus();
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
if (!success) {
|
|
1036
|
+
console.warn('execCommand copy failed');
|
|
1037
|
+
}
|
|
1038
|
+
} catch (err) {
|
|
1039
|
+
console.warn('execCommand copy threw:', err);
|
|
1040
|
+
// Restore focus on error
|
|
1041
|
+
if (previouslyFocused) {
|
|
1042
|
+
previouslyFocused.focus();
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* Request a render update (triggers selection overlay redraw)
|
|
1049
|
+
*/
|
|
1050
|
+
private requestRender(): void {
|
|
1051
|
+
// The render loop will automatically pick up the new selection state
|
|
1052
|
+
// and redraw the affected lines. This happens at 60fps.
|
|
1053
|
+
//
|
|
1054
|
+
// Note: When clearSelection() is called, it adds dirty rows to dirtySelectionRows
|
|
1055
|
+
// which the renderer can use to know which lines to redraw.
|
|
1056
|
+
}
|
|
1057
|
+
}
|