diff --git a/packages/app/src/components/terminal-emulator.tsx b/packages/app/src/components/terminal-emulator.tsx index 7504843c54..05feeb438c 100644 --- a/packages/app/src/components/terminal-emulator.tsx +++ b/packages/app/src/components/terminal-emulator.tsx @@ -22,6 +22,7 @@ import type { ITheme } from "@xterm/xterm"; import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode"; import type { PendingTerminalModifiers } from "../utils/terminal-keys"; import { + TERMINAL_SURFACE_TOUCH_ACTION, TerminalEmulatorRuntime, type TerminalOutputData, } from "../terminal/runtime/terminal-emulator-runtime"; @@ -577,7 +578,7 @@ export default function TerminalEmulator({ backgroundColor: xtermTheme.background ?? "#0b0b0b", overflow: "hidden", overscrollBehavior: "none", - touchAction: "pan-y", + touchAction: TERMINAL_SURFACE_TOUCH_ACTION, }), [xtermTheme.background], ); diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts index bf044237b0..0f0187d89c 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts @@ -223,6 +223,56 @@ function getBrowserTerminal(): BrowserTerminal { return terminal; } +interface ScrollbackTerminal { + rows: number; + buffer: { + active: { + viewportY: number; + baseY: number; + }; + }; +} + +function getScrollbackTerminal(): ScrollbackTerminal { + const terminal = window.__paseoTerminal as ScrollbackTerminal | undefined; + if (!terminal?.buffer?.active) { + throw new Error("Expected xterm scrollback buffer"); + } + return terminal; +} + +function dispatchTouch(input: { + target: HTMLElement; + type: "touchstart" | "touchmove" | "touchend"; + x: number; + y: number; +}): void { + const touch = new Touch({ + identifier: 1, + target: input.target, + clientX: input.x, + clientY: input.y, + pageX: input.x, + pageY: input.y, + screenX: input.x, + screenY: input.y, + radiusX: 1, + radiusY: 1, + rotationAngle: 0, + force: 0.5, + }); + const ended = input.type === "touchend"; + input.target.dispatchEvent( + new TouchEvent(input.type, { + bubbles: true, + cancelable: true, + touches: ended ? [] : [touch], + targetTouches: ended ? [] : [touch], + changedTouches: [touch], + }), + ); +} + interface DispatchTerminalKeyInput { host: HTMLElement; key: string; @@ -326,6 +376,59 @@ describe("terminal emulator runtime in a real browser", () => { expect(window.__paseoTerminal?.options.scrollback).toBe(42_000); }); + it("scrolls terminal history when a finger drags downward", async () => { + await page.viewport(900, 600); + const mounted = createTerminalHost({ width: 720, height: 360, scrollback: 1_000 }); + + await waitFor({ + predicate: () => mounted.sizes.length > 0 && (window.__paseoTerminal?.rows ?? 0) > 0, + }); + await writeLines( + mounted, + Array.from({ length: 80 }, (_, index) => `history-line-${index}`), + ); + + const screen = mounted.host.querySelector(".xterm-screen"); + const viewport = mounted.host.querySelector(".xterm-viewport"); + if (!screen || !viewport) { + throw new Error("Expected xterm screen and viewport"); + } + await waitFor({ + predicate: () => screen.getBoundingClientRect().height > 0, + }); + + expect(mounted.root.style.touchAction).toBe("none"); + expect(screen.style.touchAction).toBe("none"); + expect(viewport.style.touchAction).toBe("none"); + + const terminal = getScrollbackTerminal(); + const before = terminal.buffer.active.viewportY; + expect(before).toBe(terminal.buffer.active.baseY); + + const rect = screen.getBoundingClientRect(); + const lineHeight = rect.height / terminal.rows; + const startX = rect.left + rect.width / 2; + const startY = rect.top + rect.height / 2; + const draggedLines = 4; + dispatchTouch({ target: screen, type: "touchstart", x: startX, y: startY }); + dispatchTouch({ + target: screen, + type: "touchmove", + x: startX, + y: startY + lineHeight * draggedLines, + }); + dispatchTouch({ + target: screen, + type: "touchend", + x: startX, + y: startY + lineHeight * draggedLines, + }); + + const scrolledLines = before - terminal.buffer.active.viewportY; + expect(scrolledLines).toBeGreaterThanOrEqual(draggedLines - 1); + expect(scrolledLines).toBeLessThanOrEqual(draggedLines); + }); + it("does not claim PTY ownership from passive mount refits", async () => { await page.viewport(900, 600); const mounted = createTerminalHost({ width: 720, height: 360 }); diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index b1cab84d0e..970baf65a8 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -156,6 +156,11 @@ const isAppleHandheld = }); const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18; +// The screen canvas covers the scrollable viewport, and the page itself does +// not scroll. `pan-y` lets the browser claim a vertical drag and then drop it, +// so a finger swipe never reaches the scrollback. `none` keeps the gesture in +// the touch handler below. +export const TERMINAL_SURFACE_TOUCH_ACTION = "none"; const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000]; const OUTPUT_OPERATION_TIMEOUT_MS = 5_000; const EMPTY_TERMINAL_OUTPUT = new Uint8Array(0); @@ -685,6 +690,7 @@ export class TerminalEmulatorRuntime { root: input.root, }); const restoreViewportStyles = this.applyViewportTouchStyles({ + root: input.root, host: input.host, }); @@ -1387,9 +1393,15 @@ export class TerminalEmulatorRuntime { }; } - private applyViewportTouchStyles(input: { host: HTMLDivElement }): () => void { + private applyViewportTouchStyles(input: { + root: HTMLDivElement; + host: HTMLDivElement; + }): () => void { const viewportElement = input.host.querySelector(".xterm-viewport"); + const screenElement = input.host.querySelector(".xterm-screen"); + const previousRootTouchAction = input.root.style.touchAction; + const previousScreenTouchAction = screenElement?.style.touchAction ?? ""; const previousViewportOverscroll = viewportElement?.style.overscrollBehavior ?? ""; const previousViewportTouchAction = viewportElement?.style.touchAction ?? ""; const previousViewportOverflowY = viewportElement?.style.overflowY ?? ""; @@ -1397,9 +1409,14 @@ export class TerminalEmulatorRuntime { const previousViewportPointerEvents = viewportElement?.style.pointerEvents ?? ""; const previousViewportWebkitOverflowScrolling = viewportElement?.style.getPropertyValue("-webkit-overflow-scrolling") ?? ""; + + input.root.style.touchAction = TERMINAL_SURFACE_TOUCH_ACTION; + if (screenElement) { + screenElement.style.touchAction = TERMINAL_SURFACE_TOUCH_ACTION; + } if (viewportElement) { viewportElement.style.overscrollBehavior = "none"; - viewportElement.style.touchAction = "pan-y"; + viewportElement.style.touchAction = TERMINAL_SURFACE_TOUCH_ACTION; viewportElement.style.overflowY = "auto"; viewportElement.style.overflowX = "hidden"; viewportElement.style.pointerEvents = "auto"; @@ -1407,6 +1424,10 @@ export class TerminalEmulatorRuntime { } return () => { + input.root.style.touchAction = previousRootTouchAction; + if (screenElement) { + screenElement.style.touchAction = previousScreenTouchAction; + } if (viewportElement) { viewportElement.style.overscrollBehavior = previousViewportOverscroll; viewportElement.style.touchAction = previousViewportTouchAction; @@ -1421,17 +1442,25 @@ export class TerminalEmulatorRuntime { }; } + private touchScrollLineHeightPx(input: { host: HTMLDivElement; terminal: Terminal }): number { + const screen = input.host.querySelector(".xterm-screen"); + const rows = input.terminal.rows; + if (screen && rows > 0) { + const height = screen.getBoundingClientRect().height / rows; + if (height > 0) { + return height; + } + } + return DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX; + } + private setupTouchScrollHandlers(input: { root: HTMLDivElement; host: HTMLDivElement; terminal: Terminal; }): () => void { let touchScrollRemainderPx = 0; - const measuredLineHeight = - input.host.querySelector(".xterm-rows > div")?.getBoundingClientRect().height ?? - 0; - const touchScrollLineHeightPx = - measuredLineHeight > 0 ? measuredLineHeight : DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX; + let touchScrollLineHeightPx = this.touchScrollLineHeightPx(input); const activeTouch = { identifier: -1, @@ -1465,6 +1494,7 @@ export class TerminalEmulatorRuntime { activeTouch.lastY = touch.clientY; activeTouch.mode = null; touchScrollRemainderPx = 0; + touchScrollLineHeightPx = this.touchScrollLineHeightPx(input); }; const touchMoveHandler = (event: TouchEvent) => { @@ -1504,7 +1534,10 @@ export class TerminalEmulatorRuntime { touchScrollRemainderPx -= lineDelta * touchScrollLineHeightPx; } + // xterm also turns this touch into a scroll on document. Handling it + // twice jumps about two lines per cell. event.preventDefault(); + event.stopPropagation(); }; const touchEndHandler = (event: TouchEvent) => {