diff --git a/packages/tui/src/tui-alt-screen.ts b/packages/tui/src/tui-alt-screen.ts index 61d5b4825..dd83409b8 100644 --- a/packages/tui/src/tui-alt-screen.ts +++ b/packages/tui/src/tui-alt-screen.ts @@ -38,6 +38,7 @@ import { compositeTuiLine, dispatchMouseEvent, type OverlayHandle, + type OverlaySelectionRegion, retargetMouseEvent, TuiBase, type TuiMouseButton, @@ -948,6 +949,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; @@ -1056,7 +1071,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { } private getScrollbarTargetAt(x: number, y: number, includeHiddenAuto = false): ScrollbarTarget | undefined { - if (this.hasOverlay() || !this.currentLayout) return undefined; + if (this.hasSelectionBlockingOverlay() || !this.currentLayout) return undefined; for (const scrollView of getScrollViewsAt(this.currentLayout, x, y)) { const box = getScrollViewBox(this.currentLayout, scrollView); const geometry = box ? getScrollbarGeometry(box, includeHiddenAuto) : undefined; @@ -1168,7 +1183,28 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { }; } - private getSelectionPoint(event: SgrMouseEvent, scrollView?: ScrollView): SelectionPoint { + private getSelectionExclusionAt(x: number, y: number): OverlaySelectionRegion | undefined { + return this.getOverlaySelectionExclusions().find( + (region) => + x >= region.col && x < region.col + region.width && y >= region.row && y < region.row + region.height, + ); + } + + private getSelectionPoint( + event: SgrMouseEvent, + scrollView?: ScrollView, + reference?: SelectionPoint, + ): SelectionPoint | undefined { + const exclusion = this.getSelectionExclusionAt(event.x, event.y); + if (exclusion) { + if (!scrollView) return undefined; + // 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; + } if (scrollView) { const point = this.getScrollSelectionPoint(scrollView, event.x, event.y); if (point) return point; @@ -1336,7 +1372,25 @@ 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(); + this.selectionAnchor = undefined; + this.selectionFocus = undefined; + this.selectionInitialRange = undefined; + this.selectionGranularity = "character"; + this.pressedUrl = undefined; + this.requestRender(); + return; + } if (event.release) { if (!this.selectionPressActive) return; this.selectionPressActive = false; @@ -1391,10 +1445,20 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { this.stopSelectionAutoScroll(); this.selectionPressActive = true; const scrollView = - !this.hasOverlay() && this.currentLayout + !this.hasSelectionBlockingOverlay() && this.currentLayout ? getScrollViewsAt(this.currentLayout, event.x, event.y)[0] : undefined; const anchor = this.getSelectionPoint(event, scrollView); + if (!anchor) { + this.selectionPressActive = false; + this.selectionAnchor = undefined; + this.selectionFocus = undefined; + this.selectionInitialRange = undefined; + this.selectionGranularity = "character"; + this.pressedUrl = undefined; + this.requestRender(); + return; + } const word = this.getWordSelection(anchor); const clickCount = this.getClickCount(anchor, word); const range = clickCount === 2 ? word : clickCount === 3 ? this.getLineSelection(anchor) : undefined; diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index fe0f2c7d8..ea0fc33fb 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -293,6 +293,19 @@ export interface OverlayOptions { visible?: (termWidth: number, termHeight: number) => boolean; /** If true, don't capture keyboard focus when shown */ nonCapturing?: boolean; + /** + * Control whether fullscreen text selection includes this overlay. + * Defaults to "normal" to preserve existing behavior. + */ + selection?: "normal" | "exclude"; +} + +/** Screen region occupied by an overlay excluded from fullscreen text selection. */ +export interface OverlaySelectionRegion { + row: number; + col: number; + width: number; + height: number; } /** Options for {@link OverlayHandle.unfocus}. */ @@ -539,6 +552,7 @@ export abstract class TuiBase extends Container implements TUI { // Overlay stack for modal components rendered on top of base content private focusOrderCounter = 0; private overlayStack: OverlayStackEntry[] = []; + private overlaySelectionExclusions: OverlaySelectionRegion[] = []; private renderedOverlayLayouts: RenderedOverlayLayout[] = []; get hasOverlayEntries(): boolean { @@ -880,6 +894,16 @@ export abstract class TuiBase extends Container implements TUI { return this.overlayStack.some((o) => this.isOverlayVisible(o)); } + /** Check whether a visible overlay should block fullscreen text selection. */ + protected hasSelectionBlockingOverlay(): boolean { + return this.overlayStack.some((entry) => this.isOverlayVisible(entry) && entry.options?.selection !== "exclude"); + } + + /** Get the screen regions occupied by overlays excluded from text selection. */ + protected getOverlaySelectionExclusions(): readonly OverlaySelectionRegion[] { + return this.overlaySelectionExclusions; + } + /** Check if the focused component is a visible overlay */ protected isOverlayFocused(): boolean { return this.overlayStack.some( @@ -1381,6 +1405,7 @@ export abstract class TuiBase extends Container implements TUI { /** Composite all overlays into content lines (sorted by focusOrder, higher = on top). */ protected compositeOverlays(lines: string[], termWidth: number, termHeight: number): string[] { if (this.overlayStack.length === 0) { + this.overlaySelectionExclusions = []; this.renderedOverlayLayouts = []; return lines; } @@ -1389,7 +1414,14 @@ export abstract class TuiBase extends Container implements TUI { for (const entry of this.overlayStack) entry.bounds = undefined; // Pre-render all visible overlays and calculate positions - const rendered: { entry: OverlayStackEntry; overlayLines: string[]; row: number; col: number; w: number }[] = []; + const rendered: { + entry: OverlayStackEntry; + overlayLines: string[]; + row: number; + col: number; + w: number; + selection: OverlayOptions["selection"]; + }[] = []; let minLinesNeeded = result.length; const visibleEntries = this.overlayStack.filter((e) => this.isOverlayVisible(e)); @@ -1413,7 +1445,14 @@ export abstract class TuiBase extends Container implements TUI { const { row, col } = this.resolveOverlayLayout(options, overlayLines.length, termWidth, termHeight); entry.bounds = { row, col, width, height: overlayLines.length }; - rendered.push({ entry, overlayLines, row, col, w: width }); + rendered.push({ + entry, + overlayLines, + row, + col, + w: width, + selection: options?.selection, + }); minLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length); } this.renderedOverlayLayouts = rendered.map(({ entry, row, col, w, overlayLines }) => ({ @@ -1435,6 +1474,14 @@ export abstract class TuiBase extends Container implements TUI { } const viewportStart = Math.max(0, workingHeight - termHeight); + this.overlaySelectionExclusions = rendered + .filter(({ selection }) => selection === "exclude") + .map(({ row, col, w, overlayLines }) => ({ + row, + col, + width: w, + height: overlayLines.length, + })); // Composite each overlay for (const { overlayLines, row, col, w } of rendered) { diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index 0428a01d2..c650c0209 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -1705,6 +1705,152 @@ describe("TuiAltScreen", () => { tui.stop(); }); + it("keeps excluded overlays out of fullscreen text selection", 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; + }, + }); + const transcript = new ScrollView(new Text("alpha\nbeta", 0, 0), { primary: true }); + tui.setLayoutRoot( + new HStack([ + { component: transcript, basis: 15, shrink: 0 }, + { component: new Text("", 0, 0), basis: 5, shrink: 0 }, + ]), + ); + tui.showOverlay(new Text("SIDE\nPANEL", 0, 0), { + anchor: "top-right", + width: 5, + margin: 0, + nonCapturing: true, + selection: "exclude", + }); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[<0;1;1M"); + terminal.sendInput("\x1b[<32;18;2M"); + terminal.sendInput("\x1b[<0;18;2m"); + await terminal.waitForRender(); + + assert.deepStrictEqual(copied, ["alpha\nbeta"]); + + terminal.sendInput("\x1b[<0;18;1M"); + terminal.sendInput("\x1b[<0;18;1m"); + await terminal.waitForRender(); + assert.deepStrictEqual(copied, ["alpha\nbeta"]); + 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("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);