diff --git a/lib/selection-manager.ts b/lib/selection-manager.ts index 0b21365..3390bdf 100644 --- a/lib/selection-manager.ts +++ b/lib/selection-manager.ts @@ -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 = ''; diff --git a/lib/terminal.test.ts b/lib/terminal.test.ts index 0a44e97..52983a4 100644 --- a/lib/terminal.test.ts +++ b/lib/terminal.test.ts @@ -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; diff --git a/lib/terminal.ts b/lib/terminal.ts index c02e180..3582c21 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -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(); } @@ -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 { @@ -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 { @@ -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) { @@ -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 {