From afe1949009d4006b47835d2056aa50cf4191c6f4 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Tue, 21 Jul 2026 06:51:17 +0000 Subject: [PATCH 1/9] feat(terminal): add native touch scroll physics --- .../src/shared/lib/terminalTouchScroll.ts | 244 ++++++++++++++++-- 1 file changed, 220 insertions(+), 24 deletions(-) diff --git a/packages/web-core/src/shared/lib/terminalTouchScroll.ts b/packages/web-core/src/shared/lib/terminalTouchScroll.ts index 6ab49cb2cf6..ad7bc95caeb 100644 --- a/packages/web-core/src/shared/lib/terminalTouchScroll.ts +++ b/packages/web-core/src/shared/lib/terminalTouchScroll.ts @@ -21,6 +21,11 @@ import { getTerminalMobileState } from './terminalMobileState'; * xterm's private pixel→row accumulator and deterministically scrolls exactly one * line; the pointer coords must land inside `.xterm-screen` or xterm drops the report. * + * During an owned vertical drag, the bridge samples the rendered row height once + * and dispatches one line-wheel per row of finger travel for roughly 1:1 content + * tracking. On release, velocity from the trailing 120ms of touch samples drives + * an interruptible exponential-decay tail (tau = 325ms) for native-style momentum. + * * The DOM-free `createTouchScrollController` is the unit-tested seam; * `installTerminalTouchScroll` is a thin adapter that wires real touch events to it. */ @@ -34,11 +39,23 @@ export interface Rect { bottom: number; } -/** Accumulated finger travel (px) per dispatched line-wheel. */ -export const WHEEL_STEP_PX = 24; +/** Finger travel (px) per dispatched line-wheel when row height is unavailable. */ +export const FALLBACK_WHEEL_STEP_PX = 16; /** Finger travel (px) before the gesture locks to an axis. */ export const AXIS_LOCK_THRESHOLD_PX = 8; +const MIN_WHEEL_STEP_PX = 8; +const MAX_WHEEL_STEP_PX = 48; +const VELOCITY_WINDOW_MS = 120; +const VELOCITY_SAMPLE_CAPACITY = 32; +const MIN_VELOCITY_WINDOW_MS = 8; +const MIN_MOMENTUM_START_PX_PER_MS = 0.3; +const MAX_MOMENTUM_PX_PER_MS = 3.5; +const MOMENTUM_STOP_PX_PER_MS = 0.05; +const MOMENTUM_TIME_CONSTANT_MS = 325; +const MAX_MOMENTUM_WHEELS = 150; +const MAX_WHEELS_PER_FRAME = 6; + export function decideAxis( dx: number, dy: number, @@ -71,10 +88,18 @@ export interface TouchScrollDeps { getMouseTrackingMode: () => string; /** Dispatch one line-wheel: +1 = scroll down (toward newer), -1 = scroll up. */ dispatchWheel: (direction: 1 | -1, clientX: number, clientY: number) => void; + /** Rendered terminal row height, sampled once at the start of each gesture. */ + getLineHeightPx?: () => number; + /** Monotonic clock used for release-velocity samples. */ + now?: () => number; + /** Frame scheduler used by the momentum tail. */ + scheduleFrame?: (cb: (t: number) => void) => number; + /** Cancels a frame scheduled by `scheduleFrame`. */ + cancelFrame?: (id: number) => void; /** * Another touch consumer owns the gesture (D-pad after a long-press, select - * mode). Checked per move; once true the bridge stands down for the rest of - * the touch sequence. + * mode). Checked per move and momentum tick; once true during a touch the + * bridge stands down for the rest of that sequence. */ isSuppressed?: () => boolean; } @@ -84,35 +109,168 @@ export interface TouchMoveResult { prevent: boolean; } +interface VelocitySample { + t: number; + y: number; +} + +function sanitizeWheelStep(stepPx: number | undefined): number { + if (stepPx === undefined || !Number.isFinite(stepPx) || stepPx <= 0) { + return FALLBACK_WHEEL_STEP_PX; + } + return Math.min(Math.max(stepPx, MIN_WHEEL_STEP_PX), MAX_WHEEL_STEP_PX); +} + /** * Pure, DOM-free gesture controller — the testable seam. */ export function createTouchScrollController(deps: TouchScrollDeps) { + const readNow = deps.now ?? (() => 0); let axis: Axis = 'undecided'; let startX = 0; let startY = 0; let lastY = 0; + let lastClientX = 0; + let lastClientY = 0; + let stepPx = FALLBACK_WHEEL_STEP_PX; let accumulated = 0; + let velocitySamples: VelocitySample[] = []; + + let momentumFrameId: number | undefined; + let momentumGeneration = 0; + let momentumVelocity = 0; + let momentumCarry = 0; + let momentumWheelCount = 0; + let momentumPrevT = 0; + + function cancelMomentum(): void { + momentumGeneration += 1; + if (momentumFrameId !== undefined) { + deps.cancelFrame?.(momentumFrameId); + } + momentumFrameId = undefined; + momentumVelocity = 0; + momentumCarry = 0; + momentumWheelCount = 0; + momentumPrevT = 0; + } + + function scheduleMomentumFrame(): void { + if (!deps.scheduleFrame) { + cancelMomentum(); + return; + } + + const generation = momentumGeneration; + momentumFrameId = deps.scheduleFrame((nowT) => { + if (generation !== momentumGeneration) return; + momentumFrameId = undefined; + runMomentumFrame(nowT); + }); + } + + function runMomentumFrame(nowT: number): void { + if (deps.isSuppressed?.() || deps.getMouseTrackingMode() === 'none') { + cancelMomentum(); + return; + } + + const dt = Math.min(Math.max(nowT - momentumPrevT, 1), 64); + momentumPrevT = nowT; + momentumVelocity *= Math.exp(-dt / MOMENTUM_TIME_CONSTANT_MS); + momentumCarry += momentumVelocity * dt; + + let frameWheelCount = 0; + while ( + momentumCarry >= stepPx && + frameWheelCount < MAX_WHEELS_PER_FRAME && + momentumWheelCount < MAX_MOMENTUM_WHEELS + ) { + deps.dispatchWheel(1, lastClientX, lastClientY); + momentumCarry -= stepPx; + frameWheelCount += 1; + momentumWheelCount += 1; + } + while ( + momentumCarry <= -stepPx && + frameWheelCount < MAX_WHEELS_PER_FRAME && + momentumWheelCount < MAX_MOMENTUM_WHEELS + ) { + deps.dispatchWheel(-1, lastClientX, lastClientY); + momentumCarry += stepPx; + frameWheelCount += 1; + momentumWheelCount += 1; + } + + if ( + Math.abs(momentumVelocity) < MOMENTUM_STOP_PX_PER_MS || + momentumWheelCount >= MAX_MOMENTUM_WHEELS + ) { + cancelMomentum(); + return; + } + + scheduleMomentumFrame(); + } + + function appendVelocitySample(t: number, y: number): void { + velocitySamples.push({ t, y }); + const cutoff = t - VELOCITY_WINDOW_MS; + while (velocitySamples[0]?.t < cutoff) velocitySamples.shift(); + while (velocitySamples.length > VELOCITY_SAMPLE_CAPACITY) { + velocitySamples.shift(); + } + } + + function getExitVelocity(): number | undefined { + if (velocitySamples.length < 2) return undefined; + const oldest = velocitySamples[0]; + const newest = velocitySamples[velocitySamples.length - 1]; + const dt = newest.t - oldest.t; + if (dt < MIN_VELOCITY_WINDOW_MS) return undefined; + + const velocity = (oldest.y - newest.y) / dt; + if ( + !Number.isFinite(velocity) || + Math.abs(velocity) < MIN_MOMENTUM_START_PX_PER_MS + ) { + return undefined; + } + + return ( + Math.sign(velocity) * Math.min(Math.abs(velocity), MAX_MOMENTUM_PX_PER_MS) + ); + } + + function resetGesture(nextAxis: Axis): void { + axis = nextAxis; + accumulated = 0; + velocitySamples = []; + } return { onTouchStart(p: TouchPoint): void { - accumulated = 0; - if (p.touches !== 1) { - axis = 'ignore'; // pinch / multi-touch — leave it to the browser - return; - } - axis = 'undecided'; + cancelMomentum(); + resetGesture(p.touches === 1 ? 'undecided' : 'ignore'); + stepPx = sanitizeWheelStep(deps.getLineHeightPx?.()); startX = p.clientX; startY = p.clientY; lastY = p.clientY; + lastClientX = p.clientX; + lastClientY = p.clientY; + + if (p.touches !== 1) { + // Pinch / multi-touch — leave it to the browser. + return; + } + appendVelocitySample(readNow(), p.clientY); }, onTouchMove(p: TouchPoint): TouchMoveResult { if (p.touches !== 1) { // A second finger landed mid-gesture — abandon scrolling for the rest // of this touch sequence so a pinch never bridges to wheel scrolling. - axis = 'ignore'; - accumulated = 0; + resetGesture('ignore'); return { prevent: false }; } if (axis === 'ignore') return { prevent: false }; @@ -120,8 +278,7 @@ export function createTouchScrollController(deps: TouchScrollDeps) { if (deps.isSuppressed?.()) { // The gesture layer (D-pad) or select mode took this touch sequence — // never turn its drag into wheel scrolling. - axis = 'ignore'; - accumulated = 0; + resetGesture('ignore'); return { prevent: false }; } @@ -130,22 +287,28 @@ export function createTouchScrollController(deps: TouchScrollDeps) { if (axis !== 'vertical') return { prevent: false }; } + lastClientX = p.clientX; + lastClientY = p.clientY; + appendVelocitySample(readNow(), p.clientY); + + const deltaY = lastY - p.clientY; + lastY = p.clientY; + // Only bridge when xterm's own touch scroll is disabled (mouse tracking on). // Otherwise let xterm scroll its scrollback natively — avoids double-scroll. if (deps.getMouseTrackingMode() === 'none') return { prevent: false }; // Natural scrolling: finger up (clientY decreases) reveals newer content // (scroll down, +1); finger down reveals history (scroll up, -1). - accumulated += lastY - p.clientY; - lastY = p.clientY; + accumulated += deltaY; - while (accumulated >= WHEEL_STEP_PX) { + while (accumulated >= stepPx) { deps.dispatchWheel(1, p.clientX, p.clientY); - accumulated -= WHEEL_STEP_PX; + accumulated -= stepPx; } - while (accumulated <= -WHEEL_STEP_PX) { + while (accumulated <= -stepPx) { deps.dispatchWheel(-1, p.clientX, p.clientY); - accumulated += WHEEL_STEP_PX; + accumulated += stepPx; } // We've committed to the vertical gesture: always prevent page scroll/rubber-band, @@ -154,10 +317,36 @@ export function createTouchScrollController(deps: TouchScrollDeps) { }, onTouchEnd(remainingTouches = 0): void { - accumulated = 0; + cancelMomentum(); + + const exitVelocity = + remainingTouches === 0 && + axis === 'vertical' && + !deps.isSuppressed?.() && + deps.getMouseTrackingMode() !== 'none' + ? getExitVelocity() + : undefined; + // Only re-arm once every finger is up; a partial lift from a pinch must // not let the remaining finger resume a bridged scroll from stale state. - axis = remainingTouches === 0 ? 'undecided' : 'ignore'; + resetGesture(remainingTouches === 0 ? 'undecided' : 'ignore'); + + if (exitVelocity !== undefined) { + momentumVelocity = exitVelocity; + momentumPrevT = readNow(); + scheduleMomentumFrame(); + } + }, + + onTouchCancel(): void { + cancelMomentum(); + resetGesture('undecided'); + startX = 0; + startY = 0; + lastY = 0; + lastClientX = 0; + lastClientY = 0; + stepPx = FALLBACK_WHEEL_STEP_PX; }, }; } @@ -184,6 +373,11 @@ export function installTerminalTouchScroll(terminal: Terminal): () => void { const controller = createTouchScrollController({ getMouseTrackingMode: () => terminal.modes.mouseTrackingMode, + getLineHeightPx: () => + screen.getBoundingClientRect().height / Math.max(1, terminal.rows), + now: () => performance.now(), + scheduleFrame: (cb) => requestAnimationFrame(cb), + cancelFrame: (id) => cancelAnimationFrame(id), isSuppressed: () => { const state = getTerminalMobileState(terminal); return state.dpadActive || state.selectMode; @@ -227,16 +421,18 @@ export function installTerminalTouchScroll(terminal: Terminal): () => void { } }; const onEnd = (e: TouchEvent) => controller.onTouchEnd(e.touches.length); + const onCancel = () => controller.onTouchCancel(); el.addEventListener('touchstart', onStart, { passive: true }); el.addEventListener('touchmove', onMove, { passive: false }); el.addEventListener('touchend', onEnd, { passive: true }); - el.addEventListener('touchcancel', onEnd, { passive: true }); + el.addEventListener('touchcancel', onCancel, { passive: true }); return () => { + controller.onTouchCancel(); el.removeEventListener('touchstart', onStart); el.removeEventListener('touchmove', onMove); el.removeEventListener('touchend', onEnd); - el.removeEventListener('touchcancel', onEnd); + el.removeEventListener('touchcancel', onCancel); }; } From 32b37cc81b9fa5639ea160c21f1f63be42959a73 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Tue, 21 Jul 2026 06:51:24 +0000 Subject: [PATCH 2/9] test(terminal): cover touch scroll momentum --- .../shared/lib/terminalTouchScroll.test.ts | 570 ++++++++++++++---- 1 file changed, 456 insertions(+), 114 deletions(-) diff --git a/packages/web-core/src/shared/lib/terminalTouchScroll.test.ts b/packages/web-core/src/shared/lib/terminalTouchScroll.test.ts index 9a909d07203..b23470dc179 100644 --- a/packages/web-core/src/shared/lib/terminalTouchScroll.test.ts +++ b/packages/web-core/src/shared/lib/terminalTouchScroll.test.ts @@ -1,31 +1,120 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { + AXIS_LOCK_THRESHOLD_PX, + FALLBACK_WHEEL_STEP_PX, + clampToRect, createTouchScrollController, decideAxis, - clampToRect, - WHEEL_STEP_PX, - AXIS_LOCK_THRESHOLD_PX, } from './terminalTouchScroll'; -function controllerWith(mode: string) { - const wheels: Array<1 | -1> = []; +interface HarnessOptions { + mode?: string; + getLineHeightPx?: () => number; + omitLineHeightDep?: boolean; +} + +interface WheelRecord { + direction: 1 | -1; + clientX: number; + clientY: number; + t: number; +} + +function createHarness({ + mode: initialMode = 'vt200', + getLineHeightPx = () => 15, + omitLineHeightDep = false, +}: HarnessOptions = {}) { + let nowT = 0; + let mode = initialMode; + let suppressed = false; + let nextFrameId = 1; + const frames = new Map void>(); + const wheels: WheelRecord[] = []; + const ctrl = createTouchScrollController({ getMouseTrackingMode: () => mode, - dispatchWheel: (dir) => wheels.push(dir), + ...(omitLineHeightDep ? {} : { getLineHeightPx }), + isSuppressed: () => suppressed, + now: () => nowT, + scheduleFrame: (cb) => { + const id = nextFrameId++; + frames.set(id, cb); + return id; + }, + cancelFrame: (id) => frames.delete(id), + dispatchWheel: (direction, clientX, clientY) => { + wheels.push({ direction, clientX, clientY, t: nowT }); + }, }); - return { ctrl, wheels }; + + const advance = (dt: number) => { + nowT += dt; + }; + + const pumpFrame = (dt = 16): number => { + advance(dt); + const ready = [...frames.entries()]; + for (const [id] of ready) frames.delete(id); + const wheelCountBefore = wheels.length; + for (const [, cb] of ready) cb(nowT); + return wheels.length - wheelCountBefore; + }; + + const pumpUntilIdle = (maxFrames = 500, dt = 16): number[] => { + const frameWheelCounts: number[] = []; + while (frames.size > 0 && frameWheelCounts.length < maxFrames) { + frameWheelCounts.push(pumpFrame(dt)); + } + if (frames.size > 0) { + throw new Error(`momentum did not stop within ${maxFrames} frames`); + } + return frameWheelCounts; + }; + + return { + ctrl, + wheels, + advance, + pumpFrame, + pumpUntilIdle, + pendingFrames: () => frames.size, + setMode: (nextMode: string) => { + mode = nextMode; + }, + setSuppressed: (nextSuppressed: boolean) => { + suppressed = nextSuppressed; + }, + }; +} + +function startFlick( + harness: ReturnType, + { x = 20, startY = 300 }: { x?: number; startY?: number } = {} +): void { + harness.ctrl.onTouchStart({ touches: 1, clientX: x, clientY: startY }); + for (const y of [startY - 60, startY - 120, startY - 180]) { + harness.advance(16); + harness.ctrl.onTouchMove({ touches: 1, clientX: x, clientY: y }); + } + harness.ctrl.onTouchEnd(); } describe('decideAxis', () => { it('stays undecided until movement passes the lock threshold', () => { expect(decideAxis(2, 3)).toBe('undecided'); }); + it('locks vertical when vertical travel dominates', () => { expect(decideAxis(2, AXIS_LOCK_THRESHOLD_PX + 5)).toBe('vertical'); }); - it('locks ignore when horizontal travel dominates', () => { + + it('locks ignore when horizontal travel dominates or travel is tied', () => { expect(decideAxis(AXIS_LOCK_THRESHOLD_PX + 5, 2)).toBe('ignore'); + expect(decideAxis(AXIS_LOCK_THRESHOLD_PX, AXIS_LOCK_THRESHOLD_PX)).toBe( + 'ignore' + ); }); }); @@ -35,159 +124,412 @@ describe('clampToRect', () => { expect(clampToRect(0, 0, rect)).toEqual({ x: 11, y: 21 }); expect(clampToRect(999, 999, rect)).toEqual({ x: 109, y: 219 }); }); + it('leaves an interior point unchanged', () => { const rect = { left: 0, top: 0, right: 100, bottom: 100 }; expect(clampToRect(50, 60, rect)).toEqual({ x: 50, y: 60 }); }); }); -describe('createTouchScrollController (mouse tracking active)', () => { - it('translates an upward swipe into floor(travel / step) scroll-down wheels', () => { - const { ctrl, wheels } = controllerWith('vt200'); - ctrl.onTouchStart({ touches: 1, clientX: 100, clientY: 300 }); - // Cross the axis threshold, then swipe up a total of 3 full steps. - ctrl.onTouchMove({ +describe('createTouchScrollController (drag tracking)', () => { + it('tracks 150px of finger travel as 10 rendered 15px rows', () => { + const harness = createHarness({ getLineHeightPx: () => 15 }); + harness.ctrl.onTouchStart({ touches: 1, clientX: 100, clientY: 300 }); + + for (let row = 1; row <= 10; row += 1) { + harness.advance(16); + harness.ctrl.onTouchMove({ + touches: 1, + clientX: 100, + clientY: 300 - row * 15, + }); + } + + expect(harness.wheels.map((wheel) => wheel.direction)).toEqual( + Array(10).fill(1) + ); + }); + + it('translates a downward swipe into scroll-up wheels', () => { + const harness = createHarness({ getLineHeightPx: () => 15 }); + harness.ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 0 }); + harness.ctrl.onTouchMove({ touches: 1, clientX: 0, clientY: 30 }); + expect(harness.wheels.map((wheel) => wheel.direction)).toEqual([-1, -1]); + }); + + it('prevents default for a committed vertical gesture before a full row', () => { + const harness = createHarness({ getLineHeightPx: () => 15 }); + harness.ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 0 }); + const result = harness.ctrl.onTouchMove({ touches: 1, - clientX: 100, - clientY: 300 - (AXIS_LOCK_THRESHOLD_PX + 1), + clientX: 0, + clientY: -(AXIS_LOCK_THRESHOLD_PX + 1), }); - const r = ctrl.onTouchMove({ + expect(result.prevent).toBe(true); + expect(harness.wheels).toHaveLength(0); + }); + + it('samples line height once per gesture', () => { + let sampleCount = 0; + const harness = createHarness({ + getLineHeightPx: () => { + sampleCount += 1; + return sampleCount === 1 ? 15 : 30; + }, + }); + + harness.ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 100 }); + harness.ctrl.onTouchMove({ touches: 1, clientX: 0, clientY: 85 }); + harness.ctrl.onTouchMove({ touches: 1, clientX: 0, clientY: 70 }); + expect(sampleCount).toBe(1); + expect(harness.wheels).toHaveLength(2); + + harness.ctrl.onTouchEnd(); + harness.ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 100 }); + harness.ctrl.onTouchMove({ touches: 1, clientX: 0, clientY: 70 }); + expect(sampleCount).toBe(2); + expect(harness.wheels).toHaveLength(3); + }); + + it.each([ + ['zero', 0], + ['NaN', Number.NaN], + ['infinite', Number.POSITIVE_INFINITY], + ])('falls back to 16px when line height is %s', (_label, lineHeight) => { + const harness = createHarness({ getLineHeightPx: () => lineHeight }); + harness.ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 100 }); + harness.ctrl.onTouchMove({ + touches: 1, + clientX: 0, + clientY: 100 - (FALLBACK_WHEEL_STEP_PX - 1), + }); + expect(harness.wheels).toHaveLength(0); + harness.ctrl.onTouchMove({ touches: 1, - clientX: 100, - clientY: 300 - 3 * WHEEL_STEP_PX, + clientX: 0, + clientY: 100 - FALLBACK_WHEEL_STEP_PX, }); - expect(wheels).toEqual([1, 1, 1]); // finger up => scroll down - expect(r.prevent).toBe(true); + expect(harness.wheels.map((wheel) => wheel.direction)).toEqual([1]); }); - it('translates a downward swipe into scroll-up wheels', () => { - const { ctrl, wheels } = controllerWith('any'); - ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 0 }); - ctrl.onTouchMove({ + it('falls back to 16px when the optional line-height dependency is absent', () => { + const harness = createHarness({ omitLineHeightDep: true }); + harness.ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 100 }); + harness.ctrl.onTouchMove({ + touches: 1, + clientX: 0, + clientY: 100 - (FALLBACK_WHEEL_STEP_PX - 1), + }); + expect(harness.wheels).toHaveLength(0); + harness.ctrl.onTouchMove({ touches: 1, clientX: 0, - clientY: AXIS_LOCK_THRESHOLD_PX + 1, + clientY: 100 - FALLBACK_WHEEL_STEP_PX, }); - ctrl.onTouchMove({ touches: 1, clientX: 0, clientY: 2 * WHEEL_STEP_PX }); - expect(wheels).toEqual([-1, -1]); // finger down => scroll up (history) + expect(harness.wheels.map((wheel) => wheel.direction)).toEqual([1]); }); - it('prevents default for a committed vertical gesture even before a full step', () => { - const { ctrl, wheels } = controllerWith('vt200'); - ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 0 }); - const r = ctrl.onTouchMove({ + it.each([ + ['low', 4, 8], + ['high', 96, 48], + ])('clamps a %s line height to %ipx', (_label, lineHeight, stepPx) => { + const harness = createHarness({ getLineHeightPx: () => lineHeight }); + harness.ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 100 }); + harness.ctrl.onTouchMove({ touches: 1, clientX: 0, - clientY: -(AXIS_LOCK_THRESHOLD_PX + 1), + clientY: 100 - (stepPx - 1), + }); + expect(harness.wheels).toHaveLength(0); + harness.ctrl.onTouchMove({ + touches: 1, + clientX: 0, + clientY: 100 - stepPx, }); - expect(r.prevent).toBe(true); - expect(wheels.length).toBe(0); // not a full step yet, but gesture is owned + expect(harness.wheels.map((wheel) => wheel.direction)).toEqual([1]); }); }); -describe('createTouchScrollController (mouse tracking inactive)', () => { - it('never synthesizes wheels and never prevents — xterm native touch scrolls', () => { - const { ctrl, wheels } = controllerWith('none'); - ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 0 }); - const r = ctrl.onTouchMove({ touches: 1, clientX: 0, clientY: -100 }); - expect(wheels.length).toBe(0); - expect(r.prevent).toBe(false); +describe('createTouchScrollController (gesture ownership)', () => { + it('stands down when mouse tracking is inactive', () => { + const harness = createHarness({ mode: 'none' }); + harness.ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 0 }); + harness.advance(16); + const result = harness.ctrl.onTouchMove({ + touches: 1, + clientX: 0, + clientY: -100, + }); + harness.ctrl.onTouchEnd(); + expect(harness.wheels).toHaveLength(0); + expect(result.prevent).toBe(false); + expect(harness.pendingFrames()).toBe(0); + }); + + it('ignores multi-touch gestures', () => { + const harness = createHarness(); + harness.ctrl.onTouchStart({ touches: 2, clientX: 0, clientY: 0 }); + const result = harness.ctrl.onTouchMove({ + touches: 2, + clientX: 0, + clientY: -100, + }); + expect(harness.wheels).toHaveLength(0); + expect(result.prevent).toBe(false); }); -}); -describe('createTouchScrollController (gesture guards)', () => { - it('ignores multi-touch (pinch) gestures', () => { - const { ctrl, wheels } = controllerWith('vt200'); - ctrl.onTouchStart({ touches: 2, clientX: 0, clientY: 0 }); - const r = ctrl.onTouchMove({ touches: 2, clientX: 0, clientY: -100 }); - expect(wheels.length).toBe(0); - expect(r.prevent).toBe(false); - }); - - it('stays ignored after a partial multi-touch lift until all fingers are up', () => { - const { ctrl, wheels } = controllerWith('vt200'); - ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 0 }); - // A second finger lands mid-gesture (pinch) — bridge must bail. - ctrl.onTouchMove({ touches: 2, clientX: 0, clientY: -50 }); - // Lift one finger; one remains down — must NOT resume scrolling. - ctrl.onTouchEnd(1); - const stillIgnored = ctrl.onTouchMove({ + it('abandons a pinch until every finger lifts, then re-arms', () => { + const harness = createHarness(); + harness.ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 0 }); + harness.ctrl.onTouchMove({ touches: 1, clientX: 0, clientY: -15 }); + + // A second touchstart cancels the current gesture and any accumulated state. + harness.ctrl.onTouchStart({ touches: 2, clientX: 0, clientY: -15 }); + harness.ctrl.onTouchEnd(1); + const stillIgnored = harness.ctrl.onTouchMove({ touches: 1, clientX: 0, clientY: -100, }); expect(stillIgnored.prevent).toBe(false); - expect(wheels.length).toBe(0); - // All fingers up — the next clean gesture works again. - ctrl.onTouchEnd(0); - ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 0 }); - ctrl.onTouchMove({ touches: 1, clientX: 0, clientY: WHEEL_STEP_PX }); - expect(wheels).toEqual([-1]); + expect(harness.wheels).toHaveLength(1); + + harness.ctrl.onTouchEnd(0); + harness.ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 0 }); + harness.ctrl.onTouchMove({ touches: 1, clientX: 0, clientY: 15 }); + expect(harness.wheels.map((wheel) => wheel.direction)).toEqual([1, -1]); + }); + + it('abandons when a multi-touch move arrives without a second touchstart', () => { + const harness = createHarness(); + harness.ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 0 }); + harness.ctrl.onTouchMove({ touches: 2, clientX: 0, clientY: -50 }); + harness.ctrl.onTouchEnd(1); + const result = harness.ctrl.onTouchMove({ + touches: 1, + clientX: 0, + clientY: -100, + }); + expect(result.prevent).toBe(false); + expect(harness.wheels).toHaveLength(0); + }); + + it('does not start momentum while a touch remains down', () => { + const harness = createHarness(); + harness.ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 300 }); + harness.advance(16); + harness.ctrl.onTouchMove({ touches: 1, clientX: 0, clientY: 200 }); + harness.ctrl.onTouchEnd(1); + expect(harness.pendingFrames()).toBe(0); }); it('ignores a horizontal swipe', () => { - const { ctrl, wheels } = controllerWith('vt200'); - ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 0 }); - const r = ctrl.onTouchMove({ + const harness = createHarness(); + harness.ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 0 }); + const result = harness.ctrl.onTouchMove({ touches: 1, clientX: AXIS_LOCK_THRESHOLD_PX + 50, clientY: 1, }); - expect(wheels.length).toBe(0); - expect(r.prevent).toBe(false); + expect(harness.wheels).toHaveLength(0); + expect(result.prevent).toBe(false); }); it('resets between gestures so a new swipe starts clean', () => { - const { ctrl, wheels } = controllerWith('vt200'); - ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 0 }); - // Finger down one full step => one scroll-up (-1) wheel. - ctrl.onTouchMove({ touches: 1, clientX: 0, clientY: WHEEL_STEP_PX }); - ctrl.onTouchEnd(); - // A fresh horizontal gesture must not inherit the previous vertical lock. - ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 0 }); - const r = ctrl.onTouchMove({ + const harness = createHarness(); + harness.ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 0 }); + harness.ctrl.onTouchMove({ touches: 1, clientX: 0, clientY: 15 }); + harness.ctrl.onTouchEnd(); + + harness.ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 0 }); + const result = harness.ctrl.onTouchMove({ touches: 1, clientX: AXIS_LOCK_THRESHOLD_PX + 50, clientY: 0, }); - expect(r.prevent).toBe(false); - expect(wheels).toEqual([-1]); // only the first gesture's single step + expect(result.prevent).toBe(false); + expect(harness.wheels.map((wheel) => wheel.direction)).toEqual([-1]); }); -}); -describe('suppression (gesture layer / select mode)', () => { - function suppressibleController() { - const wheels: Array<1 | -1> = []; - let suppressed = false; - const ctrl = createTouchScrollController({ - getMouseTrackingMode: () => 'vt200', - dispatchWheel: (dir) => wheels.push(dir), - isSuppressed: () => suppressed, - }); - return { ctrl, wheels, setSuppressed: (v: boolean) => (suppressed = v) }; - } - - it('stands down while suppressed and stays down for the whole sequence', () => { - const { ctrl, wheels, setSuppressed } = suppressibleController(); - ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 0 }); - // D-pad engaged after a long press: the bridge must not turn the drag - // into wheel scrolling… - setSuppressed(true); - const r = ctrl.onTouchMove({ + it('stands down for the full sequence once suppression takes ownership', () => { + const harness = createHarness(); + harness.ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 0 }); + harness.setSuppressed(true); + const result = harness.ctrl.onTouchMove({ touches: 1, clientX: 0, - clientY: 3 * WHEEL_STEP_PX, + clientY: 45, }); - expect(r.prevent).toBe(false); - expect(wheels.length).toBe(0); - // …even if suppression lifts mid-sequence (finger still down). - setSuppressed(false); - ctrl.onTouchMove({ touches: 1, clientX: 0, clientY: 6 * WHEEL_STEP_PX }); - expect(wheels.length).toBe(0); - // A fresh gesture after all fingers lift scrolls again. - ctrl.onTouchEnd(0); - ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 0 }); - ctrl.onTouchMove({ touches: 1, clientX: 0, clientY: WHEEL_STEP_PX }); - expect(wheels).toEqual([-1]); + expect(result.prevent).toBe(false); + expect(harness.wheels).toHaveLength(0); + + harness.setSuppressed(false); + harness.ctrl.onTouchMove({ touches: 1, clientX: 0, clientY: 90 }); + expect(harness.wheels).toHaveLength(0); + + harness.ctrl.onTouchEnd(); + harness.ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 0 }); + harness.ctrl.onTouchMove({ touches: 1, clientX: 0, clientY: 15 }); + expect(harness.wheels.map((wheel) => wheel.direction)).toEqual([-1]); + }); +}); + +describe('createTouchScrollController (momentum)', () => { + it('does not start momentum after a slow 0.2px/ms release', () => { + const harness = createHarness(); + harness.ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 100 }); + harness.advance(50); + harness.ctrl.onTouchMove({ touches: 1, clientX: 0, clientY: 90 }); + harness.advance(50); + harness.ctrl.onTouchMove({ touches: 1, clientX: 0, clientY: 80 }); + const wheelsAtLift = harness.wheels.length; + + harness.ctrl.onTouchEnd(); + for (let frame = 0; frame < 10; frame += 1) harness.pumpFrame(); + expect(harness.wheels).toHaveLength(wheelsAtLift); + expect(harness.pendingFrames()).toBe(0); + }); + + it('continues a flick with a decaying tail and then terminates', () => { + const harness = createHarness(); + startFlick(harness, { x: 37 }); + const dragWheelCount = harness.wheels.length; + + const frameWheelCounts = harness.pumpUntilIdle(); + const postLiftWheels = harness.wheels.slice(dragWheelCount); + expect(postLiftWheels.length).toBeGreaterThanOrEqual(65); + expect(postLiftWheels.length).toBeLessThanOrEqual(80); + expect(postLiftWheels.every((wheel) => wheel.direction === 1)).toBe(true); + expect( + postLiftWheels.every( + (wheel) => wheel.clientX === 37 && wheel.clientY === 120 + ) + ).toBe(true); + expect(harness.pendingFrames()).toBe(0); + + // Wheel output over equal ten-frame windows follows the monotonic velocity decay. + const fullBuckets: number[] = []; + for (let i = 0; i + 10 <= frameWheelCounts.length; i += 10) { + fullBuckets.push( + frameWheelCounts.slice(i, i + 10).reduce((sum, count) => sum + count, 0) + ); + } + expect(fullBuckets.length).toBeGreaterThan(3); + for (let i = 1; i < fullBuckets.length; i += 1) { + expect(fullBuckets[i]).toBeLessThanOrEqual(fullBuckets[i - 1]); + } + }); + + it('cancels the tail on re-grab and requires a full row of new travel', () => { + const harness = createHarness(); + startFlick(harness); + harness.pumpFrame(); + harness.pumpFrame(); + harness.pumpFrame(); + const wheelsBeforeGrab = harness.wheels.length; + expect(harness.pendingFrames()).toBe(1); + + harness.ctrl.onTouchStart({ touches: 1, clientX: 50, clientY: 200 }); + expect(harness.pendingFrames()).toBe(0); + for (let frame = 0; frame < 10; frame += 1) harness.pumpFrame(); + expect(harness.wheels).toHaveLength(wheelsBeforeGrab); + + harness.advance(16); + harness.ctrl.onTouchMove({ touches: 1, clientX: 50, clientY: 186 }); + expect(harness.wheels).toHaveLength(wheelsBeforeGrab); + harness.advance(16); + harness.ctrl.onTouchMove({ touches: 1, clientX: 50, clientY: 185 }); + expect(harness.wheels).toHaveLength(wheelsBeforeGrab + 1); + }); + + it('halts mid-tail when suppression becomes active', () => { + const harness = createHarness(); + startFlick(harness); + harness.pumpFrame(); + harness.pumpFrame(); + const wheelsBeforeSuppression = harness.wheels.length; + + harness.setSuppressed(true); + harness.pumpFrame(); + expect(harness.wheels).toHaveLength(wheelsBeforeSuppression); + expect(harness.pendingFrames()).toBe(0); + harness.pumpFrame(); + expect(harness.wheels).toHaveLength(wheelsBeforeSuppression); + }); + + it('halts mid-tail when mouse tracking turns off', () => { + const harness = createHarness(); + startFlick(harness); + harness.pumpFrame(); + harness.pumpFrame(); + const wheelsBeforeModeChange = harness.wheels.length; + + harness.setMode('none'); + harness.pumpFrame(); + expect(harness.wheels).toHaveLength(wheelsBeforeModeChange); + expect(harness.pendingFrames()).toBe(0); + harness.pumpFrame(); + expect(harness.wheels).toHaveLength(wheelsBeforeModeChange); + }); + + it('clamps absurd velocity, drains at most six wheels per tick, and respects the cap', () => { + const tailForDistance = (distance: number) => { + const harness = createHarness({ getLineHeightPx: () => 8 }); + harness.ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 1000 }); + harness.advance(8); + harness.ctrl.onTouchMove({ + touches: 1, + clientX: 0, + clientY: 1000 - distance, + }); + const dragWheelCount = harness.wheels.length; + harness.ctrl.onTouchEnd(); + const frameWheelCounts = harness.pumpUntilIdle(); + return { + postLiftWheelCount: harness.wheels.length - dragWheelCount, + frameWheelCounts, + }; + }; + + const atVelocityCap = tailForDistance(28); // 28px / 8ms = 3.5px/ms + const absurdVelocity = tailForDistance(1000); + expect(absurdVelocity.postLiftWheelCount).toBe( + atVelocityCap.postLiftWheelCount + ); + expect(absurdVelocity.postLiftWheelCount).toBeLessThanOrEqual(150); + expect(Math.max(...absurdVelocity.frameWheelCounts)).toBeLessThanOrEqual(6); + }); + + it('touchcancel resets a fast drag without starting momentum', () => { + const harness = createHarness(); + harness.ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 300 }); + harness.advance(16); + harness.ctrl.onTouchMove({ touches: 1, clientX: 0, clientY: 240 }); + harness.advance(16); + harness.ctrl.onTouchMove({ touches: 1, clientX: 0, clientY: 180 }); + const wheelsAtCancel = harness.wheels.length; + + harness.ctrl.onTouchCancel(); + expect(harness.pendingFrames()).toBe(0); + for (let frame = 0; frame < 10; frame += 1) harness.pumpFrame(); + expect(harness.wheels).toHaveLength(wheelsAtCancel); + + harness.ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 100 }); + harness.ctrl.onTouchMove({ touches: 1, clientX: 0, clientY: 86 }); + expect(harness.wheels).toHaveLength(wheelsAtCancel); + harness.ctrl.onTouchMove({ touches: 1, clientX: 0, clientY: 85 }); + expect(harness.wheels).toHaveLength(wheelsAtCancel + 1); + }); + + it('derives momentum from one coalesced 100px move over 50ms', () => { + const harness = createHarness({ getLineHeightPx: () => 16 }); + harness.ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 100 }); + harness.advance(50); + harness.ctrl.onTouchMove({ touches: 1, clientX: 0, clientY: 0 }); + const dragWheelCount = harness.wheels.length; + harness.ctrl.onTouchEnd(); + + harness.pumpFrame(); + expect(harness.wheels.length).toBeGreaterThan(dragWheelCount); }); }); From 4a9590a3c301e145ef6dbfc5aec4c13ccecb6044 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Tue, 21 Jul 2026 06:59:29 +0000 Subject: [PATCH 3/9] fix(terminal): prevent stale touch momentum --- .../shared/lib/terminalTouchScroll.test.ts | 33 +++++++++++++++++++ .../src/shared/lib/terminalTouchScroll.ts | 2 ++ 2 files changed, 35 insertions(+) diff --git a/packages/web-core/src/shared/lib/terminalTouchScroll.test.ts b/packages/web-core/src/shared/lib/terminalTouchScroll.test.ts index b23470dc179..fede67a5d86 100644 --- a/packages/web-core/src/shared/lib/terminalTouchScroll.test.ts +++ b/packages/web-core/src/shared/lib/terminalTouchScroll.test.ts @@ -375,6 +375,39 @@ describe('createTouchScrollController (gesture ownership)', () => { }); describe('createTouchScrollController (momentum)', () => { + it('does not start momentum after a fast drag is held still for 200ms', () => { + const harness = createHarness(); + harness.ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 300 }); + harness.advance(16); + harness.ctrl.onTouchMove({ touches: 1, clientX: 0, clientY: 240 }); + harness.advance(16); + harness.ctrl.onTouchMove({ touches: 1, clientX: 0, clientY: 180 }); + const wheelsAtLift = harness.wheels.length; + + harness.advance(200); + harness.ctrl.onTouchEnd(); + for (let frame = 0; frame < 10; frame += 1) harness.pumpFrame(); + + expect(harness.wheels).toHaveLength(wheelsAtLift); + expect(harness.pendingFrames()).toBe(0); + }); + + it('starts momentum when the latest fast-drag sample is 99ms old', () => { + const harness = createHarness(); + harness.ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 300 }); + harness.advance(16); + harness.ctrl.onTouchMove({ touches: 1, clientX: 0, clientY: 240 }); + harness.advance(16); + harness.ctrl.onTouchMove({ touches: 1, clientX: 0, clientY: 180 }); + const dragWheelCount = harness.wheels.length; + + harness.advance(99); + harness.ctrl.onTouchEnd(); + harness.pumpFrame(); + + expect(harness.wheels.length).toBeGreaterThan(dragWheelCount); + }); + it('does not start momentum after a slow 0.2px/ms release', () => { const harness = createHarness(); harness.ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 100 }); diff --git a/packages/web-core/src/shared/lib/terminalTouchScroll.ts b/packages/web-core/src/shared/lib/terminalTouchScroll.ts index ad7bc95caeb..b2566d0da64 100644 --- a/packages/web-core/src/shared/lib/terminalTouchScroll.ts +++ b/packages/web-core/src/shared/lib/terminalTouchScroll.ts @@ -49,6 +49,7 @@ const MAX_WHEEL_STEP_PX = 48; const VELOCITY_WINDOW_MS = 120; const VELOCITY_SAMPLE_CAPACITY = 32; const MIN_VELOCITY_WINDOW_MS = 8; +const MOMENTUM_RELEASE_MAX_AGE_MS = 100; const MIN_MOMENTUM_START_PX_PER_MS = 0.3; const MAX_MOMENTUM_PX_PER_MS = 3.5; const MOMENTUM_STOP_PX_PER_MS = 0.05; @@ -226,6 +227,7 @@ export function createTouchScrollController(deps: TouchScrollDeps) { if (velocitySamples.length < 2) return undefined; const oldest = velocitySamples[0]; const newest = velocitySamples[velocitySamples.length - 1]; + if (readNow() - newest.t > MOMENTUM_RELEASE_MAX_AGE_MS) return undefined; const dt = newest.t - oldest.t; if (dt < MIN_VELOCITY_WINDOW_MS) return undefined; From 39c8e813dc240940c1759546f1b57da577906b9f Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Tue, 21 Jul 2026 07:31:15 +0000 Subject: [PATCH 4/9] fix(terminal): harden touch momentum physics --- .../shared/lib/terminalTouchScroll.test.ts | 145 ++++++++++++++++++ .../src/shared/lib/terminalTouchScroll.ts | 63 ++++++-- 2 files changed, 196 insertions(+), 12 deletions(-) diff --git a/packages/web-core/src/shared/lib/terminalTouchScroll.test.ts b/packages/web-core/src/shared/lib/terminalTouchScroll.test.ts index fede67a5d86..4a3b87a2b8b 100644 --- a/packages/web-core/src/shared/lib/terminalTouchScroll.test.ts +++ b/packages/web-core/src/shared/lib/terminalTouchScroll.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { AXIS_LOCK_THRESHOLD_PX, FALLBACK_WHEEL_STEP_PX, + MOMENTUM_MAX_FRAME_GAP_MS, clampToRect, createTouchScrollController, decideAxis, @@ -505,6 +506,150 @@ describe('createTouchScrollController (momentum)', () => { expect(harness.wheels).toHaveLength(wheelsBeforeModeChange); }); + it('kills momentum instead of resuming after a stalled frame', () => { + const harness = createHarness(); + startFlick(harness); + harness.pumpFrame(); + harness.pumpFrame(); + const wheelsBeforeStall = harness.wheels.length; + + expect(harness.pumpFrame(10_000)).toBe(0); + expect(harness.wheels).toHaveLength(wheelsBeforeStall); + expect(harness.pendingFrames()).toBe(0); + expect(MOMENTUM_MAX_FRAME_GAP_MS).toBe(250); + }); + + it('never flings opposite the final motion after a direction reversal', () => { + const harness = createHarness(); + harness.ctrl.onTouchStart({ + touches: 1, + clientX: 0, + clientY: 300, + timeStampMs: 0, + }); + harness.advance(60); + harness.ctrl.onTouchMove({ + touches: 1, + clientX: 0, + clientY: 180, + timeStampMs: 60, + }); + harness.advance(60); + harness.ctrl.onTouchMove({ + touches: 1, + clientX: 0, + clientY: 240, + timeStampMs: 120, + }); + const dragWheelCount = harness.wheels.length; + + harness.ctrl.onTouchEnd(0, 120); + harness.pumpFrame(); + + expect( + harness.wheels + .slice(dragWheelCount) + .every((wheel) => wheel.direction === -1) + ).toBe(true); + }); + + it('launches momentum in the direction of a substantial reverse flick', () => { + const harness = createHarness(); + harness.ctrl.onTouchStart({ + touches: 1, + clientX: 0, + clientY: 300, + timeStampMs: 0, + }); + harness.advance(40); + harness.ctrl.onTouchMove({ + touches: 1, + clientX: 0, + clientY: 180, + timeStampMs: 40, + }); + harness.advance(30); + harness.ctrl.onTouchMove({ + touches: 1, + clientX: 0, + clientY: 220, + timeStampMs: 70, + }); + harness.advance(30); + harness.ctrl.onTouchMove({ + touches: 1, + clientX: 0, + clientY: 260, + timeStampMs: 100, + }); + const dragWheelCount = harness.wheels.length; + + harness.ctrl.onTouchEnd(0, 100); + harness.pumpFrame(); + const tail = harness.wheels.slice(dragWheelCount); + + expect(tail.length).toBeGreaterThan(0); + expect(tail.every((wheel) => wheel.direction === -1)).toBe(true); + }); + + it('uses event timestamps when jank burst-delivers velocity samples', () => { + const harness = createHarness(); + harness.advance(1_000); + harness.ctrl.onTouchStart({ + touches: 1, + clientX: 0, + clientY: 300, + timeStampMs: 0, + }); + harness.ctrl.onTouchMove({ + touches: 1, + clientX: 0, + clientY: 200, + timeStampMs: 50, + }); + harness.ctrl.onTouchMove({ + touches: 1, + clientX: 0, + clientY: 100, + timeStampMs: 100, + }); + const dragWheelCount = harness.wheels.length; + + harness.ctrl.onTouchEnd(0, 100); + harness.pumpFrame(); + + expect(harness.wheels.length).toBeGreaterThan(dragWheelCount); + }); + + it('uses the touchend event time to reject a stale queued flick', () => { + const harness = createHarness(); + harness.advance(1_000); + harness.ctrl.onTouchStart({ + touches: 1, + clientX: 0, + clientY: 300, + timeStampMs: 0, + }); + harness.ctrl.onTouchMove({ + touches: 1, + clientX: 0, + clientY: 200, + timeStampMs: 50, + }); + harness.ctrl.onTouchMove({ + touches: 1, + clientX: 0, + clientY: 100, + timeStampMs: 100, + }); + const wheelsAtLift = harness.wheels.length; + + harness.ctrl.onTouchEnd(0, 201); + + expect(harness.pendingFrames()).toBe(0); + expect(harness.wheels).toHaveLength(wheelsAtLift); + }); + it('clamps absurd velocity, drains at most six wheels per tick, and respects the cap', () => { const tailForDistance = (distance: number) => { const harness = createHarness({ getLineHeightPx: () => 8 }); diff --git a/packages/web-core/src/shared/lib/terminalTouchScroll.ts b/packages/web-core/src/shared/lib/terminalTouchScroll.ts index b2566d0da64..8411c2ffb75 100644 --- a/packages/web-core/src/shared/lib/terminalTouchScroll.ts +++ b/packages/web-core/src/shared/lib/terminalTouchScroll.ts @@ -19,7 +19,10 @@ import { getTerminalMobileState } from './terminalMobileState'; * 5.5; this DOM surface is not covered by the unit tests): the wheel listener is * on `terminal.element`; `deltaMode: 1` (DOM_DELTA_LINE) + `deltaY: ±1` bypasses * xterm's private pixel→row accumulator and deterministically scrolls exactly one - * line; the pointer coords must land inside `.xterm-screen` or xterm drops the report. + * line; the pointer coords must land inside `.xterm-screen` or xterm drops the + * report. The adapter's `el.isConnected` gate is part of this untested DOM + * surface too: it must stay ahead of every `terminal.modes` read so detach or + * dispose cancels momentum without touching a disposed terminal API. * * During an owned vertical drag, the bridge samples the rendered row height once * and dispatches one line-wheel per row of finger travel for roughly 1:1 content @@ -56,6 +59,8 @@ const MOMENTUM_STOP_PX_PER_MS = 0.05; const MOMENTUM_TIME_CONSTANT_MS = 325; const MAX_MOMENTUM_WHEELS = 150; const MAX_WHEELS_PER_FRAME = 6; +export const MOMENTUM_MAX_FRAME_GAP_MS = 250; +const VELOCITY_DIRECTION_EPSILON_PX = 1; export function decideAxis( dx: number, @@ -82,6 +87,8 @@ export interface TouchPoint { touches: number; clientX: number; clientY: number; + /** Event occurrence time; falls back to handler-delivery time when omitted. */ + timeStampMs?: number; } export interface TouchScrollDeps { @@ -176,7 +183,12 @@ export function createTouchScrollController(deps: TouchScrollDeps) { return; } - const dt = Math.min(Math.max(nowT - momentumPrevT, 1), 64); + const elapsed = nowT - momentumPrevT; + if (!Number.isFinite(elapsed) || elapsed > MOMENTUM_MAX_FRAME_GAP_MS) { + cancelMomentum(); + return; + } + const dt = Math.max(elapsed, 1); momentumPrevT = nowT; momentumVelocity *= Math.exp(-dt / MOMENTUM_TIME_CONSTANT_MS); momentumCarry += momentumVelocity * dt; @@ -223,11 +235,32 @@ export function createTouchScrollController(deps: TouchScrollDeps) { } } - function getExitVelocity(): number | undefined { + function getExitVelocity(releaseAt: number): number | undefined { if (velocitySamples.length < 2) return undefined; - const oldest = velocitySamples[0]; - const newest = velocitySamples[velocitySamples.length - 1]; - if (readNow() - newest.t > MOMENTUM_RELEASE_MAX_AGE_MS) return undefined; + const newestIndex = velocitySamples.length - 1; + const newest = velocitySamples[newestIndex]; + if (releaseAt - newest.t > MOMENTUM_RELEASE_MAX_AGE_MS) return undefined; + + // Estimate only the final consistent-direction run. A reversal is a new + // intent, not noise to average together with the preceding drag. Sub-pixel + // deltas are neutral and stay inside whichever run surrounds them. + let oldestIndex = newestIndex; + let direction = 0; + for (let index = newestIndex; index > 0; index -= 1) { + const delta = velocitySamples[index].y - velocitySamples[index - 1].y; + const nextDirection = + Math.abs(delta) < VELOCITY_DIRECTION_EPSILON_PX + ? 0 + : Math.sign(delta); + if (nextDirection !== 0) { + if (direction !== 0 && nextDirection !== direction) break; + direction = nextDirection; + } + oldestIndex = index - 1; + } + if (direction === 0) return undefined; + + const oldest = velocitySamples[oldestIndex]; const dt = newest.t - oldest.t; if (dt < MIN_VELOCITY_WINDOW_MS) return undefined; @@ -265,7 +298,7 @@ export function createTouchScrollController(deps: TouchScrollDeps) { // Pinch / multi-touch — leave it to the browser. return; } - appendVelocitySample(readNow(), p.clientY); + appendVelocitySample(p.timeStampMs ?? readNow(), p.clientY); }, onTouchMove(p: TouchPoint): TouchMoveResult { @@ -291,7 +324,7 @@ export function createTouchScrollController(deps: TouchScrollDeps) { lastClientX = p.clientX; lastClientY = p.clientY; - appendVelocitySample(readNow(), p.clientY); + appendVelocitySample(p.timeStampMs ?? readNow(), p.clientY); const deltaY = lastY - p.clientY; lastY = p.clientY; @@ -318,7 +351,7 @@ export function createTouchScrollController(deps: TouchScrollDeps) { return { prevent: true }; }, - onTouchEnd(remainingTouches = 0): void { + onTouchEnd(remainingTouches = 0, timeStampMs?: number): void { cancelMomentum(); const exitVelocity = @@ -326,7 +359,7 @@ export function createTouchScrollController(deps: TouchScrollDeps) { axis === 'vertical' && !deps.isSuppressed?.() && deps.getMouseTrackingMode() !== 'none' - ? getExitVelocity() + ? getExitVelocity(timeStampMs ?? readNow()) : undefined; // Only re-arm once every finger is up; a partial lift from a pinch must @@ -358,6 +391,9 @@ export function createTouchScrollController(deps: TouchScrollDeps) { * terminal (in the creation branch) and do NOT remove on React unmount — the * listeners live on `terminal.element` and tear down with it on * `terminal.dispose()`, mirroring the existing `contextmenu`/selection handlers. + * `installTerminalTouchLayers` deliberately discards the disposer, so the + * connectivity gate below enforces the detach/dispose leg of the cancellation + * invariant: the next momentum tick stops before touching any terminal API. * Returns a disposer for tests and explicit teardown. */ export function installTerminalTouchScroll(terminal: Terminal): () => void { @@ -374,7 +410,8 @@ export function installTerminalTouchScroll(terminal: Terminal): () => void { let lastPoint = { x: 0, y: 0 }; const controller = createTouchScrollController({ - getMouseTrackingMode: () => terminal.modes.mouseTrackingMode, + getMouseTrackingMode: () => + el.isConnected ? terminal.modes.mouseTrackingMode : 'none', getLineHeightPx: () => screen.getBoundingClientRect().height / Math.max(1, terminal.rows), now: () => performance.now(), @@ -413,6 +450,7 @@ export function installTerminalTouchScroll(terminal: Terminal): () => void { touches: e.touches.length, clientX: t?.clientX ?? 0, clientY: t?.clientY ?? 0, + timeStampMs: e.timeStamp, }; }; @@ -422,7 +460,8 @@ export function installTerminalTouchScroll(terminal: Terminal): () => void { e.preventDefault(); } }; - const onEnd = (e: TouchEvent) => controller.onTouchEnd(e.touches.length); + const onEnd = (e: TouchEvent) => + controller.onTouchEnd(e.touches.length, e.timeStamp); const onCancel = () => controller.onTouchCancel(); el.addEventListener('touchstart', onStart, { passive: true }); From 879bb9a98600d45f7f26ddc9bca7bcafa120d9f5 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Tue, 21 Jul 2026 07:35:54 +0000 Subject: [PATCH 5/9] fix(terminal): make fling catches inert taps --- .../src/shared/lib/terminalMobileState.ts | 2 + .../shared/lib/terminalTouchGestures.test.ts | 14 +++++ .../src/shared/lib/terminalTouchGestures.ts | 19 +++++-- .../shared/lib/terminalTouchScroll.test.ts | 47 ++++++++++++++++ .../src/shared/lib/terminalTouchScroll.ts | 54 ++++++++++++++++--- 5 files changed, 125 insertions(+), 11 deletions(-) diff --git a/packages/web-core/src/shared/lib/terminalMobileState.ts b/packages/web-core/src/shared/lib/terminalMobileState.ts index 2fb8badd523..f937312e5fb 100644 --- a/packages/web-core/src/shared/lib/terminalMobileState.ts +++ b/packages/web-core/src/shared/lib/terminalMobileState.ts @@ -19,6 +19,8 @@ export interface TerminalMobileState { selectMode: boolean; /** A long-press D-pad gesture is running; the scroll bridge stands down. */ dpadActive: boolean; + /** The current touch sequence started by stopping live scroll momentum. */ + flingCatch?: boolean; } const EMPTY_STATE: TerminalMobileState = { diff --git a/packages/web-core/src/shared/lib/terminalTouchGestures.test.ts b/packages/web-core/src/shared/lib/terminalTouchGestures.test.ts index 3186142d548..f446762ca31 100644 --- a/packages/web-core/src/shared/lib/terminalTouchGestures.test.ts +++ b/packages/web-core/src/shared/lib/terminalTouchGestures.test.ts @@ -486,6 +486,20 @@ describe('double-tap = Tab', () => { ctrl.onTouchEnd(0, LONG_PRESS_MS + 90); expect(events).toEqual([]); }); + + it('does not count a fling catch toward the double-tap sequence', () => { + const { ctrl, events } = harness(); + ctrl.onTouchStart(p(100, 100), 0); + ctrl.onTouchEnd(0, 40, true); + + ctrl.onTouchStart(p(100, 100), 100); + ctrl.onTouchEnd(0, 140); + expect(events).toEqual([]); + + ctrl.onTouchStart(p(100, 100), 200); + ctrl.onTouchEnd(0, 240); + expect(events).toEqual(['tab']); + }); }); describe('three-finger tap = paste', () => { diff --git a/packages/web-core/src/shared/lib/terminalTouchGestures.ts b/packages/web-core/src/shared/lib/terminalTouchGestures.ts index ea29e4cc30f..cd3d6898732 100644 --- a/packages/web-core/src/shared/lib/terminalTouchGestures.ts +++ b/packages/web-core/src/shared/lib/terminalTouchGestures.ts @@ -421,7 +421,11 @@ export function createTouchGestureController(deps: GestureDeps) { return { prevent: true }; }, - onTouchEnd(remainingTouches: number, now: number): void { + onTouchEnd( + remainingTouches: number, + now: number, + flingCatch = false + ): void { if (remainingTouches > 0) return; // wait for the last finger const wasPhase = phase === 'dpad' && @@ -459,6 +463,7 @@ export function createTouchGestureController(deps: GestureDeps) { lastTap = null; return; } + if (flingCatch) return; // A clean quick tap. Second one in time + place = Tab. if ( lastTap && @@ -786,10 +791,18 @@ export function installTerminalTouchGestures( // contain contacts that began on nav/key bars and will never emit an // event on this terminal, so waiting for the global last finger strands // D-pad suppression and its timer indefinitely. - controller.onTouchEnd(0, e.timeStamp); + controller.onTouchEnd( + 0, + e.timeStamp, + getTerminalMobileState(terminal).flingCatch + ); releaseTouchOwnership(); } else { - controller.onTouchEnd(e.targetTouches.length, e.timeStamp); + controller.onTouchEnd( + e.targetTouches.length, + e.timeStamp, + getTerminalMobileState(terminal).flingCatch + ); } reschedule(); }; diff --git a/packages/web-core/src/shared/lib/terminalTouchScroll.test.ts b/packages/web-core/src/shared/lib/terminalTouchScroll.test.ts index 4a3b87a2b8b..6f327eafe3c 100644 --- a/packages/web-core/src/shared/lib/terminalTouchScroll.test.ts +++ b/packages/web-core/src/shared/lib/terminalTouchScroll.test.ts @@ -476,6 +476,53 @@ describe('createTouchScrollController (momentum)', () => { expect(harness.wheels).toHaveLength(wheelsBeforeGrab + 1); }); + it('reports a stationary re-grab as a caught fling tap', () => { + const harness = createHarness(); + startFlick(harness); + + const start = harness.ctrl.onTouchStart({ + touches: 1, + clientX: 50, + clientY: 200, + }); + const end = harness.ctrl.onTouchEnd(); + + expect(start).toEqual({ flingCatch: true }); + expect(end).toEqual({ caughtFling: true }); + expect(harness.pendingFrames()).toBe(0); + }); + + it('reports a normal stationary tap as not catching a fling', () => { + const harness = createHarness(); + + const start = harness.ctrl.onTouchStart({ + touches: 1, + clientX: 50, + clientY: 200, + }); + const end = harness.ctrl.onTouchEnd(); + + expect(start).toEqual({ flingCatch: false }); + expect(end).toEqual({ caughtFling: false }); + }); + + it('turns a fling catch into a 1:1 drag instead of a caught tap', () => { + const harness = createHarness({ getLineHeightPx: () => 15 }); + startFlick(harness); + const wheelsBeforeCatch = harness.wheels.length; + + harness.ctrl.onTouchStart({ touches: 1, clientX: 50, clientY: 200 }); + harness.advance(16); + harness.ctrl.onTouchMove({ touches: 1, clientX: 50, clientY: 170 }); + expect( + harness.wheels + .slice(wheelsBeforeCatch) + .map((wheel) => wheel.direction) + ).toEqual([1, 1]); + + expect(harness.ctrl.onTouchEnd()).toEqual({ caughtFling: false }); + }); + it('halts mid-tail when suppression becomes active', () => { const harness = createHarness(); startFlick(harness); diff --git a/packages/web-core/src/shared/lib/terminalTouchScroll.ts b/packages/web-core/src/shared/lib/terminalTouchScroll.ts index 8411c2ffb75..5defd268577 100644 --- a/packages/web-core/src/shared/lib/terminalTouchScroll.ts +++ b/packages/web-core/src/shared/lib/terminalTouchScroll.ts @@ -1,6 +1,9 @@ import type { Terminal } from '@xterm/xterm'; -import { getTerminalMobileState } from './terminalMobileState'; +import { + getTerminalMobileState, + patchTerminalMobileState, +} from './terminalMobileState'; /** * Touch → wheel scroll bridge for xterm.js 5.5. @@ -117,6 +120,16 @@ export interface TouchMoveResult { prevent: boolean; } +export interface TouchStartResult { + /** Whether this touchstart stopped a live momentum tail. */ + flingCatch: boolean; +} + +export interface TouchEndResult { + /** A live tail was caught and this sequence remained a tap. */ + caughtFling: boolean; +} + interface VelocitySample { t: number; y: number; @@ -143,6 +156,8 @@ export function createTouchScrollController(deps: TouchScrollDeps) { let stepPx = FALLBACK_WHEEL_STEP_PX; let accumulated = 0; let velocitySamples: VelocitySample[] = []; + let sequenceBeganAsCatch = false; + let verticalTravelOccurred = false; let momentumFrameId: number | undefined; let momentumGeneration = 0; @@ -284,7 +299,10 @@ export function createTouchScrollController(deps: TouchScrollDeps) { } return { - onTouchStart(p: TouchPoint): void { + onTouchStart(p: TouchPoint): TouchStartResult { + sequenceBeganAsCatch = + p.touches === 1 && momentumFrameId !== undefined; + verticalTravelOccurred = false; cancelMomentum(); resetGesture(p.touches === 1 ? 'undecided' : 'ignore'); stepPx = sanitizeWheelStep(deps.getLineHeightPx?.()); @@ -296,9 +314,10 @@ export function createTouchScrollController(deps: TouchScrollDeps) { if (p.touches !== 1) { // Pinch / multi-touch — leave it to the browser. - return; + return { flingCatch: sequenceBeganAsCatch }; } appendVelocitySample(p.timeStampMs ?? readNow(), p.clientY); + return { flingCatch: sequenceBeganAsCatch }; }, onTouchMove(p: TouchPoint): TouchMoveResult { @@ -319,6 +338,7 @@ export function createTouchScrollController(deps: TouchScrollDeps) { if (axis === 'undecided') { axis = decideAxis(p.clientX - startX, p.clientY - startY); + if (axis === 'vertical') verticalTravelOccurred = true; if (axis !== 'vertical') return { prevent: false }; } @@ -351,9 +371,17 @@ export function createTouchScrollController(deps: TouchScrollDeps) { return { prevent: true }; }, - onTouchEnd(remainingTouches = 0, timeStampMs?: number): void { + onTouchEnd( + remainingTouches = 0, + timeStampMs?: number + ): TouchEndResult { cancelMomentum(); + const caughtFling = + remainingTouches === 0 && + sequenceBeganAsCatch && + !verticalTravelOccurred; + const exitVelocity = remainingTouches === 0 && axis === 'vertical' && @@ -371,6 +399,8 @@ export function createTouchScrollController(deps: TouchScrollDeps) { momentumPrevT = readNow(); scheduleMomentumFrame(); } + + return { caughtFling }; }, onTouchCancel(): void { @@ -454,19 +484,27 @@ export function installTerminalTouchScroll(terminal: Terminal): () => void { }; }; - const onStart = (e: TouchEvent) => controller.onTouchStart(toPoint(e)); + const onStart = (e: TouchEvent) => { + const { flingCatch } = controller.onTouchStart(toPoint(e)); + patchTerminalMobileState(terminal, { flingCatch }); + }; const onMove = (e: TouchEvent) => { if (controller.onTouchMove(toPoint(e)).prevent && e.cancelable) { e.preventDefault(); } }; - const onEnd = (e: TouchEvent) => - controller.onTouchEnd(e.touches.length, e.timeStamp); + const onEnd = (e: TouchEvent) => { + const { caughtFling } = controller.onTouchEnd( + e.touches.length, + e.timeStamp + ); + if (caughtFling && e.cancelable) e.preventDefault(); + }; const onCancel = () => controller.onTouchCancel(); el.addEventListener('touchstart', onStart, { passive: true }); el.addEventListener('touchmove', onMove, { passive: false }); - el.addEventListener('touchend', onEnd, { passive: true }); + el.addEventListener('touchend', onEnd, { passive: false }); el.addEventListener('touchcancel', onCancel, { passive: true }); return () => { From 25b232a8b1267197712601b58043edae53179d4b Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Tue, 21 Jul 2026 07:38:06 +0000 Subject: [PATCH 6/9] refactor(terminal): simplify touch wheel draining --- .../shared/lib/terminalTouchScroll.test.ts | 4 +- .../src/shared/lib/terminalTouchScroll.ts | 98 ++++++++----------- 2 files changed, 41 insertions(+), 61 deletions(-) diff --git a/packages/web-core/src/shared/lib/terminalTouchScroll.test.ts b/packages/web-core/src/shared/lib/terminalTouchScroll.test.ts index 6f327eafe3c..2d5339f286f 100644 --- a/packages/web-core/src/shared/lib/terminalTouchScroll.test.ts +++ b/packages/web-core/src/shared/lib/terminalTouchScroll.test.ts @@ -515,9 +515,7 @@ describe('createTouchScrollController (momentum)', () => { harness.advance(16); harness.ctrl.onTouchMove({ touches: 1, clientX: 50, clientY: 170 }); expect( - harness.wheels - .slice(wheelsBeforeCatch) - .map((wheel) => wheel.direction) + harness.wheels.slice(wheelsBeforeCatch).map((wheel) => wheel.direction) ).toEqual([1, 1]); expect(harness.ctrl.onTouchEnd()).toEqual({ caughtFling: false }); diff --git a/packages/web-core/src/shared/lib/terminalTouchScroll.ts b/packages/web-core/src/shared/lib/terminalTouchScroll.ts index 5defd268577..aff8e88c290 100644 --- a/packages/web-core/src/shared/lib/terminalTouchScroll.ts +++ b/packages/web-core/src/shared/lib/terminalTouchScroll.ts @@ -150,7 +150,6 @@ export function createTouchScrollController(deps: TouchScrollDeps) { let axis: Axis = 'undecided'; let startX = 0; let startY = 0; - let lastY = 0; let lastClientX = 0; let lastClientY = 0; let stepPx = FALLBACK_WHEEL_STEP_PX; @@ -208,27 +207,14 @@ export function createTouchScrollController(deps: TouchScrollDeps) { momentumVelocity *= Math.exp(-dt / MOMENTUM_TIME_CONSTANT_MS); momentumCarry += momentumVelocity * dt; - let frameWheelCount = 0; - while ( - momentumCarry >= stepPx && - frameWheelCount < MAX_WHEELS_PER_FRAME && - momentumWheelCount < MAX_MOMENTUM_WHEELS - ) { - deps.dispatchWheel(1, lastClientX, lastClientY); - momentumCarry -= stepPx; - frameWheelCount += 1; - momentumWheelCount += 1; - } - while ( - momentumCarry <= -stepPx && - frameWheelCount < MAX_WHEELS_PER_FRAME && - momentumWheelCount < MAX_MOMENTUM_WHEELS - ) { - deps.dispatchWheel(-1, lastClientX, lastClientY); - momentumCarry += stepPx; - frameWheelCount += 1; - momentumWheelCount += 1; - } + const drained = drain( + momentumCarry, + Math.min(MAX_WHEELS_PER_FRAME, MAX_MOMENTUM_WHEELS - momentumWheelCount), + lastClientX, + lastClientY + ); + momentumCarry = drained.carry; + momentumWheelCount += drained.count; if ( Math.abs(momentumVelocity) < MOMENTUM_STOP_PX_PER_MS || @@ -250,6 +236,22 @@ export function createTouchScrollController(deps: TouchScrollDeps) { } } + function drain( + carry: number, + budget: number, + clientX: number, + clientY: number + ): { carry: number; count: number } { + const direction: 1 | -1 = carry >= 0 ? 1 : -1; + let count = 0; + while (Math.abs(carry) >= stepPx && count < budget) { + deps.dispatchWheel(direction, clientX, clientY); + carry -= direction * stepPx; + count += 1; + } + return { carry, count }; + } + function getExitVelocity(releaseAt: number): number | undefined { if (velocitySamples.length < 2) return undefined; const newestIndex = velocitySamples.length - 1; @@ -264,9 +266,7 @@ export function createTouchScrollController(deps: TouchScrollDeps) { for (let index = newestIndex; index > 0; index -= 1) { const delta = velocitySamples[index].y - velocitySamples[index - 1].y; const nextDirection = - Math.abs(delta) < VELOCITY_DIRECTION_EPSILON_PX - ? 0 - : Math.sign(delta); + Math.abs(delta) < VELOCITY_DIRECTION_EPSILON_PX ? 0 : Math.sign(delta); if (nextDirection !== 0) { if (direction !== 0 && nextDirection !== direction) break; direction = nextDirection; @@ -300,23 +300,19 @@ export function createTouchScrollController(deps: TouchScrollDeps) { return { onTouchStart(p: TouchPoint): TouchStartResult { - sequenceBeganAsCatch = - p.touches === 1 && momentumFrameId !== undefined; + sequenceBeganAsCatch = p.touches === 1 && momentumFrameId !== undefined; verticalTravelOccurred = false; cancelMomentum(); resetGesture(p.touches === 1 ? 'undecided' : 'ignore'); stepPx = sanitizeWheelStep(deps.getLineHeightPx?.()); startX = p.clientX; startY = p.clientY; - lastY = p.clientY; lastClientX = p.clientX; lastClientY = p.clientY; - if (p.touches !== 1) { - // Pinch / multi-touch — leave it to the browser. - return { flingCatch: sequenceBeganAsCatch }; + if (p.touches === 1) { + appendVelocitySample(p.timeStampMs ?? readNow(), p.clientY); } - appendVelocitySample(p.timeStampMs ?? readNow(), p.clientY); return { flingCatch: sequenceBeganAsCatch }; }, @@ -342,39 +338,31 @@ export function createTouchScrollController(deps: TouchScrollDeps) { if (axis !== 'vertical') return { prevent: false }; } - lastClientX = p.clientX; - lastClientY = p.clientY; - appendVelocitySample(p.timeStampMs ?? readNow(), p.clientY); - - const deltaY = lastY - p.clientY; - lastY = p.clientY; - // Only bridge when xterm's own touch scroll is disabled (mouse tracking on). // Otherwise let xterm scroll its scrollback natively — avoids double-scroll. if (deps.getMouseTrackingMode() === 'none') return { prevent: false }; + const deltaY = lastClientY - p.clientY; + lastClientX = p.clientX; + lastClientY = p.clientY; + appendVelocitySample(p.timeStampMs ?? readNow(), p.clientY); + // Natural scrolling: finger up (clientY decreases) reveals newer content // (scroll down, +1); finger down reveals history (scroll up, -1). accumulated += deltaY; - - while (accumulated >= stepPx) { - deps.dispatchWheel(1, p.clientX, p.clientY); - accumulated -= stepPx; - } - while (accumulated <= -stepPx) { - deps.dispatchWheel(-1, p.clientX, p.clientY); - accumulated += stepPx; - } + accumulated = drain( + accumulated, + Number.POSITIVE_INFINITY, + p.clientX, + p.clientY + ).carry; // We've committed to the vertical gesture: always prevent page scroll/rubber-band, // even on sub-step moves that didn't dispatch a wheel yet. return { prevent: true }; }, - onTouchEnd( - remainingTouches = 0, - timeStampMs?: number - ): TouchEndResult { + onTouchEnd(remainingTouches = 0, timeStampMs?: number): TouchEndResult { cancelMomentum(); const caughtFling = @@ -406,12 +394,6 @@ export function createTouchScrollController(deps: TouchScrollDeps) { onTouchCancel(): void { cancelMomentum(); resetGesture('undecided'); - startX = 0; - startY = 0; - lastY = 0; - lastClientX = 0; - lastClientY = 0; - stepPx = FALLBACK_WHEEL_STEP_PX; }, }; } From 06c309744a487e9209e28384b455dd86f7f981ce Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Tue, 21 Jul 2026 13:06:18 +0000 Subject: [PATCH 7/9] test(terminal): harden momentum release timing --- .../shared/lib/terminalTouchScroll.test.ts | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/packages/web-core/src/shared/lib/terminalTouchScroll.test.ts b/packages/web-core/src/shared/lib/terminalTouchScroll.test.ts index 2d5339f286f..23494b332f1 100644 --- a/packages/web-core/src/shared/lib/terminalTouchScroll.test.ts +++ b/packages/web-core/src/shared/lib/terminalTouchScroll.test.ts @@ -31,6 +31,7 @@ function createHarness({ let mode = initialMode; let suppressed = false; let nextFrameId = 1; + let scheduledFrameCount = 0; const frames = new Map void>(); const wheels: WheelRecord[] = []; @@ -40,6 +41,7 @@ function createHarness({ isSuppressed: () => suppressed, now: () => nowT, scheduleFrame: (cb) => { + scheduledFrameCount += 1; const id = nextFrameId++; frames.set(id, cb); return id; @@ -81,6 +83,7 @@ function createHarness({ pumpFrame, pumpUntilIdle, pendingFrames: () => frames.size, + scheduledFrameCount: () => scheduledFrameCount, setMode: (nextMode: string) => { mode = nextMode; }, @@ -376,6 +379,42 @@ describe('createTouchScrollController (gesture ownership)', () => { }); describe('createTouchScrollController (momentum)', () => { + it('does not schedule momentum when suppression starts immediately before lift', () => { + const harness = createHarness(); + harness.ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 300 }); + harness.advance(16); + harness.ctrl.onTouchMove({ touches: 1, clientX: 0, clientY: 240 }); + harness.advance(16); + harness.ctrl.onTouchMove({ touches: 1, clientX: 0, clientY: 180 }); + const wheelsAtLift = harness.wheels.length; + + harness.setSuppressed(true); + harness.ctrl.onTouchEnd(); + + expect(harness.scheduledFrameCount()).toBe(0); + for (let frame = 0; frame < 10; frame += 1) harness.pumpFrame(); + expect(harness.wheels).toHaveLength(wheelsAtLift); + expect(harness.pendingFrames()).toBe(0); + }); + + it('does not schedule momentum when mouse tracking stops immediately before lift', () => { + const harness = createHarness(); + harness.ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 300 }); + harness.advance(16); + harness.ctrl.onTouchMove({ touches: 1, clientX: 0, clientY: 240 }); + harness.advance(16); + harness.ctrl.onTouchMove({ touches: 1, clientX: 0, clientY: 180 }); + const wheelsAtLift = harness.wheels.length; + + harness.setMode('none'); + harness.ctrl.onTouchEnd(); + + expect(harness.scheduledFrameCount()).toBe(0); + for (let frame = 0; frame < 10; frame += 1) harness.pumpFrame(); + expect(harness.wheels).toHaveLength(wheelsAtLift); + expect(harness.pendingFrames()).toBe(0); + }); + it('does not start momentum after a fast drag is held still for 200ms', () => { const harness = createHarness(); harness.ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 300 }); @@ -454,6 +493,27 @@ describe('createTouchScrollController (momentum)', () => { } }); + it('keeps irregular 48ms-frame momentum within the 16ms-frame envelope', () => { + const tailWheelCount = (dt: number): number => { + const harness = createHarness(); + startFlick(harness); + const dragWheelCount = harness.wheels.length; + + harness.pumpUntilIdle(500, dt); + expect(harness.pendingFrames()).toBe(0); + return harness.wheels.length - dragWheelCount; + }; + + const regularTailWheels = tailWheelCount(16); + const irregularTailWheels = tailWheelCount(48); + + expect(irregularTailWheels).toBeGreaterThan(0); + expect(irregularTailWheels).toBeGreaterThanOrEqual( + regularTailWheels * 0.75 + ); + expect(irregularTailWheels).toBeLessThanOrEqual(regularTailWheels * 1.25); + }); + it('cancels the tail on re-grab and requires a full row of new travel', () => { const harness = createHarness(); startFlick(harness); @@ -564,6 +624,29 @@ describe('createTouchScrollController (momentum)', () => { expect(MOMENTUM_MAX_FRAME_GAP_MS).toBe(250); }); + it('continues below the frame-gap limit and cancels above it', () => { + const continued = createHarness(); + startFlick(continued); + const continuedSchedulesBeforeGap = continued.scheduledFrameCount(); + + continued.pumpFrame(MOMENTUM_MAX_FRAME_GAP_MS - 1); + + expect(continued.pendingFrames()).toBe(1); + expect(continued.scheduledFrameCount()).toBe( + continuedSchedulesBeforeGap + 1 + ); + + const canceled = createHarness(); + startFlick(canceled); + const wheelsBeforeGap = canceled.wheels.length; + const canceledSchedulesBeforeGap = canceled.scheduledFrameCount(); + + expect(canceled.pumpFrame(MOMENTUM_MAX_FRAME_GAP_MS + 1)).toBe(0); + expect(canceled.wheels).toHaveLength(wheelsBeforeGap); + expect(canceled.pendingFrames()).toBe(0); + expect(canceled.scheduledFrameCount()).toBe(canceledSchedulesBeforeGap); + }); + it('never flings opposite the final motion after a direction reversal', () => { const harness = createHarness(); harness.ctrl.onTouchStart({ From 572cb6a49f9b80a89b3ca2ed908bdfee4034e653 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Tue, 21 Jul 2026 13:35:02 +0000 Subject: [PATCH 8/9] fix(terminal): preserve multi-touch scroll state --- .../shared/lib/terminalTouchScroll.test.ts | 41 +++++++++++++++++++ .../src/shared/lib/terminalTouchScroll.ts | 11 +++-- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/packages/web-core/src/shared/lib/terminalTouchScroll.test.ts b/packages/web-core/src/shared/lib/terminalTouchScroll.test.ts index 23494b332f1..0ecc11733d4 100644 --- a/packages/web-core/src/shared/lib/terminalTouchScroll.test.ts +++ b/packages/web-core/src/shared/lib/terminalTouchScroll.test.ts @@ -304,6 +304,26 @@ describe('createTouchScrollController (gesture ownership)', () => { expect(harness.wheels.map((wheel) => wheel.direction)).toEqual([1, -1]); }); + it('keeps a surviving finger ignored after a partial touchcancel', () => { + const harness = createHarness(); + harness.ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 0 }); + harness.ctrl.onTouchStart({ touches: 2, clientX: 0, clientY: 0 }); + + harness.ctrl.onTouchCancel(1); + const stillIgnored = harness.ctrl.onTouchMove({ + touches: 1, + clientX: 0, + clientY: -100, + }); + expect(stillIgnored.prevent).toBe(false); + expect(harness.wheels).toHaveLength(0); + + harness.ctrl.onTouchEnd(0); + harness.ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 100 }); + harness.ctrl.onTouchMove({ touches: 1, clientX: 0, clientY: 85 }); + expect(harness.wheels.map((wheel) => wheel.direction)).toEqual([1]); + }); + it('abandons when a multi-touch move arrives without a second touchstart', () => { const harness = createHarness(); harness.ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 0 }); @@ -552,6 +572,27 @@ describe('createTouchScrollController (momentum)', () => { expect(harness.pendingFrames()).toBe(0); }); + it('retains a caught fling when another finger joins the sequence', () => { + const harness = createHarness(); + startFlick(harness); + + const firstStart = harness.ctrl.onTouchStart({ + touches: 1, + clientX: 50, + clientY: 200, + }); + const secondStart = harness.ctrl.onTouchStart({ + touches: 2, + clientX: 50, + clientY: 200, + }); + + expect(firstStart).toEqual({ flingCatch: true }); + expect(secondStart).toEqual({ flingCatch: true }); + expect(harness.ctrl.onTouchEnd(1)).toEqual({ caughtFling: false }); + expect(harness.ctrl.onTouchEnd(0)).toEqual({ caughtFling: true }); + }); + it('reports a normal stationary tap as not catching a fling', () => { const harness = createHarness(); diff --git a/packages/web-core/src/shared/lib/terminalTouchScroll.ts b/packages/web-core/src/shared/lib/terminalTouchScroll.ts index aff8e88c290..5dc8001c9f5 100644 --- a/packages/web-core/src/shared/lib/terminalTouchScroll.ts +++ b/packages/web-core/src/shared/lib/terminalTouchScroll.ts @@ -300,7 +300,9 @@ export function createTouchScrollController(deps: TouchScrollDeps) { return { onTouchStart(p: TouchPoint): TouchStartResult { - sequenceBeganAsCatch = p.touches === 1 && momentumFrameId !== undefined; + if (p.touches === 1) { + sequenceBeganAsCatch = momentumFrameId !== undefined; + } verticalTravelOccurred = false; cancelMomentum(); resetGesture(p.touches === 1 ? 'undecided' : 'ignore'); @@ -391,9 +393,9 @@ export function createTouchScrollController(deps: TouchScrollDeps) { return { caughtFling }; }, - onTouchCancel(): void { + onTouchCancel(remainingTouches = 0): void { cancelMomentum(); - resetGesture('undecided'); + resetGesture(remainingTouches === 0 ? 'undecided' : 'ignore'); }, }; } @@ -482,7 +484,8 @@ export function installTerminalTouchScroll(terminal: Terminal): () => void { ); if (caughtFling && e.cancelable) e.preventDefault(); }; - const onCancel = () => controller.onTouchCancel(); + const onCancel = (e: TouchEvent) => + controller.onTouchCancel(e.touches.length); el.addEventListener('touchstart', onStart, { passive: true }); el.addEventListener('touchmove', onMove, { passive: false }); From a6d1bb3c5823fd5accd604b1d76f959c24a62169 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Tue, 21 Jul 2026 13:37:45 +0000 Subject: [PATCH 9/9] fix(terminal): separate scroll gestures from taps --- .../shared/lib/terminalMobileState.test.ts | 2 ++ .../src/shared/lib/terminalMobileState.ts | 6 +++- .../shared/lib/terminalTouchGestures.test.ts | 30 +++++++++++++++++++ .../src/shared/lib/terminalTouchGestures.ts | 18 ++++++----- .../src/shared/lib/terminalTouchScroll.ts | 11 +++++-- 5 files changed, 56 insertions(+), 11 deletions(-) diff --git a/packages/web-core/src/shared/lib/terminalMobileState.test.ts b/packages/web-core/src/shared/lib/terminalMobileState.test.ts index 1f976810114..37a63fdef6d 100644 --- a/packages/web-core/src/shared/lib/terminalMobileState.test.ts +++ b/packages/web-core/src/shared/lib/terminalMobileState.test.ts @@ -18,6 +18,8 @@ describe('terminal mobile state store', () => { ctrlLatched: false, selectMode: false, dpadActive: false, + flingCatch: false, + scrollOwned: false, }); }); diff --git a/packages/web-core/src/shared/lib/terminalMobileState.ts b/packages/web-core/src/shared/lib/terminalMobileState.ts index f937312e5fb..261d917ed68 100644 --- a/packages/web-core/src/shared/lib/terminalMobileState.ts +++ b/packages/web-core/src/shared/lib/terminalMobileState.ts @@ -20,13 +20,17 @@ export interface TerminalMobileState { /** A long-press D-pad gesture is running; the scroll bridge stands down. */ dpadActive: boolean; /** The current touch sequence started by stopping live scroll momentum. */ - flingCatch?: boolean; + flingCatch: boolean; + /** The scroll bridge committed vertical ownership during this sequence. */ + scrollOwned: boolean; } const EMPTY_STATE: TerminalMobileState = { ctrlLatched: false, selectMode: false, dpadActive: false, + flingCatch: false, + scrollOwned: false, }; interface Entry { diff --git a/packages/web-core/src/shared/lib/terminalTouchGestures.test.ts b/packages/web-core/src/shared/lib/terminalTouchGestures.test.ts index f446762ca31..39a50c266bc 100644 --- a/packages/web-core/src/shared/lib/terminalTouchGestures.test.ts +++ b/packages/web-core/src/shared/lib/terminalTouchGestures.test.ts @@ -500,6 +500,36 @@ describe('double-tap = Tab', () => { ctrl.onTouchEnd(0, 240); expect(events).toEqual(['tab']); }); + + it('clears prior double-tap history when a fling catch interrupts it', () => { + const { ctrl, events } = harness(); + ctrl.onTouchStart(p(100, 100), 0); + ctrl.onTouchEnd(0, 40); + + ctrl.onTouchStart(p(100, 100), 100); + ctrl.onTouchEnd(0, 140, true); + + ctrl.onTouchStart(p(100, 100), 200); + ctrl.onTouchEnd(0, 240); + expect(events).toEqual([]); + }); + + it('does not let scroll-owned sequences seed or complete a double-tap', () => { + const { ctrl, events } = harness(); + ctrl.onTouchStart(p(100, 100), 0); + ctrl.onTouchEnd(0, 40, false, true); + + ctrl.onTouchStart(p(100, 100), 100); + ctrl.onTouchEnd(0, 140); + expect(events).toEqual([]); + + ctrl.onTouchStart(p(100, 100), 200); + ctrl.onTouchEnd(0, 240, false, true); + + ctrl.onTouchStart(p(100, 100), 300); + ctrl.onTouchEnd(0, 340); + expect(events).toEqual([]); + }); }); describe('three-finger tap = paste', () => { diff --git a/packages/web-core/src/shared/lib/terminalTouchGestures.ts b/packages/web-core/src/shared/lib/terminalTouchGestures.ts index cd3d6898732..34987f4574b 100644 --- a/packages/web-core/src/shared/lib/terminalTouchGestures.ts +++ b/packages/web-core/src/shared/lib/terminalTouchGestures.ts @@ -424,7 +424,8 @@ export function createTouchGestureController(deps: GestureDeps) { onTouchEnd( remainingTouches: number, now: number, - flingCatch = false + flingCatch = false, + scrollOwned = false ): void { if (remainingTouches > 0) return; // wait for the last finger const wasPhase = @@ -463,7 +464,10 @@ export function createTouchGestureController(deps: GestureDeps) { lastTap = null; return; } - if (flingCatch) return; + if (flingCatch || scrollOwned) { + lastTap = null; + return; + } // A clean quick tap. Second one in time + place = Tab. if ( lastTap && @@ -782,6 +786,7 @@ export function installTerminalTouchGestures( }; const onEnd = (e: TouchEvent) => { if (primaryTouchIdentifier === null) return; + const { flingCatch, scrollOwned } = getTerminalMobileState(terminal); const primaryStillOwned = touchWithIdentifier( e.targetTouches, primaryTouchIdentifier @@ -791,17 +796,14 @@ export function installTerminalTouchGestures( // contain contacts that began on nav/key bars and will never emit an // event on this terminal, so waiting for the global last finger strands // D-pad suppression and its timer indefinitely. - controller.onTouchEnd( - 0, - e.timeStamp, - getTerminalMobileState(terminal).flingCatch - ); + controller.onTouchEnd(0, e.timeStamp, flingCatch, scrollOwned); releaseTouchOwnership(); } else { controller.onTouchEnd( e.targetTouches.length, e.timeStamp, - getTerminalMobileState(terminal).flingCatch + flingCatch, + scrollOwned ); } reschedule(); diff --git a/packages/web-core/src/shared/lib/terminalTouchScroll.ts b/packages/web-core/src/shared/lib/terminalTouchScroll.ts index 5dc8001c9f5..ce3262b5d89 100644 --- a/packages/web-core/src/shared/lib/terminalTouchScroll.ts +++ b/packages/web-core/src/shared/lib/terminalTouchScroll.ts @@ -422,6 +422,7 @@ export function installTerminalTouchScroll(terminal: Terminal): () => void { (el.querySelector('.xterm-screen') as HTMLElement | null) ?? el; let lastKey = ''; let lastPoint = { x: 0, y: 0 }; + let scrollOwned = false; const controller = createTouchScrollController({ getMouseTrackingMode: () => @@ -470,10 +471,16 @@ export function installTerminalTouchScroll(terminal: Terminal): () => void { const onStart = (e: TouchEvent) => { const { flingCatch } = controller.onTouchStart(toPoint(e)); - patchTerminalMobileState(terminal, { flingCatch }); + scrollOwned = false; + patchTerminalMobileState(terminal, { flingCatch, scrollOwned }); }; const onMove = (e: TouchEvent) => { - if (controller.onTouchMove(toPoint(e)).prevent && e.cancelable) { + const { prevent } = controller.onTouchMove(toPoint(e)); + if (prevent && !scrollOwned) { + scrollOwned = true; + patchTerminalMobileState(terminal, { scrollOwned }); + } + if (prevent && e.cancelable) { e.preventDefault(); } };