diff --git a/packages/tui/src/tui-alt-screen.ts b/packages/tui/src/tui-alt-screen.ts index 8b7e8b870..f4d64e0ad 100644 --- a/packages/tui/src/tui-alt-screen.ts +++ b/packages/tui/src/tui-alt-screen.ts @@ -528,6 +528,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { private handleViewportInput(data: string): { consume?: boolean } | undefined { if (data === FOCUS_OUT) { const hadActiveSelection = this.selectionPressActive; + const hadNonEmptyActiveSelection = hadActiveSelection && this.getSelectionBounds() !== undefined; this.selectionPressActive = false; this.stopSelectionAutoScroll(); this.stopScrollbarHover(); @@ -539,9 +540,9 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { this.selectionFocus = undefined; this.selectionGranularity = "character"; this.selectionInitialRange = undefined; + if (hadNonEmptyActiveSelection) this.requestRender(); } this.lastClick = undefined; - this.requestRender(); return { consume: true }; } if (data === FOCUS_IN) return { consume: true }; diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index 7df229dbf..06b7dc0d4 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -1020,16 +1020,23 @@ describe("TuiAltScreen", () => { tui.stop(); }); - it("ignores orphan selection events and cancels an active selection on focus loss", async () => { + it("does not repaint idle or zero-width selections on focus loss", async () => { const terminal = new RecordingTerminal(20, 4); const tui = new TuiAltScreen(terminal); tui.addChild(new Text("alpha\nbeta\ngamma\ndelta", 0, 0)); tui.start(); await terminal.waitForRender(); + const writeCount = () => terminal.events.filter((event) => event.type === "write").length; const clipboardWriteCount = () => terminal.events.filter((event) => event.type === "write" && event.data.includes("\x1b]52;c;")).length; + const idleWriteCount = writeCount(); + terminal.sendInput("\x1b[O"); + terminal.sendInput("\x1b[I"); + await terminal.waitForRender(); + assert.strictEqual(writeCount(), idleWriteCount); + // A completed click leaves a zero-width anchor, but later orphaned drag/release events must not extend it. terminal.sendInput("\x1b[<0;1;1M"); terminal.sendInput("\x1b[<0;1;1m"); @@ -1038,10 +1045,14 @@ describe("TuiAltScreen", () => { await terminal.waitForRender(); assert.strictEqual(clipboardWriteCount(), 0); - // Losing focus also cancels a press whose matching release never arrived. - terminal.sendInput("\x1b[<0;1;1M"); + // Losing focus after a press without a drag cancels the press without repainting. + terminal.sendInput("\x1b[<0;1;3M"); + await terminal.waitForRender(); + const pressedWriteCount = writeCount(); terminal.sendInput("\x1b[O"); terminal.sendInput("\x1b[I"); + await terminal.waitForRender(); + assert.strictEqual(writeCount(), pressedWriteCount); terminal.sendInput("\x1b[<32;4;2M"); terminal.sendInput("\x1b[<0;4;2m"); await terminal.waitForRender(); @@ -1052,6 +1063,66 @@ describe("TuiAltScreen", () => { assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[?1004l"))); }); + it("clears an active visible selection on focus loss and ignores orphan events", async () => { + const terminal = new RecordingTerminal(20, 4); + const tui = new TuiAltScreen(terminal); + tui.addChild(new Text("alpha\nbeta\ngamma\ndelta", 0, 0)); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[<0;1;1M"); + terminal.sendInput("\x1b[<32;4;2M"); + await terminal.waitForRender(); + const focusLossEventCount = terminal.events.length; + terminal.sendInput("\x1b[O"); + terminal.sendInput("\x1b[I"); + await terminal.waitForRender(); + const focusLossWrites = terminal.events + .slice(focusLossEventCount) + .filter((event): event is { type: "write"; data: string } => event.type === "write") + .map((event) => event.data) + .join(""); + assert.ok(focusLossWrites.includes("alpha")); + assert.ok(focusLossWrites.includes("beta")); + assert.ok(!focusLossWrites.includes("\x1b[7m")); + + terminal.sendInput("\x1b[<32;4;2M"); + terminal.sendInput("\x1b[<0;4;2m"); + await terminal.waitForRender(); + assert.ok(terminal.events.every((event) => event.type !== "write" || !event.data.includes("\x1b]52;c;"))); + tui.stop(); + }); + + it("retains a completed visible selection across focus changes", async () => { + const terminal = new RecordingTerminal(20, 4); + const tui = new TuiAltScreen(terminal); + tui.addChild(new Text("alpha\nbeta\ngamma\ndelta", 0, 0)); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[<0;1;1M"); + terminal.sendInput("\x1b[<32;4;2M"); + terminal.sendInput("\x1b[<0;4;2m"); + await terminal.waitForRender(); + const completedWriteCount = terminal.events.filter((event) => event.type === "write").length; + terminal.sendInput("\x1b[O"); + terminal.sendInput("\x1b[I"); + await terminal.waitForRender(); + assert.strictEqual(terminal.events.filter((event) => event.type === "write").length, completedWriteCount); + + const redrawEventCount = terminal.events.length; + tui.renderNow(true); + const redrawWrites = terminal.events + .slice(redrawEventCount) + .filter((event): event is { type: "write"; data: string } => event.type === "write") + .map((event) => event.data) + .join(""); + assert.ok(redrawWrites.includes("alpha")); + assert.ok(redrawWrites.includes("beta")); + assert.ok(redrawWrites.includes("\x1b[7m")); + tui.stop(); + }); + it("stacks flash messages and collapses them as they expire", async () => { const terminal = new VirtualTerminal(20, 4); const tui = new TuiAltScreen(terminal);