fix(mobile): restore terminal input focus from blank-area taps

This commit is contained in:
Neil
2026-09-05 20:58:36 -07:00
parent 299492dab9
commit 8ffa351340
3 changed files with 114 additions and 8 deletions
@@ -0,0 +1,100 @@
// @vitest-environment happy-dom
import { afterEach, expect, it, vi } from 'vitest'
import { TERMINAL_TAP_DISPATCH_JS } from './terminal-webview-tap-dispatch-injected'
function boot(selecting = false) {
const document = globalThis.document.implementation.createHTMLDocument()
document.body.innerHTML =
'<div id="terminal-container"><div id="surface"></div></div><div id="overlay"></div>'
const container = document.getElementById('terminal-container')!
const surface = document.getElementById('surface')!
const posted = vi.fn()
const cellTap = vi.fn()
const select = vi.fn()
const cancel = vi.fn()
new Function(
'document',
'notify',
'notifyTerminalSurfaceTap',
'enterSelect',
'onCancel',
`
var surface = document.getElementById('surface');
var selectionOverlay = document.getElementById('overlay');
var handleStart = {}, handleEnd = {}, sel = {}, selMode = ${JSON.stringify(selecting ? 'select' : 'idle')};
var tapCandidate = null, longPressTimer = null, longPressOrigin = null;
var LONG_PRESS_MS = 500, LONG_PRESS_SLOP = 10, TAP_SLOP = 24, TAP_MAX_MS = 700;
function viewportToCell() { throw new Error('Blank space has no cell'); }
function cancelSelect() { selMode = 'idle'; onCancel(); }
function handleDragMove() {}
function stopEdgeScroll() {}
${TERMINAL_TAP_DISPATCH_JS}
`
)(document, posted, cellTap, select, cancel)
const touch = (type: string, points: number[][], target = container) => {
const event = new Event(type, { bubbles: true, cancelable: true })
Object.defineProperty(event, 'target', { value: target })
Object.defineProperty(event, 'touches', {
value: points.map(([clientX, clientY], identifier) => ({ identifier, clientX, clientY }))
})
document.dispatchEvent(event)
}
return { touch, posted, cellTap, select, cancel, surface }
}
afterEach(() => vi.useRealTimers())
it('restores focus on blank container taps without resolving cells or links', () => {
const h = boot()
h.touch('touchstart', [[20, 300]])
h.touch('touchend', [])
expect(h.posted.mock.calls).toEqual([[{ type: 'terminal-tap' }]])
expect(h.cellTap).not.toHaveBeenCalled()
expect(h.select).not.toHaveBeenCalled()
})
it.each(['move', 'cancel', 'pinch', 'three-finger pinch', 'hold'])(
'does not focus for a blank-space %s',
(kind) => {
vi.useFakeTimers()
const h = boot()
h.touch('touchstart', [[20, 300]])
if (kind === 'move') {
h.touch('touchmove', [[20, 340]])
}
if (kind === 'cancel') {
h.touch('touchcancel', [])
}
if (kind === 'pinch' || kind === 'three-finger pinch') {
h.touch('touchstart', [
[20, 300],
[40, 300]
])
}
if (kind === 'three-finger pinch') {
h.touch('touchstart', [
[20, 300],
[40, 300],
[60, 300]
])
}
if (kind === 'hold') {
vi.advanceTimersByTime(550)
}
h.touch('touchend', [])
expect(h.posted).not.toHaveBeenCalled()
expect(h.cellTap).not.toHaveBeenCalled()
expect(h.select).not.toHaveBeenCalled()
}
)
it('dismisses selection first and only focuses on the next blank tap', () => {
const h = boot(true)
h.touch('touchstart', [[20, 300]])
h.touch('touchend', [])
expect(h.cancel).toHaveBeenCalledOnce()
expect(h.posted).not.toHaveBeenCalled()
h.touch('touchstart', [[20, 300]])
h.touch('touchend', [])
expect(h.posted).toHaveBeenCalledWith({ type: 'terminal-tap' })
})
@@ -6,8 +6,8 @@ import { XTERM_HTML } from './terminal-webview-html'
// uncovered region ships silently. A diff here means the emitted WebView source changed —
// update these values only when that change is deliberate, and only after checking the
// document still runs. Refactors that merely move slice boundaries must leave them alone.
const EXPECTED_SHA256 = '42cc000faddc3b58b8fd4855f848c7878f0cd6166c613f66d733645e8e1b9608'
const EXPECTED_LENGTH = 729776
const EXPECTED_SHA256 = 'cb9d1d764ad765e1933ffce87f546ad8589928613da36bbf688992bb82b15eb8'
const EXPECTED_LENGTH = 730168
describe('terminal WebView payload', () => {
it('composes the expected document', () => {
@@ -57,12 +57,13 @@ export const TERMINAL_TAP_DISPATCH_JS = `
var onHandle = target === handleStart || target === handleEnd;
var inOverlay = targetInside(target, selectionOverlay);
var inSurface = targetInside(target, surface);
var inBlankContainer = target === document.getElementById('terminal-container');
// 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.
tapCandidate = null;
if (e.touches.length === 2) {
if (e.touches.length >= 2) {
// pinch latch
if (selMode === 'select') {
notify({ type: 'mobile-clip-cancel-by-pinch' });
@@ -89,7 +90,7 @@ export const TERMINAL_TAP_DISPATCH_JS = `
return;
}
if (inSurface && selMode === 'select') {
if ((inSurface || inBlankContainer) && 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.
@@ -99,11 +100,11 @@ export const TERMINAL_TAP_DISPATCH_JS = `
return;
}
if (inSurface) {
if (inSurface || inBlankContainer) {
dispatch.mode = 'surface';
dispatch.touchId = t.identifier;
tapCandidate = { x: t.clientX, y: t.clientY, t: Date.now(), identifier: t.identifier };
armLongPress(t);
tapCandidate = { x: t.clientX, y: t.clientY, t: Date.now(), identifier: t.identifier, blank: inBlankContainer };
if (inSurface) armLongPress(t);
}
}, { capture: true, passive: false });
@@ -163,7 +164,12 @@ export const TERMINAL_TAP_DISPATCH_JS = `
selMode !== 'select' &&
Date.now() - tapCandidate.t <= TAP_MAX_MS
) {
notifyTerminalSurfaceTap(tapCandidate.x, tapCandidate.y, true);
if (tapCandidate.blank) {
// Blank space has no terminal cell, link, or mouse target.
if (Date.now() - tapCandidate.t < LONG_PRESS_MS) notify({ type: 'terminal-tap' });
} else {
notifyTerminalSurfaceTap(tapCandidate.x, tapCandidate.y, true);
}
}
clearLongPress();
tapCandidate = null;