Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 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
21 changes: 18 additions & 3 deletions lib/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ export class CanvasRenderer {
// Set CSS size (what user sees)
this.canvas.style.width = `${cssWidth}px`;
this.canvas.style.height = `${cssHeight}px`;
this.canvas.style.cursor = 'text';
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not force text cursor on the canvas element

Setting the canvas cursor directly to text overrides the inherited cursor from the terminal container, so the link-hover cursor updates in Terminal.handleMouseMove (this.element.style.cursor = link ? 'pointer' : 'text') are no longer visible over the canvas. In practice this regresses hyperlink affordance: links still work, but users no longer get the pointer feedback when hovering them.

Useful? React with 👍 / 👎.


// Set actual canvas size (scaled for DPI)
this.canvas.width = cssWidth * this.devicePixelRatio;
Expand Down Expand Up @@ -589,7 +590,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 +609,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 +727,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();
});
});
});
139 changes: 115 additions & 24 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,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 @@ -313,9 +316,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 @@ -452,14 +454,29 @@ export class SelectionManager {
// Start new selection (convert to absolute coordinates)
const absoluteRow = this.viewportRowToAbsolute(cell.row);
this.selectionStart = { col: cell.col, absoluteRow };
this.selectionEnd = { col: cell.col, absoluteRow };
this.selectionEnd = null; // Don't highlight until drag
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep an initial selection end for edge-drag autoscroll

Initializing selectionEnd to null on mousedown breaks a real drag path: if the pointer leaves the canvas before an in-canvas move updates selectionEnd, document mousemove can enter auto-scroll mode and skip end updates while scrolling, and the auto-scroll extension path only runs when selectionEnd is already set. Mouseup then treats the action as a click and clears it, so fast edge drags (especially from top/bottom rows) can lose the selection entirely.

Useful? React with 👍 / 👎.

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 +513,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 +578,13 @@ export class SelectionManager {
this.isSelecting = false;
this.stopAutoScroll();

// Check if this was a click without drag, or sub-cell jitter.
// If the mouse never moved to a different cell, treat as a click.
if (!this.selectionEnd || !this.dragThresholdMet) {
this.clearSelection();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve drag selections when pointer leaves canvas early

This mouseup guard clears the selection whenever dragThresholdMet is false, but that flag is only set in the canvas mousemove handler; if the user drags quickly outside the canvas, the document-level mousemove path can update selectionEnd while dragThresholdMet stays false, so the completed drag is discarded on mouseup. In practice this breaks edge/outside drags (including auto-scroll selection) for fast pointer movement.

Useful? React with 👍 / 👎.

return;
}

if (this.hasSelection()) {
const text = this.getSelection();
if (text) {
Expand All @@ -561,21 +596,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 +909,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