pi-libtui 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/package.json +55 -0
- package/src/activity-presentation.ts +75 -0
- package/src/appearance.ts +632 -0
- package/src/background-surface.ts +125 -0
- package/src/color/palette.ts +186 -0
- package/src/color/preview.ts +157 -0
- package/src/color/resolver.ts +75 -0
- package/src/color/theme.ts +531 -0
- package/src/component-stack.ts +247 -0
- package/src/content/markdown-code.ts +60 -0
- package/src/content/terminal-text.ts +135 -0
- package/src/content/text.ts +105 -0
- package/src/controls/action-panel.ts +420 -0
- package/src/controls/dialog-button-bar.ts +287 -0
- package/src/controls/multi-select.ts +467 -0
- package/src/controls/picker-panel.ts +447 -0
- package/src/controls/screen-icon-actions.ts +134 -0
- package/src/controls/searchable-select.ts +394 -0
- package/src/controls/select-box.ts +224 -0
- package/src/controls/selectable-list.ts +433 -0
- package/src/controls/selection-action-bar.ts +382 -0
- package/src/controls/semantic-input.ts +23 -0
- package/src/controls/tab.ts +189 -0
- package/src/cursor.ts +288 -0
- package/src/decoration/editor-pills.ts +156 -0
- package/src/decoration/glyphs.ts +326 -0
- package/src/decoration/pointer-interaction.ts +123 -0
- package/src/decoration/powerline-pill.ts +104 -0
- package/src/decoration/status.ts +185 -0
- package/src/decoration/transient-pill.ts +176 -0
- package/src/diff/index.ts +21 -0
- package/src/diff/model.ts +76 -0
- package/src/diff/parse.ts +310 -0
- package/src/diff/pierre-syntax.ts +86 -0
- package/src/diff/render.ts +504 -0
- package/src/diff/structured.ts +264 -0
- package/src/diff/view.ts +75 -0
- package/src/editor/chrome.ts +341 -0
- package/src/editor/composition.ts +53 -0
- package/src/editor/layout.ts +81 -0
- package/src/editor/presentation.ts +35 -0
- package/src/editor/protocol.ts +188 -0
- package/src/editor.ts +41 -0
- package/src/extension.ts +30 -0
- package/src/folding.ts +192 -0
- package/src/host/cursor-bridge.ts +210 -0
- package/src/host/editor-bridge.ts +148 -0
- package/src/host/extension-host.ts +231 -0
- package/src/host/mouse-bridge.ts +212 -0
- package/src/host/mouse-dispatch.ts +400 -0
- package/src/host/mouse-input.ts +85 -0
- package/src/host/mouse-targets.ts +238 -0
- package/src/host/pi-layout-adapter.ts +298 -0
- package/src/host/pi-native-selection.ts +227 -0
- package/src/host/split-pane-bridge.ts +720 -0
- package/src/host/transcript-bridge.ts +236 -0
- package/src/index.ts +334 -0
- package/src/line-layout.ts +6 -0
- package/src/motion.ts +1409 -0
- package/src/mouse/events.ts +55 -0
- package/src/mouse/fullscreen-layout.ts +260 -0
- package/src/mouse/registry.ts +348 -0
- package/src/mouse.ts +37 -0
- package/src/native-binary.ts +189 -0
- package/src/overlay/anchored.ts +91 -0
- package/src/overlay/detail-card.ts +158 -0
- package/src/overlay/dialog.ts +266 -0
- package/src/overlay/floating.ts +106 -0
- package/src/overlay/framed-editor.ts +236 -0
- package/src/overlay/fullscreen.ts +170 -0
- package/src/overlay/hover-tooltip.ts +109 -0
- package/src/overlay/modal-mount.ts +118 -0
- package/src/panels.ts +207 -0
- package/src/render-cache.ts +80 -0
- package/src/render-epoch.ts +48 -0
- package/src/request-animation.ts +160 -0
- package/src/scrollbar.ts +27 -0
- package/src/selection.ts +148 -0
- package/src/split-pane.ts +232 -0
- package/src/status-presentation.ts +411 -0
- package/src/stream.ts +543 -0
- package/src/syntax.ts +316 -0
- package/src/terminal/bridge-client.ts +243 -0
- package/src/terminal/embedding.ts +6 -0
- package/src/terminal/output.ts +151 -0
- package/src/terminal/projection.ts +362 -0
- package/src/terminal/pty-host.ts +263 -0
- package/src/terminal/pty-pane.ts +308 -0
- package/src/terminal-colors.ts +334 -0
- package/src/tool/action.ts +170 -0
- package/src/tool/activity.ts +500 -0
- package/src/tool/call-preview.ts +44 -0
- package/src/tool/disclosure-action.ts +89 -0
- package/src/tool/expanded-region-viewport.ts +103 -0
- package/src/tool/index.ts +26 -0
- package/src/tool/output.ts +271 -0
- package/src/tool/transcript.ts +150 -0
- package/src/tool/view-region.ts +581 -0
- package/test/appearance.test.ts +183 -0
- package/test/bounded-stream.test.ts +150 -0
- package/test/bridge-client.test.ts +42 -0
- package/test/color-preview.test.ts +23 -0
- package/test/component-stack.test.ts +206 -0
- package/test/components.test.ts +1165 -0
- package/test/content-text.test.ts +21 -0
- package/test/cursor-bridge.test.ts +151 -0
- package/test/cursor.test.ts +147 -0
- package/test/editor-chrome.test.ts +119 -0
- package/test/editor-layout.test.ts +30 -0
- package/test/editor-registry.test.ts +203 -0
- package/test/editor-theme.test.ts +33 -0
- package/test/editor-token-pill.test.ts +72 -0
- package/test/floating-overlay.test.ts +57 -0
- package/test/folding.test.ts +147 -0
- package/test/harmonious-theme.test.ts +23 -0
- package/test/interaction.test.ts +104 -0
- package/test/key-icons.test.ts +62 -0
- package/test/markdown-code.test.ts +24 -0
- package/test/modal-overlay-mount.test.ts +94 -0
- package/test/motion.test.ts +649 -0
- package/test/mouse/bridge.test.ts +1302 -0
- package/test/mouse/extension.test.ts +205 -0
- package/test/mouse/registry.test.ts +140 -0
- package/test/mouse/screen-decoration.test.ts +167 -0
- package/test/native-binary.test.ts +52 -0
- package/test/palette.test.ts +32 -0
- package/test/panels.test.ts +74 -0
- package/test/pointer-components.test.ts +151 -0
- package/test/progress.test.ts +54 -0
- package/test/pty-host.test.ts +126 -0
- package/test/pty-pane.test.ts +62 -0
- package/test/public-surface.test.ts +75 -0
- package/test/render-cache.test.ts +25 -0
- package/test/render-epoch.test.ts +119 -0
- package/test/request-animation.test.ts +193 -0
- package/test/screen-icon-actions.test.ts +69 -0
- package/test/select-box.test.ts +86 -0
- package/test/select-pointer.test.ts +369 -0
- package/test/selectable-list.test.ts +258 -0
- package/test/selection/registry.test.ts +44 -0
- package/test/selection-action-bar.test.ts +204 -0
- package/test/split-pane-bridge.test.ts +944 -0
- package/test/split-pane.test.ts +134 -0
- package/test/status-presentation.test.ts +39 -0
- package/test/syntax.test.ts +59 -0
- package/test/terminal-colors.test.ts +115 -0
- package/test/terminal-embedding.test.ts +12 -0
- package/test/terminal-projection.test.ts +312 -0
- package/test/theme-colors.test.ts +175 -0
- package/test/tool-output.test.ts +160 -0
- package/test/tool-transcript.test.ts +1190 -0
- package/test/unified-diff.test.ts +691 -0
- package/themes/harmonious.json +61 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,944 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import {
|
|
4
|
+
type Component,
|
|
5
|
+
type Focusable,
|
|
6
|
+
HStack,
|
|
7
|
+
isFocusable,
|
|
8
|
+
ScrollView,
|
|
9
|
+
stripTerminalSequences,
|
|
10
|
+
type TUI,
|
|
11
|
+
TuiAltScreen,
|
|
12
|
+
visibleWidth,
|
|
13
|
+
} from "@earendil-works/pi-tui";
|
|
14
|
+
import { installSplitPaneBridge } from "../src/host/split-pane-bridge.ts";
|
|
15
|
+
import { getMouseRegistryState } from "../src/mouse/registry.ts";
|
|
16
|
+
import type { LayoutBox, TuiMouseEvent } from "../src/mouse.ts";
|
|
17
|
+
import { ensureMouseRegistry } from "../src/mouse.ts";
|
|
18
|
+
import { ensureSplitPaneRegistry, type SplitPaneHost, type SplitPaneRegistry } from "../src/split-pane.ts";
|
|
19
|
+
|
|
20
|
+
// type-boundary: The bridge fixtures implement only the private renderer and theme surfaces used by this compatibility patch.
|
|
21
|
+
type TuiBoundary = unknown;
|
|
22
|
+
type ThemeBoundary = unknown;
|
|
23
|
+
|
|
24
|
+
const theme = {
|
|
25
|
+
name: "test",
|
|
26
|
+
bold: (text: string) => text,
|
|
27
|
+
getColorMode: () => "truecolor",
|
|
28
|
+
getFgAnsi: () => "\x1b[38;2;120;160;220m",
|
|
29
|
+
getBgAnsi: () => "\x1b[48;2;20;24;30m",
|
|
30
|
+
} as ThemeBoundary as Theme;
|
|
31
|
+
|
|
32
|
+
function fill(label: string): Component {
|
|
33
|
+
return {
|
|
34
|
+
render: (width) => [label.repeat(width)],
|
|
35
|
+
invalidate() {},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
class FullscreenTuiFixture {
|
|
40
|
+
readonly mode = "fullscreen" as const;
|
|
41
|
+
readonly terminal = { columns: 80, rows: 24 };
|
|
42
|
+
layoutRoot: Component | undefined;
|
|
43
|
+
currentLayout: object | undefined;
|
|
44
|
+
renderRequests = 0;
|
|
45
|
+
immediateRenderRequests = 0;
|
|
46
|
+
private focusedComponent: Component | null = null;
|
|
47
|
+
|
|
48
|
+
constructor(root?: Component) {
|
|
49
|
+
this.layoutRoot = root;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
setLayoutRoot(component: Component | undefined): void {
|
|
53
|
+
if (this.layoutRoot === component) return;
|
|
54
|
+
this.layoutRoot = component;
|
|
55
|
+
this.currentLayout = undefined;
|
|
56
|
+
this.requestRender();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
requestRender(): void {
|
|
60
|
+
this.renderRequests += 1;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
requestImmediateRender(): void {
|
|
64
|
+
this.immediateRenderRequests += 1;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
getFocusedComponent(): Component | null {
|
|
68
|
+
return this.focusedComponent;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
setFocus(component: Component | null): void {
|
|
72
|
+
if (isFocusable(this.focusedComponent)) this.focusedComponent.focused = false;
|
|
73
|
+
this.focusedComponent = component;
|
|
74
|
+
if (isFocusable(component)) component.focused = true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function registry(): SplitPaneRegistry {
|
|
79
|
+
return ensureSplitPaneRegistry(Object.create(null) as typeof globalThis);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function mouseRegistry() {
|
|
83
|
+
return ensureMouseRegistry(Object.create(null) as typeof globalThis);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function render(root: Component | undefined, width: number): string[] {
|
|
87
|
+
if (!root) throw new Error("expected a layout root");
|
|
88
|
+
return root.render(width).map(stripTerminalSequences);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// type-boundary: The test locates the host-owned divider in Pi TUI's structural layout entries.
|
|
92
|
+
type LayoutEntryBoundary = unknown;
|
|
93
|
+
|
|
94
|
+
function divider(root: Component | undefined): Component & { onMouse(event: TuiMouseEvent): boolean } {
|
|
95
|
+
const entries = root && (Reflect.get(root as object, "entries") as LayoutEntryBoundary);
|
|
96
|
+
if (!Array.isArray(entries)) throw new Error("expected split layout entries");
|
|
97
|
+
for (const entry of entries) {
|
|
98
|
+
if (!entry || typeof entry !== "object") continue;
|
|
99
|
+
const component = Reflect.get(entry, "component") as LayoutEntryBoundary;
|
|
100
|
+
if (!component || typeof component !== "object") continue;
|
|
101
|
+
if (typeof Reflect.get(component, "onMouse") === "function") {
|
|
102
|
+
return component as Component & { onMouse(event: TuiMouseEvent): boolean };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
throw new Error("expected draggable split divider");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function splitEntries(root: Component | undefined): Component[] {
|
|
109
|
+
const entries = root && (Reflect.get(root as object, "entries") as LayoutEntryBoundary);
|
|
110
|
+
if (!Array.isArray(entries)) throw new Error("expected split layout entries");
|
|
111
|
+
return entries.flatMap((entry) => {
|
|
112
|
+
if (!entry || typeof entry !== "object") return [];
|
|
113
|
+
const component = Reflect.get(entry, "component") as LayoutEntryBoundary;
|
|
114
|
+
return component && typeof component === "object" ? [component as Component] : [];
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function layoutBox(component: Component, x: number, width: number, height: number, children: object[] = []) {
|
|
119
|
+
const rect = { x, y: 0, width, height };
|
|
120
|
+
return { component, rect, clip: rect, children };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function findScrollLayoutBox(box: LayoutBox, scrollView: ScrollView): LayoutBox | undefined {
|
|
124
|
+
if (box.scrollView === scrollView) return box;
|
|
125
|
+
for (const child of box.children) {
|
|
126
|
+
const found = findScrollLayoutBox(child, scrollView);
|
|
127
|
+
if (found) return found;
|
|
128
|
+
}
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function mouse(type: TuiMouseEvent["type"], screenCol: number, button?: 0 | 1 | 2): TuiMouseEvent {
|
|
133
|
+
return {
|
|
134
|
+
type,
|
|
135
|
+
row: 0,
|
|
136
|
+
col: 0,
|
|
137
|
+
screenRow: 0,
|
|
138
|
+
screenCol,
|
|
139
|
+
button,
|
|
140
|
+
wheel: undefined,
|
|
141
|
+
shift: false,
|
|
142
|
+
alt: false,
|
|
143
|
+
ctrl: false,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function focusable(label: string): Component & Focusable {
|
|
148
|
+
return { ...fill(label), focused: false };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
describe("split-pane bridge", () => {
|
|
152
|
+
test("keeps contributed scroll views visible to fullscreen native selection", () => {
|
|
153
|
+
const panes = registry();
|
|
154
|
+
const side = new ScrollView(fill("P"), { overscroll: "contain" });
|
|
155
|
+
const terminal = {
|
|
156
|
+
columns: 80,
|
|
157
|
+
rows: 24,
|
|
158
|
+
kittyProtocolActive: false,
|
|
159
|
+
start: () => {},
|
|
160
|
+
stop: () => {},
|
|
161
|
+
drainInput: async () => {},
|
|
162
|
+
write: () => {},
|
|
163
|
+
moveBy: () => {},
|
|
164
|
+
hideCursor: () => {},
|
|
165
|
+
showCursor: () => {},
|
|
166
|
+
clearLine: () => {},
|
|
167
|
+
clearFromCursor: () => {},
|
|
168
|
+
clearScreen: () => {},
|
|
169
|
+
setTitle: () => {},
|
|
170
|
+
setProgress: () => {},
|
|
171
|
+
};
|
|
172
|
+
const tui = new TuiAltScreen(terminal, false, undefined, { mouse: true });
|
|
173
|
+
const removePane = panes.mount({
|
|
174
|
+
id: "selectable",
|
|
175
|
+
position: "right",
|
|
176
|
+
component: () => side,
|
|
177
|
+
size: 20,
|
|
178
|
+
minMainSize: 1,
|
|
179
|
+
});
|
|
180
|
+
const removeBridge = installSplitPaneBridge(tui, () => theme, panes, mouseRegistry());
|
|
181
|
+
tui.setLayoutRoot(new ScrollView(fill("M"), { primary: true }));
|
|
182
|
+
tui.start();
|
|
183
|
+
tui.renderNow(true);
|
|
184
|
+
|
|
185
|
+
const layout = Reflect.get(tui, "currentLayout") as { root?: LayoutBox } | undefined;
|
|
186
|
+
const sideBox = layout?.root ? findScrollLayoutBox(layout.root, side) : undefined;
|
|
187
|
+
expect(sideBox?.scrollView).toBe(side);
|
|
188
|
+
expect(sideBox?.scrollContentLines?.join("")).toContain("P");
|
|
189
|
+
|
|
190
|
+
tui.stop();
|
|
191
|
+
removeBridge();
|
|
192
|
+
removePane();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("adopts an existing root and allocates a real right-hand column", () => {
|
|
196
|
+
const panes = registry();
|
|
197
|
+
const main = fill("M");
|
|
198
|
+
const tui = new FullscreenTuiFixture(main);
|
|
199
|
+
const removePane = panes.mount({
|
|
200
|
+
id: "right",
|
|
201
|
+
position: "right",
|
|
202
|
+
component: () => fill("P"),
|
|
203
|
+
size: 20,
|
|
204
|
+
minMainSize: 40,
|
|
205
|
+
gap: 1,
|
|
206
|
+
});
|
|
207
|
+
const removeBridge = installSplitPaneBridge(tui as TuiBoundary as TUI, () => theme, panes, mouseRegistry());
|
|
208
|
+
|
|
209
|
+
expect(tui.layoutRoot).toBeInstanceOf(HStack);
|
|
210
|
+
expect(render(tui.layoutRoot, 80)[0]).toBe(`${"M".repeat(58)} │${"P".repeat(20)}`);
|
|
211
|
+
expect(visibleWidth(render(tui.layoutRoot, 80)[0] ?? "")).toBe(80);
|
|
212
|
+
|
|
213
|
+
removeBridge();
|
|
214
|
+
expect(tui.layoutRoot).toBe(main);
|
|
215
|
+
removePane();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("reuses unchanged pane output across unrelated main renders", () => {
|
|
219
|
+
const panes = registry();
|
|
220
|
+
const tui = new FullscreenTuiFixture(fill("M"));
|
|
221
|
+
let host: SplitPaneHost | undefined;
|
|
222
|
+
let paneRenders = 0;
|
|
223
|
+
const removePane = panes.mount({
|
|
224
|
+
id: "cached",
|
|
225
|
+
position: "right",
|
|
226
|
+
component: (nextHost) => {
|
|
227
|
+
host = nextHost;
|
|
228
|
+
return {
|
|
229
|
+
render: (width) => {
|
|
230
|
+
paneRenders++;
|
|
231
|
+
return ["P".repeat(width)];
|
|
232
|
+
},
|
|
233
|
+
invalidate() {},
|
|
234
|
+
};
|
|
235
|
+
},
|
|
236
|
+
size: 20,
|
|
237
|
+
minMainSize: 1,
|
|
238
|
+
});
|
|
239
|
+
const removeBridge = installSplitPaneBridge(tui as TuiBoundary as TUI, () => theme, panes, mouseRegistry());
|
|
240
|
+
|
|
241
|
+
render(tui.layoutRoot, 80);
|
|
242
|
+
const measuredRenders = paneRenders;
|
|
243
|
+
expect(measuredRenders).toBeGreaterThan(0);
|
|
244
|
+
render(tui.layoutRoot, 80);
|
|
245
|
+
expect(paneRenders).toBe(measuredRenders);
|
|
246
|
+
|
|
247
|
+
host?.requestRender();
|
|
248
|
+
render(tui.layoutRoot, 80);
|
|
249
|
+
expect(paneRenders).toBeGreaterThan(measuredRenders);
|
|
250
|
+
|
|
251
|
+
removeBridge();
|
|
252
|
+
removePane();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("derives an initial pane width from the terminal", () => {
|
|
256
|
+
const panes = registry();
|
|
257
|
+
const tui = new FullscreenTuiFixture(fill("M"));
|
|
258
|
+
const removePane = panes.mount({
|
|
259
|
+
id: "ratio",
|
|
260
|
+
position: "right",
|
|
261
|
+
component: () => fill("P"),
|
|
262
|
+
size: 1,
|
|
263
|
+
initialRatio: 0.4,
|
|
264
|
+
minMainSize: 1,
|
|
265
|
+
gap: 0,
|
|
266
|
+
});
|
|
267
|
+
const removeBridge = installSplitPaneBridge(tui as TuiBoundary as TUI, () => theme, panes, mouseRegistry());
|
|
268
|
+
|
|
269
|
+
expect(render(tui.layoutRoot, 80)[0]).toBe(`${"M".repeat(47)}│${"P".repeat(32)}`);
|
|
270
|
+
|
|
271
|
+
removeBridge();
|
|
272
|
+
removePane();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("supports left panes and restores the full main width below the visibility threshold", () => {
|
|
276
|
+
const panes = registry();
|
|
277
|
+
const main = fill("M");
|
|
278
|
+
const tui = new FullscreenTuiFixture(main);
|
|
279
|
+
const removePane = panes.mount({
|
|
280
|
+
id: "left",
|
|
281
|
+
position: "left",
|
|
282
|
+
component: () => fill("P"),
|
|
283
|
+
size: 20,
|
|
284
|
+
minMainSize: 40,
|
|
285
|
+
gap: 2,
|
|
286
|
+
});
|
|
287
|
+
const removeBridge = installSplitPaneBridge(tui as TuiBoundary as TUI, () => theme, panes, mouseRegistry());
|
|
288
|
+
|
|
289
|
+
expect(render(tui.layoutRoot, 80)[0]).toBe(`${"P".repeat(20)}│ ${"M".repeat(57)}`);
|
|
290
|
+
expect(render(tui.layoutRoot, 43)).toEqual(["M".repeat(43)]);
|
|
291
|
+
expect(render(tui.layoutRoot, 44)[0]).toBe(`P│ ${"M".repeat(40)}`);
|
|
292
|
+
|
|
293
|
+
removeBridge();
|
|
294
|
+
removePane();
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("resizes from the visible border and clamps both pane and main widths", () => {
|
|
298
|
+
const panes = registry();
|
|
299
|
+
const tui = new FullscreenTuiFixture(fill("M"));
|
|
300
|
+
const committedSizes: number[] = [];
|
|
301
|
+
const removePane = panes.mount({
|
|
302
|
+
id: "resizable",
|
|
303
|
+
position: "right",
|
|
304
|
+
component: () => fill("P"),
|
|
305
|
+
size: 20,
|
|
306
|
+
minMainSize: 40,
|
|
307
|
+
gap: 1,
|
|
308
|
+
onResize: (size) => committedSizes.push(size),
|
|
309
|
+
});
|
|
310
|
+
const removeBridge = installSplitPaneBridge(tui as TuiBoundary as TUI, () => theme, panes, mouseRegistry());
|
|
311
|
+
const handle = divider(tui.layoutRoot);
|
|
312
|
+
|
|
313
|
+
expect(Bun.stripANSI(handle.render(1)[0] ?? "")).toBe("│");
|
|
314
|
+
tui.currentLayout = { stale: true };
|
|
315
|
+
handle.onMouse(mouse("press", 59, 0));
|
|
316
|
+
handle.onMouse(mouse("drag", 49, 0));
|
|
317
|
+
handle.onMouse(mouse("release", 49, 0));
|
|
318
|
+
expect(render(tui.layoutRoot, 80)[0]).toBe(`${"M".repeat(48)} │${"P".repeat(30)}`);
|
|
319
|
+
expect(committedSizes).toEqual([30]);
|
|
320
|
+
expect(tui.currentLayout).toBeUndefined();
|
|
321
|
+
|
|
322
|
+
handle.onMouse(mouse("press", 49, 0));
|
|
323
|
+
handle.onMouse(mouse("drag", 0, 0));
|
|
324
|
+
handle.onMouse(mouse("release", 0, 0));
|
|
325
|
+
expect(render(tui.layoutRoot, 80)[0]).toBe(`${"M".repeat(40)} │${"P".repeat(38)}`);
|
|
326
|
+
expect(committedSizes).toEqual([30, 38]);
|
|
327
|
+
|
|
328
|
+
handle.onMouse(mouse("press", 44, 0));
|
|
329
|
+
handle.onMouse(mouse("drag", 79, 0));
|
|
330
|
+
handle.onMouse(mouse("release", 79, 0));
|
|
331
|
+
expect(render(tui.layoutRoot, 80)[0]).toBe(`${"M".repeat(77)} │P`);
|
|
332
|
+
expect(tui.renderRequests).toBeGreaterThan(0);
|
|
333
|
+
|
|
334
|
+
removeBridge();
|
|
335
|
+
removePane();
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("restores each pane's preferred width after temporary replacement", () => {
|
|
339
|
+
const panes = registry();
|
|
340
|
+
const tui = new FullscreenTuiFixture(fill("M"));
|
|
341
|
+
const removeFirst = panes.mount({
|
|
342
|
+
id: "remembered",
|
|
343
|
+
position: "right",
|
|
344
|
+
component: () => fill("A"),
|
|
345
|
+
size: 20,
|
|
346
|
+
minMainSize: 40,
|
|
347
|
+
gap: 1,
|
|
348
|
+
});
|
|
349
|
+
const removeBridge = installSplitPaneBridge(tui as TuiBoundary as TUI, () => theme, panes, mouseRegistry());
|
|
350
|
+
const handle = divider(tui.layoutRoot);
|
|
351
|
+
handle.onMouse(mouse("press", 59, 0));
|
|
352
|
+
handle.onMouse(mouse("drag", 49, 0));
|
|
353
|
+
handle.onMouse(mouse("release", 49, 0));
|
|
354
|
+
|
|
355
|
+
const removeSecond = panes.mount({
|
|
356
|
+
id: "temporary",
|
|
357
|
+
position: "left",
|
|
358
|
+
component: () => fill("B"),
|
|
359
|
+
size: 12,
|
|
360
|
+
minMainSize: 40,
|
|
361
|
+
});
|
|
362
|
+
expect(render(tui.layoutRoot, 80)[0]).toContain("B".repeat(12));
|
|
363
|
+
removeSecond();
|
|
364
|
+
expect(render(tui.layoutRoot, 80)[0]).toBe(`${"M".repeat(48)} │${"A".repeat(30)}`);
|
|
365
|
+
|
|
366
|
+
removeBridge();
|
|
367
|
+
removeFirst();
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("wraps future roots once and passes an undefined root through", () => {
|
|
371
|
+
const panes = registry();
|
|
372
|
+
const tui = new FullscreenTuiFixture();
|
|
373
|
+
let created = 0;
|
|
374
|
+
let disposed = 0;
|
|
375
|
+
const removeCountedPane = panes.mount({
|
|
376
|
+
id: "future",
|
|
377
|
+
position: "right",
|
|
378
|
+
component: () => {
|
|
379
|
+
created += 1;
|
|
380
|
+
return { ...fill("P"), dispose: () => (disposed += 1) };
|
|
381
|
+
},
|
|
382
|
+
size: 12,
|
|
383
|
+
minMainSize: 30,
|
|
384
|
+
});
|
|
385
|
+
const removeBridge = installSplitPaneBridge(tui as TuiBoundary as TUI, () => theme, panes, mouseRegistry());
|
|
386
|
+
const first = fill("A");
|
|
387
|
+
|
|
388
|
+
tui.setLayoutRoot(first);
|
|
389
|
+
const wrapper = tui.layoutRoot;
|
|
390
|
+
expect(wrapper).toBeInstanceOf(HStack);
|
|
391
|
+
tui.setLayoutRoot(wrapper);
|
|
392
|
+
expect(tui.layoutRoot).toBe(wrapper);
|
|
393
|
+
expect(created).toBe(1);
|
|
394
|
+
|
|
395
|
+
tui.setLayoutRoot(undefined);
|
|
396
|
+
expect(tui.layoutRoot).toBeUndefined();
|
|
397
|
+
expect(disposed).toBe(1);
|
|
398
|
+
|
|
399
|
+
removeBridge();
|
|
400
|
+
removeCountedPane();
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test("reacts to registry mount and unmount without replacing Pi's base root", () => {
|
|
404
|
+
const panes = registry();
|
|
405
|
+
const main = fill("M");
|
|
406
|
+
const tui = new FullscreenTuiFixture(main);
|
|
407
|
+
const removeBridge = installSplitPaneBridge(tui as TuiBoundary as TUI, () => theme, panes, mouseRegistry());
|
|
408
|
+
expect(tui.layoutRoot).toBe(main);
|
|
409
|
+
|
|
410
|
+
let disposed = 0;
|
|
411
|
+
const removePane = panes.mount({
|
|
412
|
+
id: "runtime",
|
|
413
|
+
position: "right",
|
|
414
|
+
component: () => ({ ...fill("P"), dispose: () => (disposed += 1) }),
|
|
415
|
+
size: 16,
|
|
416
|
+
minMainSize: 32,
|
|
417
|
+
});
|
|
418
|
+
expect(tui.layoutRoot).toBeInstanceOf(HStack);
|
|
419
|
+
|
|
420
|
+
removePane();
|
|
421
|
+
expect(tui.layoutRoot).toBe(main);
|
|
422
|
+
expect(disposed).toBe(1);
|
|
423
|
+
removeBridge();
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("keeps one pane and wrapper alive across overlapping bridge leases", () => {
|
|
427
|
+
const panes = registry();
|
|
428
|
+
const tui = new FullscreenTuiFixture(fill("M"));
|
|
429
|
+
let created = 0;
|
|
430
|
+
let disposed = 0;
|
|
431
|
+
const removePane = panes.mount({
|
|
432
|
+
id: "leased",
|
|
433
|
+
position: "right",
|
|
434
|
+
component: () => {
|
|
435
|
+
created += 1;
|
|
436
|
+
return { ...fill("P"), dispose: () => (disposed += 1) };
|
|
437
|
+
},
|
|
438
|
+
size: 16,
|
|
439
|
+
minMainSize: 32,
|
|
440
|
+
});
|
|
441
|
+
const removeFirst = installSplitPaneBridge(tui as TuiBoundary as TUI, () => theme, panes, mouseRegistry());
|
|
442
|
+
const wrapper = tui.layoutRoot;
|
|
443
|
+
const patchedSetter = tui.setLayoutRoot;
|
|
444
|
+
const removeSecond = installSplitPaneBridge(tui as TuiBoundary as TUI, () => theme, panes, mouseRegistry());
|
|
445
|
+
|
|
446
|
+
expect(tui.layoutRoot).toBe(wrapper);
|
|
447
|
+
expect(tui.setLayoutRoot).toBe(patchedSetter);
|
|
448
|
+
expect(created).toBe(1);
|
|
449
|
+
removeFirst();
|
|
450
|
+
expect(tui.layoutRoot).toBe(wrapper);
|
|
451
|
+
expect(disposed).toBe(0);
|
|
452
|
+
removeSecond();
|
|
453
|
+
expect(tui.layoutRoot).not.toBe(wrapper);
|
|
454
|
+
expect(disposed).toBe(1);
|
|
455
|
+
removePane();
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("uses the actual renderer behind Pi's stable proxy", () => {
|
|
459
|
+
const panes = registry();
|
|
460
|
+
const renderer = new FullscreenTuiFixture(fill("M"));
|
|
461
|
+
let paneHost: SplitPaneHost | undefined;
|
|
462
|
+
let created = 0;
|
|
463
|
+
const removePane = panes.mount({
|
|
464
|
+
id: "proxy",
|
|
465
|
+
position: "right",
|
|
466
|
+
component: (host) => {
|
|
467
|
+
created += 1;
|
|
468
|
+
paneHost = host;
|
|
469
|
+
return fill("P");
|
|
470
|
+
},
|
|
471
|
+
size: 10,
|
|
472
|
+
minMainSize: 30,
|
|
473
|
+
});
|
|
474
|
+
const proxy = new Proxy(renderer as TuiBoundary as TUI, {
|
|
475
|
+
get(target, property) {
|
|
476
|
+
const value = Reflect.get(target, property, target) as TuiBoundary;
|
|
477
|
+
if (typeof value !== "function") return value;
|
|
478
|
+
return (...args: never[]) => Reflect.apply(value, target, args);
|
|
479
|
+
},
|
|
480
|
+
});
|
|
481
|
+
const removeBridge = installSplitPaneBridge(proxy, () => theme, panes, mouseRegistry());
|
|
482
|
+
|
|
483
|
+
expect(renderer.layoutRoot).toBeInstanceOf(HStack);
|
|
484
|
+
expect(created).toBe(1);
|
|
485
|
+
expect(paneHost?.getTerminalSize()).toEqual({ columns: 80, rows: 24 });
|
|
486
|
+
const previousRequests = renderer.renderRequests;
|
|
487
|
+
paneHost?.requestRender();
|
|
488
|
+
expect(renderer.renderRequests).toBe(previousRequests + 1);
|
|
489
|
+
removeBridge();
|
|
490
|
+
removePane();
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
test("forwards focus, input, and pointer events and restores captured focus", () => {
|
|
494
|
+
const panes = registry();
|
|
495
|
+
const main = focusable("M");
|
|
496
|
+
const tui = new FullscreenTuiFixture(main);
|
|
497
|
+
tui.setFocus(main);
|
|
498
|
+
let paneHost: SplitPaneHost | undefined;
|
|
499
|
+
const input: string[] = [];
|
|
500
|
+
const pointer: TuiMouseEvent[] = [];
|
|
501
|
+
const child = {
|
|
502
|
+
...focusable("P"),
|
|
503
|
+
handleInput: (data: string) => input.push(data),
|
|
504
|
+
onMouse: (event: TuiMouseEvent) => {
|
|
505
|
+
pointer.push(event);
|
|
506
|
+
return true;
|
|
507
|
+
},
|
|
508
|
+
};
|
|
509
|
+
const removePane = panes.mount({
|
|
510
|
+
id: "interactive",
|
|
511
|
+
position: "right",
|
|
512
|
+
component: (host) => {
|
|
513
|
+
paneHost = host;
|
|
514
|
+
return child;
|
|
515
|
+
},
|
|
516
|
+
size: 20,
|
|
517
|
+
minMainSize: 1,
|
|
518
|
+
});
|
|
519
|
+
const removeBridge = installSplitPaneBridge(tui as TuiBoundary as TUI, () => theme, panes, mouseRegistry());
|
|
520
|
+
|
|
521
|
+
expect(paneHost?.tui as TuiBoundary).toBe(tui);
|
|
522
|
+
expect(paneHost?.isFocused()).toBe(false);
|
|
523
|
+
paneHost?.focus();
|
|
524
|
+
expect(paneHost?.isFocused()).toBe(true);
|
|
525
|
+
expect(main.focused).toBe(false);
|
|
526
|
+
expect(child.focused).toBe(true);
|
|
527
|
+
const focused = tui.getFocusedComponent();
|
|
528
|
+
focused?.handleInput?.("hello");
|
|
529
|
+
expect(input).toEqual(["hello"]);
|
|
530
|
+
expect((focused as Component & { onMouse(event: TuiMouseEvent): boolean }).onMouse(mouse("press", 70, 0))).toBe(
|
|
531
|
+
true,
|
|
532
|
+
);
|
|
533
|
+
expect(pointer).toHaveLength(1);
|
|
534
|
+
|
|
535
|
+
paneHost?.blur();
|
|
536
|
+
expect(tui.getFocusedComponent()).toBe(main);
|
|
537
|
+
expect(main.focused).toBe(true);
|
|
538
|
+
expect(child.focused).toBe(false);
|
|
539
|
+
removeBridge();
|
|
540
|
+
removePane();
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
test("defers only the redundant host frame for asynchronous pane input", async () => {
|
|
544
|
+
const panes = registry();
|
|
545
|
+
const tui = new FullscreenTuiFixture(fill("M"));
|
|
546
|
+
const removePane = panes.mount({
|
|
547
|
+
id: "input-render",
|
|
548
|
+
position: "right",
|
|
549
|
+
component: () => ({ ...focusable("P"), defersInputRender: () => true, handleInput() {} }),
|
|
550
|
+
size: 20,
|
|
551
|
+
minMainSize: 1,
|
|
552
|
+
});
|
|
553
|
+
const removeBridge = installSplitPaneBridge(tui as TuiBoundary as TUI, () => theme, panes, mouseRegistry());
|
|
554
|
+
const pane = splitEntries(tui.layoutRoot).at(-1);
|
|
555
|
+
if (!pane) throw new Error("expected pane");
|
|
556
|
+
|
|
557
|
+
pane.handleInput?.("a");
|
|
558
|
+
tui.requestImmediateRender();
|
|
559
|
+
expect(tui.immediateRenderRequests).toBe(0);
|
|
560
|
+
await Promise.resolve();
|
|
561
|
+
tui.requestImmediateRender();
|
|
562
|
+
expect(tui.immediateRenderRequests).toBe(1);
|
|
563
|
+
|
|
564
|
+
removeBridge();
|
|
565
|
+
removePane();
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
test("reports the allocated pane viewport height so cursor-bearing children keep their first rows", () => {
|
|
569
|
+
const panes = registry();
|
|
570
|
+
const tui = new FullscreenTuiFixture(fill("M"));
|
|
571
|
+
const observedRows: number[] = [];
|
|
572
|
+
const removePane = panes.mount({
|
|
573
|
+
id: "short-viewport",
|
|
574
|
+
position: "right",
|
|
575
|
+
component: (host) => ({
|
|
576
|
+
render: () => {
|
|
577
|
+
const rows = host.getTerminalSize().rows;
|
|
578
|
+
observedRows.push(rows);
|
|
579
|
+
return ["HEADER", ...Array.from({ length: Math.max(0, rows - 2) }, () => "body"), "INPUT"];
|
|
580
|
+
},
|
|
581
|
+
invalidate() {},
|
|
582
|
+
}),
|
|
583
|
+
size: 20,
|
|
584
|
+
minMainSize: 1,
|
|
585
|
+
});
|
|
586
|
+
const removeBridge = installSplitPaneBridge(tui as TuiBoundary as TUI, () => theme, panes, mouseRegistry());
|
|
587
|
+
const root = tui.layoutRoot;
|
|
588
|
+
if (!root) throw new Error("expected split root");
|
|
589
|
+
const [main, spacer, separator, pane] = splitEntries(root);
|
|
590
|
+
if (!main || !spacer || !separator || !pane) throw new Error("expected right split entries");
|
|
591
|
+
tui.currentLayout = {
|
|
592
|
+
root: layoutBox(root, 0, 80, 6, [
|
|
593
|
+
layoutBox(main, 0, 58, 6),
|
|
594
|
+
layoutBox(spacer, 58, 1, 6),
|
|
595
|
+
layoutBox(separator, 59, 1, 6),
|
|
596
|
+
layoutBox(pane, 60, 20, 6),
|
|
597
|
+
]),
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
const lines = pane.render(20);
|
|
601
|
+
expect(observedRows.at(-1)).toBe(6);
|
|
602
|
+
expect(lines).toHaveLength(6);
|
|
603
|
+
expect(lines[0]).toBe("HEADER");
|
|
604
|
+
expect(lines.at(-1)).toBe("INPUT");
|
|
605
|
+
|
|
606
|
+
removeBridge();
|
|
607
|
+
removePane();
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
test("clicking either pane moves focus without consuming native or component mouse handling", () => {
|
|
611
|
+
const panes = registry();
|
|
612
|
+
const pointerRegistry = mouseRegistry();
|
|
613
|
+
const main = focusable("M");
|
|
614
|
+
const tui = new FullscreenTuiFixture(main);
|
|
615
|
+
tui.setFocus(main);
|
|
616
|
+
let paneHost: SplitPaneHost | undefined;
|
|
617
|
+
const removePane = panes.mount({
|
|
618
|
+
id: "click-focus",
|
|
619
|
+
position: "right",
|
|
620
|
+
component: (host) => {
|
|
621
|
+
paneHost = host;
|
|
622
|
+
return focusable("P");
|
|
623
|
+
},
|
|
624
|
+
size: 20,
|
|
625
|
+
minMainSize: 1,
|
|
626
|
+
});
|
|
627
|
+
const removeBridge = installSplitPaneBridge(tui as TuiBoundary as TUI, () => theme, panes, pointerRegistry);
|
|
628
|
+
const root = tui.layoutRoot;
|
|
629
|
+
if (!root) throw new Error("expected split root");
|
|
630
|
+
const [mainEntry, spacer, separator, pane] = splitEntries(root);
|
|
631
|
+
if (!mainEntry || !spacer || !separator || !pane) throw new Error("expected right split entries");
|
|
632
|
+
tui.currentLayout = {
|
|
633
|
+
root: layoutBox(root, 0, 80, 24, [
|
|
634
|
+
layoutBox(mainEntry, 0, 58, 24),
|
|
635
|
+
layoutBox(spacer, 58, 1, 24),
|
|
636
|
+
layoutBox(separator, 59, 1, 24),
|
|
637
|
+
layoutBox(pane, 60, 20, 24),
|
|
638
|
+
]),
|
|
639
|
+
};
|
|
640
|
+
paneHost?.focus();
|
|
641
|
+
expect(paneHost?.isFocused()).toBe(true);
|
|
642
|
+
|
|
643
|
+
const region = getMouseRegistryState(pointerRegistry).regions.find(
|
|
644
|
+
(candidate) => candidate.id === "pi-libtui.split-pane.main-focus",
|
|
645
|
+
);
|
|
646
|
+
expect(region?.getRect()).toEqual({ x: 0, y: 0, width: 58, height: 24 });
|
|
647
|
+
expect(region?.onMouse(mouse("press", 20, 0))).toBe(false);
|
|
648
|
+
expect(tui.getFocusedComponent()).toBe(main);
|
|
649
|
+
const mainRect = region?.getRect();
|
|
650
|
+
expect(mainRect ? mainRect.x + mainRect.width <= 60 : false).toBe(true);
|
|
651
|
+
|
|
652
|
+
const paneRegion = getMouseRegistryState(pointerRegistry).regions.find(
|
|
653
|
+
(candidate) => candidate.id === "pi-libtui.split-pane.pane-focus",
|
|
654
|
+
);
|
|
655
|
+
expect(paneRegion?.getRect()).toEqual({ x: 60, y: 0, width: 20, height: 24 });
|
|
656
|
+
expect(paneRegion?.onMouse(mouse("press", 70, 0))).toBe(false);
|
|
657
|
+
expect(paneHost?.isFocused()).toBe(true);
|
|
658
|
+
|
|
659
|
+
removeBridge();
|
|
660
|
+
expect(getMouseRegistryState(pointerRegistry).regions).toEqual([]);
|
|
661
|
+
removePane();
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
test("lets an empty pane decline keyboard focus while keeping its pointer surface mounted", () => {
|
|
665
|
+
const panes = registry();
|
|
666
|
+
const pointerRegistry = mouseRegistry();
|
|
667
|
+
const main = focusable("M");
|
|
668
|
+
const tui = new FullscreenTuiFixture(main);
|
|
669
|
+
tui.setFocus(main);
|
|
670
|
+
let paneHost: SplitPaneHost | undefined;
|
|
671
|
+
const removePane = panes.mount({
|
|
672
|
+
id: "non-focusable-empty",
|
|
673
|
+
position: "right",
|
|
674
|
+
component: (host) => {
|
|
675
|
+
paneHost = host;
|
|
676
|
+
return { ...focusable("P"), acceptsFocus: () => false };
|
|
677
|
+
},
|
|
678
|
+
size: 20,
|
|
679
|
+
minMainSize: 1,
|
|
680
|
+
});
|
|
681
|
+
const removeBridge = installSplitPaneBridge(tui as TuiBoundary as TUI, () => theme, panes, pointerRegistry);
|
|
682
|
+
|
|
683
|
+
paneHost?.focus();
|
|
684
|
+
expect(paneHost?.isFocused()).toBe(false);
|
|
685
|
+
expect(tui.getFocusedComponent()).toBe(main);
|
|
686
|
+
|
|
687
|
+
removeBridge();
|
|
688
|
+
removePane();
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
test("does not steal focus back after another component takes it", () => {
|
|
692
|
+
const panes = registry();
|
|
693
|
+
const main = focusable("M");
|
|
694
|
+
const overlay = focusable("O");
|
|
695
|
+
const tui = new FullscreenTuiFixture(main);
|
|
696
|
+
tui.setFocus(main);
|
|
697
|
+
let paneHost: SplitPaneHost | undefined;
|
|
698
|
+
const removePane = panes.mount({
|
|
699
|
+
id: "focus-owner",
|
|
700
|
+
position: "right",
|
|
701
|
+
component: (host) => {
|
|
702
|
+
paneHost = host;
|
|
703
|
+
return focusable("P");
|
|
704
|
+
},
|
|
705
|
+
size: 20,
|
|
706
|
+
minMainSize: 1,
|
|
707
|
+
});
|
|
708
|
+
const removeBridge = installSplitPaneBridge(tui as TuiBoundary as TUI, () => theme, panes, mouseRegistry());
|
|
709
|
+
|
|
710
|
+
paneHost?.focus();
|
|
711
|
+
tui.setFocus(overlay);
|
|
712
|
+
paneHost?.blur();
|
|
713
|
+
removePane();
|
|
714
|
+
expect(tui.getFocusedComponent()).toBe(overlay);
|
|
715
|
+
|
|
716
|
+
removeBridge();
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
test("transfers pane focus across fullscreen renderers and retains the main restore target", () => {
|
|
720
|
+
const panes = registry();
|
|
721
|
+
const main = focusable("M");
|
|
722
|
+
const first = new FullscreenTuiFixture(main);
|
|
723
|
+
first.setFocus(main);
|
|
724
|
+
const hosts: SplitPaneHost[] = [];
|
|
725
|
+
const removePane = panes.mount({
|
|
726
|
+
id: "mode-transfer",
|
|
727
|
+
position: "right",
|
|
728
|
+
component: (host) => {
|
|
729
|
+
hosts.push(host);
|
|
730
|
+
return focusable("P");
|
|
731
|
+
},
|
|
732
|
+
size: 20,
|
|
733
|
+
minMainSize: 1,
|
|
734
|
+
});
|
|
735
|
+
const removeBridge = installSplitPaneBridge(first as TuiBoundary as TUI, () => theme, panes, mouseRegistry());
|
|
736
|
+
hosts[0]?.focus();
|
|
737
|
+
const oldPane = first.getFocusedComponent();
|
|
738
|
+
if (!oldPane) throw new Error("expected focused first pane");
|
|
739
|
+
|
|
740
|
+
const second = new FullscreenTuiFixture();
|
|
741
|
+
second.setFocus(oldPane);
|
|
742
|
+
second.setLayoutRoot(main);
|
|
743
|
+
expect(hosts).toHaveLength(2);
|
|
744
|
+
expect(hosts[1]?.isFocused()).toBe(true);
|
|
745
|
+
expect(second.getFocusedComponent()).not.toBe(oldPane);
|
|
746
|
+
|
|
747
|
+
hosts[1]?.blur();
|
|
748
|
+
expect(second.getFocusedComponent()).toBe(main);
|
|
749
|
+
removeBridge();
|
|
750
|
+
removePane();
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
test("keeps the current pane alive when replacement setup fails", () => {
|
|
754
|
+
const panes = registry();
|
|
755
|
+
const tui = new FullscreenTuiFixture(fill("M"));
|
|
756
|
+
let firstDisposed = 0;
|
|
757
|
+
let themeFails = false;
|
|
758
|
+
const removeFirst = panes.mount({
|
|
759
|
+
id: "first",
|
|
760
|
+
position: "right",
|
|
761
|
+
component: () => ({ ...fill("A"), dispose: () => (firstDisposed += 1) }),
|
|
762
|
+
size: 10,
|
|
763
|
+
minMainSize: 30,
|
|
764
|
+
});
|
|
765
|
+
const removeBridge = installSplitPaneBridge(
|
|
766
|
+
tui as TuiBoundary as TUI,
|
|
767
|
+
() => {
|
|
768
|
+
if (themeFails) throw new Error("theme unavailable");
|
|
769
|
+
return theme;
|
|
770
|
+
},
|
|
771
|
+
panes,
|
|
772
|
+
mouseRegistry(),
|
|
773
|
+
);
|
|
774
|
+
const firstRoot = tui.layoutRoot;
|
|
775
|
+
themeFails = true;
|
|
776
|
+
const removeSecond = panes.mount({
|
|
777
|
+
id: "second",
|
|
778
|
+
position: "right",
|
|
779
|
+
component: () => fill("B"),
|
|
780
|
+
size: 10,
|
|
781
|
+
minMainSize: 30,
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
expect(tui.layoutRoot).toBe(firstRoot);
|
|
785
|
+
expect(firstDisposed).toBe(0);
|
|
786
|
+
themeFails = false;
|
|
787
|
+
removeSecond();
|
|
788
|
+
expect(tui.layoutRoot).toBe(firstRoot);
|
|
789
|
+
expect(firstDisposed).toBe(0);
|
|
790
|
+
removeBridge();
|
|
791
|
+
expect(firstDisposed).toBe(1);
|
|
792
|
+
removeFirst();
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
test("patches future fullscreen renderers when installed from regular mode", () => {
|
|
796
|
+
const panes = registry();
|
|
797
|
+
const removePane = panes.mount({
|
|
798
|
+
id: "mode-switch",
|
|
799
|
+
position: "right",
|
|
800
|
+
component: () => fill("P"),
|
|
801
|
+
size: 10,
|
|
802
|
+
minMainSize: 30,
|
|
803
|
+
});
|
|
804
|
+
const regular = { mode: "regular" } as TuiBoundary as TUI;
|
|
805
|
+
const original = TuiAltScreen.prototype.setLayoutRoot;
|
|
806
|
+
const removeBridge = installSplitPaneBridge(regular, () => theme, panes, mouseRegistry());
|
|
807
|
+
const future = new FullscreenTuiFixture();
|
|
808
|
+
|
|
809
|
+
Reflect.apply(TuiAltScreen.prototype.setLayoutRoot, future, [fill("M")]);
|
|
810
|
+
expect(future.layoutRoot).toBeInstanceOf(HStack);
|
|
811
|
+
removeBridge();
|
|
812
|
+
expect(TuiAltScreen.prototype.setLayoutRoot).toBe(original);
|
|
813
|
+
removePane();
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
test("preserves foreign root and method replacements during cleanup", () => {
|
|
817
|
+
const panes = registry();
|
|
818
|
+
const tui = new FullscreenTuiFixture(fill("M"));
|
|
819
|
+
const removePane = panes.mount({
|
|
820
|
+
id: "foreign",
|
|
821
|
+
position: "right",
|
|
822
|
+
component: () => fill("P"),
|
|
823
|
+
size: 10,
|
|
824
|
+
minMainSize: 30,
|
|
825
|
+
});
|
|
826
|
+
const removeBridge = installSplitPaneBridge(tui as TuiBoundary as TUI, () => theme, panes, mouseRegistry());
|
|
827
|
+
const foreignRoot = fill("F");
|
|
828
|
+
const originalSetter = FullscreenTuiFixture.prototype.setLayoutRoot;
|
|
829
|
+
const foreignSetter = function (this: FullscreenTuiFixture, root: Component | undefined): void {
|
|
830
|
+
this.layoutRoot = root;
|
|
831
|
+
};
|
|
832
|
+
tui.layoutRoot = foreignRoot;
|
|
833
|
+
FullscreenTuiFixture.prototype.setLayoutRoot = foreignSetter;
|
|
834
|
+
|
|
835
|
+
removeBridge();
|
|
836
|
+
expect(tui.layoutRoot).toBe(foreignRoot);
|
|
837
|
+
expect(FullscreenTuiFixture.prototype.setLayoutRoot).toBe(foreignSetter);
|
|
838
|
+
FullscreenTuiFixture.prototype.setLayoutRoot = originalSetter;
|
|
839
|
+
removePane();
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
test("a delegating foreign setter cannot reactivate a released bridge", () => {
|
|
843
|
+
const panes = registry();
|
|
844
|
+
const main = fill("M");
|
|
845
|
+
const tui = new FullscreenTuiFixture(main);
|
|
846
|
+
let created = 0;
|
|
847
|
+
let disposed = 0;
|
|
848
|
+
const removePane = panes.mount({
|
|
849
|
+
id: "released",
|
|
850
|
+
position: "right",
|
|
851
|
+
component: () => {
|
|
852
|
+
created += 1;
|
|
853
|
+
return { ...fill("P"), dispose: () => (disposed += 1) };
|
|
854
|
+
},
|
|
855
|
+
size: 10,
|
|
856
|
+
minMainSize: 30,
|
|
857
|
+
});
|
|
858
|
+
const originalSetter = FullscreenTuiFixture.prototype.setLayoutRoot;
|
|
859
|
+
const removeBridge = installSplitPaneBridge(tui as TuiBoundary as TUI, () => theme, panes, mouseRegistry());
|
|
860
|
+
const patchedSetter = FullscreenTuiFixture.prototype.setLayoutRoot;
|
|
861
|
+
const foreignSetter = function (this: FullscreenTuiFixture, root: Component | undefined): void {
|
|
862
|
+
Reflect.apply(patchedSetter, this, [root]);
|
|
863
|
+
};
|
|
864
|
+
FullscreenTuiFixture.prototype.setLayoutRoot = foreignSetter;
|
|
865
|
+
|
|
866
|
+
try {
|
|
867
|
+
removeBridge();
|
|
868
|
+
expect(tui.layoutRoot).toBe(main);
|
|
869
|
+
expect(disposed).toBe(1);
|
|
870
|
+
const replacement = fill("R");
|
|
871
|
+
tui.setLayoutRoot(replacement);
|
|
872
|
+
expect(tui.layoutRoot).toBe(replacement);
|
|
873
|
+
expect(created).toBe(1);
|
|
874
|
+
} finally {
|
|
875
|
+
FullscreenTuiFixture.prototype.setLayoutRoot = originalSetter;
|
|
876
|
+
removePane();
|
|
877
|
+
}
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
test("contains component factory, render, invalidation, and disposal failures", () => {
|
|
881
|
+
const panes = registry();
|
|
882
|
+
const main = focusable("M");
|
|
883
|
+
const tui = new FullscreenTuiFixture(main);
|
|
884
|
+
tui.setFocus(main);
|
|
885
|
+
const removeThrowingFactory = panes.mount({
|
|
886
|
+
id: "throwing-factory",
|
|
887
|
+
position: "right",
|
|
888
|
+
component: () => {
|
|
889
|
+
throw new Error("factory failed");
|
|
890
|
+
},
|
|
891
|
+
size: 10,
|
|
892
|
+
minMainSize: 30,
|
|
893
|
+
});
|
|
894
|
+
const removeBridge = installSplitPaneBridge(tui as TuiBoundary as TUI, () => theme, panes, mouseRegistry());
|
|
895
|
+
expect(() => render(tui.layoutRoot, 80)).not.toThrow();
|
|
896
|
+
|
|
897
|
+
removeThrowingFactory();
|
|
898
|
+
let brokenHost: SplitPaneHost | undefined;
|
|
899
|
+
const removeBrokenPane = panes.mount({
|
|
900
|
+
id: "broken-component",
|
|
901
|
+
position: "right",
|
|
902
|
+
component: (host) => {
|
|
903
|
+
brokenHost = host;
|
|
904
|
+
return {
|
|
905
|
+
get focused() {
|
|
906
|
+
return false;
|
|
907
|
+
},
|
|
908
|
+
set focused(_value: boolean) {
|
|
909
|
+
throw new Error("focus failed");
|
|
910
|
+
},
|
|
911
|
+
render: () => {
|
|
912
|
+
throw new Error("render failed");
|
|
913
|
+
},
|
|
914
|
+
handleInput: () => {
|
|
915
|
+
throw new Error("input failed");
|
|
916
|
+
},
|
|
917
|
+
onMouse: () => {
|
|
918
|
+
throw new Error("mouse failed");
|
|
919
|
+
},
|
|
920
|
+
invalidate: () => {
|
|
921
|
+
throw new Error("invalidate failed");
|
|
922
|
+
},
|
|
923
|
+
dispose: () => {
|
|
924
|
+
throw new Error("dispose failed");
|
|
925
|
+
},
|
|
926
|
+
};
|
|
927
|
+
},
|
|
928
|
+
size: 10,
|
|
929
|
+
minMainSize: 30,
|
|
930
|
+
});
|
|
931
|
+
const wrapper = tui.layoutRoot;
|
|
932
|
+
expect(() => render(wrapper, 80)).not.toThrow();
|
|
933
|
+
expect(() => wrapper?.invalidate()).not.toThrow();
|
|
934
|
+
expect(() => brokenHost?.focus()).not.toThrow();
|
|
935
|
+
const focused = tui.getFocusedComponent();
|
|
936
|
+
expect(() => focused?.handleInput?.("x")).not.toThrow();
|
|
937
|
+
expect(() =>
|
|
938
|
+
(focused as Component & { onMouse(event: TuiMouseEvent): boolean }).onMouse(mouse("press", 70, 0)),
|
|
939
|
+
).not.toThrow();
|
|
940
|
+
expect(() => brokenHost?.blur()).not.toThrow();
|
|
941
|
+
expect(() => removeBrokenPane()).not.toThrow();
|
|
942
|
+
expect(() => removeBridge()).not.toThrow();
|
|
943
|
+
});
|
|
944
|
+
});
|