From 6aee71d807c3bbba7be5625420cd72cd7fa5f8da Mon Sep 17 00:00:00 2001 From: Big Boss Date: Mon, 5 Jan 2026 14:38:18 -0600 Subject: [PATCH 1/9] feat(selection): Add triple-click to select entire line Add triple-click support to SelectionManager for selecting entire lines, matching standard terminal behavior. Detection uses click timing within 500ms threshold to distinguish from double-click word selection. --- lib/selection-manager.ts | 43 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/lib/selection-manager.ts b/lib/selection-manager.ts index d88c66d..93e6702 100644 --- a/lib/selection-manager.ts +++ b/lib/selection-manager.ts @@ -559,8 +559,42 @@ export class SelectionManager { }; document.addEventListener('mouseup', this.boundMouseUpHandler); + // Track click timing for triple-click detection + let lastClickTime = 0; + let clickCount = 0; + const TRIPLE_CLICK_THRESHOLD_MS = 500; + // Double-click - select word canvas.addEventListener('dblclick', (e: MouseEvent) => { + const now = Date.now(); + if (now - lastClickTime < TRIPLE_CLICK_THRESHOLD_MS) { + clickCount++; + } else { + clickCount = 2; // Double-click resets to 2 + } + lastClickTime = now; + + // Check for triple-click (select line) + if (clickCount >= 3) { + clickCount = 0; + const cell = this.pixelToCell(e.offsetX, e.offsetY); + const dims = this.wasmTerm.getDimensions(); + const absoluteRow = this.viewportRowToAbsolute(cell.row); + + // Select entire line + this.selectionStart = { col: 0, absoluteRow }; + this.selectionEnd = { col: dims.cols - 1, absoluteRow }; + this.requestRender(); + + const text = this.getSelection(); + if (text) { + this.copyToClipboard(text); + this.selectionChangedEmitter.fire(); + } + return; + } + + // Double-click - select word const cell = this.pixelToCell(e.offsetX, e.offsetY); const word = this.getWordAtCell(cell.col, cell.row); @@ -578,6 +612,15 @@ export class SelectionManager { } }); + // Track single clicks for triple-click detection timing + canvas.addEventListener('click', () => { + const now = Date.now(); + if (now - lastClickTime >= TRIPLE_CLICK_THRESHOLD_MS) { + clickCount = 1; + } + lastClickTime = now; + }); + // Right-click (context menu) - position textarea to show browser's native menu // This allows Copy/Paste options to appear in the context menu this.boundContextMenuHandler = (e: MouseEvent) => { From 4364419bbb2edecfc96d19c2d8ee9b9e8f4fa832 Mon Sep 17 00:00:00 2001 From: Big Boss Date: Mon, 5 Jan 2026 15:29:01 -0600 Subject: [PATCH 2/9] fix(selection): Use event.detail for double/triple-click detection The previous implementation tried to track triple-clicks via the dblclick event, but browsers only fire dblclick on the second click (with detail=2), not the third. Triple-clicks never triggered line selection. Fix: Use the click event with event.detail: - detail === 2: double-click -> select word - detail >= 3: triple-click -> select line This is the standard browser API for detecting multi-clicks. --- lib/selection-manager.ts | 68 ++++++++++++++-------------------------- 1 file changed, 23 insertions(+), 45 deletions(-) diff --git a/lib/selection-manager.ts b/lib/selection-manager.ts index 93e6702..0b71694 100644 --- a/lib/selection-manager.ts +++ b/lib/selection-manager.ts @@ -559,24 +559,29 @@ export class SelectionManager { }; document.addEventListener('mouseup', this.boundMouseUpHandler); - // Track click timing for triple-click detection - let lastClickTime = 0; - let clickCount = 0; - const TRIPLE_CLICK_THRESHOLD_MS = 500; - - // Double-click - select word - canvas.addEventListener('dblclick', (e: MouseEvent) => { - const now = Date.now(); - if (now - lastClickTime < TRIPLE_CLICK_THRESHOLD_MS) { - clickCount++; - } else { - clickCount = 2; // Double-click resets to 2 - } - lastClickTime = now; - - // Check for triple-click (select line) - if (clickCount >= 3) { - clickCount = 0; + // Handle click events for double-click (word) and triple-click (line) selection + // Use event.detail which browsers set to click count (1, 2, 3, etc.) + canvas.addEventListener('click', (e: MouseEvent) => { + // event.detail: 1 = single, 2 = double, 3 = triple click + if (e.detail === 2) { + // Double-click - select word + const cell = this.pixelToCell(e.offsetX, e.offsetY); + const word = this.getWordAtCell(cell.col, cell.row); + + if (word) { + const absoluteRow = this.viewportRowToAbsolute(cell.row); + this.selectionStart = { col: word.startCol, absoluteRow }; + this.selectionEnd = { col: word.endCol, absoluteRow }; + this.requestRender(); + + const text = this.getSelection(); + if (text) { + this.copyToClipboard(text); + this.selectionChangedEmitter.fire(); + } + } + } else if (e.detail >= 3) { + // Triple-click (or more) - select entire line const cell = this.pixelToCell(e.offsetX, e.offsetY); const dims = this.wasmTerm.getDimensions(); const absoluteRow = this.viewportRowToAbsolute(cell.row); @@ -591,34 +596,7 @@ export class SelectionManager { this.copyToClipboard(text); this.selectionChangedEmitter.fire(); } - return; - } - - // Double-click - select word - const cell = this.pixelToCell(e.offsetX, e.offsetY); - const word = this.getWordAtCell(cell.col, cell.row); - - if (word) { - const absoluteRow = this.viewportRowToAbsolute(cell.row); - this.selectionStart = { col: word.startCol, absoluteRow }; - this.selectionEnd = { col: word.endCol, absoluteRow }; - this.requestRender(); - - const text = this.getSelection(); - if (text) { - this.copyToClipboard(text); - this.selectionChangedEmitter.fire(); - } - } - }); - - // Track single clicks for triple-click detection timing - canvas.addEventListener('click', () => { - const now = Date.now(); - if (now - lastClickTime >= TRIPLE_CLICK_THRESHOLD_MS) { - clickCount = 1; } - lastClickTime = now; }); // Right-click (context menu) - position textarea to show browser's native menu From 91b4a96e4ec7b28b6d36c582d2b75cee90a75654 Mon Sep 17 00:00:00 2001 From: Big Boss Date: Mon, 5 Jan 2026 16:03:09 -0600 Subject: [PATCH 3/9] fix(selection): Expand word chars to include paths like native Ghostty Double-click now selects entire paths (e.g., ~/foo/bar.ts) instead of breaking at slashes. Added characters to word boundary: - / (path separator) - . (file extensions) - ~ (home directory) - : (line numbers like file.ts:42) - @ (emails/usernames) - + (common in URLs/paths) This matches native Ghostty terminal behavior. --- lib/selection-manager.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/selection-manager.ts b/lib/selection-manager.ts index 0b71694..98ed3a5 100644 --- a/lib/selection-manager.ts +++ b/lib/selection-manager.ts @@ -850,11 +850,13 @@ export class SelectionManager { const line = this.wasmTerm.getLine(row); if (!line) return null; - // Word characters: letters, numbers, underscore, dash + // Word characters: letters, numbers, and common path/URL characters + // Matches native Ghostty behavior where double-click selects entire paths + // Includes: / (path sep), . (extensions), ~ (home), : (line numbers), @ (emails) const isWordChar = (cell: GhosttyCell) => { if (!cell || cell.codepoint === 0) return false; const char = String.fromCodePoint(cell.codepoint); - return /[\w-]/.test(char); + return /[\w\-./~:@+]/.test(char); }; // Only return if we're actually on a word character From 67019f784bcefdbd95b7ad53b582ae08f153c1cf Mon Sep 17 00:00:00 2001 From: Big Boss Date: Mon, 5 Jan 2026 16:28:40 -0600 Subject: [PATCH 4/9] fix(selection): Triple-click selects text content only, not full width Match native Ghostty behavior: triple-click now selects only the actual line content, excluding trailing whitespace/empty cells. Previously it selected the entire terminal width which showed as a full-width highlight. --- lib/selection-manager.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/lib/selection-manager.ts b/lib/selection-manager.ts index 98ed3a5..6e0e84f 100644 --- a/lib/selection-manager.ts +++ b/lib/selection-manager.ts @@ -581,14 +581,25 @@ export class SelectionManager { } } } else if (e.detail >= 3) { - // Triple-click (or more) - select entire line + // Triple-click (or more) - select line content (like native Ghostty) const cell = this.pixelToCell(e.offsetX, e.offsetY); - const dims = this.wasmTerm.getDimensions(); const absoluteRow = this.viewportRowToAbsolute(cell.row); - // Select entire line + // Find actual line length (exclude trailing empty cells) + const line = this.wasmTerm.getLine(cell.row); + let endCol = 0; + if (line) { + for (let i = line.length - 1; i >= 0; i--) { + if (line[i] && line[i].codepoint !== 0 && line[i].codepoint !== 32) { + endCol = i; + break; + } + } + } + + // Select line content only (not trailing whitespace) this.selectionStart = { col: 0, absoluteRow }; - this.selectionEnd = { col: dims.cols - 1, absoluteRow }; + this.selectionEnd = { col: endCol, absoluteRow }; this.requestRender(); const text = this.getSelection(); From 6ec7e674cb478f3322c5683e99dddaaf7b144e12 Mon Sep 17 00:00:00 2001 From: Big Boss Date: Mon, 5 Jan 2026 16:39:36 -0600 Subject: [PATCH 5/9] fix(selection): Use scrollback-aware line retrieval in triple-click Triple-click was using viewport-relative getLine() to compute line length, which reads the wrong line when the viewport is scrolled into scrollback. Now uses the same scrollback-aware pattern as getSelection() - checking absoluteRow against scrollbackLength to choose between getScrollbackLine() and getLine(). --- lib/selection-manager.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/selection-manager.ts b/lib/selection-manager.ts index 6e0e84f..5eada32 100644 --- a/lib/selection-manager.ts +++ b/lib/selection-manager.ts @@ -586,7 +586,17 @@ export class SelectionManager { const absoluteRow = this.viewportRowToAbsolute(cell.row); // Find actual line length (exclude trailing empty cells) - const line = this.wasmTerm.getLine(cell.row); + // Use scrollback-aware line retrieval (like getSelection does) + const scrollbackLength = this.wasmTerm.getScrollbackLength(); + let line: GhosttyCell[] | null = null; + if (absoluteRow < scrollbackLength) { + // Row is in scrollback + line = this.wasmTerm.getScrollbackLine(absoluteRow); + } else { + // Row is in screen buffer + const screenRow = absoluteRow - scrollbackLength; + line = this.wasmTerm.getLine(screenRow); + } let endCol = 0; if (line) { for (let i = line.length - 1; i >= 0; i--) { From 7e5dbe52d9569f8e7af0eb0069e9fe8973600ecb Mon Sep 17 00:00:00 2001 From: Big Boss Date: Mon, 5 Jan 2026 16:49:18 -0600 Subject: [PATCH 6/9] fix(selection): Handle single-character line selection in triple-click When a line has only one character at column 0, the previous code set selectionEnd.col = 0, making start == end. hasSelection() then returned false, preventing the selection from being rendered or copied. Fix: use endCol + 1 to ensure start != end, and skip selection entirely for empty lines (endCol = -1). The extra column is harmless because getSelection() trims trailing empty cells. --- lib/selection-manager.ts | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/lib/selection-manager.ts b/lib/selection-manager.ts index 5eada32..700c02a 100644 --- a/lib/selection-manager.ts +++ b/lib/selection-manager.ts @@ -597,7 +597,8 @@ export class SelectionManager { const screenRow = absoluteRow - scrollbackLength; line = this.wasmTerm.getLine(screenRow); } - let endCol = 0; + // Find last non-empty cell (-1 means empty line) + let endCol = -1; if (line) { for (let i = line.length - 1; i >= 0; i--) { if (line[i] && line[i].codepoint !== 0 && line[i].codepoint !== 32) { @@ -607,15 +608,19 @@ export class SelectionManager { } } - // Select line content only (not trailing whitespace) - this.selectionStart = { col: 0, absoluteRow }; - this.selectionEnd = { col: endCol, absoluteRow }; - this.requestRender(); + // Only select if line has content (endCol >= 0) + if (endCol >= 0) { + // Select line content only (not trailing whitespace) + // Use endCol + 1 to make the range exclusive, avoiding start == end for single-char lines + this.selectionStart = { col: 0, absoluteRow }; + this.selectionEnd = { col: endCol + 1, absoluteRow }; + this.requestRender(); - const text = this.getSelection(); - if (text) { - this.copyToClipboard(text); - this.selectionChangedEmitter.fire(); + const text = this.getSelection(); + if (text) { + this.copyToClipboard(text); + this.selectionChangedEmitter.fire(); + } } } }); From 7c3b259450be3dfd0a1a205225f7b90fb08c9e20 Mon Sep 17 00:00:00 2001 From: Big Boss Date: Mon, 5 Jan 2026 16:55:37 -0600 Subject: [PATCH 7/9] fix(selection): Properly handle single-cell selections without extra highlight The previous fix used endCol + 1 to avoid hasSelection() returning false for single-char lines, but this caused the renderer to highlight an extra trailing cell. Fix: modify hasSelection() to distinguish between drag selections (where same start/end means no drag happened) and programmatic selections (where same start/end is valid for single-char content like triple-click). - During drag (isSelecting=true): same-cell = no selection (unchanged) - Programmatic (isSelecting=false): same-cell = valid selection (new) This allows single-char line selections without highlighting extra cells. --- lib/selection-manager.test.ts | 7 ++++--- lib/selection-manager.ts | 22 +++++++++++++++------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/lib/selection-manager.test.ts b/lib/selection-manager.test.ts index 6732810..8f7a942 100644 --- a/lib/selection-manager.test.ts +++ b/lib/selection-manager.test.ts @@ -117,17 +117,18 @@ describe('SelectionManager', () => { term.dispose(); }); - test('hasSelection returns false for single cell selection', async () => { + test('hasSelection returns true for single cell programmatic selection', async () => { if (!container) return; const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); term.open(container); - // Same start and end = no real selection + // Programmatic single-cell selection should be valid + // (e.g., triple-click on single-char line, or select(col, row, 1)) setSelectionAbsolute(term, 5, 0, 5, 0); const selMgr = (term as any).selectionManager; - expect(selMgr.hasSelection()).toBe(false); + expect(selMgr.hasSelection()).toBe(true); term.dispose(); }); diff --git a/lib/selection-manager.ts b/lib/selection-manager.ts index 700c02a..9dc7aa0 100644 --- a/lib/selection-manager.ts +++ b/lib/selection-manager.ts @@ -205,15 +205,24 @@ export class SelectionManager { /** * Check if there's an active selection + * Note: Same-cell selection (start == end) during drag means no selection, + * but we allow it when selection was set programmatically (e.g., triple-click) + * by checking if we're currently dragging. */ hasSelection(): boolean { if (!this.selectionStart || !this.selectionEnd) return false; - // Check if start and end are the same (single cell, no real selection) - return !( - this.selectionStart.col === this.selectionEnd.col && - this.selectionStart.absoluteRow === this.selectionEnd.absoluteRow - ); + // If currently dragging (isSelecting), same-cell means no drag yet + // Otherwise (programmatic selection), same-cell is valid for single-char content + if (this.isSelecting) { + return !( + this.selectionStart.col === this.selectionEnd.col && + this.selectionStart.absoluteRow === this.selectionEnd.absoluteRow + ); + } + + // For non-drag selections, coordinates exist means selection exists + return true; } /** @@ -611,9 +620,8 @@ export class SelectionManager { // Only select if line has content (endCol >= 0) if (endCol >= 0) { // Select line content only (not trailing whitespace) - // Use endCol + 1 to make the range exclusive, avoiding start == end for single-char lines this.selectionStart = { col: 0, absoluteRow }; - this.selectionEnd = { col: endCol + 1, absoluteRow }; + this.selectionEnd = { col: endCol, absoluteRow }; this.requestRender(); const text = this.getSelection(); From 53fa302cc2b8aef5b4e88d029003db12690e24f4 Mon Sep 17 00:00:00 2001 From: Big Boss Date: Mon, 5 Jan 2026 17:05:36 -0600 Subject: [PATCH 8/9] fix(selection): Clear same-cell selection on mouseup without drag A click without drag was leaving a one-cell selection that persisted after mouseup, which differs from native terminal behavior. Fix: in mouseup handler, check if start equals end (no drag happened) and clear the selection coordinates. This ensures clicks don't create selections while preserving programmatic selections (triple-click on single-char line, select() API). Simplified hasSelection() since same-cell from click-without-drag is now cleared in mouseup - any remaining same-cell selection is valid. --- lib/selection-manager.ts | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/lib/selection-manager.ts b/lib/selection-manager.ts index 9dc7aa0..151b91e 100644 --- a/lib/selection-manager.ts +++ b/lib/selection-manager.ts @@ -205,23 +205,14 @@ export class SelectionManager { /** * Check if there's an active selection - * Note: Same-cell selection (start == end) during drag means no selection, - * but we allow it when selection was set programmatically (e.g., triple-click) - * by checking if we're currently dragging. */ hasSelection(): boolean { if (!this.selectionStart || !this.selectionEnd) return false; - // If currently dragging (isSelecting), same-cell means no drag yet - // Otherwise (programmatic selection), same-cell is valid for single-char content - if (this.isSelecting) { - return !( - this.selectionStart.col === this.selectionEnd.col && - this.selectionStart.absoluteRow === this.selectionEnd.absoluteRow - ); - } - - // For non-drag selections, coordinates exist means selection exists + // Same start and end means no real selection + // Note: click-without-drag clears same-cell in mouseup handler, + // so any same-cell selection here is programmatic (e.g., triple-click single-char) + // which IS a valid selection return true; } @@ -559,6 +550,20 @@ export class SelectionManager { this.isSelecting = false; this.stopAutoScroll(); + // Check if this was a click without drag (start == end) + // If so, clear the selection - a click shouldn't create a selection + if ( + this.selectionStart && + this.selectionEnd && + this.selectionStart.col === this.selectionEnd.col && + this.selectionStart.absoluteRow === this.selectionEnd.absoluteRow + ) { + // Clear same-cell selection from click-without-drag + this.selectionStart = null; + this.selectionEnd = null; + return; + } + const text = this.getSelection(); if (text) { this.copyToClipboard(text); From 6ad518e43312e36e693dc6d93722fb2a53fae421 Mon Sep 17 00:00:00 2001 From: Big Boss Date: Mon, 5 Jan 2026 18:24:06 -0600 Subject: [PATCH 9/9] fix(renderer): Use semi-transparent selection overlay like VS Code Changed selection rendering from solid color replacement to a semi-transparent overlay (40% opacity) that preserves original text colors. This matches VS Code's editor selection behavior and improves readability. - Selection background now overlays on top of cell background - Text keeps original colors unless selectionForeground is defined - Added TODO for configurable opacity via theme.selectionOpacity --- lib/renderer.ts | 52 +++++++++++++++++++++++++++++-------------------- 1 file changed, 31 insertions(+), 21 deletions(-) diff --git a/lib/renderer.ts b/lib/renderer.ts index 0e682be..7b171de 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -553,12 +553,8 @@ export class CanvasRenderer { // Check if this cell is selected const isSelected = this.isInSelection(x, y); - if (isSelected) { - // Draw selection background (solid color, not overlay) - this.ctx.fillStyle = this.theme.selectionBackground; - this.ctx.fillRect(cellX, cellY, cellWidth, this.metrics.height); - return; // Selection background replaces cell background - } + // For selected cells, we'll draw the selection overlay AFTER the normal background + // This creates a tinted effect like VS Code's editor selection // Extract background color and handle inverse let bg_r = cell.bg_r, @@ -579,6 +575,17 @@ export class CanvasRenderer { this.ctx.fillStyle = this.rgbToCSS(bg_r, bg_g, bg_b); this.ctx.fillRect(cellX, cellY, cellWidth, this.metrics.height); } + + // Draw selection overlay on top (semi-transparent like VS Code editor) + // This creates a tinted highlight effect that preserves text readability + // TODO: Make opacity configurable via theme.selectionOpacity (default 0.4) + if (isSelected && this.theme.selectionBackground) { + const selectionOpacity = 0.4; // Adjust for lighter/darker selection tint + this.ctx.globalAlpha = selectionOpacity; + this.ctx.fillStyle = this.theme.selectionBackground; + this.ctx.fillRect(cellX, cellY, cellWidth, this.metrics.height); + this.ctx.globalAlpha = 1.0; + } } /** @@ -604,22 +611,24 @@ export class CanvasRenderer { if (cell.flags & CellFlags.BOLD) fontStyle += 'bold '; this.ctx.font = `${fontStyle}${this.fontSize}px ${this.fontFamily}`; - // Set text color - use selection foreground if selected - if (isSelected) { - this.ctx.fillStyle = this.theme.selectionForeground; - } else { - // Extract colors and handle inverse - let fg_r = cell.fg_r, - fg_g = cell.fg_g, - fg_b = cell.fg_b; - - if (cell.flags & CellFlags.INVERSE) { - // When inverted, foreground becomes background - fg_r = cell.bg_r; - fg_g = cell.bg_g; - fg_b = cell.bg_b; - } + // Extract colors and handle inverse + let fg_r = cell.fg_r, + fg_g = cell.fg_g, + fg_b = cell.fg_b; + if (cell.flags & CellFlags.INVERSE) { + // When inverted, foreground becomes background + fg_r = cell.bg_r; + fg_g = cell.bg_g; + fg_b = cell.bg_b; + } + + // Set text color - use selection foreground only if explicitly defined + // Otherwise keep original text color (works better with semi-transparent overlay) + const selFg = this.theme.selectionForeground; + if (isSelected && selFg && selFg !== 'undefined') { + this.ctx.fillStyle = selFg; + } else { this.ctx.fillStyle = this.rgbToCSS(fg_r, fg_g, fg_b); } @@ -926,6 +935,7 @@ export class CanvasRenderer { return false; } + /** * Set the currently hovered hyperlink ID for rendering underlines */