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