diff --git a/lib/src/lib/copy-editor.test.ts b/lib/src/lib/copy-editor.test.ts index 61afca767..bf4252a20 100644 --- a/lib/src/lib/copy-editor.test.ts +++ b/lib/src/lib/copy-editor.test.ts @@ -220,13 +220,19 @@ describe('copySelection', () => { }); it.each([true, false])('neither flashes nor fails a newer selection when an earlier copy finishes (copied: %s)', async (copied) => { - let complete!: (copied: boolean) => void; - vi.mocked(writeTextToClipboard).mockImplementation(() => new Promise((resolve) => { complete = resolve; })); - select({}); - const pending = copySelection(ID); - beginDrag(ID, { row: 9, col: 1, altKey: false, startedInScrollback: false }); - complete(copied); - await pending; - expect(getMouseSelectionState(ID)).toMatchObject({ copyOutcome: null, selection: { startRow: 9 } }); + const newer = [ + () => beginDrag(ID, { row: 9, col: 1, altKey: false, startedInScrollback: false }), + () => select({ startRow: 9, endRow: 9 }), + ]; + for (const replace of newer) { + let complete!: (copied: boolean) => void; + vi.mocked(writeTextToClipboard).mockImplementation(() => new Promise((resolve) => { complete = resolve; })); + select({}); + const pending = copySelection(ID); + replace(); + complete(copied); + await pending; + expect(getMouseSelectionState(ID)).toMatchObject({ copyOutcome: null, selection: { startRow: 9 } }); + } }); }); diff --git a/lib/src/lib/copy-editor.ts b/lib/src/lib/copy-editor.ts index 4f830108a..edff29921 100644 --- a/lib/src/lib/copy-editor.ts +++ b/lib/src/lib/copy-editor.ts @@ -37,9 +37,9 @@ export function openCopyEditor(id: string, terminal: Terminal | undefined = regi if (terminal && sel && !sel.dragging) setCopyEditor(id, editorFor(terminalCopyBuffer(terminal), spanOfSelection(sel), 'auto')); } -/** Move the selection to `span`, where a resize's reflow put its cells, and - * rebuild its editor from the terminal (spec §3.4). Writes nothing when - * neither the span nor the editor's width changed. */ +/** Move the selection to `span`, where a scroll, trim, or resize's reflow put + * its cells, and rebuild its editor from the terminal (spec §3.4). Writes + * nothing when neither the span nor the editor's width changed. */ export function followCopySelection(id: string, terminal: Terminal, span: Span): void { const { selection, copyEditor: editor } = getMouseSelectionState(id); if (!selection) return; @@ -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, null, { preserveProgramCopy: true }); + setSelection(id, moved, null, 'follow'); 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 : {} }, { preserveProgramCopy: true }); + setSelection(id, moved, { ...next, scope: Math.max(0, scope), overrides: scope >= 0 && sameWidth ? editor.overrides : {} }, 'follow'); } export type FormatRenderings = Partial>; @@ -163,5 +163,5 @@ export function nudgeCopyEdge(id: string, edge: 'start' | 'end', dir: 1 | -1): v ? { ...span, start: nudge(buf, span.start, dir, 'start') } : { ...span, end: nudge(buf, span.end, dir, 'end') }; if (spanEquals(moved, span) || comparePos(moved.start, moved.end) > 0) return; - setSelection(id, selectionOfSpan(moved, sel), editorFor(buf, moved, copyEditor.format)); + setSelection(id, selectionOfSpan(moved, sel), editorFor(buf, moved, copyEditor.format), 'edit'); } diff --git a/lib/src/lib/copy-selection.ts b/lib/src/lib/copy-selection.ts index 90dbeda17..fe6df8f26 100644 --- a/lib/src/lib/copy-selection.ts +++ b/lib/src/lib/copy-selection.ts @@ -14,12 +14,12 @@ import { COPY_FLASH_MS, failCopy, flashCopy, getMouseSelectionState, TOUCH_COPY_ * says so instead, for that selection, and keeps it. */ export async function copySelection(terminalId: string, { touch = false }: { touch?: boolean } = {}): Promise { - const { selection, selectionIdentity, copyEditor, programCopy } = getMouseSelectionState(terminalId); + const { selection, selectionEpoch, copyEditor, programCopy } = getMouseSelectionState(terminalId); if (!selection || !copyEditor) return; const text = editorRendering(copyEditor, programCopy).text; if (!text) return; const copied = await writeTextToClipboard(text); - if (getMouseSelectionState(terminalId).selectionIdentity !== selectionIdentity) return; + if (getMouseSelectionState(terminalId).selectionEpoch !== selectionEpoch) return; if (!copied) { failCopy(terminalId); return; diff --git a/lib/src/lib/mouse-selection.test.ts b/lib/src/lib/mouse-selection.test.ts index 496fd0a89..b057bf73f 100644 --- a/lib/src/lib/mouse-selection.test.ts +++ b/lib/src/lib/mouse-selection.test.ts @@ -40,7 +40,7 @@ describe('mouse-selection: default state', () => { bracketedPaste: false, override: 'off', selection: null, - selectionIdentity: null, + selectionEpoch: 0, hintToken: null, copyEditor: null, programCopy: null, diff --git a/lib/src/lib/mouse-selection.ts b/lib/src/lib/mouse-selection.ts index 273e2e581..02aa81762 100644 --- a/lib/src/lib/mouse-selection.ts +++ b/lib/src/lib/mouse-selection.ts @@ -63,8 +63,9 @@ export interface MouseSelectionState { bracketedPaste: boolean; override: OverrideState; selection: Selection | null; - /** Stable through edge movement and reflow; cleared with the selection. */ - selectionIdentity: object | null; + /** Bumped when a selection ends or is replaced; held through an edit or + * follow ({@link SelectionWrite}). */ + selectionEpoch: number; /** The token under a drag's head, in absolute buffer rows. */ hintToken: BufferToken | null; /** Open while non-null: always the editor written with this exact @@ -86,7 +87,7 @@ export const DEFAULT_MOUSE_SELECTION_STATE: MouseSelectionState = Object.freeze( bracketedPaste: false, override: 'off', selection: null, - selectionIdentity: null, + selectionEpoch: 0, hintToken: null, copyEditor: null, programCopy: null, @@ -101,7 +102,7 @@ let cachedSnapshot: Map | null = null; * one cannot leave another's editor, offer, or flash behind. */ function clearSelection(s: MouseSelectionState): void { s.selection = null; - s.selectionIdentity = null; + s.selectionEpoch++; s.copyEditor = null; s.programCopy = null; s.copyOutcome = null; @@ -174,21 +175,25 @@ export function setOverride(id: string, override: OverrideState): void { notify(); } +/** How a write relates to the selection before it: `replace` starts another, + * `edit` moves its edges, and `follow` carries the same cells through a + * scroll, trim, or reflow, so only it keeps the program's offer. */ +export type SelectionWrite = 'replace' | 'edit' | 'follow'; + /** * Replace the selection and, in the same write, the copy editor over it: none * 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, { preserveProgramCopy = false } = {}): void { +export function setSelection(id: string, selection: Selection | null, copyEditor: CopyEditorState | null = null, write: SelectionWrite = 'replace'): void { const s = ensure(id); if (s.selection === null && selection === null) return; if (selection === null) clearSelection(s); else { - // Replacing keeps the flash and drag hint. Only following the same cells - // through scrolling may also retain the program's copy offer. - s.selectionIdentity ??= {}; + // Every write keeps the flash and drag hint. + if (write === 'replace') s.selectionEpoch++; s.selection = selection; - if (!preserveProgramCopy) s.programCopy = null; + if (write !== 'follow') s.programCopy = null; s.copyEditor = selection.dragging || !copyEditor ? null : copyEditor.format === 'program' && s.programCopy === null ? { ...copyEditor, format: 'auto', overrides: {} } : copyEditor; } @@ -226,7 +231,6 @@ export function beginDrag( // Clearing the in-flight copy flash too keeps its timer from clearing this // new selection when it fires. clearSelection(s); - s.selectionIdentity = {}; s.selection = { startRow: args.row, startCol: args.col, diff --git a/lib/src/lib/selection-reflow.ts b/lib/src/lib/selection-reflow.ts index 0428def97..efd8fa6f5 100644 --- a/lib/src/lib/selection-reflow.ts +++ b/lib/src/lib/selection-reflow.ts @@ -67,6 +67,13 @@ function locateEdge(buffer: IBuffer, edge: EdgeAnchor): GridPos | null { } } +/** A marker on absolute buffer row `row`; `registerMarker` counts from the + * cursor's row. */ +export function markRow(terminal: Terminal, row: number): IMarker { + const buffer = terminal.buffer.active; + return terminal.registerMarker(row - (buffer.baseY + buffer.cursorY)); +} + /** Anchor a finalized linewise selection Dormouse owns; null for anything a * resize cancels. The alternate buffer never reflows, and its program * redraws on resize. */ @@ -78,11 +85,7 @@ export function anchorSelection(terminal: Terminal, sel: Selection): ReflowAncho const start = lineOffset(buffer, from, 'start'); const end = lineOffset(buffer, to, 'end'); if (text === null || !start || !end) return null; - const mark = ({ first, offset }: { first: number; offset: number }): EdgeAnchor => ({ - // `registerMarker` counts from the cursor's row. - marker: terminal.registerMarker(first - (buffer.baseY + buffer.cursorY)), - offset, - }); + const mark = ({ first, offset }: { first: number; offset: number }): EdgeAnchor => ({ marker: markRow(terminal, first), offset }); const anchor: Anchor = { start: mark(start), end: mark(end), diff --git a/lib/src/lib/selection-watch.test.ts b/lib/src/lib/selection-watch.test.ts index bcdde27a2..8e3770eb9 100644 --- a/lib/src/lib/selection-watch.test.ts +++ b/lib/src/lib/selection-watch.test.ts @@ -38,22 +38,28 @@ describe('watchSelection', () => { watch.dispose(); }); - it.each(['linewise', 'block', 'program'] as const)('carries a %s selection when older scrollback is trimmed', async kind => { + it.each([ + ['linewise', {}], + ['block', { shape: 'block' }], + ['program', { owner: 'program' }], + ] as const)('carries a %s selection when older scrollback is trimmed', async (kind, over) => { 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 } : {}) })); + setSelection(id, finalizedSelection({ startRow: 2, endRow: 3, endCol: 5, ...over })); openCopyEditor(id, terminal); if (kind === 'program') { offerProgramCopy(id, 'source text'); setCopyFormat(id, 'program'); } + const markers = terminal.markers.map((m) => m.id); await write(terminal, '\r\ntail2'); watch.onRender(); expect(getMouseSelectionState(id).selection).toMatchObject({ startRow: 1, endRow: 2, endCol: 5 }); + // Following the trim reuses the markers that moved with it. + expect(terminal.markers.map((m) => m.id)).toEqual(markers); expect(getMouseSelectionState(id).copyEditor).not.toBeNull(); if (kind === 'program') { expect(getMouseSelectionState(id)).toMatchObject({ programCopy: 'source text', copyEditor: { format: 'program' } }); diff --git a/lib/src/lib/selection-watch.ts b/lib/src/lib/selection-watch.ts index b4cd32eae..7d0799a20 100644 --- a/lib/src/lib/selection-watch.ts +++ b/lib/src/lib/selection-watch.ts @@ -2,7 +2,7 @@ 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 { anchorSelection, followReflow, markRow, type ReflowAnchor } from './selection-reflow'; import { extractSelectionText } from './selection-text'; // A pane's selection held to the characters under it @@ -25,30 +25,33 @@ export function watchSelection(id: string, terminal: Terminal): SelectionWatch { 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; + let scrollMarker: IMarker | null = null; + /** While {@link onRender} follows a trim: the text matched and every marker + * moved with it, so its write needs no fresh readings. */ + let following = false; const unsubscribe = subscribeToMouseSelection(() => { const sel = getMouseSelectionState(id).selection; if (sel === selection) return; selection = sel; + if (following) return; 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 }; - } + scrollMarker?.dispose(); + scrollMarker = sel && !sel.dragging && terminal.buffer.active.type === 'normal' + ? markRow(terminal, Math.min(sel.startRow, sel.endRow)) : null; }); return { onRender() { if (!selection || baseline === null) return; - if (scrollAnchor?.marker.isDisposed) { setSelection(id, null); return; } - const shift = scrollAnchor ? scrollAnchor.marker.line - scrollAnchor.row : 0; + if (scrollMarker?.isDisposed) { setSelection(id, null); return; } + const shift = scrollMarker ? scrollMarker.line - Math.min(selection.startRow, selection.endRow) : 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)); + else if (moved !== selection) { + following = true; + try { followCopySelection(id, terminal, spanOfSelection(moved)); } finally { following = false; } + } }, onResize() { const span = anchor && followReflow(terminal, anchor); @@ -58,7 +61,7 @@ export function watchSelection(id: string, terminal: Terminal): SelectionWatch { dispose() { unsubscribe(); anchor?.dispose(); - scrollAnchor?.marker.dispose(); + scrollMarker?.dispose(); }, }; } diff --git a/lib/src/lib/terminal-lifecycle.selection.test.ts b/lib/src/lib/terminal-lifecycle.selection.test.ts index 05b50d316..cbba6994d 100644 --- a/lib/src/lib/terminal-lifecycle.selection.test.ts +++ b/lib/src/lib/terminal-lifecycle.selection.test.ts @@ -36,7 +36,7 @@ import { copySelection } from './copy-selection'; import { flipCopyBreak, openCopyEditor, setCopyFormat, setCopyScope } from './copy-editor'; import type { EditorFormat } from './copy-text'; import { finalizedSelection, WRAPPING } from './copy-text-fixtures'; -import { beginDrag, flashCopy, getMouseSelectionState, setSelection, type Selection } from './mouse-selection'; +import { beginDrag, COPY_FLASH_MS, flashCopy, getMouseSelectionState, setSelection, type Selection } from './mouse-selection'; import { getOrCreateTerminal, writeUserInput } from './terminal-registry'; const write = (terminal: Terminal, data: string) => new Promise((resolve) => terminal.write(data, resolve)); @@ -146,7 +146,7 @@ describe('a terminal resize', () => { complete(copied); await pending; expect(getMouseSelectionState(id)).toMatchObject({ selection: { endRow: 2, endCol: 1 }, copyOutcome: copied ? 'copied' : 'failed' }); - vi.advanceTimersByTime(1500); + vi.advanceTimersByTime(COPY_FLASH_MS); expect(getMouseSelectionState(id).selection === null).toBe(copied); } finally { vi.useRealTimers();