From b64ec213df03e6f42de2dfc442a9fb8560d75f39 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sun, 20 Sep 2026 09:49:08 -0400 Subject: [PATCH] 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 --- .../terminal/document/document-externals.ts | 27 -- .../src/terminal/document/document-scope.ts | 6 +- .../src/terminal/document/mode-mirroring.ts | 3 +- .../src/terminal/document/mouse-click-drag.ts | 7 +- .../terminal/document/mouse-input-encoding.ts | 230 ++++++++++++++++++ .../mouse-report-and-scroll-routing.test.ts | 37 +++ mobile/src/terminal/document/osc-link-tap.ts | 2 +- mobile/src/terminal/document/path-tap.ts | 2 +- mobile/src/terminal/document/reflow.ts | 2 +- mobile/src/terminal/document/surface-tap.ts | 6 +- mobile/src/terminal/document/tap-dispatch.ts | 4 +- mobile/src/terminal/document/url-tap.ts | 2 +- mobile/src/terminal/document/viewport-cell.ts | 36 +++ .../terminal/document/viewport-transform.ts | 3 +- mobile/src/terminal/document/wheel-scroll.ts | 2 +- 15 files changed, 325 insertions(+), 44 deletions(-) create mode 100644 mobile/src/terminal/document/mouse-input-encoding.ts create mode 100644 mobile/src/terminal/document/mouse-report-and-scroll-routing.test.ts create mode 100644 mobile/src/terminal/document/viewport-cell.ts diff --git a/mobile/src/terminal/document/document-externals.ts b/mobile/src/terminal/document/document-externals.ts index 1aa93159c23..58e97a4b61b 100644 --- a/mobile/src/terminal/document/document-externals.ts +++ b/mobile/src/terminal/document/document-externals.ts @@ -12,24 +12,9 @@ /** `runtime-state-and-text-scaling`: posts one message to the native host. */ export declare function notify(message: Record): 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 diff --git a/mobile/src/terminal/document/document-scope.ts b/mobile/src/terminal/document/document-scope.ts index 80314077ae7..f546defe3d7 100644 --- a/mobile/src/terminal/document/document-scope.ts +++ b/mobile/src/terminal/document/document-scope.ts @@ -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 diff --git a/mobile/src/terminal/document/mode-mirroring.ts b/mobile/src/terminal/document/mode-mirroring.ts index 8fb60e73e41..a9f6f740808 100644 --- a/mobile/src/terminal/document/mode-mirroring.ts +++ b/mobile/src/terminal/document/mode-mirroring.ts @@ -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() { diff --git a/mobile/src/terminal/document/mouse-click-drag.ts b/mobile/src/terminal/document/mouse-click-drag.ts index 2b4b41e9d63..6b0a3d5016c 100644 --- a/mobile/src/terminal/document/mouse-click-drag.ts +++ b/mobile/src/terminal/document/mouse-click-drag.ts @@ -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' diff --git a/mobile/src/terminal/document/mouse-input-encoding.ts b/mobile/src/terminal/document/mouse-input-encoding.ts new file mode 100644 index 00000000000..9c314c6eaf5 --- /dev/null +++ b/mobile/src/terminal/document/mouse-input-encoding.ts @@ -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) +} diff --git a/mobile/src/terminal/document/mouse-report-and-scroll-routing.test.ts b/mobile/src/terminal/document/mouse-report-and-scroll-routing.test.ts new file mode 100644 index 00000000000..bd31d3b0c24 --- /dev/null +++ b/mobile/src/terminal/document/mouse-report-and-scroll-routing.test.ts @@ -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 + } + }) + }) +}) diff --git a/mobile/src/terminal/document/osc-link-tap.ts b/mobile/src/terminal/document/osc-link-tap.ts index 280ad32181c..2c618563594 100644 --- a/mobile/src/terminal/document/osc-link-tap.ts +++ b/mobile/src/terminal/document/osc-link-tap.ts @@ -1,5 +1,5 @@ import { cellColToStringIndex, getLineText } from './cell-geometry' -import { viewportToCell } from './document-externals' +import { viewportToCell } from './viewport-cell' import { scope, type TerminalDocumentLine, diff --git a/mobile/src/terminal/document/path-tap.ts b/mobile/src/terminal/document/path-tap.ts index ae9b97dba84..42b0f25b839 100644 --- a/mobile/src/terminal/document/path-tap.ts +++ b/mobile/src/terminal/document/path-tap.ts @@ -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. diff --git a/mobile/src/terminal/document/reflow.ts b/mobile/src/terminal/document/reflow.ts index c1299a3c00a..d4f031557e3 100644 --- a/mobile/src/terminal/document/reflow.ts +++ b/mobile/src/terminal/document/reflow.ts @@ -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' diff --git a/mobile/src/terminal/document/surface-tap.ts b/mobile/src/terminal/document/surface-tap.ts index b5978fc0aca..efb7bbaa9b3 100644 --- a/mobile/src/terminal/document/surface-tap.ts +++ b/mobile/src/terminal/document/surface-tap.ts @@ -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' diff --git a/mobile/src/terminal/document/tap-dispatch.ts b/mobile/src/terminal/document/tap-dispatch.ts index 361351007b8..f1aec1efe1d 100644 --- a/mobile/src/terminal/document/tap-dispatch.ts +++ b/mobile/src/terminal/document/tap-dispatch.ts @@ -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' diff --git a/mobile/src/terminal/document/url-tap.ts b/mobile/src/terminal/document/url-tap.ts index 023d2cc1980..363f87a5e18 100644 --- a/mobile/src/terminal/document/url-tap.ts +++ b/mobile/src/terminal/document/url-tap.ts @@ -1,5 +1,5 @@ import { cellColToStringIndex, getLineText } from './cell-geometry' -import { viewportToCell } from './document-externals' +import { viewportToCell } from './viewport-cell' import { terminalFileUrlRegexSource, terminalHttpUrlMaxLength, diff --git a/mobile/src/terminal/document/viewport-cell.ts b/mobile/src/terminal/document/viewport-cell.ts new file mode 100644 index 00000000000..32ad1076451 --- /dev/null +++ b/mobile/src/terminal/document/viewport-cell.ts @@ -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 } +} diff --git a/mobile/src/terminal/document/viewport-transform.ts b/mobile/src/terminal/document/viewport-transform.ts index 347897e3caf..2c936b419a9 100644 --- a/mobile/src/terminal/document/viewport-transform.ts +++ b/mobile/src/terminal/document/viewport-transform.ts @@ -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' diff --git a/mobile/src/terminal/document/wheel-scroll.ts b/mobile/src/terminal/document/wheel-scroll.ts index 4de56f73378..aa83f85a5fa 100644 --- a/mobile/src/terminal/document/wheel-scroll.ts +++ b/mobile/src/terminal/document/wheel-scroll.ts @@ -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 {