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

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