From aa5a86deee052333be89035e0cf5834c9c46c3bf Mon Sep 17 00:00:00 2001 From: wutongyuonce <147830929+wutongyuonce@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:18:05 +0800 Subject: [PATCH] fix(tui): snap excluded overlay drags to the approach side `getSelectionPoint` picked the exclusion boundary with `event.x < exclusion.col`, but that branch is only reached when the pointer is already inside the region, so the comparison was always false and every drag snapped to the right edge. Dragging into an excluded overlay from the left therefore also selected the text the overlay covers. The edge now follows the pointer's previous position. The reference point is passed only for motion and release, so a fresh press inside an excluded overlay still cannot start a selection. --- packages/tui/src/tui-alt-screen.ts | 20 ++++++- packages/tui/test/tui-alt-screen.test.ts | 70 ++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 3 deletions(-) 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);