diff --git a/packages/tui/src/tui-alt-screen.ts b/packages/tui/src/tui-alt-screen.ts index 54b277a33..709c22412 100644 --- a/packages/tui/src/tui-alt-screen.ts +++ b/packages/tui/src/tui-alt-screen.ts @@ -910,6 +910,20 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { if (this.handleSearchMouseEvent(raw)) return; + // A selection drag that is already in progress keeps the pointer, the same way a component + // that received the press owns the whole gesture. Without this, an overlay that handles mouse + // events swallows the motion and the release, and the drag dies at its edge. + if ( + type !== "press" && + ((raw.button & 3) === 0 || ((raw.button & 3) === 3 && type === "release")) && + this.selectionPressActive && + !this.mouseCapture && + !this.mousePressTarget + ) { + this.handleSelectionMouseEvent(raw); + return; + } + const overlay = this.dispatchMouseToOverlay(event); if (!overlay.hit) { if (this.handleScrollToEndIndicatorMouseEvent(raw)) return; diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index bad7e62df..a85fd44d6 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -1562,6 +1562,42 @@ describe("TuiAltScreen", () => { tui.stop(); }); + it("keeps an in-progress selection drag away from a mouse-handling excluded overlay", async () => { + const terminal = new RecordingTerminal(20, 2); + const copied: string[] = []; + const overlayEvents: 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 MouseRegion(new Text("SECRET", 0, 0), (event) => { + overlayEvents.push(event.type); + return { handled: true }; + }), + { 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"]); + assert.deepStrictEqual(overlayEvents, []); + + terminal.sendInput("\x1b[<0;7;1M"); + terminal.sendInput("\x1b[<0;7;1m"); + await terminal.waitForRender(); + assert.deepStrictEqual(overlayEvents, ["press", "release", "click"]); + assert.deepStrictEqual(copied, ["abcde"]); + 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);