feat(tui): add non-capturing overlays with focus control (#1916)
This commit is contained in:
@@ -14,10 +14,13 @@
|
||||
* /overlay-maxheight - Test maxHeight truncation
|
||||
* /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-streaming - Multiple input panels with simulated streaming (Tab to cycle focus)
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@mariozechner/pi-coding-agent";
|
||||
import type { OverlayAnchor, OverlayHandle, OverlayOptions, TUI } from "@mariozechner/pi-tui";
|
||||
import type { Component, OverlayAnchor, OverlayHandle, OverlayOptions, TUI } from "@mariozechner/pi-tui";
|
||||
import { matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
||||
import { spawn } from "child_process";
|
||||
|
||||
@@ -256,6 +259,42 @@ export default function (pi: ExtensionAPI) {
|
||||
globalToggleHandle = null;
|
||||
},
|
||||
});
|
||||
|
||||
// Non-capturing overlay demo - passive info panel that doesn't steal focus
|
||||
pi.registerCommand("overlay-passive", {
|
||||
description: "Test non-capturing overlay (passive info panel alongside active overlay)",
|
||||
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
||||
ctx.ui.setEditorText("");
|
||||
await ctx.ui.custom<void>((tui, theme, _kb, done) => new PassiveDemoController(tui, theme, done), {
|
||||
overlay: true,
|
||||
overlayOptions: { anchor: "center", width: 48 },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Focus cycling demo - demonstrates focus(), unfocus(), isFocused() and rendering order
|
||||
pi.registerCommand("overlay-focus", {
|
||||
description: "Test focus cycling and rendering order with non-capturing overlays",
|
||||
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
||||
ctx.ui.setEditorText("");
|
||||
await ctx.ui.custom<void>((tui, theme, _kb, done) => new FocusDemoController(tui, theme, done), {
|
||||
overlay: true,
|
||||
overlayOptions: { anchor: "bottom-center", width: 55, margin: { bottom: 1 } },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Test multiple input panels with simulated streaming
|
||||
pi.registerCommand("overlay-streaming", {
|
||||
description: "Multiple input panels with simulated streaming (Tab to cycle focus)",
|
||||
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
||||
ctx.ui.setEditorText("");
|
||||
await ctx.ui.custom<void>((tui, theme, _kb, done) => new StreamingInputController(tui, theme, done), {
|
||||
overlay: true,
|
||||
overlayOptions: { anchor: "bottom-center", width: 60, margin: { bottom: 1 } },
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
@@ -879,3 +918,431 @@ class ToggleDemoComponent extends BaseOverlay {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// === Non-capturing passive overlay demo ===
|
||||
|
||||
class PassiveDemoController extends BaseOverlay {
|
||||
focused = false;
|
||||
private typed = "";
|
||||
private timerComponent: TimerPanel;
|
||||
private timerHandle: OverlayHandle | null = null;
|
||||
private interval: ReturnType<typeof setInterval> | null = null;
|
||||
private inputCount = 0;
|
||||
private lastInputDebug = "";
|
||||
|
||||
constructor(
|
||||
private tui: TUI,
|
||||
theme: Theme,
|
||||
private done: () => void,
|
||||
) {
|
||||
super(theme);
|
||||
this.timerComponent = new TimerPanel(theme);
|
||||
this.timerHandle = this.tui.showOverlay(this.timerComponent, {
|
||||
nonCapturing: true,
|
||||
anchor: "top-right",
|
||||
width: 22,
|
||||
margin: { top: 1, right: 2 },
|
||||
});
|
||||
this.interval = setInterval(() => {
|
||||
this.timerComponent.tick();
|
||||
this.tui.requestRender();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
this.inputCount++;
|
||||
this.lastInputDebug = `len=${data.length} c0=${data.charCodeAt(0)}`;
|
||||
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
||||
this.cleanup();
|
||||
this.done();
|
||||
} else if (matchesKey(data, "backspace")) {
|
||||
this.typed = this.typed.slice(0, -1);
|
||||
} else if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
||||
this.typed += data;
|
||||
}
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const th = this.theme;
|
||||
const display = this.typed.length > 0 ? this.typed : th.fg("dim", "(type here)");
|
||||
return this.box(
|
||||
[
|
||||
"",
|
||||
` ${th.fg("dim", `focused=${this.focused} inputs=${this.inputCount}`)}`,
|
||||
` ${th.fg("dim", `last: ${this.lastInputDebug || "none"}`)}`,
|
||||
"",
|
||||
` > ${display}`,
|
||||
"",
|
||||
th.fg("dim", " Type to prove input goes here."),
|
||||
th.fg("dim", " Press Esc to close both."),
|
||||
"",
|
||||
],
|
||||
width,
|
||||
"Non-Capturing Demo",
|
||||
);
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
this.timerHandle?.hide();
|
||||
this.timerHandle = null;
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
this.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
class TimerPanel extends BaseOverlay {
|
||||
private seconds = 0;
|
||||
|
||||
tick(): void {
|
||||
this.seconds++;
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const th = this.theme;
|
||||
const mins = Math.floor(this.seconds / 60);
|
||||
const secs = this.seconds % 60;
|
||||
const time = `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
|
||||
return this.box([` ${th.fg("accent", time)}`, th.fg("dim", " nonCapturing: true")], width, "Timer");
|
||||
}
|
||||
}
|
||||
|
||||
// === Focus cycling demo ===
|
||||
|
||||
class FocusDemoController extends BaseOverlay {
|
||||
private panels: FocusPanel[] = [];
|
||||
private handles: OverlayHandle[] = [];
|
||||
private focusIndex = -1;
|
||||
|
||||
constructor(
|
||||
private tui: TUI,
|
||||
theme: Theme,
|
||||
private done: () => void,
|
||||
) {
|
||||
super(theme);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
private close(): void {
|
||||
for (const handle of this.handles) handle.hide();
|
||||
this.handles = [];
|
||||
this.panels = [];
|
||||
this.done();
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
||||
this.close();
|
||||
} else if (matchesKey(data, "tab")) {
|
||||
this.cycleFocus();
|
||||
}
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const th = this.theme;
|
||||
const focused = this.focusIndex === -1 ? "Controller" : (this.panels[this.focusIndex]?.label ?? "?");
|
||||
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.",
|
||||
"",
|
||||
" Focused panel renders on top",
|
||||
" (focus-based rendering order).",
|
||||
"",
|
||||
th.fg("dim", " Tab = cycle focus | Esc = close"),
|
||||
"",
|
||||
],
|
||||
width,
|
||||
"Focus Demo",
|
||||
);
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
for (const handle of this.handles) handle.hide();
|
||||
}
|
||||
}
|
||||
|
||||
class FocusPanel extends BaseOverlay {
|
||||
handle: OverlayHandle | null = null;
|
||||
readonly label: string;
|
||||
|
||||
constructor(
|
||||
theme: Theme,
|
||||
label: string,
|
||||
private color: "error" | "success" | "accent",
|
||||
private onTab: () => void,
|
||||
private onClose: () => void,
|
||||
) {
|
||||
super(theme);
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (matchesKey(data, "tab")) {
|
||||
this.onTab();
|
||||
} else if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
||||
this.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
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 padLine = (s: string) => truncateToWidth(s, innerW, "...", true);
|
||||
const lines: string[] = [];
|
||||
|
||||
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("") + border("│"));
|
||||
lines.push(border(`╰${"─".repeat(innerW)}╯`));
|
||||
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
|
||||
// === Streaming input panel test (/overlay-streaming) ===
|
||||
|
||||
class StreamingInputController extends BaseOverlay {
|
||||
private panels: StreamingInputPanel[] = [];
|
||||
private handles: OverlayHandle[] = [];
|
||||
private focusIndex = -1; // -1 = controller focused, 0-2 = panel focused
|
||||
private streamLines: string[] = [];
|
||||
private streamInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private lineCount = 0;
|
||||
|
||||
constructor(
|
||||
private tui: TUI,
|
||||
theme: Theme,
|
||||
private done: () => void,
|
||||
) {
|
||||
super(theme);
|
||||
|
||||
// Create 3 input panels as non-capturing overlays
|
||||
const colors = ["error", "success", "accent"] as const;
|
||||
const labels = ["Panel A", "Panel B", "Panel C"];
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const panel = new StreamingInputPanel(
|
||||
theme,
|
||||
labels[i]!,
|
||||
colors[i]!,
|
||||
() => this.cycleFocus(),
|
||||
() => this.close(),
|
||||
);
|
||||
const handle = this.tui.showOverlay(panel, {
|
||||
nonCapturing: true,
|
||||
row: 1 + i * 9,
|
||||
col: 2,
|
||||
width: 35,
|
||||
});
|
||||
panel.handle = handle;
|
||||
this.panels.push(panel);
|
||||
this.handles.push(handle);
|
||||
}
|
||||
|
||||
// Start with controller focused (focusIndex = -1)
|
||||
|
||||
// Start simulated streaming
|
||||
this.streamInterval = setInterval(() => {
|
||||
this.lineCount++;
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
this.streamLines.push(`[${timestamp}] Streaming line ${this.lineCount}...`);
|
||||
if (this.streamLines.length > 8) {
|
||||
this.streamLines.shift();
|
||||
}
|
||||
this.tui.requestRender();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
private cycleFocus(): void {
|
||||
// Unfocus current panel if any
|
||||
if (this.focusIndex >= 0 && this.focusIndex < this.handles.length) {
|
||||
this.handles[this.focusIndex]!.unfocus();
|
||||
}
|
||||
|
||||
// Cycle: -1 (controller) → 0 → 1 → 2 → -1 ...
|
||||
this.focusIndex++;
|
||||
if (this.focusIndex >= this.handles.length) {
|
||||
this.focusIndex = -1; // Back to controller
|
||||
}
|
||||
|
||||
// Focus new panel if any
|
||||
if (this.focusIndex >= 0) {
|
||||
this.handles[this.focusIndex]!.focus();
|
||||
}
|
||||
|
||||
this.tui.requestRender();
|
||||
}
|
||||
|
||||
private close(): void {
|
||||
if (this.streamInterval) {
|
||||
clearInterval(this.streamInterval);
|
||||
this.streamInterval = null;
|
||||
}
|
||||
for (const handle of this.handles) handle.hide();
|
||||
this.handles = [];
|
||||
this.panels = [];
|
||||
this.done();
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
||||
this.close();
|
||||
} else if (matchesKey(data, "tab")) {
|
||||
this.cycleFocus();
|
||||
}
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const th = this.theme;
|
||||
const focusedLabel =
|
||||
this.focusIndex === -1
|
||||
? th.fg("success", "Controller (this panel)")
|
||||
: (this.panels[this.focusIndex]?.label ?? "?");
|
||||
|
||||
const lines = [
|
||||
"",
|
||||
` Current focus: ${th.fg("accent", focusedLabel)}`,
|
||||
"",
|
||||
" Simulated streaming output:",
|
||||
th.fg("dim", " ─".repeat((width - 2) / 2)),
|
||||
];
|
||||
|
||||
for (const line of this.streamLines) {
|
||||
lines.push(` ${th.fg("dim", line)}`);
|
||||
}
|
||||
|
||||
while (lines.length < 12) {
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push(th.fg("dim", " ─".repeat((width - 2) / 2)));
|
||||
lines.push("");
|
||||
lines.push(` Three ${th.fg("accent", "nonCapturing")} input panels on the left.`);
|
||||
lines.push(" Tab cycles: Controller → Panel A → B → C → Controller");
|
||||
lines.push(" Type in each panel to test input routing.");
|
||||
lines.push("");
|
||||
lines.push(th.fg("dim", " Tab = cycle focus | Esc = close all"));
|
||||
lines.push("");
|
||||
|
||||
return this.box(lines, width, "Streaming + Input Test");
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
class StreamingInputPanel implements Component {
|
||||
handle: OverlayHandle | null = null;
|
||||
private typed = "";
|
||||
readonly label: string;
|
||||
|
||||
constructor(
|
||||
private theme: Theme,
|
||||
label: string,
|
||||
private color: "error" | "success" | "accent",
|
||||
private onTab: () => void,
|
||||
private onClose: () => void,
|
||||
) {
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (matchesKey(data, "tab")) {
|
||||
this.onTab();
|
||||
} else if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
||||
this.onClose();
|
||||
} else if (matchesKey(data, "backspace")) {
|
||||
this.typed = this.typed.slice(0, -1);
|
||||
} else if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
||||
this.typed += 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 padLine = (s: string) => {
|
||||
const w = visibleWidth(s);
|
||||
return s + " ".repeat(Math.max(0, innerW - w));
|
||||
};
|
||||
|
||||
const inputDisplay = this.typed.length > 0 ? this.typed : th.fg("dim", "(type here)");
|
||||
const truncatedInput = truncateToWidth(` > ${inputDisplay}`, innerW, "...", true);
|
||||
|
||||
const lines: string[] = [];
|
||||
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("") + border("│"));
|
||||
}
|
||||
lines.push(border("│") + padLine(truncatedInput) + border("│"));
|
||||
lines.push(border("│") + padLine("") + border("│"));
|
||||
lines.push(border("│") + padLine(th.fg("dim", " Tab | Esc")) + border("│"));
|
||||
lines.push(border(`╰${"─".repeat(innerW)}╯`));
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
609
packages/tui/test/overlay-non-capturing.test.ts
Normal file
609
packages/tui/test/overlay-non-capturing.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user