diff --git a/muxplex/frontend/terminal.js b/muxplex/frontend/terminal.js index 867d5ee..007534b 100644 --- a/muxplex/frontend/terminal.js +++ b/muxplex/frontend/terminal.js @@ -247,4 +247,70 @@ function closeTerminal() { window._openTerminal = openTerminal; window._closeTerminal = closeTerminal; +// --------------------------------------------------------------------------- +// Android touch scroll — rAF-batched WheelEvent dispatch +// Android batches touchmove events irregularly; dispatching one WheelEvent +// per frame (via requestAnimationFrame) smooths over burst delivery. +// UA-gated: iOS and macOS are unaffected (they use mouse wheel natively). +// --------------------------------------------------------------------------- +;(function initAndroidTerminalScroll() { + if (!/Android/i.test(navigator.userAgent)) return; + + var container = document.getElementById('terminal-container'); + if (!container) return; + + var _lastY = 0; + var _accumulated = 0; // pixel debt between rAF ticks + var _rafId = null; + var SCROLL_PX = 20; // pixels of touch movement = one WheelEvent dispatch + + function flushScroll() { + _rafId = null; + if (!_term || Math.abs(_accumulated) < SCROLL_PX) return; + + var viewport = container.querySelector('.xterm-viewport'); + if (!viewport) { _accumulated = 0; return; } + + // One WheelEvent per frame — dir * 120 = one standard scroll click + var dir = _accumulated > 0 ? 1 : -1; + viewport.dispatchEvent(new WheelEvent('wheel', { + deltaY: dir * 120, + deltaMode: WheelEvent.DOM_DELTA_PIXEL, + bubbles: true, + cancelable: true, + })); + _accumulated -= dir * SCROLL_PX; + + // Self-schedule until remainder is consumed + if (Math.abs(_accumulated) >= SCROLL_PX) { + _rafId = requestAnimationFrame(flushScroll); + } + } + + container.addEventListener('touchstart', function (e) { + _lastY = e.touches[0].clientY; + _accumulated = 0; + if (_rafId) { cancelAnimationFrame(_rafId); _rafId = null; } + }, { passive: true }); + + container.addEventListener('touchmove', function (e) { + if (!_term) return; + e.preventDefault(); // block outer-container scroll + + var y = e.touches[0].clientY; + _accumulated += _lastY - y; // positive = swipe up = newer content + _lastY = y; + + if (!_rafId) { + _rafId = requestAnimationFrame(flushScroll); + } + }, { passive: false }); // passive:false required for preventDefault + + container.addEventListener('touchend', function () { + _lastY = 0; + _accumulated = 0; + if (_rafId) { cancelAnimationFrame(_rafId); _rafId = null; } + }, { passive: true }); +})(); + diff --git a/muxplex/frontend/tests/test_terminal.mjs b/muxplex/frontend/tests/test_terminal.mjs index 3e73efc..d467737 100644 --- a/muxplex/frontend/tests/test_terminal.mjs +++ b/muxplex/frontend/tests/test_terminal.mjs @@ -5,6 +5,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; +import fs from 'node:fs'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -718,4 +719,18 @@ test('terminal is auto-focused when WebSocket opens', () => { '_term.focus() should be called exactly once when the WebSocket open event fires'); }); +// --- Android touch scroll --------------------------------------------------- + +test('terminal.js Android touch scroll is UA-gated', () => { + const source = fs.readFileSync( + new URL('../terminal.js', import.meta.url), 'utf8' + ); + assert.ok(source.includes('Android'), 'must UA-detect Android before adding handlers'); + assert.ok(source.includes('requestAnimationFrame'), 'must use rAF to batch scroll dispatch'); + assert.ok(source.includes('e.preventDefault'), 'touchmove must preventDefault to block outer scroll'); + assert.ok(source.includes('WheelEvent'), 'must dispatch WheelEvent to xterm viewport'); + assert.ok(source.includes('passive: false'), 'touchmove must be non-passive'); + assert.ok(!source.includes('scrollLines'), 'must NOT use scrollLines (scrolls local buffer not PTY)'); +}); +