fix(tui): harden overlay focus restoration

This commit is contained in:
Nico Bailon
2026-05-30 12:48:05 -07:00
parent 82f29ea442
commit 91a2f86600
8 changed files with 542 additions and 176 deletions

View File

@@ -2,14 +2,6 @@
## [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)).
## [0.78.0] - 2026-05-29
### Fixed

View File

@@ -116,11 +116,17 @@ 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 the next visible overlay or previous target
handle.unfocus(); // Release focus to normal fallback
handle.unfocus({ target: baseComponent }); // Release this overlay to a specific component
handle.unfocus({ target: null }); // Release this overlay without focusing another component
handle.unfocus({ target: null }); // Release this overlay and leave focus empty
handle.isFocused(); // Check if overlay has focus
handle.unfocus();
// Overlay loses focus; TUI falls back to another visible capturing overlay or the previous focus target.
handle.unfocus({ target: null });
// Overlay loses focus; no component receives input until focus is set again.
// 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 }).

View File

@@ -194,25 +194,32 @@ export interface OverlayHandle {
isHidden(): boolean;
/** Focus this overlay and bring it to the visual front */
focus(): void;
/** Release focus to the next visible overlay or the previous target, or to an explicit target when provided */
/** Release focus to the next visible capturing overlay or previous target, or to an explicit target when provided */
unfocus(options?: OverlayUnfocusOptions): void;
/** Check if this overlay currently has focus */
isFocused(): boolean;
}
type ActiveOverlayRestoreFocusState = { status: "eligible" } | { status: "blocked"; blockedBy: Component };
type OverlayRestoreFocusState = { status: "inactive" } | ActiveOverlayRestoreFocusState;
type RestorableOverlayStackEntry = OverlayStackEntry & { restoreFocus: ActiveOverlayRestoreFocusState };
type OverlayStackEntry = {
component: Component;
options: OverlayOptions | undefined;
options?: OverlayOptions;
preFocus: Component | null;
hidden: boolean;
focusOrder: number;
restoreFocus: OverlayRestoreFocusState;
};
type OverlayBlockedFocusResume = { status: "restore-overlay" } | { status: "focus-target"; target: Component | null };
type EligibleOverlayFocusRestoreState = { status: "eligible"; overlay: OverlayStackEntry };
type BlockedOverlayFocusRestoreState = {
status: "blocked";
overlay: OverlayStackEntry;
blockedBy: Component;
resume: OverlayBlockedFocusResume;
};
type ActiveOverlayFocusRestoreState = EligibleOverlayFocusRestoreState | BlockedOverlayFocusRestoreState;
type OverlayFocusRestoreState = { status: "inactive" } | ActiveOverlayFocusRestoreState;
type OverlayFocusRestorePolicy = "clear" | "preserve";
/**
* Container - a component that contains other components
*/
@@ -282,6 +289,7 @@ export class TUI extends Container {
// Overlay stack for modal components rendered on top of base content
private focusOrderCounter = 0;
private overlayStack: OverlayStackEntry[] = [];
private overlayFocusRestore: OverlayFocusRestoreState = { status: "inactive" };
constructor(terminal: Terminal, showHardwareCursor?: boolean) {
super();
@@ -322,36 +330,62 @@ export class TUI extends Container {
}
setFocus(component: Component | null): void {
this.setFocusInternal({ component, overlayFocusRestore: "clear" });
}
private setFocusInternal({
component,
overlayFocusRestore,
}: {
component: Component | null;
overlayFocusRestore: OverlayFocusRestorePolicy;
}): void {
const previousFocus = this.focusedComponent;
let nextFocus = component;
const previousFocusedOverlay = previousFocus
? this.overlayStack.find((entry) => entry.component === previousFocus && this.isOverlayVisible(entry))
: undefined;
const nextFocusIsOverlay = nextFocus ? this.overlayStack.some((entry) => entry.component === nextFocus) : false;
const restoreState = this.getVisibleOverlayFocusRestore();
if (nextFocus && !nextFocusIsOverlay) {
const restoreOverlay = this.getRestoreFocusOverlay();
if (
restoreOverlay?.restoreFocus.status === "blocked" &&
restoreOverlay.restoreFocus.blockedBy === previousFocus
) {
nextFocus = restoreOverlay.component;
if (restoreState.status === "blocked" && restoreState.blockedBy === previousFocus) {
if (restoreState.resume.status === "focus-target" || !this.isComponentMounted(restoreState.blockedBy)) {
nextFocus = this.resolveBlockedOverlayFocusResume(restoreState);
} else {
this.overlayFocusRestore = {
status: "blocked",
overlay: restoreState.overlay,
blockedBy: nextFocus,
resume: restoreState.resume,
};
}
} else if (
previousFocusedOverlay &&
previousFocusedOverlay.restoreFocus.status !== "inactive" &&
restoreState.status !== "inactive" &&
restoreState.overlay === previousFocusedOverlay &&
!this.isOverlayFocusAncestor(previousFocusedOverlay, nextFocus)
) {
previousFocusedOverlay.restoreFocus = { status: "blocked", blockedBy: nextFocus };
this.overlayFocusRestore = {
status: "blocked",
overlay: previousFocusedOverlay,
blockedBy: nextFocus,
resume: { status: "restore-overlay" },
};
}
} else if (nextFocus === null) {
if (restoreState.status === "blocked" && restoreState.blockedBy === previousFocus) {
nextFocus = this.resolveBlockedOverlayFocusResume(restoreState);
} else if (overlayFocusRestore === "clear") {
this.clearOverlayFocusRestore();
}
}
// Clear focused flag on old component
if (isFocusable(this.focusedComponent)) {
this.focusedComponent.focused = false;
}
this.focusedComponent = nextFocus;
// Set focused flag on new component
if (isFocusable(nextFocus)) {
nextFocus.focused = true;
}
@@ -360,26 +394,33 @@ export class TUI extends Container {
? this.overlayStack.find((entry) => entry.component === nextFocus && this.isOverlayVisible(entry))
: undefined;
if (focusedOverlay) {
this.markOverlayRestoreFocusEligible(focusedOverlay);
this.overlayFocusRestore = { status: "eligible", overlay: focusedOverlay };
}
}
private markOverlayRestoreFocusEligible(entry: OverlayStackEntry): void {
for (const overlay of this.overlayStack) {
this.clearOverlayRestoreFocus(overlay);
private clearOverlayFocusRestore(): void {
this.overlayFocusRestore = { status: "inactive" };
}
private clearOverlayFocusRestoreFor(overlay: OverlayStackEntry): void {
if (this.overlayFocusRestore.status !== "inactive" && this.overlayFocusRestore.overlay === overlay) {
this.clearOverlayFocusRestore();
}
entry.restoreFocus = { status: "eligible" };
}
private clearOverlayRestoreFocus(entry: OverlayStackEntry): void {
entry.restoreFocus = { status: "inactive" };
private resolveBlockedOverlayFocusResume(restoreState: BlockedOverlayFocusRestoreState): Component | null {
if (restoreState.resume.status === "restore-overlay") return restoreState.overlay.component;
this.clearOverlayFocusRestore();
return restoreState.resume.target;
}
private getRestoreFocusOverlay(): RestorableOverlayStackEntry | undefined {
return this.overlayStack.find(
(overlay): overlay is RestorableOverlayStackEntry =>
overlay.restoreFocus.status !== "inactive" && this.isOverlayVisible(overlay),
);
private getVisibleOverlayFocusRestore(): OverlayFocusRestoreState {
const restoreState = this.overlayFocusRestore;
if (restoreState.status === "inactive") return restoreState;
if (!this.overlayStack.includes(restoreState.overlay) || !this.isOverlayVisible(restoreState.overlay)) {
return { status: "inactive" };
}
return restoreState;
}
private isOverlayFocusAncestor(entry: OverlayStackEntry, component: Component): boolean {
@@ -393,6 +434,24 @@ export class TUI extends Container {
return false;
}
private retargetOverlayPreFocus(removed: OverlayStackEntry): void {
for (const overlay of this.overlayStack) {
if (overlay !== removed && overlay.preFocus === removed.component) {
overlay.preFocus = removed.preFocus;
}
}
}
private isComponentMounted(component: Component): boolean {
return this.children.some((child) => this.containsComponent(child, component));
}
private containsComponent(root: Component, target: Component): boolean {
if (root === target) return true;
if (!(root instanceof Container)) return false;
return root.children.some((child) => this.containsComponent(child, target));
}
/**
* Show an overlay component with configurable positioning and sizing.
* Returns a handle to control the overlay's visibility.
@@ -400,11 +459,10 @@ export class TUI extends Container {
showOverlay(component: Component, options?: OverlayOptions): OverlayHandle {
const entry: OverlayStackEntry = {
component,
options,
...(options === undefined ? {} : { options }),
preFocus: this.focusedComponent,
hidden: false,
focusOrder: ++this.focusOrderCounter,
restoreFocus: { status: "inactive" },
};
this.overlayStack.push(entry);
// Only focus if overlay is actually visible
@@ -419,7 +477,8 @@ export class TUI extends Container {
hide: () => {
const index = this.overlayStack.indexOf(entry);
if (index !== -1) {
this.clearOverlayRestoreFocus(entry);
this.clearOverlayFocusRestoreFor(entry);
this.retargetOverlayPreFocus(entry);
this.overlayStack.splice(index, 1);
// Restore focus if this overlay had focus
if (this.focusedComponent === component) {
@@ -435,7 +494,7 @@ export class TUI extends Container {
entry.hidden = hidden;
// Update focus when hiding/showing
if (hidden) {
this.clearOverlayRestoreFocus(entry);
this.clearOverlayFocusRestoreFor(entry);
// If this overlay had focus, move focus to next visible or preFocus
if (this.focusedComponent === component) {
const topVisible = this.getTopmostVisibleOverlay();
@@ -459,17 +518,29 @@ export class TUI extends Container {
},
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.
const restoreState = this.overlayFocusRestore;
const hasPendingRestore = restoreState.status !== "inactive" && restoreState.overlay === entry;
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);
// 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)) {
if (
restoreState.status === "blocked" &&
restoreState.overlay === entry &&
this.focusedComponent === restoreState.blockedBy
) {
if (unfocusOptions) {
this.overlayFocusRestore = {
status: "blocked",
overlay: entry,
blockedBy: restoreState.blockedBy,
resume: { status: "focus-target", target: unfocusOptions.target },
};
} else {
this.clearOverlayFocusRestore();
}
this.requestRender();
return;
}
this.clearOverlayFocusRestoreFor(entry);
if (isFocused || unfocusOptions) {
const topVisible = this.getTopmostVisibleOverlay();
const fallbackTarget = topVisible && topVisible !== entry ? topVisible.component : entry.preFocus;
this.setFocus(unfocusOptions ? unfocusOptions.target : fallbackTarget);
@@ -482,9 +553,11 @@ export class TUI extends Container {
/** Hide the topmost overlay and restore previous focus. */
hideOverlay(): void {
const overlay = this.overlayStack.pop();
const overlay = this.overlayStack[this.overlayStack.length - 1];
if (!overlay) return;
this.clearOverlayRestoreFocus(overlay);
this.clearOverlayFocusRestoreFor(overlay);
this.retargetOverlayPreFocus(overlay);
this.overlayStack.pop();
if (this.focusedComponent === overlay.component) {
// Find topmost visible overlay, or fall back to preFocus
const topVisible = this.getTopmostVisibleOverlay();
@@ -666,20 +739,22 @@ export class TUI extends Container {
if (topVisible) {
this.setFocus(topVisible.component);
} else {
// No visible overlays, restore to preFocus
this.setFocus(focusedOverlay.preFocus);
this.setFocusInternal({ component: focusedOverlay.preFocus, overlayFocusRestore: "preserve" });
}
}
const focusIsOverlay = this.overlayStack.some((o) => o.component === this.focusedComponent);
if (!focusIsOverlay) {
const overlayToRestore = this.getRestoreFocusOverlay();
if (
overlayToRestore &&
(overlayToRestore.restoreFocus.status === "eligible" ||
overlayToRestore.restoreFocus.blockedBy !== this.focusedComponent)
) {
this.setFocus(overlayToRestore.component);
const restoreState = this.getVisibleOverlayFocusRestore();
if (restoreState.status === "eligible") {
this.setFocus(restoreState.overlay.component);
} else if (restoreState.status === "blocked" && restoreState.blockedBy !== this.focusedComponent) {
if (restoreState.resume.status === "restore-overlay") {
this.setFocus(restoreState.overlay.component);
} else {
this.clearOverlayFocusRestore();
this.setFocus(restoreState.resume.target);
}
}
}

View File

@@ -1,7 +1,7 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import type { Component, Focusable } from "../src/tui.ts";
import { TUI } from "../src/tui.ts";
import { Container, TUI } from "../src/tui.ts";
import { VirtualTerminal } from "./virtual-terminal.ts";
class StaticOverlay implements Component {
@@ -222,6 +222,35 @@ describe("TUI overlay non-capturing", () => {
}
});
it("removed focused child overlay does not become parent overlay fallback", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const editor = new FocusableOverlay(["EDITOR"]);
const child = new FocusableOverlay(["CHILD"]);
const parent = new FocusableOverlay(["PARENT"]);
tui.addChild(new EmptyContent());
tui.setFocus(editor);
tui.start();
try {
const childHandle = tui.showOverlay(child, { nonCapturing: true });
childHandle.focus();
const parentHandle = tui.showOverlay(parent);
assert.strictEqual(parent.focused, true);
childHandle.hide();
parentHandle.hide();
terminal.sendInput("x");
await renderAndFlush(tui, terminal);
assert.deepStrictEqual(editor.inputs, ["x"]);
assert.deepStrictEqual(child.inputs, []);
assert.deepStrictEqual(parent.inputs, []);
assert.strictEqual(editor.focused, true);
} finally {
tui.stop();
}
});
it("microtask-deferred sub-overlay pattern (showExtensionCustom simulation) restores focus", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
@@ -234,18 +263,18 @@ describe("TUI overlay non-capturing", () => {
try {
// Simulate showExtensionCustom: factory creates timer synchronously,
// then .then() pushes controller as a microtask
let timerHandle: ReturnType<typeof tui.showOverlay>;
let timerHandle: ReturnType<typeof tui.showOverlay> | null = null;
let doneFn: () => void = () => {
throw new Error("doneFn was not initialized");
};
const overlayPromise = new Promise<void>((resolve) => {
doneFn = () => {
if (!timerHandle) throw new Error("timerHandle was not initialized");
timerHandle.hide();
tui.hideOverlay();
resolve();
};
// Factory runs synchronously: creates timer sub-overlay
timerHandle = tui.showOverlay(timer, { nonCapturing: true });
// .then() runs as microtask — same as showExtensionCustom
Promise.resolve(controller).then((c) => {
@@ -253,8 +282,7 @@ describe("TUI overlay non-capturing", () => {
});
});
// Wait for .then() microtask and renders to settle
await new Promise<void>((r) => setTimeout(r, 50));
await Promise.resolve();
await renderAndFlush(tui, terminal);
assert.strictEqual(controller.focused, true);
@@ -391,20 +419,110 @@ describe("TUI overlay non-capturing", () => {
}
});
it("blocked replacement can move focus internally before overlay restore", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const base = new Container();
const editor = new FocusableOverlay(["EDITOR"]);
const firstReplacement = new FocusableOverlay(["FIRST"]);
const secondReplacement = new FocusableOverlay(["SECOND"]);
const overlay = new FocusableOverlay(["OVERLAY"]);
overlay.handleInput = (data: string) => {
overlay.inputs.push(data);
if (data === "b") tui.setFocus(firstReplacement);
};
firstReplacement.handleInput = (data: string) => {
firstReplacement.inputs.push(data);
if (data === "n") tui.setFocus(secondReplacement);
};
secondReplacement.handleInput = (data: string) => {
secondReplacement.inputs.push(data);
if (data === "\r") {
base.clear();
base.addChild(editor);
tui.setFocus(editor);
}
};
base.addChild(editor);
base.addChild(firstReplacement);
base.addChild(secondReplacement);
tui.addChild(base);
tui.setFocus(editor);
tui.start();
try {
tui.showOverlay(overlay);
terminal.sendInput("b");
await renderAndFlush(tui, terminal);
terminal.sendInput("n");
await renderAndFlush(tui, terminal);
terminal.sendInput("2");
terminal.sendInput("\r");
await renderAndFlush(tui, terminal);
assert.deepStrictEqual(overlay.inputs, ["b"]);
assert.deepStrictEqual(firstReplacement.inputs, ["n"]);
assert.deepStrictEqual(secondReplacement.inputs, ["2", "\r"]);
assert.strictEqual(overlay.focused, true);
} finally {
tui.stop();
}
});
it("removed replacement restores overlay even when overlay preFocus differs from next focus", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const base = new Container();
const editor = new FocusableOverlay(["EDITOR"]);
const palette = new FocusableOverlay(["PALETTE"]);
const replacement = new FocusableOverlay(["REPLACEMENT"]);
const overlay = new FocusableOverlay(["OVERLAY"]);
overlay.handleInput = (data: string) => {
overlay.inputs.push(data);
if (data === "b") tui.setFocus(replacement);
};
replacement.handleInput = (data: string) => {
replacement.inputs.push(data);
if (data === "\r") {
base.clear();
base.addChild(editor);
tui.setFocus(editor);
}
};
base.addChild(editor);
base.addChild(palette);
base.addChild(replacement);
tui.addChild(base);
tui.setFocus(palette);
tui.start();
try {
tui.showOverlay(overlay);
terminal.sendInput("b");
await renderAndFlush(tui, terminal);
terminal.sendInput("\r");
terminal.sendInput("x");
await renderAndFlush(tui, terminal);
assert.deepStrictEqual(overlay.inputs, ["b", "x"]);
assert.deepStrictEqual(replacement.inputs, ["\r"]);
assert.deepStrictEqual(editor.inputs, []);
assert.strictEqual(overlay.focused, true);
} finally {
tui.stop();
}
});
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 fallback = new FocusableOverlay(["FALLBACK"]);
const target = new FocusableOverlay(["TARGET"]);
const replacement = new FocusableOverlay(["REPLACEMENT"]);
const overlay = new FocusableOverlay(["OVERLAY"]);
replacement.handleInput = (data: string) => {
replacement.inputs.push(data);
if (data === "\r") {
tui.setFocus(editor);
}
if (data === "\r") tui.setFocus(fallback);
};
tui.addChild(new EmptyContent());
tui.setFocus(editor);
tui.start();
try {
const overlayHandle = tui.showOverlay(overlay);
@@ -412,20 +530,21 @@ describe("TUI overlay non-capturing", () => {
overlay.inputs.push(data);
if (data === "b") {
tui.setFocus(replacement);
overlayHandle.unfocus({ target: editor });
overlayHandle.unfocus({ target });
}
};
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);
assert.deepStrictEqual(replacement.inputs, ["\r"]);
assert.deepStrictEqual(fallback.inputs, []);
assert.deepStrictEqual(target.inputs, ["x"]);
} finally {
tui.stop();
}
@@ -541,6 +660,54 @@ describe("TUI overlay non-capturing", () => {
}
});
it("setFocus(null) explicitly clears visible overlay restore", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const overlay = new FocusableOverlay(["OVERLAY"]);
tui.addChild(new EmptyContent());
tui.start();
try {
tui.showOverlay(overlay);
tui.setFocus(null);
terminal.sendInput("x");
await renderAndFlush(tui, terminal);
assert.deepStrictEqual(overlay.inputs, []);
assert.strictEqual(overlay.focused, false);
} finally {
tui.stop();
}
});
it("blocked replacement setFocus(null) resumes the visible overlay", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const replacement = new FocusableOverlay(["REPLACEMENT"]);
const overlay = new FocusableOverlay(["OVERLAY"]);
replacement.handleInput = (data: string) => {
replacement.inputs.push(data);
if (data === "\r") tui.setFocus(null);
};
overlay.handleInput = (data: string) => {
overlay.inputs.push(data);
if (data === "b") tui.setFocus(replacement);
};
tui.addChild(new EmptyContent());
tui.start();
try {
tui.showOverlay(overlay);
terminal.sendInput("b");
await renderAndFlush(tui, terminal);
terminal.sendInput("\r");
terminal.sendInput("x");
await renderAndFlush(tui, terminal);
assert.deepStrictEqual(replacement.inputs, ["\r"]);
assert.deepStrictEqual(overlay.inputs, ["b", "x"]);
assert.strictEqual(overlay.focused, true);
} finally {
tui.stop();
}
});
it("temporarily invisible focused overlay falls back without losing restore eligibility", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);