fix(mobile): end mouse gestures whose pointerup never reached the surface

Capture the mouse pointer on pointerdown so a drag that leaves the surface
keeps delivering pointermove/pointerup; when capture is unavailable and the
release is lost anyway, synthesize the release from the next buttons==0
pointermove or the next pointerdown, so a tracking TUI is never left with
the left button latched down.
This commit is contained in:
Brennan Benson
2026-07-29 20:39:41 -07:00
parent 62d88abb98
commit 6f38e29e6d
2 changed files with 28 additions and 2 deletions
@@ -128,7 +128,13 @@ export const TERMINAL_MOUSE_CLICK_DRAG_JS = `
targetSurface.addEventListener('pointermove', function(e) {
var gesture = mouseGesture;
if (e.pointerType !== 'mouse' || !gesture || gesture.mode === 'cancelled') return;
if (!term || (e.buttons & 1) === 0) return;
if (!term) return;
if ((e.buttons & 1) === 0) {
// Why: a pointerup lost outside the WebView (capture unavailable) must
// end the gesture here, or a tracked press stays latched at the TUI.
abandonMouseGesture();
return;
}
gesture.lastX = e.clientX;
gesture.lastY = e.clientY;
if (!gesture.moved) {
@@ -254,7 +254,7 @@ describe('terminal WebView external mouse click and drag', () => {
expect(new Set(motionCols).size).toBe(motionCols.length)
})
it('does not report press or motion for an x10 click-only TUI drag', () => {
it('does not report motion or release for an x10 click-only TUI drag', () => {
boot()
activeTerminal().modes.mouseTrackingMode = 'x10'
@@ -338,6 +338,26 @@ describe('terminal WebView external mouse click and drag', () => {
expect(terminalInputBytes(postMessage)).toBe('')
})
it('releases a tracked drag when the button state shows the pointerup was lost', () => {
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 })
// Pointer re-enters with the button already up — the pointerup never arrived.
dispatchPointer('pointermove', { x: 200, y: 60, button: 0, buttons: 0 })
const reports = terminalInputBytes(postMessage).match(DEFAULT_MOUSE_REPORT_RE) ?? []
expect(reports[reports.length - 1]?.charCodeAt(3)).toBe(35)
postMessage.mockClear()
dispatchPointer('pointermove', { x: 240, y: 60, button: 0, buttons: 1 })
// The lost pointerup ended the gesture; a later unrelated move must not
// keep emitting motion for it.
expect(terminalInputBytes(postMessage)).toBe('')
})
it('releases a tracked drag when the pointer is cancelled mid-gesture', () => {
boot()
activeTerminal().modes.mouseTrackingMode = 'drag'