diff --git a/lib/renderer.ts b/lib/renderer.ts index dafb01e..3b51bfd 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -589,7 +589,7 @@ export class CanvasRenderer { * Render a cell's text and decorations (Pass 2 of two-pass rendering) * Selection foreground color is applied here to match the selection background. */ - private renderCellText(cell: GhosttyCell, x: number, y: number): void { + private renderCellText(cell: GhosttyCell, x: number, y: number, colorOverride?: string): void { const cellX = x * this.metrics.width; const cellY = y * this.metrics.height; const cellWidth = this.metrics.width * cell.width; @@ -608,8 +608,10 @@ 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) { + // Set text color - use override, selection foreground, or normal color + if (colorOverride) { + this.ctx.fillStyle = colorOverride; + } else if (isSelected) { this.ctx.fillStyle = this.theme.selectionForeground; } else { // Extract colors and handle inverse @@ -724,6 +726,18 @@ export class CanvasRenderer { case 'block': // Full cell block this.ctx.fillRect(cursorX, cursorY, this.metrics.width, this.metrics.height); + // Re-draw character under cursor with cursorAccent color + { + const line = this.currentBuffer?.getLine(y); + if (line?.[x]) { + this.ctx.save(); + this.ctx.beginPath(); + this.ctx.rect(cursorX, cursorY, this.metrics.width, this.metrics.height); + this.ctx.clip(); + this.renderCellText(line[x], x, y, this.theme.cursorAccent); + this.ctx.restore(); + } + } break; case 'underline': diff --git a/lib/selection-manager.test.ts b/lib/selection-manager.test.ts index 6732810..663abc0 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(); }); @@ -529,4 +530,108 @@ describe('SelectionManager', () => { term.dispose(); }); }); + + describe('scrollback content accuracy', () => { + test('getScrollbackLine returns correct content after lines scroll off', async () => { + const container = document.createElement('div'); + Object.defineProperty(container, 'clientWidth', { value: 800 }); + Object.defineProperty(container, 'clientHeight', { value: 480 }); + if (!container) return; + + const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); + term.open(container); + + // Write 50 lines to push content into scrollback (terminal has 24 rows) + for (let i = 0; i < 50; i++) { + term.write(`Line ${i}\r\n`); + } + + const wasmTerm = (term as any).wasmTerm; + const scrollbackLen = wasmTerm.getScrollbackLength(); + expect(scrollbackLen).toBeGreaterThan(0); + + // First scrollback line (oldest) should contain "Line 0" + const firstLine = wasmTerm.getScrollbackLine(0); + expect(firstLine).not.toBeNull(); + const firstText = firstLine! + .map((c: any) => (c.codepoint ? String.fromCodePoint(c.codepoint) : '')) + .join('') + .trim(); + expect(firstText).toContain('Line 0'); + + // Last scrollback line should contain content near the boundary + const lastLine = wasmTerm.getScrollbackLine(scrollbackLen - 1); + expect(lastLine).not.toBeNull(); + const lastText = lastLine! + .map((c: any) => (c.codepoint ? String.fromCodePoint(c.codepoint) : '')) + .join('') + .trim(); + // The last scrollback line is the one just above the visible viewport + expect(lastText).toMatch(/Line \d+/); + + term.dispose(); + }); + + test('selection clears when user types', async () => { + const container = document.createElement('div'); + Object.defineProperty(container, 'clientWidth', { value: 800 }); + Object.defineProperty(container, 'clientHeight', { value: 480 }); + if (!container) return; + + const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); + term.open(container); + + term.write('Hello World\r\n'); + + const selMgr = (term as any).selectionManager; + selMgr.selectLines(0, 0); + expect(selMgr.hasSelection()).toBe(true); + + // Simulate the input callback clearing selection + // The actual input handler calls clearSelection before firing data + selMgr.clearSelection(); + expect(selMgr.hasSelection()).toBe(false); + + term.dispose(); + }); + + test('triple-click selects correct line in scrollback region', async () => { + const container = document.createElement('div'); + Object.defineProperty(container, 'clientWidth', { value: 800 }); + Object.defineProperty(container, 'clientHeight', { value: 480 }); + if (!container) return; + + const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); + term.open(container); + + // Write enough lines to create scrollback + for (let i = 0; i < 50; i++) { + term.write(`TestLine${i}\r\n`); + } + + const wasmTerm = (term as any).wasmTerm; + const scrollbackLen = wasmTerm.getScrollbackLength(); + expect(scrollbackLen).toBeGreaterThan(0); + + // Verify multiple scrollback lines have correct content + for (let i = 0; i < Math.min(5, scrollbackLen); i++) { + const line = wasmTerm.getScrollbackLine(i); + expect(line).not.toBeNull(); + const text = line! + .map((c: any) => (c.codepoint ? String.fromCodePoint(c.codepoint) : '')) + .join('') + .trim(); + expect(text).toContain(`TestLine${i}`); + } + + // Use selectLines to select a single line and verify content + const selMgr = (term as any).selectionManager; + selMgr.selectLines(0, 0); + expect(selMgr.hasSelection()).toBe(true); + const selectedText = selMgr.getSelection(); + expect(selectedText.length).toBeGreaterThan(0); + + term.dispose(); + }); + }); }); diff --git a/lib/selection-manager.ts b/lib/selection-manager.ts index 0fdfa69..56d4605 100644 --- a/lib/selection-manager.ts +++ b/lib/selection-manager.ts @@ -43,6 +43,9 @@ export class SelectionManager { private selectionStart: { col: number; absoluteRow: number } | null = null; private selectionEnd: { col: number; absoluteRow: number } | null = null; private isSelecting: boolean = false; + private mouseDownX: number = 0; + private mouseDownY: number = 0; + private dragThresholdMet: boolean = false; private mouseDownTarget: EventTarget | null = null; // Track where mousedown occurred // Track rows that need redraw for clearing old selection @@ -209,11 +212,10 @@ 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 - ); + // Don't report selection until drag threshold is met (prevents flash on click) + if (this.isSelecting && !this.dragThresholdMet) return false; + + return true; } /** @@ -313,9 +315,8 @@ export class SelectionManager { } // Convert viewport rows to absolute rows - const viewportY = this.getViewportY(); - this.selectionStart = { col: 0, absoluteRow: viewportY + start }; - this.selectionEnd = { col: dims.cols - 1, absoluteRow: viewportY + end }; + this.selectionStart = { col: 0, absoluteRow: this.viewportRowToAbsolute(start) }; + this.selectionEnd = { col: dims.cols - 1, absoluteRow: this.viewportRowToAbsolute(end) }; this.requestRender(); this.selectionChangedEmitter.fire(); } @@ -454,12 +455,27 @@ export class SelectionManager { this.selectionStart = { col: cell.col, absoluteRow }; this.selectionEnd = { col: cell.col, absoluteRow }; this.isSelecting = true; + this.mouseDownX = e.offsetX; + this.mouseDownY = e.offsetY; + this.dragThresholdMet = false; } }); // Mouse move on canvas - update selection canvas.addEventListener('mousemove', (e: MouseEvent) => { if (this.isSelecting) { + // Check if drag threshold has been met + if (!this.dragThresholdMet) { + const dx = e.offsetX - this.mouseDownX; + const dy = e.offsetY - this.mouseDownY; + // Use 50% of cell width as threshold to scale with font size + const threshold = this.renderer.getMetrics().width * 0.5; + if (dx * dx + dy * dy < threshold * threshold) { + return; // Below threshold, ignore + } + this.dragThresholdMet = true; + } + // Mark current selection rows as dirty before updating this.markCurrentSelectionDirty(); @@ -496,6 +512,17 @@ export class SelectionManager { // Document-level mousemove for tracking mouse position during drag outside canvas this.boundDocumentMouseMoveHandler = (e: MouseEvent) => { if (this.isSelecting) { + // Check drag threshold (same as canvas mousemove) + if (!this.dragThresholdMet) { + const dx = e.clientX - (canvas.getBoundingClientRect().left + this.mouseDownX); + const dy = e.clientY - (canvas.getBoundingClientRect().top + this.mouseDownY); + const threshold = this.renderer.getMetrics().width * 0.5; + if (dx * dx + dy * dy < threshold * threshold) { + return; + } + this.dragThresholdMet = true; + } + const rect = canvas.getBoundingClientRect(); // Update selection based on clamped position @@ -550,6 +577,12 @@ export class SelectionManager { this.isSelecting = false; this.stopAutoScroll(); + // Check if this was a click without drag (threshold never met). + if (!this.dragThresholdMet) { + this.clearSelection(); + return; + } + if (this.hasSelection()) { const text = this.getSelection(); if (text) { @@ -561,21 +594,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); + // 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(); - if (word) { + 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(); + } } } }); @@ -828,14 +907,24 @@ export class SelectionManager { * Get word boundaries at a cell position */ private getWordAtCell(col: number, row: number): { startCol: number; endCol: number } | null { - const line = this.wasmTerm.getLine(row); + const absoluteRow = this.viewportRowToAbsolute(row); + const scrollbackLength = this.wasmTerm.getScrollbackLength(); + let line: GhosttyCell[] | null; + if (absoluteRow < scrollbackLength) { + line = this.wasmTerm.getScrollbackLine(absoluteRow); + } else { + const screenRow = absoluteRow - scrollbackLength; + line = this.wasmTerm.getLine(screenRow); + } 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), @ (emails), + (encodings) 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 diff --git a/lib/terminal.ts b/lib/terminal.ts index deef77e..81cca80 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -375,6 +375,8 @@ export class Terminal implements ITerminalCore { // Create canvas element this.canvas = document.createElement('canvas'); this.canvas.style.display = 'block'; + this.canvas.style.cursor = 'text'; + parent.appendChild(this.canvas); // Create hidden textarea for keyboard input (must be inside parent for event bubbling) @@ -451,6 +453,8 @@ export class Terminal implements ITerminalCore { if (this.options.disableStdin) { return; } + // Clear selection when user types + this.selectionManager?.clearSelection(); // Input handler fires data events this.dataEmitter.fire(data); }, @@ -1377,9 +1381,13 @@ export class Terminal implements ITerminalCore { // Notify new link we're entering link?.hover?.(true); - // Update cursor style + // Update cursor style on both container and canvas + const cursorStyle = link ? 'pointer' : 'text'; if (this.element) { - this.element.style.cursor = link ? 'pointer' : 'text'; + this.element.style.cursor = cursorStyle; + } + if (this.canvas) { + this.canvas.style.cursor = cursorStyle; } // Update renderer for underline (for regex URLs without hyperlink_id) @@ -1443,6 +1451,9 @@ export class Terminal implements ITerminalCore { // Reset cursor if (this.element) { this.element.style.cursor = 'text'; + if (this.canvas) { + this.canvas.style.cursor = 'text'; + } } } };