diff --git a/packages/tui/src/tui-alt-screen.ts b/packages/tui/src/tui-alt-screen.ts index c99c9b3c5..54b277a33 100644 --- a/packages/tui/src/tui-alt-screen.ts +++ b/packages/tui/src/tui-alt-screen.ts @@ -1142,11 +1142,18 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { ); } - private getSelectionPoint(event: SgrMouseEvent, scrollView?: ScrollView): SelectionPoint | undefined { + private getSelectionPoint( + event: SgrMouseEvent, + scrollView?: ScrollView, + reference?: SelectionPoint, + ): SelectionPoint | undefined { const exclusion = this.getSelectionExclusionAt(event.x, event.y); if (exclusion) { if (!scrollView) return undefined; - const boundaryX = event.x < exclusion.col ? exclusion.col - 1 : exclusion.col + exclusion.width; + // Unlike a press, a drag may cross the excluded region: it has to snap to the + // edge the pointer came from, not always to the same one. + if (!reference) return undefined; + const boundaryX = reference.col > event.x ? exclusion.col + exclusion.width : exclusion.col - 1; const point = this.getScrollSelectionPoint(scrollView, boundaryX, event.y); if (point) return point; } @@ -1317,7 +1324,14 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { const button = event.button & 3; if (button !== 0 && !(event.release && button === 3)) return; const anchorScrollView = this.selectionAnchor?.scrollView; - const point = this.getSelectionPoint(event, anchorScrollView); + // Motion and release continue an in-progress drag; a bare press starts a new one and + // must not start inside an excluded overlay. + const continuing = event.release || (event.button & 32) !== 0; + const point = this.getSelectionPoint( + event, + anchorScrollView, + continuing ? (this.selectionFocus ?? this.selectionAnchor) : undefined, + ); if (!point) { this.selectionPressActive = false; this.stopSelectionAutoScroll(); diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index 42995fc28..bad7e62df 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -1492,6 +1492,76 @@ describe("TuiAltScreen", () => { tui.stop(); }); + it("snaps an excluded overlay drag to the edge the pointer came from", async () => { + const terminal = new RecordingTerminal(20, 2); + const copied: string[] = []; + const tui = new TuiAltScreen(terminal, undefined, undefined, { + copySelection: async (text) => { + copied.push(text); + return true; + }, + }); + tui.setLayoutRoot(new ScrollView(new Text("abcdefghijklmnopqrst", 0, 0), { primary: true })); + tui.showOverlay(new Text("SECRET", 0, 0), { + row: 0, + col: 5, + width: 6, + margin: 0, + nonCapturing: true, + selection: "exclude", + }); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[<0;1;1M"); + terminal.sendInput("\x1b[<32;7;1M"); + terminal.sendInput("\x1b[<0;7;1m"); + await terminal.waitForRender(); + assert.deepStrictEqual(copied, ["abcde"]); + + terminal.sendInput("\x1b[<0;14;1M"); + terminal.sendInput("\x1b[<32;7;1M"); + terminal.sendInput("\x1b[<0;7;1m"); + await terminal.waitForRender(); + assert.deepStrictEqual(copied, ["abcde", "lmn"]); + tui.stop(); + }); + + it("does not start a selection inside an excluded overlay while a selection exists", async () => { + const terminal = new RecordingTerminal(20, 2); + const copied: string[] = []; + const tui = new TuiAltScreen(terminal, undefined, undefined, { + copySelection: async (text) => { + copied.push(text); + return true; + }, + }); + tui.setLayoutRoot(new ScrollView(new Text("abcdefghijklmnopqrst", 0, 0), { primary: true })); + tui.showOverlay(new Text("SECRET", 0, 0), { + row: 0, + col: 5, + width: 6, + margin: 0, + nonCapturing: true, + selection: "exclude", + }); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[<0;1;1M"); + terminal.sendInput("\x1b[<32;4;1M"); + terminal.sendInput("\x1b[<0;4;1m"); + await terminal.waitForRender(); + assert.deepStrictEqual(copied, ["abcd"]); + + terminal.sendInput("\x1b[<0;8;1M"); + terminal.sendInput("\x1b[<32;3;1M"); + terminal.sendInput("\x1b[<0;3;1m"); + await terminal.waitForRender(); + assert.deepStrictEqual(copied, ["abcd"]); + tui.stop(); + }); + it("does not repaint idle or zero-width selections on focus loss", async () => { const terminal = new RecordingTerminal(20, 4); const tui = new TuiAltScreen(terminal);