diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 5bf01b73e..180b7df29 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -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)). diff --git a/packages/tui/README.md b/packages/tui/README.md index 76e3317de..f07b785a6 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -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(); diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index a645c8a65..fe0fed001 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -99,6 +99,7 @@ export { type OverlayHandle, type OverlayMargin, type OverlayOptions, + type OverlayUnfocusOptions, type SizeValue, TUI, } from "./tui.ts"; diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index cc6ac6f62..0d5a7ee62 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -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 { diff --git a/packages/tui/test/overlay-non-capturing.test.ts b/packages/tui/test/overlay-non-capturing.test.ts index 3c61b5616..e6490d4a5 100644 --- a/packages/tui/test/overlay-non-capturing.test.ts +++ b/packages/tui/test/overlay-non-capturing.test.ts @@ -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", () => {