Skip to content
Merged
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
4 changes: 2 additions & 2 deletions docs/specs/mouse-and-clipboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,9 @@ Source of truth: `lib/src/lib/terminal-mouse-router.ts`.

### 3.4 Selection Follows Content

A selection is anchored to the characters under it, not to screen coordinates: stored in absolute buffer rows (scrollback + viewport).
**Must anchor selections to characters in absolute buffer rows** (scrollback + viewport).

- **Pure scroll** — vertical translation with no character changes — carries the selection along; coordinate math only, no matching.
- **Must follow vertical scrolling and older scrollback trims** using coordinate math, never matching. **Must cancel when selected rows are trimmed**, even if identical text replaces them.
- **Must cancel a finalized selection on the next xterm render when its extracted selected text changes**; repaints elsewhere are irrelevant. **Never add a partial-match or content-tracking heuristic** (§9.1).
- **Terminal resize** carries a finalized linewise selection Dormouse owns, in the normal buffer, through xterm's reflow (rationale). Its editor stays open in the same format and same-labeled scope, else As selected; per-break edits drop unless the width held.
- **Must cancel if an edge's line was trimmed or its cells no longer read as the selected text** (rationale).
Expand Down
4 changes: 2 additions & 2 deletions lib/src/lib/copy-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,13 @@ export function followCopySelection(id: string, terminal: Terminal, span: Span):
if (spanEquals(span, spanOfSelection(selection)) && (!editor || sameWidth)) return;
const moved = selectionOfSpan(span, selection);
if (!editor) {
setSelection(id, moved);
setSelection(id, moved, null, { preserveProgramCopy: true });
return;
}
const next = editorFor(terminalCopyBuffer(terminal), span, editor.format);
const label = editor.scopes[editor.scope].label;
const scope = next.scopes.findIndex((s) => s.label === label);
setSelection(id, moved, { ...next, scope: Math.max(0, scope), overrides: scope >= 0 && sameWidth ? editor.overrides : {} });
setSelection(id, moved, { ...next, scope: Math.max(0, scope), overrides: scope >= 0 && sameWidth ? editor.overrides : {} }, { preserveProgramCopy: true });
}

