This commit is contained in:
梧桐雨 2026-11-05 10:26:45 -08:00 • committed by GitHub
commit 8b648f179a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 263 additions and 6 deletions

View file

@ -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;

View file

@ -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) {

View file

@ -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);