refactor(mobile): extract the mouse-report and scroll-routing slice

Two modules around the already-extracted mouse-report-cell group: the viewport
cell lookup that precedes it, and the mouse input encoding and scroll routing
that follow. Eight more names stop being externals, which leaves ten.

Counts: qualified 49, scope declarations 0, rebindings 49, braced bodies 42,
unbound catches 3, number properties 0, shorthand properties 0.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-20 09:49:08 -04:00
parent 666e543617
commit b64ec213df
15 changed files with 325 additions and 44 deletions
@@ -12,24 +12,9 @@
/** `runtime-state-and-text-scaling`: posts one message to the native host. */
export declare function notify(message: Record<string, unknown>): void
/** `terminal-init-and-write`: whether xterm is showing the alternate screen buffer. */
export declare function isAlternateBufferActive(): boolean
/** `mouse-report-and-scroll-routing`: whether scrolling should reach the TUI as input. */
export declare function shouldRouteScrollToTerminalInput(): boolean
/** `mouse-report-and-scroll-routing`: sends a line scroll to the terminal at a point. */
export declare function routeScrollLines(lines: number, clientX: number, clientY: number): void
/** `surface-touch-gestures`: binds touch, wheel and tap handlers to a surface element. */
export declare function attachSurfaceEventHandlers(target: HTMLElement): void
/** `mouse-report-and-scroll-routing`: the terminal cell under a viewport point, or null. */
export declare function viewportToCell(
originX: number,
originY: number
): { row: number; col: number } | null
/** `selection-overlay`: starts a selection at a cell. */
export declare function enterSelect(col: number, row: number): void
@@ -42,24 +27,12 @@ export declare function handleDragMove(handle: string, clientX: number, clientY:
/** `selection-overlay`: stops the edge-scroll a handle drag may have started. */
export declare function stopEdgeScroll(): void
/** `mouse-report-and-scroll-routing`: the tracking mode the TUI last asked for. */
export declare function getMouseTrackingMode(): string
/** `mouse-report-and-scroll-routing`: whether a coordinate fits an SGR report. */
export declare function isSafeSgrMouseCoordinate(value: number): boolean
/** `selection-overlay`: mirrors the document selection into xterm's own selection. */
export declare function applyXtermSelection(): void
/** `selection-overlay`: moves the handles and the menu pill to the current selection. */
export declare function repositionOverlay(): void
/** `mouse-report-and-scroll-routing`: the bytes a plain click sends, or '' when it sends none. */
export declare function buildMouseClickInput(originX: number, originY: number): string
/** `mouse-report-and-scroll-routing`: whether a tracking mode consumes plain clicks. */
export declare function isClickMouseTrackingMode(mode: string): boolean
/** `host-message-router`: routes one decoded host message. */
export declare function handleMsg(msg: unknown): void
@@ -99,7 +99,11 @@ export type TerminalDocumentTerminal = {
}
readonly element?: HTMLElement
readonly _core?: TerminalDocumentCore
readonly modes?: { bracketedPasteMode?: boolean }
readonly modes?: {
bracketedPasteMode?: boolean
mouseTrackingMode?: string
applicationCursorKeysMode?: boolean
}
onLineFeed?: (listener: () => void) => TerminalDocumentDisposable
onScroll?: (listener: () => void) => TerminalDocumentDisposable
onWriteParsed?: (listener: () => void) => TerminalDocumentDisposable
@@ -1,4 +1,5 @@
import { getMouseTrackingMode, notify } from './document-externals'
import { notify } from './document-externals'
import { getMouseTrackingMode } from './mouse-input-encoding'
import { scope } from './document-scope'
export function emitModesIfChanged() {
@@ -1,14 +1,13 @@
import {
applyXtermSelection,
cancelSelect,
getMouseTrackingMode,
handleDragMove,
isSafeSgrMouseCoordinate,
notify,
repositionOverlay,
stopEdgeScroll,
viewportToCell
stopEdgeScroll
} from './document-externals'
import { getMouseTrackingMode, isSafeSgrMouseCoordinate } from './mouse-input-encoding'
import { viewportToCell } from './viewport-cell'
import { scope } from './document-scope'
import { notifyTerminalSurfaceTap } from './surface-tap'
import { viewportToMouseReportCell } from './mouse-report-cell'
@@ -0,0 +1,230 @@
import { notify } from './document-externals'
import { scope } from './document-scope'
import { viewportToMouseReportCell } from './mouse-report-cell'
export function isAlternateBufferActive() {
try {
return !!(
scope.term &&
scope.term.buffer &&
scope.term.buffer.active &&
scope.term.buffer.active.type === 'alternate'
)
} catch {
return false
}
}
export function getMouseTrackingMode() {
try {
if (scope.term && scope.term.modes && typeof scope.term.modes.mouseTrackingMode === 'string') {
const mode = scope.term.modes.mouseTrackingMode
if (mode === 'x10' || mode === 'vt200' || mode === 'drag' || mode === 'any') {
return mode
}
return 'none'
}
} catch {}
if (
scope.trackedMouseTrackingMode === 'x10' ||
scope.trackedMouseTrackingMode === 'vt200' ||
scope.trackedMouseTrackingMode === 'drag' ||
scope.trackedMouseTrackingMode === 'any'
) {
return scope.trackedMouseTrackingMode
}
return 'none'
}
export function repeatSequence(sequence: string, count: number) {
let out = ''
for (let i = 0; i < count; i++) {
out += sequence
}
return out
}
export function buildArrowScrollSequence(lines: number) {
let prefix = '['
try {
if (scope.term && scope.term.modes && scope.term.modes.applicationCursorKeysMode) {
prefix = 'O'
}
} catch {}
return scope.ESC + prefix + (lines < 0 ? 'A' : 'B')
}
export function buildMouseWheelSequence(lines: number, clientX: number, clientY: number) {
const cell = viewportToMouseReportCell(clientX, clientY)
if (!cell) {
return ''
}
const eventCode = lines < 0 ? 64 : 65
if (scope.sgrMousePixelsMode) {
if (!isSafeSgrMouseCoordinate(cell.x) || !isSafeSgrMouseCoordinate(cell.y)) {
return ''
}
return scope.ESC + '[<' + eventCode + ';' + cell.x + ';' + cell.y + 'M'
}
if (scope.sgrMouseMode) {
// Why: xterm increments zero-based mouse cells before encoding reports.
const sgrCol = cell.col + 1
const sgrRow = cell.row + 1
if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) {
return ''
}
return scope.ESC + '[<' + eventCode + ';' + sgrCol + ';' + sgrRow + 'M'
}
// Why: xterm increments zero-based mouse cells before encoding reports.
const button = eventCode + 32
const col = cell.col + 1 + 32
const row = cell.row + 1 + 32
// Why: non-SGR mouse bytes above ASCII are not preserved reliably through
// the mobile JSON/RPC string path. Fall back to keys for wide terminals.
if (button > 126 || col > 126 || row > 126) {
return ''
}
return (
scope.ESC +
'[M' +
String.fromCharCode(button) +
String.fromCharCode(col) +
String.fromCharCode(row)
)
}
export function isSafeSgrMouseCoordinate(value: number) {
return Number.isInteger(value) && value >= 0 && value <= 9999
}
export function buildMouseClickInput(clientX: number, clientY: number) {
const mouseTrackingMode = getMouseTrackingMode()
if (!isClickMouseTrackingMode(mouseTrackingMode)) {
return ''
}
const cell = viewportToMouseReportCell(clientX, clientY)
if (!cell) {
return ''
}
if (scope.sgrMousePixelsMode) {
// Why: xterm 1016 keeps SGR syntax but reports raw zero-based pixel positions.
const pixelX = cell.x
const pixelY = cell.y
if (!isSafeSgrMouseCoordinate(pixelX) || !isSafeSgrMouseCoordinate(pixelY)) {
return ''
}
const pixelPress = scope.ESC + '[<0;' + pixelX + ';' + pixelY + 'M'
if (mouseTrackingMode === 'x10') {
return pixelPress
}
return pixelPress + scope.ESC + '[<0;' + pixelX + ';' + pixelY + 'm'
}
if (scope.sgrMouseMode) {
// Why: xterm increments zero-based mouse cells before encoding reports.
const sgrCol = cell.col + 1
const sgrRow = cell.row + 1
if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) {
return ''
}
const sgrPress = scope.ESC + '[<0;' + sgrCol + ';' + sgrRow + 'M'
if (mouseTrackingMode === 'x10') {
return sgrPress
}
return sgrPress + scope.ESC + '[<0;' + sgrCol + ';' + sgrRow + 'm'
}
// Why: non-SGR click coordinates use printable ASCII bytes on the mobile
// bridge; unsafe wide-terminal cells must not turn into corrupted input.
const col = cell.col + 1 + 32
const row = cell.row + 1 + 32
if (col > 126 || row > 126) {
return ''
}
const press =
scope.ESC + '[M' + String.fromCharCode(32) + String.fromCharCode(col) + String.fromCharCode(row)
if (mouseTrackingMode === 'x10') {
return press
}
return (
press +
scope.ESC +
'[M' +
String.fromCharCode(35) +
String.fromCharCode(col) +
String.fromCharCode(row)
)
}
export function isClickMouseTrackingMode(mode: string) {
return mode !== 'none'
}
export function isWheelMouseTrackingMode(mode: string) {
return mode !== 'none' && mode !== 'x10'
}
export function shouldRouteScrollToTerminalInput() {
return isWheelMouseTrackingMode(getMouseTrackingMode()) || isAlternateBufferActive()
}
export function buildMouseWheelScrollInput(lines: number, clientX: number, clientY: number) {
const count = Math.min(Math.abs(lines), 32)
if (count === 0) {
return ''
}
const sequence = buildMouseWheelSequence(lines, clientX, clientY)
if (!sequence) {
return ''
}
return repeatSequence(sequence, count)
}
export function buildTuiScrollInput(lines: number, clientX: number, clientY: number) {
const count = Math.min(Math.abs(lines), 32)
if (count === 0) {
return ''
}
const mouseTrackingMode = getMouseTrackingMode()
let sequence = ''
if (isWheelMouseTrackingMode(mouseTrackingMode)) {
sequence = buildMouseWheelSequence(lines, clientX, clientY)
}
if (!sequence) {
sequence = buildArrowScrollSequence(lines)
}
return repeatSequence(sequence, count)
}
export function routeScrollLines(lines: number, clientX: number, clientY: number) {
if (!scope.term || lines === 0) {
return
}
const mouseTrackingMode = getMouseTrackingMode()
const alternateBufferActive = isAlternateBufferActive()
if (isWheelMouseTrackingMode(mouseTrackingMode)) {
// Why: xterm sends wheel events to mouse-aware TUIs before considering
// scrollback, even if the app stays on the normal buffer.
const mouseInput = buildMouseWheelScrollInput(lines, clientX, clientY)
if (mouseInput) {
notify({ type: 'terminal-input', bytes: mouseInput })
return
}
// Why: default mouse encoding can be unrepresentable in our ASCII-safe
// RPC path on wide terminals. Send bounded arrows instead of local
// scrollback/no-op while a mouse-aware app owns scroll gestures.
const fallbackInput = buildTuiScrollInput(lines, clientX, clientY)
if (fallbackInput) {
notify({ type: 'terminal-input', bytes: fallbackInput })
}
return
}
if (alternateBufferActive) {
// Why: alternate-screen TUIs own their scroll state and xterm has no
// scrollback there, so mobile scroll gestures must become terminal input.
const input = buildTuiScrollInput(lines, clientX, clientY)
if (input) {
notify({ type: 'terminal-input', bytes: input })
}
return
}
scope.term.scrollLines(lines)
}
@@ -0,0 +1,37 @@
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { emitTerminalDocumentModule } from '../../../scripts/build-terminal-document-script.mjs'
import { TERMINAL_HTML_MOUSE_REPORT_AND_SCROLL_ROUTING } from '../terminal-webview-html/mouse-report-and-scroll-routing'
import { compareTerminalDocumentScripts } from './terminal-document-equivalence.test-support'
/** The slice wraps the already-extracted mouse-report-cell group between its two halves. */
const modulePaths = [
'./viewport-cell.ts',
'./mouse-report-cell.ts',
'./mouse-input-encoding.ts'
].map((relative) => fileURLToPath(new URL(relative, import.meta.url)))
describe('the mouse-report and scroll-routing slice', () => {
it('emits the script the document carries, modulo the six normalisations', async () => {
const emitted = (await Promise.all(modulePaths.map(emitTerminalDocumentModule))).join('\n')
expect(
compareTerminalDocumentScripts(
TERMINAL_HTML_MOUSE_REPORT_AND_SCROLL_ROUTING,
emitted,
'scope'
)
).toEqual({
equivalent: true,
normalisations: {
qualifiedReferences: 49,
scopeFieldDeclarations: 0,
rebindings: 49,
bracedBodies: 42,
// Three reads of xterm's mode state, each of which may not exist.
unboundCatches: 3,
numberProperties: 0,
shorthandProperties: 0
}
})
})
})
+1 -1
View File
@@ -1,5 +1,5 @@
import { cellColToStringIndex, getLineText } from './cell-geometry'
import { viewportToCell } from './document-externals'
import { viewportToCell } from './viewport-cell'
import {
scope,
type TerminalDocumentLine,
+1 -1
View File
@@ -1,5 +1,5 @@
import { cellColToStringIndex, getLineText } from './cell-geometry'
import { viewportToCell } from './document-externals'
import { viewportToCell } from './viewport-cell'
/**
* File-path-under-tap detection.
+1 -1
View File
@@ -1,5 +1,5 @@
import { applyFitScale } from './fit-scale'
import { isAlternateBufferActive } from './document-externals'
import { isAlternateBufferActive } from './mouse-input-encoding'
import { updateScrollIndicator } from './viewport-transform'
import { emitKeyboardAvoidanceMetrics } from './keyboard-avoidance-metrics'
import { scope } from './document-scope'
+3 -3
View File
@@ -1,9 +1,9 @@
import { notify } from './document-externals'
import {
buildMouseClickInput,
getMouseTrackingMode,
isClickMouseTrackingMode,
notify
} from './document-externals'
isClickMouseTrackingMode
} from './mouse-input-encoding'
import { oscLinkAtViewportPoint, resolveTerminalFileUrlTap } from './osc-link-tap'
import { filePathAtViewportPoint } from './path-tap'
import { fileUrlAtViewportPoint, urlAtViewportPoint } from './url-tap'
+2 -2
View File
@@ -3,9 +3,9 @@ import {
enterSelect,
handleDragMove,
notify,
stopEdgeScroll,
viewportToCell
stopEdgeScroll
} from './document-externals'
import { viewportToCell } from './viewport-cell'
import { scope } from './document-scope'
import { notifyTerminalSurfaceTap } from './surface-tap'
+1 -1
View File
@@ -1,5 +1,5 @@
import { cellColToStringIndex, getLineText } from './cell-geometry'
import { viewportToCell } from './document-externals'
import { viewportToCell } from './viewport-cell'
import {
terminalFileUrlRegexSource,
terminalHttpUrlMaxLength,
@@ -0,0 +1,36 @@
import { scope } from './document-scope'
import { getCellHeight } from './fit-scale'
import { getCellWidth, getTotalScale } from './viewport-transform'
export function viewportToCell(clientX: number, clientY: number) {
if (!scope.term) {
return null
}
const cellW = getCellWidth()
const cellH = getCellHeight()
if (cellW <= 0 || cellH <= 0) {
return null
}
let total = getTotalScale()
if (total <= 0) {
total = 1
}
const sx = (clientX - scope.panX) / total
const sy = (clientY - scope.panY) / total
let col = Math.floor(sx / cellW)
let viewportRow = Math.floor(sy / cellH)
if (col < 0) {
col = 0
}
if (col > scope.term.cols - 1) {
col = scope.term.cols - 1
}
if (viewportRow < 0) {
viewportRow = 0
}
if (viewportRow > scope.term.rows - 1) {
viewportRow = scope.term.rows - 1
}
const viewportY = scope.term.buffer.active.viewportY
return { col: col, row: viewportRow + viewportY }
}
@@ -1,5 +1,6 @@
import { terminalDefaultTheme } from './document-constants'
import { repositionOverlay, shouldRouteScrollToTerminalInput } from './document-externals'
import { repositionOverlay } from './document-externals'
import { shouldRouteScrollToTerminalInput } from './mouse-input-encoding'
import { scope } from './document-scope'
import { scrollIndicator, scrollThumb } from './text-scaling'
+1 -1
View File
@@ -1,5 +1,5 @@
import { getCellHeight } from './fit-scale'
import { routeScrollLines, shouldRouteScrollToTerminalInput } from './document-externals'
import { routeScrollLines, shouldRouteScrollToTerminalInput } from './mouse-input-encoding'
import { getTotalScale } from './viewport-transform'
import { dispatcherShouldBlockSurface } from './tap-dispatch'
import {