diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 91c7cc855596..1a11cb16b104 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1520,7 +1520,7 @@ export default function ThreadTerminalDrawer({ ? "border-border" : "border-border/70" }`} - onMouseDown={() => { + onPointerDown={() => { if (terminalId !== resolvedActiveTerminalId) { onActiveTerminalChange(terminalId); } diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 59150ee320ae..351a9e5b96cd 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -179,6 +179,20 @@ describe("GhosttyTerminalSurface visibility", () => { }), ); }, + touch(type: string, clientY: number, pointerId = 2) { + canvas.dispatchEvent( + Object.assign(new Event(type, { cancelable: true }), { + clientX: 40, + clientY, + pointerId, + pointerType: "touch", + isPrimary: pointerId === 2, + button: 0, + buttons: type === "pointerup" ? 0 : 1, + shiftKey: false, + }), + ); + }, async create(options: Partial = {}) { const surface = await GhosttyTerminalSurface.create(mount as unknown as HTMLElement, { theme: { @@ -247,6 +261,91 @@ describe("GhosttyTerminalSurface visibility", () => { expect(harness.frames.size).toBe(0); }); + it("scrolls touch drags in both directions without selecting or sending input", async () => { + const harness = createHarness(); + const surface = await harness.create(); + surface.write(Array.from({ length: 50 }, (_, i) => `row ${i}\r\n`).join("")); + harness.flushFrame(); + const bottom = harness.renderedSnapshot.rowData[0]?.text; + harness.touch("pointerdown", 20); + for (let y = 22; y <= 68; y += 2) harness.touch("pointermove", y); + harness.flushFrame(); + expect(harness.renderedSnapshot.rowData[0]?.text).not.toBe(bottom); + expect(surface.getSelection()).toBe(""); + harness.touch("pointermove", 20); + harness.touch("pointerup", 20); + harness.flushFrame(); + expect(harness.renderedSnapshot.rowData[0]?.text).toBe(bottom); + expect(harness.onData).not.toHaveBeenCalled(); + }); + + it("sends touch scrolling to alternate-screen applications", async () => { + const harness = createHarness(); + const surface = await harness.create(); + surface.write("\x1b[?1049h\x1b[?1h"); + harness.touch("pointerdown", 20); + harness.touch("pointermove", 52); + harness.touch("pointerup", 52); + expect(harness.onData.mock.calls).toEqual([["\x1bOA\x1bOA"]]); + }); + + it("reports touch scrolling and taps to mouse-tracking applications", async () => { + const harness = createHarness(); + const surface = await harness.create(); + surface.write("\x1b[?1000h\x1b[?1006h"); + harness.touch("pointerdown", 20); + harness.touch("pointermove", 52); + harness.touch("pointerup", 52); + expect(harness.onData.mock.calls.map(([data]) => data)).toEqual([ + "\x1b[<64;5;4M", + "\x1b[<64;5;4M", + ]); + harness.onData.mockClear(); + harness.touch("pointerdown", 20); + harness.touch("pointerup", 20); + expect(harness.onData.mock.calls.map(([data]) => data)).toEqual([ + "\x1b[<0;5;2M", + "\x1b[<0;5;2m", + ]); + }); + + it("opens a link on touch tap but not on drag or cancellation", async () => { + const harness = createHarness(); + const onLinkActivate = vi.fn(); + const surface = await harness.create({ onLinkActivate }); + surface.write("https://example.com"); + harness.flushFrame(); + harness.touch("pointerdown", 5); + harness.touch("pointerup", 5); + expect(onLinkActivate).toHaveBeenCalledTimes(1); + harness.touch("pointerdown", 5); + harness.touch("pointermove", 37); + harness.touch("pointerup", 37); + harness.touch("pointerdown", 5); + harness.touch("pointercancel", 5); + expect(onLinkActivate).toHaveBeenCalledTimes(1); + }); + + it("ends a canceled touch drag and ignores a second finger", async () => { + const harness = createHarness(); + const surface = await harness.create(); + surface.write(Array.from({ length: 50 }, (_, i) => `row ${i}\r\n`).join("")); + harness.flushFrame(); + const bottom = harness.renderedSnapshot.rowData[0]?.text; + harness.touch("pointerdown", 20); + harness.touch("pointerdown", 20, 3); + harness.touch("pointermove", 68, 3); + harness.touch("pointercancel", 20); + harness.touch("pointermove", 68); + harness.flushFrame(); + expect(harness.renderedSnapshot.rowData[0]?.text).toBe(bottom); + harness.touch("pointerdown", 20); + harness.touch("pointermove", 68); + harness.touch("pointerup", 68); + harness.flushFrame(); + expect(harness.renderedSnapshot.rowData[0]?.text).not.toBe(bottom); + }); + it("keeps the selection on reveal and applies a hidden selection clear", async () => { const harness = createHarness(); const surface = await harness.create(); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index be62ede4d065..3ab6a30cd85b 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -629,6 +629,14 @@ export class GhosttyTerminalSurface { private clearSelectionAfterCopy = false; private primedCopySelection = ""; private wheelRemainder = 0; + private touchScroll: { + pointerId: number; + startX: number; + startY: number; + lastY: number; + remainder: number; + moved: boolean; + } | null = null; private lastMouseMotionData = ""; private mouseAnyEventTracking = false; private dprMedia: MediaQueryList | null = null; @@ -679,6 +687,7 @@ export class GhosttyTerminalSurface { ): Promise { const canvas = document.createElement("canvas"); canvas.className = "block size-full cursor-text"; + canvas.style.touchAction = "pan-x pinch-zoom"; canvas.setAttribute("aria-hidden", "true"); const input = document.createElement("textarea"); @@ -1282,6 +1291,21 @@ export class GhosttyTerminalSurface { } private readonly onPointerDown = (event: PointerEvent) => { + if (event.pointerType === "touch") { + event.preventDefault(); + if (!event.isPrimary || this.touchScroll !== null) return; + this.touchScroll = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + lastY: event.clientY, + remainder: 0, + moved: false, + }; + this.clearHoveredLink(); + this.canvas.setPointerCapture(event.pointerId); + return; + } this.focus(); if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) { const button = ghosttyMouseButton(event.button); @@ -1366,6 +1390,25 @@ export class GhosttyTerminalSurface { } private readonly onPointerMove = (event: PointerEvent) => { + if (event.pointerType === "touch") { + const touch = this.touchScroll; + if (touch?.pointerId !== event.pointerId) return; + event.preventDefault(); + touch.moved ||= + Math.hypot(event.clientX - touch.startX, event.clientY - touch.startY) > + TERMINAL_LINK_DRAG_THRESHOLD_PX; + if (!touch.moved) return; + const delta = terminalWheelDeltaRows( + { deltaY: touch.lastY - event.clientY, deltaMode: 0 }, + this.metrics.height, + this.rows, + touch.remainder, + ); + touch.lastY = event.clientY; + touch.remainder = delta.remainder; + this.scrollRows(delta.rows, event); + return; + } if (this.linkActivationPointerId === event.pointerId) { const origin = this.linkActivationOrigin; if ( @@ -1506,6 +1549,26 @@ export class GhosttyTerminalSurface { } private readonly onPointerUp = (event: PointerEvent) => { + if (event.pointerType === "touch") { + const touch = this.touchScroll; + if (touch?.pointerId !== event.pointerId) return; + this.touchScroll = null; + event.preventDefault(); + if (this.canvas.hasPointerCapture(event.pointerId)) { + this.canvas.releasePointerCapture(event.pointerId); + } + if (event.type === "pointercancel" || touch.moved) return; + this.focus(); + if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) { + this.sendMouse("press", ghosttyMouseButton(0), event); + this.sendMouse("release", ghosttyMouseButton(0), event); + } else { + const link = this.linkAt(event.clientX, event.clientY); + if (link) this.options.onLinkActivate(link.text, event); + else this.clearSelection(); + } + return; + } this.setSelectionAutoscroll(0); if (this.linkActivationPointerId === event.pointerId) { event.preventDefault(); @@ -1565,10 +1628,14 @@ export class GhosttyTerminalSurface { this.wheelRemainder, ); this.wheelRemainder = delta.remainder; - if (delta.rows === 0) return; - const magnitude = Math.abs(delta.rows); + this.scrollRows(delta.rows, event); + }; + + private scrollRows(rows: number, event: MouseEvent): void { + if (rows === 0) return; + const magnitude = Math.abs(rows); if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) { - const button = delta.rows < 0 ? 4 : 5; + const button = rows < 0 ? 4 : 5; for (let index = 0; index < magnitude; index += 1) { this.sendMouse("press", button, event); } @@ -1577,11 +1644,11 @@ export class GhosttyTerminalSurface { if (this.core.isAlternateScreen()) { // The alternate screen has no scrollback: translate wheel motion into // arrow keys so full-screen apps like vim and less scroll, matching xterm. - this.options.onData(terminalWheelArrowData(delta.rows, this.core.isApplicationCursorKeys())); + this.options.onData(terminalWheelArrowData(rows, this.core.isApplicationCursorKeys())); return; } - this.scrollViewport(delta.rows); - }; + this.scrollViewport(rows); + } private readonly onMouseDown = (event: MouseEvent) => { // Cancelling the middle button here stops autoscroll while still letting