From ab1f579ea98d7b0cc5b0dafad9f2c3126d65be16 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Sun, 29 Mar 2026 06:29:09 -0700 Subject: [PATCH] feat: Android-only rAF-batched touch scroll for terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android batches touchmove events irregularly — firing one WheelEvent per touchmove caused burst delivery (5 events → 5 WheelEvents → visible jump). Fix: accumulate delta in touchmove, dispatch exactly one WheelEvent per requestAnimationFrame tick (≤60fps). Remainder carries over to next frame. UA-gated (/Android/i) — iOS and macOS are completely unaffected. WheelEvent path → xterm.js mouse reporting → tmux (correct inside path). e.preventDefault() blocks outer-container scroll on Android. --- muxplex/frontend/terminal.js | 66 ++++++++++++++++++++++++ muxplex/frontend/tests/test_terminal.mjs | 15 ++++++ 2 files changed, 81 insertions(+) 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)'); +}); +