Skip to content

Commit 2bed2d8

Browse files
chapterjasonclaude
andcommitted
Add touch scrolling support for mobile
xterm's xterm-screen overlays the viewport and eats touch events, so native overflow scrolling never fires on mobile. Translate single-finger drags into scroll input and suppress the browser's default touch gestures (pull-to-refresh, overscroll rubber-band, double-tap zoom) on the terminal element. Normal buffer accumulates the pixel delta and calls term.scrollLines() to advance the scrollback. Alt buffer dispatches a synthetic WheelEvent at the viewport so xterm's own wheel handler emits protocol-correct mouse-wheel escapes for apps that have opted into mouse tracking, and is swallowed by the existing capture handler otherwise -- matching the desktop wheel policy. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6278ba7 commit 2bed2d8

2 files changed

Lines changed: 100 additions & 0 deletions

File tree

client/src/styles/_layout.scss

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,15 @@
2828
flex: 1 1 0;
2929
min-height: 0;
3030
overflow: hidden;
31+
// Disable all default browser touch gestures inside the terminal
32+
// (pull-to-refresh, overscroll rubber-band, double-tap zoom). Touch
33+
// input is re-dispatched as synthetic wheel events by terminal/factory.ts
34+
// so xterm's own wheel handling applies uniformly on desktop and mobile.
35+
touch-action: none;
36+
overscroll-behavior: contain;
3137

3238
.xterm .xterm-viewport {
3339
overflow-y: auto;
40+
overscroll-behavior: contain;
3441
}
3542
}

client/src/terminal/factory.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,99 @@ export function createTerminal(container: HTMLElement): TerminalBundle {
7878
viewport.addEventListener("paste", () => {
7979
setTimeout(() => term.clearSelection(), 0);
8080
});
81+
82+
// Touch support. xterm's xterm-screen overlays xterm-viewport and eats
83+
// touch events, so mobile browsers never see a native scroll gesture.
84+
// Translate single-finger drags into synthetic wheel events dispatched
85+
// at xterm's viewport. xterm's own wheel handling then applies — which
86+
// means touch inherits exactly the same policy as the mouse wheel:
87+
// - normal buffer: scrolls the scrollback
88+
// - alt buffer + mouse tracking: emits protocol-correct mouse-wheel
89+
// escapes (apps with mouse support scroll natively)
90+
// - alt buffer without mouse tracking: swallowed by the capture
91+
// handler above, same as desktop
92+
const xtermViewport = viewport.querySelector<HTMLElement>(".xterm-viewport");
93+
let touchId: number | null = null;
94+
let lastY = 0;
95+
let pixelAccum = 0;
96+
97+
const findTouch = (list: TouchList, id: number): Touch | null => {
98+
for (let i = 0; i < list.length; i++) {
99+
const t = list[i];
100+
if (t && t.identifier === id) return t;
101+
}
102+
return null;
103+
};
104+
105+
// Row height via public surface: viewport's rendered height / term.rows.
106+
// Falls back to a plausible pixel height before layout is ready.
107+
const rowHeight = (): number => {
108+
const h = xtermViewport?.clientHeight ?? 0;
109+
const rows = term.rows || 1;
110+
return h > 0 ? h / rows : 17;
111+
};
112+
113+
const onTouchStart = (event: TouchEvent): void => {
114+
const t = event.touches[0];
115+
if (event.touches.length !== 1 || !t) {
116+
touchId = null;
117+
return;
118+
}
119+
touchId = t.identifier;
120+
lastY = t.clientY;
121+
pixelAccum = 0;
122+
};
123+
124+
const onTouchMove = (event: TouchEvent): void => {
125+
if (touchId === null || !xtermViewport) return;
126+
const t = findTouch(event.touches, touchId);
127+
if (!t) return;
128+
const dy = lastY - t.clientY;
129+
if (dy === 0) return;
130+
lastY = t.clientY;
131+
132+
if (term.buffer.active.type === "normal") {
133+
// Map continuous pixel delta onto xterm's row-based scroll API.
134+
// Synthetic WheelEvents don't trigger the browser's default scroll of
135+
// an overflow:auto element (gated on isTrusted), so we use xterm's
136+
// public scrollLines() — the same entry point used by its own wheel
137+
// handler after normalising pixels to lines.
138+
pixelAccum += dy;
139+
const rh = rowHeight();
140+
const lines = Math.trunc(pixelAccum / rh);
141+
if (lines !== 0) {
142+
pixelAccum -= lines * rh;
143+
term.scrollLines(lines);
144+
}
145+
} else {
146+
// Alt buffer: no scrollback to slide. Dispatch a synthetic wheel so
147+
// xterm's JS handler runs — it emits protocol-correct mouse-wheel
148+
// escapes when the app has opted into mouse tracking, and is
149+
// swallowed by the capture handler above otherwise (matching the
150+
// desktop wheel policy).
151+
xtermViewport.dispatchEvent(
152+
new WheelEvent("wheel", {
153+
deltaY: dy,
154+
deltaMode: WheelEvent.DOM_DELTA_PIXEL,
155+
clientX: t.clientX,
156+
clientY: t.clientY,
157+
bubbles: true,
158+
cancelable: true,
159+
}),
160+
);
161+
}
162+
};
163+
164+
const onTouchEnd = (event: TouchEvent): void => {
165+
if (touchId === null) return;
166+
if (findTouch(event.touches, touchId)) return;
167+
touchId = null;
168+
};
169+
170+
viewport.addEventListener("touchstart", onTouchStart, { passive: true });
171+
viewport.addEventListener("touchmove", onTouchMove, { passive: true });
172+
viewport.addEventListener("touchend", onTouchEnd, { passive: true });
173+
viewport.addEventListener("touchcancel", onTouchEnd, { passive: true });
81174
}
82175

83176
term.onSelectionChange(() => {

0 commit comments

Comments
 (0)