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:
@@ -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;
|
||||
|
||||
// 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: deltaY * 3, // scale: ~40px swipe ≈ 1 wheel click
|
||||
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 });
|
||||
})();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user