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 */ 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 d88c66d..151b91e 100644 --- a/lib/selection-manager.ts +++ b/lib/selection-manager.ts @@ -209,11 +209,11 @@ export class SelectionManager { 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 - ); + // 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; } /** @@ -550,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); @@ -559,21 +573,67 @@ export class SelectionManager { }; document.addEventListener('mouseup', this.boundMouseUpHandler); - // Double-click - select word - canvas.addEventListener('dblclick', (e: MouseEvent) => { - const cell = this.pixelToCell(e.offsetX, e.offsetY); - const word = this.getWordAtCell(cell.col, cell.row); - - if (word) { + // 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 line content (like native Ghostty) + const cell = this.pixelToCell(e.offsetX, e.offsetY); 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(); + // Find actual line length (exclude trailing empty cells) + // 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); + } + // 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) { + endCol = i; + break; + } + } + } + + // Only select if line has content (endCol >= 0) + if (endCol >= 0) { + // Select line content only (not trailing whitespace) + this.selectionStart = { col: 0, absoluteRow }; + this.selectionEnd = { col: endCol, absoluteRow }; + this.requestRender(); + + const text = this.getSelection(); + if (text) { + this.copyToClipboard(text); + this.selectionChangedEmitter.fire(); + } } } }); @@ -829,11 +889,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