diff --git a/muxplex/frontend/terminal.js b/muxplex/frontend/terminal.js index 21c9605..0a766c8 100644 --- a/muxplex/frontend/terminal.js +++ b/muxplex/frontend/terminal.js @@ -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 }); })(); diff --git a/muxplex/frontend/tests/test_terminal.mjs b/muxplex/frontend/tests/test_terminal.mjs index 2102c57..b227fb5 100644 --- a/muxplex/frontend/tests/test_terminal.mjs +++ b/muxplex/frontend/tests/test_terminal.mjs @@ -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'); +});