Merge pull request #5235 from nicobailon/feat/issue-5129-tui-overlay-focus
Feat/issue 5129 tui overlay focus
This commit is contained in:
@@ -2371,7 +2371,7 @@ const result = await ctx.ui.custom<string | null>(
|
||||
);
|
||||
```
|
||||
|
||||
For advanced positioning (anchors, margins, percentages, responsive visibility), pass `overlayOptions`. Use `onHandle` to control visibility programmatically:
|
||||
For advanced positioning (anchors, margins, percentages, responsive visibility), pass `overlayOptions`. Use `onHandle` to control focus or visibility programmatically:
|
||||
|
||||
```typescript
|
||||
const result = await ctx.ui.custom<string | null>(
|
||||
@@ -2379,12 +2379,19 @@ const result = await ctx.ui.custom<string | null>(
|
||||
{
|
||||
overlay: true,
|
||||
overlayOptions: { anchor: "top-right", width: "50%", margin: 2 },
|
||||
onHandle: (handle) => { /* handle.setHidden(true/false) */ }
|
||||
onHandle: (handle) => {
|
||||
handle.focus(); // focus this overlay and bring it to the visual front
|
||||
// handle.unfocus({ target: editorComponent }); // release input to a specific component
|
||||
// handle.setHidden(true/false); // toggle visibility
|
||||
// handle.hide(); // permanently remove
|
||||
}
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
See [tui.md](tui.md) for the full `OverlayOptions` API and [overlay-qa-tests.ts](../examples/extensions/overlay-qa-tests.ts) for examples.
|
||||
A focused visible overlay can reclaim input after temporary non-overlay custom UI closes. If you intentionally want another component to keep input while the overlay stays visible, call `handle.unfocus({ target })`. Passing `{ target: null }` releases the overlay without focusing another component.
|
||||
|
||||
See [tui.md](tui.md) for the full `OverlayOptions` and `OverlayHandle` API and [overlay-qa-tests.ts](../examples/extensions/overlay-qa-tests.ts) for examples.
|
||||
|
||||
### Custom Editor
|
||||
|
||||
|
||||
@@ -145,8 +145,11 @@ const result = await ctx.ui.custom<string | null>(
|
||||
// Responsive: hide on narrow terminals
|
||||
visible: (termWidth, termHeight) => termWidth >= 80,
|
||||
},
|
||||
// Get handle for programmatic visibility control
|
||||
// Get handle for programmatic focus and visibility control
|
||||
onHandle: (handle) => {
|
||||
// handle.focus() - focus this overlay and bring it to the visual front
|
||||
// handle.unfocus() - release input to normal fallback
|
||||
// handle.unfocus({ target }) - release input to a specific component or null
|
||||
// handle.setHidden(true/false) - toggle visibility
|
||||
// handle.hide() - permanently remove
|
||||
},
|
||||
@@ -154,6 +157,12 @@ const result = await ctx.ui.custom<string | null>(
|
||||
);
|
||||
```
|
||||
|
||||
### Overlay Focus
|
||||
|
||||
A focused visible overlay keeps input ownership across temporary non-overlay UI. If an overlay opens another `ctx.ui.custom()` component without `{ overlay: true }`, that replacement UI receives input while it is active; when it closes, the focused overlay can reclaim input.
|
||||
|
||||
Use `handle.unfocus()` when a visible overlay should stop owning input and let TUI fall back to another visible capturing overlay or the previous focus target. Use `handle.unfocus({ target })` when a specific component should receive input while the overlay stays visible. Passing `{ target: null }` intentionally leaves no focused component until focus is set again.
|
||||
|
||||
### Overlay Lifecycle
|
||||
|
||||
Overlay components are disposed when closed. Don't reuse references - create fresh instances:
|
||||
|
||||
@@ -15,13 +15,13 @@
|
||||
* /overlay-sidepanel - Responsive sidepanel (hides when terminal < 100 cols)
|
||||
* /overlay-toggle - Toggle visibility demo (demonstrates OverlayHandle.setHidden)
|
||||
* /overlay-passive - Non-capturing overlay demo (passive info panel alongside active overlay)
|
||||
* /overlay-focus - Focus cycling and rendering order with non-capturing overlays
|
||||
* /overlay-focus - Focus cycling, input routing, dismissal, and rendering order with overlays
|
||||
* /overlay-streaming - Multiple input panels with simulated streaming (Tab to cycle focus)
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
||||
import type { Component, OverlayAnchor, OverlayHandle, OverlayOptions, TUI } from "@earendil-works/pi-tui";
|
||||
import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
||||
import { Input, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
||||
import { spawn } from "child_process";
|
||||
|
||||
// Global handle for toggle demo (in real code, use a more elegant pattern)
|
||||
@@ -272,9 +272,9 @@ export default function (pi: ExtensionAPI) {
|
||||
},
|
||||
});
|
||||
|
||||
// Focus cycling demo - demonstrates focus(), unfocus(), isFocused() and rendering order
|
||||
// Focus cycling demo - demonstrates focus(), input routing, per-panel dismissal, and rendering order
|
||||
pi.registerCommand("overlay-focus", {
|
||||
description: "Test focus cycling and rendering order with non-capturing overlays",
|
||||
description: "Test focus cycling, input routing, dismissal, and rendering order with overlays",
|
||||
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
||||
ctx.ui.setEditorText("");
|
||||
await ctx.ui.custom<void>((tui, theme, _kb, done) => new FocusDemoController(tui, theme, done), {
|
||||
@@ -1031,57 +1031,66 @@ class TimerPanel extends BaseOverlay {
|
||||
|
||||
// === Focus cycling demo ===
|
||||
|
||||
type FocusPanelColor = "error" | "success" | "accent";
|
||||
type FocusPanelConfig = { label: string; color: FocusPanelColor; options: OverlayOptions };
|
||||
type FocusPanelEntry = { panel: FocusPanel; handle: OverlayHandle };
|
||||
|
||||
const FOCUS_PANEL_CONFIGS = [
|
||||
{ label: "Alpha", color: "error", options: { row: 2, col: 4, width: 34 } },
|
||||
{ label: "Beta", color: "success", options: { row: 5, col: 28, width: 34 } },
|
||||
{ label: "Gamma", color: "accent", options: { row: 8, col: 52, width: 34 } },
|
||||
] satisfies FocusPanelConfig[];
|
||||
|
||||
class FocusDemoController extends BaseOverlay {
|
||||
private tui: TUI;
|
||||
private panels: FocusPanel[] = [];
|
||||
private handles: OverlayHandle[] = [];
|
||||
private focusIndex = -1;
|
||||
private done: () => void;
|
||||
private readonly tui: TUI;
|
||||
private entries: FocusPanelEntry[] = [];
|
||||
private readonly done: () => void;
|
||||
private closed = false;
|
||||
|
||||
constructor(tui: TUI, theme: Theme, done: () => void) {
|
||||
super(theme);
|
||||
this.tui = tui;
|
||||
this.done = done;
|
||||
const colors = ["error", "success", "accent"] as const;
|
||||
const labels = ["Alpha", "Beta", "Gamma"];
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const panel = new FocusPanel(
|
||||
theme,
|
||||
labels[i]!,
|
||||
colors[i]!,
|
||||
() => this.cycleFocus(),
|
||||
() => this.close(),
|
||||
);
|
||||
const handle = this.tui.showOverlay(panel, {
|
||||
nonCapturing: true,
|
||||
row: 2,
|
||||
col: 5 + i * 6,
|
||||
width: 28,
|
||||
});
|
||||
panel.handle = handle;
|
||||
this.panels.push(panel);
|
||||
this.handles.push(handle);
|
||||
for (const config of FOCUS_PANEL_CONFIGS) {
|
||||
const panel = new FocusPanel({ theme, config, controller: this });
|
||||
const handle = this.tui.showOverlay(panel, { nonCapturing: true, ...config.options });
|
||||
this.entries.push({ panel, handle });
|
||||
}
|
||||
|
||||
this.focusFirstOpenPanel();
|
||||
}
|
||||
|
||||
private cycleFocus(): void {
|
||||
if (this.focusIndex >= 0 && this.focusIndex < this.handles.length) {
|
||||
this.handles[this.focusIndex]!.unfocus();
|
||||
}
|
||||
this.focusIndex++;
|
||||
if (this.focusIndex >= this.handles.length) {
|
||||
this.focusIndex = -1;
|
||||
} else {
|
||||
this.handles[this.focusIndex]!.focus();
|
||||
}
|
||||
this.tui.requestRender();
|
||||
focusNext(current: FocusPanel, direction: 1 | -1 = 1): void {
|
||||
const openEntries = this.openEntries();
|
||||
const currentOpenPosition = openEntries.findIndex((entry) => entry.panel === current);
|
||||
if (currentOpenPosition === -1) throw new Error(`Panel ${current.label} is not open`);
|
||||
const nextOpenPosition = (currentOpenPosition + direction + openEntries.length) % openEntries.length;
|
||||
this.focusEntryAt(openEntries, nextOpenPosition);
|
||||
}
|
||||
|
||||
private close(): void {
|
||||
for (const handle of this.handles) handle.hide();
|
||||
this.handles = [];
|
||||
this.panels = [];
|
||||
dismiss(panel: FocusPanel): void {
|
||||
const openEntries = this.openEntries();
|
||||
const currentOpenPosition = openEntries.findIndex((candidate) => candidate.panel === panel);
|
||||
if (currentOpenPosition === -1) return;
|
||||
const entry = openEntries[currentOpenPosition];
|
||||
if (!entry) throw new Error(`Invalid focus panel index ${currentOpenPosition}`);
|
||||
const remainingEntries = openEntries.filter((candidate) => candidate.panel !== panel);
|
||||
|
||||
entry.panel.closed = true;
|
||||
entry.handle.hide();
|
||||
if (remainingEntries.length === 0) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
|
||||
this.focusEntryAt(remainingEntries, currentOpenPosition % remainingEntries.length);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
this.hidePanels();
|
||||
this.done();
|
||||
}
|
||||
|
||||
@@ -1089,86 +1098,148 @@ class FocusDemoController extends BaseOverlay {
|
||||
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
||||
this.close();
|
||||
} else if (matchesKey(data, "tab")) {
|
||||
this.cycleFocus();
|
||||
this.focusFirstOpenPanel();
|
||||
}
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const th = this.theme;
|
||||
const focused = this.focusIndex === -1 ? "Controller" : (this.panels[this.focusIndex]?.label ?? "?");
|
||||
const focused = this.entries.find((entry) => entry.handle.isFocused())?.panel.label ?? "Controller";
|
||||
return this.box(
|
||||
[
|
||||
"",
|
||||
` Current focus: ${th.fg("accent", focused)}`,
|
||||
"",
|
||||
" Three overlapping panels above are",
|
||||
` all ${th.fg("accent", "nonCapturing")}. Press Tab to`,
|
||||
" cycle focus() between them.",
|
||||
` ${th.fg("accent", "nonCapturing")} overlays controlled with`,
|
||||
" raw OverlayHandle.focus()/hide().",
|
||||
"",
|
||||
" Focused panel renders on top",
|
||||
" (focus-based rendering order).",
|
||||
" Type in the focused panel's input.",
|
||||
" Focused panel renders on top.",
|
||||
"",
|
||||
th.fg("dim", " Tab = cycle focus | Esc = close"),
|
||||
th.fg("dim", " Tab/Shift+Tab = cycle panels"),
|
||||
th.fg("dim", " Esc/Ctrl+D = dismiss panel"),
|
||||
th.fg("dim", " Ctrl+C = close all"),
|
||||
"",
|
||||
],
|
||||
width,
|
||||
"Focus Demo",
|
||||
"Focus + Input Demo",
|
||||
);
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
for (const handle of this.handles) handle.hide();
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
this.hidePanels();
|
||||
}
|
||||
|
||||
private focusFirstOpenPanel(): void {
|
||||
const firstOpen = this.openEntries()[0];
|
||||
if (firstOpen) {
|
||||
firstOpen.handle.focus();
|
||||
this.tui.requestRender();
|
||||
}
|
||||
}
|
||||
|
||||
private focusEntryAt(entries: FocusPanelEntry[], index: number): void {
|
||||
const entry = entries[index];
|
||||
if (!entry) throw new Error(`Invalid focus panel index ${index}`);
|
||||
entry.handle.focus();
|
||||
this.tui.requestRender();
|
||||
}
|
||||
|
||||
private hidePanels(): void {
|
||||
for (const entry of this.entries) {
|
||||
if (!entry.panel.closed) {
|
||||
entry.panel.closed = true;
|
||||
entry.handle.hide();
|
||||
}
|
||||
}
|
||||
this.entries = [];
|
||||
}
|
||||
|
||||
private openEntries(): FocusPanelEntry[] {
|
||||
return this.entries.filter((entry) => !entry.panel.closed);
|
||||
}
|
||||
}
|
||||
|
||||
class FocusPanel extends BaseOverlay {
|
||||
handle: OverlayHandle | null = null;
|
||||
focused = false;
|
||||
closed = false;
|
||||
readonly label: string;
|
||||
private color: "error" | "success" | "accent";
|
||||
private onTab: () => void;
|
||||
private onClose: () => void;
|
||||
private readonly color: FocusPanelColor;
|
||||
private readonly controller: FocusDemoController;
|
||||
private readonly input = new Input();
|
||||
private inputs: string[] = [];
|
||||
|
||||
constructor(
|
||||
theme: Theme,
|
||||
label: string,
|
||||
color: "error" | "success" | "accent",
|
||||
onTab: () => void,
|
||||
onClose: () => void,
|
||||
) {
|
||||
constructor({
|
||||
theme,
|
||||
config,
|
||||
controller,
|
||||
}: {
|
||||
theme: Theme;
|
||||
config: FocusPanelConfig;
|
||||
controller: FocusDemoController;
|
||||
}) {
|
||||
super(theme);
|
||||
this.label = label;
|
||||
this.color = color;
|
||||
this.onTab = onTab;
|
||||
this.onClose = onClose;
|
||||
this.label = config.label;
|
||||
this.color = config.color;
|
||||
this.controller = controller;
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (matchesKey(data, "tab")) {
|
||||
this.onTab();
|
||||
} else if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
||||
this.onClose();
|
||||
this.controller.focusNext(this);
|
||||
} else if (matchesKey(data, "shift+tab")) {
|
||||
this.controller.focusNext(this, -1);
|
||||
} else if (matchesKey(data, "escape") || matchesKey(data, "ctrl+d")) {
|
||||
this.controller.dismiss(this);
|
||||
} else if (matchesKey(data, "ctrl+c")) {
|
||||
this.controller.close();
|
||||
} else if (matchesKey(data, "return")) {
|
||||
this.inputs.push("Enter");
|
||||
} else if (matchesKey(data, "up")) {
|
||||
this.inputs.push("↑");
|
||||
} else if (matchesKey(data, "down")) {
|
||||
this.inputs.push("↓");
|
||||
} else if (matchesKey(data, "left")) {
|
||||
this.input.handleInput(data);
|
||||
this.inputs.push("←");
|
||||
} else if (matchesKey(data, "right")) {
|
||||
this.input.handleInput(data);
|
||||
this.inputs.push("→");
|
||||
} else if (matchesKey(data, "backspace")) {
|
||||
this.input.handleInput(data);
|
||||
this.inputs.push("Backspace");
|
||||
} else {
|
||||
this.input.handleInput(data);
|
||||
this.inputs.push(JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const th = this.theme;
|
||||
const focused = this.handle?.isFocused() ?? false;
|
||||
const innerW = Math.max(1, width - 2);
|
||||
const border = (c: string) => th.fg(this.color, c);
|
||||
const border = (c: string) => th.fg(this.focused ? this.color : "dim", c);
|
||||
const padLine = (s: string) => truncateToWidth(s, innerW, "...", true);
|
||||
const recent = this.inputs.length === 0 ? "(none)" : this.inputs.slice(-6).join(" ");
|
||||
const lines: string[] = [];
|
||||
|
||||
this.input.focused = this.focused;
|
||||
const [inputLine = ""] = this.input.render(Math.max(1, innerW - 8));
|
||||
lines.push(border(`╭${"─".repeat(innerW)}╮`));
|
||||
lines.push(border("│") + padLine(` ${th.fg("accent", this.label)}`) + border("│"));
|
||||
lines.push(border("│") + padLine("") + border("│"));
|
||||
if (focused) {
|
||||
lines.push(border("│") + padLine(th.fg("success", " ● FOCUSED")) + border("│"));
|
||||
lines.push(border("│") + padLine(th.fg("dim", " (receiving input)")) + border("│"));
|
||||
} else {
|
||||
lines.push(border("│") + padLine(th.fg("dim", " ○ unfocused")) + border("│"));
|
||||
lines.push(border("│") + padLine(th.fg("dim", " (passive)")) + border("│"));
|
||||
}
|
||||
lines.push(
|
||||
border("│") +
|
||||
padLine(
|
||||
` ${th.fg(this.color, this.label)} ${this.focused ? th.fg("success", "FOCUSED") : th.fg("dim", "visible")}`,
|
||||
) +
|
||||
border("│"),
|
||||
);
|
||||
lines.push(border("│") + padLine("") + border("│"));
|
||||
lines.push(border("│") + padLine(` Input: ${inputLine}`) + border("│"));
|
||||
lines.push(border("│") + padLine(` Keys: ${recent}`) + border("│"));
|
||||
lines.push(border("│") + padLine(th.fg("dim", " Tab/Shift+Tab focus")) + border("│"));
|
||||
lines.push(border("│") + padLine(th.fg("dim", " Esc/Ctrl+D dismiss")) + border("│"));
|
||||
lines.push(border(`╰${"─".repeat(innerW)}╯`));
|
||||
|
||||
return lines;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { homedir } from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { type AutocompleteProvider, CombinedAutocompleteProvider, Container } from "@earendil-works/pi-tui";
|
||||
import { type AutocompleteProvider, CombinedAutocompleteProvider } from "@earendil-works/pi-tui";
|
||||
import { beforeAll, describe, expect, test, vi } from "vitest";
|
||||
import { type Component, Container, type Focusable, TUI } from "../../tui/src/tui.ts";
|
||||
import { VirtualTerminal } from "../../tui/test/virtual-terminal.ts";
|
||||
import type { AutocompleteProviderFactory } from "../src/core/extensions/types.ts";
|
||||
import type { SourceInfo } from "../src/core/source-info.ts";
|
||||
import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts";
|
||||
@@ -17,6 +19,41 @@ function renderAll(container: Container, width = 120): string {
|
||||
return container.children.flatMap((child) => child.render(width)).join("\n");
|
||||
}
|
||||
|
||||
class TestFocusableComponent implements Component, Focusable {
|
||||
focused = false;
|
||||
inputs: string[] = [];
|
||||
private readonly label: string;
|
||||
private text = "";
|
||||
|
||||
constructor(label: string) {
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
this.inputs.push(data);
|
||||
}
|
||||
|
||||
getText(): string {
|
||||
return this.text;
|
||||
}
|
||||
|
||||
setText(text: string): void {
|
||||
this.text = text;
|
||||
}
|
||||
|
||||
render(): string[] {
|
||||
return [this.label];
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
}
|
||||
|
||||
async function flushTui(tui: TUI, terminal: VirtualTerminal): Promise<void> {
|
||||
tui.requestRender(true);
|
||||
await Promise.resolve();
|
||||
await terminal.waitForRender();
|
||||
}
|
||||
|
||||
function normalizeRenderedOutput(container: Container, width = 220): string {
|
||||
return renderAll(container, width)
|
||||
.replace(/\u001b\[[0-9;]*m/g, "")
|
||||
@@ -148,6 +185,78 @@ describe("InteractiveMode.createExtensionUIContext setTheme", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("InteractiveMode.showExtensionCustom", () => {
|
||||
beforeAll(() => {
|
||||
initTheme("dark");
|
||||
});
|
||||
|
||||
test("overlay custom UI reclaims input after non-overlay custom UI closes", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const ui = new TUI(terminal);
|
||||
const editorContainer = new Container();
|
||||
const editor = new TestFocusableComponent("EDITOR");
|
||||
const palette = new TestFocusableComponent("PALETTE");
|
||||
const overlay = new TestFocusableComponent("OVERLAY");
|
||||
const replacement = new TestFocusableComponent("REPLACEMENT");
|
||||
let closeOverlay: (value: string) => void = () => {
|
||||
throw new Error("closeOverlay was not initialized");
|
||||
};
|
||||
let closeReplacement: (value: string) => void = () => {
|
||||
throw new Error("closeReplacement was not initialized");
|
||||
};
|
||||
const fakeThis = {
|
||||
editor,
|
||||
editorContainer,
|
||||
keybindings: {},
|
||||
ui,
|
||||
};
|
||||
const showExtensionCustom = <T>(
|
||||
factory: (tui: TUI, theme: unknown, keybindings: unknown, done: (result: T) => void) => Component,
|
||||
options?: { overlay?: boolean },
|
||||
): Promise<T> =>
|
||||
(InteractiveMode as any).prototype.showExtensionCustom.call(fakeThis, factory, options) as Promise<T>;
|
||||
|
||||
editorContainer.addChild(editor);
|
||||
ui.addChild(editorContainer);
|
||||
ui.addChild(palette);
|
||||
ui.setFocus(palette);
|
||||
ui.start();
|
||||
try {
|
||||
const overlayPromise = showExtensionCustom<string>(
|
||||
(_tui, _theme, _keybindings, done) => {
|
||||
closeOverlay = done;
|
||||
return overlay;
|
||||
},
|
||||
{ overlay: true },
|
||||
);
|
||||
await flushTui(ui, terminal);
|
||||
expect(overlay.focused).toBe(true);
|
||||
|
||||
const replacementPromise = showExtensionCustom<string>((_tui, _theme, _keybindings, done) => {
|
||||
closeReplacement = done;
|
||||
return replacement;
|
||||
});
|
||||
await flushTui(ui, terminal);
|
||||
expect(replacement.focused).toBe(true);
|
||||
|
||||
closeReplacement("done");
|
||||
await replacementPromise;
|
||||
await flushTui(ui, terminal);
|
||||
terminal.sendInput("x");
|
||||
await flushTui(ui, terminal);
|
||||
|
||||
expect(overlay.inputs).toEqual(["x"]);
|
||||
expect(editor.inputs).toEqual([]);
|
||||
expect(overlay.focused).toBe(true);
|
||||
|
||||
closeOverlay("closed");
|
||||
await overlayPromise;
|
||||
} finally {
|
||||
ui.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("InteractiveMode.createExtensionUIContext addAutocompleteProvider", () => {
|
||||
test("stores wrapper factories and rebuilds autocomplete immediately", () => {
|
||||
const wrapper: AutocompleteProviderFactory = (current) => current;
|
||||
|
||||
@@ -116,9 +116,21 @@ 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 normal fallback
|
||||
handle.unfocus({ target: baseComponent }); // Release this overlay to a specific 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 }).
|
||||
|
||||
// Hide topmost overlay
|
||||
tui.hideOverlay();
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@ export {
|
||||
type OverlayHandle,
|
||||
type OverlayMargin,
|
||||
type OverlayOptions,
|
||||
type OverlayUnfocusOptions,
|
||||
type SizeValue,
|
||||
TUI,
|
||||
} from "./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,12 +194,32 @@ 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 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 OverlayStackEntry = {
|
||||
component: Component;
|
||||
options?: OverlayOptions;
|
||||
preFocus: Component | null;
|
||||
hidden: boolean;
|
||||
focusOrder: number;
|
||||
};
|
||||
|
||||
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
|
||||
*/
|
||||
@@ -262,13 +288,8 @@ export class TUI extends Container {
|
||||
|
||||
// Overlay stack for modal components rendered on top of base content
|
||||
private focusOrderCounter = 0;
|
||||
private overlayStack: {
|
||||
component: Component;
|
||||
options?: OverlayOptions;
|
||||
preFocus: Component | null;
|
||||
hidden: boolean;
|
||||
focusOrder: number;
|
||||
}[] = [];
|
||||
private overlayStack: OverlayStackEntry[] = [];
|
||||
private overlayFocusRestore: OverlayFocusRestoreState = { status: "inactive" };
|
||||
|
||||
constructor(terminal: Terminal, showHardwareCursor?: boolean) {
|
||||
super();
|
||||
@@ -309,17 +330,126 @@ export class TUI extends Container {
|
||||
}
|
||||
|
||||
setFocus(component: Component | null): void {
|
||||
// Clear focused flag on old component
|
||||
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) {
|
||||
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 &&
|
||||
restoreState.status !== "inactive" &&
|
||||
restoreState.overlay === previousFocusedOverlay &&
|
||||
!this.isOverlayFocusAncestor(previousFocusedOverlay, 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();
|
||||
}
|
||||
}
|
||||
|
||||
if (isFocusable(this.focusedComponent)) {
|
||||
this.focusedComponent.focused = false;
|
||||
}
|
||||
|
||||
this.focusedComponent = component;
|
||||
this.focusedComponent = nextFocus;
|
||||
|
||||
// Set focused flag on new component
|
||||
if (isFocusable(component)) {
|
||||
component.focused = true;
|
||||
if (isFocusable(nextFocus)) {
|
||||
nextFocus.focused = true;
|
||||
}
|
||||
|
||||
const focusedOverlay = nextFocus
|
||||
? this.overlayStack.find((entry) => entry.component === nextFocus && this.isOverlayVisible(entry))
|
||||
: undefined;
|
||||
if (focusedOverlay) {
|
||||
this.overlayFocusRestore = { status: "eligible", overlay: focusedOverlay };
|
||||
}
|
||||
}
|
||||
|
||||
private clearOverlayFocusRestore(): void {
|
||||
this.overlayFocusRestore = { status: "inactive" };
|
||||
}
|
||||
|
||||
private clearOverlayFocusRestoreFor(overlay: OverlayStackEntry): void {
|
||||
if (this.overlayFocusRestore.status !== "inactive" && this.overlayFocusRestore.overlay === overlay) {
|
||||
this.clearOverlayFocusRestore();
|
||||
}
|
||||
}
|
||||
|
||||
private resolveBlockedOverlayFocusResume(restoreState: BlockedOverlayFocusRestoreState): Component | null {
|
||||
if (restoreState.resume.status === "restore-overlay") return restoreState.overlay.component;
|
||||
this.clearOverlayFocusRestore();
|
||||
return restoreState.resume.target;
|
||||
}
|
||||
|
||||
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 {
|
||||
const visited = new Set<Component>();
|
||||
let current = entry.preFocus;
|
||||
while (current && !visited.has(current)) {
|
||||
visited.add(current);
|
||||
if (current === component) return true;
|
||||
current = this.overlayStack.find((overlay) => overlay.component === current)?.preFocus ?? null;
|
||||
}
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -327,9 +457,9 @@ export class TUI extends Container {
|
||||
* Returns a handle to control the overlay's visibility.
|
||||
*/
|
||||
showOverlay(component: Component, options?: OverlayOptions): OverlayHandle {
|
||||
const entry = {
|
||||
const entry: OverlayStackEntry = {
|
||||
component,
|
||||
options,
|
||||
...(options === undefined ? {} : { options }),
|
||||
preFocus: this.focusedComponent,
|
||||
hidden: false,
|
||||
focusOrder: ++this.focusOrderCounter,
|
||||
@@ -347,6 +477,8 @@ export class TUI extends Container {
|
||||
hide: () => {
|
||||
const index = this.overlayStack.indexOf(entry);
|
||||
if (index !== -1) {
|
||||
this.clearOverlayFocusRestoreFor(entry);
|
||||
this.retargetOverlayPreFocus(entry);
|
||||
this.overlayStack.splice(index, 1);
|
||||
// Restore focus if this overlay had focus
|
||||
if (this.focusedComponent === component) {
|
||||
@@ -362,6 +494,7 @@ export class TUI extends Container {
|
||||
entry.hidden = hidden;
|
||||
// Update focus when hiding/showing
|
||||
if (hidden) {
|
||||
this.clearOverlayFocusRestoreFor(entry);
|
||||
// If this overlay had focus, move focus to next visible or preFocus
|
||||
if (this.focusedComponent === component) {
|
||||
const topVisible = this.getTopmostVisibleOverlay();
|
||||
@@ -379,16 +512,39 @@ export class TUI extends Container {
|
||||
isHidden: () => entry.hidden,
|
||||
focus: () => {
|
||||
if (!this.overlayStack.includes(entry) || !this.isOverlayVisible(entry)) return;
|
||||
if (this.focusedComponent !== component) {
|
||||
this.setFocus(component);
|
||||
}
|
||||
entry.focusOrder = ++this.focusOrderCounter;
|
||||
this.setFocus(component);
|
||||
this.requestRender();
|
||||
},
|
||||
unfocus: () => {
|
||||
if (this.focusedComponent !== component) return;
|
||||
const topVisible = this.getTopmostVisibleOverlay();
|
||||
this.setFocus(topVisible && topVisible !== entry ? topVisible.component : entry.preFocus);
|
||||
unfocus: (unfocusOptions) => {
|
||||
const isFocused = this.focusedComponent === component;
|
||||
const restoreState = this.overlayFocusRestore;
|
||||
const hasPendingRestore = restoreState.status !== "inactive" && restoreState.overlay === entry;
|
||||
if (!isFocused && !hasPendingRestore) return;
|
||||
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);
|
||||
}
|
||||
this.requestRender();
|
||||
},
|
||||
isFocused: () => this.focusedComponent === component,
|
||||
@@ -397,8 +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.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();
|
||||
@@ -414,7 +573,7 @@ export class TUI extends Container {
|
||||
}
|
||||
|
||||
/** Check if an overlay entry is currently visible */
|
||||
private isOverlayVisible(entry: (typeof this.overlayStack)[number]): boolean {
|
||||
private isOverlayVisible(entry: OverlayStackEntry): boolean {
|
||||
if (entry.hidden) return false;
|
||||
if (entry.options?.visible) {
|
||||
return entry.options.visible(this.terminal.columns, this.terminal.rows);
|
||||
@@ -422,15 +581,16 @@ export class TUI extends Container {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Find the topmost visible capturing overlay, if any */
|
||||
private getTopmostVisibleOverlay(): (typeof this.overlayStack)[number] | 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];
|
||||
/** Find the visual-frontmost visible capturing overlay, if any */
|
||||
private getTopmostVisibleOverlay(): OverlayStackEntry | undefined {
|
||||
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 {
|
||||
@@ -579,8 +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 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,16 +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 doneFn: () => void;
|
||||
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) => {
|
||||
@@ -251,15 +282,14 @@ 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);
|
||||
assert.strictEqual(editor.focused, false);
|
||||
|
||||
// Simulate Esc: cleanup + close (from inside handleInput)
|
||||
doneFn!();
|
||||
doneFn();
|
||||
// Now await the promise (simulating showExtensionCustom resolving)
|
||||
await overlayPromise;
|
||||
await renderAndFlush(tui, terminal);
|
||||
@@ -305,6 +335,473 @@ describe("TUI overlay non-capturing", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("active base focus replacement receives close input before overlay restore", 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"]);
|
||||
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") {
|
||||
tui.setFocus(editor);
|
||||
}
|
||||
};
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.setFocus(editor);
|
||||
tui.start();
|
||||
try {
|
||||
tui.showOverlay(overlay);
|
||||
assert.strictEqual(overlay.focused, true);
|
||||
terminal.sendInput("b");
|
||||
await renderAndFlush(tui, terminal);
|
||||
assert.strictEqual(replacement.focused, true);
|
||||
|
||||
terminal.sendInput("\r");
|
||||
await renderAndFlush(tui, terminal);
|
||||
assert.deepStrictEqual(replacement.inputs, ["\r"]);
|
||||
assert.deepStrictEqual(overlay.inputs, ["b"]);
|
||||
assert.strictEqual(overlay.focused, true);
|
||||
|
||||
terminal.sendInput("x");
|
||||
await renderAndFlush(tui, terminal);
|
||||
assert.deepStrictEqual(overlay.inputs, ["b", "x"]);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("active replacement still receives input when it is another overlay preFocus", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui = new TUI(terminal);
|
||||
const editor = new FocusableOverlay(["EDITOR"]);
|
||||
const replacement = new FocusableOverlay(["REPLACEMENT"]);
|
||||
const passive = new FocusableOverlay(["PASSIVE"]);
|
||||
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") {
|
||||
tui.setFocus(editor);
|
||||
}
|
||||
};
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.setFocus(editor);
|
||||
tui.start();
|
||||
try {
|
||||
tui.setFocus(replacement);
|
||||
tui.showOverlay(passive, { nonCapturing: true });
|
||||
tui.setFocus(editor);
|
||||
tui.showOverlay(overlay);
|
||||
terminal.sendInput("b");
|
||||
await renderAndFlush(tui, terminal);
|
||||
assert.strictEqual(replacement.focused, true);
|
||||
|
||||
terminal.sendInput("1");
|
||||
terminal.sendInput("\r");
|
||||
await renderAndFlush(tui, terminal);
|
||||
assert.deepStrictEqual(replacement.inputs, ["1", "\r"]);
|
||||
assert.deepStrictEqual(overlay.inputs, ["b"]);
|
||||
assert.strictEqual(overlay.focused, true);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
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 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(fallback);
|
||||
};
|
||||
tui.addChild(new EmptyContent());
|
||||
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 });
|
||||
}
|
||||
};
|
||||
|
||||
terminal.sendInput("b");
|
||||
await renderAndFlush(tui, terminal);
|
||||
assert.strictEqual(replacement.focused, true);
|
||||
terminal.sendInput("\r");
|
||||
terminal.sendInput("x");
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
assert.deepStrictEqual(overlay.inputs, ["b"]);
|
||||
assert.deepStrictEqual(replacement.inputs, ["\r"]);
|
||||
assert.deepStrictEqual(fallback.inputs, []);
|
||||
assert.deepStrictEqual(target.inputs, ["x"]);
|
||||
} 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);
|
||||
const editor = new FocusableOverlay(["EDITOR"]);
|
||||
const replacement = new FocusableOverlay(["REPLACEMENT"]);
|
||||
const overlay = new FocusableOverlay(["OVERLAY"]);
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.setFocus(editor);
|
||||
tui.start();
|
||||
try {
|
||||
tui.showOverlay(overlay);
|
||||
assert.strictEqual(overlay.focused, true);
|
||||
tui.setFocus(replacement);
|
||||
tui.setFocus(editor);
|
||||
terminal.sendInput("x");
|
||||
await renderAndFlush(tui, terminal);
|
||||
assert.deepStrictEqual(overlay.inputs, ["x"]);
|
||||
assert.deepStrictEqual(editor.inputs, []);
|
||||
assert.strictEqual(overlay.focused, true);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("handleInput restores focus to explicitly focused raw sub-overlay after base focus steal", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui = new TUI(terminal);
|
||||
const editor = new FocusableOverlay(["EDITOR"]);
|
||||
const controller = new FocusableOverlay(["CONTROLLER"]);
|
||||
const subOverlay = new FocusableOverlay(["SUB"]);
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.setFocus(editor);
|
||||
tui.start();
|
||||
try {
|
||||
tui.showOverlay(controller);
|
||||
const subHandle = tui.showOverlay(subOverlay, { nonCapturing: true });
|
||||
subHandle.focus();
|
||||
tui.setFocus(editor);
|
||||
terminal.sendInput("x");
|
||||
await renderAndFlush(tui, terminal);
|
||||
assert.deepStrictEqual(subOverlay.inputs, ["x"]);
|
||||
assert.deepStrictEqual(controller.inputs, []);
|
||||
assert.deepStrictEqual(editor.inputs, []);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("passive non-capturing overlay does not regain input after base focus", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui = new TUI(terminal);
|
||||
const editor = new FocusableOverlay(["EDITOR"]);
|
||||
const passive = new FocusableOverlay(["PASSIVE"]);
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.setFocus(editor);
|
||||
tui.start();
|
||||
try {
|
||||
tui.showOverlay(passive, { nonCapturing: true });
|
||||
terminal.sendInput("x");
|
||||
await renderAndFlush(tui, terminal);
|
||||
assert.deepStrictEqual(editor.inputs, ["x"]);
|
||||
assert.deepStrictEqual(passive.inputs, []);
|
||||
assert.strictEqual(editor.focused, true);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("explicitly focused non-capturing overlay regains input after base focus steal", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui = new TUI(terminal);
|
||||
const editor = new FocusableOverlay(["EDITOR"]);
|
||||
const overlay = new FocusableOverlay(["NC"]);
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.setFocus(editor);
|
||||
tui.start();
|
||||
try {
|
||||
const handle = tui.showOverlay(overlay, { nonCapturing: true });
|
||||
handle.focus();
|
||||
tui.setFocus(editor);
|
||||
terminal.sendInput("x");
|
||||
await renderAndFlush(tui, terminal);
|
||||
assert.deepStrictEqual(overlay.inputs, ["x"]);
|
||||
assert.deepStrictEqual(editor.inputs, []);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("unfocus() prevents visible overlay from regaining input", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui = new TUI(terminal);
|
||||
const editor = new FocusableOverlay(["EDITOR"]);
|
||||
const overlay = new FocusableOverlay(["OVERLAY"]);
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.setFocus(editor);
|
||||
tui.start();
|
||||
try {
|
||||
const handle = tui.showOverlay(overlay);
|
||||
handle.unfocus();
|
||||
terminal.sendInput("x");
|
||||
await renderAndFlush(tui, terminal);
|
||||
assert.deepStrictEqual(editor.inputs, ["x"]);
|
||||
assert.deepStrictEqual(overlay.inputs, []);
|
||||
assert.strictEqual(editor.focused, true);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
const editor = new FocusableOverlay(["EDITOR"]);
|
||||
const overlay = new FocusableOverlay(["OVERLAY"]);
|
||||
let visible = true;
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.setFocus(editor);
|
||||
tui.start();
|
||||
try {
|
||||
tui.showOverlay(overlay, { visible: () => visible });
|
||||
tui.setFocus(editor);
|
||||
visible = false;
|
||||
terminal.sendInput("x");
|
||||
await renderAndFlush(tui, terminal);
|
||||
assert.deepStrictEqual(editor.inputs, ["x"]);
|
||||
assert.deepStrictEqual(overlay.inputs, []);
|
||||
visible = true;
|
||||
terminal.sendInput("y");
|
||||
await renderAndFlush(tui, terminal);
|
||||
assert.deepStrictEqual(editor.inputs, ["x"]);
|
||||
assert.deepStrictEqual(overlay.inputs, ["y"]);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("temporarily invisible focused overlay with null preFocus restores when visible again", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui = new TUI(terminal);
|
||||
const overlay = new FocusableOverlay(["OVERLAY"]);
|
||||
let visible = true;
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.start();
|
||||
try {
|
||||
tui.showOverlay(overlay, { visible: () => visible });
|
||||
visible = false;
|
||||
terminal.sendInput("x");
|
||||
await renderAndFlush(tui, terminal);
|
||||
assert.deepStrictEqual(overlay.inputs, []);
|
||||
visible = true;
|
||||
terminal.sendInput("y");
|
||||
await renderAndFlush(tui, terminal);
|
||||
assert.deepStrictEqual(overlay.inputs, ["y"]);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("cyclic overlay preFocus ancestry does not hang focus changes", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui = new TUI(terminal);
|
||||
const editor = new FocusableOverlay(["EDITOR"]);
|
||||
const overlay = new FocusableOverlay(["OVERLAY"]);
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.setFocus(overlay);
|
||||
tui.start();
|
||||
try {
|
||||
const handle = tui.showOverlay(overlay, { nonCapturing: true });
|
||||
handle.focus();
|
||||
tui.setFocus(editor);
|
||||
terminal.sendInput("x");
|
||||
await renderAndFlush(tui, terminal);
|
||||
assert.deepStrictEqual(editor.inputs, ["x"]);
|
||||
assert.deepStrictEqual(overlay.inputs, []);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("handleInput restores the focus-order top overlay after base focus steal", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui = new TUI(terminal);
|
||||
const editor = new FocusableOverlay(["EDITOR"]);
|
||||
const lower = new FocusableOverlay(["LOWER"]);
|
||||
const upper = new FocusableOverlay(["UPPER"]);
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.setFocus(editor);
|
||||
tui.start();
|
||||
try {
|
||||
const lowerHandle = tui.showOverlay(lower);
|
||||
tui.showOverlay(upper);
|
||||
lowerHandle.focus();
|
||||
tui.setFocus(editor);
|
||||
terminal.sendInput("x");
|
||||
await renderAndFlush(tui, terminal);
|
||||
assert.deepStrictEqual(lower.inputs, ["x"]);
|
||||
assert.deepStrictEqual(upper.inputs, []);
|
||||
assert.deepStrictEqual(editor.inputs, []);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("hideOverlay() does not reassign focus when topmost overlay is non-capturing", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui = new TUI(terminal);
|
||||
@@ -481,6 +978,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", () => {
|
||||
|
||||
Reference in New Issue
Block a user