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]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added `OverlayHandle.unfocus({ target })` for explicitly releasing overlay focus to a chosen component while overlays remain visible.
|
||||||
|
|
||||||
### Fixed
|
### 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)).
|
- 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.setHidden(false); // Show again after hiding
|
||||||
handle.isHidden(); // Check if temporarily hidden
|
handle.isHidden(); // Check if temporarily hidden
|
||||||
handle.focus(); // Focus and bring to visual front
|
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
|
handle.isFocused(); // Check if overlay has focus
|
||||||
|
|
||||||
// A focused visible overlay keeps keyboard input until hidden or explicitly unfocused.
|
// A focused visible overlay reclaims keyboard input after temporary replacement UI
|
||||||
// If you want a base component to receive input while the overlay remains visible,
|
// releases focus. If you want a specific component to receive input while overlays remain
|
||||||
// call handle.unfocus() before focusing the base component.
|
// visible, call handle.unfocus({ target: component }).
|
||||||
|
|
||||||
// Hide topmost overlay
|
// Hide topmost overlay
|
||||||
tui.hideOverlay();
|
tui.hideOverlay();
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export {
|
|||||||
type OverlayHandle,
|
type OverlayHandle,
|
||||||
type OverlayMargin,
|
type OverlayMargin,
|
||||||
type OverlayOptions,
|
type OverlayOptions,
|
||||||
|
type OverlayUnfocusOptions,
|
||||||
type SizeValue,
|
type SizeValue,
|
||||||
TUI,
|
TUI,
|
||||||
} from "./tui.ts";
|
} from "./tui.ts";
|
||||||
|
|||||||
@@ -176,6 +176,12 @@ export interface OverlayOptions {
|
|||||||
nonCapturing?: boolean;
|
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
|
* Handle returned by showOverlay for controlling the overlay
|
||||||
*/
|
*/
|
||||||
@@ -188,8 +194,8 @@ export interface OverlayHandle {
|
|||||||
isHidden(): boolean;
|
isHidden(): boolean;
|
||||||
/** Focus this overlay and bring it to the visual front */
|
/** Focus this overlay and bring it to the visual front */
|
||||||
focus(): void;
|
focus(): void;
|
||||||
/** Release focus to the previous target */
|
/** Release focus to the next visible overlay or the previous target, or to an explicit target when provided */
|
||||||
unfocus(): void;
|
unfocus(options?: OverlayUnfocusOptions): void;
|
||||||
/** Check if this overlay currently has focus */
|
/** Check if this overlay currently has focus */
|
||||||
isFocused(): boolean;
|
isFocused(): boolean;
|
||||||
}
|
}
|
||||||
@@ -328,7 +334,6 @@ export class TUI extends Container {
|
|||||||
restoreOverlay?.restoreFocus.status === "blocked" &&
|
restoreOverlay?.restoreFocus.status === "blocked" &&
|
||||||
restoreOverlay.restoreFocus.blockedBy === previousFocus
|
restoreOverlay.restoreFocus.blockedBy === previousFocus
|
||||||
) {
|
) {
|
||||||
restoreOverlay.restoreFocus = { status: "eligible" };
|
|
||||||
nextFocus = restoreOverlay.component;
|
nextFocus = restoreOverlay.component;
|
||||||
} else if (
|
} else if (
|
||||||
previousFocusedOverlay &&
|
previousFocusedOverlay &&
|
||||||
@@ -452,11 +457,23 @@ export class TUI extends Container {
|
|||||||
this.setFocus(component);
|
this.setFocus(component);
|
||||||
this.requestRender();
|
this.requestRender();
|
||||||
},
|
},
|
||||||
unfocus: () => {
|
unfocus: (unfocusOptions) => {
|
||||||
if (this.focusedComponent !== component) return;
|
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);
|
this.clearOverlayRestoreFocus(entry);
|
||||||
|
// 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 topVisible = this.getTopmostVisibleOverlay();
|
||||||
this.setFocus(topVisible && topVisible !== entry ? topVisible.component : entry.preFocus);
|
const fallbackTarget = topVisible && topVisible !== entry ? topVisible.component : entry.preFocus;
|
||||||
|
this.setFocus(unfocusOptions ? unfocusOptions.target : fallbackTarget);
|
||||||
|
}
|
||||||
this.requestRender();
|
this.requestRender();
|
||||||
},
|
},
|
||||||
isFocused: () => this.focusedComponent === component,
|
isFocused: () => this.focusedComponent === component,
|
||||||
@@ -491,15 +508,16 @@ export class TUI extends Container {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Find the topmost visible capturing overlay, if any */
|
/** Find the visual-frontmost visible capturing overlay, if any */
|
||||||
private getTopmostVisibleOverlay(): OverlayStackEntry | undefined {
|
private getTopmostVisibleOverlay(): OverlayStackEntry | undefined {
|
||||||
for (let i = this.overlayStack.length - 1; i >= 0; i--) {
|
let topmost: OverlayStackEntry | undefined;
|
||||||
if (this.overlayStack[i].options?.nonCapturing) continue;
|
for (const overlay of this.overlayStack) {
|
||||||
if (this.isOverlayVisible(this.overlayStack[i])) {
|
if (overlay.options?.nonCapturing || !this.isOverlayVisible(overlay)) continue;
|
||||||
return this.overlayStack[i];
|
if (!topmost || overlay.focusOrder > topmost.focusOrder) {
|
||||||
|
topmost = overlay;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return undefined;
|
return topmost;
|
||||||
}
|
}
|
||||||
|
|
||||||
override invalidate(): void {
|
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 () => {
|
it("handleInput restores focus to a visible focused overlay after base focus steal", async () => {
|
||||||
const terminal = new VirtualTerminal(80, 24);
|
const terminal = new VirtualTerminal(80, 24);
|
||||||
const tui = new TUI(terminal);
|
const tui = new TUI(terminal);
|
||||||
@@ -771,6 +811,95 @@ describe("TUI overlay non-capturing", () => {
|
|||||||
tui.stop();
|
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", () => {
|
describe("rendering order", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user