fix: smooth Android terminal scroll via delta accumulation

Old: variable deltaY*3 per touchmove — Android batches events causing
burst-scroll then pause (jumpy). New: accumulate pixels across events,
fire fixed deltaY=120 per SCROLL_PX pixels. Each scroll step is
identical regardless of when Android delivers touchmove events.

SCROLL_PX=20 → ~40px swipe = 2 scroll clicks. Accumulator resets on
touchstart and touchend. macOS/iPad/mouse-wheel path unchanged.
This commit is contained in:
Brian Krabach
2026-03-28 16:19:30 -07:00
parent c1701bf26b
commit b2b9fd1746
2 changed files with 36 additions and 20 deletions
+22 -20
View File
@@ -248,21 +248,23 @@ window._openTerminal = openTerminal;
window._closeTerminal = closeTerminal;
// ---------------------------------------------------------------------------
// Touch scroll — forwards vertical swipe to xterm.js as a WheelEvent.
// xterm.js processes this identically to a real mouse wheel: if mouse
// reporting is enabled (tmux set -g mouse on), escape sequences are sent
// to the PTY so tmux/applications handle the scroll (inside).
// Mouse wheel scroll (the `wheel` event path) is unaffected — this adds
// touch on top.
// Touch scroll — accumulates delta and dispatches fixed-size WheelEvents.
// Fixed deltaY=120 per SCROLL_PX pixels ensures consistent scroll steps
// regardless of how Android Chrome batches touchmove events. Each WheelEvent
// travels through xterm.js mouse reporting to tmux (the "inside" path).
// Mouse wheel (wheel event) and iOS/desktop are unaffected.
// ---------------------------------------------------------------------------
;(function initTerminalTouchScroll() {
var container = document.getElementById('terminal-container');
if (!container || typeof container.addEventListener !== 'function') return;
var _touchLastY = 0;
var _accumulated = 0; // running pixel debt between scroll events
var SCROLL_PX = 20; // pixels of touch movement = one scroll click
container.addEventListener('touchstart', function (e) {
_touchLastY = e.touches[0].clientY;
_accumulated = 0; // reset on new touch
}, { passive: true });
container.addEventListener('touchmove', function (e) {
@@ -270,28 +272,28 @@ window._closeTerminal = closeTerminal;
e.preventDefault();
var y = e.touches[0].clientY;
var deltaY = _touchLastY - y; // positive = finger moved up = scroll toward newer content
_accumulated += _touchLastY - y; // accumulate — positive = swipe up
_touchLastY = y;
if (Math.abs(deltaY) < 2) return; // ignore micro-jitter
// Dispatch a synthetic WheelEvent on the xterm.js viewport so xterm.js
// handles it exactly as it handles a real mouse wheel: if mouse reporting
// is enabled (tmux mouse mode), it sends escape sequences to the PTY
// (scrolls inside the session). This is the correct "inside" path —
// unlike local-buffer scroll which never reaches the PTY.
var viewport = container.querySelector('.xterm-viewport');
if (!viewport) return;
viewport.dispatchEvent(new WheelEvent('wheel', {
deltaY: deltaY * 3, // scale: ~40px swipe ≈ 1 wheel click
deltaMode: WheelEvent.DOM_DELTA_PIXEL,
bubbles: true,
cancelable: true,
}));
// Fire one standard-sized click (deltaY=120) per SCROLL_PX pixels.
// Fixed deltaY makes every step identical regardless of Android event batching.
while (Math.abs(_accumulated) >= SCROLL_PX) {
var dir = _accumulated > 0 ? 1 : -1;
viewport.dispatchEvent(new WheelEvent('wheel', {
deltaY: dir * 120, // 120 = one standard wheel click, consistent
deltaMode: WheelEvent.DOM_DELTA_PIXEL,
bubbles: true,
cancelable: true,
}));
_accumulated -= dir * SCROLL_PX;
}
}, { passive: false }); // passive: false required to allow e.preventDefault()
container.addEventListener('touchend', function () {
_touchLastY = 0;
_accumulated = 0; // discard sub-threshold remainder on lift
}, { passive: true });
})();
+14
View File
@@ -742,3 +742,17 @@ test('terminal.js touchstart and touchend are passive', () => {
assert.ok(touchstartPassive, 'touchstart listener must declare passive');
assert.ok(touchendPassive, 'touchend listener must declare passive');
});
test('terminal.js touchmove uses accumulation for consistent scroll steps', () => {
const source = fs.readFileSync(
new URL('../terminal.js', import.meta.url), 'utf8'
);
assert.ok(source.includes('touchmove'), 'touchmove listener must be present');
assert.ok(source.includes('e.preventDefault'), 'must call preventDefault');
assert.ok(source.includes('WheelEvent'), 'must dispatch WheelEvent');
assert.ok(source.includes('.xterm-viewport'), 'must target xterm viewport');
assert.ok(source.includes('_accumulated'), 'must accumulate delta between events');
assert.ok(source.includes('SCROLL_PX'), 'must use a threshold constant');
assert.ok(!source.includes('deltaY * 3'), 'must NOT use variable deltaY scaling (causes jumpy scroll)');
assert.ok(source.includes('passive: false'), 'touchmove must be non-passive');
});