mirror of
https://github.com/earendil-works/pi.git
synced 2026-06-18 15:54:04 +08:00
fix(tui): release overlay focus to explicit targets
Add an explicit overlay unfocus target so callers can move input to the editor or another component while overlays remain visible. Align fallback overlay focus with visual focus order and cover blocked replacement release, null targets, and multi-overlay cycling.
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Added `OverlayHandle.unfocus({ target })` for explicitly releasing overlay focus to a chosen component while overlays remain visible.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed focused visible overlays losing input to base components after focus restoration behind the overlay ([#5129](https://github.com/earendil-works/pi/issues/5129)).
|
||||
|
||||
@@ -116,12 +116,14 @@ handle.setHidden(true); // Temporarily hide (can show again)
|
||||
handle.setHidden(false); // Show again after hiding
|
||||
handle.isHidden(); // Check if temporarily hidden
|
||||
handle.focus(); // Focus and bring to visual front
|
||||
handle.unfocus(); // Release focus to previous target
|
||||
handle.unfocus(); // Release focus to the next visible overlay or previous target
|
||||
handle.unfocus({ target: baseComponent }); // Release this overlay to a specific component
|
||||
handle.unfocus({ target: null }); // Release this overlay without focusing another component
|
||||
handle.isFocused(); // Check if overlay has focus
|
||||
|
||||
// A focused visible overlay keeps keyboard input until hidden or explicitly unfocused.
|
||||
// If you want a base component to receive input while the overlay remains visible,
|
||||
// call handle.unfocus() before focusing the base component.
|
||||
// A focused visible overlay reclaims keyboard input after temporary replacement UI
|
||||
// releases focus. If you want a specific component to receive input while overlays remain
|
||||
// visible, call handle.unfocus({ target: component }).
|
||||
|
||||
// Hide topmost overlay
|
||||
tui.hideOverlay();
|
||||
|
||||
@@ -99,6 +99,7 @@ export {
|
||||
type OverlayHandle,
|
||||
type OverlayMargin,
|
||||
type OverlayOptions,
|
||||
type OverlayUnfocusOptions,
|
||||
type SizeValue,
|
||||
TUI,
|
||||
} from "./tui.ts";
|
||||
|
||||
+31
-13
@@ -176,6 +176,12 @@ export interface OverlayOptions {
|
||||
nonCapturing?: boolean;
|
||||
}
|
||||
|
||||
/** Options for {@link OverlayHandle.unfocus}. */
|
||||
export interface OverlayUnfocusOptions {
|
||||
/** Explicit target to focus after releasing this overlay. */
|
||||
target: Component | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle returned by showOverlay for controlling the overlay
|
||||
*/
|
||||
@@ -188,8 +194,8 @@ export interface OverlayHandle {
|
||||
isHidden(): boolean;
|
||||
/** Focus this overlay and bring it to the visual front */
|
||||
focus(): void;
|
||||
/** Release focus to the previous target */
|
||||
unfocus(): void;
|
||||
/** Release focus to the next visible overlay or the previous target, or to an explicit target when provided */
|
||||
unfocus(options?: OverlayUnfocusOptions): void;
|
||||
/** Check if this overlay currently has focus */
|
||||
isFocused(): boolean;
|
||||
}
|
||||
@@ -328,7 +334,6 @@ export class TUI extends Container {
|
||||
restoreOverlay?.restoreFocus.status === "blocked" &&
|
||||
restoreOverlay.restoreFocus.blockedBy === previousFocus
|
||||
) {
|
||||
restoreOverlay.restoreFocus = { status: "eligible" };
|
||||
nextFocus = restoreOverlay.component;
|
||||
} else if (
|
||||
previousFocusedOverlay &&
|
||||
@@ -452,11 +457,23 @@ export class TUI extends Container {
|
||||
this.setFocus(component);
|
||||
this.requestRender();
|
||||
},
|
||||
unfocus: () => {
|
||||
if (this.focusedComponent !== component) return;
|
||||
unfocus: (unfocusOptions) => {
|
||||
const isFocused = this.focusedComponent === component;
|
||||
const hasPendingRestore = entry.restoreFocus.status !== "inactive";
|
||||
// Nothing to release: we neither hold focus nor have a pending reclaim.
|
||||
if (!isFocused && !hasPendingRestore) return;
|
||||
// True when this overlay is waiting to reclaim focus from the component that
|
||||
// currently holds it; computed before clearing the (about-to-be-reset) state.
|
||||
const blockedByFocused =
|
||||
entry.restoreFocus.status === "blocked" && this.focusedComponent === entry.restoreFocus.blockedBy;
|
||||
this.clearOverlayRestoreFocus(entry);
|
||||
const topVisible = this.getTopmostVisibleOverlay();
|
||||
this.setFocus(topVisible && topVisible !== entry ? topVisible.component : entry.preFocus);
|
||||
// Move focus only if we currently hold it, or the caller named an explicit
|
||||
// target and we're not mid-reclaim from the focused component.
|
||||
if (isFocused || (unfocusOptions && !blockedByFocused)) {
|
||||
const topVisible = this.getTopmostVisibleOverlay();
|
||||
const fallbackTarget = topVisible && topVisible !== entry ? topVisible.component : entry.preFocus;
|
||||
this.setFocus(unfocusOptions ? unfocusOptions.target : fallbackTarget);
|
||||
}
|
||||
this.requestRender();
|
||||
},
|
||||
isFocused: () => this.focusedComponent === component,
|
||||
@@ -491,15 +508,16 @@ export class TUI extends Container {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Find the topmost visible capturing overlay, if any */
|
||||
/** Find the visual-frontmost visible capturing overlay, if any */
|
||||
private getTopmostVisibleOverlay(): OverlayStackEntry | undefined {
|
||||
for (let i = this.overlayStack.length - 1; i >= 0; i--) {
|
||||
if (this.overlayStack[i].options?.nonCapturing) continue;
|
||||
if (this.isOverlayVisible(this.overlayStack[i])) {
|
||||
return this.overlayStack[i];
|
||||
let topmost: OverlayStackEntry | undefined;
|
||||
for (const overlay of this.overlayStack) {
|
||||
if (overlay.options?.nonCapturing || !this.isOverlayVisible(overlay)) continue;
|
||||
if (!topmost || overlay.focusOrder > topmost.focusOrder) {
|
||||
topmost = overlay;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
return topmost;
|
||||
}
|
||||
|
||||
override invalidate(): void {
|
||||
|
||||
@@ -391,6 +391,46 @@ describe("TUI overlay non-capturing", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("unfocus target releases a blocked overlay while replacement remains focused", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui = new TUI(terminal);
|
||||
const editor = new FocusableOverlay(["EDITOR"]);
|
||||
const replacement = new FocusableOverlay(["REPLACEMENT"]);
|
||||
const overlay = new FocusableOverlay(["OVERLAY"]);
|
||||
replacement.handleInput = (data: string) => {
|
||||
replacement.inputs.push(data);
|
||||
if (data === "\r") {
|
||||
tui.setFocus(editor);
|
||||
}
|
||||
};
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.setFocus(editor);
|
||||
tui.start();
|
||||
try {
|
||||
const overlayHandle = tui.showOverlay(overlay);
|
||||
overlay.handleInput = (data: string) => {
|
||||
overlay.inputs.push(data);
|
||||
if (data === "b") {
|
||||
tui.setFocus(replacement);
|
||||
overlayHandle.unfocus({ target: editor });
|
||||
}
|
||||
};
|
||||
terminal.sendInput("b");
|
||||
await renderAndFlush(tui, terminal);
|
||||
assert.strictEqual(replacement.focused, true);
|
||||
|
||||
terminal.sendInput("\r");
|
||||
terminal.sendInput("x");
|
||||
await renderAndFlush(tui, terminal);
|
||||
assert.deepStrictEqual(replacement.inputs, ["\r"]);
|
||||
assert.deepStrictEqual(overlay.inputs, ["b"]);
|
||||
assert.deepStrictEqual(editor.inputs, ["x"]);
|
||||
assert.strictEqual(editor.focused, true);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("handleInput restores focus to a visible focused overlay after base focus steal", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui = new TUI(terminal);
|
||||
@@ -771,6 +811,95 @@ describe("TUI overlay non-capturing", () => {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("explicit unfocus target supports cycling between three overlays and editor", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui = new TUI(terminal);
|
||||
const editor = new FocusableOverlay(["EDITOR"]);
|
||||
const a = new FocusableOverlay(["A"]);
|
||||
const b = new FocusableOverlay(["B"]);
|
||||
const c = new FocusableOverlay(["C"]);
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.setFocus(editor);
|
||||
tui.start();
|
||||
try {
|
||||
const aHandle = tui.showOverlay(a);
|
||||
const bHandle = tui.showOverlay(b);
|
||||
const cHandle = tui.showOverlay(c);
|
||||
|
||||
aHandle.focus();
|
||||
terminal.sendInput("a");
|
||||
await renderAndFlush(tui, terminal);
|
||||
bHandle.focus();
|
||||
terminal.sendInput("b");
|
||||
await renderAndFlush(tui, terminal);
|
||||
cHandle.focus();
|
||||
terminal.sendInput("c");
|
||||
await renderAndFlush(tui, terminal);
|
||||
cHandle.unfocus({ target: editor });
|
||||
terminal.sendInput("e");
|
||||
await renderAndFlush(tui, terminal);
|
||||
aHandle.focus();
|
||||
terminal.sendInput("A");
|
||||
await renderAndFlush(tui, terminal);
|
||||
aHandle.unfocus({ target: editor });
|
||||
terminal.sendInput("E");
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
assert.deepStrictEqual(a.inputs, ["a", "A"]);
|
||||
assert.deepStrictEqual(b.inputs, ["b"]);
|
||||
assert.deepStrictEqual(c.inputs, ["c"]);
|
||||
assert.deepStrictEqual(editor.inputs, ["e", "E"]);
|
||||
assert.strictEqual(editor.focused, true);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("explicit null unfocus target clears focus without restoring overlays", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui = new TUI(terminal);
|
||||
const overlay = new FocusableOverlay(["OVERLAY"]);
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.start();
|
||||
try {
|
||||
const handle = tui.showOverlay(overlay);
|
||||
handle.unfocus({ target: null });
|
||||
terminal.sendInput("x");
|
||||
await renderAndFlush(tui, terminal);
|
||||
assert.deepStrictEqual(overlay.inputs, []);
|
||||
assert.strictEqual(handle.isFocused(), false);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("hiding focused overlay falls back to next visual-frontmost overlay", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui = new TUI(terminal);
|
||||
const editor = new FocusableOverlay(["EDITOR"]);
|
||||
const a = new FocusableOverlay(["A"]);
|
||||
const b = new FocusableOverlay(["B"]);
|
||||
const c = new FocusableOverlay(["C"]);
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.setFocus(editor);
|
||||
tui.start();
|
||||
try {
|
||||
const aHandle = tui.showOverlay(a);
|
||||
const bHandle = tui.showOverlay(b);
|
||||
tui.showOverlay(c);
|
||||
aHandle.focus();
|
||||
bHandle.focus();
|
||||
bHandle.setHidden(true);
|
||||
terminal.sendInput("x");
|
||||
await renderAndFlush(tui, terminal);
|
||||
assert.deepStrictEqual(a.inputs, ["x"]);
|
||||
assert.deepStrictEqual(c.inputs, []);
|
||||
assert.strictEqual(a.focused, true);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("rendering order", () => {
|
||||
|
||||
Reference in New Issue
Block a user