Skip to content
Open
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
52 changes: 31 additions & 21 deletions lib/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
}
}

/**
Expand All @@ -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);
}

Expand Down Expand Up @@ -926,6 +935,7 @@ export class CanvasRenderer {
return false;
}


/**
* Set the currently hovered hyperlink ID for rendering underlines
*/
Expand Down
7 changes: 4 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
102 changes: 82 additions & 20 deletions lib/selection-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

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