mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
co-authored by
Prateek Rungta
Claude Opus 5.5
parent
251bbf7bb0
commit
efcf8f0888
@@ -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) {
|
||||
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user