Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions docs/specs/mouse-and-clipboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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.**

Expand Down Expand Up @@ -229,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`.

Expand Down Expand Up @@ -358,7 +361,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.
Expand Down
23 changes: 23 additions & 0 deletions lib/src/lib/copy-text.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Terminal } from '@xterm/xterm';
import {
autoBreak,
computeScopes,
multiClickSpan,
nudge,
render,
terminalCopyBuffer,
Expand Down Expand Up @@ -363,6 +364,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 });
Expand Down
20 changes: 20 additions & 0 deletions lib/src/lib/copy-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,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--;
Expand Down
10 changes: 0 additions & 10 deletions lib/src/lib/mouse-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions lib/src/lib/terminal-lifecycle.selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown> | 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<typeof import('@xterm/xterm')>('@xterm/xterm');
class Terminal extends xterm.Terminal {
constructor(options?: ConstructorParameters<typeof xterm.Terminal>[0]) {
super(options);
constructed.push(options as Record<string, unknown> | undefined);
}
override open(): void {}
override loadAddon(): void {}
}
Expand Down Expand Up @@ -41,6 +48,13 @@ import { getOrCreateTerminal, writeUserInput } from './terminal-registry';

const write = (terminal: Terminal, data: string) => new Promise<void>((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');
Expand Down
6 changes: 6 additions & 0 deletions lib/src/lib/terminal-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,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();
Expand Down Expand Up @@ -326,6 +331,7 @@ function setupTerminalEntry(id: string, options: TerminalEntryOptions = {}): Ter
terminal,
element,
getOverlayDims: getTerminalOverlayDims,
shiftClaimsMouse: !IS_MAC,
});

const cleanup = () => {
Expand Down
128 changes: 126 additions & 2 deletions lib/src/lib/terminal-mouse-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Array<(ev: MouseEvent | PointerEvent) => void>>();
Expand Down Expand Up @@ -37,6 +38,8 @@ class FakeElement extends ListenerHost {
releasePointerCapture = vi.fn();
querySelector = vi.fn();
dispatchEvent = vi.fn();
readonly classes = new Set<string>();
classList = { contains: (name: string) => this.classes.has(name) };

getBoundingClientRect(): Pick<DOMRect, 'left' | 'top'> {
return { left: 0, top: 0 };
Expand Down Expand Up @@ -89,15 +92,16 @@ 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[], shiftClaimsMouse = false) {
const element = new FakeElement();
const screen = new FakeElement();
element.querySelector.mockReturnValue(screen);
const terminal = {
cols: 80,
clearSelection: vi.fn(),
focus: vi.fn(),
buffer: {
buffer: rows ? fakeXterm(rows).buffer : {
active: {
getLine: vi.fn(() => ({ length: 0, translateToString: () => '' })),
},
Expand All @@ -108,6 +112,7 @@ function createHarness(windowHost: ListenerHost) {
terminal: terminal as never,
element: element as never,
getOverlayDims: () => dims,
shiftClaimsMouse,
});
return { cleanup, element, screen, terminal, windowHost };
}
Expand Down Expand Up @@ -572,3 +577,122 @@ 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<typeof createHarness>, detail: number, over: Partial<MouseEvent> = {}) {
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();
});
});

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();
});
});
Loading
Loading