mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 16:03:26 +00:00
fix(mobile): route external mouse click and drag to the terminal
The terminal WebView suppresses mousedown/click at capture so xterm's own mouse handling stays inert (its onData bytes are dropped by the mobile bridge). That left hardware mouse clicks and drags with no path at all: touch taps reached mouse-aware TUIs and drove selection, while a Bluetooth mouse or trackpad click did nothing (#8818; wheel half landed in #11247). Add a pointer-event router on the terminal surface (pointerType 'mouse', left button only) that mirrors touch semantics: - plain click: same pipeline as a touch tap (links/file paths first, then tracking-mode press+release reports, else keyboard focus), and a click on an active selection dismisses it like touch does - drag with mouse tracking: press at the anchor, per-cell motion reports (drag/any modes), release on pointerup or pointercancel - drag without tracking: character-anchored selection reusing the touch handle-drag plumbing (edge scroll, handles, copy pill) Widen the RN gesture-input grammar to pass left-drag motion reports (SGR button 32, default-encoding byte 64) through the existing validation and rate limiting. Mock server: echo the subscribe viewport and serialize scrollback so the session screen leaves the resubscribe loop, serve the session-tabs subscribe stream, and add a MOCK_TUI=1 mouse-tracking scenario plus a [SEND] byte log - the rig used to reproduce and verify this fix on an Android emulator. Fixes #8818
This commit is contained in:
@@ -13,11 +13,7 @@ import { handleMockGitRequest } from './mock-server-git-state'
|
||||
import { handleMockAccountRequest } from './mock-server-account-rpc'
|
||||
import { handleMockNativeChatRequest } from './mock-server-native-chat-scenario'
|
||||
import { handleMockSessionTabsRequest } from './mock-server-session-tabs-fixture'
|
||||
import {
|
||||
createMockTerminals,
|
||||
FAKE_SCROLLBACK,
|
||||
STREAMING_CHUNKS
|
||||
} from './mock-server-terminal-fixtures'
|
||||
import { handleMockTerminalRequest } from './mock-server-terminal-stream'
|
||||
import { createMockRepos, createMockWorktrees, readScenarioNumber } from './mobile-lag-scenario'
|
||||
|
||||
const MOCK_REPO_COUNT = readScenarioNumber('MOCK_REPO_COUNT', 2)
|
||||
@@ -126,7 +122,8 @@ export function handleRequest(
|
||||
handleMockFilePreviewRequest(request, respond, success, error) ||
|
||||
handleMockAccountRequest(request, respond, success, error) ||
|
||||
handleMockNativeChatRequest(request, respond, success, error, ws) ||
|
||||
handleMockSessionTabsRequest(request, respond, success, terminalListWorktreeId)
|
||||
handleMockSessionTabsRequest(request, respond, success, terminalListWorktreeId) ||
|
||||
handleMockTerminalRequest(request, respond, success, ws, terminalListWorktreeId)
|
||||
) {
|
||||
return
|
||||
}
|
||||
@@ -279,44 +276,6 @@ export function handleRequest(
|
||||
break
|
||||
}
|
||||
|
||||
case 'terminal.list': {
|
||||
const terminals = createMockTerminals(terminalListWorktreeId(request.params?.worktree))
|
||||
respond(
|
||||
success(request.id, {
|
||||
terminals,
|
||||
totalCount: terminals.length,
|
||||
truncated: false
|
||||
})
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
case 'terminal.subscribe': {
|
||||
respond(success(request.id, { type: 'scrollback', lines: FAKE_SCROLLBACK, truncated: false }))
|
||||
|
||||
let chunkIndex = 0
|
||||
const interval = setInterval(() => {
|
||||
if (chunkIndex >= STREAMING_CHUNKS.length || ws.readyState !== ws.OPEN) {
|
||||
clearInterval(interval)
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
respond(success(request.id, { type: 'end' }))
|
||||
}
|
||||
return
|
||||
}
|
||||
respond(success(request.id, { type: 'data', chunk: STREAMING_CHUNKS[chunkIndex] }, true))
|
||||
chunkIndex++
|
||||
}, 500)
|
||||
break
|
||||
}
|
||||
|
||||
case 'terminal.send':
|
||||
respond(success(request.id, { send: { handle: 'term-1', ok: true } }))
|
||||
break
|
||||
|
||||
case 'terminal.unsubscribe':
|
||||
respond(success(request.id, { unsubscribed: true }))
|
||||
break
|
||||
|
||||
case 'files.open':
|
||||
case 'files.openDiff':
|
||||
respond(
|
||||
|
||||
@@ -60,10 +60,24 @@ export function handleMockSessionTabsRequest(
|
||||
// absent or `id:`-prefixed selector means.
|
||||
resolveWorktreeId: (selector: unknown) => string | undefined
|
||||
): boolean {
|
||||
if (request.method !== 'session.tabs.list') {
|
||||
return false
|
||||
if (request.method === 'session.tabs.list') {
|
||||
const worktreeId = resolveWorktreeId(request.params?.worktree) ?? 'mock'
|
||||
respond(success(request.id, createMockSessionTabs(worktreeId)))
|
||||
return true
|
||||
}
|
||||
const worktreeId = resolveWorktreeId(request.params?.worktree) ?? 'mock'
|
||||
respond(success(request.id, createMockSessionTabs(worktreeId)))
|
||||
return true
|
||||
if (request.method === 'session.tabs.subscribe') {
|
||||
// Without a live stream the client's health loop keeps invalidating list
|
||||
// fetches mid-flight (barrier bump on the failed probe), so the session
|
||||
// screen never leaves 'Loading tabs'. snapshot then updated => 'live'.
|
||||
const worktreeId = resolveWorktreeId(request.params?.worktree) ?? 'mock'
|
||||
const snapshot = createMockSessionTabs(worktreeId)
|
||||
respond(success(request.id, { type: 'snapshot', ...snapshot }, true))
|
||||
respond(success(request.id, { type: 'updated', ...snapshot }, true))
|
||||
return true
|
||||
}
|
||||
if (request.method === 'session.tabs.unsubscribe') {
|
||||
respond(success(request.id, { unsubscribed: true }))
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { WebSocket } from 'ws'
|
||||
import type { RpcRequest, RpcResponse } from './mock-server-rpc-handlers'
|
||||
import {
|
||||
createMockTerminals,
|
||||
FAKE_SCROLLBACK,
|
||||
STREAMING_CHUNKS
|
||||
} from './mock-server-terminal-fixtures'
|
||||
|
||||
/** Terminal list/stream/input backend for the mock server. Returns false for
|
||||
* methods it does not own. */
|
||||
export function handleMockTerminalRequest(
|
||||
request: RpcRequest,
|
||||
respond: (response: RpcResponse) => void,
|
||||
success: (id: string, result: unknown, streaming?: boolean) => RpcResponse,
|
||||
ws: WebSocket,
|
||||
// Shared with `session.tabs.list` so both surfaces agree on which worktree an
|
||||
// absent or `id:`-prefixed selector means.
|
||||
resolveWorktreeId: (selector: unknown) => string | undefined
|
||||
): boolean {
|
||||
switch (request.method) {
|
||||
case 'terminal.list': {
|
||||
const terminals = createMockTerminals(resolveWorktreeId(request.params?.worktree))
|
||||
respond(
|
||||
success(request.id, {
|
||||
terminals,
|
||||
totalCount: terminals.length,
|
||||
truncated: false
|
||||
})
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
case 'terminal.subscribe': {
|
||||
// Why: the client resubscribes until scrollback echoes its viewport dims;
|
||||
// the legacy `lines` shape left the session screen in that loop forever.
|
||||
const viewport = request.params?.viewport as { cols?: number; rows?: number } | undefined
|
||||
// MOCK_TUI=1 arms SGR drag mouse tracking (1002/1006) inside the scrollback
|
||||
// itself so every xterm re-init re-enters the mode - used by mouse/touch
|
||||
// input repros (#8818).
|
||||
const tuiPreamble =
|
||||
process.env.MOCK_TUI === '1'
|
||||
? '\x1b[?1002h\x1b[?1006h[mock] mouse tracking ON (1002/1006)\r\n'
|
||||
: ''
|
||||
respond(
|
||||
success(request.id, {
|
||||
type: 'scrollback',
|
||||
cols: viewport?.cols ?? 80,
|
||||
rows: viewport?.rows ?? 24,
|
||||
serialized: FAKE_SCROLLBACK.replace(/\n/g, '\r\n') + tuiPreamble,
|
||||
truncated: false
|
||||
})
|
||||
)
|
||||
|
||||
let chunkIndex = 0
|
||||
const interval = setInterval(() => {
|
||||
if (chunkIndex >= STREAMING_CHUNKS.length || ws.readyState !== ws.OPEN) {
|
||||
// Why: no `end` event - a live terminal stream stays open, and `end`
|
||||
// makes the client tear the subscription down and blank the pane.
|
||||
clearInterval(interval)
|
||||
return
|
||||
}
|
||||
respond(success(request.id, { type: 'data', chunk: STREAMING_CHUNKS[chunkIndex] }, true))
|
||||
chunkIndex++
|
||||
}, 500)
|
||||
return true
|
||||
}
|
||||
|
||||
case 'terminal.send':
|
||||
// Input-routing repros (#8818) assert on the exact bytes reaching the host.
|
||||
console.log(
|
||||
`[SEND] terminal=${String(request.params?.terminal)} text=${JSON.stringify(request.params?.text)}`
|
||||
)
|
||||
respond(success(request.id, { send: { handle: 'term-1', ok: true } }))
|
||||
return true
|
||||
|
||||
case 'terminal.unsubscribe':
|
||||
respond(success(request.id, { unsubscribed: true }))
|
||||
return true
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,22 @@ describe('isTerminalGestureInput', () => {
|
||||
expect(isTerminalGestureInput(`${ESC}[<0;9999;9999M${ESC}[<0;9999;9999m`)).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts SGR left-drag motion sequences but rejects a motion release', () => {
|
||||
expect(isTerminalGestureInput(`${ESC}[<32;10;5M${ESC}[<32;11;5M`)).toBe(true)
|
||||
expect(countTerminalGestureInputSequences(`${ESC}[<32;10;5M${ESC}[<32;11;5M`)).toBe(2)
|
||||
expect(isTerminalGestureInput(`${ESC}[<0;10;5M${ESC}[<32;11;5M${ESC}[<0;11;5m`)).toBe(true)
|
||||
expect(isTerminalGestureInput(`${ESC}[<32;10;5m`)).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts default-encoding left-drag motion sequences', () => {
|
||||
expect(isTerminalGestureInput(`${ESC}[M${String.fromCharCode(64, 43, 38)}`)).toBe(true)
|
||||
expect(
|
||||
isTerminalGestureInput(
|
||||
`${ESC}[M${String.fromCharCode(32, 33, 33)}${ESC}[M${String.fromCharCode(64, 34, 33)}${ESC}[M${String.fromCharCode(35, 34, 33)}`
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts repeated default mouse wheel sequences', () => {
|
||||
expect(
|
||||
isTerminalGestureInput(
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
const ESC = '\x1b'
|
||||
const MAX_TERMINAL_GESTURE_INPUT_LENGTH = 2048
|
||||
const MAX_TERMINAL_GESTURE_INPUT_SEQUENCES = 32
|
||||
// Buttons: 0 left press/release, 32 left-drag motion, 64/65 wheel.
|
||||
const SGR_MOUSE_GESTURE_SEQUENCE_RE = new RegExp(
|
||||
`^${ESC}\\[<(0|64|65);([0-9]{1,4});([0-9]{1,4})([Mm])$`
|
||||
`^${ESC}\\[<(0|32|64|65);([0-9]{1,4});([0-9]{1,4})([Mm])$`
|
||||
)
|
||||
|
||||
function isDefaultMouseGestureSequence(bytes: string, offset: number): number | null {
|
||||
@@ -12,8 +13,9 @@ function isDefaultMouseGestureSequence(bytes: string, offset: number): number |
|
||||
const button = bytes.charCodeAt(offset + 3)
|
||||
const col = bytes.charCodeAt(offset + 4)
|
||||
const row = bytes.charCodeAt(offset + 5)
|
||||
// Buttons: 32 left press, 35 release, 64 left-drag motion, 96/97 wheel.
|
||||
if (
|
||||
(button === 32 || button === 35 || button === 96 || button === 97) &&
|
||||
(button === 32 || button === 35 || button === 64 || button === 96 || button === 97) &&
|
||||
col >= 33 &&
|
||||
col <= 126 &&
|
||||
row >= 33 &&
|
||||
|
||||
@@ -11,6 +11,7 @@ import { TERMINAL_WEBVIEW_THEME_JS } from './terminal-webview-theme-injected'
|
||||
import { TERMINAL_QUERY_REPLY_JS } from './terminal-webview-query-reply-injected'
|
||||
import { URL_TAP_WEBVIEW_JS } from './terminal-webview-url-tap'
|
||||
import { TERMINAL_WEBGL_RECOVERY_JS } from './terminal-webview-webgl-recovery-injected'
|
||||
import { TERMINAL_MOUSE_CLICK_DRAG_JS } from './terminal-webview-mouse-click-drag-injected'
|
||||
import { TERMINAL_WHEEL_SCROLL_JS } from './terminal-webview-wheel-scroll-injected'
|
||||
|
||||
const DEFAULT_TERMINAL_THEME: RuntimeMobileTerminalTheme['theme'] = {
|
||||
@@ -1640,6 +1641,10 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
|
||||
// terminal-webview-wheel-scroll-injected.ts (extracted for max-lines).
|
||||
${TERMINAL_WHEEL_SCROLL_JS}
|
||||
|
||||
// External mouse click/drag: see
|
||||
// terminal-webview-mouse-click-drag-injected.ts (extracted for max-lines).
|
||||
${TERMINAL_MOUSE_CLICK_DRAG_JS}
|
||||
|
||||
btnCopy.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -1697,6 +1702,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
|
||||
targetSurface.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); }, true);
|
||||
|
||||
attachSurfaceWheelHandler(targetSurface);
|
||||
attachSurfaceMouseClickDragHandler(targetSurface);
|
||||
|
||||
targetSurface.addEventListener('touchstart', function(e) {
|
||||
if (dispatcherShouldBlockSurface()) return;
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// Indirect-pointer (external mouse / trackpad) click and drag for the terminal
|
||||
// surface, injected into XTERM_HTML. Extracted from terminal-webview-html.ts to
|
||||
// keep that file within its max-lines budget. Companion to
|
||||
// terminal-webview-wheel-scroll-injected.ts, which owns the wheel half (#11247);
|
||||
// this owns the click/drag half of #8818. Closes over host-IIFE state/functions:
|
||||
// term, ESC, sel, selMode, selectionOverlay, TAP_SLOP, getMouseTrackingMode,
|
||||
// viewportToCell, viewportToMouseReportCell, isSafeSgrMouseCoordinate,
|
||||
// sgrMouseMode, sgrMousePixelsMode, notify, notifyTerminalSurfaceTap,
|
||||
// cancelSelect, applyXtermSelection, repositionOverlay, handleDragMove,
|
||||
// stopEdgeScroll, and dispatcherShouldBlockSurface.
|
||||
//
|
||||
// Why pointer events: a hardware mouse on Android/iPadOS raises pointer events
|
||||
// with pointerType 'mouse' and NO touch events, while a finger raises
|
||||
// pointerType 'touch' plus the touch events the document dispatcher owns. The
|
||||
// capture-phase mousedown/click suppression in attachSurfaceEventHandlers stays:
|
||||
// it is what keeps xterm's own mouse handling inert (its onData output is
|
||||
// dropped by the mobile bridge), and pointer events are unaffected by it.
|
||||
export const TERMINAL_MOUSE_CLICK_DRAG_JS = `
|
||||
var mouseGesture = null;
|
||||
|
||||
// One report per transition, built with the same encoding ladder as
|
||||
// buildMouseClickInput: SGR pixels (1016) > SGR (1006) > default. Returns ''
|
||||
// when the mode does not report this transition (x10 has no release, only
|
||||
// drag/any report motion) or the cell is not encodable.
|
||||
function buildMouseButtonReport(kind, clientX, clientY) {
|
||||
var mouseTrackingMode = getMouseTrackingMode();
|
||||
if (mouseTrackingMode === 'none') return '';
|
||||
if (kind === 'motion' && mouseTrackingMode !== 'drag' && mouseTrackingMode !== 'any') return '';
|
||||
if (kind === 'release' && mouseTrackingMode === 'x10') return '';
|
||||
var cell = viewportToMouseReportCell(clientX, clientY);
|
||||
if (!cell) return '';
|
||||
var sgrButton = kind === 'motion' ? 32 : 0;
|
||||
var sgrFinal = kind === 'release' ? 'm' : 'M';
|
||||
if (sgrMousePixelsMode) {
|
||||
if (!isSafeSgrMouseCoordinate(cell.x) || !isSafeSgrMouseCoordinate(cell.y)) return '';
|
||||
return ESC + '[<' + sgrButton + ';' + cell.x + ';' + cell.y + sgrFinal;
|
||||
}
|
||||
if (sgrMouseMode) {
|
||||
// Why: xterm increments zero-based mouse cells before encoding reports.
|
||||
var sgrCol = cell.col + 1;
|
||||
var sgrRow = cell.row + 1;
|
||||
if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return '';
|
||||
return ESC + '[<' + sgrButton + ';' + sgrCol + ';' + sgrRow + sgrFinal;
|
||||
}
|
||||
var button = kind === 'motion' ? 64 : kind === 'release' ? 35 : 32;
|
||||
var col = cell.col + 1 + 32;
|
||||
var row = cell.row + 1 + 32;
|
||||
// Why: non-SGR mouse bytes above ASCII are not preserved reliably through
|
||||
// the mobile JSON/RPC string path; drop instead of corrupting input.
|
||||
if (col > 126 || row > 126) return '';
|
||||
return ESC + '[M' + String.fromCharCode(button) + String.fromCharCode(col) + String.fromCharCode(row);
|
||||
}
|
||||
|
||||
function mouseReportCellKey(clientX, clientY) {
|
||||
var cell = viewportToMouseReportCell(clientX, clientY);
|
||||
return cell ? cell.col + ',' + cell.row : null;
|
||||
}
|
||||
|
||||
function abandonMouseGesture() {
|
||||
var gesture = mouseGesture;
|
||||
mouseGesture = null;
|
||||
if (!gesture) return;
|
||||
if (gesture.mode === 'tracking') {
|
||||
// Why: the press report already went to the TUI; a lost pointer must not
|
||||
// leave the button latched down on the far side.
|
||||
var release = buildMouseButtonReport('release', gesture.lastX, gesture.lastY);
|
||||
if (release) notify({ type: 'terminal-input', bytes: release });
|
||||
} else if (gesture.mode === 'selecting') {
|
||||
if (sel) sel.activeHandle = null;
|
||||
stopEdgeScroll();
|
||||
}
|
||||
}
|
||||
|
||||
function beginMouseDrag(gesture) {
|
||||
gesture.moved = true;
|
||||
if (getMouseTrackingMode() !== 'none') {
|
||||
gesture.mode = 'tracking';
|
||||
gesture.lastCellKey = mouseReportCellKey(gesture.startX, gesture.startY);
|
||||
var press = buildMouseButtonReport('press', gesture.startX, gesture.startY);
|
||||
if (press) notify({ type: 'terminal-input', bytes: press });
|
||||
return;
|
||||
}
|
||||
var anchor = viewportToCell(gesture.startX, gesture.startY);
|
||||
if (!anchor) {
|
||||
gesture.mode = 'cancelled';
|
||||
return;
|
||||
}
|
||||
// Why: mouse drags select character-anchored ranges like desktop terminals,
|
||||
// not the word-seeded long-press selection; reuse the touch handle-drag
|
||||
// plumbing (edge scroll included) by acting as a live 'end' handle.
|
||||
gesture.mode = 'selecting';
|
||||
selMode = 'select';
|
||||
sel = { anchor: anchor, focus: anchor, activeHandle: 'end' };
|
||||
selectionOverlay.classList.add('active');
|
||||
notify({ type: 'set-select-mode', enabled: true });
|
||||
applyXtermSelection();
|
||||
repositionOverlay();
|
||||
}
|
||||
|
||||
function attachSurfaceMouseClickDragHandler(targetSurface) {
|
||||
targetSurface.addEventListener('pointerdown', function(e) {
|
||||
if (e.pointerType !== 'mouse' || e.button !== 0) return;
|
||||
if (dispatcherShouldBlockSurface() || !term) return;
|
||||
mouseGesture = {
|
||||
startX: e.clientX, startY: e.clientY,
|
||||
lastX: e.clientX, lastY: e.clientY,
|
||||
lastCellKey: null,
|
||||
moved: false,
|
||||
mode: 'pending',
|
||||
dismissedSelection: false
|
||||
};
|
||||
if (selMode === 'select') {
|
||||
// Why: touch parity — pressing outside the pill dismisses the current
|
||||
// selection; the same press may still start a new drag selection.
|
||||
cancelSelect();
|
||||
mouseGesture.dismissedSelection = true;
|
||||
}
|
||||
}, true);
|
||||
|
||||
targetSurface.addEventListener('pointermove', function(e) {
|
||||
var gesture = mouseGesture;
|
||||
if (e.pointerType !== 'mouse' || !gesture || gesture.mode === 'cancelled') return;
|
||||
if (!term || (e.buttons & 1) === 0) return;
|
||||
gesture.lastX = e.clientX;
|
||||
gesture.lastY = e.clientY;
|
||||
if (!gesture.moved) {
|
||||
var dx = Math.abs(e.clientX - gesture.startX);
|
||||
var dy = Math.abs(e.clientY - gesture.startY);
|
||||
if (dx + dy <= TAP_SLOP) return;
|
||||
beginMouseDrag(gesture);
|
||||
}
|
||||
if (gesture.mode === 'tracking') {
|
||||
// Why: one motion report per cell keeps drags bounded by grid size, not
|
||||
// by pointermove cadence, so the RN rate limiter is never the bottleneck.
|
||||
var cellKey = mouseReportCellKey(e.clientX, e.clientY);
|
||||
if (cellKey && cellKey !== gesture.lastCellKey) {
|
||||
gesture.lastCellKey = cellKey;
|
||||
var motion = buildMouseButtonReport('motion', e.clientX, e.clientY);
|
||||
if (motion) notify({ type: 'terminal-input', bytes: motion });
|
||||
}
|
||||
} else if (gesture.mode === 'selecting') {
|
||||
handleDragMove('end', e.clientX, e.clientY);
|
||||
}
|
||||
}, true);
|
||||
|
||||
targetSurface.addEventListener('pointerup', function(e) {
|
||||
var gesture = mouseGesture;
|
||||
if (e.pointerType !== 'mouse' || !gesture || e.button !== 0) return;
|
||||
mouseGesture = null;
|
||||
if (gesture.mode === 'cancelled' || !term) return;
|
||||
if (gesture.mode === 'tracking') {
|
||||
var release = buildMouseButtonReport('release', e.clientX, e.clientY);
|
||||
if (release) notify({ type: 'terminal-input', bytes: release });
|
||||
return;
|
||||
}
|
||||
if (gesture.mode === 'selecting') {
|
||||
if (sel) sel.activeHandle = null;
|
||||
stopEdgeScroll();
|
||||
repositionOverlay();
|
||||
return;
|
||||
}
|
||||
if (dispatcherShouldBlockSurface()) return;
|
||||
// Why: a dismissing tap only clears the selection (touch parity); it must
|
||||
// not also open a link or focus the keyboard underneath.
|
||||
if (gesture.dismissedSelection) return;
|
||||
// Plain click == touch tap: links and file paths win, then tracking-mode
|
||||
// click reports, then keyboard focus.
|
||||
notifyTerminalSurfaceTap(e.clientX, e.clientY);
|
||||
}, true);
|
||||
|
||||
targetSurface.addEventListener('pointercancel', function(e) {
|
||||
if (e.pointerType !== 'mouse') return;
|
||||
abandonMouseGesture();
|
||||
}, true);
|
||||
|
||||
// Why: Android input injection can pair a mouse-flavored pointerdown with
|
||||
// real touch events (SOURCE_MOUSE + TOOL_TYPE_FINGER). If touch arrives,
|
||||
// the document touch dispatcher owns the gesture.
|
||||
targetSurface.addEventListener('touchstart', function() {
|
||||
if (mouseGesture) abandonMouseGesture();
|
||||
}, true);
|
||||
}
|
||||
`
|
||||
@@ -0,0 +1,354 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { XTERM_HTML } from './terminal-webview-html'
|
||||
|
||||
function iifeSource(): string {
|
||||
const start = XTERM_HTML.indexOf('(function() {')
|
||||
const end = XTERM_HTML.lastIndexOf('})();')
|
||||
return XTERM_HTML.slice(start, end + '})();'.length)
|
||||
}
|
||||
|
||||
function bodyMarkup(): string {
|
||||
const start = XTERM_HTML.indexOf('<body>') + '<body>'.length
|
||||
const end = XTERM_HTML.indexOf('<script>', start)
|
||||
return XTERM_HTML.slice(start, end)
|
||||
}
|
||||
|
||||
type BufferState = {
|
||||
baseY: number
|
||||
type: 'alternate' | 'normal'
|
||||
viewportY: number
|
||||
}
|
||||
|
||||
type TerminalStub = ReturnType<typeof makeTerminal>
|
||||
type RegisteredWindowListener = {
|
||||
listener: EventListenerOrEventListenerObject
|
||||
options?: boolean | AddEventListenerOptions
|
||||
type: string
|
||||
}
|
||||
|
||||
const ESC = '\u001b'
|
||||
const DEFAULT_MOUSE_REPORT_RE = new RegExp(`${ESC}\\[M[\\s\\S]{3}`, 'g')
|
||||
|
||||
function makeTerminal(
|
||||
buffer: BufferState,
|
||||
select: (col: number, row: number, len: number) => void
|
||||
) {
|
||||
const terminal = {
|
||||
cols: 40,
|
||||
rows: 24,
|
||||
options: { fontSize: 13 },
|
||||
modes: { mouseTrackingMode: 'none' as string },
|
||||
element: null as HTMLElement | null,
|
||||
_core: {
|
||||
_renderService: { dimensions: { css: { cell: { width: 8, height: 15 } } } }
|
||||
},
|
||||
buffer: {
|
||||
active: {
|
||||
get baseY() {
|
||||
return buffer.baseY
|
||||
},
|
||||
get type() {
|
||||
return buffer.type
|
||||
},
|
||||
get viewportY() {
|
||||
return buffer.viewportY
|
||||
},
|
||||
cursorY: 0,
|
||||
length: 1,
|
||||
getLine: () => null
|
||||
}
|
||||
},
|
||||
write(_data: string, callback?: () => void) {
|
||||
callback?.()
|
||||
},
|
||||
open(surface: HTMLElement) {
|
||||
terminal.element = surface
|
||||
},
|
||||
loadAddon() {},
|
||||
resize(cols: number, rows: number) {
|
||||
terminal.cols = cols
|
||||
terminal.rows = rows
|
||||
},
|
||||
clear() {},
|
||||
reset() {},
|
||||
refresh() {},
|
||||
selectAll() {},
|
||||
clearSelection() {},
|
||||
select,
|
||||
scrollLines() {},
|
||||
scrollToBottom() {},
|
||||
scrollToLine() {},
|
||||
attachCustomKeyEventHandler() {},
|
||||
getSelection: () => '',
|
||||
onData: () => ({ dispose() {} }),
|
||||
onLineFeed: () => ({ dispose() {} }),
|
||||
onScroll: () => ({ dispose() {} }),
|
||||
onWriteParsed: () => ({ dispose() {} }),
|
||||
dispose() {}
|
||||
}
|
||||
return terminal
|
||||
}
|
||||
|
||||
function surfaceEl(): HTMLElement {
|
||||
const surface = document.getElementById('terminal-surface')
|
||||
if (!surface) {
|
||||
throw new Error('terminal surface missing')
|
||||
}
|
||||
return surface
|
||||
}
|
||||
|
||||
function dispatchPointer(
|
||||
type: 'pointerdown' | 'pointermove' | 'pointerup' | 'pointercancel',
|
||||
init: {
|
||||
pointerType?: string
|
||||
x?: number
|
||||
y?: number
|
||||
button?: number
|
||||
buttons?: number
|
||||
} = {}
|
||||
): void {
|
||||
const event = new MouseEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: init.x ?? 40,
|
||||
clientY: init.y ?? 60,
|
||||
button: init.button ?? 0,
|
||||
buttons: init.buttons ?? 0
|
||||
})
|
||||
// Why: happy-dom's PointerEvent init drops pointerType; grafting it onto a
|
||||
// MouseEvent exercises the same duck-typed reads the WebView handler does.
|
||||
Object.defineProperty(event, 'pointerType', { value: init.pointerType ?? 'mouse' })
|
||||
surfaceEl().dispatchEvent(event)
|
||||
}
|
||||
|
||||
function mouseClick(x: number, y: number): void {
|
||||
dispatchPointer('pointerdown', { x, y, button: 0, buttons: 1 })
|
||||
dispatchPointer('pointerup', { x, y, button: 0, buttons: 0 })
|
||||
}
|
||||
|
||||
function mouseDrag(x1: number, y1: number, x2: number, y2: number): void {
|
||||
dispatchPointer('pointerdown', { x: x1, y: y1, button: 0, buttons: 1 })
|
||||
const midX = Math.round((x1 + x2) / 2)
|
||||
const midY = Math.round((y1 + y2) / 2)
|
||||
dispatchPointer('pointermove', { x: midX, y: midY, button: 0, buttons: 1 })
|
||||
dispatchPointer('pointermove', { x: x2, y: y2, button: 0, buttons: 1 })
|
||||
dispatchPointer('pointerup', { x: x2, y: y2, button: 0, buttons: 0 })
|
||||
}
|
||||
|
||||
function postedMessages(postMessage: ReturnType<typeof vi.fn>): Array<Record<string, unknown>> {
|
||||
return postMessage.mock.calls.map(([raw]) => JSON.parse(String(raw)) as Record<string, unknown>)
|
||||
}
|
||||
|
||||
function terminalInputBytes(postMessage: ReturnType<typeof vi.fn>): string {
|
||||
return postedMessages(postMessage)
|
||||
.filter((msg) => msg.type === 'terminal-input')
|
||||
.map((msg) => (msg.bytes as string) ?? '')
|
||||
.join('')
|
||||
}
|
||||
|
||||
describe('terminal WebView external mouse click and drag', () => {
|
||||
let animationFrames: Array<() => void>
|
||||
let buffer: BufferState
|
||||
let postMessage: ReturnType<typeof vi.fn>
|
||||
let registeredWindowListeners: RegisteredWindowListener[]
|
||||
let select: ReturnType<typeof vi.fn>
|
||||
let terminals: TerminalStub[]
|
||||
|
||||
function boot(): void {
|
||||
document.body.innerHTML = bodyMarkup()
|
||||
new Function(iifeSource())()
|
||||
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.
|
||||
while (animationFrames.length > 0) {
|
||||
animationFrames.shift()?.()
|
||||
}
|
||||
}
|
||||
|
||||
function activeTerminal(): TerminalStub {
|
||||
const terminal = terminals[terminals.length - 1]
|
||||
if (!terminal) {
|
||||
throw new Error('terminal missing')
|
||||
}
|
||||
return terminal
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
animationFrames = []
|
||||
buffer = { baseY: 0, type: 'normal', viewportY: 0 }
|
||||
registeredWindowListeners = []
|
||||
select = vi.fn()
|
||||
terminals = []
|
||||
const addWindowEventListener = window.addEventListener.bind(window)
|
||||
vi.spyOn(window, 'addEventListener').mockImplementation(((
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject,
|
||||
options?: boolean | AddEventListenerOptions
|
||||
) => {
|
||||
registeredWindowListeners.push({ type, listener, options })
|
||||
addWindowEventListener(type, listener, options)
|
||||
}) as typeof window.addEventListener)
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: () => void) => {
|
||||
animationFrames.push(callback)
|
||||
return animationFrames.length
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', () => {})
|
||||
Object.defineProperty(window, 'innerWidth', { value: 381, configurable: true })
|
||||
Object.defineProperty(window, 'innerHeight', { value: 612, configurable: true })
|
||||
postMessage = vi.fn()
|
||||
const webWindow = window as unknown as {
|
||||
Terminal: new () => TerminalStub
|
||||
ReactNativeWebView: { postMessage: (data: string) => void }
|
||||
}
|
||||
webWindow.Terminal = function () {
|
||||
const terminal = makeTerminal(buffer, select)
|
||||
terminals.push(terminal)
|
||||
return terminal
|
||||
} as unknown as new () => TerminalStub
|
||||
webWindow.ReactNativeWebView = { postMessage }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const { type, listener, options } of registeredWindowListeners) {
|
||||
window.removeEventListener(type, listener as EventListener, options)
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('reports a mouse click to a click-tracking TUI the way a touch tap does (#8818)', () => {
|
||||
boot()
|
||||
activeTerminal().modes.mouseTrackingMode = 'vt200'
|
||||
|
||||
mouseClick(40, 60)
|
||||
|
||||
// Default (non-SGR) encoding: press (32=' ') then release (35='#'), each
|
||||
// ESC [ M btn col row. Cell bytes depend on the fit scale, not on routing.
|
||||
const bytes = terminalInputBytes(postMessage)
|
||||
expect(bytes).toHaveLength(12)
|
||||
expect(bytes.slice(0, 4)).toBe(`${ESC}[M `)
|
||||
expect(bytes.slice(6, 10)).toBe(`${ESC}[M#`)
|
||||
expect(bytes.slice(4, 6)).toBe(bytes.slice(10, 12))
|
||||
})
|
||||
|
||||
it('sends press, per-cell motion, and release for a drag-tracking mouse drag', () => {
|
||||
boot()
|
||||
activeTerminal().modes.mouseTrackingMode = 'drag'
|
||||
|
||||
mouseDrag(40, 60, 160, 60)
|
||||
|
||||
const bytes = terminalInputBytes(postMessage)
|
||||
const reports = bytes.match(DEFAULT_MOUSE_REPORT_RE) ?? []
|
||||
expect(reports.length).toBeGreaterThanOrEqual(3)
|
||||
expect(reports[0]?.charCodeAt(3)).toBe(32)
|
||||
for (const motion of reports.slice(1, -1)) {
|
||||
expect(motion.charCodeAt(3)).toBe(64)
|
||||
}
|
||||
expect(reports[reports.length - 1]?.charCodeAt(3)).toBe(35)
|
||||
// Motion reports are deduped per cell, so a horizontal drag advances columns.
|
||||
const motionCols = reports.slice(1, -1).map((report) => report.charCodeAt(4))
|
||||
expect(new Set(motionCols).size).toBe(motionCols.length)
|
||||
})
|
||||
|
||||
it('does not report press or motion for an x10 click-only TUI drag', () => {
|
||||
boot()
|
||||
activeTerminal().modes.mouseTrackingMode = 'x10'
|
||||
|
||||
mouseDrag(40, 60, 160, 60)
|
||||
|
||||
// x10 reports presses only; the press goes out at drag start, no motion or
|
||||
// release must follow.
|
||||
const bytes = terminalInputBytes(postMessage)
|
||||
expect(bytes.slice(0, 4)).toBe(`${ESC}[M `)
|
||||
expect(bytes).toHaveLength(6)
|
||||
})
|
||||
|
||||
it('selects character-anchored text on a mouse drag outside tracking mode', () => {
|
||||
boot()
|
||||
// Why: init itself emits a set-select-mode reset; only the drag matters here.
|
||||
postMessage.mockClear()
|
||||
|
||||
mouseDrag(40, 60, 160, 90)
|
||||
|
||||
expect(terminalInputBytes(postMessage)).toBe('')
|
||||
expect(select).toHaveBeenCalled()
|
||||
expect(document.getElementById('selection-overlay')?.classList.contains('active')).toBe(true)
|
||||
const modes = postedMessages(postMessage).filter((msg) => msg.type === 'set-select-mode')
|
||||
expect(modes).toEqual([{ type: 'set-select-mode', enabled: true }])
|
||||
})
|
||||
|
||||
it('dismisses an existing selection with a click without focusing the keyboard', () => {
|
||||
boot()
|
||||
mouseDrag(40, 60, 160, 90)
|
||||
postMessage.mockClear()
|
||||
|
||||
mouseClick(240, 200)
|
||||
|
||||
const messages = postedMessages(postMessage)
|
||||
expect(messages.filter((msg) => msg.type === 'set-select-mode')).toEqual([
|
||||
{ type: 'set-select-mode', enabled: false }
|
||||
])
|
||||
expect(messages.filter((msg) => msg.type === 'terminal-tap')).toEqual([])
|
||||
expect(document.getElementById('selection-overlay')?.classList.contains('active')).toBe(false)
|
||||
})
|
||||
|
||||
it('routes a plain mouse click to the tap pipeline for keyboard focus', () => {
|
||||
boot()
|
||||
|
||||
mouseClick(40, 60)
|
||||
|
||||
const messages = postedMessages(postMessage)
|
||||
expect(messages.filter((msg) => msg.type === 'terminal-tap')).toHaveLength(1)
|
||||
expect(terminalInputBytes(postMessage)).toBe('')
|
||||
})
|
||||
|
||||
it('ignores non-mouse pointers and non-left buttons', () => {
|
||||
boot()
|
||||
activeTerminal().modes.mouseTrackingMode = 'vt200'
|
||||
|
||||
dispatchPointer('pointerdown', { pointerType: 'touch', x: 40, y: 60, button: 0, buttons: 1 })
|
||||
dispatchPointer('pointerup', { pointerType: 'touch', x: 40, y: 60, button: 0, buttons: 0 })
|
||||
dispatchPointer('pointerdown', { x: 40, y: 60, button: 2, buttons: 2 })
|
||||
dispatchPointer('pointerup', { x: 40, y: 60, button: 2, buttons: 0 })
|
||||
|
||||
expect(terminalInputBytes(postMessage)).toBe('')
|
||||
expect(postedMessages(postMessage).filter((msg) => msg.type === 'terminal-tap')).toEqual([])
|
||||
})
|
||||
|
||||
it('lets the touch dispatcher own a gesture when touch events follow pointerdown', () => {
|
||||
boot()
|
||||
activeTerminal().modes.mouseTrackingMode = 'vt200'
|
||||
|
||||
dispatchPointer('pointerdown', { x: 40, y: 60, button: 0, buttons: 1 })
|
||||
const touchStart = new Event('touchstart', { bubbles: true })
|
||||
// Why: the document tap dispatcher reads touches[0]; happy-dom's plain
|
||||
// Event lacks it.
|
||||
Object.defineProperty(touchStart, 'touches', {
|
||||
value: [{ identifier: 7, clientX: 40, clientY: 60 }]
|
||||
})
|
||||
surfaceEl().dispatchEvent(touchStart)
|
||||
dispatchPointer('pointerup', { x: 40, y: 60, button: 0, buttons: 0 })
|
||||
|
||||
// Why: Android SOURCE_MOUSE injections can pair a mouse pointerdown with
|
||||
// real touch events; double-handling would double the click report.
|
||||
expect(terminalInputBytes(postMessage)).toBe('')
|
||||
})
|
||||
|
||||
it('releases a tracked drag when the pointer is cancelled mid-gesture', () => {
|
||||
boot()
|
||||
activeTerminal().modes.mouseTrackingMode = 'drag'
|
||||
|
||||
dispatchPointer('pointerdown', { x: 40, y: 60, button: 0, buttons: 1 })
|
||||
dispatchPointer('pointermove', { x: 160, y: 60, button: 0, buttons: 1 })
|
||||
dispatchPointer('pointercancel', { x: 160, y: 60 })
|
||||
|
||||
const bytes = terminalInputBytes(postMessage)
|
||||
const reports = bytes.match(DEFAULT_MOUSE_REPORT_RE) ?? []
|
||||
// Press went to the TUI, so the cancel must not leave the button latched.
|
||||
expect(reports[reports.length - 1]?.charCodeAt(3)).toBe(35)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user