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.
This commit is contained in:
wutongyuonce 2026-09-16 18:18:05 +08:00
parent 2a40166396
commit aa5a86deee
2 changed files with 87 additions and 3 deletions

View file

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

View file

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