fix(tui): keep in-progress selection drags away from overlays

Mouse dispatch asked overlays first and returned on `{ handled: true }`, so an
excluded overlay that handles mouse events swallowed the motion and release of a
drag that had started on selectable content. A persistent interactive sidebar broke
the drag at its edge and copied nothing.

An in-progress selection drag now owns the pointer until release, the way a component
that received the press owns its gesture. Overlays, the scroll-to-end indicator, the
scrollbar and the layout are skipped for those events; a press still routes normally,
so clicking inside an overlay keeps working.
This commit is contained in:
wutongyuonce 2026-09-16 18:19:00 +08:00
parent aa5a86deee
commit 3245ae2217
2 changed files with 50 additions and 0 deletions

View file

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

View file

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