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.
This commit is contained in:
paseo-bot[bot]
2026-11-04 11:25:47 +00:00
parent a1cd62fbc9
commit 1c35da2bdb
2 changed files with 70 additions and 139 deletions
+70 -11
View File
@@ -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<void>,
) {
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();
@@ -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<HTMLElement>(".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);
};
}
}