mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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,5 +1,5 @@
|
||||
import { cellColToStringIndex, getLineText } from './cell-geometry'
|
||||
import { viewportToCell } from './document-externals'
|
||||
import { viewportToCell } from './viewport-cell'
|
||||
import {
|
||||
scope,
|
||||
type TerminalDocumentLine,
|
||||
|
||||
@@ -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,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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,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,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 {
|
||||
|
||||
Reference in New Issue
Block a user