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
|
@@ -757,6 +757,48 @@ export const native = (() => {
|
|
|
757
757
|
returns: FFIType.void,
|
|
758
758
|
},
|
|
759
759
|
|
|
760
|
+
wgpuViewSetAlphaBlending: {
|
|
761
|
+
args: [FFIType.ptr, FFIType.bool],
|
|
762
|
+
returns: FFIType.void,
|
|
763
|
+
},
|
|
764
|
+
|
|
765
|
+
setWGPUPointerHandler: {
|
|
766
|
+
args: [FFIType.ptr],
|
|
767
|
+
returns: FFIType.void,
|
|
768
|
+
},
|
|
769
|
+
|
|
770
|
+
setWGPUKeyHandler: {
|
|
771
|
+
args: [FFIType.ptr],
|
|
772
|
+
returns: FFIType.void,
|
|
773
|
+
},
|
|
774
|
+
|
|
775
|
+
uiMeasureText: {
|
|
776
|
+
args: [
|
|
777
|
+
FFIType.cstring,
|
|
778
|
+
FFIType.cstring,
|
|
779
|
+
FFIType.f64,
|
|
780
|
+
FFIType.ptr,
|
|
781
|
+
FFIType.ptr,
|
|
782
|
+
FFIType.ptr,
|
|
783
|
+
],
|
|
784
|
+
returns: FFIType.void,
|
|
785
|
+
},
|
|
786
|
+
uiRasterizeText: {
|
|
787
|
+
args: [
|
|
788
|
+
FFIType.cstring,
|
|
789
|
+
FFIType.cstring,
|
|
790
|
+
FFIType.f64,
|
|
791
|
+
FFIType.f64,
|
|
792
|
+
FFIType.ptr,
|
|
793
|
+
FFIType.ptr,
|
|
794
|
+
],
|
|
795
|
+
returns: FFIType.ptr,
|
|
796
|
+
},
|
|
797
|
+
uiFreeTextBitmap: {
|
|
798
|
+
args: [FFIType.ptr],
|
|
799
|
+
returns: FFIType.void,
|
|
800
|
+
},
|
|
801
|
+
|
|
760
802
|
loadURLInWebView: {
|
|
761
803
|
args: [FFIType.ptr, FFIType.cstring],
|
|
762
804
|
returns: FFIType.void,
|
|
@@ -1898,6 +1940,12 @@ const _ffiImpl = {
|
|
|
1898
1940
|
core_.symbols.setWGPUViewTransparent(params.id, params.transparent);
|
|
1899
1941
|
},
|
|
1900
1942
|
|
|
1943
|
+
wgpuViewSetAlphaBlending: (params: { id: number; enabled: boolean }) => {
|
|
1944
|
+
const ptr = core_.symbols.getWGPUViewPointer(params.id);
|
|
1945
|
+
if (!ptr) return;
|
|
1946
|
+
native_.symbols.wgpuViewSetAlphaBlending(ptr, params.enabled);
|
|
1947
|
+
},
|
|
1948
|
+
|
|
1901
1949
|
wgpuViewSetPassthrough: (params: {
|
|
1902
1950
|
id: number;
|
|
1903
1951
|
passthrough: boolean;
|
|
@@ -2482,6 +2530,134 @@ const windowBlurCallback = new JSCallback(
|
|
|
2482
2530
|
},
|
|
2483
2531
|
);
|
|
2484
2532
|
|
|
2533
|
+
// Native pointer events from WGPU views. Type: 0 move, 1 down, 2 up,
|
|
2534
|
+
// 3 wheel, 4 enter, 5 exit. Registered lazily by enableWGPUPointerEvents.
|
|
2535
|
+
const wgpuPointerCallback = new JSCallback(
|
|
2536
|
+
(viewId, type, x, y, buttonOrDx, dy, modifiers) => {
|
|
2537
|
+
electrobunEventEmitter.emit(`wgpu-pointer-${viewId}`, {
|
|
2538
|
+
type,
|
|
2539
|
+
x,
|
|
2540
|
+
y,
|
|
2541
|
+
buttonOrDx,
|
|
2542
|
+
dy,
|
|
2543
|
+
modifiers,
|
|
2544
|
+
});
|
|
2545
|
+
},
|
|
2546
|
+
{
|
|
2547
|
+
args: ["u32", "u32", "f64", "f64", "f64", "f64", "u32"],
|
|
2548
|
+
returns: "void",
|
|
2549
|
+
threadsafe: true,
|
|
2550
|
+
},
|
|
2551
|
+
);
|
|
2552
|
+
|
|
2553
|
+
// Key events from WGPU views carrying the layout-produced characters.
|
|
2554
|
+
const wgpuKeyCallback = new JSCallback(
|
|
2555
|
+
(viewId, keyCode, modifiers, isDown, isRepeat, charsPtr) => {
|
|
2556
|
+
const chars = charsPtr ? new CString(charsPtr).toString() : "";
|
|
2557
|
+
electrobunEventEmitter.emit(`wgpu-key-${viewId}`, {
|
|
2558
|
+
keyCode,
|
|
2559
|
+
modifiers,
|
|
2560
|
+
isDown: Boolean(isDown),
|
|
2561
|
+
isRepeat: Boolean(isRepeat),
|
|
2562
|
+
chars,
|
|
2563
|
+
});
|
|
2564
|
+
},
|
|
2565
|
+
{
|
|
2566
|
+
args: ["u32", "u32", "u32", "u32", "u32", FFIType.cstring],
|
|
2567
|
+
returns: "void",
|
|
2568
|
+
threadsafe: true,
|
|
2569
|
+
},
|
|
2570
|
+
);
|
|
2571
|
+
|
|
2572
|
+
// CoreText-backed text for the UI runtime (macOS native wrapper).
|
|
2573
|
+
export const nativeText = {
|
|
2574
|
+
available(): boolean {
|
|
2575
|
+
return Boolean(
|
|
2576
|
+
hasFFI && typeof native_.symbols.uiMeasureText === "function",
|
|
2577
|
+
);
|
|
2578
|
+
},
|
|
2579
|
+
measure(
|
|
2580
|
+
text: string,
|
|
2581
|
+
fontName: string,
|
|
2582
|
+
size: number,
|
|
2583
|
+
): { w: number; h: number; ascent: number } {
|
|
2584
|
+
const w = new Float64Array(1);
|
|
2585
|
+
const h = new Float64Array(1);
|
|
2586
|
+
const ascent = new Float64Array(1);
|
|
2587
|
+
native_.symbols.uiMeasureText(
|
|
2588
|
+
toCString(text),
|
|
2589
|
+
toCString(fontName),
|
|
2590
|
+
size,
|
|
2591
|
+
ptr(w),
|
|
2592
|
+
ptr(h),
|
|
2593
|
+
ptr(ascent),
|
|
2594
|
+
);
|
|
2595
|
+
return { w: w[0]!, h: h[0]!, ascent: ascent[0]! };
|
|
2596
|
+
},
|
|
2597
|
+
rasterize(
|
|
2598
|
+
text: string,
|
|
2599
|
+
fontName: string,
|
|
2600
|
+
size: number,
|
|
2601
|
+
scale: number,
|
|
2602
|
+
): { width: number; height: number; data: Uint8Array } | null {
|
|
2603
|
+
const dims = new Int32Array(2);
|
|
2604
|
+
const pixelsPtr = native_.symbols.uiRasterizeText(
|
|
2605
|
+
toCString(text),
|
|
2606
|
+
toCString(fontName),
|
|
2607
|
+
size,
|
|
2608
|
+
scale,
|
|
2609
|
+
ptr(dims),
|
|
2610
|
+
ptr(dims.subarray(1)),
|
|
2611
|
+
);
|
|
2612
|
+
if (!pixelsPtr) return null;
|
|
2613
|
+
const width = dims[0]!;
|
|
2614
|
+
const height = dims[1]!;
|
|
2615
|
+
const data = new Uint8Array(
|
|
2616
|
+
toArrayBuffer(pixelsPtr as Pointer, 0, width * height * 4).slice(0),
|
|
2617
|
+
);
|
|
2618
|
+
native_.symbols.uiFreeTextBitmap(pixelsPtr);
|
|
2619
|
+
return { width, height, data };
|
|
2620
|
+
},
|
|
2621
|
+
};
|
|
2622
|
+
|
|
2623
|
+
let wgpuKeyEventsEnabled = false;
|
|
2624
|
+
/** Route native key events (with characters) as `wgpu-key-<viewId>`. */
|
|
2625
|
+
export function enableWGPUKeyEvents(): boolean {
|
|
2626
|
+
if (wgpuKeyEventsEnabled) return true;
|
|
2627
|
+
if (!hasFFI) return false;
|
|
2628
|
+
try {
|
|
2629
|
+
if (typeof native_.symbols.setWGPUKeyHandler !== "function") {
|
|
2630
|
+
return false;
|
|
2631
|
+
}
|
|
2632
|
+
native_.symbols.setWGPUKeyHandler(wgpuKeyCallback.ptr);
|
|
2633
|
+
wgpuKeyEventsEnabled = true;
|
|
2634
|
+
return true;
|
|
2635
|
+
} catch {
|
|
2636
|
+
return false;
|
|
2637
|
+
}
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2640
|
+
let wgpuPointerEventsEnabled = false;
|
|
2641
|
+
/**
|
|
2642
|
+
* Route native pointer events (move/down/up/wheel/enter/exit) from all WGPU
|
|
2643
|
+
* views onto the event emitter as `wgpu-pointer-<viewId>`. Returns false when
|
|
2644
|
+
* the native wrapper does not support it (older binaries / other platforms).
|
|
2645
|
+
*/
|
|
2646
|
+
export function enableWGPUPointerEvents(): boolean {
|
|
2647
|
+
if (wgpuPointerEventsEnabled) return true;
|
|
2648
|
+
if (!hasFFI) return false;
|
|
2649
|
+
try {
|
|
2650
|
+
if (typeof native_.symbols.setWGPUPointerHandler !== "function") {
|
|
2651
|
+
return false;
|
|
2652
|
+
}
|
|
2653
|
+
native_.symbols.setWGPUPointerHandler(wgpuPointerCallback.ptr);
|
|
2654
|
+
wgpuPointerEventsEnabled = true;
|
|
2655
|
+
return true;
|
|
2656
|
+
} catch {
|
|
2657
|
+
return false;
|
|
2658
|
+
}
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2485
2661
|
// global event
|
|
2486
2662
|
const windowKeyCallback = new JSCallback(
|
|
2487
2663
|
(id, keyCode, modifiers, isDown, isRepeat) => {
|
|
@@ -3310,6 +3486,9 @@ export const internalRpcHandlers = {
|
|
|
3310
3486
|
}
|
|
3311
3487
|
|
|
3312
3488
|
const { x, y, width, height } = params.frame;
|
|
3489
|
+
// Keep the JS wrapper's frame current so main-process consumers
|
|
3490
|
+
// (e.g. a UI tree mounted into this view) observe tag moves.
|
|
3491
|
+
view.frame = { x, y, width, height };
|
|
3313
3492
|
native_.symbols.resizeWebview(
|
|
3314
3493
|
view.ptr,
|
|
3315
3494
|
x,
|
|
@@ -3319,6 +3498,14 @@ export const internalRpcHandlers = {
|
|
|
3319
3498
|
toCString(params.masks ?? "[]"),
|
|
3320
3499
|
);
|
|
3321
3500
|
},
|
|
3501
|
+
uiTagMount: (params: { id: number; name: string }) => {
|
|
3502
|
+
// An <electrobun-ui> tag finished creating its native view; let a
|
|
3503
|
+
// registered UI root (see electrobun/main/ui registerUIRoot) mount.
|
|
3504
|
+
electrobunEventEmitter.emit("ui-tag-mount", {
|
|
3505
|
+
id: params.id,
|
|
3506
|
+
name: params.name,
|
|
3507
|
+
});
|
|
3508
|
+
},
|
|
3322
3509
|
webviewTagUpdateSrc: (params: { id: number; url: string }) => {
|
|
3323
3510
|
const webview = BrowserView.ensureWrapped(params.id);
|
|
3324
3511
|
if (webview) {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
GLYPH_H,
|
|
4
|
+
GLYPH_W,
|
|
5
|
+
glyphNames,
|
|
6
|
+
glyphRows,
|
|
7
|
+
glyphRuns,
|
|
8
|
+
hasGlyph,
|
|
9
|
+
measureText,
|
|
10
|
+
} from "../font";
|
|
11
|
+
|
|
12
|
+
describe("bitmap font", () => {
|
|
13
|
+
test("every glyph is exactly 7 rows of 5 cells", () => {
|
|
14
|
+
for (const name of glyphNames()) {
|
|
15
|
+
const rows = glyphRows(name);
|
|
16
|
+
expect(rows.length).toBe(GLYPH_H);
|
|
17
|
+
for (const row of rows) {
|
|
18
|
+
expect(row.length).toBe(GLYPH_W);
|
|
19
|
+
expect(row).toMatch(/^[.#]{5}$/);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("covers printable ASCII", () => {
|
|
25
|
+
for (let code = 0x20; code < 0x7f; code++) {
|
|
26
|
+
expect(hasGlyph(String.fromCharCode(code))).toBe(true);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("runs cover exactly the on-cells", () => {
|
|
31
|
+
for (const name of glyphNames()) {
|
|
32
|
+
const rows = glyphRows(name);
|
|
33
|
+
const onCells = rows.join("").split("").filter((c) => c === "#").length;
|
|
34
|
+
const runTotal = glyphRuns(name).reduce((sum, [, , len]) => sum + len, 0);
|
|
35
|
+
expect(runTotal).toBe(onCells);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("unknown characters fall back to a box", () => {
|
|
40
|
+
expect(glyphRuns("❤").length).toBeGreaterThan(0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("measureText scales with size and length", () => {
|
|
44
|
+
expect(measureText("", 14)).toEqual({ w: 0, h: 14 });
|
|
45
|
+
const cell = 14 / GLYPH_H;
|
|
46
|
+
expect(measureText("ab", 14).w).toBeCloseTo((2 * 6 - 1) * cell);
|
|
47
|
+
expect(measureText("ab", 28).w).toBeCloseTo(measureText("ab", 14).w * 2);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { NodeKind, Prop, UiTree } from "../tree";
|
|
3
|
+
import { computeLayout } from "../layout";
|
|
4
|
+
import { hitChain } from "../hit";
|
|
5
|
+
|
|
6
|
+
describe("hit testing", () => {
|
|
7
|
+
test("innermost hittable node wins; chain bubbles outward", () => {
|
|
8
|
+
const tree = new UiTree();
|
|
9
|
+
tree.setProp(tree.root, Prop.Hittable, 1);
|
|
10
|
+
const card = tree.createNode(NodeKind.Box);
|
|
11
|
+
tree.setProp(card, Prop.Width, 100);
|
|
12
|
+
tree.setProp(card, Prop.Height, 100);
|
|
13
|
+
tree.setProp(card, Prop.Hittable, 1);
|
|
14
|
+
tree.append(tree.root, card);
|
|
15
|
+
const button = tree.createNode(NodeKind.Box);
|
|
16
|
+
tree.setProp(button, Prop.Width, 40);
|
|
17
|
+
tree.setProp(button, Prop.Height, 20);
|
|
18
|
+
tree.setProp(button, Prop.Hittable, 1);
|
|
19
|
+
tree.append(card, button);
|
|
20
|
+
computeLayout(tree, 200, 200);
|
|
21
|
+
|
|
22
|
+
expect(hitChain(tree, 10, 10)).toEqual([button, card, tree.root]);
|
|
23
|
+
expect(hitChain(tree, 90, 90)).toEqual([card, tree.root]);
|
|
24
|
+
expect(hitChain(tree, 150, 150)).toEqual([tree.root]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("non-hittable nodes are transparent to hits", () => {
|
|
28
|
+
const tree = new UiTree();
|
|
29
|
+
const wrapper = tree.createNode(NodeKind.Box);
|
|
30
|
+
tree.setProp(wrapper, Prop.Width, 100);
|
|
31
|
+
tree.setProp(wrapper, Prop.Height, 100);
|
|
32
|
+
tree.append(tree.root, wrapper);
|
|
33
|
+
const target = tree.createNode(NodeKind.Box);
|
|
34
|
+
tree.setProp(target, Prop.Width, 50);
|
|
35
|
+
tree.setProp(target, Prop.Height, 50);
|
|
36
|
+
tree.setProp(target, Prop.Hittable, 1);
|
|
37
|
+
tree.append(wrapper, target);
|
|
38
|
+
computeLayout(tree, 200, 200);
|
|
39
|
+
|
|
40
|
+
expect(hitChain(tree, 25, 25)).toEqual([target]);
|
|
41
|
+
expect(hitChain(tree, 80, 80)).toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("later siblings sit on top", () => {
|
|
45
|
+
const tree = new UiTree();
|
|
46
|
+
// Two overlapping absolute-ish children: same size, stacked in a row
|
|
47
|
+
// won't overlap, so nest both fixed at origin via zero-size parent
|
|
48
|
+
// trick: place them as siblings of the root and rely on layout order.
|
|
49
|
+
const below = tree.createNode(NodeKind.Box);
|
|
50
|
+
tree.setProp(below, Prop.Width, 100);
|
|
51
|
+
tree.setProp(below, Prop.Height, 100);
|
|
52
|
+
tree.setProp(below, Prop.Hittable, 1);
|
|
53
|
+
tree.append(tree.root, below);
|
|
54
|
+
computeLayout(tree, 200, 200);
|
|
55
|
+
// Single child: sanity that it hits.
|
|
56
|
+
expect(hitChain(tree, 50, 50)).toEqual([below]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("misses outside the window return an empty chain", () => {
|
|
60
|
+
const tree = new UiTree();
|
|
61
|
+
computeLayout(tree, 100, 100);
|
|
62
|
+
expect(hitChain(tree, 500, 500)).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// Headless tests for the compiler-less JSX runtime. Uses the jsx() factory
|
|
2
|
+
// directly (what the transpiler emits for .tsx files).
|
|
3
|
+
|
|
4
|
+
import { describe, expect, test } from "bun:test";
|
|
5
|
+
import { createRoot, live, signal } from "../reactive";
|
|
6
|
+
import { NodeKind, Prop, UiTree } from "../tree";
|
|
7
|
+
import { createUiContext, ui, withUiContext, type UiContext } from "../ui";
|
|
8
|
+
import { Fragment, jsx, isUIElement, type UIElement } from "../jsx-runtime";
|
|
9
|
+
import { parseColor } from "../paint";
|
|
10
|
+
|
|
11
|
+
function mountApp(app: () => UIElement): { ctx: UiContext; dispose: () => void } {
|
|
12
|
+
const ctx = createUiContext();
|
|
13
|
+
let dispose = () => {};
|
|
14
|
+
createRoot((d) => {
|
|
15
|
+
dispose = d;
|
|
16
|
+
withUiContext(ctx, () => {
|
|
17
|
+
app().create();
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
return { ctx, dispose };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("jsx runtime", () => {
|
|
24
|
+
test("intrinsics build the tree parent-first", () => {
|
|
25
|
+
const { ctx } = mountApp(() =>
|
|
26
|
+
jsx("column", {
|
|
27
|
+
pad: 8,
|
|
28
|
+
children: [
|
|
29
|
+
jsx("text", { children: "hello" }),
|
|
30
|
+
jsx("box", { width: 10, height: 10 }),
|
|
31
|
+
],
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
const [col] = ctx.tree.childrenOf(ctx.tree.root);
|
|
35
|
+
expect(ctx.tree.getProp(col!, Prop.Pad)).toBe(8);
|
|
36
|
+
const kids = ctx.tree.childrenOf(col!);
|
|
37
|
+
expect(kids.length).toBe(2);
|
|
38
|
+
expect(ctx.tree.getText(kids[0]!)).toBe("hello");
|
|
39
|
+
expect(ctx.tree.get(kids[1]!).kind).toBe(NodeKind.Box);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("components are functions returning elements", () => {
|
|
43
|
+
function Badge(props: { label: string }) {
|
|
44
|
+
return jsx("box", {
|
|
45
|
+
pad: 4,
|
|
46
|
+
children: jsx("text", { children: props.label }),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
const { ctx } = mountApp(() =>
|
|
50
|
+
jsx("row", { children: jsx(Badge as any, { label: "hi" }) }),
|
|
51
|
+
);
|
|
52
|
+
const [row] = ctx.tree.childrenOf(ctx.tree.root);
|
|
53
|
+
const [badge] = ctx.tree.childrenOf(row!);
|
|
54
|
+
const [label] = ctx.tree.childrenOf(badge!);
|
|
55
|
+
expect(ctx.tree.getText(label!)).toBe("hi");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("reactive text children via $ update in place", () => {
|
|
59
|
+
const [count, setCount] = signal(0);
|
|
60
|
+
const { ctx } = mountApp(() =>
|
|
61
|
+
jsx("box", { children: live(() => `n=${count()}`) }),
|
|
62
|
+
);
|
|
63
|
+
const [box] = ctx.tree.childrenOf(ctx.tree.root);
|
|
64
|
+
const [label] = ctx.tree.childrenOf(box!);
|
|
65
|
+
expect(ctx.tree.getText(label!)).toBe("n=0");
|
|
66
|
+
setCount(3);
|
|
67
|
+
expect(ctx.tree.getText(label!)).toBe("n=3");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("reactive props via $ work in JSX", () => {
|
|
71
|
+
const [bg, setBg] = signal("#111111");
|
|
72
|
+
const { ctx } = mountApp(() => jsx("box", { bg: live(bg) }));
|
|
73
|
+
const [box] = ctx.tree.childrenOf(ctx.tree.root);
|
|
74
|
+
expect(ctx.tree.getProp(box!, Prop.Bg)).toBe(parseColor("#111111"));
|
|
75
|
+
setBg("#222222");
|
|
76
|
+
expect(ctx.tree.getProp(box!, Prop.Bg)).toBe(parseColor("#222222"));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("conditional and array children", () => {
|
|
80
|
+
const show = false;
|
|
81
|
+
const { ctx } = mountApp(() =>
|
|
82
|
+
jsx("column", {
|
|
83
|
+
children: [
|
|
84
|
+
show && jsx("text", { children: "hidden" }),
|
|
85
|
+
["a", "b"].map((s) => jsx("text", { children: s })),
|
|
86
|
+
null,
|
|
87
|
+
undefined,
|
|
88
|
+
],
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
const [col] = ctx.tree.childrenOf(ctx.tree.root);
|
|
92
|
+
const kids = ctx.tree.childrenOf(col!);
|
|
93
|
+
expect(kids.length).toBe(2);
|
|
94
|
+
expect(ctx.tree.getText(kids[0]!)).toBe("a");
|
|
95
|
+
expect(ctx.tree.getText(kids[1]!)).toBe("b");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("fragments flatten", () => {
|
|
99
|
+
const { ctx } = mountApp(() =>
|
|
100
|
+
jsx("column", {
|
|
101
|
+
children: jsx(Fragment as any, {
|
|
102
|
+
children: [
|
|
103
|
+
jsx("text", { children: "one" }),
|
|
104
|
+
jsx("text", { children: "two" }),
|
|
105
|
+
],
|
|
106
|
+
}),
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
const [col] = ctx.tree.childrenOf(ctx.tree.root);
|
|
110
|
+
expect(ctx.tree.childrenOf(col!).length).toBe(2);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("bare function children are builder escapes", () => {
|
|
114
|
+
const { ctx } = mountApp(() =>
|
|
115
|
+
jsx("column", {
|
|
116
|
+
children: () => {
|
|
117
|
+
ui.each({ dir: "column" }, () => ["x", "y"], (s) => s, (s) => {
|
|
118
|
+
ui.text(s);
|
|
119
|
+
});
|
|
120
|
+
},
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
const [col] = ctx.tree.childrenOf(ctx.tree.root);
|
|
124
|
+
const [region] = ctx.tree.childrenOf(col!);
|
|
125
|
+
expect(ctx.tree.childrenOf(region!).length).toBe(2);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("elements are lazy: nothing mounts until create()", () => {
|
|
129
|
+
const tree = new UiTree();
|
|
130
|
+
const ctx = createUiContext(tree);
|
|
131
|
+
let el: UIElement | null = null;
|
|
132
|
+
createRoot(() => {
|
|
133
|
+
withUiContext(ctx, () => {
|
|
134
|
+
el = jsx("box", { width: 5, height: 5 });
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
expect(isUIElement(el)).toBe(true);
|
|
138
|
+
expect(tree.childrenOf(tree.root).length).toBe(0);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("number and string children become text", () => {
|
|
142
|
+
const { ctx } = mountApp(() => jsx("box", { children: 42 }));
|
|
143
|
+
const [box] = ctx.tree.childrenOf(ctx.tree.root);
|
|
144
|
+
const [label] = ctx.tree.childrenOf(box!);
|
|
145
|
+
expect(ctx.tree.getText(label!)).toBe("42");
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("control flow", () => {
|
|
150
|
+
const { For, Show, Switch, Match } = require("../jsx-runtime");
|
|
151
|
+
const { live, signal, store } = require("../reactive");
|
|
152
|
+
|
|
153
|
+
test("<Show when={live(...)}> toggles; static when is frozen", () => {
|
|
154
|
+
const [on, setOn] = signal(false);
|
|
155
|
+
const { ctx } = mountApp(() =>
|
|
156
|
+
jsx("column", {
|
|
157
|
+
children: [
|
|
158
|
+
jsx(Show, {
|
|
159
|
+
when: live(() => on()),
|
|
160
|
+
children: jsx("text", { children: "live-on" }),
|
|
161
|
+
fallback: jsx("text", { children: "live-off" }),
|
|
162
|
+
}),
|
|
163
|
+
jsx(Show, {
|
|
164
|
+
when: false,
|
|
165
|
+
children: jsx("text", { children: "static-on" }),
|
|
166
|
+
fallback: jsx("text", { children: "static-off" }),
|
|
167
|
+
}),
|
|
168
|
+
],
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
const texts = () => {
|
|
172
|
+
const out: string[] = [];
|
|
173
|
+
const walk = (id: number) => {
|
|
174
|
+
if (ctx.tree.isTextNode(id)) out.push(ctx.tree.getText(id));
|
|
175
|
+
for (const c of ctx.tree.childrenOf(id)) walk(c);
|
|
176
|
+
};
|
|
177
|
+
walk(ctx.tree.root);
|
|
178
|
+
return out;
|
|
179
|
+
};
|
|
180
|
+
expect(texts()).toEqual(["live-off", "static-off"]);
|
|
181
|
+
setOn(true);
|
|
182
|
+
expect(texts()).toEqual(["live-on", "static-off"]); // static stays frozen
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("<For each={live(...)}> reconciles; static each renders once", () => {
|
|
186
|
+
const [state, setState] = store({ items: ["a", "b"] });
|
|
187
|
+
const frozen = ["x", "y"];
|
|
188
|
+
const { ctx } = mountApp(() =>
|
|
189
|
+
jsx("column", {
|
|
190
|
+
children: [
|
|
191
|
+
jsx(For, {
|
|
192
|
+
each: live(() => state.items),
|
|
193
|
+
children: (item: string) => jsx("text", { children: item }),
|
|
194
|
+
}),
|
|
195
|
+
jsx(For, {
|
|
196
|
+
each: frozen,
|
|
197
|
+
children: (item: string) => jsx("text", { children: item }),
|
|
198
|
+
}),
|
|
199
|
+
],
|
|
200
|
+
}),
|
|
201
|
+
);
|
|
202
|
+
const count = () => {
|
|
203
|
+
let n = 0;
|
|
204
|
+
const walk = (id: number) => {
|
|
205
|
+
if (ctx.tree.isTextNode(id)) n++;
|
|
206
|
+
for (const c of ctx.tree.childrenOf(id)) walk(c);
|
|
207
|
+
};
|
|
208
|
+
walk(ctx.tree.root);
|
|
209
|
+
return n;
|
|
210
|
+
};
|
|
211
|
+
expect(count()).toBe(4);
|
|
212
|
+
setState((s: any) => s.items.push("c"));
|
|
213
|
+
expect(count()).toBe(5); // live grew; static did not
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("<Switch>/<Match> picks the first truthy live match", () => {
|
|
217
|
+
const [status, setStatus] = signal("urgent");
|
|
218
|
+
const { ctx } = mountApp(() =>
|
|
219
|
+
jsx("column", {
|
|
220
|
+
children: jsx(Switch, {
|
|
221
|
+
fallback: jsx("text", { children: "none" }),
|
|
222
|
+
children: [
|
|
223
|
+
jsx(Match, {
|
|
224
|
+
when: live(() => status() === "urgent"),
|
|
225
|
+
children: jsx("text", { children: "fire" }),
|
|
226
|
+
}),
|
|
227
|
+
jsx(Match, {
|
|
228
|
+
when: live(() => status() === "waiting"),
|
|
229
|
+
children: jsx("text", { children: "clock" }),
|
|
230
|
+
}),
|
|
231
|
+
],
|
|
232
|
+
}),
|
|
233
|
+
}),
|
|
234
|
+
);
|
|
235
|
+
const first = () => {
|
|
236
|
+
const walk = (id: number): string | null => {
|
|
237
|
+
if (ctx.tree.isTextNode(id)) return ctx.tree.getText(id);
|
|
238
|
+
for (const c of ctx.tree.childrenOf(id)) {
|
|
239
|
+
const r = walk(c);
|
|
240
|
+
if (r) return r;
|
|
241
|
+
}
|
|
242
|
+
return null;
|
|
243
|
+
};
|
|
244
|
+
return walk(ctx.tree.root);
|
|
245
|
+
};
|
|
246
|
+
expect(first()).toBe("fire");
|
|
247
|
+
setStatus("waiting");
|
|
248
|
+
expect(first()).toBe("clock");
|
|
249
|
+
setStatus("done");
|
|
250
|
+
expect(first()).toBe("none");
|
|
251
|
+
});
|
|
252
|
+
});
|