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.
Files changed (55) hide show
  1. package/README.md +9 -0
  2. package/bin/electrobun.cjs +165 -0
  3. package/dist/api/browser/ui/__tests__/dom.test.ts +473 -0
  4. package/dist/api/browser/ui/__tests__/domStub.ts +218 -0
  5. package/dist/api/browser/ui/dom.ts +490 -0
  6. package/dist/api/browser/ui/index.ts +44 -0
  7. package/dist/api/browser/ui/jsx-dev-runtime.ts +16 -0
  8. package/dist/api/browser/ui/jsx-runtime.ts +56 -0
  9. package/dist/api/config/ElectrobunConfig.ts +33 -0
  10. package/dist/api/preload/.generated/compiled.ts +1 -1
  11. package/dist/api/preload/index.ts +2 -0
  12. package/dist/api/preload/uiTag.ts +45 -0
  13. package/dist/api/sdks/main/__tests__/utils-quit-exit-code.test.ts +44 -0
  14. package/dist/api/sdks/main/core/GpuWindow.ts +19 -0
  15. package/dist/api/sdks/main/core/Utils.ts +52 -4
  16. package/dist/api/sdks/main/core/WGPUView.ts +9 -0
  17. package/dist/api/sdks/main/entries/ui.ts +1 -0
  18. package/dist/api/sdks/main/proc/native.ts +187 -0
  19. package/dist/api/sdks/main/ui/__tests__/font.test.ts +49 -0
  20. package/dist/api/sdks/main/ui/__tests__/hit.test.ts +64 -0
  21. package/dist/api/sdks/main/ui/__tests__/jsx.test.ts +252 -0
  22. package/dist/api/sdks/main/ui/__tests__/layout.test.ts +159 -0
  23. package/dist/api/sdks/main/ui/__tests__/paint.test.ts +115 -0
  24. package/dist/api/sdks/main/ui/__tests__/reactive.test.ts +456 -0
  25. package/dist/api/sdks/main/ui/__tests__/scroll-focus-input.test.ts +298 -0
  26. package/dist/api/sdks/main/ui/__tests__/tree.test.ts +96 -0
  27. package/dist/api/sdks/main/ui/__tests__/ui.test.ts +170 -0
  28. package/dist/api/sdks/main/ui/elements.ts +135 -0
  29. package/dist/api/sdks/main/ui/font.ts +168 -0
  30. package/dist/api/sdks/main/ui/hit.ts +46 -0
  31. package/dist/api/sdks/main/ui/index.ts +71 -0
  32. package/dist/api/sdks/main/ui/input.ts +268 -0
  33. package/dist/api/sdks/main/ui/jsx-dev-runtime.ts +16 -0
  34. package/dist/api/sdks/main/ui/jsx-runtime.ts +136 -0
  35. package/dist/api/sdks/main/ui/keymap.ts +147 -0
  36. package/dist/api/sdks/main/ui/layout.ts +178 -0
  37. package/dist/api/sdks/main/ui/paint.ts +196 -0
  38. package/dist/api/sdks/main/ui/reactive.ts +4 -0
  39. package/dist/api/sdks/main/ui/renderer.ts +278 -0
  40. package/dist/api/sdks/main/ui/text.ts +175 -0
  41. package/dist/api/sdks/main/ui/textInput.ts +121 -0
  42. package/dist/api/sdks/main/ui/tree.ts +276 -0
  43. package/dist/api/sdks/main/ui/ui.ts +457 -0
  44. package/dist/api/sdks/main/ui/uiTagHost.ts +56 -0
  45. package/dist/api/sdks/main/ui/uiwindow.ts +330 -0
  46. package/dist/api/shared/build-dependencies.test.ts +1 -1
  47. package/dist/api/shared/build-dependencies.ts +4 -4
  48. package/dist/api/shared/linux-webkit-automation.test.ts +1 -1
  49. package/dist/api/shared/warren/jsx.ts +279 -0
  50. package/dist/api/shared/warren/reactive.ts +638 -0
  51. package/dist/api/shared/windows-unicode-ui.test.ts +4 -4
  52. package/dist/preload-full.js +35 -0
  53. package/dist/zig-sdk/electrobun.zig +197 -162
  54. package/{dash.config.ts → hutch.config.ts} +4 -3
  55. package/package.json +14 -2
