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,457 @@
1
+ // Builder-style UI API over the retained tree. Components are plain
2
+ // functions; children are declared in a callback so code indents like
3
+ // markup; any prop can be a thunk, which becomes its own fine-grained
4
+ // effect updating exactly one tree prop.
5
+ //
6
+ // ui.column({ pad: 24, gap: 12, bg: "#16161e" }, () => {
7
+ // ui.text(() => `Count: ${count()}`, { size: 42, color: accent });
8
+ // ui.row({ gap: 8 }, () => { ... });
9
+ // });
10
+
11
+ import {
12
+ claimLive,
13
+ cleanup,
14
+ createChildScope,
15
+ disposeScope,
16
+ getOwner,
17
+ inert,
18
+ isLive,
19
+ liveScope,
20
+ runWithOwner,
21
+ signal,
22
+ type Accessor,
23
+ type LiveBinding,
24
+ type Reactive,
25
+ } from "./reactive";
26
+ import { NodeKind, Prop, UiTree } from "./tree";
27
+ import { Align, Justify } from "./layout";
28
+ import { parseColor } from "./paint";
29
+
30
+ export type { Reactive };
31
+
32
+ function bareFunctionError(): never {
33
+ throw new Error(
34
+ 'Warren: a value prop received a bare function. Wrap reactive expressions with live(() => ...) from "electrobun/main/ui", or pass a plain value.',
35
+ );
36
+ }
37
+
38
+ export interface PointerEventInfo {
39
+ x: number;
40
+ y: number;
41
+ target: number;
42
+ }
43
+
44
+ export interface KeyEventInfo {
45
+ keyCode: number;
46
+ modifiers: number;
47
+ isRepeat: boolean;
48
+ /** Characters produced by the keyboard layout (native key events only). */
49
+ chars?: string;
50
+ }
51
+
52
+ export interface Handlers {
53
+ onClick?: (e: PointerEventInfo) => void;
54
+ onPointerDown?: (e: PointerEventInfo) => void;
55
+ onPointerUp?: (e: PointerEventInfo) => void;
56
+ onPointerEnter?: (e: PointerEventInfo) => void;
57
+ onPointerLeave?: (e: PointerEventInfo) => void;
58
+ /** Keys route to the focused node first; return true to stop bubbling. */
59
+ onKeyDown?: (e: KeyEventInfo) => boolean | void;
60
+ }
61
+
62
+ export interface BoxProps extends Handlers {
63
+ dir?: "row" | "column";
64
+ gap?: Reactive<number>;
65
+ pad?: Reactive<number>;
66
+ width?: Reactive<number>;
67
+ height?: Reactive<number>;
68
+ grow?: Reactive<number>;
69
+ justify?: "start" | "center" | "end" | "between";
70
+ align?: "start" | "center" | "end" | "stretch";
71
+ bg?: Reactive<string | number>;
72
+ radius?: Reactive<number>;
73
+ border?: Reactive<number>;
74
+ borderColor?: Reactive<string | number>;
75
+ /** "scroll" clips children and offsets them by scroll on the main axis. */
76
+ overflow?: "visible" | "scroll";
77
+ scroll?: Reactive<number>;
78
+ /** Focusable nodes receive clicks-to-focus and focused key routing. */
79
+ focusable?: boolean;
80
+ /**
81
+ * Pressing and dragging this node moves the host window (frameless
82
+ * windows). A press without movement still delivers onClick.
83
+ */
84
+ windowDrag?: boolean;
85
+ }
86
+
87
+ export interface TextProps {
88
+ size?: Reactive<number>;
89
+ color?: Reactive<string | number>;
90
+ }
91
+
92
+ export interface AnchorRect {
93
+ x: number;
94
+ y: number;
95
+ width: number;
96
+ height: number;
97
+ }
98
+
99
+ export interface AnchorProps {
100
+ width?: Reactive<number>;
101
+ height?: Reactive<number>;
102
+ grow?: Reactive<number>;
103
+ /** Called with the anchor's absolute rect whenever layout moves it. */
104
+ onFrame: (rect: AnchorRect) => void;
105
+ }
106
+
107
+ export interface UiContext {
108
+ tree: UiTree;
109
+ handlers: Map<number, Handlers>;
110
+ anchors: Map<number, (rect: AnchorRect) => void>;
111
+ keyHandlers: Set<(e: KeyEventInfo) => void>;
112
+ parentStack: number[];
113
+ /** Native window hosting this tree; 0 when headless (tests). */
114
+ windowId: number;
115
+ /** Reactive id of the focused node (0 = none). */
116
+ focusedId: Accessor<number>;
117
+ setFocused(id: number): void;
118
+ /** Reactive id of the hovered node (0 = none) — set by the input driver. */
119
+ hoveredId: Accessor<number>;
120
+ setHovered(id: number): void;
121
+ }
122
+
123
+ export function createUiContext(tree = new UiTree()): UiContext {
124
+ const [focusedId, setFocusedId] = signal(0);
125
+ const [hoveredId, setHoveredId] = signal(0);
126
+ return {
127
+ tree,
128
+ handlers: new Map(),
129
+ anchors: new Map(),
130
+ keyHandlers: new Set(),
131
+ parentStack: [tree.root],
132
+ windowId: 0,
133
+ focusedId,
134
+ setFocused: (id: number) => setFocusedId(id),
135
+ hoveredId,
136
+ setHovered: (id: number) => setHoveredId(id),
137
+ };
138
+ }
139
+
140
+ let current: UiContext | null = null;
141
+
142
+ export function withUiContext<T>(ctx: UiContext, fn: () => T): T {
143
+ const prev = current;
144
+ current = ctx;
145
+ try {
146
+ return fn();
147
+ } finally {
148
+ current = prev;
149
+ }
150
+ }
151
+
152
+ function requireCtx(): UiContext {
153
+ if (!current) {
154
+ throw new Error(
155
+ "No active UI context: build nodes inside createUIWindow(...) or withUiContext(...)",
156
+ );
157
+ }
158
+ return current;
159
+ }
160
+
161
+ /** The active build context — for element libraries layered on the core. */
162
+ export function getUiContext(): UiContext {
163
+ return requireCtx();
164
+ }
165
+
166
+ /** Unwrap a Reactive<T> — live binding or plain value. */
167
+ export function read<T>(value: Reactive<T>): T {
168
+ if (isLive(value)) {
169
+ (value as LiveBinding<T>).claimed = true;
170
+ return (value as LiveBinding<T>).fn();
171
+ }
172
+ if (typeof value === "function") bareFunctionError();
173
+ return value as T;
174
+ }
175
+
176
+ /** Run `fn` with `parent` as the attachment point for created nodes. */
177
+ function withParent<T>(ctx: UiContext, parent: number, fn: () => T): T {
178
+ ctx.parentStack.push(parent);
179
+ try {
180
+ return fn();
181
+ } finally {
182
+ ctx.parentStack.pop();
183
+ }
184
+ }
185
+
186
+ const JUSTIFY: Record<string, Justify> = {
187
+ start: Justify.Start,
188
+ center: Justify.Center,
189
+ end: Justify.End,
190
+ between: Justify.SpaceBetween,
191
+ };
192
+
193
+ const ALIGN: Record<string, Align> = {
194
+ start: Align.Start,
195
+ center: Align.Center,
196
+ end: Align.End,
197
+ stretch: Align.Stretch,
198
+ };
199
+
200
+ function applyNumber(
201
+ ctx: UiContext,
202
+ id: number,
203
+ prop: Prop,
204
+ value: Reactive<number> | undefined,
205
+ ): void {
206
+ if (value === undefined) return;
207
+ if (isLive(value)) {
208
+ claimLive(value as LiveBinding<number>, (v) =>
209
+ ctx.tree.setProp(id, prop, v),
210
+ );
211
+ } else if (typeof value === "function") {
212
+ bareFunctionError();
213
+ } else {
214
+ ctx.tree.setProp(id, prop, value);
215
+ }
216
+ }
217
+
218
+ function applyColor(
219
+ ctx: UiContext,
220
+ id: number,
221
+ prop: Prop,
222
+ value: Reactive<string | number> | undefined,
223
+ ): void {
224
+ if (value === undefined) return;
225
+ if (isLive(value)) {
226
+ claimLive(value as LiveBinding<string | number>, (v) =>
227
+ ctx.tree.setProp(id, prop, parseColor(v)),
228
+ );
229
+ } else if (typeof value === "function") {
230
+ bareFunctionError();
231
+ } else {
232
+ ctx.tree.setProp(id, prop, parseColor(value));
233
+ }
234
+ }
235
+
236
+ function attachToParent(ctx: UiContext, id: number): void {
237
+ const parent = ctx.parentStack[ctx.parentStack.length - 1]!;
238
+ ctx.tree.append(parent, id);
239
+ }
240
+
241
+ const HANDLER_KEYS = [
242
+ "onClick",
243
+ "onPointerDown",
244
+ "onPointerUp",
245
+ "onPointerEnter",
246
+ "onPointerLeave",
247
+ "onKeyDown",
248
+ ] as const;
249
+
250
+ function registerHandlers(ctx: UiContext, id: number, props: Handlers): void {
251
+ if (!HANDLER_KEYS.some((key) => props[key])) return;
252
+ ctx.tree.setProp(id, Prop.Hittable, 1);
253
+ // Handlers is a structural subset of the props object; the map is only
254
+ // ever read by handler key.
255
+ ctx.handlers.set(id, props);
256
+ cleanup(() => ctx.handlers.delete(id));
257
+ }
258
+
259
+ function box(props: BoxProps, children?: () => void): number {
260
+ const ctx = requireCtx();
261
+ const id = ctx.tree.createNode(NodeKind.Box);
262
+ if (props.dir === "column") ctx.tree.setProp(id, Prop.Dir, 1);
263
+ if (props.justify) ctx.tree.setProp(id, Prop.Justify, JUSTIFY[props.justify]!);
264
+ if (props.align) ctx.tree.setProp(id, Prop.Align, ALIGN[props.align]!);
265
+ applyNumber(ctx, id, Prop.Gap, props.gap);
266
+ applyNumber(ctx, id, Prop.Pad, props.pad);
267
+ applyNumber(ctx, id, Prop.Width, props.width);
268
+ applyNumber(ctx, id, Prop.Height, props.height);
269
+ applyNumber(ctx, id, Prop.Grow, props.grow);
270
+ applyNumber(ctx, id, Prop.Radius, props.radius);
271
+ applyNumber(ctx, id, Prop.BorderWidth, props.border);
272
+ applyColor(ctx, id, Prop.Bg, props.bg);
273
+ applyColor(ctx, id, Prop.BorderColor, props.borderColor);
274
+ if (props.overflow === "scroll") ctx.tree.setProp(id, Prop.Overflow, 1);
275
+ applyNumber(ctx, id, Prop.Scroll, props.scroll);
276
+ if (props.windowDrag) {
277
+ ctx.tree.setProp(id, Prop.WindowDrag, 1);
278
+ ctx.tree.setProp(id, Prop.Hittable, 1);
279
+ }
280
+ if (props.focusable) {
281
+ ctx.tree.setProp(id, Prop.Focusable, 1);
282
+ ctx.tree.setProp(id, Prop.Hittable, 1);
283
+ // Focus lifecycle belongs with the focusable flag: never leave a
284
+ // destroyed node focused.
285
+ cleanup(() => {
286
+ if (inert(ctx.focusedId) === id) ctx.setFocused(0);
287
+ });
288
+ }
289
+ registerHandlers(ctx, id, props);
290
+ attachToParent(ctx, id);
291
+ if (children) withParent(ctx, id, children);
292
+ return id;
293
+ }
294
+
295
+ function row(props: BoxProps = {}, children?: () => void): number {
296
+ return box({ ...props, dir: "row" }, children);
297
+ }
298
+
299
+ function column(props: BoxProps = {}, children?: () => void): number {
300
+ return box({ ...props, dir: "column" }, children);
301
+ }
302
+
303
+ function text(
304
+ content: Reactive<string | number>,
305
+ props: TextProps = {},
306
+ ): number {
307
+ const ctx = requireCtx();
308
+ const id = ctx.tree.createTextNode("");
309
+ if (isLive(content)) {
310
+ claimLive(content as LiveBinding<string | number>, (v) =>
311
+ ctx.tree.setText(id, String(v)),
312
+ );
313
+ } else if (typeof content === "function") {
314
+ bareFunctionError();
315
+ } else {
316
+ ctx.tree.setText(id, String(content));
317
+ }
318
+ applyNumber(ctx, id, Prop.FontSize, props.size);
319
+ applyColor(ctx, id, Prop.TextColor, props.color);
320
+ attachToParent(ctx, id);
321
+ return id;
322
+ }
323
+
324
+ /** Fixed or growing empty space along the parent's main axis. */
325
+ function spacer(grow: Reactive<number> = 1): number {
326
+ return box({ grow });
327
+ }
328
+
329
+ /**
330
+ * Anchor for a native layer (webview, wgpu view): occupies layout space and
331
+ * reports its rect so nativeWrapper can composite the real surface over it.
332
+ */
333
+ function anchor(props: AnchorProps): number {
334
+ const ctx = requireCtx();
335
+ const id = ctx.tree.createNode(NodeKind.Anchor);
336
+ applyNumber(ctx, id, Prop.Width, props.width);
337
+ applyNumber(ctx, id, Prop.Height, props.height);
338
+ applyNumber(ctx, id, Prop.Grow, props.grow);
339
+ ctx.anchors.set(id, props.onFrame);
340
+ cleanup(() => ctx.anchors.delete(id));
341
+ attachToParent(ctx, id);
342
+ return id;
343
+ }
344
+
345
+ /**
346
+ * Reactive region: the builder re-runs (and the subtree rebuilds) whenever a
347
+ * signal it reads changes. Prop-level updates elsewhere stay fine-grained;
348
+ * this is the coarse escape hatch for lists and conditionals.
349
+ */
350
+ function dynamic(props: BoxProps, builder: () => void): number {
351
+ const ctx = requireCtx();
352
+ const id = box(props);
353
+ liveScope(() => {
354
+ ctx.tree.destroyChildren(id);
355
+ withParent(ctx, id, () => withUiContext(ctx, builder));
356
+ });
357
+ return id;
358
+ }
359
+
360
+ /** Register a window-level key handler (removed on owner disposal). */
361
+ export function onKey(handler: (e: KeyEventInfo) => void): void {
362
+ const ctx = requireCtx();
363
+ ctx.keyHandlers.add(handler);
364
+ cleanup(() => ctx.keyHandlers.delete(handler));
365
+ }
366
+
367
+ interface EachEntry {
368
+ scope: unknown;
369
+ node: number;
370
+ setIndex: (i: number) => void;
371
+ }
372
+
373
+ /**
374
+ * Keyed list region: items are diffed by key, so unchanged rows keep their
375
+ * subtree (and per-row state) across filters and reorders. Each row renders
376
+ * inside its own container box; `render` receives the item and a reactive
377
+ * index accessor.
378
+ */
379
+ function each<T>(
380
+ props: BoxProps,
381
+ items: Accessor<readonly T[]>,
382
+ key: (item: T, index: number) => string | number,
383
+ render: (item: T, index: Accessor<number>) => void,
384
+ ): number {
385
+ const ctx = requireCtx();
386
+ const regionId = box(props);
387
+ const hostOwner = getOwner();
388
+ if (!hostOwner) {
389
+ throw new Error("ui.each must be created inside a reactive root");
390
+ }
391
+ // Row scopes parent under the host scope (not the diff scope), so rows
392
+ // survive re-runs and are torn down with the region.
393
+ const entries = new Map<string | number, EachEntry>();
394
+
395
+ liveScope(() => {
396
+ const list = items();
397
+ const seen = new Set<string | number>();
398
+ let prevNode = 0;
399
+
400
+ for (let i = 0; i < list.length; i++) {
401
+ const item = list[i]!;
402
+ const k = key(item, i);
403
+ if (seen.has(k)) {
404
+ throw new Error(`ui.each: duplicate key "${String(k)}"`);
405
+ }
406
+ seen.add(k);
407
+
408
+ let entry = entries.get(k);
409
+ if (!entry) {
410
+ const scope = createChildScope(hostOwner);
411
+ const [index, setIndex] = signal(i);
412
+ // runWithOwner suspends tracking, so the diff scope never
413
+ // tracks reads made while building a row.
414
+ const node = runWithOwner(scope, () =>
415
+ withUiContext(ctx, () =>
416
+ withParent(ctx, regionId, () => box({}, () => render(item, index))),
417
+ ),
418
+ );
419
+ entry = { scope, node, setIndex };
420
+ entries.set(k, entry);
421
+ } else {
422
+ entry.setIndex(i);
423
+ }
424
+
425
+ // Keep sibling order in sync with item order.
426
+ const anchorNode = prevNode === 0
427
+ ? ctx.tree.firstChildOf(regionId)
428
+ : ctx.tree.nextSiblingOf(prevNode);
429
+ if (anchorNode !== entry.node) {
430
+ ctx.tree.insertBefore(regionId, entry.node, anchorNode);
431
+ }
432
+ prevNode = entry.node;
433
+ }
434
+
435
+ // Remove rows whose keys are gone.
436
+ for (const [k, entry] of entries) {
437
+ if (!seen.has(k)) {
438
+ disposeScope(entry.scope);
439
+ if (ctx.tree.has(entry.node)) ctx.tree.destroy(entry.node);
440
+ entries.delete(k);
441
+ }
442
+ }
443
+ });
444
+
445
+ return regionId;
446
+ }
447
+
448
+ export const ui = {
449
+ box,
450
+ row,
451
+ column,
452
+ text,
453
+ spacer,
454
+ anchor,
455
+ dynamic,
456
+ each,
457
+ };
@@ -0,0 +1,56 @@
1
+ // Host side of <electrobun-ui>: a webview places the tag; nativeWrapper
2
+ // composites a Dawn view over it; and the app mounts a named reactive UI
3
+ // tree into that view from the trusted main process.
4
+
5
+ import electrobunEventEmitter from "../events/eventEmitter";
6
+ import { WGPUView } from "../core/WGPUView";
7
+ import { createUIView, type UIApp, type UIMountOptions, type UIView } from "./uiwindow";
8
+
9
+ export interface UIRootRegistration {
10
+ /** Stop accepting new mounts and dispose all live ones. */
11
+ dispose(): void;
12
+ }
13
+
14
+ /**
15
+ * Mount `app` into every <electrobun-ui name="..."> tag with this name.
16
+ * Each tag instance gets its own tree (the builder runs per mount). Views
17
+ * removed by the tag (element disconnected) are detected and disposed.
18
+ */
19
+ export function registerUIRoot(
20
+ name: string,
21
+ options: UIMountOptions,
22
+ app: UIApp,
23
+ ): UIRootRegistration {
24
+ const live = new Set<{ mounted: UIView; watch: ReturnType<typeof setInterval> }>();
25
+
26
+ const onMount = async (params: { id: number; name: string }) => {
27
+ if ((params?.name ?? "") !== name) return;
28
+ const view = WGPUView.getById(params.id);
29
+ if (!view || view.isRemoved) return;
30
+ const mounted = await createUIView(view, options, app);
31
+ const entry = {
32
+ mounted,
33
+ watch: setInterval(() => {
34
+ if (view.isRemoved) {
35
+ clearInterval(entry.watch);
36
+ live.delete(entry);
37
+ mounted.dispose();
38
+ }
39
+ }, 250),
40
+ };
41
+ live.add(entry);
42
+ };
43
+
44
+ electrobunEventEmitter.on("ui-tag-mount", onMount);
45
+
46
+ return {
47
+ dispose() {
48
+ electrobunEventEmitter.off("ui-tag-mount", onMount);
49
+ for (const entry of live) {
50
+ clearInterval(entry.watch);
51
+ entry.mounted.dispose();
52
+ }
53
+ live.clear();
54
+ },
55
+ };
56
+ }