Skip to content

Commit 3770a9a

Browse files
authored
fix: copy/paste selecting wrong text (#48)
1 parent 5d9e68c commit 3770a9a

File tree

3 files changed

+92
-15
lines changed

3 files changed

+92
-15
lines changed

lib/selection-manager.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,15 @@ export class SelectionManager {
8282
const { startCol, startRow, endCol, endRow } = coords;
8383

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

lib/terminal.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1425,6 +1425,48 @@ describe('Selection with Scrollback', () => {
14251425
term.dispose();
14261426
});
14271427

1428+
test('should select correct text with fractional viewportY (smooth scroll)', async () => {
1429+
if (!container) return;
1430+
1431+
const term = new Terminal({ cols: 80, rows: 24, scrollback: 1000 });
1432+
await term.open(container);
1433+
1434+
// Write 100 simple numbered lines
1435+
for (let i = 0; i < 100; i++) {
1436+
term.write(`Line ${i.toString().padStart(3, '0')}\r\n`);
1437+
}
1438+
1439+
// Simulate a fractional viewportY as produced by smooth scrolling.
1440+
// We set it directly to avoid needing to call private smooth scroll APIs.
1441+
(term as any).viewportY = 10.7;
1442+
1443+
// Sanity check that getViewportY returns the raw value
1444+
expect(term.getViewportY()).toBeCloseTo(10.7);
1445+
1446+
// SelectionManager interprets viewport rows using Math.floor(viewportY),
1447+
// matching CanvasRenderer. With viewportY=10.7, floor(viewportY)=10.
1448+
// At this point scrollbackLength is 77 (lines 0-76) and the screen shows 77-99.
1449+
// For viewport row 0:
1450+
// scrollbackOffset = 77 - 10 + 0 = 67 => "Line 067"
1451+
// For viewport row 1:
1452+
// scrollbackOffset = 77 - 10 + 1 = 68 => "Line 068"
1453+
1454+
if ((term as any).selectionManager) {
1455+
const selMgr = (term as any).selectionManager;
1456+
(selMgr as any).selectionStart = { col: 0, row: 0 };
1457+
(selMgr as any).selectionEnd = { col: 10, row: 1 };
1458+
1459+
const selectedText = selMgr.getSelection();
1460+
1461+
expect(selectedText).toContain('Line 067');
1462+
expect(selectedText).toContain('Line 068');
1463+
// Ensure we didn't accidentally select from the wrong region (e.g. current screen)
1464+
expect(selectedText).not.toContain('Line 077');
1465+
expect(selectedText).not.toContain('Line 078');
1466+
}
1467+
1468+
term.dispose();
1469+
});
14281470
test('should handle selection in pure scrollback content', async () => {
14291471
if (!container) return;
14301472

lib/terminal.ts

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,16 @@ export class Terminal implements ITerminalCore {
564564
/**
565565
* Get selection position as buffer range
566566
*/
567+
/**
568+
* Get the current viewport Y position.
569+
*
570+
* This is the number of lines scrolled back from the bottom of the
571+
* scrollback buffer. It may be fractional during smooth scrolling.
572+
*/
573+
public getViewportY(): number {
574+
return this.viewportY;
575+
}
576+
567577
public getSelectionPosition(): IBufferRange | undefined {
568578
return this.selectionManager?.getSelectionPosition();
569579
}
@@ -1041,16 +1051,22 @@ export class Terminal implements ITerminalCore {
10411051
let hyperlinkId = 0;
10421052

10431053
// When scrolled, fetch from scrollback or screen based on position
1054+
// NOTE: viewportY may be fractional during smooth scrolling. The renderer
1055+
// uses Math.floor(viewportY) when mapping viewport rows to scrollback vs
1056+
// screen; we mirror that logic here so link hit-testing matches what the
1057+
// user sees on screen.
10441058
let line: GhosttyCell[] | null = null;
1045-
if (this.viewportY > 0) {
1059+
const rawViewportY = this.getViewportY();
1060+
const viewportY = Math.max(0, Math.floor(rawViewportY));
1061+
if (viewportY > 0) {
10461062
const scrollbackLength = this.wasmTerm.getScrollbackLength();
1047-
if (viewportRow < this.viewportY) {
1063+
if (viewportRow < viewportY) {
10481064
// Mouse is over scrollback content
1049-
const scrollbackOffset = scrollbackLength - this.viewportY + viewportRow;
1065+
const scrollbackOffset = scrollbackLength - viewportY + viewportRow;
10501066
line = this.wasmTerm.getScrollbackLine(scrollbackOffset);
10511067
} else {
10521068
// Mouse is over screen content (bottom part of viewport)
1053-
const screenRow = viewportRow - this.viewportY;
1069+
const screenRow = viewportRow - viewportY;
10541070
line = this.wasmTerm.getLine(screenRow);
10551071
}
10561072
} else {
@@ -1077,14 +1093,18 @@ export class Terminal implements ITerminalCore {
10771093
const scrollbackLength = this.wasmTerm.getScrollbackLength();
10781094
let bufferRow: number;
10791095

1080-
if (this.viewportY > 0) {
1096+
// Use floored viewportY for buffer mapping (must match renderer & selection)
1097+
const rawViewportYForBuffer = this.getViewportY();
1098+
const viewportYForBuffer = Math.max(0, Math.floor(rawViewportYForBuffer));
1099+
1100+
if (viewportYForBuffer > 0) {
10811101
// When scrolled, the buffer row depends on where in the viewport we are
1082-
if (viewportRow < this.viewportY) {
1102+
if (viewportRow < viewportYForBuffer) {
10831103
// Mouse is over scrollback content
1084-
bufferRow = scrollbackLength - this.viewportY + viewportRow;
1104+
bufferRow = scrollbackLength - viewportYForBuffer + viewportRow;
10851105
} else {
10861106
// Mouse is over screen content (bottom part of viewport)
1087-
const screenRow = viewportRow - this.viewportY;
1107+
const screenRow = viewportRow - viewportYForBuffer;
10881108
bufferRow = scrollbackLength + screenRow;
10891109
}
10901110
} else {
@@ -1119,8 +1139,11 @@ export class Terminal implements ITerminalCore {
11191139
const scrollbackLength = this.wasmTerm?.getScrollbackLength() || 0;
11201140

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

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

1195-
if (this.viewportY > 0) {
1196-
if (viewportRow < this.viewportY) {
1197-
bufferRow = scrollbackLength - this.viewportY + viewportRow;
1218+
// Use floored viewportY for buffer mapping (must match renderer & selection)
1219+
const rawViewportYForClick = this.getViewportY();
1220+
const viewportYForClick = Math.max(0, Math.floor(rawViewportYForClick));
1221+
1222+
if (viewportYForClick > 0) {
1223+
if (viewportRow < viewportYForClick) {
1224+
bufferRow = scrollbackLength - viewportYForClick + viewportRow;
11981225
} else {
1199-
const screenRow = viewportRow - this.viewportY;
1226+
const screenRow = viewportRow - viewportYForClick;
12001227
bufferRow = scrollbackLength + screenRow;
12011228
}
12021229
} else {

0 commit comments

Comments
 (0)