fix(mobile): keep terminal scroll gestures from becoming taps (#31445)

Fixes #25457
Fixes #25458

Supersedes community PR #25432, community PR #25435. Original contributor credit is preserved below.

Co-authored-by: Prateek Rungta <prungta2@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Neil
2026-10-21 01:16:49 -07:00
committed by GitHub
co-authored by Prateek Rungta Claude Opus 5.5
parent 251bbf7bb0
commit efcf8f0888
6 changed files with 472 additions and 17 deletions
@@ -43,6 +43,8 @@ export function applyNormalBufferScrollDelta(scope: TerminalDocumentScope, delta
const applied = clampNormalScrollLines(scope, lines)
if (applied !== 0) {
scope.term.scrollLines(applied)
// Why: a touch that scrolled a row is a scroll, even inside TAP_SLOP.
scope.tapCandidate = null
// Why: xterm's renderer is row-based. Buffer touch pixels and only
// commit whole rows so TUI canvas layers do not shimmer between
// fractional transforms and xterm repaints.
@@ -52,13 +54,6 @@ export function applyNormalBufferScrollDelta(scope: TerminalDocumentScope, delta
scope.smoothScrollOffsetY = 0
}
}
const limit = effectiveCellH - 1
if (scope.smoothScrollOffsetY > limit) {
scope.smoothScrollOffsetY = limit
}
if (scope.smoothScrollOffsetY < -limit) {
scope.smoothScrollOffsetY = -limit
}
updateScrollIndicator(scope, true)
return true
}
@@ -89,6 +84,15 @@ export function enqueueNormalBufferScrollDelta(scope: TerminalDocumentScope, del
return true
}
/** Whether the deltas queued for the next frame, with the remainder already held, move a row. */
export function queuedNormalBufferScrollReachesRow(scope: TerminalDocumentScope) {
const effectiveCellH = getCellHeight(scope) * getTotalScale(scope)
return (
effectiveCellH > 0 &&
Math.abs(scope.smoothScrollOffsetY - scope.pendingNormalScrollDeltaY) >= effectiveCellH
)
}
export function resetSmoothScrollOffset(scope: TerminalDocumentScope) {
scope.pendingNormalScrollDeltaY = 0
if (scope.normalScrollFrameId !== null) {
@@ -2,6 +2,8 @@ import type { TerminalDocumentScope } from './document-scope'
import { notify } from './host-notify'
import { repositionOverlay, stopEdgeScroll } from './selection-overlay'
import { seedWordSelection } from './selection-range'
import { resetSmoothScrollOffset } from './normal-buffer-smooth-scroll'
import { stopSurfaceTouchGestures } from './surface-touch-gestures'
// Why: entering and leaving select mode drives the overlay; kept out of selection-range so the
// range math the overlay reads doesn't import the overlay back.
@@ -27,6 +29,8 @@ export function cancelSelect(scope: TerminalDocumentScope) {
}
export function enterSelect(scope: TerminalDocumentScope, col: number, absRow: number) {
stopSurfaceTouchGestures(scope)
resetSmoothScrollOffset(scope)
scope.selMode = 'select'
seedWordSelection(scope, col, absRow)
scope.selectionOverlay!.classList.add('active')
@@ -22,6 +22,9 @@ import { updateTransform } from './viewport-transform'
import { viewportPoint } from './viewport-cell'
import { attachSurfaceWheelHandler } from './wheel-scroll'
/** How long after the last touchmove its velocity still launches momentum, in milliseconds. */
const RELEASE_VELOCITY_MAX_AGE_MS = 100
/** A surface that has already been wired, so a re-mount does not stack handlers. */
type TerminalGestureSurface = HTMLElement & { __orcaSurfaceHandlersAttached?: boolean }
@@ -106,11 +109,14 @@ export function attachSurfaceEventHandlers(
if (scope.touchGesture.momentumId) {
cancelAnimationFrame(scope.touchGesture.momentumId)
scope.touchGesture.momentumId = null
// Why: this touch stopped a coast; lifting it must not also tap.
scope.tapCandidate = null
}
const touches = touchesInRoot(scope.root, e.touches)
if (touches.length === 2) {
stopSurfaceTouchGestures(scope)
resetSmoothScrollOffset(scope)
scope.touchGesture.isPinching = true
scope.smoothScrollOffsetY = 0
scope.touchGesture.pinchDist = getDistance(touches[0], touches[1])
scope.touchGesture.pinchScale = scope.userScale
const mid = pinchMidpoint(scope, touches[0], touches[1])
@@ -193,6 +199,8 @@ export function attachSurfaceEventHandlers(
if (lines !== 0) {
scope.touchGesture.accumDelta -= lines * effectiveCellH
routeScrollLines(scope, lines, x, y)
// Why: a touch that scrolled a row is a scroll, even inside TAP_SLOP.
scope.tapCandidate = null
}
} else {
if (enqueueNormalBufferScrollDelta(scope, deltaY)) {
@@ -247,7 +255,9 @@ export function attachSurfaceEventHandlers(
}
if (touches.length === 0) {
let vel = scope.touchGesture.velY
// Why: velY is the speed of the last move; a finger that then rested has none to launch.
const rested = Date.now() - scope.touchGesture.lastTime > RELEASE_VELOCITY_MAX_AGE_MS
let vel = rested ? 0 : scope.touchGesture.velY
const FRICTION = 0.972
const MIN_VEL = 0.012
let lastMomentumTime = performance.now()
@@ -284,6 +294,18 @@ export function attachSurfaceEventHandlers(
},
{ capture: true, passive: true }
)
targetSurface.addEventListener(
'touchcancel',
function () {
if (targetSurface !== scope.surface) {
return
}
stopSurfaceTouchGestures(scope)
resetSmoothScrollOffset(scope)
},
{ capture: true, passive: true }
)
}
export function startSurfaceTouchGestures(scope: TerminalDocumentScope) {
+4 -1
View File
@@ -3,6 +3,7 @@ import { cancelSelect, enterSelect } from './selection-mode'
import { notify } from './host-notify'
import { viewportToCell } from './viewport-cell'
import type { TerminalDocumentScope } from './document-scope'
import { queuedNormalBufferScrollReachesRow } from './normal-buffer-smooth-scroll'
import { notifyTerminalSurfaceTap } from './surface-tap'
import {
eventTargetInRoot,
@@ -247,7 +248,9 @@ function onDocumentTouchEnd(scope: TerminalDocumentScope, e: TouchEvent) {
touches.length === 0 &&
scope.tapCandidate &&
scope.selMode !== 'select' &&
Date.now() - scope.tapCandidate.t <= TAP_MAX_MS
Date.now() - scope.tapCandidate.t <= TAP_MAX_MS &&
// Why: a lift can beat the frame that commits the row; movement that cancelled out leaves a tap.
!queuedNormalBufferScrollReachesRow(scope)
) {
notifyTerminalSurfaceTap(scope, scope.tapCandidate.x, scope.tapCandidate.y, true)
}
@@ -3,7 +3,7 @@
// printed http(s) URL must post an `open-url` message (which RN routes to the
// in-app/phone browser). Regression guard for taps that jitter a few pixels —
// those were being swallowed because the tap shared the long-press slop gate.
import { beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TERMINAL_DOCUMENT_SCRIPT } from './terminal-webview-document-script.generated'
import { TERMINAL_DOCUMENT_MARKUP } from './terminal-webview-html'
@@ -12,7 +12,11 @@ function bodyMarkup(): string {
}
// Minimal xterm stub: one scrollback line containing a URL, fixed 8x15 cells.
function makeTerminal(lineRef: { current: string }, mouseTrackingMode = 'none') {
function makeTerminal(
lineRef: { current: string },
mouseTrackingMode = 'none',
scrollbackRows = 0
) {
return {
cols: 80,
rows: 24,
@@ -23,7 +27,7 @@ function makeTerminal(lineRef: { current: string }, mouseTrackingMode = 'none')
buffer: {
active: {
viewportY: 0,
baseY: 0,
baseY: scrollbackRows,
length: 1,
cursorY: 0,
type: 'normal' as const,
@@ -70,13 +74,14 @@ type OscLinkRange = { row: number; startCol: number; endCol: number; uri: string
function boot(
line: string,
oscLinks?: OscLinkRange[],
mouseTrackingMode = 'none'
mouseTrackingMode = 'none',
scrollbackRows = 0
): { posted: Posted; setLine: (line: string) => void } {
const posted: Posted = []
const lineRef = { current: line }
const w = window as unknown as { Terminal: unknown; ReactNativeWebView: unknown }
w.Terminal = function () {
return makeTerminal(lineRef, mouseTrackingMode)
return makeTerminal(lineRef, mouseTrackingMode, scrollbackRows)
}
w.ReactNativeWebView = {
postMessage(s: string) {
@@ -106,8 +111,8 @@ function fireTouch(type: string, touches: Array<{ x: number; y: number }>): void
Object.defineProperty(ev, 'touches', {
value: touches.map((p, i) => ({ identifier: i, clientX: p.x, clientY: p.y, target: surface }))
})
Object.defineProperty(ev, 'target', { value: surface })
document.dispatchEvent(ev)
// Dispatched on the surface so its own scroll handlers see the touch, as on a device.
surface.dispatchEvent(ev)
}
// Wait one macrotask so init()'s rAF chain (term.open -> ready) settles.
@@ -122,10 +127,22 @@ describe('terminal WebView tap routing', () => {
const screenXForCol = (col: number): number => col * 8 * 0.3125
beforeEach(() => {
vi.spyOn(window, 'addEventListener')
vi.spyOn(document, 'addEventListener')
Object.defineProperty(window, 'innerWidth', { value: 200, configurable: true })
Object.defineProperty(window, 'innerHeight', { value: 400, configurable: true })
})
afterEach(() => {
for (const [type, listener, options] of vi.mocked(window.addEventListener).mock.calls) {
window.removeEventListener(type, listener, options)
}
for (const [type, listener, options] of vi.mocked(document.addEventListener).mock.calls) {
document.removeEventListener(type, listener, options)
}
vi.restoreAllMocks()
})
it('posts open-url when a clean tap lands on a URL', async () => {
const { posted } = boot(URL_LINE)
await settle()
@@ -304,4 +321,77 @@ describe('terminal WebView tap routing', () => {
fireTouch('touchend', [])
expect(posted.find((m) => m.type === 'open-url')).toBeUndefined()
})
// Why: 15px rows at this fit scale are ~4.7px on screen, so the drags below
// scroll rows while staying inside TAP_SLOP.
it('does not tap after a short drag that scrolled scrollback', async () => {
const { posted } = boot('plain output', undefined, 'none', 50)
await settle()
fireTouch('touchstart', [{ x: 20, y: 100 }])
fireTouch('touchmove', [{ x: 20, y: 88 }])
fireTouch('touchend', [])
expect(posted.find((m) => m.type === 'terminal-tap')).toBeUndefined()
})
it('does not tap or click after a short drag that scrolled a mouse-tracking TUI', async () => {
const { posted } = boot('interactive prompt', undefined, 'drag')
await settle()
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({ type: 'write', data: '\u001b[?1006l' })
})
)
fireTouch('touchstart', [{ x: 20, y: 100 }])
fireTouch('touchmove', [{ x: 20, y: 88 }])
fireTouch('touchend', [])
expect(posted.find((m) => m.type === 'terminal-tap')).toBeUndefined()
const input = posted
.filter((m) => m.type === 'terminal-input')
.map((m) => String(m.bytes))
.join('')
// Wheel reports only: no button-press (space) or release (#) report.
const reports = input.split('\u001b[M').slice(1)
expect(reports.length).toBeGreaterThan(0)
expect(reports.every((report) => report[0] === '`' || report[0] === 'a')).toBe(true)
})
it('still taps when the touch moves less than a row', async () => {
const { posted } = boot('plain output', undefined, 'none', 50)
await settle()
fireTouch('touchstart', [{ x: 20, y: 100 }])
fireTouch('touchmove', [{ x: 20, y: 97 }])
fireTouch('touchend', [])
expect(posted.filter((m) => m.type === 'terminal-tap')).toHaveLength(1)
})
it('still taps when the touch moves a row and back before anything scrolls', async () => {
const { posted } = boot('plain output', undefined, 'none', 50)
await settle()
fireTouch('touchstart', [{ x: 20, y: 100 }])
fireTouch('touchmove', [{ x: 20, y: 94 }])
fireTouch('touchmove', [{ x: 20, y: 100 }])
fireTouch('touchend', [])
expect(posted.filter((m) => m.type === 'terminal-tap')).toHaveLength(1)
})
it('does not tap when the touch stops a coast', async () => {
let now = 1_000
vi.spyOn(Date, 'now').mockImplementation(() => now)
const { posted } = boot('plain output', undefined, 'none', 50)
await settle()
fireTouch('touchstart', [{ x: 20, y: 300 }])
now += 16
fireTouch('touchmove', [{ x: 20, y: 240 }])
now += 16
fireTouch('touchmove', [{ x: 20, y: 180 }])
fireTouch('touchend', [])
const beforeStoppingTouch = posted.length
fireTouch('touchstart', [{ x: 20, y: 180 }])
fireTouch('touchend', [])
expect(posted.slice(beforeStoppingTouch).find((m) => m.type === 'terminal-tap')).toBeUndefined()
})
})
@@ -0,0 +1,332 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { terminalDocumentDouble } from './document/document-terminal-double.test-support'
import type { TerminalDocumentTerminal } from './document/document-terminal-shape'
import { createTerminalDocument } from './document/create-terminal-document'
import { TERMINAL_DOCUMENT_SCRIPT } from './terminal-webview-document-script.generated'
import { TERMINAL_DOCUMENT_MARKUP } from './terminal-webview-html'
type BufferState = {
baseY: number
type: 'alternate' | 'normal'
viewportY: number
}
// 40 cols of 8px cells fit a 381px viewport unscaled, so one row is 15 screen px.
const CELL_HEIGHT = 15
const FRAME_MS = 16
const ESC_ARROW_DOWN = '\u001b[B'
function makeTerminal(
buffer: BufferState,
scrollLines: (lines: number) => void
): TerminalDocumentTerminal {
let openedOn: HTMLElement | undefined
return {
...terminalDocumentDouble().terminal,
cols: 40,
rows: 24,
modes: { mouseTrackingMode: 'none' },
_core: {
_renderService: { dimensions: { css: { cell: { width: 8, height: CELL_HEIGHT } } } }
},
buffer: {
active: {
get baseY() {
return buffer.baseY
},
get type() {
return buffer.type
},
get viewportY() {
return buffer.viewportY
},
cursorY: 0,
length: 1,
getLine: () => undefined
}
},
get element() {
return openedOn
},
open(surface: HTMLElement) {
openedOn = surface
},
scrollLines
}
}
describe('terminal WebView touch scrolling', () => {
let animationFrames: Map<number, (frameTime: number) => void>
let nextFrameId: number
let buffer: BufferState
let now: number
let postMessage: ReturnType<typeof vi.fn<(data: string) => void>>
let scrollLines: ReturnType<typeof vi.fn<(lines: number) => void>>
let stopDocument: (() => void) | undefined
function boot(importedDocument = false): void {
document.body.innerHTML = TERMINAL_DOCUMENT_MARKUP
// The bundle the WebView loads, run as the WebView runs it.
if (importedDocument) {
const terminalDocument = createTerminalDocument()
stopDocument = () => terminalDocument.stop()
} else {
new Function(TERMINAL_DOCUMENT_SCRIPT)()
}
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({ type: 'init', cols: 40, rows: 24, initialData: '' })
})
)
// Why: init commits the replacement surface on the next animation frame.
runFrames(8)
}
function runFrames(count: number): void {
for (let i = 0; i < count && animationFrames.size > 0; i++) {
now += FRAME_MS
const currentFrames = [...animationFrames]
for (const [id, callback] of currentFrames) {
if (animationFrames.delete(id)) {
callback(now)
}
}
}
}
function fireTouch(type: string, point: { x: number; y: number } | null): void {
fireTouches(type, point ? [point] : [])
}
function fireTouches(type: string, points: Array<{ x: number; y: number }>): void {
const surface = document.getElementById('terminal-surface')
if (!surface) {
throw new Error('terminal surface missing')
}
const event = new Event(type, { bubbles: true, cancelable: true })
const touches = points.map((point, identifier) => ({
identifier,
clientX: point.x,
clientY: point.y,
target: surface
}))
Object.defineProperty(event, 'touches', { value: touches })
surface.dispatchEvent(event)
}
/** A finger moving up the screen, one touchmove per frame; returns where it ended. */
function dragUp(fromY: number, stepPx: number, steps: number): number {
let y = fromY
for (let i = 0; i < steps; i++) {
y -= stepPx
now += FRAME_MS
fireTouch('touchmove', { x: 40, y })
runFrames(1)
}
return y
}
function scrolledRows(): number {
return scrollLines.mock.calls.reduce((total, [lines]) => total + lines, 0)
}
function terminalInputBytes(): string {
return postMessage.mock.calls
.map(([raw]): { bytes?: string; type?: string } => JSON.parse(raw))
.filter((msg) => msg.type === 'terminal-input')
.map((msg) => msg.bytes ?? '')
.join('')
}
beforeEach(() => {
vi.spyOn(window, 'addEventListener')
vi.spyOn(document, 'addEventListener')
animationFrames = new Map()
nextFrameId = 1
buffer = { baseY: 500, type: 'normal', viewportY: 100 }
now = 1_000
scrollLines = vi.fn<(lines: number) => void>()
postMessage = vi.fn<(data: string) => void>()
vi.spyOn(Date, 'now').mockImplementation(() => now)
vi.spyOn(performance, 'now').mockImplementation(() => now)
vi.stubGlobal('requestAnimationFrame', (callback: (frameTime: number) => void) => {
const id = nextFrameId++
animationFrames.set(id, callback)
return id
})
vi.stubGlobal('cancelAnimationFrame', (id: number) => {
animationFrames.delete(id)
})
Object.defineProperty(window, 'innerWidth', { value: 381, configurable: true })
Object.defineProperty(window, 'innerHeight', { value: 612, configurable: true })
Object.assign(window, {
Terminal: function () {
return makeTerminal(buffer, scrollLines)
},
ReactNativeWebView: { postMessage }
})
})
afterEach(() => {
stopDocument?.()
stopDocument = undefined
for (const [type, listener, options] of vi.mocked(window.addEventListener).mock.calls) {
window.removeEventListener(type, listener, options)
}
for (const [type, listener, options] of vi.mocked(document.addEventListener).mock.calls) {
document.removeEventListener(type, listener, options)
}
vi.restoreAllMocks()
vi.unstubAllGlobals()
vi.useRealTimers()
})
it('scrolls scrollback for a drag slower than one pixel per frame', () => {
boot()
fireTouch('touchstart', { x: 40, y: 400 })
dragUp(400, 0.5, 200)
// 100px of travel over 15px rows.
expect(scrolledRows()).toBe(6)
})
it('carries the sub-row remainder of a fast drag into the next move', () => {
boot()
fireTouch('touchstart', { x: 40, y: 400 })
const y = dragUp(400, 100, 1)
expect(scrolledRows()).toBe(6)
// 10px left over from the first move plus 5px more is one more row.
dragUp(y, 5, 1)
expect(scrolledRows()).toBe(7)
})
it('coasts when the finger lifts mid-drag', () => {
boot()
fireTouch('touchstart', { x: 40, y: 400 })
dragUp(400, 20, 5)
const rowsWhileTouching = scrolledRows()
fireTouch('touchend', null)
runFrames(30)
expect(scrolledRows()).toBeGreaterThan(rowsWhileTouching)
})
it('does not coast when the finger rested before lifting', () => {
boot()
fireTouch('touchstart', { x: 40, y: 400 })
dragUp(400, 20, 5)
const rowsWhileTouching = scrolledRows()
now += 500
fireTouch('touchend', null)
runFrames(30)
expect(scrolledRows()).toBe(rowsWhileTouching)
})
it('does not send a resting finger’s momentum to an alternate-screen app', () => {
buffer = { baseY: 0, type: 'alternate', viewportY: 0 }
boot()
fireTouch('touchstart', { x: 40, y: 400 })
dragUp(400, 20, 5)
const bytesWhileTouching = terminalInputBytes()
expect(bytesWhileTouching).toContain(ESC_ARROW_DOWN)
now += 500
fireTouch('touchend', null)
runFrames(30)
expect(terminalInputBytes()).toBe(bytesWhileTouching)
})
it('drops an uncommitted scroll when a second finger starts a pinch', () => {
boot()
fireTouch('touchstart', { x: 40, y: 400 })
now += FRAME_MS
fireTouch('touchmove', { x: 40, y: 380 })
fireTouches('touchstart', [
{ x: 40, y: 380 },
{ x: 140, y: 380 }
])
runFrames(1)
fireTouch('touchend', null)
runFrames(30)
expect(scrolledRows()).toBe(0)
expect(terminalInputBytes()).toBe('')
})
it('drops queued scroll and velocity when the platform cancels a touch', () => {
boot()
fireTouch('touchstart', { x: 40, y: 400 })
now += FRAME_MS
fireTouch('touchmove', { x: 40, y: 320 })
fireTouch('touchcancel', null)
fireTouch('touchend', null)
runFrames(30)
expect(scrolledRows()).toBe(0)
expect(terminalInputBytes()).toBe('')
fireTouch('touchstart', { x: 40, y: 320 })
fireTouch('touchend', null)
expect(
postMessage.mock.calls.filter(([raw]) => JSON.parse(raw).type === 'terminal-tap')
).toHaveLength(1)
})
it('does not send cancelled momentum to an alternate-screen app', () => {
buffer = { baseY: 0, type: 'alternate', viewportY: 0 }
boot()
fireTouch('touchstart', { x: 40, y: 400 })
dragUp(400, 20, 5)
const bytesBeforeCancel = terminalInputBytes()
expect(bytesBeforeCancel).toContain(ESC_ARROW_DOWN)
fireTouch('touchcancel', null)
fireTouch('touchend', null)
runFrames(30)
expect(terminalInputBytes()).toBe(bytesBeforeCancel)
})
it('retires queued scroll and momentum when the document is disposed', () => {
boot(true)
fireTouch('touchstart', { x: 40, y: 400 })
now += FRAME_MS
fireTouch('touchmove', { x: 40, y: 320 })
fireTouch('touchend', null)
stopDocument?.()
runFrames(30)
expect(scrolledRows()).toBe(0)
expect(terminalInputBytes()).toBe('')
})
it('retires subthreshold scroll velocity when a long press enters selection', () => {
boot()
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
fireTouch('touchstart', { x: 40, y: 400 })
now += 480
fireTouch('touchmove', { x: 40, y: 399.5 })
now += 19
fireTouch('touchmove', { x: 40, y: 391.5 })
now += 1
vi.advanceTimersByTime(500)
expect(
postMessage.mock.calls.some(([raw]) => {
const message = JSON.parse(raw)
return message.type === 'set-select-mode' && message.enabled
})
).toBe(true)
fireTouch('touchend', null)
runFrames(30)
expect(scrolledRows()).toBe(0)
expect(terminalInputBytes()).toBe('')
})
})