fix(tui): harden overlay focus restoration
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 }).
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user