feat(tui): add non-capturing overlays with focus control (#1916)

This commit is contained in:
Nico Bailon
2026-03-07 05:19:16 -08:00
committed by GitHub
parent 3e6e459cfc
commit 841c95ac9c
5 changed files with 1141 additions and 28 deletions

View File

@@ -2,6 +2,18 @@
## [Unreleased]
### Added
- Added non-capturing overlays via `OverlayOptions.nonCapturing` and new `OverlayHandle` methods: `focus()`, `unfocus()`, and `isFocused()` for programmatic overlay focus control ([#1355](https://github.com/badlogic/pi-mono/issues/1355))
### Changed
- Overlay compositing order now uses focus order so focused overlays render on top while preserving stack semantics for show/hide behavior ([#1355](https://github.com/badlogic/pi-mono/issues/1355))
### Fixed
- Fixed automatic focus restoration to skip non-capturing overlays and fixed `hideOverlay()` to only reassign focus when the popped overlay had focus ([#1355](https://github.com/badlogic/pi-mono/issues/1355))
## [0.56.3] - 2026-03-06
### Added

View File

@@ -93,6 +93,9 @@ const handle = tui.showOverlay(component, {
// Responsive visibility
visible: (termWidth, termHeight) => termWidth >= 100 // Hide on narrow terminals
// Focus behavior
nonCapturing: true // Don't auto-focus when shown
});
// OverlayHandle methods
@@ -100,6 +103,9 @@ handle.hide(); // Permanently remove the overlay
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.isFocused(); // Check if overlay has focus
// Hide topmost overlay
tui.hideOverlay();

View File

@@ -145,6 +145,8 @@ export interface OverlayOptions {
* Called each render cycle with current terminal dimensions.
*/
visible?: (termWidth: number, termHeight: number) => boolean;
/** If true, don't capture keyboard focus when shown */
nonCapturing?: boolean;
}
/**
@@ -157,6 +159,12 @@ export interface OverlayHandle {
setHidden(hidden: boolean): void;
/** Check if overlay is temporarily hidden */
isHidden(): boolean;
/** Focus this overlay and bring it to the visual front */
focus(): void;
/** Release focus to the previous target */
unfocus(): void;
/** Check if this overlay currently has focus */
isFocused(): boolean;
}
/**
@@ -221,11 +229,13 @@ export class TUI extends Container {
private stopped = false;
// 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;
}[] = [];
constructor(terminal: Terminal, showHardwareCursor?: boolean) {
@@ -285,10 +295,16 @@ export class TUI extends Container {
* Returns a handle to control the overlay's visibility.
*/
showOverlay(component: Component, options?: OverlayOptions): OverlayHandle {
const entry = { component, options, preFocus: this.focusedComponent, hidden: false };
const entry = {
component,
options,
preFocus: this.focusedComponent,
hidden: false,
focusOrder: ++this.focusOrderCounter,
};
this.overlayStack.push(entry);
// Only focus if overlay is actually visible
if (this.isOverlayVisible(entry)) {
if (!options?.nonCapturing && this.isOverlayVisible(entry)) {
this.setFocus(component);
}
this.terminal.hideCursor();
@@ -321,13 +337,29 @@ export class TUI extends Container {
}
} else {
// Restore focus to this overlay when showing (if it's actually visible)
if (this.isOverlayVisible(entry)) {
if (!options?.nonCapturing && this.isOverlayVisible(entry)) {
entry.focusOrder = ++this.focusOrderCounter;
this.setFocus(component);
}
}
this.requestRender();
},
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.requestRender();
},
unfocus: () => {
if (this.focusedComponent !== component) return;
const topVisible = this.getTopmostVisibleOverlay();
this.setFocus(topVisible && topVisible !== entry ? topVisible.component : entry.preFocus);
this.requestRender();
},
isFocused: () => this.focusedComponent === component,
};
}
@@ -335,9 +367,11 @@ export class TUI extends Container {
hideOverlay(): void {
const overlay = this.overlayStack.pop();
if (!overlay) return;
// Find topmost visible overlay, or fall back to preFocus
const topVisible = this.getTopmostVisibleOverlay();
this.setFocus(topVisible?.component ?? overlay.preFocus);
if (this.focusedComponent === overlay.component) {
// Find topmost visible overlay, or fall back to preFocus
const topVisible = this.getTopmostVisibleOverlay();
this.setFocus(topVisible?.component ?? overlay.preFocus);
}
if (this.overlayStack.length === 0) this.terminal.hideCursor();
this.requestRender();
}
@@ -356,9 +390,10 @@ export class TUI extends Container {
return true;
}
/** Find the topmost visible overlay, if any */
/** 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];
}
@@ -678,7 +713,7 @@ export class TUI extends Container {
}
}
/** Composite all overlays into content lines (in stack order, later = on top). */
/** Composite all overlays into content lines (sorted by focusOrder, higher = on top). */
private compositeOverlays(lines: string[], termWidth: number, termHeight: number): string[] {
if (this.overlayStack.length === 0) return lines;
const result = [...lines];
@@ -687,10 +722,9 @@ export class TUI extends Container {
const rendered: { overlayLines: string[]; row: number; col: number; w: number }[] = [];
let minLinesNeeded = result.length;
for (const entry of this.overlayStack) {
// Skip invisible overlays (hidden or visible() returns false)
if (!this.isOverlayVisible(entry)) continue;
const visibleEntries = this.overlayStack.filter((e) => this.isOverlayVisible(e));
visibleEntries.sort((a, b) => a.focusOrder - b.focusOrder);
for (const entry of visibleEntries) {
const { component, options } = entry;
// Get layout with height=0 first to determine width and maxHeight
@@ -723,9 +757,6 @@ export class TUI extends Container {
const viewportStart = Math.max(0, workingHeight - termHeight);
// Track which lines were modified for final verification
const modifiedLines = new Set<number>();
// Composite each overlay
for (const { overlayLines, row, col, w } of rendered) {
for (let i = 0; i < overlayLines.length; i++) {
@@ -736,22 +767,10 @@ export class TUI extends Container {
const truncatedOverlayLine =
visibleWidth(overlayLines[i]) > w ? sliceByColumn(overlayLines[i], 0, w, true) : overlayLines[i];
result[idx] = this.compositeLineAt(result[idx], truncatedOverlayLine, col, w, termWidth);
modifiedLines.add(idx);
}
}
}
// Final verification: ensure no composited line exceeds terminal width
// This is a belt-and-suspenders safeguard - compositeLineAt should already
// guarantee this, but we verify here to prevent crashes from any edge cases
// Only check lines that were actually modified (optimization)
for (const idx of modifiedLines) {
const lineWidth = visibleWidth(result[idx]);
if (lineWidth > termWidth) {
result[idx] = sliceByColumn(result[idx], 0, termWidth, true);
}
}
return result;
}

View File

@@ -0,0 +1,609 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import type { Component, Focusable } from "../src/tui.js";
import { TUI } from "../src/tui.js";
import { VirtualTerminal } from "./virtual-terminal.js";
class StaticOverlay implements Component {
constructor(private lines: string[]) {}
render(): string[] {
return this.lines;
}
invalidate(): void {}
}
class EmptyContent implements Component {
render(): string[] {
return [];
}
invalidate(): void {}
}
class FocusableOverlay implements Component, Focusable {
focused = false;
inputs: string[] = [];
constructor(private lines: string[]) {}
handleInput(data: string): void {
this.inputs.push(data);
}
render(): string[] {
return this.lines;
}
invalidate(): void {}
}
async function renderAndFlush(tui: TUI, terminal: VirtualTerminal): Promise<void> {
tui.requestRender(true);
await new Promise<void>((resolve) => process.nextTick(resolve));
await terminal.flush();
}
describe("TUI overlay non-capturing", () => {
describe("focus management", () => {
it("non-capturing overlay preserves focus on creation", 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 {
tui.showOverlay(overlay, { nonCapturing: true });
await renderAndFlush(tui, terminal);
assert.strictEqual(editor.focused, true);
assert.strictEqual(overlay.focused, false);
} finally {
tui.stop();
}
});
it("focus() transfers focus to the overlay", 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, { nonCapturing: true });
handle.focus();
await renderAndFlush(tui, terminal);
assert.strictEqual(editor.focused, false);
assert.strictEqual(overlay.focused, true);
assert.strictEqual(handle.isFocused(), true);
} finally {
tui.stop();
}
});
it("unfocus() restores previous focus", 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, { nonCapturing: true });
handle.focus();
handle.unfocus();
await renderAndFlush(tui, terminal);
assert.strictEqual(editor.focused, true);
assert.strictEqual(overlay.focused, false);
assert.strictEqual(handle.isFocused(), false);
} finally {
tui.stop();
}
});
it("setHidden(false) on non-capturing overlay does not auto-focus", 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, { nonCapturing: true });
handle.setHidden(true);
handle.setHidden(false);
await renderAndFlush(tui, terminal);
assert.strictEqual(editor.focused, true);
assert.strictEqual(overlay.focused, false);
} finally {
tui.stop();
}
});
it("hide() when overlay is not focused does not change focus", 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, { nonCapturing: true });
handle.hide();
await renderAndFlush(tui, terminal);
assert.strictEqual(editor.focused, true);
} finally {
tui.stop();
}
});
it("hide() when focused restores focus correctly", 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, { nonCapturing: true });
handle.focus();
handle.hide();
await renderAndFlush(tui, terminal);
assert.strictEqual(editor.focused, true);
assert.strictEqual(overlay.focused, false);
} finally {
tui.stop();
}
});
it("capturing overlay removed with non-capturing below restores focus to editor", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const editor = new FocusableOverlay(["EDITOR"]);
const nonCapturing = new FocusableOverlay(["NC"]);
const capturing = new FocusableOverlay(["CAP"]);
tui.addChild(new EmptyContent());
tui.setFocus(editor);
tui.start();
try {
tui.showOverlay(nonCapturing, { nonCapturing: true });
const handle = tui.showOverlay(capturing);
assert.strictEqual(capturing.focused, true);
handle.hide();
await renderAndFlush(tui, terminal);
assert.strictEqual(editor.focused, true);
assert.strictEqual(nonCapturing.focused, false);
} finally {
tui.stop();
}
});
it("sub-overlay cleanup then hideOverlay restores focus and input to editor", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const editor = new FocusableOverlay(["EDITOR"]);
const timer = new FocusableOverlay(["TIMER"]);
const controller = new FocusableOverlay(["CTRL"]);
tui.addChild(new EmptyContent());
tui.setFocus(editor);
tui.start();
try {
const timerHandle = tui.showOverlay(timer, { nonCapturing: true });
tui.showOverlay(controller);
assert.strictEqual(controller.focused, true);
assert.strictEqual(editor.focused, false);
timerHandle.hide();
tui.hideOverlay();
await renderAndFlush(tui, terminal);
assert.strictEqual(editor.focused, true);
assert.strictEqual(controller.focused, false);
assert.strictEqual(timer.focused, false);
terminal.sendInput("x");
await renderAndFlush(tui, terminal);
assert.deepStrictEqual(editor.inputs, ["x"]);
assert.deepStrictEqual(controller.inputs, []);
assert.deepStrictEqual(timer.inputs, []);
} 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);
const editor = new FocusableOverlay(["EDITOR"]);
const timer = new FocusableOverlay(["TIMER"]);
const controller = new FocusableOverlay(["CTRL"]);
tui.addChild(new EmptyContent());
tui.setFocus(editor);
tui.start();
try {
// Simulate showExtensionCustom: factory creates timer synchronously,
// then .then() pushes controller as a microtask
let timerHandle: ReturnType<typeof tui.showOverlay>;
let doneFn: () => void;
const overlayPromise = new Promise<void>((resolve) => {
doneFn = () => {
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) => {
tui.showOverlay(c);
});
});
// Wait for .then() microtask and renders to settle
await new Promise<void>((r) => setTimeout(r, 50));
await renderAndFlush(tui, terminal);
assert.strictEqual(controller.focused, true);
assert.strictEqual(editor.focused, false);
// Simulate Esc: cleanup + close (from inside handleInput)
doneFn!();
// Now await the promise (simulating showExtensionCustom resolving)
await overlayPromise;
await renderAndFlush(tui, terminal);
assert.strictEqual(editor.focused, true, "editor should regain focus");
assert.strictEqual(controller.focused, false);
assert.strictEqual(timer.focused, false);
terminal.sendInput("x");
await renderAndFlush(tui, terminal);
assert.deepStrictEqual(editor.inputs, ["x"], "editor should receive input after close");
assert.deepStrictEqual(controller.inputs, []);
} finally {
tui.stop();
}
});
it("handleInput redirection skips non-capturing overlays when focused overlay becomes invisible", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const editor = new FocusableOverlay(["EDITOR"]);
const fallbackCapturing = new FocusableOverlay(["FALLBACK"]);
const nonCapturing = new FocusableOverlay(["NC"]);
const primary = new FocusableOverlay(["PRIMARY"]);
let isVisible = true;
tui.addChild(new EmptyContent());
tui.setFocus(editor);
tui.start();
try {
tui.showOverlay(fallbackCapturing);
tui.showOverlay(nonCapturing, { nonCapturing: true });
tui.showOverlay(primary, { visible: () => isVisible });
assert.strictEqual(primary.focused, true);
isVisible = false;
terminal.sendInput("x");
await renderAndFlush(tui, terminal);
assert.deepStrictEqual(primary.inputs, []);
assert.deepStrictEqual(nonCapturing.inputs, []);
assert.deepStrictEqual(fallbackCapturing.inputs, ["x"]);
assert.strictEqual(fallbackCapturing.focused, true);
} 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);
const editor = new FocusableOverlay(["EDITOR"]);
const capturing = new FocusableOverlay(["CAP"]);
const nonCapturing = new FocusableOverlay(["NC"]);
tui.addChild(new EmptyContent());
tui.setFocus(editor);
tui.start();
try {
tui.showOverlay(capturing);
tui.showOverlay(nonCapturing, { nonCapturing: true });
assert.strictEqual(capturing.focused, true);
tui.hideOverlay();
await renderAndFlush(tui, terminal);
assert.strictEqual(capturing.focused, true);
} finally {
tui.stop();
}
});
it("multiple capturing and non-capturing overlays restore focus through removals", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const editor = new FocusableOverlay(["EDITOR"]);
const c1 = new FocusableOverlay(["C1"]);
const n1 = new FocusableOverlay(["N1"]);
const c2 = new FocusableOverlay(["C2"]);
const n2 = new FocusableOverlay(["N2"]);
tui.addChild(new EmptyContent());
tui.setFocus(editor);
tui.start();
try {
const c1Handle = tui.showOverlay(c1);
tui.showOverlay(n1, { nonCapturing: true });
const c2Handle = tui.showOverlay(c2);
tui.showOverlay(n2, { nonCapturing: true });
assert.strictEqual(c2.focused, true);
c2Handle.hide();
await renderAndFlush(tui, terminal);
assert.strictEqual(c1.focused, true);
c1Handle.hide();
await renderAndFlush(tui, terminal);
assert.strictEqual(editor.focused, true);
} finally {
tui.stop();
}
});
it("capturing overlay unfocus() on topmost capturing overlay falls back to preFocus", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const editor = new FocusableOverlay(["EDITOR"]);
const capturing = new FocusableOverlay(["CAP"]);
tui.addChild(new EmptyContent());
tui.setFocus(editor);
tui.start();
try {
const handle = tui.showOverlay(capturing);
assert.strictEqual(capturing.focused, true);
handle.unfocus();
await renderAndFlush(tui, terminal);
assert.strictEqual(editor.focused, true);
assert.strictEqual(capturing.focused, false);
} finally {
tui.stop();
}
});
});
describe("no-op guards", () => {
it("focus() on hidden overlay is a no-op", 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, { nonCapturing: true });
handle.setHidden(true);
handle.focus();
await renderAndFlush(tui, terminal);
assert.strictEqual(editor.focused, true);
assert.strictEqual(handle.isFocused(), false);
} finally {
tui.stop();
}
});
it("focus() after hide() is a no-op", 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, { nonCapturing: true });
handle.hide();
handle.focus();
await renderAndFlush(tui, terminal);
assert.strictEqual(editor.focused, true);
assert.strictEqual(handle.isFocused(), false);
} finally {
tui.stop();
}
});
it("unfocus() when overlay does not have focus is a no-op", 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, { nonCapturing: true });
handle.unfocus();
await renderAndFlush(tui, terminal);
assert.strictEqual(editor.focused, true);
assert.strictEqual(overlay.focused, false);
} finally {
tui.stop();
}
});
it("unfocus() with null preFocus clears focus and does not route input back to overlay", 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);
assert.strictEqual(overlay.focused, true);
handle.unfocus();
assert.strictEqual(overlay.focused, false);
terminal.sendInput("x");
await renderAndFlush(tui, terminal);
assert.deepStrictEqual(overlay.inputs, []);
assert.strictEqual(handle.isFocused(), false);
} finally {
tui.stop();
}
});
});
describe("focus cycle prevention", () => {
it("toggle focus between non-capturing overlays then unfocus returns to 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"]);
tui.addChild(new EmptyContent());
tui.setFocus(editor);
tui.start();
try {
const aHandle = tui.showOverlay(a, { nonCapturing: true });
const bHandle = tui.showOverlay(b, { nonCapturing: true });
aHandle.focus();
bHandle.focus();
aHandle.focus();
aHandle.unfocus();
await renderAndFlush(tui, terminal);
assert.strictEqual(editor.focused, true);
assert.strictEqual(a.focused, false);
assert.strictEqual(b.focused, false);
} finally {
tui.stop();
}
});
});
describe("rendering order", () => {
it("focus() on already-focused overlay bumps visual order", async () => {
const terminal = new VirtualTerminal(20, 6);
const tui = new TUI(terminal);
const editor = new FocusableOverlay(["EDITOR"]);
tui.addChild(new EmptyContent());
tui.setFocus(editor);
tui.start();
try {
const aHandle = tui.showOverlay(new StaticOverlay(["A"]), { row: 0, col: 0, width: 1, nonCapturing: true });
tui.showOverlay(new StaticOverlay(["B"]), { row: 0, col: 0, width: 1, nonCapturing: true });
aHandle.focus();
tui.showOverlay(new StaticOverlay(["C"]), { row: 0, col: 0, width: 1, nonCapturing: true });
await renderAndFlush(tui, terminal);
assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "C");
aHandle.focus();
await renderAndFlush(tui, terminal);
assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "A");
assert.strictEqual(aHandle.isFocused(), true);
} finally {
tui.stop();
}
});
it("default rendering order for overlapping overlays follows creation order", async () => {
const terminal = new VirtualTerminal(20, 6);
const tui = new TUI(terminal);
tui.addChild(new EmptyContent());
tui.start();
try {
tui.showOverlay(new StaticOverlay(["A"]), { row: 0, col: 0, width: 1, nonCapturing: true });
tui.showOverlay(new StaticOverlay(["B"]), { row: 0, col: 0, width: 1, nonCapturing: true });
await renderAndFlush(tui, terminal);
assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "B");
} finally {
tui.stop();
}
});
it("focus() on lower overlay renders it on top", async () => {
const terminal = new VirtualTerminal(20, 6);
const tui = new TUI(terminal);
tui.addChild(new EmptyContent());
tui.start();
try {
const lower = tui.showOverlay(new StaticOverlay(["A"]), { row: 0, col: 0, width: 1, nonCapturing: true });
tui.showOverlay(new StaticOverlay(["B"]), { row: 0, col: 0, width: 1, nonCapturing: true });
await renderAndFlush(tui, terminal);
assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "B");
lower.focus();
await renderAndFlush(tui, terminal);
assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "A");
} finally {
tui.stop();
}
});
it("focusing middle overlay places it on top while preserving others relative order", async () => {
const terminal = new VirtualTerminal(20, 6);
const tui = new TUI(terminal);
tui.addChild(new EmptyContent());
tui.start();
try {
tui.showOverlay(new StaticOverlay(["A"]), { row: 0, col: 0, width: 1, nonCapturing: true });
const middle = tui.showOverlay(new StaticOverlay(["B"]), { row: 0, col: 0, width: 1, nonCapturing: true });
const top = tui.showOverlay(new StaticOverlay(["C"]), { row: 0, col: 0, width: 1, nonCapturing: true });
await renderAndFlush(tui, terminal);
assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "C");
middle.focus();
await renderAndFlush(tui, terminal);
assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "B");
middle.hide();
await renderAndFlush(tui, terminal);
assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "C");
top.hide();
await renderAndFlush(tui, terminal);
assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "A");
} finally {
tui.stop();
}
});
it("capturing overlay hidden and shown again renders on top after unhide", async () => {
const terminal = new VirtualTerminal(20, 6);
const tui = new TUI(terminal);
tui.addChild(new EmptyContent());
tui.start();
try {
tui.showOverlay(new StaticOverlay(["A"]), { row: 0, col: 0, width: 1, nonCapturing: true });
const capturing = tui.showOverlay(new StaticOverlay(["B"]), { row: 0, col: 0, width: 1 });
await renderAndFlush(tui, terminal);
assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "B");
capturing.setHidden(true);
tui.showOverlay(new StaticOverlay(["C"]), { row: 0, col: 0, width: 1, nonCapturing: true });
await renderAndFlush(tui, terminal);
assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "C");
capturing.setHidden(false);
await renderAndFlush(tui, terminal);
assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "B");
} finally {
tui.stop();
}
});
it("unfocus() does not change visual order until another overlay is focused", async () => {
const terminal = new VirtualTerminal(20, 6);
const tui = new TUI(terminal);
const editor = new FocusableOverlay(["EDITOR"]);
tui.addChild(new EmptyContent());
tui.setFocus(editor);
tui.start();
try {
const a = tui.showOverlay(new StaticOverlay(["A"]), { row: 0, col: 0, width: 1, nonCapturing: true });
const b = tui.showOverlay(new StaticOverlay(["B"]), { row: 0, col: 0, width: 1, nonCapturing: true });
await renderAndFlush(tui, terminal);
assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "B");
a.focus();
await renderAndFlush(tui, terminal);
assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "A");
a.unfocus();
await renderAndFlush(tui, terminal);
assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "A");
b.focus();
await renderAndFlush(tui, terminal);
assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "B");
} finally {
tui.stop();
}
});
});
});