diff --git a/mobile/src/terminal/document/document-externals.ts b/mobile/src/terminal/document/document-externals.ts index 4367ca5dbfd..c8c1eefa875 100644 --- a/mobile/src/terminal/document/document-externals.ts +++ b/mobile/src/terminal/document/document-externals.ts @@ -36,9 +36,6 @@ export declare function updateScrollIndicator(visible: boolean): void /** `runtime-state-and-text-scaling`: one diagnostic line, forwarded to the host. */ export declare function flog(name: string, detail: Record): void -/** `surface-touch-gestures`: whether a dispatcher above the surface is swallowing input. */ -export declare function dispatcherShouldBlockSurface(): boolean - /** `mouse-report-and-scroll-routing`: whether scrolling should reach the TUI as input. */ export declare function shouldRouteScrollToTerminalInput(): boolean @@ -68,3 +65,22 @@ export declare function getLineText(row: number): string /** `selection-overlay`: the string index a cell column lands on, wide characters included. */ export declare function cellColToStringIndex(row: number, col: number): number + +/** `selection-overlay`: starts a selection at a cell. */ +export declare function enterSelect(col: number, row: number): void + +/** `selection-overlay`: clears the selection and leaves select mode. */ +export declare function cancelSelect(): void + +/** `selection-overlay`: moves one selection handle to a viewport point. */ +export declare function handleDragMove(handle: string, clientX: number, clientY: number): void + +/** `selection-overlay`: stops the edge-scroll a handle drag may have started. */ +export declare function stopEdgeScroll(): void + +/** `surface-touch-gestures`: reports a surface tap to the host. */ +export declare function notifyTerminalSurfaceTap( + clientX: number, + clientY: number, + fromTouch: boolean +): void diff --git a/mobile/src/terminal/document/document-scope.ts b/mobile/src/terminal/document/document-scope.ts index 7cd212964ef..67375715ade 100644 --- a/mobile/src/terminal/document/document-scope.ts +++ b/mobile/src/terminal/document/document-scope.ts @@ -98,6 +98,30 @@ export type TerminalDocumentScope = { terminalTheme: TerminalDocumentTheme /** `terminal-theme`: the contrast floor in force, published or derived from the background. */ terminalMinimumContrastRatio: number + /** `selection-overlay`: the press duration that starts a selection, in milliseconds. */ + LONG_PRESS_MS: number + /** `selection-overlay`: the travel that cancels a pending long press, in pixels. */ + LONG_PRESS_SLOP: number + /** `selection-overlay`: the travel that disqualifies a tap, in pixels. */ + TAP_SLOP: number + /** `selection-overlay`: the longest press still counted as a tap, in milliseconds. */ + TAP_MAX_MS: number + /** `selection-overlay`: the overlay element that carries the handles and the menu pill. */ + selectionOverlay: HTMLElement | null + /** `selection-overlay`: the selection's leading handle element. */ + handleStart: HTMLElement | null + /** `selection-overlay`: the selection's trailing handle element. */ + handleEnd: HTMLElement | null + /** `selection-overlay`: `navigate` or `select`. */ + selMode: string + /** `selection-overlay`: the live selection, or null when there is none. */ + sel: TerminalDocumentSelection | null + /** `selection-overlay`: the pending long-press timer. */ + longPressTimer: ReturnType | null + /** `selection-overlay`: where the pending long press started. */ + longPressOrigin: TerminalDocumentTouchOrigin | null + /** `selection-overlay`: the touch that may still resolve as a tap. */ + tapCandidate: TerminalDocumentTapCandidate | null /** `surface-swap`: the element xterm is currently mounted on. */ surface: HTMLElement | null /** `surface-swap`: the terminal of a hidden replacement surface that has not committed. */ @@ -105,6 +129,15 @@ export type TerminalDocumentScope = { } /** An xterm listener handle, as the document disposes of one. */ +/** The live selection; only the dragged handle is read outside the overlay slice. */ +export type TerminalDocumentSelection = { activeHandle: string | null } + +/** Where a press began, and which finger began it. */ +export type TerminalDocumentTouchOrigin = { x: number; y: number; identifier: number } + +/** A touch that may still resolve as a tap: its origin, its start time and its finger. */ +export type TerminalDocumentTapCandidate = TerminalDocumentTouchOrigin & { t: number } + export type TerminalDocumentDisposable = { dispose?: () => void } /** xterm's WebGL addon, as the document loads, repaints and disposes of it. */ @@ -134,6 +167,18 @@ export function createTerminalDocumentScope(): TerminalDocumentScope { defaultTheme: DEFAULT_TERMINAL_THEME, terminalTheme: DEFAULT_TERMINAL_THEME, terminalMinimumContrastRatio: 3, + LONG_PRESS_MS: 500, + LONG_PRESS_SLOP: 10, + TAP_SLOP: 24, + TAP_MAX_MS: 700, + selectionOverlay: null, + handleStart: null, + handleEnd: null, + selMode: 'navigate', + sel: null, + longPressTimer: null, + longPressOrigin: null, + tapCandidate: null, wheelAccumDeltaY: 0, surface: null, pendingTerm: null diff --git a/mobile/src/terminal/document/tap-dispatch.test.ts b/mobile/src/terminal/document/tap-dispatch.test.ts new file mode 100644 index 00000000000..b496091c848 --- /dev/null +++ b/mobile/src/terminal/document/tap-dispatch.test.ts @@ -0,0 +1,26 @@ +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { emitTerminalDocumentModule } from '../../../scripts/build-terminal-document-script.mjs' +import { TERMINAL_TAP_DISPATCH_JS } from '../terminal-webview-tap-dispatch-injected' +import { compareTerminalDocumentScripts } from './terminal-document-equivalence.test-support' + +const modulePath = fileURLToPath(new URL('./tap-dispatch.ts', import.meta.url)) + +describe('the tap-dispatch module', () => { + it('emits the script the document carries, modulo the five normalisations', async () => { + const emitted = await emitTerminalDocumentModule(modulePath) + expect(compareTerminalDocumentScripts(TERMINAL_TAP_DISPATCH_JS, emitted, 'scope')).toEqual({ + equivalent: true, + normalisations: { + // The heaviest reader of shared state so far: the selection, its elements, its thresholds + // and both press origins are all declared by the overlay slice, which is still document text. + qualifiedReferences: 49, + scopeFieldDeclarations: 0, + rebindings: 15, + bracedBodies: 11, + unboundCatches: 0, + numberProperties: 0 + } + }) + }) +}) diff --git a/mobile/src/terminal/document/tap-dispatch.ts b/mobile/src/terminal/document/tap-dispatch.ts new file mode 100644 index 00000000000..cb63760cec3 --- /dev/null +++ b/mobile/src/terminal/document/tap-dispatch.ts @@ -0,0 +1,253 @@ +import { + cancelSelect, + enterSelect, + handleDragMove, + notify, + notifyTerminalSurfaceTap, + stopEdgeScroll, + viewportToCell +} from './document-externals' +import { scope } from './document-scope' + +// ============================================================ +// LATCHING TOUCH DISPATCHER (document-level) +// ============================================================ + +/** What the dispatcher has latched onto, and the fingers it is tracking. */ +export type TerminalTouchDispatch = { + mode: string + touchId: number | null + touchIds: number[] | null + longPressFingerInsideOverlay: boolean +} + +/** An element a target can be tested against; a method so a real element satisfies it. */ +type TerminalDocumentTargetContainer = { contains(other: EventTarget | null): boolean } + +const dispatch: TerminalTouchDispatch = { + mode: 'idle', + touchId: null, + touchIds: null, + longPressFingerInsideOverlay: false +} + +export function touchById(touches: TouchList, id: number | null) { + for (let i = 0; i < touches.length; i++) { + if (touches[i].identifier === id) { + return touches[i] + } + } + return null +} + +export function targetInside( + target: EventTarget | null, + el: TerminalDocumentTargetContainer | null +) { + if (!target || !el) { + return false + } + return el.contains(target) +} + +export function clearLongPress() { + if (scope.longPressTimer) { + clearTimeout(scope.longPressTimer) + scope.longPressTimer = null + } + scope.longPressOrigin = null +} + +export function armLongPress(touch: Touch) { + scope.longPressOrigin = { x: touch.clientX, y: touch.clientY, identifier: touch.identifier } + scope.longPressTimer = setTimeout(function () { + scope.longPressTimer = null + if (!scope.longPressOrigin) { + return + } + const c = viewportToCell(scope.longPressOrigin.x, scope.longPressOrigin.y) + if (!c) { + return + } + enterSelect(c.col, c.row) + }, scope.LONG_PRESS_MS) +} + +export function touchSlopExceeded(t: Touch) { + if (!scope.longPressOrigin) { + return false + } + const dx = Math.abs(t.clientX - scope.longPressOrigin.x) + const dy = Math.abs(t.clientY - scope.longPressOrigin.y) + return dx + dy > scope.LONG_PRESS_SLOP +} + +// Why: existing surface handlers stay attached to surface but we wrap +// their entry to no-op when the dispatcher latches into select-drag. +export function dispatcherShouldBlockSurface() { + return dispatch.mode === 'select-drag' +} + +document.addEventListener( + 'touchstart', + function (e) { + const t = e.touches[0] + const target = e.target + const onHandle = target === scope.handleStart || target === scope.handleEnd + const inOverlay = targetInside(target, scope.selectionOverlay) + const inSurface = targetInside(target, scope.surface) + // Why: clear any stale tap candidate up front; only a fresh single-finger + // surface touch (below) re-arms it, so handle drags / pinches / dismiss + // taps never resolve as a link tap on touchend. + scope.tapCandidate = null + + if (e.touches.length === 2) { + // pinch latch + if (scope.selMode === 'select') { + notify({ type: 'mobile-clip-cancel-by-pinch' }) + cancelSelect() + } + dispatch.mode = 'pinch' + dispatch.touchIds = [e.touches[0].identifier, e.touches[1].identifier] + clearLongPress() + return + } + + if (onHandle && scope.selMode === 'select') { + // start handle drag + const handleName = target === scope.handleStart ? 'start' : 'end' + // oxlint-disable-next-line typescript/no-non-null-assertion -- SAFETY: a handle only exists while a selection does. + scope.sel!.activeHandle = handleName + dispatch.mode = 'select-drag' + dispatch.touchId = t.identifier + e.preventDefault() + return + } + + if (inOverlay) { + // tap on menu pill — let the buttons' own handlers fire + return + } + + if (inSurface && scope.selMode === 'select') { + // Why: tap-to-dismiss matches native iOS/Android — touching outside the + // selection clears it. We cancel immediately and latch to 'surface' so + // the same gesture still drives scroll/pan without a second touch. + cancelSelect() + dispatch.mode = 'surface' + dispatch.touchId = t.identifier + return + } + + if (inSurface) { + dispatch.mode = 'surface' + dispatch.touchId = t.identifier + scope.tapCandidate = { x: t.clientX, y: t.clientY, t: Date.now(), identifier: t.identifier } + armLongPress(t) + } + }, + { capture: true, passive: false } +) + +document.addEventListener( + 'touchmove', + function (e) { + if (dispatch.mode === 'select-drag') { + const t = touchById(e.touches, dispatch.touchId) + if (!t || !scope.sel || !scope.sel.activeHandle) { + return + } + e.preventDefault() + handleDragMove(scope.sel.activeHandle, t.clientX, t.clientY) + return + } + if (dispatch.mode === 'surface' || dispatch.mode === 'pinch') { + // long-press slop check + if (scope.longPressTimer && e.touches.length === 1) { + if (touchSlopExceeded(e.touches[0])) { + clearLongPress() + } + } + // Why: disqualify the tap only once the finger travels past TAP_SLOP + // (a scroll/pan), independent of the long-press timer — so a tap that + // jitters under TAP_SLOP still opens the link/path under the finger. + if (scope.tapCandidate && e.touches.length === 1) { + const mt = e.touches[0] + if (mt.identifier === scope.tapCandidate.identifier) { + const dx = Math.abs(mt.clientX - scope.tapCandidate.x) + const dy = Math.abs(mt.clientY - scope.tapCandidate.y) + if (dx + dy > scope.TAP_SLOP) { + scope.tapCandidate = null + } + } + } else if (e.touches.length !== 1) { + scope.tapCandidate = null + } + // existing surface handler will run from its own listener + } + }, + { capture: true, passive: false } +) + +document.addEventListener( + 'touchend', + function (e) { + if (dispatch.mode === 'select-drag') { + if (scope.sel) { + scope.sel.activeHandle = null + } + stopEdgeScroll() + dispatch.mode = 'idle' + dispatch.touchId = null + return + } + if (dispatch.mode === 'pinch') { + if (e.touches.length < 2) { + dispatch.mode = e.touches.length === 1 ? 'surface' : 'idle' + dispatch.touchIds = null + if (e.touches.length === 1) { + dispatch.touchId = e.touches[0].identifier + } + } + return + } + if (dispatch.mode === 'surface') { + // Why: fire the tap from the tap-candidate origin (survives jitter under + // TAP_SLOP) rather than longPressOrigin, which the press-to-select slop + // can null mid-tap — that was dropping URL/file taps that moved a few px. + if ( + e.touches.length === 0 && + scope.tapCandidate && + scope.selMode !== 'select' && + Date.now() - scope.tapCandidate.t <= scope.TAP_MAX_MS + ) { + notifyTerminalSurfaceTap(scope.tapCandidate.x, scope.tapCandidate.y, true) + } + clearLongPress() + scope.tapCandidate = null + if (e.touches.length === 0) { + dispatch.mode = 'idle' + dispatch.touchId = null + } + } + }, + { capture: true, passive: true } +) + +document.addEventListener( + 'touchcancel', + function () { + clearLongPress() + scope.tapCandidate = null + stopEdgeScroll() + if (dispatch.mode === 'select-drag') { + if (scope.sel) { + scope.sel.activeHandle = null + } + } + dispatch.mode = 'idle' + dispatch.touchId = null + dispatch.touchIds = null + }, + { capture: true, passive: true } +) diff --git a/mobile/src/terminal/document/wheel-scroll.ts b/mobile/src/terminal/document/wheel-scroll.ts index e716fef5aaf..f8bf8c70d6e 100644 --- a/mobile/src/terminal/document/wheel-scroll.ts +++ b/mobile/src/terminal/document/wheel-scroll.ts @@ -1,5 +1,4 @@ import { - dispatcherShouldBlockSurface, enqueueNormalBufferScrollDelta, getCellHeight, getTotalScale, @@ -7,6 +6,7 @@ import { routeScrollLines, shouldRouteScrollToTerminalInput } from './document-externals' +import { dispatcherShouldBlockSurface } from './tap-dispatch' import { scope } from './document-scope' scope.wheelAccumDeltaY = 0