Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions lib/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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':
Expand Down
111 changes: 108 additions & 3 deletions lib/selection-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down Expand Up @@ -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();
});
});
});
135 changes: 112 additions & 23 deletions lib/selection-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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();
}
}
}
});
Expand Down Expand Up @@ -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
Expand Down
Loading