From 1c35da2bdb783f74c5e8b36e9859c9340afc1b55 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:25:47 +0000 Subject: [PATCH] fix(app): let xterm own terminal touch input Remove the duplicate gesture implementation so history scrolls once and terminal applications retain arrow-key and mouse-report input. Cover live application modes with trusted touch gestures and a real raw-stdin process. --- .../app/e2e/browser/terminal-find.spec.ts | 81 +++++++++-- .../runtime/terminal-emulator-runtime.ts | 128 ------------------ 2 files changed, 70 insertions(+), 139 deletions(-) diff --git a/packages/app/e2e/browser/terminal-find.spec.ts b/packages/app/e2e/browser/terminal-find.spec.ts index 606b26f753..6e83f095b2 100644 --- a/packages/app/e2e/browser/terminal-find.spec.ts +++ b/packages/app/e2e/browser/terminal-find.spec.ts @@ -500,8 +500,22 @@ test.describe("terminal history with a finger", () => { await expectSingleFingerScroll(page, 1); await expectMouseWheelScrollsHistory(page); }); + + for (const mode of ["alternate", "mouse"] as const) { + test(`preserves finger navigation in ${mode} input mode`, async ({ page }) => { + await openPhoneInputCapture(page, mode); + await expectFingerNavigationInput(page, mode); + }); + } }); +async function expectFingerNavigationInput(page: Page, mode: "alternate" | "mouse") { + await dragTerminalWithFinger(page, 24, async () => { + const prefix = mode === "alternate" ? "TOUCH_BYTES:1b4f41" : "TOUCH_BYTES:1b5b3c3634"; + await expect.poll(() => getTerminalBufferText(page)).toContain(prefix); + }); +} + async function openPhoneTerminalHistory(page: Page) { await harness.createTerminal({ name: "Finger scroll history", @@ -513,12 +527,47 @@ async function openPhoneTerminalHistory(page: Page) { "for i in $(seq 1 150); do echo HISTORY_$i; done; exec sleep 300", ], }); + await openPhoneTerminal(page); + await expect.poll(() => getTerminalBufferText(page)).toContain("HISTORY_150"); +} + +async function openPhoneInputCapture(page: Page, mode: "alternate" | "mouse") { + const escapes = mode === "alternate" ? "\x1b[?1049h\x1b[?1h" : "\x1b[?1000h\x1b[?1006h"; + await harness.createTerminal({ + name: "Touch application input", + command: process.execPath, + args: [ + "-e", + ` + process.stdin.setRawMode(true); + process.stdin.once("data", () => { + process.stdin.on("data", chunk => { + process.stdout.write("\\r\\nTOUCH_BYTES:" + chunk.toString("hex") + ":END\\r\\n"); + }); + process.stdout.write(${JSON.stringify(escapes)} + "TOUCH_INPUT_READY\\r\\n"); + }); + process.stdout.write("TOUCH_START_READY\\r\\n"); + `, + ], + }); + await openPhoneTerminal(page); + await expect.poll(() => getTerminalBufferText(page)).toContain("TOUCH_START_READY"); + await input(page).press("x"); + await expect.poll(() => getTerminalBufferText(page)).toContain("TOUCH_INPUT_READY"); + await expect + .poll(() => page.evaluate(() => window.__paseoTerminal!.buffer.active.type)) + .toBe(mode === "alternate" ? "alternate" : "normal"); + await expect + .poll(() => page.evaluate(() => window.__paseoTerminal!.modes.mouseTrackingMode)) + .toBe(mode === "mouse" ? "vt200" : "none"); +} + +async function openPhoneTerminal(page: Page) { await page.goto("/"); await page.getByRole("button", { name: "Open menu", exact: true }).click(); await page.getByTestId(`sidebar-workspace-row-${getServerId()}:${harness.workspaceId}`).click(); await expect(page.getByRole("textbox", { name: "Terminal input" })).toBeVisible(); await expect(page.getByTestId("terminal-surface").filter({ visible: true })).toBeVisible(); - await expect.poll(() => getTerminalBufferText(page)).toContain("HISTORY_150"); } async function expectMouseWheelScrollsHistory(page: Page) { @@ -529,6 +578,24 @@ async function expectMouseWheelScrollsHistory(page: Page) { } async function expectSingleFingerScroll(page: Page, moves: number) { + await dragTerminalWithFinger(page, moves, async ({ lineHeight, before }) => { + await expect.poll(async () => before - (await viewport(page))!).toBeGreaterThan(0); + const moved = before - (await viewport(page))!; + await test.info().attach("finger-scroll", { + body: JSON.stringify({ moves, distancePx: 120, lineHeight, rowsMoved: moved }), + contentType: "application/json", + }); + expect(moved, "one gesture must not scroll twice its physical distance").toBeLessThanOrEqual( + Math.ceil(120 / lineHeight), + ); + }); +} + +async function dragTerminalWithFinger( + page: Page, + moves: number, + inspect: (state: { lineHeight: number; before: number }) => Promise, +) { await test.step(`Drag history with ${moves} touch moves`, async () => { const screen = page .getByTestId("terminal-surface") @@ -552,16 +619,8 @@ async function expectSingleFingerScroll(page: Page, moves: number) { touchPoints: [{ x: start.x, y: start.y + (120 * step) / moves, id: 1 }], }); } - // Assert before lifting the finger so xterm's inertial scrolling is excluded. - await expect.poll(async () => before - (await viewport(page))!).toBeGreaterThan(0); - const moved = before - (await viewport(page))!; - await test.info().attach("finger-scroll", { - body: JSON.stringify({ moves, distancePx: 120, lineHeight, rowsMoved: moved }), - contentType: "application/json", - }); - expect(moved, "one gesture must not scroll twice its physical distance").toBeLessThanOrEqual( - Math.ceil(120 / lineHeight), - ); + // Check before lifting the finger to exclude inertial scrolling. + await inspect({ lineHeight, before }); } finally { await session.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }); await session.detach(); diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index 9da1930bbc..ae74a4945f 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -123,7 +123,6 @@ interface TerminalEmulatorRuntimeDisposables { removeVisualViewportResize: () => void; clearFitTimeouts: () => void; removeFontListeners: () => void; - removeTouchListeners: () => void; removeFilePasteListener: () => void; restoreDocumentStyles: () => void; restoreViewportStyles: () => void; @@ -155,7 +154,6 @@ const isAppleHandheld = maxTouchPoints: navigator.maxTouchPoints, }); -const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18; 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); @@ -764,11 +762,6 @@ export class TerminalEmulatorRuntime { }; input.host.addEventListener("paste", filePasteHandler, true); - const removeTouchListeners = this.setupTouchScrollHandlers({ - root: input.root, - host: input.host, - terminal, - }); const resizeObserver = new ResizeObserver(() => { fitAndEmitResize({ shouldClaim: false }); }); @@ -848,7 +841,6 @@ export class TerminalEmulatorRuntime { removeFontListeners: () => { fontSet?.removeEventListener?.("loadingdone", fontReadyHandler); }, - removeTouchListeners, removeFilePasteListener: () => { input.host.removeEventListener("paste", filePasteHandler, true); }, @@ -880,7 +872,6 @@ export class TerminalEmulatorRuntime { disposables.removeVisualViewportResize(); disposables.clearFitTimeouts(); disposables.removeFontListeners(); - disposables.removeTouchListeners(); disposables.removeFilePasteListener(); disposables.disposeFitAddon(); disposables.disposeWebglAddon(); @@ -1420,123 +1411,4 @@ export class TerminalEmulatorRuntime { } }; } - - 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; - - const activeTouch = { - identifier: -1, - startX: 0, - startY: 0, - lastX: 0, - lastY: 0, - mode: null as "vertical" | "horizontal" | null, - }; - - const touchStartHandler = (event: TouchEvent) => { - if (event.touches.length !== 1) { - touchScrollRemainderPx = 0; - activeTouch.identifier = -1; - activeTouch.mode = null; - return; - } - - const touch = event.touches[0]; - if (!touch) { - touchScrollRemainderPx = 0; - activeTouch.identifier = -1; - activeTouch.mode = null; - return; - } - - activeTouch.identifier = touch.identifier; - activeTouch.startX = touch.clientX; - activeTouch.startY = touch.clientY; - activeTouch.lastX = touch.clientX; - activeTouch.lastY = touch.clientY; - activeTouch.mode = null; - touchScrollRemainderPx = 0; - }; - - const touchMoveHandler = (event: TouchEvent) => { - if (event.touches.length !== 1) { - return; - } - - const touch = Array.from(event.touches).find( - (candidate) => candidate.identifier === activeTouch.identifier, - ); - if (!touch) { - return; - } - - const totalDeltaX = touch.clientX - activeTouch.startX; - const totalDeltaY = touch.clientY - activeTouch.startY; - if (activeTouch.mode === null) { - const absX = Math.abs(totalDeltaX); - const absY = Math.abs(totalDeltaY); - if (absX > 8 || absY > 8) { - activeTouch.mode = absY >= absX ? "vertical" : "horizontal"; - } - } - - const deltaY = touch.clientY - activeTouch.lastY; - activeTouch.lastX = touch.clientX; - activeTouch.lastY = touch.clientY; - - if (activeTouch.mode !== "vertical") { - return; - } - - touchScrollRemainderPx += deltaY; - const lineDelta = Math.trunc(touchScrollRemainderPx / touchScrollLineHeightPx); - if (lineDelta !== 0) { - input.terminal.scrollLines(-lineDelta); - touchScrollRemainderPx -= lineDelta * touchScrollLineHeightPx; - } - - // xterm also handles touchmove on document. A claimed vertical drag - // must not scroll through both handlers. - event.preventDefault(); - event.stopPropagation(); - }; - - const touchEndHandler = (event: TouchEvent) => { - const activeTouchEnded = Array.from(event.changedTouches).some( - (touch) => touch.identifier === activeTouch.identifier, - ); - if (activeTouchEnded || event.touches.length === 0) { - touchScrollRemainderPx = 0; - activeTouch.identifier = -1; - activeTouch.mode = null; - } - }; - - const touchCancelHandler = () => { - touchScrollRemainderPx = 0; - activeTouch.identifier = -1; - activeTouch.mode = null; - }; - - input.root.addEventListener("touchstart", touchStartHandler, { passive: true }); - input.root.addEventListener("touchmove", touchMoveHandler, { passive: false }); - input.root.addEventListener("touchend", touchEndHandler, { passive: true }); - input.root.addEventListener("touchcancel", touchCancelHandler, { passive: true }); - - return () => { - input.root.removeEventListener("touchstart", touchStartHandler); - input.root.removeEventListener("touchmove", touchMoveHandler); - input.root.removeEventListener("touchend", touchEndHandler); - input.root.removeEventListener("touchcancel", touchCancelHandler); - }; - } }