@luan.sh/pi-libtui 0.3.8
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 +208 -0
- package/native-revision +1 -0
- package/package.json +58 -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,720 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
type Component,
|
|
4
|
+
type Focusable,
|
|
5
|
+
HStack,
|
|
6
|
+
isFocusable,
|
|
7
|
+
type TUI,
|
|
8
|
+
TuiAltScreen,
|
|
9
|
+
truncateToWidth,
|
|
10
|
+
VStack,
|
|
11
|
+
} from "@earendil-works/pi-tui";
|
|
12
|
+
import { tuiTheme } from "../color/theme.ts";
|
|
13
|
+
import type { LayoutBox, MouseRect, MouseRegistry, TuiMouseEvent } from "../mouse.ts";
|
|
14
|
+
import { RenderedLinesCache } from "../render-cache.ts";
|
|
15
|
+
import type { MountedSplitPane, SplitPaneComponent, SplitPaneHost, SplitPaneRegistry } from "../split-pane.ts";
|
|
16
|
+
import { intersect, isComponent, rendererLayoutFrame } from "./pi-layout-adapter.ts";
|
|
17
|
+
|
|
18
|
+
const INSTALLATION_KEY = Symbol.for("pi-libtui/split-pane-bridge/v3");
|
|
19
|
+
const INSTALLATION_PROTOCOL = "pi-libtui/split-pane-bridge/v3" as const;
|
|
20
|
+
|
|
21
|
+
type LayoutRootSetter = (this: object, component: Component | undefined) => void;
|
|
22
|
+
type ImmediateRenderer = (this: object) => void;
|
|
23
|
+
|
|
24
|
+
interface RendererState {
|
|
25
|
+
readonly base: Component;
|
|
26
|
+
readonly definition?: MountedSplitPane;
|
|
27
|
+
readonly pane?: SafeSplitPane;
|
|
28
|
+
readonly wrapper?: HStack;
|
|
29
|
+
suppressImmediateRender?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface SplitPaneInstallation {
|
|
33
|
+
readonly protocol: typeof INSTALLATION_PROTOCOL;
|
|
34
|
+
readonly version: 3;
|
|
35
|
+
refs: number;
|
|
36
|
+
theme: () => Theme;
|
|
37
|
+
readonly registry: SplitPaneRegistry;
|
|
38
|
+
readonly mouseRegistry: MouseRegistry;
|
|
39
|
+
readonly original: LayoutRootSetter;
|
|
40
|
+
readonly originalDescriptor: PropertyDescriptor | undefined;
|
|
41
|
+
readonly patched: LayoutRootSetter;
|
|
42
|
+
readonly originalImmediate: ImmediateRenderer;
|
|
43
|
+
readonly originalImmediateDescriptor: PropertyDescriptor | undefined;
|
|
44
|
+
readonly patchedImmediate: ImmediateRenderer;
|
|
45
|
+
readonly renderers: Map<object, RendererState>;
|
|
46
|
+
readonly wrapperBases: WeakMap<object, Component>;
|
|
47
|
+
readonly paneSizes: Map<string, number>;
|
|
48
|
+
readonly unsubscribe: () => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// type-boundary: Pi 0.84.2 renderer fields and cross-realm installation markers are narrowed before use.
|
|
52
|
+
type PiPrivateValue = unknown;
|
|
53
|
+
|
|
54
|
+
function isInstallation(value: PiPrivateValue): value is SplitPaneInstallation {
|
|
55
|
+
if (!value || typeof value !== "object") return false;
|
|
56
|
+
const candidate = value as Partial<SplitPaneInstallation>;
|
|
57
|
+
const renderers = candidate.renderers as PiPrivateValue;
|
|
58
|
+
const registry = candidate.registry as PiPrivateValue;
|
|
59
|
+
const mouseRegistry = candidate.mouseRegistry as PiPrivateValue;
|
|
60
|
+
const wrapperBases = candidate.wrapperBases as PiPrivateValue;
|
|
61
|
+
const paneSizes = candidate.paneSizes as PiPrivateValue;
|
|
62
|
+
return (
|
|
63
|
+
candidate.protocol === INSTALLATION_PROTOCOL &&
|
|
64
|
+
candidate.version === 3 &&
|
|
65
|
+
typeof candidate.refs === "number" &&
|
|
66
|
+
typeof candidate.theme === "function" &&
|
|
67
|
+
typeof candidate.original === "function" &&
|
|
68
|
+
typeof candidate.patched === "function" &&
|
|
69
|
+
typeof candidate.originalImmediate === "function" &&
|
|
70
|
+
typeof candidate.patchedImmediate === "function" &&
|
|
71
|
+
typeof candidate.unsubscribe === "function" &&
|
|
72
|
+
!!renderers &&
|
|
73
|
+
typeof renderers === "object" &&
|
|
74
|
+
typeof Reflect.get(renderers, "get") === "function" &&
|
|
75
|
+
typeof Reflect.get(renderers, "set") === "function" &&
|
|
76
|
+
typeof Reflect.get(renderers, "delete") === "function" &&
|
|
77
|
+
typeof Reflect.get(renderers, "values") === "function" &&
|
|
78
|
+
!!registry &&
|
|
79
|
+
typeof registry === "object" &&
|
|
80
|
+
typeof Reflect.get(registry, "current") === "function" &&
|
|
81
|
+
typeof Reflect.get(registry, "subscribe") === "function" &&
|
|
82
|
+
!!mouseRegistry &&
|
|
83
|
+
typeof mouseRegistry === "object" &&
|
|
84
|
+
typeof Reflect.get(mouseRegistry, "registerOverlayRegion") === "function" &&
|
|
85
|
+
!!wrapperBases &&
|
|
86
|
+
typeof wrapperBases === "object" &&
|
|
87
|
+
typeof Reflect.get(wrapperBases, "get") === "function" &&
|
|
88
|
+
typeof Reflect.get(wrapperBases, "set") === "function" &&
|
|
89
|
+
!!paneSizes &&
|
|
90
|
+
typeof paneSizes === "object" &&
|
|
91
|
+
typeof Reflect.get(paneSizes, "get") === "function" &&
|
|
92
|
+
typeof Reflect.get(paneSizes, "set") === "function"
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function originalMethod<T extends LayoutRootSetter | ImmediateRenderer>(
|
|
97
|
+
prototype: object,
|
|
98
|
+
name: "setLayoutRoot" | "requestImmediateRender",
|
|
99
|
+
): { descriptor: PropertyDescriptor | undefined; method: T } | undefined {
|
|
100
|
+
const value = Reflect.get(prototype, name) as PiPrivateValue;
|
|
101
|
+
if (typeof value !== "function") return undefined;
|
|
102
|
+
return {
|
|
103
|
+
descriptor: Object.getOwnPropertyDescriptor(prototype, name),
|
|
104
|
+
method: value as T,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function installMethod(
|
|
109
|
+
prototype: object,
|
|
110
|
+
name: "setLayoutRoot" | "requestImmediateRender",
|
|
111
|
+
method: LayoutRootSetter | ImmediateRenderer,
|
|
112
|
+
): void {
|
|
113
|
+
Object.defineProperty(prototype, name, {
|
|
114
|
+
configurable: true,
|
|
115
|
+
writable: true,
|
|
116
|
+
value: method,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function restoreMethod(
|
|
121
|
+
prototype: object,
|
|
122
|
+
name: "setLayoutRoot" | "requestImmediateRender",
|
|
123
|
+
descriptor: PropertyDescriptor | undefined,
|
|
124
|
+
patched: LayoutRootSetter | ImmediateRenderer,
|
|
125
|
+
): void {
|
|
126
|
+
if (Reflect.get(prototype, name) !== patched) return;
|
|
127
|
+
if (descriptor) Object.defineProperty(prototype, name, descriptor);
|
|
128
|
+
else Reflect.deleteProperty(prototype, name);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function focusedComponent(renderer: object): Component | null | undefined {
|
|
132
|
+
try {
|
|
133
|
+
const method = Reflect.get(renderer, "getFocusedComponent") as PiPrivateValue;
|
|
134
|
+
if (typeof method !== "function") return undefined;
|
|
135
|
+
const focused = Reflect.apply(method, renderer, []) as PiPrivateValue;
|
|
136
|
+
return focused === null || isComponent(focused) ? focused : undefined;
|
|
137
|
+
} catch {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function setRendererFocus(renderer: object, component: Component | null): boolean {
|
|
143
|
+
try {
|
|
144
|
+
const method = Reflect.get(renderer, "setFocus") as PiPrivateValue;
|
|
145
|
+
if (typeof method !== "function") return false;
|
|
146
|
+
Reflect.apply(method, renderer, [component]);
|
|
147
|
+
return focusedComponent(renderer) === component;
|
|
148
|
+
} catch {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
class SafeSplitPane extends VStack implements Focusable {
|
|
154
|
+
private readonly child: SplitPaneComponent | undefined;
|
|
155
|
+
private readonly removeMainFocusRegion: () => void;
|
|
156
|
+
private readonly removePaneFocusRegion: () => void;
|
|
157
|
+
private _focused = false;
|
|
158
|
+
private restoreFocus: Component | null | undefined;
|
|
159
|
+
private readonly renderCache = new RenderedLinesCache();
|
|
160
|
+
|
|
161
|
+
constructor(
|
|
162
|
+
private readonly renderer: object,
|
|
163
|
+
theme: Theme,
|
|
164
|
+
factory: MountedSplitPane["component"],
|
|
165
|
+
private readonly base: Component,
|
|
166
|
+
mouseRegistry: MouseRegistry,
|
|
167
|
+
private readonly suppressNextImmediateRender: () => void,
|
|
168
|
+
) {
|
|
169
|
+
super();
|
|
170
|
+
const host: SplitPaneHost = {
|
|
171
|
+
tui: renderer as TUI,
|
|
172
|
+
getTerminalSize: () => ({ columns: rendererColumns(renderer), rows: this.viewportRows() }),
|
|
173
|
+
requestRender: () => {
|
|
174
|
+
this.renderCache.clear();
|
|
175
|
+
requestRender(renderer);
|
|
176
|
+
},
|
|
177
|
+
focus: () => this.focus(),
|
|
178
|
+
blur: () => this.blur(),
|
|
179
|
+
isFocused: () => this.isFocused(),
|
|
180
|
+
};
|
|
181
|
+
try {
|
|
182
|
+
this.child = factory(host, theme);
|
|
183
|
+
} catch {
|
|
184
|
+
this.child = undefined;
|
|
185
|
+
}
|
|
186
|
+
if (this.child) this.addChild(this.child, { basis: 0, grow: 1, shrink: 1, minSize: 0 });
|
|
187
|
+
this.removeMainFocusRegion = mouseRegistry.registerOverlayRegion({
|
|
188
|
+
id: "pi-libtui.split-pane.main-focus",
|
|
189
|
+
priority: Number.MAX_SAFE_INTEGER,
|
|
190
|
+
getRect: () => this.mainRect(),
|
|
191
|
+
onMouse: (event) => {
|
|
192
|
+
if (event.type === "press" && event.button === 0) this.blur();
|
|
193
|
+
// This is a focus prepass. Pi and components still own the click.
|
|
194
|
+
return false;
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
this.removePaneFocusRegion = mouseRegistry.registerOverlayRegion({
|
|
198
|
+
id: "pi-libtui.split-pane.pane-focus",
|
|
199
|
+
priority: Number.MAX_SAFE_INTEGER,
|
|
200
|
+
getRect: () => componentRect(this.renderer, this),
|
|
201
|
+
onMouse: (event) => {
|
|
202
|
+
if (event.type === "press" && event.button === 0) this.focus();
|
|
203
|
+
// The contributed component still owns the click itself.
|
|
204
|
+
return false;
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
get focused(): boolean {
|
|
210
|
+
return this._focused;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
set focused(value: boolean) {
|
|
214
|
+
if (this._focused !== value) this.renderCache.clear();
|
|
215
|
+
this._focused = value;
|
|
216
|
+
try {
|
|
217
|
+
if (this.child && isFocusable(this.child)) this.child.focused = value;
|
|
218
|
+
} catch {
|
|
219
|
+
// A contributed focus setter must not break the renderer.
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
get wantsKeyRelease(): boolean {
|
|
224
|
+
return this.child?.wantsKeyRelease === true;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
handleInput(data: string): void {
|
|
228
|
+
this.renderCache.clear();
|
|
229
|
+
let deferred = false;
|
|
230
|
+
try {
|
|
231
|
+
deferred = this.child?.defersInputRender?.(data) === true;
|
|
232
|
+
} catch {
|
|
233
|
+
// Input rendering hints are optional; forwarding remains authoritative.
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
this.child?.handleInput?.(data);
|
|
237
|
+
if (deferred) this.suppressNextImmediateRender();
|
|
238
|
+
} catch {
|
|
239
|
+
// A contributed input handler must not break terminal dispatch.
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
onMouse(event: TuiMouseEvent): boolean {
|
|
244
|
+
try {
|
|
245
|
+
const handled = this.child?.onMouse?.(event) === true;
|
|
246
|
+
if (handled) this.renderCache.clear();
|
|
247
|
+
return handled;
|
|
248
|
+
} catch {
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
focus(): void {
|
|
254
|
+
if (this.isFocused()) return;
|
|
255
|
+
if (this.child?.acceptsFocus?.() === false) return;
|
|
256
|
+
const previous = focusedComponent(this.renderer);
|
|
257
|
+
if (previous === undefined) return;
|
|
258
|
+
this.restoreFocus = previous;
|
|
259
|
+
if (!setRendererFocus(this.renderer, this)) this.restoreFocus = undefined;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
blur(): void {
|
|
263
|
+
if (!this.isFocused()) return;
|
|
264
|
+
const target = this.restoreFocus ?? null;
|
|
265
|
+
if (setRendererFocus(this.renderer, target)) this.restoreFocus = undefined;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
isFocused(): boolean {
|
|
269
|
+
return focusedComponent(this.renderer) === this;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
transferFocusFrom(previous: SafeSplitPane): void {
|
|
273
|
+
if (focusedComponent(this.renderer) !== previous) return;
|
|
274
|
+
this.restoreFocus = previous.restoreFocus;
|
|
275
|
+
if (!setRendererFocus(this.renderer, this)) this.restoreFocus = undefined;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
render(width: number): string[] {
|
|
279
|
+
const rows = this.viewportRows();
|
|
280
|
+
return this.renderCache.get(width, `${rows}`, () => {
|
|
281
|
+
try {
|
|
282
|
+
const lines = this.child?.render(width) ?? [];
|
|
283
|
+
if (!Array.isArray(lines) || !lines.every((line) => typeof line === "string")) return [];
|
|
284
|
+
const bounded = lines.slice(0, rows);
|
|
285
|
+
return this.child?.rendersWithinWidth === true
|
|
286
|
+
? bounded
|
|
287
|
+
: bounded.map((line) => truncateToWidth(line, width, ""));
|
|
288
|
+
} catch {
|
|
289
|
+
return [];
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
invalidate(): void {
|
|
295
|
+
this.renderCache.clear();
|
|
296
|
+
try {
|
|
297
|
+
this.child?.invalidate();
|
|
298
|
+
} catch {
|
|
299
|
+
// A contributed pane must not break host invalidation.
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
dispose(): void {
|
|
304
|
+
this.removePaneFocusRegion();
|
|
305
|
+
this.removeMainFocusRegion();
|
|
306
|
+
this.blur();
|
|
307
|
+
try {
|
|
308
|
+
this.child?.dispose?.();
|
|
309
|
+
} catch {
|
|
310
|
+
// Cleanup is best effort across optional extension realms.
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private viewportRows(): number {
|
|
315
|
+
return componentRect(this.renderer, this)?.height ?? rendererRows(this.renderer);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private mainRect(): MouseRect | undefined {
|
|
319
|
+
return componentRect(this.renderer, this.base);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function componentRect(renderer: object, component: Component): MouseRect | undefined {
|
|
324
|
+
const frame = rendererLayoutFrame(renderer);
|
|
325
|
+
if (!frame) return undefined;
|
|
326
|
+
let result: MouseRect | undefined;
|
|
327
|
+
const visit = (box: LayoutBox): void => {
|
|
328
|
+
if (result) return;
|
|
329
|
+
if (box.component === component) {
|
|
330
|
+
const visible = intersect(box.rect, box.clip);
|
|
331
|
+
if (visible.width > 0 && visible.height > 0) result = visible;
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
for (const child of box.children) visit(child);
|
|
335
|
+
};
|
|
336
|
+
visit(frame.root);
|
|
337
|
+
return result;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
class SplitSpacer implements Component {
|
|
341
|
+
constructor(private readonly maxRows: () => number) {}
|
|
342
|
+
|
|
343
|
+
render(width: number): string[] {
|
|
344
|
+
return Array.from({ length: Math.max(0, this.maxRows()) }, () => " ".repeat(Math.max(0, width)));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
invalidate(): void {}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
class SplitDivider implements Component {
|
|
351
|
+
private hovered = false;
|
|
352
|
+
private dragging = false;
|
|
353
|
+
private resize: (screenColumn: number, committed: boolean) => void = () => {};
|
|
354
|
+
|
|
355
|
+
constructor(
|
|
356
|
+
private readonly theme: Theme,
|
|
357
|
+
private readonly maxRows: () => number,
|
|
358
|
+
private readonly repaint: () => void,
|
|
359
|
+
) {}
|
|
360
|
+
|
|
361
|
+
setResize(resize: (screenColumn: number, committed: boolean) => void): void {
|
|
362
|
+
this.resize = resize;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
render(width: number): string[] {
|
|
366
|
+
const colors = tuiTheme(this.theme);
|
|
367
|
+
const border = colors.fg(this.hovered || this.dragging ? "accent" : "border", "│");
|
|
368
|
+
const line = `${border}${" ".repeat(Math.max(0, width - 1))}`;
|
|
369
|
+
return Array.from({ length: Math.max(0, this.maxRows()) }, () => line);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
invalidate(): void {}
|
|
373
|
+
|
|
374
|
+
onMouse(event: TuiMouseEvent): boolean {
|
|
375
|
+
if (event.type === "press") {
|
|
376
|
+
if (event.button !== 0) return false;
|
|
377
|
+
this.dragging = true;
|
|
378
|
+
this.setHovered(true);
|
|
379
|
+
this.resize(event.screenCol, false);
|
|
380
|
+
return true;
|
|
381
|
+
}
|
|
382
|
+
if (event.type === "drag" && this.dragging) {
|
|
383
|
+
this.resize(event.screenCol, false);
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
if (event.type === "release" && this.dragging) {
|
|
387
|
+
this.dragging = false;
|
|
388
|
+
this.resize(event.screenCol, true);
|
|
389
|
+
this.setHovered(false);
|
|
390
|
+
return true;
|
|
391
|
+
}
|
|
392
|
+
if (event.type === "enter" || event.type === "move") {
|
|
393
|
+
this.setHovered(true);
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
if (event.type === "leave") {
|
|
397
|
+
this.setHovered(false);
|
|
398
|
+
return true;
|
|
399
|
+
}
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
private setHovered(hovered: boolean): void {
|
|
404
|
+
if (this.hovered === hovered) return;
|
|
405
|
+
this.hovered = hovered;
|
|
406
|
+
this.repaint();
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
class ResizableSplitLayout extends HStack {
|
|
411
|
+
private preferredSize: number;
|
|
412
|
+
|
|
413
|
+
constructor(
|
|
414
|
+
base: Component,
|
|
415
|
+
pane: SafeSplitPane,
|
|
416
|
+
private readonly definition: MountedSplitPane,
|
|
417
|
+
private readonly terminalColumns: () => number,
|
|
418
|
+
private readonly repaint: () => void,
|
|
419
|
+
preferredSize: number,
|
|
420
|
+
onResize: (size: number) => void,
|
|
421
|
+
maxRows: () => number,
|
|
422
|
+
theme: Theme,
|
|
423
|
+
) {
|
|
424
|
+
const divider = new SplitDivider(theme, maxRows, repaint);
|
|
425
|
+
let syncSize = (_width: number): void => {};
|
|
426
|
+
const visible = ({ width }: { width: number }) => {
|
|
427
|
+
syncSize(width);
|
|
428
|
+
return width >= definition.minMainSize + definition.gap + 2;
|
|
429
|
+
};
|
|
430
|
+
const main = { component: base, basis: 0, grow: 1, shrink: 1, minSize: definition.minMainSize } as const;
|
|
431
|
+
const auxiliary = {
|
|
432
|
+
component: pane,
|
|
433
|
+
basis: definition.size,
|
|
434
|
+
grow: 0,
|
|
435
|
+
shrink: 0,
|
|
436
|
+
minSize: definition.size,
|
|
437
|
+
maxSize: definition.size,
|
|
438
|
+
visible,
|
|
439
|
+
};
|
|
440
|
+
const separator = {
|
|
441
|
+
component: divider,
|
|
442
|
+
basis: 1,
|
|
443
|
+
grow: 0,
|
|
444
|
+
shrink: 0,
|
|
445
|
+
minSize: 1,
|
|
446
|
+
maxSize: 1,
|
|
447
|
+
visible,
|
|
448
|
+
} as const;
|
|
449
|
+
const spacer = {
|
|
450
|
+
component: new SplitSpacer(maxRows),
|
|
451
|
+
basis: definition.gap,
|
|
452
|
+
grow: 0,
|
|
453
|
+
shrink: 0,
|
|
454
|
+
minSize: definition.gap,
|
|
455
|
+
maxSize: definition.gap,
|
|
456
|
+
visible,
|
|
457
|
+
} as const;
|
|
458
|
+
const split =
|
|
459
|
+
definition.position === "left" ? [auxiliary, separator, spacer, main] : [main, spacer, separator, auxiliary];
|
|
460
|
+
super(split, { align: "stretch" });
|
|
461
|
+
this.preferredSize = preferredSize;
|
|
462
|
+
syncSize = (width) => this.applyEffectiveSize(width);
|
|
463
|
+
this.applyEffectiveSize(this.terminalColumns());
|
|
464
|
+
divider.setResize((screenColumn, committed) => {
|
|
465
|
+
const raw = definition.position === "left" ? screenColumn : this.terminalColumns() - screenColumn - 1;
|
|
466
|
+
const size = this.clampPreferred(raw);
|
|
467
|
+
if (size !== this.preferredSize) {
|
|
468
|
+
this.preferredSize = size;
|
|
469
|
+
onResize(size);
|
|
470
|
+
this.applyEffectiveSize(this.terminalColumns());
|
|
471
|
+
this.repaint();
|
|
472
|
+
}
|
|
473
|
+
if (committed) definition.onResize?.(size);
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
private applyEffectiveSize(width: number): void {
|
|
478
|
+
const size = this.effectiveSize(width);
|
|
479
|
+
for (const entry of this.entries) {
|
|
480
|
+
if (entry.component instanceof SafeSplitPane) {
|
|
481
|
+
entry.basis = size;
|
|
482
|
+
entry.minSize = size;
|
|
483
|
+
entry.maxSize = size;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
private clampPreferred(size: number): number {
|
|
489
|
+
const available = this.terminalColumns() - this.definition.minMainSize - this.definition.gap - 1;
|
|
490
|
+
return Math.max(1, Math.min(available, Math.floor(size)));
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
private effectiveSize(width: number): number {
|
|
494
|
+
const available = width - this.definition.minMainSize - this.definition.gap - 1;
|
|
495
|
+
return Math.max(1, Math.min(this.preferredSize, available));
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function rendererRoot(renderer: object): Component | undefined {
|
|
500
|
+
const root = Reflect.get(renderer, "layoutRoot") as PiPrivateValue;
|
|
501
|
+
return isComponent(root) ? root : undefined;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function rendererRows(renderer: object): number {
|
|
505
|
+
const terminal = Reflect.get(renderer, "terminal") as PiPrivateValue;
|
|
506
|
+
if (!terminal || typeof terminal !== "object") return 0;
|
|
507
|
+
const rows = Reflect.get(terminal, "rows") as PiPrivateValue;
|
|
508
|
+
return typeof rows === "number" && Number.isFinite(rows) ? Math.max(0, Math.floor(rows)) : 0;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function rendererColumns(renderer: object): number {
|
|
512
|
+
const terminal = Reflect.get(renderer, "terminal") as PiPrivateValue;
|
|
513
|
+
if (!terminal || typeof terminal !== "object") return 0;
|
|
514
|
+
const columns = Reflect.get(terminal, "columns") as PiPrivateValue;
|
|
515
|
+
return typeof columns === "number" && Number.isFinite(columns) ? Math.max(0, Math.floor(columns)) : 0;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function requestRender(renderer: object): void {
|
|
519
|
+
try {
|
|
520
|
+
const method = Reflect.get(renderer, "requestRender") as PiPrivateValue;
|
|
521
|
+
if (typeof method === "function") Reflect.apply(method, renderer, []);
|
|
522
|
+
} catch {
|
|
523
|
+
// A stale optional pane host must not break the renderer.
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function requestLayoutRender(renderer: object): void {
|
|
528
|
+
try {
|
|
529
|
+
Reflect.set(renderer, "currentLayout", undefined);
|
|
530
|
+
} catch {
|
|
531
|
+
// A renderer without Pi's structural layout cache can still repaint normally.
|
|
532
|
+
}
|
|
533
|
+
requestRender(renderer);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function disposePane(state: RendererState | undefined): void {
|
|
537
|
+
state?.pane?.dispose();
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function buildWrapper(
|
|
541
|
+
installation: SplitPaneInstallation,
|
|
542
|
+
renderer: object,
|
|
543
|
+
base: Component,
|
|
544
|
+
definition: MountedSplitPane,
|
|
545
|
+
theme: Theme,
|
|
546
|
+
mouseRegistry: MouseRegistry,
|
|
547
|
+
): { pane: SafeSplitPane; wrapper: HStack } {
|
|
548
|
+
const pane = new SafeSplitPane(renderer, theme, definition.component, base, mouseRegistry, () => {
|
|
549
|
+
const state = installation.renderers.get(renderer);
|
|
550
|
+
if (!state || state.pane !== pane) return;
|
|
551
|
+
state.suppressImmediateRender = true;
|
|
552
|
+
queueMicrotask(() => {
|
|
553
|
+
if (state.suppressImmediateRender) state.suppressImmediateRender = false;
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
const wrapper = new ResizableSplitLayout(
|
|
557
|
+
base,
|
|
558
|
+
pane,
|
|
559
|
+
definition,
|
|
560
|
+
() => rendererColumns(renderer),
|
|
561
|
+
() => requestLayoutRender(renderer),
|
|
562
|
+
installation.paneSizes.get(definition.id) ??
|
|
563
|
+
(definition.initialRatio === undefined
|
|
564
|
+
? definition.size
|
|
565
|
+
: Math.max(1, Math.floor(rendererColumns(renderer) * definition.initialRatio))),
|
|
566
|
+
(size) => installation.paneSizes.set(definition.id, size),
|
|
567
|
+
() => componentRect(renderer, pane)?.height ?? rendererRows(renderer),
|
|
568
|
+
theme,
|
|
569
|
+
);
|
|
570
|
+
return { pane, wrapper };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function applyRoot(installation: SplitPaneInstallation, renderer: object, component: Component | undefined): void {
|
|
574
|
+
if (!component) {
|
|
575
|
+
Reflect.apply(installation.original, renderer, [undefined]);
|
|
576
|
+
const previous = installation.renderers.get(renderer);
|
|
577
|
+
installation.renderers.delete(renderer);
|
|
578
|
+
disposePane(previous);
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const base = installation.wrapperBases.get(component as object) ?? component;
|
|
583
|
+
const previous = installation.renderers.get(renderer);
|
|
584
|
+
const definition = installation.registry.current();
|
|
585
|
+
if (!definition) {
|
|
586
|
+
Reflect.apply(installation.original, renderer, [base]);
|
|
587
|
+
installation.renderers.set(renderer, { base });
|
|
588
|
+
disposePane(previous);
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
if (previous?.base === base && previous.definition === definition && previous.wrapper) {
|
|
592
|
+
Reflect.apply(installation.original, renderer, [previous.wrapper]);
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const { pane, wrapper } = buildWrapper(
|
|
597
|
+
installation,
|
|
598
|
+
renderer,
|
|
599
|
+
base,
|
|
600
|
+
definition,
|
|
601
|
+
installation.theme(),
|
|
602
|
+
installation.mouseRegistry,
|
|
603
|
+
);
|
|
604
|
+
try {
|
|
605
|
+
Reflect.apply(installation.original, renderer, [wrapper]);
|
|
606
|
+
} catch (error) {
|
|
607
|
+
pane.dispose();
|
|
608
|
+
throw error;
|
|
609
|
+
}
|
|
610
|
+
for (const [owner, state] of installation.renderers) {
|
|
611
|
+
if (owner !== renderer && state.pane) pane.transferFocusFrom(state.pane);
|
|
612
|
+
}
|
|
613
|
+
installation.wrapperBases.set(wrapper, base);
|
|
614
|
+
installation.renderers.set(renderer, { base, definition, pane, wrapper });
|
|
615
|
+
disposePane(previous);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function refresh(installation: SplitPaneInstallation, prototype: object): void {
|
|
619
|
+
if (Reflect.get(prototype, "setLayoutRoot") !== installation.patched) return;
|
|
620
|
+
for (const [renderer, state] of installation.renderers) applyRoot(installation, renderer, state.base);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function ensureCurrentRoot(installation: SplitPaneInstallation, tui: TUI): void {
|
|
624
|
+
const current = rendererRoot(tui as object);
|
|
625
|
+
if (!current) return;
|
|
626
|
+
const setter = Reflect.get(tui as object, "setLayoutRoot") as PiPrivateValue;
|
|
627
|
+
if (typeof setter !== "function") return;
|
|
628
|
+
// Pi gives extension widgets a stable proxy. Calling its method makes the
|
|
629
|
+
// patched setter receive the active renderer as `this`, not the proxy.
|
|
630
|
+
Reflect.apply(setter, tui, [installation.wrapperBases.get(current as object) ?? current]);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function releaseBridge(prototype: object, installation: SplitPaneInstallation): () => void {
|
|
634
|
+
let disposed = false;
|
|
635
|
+
return () => {
|
|
636
|
+
if (disposed) return;
|
|
637
|
+
disposed = true;
|
|
638
|
+
installation.refs -= 1;
|
|
639
|
+
if (installation.refs > 0) return;
|
|
640
|
+
installation.unsubscribe();
|
|
641
|
+
for (const [renderer, state] of installation.renderers) {
|
|
642
|
+
if (state.wrapper && rendererRoot(renderer) === state.wrapper) {
|
|
643
|
+
Reflect.apply(installation.original, renderer, [state.base]);
|
|
644
|
+
}
|
|
645
|
+
disposePane(state);
|
|
646
|
+
}
|
|
647
|
+
installation.renderers.clear();
|
|
648
|
+
restoreMethod(prototype, "setLayoutRoot", installation.originalDescriptor, installation.patched);
|
|
649
|
+
restoreMethod(
|
|
650
|
+
prototype,
|
|
651
|
+
"requestImmediateRender",
|
|
652
|
+
installation.originalImmediateDescriptor,
|
|
653
|
+
installation.patchedImmediate,
|
|
654
|
+
);
|
|
655
|
+
if (Reflect.get(prototype, INSTALLATION_KEY) === installation) Reflect.deleteProperty(prototype, INSTALLATION_KEY);
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** Install the fullscreen layout-root bridge used by split-pane contributions. */
|
|
660
|
+
export function installSplitPaneBridge(
|
|
661
|
+
tui: TUI,
|
|
662
|
+
theme: () => Theme,
|
|
663
|
+
registry: SplitPaneRegistry,
|
|
664
|
+
mouseRegistry: MouseRegistry,
|
|
665
|
+
): () => void {
|
|
666
|
+
const prototype = tui.mode === "fullscreen" ? Reflect.getPrototypeOf(tui as object) : TuiAltScreen.prototype;
|
|
667
|
+
if (!prototype) return () => {};
|
|
668
|
+
const active = Reflect.get(prototype, INSTALLATION_KEY) as PiPrivateValue;
|
|
669
|
+
if (isInstallation(active)) {
|
|
670
|
+
if (
|
|
671
|
+
Reflect.get(prototype, "setLayoutRoot") !== active.patched ||
|
|
672
|
+
Reflect.get(prototype, "requestImmediateRender") !== active.patchedImmediate
|
|
673
|
+
)
|
|
674
|
+
return () => {};
|
|
675
|
+
active.refs += 1;
|
|
676
|
+
active.theme = theme;
|
|
677
|
+
ensureCurrentRoot(active, tui);
|
|
678
|
+
return releaseBridge(prototype, active);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const original = originalMethod<LayoutRootSetter>(prototype, "setLayoutRoot");
|
|
682
|
+
const originalImmediate = originalMethod<ImmediateRenderer>(prototype, "requestImmediateRender");
|
|
683
|
+
if (!original || !originalImmediate) return () => {};
|
|
684
|
+
let installation!: SplitPaneInstallation;
|
|
685
|
+
const patched: LayoutRootSetter = function (component) {
|
|
686
|
+
if (installation.refs <= 0) Reflect.apply(installation.original, this, [component]);
|
|
687
|
+
else applyRoot(installation, this, component);
|
|
688
|
+
};
|
|
689
|
+
const patchedImmediate: ImmediateRenderer = function () {
|
|
690
|
+
const state = installation.renderers.get(this);
|
|
691
|
+
if (installation.refs > 0 && state?.suppressImmediateRender) {
|
|
692
|
+
state.suppressImmediateRender = false;
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
Reflect.apply(installation.originalImmediate, this, []);
|
|
696
|
+
};
|
|
697
|
+
installation = {
|
|
698
|
+
protocol: INSTALLATION_PROTOCOL,
|
|
699
|
+
version: 3,
|
|
700
|
+
refs: 1,
|
|
701
|
+
theme,
|
|
702
|
+
registry,
|
|
703
|
+
mouseRegistry,
|
|
704
|
+
original: original.method,
|
|
705
|
+
originalDescriptor: original.descriptor,
|
|
706
|
+
patched,
|
|
707
|
+
originalImmediate: originalImmediate.method,
|
|
708
|
+
originalImmediateDescriptor: originalImmediate.descriptor,
|
|
709
|
+
patchedImmediate,
|
|
710
|
+
renderers: new Map(),
|
|
711
|
+
wrapperBases: new WeakMap(),
|
|
712
|
+
paneSizes: new Map(),
|
|
713
|
+
unsubscribe: registry.subscribe(() => refresh(installation, prototype)),
|
|
714
|
+
};
|
|
715
|
+
Object.defineProperty(prototype, INSTALLATION_KEY, { configurable: true, value: installation });
|
|
716
|
+
installMethod(prototype, "setLayoutRoot", patched);
|
|
717
|
+
installMethod(prototype, "requestImmediateRender", patchedImmediate);
|
|
718
|
+
ensureCurrentRoot(installation, tui);
|
|
719
|
+
return releaseBridge(prototype, installation);
|
|
720
|
+
}
|