export type FormatRenderings = Partial<Record<EditorFormat, Rendering>>;
Expand Down
10 changes: 5 additions & 5 deletions lib/src/lib/mouse-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,18 +179,18 @@ export function setOverride(id: string, override: OverrideState): void {
* unless one is given for a finalized selection, so an editor never outlives
* the selection it was opened for.
*/
export function setSelection(id: string, selection: Selection | null, copyEditor: CopyEditorState | null = null): void {
export function setSelection(id: string, selection: Selection | null, copyEditor: CopyEditorState | null = null, { preserveProgramCopy = false } = {}): void {
const s = ensure(id);
if (s.selection === null && selection === null) return;
if (selection === null) clearSelection(s);
else {
// A replaced selection keeps an in-flight flash and the drag's hint, but
// not the program's offer, so its editor cannot show that offer either.
// Replacing keeps the flash and drag hint. Only following the same cells
// through scrolling may also retain the program's copy offer.
s.selectionIdentity ??= {};
s.selection = selection;
s.programCopy = null;
if (!preserveProgramCopy) s.programCopy = null;
s.copyEditor = selection.dragging || !copyEditor ? null
: copyEditor.format === 'program' ? { ...copyEditor, format: 'auto', overrides: {} } : copyEditor;
: copyEditor.format === 'program' && s.programCopy === null ? { ...copyEditor, format: 'auto', overrides: {} } : copyEditor;
}
notify();
}
Expand Down
43 changes: 41 additions & 2 deletions lib/src/lib/selection-watch.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from 'vitest';
import { Terminal } from '@xterm/xterm';
import { openCopyEditor, setCopyFormat } from './copy-editor';
import { finalizedSelection } from './copy-text-fixtures';
import { __resetMouseSelectionForTests, getMouseSelectionState, setSelection } from './mouse-selection';
import { __resetMouseSelectionForTests, getMouseSelectionState, offerProgramCopy, setSelection } from './mouse-selection';
import { watchSelection } from './selection-watch';

/** Cancel-on-change, compared on each render (docs/specs/mouse-and-clipboard.md
Expand Down Expand Up @@ -37,9 +38,47 @@ describe('watchSelection', () => {
watch.dispose();
});

it.each(['linewise', 'block', 'program'] as const)('carries a %s selection when older scrollback is trimmed', async kind => {
const id = 'trim-' + kind;
const terminal = new Terminal({ cols: 20, rows: 3, scrollback: 3, allowProposedApi: true });
live.push(terminal);
await write(terminal, 'old0\r\nold1\r\nkeep A\r\nkeep B\r\ntail0\r\ntail1');
const watch = watchSelection(id, terminal);
setSelection(id, finalizedSelection({ startRow: 2, endRow: 3, endCol: 5,
shape: kind === 'block' ? 'block' : 'linewise', ...(kind === 'program' ? { owner: 'program' as const } : {}) }));
openCopyEditor(id, terminal);
if (kind === 'program') {
offerProgramCopy(id, 'source text');
setCopyFormat(id, 'program');
}
await write(terminal, '\r\ntail2');
watch.onRender();
expect(getMouseSelectionState(id).selection).toMatchObject({ startRow: 1, endRow: 2, endCol: 5 });
expect(getMouseSelectionState(id).copyEditor).not.toBeNull();
if (kind === 'program') {
expect(getMouseSelectionState(id)).toMatchObject({ programCopy: 'source text', copyEditor: { format: 'program' } });
}
await write(terminal, '\r\ntail3\r\ntail4');
watch.onRender();
expect(getMouseSelectionState(id).selection).toBeNull();
watch.dispose();
});

it('cancels trimmed selected rows even when their replacements have identical text', async () => {
const terminal = new Terminal({ cols: 20, rows: 3, scrollback: 1, allowProposedApi: true });
live.push(terminal);
await write(terminal, 'same\r\nsame\r\nsame\r\nsame');
const watch = watchSelection('trim-identical', terminal);
setSelection('trim-identical', finalizedSelection({ endCol: 3 }));
await write(terminal, '\r\nsame');
watch.onRender();
expect(getMouseSelectionState('trim-identical').selection).toBeNull();
watch.dispose();
});

it('releases its anchor and stops watching once disposed', async () => {
const { terminal, watch } = await watched('w-2');
expect(terminal.markers).toHaveLength(2);
expect(terminal.markers).toHaveLength(3);
watch.dispose();
expect(terminal.markers).toHaveLength(0);
setSelection('w-2', finalizedSelection({ endCol: 2 }));
Expand Down
21 changes: 19 additions & 2 deletions lib/src/lib/selection-watch.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Terminal } from '@xterm/xterm';
import type { IMarker, Terminal } from '@xterm/xterm';
import { followCopySelection } from './copy-editor';
import { spanOfSelection } from './copy-text';
import { getMouseSelectionState, setSelection, subscribeToMouseSelection, type Selection } from './mouse-selection';
import { anchorSelection, followReflow, type ReflowAnchor } from './selection-reflow';
import { extractSelectionText } from './selection-text';
Expand All @@ -22,17 +23,32 @@ export function watchSelection(id: string, terminal: Terminal): SelectionWatch {
let baseline: string | null = null;
/** Its edges, carried through a resize's reflow. */
let anchor: ReflowAnchor | null = null;
// A full normal buffer trims its oldest rows as output arrives. One marker
// on the earlier edge distinguishes translation from replacement text.
let scrollAnchor: { marker: IMarker; row: number } | null = null;
const unsubscribe = subscribeToMouseSelection(() => {
const sel = getMouseSelectionState(id).selection;
if (sel === selection) return;
selection = sel;
baseline = sel && !sel.dragging ? extractSelectionText(terminal, sel) : null;
anchor?.dispose();
anchor = sel && anchorSelection(terminal, sel);
scrollAnchor?.marker.dispose();
scrollAnchor = null;
const buffer = terminal.buffer.active;
if (sel && !sel.dragging && buffer.type === 'normal') {
const row = Math.min(sel.startRow, sel.endRow);
scrollAnchor = { marker: terminal.registerMarker(row - (buffer.baseY + buffer.cursorY)), row };
}
});
return {
onRender() {
if (selection && baseline !== null && extractSelectionText(terminal, selection) !== baseline) setSelection(id, null);
if (!selection || baseline === null) return;
if (scrollAnchor?.marker.isDisposed) { setSelection(id, null); return; }
const shift = scrollAnchor ? scrollAnchor.marker.line - scrollAnchor.row : 0;
const moved = shift ? { ...selection, startRow: selection.startRow + shift, endRow: selection.endRow + shift } : selection;
if (extractSelectionText(terminal, moved) !== baseline) setSelection(id, null);
else if (moved !== selection) followCopySelection(id, terminal, spanOfSelection(moved));
},
onResize() {
const span = anchor && followReflow(terminal, anchor);
Expand All @@ -42,6 +58,7 @@ export function watchSelection(id: string, terminal: Terminal): SelectionWatch {
dispose() {
unsubscribe();
anchor?.dispose();
scrollAnchor?.marker.dispose();
},
};
}
4 changes: 2 additions & 2 deletions lib/src/lib/terminal-lifecycle.selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,8 @@ describe('a terminal resize', () => {
expect(copyEditor!.overrides).toEqual({});
expect(copyEditor!.scopes[copyEditor!.scope].label).toBe('Whole words');
expect(copyEditor!.scopes[0].span).toEqual({ start: { row: 0, col: 6 }, end: { row: 2, col: 1 }, block: false });
// The moved selection's two edges, the first selection's released.
expect(terminal.markers).toHaveLength(2);
// The moved selection's reflow edges and scroll anchor; the old ones released.
expect(terminal.markers).toHaveLength(3);
});

it('keeps per-break edits through a resize that moves the selection but keeps the width', async () => {
Expand Down
Loading