@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.
@@ -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
+ }