Skip to content

Commit 6a1a50d

Browse files
authored
feat(selection): Add triple-click and selection improvements (#115)
Co-authored-by: Big Boss <bigboss@metalrodeo.xyz>
1 parent 65ed96f commit 6a1a50d

File tree

3 files changed

+233
-28
lines changed

3 files changed

+233
-28
lines changed

lib/selection-manager.test.ts

Lines changed: 108 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,17 +117,18 @@ describe('SelectionManager', () => {
117117
term.dispose();
118118
});
119119

120-
test('hasSelection returns false for single cell selection', async () => {
120+
test('hasSelection returns true for single cell programmatic selection', async () => {
121121
if (!container) return;
122122

123123
const term = await createIsolatedTerminal({ cols: 80, rows: 24 });
124124
term.open(container);
125125

126-
// Same start and end = no real selection
126+
// Programmatic single-cell selection should be valid
127+
// (e.g., triple-click on single-char line, or select(col, row, 1))
127128
setSelectionAbsolute(term, 5, 0, 5, 0);
128129

129130
const selMgr = (term as any).selectionManager;
130-
expect(selMgr.hasSelection()).toBe(false);
131+
expect(selMgr.hasSelection()).toBe(true);
131132

132133
term.dispose();
133134
});
@@ -529,4 +530,108 @@ describe('SelectionManager', () => {
529530
term.dispose();
530531
});
531532
});
533+
534+
describe('scrollback content accuracy', () => {
535+
test('getScrollbackLine returns correct content after lines scroll off', async () => {
536+
const container = document.createElement('div');
537+
Object.defineProperty(container, 'clientWidth', { value: 800 });
538+
Object.defineProperty(container, 'clientHeight', { value: 480 });
539+
if (!container) return;
540+
541+
const term = await createIsolatedTerminal({ cols: 80, rows: 24 });
542+
term.open(container);
543+
544+
// Write 50 lines to push content into scrollback (terminal has 24 rows)
545+
for (let i = 0; i < 50; i++) {
546+
term.write(`Line ${i}\r\n`);
547+
}
548+
549+
const wasmTerm = (term as any).wasmTerm;
550+
const scrollbackLen = wasmTerm.getScrollbackLength();
551+
expect(scrollbackLen).toBeGreaterThan(0);
552+
553+
// First scrollback line (oldest) should contain "Line 0"
554+
const firstLine = wasmTerm.getScrollbackLine(0);
555+
expect(firstLine).not.toBeNull();
556+
const firstText = firstLine!
557+
.map((c: any) => (c.codepoint ? String.fromCodePoint(c.codepoint) : ''))
558+
.join('')
559+
.trim();
560+
expect(firstText).toContain('Line 0');
561+
562+
// Last scrollback line should contain content near the boundary
563+
const lastLine = wasmTerm.getScrollbackLine(scrollbackLen - 1);
564+
expect(lastLine).not.toBeNull();
565+
const lastText = lastLine!
566+
.map((c: any) => (c.codepoint ? String.fromCodePoint(c.codepoint) : ''))
567+
.join('')
568+
.trim();
569+
// The last scrollback line is the one just above the visible viewport
570+
expect(lastText).toMatch(/Line \d+/);
571+
572+
term.dispose();
573+
});
574+
575+
test('selection clears when user types', async () => {
576+
const container = document.createElement('div');
577+
Object.defineProperty(container, 'clientWidth', { value: 800 });
578+
Object.defineProperty(container, 'clientHeight', { value: 480 });
579+
if (!container) return;
580+
581+
const term = await createIsolatedTerminal({ cols: 80, rows: 24 });
582+
term.open(container);
583+
584+
term.write('Hello World\r\n');
585+
586+
const selMgr = (term as any).selectionManager;
587+
selMgr.selectLines(0, 0);
588+
expect(selMgr.hasSelection()).toBe(true);
589+
590+
// Simulate the input callback clearing selection
591+
// The actual input handler calls clearSelection before firing data
592+
selMgr.clearSelection();
593+
expect(selMgr.hasSelection()).toBe(false);
594+
595+
term.dispose();
596+
});
597+
598+
test('triple-click selects correct line in scrollback region', async () => {
599+
const container = document.createElement('div');
600+
Object.defineProperty(container, 'clientWidth', { value: 800 });
601+
Object.defineProperty(container, 'clientHeight', { value: 480 });
602+
if (!container) return;
603+
604+
const term = await createIsolatedTerminal({ cols: 80, rows: 24 });
605+
term.open(container);
606+
607+
// Write enough lines to create scrollback
608+
for (let i = 0; i < 50; i++) {
609+
term.write(`TestLine${i}\r\n`);
610+
}
611+
612+
const wasmTerm = (term as any).wasmTerm;
613+
const scrollbackLen = wasmTerm.getScrollbackLength();
614+
expect(scrollbackLen).toBeGreaterThan(0);
615+
616+
// Verify multiple scrollback lines have correct content
617+
for (let i = 0; i < Math.min(5, scrollbackLen); i++) {
618+
const line = wasmTerm.getScrollbackLine(i);
619+
expect(line).not.toBeNull();
620+
const text = line!
621+
.map((c: any) => (c.codepoint ? String.fromCodePoint(c.codepoint) : ''))
622+
.join('')
623+
.trim();
624+
expect(text).toContain(`TestLine${i}`);
625+
}
626+
627+
// Use selectLines to select a single line and verify content
628+
const selMgr = (term as any).selectionManager;
629+
selMgr.selectLines(0, 0);
630+
expect(selMgr.hasSelection()).toBe(true);
631+
const selectedText = selMgr.getSelection();
632+
expect(selectedText.length).toBeGreaterThan(0);
633+
634+
term.dispose();
635+
});
636+
});
532637
});

