From 7fd6f18c7c268c3c61575de99e64255032d252f5 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 30 Oct 2026 13:25:19 -0700 Subject: [PATCH 1/2] Select a word on double-click and a line on triple-click A double-click in a terminal that is not reporting the mouse painted xterm.js's own word selection: a different look from the terminal's selection, and one Cmd+C never copied. The mouse router consumed a press only while the program reported the mouse, so with reporting off every press also reached xterm's SelectionService, which selects on a multi-click (and extends on a Shift-click) by itself. Drags never showed it only because the router swallowed their moves. The router now owns multi-clicks the terminal owns: a double-click selects the token under it (the cells Whole words grows over), a triple-click its logical line, both as the terminal's selection, with the copy editor opening on release as after a drag. A press it owns stops at xterm's screen element when it is a multi-click or a Shift-click, after the linkifier there has seen it, so xterm's selection never sees it and a link's double-click still pins its preview. A double-click on a link or a blank selects nothing. Co-Authored-By: Claude Opus 5.5 --- docs/specs/mouse-and-clipboard.md | 4 +- lib/src/lib/copy-text.test.ts | 23 ++++++ lib/src/lib/copy-text.ts | 20 ++++++ lib/src/lib/terminal-mouse-router.test.ts | 87 ++++++++++++++++++++++- lib/src/lib/terminal-mouse-router.ts | 36 ++++++++++ lib/src/stories/TextSelection.stories.tsx | 44 +++++++++++- scripts/spec-word-budgets.json | 2 +- 7 files changed, 211 insertions(+), 5 deletions(-) diff --git a/docs/specs/mouse-and-clipboard.md b/docs/specs/mouse-and-clipboard.md index 240c60b0e..acc6d03e7 100644 --- a/docs/specs/mouse-and-clipboard.md +++ b/docs/specs/mouse-and-clipboard.md @@ -58,6 +58,8 @@ Selection is available whenever the terminal handles the mouse (§3.5, §6.1). - **A selection edge is the cell boundary nearest the pointer**, as xterm.js's own selection reads it: the earlier edge (reading order, or column order for a block) takes the cell after its boundary, the later edge the cell before (rationale). Source of truth: `dragCells` in `lib/src/lib/drag-cells.ts`, pinned by its test. - **Must begin selection only once a click-and-drag crosses the drag threshold**; plain clicks shift pane focus or activate hyperlinks. +- **A mouse double-click selects the token under it (as Whole words grows, §4.2), and a triple-click its logical line**, released into the copy editor as a drag is (§3.7). A blank or a link selects nothing; a link's double-click is its own (`docs/specs/dor-tool.md` -> "Terminal links"). Dragging on is an ordinary drag. +- **Never let xterm.js's own selection see a multi-click or Shift-click the terminal owns**; no copy reads its highlight. Pinned by `lib/src/lib/terminal-mouse-router.test.ts` and `lib/src/stories/TextSelection.stories.tsx`. - On touch or pen, a primary pointer tap-and-drag takes the same path; non-primary touch pointers are ignored. - **A drag whose button comes up outside the webview iframe must still finalize.** @@ -358,7 +360,7 @@ Source of truth: `normalizeExternalUri` in `lib/src/lib/external-links.ts` (pinn ### 9.1 Mouse and Selection - Auto-scroll during a drag that reaches the viewport edge. -- Double-click to select word, triple-click to select line. +- Extending a double- or triple-click's selection by words or lines as the mouse drags on. - More formats (strip line numbers, strip prompts, join hyphenated line-breaks, Markdown rebuilt from styling) and scopes (a command's output from OSC 133 marks, a TUI message). - Contextual editor actions (Open URL, Open in `$EDITOR`, Copy hash). - A "quiet mode" setting to suppress hints for experienced users. diff --git a/lib/src/lib/copy-text.test.ts b/lib/src/lib/copy-text.test.ts index 350481ab3..b2b111d0e 100644 --- a/lib/src/lib/copy-text.test.ts +++ b/lib/src/lib/copy-text.test.ts @@ -3,6 +3,7 @@ import type { Terminal } from '@xterm/xterm'; import { autoBreak, computeScopes, + multiClickSpan, nudge, render, terminalCopyBuffer, @@ -302,6 +303,28 @@ describe('copy-text scopes', () => { }); }); +describe('copy-text multiClickSpan', () => { + it('selects the token under a double click, across a soft wrap', () => { + const url = 'see https://github.com/diffplug/dormouse/pull/853/files ok'; + const soft = lines([url.slice(0, 30), url.slice(30)], { cols: 30, wrapped: [1] }); + expect(multiClickSpan(soft, { row: 1, col: 3 }, 2)).toEqual(span(0, 4, 1, 24)); + expect(multiClickSpan(soft, { row: 0, col: 1 }, 2)).toEqual(span(0, 0, 0, 2)); + }); + + it('selects a triple-clicked logical line from its first text cell to its last', () => { + const buf = lines([' aaaa bbbb cccc dd', 'dd eeee', 'next'], { cols: 19, wrapped: [1] }); + expect(multiClickSpan(buf, { row: 1, col: 5 }, 3)).toEqual(span(0, 2, 1, 6)); + expect(multiClickSpan(buf, { row: 2, col: 0 }, 4)).toEqual(span(2, 0, 2, 3)); + }); + + it('selects nothing from a blank cell or a blank line', () => { + const buf = lines(['one two', ''], { cols: 20 }); + expect(multiClickSpan(buf, { row: 0, col: 3 }, 2)).toBeNull(); + expect(multiClickSpan(buf, { row: 0, col: 12 }, 2)).toBeNull(); + expect(multiClickSpan(buf, { row: 1, col: 0 }, 3)).toBeNull(); + }); +}); + describe('copy-text nudge', () => { it.each(['start', 'end'] as const)('nudges an out-of-range %s left into its own row', (edge) => { const buf = lines(['alpha beta', 'gamma delta'], { cols: 80 }); diff --git a/lib/src/lib/copy-text.ts b/lib/src/lib/copy-text.ts index 663d9ba06..49196dab4 100644 --- a/lib/src/lib/copy-text.ts +++ b/lib/src/lib/copy-text.ts @@ -549,6 +549,26 @@ function snapToWords(buf: CopyBuffer, sel: Span): Span { return { start: { row: sr, col: sc }, end: { row: er, col: ec }, block: false }; } +/** What a multi-click on cell `pos` selects (spec §3.1): two clicks the token + * under it, as Whole words grows an edge; three or more its logical line, from + * its first text cell to its last. Null on a blank cell, or a blank line. */ +export function multiClickSpan(buf: CopyBuffer, pos: GridPos, clicks: number): Span | null { + if (clicks === 2) { + return isBlankCell(buf.row(pos.row).cells[pos.col]) ? null : snapToWords(buf, { start: pos, end: pos, block: false }); + } + const { top, bottom } = buf.row(pos.row); + let start: GridPos | null = null; + let end: GridPos | null = null; + for (let row = top; row <= bottom; row++) { + buf.row(row).cells.forEach((cell, col) => { + if (isBlankCell(cell)) return; + start ??= { row, col }; + end = { row, col }; + }); + } + return start && end ? { start, end, block: false } : null; +} + function toParagraph(buf: CopyBuffer, sel: Span): Span { let sr = sel.start.row; if (!line(buf, sr).boundary) while (sr > 0 && !line(buf, sr - 1).boundary) sr--; diff --git a/lib/src/lib/terminal-mouse-router.test.ts b/lib/src/lib/terminal-mouse-router.test.ts index a1271a6e3..19850ad3b 100644 --- a/lib/src/lib/terminal-mouse-router.test.ts +++ b/lib/src/lib/terminal-mouse-router.test.ts @@ -10,6 +10,7 @@ import { setOverride, } from './mouse-selection'; import type { TerminalOverlayDims } from './terminal-store'; +import { fakeXterm } from './copy-text-fixtures'; class ListenerHost { private readonly listeners = new Map void>>(); @@ -37,6 +38,8 @@ class FakeElement extends ListenerHost { releasePointerCapture = vi.fn(); querySelector = vi.fn(); dispatchEvent = vi.fn(); + readonly classes = new Set(); + classList = { contains: (name: string) => this.classes.has(name) }; getBoundingClientRect(): Pick { return { left: 0, top: 0 }; @@ -89,7 +92,8 @@ const dims: TerminalOverlayDims = { gridTop: 0, }; -function createHarness(windowHost: ListenerHost) { +/** `rows` are the buffer's text, for presses that read it. */ +function createHarness(windowHost: ListenerHost, rows?: readonly string[]) { const element = new FakeElement(); const screen = new FakeElement(); element.querySelector.mockReturnValue(screen); @@ -97,7 +101,7 @@ function createHarness(windowHost: ListenerHost) { cols: 80, clearSelection: vi.fn(), focus: vi.fn(), - buffer: { + buffer: rows ? fakeXterm(rows).buffer : { active: { getLine: vi.fn(() => ({ length: 0, translateToString: () => '' })), }, @@ -572,3 +576,82 @@ describe('terminal-mouse-router: override suppression', () => { cleanup(); }); }); + +describe('terminal-mouse-router: multi-click', () => { + const ROWS = ['$ cat --oneline', 'second row']; + + /** One press and release at column 8 of row 0, the press dispatched as the + * DOM does: the wrapper's capture, then the screen's bubble. */ + function click(h: ReturnType, detail: number, over: Partial = {}) { + const down = mouseEvent({ clientX: 85, clientY: 5, detail, ...over }); + h.element.emit('mousedown', down); + h.screen.emit('mousedown', down); + windowHost.emit('mouseup', mouseEvent({ clientX: 85, clientY: 5, detail, ...over })); + return down; + } + + it('selects the double-clicked word as the terminal’s own selection, its editor open, xterm’s selection never reached', () => { + const h = createHarness(windowHost, ROWS); + expect(click(h, 1).stopPropagation).not.toHaveBeenCalled(); + const down = click(h, 2); + // Column 8 is in "--oneline", columns 6 to 14. + expect(getMouseSelectionState('t1').selection).toMatchObject({ startRow: 0, startCol: 6, endRow: 0, endCol: 14, dragging: false, shape: 'linewise' }); + expect(getMouseSelectionState('t1').selection?.owner).toBeUndefined(); + expect(getMouseSelectionState('t1').copyEditor?.scopes[0].span).toEqual({ start: { row: 0, col: 6 }, end: { row: 0, col: 14 }, block: false }); + // Stopped at the screen, after xterm's linkifier there saw it. + expect(down.preventDefault).toHaveBeenCalledOnce(); + expect(down.stopPropagation).toHaveBeenCalledOnce(); + expect(down.stopImmediatePropagation).not.toHaveBeenCalled(); + expect(h.terminal.focus).toHaveBeenCalled(); + h.cleanup(); + }); + + it('selects the triple-clicked line', () => { + const h = createHarness(windowHost, ROWS); + click(h, 1); + click(h, 2); + click(h, 3); + expect(getMouseSelectionState('t1').selection).toMatchObject({ startRow: 0, startCol: 0, endRow: 0, endCol: 14, dragging: false }); + expect(getMouseSelectionState('t1').copyEditor).not.toBeNull(); + h.cleanup(); + }); + + it('leaves a double-clicked link to the link, xterm still kept from selecting it', () => { + const h = createHarness(windowHost, ROWS); + h.screen.classes.add('xterm-cursor-pointer'); + const down = click(h, 2); + expect(getMouseSelectionState('t1').selection).toBeNull(); + expect(down.stopPropagation).toHaveBeenCalledOnce(); + h.cleanup(); + }); + + it('keeps a Shift-click from xterm’s selection without selecting', () => { + const h = createHarness(windowHost, ROWS); + click(h, 1); + const down = click(h, 1, { shiftKey: true }); + expect(down.stopPropagation).toHaveBeenCalledOnce(); + expect(getMouseSelectionState('t1').selection).toBeNull(); + h.cleanup(); + }); + + it('drags from a double-click’s press as from any other', () => { + const h = createHarness(windowHost, ROWS); + const down = mouseEvent({ clientX: 85, clientY: 5, detail: 2 }); + h.element.emit('mousedown', down); + h.screen.emit('mousedown', down); + windowHost.emit('mousemove', mouseEvent({ clientX: 41, clientY: 15, buttons: 1 })); + windowHost.emit('mouseup', mouseEvent({ clientX: 41, clientY: 15 })); + expect(getMouseSelectionState('t1').selection).toMatchObject({ startRow: 0, startCol: 8, endRow: 1, endCol: 3, dragging: false }); + h.cleanup(); + }); + + it('leaves a program’s double-click to the program', () => { + const h = createHarness(windowHost, ROWS); + setMouseReporting('t1', 'vt200'); + const down = click(h, 2); + expect(getMouseSelectionState('t1').selection).toBeNull(); + expect(down.stopPropagation).not.toHaveBeenCalled(); + expect(down.preventDefault).not.toHaveBeenCalled(); + h.cleanup(); + }); +}); diff --git a/lib/src/lib/terminal-mouse-router.ts b/lib/src/lib/terminal-mouse-router.ts index a8ba1d13f..5e897fab5 100644 --- a/lib/src/lib/terminal-mouse-router.ts +++ b/lib/src/lib/terminal-mouse-router.ts @@ -12,6 +12,7 @@ import { updateDrag, } from './mouse-selection'; import { openCopyEditor } from './copy-editor'; +import { multiClickSpan, terminalCopyBuffer } from './copy-text'; import { boundaryAt, dragCells, type PointerBoundary } from './drag-cells'; import { detectTokenInBuffer } from './smart-token'; import type { TerminalOverlayDims } from './terminal-store'; @@ -114,8 +115,13 @@ export function attachTerminalMouseRouter({ clientX: number; clientY: number; touchLike: boolean; + /** A mouse double or triple click, which selected at its press: its + * release opens the editor over that selection unless it became a drag. */ + multiClick: boolean; } | null = null; let activePointerId: number | null = null; + // The latest primary mouse press the terminal owns, for onScreenMouseDown. + let ownedMousePress: MouseEvent | null = null; let suppressSyntheticMouseUntil = 0; // The most recent touch that ended as a tap (no drag), used to recognize a double-tap. let lastTouchTap: { time: number; x: number; y: number } | null = null; @@ -166,6 +172,21 @@ export function attachTerminalMouseRouter({ // must leave no pendingDrag behind, or its release would read as the paired // click that ends a temporary override (spec §2). if (ev.button !== 0) return true; + // A press on a link is the link's: a double-click there pins its preview. + const multiClick = !opts.touchLike && ev.detail >= 2 && !mouseCaptureElement.classList.contains('xterm-cursor-pointer'); + const clickSpan = multiClick ? multiClickSpan(terminalCopyBuffer(terminal), cell, ev.detail) : null; + if (clickSpan) { + const { start, end } = clickSpan; + setSelection(id, { + startRow: start.row, + startCol: start.col, + endRow: end.row, + endCol: end.col, + shape: 'linewise', + dragging: false, + startedInScrollback: cell.startedInScrollback, + }); + } pendingDrag = { row: cell.row, col: cell.col, @@ -176,7 +197,9 @@ export function attachTerminalMouseRouter({ clientX: ev.clientX, clientY: ev.clientY, touchLike: opts.touchLike, + multiClick: clickSpan !== null, }; + if (!opts.touchLike) ownedMousePress = ev; return true; }; @@ -234,6 +257,7 @@ export function attachTerminalMouseRouter({ })); } } + if (pendingDrag.multiClick) openCopyEditor(id, terminal); clearTemporaryOverrideAfterMouseDispatch(id); pendingDrag = null; return; @@ -283,6 +307,16 @@ export function attachTerminalMouseRouter({ beginPendingDrag(ev, { touchLike: false }); }; + // xterm's own selection runs from a press on its element, which bubbles there + // after the linkifier on its screen has seen it. A multi-click or Shift-click + // there would paint a selection the terminal does not own and no copy reads, + // so a press the terminal owns stops at the screen (spec §3.1). + const onScreenMouseDown = (ev: MouseEvent) => { + if (ev !== ownedMousePress || (ev.detail < 2 && !ev.shiftKey)) return; + consumePointerEvent(ev); + terminal.focus(); + }; + const onPointerDown = (ev: PointerEvent) => { if (ev.pointerType === 'mouse') { if (ev.button !== 0) return; @@ -425,6 +459,7 @@ export function attachTerminalMouseRouter({ element.addEventListener('mousedown', onMouseDown, true); element.addEventListener('pointerdown', onPointerDown, true); + mouseCaptureElement.addEventListener('mousedown', onScreenMouseDown); for (const type of OVERRIDE_MOUSE_EVENTS) { element.addEventListener(type, onOverrideMouseEvent, true); } @@ -439,6 +474,7 @@ export function attachTerminalMouseRouter({ return () => { element.removeEventListener('mousedown', onMouseDown, true); element.removeEventListener('pointerdown', onPointerDown, true); + mouseCaptureElement.removeEventListener('mousedown', onScreenMouseDown); for (const type of OVERRIDE_MOUSE_EVENTS) { element.removeEventListener(type, onOverrideMouseEvent, true); } diff --git a/lib/src/stories/TextSelection.stories.tsx b/lib/src/stories/TextSelection.stories.tsx index d1fd867f7..c7647b530 100644 --- a/lib/src/stories/TextSelection.stories.tsx +++ b/lib/src/stories/TextSelection.stories.tsx @@ -1,8 +1,10 @@ import type { Meta, StoryObj } from '@storybook/react'; +import { userEvent } from 'storybook/test'; import { TerminalPane } from '../components/TerminalPane'; import { flattenScenario, SCENARIO_LS_OUTPUT } from '../lib/platform'; import { getMouseSelectionState, setSelection, type Selection } from '../lib/mouse-selection'; import { openCopyEditor } from '../lib/copy-editor'; +import { getTerminalInstance, getTerminalOverlayDims } from '../lib/terminal-registry'; import { settleTerminals, waitForCondition } from './settle-terminals'; import { useStorySelection } from './story-selection'; @@ -16,9 +18,11 @@ function TextSelectionStory({ selection, }: { id: string; - selection: Omit; + /** None for a story whose `play` selects with the mouse. */ + selection?: Omit; }) { useStorySelection(id, (terminal) => { + if (!selection) return; setSelection(id, { ...selection, startedInScrollback: false }); // A finalized drag opens the copy editor, as mouse-up does. if (!selection.dragging) openCopyEditor(id, terminal); @@ -111,3 +115,41 @@ export const EditorAfterDragUp: Story = { }, }, }; + +// --- Mouse multi-click --------------------------------------------------- + +/** Double-click the middle of `word`, the first on screen, as a mouse does. */ +async function doubleClickWord(id: string, word: string): Promise<{ row: number; col: number }> { + const terminal = getTerminalInstance(id)!; + const dims = getTerminalOverlayDims(id)!; + const buffer = terminal.buffer.active; + let row = buffer.viewportY; + while (!buffer.getLine(row)!.translateToString().includes(word)) row++; + const col = buffer.getLine(row)!.translateToString().indexOf(word); + const screen = document.querySelector('.xterm-screen')!; + const rect = screen.getBoundingClientRect(); + const coords = { + clientX: rect.left + (col + word.length / 2) * dims.cellWidth, + clientY: rect.top + (row - buffer.viewportY + 0.5) * dims.cellHeight, + }; + await userEvent.pointer([{ target: screen, coords, keys: '[MouseLeft][MouseLeft]' }]); + return { row, col }; +} + +/** A double-click selects its word as the terminal's own selection, the editor + * open over it, and xterm.js paints no selection of its own. */ +export const EditorAfterDoubleClick: Story = { + args: { id: 'text-sel-double-click' }, + play: async ({ args }) => { + await settleTerminals(); + await waitForCondition(() => (getTerminalOverlayDims(args.id)?.cellHeight ?? 0) > 0); + const word = 'package.json'; + const { row, col } = await doubleClickWord(args.id, word); + await waitForCondition(() => getMouseSelectionState(args.id).copyEditor !== null); + const { selection } = getMouseSelectionState(args.id); + const want = { startRow: row, startCol: col, endRow: row, endCol: col + word.length - 1 }; + const got = selection && { startRow: selection.startRow, startCol: selection.startCol, endRow: selection.endRow, endCol: selection.endCol }; + if (JSON.stringify(got) !== JSON.stringify(want)) throw new Error(`double-click selected ${JSON.stringify(got)}, not ${JSON.stringify(want)}`); + if (getTerminalInstance(args.id)!.hasSelection()) throw new Error('xterm.js painted its own selection'); + }, +}; diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index adf6b82b5..ef8a714b5 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -15,7 +15,7 @@ "docs/specs/hosted.md": 6500, "docs/specs/layout.md": 7250, "docs/specs/mobile-terminal-ui.md": 1250, - "docs/specs/mouse-and-clipboard.md": 4200, + "docs/specs/mouse-and-clipboard.md": 4300, "docs/specs/one-time.md": 2650, "docs/specs/pocket-app.md": 3650, "docs/specs/pricing.md": 2750, From 22962cad06c3df33fcced6b5ec157efab5d10e20 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 30 Oct 2026 15:58:28 -0700 Subject: [PATCH 2/2] Take a Shift-drag over a mouse program as the terminal's selection Off macOS, xterm.js treats a Shift-held press as a forced selection, even while the program reports the mouse. The router left that press to the program, so xterm painted its own selection over a shadowed drag: not the terminal's selection, and not one Cmd+C or Ctrl+C copied. The router now owns a Shift-held primary press over a reporting program off macOS. That gives a Shift-drag the terminal's own selection and copy editor, and keeps every event from the program and from xterm. Whether a gesture's events are kept from xterm is now decided at its press and held until its release. Before, it was worked out again on each event from the store, so a Shift-click's release (and a scrollback press's moves before it became a drag) still reached xterm and the program. That leaves stateRequiresNativeMouseSuppression unused, so it is removed. The sweep for other ways xterm's own selection could win found one more: macOS right-click selected a word (rightClickSelectsWord defaults on there). The terminal now turns that off, and pins Option-click forced selection off too. Co-Authored-By: Claude Opus 5.5 --- docs/specs/mouse-and-clipboard.md | 5 ++- lib/src/lib/mouse-selection.ts | 10 ----- .../lib/terminal-lifecycle.selection.test.ts | 14 ++++++ lib/src/lib/terminal-lifecycle.ts | 6 +++ lib/src/lib/terminal-mouse-router.test.ts | 43 ++++++++++++++++++- lib/src/lib/terminal-mouse-router.ts | 24 ++++++----- scripts/spec-word-budgets.json | 2 +- 7 files changed, 80 insertions(+), 24 deletions(-) diff --git a/docs/specs/mouse-and-clipboard.md b/docs/specs/mouse-and-clipboard.md index acc6d03e7..a265b221e 100644 --- a/docs/specs/mouse-and-clipboard.md +++ b/docs/specs/mouse-and-clipboard.md @@ -59,7 +59,7 @@ Selection is available whenever the terminal handles the mouse (§3.5, §6.1). - **A selection edge is the cell boundary nearest the pointer**, as xterm.js's own selection reads it: the earlier edge (reading order, or column order for a block) takes the cell after its boundary, the later edge the cell before (rationale). Source of truth: `dragCells` in `lib/src/lib/drag-cells.ts`, pinned by its test. - **Must begin selection only once a click-and-drag crosses the drag threshold**; plain clicks shift pane focus or activate hyperlinks. - **A mouse double-click selects the token under it (as Whole words grows, §4.2), and a triple-click its logical line**, released into the copy editor as a drag is (§3.7). A blank or a link selects nothing; a link's double-click is its own (`docs/specs/dor-tool.md` -> "Terminal links"). Dragging on is an ordinary drag. -- **Never let xterm.js's own selection see a multi-click or Shift-click the terminal owns**; no copy reads its highlight. Pinned by `lib/src/lib/terminal-mouse-router.test.ts` and `lib/src/stories/TextSelection.stories.tsx`. +- **Never let xterm.js select on a gesture of its own**: a multi-click or Shift-click the terminal owns never reaches its selection, and its right-click and Option-click selection stay off; no copy reads its highlight. Pinned by `lib/src/lib/terminal-mouse-router.test.ts` and `lib/src/stories/TextSelection.stories.tsx`. - On touch or pen, a primary pointer tap-and-drag takes the same path; non-primary touch pointers are ignored. - **A drag whose button comes up outside the webview iframe must still finalize.** @@ -231,10 +231,11 @@ A token is whitespace-delimited and **runs on across soft wraps**. Trailing char |---|---|---|---| | No | — | Terminal | Terminal | | Yes | No | Inside program, shadowed (§3.8) | Terminal | +| Yes | No, Shift held off macOS | Terminal | Terminal | | Yes | Temporary | Terminal, ends on mouse-up | Terminal | | Yes | Sticky | Terminal | Terminal | -**Ownership is decided at mouse-down and latched for the whole drag** (§3.5). Wheel events follow the override rows only: swallowed while an override is active (§2); in the "Yes / No override" row they reach the inside program in both regions. +**Ownership is decided at mouse-down and latched until mouse-up** (§3.5): over a reporting program, no event of a press the terminal owns, a click included, reaches the program or xterm.js. Pinned by `lib/src/lib/terminal-mouse-router.test.ts`. Wheel events follow the override rows only: swallowed while an override is active (§2); in the "Yes / No override" row they reach the inside program in both regions. Source of truth: `terminalOwnsEvent` in `lib/src/lib/terminal-mouse-router.ts`. diff --git a/lib/src/lib/mouse-selection.ts b/lib/src/lib/mouse-selection.ts index 02aa81762..e422ce820 100644 --- a/lib/src/lib/mouse-selection.ts +++ b/lib/src/lib/mouse-selection.ts @@ -284,16 +284,6 @@ 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.selection.test.ts b/lib/src/lib/terminal-lifecycle.selection.test.ts index f2131cc97..5fc4ba009 100644 --- a/lib/src/lib/terminal-lifecycle.selection.test.ts +++ b/lib/src/lib/terminal-lifecycle.selection.test.ts @@ -9,11 +9,18 @@ import type { Terminal } from '@xterm/xterm'; * terminal owns through the reflow (§3.4). */ +/** Each terminal's constructor options, in order. */ +const constructed = vi.hoisted(() => [] as Array | undefined>); + // xterm's real buffer, so a resize reflows; never opened, and the addons it // loads are stand-ins, since no canvas can measure glyphs. vi.mock('@xterm/xterm', async () => { const xterm = await vi.importActual('@xterm/xterm'); class Terminal extends xterm.Terminal { + constructor(options?: ConstructorParameters[0]) { + super(options); + constructed.push(options as Record | undefined); + } override open(): void {} override loadAddon(): void {} } @@ -41,6 +48,13 @@ import { getOrCreateTerminal, writeUserInput } from './terminal-registry'; const write = (terminal: Terminal, data: string) => new Promise((resolve) => terminal.write(data, resolve)); +describe('xterm.js options', () => { + it('never lets xterm.js select on a gesture of its own, on any platform (§3.1)', () => { + getOrCreateTerminal('pane-options'); + expect(constructed.at(-1)).toMatchObject({ rightClickSelectsWord: false, macOptionClickForcesSelection: false }); + }); +}); + describe('writeUserInput', () => { it('ends a finalized selection, and a shadowed drag', () => { getOrCreateTerminal('pane-1'); diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index a969e6514..e4c4339d6 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -153,6 +153,11 @@ function createXtermHost(id: string, grid?: TerminalGrid): { terminal: Terminal; cursorBlink: cfg.terminal.cursorBlink, theme, vtExtensions: xtermVtExtensions(), + // xterm.js's own selection is one no copy reads, so it never selects on a + // gesture of its own (docs/specs/mouse-and-clipboard.md §3.1); macOS + // right-click would select a word by default. + rightClickSelectsWord: false, + macOptionClickForcesSelection: false, linkHandler: { activate: (event, uri, range) => { event.preventDefault(); @@ -302,6 +307,7 @@ function setupTerminalEntry(id: string, options: TerminalEntryOptions = {}): Ter terminal, element, getOverlayDims: getTerminalOverlayDims, + shiftClaimsMouse: !IS_MAC, }); const cleanup = () => { diff --git a/lib/src/lib/terminal-mouse-router.test.ts b/lib/src/lib/terminal-mouse-router.test.ts index 19850ad3b..6dbc3ee76 100644 --- a/lib/src/lib/terminal-mouse-router.test.ts +++ b/lib/src/lib/terminal-mouse-router.test.ts @@ -93,7 +93,7 @@ const dims: TerminalOverlayDims = { }; /** `rows` are the buffer's text, for presses that read it. */ -function createHarness(windowHost: ListenerHost, rows?: readonly string[]) { +function createHarness(windowHost: ListenerHost, rows?: readonly string[], shiftClaimsMouse = false) { const element = new FakeElement(); const screen = new FakeElement(); element.querySelector.mockReturnValue(screen); @@ -112,6 +112,7 @@ function createHarness(windowHost: ListenerHost, rows?: readonly string[]) { terminal: terminal as never, element: element as never, getOverlayDims: () => dims, + shiftClaimsMouse, }); return { cleanup, element, screen, terminal, windowHost }; } @@ -655,3 +656,43 @@ describe('terminal-mouse-router: multi-click', () => { h.cleanup(); }); }); + +describe('terminal-mouse-router: Shift over a reporting program (§6.1)', () => { + /** A Shift-held press, move, and release, every event kept for inspection. */ + function shiftGesture(shiftClaimsMouse: boolean, to: [number, number]) { + const h = createHarness(windowHost, undefined, shiftClaimsMouse); + setMouseReporting('t1', 'vt200'); + const events = [ + mouseEvent({ clientX: 5, clientY: 5, shiftKey: true }), + mouseEvent({ clientX: to[0], clientY: to[1], shiftKey: true, buttons: 1 }), + mouseEvent({ clientX: to[0], clientY: to[1], shiftKey: true }), + ]; + h.element.emit('mousedown', events[0]); + windowHost.emit('mousemove', events[1]); + windowHost.emit('mouseup', events[2]); + return { h, events }; + } + + it('makes a Shift-drag the terminal’s own selection, its editor open, none of it reaching xterm', () => { + const { h, events } = shiftGesture(true, [45, 25]); + expect(getMouseSelectionState('t1').selection).toMatchObject({ startRow: 0, startCol: 0, endRow: 2, endCol: 3, dragging: false }); + expect(getMouseSelectionState('t1').selection?.owner).toBeUndefined(); + expect(getMouseSelectionState('t1').copyEditor).not.toBeNull(); + for (const ev of events) expect(ev.stopImmediatePropagation).toHaveBeenCalled(); + h.cleanup(); + }); + + it('keeps a Shift-click from the program and from xterm alike', () => { + const { h, events } = shiftGesture(true, [6, 5]); + expect(getMouseSelectionState('t1').selection).toBeNull(); + for (const ev of events) expect(ev.stopImmediatePropagation).toHaveBeenCalled(); + h.cleanup(); + }); + + it('leaves a Shift-drag to the program where Shift does not claim the mouse (macOS)', () => { + const { h, events } = shiftGesture(false, [45, 25]); + expect(getMouseSelectionState('t1').selection).toMatchObject({ owner: 'program' }); + for (const ev of events) expect(ev.preventDefault).not.toHaveBeenCalled(); + h.cleanup(); + }); +}); diff --git a/lib/src/lib/terminal-mouse-router.ts b/lib/src/lib/terminal-mouse-router.ts index 5e897fab5..df0d5239c 100644 --- a/lib/src/lib/terminal-mouse-router.ts +++ b/lib/src/lib/terminal-mouse-router.ts @@ -8,7 +8,6 @@ import { setHintToken, setOverride, setSelection, - stateRequiresNativeMouseSuppression, updateDrag, } from './mouse-selection'; import { openCopyEditor } from './copy-editor'; @@ -57,11 +56,15 @@ export function attachTerminalMouseRouter({ terminal, element, getOverlayDims, + shiftClaimsMouse, }: { id: string; terminal: Terminal; element: HTMLDivElement; getOverlayDims: (id: string) => TerminalOverlayDims | null; + /** Whether a Shift-held primary press over a mouse-reporting program is the + * terminal's (spec §6.1): off macOS, as xterm.js's own convention has it. */ + shiftClaimsMouse: boolean; }): () => void { /** The cell under the pointer, and (`b`) the cell boundary nearest it, * which is what a selection edge takes (spec §3.1). */ @@ -144,9 +147,13 @@ export function attachTerminalMouseRouter({ const terminalOwns = state.mouseReporting === 'none' || state.override !== 'off' - || cell.startedInScrollback; + || cell.startedInScrollback + || (shiftClaimsMouse && ev.shiftKey && ev.button === 0); return { state, cell, terminalOwns }; }; + // Whether the current gesture's events are kept from xterm.js: decided at + // its press and latched until its release (spec §6.1). + let gestureSuppressesNative = false; const beginPendingDrag = ( ev: MouseEvent | PointerEvent, @@ -164,6 +171,7 @@ export function attachTerminalMouseRouter({ return false; } const suppressNativeMouse = state.mouseReporting !== 'none'; + gestureSuppressesNative = suppressNativeMouse; if (suppressNativeMouse || opts.touchLike) { consumePointerEvent(ev, true); terminal.focus(); @@ -206,8 +214,7 @@ export function attachTerminalMouseRouter({ const updatePendingOrActiveDrag = (ev: MouseEvent | PointerEvent) => { let consumed = false; if (pendingDrag) { - const suppressNativeMouse = stateRequiresNativeMouseSuppression(getMouseSelectionState(id)); - if (suppressNativeMouse || pendingDrag.touchLike) { + if (gestureSuppressesNative || pendingDrag.touchLike) { consumePointerEvent(ev, true); consumed = true; } @@ -231,8 +238,7 @@ export function attachTerminalMouseRouter({ const cell = computeCell(ev); dragHead = { row: cell.row, b: cell.b }; applyDrag(ev.altKey); - const suppressNativeMouse = stateRequiresNativeMouseSuppression(getMouseSelectionState(id)); - if (!consumed) consumePointerEvent(ev, suppressNativeMouse || isNonMousePointerEvent(ev)); + if (!consumed) consumePointerEvent(ev, gestureSuppressesNative || isNonMousePointerEvent(ev)); setHintToken(id, detectTokenInBuffer(terminal.buffer.active, cell.row, cell.col)); }; @@ -244,8 +250,7 @@ export function attachTerminalMouseRouter({ // both: a mousemove reports button 0. if (ev.button !== 0) return; if (pendingDrag) { - const suppressNativeMouse = stateRequiresNativeMouseSuppression(getMouseSelectionState(id)); - if (suppressNativeMouse || pendingDrag.touchLike) consumePointerEvent(ev, true); + if (gestureSuppressesNative || pendingDrag.touchLike) consumePointerEvent(ev, true); // A touch press that releases without ever dragging is a tap — remember it // so the next press can be recognized as a double-tap (block selection). if (pendingDrag.touchLike) { @@ -263,12 +268,11 @@ export function attachTerminalMouseRouter({ return; } if (!isDragging(id)) return; - const suppressNativeMouse = stateRequiresNativeMouseSuppression(getMouseSelectionState(id)); endDrag(id); setHintToken(id, null); openCopyEditor(id, terminal); clearTemporaryOverrideAfterMouseDispatch(id); - consumePointerEvent(ev, suppressNativeMouse || isNonMousePointerEvent(ev)); + consumePointerEvent(ev, gestureSuppressesNative || isNonMousePointerEvent(ev)); }; /** Remember where a program drag is, once it is a drag; the cell is measured diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index ef8a714b5..51bad2a50 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -15,7 +15,7 @@ "docs/specs/hosted.md": 6500, "docs/specs/layout.md": 7250, "docs/specs/mobile-terminal-ui.md": 1250, - "docs/specs/mouse-and-clipboard.md": 4300, + "docs/specs/mouse-and-clipboard.md": 4350, "docs/specs/one-time.md": 2650, "docs/specs/pocket-app.md": 3650, "docs/specs/pricing.md": 2750,