diff --git a/docs/specs/mouse-and-clipboard.md b/docs/specs/mouse-and-clipboard.md index d716cf58c..ad5b93298 100644 --- a/docs/specs/mouse-and-clipboard.md +++ b/docs/specs/mouse-and-clipboard.md @@ -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). diff --git a/lib/src/lib/copy-editor.ts b/lib/src/lib/copy-editor.ts index c7f83b090..4f830108a 100644 --- a/lib/src/lib/copy-editor.ts +++ b/lib/src/lib/copy-editor.ts @@ -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>; diff --git a/lib/src/lib/mouse-selection.ts b/lib/src/lib/mouse-selection.ts index a2dd46433..273e2e581 100644 --- a/lib/src/lib/mouse-selection.ts +++ b/lib/src/lib/mouse-selection.ts @@ -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(); } diff --git a/lib/src/lib/selection-watch.test.ts b/lib/src/lib/selection-watch.test.ts index acc6a6949..bcdde27a2 100644 --- a/lib/src/lib/selection-watch.test.ts +++ b/lib/src/lib/selection-watch.test.ts @@ -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 @@ -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 })); diff --git a/lib/src/lib/selection-watch.ts b/lib/src/lib/selection-watch.ts index d54bf1eee..b4cd32eae 100644 --- a/lib/src/lib/selection-watch.ts +++ b/lib/src/lib/selection-watch.ts @@ -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'; @@ -22,6 +23,9 @@ 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; @@ -29,10 +33,22 @@ export function watchSelection(id: string, terminal: Terminal): SelectionWatch { 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); @@ -42,6 +58,7 @@ export function watchSelection(id: string, terminal: Terminal): SelectionWatch { dispose() { unsubscribe(); anchor?.dispose(); + scrollAnchor?.marker.dispose(); }, }; } diff --git a/lib/src/lib/terminal-lifecycle.selection.test.ts b/lib/src/lib/terminal-lifecycle.selection.test.ts index 694a78601..05b50d316 100644 --- a/lib/src/lib/terminal-lifecycle.selection.test.ts +++ b/lib/src/lib/terminal-lifecycle.selection.test.ts @@ -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 () => {