lib/selection-manager.ts

Lines changed: 112 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ export class SelectionManager {
4343
private selectionStart: { col: number; absoluteRow: number } | null = null;
4444
private selectionEnd: { col: number; absoluteRow: number } | null = null;
4545
private isSelecting: boolean = false;
46+
private mouseDownX: number = 0;
47+
private mouseDownY: number = 0;
48+
private dragThresholdMet: boolean = false;
4649
private mouseDownTarget: EventTarget | null = null; // Track where mousedown occurred
4750

4851
// Track rows that need redraw for clearing old selection
@@ -209,11 +212,10 @@ export class SelectionManager {
209212
hasSelection(): boolean {
210213
if (!this.selectionStart || !this.selectionEnd) return false;
211214

212-
// Check if start and end are the same (single cell, no real selection)
213-
return !(
214-
this.selectionStart.col === this.selectionEnd.col &&
215-
this.selectionStart.absoluteRow === this.selectionEnd.absoluteRow
216-
);
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;
217219
}
218220

219221
/**
@@ -313,9 +315,8 @@ export class SelectionManager {
313315
}
314316

315317
// Convert viewport rows to absolute rows
316-
const viewportY = this.getViewportY();
317-
this.selectionStart = { col: 0, absoluteRow: viewportY + start };
318-
this.selectionEnd = { col: dims.cols - 1, absoluteRow: viewportY + end };
318+
this.selectionStart = { col: 0, absoluteRow: this.viewportRowToAbsolute(start) };
319+
this.selectionEnd = { col: dims.cols - 1, absoluteRow: this.viewportRowToAbsolute(end) };
319320
this.requestRender();
320321
this.selectionChangedEmitter.fire();
321322
}
@@ -454,12 +455,27 @@ export class SelectionManager {
454455
this.selectionStart = { col: cell.col, absoluteRow };
455456
this.selectionEnd = { col: cell.col, absoluteRow };
456457
this.isSelecting = true;
458+
this.mouseDownX = e.offsetX;
459+
this.mouseDownY = e.offsetY;
460+
this.dragThresholdMet = false;
457461
}
458462
});
459463

460464
// Mouse move on canvas - update selection
461465
canvas.addEventListener('mousemove', (e: MouseEvent) => {
462466
if (this.isSelecting) {
467+
// Check if drag threshold has been met
468+
if (!this.dragThresholdMet) {
469+
const dx = e.offsetX - this.mouseDownX;
470+
const dy = e.offsetY - this.mouseDownY;
471+
// Use 50% of cell width as threshold to scale with font size
472+
const threshold = this.renderer.getMetrics().width * 0.5;
473+
if (dx * dx + dy * dy < threshold * threshold) {
474+
return; // Below threshold, ignore
475+
}
476+
this.dragThresholdMet = true;
477+
}
478+
463479
// Mark current selection rows as dirty before updating
464480
this.markCurrentSelectionDirty();
465481

@@ -496,6 +512,17 @@ export class SelectionManager {
496512
// Document-level mousemove for tracking mouse position during drag outside canvas
497513
this.boundDocumentMouseMoveHandler = (e: MouseEvent) => {
498514
if (this.isSelecting) {
515+
// Check drag threshold (same as canvas mousemove)
516+
if (!this.dragThresholdMet) {
517+
const dx = e.clientX - (canvas.getBoundingClientRect().left + this.mouseDownX);
518+
const dy = e.clientY - (canvas.getBoundingClientRect().top + this.mouseDownY);
519+
const threshold = this.renderer.getMetrics().width * 0.5;
520+
if (dx * dx + dy * dy < threshold * threshold) {
521+
return;
522+
}
523+
this.dragThresholdMet = true;
524+
}
525+
499526
const rect = canvas.getBoundingClientRect();
500527

501528
// Update selection based on clamped position
@@ -550,6 +577,12 @@ export class SelectionManager {
550577
this.isSelecting = false;
551578
this.stopAutoScroll();
552579

580+
// Check if this was a click without drag (threshold never met).
581+
if (!this.dragThresholdMet) {
582+
this.clearSelection();
583+
return;
584+
}
585+
553586
if (this.hasSelection()) {
554587
const text = this.getSelection();
555588
if (text) {
@@ -561,21 +594,67 @@ export class SelectionManager {
561594
};
562595
document.addEventListener('mouseup', this.boundMouseUpHandler);
563596

564-
// Double-click - select word
565-
canvas.addEventListener('dblclick', (e: MouseEvent) => {
566-
const cell = this.pixelToCell(e.offsetX, e.offsetY);
567-
const word = this.getWordAtCell(cell.col, cell.row);
597+
// Handle click events for double-click (word) and triple-click (line) selection
598+
// Use event.detail which browsers set to click count (1, 2, 3, etc.)
599+
canvas.addEventListener('click', (e: MouseEvent) => {
600+
// event.detail: 1 = single, 2 = double, 3 = triple click
601+
if (e.detail === 2) {
602+
// Double-click - select word
603+
const cell = this.pixelToCell(e.offsetX, e.offsetY);
604+
const word = this.getWordAtCell(cell.col, cell.row);
605+
606+
if (word) {
607+
const absoluteRow = this.viewportRowToAbsolute(cell.row);
608+
this.selectionStart = { col: word.startCol, absoluteRow };
609+
this.selectionEnd = { col: word.endCol, absoluteRow };
610+
this.requestRender();
568611

569-
if (word) {
612+
const text = this.getSelection();
613+
if (text) {
614+
this.copyToClipboard(text);
615+
this.selectionChangedEmitter.fire();
616+
}
617+
}
618+
} else if (e.detail >= 3) {
619+
// Triple-click (or more) - select line content (like native Ghostty)
620+
const cell = this.pixelToCell(e.offsetX, e.offsetY);
570621
const absoluteRow = this.viewportRowToAbsolute(cell.row);
571-
this.selectionStart = { col: word.startCol, absoluteRow };
572-
this.selectionEnd = { col: word.endCol, absoluteRow };
573-
this.requestRender();
574622

575-
const text = this.getSelection();
576-
if (text) {
577-
this.copyToClipboard(text);
578-
this.selectionChangedEmitter.fire();
623+
// Find actual line length (exclude trailing empty cells)
624+
// Use scrollback-aware line retrieval (like getSelection does)
625+
const scrollbackLength = this.wasmTerm.getScrollbackLength();
626+
let line: GhosttyCell[] | null = null;
627+
if (absoluteRow < scrollbackLength) {
628+
// Row is in scrollback
629+
line = this.wasmTerm.getScrollbackLine(absoluteRow);
630+
} else {
631+
// Row is in screen buffer
632+
const screenRow = absoluteRow - scrollbackLength;
633+
line = this.wasmTerm.getLine(screenRow);
634+
}
635+
// Find last non-empty cell (-1 means empty line)
636+
let endCol = -1;
637+
if (line) {
638+
for (let i = line.length - 1; i >= 0; i--) {
639+
if (line[i] && line[i].codepoint !== 0 && line[i].codepoint !== 32) {
640+
endCol = i;
641+
break;
642+
}
643+
}
644+
}
645+
646+
// Only select if line has content (endCol >= 0)
647+
if (endCol >= 0) {
648+
// Select line content only (not trailing whitespace)
649+
this.selectionStart = { col: 0, absoluteRow };
650+
this.selectionEnd = { col: endCol, absoluteRow };
651+
this.requestRender();
652+
653+
const text = this.getSelection();
654+
if (text) {
655+
this.copyToClipboard(text);
656+
this.selectionChangedEmitter.fire();
657+
}
579658
}
580659
}
581660
});
@@ -828,14 +907,24 @@ export class SelectionManager {
828907
* Get word boundaries at a cell position
829908
*/
830909
private getWordAtCell(col: number, row: number): { startCol: number; endCol: number } | null {
831-
const line = this.wasmTerm.getLine(row);
910+
const absoluteRow = this.viewportRowToAbsolute(row);
911+
const scrollbackLength = this.wasmTerm.getScrollbackLength();
912+
let line: GhosttyCell[] | null;
913+
if (absoluteRow < scrollbackLength) {
914+
line = this.wasmTerm.getScrollbackLine(absoluteRow);
915+
} else {
916+
const screenRow = absoluteRow - scrollbackLength;
917+
line = this.wasmTerm.getLine(screenRow);
918+
}
832919
if (!line) return null;
833920

834-
// Word characters: letters, numbers, underscore, dash
921+
// Word characters: letters, numbers, and common path/URL characters
922+
// Matches native Ghostty behavior where double-click selects entire paths
923+
// Includes: / (path sep), . (extensions), ~ (home), @ (emails), + (encodings)
835924
const isWordChar = (cell: GhosttyCell) => {
836925
if (!cell || cell.codepoint === 0) return false;
837926
const char = String.fromCodePoint(cell.codepoint);
838-
return /[\w-]/.test(char);
927+
return /[\w\-./~@+]/.test(char);
839928
};
840929

841930
// Only return if we're actually on a word character

lib/terminal.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,8 @@ export class Terminal implements ITerminalCore {
376376
// Create canvas element
377377
this.canvas = document.createElement('canvas');
378378
this.canvas.style.display = 'block';
379+
this.canvas.style.cursor = 'text';
380+
379381
parent.appendChild(this.canvas);
380382

381383
// Create hidden textarea for keyboard input (must be inside parent for event bubbling)
@@ -452,6 +454,8 @@ export class Terminal implements ITerminalCore {
452454
if (this.options.disableStdin) {
453455
return;
454456
}
457+
// Clear selection when user types
458+
this.selectionManager?.clearSelection();
455459
// Input handler fires data events
456460
this.dataEmitter.fire(data);
457461
},
@@ -1411,9 +1415,13 @@ export class Terminal implements ITerminalCore {
14111415
// Notify new link we're entering
14121416
link?.hover?.(true);
14131417

1414-
// Update cursor style
1418+
// Update cursor style on both container and canvas
1419+
const cursorStyle = link ? 'pointer' : 'text';
14151420
if (this.element) {
1416-
this.element.style.cursor = link ? 'pointer' : 'text';
1421+
this.element.style.cursor = cursorStyle;
1422+
}
1423+
if (this.canvas) {
1424+
this.canvas.style.cursor = cursorStyle;
14171425
}
14181426

14191427
// Update renderer for underline (for regex URLs without hyperlink_id)
@@ -1477,6 +1485,9 @@ export class Terminal implements ITerminalCore {
14771485
// Reset cursor
14781486
if (this.element) {
14791487
this.element.style.cursor = 'text';
1488+
if (this.canvas) {
1489+
this.canvas.style.cursor = 'text';
1490+
}
14801491
}
14811492
}
14821493
};

0 commit comments

Comments
 (0)