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