| author | |
| committer | |
| log | 1ce8089ebd3317dad220f49deb541654c2f05fb5 |
| tree | 51d64e789a2114cb208f3df9e775cf290b9259d7 |
| parent | 9c9ae43d80fa0e7a697257dc4cc9be950e534747 |
| signature | Signed by PGP key B5690EEEBB952194 |
3 files changed, 173 insertions(+), 7 deletions(-)
apps/web/src/components/ThreadTerminalDrawer.tsx+1-1| ... | ... | @@ -1700,7 +1700,7 @@ export default function ThreadTerminalDrawer({ |
| 1700 | 1700 | ? "border-border" |
| 1701 | 1701 | : "border-border/70" |
| 1702 | 1702 | }`} |
| 1703 | onMouseDown={() => { | |
| 1703 | onPointerDown={() => { | |
| 1704 | 1704 | if (terminalId !== resolvedActiveTerminalId) { |
| 1705 | 1705 | onActiveTerminalChange(terminalId); |
| 1706 | 1706 | } |
apps/web/src/terminal/ghostty/surface.test.ts+99| ... | ... | @@ -228,6 +228,20 @@ describe("GhosttyTerminalSurface visibility", () => { |
| 228 | 228 | }), |
| 229 | 229 | ); |
| 230 | 230 | }, |
| 231 | touch(type: string, clientY: number, pointerId = 2) { | |
| 232 | canvas.dispatchEvent( | |
| 233 | Object.assign(new Event(type, { cancelable: true }), { | |
| 234 | clientX: 40, | |
| 235 | clientY, | |
| 236 | pointerId, | |
| 237 | pointerType: "touch", | |
| 238 | isPrimary: pointerId === 2, | |
| 239 | button: 0, | |
| 240 | buttons: type === "pointerup" ? 0 : 1, | |
| 241 | shiftKey: false, | |
| 242 | }), | |
| 243 | ); | |
| 244 | }, | |
| 231 | 245 | async create(options: Partial<GhosttyTerminalSurfaceOptions> = {}) { |
| 232 | 246 | const surface = await GhosttyTerminalSurface.create(mount as unknown as HTMLElement, { |
| 233 | 247 | theme: { |
| ... | ... | @@ -580,6 +594,91 @@ describe("GhosttyTerminalSurface visibility", () => { |
| 580 | 594 | expect(harness.frames.size).toBe(0); |
| 581 | 595 | }); |
| 582 | 596 | |
| 597 | it("scrolls touch drags in both directions without selecting or sending input", async () => { | |
| 598 | const harness = createHarness(); | |
| 599 | const surface = await harness.create(); | |
| 600 | surface.write(Array.from({ length: 50 }, (_, i) => `row ${i}\r\n`).join("")); | |
| 601 | harness.flushFrame(); | |
| 602 | const bottom = harness.renderedSnapshot.rowData[0]?.text; | |
| 603 | harness.touch("pointerdown", 20); | |
| 604 | for (let y = 22; y <= 68; y += 2) harness.touch("pointermove", y); | |
| 605 | harness.flushFrame(); | |
| 606 | expect(harness.renderedSnapshot.rowData[0]?.text).not.toBe(bottom); | |
| 607 | expect(surface.getSelection()).toBe(""); | |
| 608 | harness.touch("pointermove", 20); | |
| 609 | harness.touch("pointerup", 20); | |
| 610 | harness.flushFrame(); | |
| 611 | expect(harness.renderedSnapshot.rowData[0]?.text).toBe(bottom); | |
| 612 | expect(harness.onData).not.toHaveBeenCalled(); | |
| 613 | }); | |
| 614 | ||
| 615 | it("sends touch scrolling to alternate-screen applications", async () => { | |
| 616 | const harness = createHarness(); | |
| 617 | const surface = await harness.create(); | |
| 618 | surface.write("\x1b[?1049h\x1b[?1h"); | |
| 619 | harness.touch("pointerdown", 20); | |
| 620 | harness.touch("pointermove", 52); | |
| 621 | harness.touch("pointerup", 52); | |
| 622 | expect(harness.onData.mock.calls).toEqual([["\x1bOA\x1bOA"]]); | |
| 623 | }); | |
| 624 | ||
| 625 | it("reports touch scrolling and taps to mouse-tracking applications", async () => { | |
| 626 | const harness = createHarness(); | |
| 627 | const surface = await harness.create(); | |
| 628 | surface.write("\x1b[?1000h\x1b[?1006h"); | |
| 629 | harness.touch("pointerdown", 20); | |
| 630 | harness.touch("pointermove", 52); | |
| 631 | harness.touch("pointerup", 52); | |
| 632 | expect(harness.onData.mock.calls.map(([data]) => data)).toEqual([ | |
| 633 | "\x1b[<64;5;4M", | |
| 634 | "\x1b[<64;5;4M", | |
| 635 | ]); | |
| 636 | harness.onData.mockClear(); | |
| 637 | harness.touch("pointerdown", 20); | |
| 638 | harness.touch("pointerup", 20); | |
| 639 | expect(harness.onData.mock.calls.map(([data]) => data)).toEqual([ | |
| 640 | "\x1b[<0;5;2M", | |
| 641 | "\x1b[<0;5;2m", | |
| 642 | ]); | |
| 643 | }); | |
| 644 | ||
| 645 | it("opens a link on touch tap but not on drag or cancellation", async () => { | |
| 646 | const harness = createHarness(); | |
| 647 | const onLinkActivate = vi.fn(); | |
| 648 | const surface = await harness.create({ onLinkActivate }); | |
| 649 | surface.write("https://example.com"); | |
| 650 | harness.flushFrame(); | |
| 651 | harness.touch("pointerdown", 5); | |
| 652 | harness.touch("pointerup", 5); | |
| 653 | expect(onLinkActivate).toHaveBeenCalledTimes(1); | |
| 654 | harness.touch("pointerdown", 5); | |
| 655 | harness.touch("pointermove", 37); | |
| 656 | harness.touch("pointerup", 37); | |
| 657 | harness.touch("pointerdown", 5); | |
| 658 | harness.touch("pointercancel", 5); | |
| 659 | expect(onLinkActivate).toHaveBeenCalledTimes(1); | |
| 660 | }); | |
| 661 | ||
| 662 | it("ends a canceled touch drag and ignores a second finger", async () => { | |
| 663 | const harness = createHarness(); | |
| 664 | const surface = await harness.create(); | |
| 665 | surface.write(Array.from({ length: 50 }, (_, i) => `row ${i}\r\n`).join("")); | |
| 666 | harness.flushFrame(); | |
| 667 | const bottom = harness.renderedSnapshot.rowData[0]?.text; | |
| 668 | harness.touch("pointerdown", 20); | |
| 669 | harness.touch("pointerdown", 20, 3); | |
| 670 | harness.touch("pointermove", 68, 3); | |
| 671 | harness.touch("pointercancel", 20); | |
| 672 | harness.touch("pointermove", 68); | |
| 673 | harness.flushFrame(); | |
| 674 | expect(harness.renderedSnapshot.rowData[0]?.text).toBe(bottom); | |
| 675 | harness.touch("pointerdown", 20); | |
| 676 | harness.touch("pointermove", 68); | |
| 677 | harness.touch("pointerup", 68); | |
| 678 | harness.flushFrame(); | |
| 679 | expect(harness.renderedSnapshot.rowData[0]?.text).not.toBe(bottom); | |
| 680 | }); | |
| 681 | ||
| 583 | 682 | it("keeps the selection on reveal and applies a hidden selection clear", async () => { |
| 584 | 683 | const harness = createHarness(); |
| 585 | 684 | const surface = await harness.create(); |
apps/web/src/terminal/ghostty/surface.ts+73-6| ... | ... | @@ -631,6 +631,14 @@ export class GhosttyTerminalSurface { |
| 631 | 631 | private clearSelectionAfterCopy = false; |
| 632 | 632 | private primedCopySelection = ""; |
| 633 | 633 | private wheelRemainder = 0; |
| 634 | private touchScroll: { | |
| 635 | pointerId: number; | |
| 636 | startX: number; | |
| 637 | startY: number; | |
| 638 | lastY: number; | |
| 639 | remainder: number; | |
| 640 | moved: boolean; | |
| 641 | } | null = null; | |
| 634 | 642 | private lastMouseMotionData = ""; |
| 635 | 643 | private mouseAnyEventTracking = false; |
| 636 | 644 | private dprMedia: MediaQueryList | null = null; |
| ... | ... | @@ -680,6 +688,7 @@ export class GhosttyTerminalSurface { |
| 680 | 688 | ): Promise<GhosttyTerminalSurface> { |
| 681 | 689 | const canvas = document.createElement("canvas"); |
| 682 | 690 | canvas.className = "block size-full cursor-text"; |
| 691 | canvas.style.touchAction = "pan-x pinch-zoom"; | |
| 683 | 692 | canvas.setAttribute("aria-hidden", "true"); |
| 684 | 693 | |
| 685 | 694 | const input = document.createElement("textarea"); |
| ... | ... | @@ -1361,6 +1370,21 @@ export class GhosttyTerminalSurface { |
| 1361 | 1370 | } |
| 1362 | 1371 | |
| 1363 | 1372 | private readonly onPointerDown = (event: PointerEvent) => { |
| 1373 | if (event.pointerType === "touch") { | |
| 1374 | event.preventDefault(); | |
| 1375 | if (!event.isPrimary || this.touchScroll !== null) return; | |
| 1376 | this.touchScroll = { | |
| 1377 | pointerId: event.pointerId, | |
| 1378 | startX: event.clientX, | |
| 1379 | startY: event.clientY, | |
| 1380 | lastY: event.clientY, | |
| 1381 | remainder: 0, | |
| 1382 | moved: false, | |
| 1383 | }; | |
| 1384 | this.clearHoveredLink(); | |
| 1385 | this.canvas.setPointerCapture(event.pointerId); | |
| 1386 | return; | |
| 1387 | } | |
| 1364 | 1388 | this.focus(); |
| 1365 | 1389 | if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) { |
| 1366 | 1390 | const button = ghosttyMouseButton(event.button); |
| ... | ... | @@ -1445,6 +1469,25 @@ export class GhosttyTerminalSurface { |
| 1445 | 1469 | } |
| 1446 | 1470 | |
| 1447 | 1471 | private readonly onPointerMove = (event: PointerEvent) => { |
| 1472 | if (event.pointerType === "touch") { | |
| 1473 | const touch = this.touchScroll; | |
| 1474 | if (touch?.pointerId !== event.pointerId) return; | |
| 1475 | event.preventDefault(); | |
| 1476 | touch.moved ||= | |
| 1477 | Math.hypot(event.clientX - touch.startX, event.clientY - touch.startY) > | |
| 1478 | TERMINAL_LINK_DRAG_THRESHOLD_PX; | |
| 1479 | if (!touch.moved) return; | |
| 1480 | const delta = terminalWheelDeltaRows( | |
| 1481 | { deltaY: touch.lastY - event.clientY, deltaMode: 0 }, | |
| 1482 | this.metrics.height, | |
| 1483 | this.rows, | |
| 1484 | touch.remainder, | |
| 1485 | ); | |
| 1486 | touch.lastY = event.clientY; | |
| 1487 | touch.remainder = delta.remainder; | |
| 1488 | this.scrollRows(delta.rows, event); | |
| 1489 | return; | |
| 1490 | } | |
| 1448 | 1491 | if (this.linkActivationPointerId === event.pointerId) { |
| 1449 | 1492 | const origin = this.linkActivationOrigin; |
| 1450 | 1493 | if ( |
| ... | ... | @@ -1585,6 +1628,26 @@ export class GhosttyTerminalSurface { |
| 1585 | 1628 | } |
| 1586 | 1629 | |
| 1587 | 1630 | private readonly onPointerUp = (event: PointerEvent) => { |
| 1631 | if (event.pointerType === "touch") { | |
| 1632 | const touch = this.touchScroll; | |
| 1633 | if (touch?.pointerId !== event.pointerId) return; | |
| 1634 | this.touchScroll = null; | |
| 1635 | event.preventDefault(); | |
| 1636 | if (this.canvas.hasPointerCapture(event.pointerId)) { | |
| 1637 | this.canvas.releasePointerCapture(event.pointerId); | |
| 1638 | } | |
| 1639 | if (event.type === "pointercancel" || touch.moved) return; | |
| 1640 | this.focus(); | |
| 1641 | if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) { | |
| 1642 | this.sendMouse("press", ghosttyMouseButton(0), event); | |
| 1643 | this.sendMouse("release", ghosttyMouseButton(0), event); | |
| 1644 | } else { | |
| 1645 | const link = this.linkAt(event.clientX, event.clientY); | |
| 1646 | if (link) this.options.onLinkActivate(link.text, event); | |
| 1647 | else this.clearSelection(); | |
| 1648 | } | |
| 1649 | return; | |
| 1650 | } | |
| 1588 | 1651 | this.setSelectionAutoscroll(0); |
| 1589 | 1652 | if (this.linkActivationPointerId === event.pointerId) { |
| 1590 | 1653 | event.preventDefault(); |
| ... | ... | @@ -1644,10 +1707,14 @@ export class GhosttyTerminalSurface { |
| 1644 | 1707 | this.wheelRemainder, |
| 1645 | 1708 | ); |
| 1646 | 1709 | this.wheelRemainder = delta.remainder; |
| 1647 | if (delta.rows === 0) return; | |
| 1648 | const magnitude = Math.abs(delta.rows); | |
| 1710 | this.scrollRows(delta.rows, event); | |
| 1711 | }; | |
| 1712 | ||
| 1713 | private scrollRows(rows: number, event: MouseEvent): void { | |
| 1714 | if (rows === 0) return; | |
| 1715 | const magnitude = Math.abs(rows); | |
| 1649 | 1716 | if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) { |
| 1650 | const button = delta.rows < 0 ? 4 : 5; | |
| 1717 | const button = rows < 0 ? 4 : 5; | |
| 1651 | 1718 | for (let index = 0; index < magnitude; index += 1) { |
| 1652 | 1719 | this.sendMouse("press", button, event); |
| 1653 | 1720 | } |
| ... | ... | @@ -1656,11 +1723,11 @@ export class GhosttyTerminalSurface { |
| 1656 | 1723 | if (this.core.isAlternateScreen()) { |
| 1657 | 1724 | // The alternate screen has no scrollback: translate wheel motion into |
| 1658 | 1725 | // arrow keys so full-screen apps like vim and less scroll, matching xterm. |
| 1659 | this.options.onData(terminalWheelArrowData(delta.rows, this.core.isApplicationCursorKeys())); | |
| 1726 | this.options.onData(terminalWheelArrowData(rows, this.core.isApplicationCursorKeys())); | |
| 1660 | 1727 | return; |
| 1661 | 1728 | } |
| 1662 | this.scrollViewport(delta.rows); | |
| 1663 | }; | |
| 1729 | this.scrollViewport(rows); | |
| 1730 | } | |
| 1664 | 1731 | |
| 1665 | 1732 | private readonly onMouseDown = (event: MouseEvent) => { |
| 1666 | 1733 | // Cancelling the middle button here stops autoscroll while still letting |