@@ -0,0 +1,298 @@
1
+ // Tests for the launcher-driving primitives: scroll/clip containers, keyed
2
+ // each(), the keymap/edit reducer, focus wiring, and textInput editing.
3
+
4
+ import { describe, expect, test } from "bun:test";
5
+ import { createRoot, live, signal, store } from "../reactive";
6
+ import { NodeKind, Prop, UiTree } from "../tree";
7
+ import { computeLayout } from "../layout";
8
+ import { FLOATS_PER_INSTANCE, paint } from "../paint";
9
+ import { hitChain } from "../hit";
10
+ import { Key, Mod, applyEditKey, charForKey } from "../keymap";
11
+ import { createUiContext, ui, withUiContext, type UiContext } from "../ui";
12
+ import { textInput } from "../textInput";
13
+
14
+ function build(app: () => void): { ctx: UiContext; dispose: () => void } {
15
+ const ctx = createUiContext();
16
+ let dispose = () => {};
17
+ createRoot((d) => {
18
+ dispose = d;
19
+ withUiContext(ctx, app);
20
+ });
21
+ return { ctx, dispose };
22
+ }
23
+
24
+ describe("scroll containers", () => {
25
+ function scrollTree(scrollY: number) {
26
+ const tree = new UiTree();
27
+ const container = tree.createNode(NodeKind.Box);
28
+ tree.setProp(container, Prop.Dir, 1);
29
+ tree.setProp(container, Prop.Height, 100);
30
+ tree.setProp(container, Prop.Overflow, 1);
31
+ tree.setProp(container, Prop.Scroll, scrollY);
32
+ tree.setProp(container, Prop.Gap, 10);
33
+ tree.append(tree.root, container);
34
+ const rows: number[] = [];
35
+ for (let i = 0; i < 5; i++) {
36
+ const row = tree.createNode(NodeKind.Box);
37
+ tree.setProp(row, Prop.Height, 30);
38
+ tree.setProp(row, Prop.Width, 50);
39
+ tree.setProp(row, Prop.Bg, 0x333333ff);
40
+ tree.append(container, row);
41
+ rows.push(row);
42
+ }
43
+ computeLayout(tree, 200, 100);
44
+ return { tree, container, rows };
45
+ }
46
+
47
+ test("children keep natural size and record content extent", () => {
48
+ const { tree, container } = scrollTree(0);
49
+ // 5 rows x 30 + 4 gaps x 10 = 190
50
+ expect(tree.get(container).contentMain).toBe(190);
51
+ expect(tree.get(container).h).toBe(100);
52
+ });
53
+
54
+ test("scrollY shifts children on the main axis", () => {
55
+ const { tree: at0, rows: rows0 } = scrollTree(0);
56
+ const { tree: at40, rows: rows40 } = scrollTree(40);
57
+ expect(at0.get(rows0[0]!).y).toBe(0);
58
+ expect(at40.get(rows40[0]!).y).toBe(-40);
59
+ expect(at40.get(rows40[2]!).y).toBe(40);
60
+ });
61
+
62
+ test("grow children are not inflated inside scroll containers", () => {
63
+ const tree = new UiTree();
64
+ const container = tree.createNode(NodeKind.Box);
65
+ tree.setProp(container, Prop.Dir, 1);
66
+ tree.setProp(container, Prop.Height, 300);
67
+ tree.setProp(container, Prop.Overflow, 1);
68
+ tree.append(tree.root, container);
69
+ const child = tree.createNode(NodeKind.Box);
70
+ tree.setProp(child, Prop.Height, 20);
71
+ tree.setProp(child, Prop.Grow, 1);
72
+ tree.append(container, child);
73
+ computeLayout(tree, 200, 300);
74
+ expect(tree.get(child).h).toBe(20);
75
+ });
76
+
77
+ test("paint clips children to the container and culls off-screen rows", () => {
78
+ const { tree, container } = scrollTree(0);
79
+ const buffer = paint(tree);
80
+ // Rows at y=0,40,80 intersect the 100px container; y=120,160 culled.
81
+ expect(buffer.count).toBe(3);
82
+ const clip = Array.from(
83
+ buffer.data.subarray(12, 16),
84
+ );
85
+ const c = tree.get(container);
86
+ expect(clip).toEqual([c.x, c.y, c.w, c.h]);
87
+ });
88
+
89
+ test("nested scroll containers intersect clips", () => {
90
+ const tree = new UiTree();
91
+ const outer = tree.createNode(NodeKind.Box);
92
+ tree.setProp(outer, Prop.Dir, 1);
93
+ tree.setProp(outer, Prop.Height, 100);
94
+ tree.setProp(outer, Prop.Width, 100);
95
+ tree.setProp(outer, Prop.Overflow, 1);
96
+ tree.append(tree.root, outer);
97
+ const inner = tree.createNode(NodeKind.Box);
98
+ tree.setProp(inner, Prop.Dir, 1);
99
+ tree.setProp(inner, Prop.Height, 150);
100
+ tree.setProp(inner, Prop.Width, 80);
101
+ tree.setProp(inner, Prop.Overflow, 1);
102
+ tree.append(outer, inner);
103
+ const leaf = tree.createNode(NodeKind.Box);
104
+ tree.setProp(leaf, Prop.Height, 20);
105
+ tree.setProp(leaf, Prop.Width, 20);
106
+ tree.setProp(leaf, Prop.Bg, 0xffffffff);
107
+ tree.append(inner, leaf);
108
+ computeLayout(tree, 200, 200);
109
+ const buffer = paint(tree);
110
+ const last = (buffer.count - 1) * FLOATS_PER_INSTANCE;
111
+ const clip = Array.from(buffer.data.subarray(last + 12, last + 16));
112
+ // Inner extends to 150 but outer clips at 100.
113
+ expect(clip[3]).toBe(100);
114
+ });
115
+
116
+ test("hit testing respects scroll offset and container bounds", () => {
117
+ const { tree, rows } = scrollTree(40);
118
+ // Row 2 sits at y=40 on screen after scrolling.
119
+ tree.setProp(rows[2]!, Prop.Hittable, 1);
120
+ expect(hitChain(tree, 10, 50)).toEqual([rows[2]!]);
121
+ // Row 4 is below the container (clipped): unreachable.
122
+ tree.setProp(rows[4]!, Prop.Hittable, 1);
123
+ expect(hitChain(tree, 10, 130).includes(rows[4]!)).toBe(false);
124
+ });
125
+ });
126
+
127
+ describe("keymap and edit reducer", () => {
128
+ test("maps characters with and without shift", () => {
129
+ expect(charForKey(0, 0)).toBe("a");
130
+ expect(charForKey(0, Mod.Shift)).toBe("A");
131
+ expect(charForKey(19, 0)).toBe("2");
132
+ expect(charForKey(19, Mod.Shift)).toBe("@");
133
+ expect(charForKey(0, Mod.Cmd)).toBe(null);
134
+ expect(charForKey(Key.Left, 0)).toBe(null);
135
+ });
136
+
137
+ test("inserts at the caret", () => {
138
+ const r = applyEditKey({ value: "ac", caret: 1 }, 11, 0); // 'b'
139
+ expect(r.value).toBe("abc");
140
+ expect(r.caret).toBe(2);
141
+ });
142
+
143
+ test("backspace variants", () => {
144
+ expect(applyEditKey({ value: "abc", caret: 3 }, Key.Backspace, 0).value).toBe("ab");
145
+ expect(
146
+ applyEditKey({ value: "abc def", caret: 7 }, Key.Backspace, Mod.Alt).value,
147
+ ).toBe("abc ");
148
+ expect(
149
+ applyEditKey({ value: "abc", caret: 3 }, Key.Backspace, Mod.Cmd).value,
150
+ ).toBe("");
151
+ const atStart = applyEditKey({ value: "abc", caret: 0 }, Key.Backspace, 0);
152
+ expect(atStart.value).toBe("abc");
153
+ expect(atStart.handled).toBe(true);
154
+ });
155
+
156
+ test("caret movement with word and line modifiers", () => {
157
+ expect(applyEditKey({ value: "ab cd", caret: 5 }, Key.Left, 0).caret).toBe(4);
158
+ expect(applyEditKey({ value: "ab cd", caret: 5 }, Key.Left, Mod.Alt).caret).toBe(3);
159
+ expect(applyEditKey({ value: "ab cd", caret: 5 }, Key.Left, Mod.Cmd).caret).toBe(0);
160
+ expect(applyEditKey({ value: "ab cd", caret: 0 }, Key.Right, Mod.Cmd).caret).toBe(5);
161
+ });
162
+
163
+ test("Enter submits; unknown keys pass through", () => {
164
+ expect(applyEditKey({ value: "x", caret: 1 }, Key.Return, 0).submit).toBe(true);
165
+ const up = applyEditKey({ value: "x", caret: 1 }, Key.Up, 0);
166
+ expect(up.handled).toBe(false);
167
+ });
168
+ });
169
+
170
+ describe("keyed each", () => {
171
+ test("rows keep their nodes across reorder and removal", () => {
172
+ const [items, setItems] = signal(["a", "b", "c"]);
173
+ const { ctx } = build(() => {
174
+ ui.each({ dir: "column" }, items, (s) => s, (s) => {
175
+ ui.text(s);
176
+ });
177
+ });
178
+ const [region] = ctx.tree.childrenOf(ctx.tree.root);
179
+ const before = ctx.tree.childrenOf(region!);
180
+ expect(before.length).toBe(3);
181
+ const nodeFor = new Map(
182
+ ["a", "b", "c"].map((k, i) => [k, before[i]!]),
183
+ );
184
+
185
+ setItems(["c", "a"]);
186
+ const after = ctx.tree.childrenOf(region!);
187
+ expect(after).toEqual([nodeFor.get("c")!, nodeFor.get("a")!]);
188
+ expect(ctx.tree.has(nodeFor.get("b")!)).toBe(false);
189
+ });
190
+
191
+ test("row-scoped effects are disposed with their row", () => {
192
+ const [items, setItems] = signal(["x", "y"]);
193
+ const [tick, setTick] = signal(0);
194
+ const runs: string[] = [];
195
+ build(() => {
196
+ ui.each({}, items, (s) => s, (s) => {
197
+ ui.text(live(() => {
198
+ tick();
199
+ runs.push(s);
200
+ return s;
201
+ }));
202
+ });
203
+ });
204
+ runs.length = 0;
205
+ setItems(["x"]);
206
+ setTick(1);
207
+ expect(runs).toEqual(["x"]);
208
+ });
209
+
210
+ test("index accessor updates in place", () => {
211
+ const [items, setItems] = signal(["a", "b"]);
212
+ const { ctx } = build(() => {
213
+ ui.each({}, items, (s) => s, (s, index) => {
214
+ ui.text(live(() => `${s}:${index()}`));
215
+ });
216
+ });
217
+ const [region] = ctx.tree.childrenOf(ctx.tree.root);
218
+ const textOf = (row: number) =>
219
+ ctx.tree.getText(ctx.tree.firstChildOf(row));
220
+ setItems(["b", "a"]);
221
+ const rows = ctx.tree.childrenOf(region!);
222
+ expect(textOf(rows[0]!)).toBe("b:0");
223
+ expect(textOf(rows[1]!)).toBe("a:1");
224
+ });
225
+
226
+ test("store-backed items work through produce", () => {
227
+ const [state, setState] = store({ items: ["one"] });
228
+ const { ctx } = build(() => {
229
+ ui.each({}, () => state.items, (s) => s, (s) => {
230
+ ui.text(s);
231
+ });
232
+ });
233
+ const [region] = ctx.tree.childrenOf(ctx.tree.root);
234
+ setState(((st) => st.items.push("two")));
235
+ expect(ctx.tree.childrenOf(region!).length).toBe(2);
236
+ });
237
+ });
238
+
239
+ describe("focus and textInput", () => {
240
+ test("focusable prop marks node and click focus targets innermost", () => {
241
+ const { ctx } = build(() => {
242
+ ui.box({ width: 50, height: 50, focusable: true });
243
+ });
244
+ const [box] = ctx.tree.childrenOf(ctx.tree.root);
245
+ expect(ctx.tree.getProp(box!, Prop.Focusable)).toBe(1);
246
+ expect(ctx.tree.getProp(box!, Prop.Hittable)).toBe(1);
247
+ ctx.setFocused(box!);
248
+ expect(ctx.focusedId()).toBe(box!);
249
+ });
250
+
251
+ test("textInput edits through its key handler", () => {
252
+ const [value, setValue] = signal("");
253
+ let submitted = "";
254
+ const { ctx } = build(() => {
255
+ textInput({
256
+ value,
257
+ onInput: setValue,
258
+ onSubmit: (v) => {
259
+ submitted = v;
260
+ },
261
+ autofocus: true,
262
+ });
263
+ });
264
+ const inputId = ctx.focusedId();
265
+ expect(inputId).toBeGreaterThan(0);
266
+ const key = ctx.handlers.get(inputId)!.onKeyDown!;
267
+
268
+ key({ keyCode: 4, modifiers: 0, isRepeat: false }); // h
269
+ key({ keyCode: 34, modifiers: 0, isRepeat: false }); // i
270
+ expect(value()).toBe("hi");
271
+
272
+ key({ keyCode: Key.Backspace, modifiers: 0, isRepeat: false });
273
+ expect(value()).toBe("h");
274
+
275
+ expect(
276
+ key({ keyCode: Key.Down, modifiers: 0, isRepeat: false }),
277
+ ).toBe(false); // passes through for list navigation
278
+
279
+ key({ keyCode: Key.Return, modifiers: 0, isRepeat: false });
280
+ expect(submitted).toBe("h");
281
+ });
282
+
283
+ test("unfocused input ignores nothing but stays inert visually", () => {
284
+ const [value, setValue] = signal("seed");
285
+ const { ctx } = build(() => {
286
+ textInput({ value, onInput: setValue });
287
+ });
288
+ expect(ctx.focusedId()).toBe(0);
289
+ // The value renders in the caret-split spans regardless of focus.
290
+ const texts: string[] = [];
291
+ const walk = (id: number) => {
292
+ if (ctx.tree.isTextNode(id)) texts.push(ctx.tree.getText(id));
293
+ for (const c of ctx.tree.childrenOf(id)) walk(c);
294
+ };
295
+ walk(ctx.tree.root);
296
+ expect(texts.join("")).toContain("seed");
297
+ });
298
+ });
@@ -0,0 +1,96 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { NodeKind, Prop, UiTree } from "../tree";
3
+
4
+ describe("retained tree", () => {
5
+ test("append and traverse siblings in order", () => {
6
+ const tree = new UiTree();
7
+ const a = tree.createNode(NodeKind.Box);
8
+ const b = tree.createNode(NodeKind.Box);
9
+ const c = tree.createNode(NodeKind.Box);
10
+ tree.append(tree.root, a);
11
+ tree.append(tree.root, b);
12
+ tree.append(tree.root, c);
13
+ expect(tree.childrenOf(tree.root)).toEqual([a, b, c]);
14
+ expect(tree.firstChildOf(tree.root)).toBe(a);
15
+ expect(tree.nextSiblingOf(a)).toBe(b);
16
+ expect(tree.parentOf(c)).toBe(tree.root);
17
+ });
18
+
19
+ test("insertBefore an anchor", () => {
20
+ const tree = new UiTree();
21
+ const a = tree.createNode(NodeKind.Box);
22
+ const b = tree.createNode(NodeKind.Box);
23
+ const c = tree.createNode(NodeKind.Box);
24
+ tree.append(tree.root, a);
25
+ tree.append(tree.root, c);
26
+ tree.insertBefore(tree.root, b, c);
27
+ expect(tree.childrenOf(tree.root)).toEqual([a, b, c]);
28
+ });
29
+
30
+ test("insertBefore the first child updates first pointer", () => {
31
+ const tree = new UiTree();
32
+ const a = tree.createNode(NodeKind.Box);
33
+ const b = tree.createNode(NodeKind.Box);
34
+ tree.append(tree.root, a);
35
+ tree.insertBefore(tree.root, b, a);
36
+ expect(tree.childrenOf(tree.root)).toEqual([b, a]);
37
+ });
38
+
39
+ test("re-parenting detaches from the old parent", () => {
40
+ const tree = new UiTree();
41
+ const parentA = tree.createNode(NodeKind.Box);
42
+ const parentB = tree.createNode(NodeKind.Box);
43
+ const child = tree.createNode(NodeKind.Box);
44
+ tree.append(tree.root, parentA);
45
+ tree.append(tree.root, parentB);
46
+ tree.append(parentA, child);
47
+ tree.append(parentB, child);
48
+ expect(tree.childrenOf(parentA)).toEqual([]);
49
+ expect(tree.childrenOf(parentB)).toEqual([child]);
50
+ });
51
+
52
+ test("destroy frees the whole subtree", () => {
53
+ const tree = new UiTree();
54
+ const parent = tree.createNode(NodeKind.Box);
55
+ const child = tree.createNode(NodeKind.Box);
56
+ const grandchild = tree.createTextNode("hi");
57
+ tree.append(tree.root, parent);
58
+ tree.append(parent, child);
59
+ tree.append(child, grandchild);
60
+ const before = tree.size;
61
+ tree.destroy(parent);
62
+ expect(tree.size).toBe(before - 3);
63
+ expect(tree.has(parent)).toBe(false);
64
+ expect(tree.has(grandchild)).toBe(false);
65
+ expect(tree.childrenOf(tree.root)).toEqual([]);
66
+ });
67
+
68
+ test("props only dirty the tree when they change", () => {
69
+ const tree = new UiTree();
70
+ const box = tree.createNode(NodeKind.Box);
71
+ tree.append(tree.root, box);
72
+ tree.takeDirty();
73
+ tree.setProp(box, Prop.Gap, 8);
74
+ expect(tree.takeDirty()).toBe(true);
75
+ tree.setProp(box, Prop.Gap, 8);
76
+ expect(tree.takeDirty()).toBe(false);
77
+ });
78
+
79
+ test("text updates dirty the tree", () => {
80
+ const tree = new UiTree();
81
+ const label = tree.createTextNode("a");
82
+ tree.append(tree.root, label);
83
+ tree.takeDirty();
84
+ tree.setText(label, "b");
85
+ expect(tree.takeDirty()).toBe(true);
86
+ tree.setText(label, "b");
87
+ expect(tree.takeDirty()).toBe(false);
88
+ });
89
+
90
+ test("guards: root cannot be destroyed or re-parented", () => {
91
+ const tree = new UiTree();
92
+ expect(() => tree.destroy(tree.root)).toThrow();
93
+ const box = tree.createNode(NodeKind.Box);
94
+ expect(() => tree.insertBefore(box, tree.root)).toThrow();
95
+ });
96
+ });
@@ -0,0 +1,170 @@
1
+ // Headless integration: the builder API driving the retained tree through
2
+ // signals — the whole runtime except the GPU and the window.
3
+
4
+ import { describe, expect, test } from "bun:test";
5
+ import { createRoot, live, signal, store } from "../reactive";
6
+ import { Prop } from "../tree";
7
+ import { computeLayout } from "../layout";
8
+ import { hitChain } from "../hit";
9
+ import { parseColor } from "../paint";
10
+ import { createUiContext, ui, withUiContext, type UiContext } from "../ui";
11
+
12
+ function build(app: () => void): {
13
+ ctx: UiContext;
14
+ dispose: () => void;
15
+ } {
16
+ const ctx = createUiContext();
17
+ let dispose = () => {};
18
+ createRoot((d) => {
19
+ dispose = d;
20
+ withUiContext(ctx, app);
21
+ });
22
+ return { ctx, dispose };
23
+ }
24
+
25
+ describe("builder API", () => {
26
+ test("children nest under the declaring parent", () => {
27
+ const { ctx } = build(() => {
28
+ ui.column({}, () => {
29
+ ui.text("a");
30
+ ui.text("b");
31
+ });
32
+ });
33
+ const [col] = ctx.tree.childrenOf(ctx.tree.root);
34
+ const kids = ctx.tree.childrenOf(col!);
35
+ expect(kids.length).toBe(2);
36
+ expect(ctx.tree.getText(kids[0]!)).toBe("a");
37
+ expect(ctx.tree.getText(kids[1]!)).toBe("b");
38
+ });
39
+
40
+ test("thunk props become fine-grained effects on one tree prop", () => {
41
+ const [bg, setBg] = signal("#111111");
42
+ const { ctx } = build(() => {
43
+ ui.box({ bg: live(bg) });
44
+ });
45
+ const [box] = ctx.tree.childrenOf(ctx.tree.root);
46
+ expect(ctx.tree.getProp(box!, Prop.Bg)).toBe(parseColor("#111111"));
47
+ ctx.tree.takeDirty();
48
+ setBg("#222222");
49
+ expect(ctx.tree.getProp(box!, Prop.Bg)).toBe(parseColor("#222222"));
50
+ expect(ctx.tree.takeDirty()).toBe(true);
51
+ });
52
+
53
+ test("reactive text updates the text node", () => {
54
+ const [count, setCount] = signal(0);
55
+ const { ctx } = build(() => {
56
+ ui.text(live(() => `Count: ${count()}`));
57
+ });
58
+ const [label] = ctx.tree.childrenOf(ctx.tree.root);
59
+ expect(ctx.tree.getText(label!)).toBe("Count: 0");
60
+ setCount(5);
61
+ expect(ctx.tree.getText(label!)).toBe("Count: 5");
62
+ });
63
+
64
+ test("bare functions in value props throw loudly", () => {
65
+ const [bg] = signal("#111111");
66
+ expect(() =>
67
+ build(() => {
68
+ ui.box({ bg: bg as any });
69
+ }),
70
+ ).toThrow(/live\(/);
71
+ expect(() =>
72
+ build(() => {
73
+ ui.text((() => "nope") as any);
74
+ }),
75
+ ).toThrow(/live\(/);
76
+ });
77
+
78
+ test("reactive text updates via marker", () => {
79
+ const [count, setCount] = signal(0);
80
+ const { ctx } = build(() => {
81
+ ui.text(live(() => `Count: ${count()}`));
82
+ });
83
+ const [label] = ctx.tree.childrenOf(ctx.tree.root);
84
+ expect(ctx.tree.getText(label!)).toBe("Count: 0");
85
+ setCount(5);
86
+ expect(ctx.tree.getText(label!)).toBe("Count: 5");
87
+ });
88
+
89
+ test("handlers register, mark hittable, and unregister on dispose", () => {
90
+ let clicks = 0;
91
+ const { ctx, dispose } = build(() => {
92
+ ui.box({
93
+ width: 50,
94
+ height: 50,
95
+ onClick: () => clicks++,
96
+ });
97
+ });
98
+ const [box] = ctx.tree.childrenOf(ctx.tree.root);
99
+ expect(ctx.tree.getProp(box!, Prop.Hittable)).toBe(1);
100
+ computeLayout(ctx.tree, 100, 100);
101
+ expect(hitChain(ctx.tree, 10, 10)).toEqual([box!]);
102
+ ctx.handlers.get(box!)!.onClick!({ x: 10, y: 10, target: box! });
103
+ expect(clicks).toBe(1);
104
+ dispose();
105
+ expect(ctx.handlers.size).toBe(0);
106
+ });
107
+
108
+ test("dynamic regions rebuild when store state changes", () => {
109
+ const [state, setState] = store({
110
+ items: [] as string[],
111
+ });
112
+ const { ctx } = build(() => {
113
+ ui.dynamic({ dir: "column" }, () => {
114
+ for (const item of state.items) {
115
+ ui.text(item);
116
+ }
117
+ });
118
+ });
119
+ const [region] = ctx.tree.childrenOf(ctx.tree.root);
120
+ expect(ctx.tree.childrenOf(region!).length).toBe(0);
121
+
122
+ setState(((s) => s.items.push("first")));
123
+ expect(ctx.tree.childrenOf(region!).length).toBe(1);
124
+
125
+ setState(((s) => s.items.push("second")));
126
+ const kids = ctx.tree.childrenOf(region!);
127
+ expect(kids.length).toBe(2);
128
+ expect(ctx.tree.getText(kids[0]!)).toBe("first");
129
+ expect(ctx.tree.getText(kids[1]!)).toBe("second");
130
+ });
131
+
132
+ test("dynamic rebuilds dispose stale handlers and nodes", () => {
133
+ const [show, setShow] = signal(true);
134
+ const { ctx } = build(() => {
135
+ ui.dynamic({}, () => {
136
+ if (show()) {
137
+ ui.box({ width: 10, height: 10, onClick: () => {} });
138
+ }
139
+ });
140
+ });
141
+ expect(ctx.handlers.size).toBe(1);
142
+ const sizeWithButton = ctx.tree.size;
143
+ setShow(false);
144
+ expect(ctx.handlers.size).toBe(0);
145
+ expect(ctx.tree.size).toBe(sizeWithButton - 1);
146
+ setShow(true);
147
+ expect(ctx.handlers.size).toBe(1);
148
+ });
149
+
150
+ test("anchors report through the anchor registry", () => {
151
+ const frames: Array<{ width: number }> = [];
152
+ const { ctx } = build(() => {
153
+ ui.anchor({
154
+ grow: 1,
155
+ onFrame: (rect) => frames.push({ width: rect.width }),
156
+ });
157
+ });
158
+ expect(ctx.anchors.size).toBe(1);
159
+ const [anchorId] = ctx.tree.childrenOf(ctx.tree.root);
160
+ computeLayout(ctx.tree, 300, 100);
161
+ const node = ctx.tree.get(anchorId!);
162
+ ctx.anchors.get(anchorId!)!({
163
+ x: node.x,
164
+ y: node.y,
165
+ width: node.w,
166
+ height: node.h,
167
+ });
168
+ expect(frames).toEqual([{ width: 300 }]);
169
+ });
170
+ });
@@ -0,0 +1,135 @@
1
+ // Native-layer elements: the UIWindow equivalents of <electrobun-wgpu> and
2
+ // <electrobun-webview>. Each is an anchor node in the retained tree — the UI
3
+ // layout decides where it goes, the anchor reports its rect, and nativeWrapper
4
+ // composites the real native surface there. Lifecycle follows the reactive
5
+ // scope: removed nodes tear down their native views.
6
+
7
+ import { WGPUView } from "../core/WGPUView";
8
+ import { BrowserView } from "../core/BrowserView";
9
+ import { ffi } from "../proc/native";
10
+ import { cleanup, inert } from "./reactive";
11
+ import { getUiContext, ui, type AnchorRect, type Reactive } from "./ui";
12
+
13
+ export interface WgpuSurfaceProps {
14
+ width?: Reactive<number>;
15
+ height?: Reactive<number>;
16
+ grow?: Reactive<number>;
17
+ transparent?: boolean;
18
+ /** Called once, after layout first places the surface. */
19
+ onReady?: (view: WGPUView) => void;
20
+ /** Called on every subsequent layout move/resize. */
21
+ onFrame?: (view: WGPUView, rect: AnchorRect) => void;
22
+ }
23
+
24
+ /**
25
+ * A native Dawn surface positioned by the UI layout — the `<electrobun-wgpu>`
26
+ * equivalent. The view is created lazily on first layout so it never exists
27
+ * at a zero-size frame.
28
+ */
29
+ export function wgpuSurface(props: WgpuSurfaceProps): number {
30
+ const ctx = getUiContext();
31
+ if (ctx.windowId === 0) {
32
+ throw new Error("wgpuSurface requires a mounted UIWindow (no windowId)");
33
+ }
34
+ const windowId = ctx.windowId;
35
+ let view: WGPUView | null = null;
36
+
37
+ const id = ui.anchor({
38
+ width: props.width,
39
+ height: props.height,
40
+ grow: props.grow,
41
+ onFrame: (rect) => {
42
+ if (rect.width <= 0 || rect.height <= 0) return;
43
+ if (!view) {
44
+ view = new WGPUView({
45
+ windowId,
46
+ frame: {
47
+ x: rect.x,
48
+ y: rect.y,
49
+ width: rect.width,
50
+ height: rect.height,
51
+ },
52
+ autoResize: false,
53
+ startTransparent: props.transparent ?? false,
54
+ startPassthrough: false,
55
+ });
56
+ inert(() => props.onReady?.(view!));
57
+ } else {
58
+ view.setFrame(rect.x, rect.y, rect.width, rect.height);
59
+ inert(() => props.onFrame?.(view!, rect));
60
+ }
61
+ },
62
+ });
63
+
64
+ cleanup(() => {
65
+ view?.remove();
66
+ view = null;
67
+ });
68
+ return id;
69
+ }
70
+
71
+ export interface WebviewElementProps {
72
+ url?: string;
73
+ html?: string;
74
+ width?: Reactive<number>;
75
+ height?: Reactive<number>;
76
+ grow?: Reactive<number>;
77
+ partition?: string;
78
+ sandbox?: boolean;
79
+ onReady?: (view: BrowserView) => void;
80
+ }
81
+
82
+ /**
83
+ * An out-of-process webview positioned by the UI layout — the
84
+ * `<electrobun-webview>` equivalent. Runs in the OOPIF-style webview
85
+ * infrastructure (own process for CEF, own WKWebView otherwise); the UI tree
86
+ * only owns its rectangle.
87
+ */
88
+ export function webview(props: WebviewElementProps): number {
89
+ const ctx = getUiContext();
90
+ if (ctx.windowId === 0) {
91
+ throw new Error("webview requires a mounted UIWindow (no windowId)");
92
+ }
93
+ const windowId = ctx.windowId;
94
+ let view: BrowserView | null = null;
95
+
96
+ const id = ui.anchor({
97
+ width: props.width,
98
+ height: props.height,
99
+ grow: props.grow,
100
+ onFrame: (rect) => {
101
+ if (rect.width <= 0 || rect.height <= 0) return;
102
+ if (!view) {
103
+ view = new BrowserView({
104
+ windowId,
105
+ url: props.url ?? null,
106
+ html: props.html ?? null,
107
+ partition: props.partition,
108
+ sandbox: props.sandbox ?? false,
109
+ autoResize: false,
110
+ frame: {
111
+ x: rect.x,
112
+ y: rect.y,
113
+ width: rect.width,
114
+ height: rect.height,
115
+ },
116
+ });
117
+ inert(() => props.onReady?.(view!));
118
+ } else {
119
+ view.frame = {
120
+ x: rect.x,
121
+ y: rect.y,
122
+ width: rect.width,
123
+ height: rect.height,
124
+ };
125
+ ffi.request.resizeWebview({ id: view.id, frame: view.frame });
126
+ }
127
+ },
128
+ });
129
+
130
+ cleanup(() => {
131
+ view?.remove();
132
+ view = null;
133
+ });
134
+ return id;
135
+ }