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
10 changes: 9 additions & 1 deletion lib/selection-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,15 @@ export class SelectionManager {
const { startCol, startRow, endCol, endRow } = coords;

// Get viewport state to handle scrollback correctly
const viewportY = (this.terminal as any).viewportY || 0;
// Note: viewportY can be fractional during smooth scrolling, but the renderer
// always uses Math.floor(viewportY) when mapping viewport rows to scrollback
// vs screen. We mirror that logic here so copied text matches the visual
// selection exactly.
const rawViewportY =
typeof (this.terminal as any).getViewportY === 'function'
? (this.terminal as any).getViewportY()
: (this.terminal as any).viewportY || 0;
const viewportY = Math.max(0, Math.floor(rawViewportY));
const scrollbackLength = this.wasmTerm.getScrollbackLength();
let text = '';

Expand Down
42 changes: 42 additions & 0 deletions lib/terminal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1425,6 +1425,48 @@ describe('Selection with Scrollback', () => {
term.dispose();
});

test('should select correct text with fractional viewportY (smooth scroll)', async () => {
if (!container) return;

const term = new Terminal({ cols: 80, rows: 24, scrollback: 1000 });
await term.open(container);

// Write 100 simple numbered lines
for (let i = 0; i < 100; i++) {
term.write(`Line ${i.toString().padStart(3, '0')}\r\n`);
}

// Simulate a fractional viewportY as produced by smooth scrolling.
// We set it directly to avoid needing to call private smooth scroll APIs.
(term as any).viewportY = 10.7;

// Sanity check that getViewportY returns the raw value
expect(term.getViewportY()).toBeCloseTo(10.7);

// SelectionManager interprets viewport rows using Math.floor(viewportY),
// matching CanvasRenderer. With viewportY=10.7, floor(viewportY)=10.
// At this point scrollbackLength is 77 (lines 0-76) and the screen shows 77-99.
// For viewport row 0:
// scrollbackOffset = 77 - 10 + 0 = 67 => "Line 067"
// For viewport row 1:
// scrollbackOffset = 77 - 10 + 1 = 68 => "Line 068"

if ((term as any).selectionManager) {
const selMgr = (term as any).selectionManager;
(selMgr as any).selectionStart = { col: 0, row: 0 };
(selMgr as any).selectionEnd = { col: 10, row: 1 };

const selectedText = selMgr.getSelection();

expect(selectedText).toContain('Line 067');
expect(selectedText).toContain('Line 068');
// Ensure we didn't accidentally select from the wrong region (e.g. current screen)
expect(selectedText).not.toContain('Line 077');
expect(selectedText).not.toContain('Line 078');
}

term.dispose();
});
test('should handle selection in pure scrollback content', async () => {
if (!container) return;

Expand Down
55 changes: 41 additions & 14 deletions lib/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,16 @@ export class Terminal implements ITerminalCore {
/**
* Get selection position as buffer range
*/
/**
* Get the current viewport Y position.
*
* This is the number of lines scrolled back from the bottom of the
* scrollback buffer. It may be fractional during smooth scrolling.
*/
public getViewportY(): number {
return this.viewportY;
}

public getSelectionPosition(): IBufferRange | undefined {
return this.selectionManager?.getSelectionPosition();
}
Expand Down Expand Up @@ -1041,16 +1051,22 @@ export class Terminal implements ITerminalCore {
let hyperlinkId = 0;

// When scrolled, fetch from scrollback or screen based on position
// NOTE: viewportY may be fractional during smooth scrolling. The renderer
// uses Math.floor(viewportY) when mapping viewport rows to scrollback vs
// screen; we mirror that logic here so link hit-testing matches what the
// user sees on screen.
let line: GhosttyCell[] | null = null;
if (this.viewportY > 0) {
const rawViewportY = this.getViewportY();
const viewportY = Math.max(0, Math.floor(rawViewportY));
if (viewportY > 0) {
const scrollbackLength = this.wasmTerm.getScrollbackLength();
if (viewportRow < this.viewportY) {
if (viewportRow < viewportY) {
// Mouse is over scrollback content
const scrollbackOffset = scrollbackLength - this.viewportY + viewportRow;
const scrollbackOffset = scrollbackLength - viewportY + viewportRow;
line = this.wasmTerm.getScrollbackLine(scrollbackOffset);
} else {
// Mouse is over screen content (bottom part of viewport)
const screenRow = viewportRow - this.viewportY;
const screenRow = viewportRow - viewportY;
line = this.wasmTerm.getLine(screenRow);
}
} else {
Expand All @@ -1077,14 +1093,18 @@ export class Terminal implements ITerminalCore {
const scrollbackLength = this.wasmTerm.getScrollbackLength();
let bufferRow: number;

if (this.viewportY > 0) {
// Use floored viewportY for buffer mapping (must match renderer & selection)
const rawViewportYForBuffer = this.getViewportY();
const viewportYForBuffer = Math.max(0, Math.floor(rawViewportYForBuffer));

if (viewportYForBuffer > 0) {
// When scrolled, the buffer row depends on where in the viewport we are
if (viewportRow < this.viewportY) {
if (viewportRow < viewportYForBuffer) {
// Mouse is over scrollback content
bufferRow = scrollbackLength - this.viewportY + viewportRow;
bufferRow = scrollbackLength - viewportYForBuffer + viewportRow;
} else {
// Mouse is over screen content (bottom part of viewport)
const screenRow = viewportRow - this.viewportY;
const screenRow = viewportRow - viewportYForBuffer;
bufferRow = scrollbackLength + screenRow;
}
} else {
Expand Down Expand Up @@ -1119,8 +1139,11 @@ export class Terminal implements ITerminalCore {
const scrollbackLength = this.wasmTerm?.getScrollbackLength() || 0;

// Calculate viewport Y for start and end positions
const startViewportY = link.range.start.y - scrollbackLength + this.viewportY;
const endViewportY = link.range.end.y - scrollbackLength + this.viewportY;
// Use floored viewportY so overlay rows match renderer & selection
const rawViewportYForLinks = this.getViewportY();
const viewportYForLinks = Math.max(0, Math.floor(rawViewportYForLinks));
const startViewportY = link.range.start.y - scrollbackLength + viewportYForLinks;
const endViewportY = link.range.end.y - scrollbackLength + viewportYForLinks;

// Only show underline if link is visible in viewport
if (startViewportY < this.rows && endViewportY >= 0) {
Expand Down Expand Up @@ -1192,11 +1215,15 @@ export class Terminal implements ITerminalCore {
const scrollbackLength = this.wasmTerm.getScrollbackLength();
let bufferRow: number;

if (this.viewportY > 0) {
if (viewportRow < this.viewportY) {
bufferRow = scrollbackLength - this.viewportY + viewportRow;
// Use floored viewportY for buffer mapping (must match renderer & selection)
const rawViewportYForClick = this.getViewportY();
const viewportYForClick = Math.max(0, Math.floor(rawViewportYForClick));

if (viewportYForClick > 0) {
if (viewportRow < viewportYForClick) {
bufferRow = scrollbackLength - viewportYForClick + viewportRow;
} else {
const screenRow = viewportRow - this.viewportY;
const screenRow = viewportRow - viewportYForClick;
bufferRow = scrollbackLength + screenRow;
}
} else {
Expand Down