diff --git a/lib/src/lib/mouse-selection.ts b/lib/src/lib/mouse-selection.ts index 69abfce..14a29f2 100644 --- a/lib/src/lib/mouse-selection.ts +++ b/lib/src/lib/mouse-selection.ts @@ -201,6 +201,16 @@ export function isDragging(id: string): boolean { return !!s?.selection?.dragging; } +/** + * True when xterm is in a mouse-reporting mode AND the user is in a context + * where the host (not the PTY) should own the mouse — i.e. an active override + * or a selection that began in scrollback. + */ +export function stateRequiresNativeMouseSuppression(state: MouseSelectionState): boolean { + return state.mouseReporting !== 'none' + && (state.override !== 'off' || state.selection?.startedInScrollback === true); +} + /** * Extend the in-progress selection to fully cover a detected token (spec §5.3). * No-op when no drag is active. Preserves the drag anchor; adjusts the end diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index 4da2477..91cdd47 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -21,6 +21,7 @@ import { inputContainsEnter, inputIsReplayTerminalReport, inputIsSyntheticTerminalReport, + stripMouseReportsFromInput, writeReplay, } from './terminal-report-filter'; import { getTerminalTheme, paintTerminalHost, startThemeObserver } from './terminal-theme'; @@ -76,20 +77,26 @@ function wireXtermHandlers( selectionBaselineRef: { current: string | null }, ): () => void { const inputDisposable = terminal.onData((data) => { - const isSyntheticTerminalReport = inputIsSyntheticTerminalReport(data); + let input = data; + if (getMouseSelectionState(id).override !== 'off') { + input = stripMouseReportsFromInput(input); + if (input.length === 0) return; + } + + const isSyntheticTerminalReport = inputIsSyntheticTerminalReport(input); - if (inputIsReplayTerminalReport(data) && registry.get(id)?.isReplaying) return; + if (inputIsReplayTerminalReport(input) && registry.get(id)?.isReplaying) return; if (!isSyntheticTerminalReport) { const entry = registry.get(id); const hadTodo = entry?.todo === true; getPlatform().alertAttend(id); - if (hadTodo && inputContainsEnter(data)) { + if (hadTodo && inputContainsEnter(input)) { getPlatform().alertClearTodo(id); } } - getPlatform().writePty(id, data); + getPlatform().writePty(id, input); }); const resizeDisposable = terminal.onResize(({ cols, rows }) => { diff --git a/lib/src/lib/terminal-mouse-router.test.ts b/lib/src/lib/terminal-mouse-router.test.ts new file mode 100644 index 0000000..1ce80bb --- /dev/null +++ b/lib/src/lib/terminal-mouse-router.test.ts @@ -0,0 +1,170 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { attachTerminalMouseRouter } from './terminal-mouse-router'; +import { + __resetMouseSelectionForTests, + getMouseSelectionState, + setMouseReporting, + setOverride, +} from './mouse-selection'; +import type { TerminalOverlayDims } from './terminal-store'; + +class ListenerHost { + private readonly listeners = new Map void>>(); + + addEventListener(type: string, listener: (ev: MouseEvent) => void): void { + const listeners = this.listeners.get(type) ?? []; + listeners.push(listener); + this.listeners.set(type, listeners); + } + + removeEventListener(type: string, listener: (ev: MouseEvent) => void): void { + const listeners = this.listeners.get(type) ?? []; + this.listeners.set(type, listeners.filter((l) => l !== listener)); + } + + emit(type: string, ev: FakeMouseEvent): void { + for (const listener of [...(this.listeners.get(type) ?? [])]) { + listener(ev); + } + } +} + +class FakeElement extends ListenerHost { + getBoundingClientRect(): Pick { + return { left: 0, top: 0 }; + } +} + +type FakeMouseEvent = MouseEvent & { + preventDefault: ReturnType; + stopPropagation: ReturnType; + stopImmediatePropagation: ReturnType; +}; + +function mouseEvent(overrides: Partial = {}): FakeMouseEvent { + return { + button: 0, + clientX: 5, + clientY: 5, + altKey: false, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + stopImmediatePropagation: vi.fn(), + ...overrides, + } as FakeMouseEvent; +} + +const dims: TerminalOverlayDims = { + cols: 80, + rows: 24, + viewportY: 0, + baseY: 0, + elementWidth: 800, + elementHeight: 240, + cellWidth: 10, + cellHeight: 10, + gridLeft: 0, + gridTop: 0, +}; + +function createHarness(windowHost: ListenerHost) { + const element = new FakeElement(); + const terminal = { + cols: 80, + clearSelection: vi.fn(), + focus: vi.fn(), + buffer: { + active: { + getLine: vi.fn(() => ({ translateToString: () => '' })), + }, + }, + }; + const cleanup = attachTerminalMouseRouter({ + id: 't1', + terminal: terminal as never, + element: element as never, + getOverlayDims: () => dims, + setSelectionBaseline: vi.fn(), + }); + return { cleanup, element, terminal, windowHost }; +} + +let windowHost: ListenerHost; + +beforeEach(() => { + windowHost = new ListenerHost(); + vi.stubGlobal('window', windowHost); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + __resetMouseSelectionForTests(); +}); + +describe('terminal-mouse-router: override suppression', () => { + it('suppresses temporary override mousedown before xterm can handle it', () => { + const { cleanup, element, terminal } = createHarness(windowHost); + setMouseReporting('t1', 'vt200'); + setOverride('t1', 'temporary'); + + const ev = mouseEvent(); + element.emit('mousedown', ev); + + expect(ev.preventDefault).toHaveBeenCalledOnce(); + expect(ev.stopPropagation).toHaveBeenCalledOnce(); + expect(ev.stopImmediatePropagation).toHaveBeenCalledOnce(); + expect(terminal.focus).toHaveBeenCalledOnce(); + cleanup(); + }); + + it('does not suppress live-region mouse events while reporting is active without an override', () => { + const { cleanup, element } = createHarness(windowHost); + setMouseReporting('t1', 'vt200'); + + const ev = mouseEvent(); + element.emit('mousedown', ev); + + expect(ev.preventDefault).not.toHaveBeenCalled(); + expect(ev.stopPropagation).not.toHaveBeenCalled(); + expect(ev.stopImmediatePropagation).not.toHaveBeenCalled(); + cleanup(); + }); + + it('suppresses pre-drag movement and clears temporary override after the paired mouseup', async () => { + const { cleanup, element } = createHarness(windowHost); + setMouseReporting('t1', 'vt200'); + setOverride('t1', 'temporary'); + + element.emit('mousedown', mouseEvent()); + + const move = mouseEvent({ clientX: 6 }); + windowHost.emit('mousemove', move); + expect(move.preventDefault).toHaveBeenCalledOnce(); + expect(getMouseSelectionState('t1').override).toBe('temporary'); + + const up = mouseEvent(); + windowHost.emit('mouseup', up); + expect(up.preventDefault).toHaveBeenCalledOnce(); + + await Promise.resolve(); + expect(getMouseSelectionState('t1').override).toBe('off'); + cleanup(); + }); + + it('suppresses sticky override mousemove without clearing the override on mouseup', async () => { + const { cleanup, element } = createHarness(windowHost); + setMouseReporting('t1', 'vt200'); + setOverride('t1', 'permanent'); + + const move = mouseEvent(); + element.emit('mousemove', move); + expect(move.preventDefault).toHaveBeenCalledOnce(); + + element.emit('mousedown', mouseEvent()); + windowHost.emit('mouseup', mouseEvent()); + + await Promise.resolve(); + expect(getMouseSelectionState('t1').override).toBe('permanent'); + cleanup(); + }); +}); diff --git a/lib/src/lib/terminal-mouse-router.ts b/lib/src/lib/terminal-mouse-router.ts index 5bf3e10..b6dd217 100644 --- a/lib/src/lib/terminal-mouse-router.ts +++ b/lib/src/lib/terminal-mouse-router.ts @@ -1,4 +1,4 @@ -import { Terminal } from '@xterm/xterm'; +import type { Terminal } from '@xterm/xterm'; import { beginDrag, endDrag, @@ -7,12 +7,33 @@ import { setDragAlt, setHintToken, setOverride, + stateRequiresNativeMouseSuppression, updateDrag, } from './mouse-selection'; import { detectTokenAt } from './smart-token'; import { extractSelectionText } from './selection-text'; import type { TerminalOverlayDims } from './terminal-store'; +const OVERRIDE_MOUSE_EVENTS = ['mousemove', 'mouseup', 'click', 'dblclick', 'auxclick', 'contextmenu'] as const; + +function consumeMouseEvent(ev: MouseEvent, stopImmediate = false): void { + ev.preventDefault(); + ev.stopPropagation(); + if (stopImmediate) ev.stopImmediatePropagation(); +} + +// Defer the override clear so any same-tick listener that re-reads the state +// (e.g. xterm's own mouseup handler) still sees `temporary` and can emit its +// trailing report before we flip back to `off`. +function clearTemporaryOverrideAfterMouseDispatch(id: string): void { + if (getMouseSelectionState(id).override !== 'temporary') return; + queueMicrotask(() => { + if (getMouseSelectionState(id).override === 'temporary') { + setOverride(id, 'off'); + } + }); +} + export function attachTerminalMouseRouter({ id, terminal, @@ -47,12 +68,12 @@ export function attachTerminalMouseRouter({ col: number; altKey: boolean; startedInScrollback: boolean; + button: number; clientX: number; clientY: number; } | null = null; const onMouseDown = (ev: MouseEvent) => { - if (ev.button !== 0) return; const state = getMouseSelectionState(id); const cell = computeCell(ev); const terminalOwns = @@ -60,18 +81,33 @@ export function attachTerminalMouseRouter({ || state.override !== 'off' || cell.startedInScrollback; if (!terminalOwns) return; + const suppressNativeMouse = state.mouseReporting !== 'none'; + if (suppressNativeMouse) { + consumeMouseEvent(ev, true); + terminal.focus(); + } + if (ev.button !== 0 && !suppressNativeMouse) return; pendingDrag = { row: cell.row, col: cell.col, altKey: ev.altKey, startedInScrollback: cell.startedInScrollback, + button: ev.button, clientX: ev.clientX, clientY: ev.clientY, }; }; + const onOverrideMouseEvent = (ev: MouseEvent) => { + const state = getMouseSelectionState(id); + if (state.mouseReporting === 'none' || state.override === 'off') return; + consumeMouseEvent(ev, true); + }; + const onWindowMouseMove = (ev: MouseEvent) => { if (pendingDrag) { + if (stateRequiresNativeMouseSuppression(getMouseSelectionState(id))) consumeMouseEvent(ev, true); + if (pendingDrag.button !== 0) return; const dx = ev.clientX - pendingDrag.clientX; const dy = ev.clientY - pendingDrag.clientY; if (dx * dx + dy * dy < DRAG_THRESHOLD_PX_SQ) return; @@ -87,8 +123,7 @@ export function attachTerminalMouseRouter({ if (!isDragging(id)) return; const cell = computeCell(ev); updateDrag(id, { row: cell.row, col: cell.col, altKey: ev.altKey }); - ev.preventDefault(); - ev.stopPropagation(); + consumeMouseEvent(ev, stateRequiresNativeMouseSuppression(getMouseSelectionState(id))); const line = terminal.buffer.active.getLine(cell.row); const text = line?.translateToString(false, 0, terminal.cols); @@ -103,24 +138,22 @@ export function attachTerminalMouseRouter({ }; const onWindowMouseUp = (ev: MouseEvent) => { - if (ev.button !== 0) return; if (pendingDrag) { - if (getMouseSelectionState(id).override === 'temporary') { - setOverride(id, 'off'); - } + if (ev.button !== pendingDrag.button) return; + if (stateRequiresNativeMouseSuppression(getMouseSelectionState(id))) consumeMouseEvent(ev, true); + clearTemporaryOverrideAfterMouseDispatch(id); pendingDrag = null; return; } + if (ev.button !== 0) return; if (!isDragging(id)) return; + const suppressNativeMouse = stateRequiresNativeMouseSuppression(getMouseSelectionState(id)); endDrag(id); setHintToken(id, null); const sel = getMouseSelectionState(id).selection; setSelectionBaseline(sel ? extractSelectionText(terminal, sel) : null); - if (getMouseSelectionState(id).override === 'temporary') { - setOverride(id, 'off'); - } - ev.preventDefault(); - ev.stopPropagation(); + clearTemporaryOverrideAfterMouseDispatch(id); + consumeMouseEvent(ev, suppressNativeMouse); }; const onAltChange = (ev: KeyboardEvent) => { @@ -129,6 +162,9 @@ export function attachTerminalMouseRouter({ }; element.addEventListener('mousedown', onMouseDown, true); + for (const type of OVERRIDE_MOUSE_EVENTS) { + element.addEventListener(type, onOverrideMouseEvent, true); + } window.addEventListener('mousemove', onWindowMouseMove, true); window.addEventListener('mouseup', onWindowMouseUp, true); window.addEventListener('keydown', onAltChange, true); @@ -136,6 +172,9 @@ export function attachTerminalMouseRouter({ return () => { element.removeEventListener('mousedown', onMouseDown, true); + for (const type of OVERRIDE_MOUSE_EVENTS) { + element.removeEventListener(type, onOverrideMouseEvent, true); + } window.removeEventListener('mousemove', onWindowMouseMove, true); window.removeEventListener('mouseup', onWindowMouseUp, true); window.removeEventListener('keydown', onAltChange, true); diff --git a/lib/src/lib/terminal-report-filter.test.ts b/lib/src/lib/terminal-report-filter.test.ts new file mode 100644 index 0000000..d9a0222 --- /dev/null +++ b/lib/src/lib/terminal-report-filter.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { stripMouseReportsFromInput } from './terminal-report-filter'; + +describe('terminal-report-filter: mouse reports', () => { + it('removes X10 / VT200 mouse reports', () => { + expect(stripMouseReportsFromInput('\x1b[M !!')).toBe(''); + }); + + it('removes SGR mouse press, release, and wheel reports', () => { + const input = '\x1b[<0;12;4M\x1b[<0;12;4m\x1b[<64;12;4M'; + expect(stripMouseReportsFromInput(input)).toBe(''); + }); + + it('removes URXVT mouse reports', () => { + expect(stripMouseReportsFromInput('\x1b[32;12;4M')).toBe(''); + }); + + it('preserves non-mouse input around stripped reports', () => { + const input = 'a\x1b[<0;12;4M\r\x1b[M !!b'; + expect(stripMouseReportsFromInput(input)).toBe('a\rb'); + }); +}); diff --git a/lib/src/lib/terminal-report-filter.ts b/lib/src/lib/terminal-report-filter.ts index 6790c81..f2812c7 100644 --- a/lib/src/lib/terminal-report-filter.ts +++ b/lib/src/lib/terminal-report-filter.ts @@ -14,6 +14,10 @@ const REPLAY_REPORT_FOCUS = /\x1b\[[IO]/; const REPORT_DCS = /\x1bP[\s\S]*?\x1b\\/; const REPLAY_REPORT_TOKENS = new RegExp(`${REPLAY_REPORT_CSI.source}|${REPLAY_REPORT_FOCUS.source}|${REPORT_OSC.source}|${REPORT_DCS.source}|.`, 'gs'); const REPLAY_REPORT_VALIDATE = new RegExp(`^(?:${REPLAY_REPORT_CSI.source}|${REPLAY_REPORT_FOCUS.source}|${REPORT_OSC.source}|${REPORT_DCS.source})$`); +const MOUSE_REPORT_X10 = /\x1b\[M[\s\S]{3}/; +const MOUSE_REPORT_SGR = /\x1b\[<\d+;\d+;\d+[mM]/; +const MOUSE_REPORT_URXVT = /\x1b\[\d+;\d+;\d+M/; +const MOUSE_REPORT_TOKENS = new RegExp(`${MOUSE_REPORT_X10.source}|${MOUSE_REPORT_SGR.source}|${MOUSE_REPORT_URXVT.source}`, 'g'); export function inputIsSyntheticTerminalReport(data: string): boolean { if (data.length === 0) return false; @@ -29,6 +33,10 @@ export function inputIsReplayTerminalReport(data: string): boolean { return chunks.every((chunk) => REPLAY_REPORT_VALIDATE.test(chunk)); } +export function stripMouseReportsFromInput(data: string): string { + return data.replace(MOUSE_REPORT_TOKENS, ''); +} + export function writeReplay(entry: TerminalEntry, ...chunks: string[]): void { if (chunks.length === 0) return; entry.isReplaying = true;