diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index c203c5e3cc62efeee44f7d00913c4170776d76b8..942b16f02cb903fdfe6aa48e06b34c3a5892f323 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -157,6 +157,7 @@ describe("GhosttyTerminalSurface visibility", () => { "window", Object.assign(new EventTarget(), { devicePixelRatio: 1, + innerHeight: 104, requestAnimationFrame: requestFrame, cancelAnimationFrame: (id: number) => frames.delete(id), setTimeout, @@ -214,11 +215,12 @@ describe("GhosttyTerminalSurface visibility", () => { buttons: number, modifiers: boolean | Partial> = {}, button = 0, + clientY = 5, ) { canvas.dispatchEvent( Object.assign(new Event(type, { cancelable: true }), { clientX, - clientY: 5, + clientY, pointerId: 1, button, buttons, @@ -611,6 +613,29 @@ describe("GhosttyTerminalSurface visibility", () => { expect(harness.renderedSnapshot.rowData[0]?.cells.some((cell) => cell.selected)).toBe(false); }); + it("scrolls a selection drag held at the terminal edge, even when the window ends there", async () => { + const harness = createHarness(); + const surface = await harness.create(); + surface.write(Array.from({ length: 40 }, (_, index) => `line ${index}`).join("\r\n")); + harness.flushFrame(); + const latest = harness.renderedSnapshot.rowData[0]?.text; + + harness.pointer("pointerdown", 5, 1, {}, 0, 50); + harness.pointer("pointermove", 5, 1, {}, 0, 1); + vi.advanceTimersByTime(400); + harness.flushFrame(); + expect(surface.isAtBottom()).toBe(false); + + harness.pointer("pointermove", 5, 1, {}, 0, 103); + vi.advanceTimersByTime(2_000); + harness.pointer("pointerup", 5, 0, {}, 0, 103); + harness.flushFrame(); + expect(surface.isAtBottom()).toBe(true); + expect(harness.renderedSnapshot.rowData[0]?.text).toBe(latest); + expect(surface.getSelection()).not.toBe(""); + expect(surface.getSelectionPosition()?.end.y).toBe(39); + }); + it("pastes the terminal selection, and only that, on a Linux middle click", async () => { const harness = createHarness(); const readText = vi.fn(async () => "clipboard text"); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 7d35733ed44ae7faef8be6e4493cfd8ff1892e3c..ffba9e5bef555cce2e613c732fe6d608a261f61c 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -1490,9 +1490,9 @@ export class GhosttyTerminalSurface { this.clearHoveredLink(); this.selectionPointer = { x: event.clientX, y: event.clientY }; const bounds = this.canvas.getBoundingClientRect(); - this.setSelectionAutoscroll( - event.clientY < bounds.top ? -1 : event.clientY > bounds.bottom ? 1 : 0, - ); + const top = Math.max(bounds.top, 0) + CONTENT_PADDING; + const bottom = Math.min(bounds.bottom, window.innerHeight) - CONTENT_PADDING; + this.setSelectionAutoscroll(event.clientY < top ? -1 : event.clientY > bottom ? 1 : 0); const cell = this.cellAt(event.clientX, event.clientY); if (cell.x === this.selectionEnd?.x && cell.y === this.selectionEnd.y) return; this.extendSelectionTo(event.clientX, event.clientY);