electrobun 1.18.4-beta.18 → 1.18.4-beta.21
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/README.md +9 -0
- package/bin/electrobun.cjs +165 -0
- package/dist/api/browser/ui/__tests__/dom.test.ts +473 -0
- package/dist/api/browser/ui/__tests__/domStub.ts +218 -0
- package/dist/api/browser/ui/dom.ts +490 -0
- package/dist/api/browser/ui/index.ts +44 -0
- package/dist/api/browser/ui/jsx-dev-runtime.ts +16 -0
- package/dist/api/browser/ui/jsx-runtime.ts +56 -0
- package/dist/api/config/ElectrobunConfig.ts +33 -0
- package/dist/api/preload/.generated/compiled.ts +1 -1
- package/dist/api/preload/index.ts +2 -0
- package/dist/api/preload/uiTag.ts +45 -0
- package/dist/api/sdks/main/__tests__/utils-quit-exit-code.test.ts +44 -0
- package/dist/api/sdks/main/core/GpuWindow.ts +19 -0
- package/dist/api/sdks/main/core/Utils.ts +52 -4
- package/dist/api/sdks/main/core/WGPUView.ts +9 -0
- package/dist/api/sdks/main/entries/ui.ts +1 -0
- package/dist/api/sdks/main/proc/native.ts +187 -0
- package/dist/api/sdks/main/ui/__tests__/font.test.ts +49 -0
- package/dist/api/sdks/main/ui/__tests__/hit.test.ts +64 -0
- package/dist/api/sdks/main/ui/__tests__/jsx.test.ts +252 -0
- package/dist/api/sdks/main/ui/__tests__/layout.test.ts +159 -0
- package/dist/api/sdks/main/ui/__tests__/paint.test.ts +115 -0
- package/dist/api/sdks/main/ui/__tests__/reactive.test.ts +456 -0
- package/dist/api/sdks/main/ui/__tests__/scroll-focus-input.test.ts +298 -0
- package/dist/api/sdks/main/ui/__tests__/tree.test.ts +96 -0
- package/dist/api/sdks/main/ui/__tests__/ui.test.ts +170 -0
- package/dist/api/sdks/main/ui/elements.ts +135 -0
- package/dist/api/sdks/main/ui/font.ts +168 -0
- package/dist/api/sdks/main/ui/hit.ts +46 -0
- package/dist/api/sdks/main/ui/index.ts +71 -0
- package/dist/api/sdks/main/ui/input.ts +268 -0
- package/dist/api/sdks/main/ui/jsx-dev-runtime.ts +16 -0
- package/dist/api/sdks/main/ui/jsx-runtime.ts +136 -0
- package/dist/api/sdks/main/ui/keymap.ts +147 -0
- package/dist/api/sdks/main/ui/layout.ts +178 -0
- package/dist/api/sdks/main/ui/paint.ts +196 -0
- package/dist/api/sdks/main/ui/reactive.ts +4 -0
- package/dist/api/sdks/main/ui/renderer.ts +278 -0
- package/dist/api/sdks/main/ui/text.ts +175 -0
- package/dist/api/sdks/main/ui/textInput.ts +121 -0
- package/dist/api/sdks/main/ui/tree.ts +276 -0
- package/dist/api/sdks/main/ui/ui.ts +457 -0
- package/dist/api/sdks/main/ui/uiTagHost.ts +56 -0
- package/dist/api/sdks/main/ui/uiwindow.ts +330 -0
- package/dist/api/shared/build-dependencies.test.ts +1 -1
- package/dist/api/shared/build-dependencies.ts +4 -4
- package/dist/api/shared/linux-webkit-automation.test.ts +1 -1
- package/dist/api/shared/warren/jsx.ts +279 -0
- package/dist/api/shared/warren/reactive.ts +638 -0
- package/dist/api/shared/windows-unicode-ui.test.ts +4 -4
- package/dist/preload-full.js +35 -0
- package/dist/zig-sdk/electrobun.zig +197 -162
- package/{dash.config.ts → hutch.config.ts} +4 -3
- package/package.json +14 -2
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// Mounting a UI tree onto a Dawn target. Two shapes:
|
|
2
|
+
//
|
|
3
|
+
// - createUIWindow: a GpuWindow whose whole content is the retained UI tree —
|
|
4
|
+
// app chrome without a webview.
|
|
5
|
+
// - createUIView: a UI tree rendered into an existing WGPUView composited
|
|
6
|
+
// inside any window (e.g. a view created by an <electrobun-wgpu>-style tag
|
|
7
|
+
// over a webview) — UI layered on top of web content.
|
|
8
|
+
//
|
|
9
|
+
// Both are invalidation-driven: the frame tick is a cheap input poll unless
|
|
10
|
+
// something marked the tree dirty.
|
|
11
|
+
|
|
12
|
+
import { GpuWindow } from "../core/GpuWindow";
|
|
13
|
+
import type { WGPUView } from "../core/WGPUView";
|
|
14
|
+
import { batch, createRoot, inert } from "./reactive";
|
|
15
|
+
import { Prop } from "./tree";
|
|
16
|
+
import { hitChain, scrollTargetAt } from "./hit";
|
|
17
|
+
import { computeLayout } from "./layout";
|
|
18
|
+
import { paint } from "./paint";
|
|
19
|
+
import { createUiRenderer } from "./renderer";
|
|
20
|
+
import { attachInput } from "./input";
|
|
21
|
+
import { tryEnableNativeText } from "./text";
|
|
22
|
+
import { isUIElement, type UIElement } from "./jsx-runtime";
|
|
23
|
+
import { nativeText } from "../proc/native";
|
|
24
|
+
import {
|
|
25
|
+
createUiContext,
|
|
26
|
+
withUiContext,
|
|
27
|
+
type AnchorRect,
|
|
28
|
+
type KeyEventInfo,
|
|
29
|
+
type PointerEventInfo,
|
|
30
|
+
type UiContext,
|
|
31
|
+
} from "./ui";
|
|
32
|
+
import electrobunEventEmitter from "../events/eventEmitter";
|
|
33
|
+
|
|
34
|
+
export interface UIMountOptions {
|
|
35
|
+
/** Painted every frame behind the tree. */
|
|
36
|
+
background?: string;
|
|
37
|
+
/** Frame tick in ms: input poll always, render only when dirty. */
|
|
38
|
+
tickMs?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface UIWindowOptions extends UIMountOptions {
|
|
42
|
+
title: string;
|
|
43
|
+
width: number;
|
|
44
|
+
height: number;
|
|
45
|
+
titleBarStyle?: "hidden" | "hiddenInset" | "default";
|
|
46
|
+
/**
|
|
47
|
+
* Transparent window: pair with an alpha background (e.g. "#00000000")
|
|
48
|
+
* and a rounded root box for a floating-panel look.
|
|
49
|
+
*/
|
|
50
|
+
transparent?: boolean;
|
|
51
|
+
alwaysOnTop?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface UIMount {
|
|
55
|
+
context: UiContext;
|
|
56
|
+
dispose(): void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface UIWindow extends UIMount {
|
|
60
|
+
window: GpuWindow;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface UIView extends UIMount {
|
|
64
|
+
view: WGPUView;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface MountTarget {
|
|
68
|
+
renderTarget: GpuWindow | WGPUView;
|
|
69
|
+
/** WGPUView id whose native pointer events drive this mount. */
|
|
70
|
+
viewId: number;
|
|
71
|
+
windowId: number;
|
|
72
|
+
getSize(): { width: number; height: number };
|
|
73
|
+
/** Offset of the render target inside the window's content area. */
|
|
74
|
+
viewOffset(): { x: number; y: number };
|
|
75
|
+
/** False once the underlying native target is gone; ticks become no-ops. */
|
|
76
|
+
isAlive?(): boolean;
|
|
77
|
+
/** False while hidden: skip input polling and rendering until shown. */
|
|
78
|
+
isVisible?(): boolean;
|
|
79
|
+
/** Whether windowDrag nodes may move the host window (window mounts). */
|
|
80
|
+
allowWindowDrag?: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type UIApp = () => void | UIElement;
|
|
84
|
+
|
|
85
|
+
async function mount(
|
|
86
|
+
target: MountTarget,
|
|
87
|
+
options: UIMountOptions,
|
|
88
|
+
app: UIApp,
|
|
89
|
+
): Promise<{ context: UiContext; stop: () => void }> {
|
|
90
|
+
const background = options.background ?? "#141420";
|
|
91
|
+
// System-font text when the native wrapper provides it (macOS); the
|
|
92
|
+
// built-in bitmap font otherwise.
|
|
93
|
+
tryEnableNativeText(nativeText.available() ? nativeText : null);
|
|
94
|
+
const renderer = await createUiRenderer(
|
|
95
|
+
target.renderTarget,
|
|
96
|
+
background,
|
|
97
|
+
target.getSize(),
|
|
98
|
+
);
|
|
99
|
+
const ctx = createUiContext();
|
|
100
|
+
ctx.windowId = target.windowId;
|
|
101
|
+
const { tree } = ctx;
|
|
102
|
+
|
|
103
|
+
let disposeRoot = () => {};
|
|
104
|
+
createRoot((dispose) => {
|
|
105
|
+
disposeRoot = dispose;
|
|
106
|
+
withUiContext(ctx, () => {
|
|
107
|
+
const result = app();
|
|
108
|
+
// JSX apps return a lazy element; the builder API returns nothing.
|
|
109
|
+
if (isUIElement(result)) result.create();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const pointerHandler = (
|
|
114
|
+
type: "click" | "down" | "up" | "enter" | "leave",
|
|
115
|
+
targetId: number,
|
|
116
|
+
x: number,
|
|
117
|
+
y: number,
|
|
118
|
+
) => {
|
|
119
|
+
if (type === "down") {
|
|
120
|
+
// Click-to-focus: innermost focusable ancestor of the hit, or blur
|
|
121
|
+
// (targetId 0 = background click).
|
|
122
|
+
let id = targetId;
|
|
123
|
+
while (id !== 0 && tree.has(id)) {
|
|
124
|
+
if (tree.getProp(id, Prop.Focusable) === 1) break;
|
|
125
|
+
id = tree.parentOf(id);
|
|
126
|
+
}
|
|
127
|
+
ctx.setFocused(id);
|
|
128
|
+
} else if (type === "enter") {
|
|
129
|
+
ctx.setHovered(targetId);
|
|
130
|
+
} else if (type === "leave") {
|
|
131
|
+
if (inert(ctx.hoveredId) === targetId) ctx.setHovered(0);
|
|
132
|
+
}
|
|
133
|
+
const handlers = ctx.handlers.get(targetId);
|
|
134
|
+
if (!handlers) return;
|
|
135
|
+
const event: PointerEventInfo = { x, y, target: targetId };
|
|
136
|
+
const fn =
|
|
137
|
+
type === "click"
|
|
138
|
+
? handlers.onClick
|
|
139
|
+
: type === "down"
|
|
140
|
+
? handlers.onPointerDown
|
|
141
|
+
: type === "up"
|
|
142
|
+
? handlers.onPointerUp
|
|
143
|
+
: type === "enter"
|
|
144
|
+
? handlers.onPointerEnter
|
|
145
|
+
: handlers.onPointerLeave;
|
|
146
|
+
if (fn) batch(() => fn(event));
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const input = attachInput(target.windowId, target.viewId, target.viewOffset, {
|
|
150
|
+
hitChain: (x, y) => hitChain(tree, x, y),
|
|
151
|
+
dispatchWheel: (x, y, dx, dy) => {
|
|
152
|
+
const targetId = scrollTargetAt(tree, x, y);
|
|
153
|
+
if (targetId === 0) return;
|
|
154
|
+
const node = tree.get(targetId);
|
|
155
|
+
const column = tree.getProp(targetId, Prop.Dir) === 1;
|
|
156
|
+
const delta = column ? dy : dx;
|
|
157
|
+
const viewport = column ? node.h : node.w;
|
|
158
|
+
const max = Math.max(0, node.contentMain - viewport);
|
|
159
|
+
const current = tree.getProp(targetId, Prop.Scroll);
|
|
160
|
+
// Natural scrolling: positive delta scrolls content down/right.
|
|
161
|
+
const next = Math.max(0, Math.min(max, current - delta));
|
|
162
|
+
tree.setProp(targetId, Prop.Scroll, next);
|
|
163
|
+
},
|
|
164
|
+
isDragHandle: (id) => {
|
|
165
|
+
if (!target.allowWindowDrag) return false;
|
|
166
|
+
for (let n = id; n !== 0 && tree.has(n); n = tree.parentOf(n)) {
|
|
167
|
+
if (tree.getProp(n, Prop.WindowDrag) === 1) return true;
|
|
168
|
+
}
|
|
169
|
+
return false;
|
|
170
|
+
},
|
|
171
|
+
dispatchPointer: pointerHandler,
|
|
172
|
+
dispatchKey: (e: KeyEventInfo) => {
|
|
173
|
+
batch(() => {
|
|
174
|
+
// Focused node first, bubbling through ancestors; a handler
|
|
175
|
+
// returning true stops propagation to the window-level handlers.
|
|
176
|
+
let id = inert(ctx.focusedId);
|
|
177
|
+
while (id !== 0 && tree.has(id)) {
|
|
178
|
+
const handler = ctx.handlers.get(id)?.onKeyDown;
|
|
179
|
+
if (handler && handler(e) === true) return;
|
|
180
|
+
id = tree.parentOf(id);
|
|
181
|
+
}
|
|
182
|
+
for (const handler of ctx.keyHandlers) handler(e);
|
|
183
|
+
});
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
let lastWidth = 0;
|
|
188
|
+
let lastHeight = 0;
|
|
189
|
+
const lastAnchorRects = new Map<number, AnchorRect>();
|
|
190
|
+
const syncAnchors = () => {
|
|
191
|
+
for (const [id, onFrame] of ctx.anchors) {
|
|
192
|
+
if (!tree.has(id)) {
|
|
193
|
+
lastAnchorRects.delete(id);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const node = tree.get(id);
|
|
197
|
+
const rect: AnchorRect = {
|
|
198
|
+
x: node.x,
|
|
199
|
+
y: node.y,
|
|
200
|
+
width: node.w,
|
|
201
|
+
height: node.h,
|
|
202
|
+
};
|
|
203
|
+
const last = lastAnchorRects.get(id);
|
|
204
|
+
if (
|
|
205
|
+
!last ||
|
|
206
|
+
last.x !== rect.x ||
|
|
207
|
+
last.y !== rect.y ||
|
|
208
|
+
last.width !== rect.width ||
|
|
209
|
+
last.height !== rect.height
|
|
210
|
+
) {
|
|
211
|
+
lastAnchorRects.set(id, rect);
|
|
212
|
+
onFrame(rect);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const renderFrame = (width: number, height: number) => {
|
|
218
|
+
if (width <= 0 || height <= 0) return;
|
|
219
|
+
renderer.resize(width, height);
|
|
220
|
+
computeLayout(tree, width, height);
|
|
221
|
+
syncAnchors();
|
|
222
|
+
renderer.render(paint(tree), width, height);
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const timer = setInterval(() => {
|
|
226
|
+
if (target.isAlive && !target.isAlive()) return;
|
|
227
|
+
if (target.isVisible && !target.isVisible()) return;
|
|
228
|
+
input.poll();
|
|
229
|
+
const { width, height } = target.getSize();
|
|
230
|
+
const sizeChanged = width !== lastWidth || height !== lastHeight;
|
|
231
|
+
if (tree.takeDirty() || sizeChanged) {
|
|
232
|
+
lastWidth = width;
|
|
233
|
+
lastHeight = height;
|
|
234
|
+
renderFrame(width, height);
|
|
235
|
+
}
|
|
236
|
+
}, options.tickMs ?? 8);
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
context: ctx,
|
|
240
|
+
stop() {
|
|
241
|
+
clearInterval(timer);
|
|
242
|
+
input.dispose();
|
|
243
|
+
disposeRoot();
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export async function createUIWindow(
|
|
249
|
+
options: UIWindowOptions,
|
|
250
|
+
app: UIApp,
|
|
251
|
+
): Promise<UIWindow> {
|
|
252
|
+
const win = new GpuWindow({
|
|
253
|
+
title: options.title,
|
|
254
|
+
frame: { width: options.width, height: options.height },
|
|
255
|
+
// hiddenInset keeps the window frame equal to the content area, so
|
|
256
|
+
// cursor-to-local math needs no title-bar offset.
|
|
257
|
+
titleBarStyle: options.titleBarStyle ?? "hiddenInset",
|
|
258
|
+
transparent: options.transparent ?? false,
|
|
259
|
+
});
|
|
260
|
+
if (options.alwaysOnTop) {
|
|
261
|
+
win.setAlwaysOnTop(true);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const mounted = await mount(
|
|
265
|
+
{
|
|
266
|
+
renderTarget: win,
|
|
267
|
+
viewId: win.wgpuViewId,
|
|
268
|
+
windowId: win.id,
|
|
269
|
+
getSize: () => win.getSize(),
|
|
270
|
+
viewOffset: () => ({ x: 0, y: 0 }),
|
|
271
|
+
// Hidden windows skip layout/paint/GPU entirely (e.g. a focused
|
|
272
|
+
// textInput's caret blink must not keep a hidden palette rendering).
|
|
273
|
+
isVisible: () => win.isVisible(),
|
|
274
|
+
allowWindowDrag: true,
|
|
275
|
+
},
|
|
276
|
+
options,
|
|
277
|
+
app,
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
const onClose = () => mounted.stop();
|
|
281
|
+
electrobunEventEmitter.on(`close-${win.id}`, onClose);
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
window: win,
|
|
285
|
+
context: mounted.context,
|
|
286
|
+
dispose() {
|
|
287
|
+
electrobunEventEmitter.off(`close-${win.id}`, onClose);
|
|
288
|
+
mounted.stop();
|
|
289
|
+
win.close();
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Mount a UI tree into an existing WGPUView. The view's frame (tracked via
|
|
296
|
+
* the JS wrapper) supplies size and pointer offset; pair it with an
|
|
297
|
+
* <electrobun-wgpu>-style tag to layer reactive native UI over a webview.
|
|
298
|
+
* Note: frames moved natively without updating the JS wrapper (tag overlay
|
|
299
|
+
* sync) are not observed yet — production work alongside native pointer
|
|
300
|
+
* events.
|
|
301
|
+
*/
|
|
302
|
+
export async function createUIView(
|
|
303
|
+
view: WGPUView,
|
|
304
|
+
options: UIMountOptions,
|
|
305
|
+
app: UIApp,
|
|
306
|
+
): Promise<UIView> {
|
|
307
|
+
const mounted = await mount(
|
|
308
|
+
{
|
|
309
|
+
renderTarget: view,
|
|
310
|
+
viewId: view.id,
|
|
311
|
+
windowId: view.windowId,
|
|
312
|
+
getSize: () => ({
|
|
313
|
+
width: view.frame.width,
|
|
314
|
+
height: view.frame.height,
|
|
315
|
+
}),
|
|
316
|
+
viewOffset: () => ({ x: view.frame.x, y: view.frame.y }),
|
|
317
|
+
isAlive: () => !view.isRemoved,
|
|
318
|
+
},
|
|
319
|
+
options,
|
|
320
|
+
app,
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
view,
|
|
325
|
+
context: mounted.context,
|
|
326
|
+
dispose() {
|
|
327
|
+
mounted.stop();
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
}
|
|
@@ -42,7 +42,7 @@ describe("owned build dependency artifacts", () => {
|
|
|
42
42
|
);
|
|
43
43
|
|
|
44
44
|
expect(artifact.url).toBe(
|
|
45
|
-
"https://artifacts.example.test/zig-bsdiff/releases/0.1.
|
|
45
|
+
"https://artifacts.example.test/zig-bsdiff/releases/0.1.22/zig-bsdiff-linux-x64.tar.gz",
|
|
46
46
|
);
|
|
47
47
|
});
|
|
48
48
|
});
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
export const BUILD_DEPENDENCIES_PUBLIC_BASE_URL =
|
|
2
2
|
"https://electrobun-artifacts.blackboard.sh";
|
|
3
3
|
|
|
4
|
-
export const ZIG_VERSION = "0.
|
|
4
|
+
export const ZIG_VERSION = "0.16.0";
|
|
5
5
|
|
|
6
6
|
export const OWNED_BUILD_DEPENDENCY_VERSIONS = {
|
|
7
|
-
"zig-bsdiff": "0.1.
|
|
8
|
-
"zig-zstd": "0.1.
|
|
9
|
-
"zig-asar": "0.2.
|
|
7
|
+
"zig-bsdiff": "0.1.22",
|
|
8
|
+
"zig-zstd": "0.1.7",
|
|
9
|
+
"zig-asar": "0.2.7",
|
|
10
10
|
"electrobun-dawn": "0.2.5",
|
|
11
11
|
} as const;
|
|
12
12
|
|
|
@@ -30,7 +30,7 @@ describe("Linux WebKitGTK automation contract", () => {
|
|
|
30
30
|
"automation.private_inspector_server_environment_variable",
|
|
31
31
|
);
|
|
32
32
|
expect(launcher).toContain(
|
|
33
|
-
"env_map.
|
|
33
|
+
"env_map.swapRemove(automation.inspector_server_environment_variable)",
|
|
34
34
|
);
|
|
35
35
|
expect(launcher).not.toContain("argv = launcher_args");
|
|
36
36
|
});
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// Warren JSX core — renderer-agnostic.
|
|
2
|
+
//
|
|
3
|
+
// Everything a renderer target shares: lazy elements, child mounting,
|
|
4
|
+
// component invocation, and the control-flow components (Show / For /
|
|
5
|
+
// Switch / Match) with their live-vs-frozen-snapshot semantics. A renderer
|
|
6
|
+
// (GPU retained tree, browser DOM) supplies five primitives and gets the
|
|
7
|
+
// whole component model:
|
|
8
|
+
//
|
|
9
|
+
// text(value) create a text leaf at the insertion point
|
|
10
|
+
// dynamic(build) reactive region: build re-runs on change
|
|
11
|
+
// each(items, key, render) keyed list region with row reconciliation
|
|
12
|
+
// intrinsic(type, props) create one intrinsic element (tag vocabulary
|
|
13
|
+
// is renderer-owned; children arrive in props)
|
|
14
|
+
// escape(fn) a bare function child — renderer escape hatch
|
|
15
|
+
//
|
|
16
|
+
// Each package binds a renderer once via createJsxRuntime() and re-exports
|
|
17
|
+
// the result as its jsx-runtime module; JSX.IntrinsicElements typing stays
|
|
18
|
+
// per-package (types only, Solid-style — the runtime never enumerates tags).
|
|
19
|
+
|
|
20
|
+
import { inert, isLive, memo, type Accessor, type LiveBinding } from "./reactive";
|
|
21
|
+
|
|
22
|
+
export interface UIElement {
|
|
23
|
+
readonly __electrobunElement: true;
|
|
24
|
+
/** Create this element's nodes under the current build parent. */
|
|
25
|
+
create(): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isUIElement(value: unknown): value is UIElement {
|
|
29
|
+
return (
|
|
30
|
+
typeof value === "object" &&
|
|
31
|
+
value !== null &&
|
|
32
|
+
(value as any).__electrobunElement === true
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type UIChild =
|
|
37
|
+
| UIElement
|
|
38
|
+
| string
|
|
39
|
+
| number
|
|
40
|
+
| boolean
|
|
41
|
+
| null
|
|
42
|
+
| undefined
|
|
43
|
+
| LiveBinding<string | number>
|
|
44
|
+
| (() => void)
|
|
45
|
+
| UIChild[];
|
|
46
|
+
|
|
47
|
+
export function element(create: () => void): UIElement {
|
|
48
|
+
return { __electrobunElement: true, create };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface WarrenRenderer {
|
|
52
|
+
text(value: string | number | LiveBinding<string | number>): void;
|
|
53
|
+
/**
|
|
54
|
+
* A claimed live() child. Text-only renderers may bind it as reactive
|
|
55
|
+
* text; DOM-style renderers should evaluate inside a region so element
|
|
56
|
+
* results ({live(() => open() && <div/>)}) remount on change.
|
|
57
|
+
*/
|
|
58
|
+
liveChild(binding: LiveBinding<unknown>): void;
|
|
59
|
+
dynamic(build: () => void): void;
|
|
60
|
+
each<T>(
|
|
61
|
+
items: () => readonly T[],
|
|
62
|
+
key: (item: T, index: number) => string | number,
|
|
63
|
+
render: (item: T, index: Accessor<number>) => void,
|
|
64
|
+
): void;
|
|
65
|
+
intrinsic(type: string, props: Record<string, unknown>): void;
|
|
66
|
+
escape(fn: () => unknown): void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// --- Match markers (structural brand: survives mixed module realms) -------
|
|
70
|
+
|
|
71
|
+
const MATCH_BRAND = "__warrenMatch";
|
|
72
|
+
|
|
73
|
+
export interface MatchProps {
|
|
74
|
+
when: unknown;
|
|
75
|
+
children?: UIChild;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface MatchMarker {
|
|
79
|
+
[MATCH_BRAND]: true;
|
|
80
|
+
when: unknown;
|
|
81
|
+
children?: UIChild;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function Match(props: MatchProps): MatchMarker {
|
|
85
|
+
return { [MATCH_BRAND]: true, when: props.when, children: props.children };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isMatch(value: unknown): value is MatchMarker {
|
|
89
|
+
return (
|
|
90
|
+
typeof value === "object" &&
|
|
91
|
+
value !== null &&
|
|
92
|
+
(value as any)[MATCH_BRAND] === true
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// --- Control-flow prop shapes ---------------------------------------------
|
|
97
|
+
|
|
98
|
+
export interface ShowProps {
|
|
99
|
+
when: unknown;
|
|
100
|
+
fallback?: UIChild;
|
|
101
|
+
children?: UIChild;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface ForProps<T> {
|
|
105
|
+
each: readonly T[] | LiveBinding<readonly T[]>;
|
|
106
|
+
/** Row identity for reconciliation; defaults to item identity. */
|
|
107
|
+
key?: (item: T, index: number) => string | number;
|
|
108
|
+
fallback?: UIChild;
|
|
109
|
+
children?: (item: T, index: Accessor<number>) => UIChild;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface SwitchProps {
|
|
113
|
+
fallback?: UIChild;
|
|
114
|
+
children?: unknown;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
type ComponentFn = (props: Record<string, unknown>) => UIElement | UIChild;
|
|
118
|
+
|
|
119
|
+
export interface WarrenJsxRuntime {
|
|
120
|
+
jsx(
|
|
121
|
+
type: string | ComponentFn,
|
|
122
|
+
props: Record<string, unknown> | null,
|
|
123
|
+
key?: unknown,
|
|
124
|
+
): UIElement;
|
|
125
|
+
jsxs(
|
|
126
|
+
type: string | ComponentFn,
|
|
127
|
+
props: Record<string, unknown> | null,
|
|
128
|
+
key?: unknown,
|
|
129
|
+
): UIElement;
|
|
130
|
+
Fragment(props: { children?: UIChild }): UIElement;
|
|
131
|
+
mountChild(child: UIChild): void;
|
|
132
|
+
Show(props: ShowProps): UIElement;
|
|
133
|
+
For<T>(props: ForProps<T>): UIElement;
|
|
134
|
+
Switch(props: SwitchProps): UIElement;
|
|
135
|
+
Match: typeof Match;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function createJsxRuntime(renderer: WarrenRenderer): WarrenJsxRuntime {
|
|
139
|
+
function mountChild(child: UIChild): void {
|
|
140
|
+
if (child == null || typeof child === "boolean") return;
|
|
141
|
+
if (Array.isArray(child)) {
|
|
142
|
+
for (const c of child) mountChild(c);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (isUIElement(child)) {
|
|
146
|
+
child.create();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (isLive(child)) {
|
|
150
|
+
const binding = child as LiveBinding<unknown>;
|
|
151
|
+
binding.claimed = true;
|
|
152
|
+
renderer.liveChild(binding);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (typeof child === "function") {
|
|
156
|
+
renderer.escape(child as () => unknown);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
renderer.text(String(child));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function jsx(
|
|
163
|
+
type: string | ComponentFn,
|
|
164
|
+
props: Record<string, unknown> | null,
|
|
165
|
+
key?: unknown,
|
|
166
|
+
): UIElement {
|
|
167
|
+
let resolved = props ?? {};
|
|
168
|
+
if (typeof type === "function") {
|
|
169
|
+
// The automatic JSX transform lifts `key` out of props into the
|
|
170
|
+
// third argument; components (For) receive it back as a prop.
|
|
171
|
+
if (key !== undefined && resolved["key"] === undefined) {
|
|
172
|
+
resolved = { ...resolved, key };
|
|
173
|
+
}
|
|
174
|
+
// Component bodies are inert (the one-line rule) — including when
|
|
175
|
+
// the component is created lazily inside a live region's tracked
|
|
176
|
+
// build. Props were already evaluated at the call site, so region
|
|
177
|
+
// reactivity on prop expressions is unaffected; statement lives
|
|
178
|
+
// declared in the body stay deferred instead of running (and
|
|
179
|
+
// tracking) synchronously into the enclosing region.
|
|
180
|
+
const result = inert(() => type(resolved));
|
|
181
|
+
if (isUIElement(result)) return result;
|
|
182
|
+
// Match markers pass through raw so Switch can collect them.
|
|
183
|
+
if (isMatch(result)) return result as unknown as UIElement;
|
|
184
|
+
// Components may return any child shape (fragment arrays, strings...).
|
|
185
|
+
return element(() => mountChild(result as UIChild));
|
|
186
|
+
}
|
|
187
|
+
return element(() => renderer.intrinsic(type, resolved));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function Fragment(props: { children?: UIChild }): UIElement {
|
|
191
|
+
return element(() => mountChild(props.children));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// --- Control flow: a live() prop reconciles on change; a plain value is
|
|
195
|
+
// a snapshot that renders once and never updates. ------------------------
|
|
196
|
+
|
|
197
|
+
function Show(props: ShowProps): UIElement {
|
|
198
|
+
const { when } = props;
|
|
199
|
+
if (isLive(when)) {
|
|
200
|
+
when.claimed = true;
|
|
201
|
+
return element(() => {
|
|
202
|
+
renderer.dynamic(() => {
|
|
203
|
+
mountChild(when.fn() ? props.children : props.fallback);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
return element(() => mountChild(when ? props.children : props.fallback));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function For<T>(props: ForProps<T>): UIElement {
|
|
211
|
+
const render = props.children;
|
|
212
|
+
if (typeof render !== "function") {
|
|
213
|
+
throw new Error(
|
|
214
|
+
"Warren: <For> takes a function child: (item, index) => ...",
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
const keyOf =
|
|
218
|
+
props.key ?? ((item: T) => item as unknown as string | number);
|
|
219
|
+
const { each } = props;
|
|
220
|
+
if (isLive(each)) {
|
|
221
|
+
each.claimed = true;
|
|
222
|
+
const items = () => (each.fn() ?? []) as readonly T[];
|
|
223
|
+
return element(() => {
|
|
224
|
+
// The region depends only on emptiness (equality-cut memo), so
|
|
225
|
+
// list changes reconcile rows in the keyed each instead of
|
|
226
|
+
// rebuilding the whole region; only empty<->non-empty flips it.
|
|
227
|
+
const empty = memo(() => items().length === 0);
|
|
228
|
+
renderer.dynamic(() => {
|
|
229
|
+
if (empty()) {
|
|
230
|
+
mountChild(props.fallback);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
renderer.each(items, keyOf, (item, index) => {
|
|
234
|
+
mountChild(render(item, index));
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
// Snapshot: rendered once, no reconciliation.
|
|
240
|
+
const list = (each ?? []) as readonly T[];
|
|
241
|
+
return element(() => {
|
|
242
|
+
if (list.length === 0) {
|
|
243
|
+
mountChild(props.fallback);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
list.forEach((item, i) => mountChild(render(item, () => i)));
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function Switch(props: SwitchProps): UIElement {
|
|
251
|
+
const kids = Array.isArray(props.children)
|
|
252
|
+
? props.children
|
|
253
|
+
: [props.children];
|
|
254
|
+
const matches = kids.filter(isMatch);
|
|
255
|
+
const anyLive = matches.some((m) => isLive(m.when));
|
|
256
|
+
for (const m of matches) {
|
|
257
|
+
if (isLive(m.when)) (m.when as LiveBinding<unknown>).claimed = true;
|
|
258
|
+
}
|
|
259
|
+
const pick = (): UIChild | undefined => {
|
|
260
|
+
for (const m of matches) {
|
|
261
|
+
const truthy = isLive(m.when)
|
|
262
|
+
? (m.when as LiveBinding<unknown>).fn()
|
|
263
|
+
: m.when;
|
|
264
|
+
if (truthy) return m.children;
|
|
265
|
+
}
|
|
266
|
+
return props.fallback;
|
|
267
|
+
};
|
|
268
|
+
if (anyLive) {
|
|
269
|
+
return element(() => {
|
|
270
|
+
renderer.dynamic(() => {
|
|
271
|
+
mountChild(pick());
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
return element(() => mountChild(pick()));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return { jsx, jsxs: jsx, Fragment, mountChild, Show, For, Switch, Match };
|
|
279
|
+
}
|