mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
Merge 3245ae2217 into 20d86c3657
This commit is contained in:
commit
8b648f179a
3 changed files with 263 additions and 6 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue