electrobun 1.18.4-beta.19 → 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 (53) hide show
  1. package/dist/api/browser/ui/__tests__/dom.test.ts +473 -0
  2. package/dist/api/browser/ui/__tests__/domStub.ts +218 -0
  3. package/dist/api/browser/ui/dom.ts +490 -0
  4. package/dist/api/browser/ui/index.ts +44 -0
  5. package/dist/api/browser/ui/jsx-dev-runtime.ts +16 -0
  6. package/dist/api/browser/ui/jsx-runtime.ts +56 -0
  7. package/dist/api/config/ElectrobunConfig.ts +33 -0
  8. package/dist/api/preload/.generated/compiled.ts +1 -1
  9. package/dist/api/preload/index.ts +2 -0
  10. package/dist/api/preload/uiTag.ts +45 -0
  11. package/dist/api/sdks/main/__tests__/utils-quit-exit-code.test.ts +44 -0
  12. package/dist/api/sdks/main/core/GpuWindow.ts +19 -0
  13. package/dist/api/sdks/main/core/Utils.ts +52 -4
  14. package/dist/api/sdks/main/core/WGPUView.ts +9 -0
  15. package/dist/api/sdks/main/entries/ui.ts +1 -0
  16. package/dist/api/sdks/main/proc/native.ts +187 -0
  17. package/dist/api/sdks/main/ui/__tests__/font.test.ts +49 -0
  18. package/dist/api/sdks/main/ui/__tests__/hit.test.ts +64 -0
  19. package/dist/api/sdks/main/ui/__tests__/jsx.test.ts +252 -0
  20. package/dist/api/sdks/main/ui/__tests__/layout.test.ts +159 -0
  21. package/dist/api/sdks/main/ui/__tests__/paint.test.ts +115 -0
  22. package/dist/api/sdks/main/ui/__tests__/reactive.test.ts +456 -0
  23. package/dist/api/sdks/main/ui/__tests__/scroll-focus-input.test.ts +298 -0
  24. package/dist/api/sdks/main/ui/__tests__/tree.test.ts +96 -0
  25. package/dist/api/sdks/main/ui/__tests__/ui.test.ts +170 -0
  26. package/dist/api/sdks/main/ui/elements.ts +135 -0
  27. package/dist/api/sdks/main/ui/font.ts +168 -0
  28. package/dist/api/sdks/main/ui/hit.ts +46 -0
  29. package/dist/api/sdks/main/ui/index.ts +71 -0
  30. package/dist/api/sdks/main/ui/input.ts +268 -0
  31. package/dist/api/sdks/main/ui/jsx-dev-runtime.ts +16 -0
  32. package/dist/api/sdks/main/ui/jsx-runtime.ts +136 -0
  33. package/dist/api/sdks/main/ui/keymap.ts +147 -0
  34. package/dist/api/sdks/main/ui/layout.ts +178 -0
  35. package/dist/api/sdks/main/ui/paint.ts +196 -0
  36. package/dist/api/sdks/main/ui/reactive.ts +4 -0
  37. package/dist/api/sdks/main/ui/renderer.ts +278 -0
  38. package/dist/api/sdks/main/ui/text.ts +175 -0
  39. package/dist/api/sdks/main/ui/textInput.ts +121 -0
  40. package/dist/api/sdks/main/ui/tree.ts +276 -0
  41. package/dist/api/sdks/main/ui/ui.ts +457 -0
  42. package/dist/api/sdks/main/ui/uiTagHost.ts +56 -0
  43. package/dist/api/sdks/main/ui/uiwindow.ts +330 -0
  44. package/dist/api/shared/build-dependencies.test.ts +1 -1
  45. package/dist/api/shared/build-dependencies.ts +4 -4
  46. package/dist/api/shared/linux-webkit-automation.test.ts +1 -1
  47. package/dist/api/shared/warren/jsx.ts +279 -0
  48. package/dist/api/shared/warren/reactive.ts +638 -0
  49. package/dist/api/shared/windows-unicode-ui.test.ts +4 -4
  50. package/dist/preload-full.js +35 -0
  51. package/dist/zig-sdk/electrobun.zig +197 -162
  52. package/{dash.config.ts → hutch.config.ts} +3 -2
  53. package/package.json +11 -2
@@ -0,0 +1,136 @@
1
+ // JSX runtime for Warren's GPU renderer — no compiler beyond the
2
+ // TypeScript/Bun transpiler that Cottontail already ships. Point tsconfig
3
+ // at it:
4
+ //
5
+ // { "jsx": "react-jsx", "jsxImportSource": "electrobun/main/ui" }
6
+ //
7
+ // Semantics mirror the builder API exactly:
8
+ // - Elements are lazy values; mounting creates nodes parent-first.
9
+ // - Components are plain functions called once with their props (Solid-style,
10
+ // no re-render); they return elements.
11
+ // - Reactivity stays explicit: `bg={live(() => ...)}` is reactive, `bg={value}`
12
+ // is static, and a bare function child `{() => ui.anything(...)}` is a
13
+ // builder escape that runs against the current parent — so the whole
14
+ // builder API (each, dynamic, textInput, webview, wgpuSurface) is usable
15
+ // inside JSX unchanged.
16
+ //
17
+ // The component model itself (lazy elements, Show/For/Switch/Match) is the
18
+ // shared Warren JSX core; this module binds it to the retained-tree builder.
19
+
20
+ import type { LiveBinding, Reactive } from "./reactive";
21
+ import { ui, type BoxProps, type TextProps } from "./ui";
22
+ import {
23
+ createJsxRuntime,
24
+ isUIElement,
25
+ Match,
26
+ type ForProps,
27
+ type MatchProps,
28
+ type ShowProps,
29
+ type SwitchProps,
30
+ type UIChild,
31
+ type UIElement,
32
+ type WarrenRenderer,
33
+ } from "../../../shared/warren/jsx";
34
+
35
+ export { isUIElement, Match };
36
+ export type {
37
+ ForProps,
38
+ MatchProps,
39
+ ShowProps,
40
+ SwitchProps,
41
+ UIChild,
42
+ UIElement,
43
+ };
44
+
45
+ const INTRINSICS = new Set(["box", "row", "column", "text", "spacer"]);
46
+
47
+ const renderer: WarrenRenderer = {
48
+ text(value) {
49
+ ui.text(value as Reactive<string | number>);
50
+ },
51
+ liveChild(binding) {
52
+ // GPU text nodes are leaves with no wrapper: bind as reactive text.
53
+ // (Element-returning live children aren't part of the GPU JSX types.)
54
+ ui.text(binding as Reactive<string | number>);
55
+ },
56
+ dynamic(build) {
57
+ ui.dynamic({}, build);
58
+ },
59
+ each(items, key, render) {
60
+ ui.each({}, items, key, render);
61
+ },
62
+ intrinsic(type, props) {
63
+ if (!INTRINSICS.has(type)) {
64
+ throw new Error(
65
+ `Warren: unknown element <${type}> — the GPU renderer provides box, row, column, text, and spacer.`,
66
+ );
67
+ }
68
+ const { children, ...rest } = props;
69
+ switch (type) {
70
+ case "box":
71
+ case "row":
72
+ case "column": {
73
+ ui[type](
74
+ rest as BoxProps,
75
+ children === undefined
76
+ ? undefined
77
+ : () => mountChild(children as UIChild),
78
+ );
79
+ return;
80
+ }
81
+ case "text": {
82
+ if (Array.isArray(children)) {
83
+ throw new Error(
84
+ "<text> takes a single child (string, number, or live(() => ...)); compose the string inside one expression.",
85
+ );
86
+ }
87
+ ui.text(
88
+ (children ?? "") as Reactive<string | number>,
89
+ rest as TextProps,
90
+ );
91
+ return;
92
+ }
93
+ case "spacer": {
94
+ ui.spacer((rest as { grow?: Reactive<number> }).grow ?? 1);
95
+ return;
96
+ }
97
+ }
98
+ },
99
+ escape(fn) {
100
+ // Builder escape: run builder-API code against the current parent.
101
+ // Return values (node ids) are intentionally ignored.
102
+ fn();
103
+ },
104
+ };
105
+
106
+ const runtime = createJsxRuntime(renderer);
107
+
108
+ export const jsx = runtime.jsx;
109
+ export const jsxs = runtime.jsxs;
110
+ export const Fragment = runtime.Fragment;
111
+ export const mountChild = runtime.mountChild;
112
+ export const Show = runtime.Show;
113
+ export const For = runtime.For;
114
+ export const Switch = runtime.Switch;
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Types
118
+ // ---------------------------------------------------------------------------
119
+
120
+ type WithChildren<P> = P & { children?: UIChild };
121
+
122
+ export declare namespace JSX {
123
+ type Element = UIElement;
124
+ interface ElementChildrenAttribute {
125
+ children: {};
126
+ }
127
+ interface IntrinsicElements {
128
+ box: WithChildren<BoxProps>;
129
+ row: WithChildren<BoxProps>;
130
+ column: WithChildren<BoxProps>;
131
+ text: TextProps & {
132
+ children?: string | number | LiveBinding<string | number>;
133
+ };
134
+ spacer: { grow?: Reactive<number> };
135
+ }
136
+ }
@@ -0,0 +1,147 @@
1
+ // Keyboard mapping and text-edit reducer. Pure — testable without a window.
2
+ //
3
+ // Prototype honesty: the native key event carries a virtual key code but no
4
+ // character, so this maps macOS virtual key codes assuming a US layout.
5
+ // Production work is characters (and IME) in the native key event itself.
6
+
7
+ export const Mod = {
8
+ Shift: 1 << 0,
9
+ Ctrl: 1 << 1,
10
+ Alt: 1 << 2,
11
+ Cmd: 1 << 3,
12
+ } as const;
13
+
14
+ export const Key = {
15
+ Return: 36,
16
+ Tab: 48,
17
+ Space: 49,
18
+ Backspace: 51,
19
+ Escape: 53,
20
+ Left: 123,
21
+ Right: 124,
22
+ Down: 125,
23
+ Up: 126,
24
+ } as const;
25
+
26
+ // macOS virtual key code → [char, shiftedChar]
27
+ const CHARS: Record<number, [string, string]> = {
28
+ 0: ["a", "A"], 1: ["s", "S"], 2: ["d", "D"], 3: ["f", "F"],
29
+ 4: ["h", "H"], 5: ["g", "G"], 6: ["z", "Z"], 7: ["x", "X"],
30
+ 8: ["c", "C"], 9: ["v", "V"], 11: ["b", "B"], 12: ["q", "Q"],
31
+ 13: ["w", "W"], 14: ["e", "E"], 15: ["r", "R"], 16: ["y", "Y"],
32
+ 17: ["t", "T"], 18: ["1", "!"], 19: ["2", "@"], 20: ["3", "#"],
33
+ 21: ["4", "$"], 22: ["6", "^"], 23: ["5", "%"], 24: ["=", "+"],
34
+ 25: ["9", "("], 26: ["7", "&"], 27: ["-", "_"], 28: ["8", "*"],
35
+ 29: ["0", ")"], 30: ["]", "}"], 31: ["o", "O"], 32: ["u", "U"],
36
+ 33: ["[", "{"], 34: ["i", "I"], 35: ["p", "P"], 37: ["l", "L"],
37
+ 38: ["j", "J"], 39: ["'", '"'], 40: ["k", "K"], 41: [";", ":"],
38
+ 42: ["\\", "|"], 43: [",", "<"], 44: ["/", "?"], 45: ["n", "N"],
39
+ 46: ["m", "M"], 47: [".", ">"], 49: [" ", " "], 50: ["`", "~"],
40
+ };
41
+
42
+ /** Printable character for a key event, or null. */
43
+ export function charForKey(keyCode: number, modifiers: number): string | null {
44
+ if (modifiers & (Mod.Cmd | Mod.Ctrl)) return null;
45
+ const entry = CHARS[keyCode];
46
+ if (!entry) return null;
47
+ return modifiers & Mod.Shift ? entry[1] : entry[0];
48
+ }
49
+
50
+ export interface EditState {
51
+ value: string;
52
+ caret: number;
53
+ }
54
+
55
+ export interface EditResult extends EditState {
56
+ handled: boolean;
57
+ submit: boolean;
58
+ }
59
+
60
+ /**
61
+ * Apply one key event to a text-edit state. Handles character insertion,
62
+ * backspace (with cmd = clear-to-start, alt = delete word), caret movement
63
+ * (with cmd = home/end, alt = word-wise), and Enter (submit). Unhandled keys
64
+ * (e.g. Up/Down, Escape) pass through so callers can bubble them.
65
+ */
66
+ export function applyEditKey(
67
+ state: EditState,
68
+ keyCode: number,
69
+ modifiers: number,
70
+ chars?: string,
71
+ ): EditResult {
72
+ const { value } = state;
73
+ const caret = Math.max(0, Math.min(state.caret, value.length));
74
+
75
+ if (keyCode === Key.Return) {
76
+ return { value, caret, handled: true, submit: true };
77
+ }
78
+
79
+ if (keyCode === Key.Backspace) {
80
+ if (caret === 0) return { value, caret, handled: true, submit: false };
81
+ let from = caret - 1;
82
+ if (modifiers & Mod.Cmd) from = 0;
83
+ else if (modifiers & Mod.Alt) from = wordLeft(value, caret);
84
+ return {
85
+ value: value.slice(0, from) + value.slice(caret),
86
+ caret: from,
87
+ handled: true,
88
+ submit: false,
89
+ };
90
+ }
91
+
92
+ if (keyCode === Key.Left) {
93
+ const to =
94
+ modifiers & Mod.Cmd ? 0
95
+ : modifiers & Mod.Alt ? wordLeft(value, caret)
96
+ : Math.max(0, caret - 1);
97
+ return { value, caret: to, handled: true, submit: false };
98
+ }
99
+
100
+ if (keyCode === Key.Right) {
101
+ const to =
102
+ modifiers & Mod.Cmd ? value.length
103
+ : modifiers & Mod.Alt ? wordRight(value, caret)
104
+ : Math.min(value.length, caret + 1);
105
+ return { value, caret: to, handled: true, submit: false };
106
+ }
107
+
108
+ // Prefer the keyboard layout's characters when the native event provides
109
+ // them; fall back to the built-in US map. Control characters and
110
+ // cmd/ctrl-chorded keys never insert.
111
+ let char: string | null = null;
112
+ if (
113
+ chars &&
114
+ chars.length > 0 &&
115
+ !(modifiers & (Mod.Cmd | Mod.Ctrl)) &&
116
+ // eslint-disable-next-line no-control-regex
117
+ !/[\u0000-\u001f\u007f\uf700-\uf8ff]/.test(chars)
118
+ ) {
119
+ char = chars;
120
+ } else {
121
+ char = charForKey(keyCode, modifiers);
122
+ }
123
+ if (char !== null) {
124
+ return {
125
+ value: value.slice(0, caret) + char + value.slice(caret),
126
+ caret: caret + 1,
127
+ handled: true,
128
+ submit: false,
129
+ };
130
+ }
131
+
132
+ return { value, caret, handled: false, submit: false };
133
+ }
134
+
135
+ function wordLeft(value: string, from: number): number {
136
+ let i = from;
137
+ while (i > 0 && value[i - 1] === " ") i--;
138
+ while (i > 0 && value[i - 1] !== " ") i--;
139
+ return i;
140
+ }
141
+
142
+ function wordRight(value: string, from: number): number {
143
+ let i = from;
144
+ while (i < value.length && value[i] === " ") i++;
145
+ while (i < value.length && value[i] !== " ") i++;
146
+ return i;
147
+ }
@@ -0,0 +1,178 @@
1
+ // Minimal flex-like layout over the retained tree: row/column direction,
2
+ // padding, gap, fixed or content sizing, grow distribution, justify/align.
3
+ // Pure — no Electrobun imports — so it runs under plain `bun test`.
4
+
5
+ import { Align, Justify, NodeKind, Prop, type UiTree } from "./tree";
6
+ import { measure as measureText } from "./text";
7
+
8
+ export { Align, Justify };
9
+
10
+ const isColumn = (tree: UiTree, id: number) => tree.getProp(id, Prop.Dir) === 1;
11
+
12
+ /**
13
+ * Natural (content) size of a node given no forced dimensions. Fixed props
14
+ * win; boxes sum children along the main axis and take the max cross size.
15
+ */
16
+ function measure(tree: UiTree, id: number): { w: number; h: number } {
17
+ const node = tree.get(id);
18
+ const fixedW = node.props[Prop.Width]!;
19
+ const fixedH = node.props[Prop.Height]!;
20
+
21
+ if (node.kind === NodeKind.Text) {
22
+ const size = measureText(node.text, node.props[Prop.FontSize]!);
23
+ return {
24
+ w: fixedW >= 0 ? fixedW : size.w,
25
+ h: fixedH >= 0 ? fixedH : size.h,
26
+ };
27
+ }
28
+
29
+ if (fixedW >= 0 && fixedH >= 0) return { w: fixedW, h: fixedH };
30
+
31
+ const pad = node.props[Prop.Pad]!;
32
+ const gap = node.props[Prop.Gap]!;
33
+ const column = isColumn(tree, id);
34
+
35
+ let main = 0;
36
+ let cross = 0;
37
+ let count = 0;
38
+ for (let c = node.first; c !== 0; c = tree.get(c).next) {
39
+ const size = measure(tree, c);
40
+ const childMain = column ? size.h : size.w;
41
+ const childCross = column ? size.w : size.h;
42
+ main += childMain;
43
+ cross = Math.max(cross, childCross);
44
+ count++;
45
+ }
46
+ if (count > 1) main += gap * (count - 1);
47
+
48
+ const contentW = (column ? cross : main) + pad * 2;
49
+ const contentH = (column ? main : cross) + pad * 2;
50
+ return {
51
+ w: fixedW >= 0 ? fixedW : contentW,
52
+ h: fixedH >= 0 ? fixedH : contentH,
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Assign the final rect for `id` and arrange its children inside it.
58
+ * Coordinates are absolute (window content space).
59
+ */
60
+ function place(
61
+ tree: UiTree,
62
+ id: number,
63
+ x: number,
64
+ y: number,
65
+ w: number,
66
+ h: number,
67
+ ): void {
68
+ const node = tree.get(id);
69
+ node.x = x;
70
+ node.y = y;
71
+ node.w = w;
72
+ node.h = h;
73
+
74
+ if (node.kind !== NodeKind.Box && node.kind !== NodeKind.Anchor) return;
75
+
76
+ const pad = node.props[Prop.Pad]!;
77
+ const gap = node.props[Prop.Gap]!;
78
+ const justify = node.props[Prop.Justify]! as Justify;
79
+ const align = node.props[Prop.Align]! as Align;
80
+ const column = isColumn(tree, id);
81
+
82
+ const innerX = x + pad;
83
+ const innerY = y + pad;
84
+ const innerW = Math.max(0, w - pad * 2);
85
+ const innerH = Math.max(0, h - pad * 2);
86
+ const innerMain = column ? innerH : innerW;
87
+ const innerCross = column ? innerW : innerH;
88
+
89
+ const scrollable = node.props[Prop.Overflow]! === 1;
90
+
91
+ // First pass: natural sizes and grow weights.
92
+ const children = tree.childrenOf(id);
93
+ if (children.length === 0) {
94
+ node.contentMain = pad * 2;
95
+ return;
96
+ }
97
+ const mains: number[] = [];
98
+ const crosses: number[] = [];
99
+ let usedMain = 0;
100
+ let totalGrow = 0;
101
+ for (const child of children) {
102
+ const size = measure(tree, child);
103
+ const main = column ? size.h : size.w;
104
+ const cross = column ? size.w : size.h;
105
+ mains.push(main);
106
+ crosses.push(cross);
107
+ usedMain += main;
108
+ totalGrow += tree.getProp(child, Prop.Grow);
109
+ }
110
+ const gapsTotal = gap * (children.length - 1);
111
+ usedMain += gapsTotal;
112
+ node.contentMain = usedMain + pad * 2;
113
+
114
+ // Distribute leftover space to grow children. Scroll containers keep
115
+ // natural sizes — content defines its own extent.
116
+ const leftover = Math.max(0, innerMain - usedMain);
117
+ if (!scrollable && leftover > 0 && totalGrow > 0) {
118
+ for (let i = 0; i < children.length; i++) {
119
+ const grow = tree.getProp(children[i]!, Prop.Grow);
120
+ if (grow > 0) {
121
+ mains[i]! += (leftover * grow) / totalGrow;
122
+ }
123
+ }
124
+ usedMain = innerMain;
125
+ }
126
+
127
+ // Main-axis start offset / spacing from justify. Scroll containers pin to
128
+ // start and offset by the scroll position instead.
129
+ let cursor = column ? innerY : innerX;
130
+ let spacing = gap;
131
+ if (scrollable) {
132
+ cursor -= node.props[Prop.Scroll]!;
133
+ } else {
134
+ const free = Math.max(0, innerMain - usedMain);
135
+ if (justify === Justify.Center) cursor += free / 2;
136
+ else if (justify === Justify.End) cursor += free;
137
+ else if (justify === Justify.SpaceBetween && children.length > 1) {
138
+ spacing = gap + free / (children.length - 1);
139
+ }
140
+ }
141
+
142
+ for (let i = 0; i < children.length; i++) {
143
+ const child = children[i]!;
144
+ const main = mains[i]!;
145
+ let cross = crosses[i]!;
146
+ if (align === Align.Stretch) {
147
+ // Flexbox rule: stretch only fills auto cross sizes — a fixed
148
+ // width/height wins, and text keeps its intrinsic size.
149
+ const childNode = tree.get(child);
150
+ const crossFixed =
151
+ childNode.props[column ? Prop.Width : Prop.Height]! >= 0;
152
+ if (!crossFixed && childNode.kind !== NodeKind.Text) {
153
+ cross = innerCross;
154
+ }
155
+ }
156
+ cross = Math.min(cross, innerCross);
157
+
158
+ let crossOffset = 0;
159
+ if (align === Align.Center) crossOffset = (innerCross - cross) / 2;
160
+ else if (align === Align.End) crossOffset = innerCross - cross;
161
+
162
+ if (column) {
163
+ place(tree, child, innerX + crossOffset, cursor, cross, main);
164
+ } else {
165
+ place(tree, child, cursor, innerY + crossOffset, main, cross);
166
+ }
167
+ cursor += main + spacing;
168
+ }
169
+ }
170
+
171
+ /** Lay out the whole tree into a width x height window content area. */
172
+ export function computeLayout(
173
+ tree: UiTree,
174
+ width: number,
175
+ height: number,
176
+ ): void {
177
+ place(tree, tree.root, 0, 0, width, height);
178
+ }
@@ -0,0 +1,196 @@
1
+ // Flatten the laid-out tree into the instanced-quad command buffer:
2
+ // 12 floats per instance — rect(x,y,w,h), color(r,g,b,a), misc(radius,0,0,0).
3
+ // Boxes, borders, and glyph runs all become colored rectangles. Instances
4
+ // draw in buffer order, so parents are emitted before children (painter's
5
+ // algorithm). Pure — testable without a GPU.
6
+
7
+ import { NodeKind, Prop, type UiTree } from "./tree";
8
+ import { GLYPH_ADVANCE, GLYPH_H, glyphRuns } from "./font";
9
+ import { atlasEntry, isNativeTextActive } from "./text";
10
+
11
+ export const FLOATS_PER_INSTANCE = 20;
12
+
13
+ // "No clip" sentinel large enough to cover any window.
14
+ const OPEN_CLIP: Clip = { x: -1e7, y: -1e7, w: 2e7, h: 2e7 };
15
+
16
+ interface Clip {
17
+ x: number;
18
+ y: number;
19
+ w: number;
20
+ h: number;
21
+ }
22
+
23
+ function intersectClip(a: Clip, b: Clip): Clip {
24
+ const x = Math.max(a.x, b.x);
25
+ const y = Math.max(a.y, b.y);
26
+ return {
27
+ x,
28
+ y,
29
+ w: Math.max(0, Math.min(a.x + a.w, b.x + b.w) - x),
30
+ h: Math.max(0, Math.min(a.y + a.h, b.y + b.h) - y),
31
+ };
32
+ }
33
+
34
+ export interface PaintBuffer {
35
+ data: Float32Array;
36
+ count: number;
37
+ }
38
+
39
+ export function parseColor(color: string | number): number {
40
+ if (typeof color === "number") return color >>> 0;
41
+ let hex = color.trim();
42
+ if (hex.startsWith("#")) hex = hex.slice(1);
43
+ if (hex.length === 3) {
44
+ hex = hex
45
+ .split("")
46
+ .map((c) => c + c)
47
+ .join("");
48
+ }
49
+ if (hex.length === 6) hex += "ff";
50
+ if (hex.length !== 8) throw new Error(`Unsupported color: ${color}`);
51
+ return Number.parseInt(hex, 16) >>> 0;
52
+ }
53
+
54
+ class InstanceWriter {
55
+ data = new Float32Array(256 * FLOATS_PER_INSTANCE);
56
+ count = 0;
57
+
58
+ emit(
59
+ x: number,
60
+ y: number,
61
+ w: number,
62
+ h: number,
63
+ rgba: number,
64
+ radius: number,
65
+ clip: Clip,
66
+ uv?: { u0: number; v0: number; u1: number; v1: number },
67
+ ): void {
68
+ const a = (rgba & 0xff) / 255;
69
+ if (a === 0 || w <= 0 || h <= 0 || clip.w <= 0 || clip.h <= 0) return;
70
+ // Fully outside the clip: skip the instance entirely.
71
+ if (
72
+ x >= clip.x + clip.w ||
73
+ y >= clip.y + clip.h ||
74
+ x + w <= clip.x ||
75
+ y + h <= clip.y
76
+ ) {
77
+ return;
78
+ }
79
+ if ((this.count + 1) * FLOATS_PER_INSTANCE > this.data.length) {
80
+ const grown = new Float32Array(this.data.length * 2);
81
+ grown.set(this.data);
82
+ this.data = grown;
83
+ }
84
+ const o = this.count * FLOATS_PER_INSTANCE;
85
+ this.data[o] = x;
86
+ this.data[o + 1] = y;
87
+ this.data[o + 2] = w;
88
+ this.data[o + 3] = h;
89
+ this.data[o + 4] = ((rgba >>> 24) & 0xff) / 255;
90
+ this.data[o + 5] = ((rgba >>> 16) & 0xff) / 255;
91
+ this.data[o + 6] = ((rgba >>> 8) & 0xff) / 255;
92
+ this.data[o + 7] = a;
93
+ this.data[o + 8] = radius;
94
+ this.data[o + 9] = uv ? 1 : 0; // textured flag
95
+ this.data[o + 10] = 0;
96
+ this.data[o + 11] = 0;
97
+ this.data[o + 12] = clip.x;
98
+ this.data[o + 13] = clip.y;
99
+ this.data[o + 14] = clip.w;
100
+ this.data[o + 15] = clip.h;
101
+ this.data[o + 16] = uv ? uv.u0 : 0;
102
+ this.data[o + 17] = uv ? uv.v0 : 0;
103
+ this.data[o + 18] = uv ? uv.u1 : 0;
104
+ this.data[o + 19] = uv ? uv.v1 : 0;
105
+ this.count++;
106
+ }
107
+ }
108
+
109
+ function paintText(
110
+ writer: InstanceWriter,
111
+ tree: UiTree,
112
+ id: number,
113
+ clip: Clip,
114
+ ): void {
115
+ const node = tree.get(id);
116
+ const size = node.props[Prop.FontSize]!;
117
+ const color = node.props[Prop.TextColor]! >>> 0;
118
+
119
+ // Native backend: one textured quad sampled from the glyph atlas.
120
+ if (isNativeTextActive()) {
121
+ const entry = atlasEntry(node.text, size);
122
+ if (entry) {
123
+ writer.emit(node.x, node.y, entry.w, entry.h, color, 0, clip, entry);
124
+ }
125
+ return;
126
+ }
127
+
128
+ // Bitmap fallback: glyph runs as rects.
129
+ const cell = size / GLYPH_H;
130
+ let penX = node.x;
131
+ for (const char of node.text) {
132
+ for (const [row, col, len] of glyphRuns(char)) {
133
+ writer.emit(
134
+ penX + col * cell,
135
+ node.y + row * cell,
136
+ len * cell,
137
+ cell,
138
+ color,
139
+ 0,
140
+ clip,
141
+ );
142
+ }
143
+ penX += GLYPH_ADVANCE * cell;
144
+ }
145
+ }
146
+
147
+ function paintNode(
148
+ writer: InstanceWriter,
149
+ tree: UiTree,
150
+ id: number,
151
+ clip: Clip,
152
+ ): void {
153
+ const node = tree.get(id);
154
+
155
+ if (node.kind === NodeKind.Text) {
156
+ paintText(writer, tree, id, clip);
157
+ } else if (node.kind === NodeKind.Box) {
158
+ const bg = node.props[Prop.Bg]! >>> 0;
159
+ const radius = node.props[Prop.Radius]!;
160
+ const borderWidth = node.props[Prop.BorderWidth]!;
161
+ const borderColor = node.props[Prop.BorderColor]! >>> 0;
162
+
163
+ if (borderWidth > 0 && (borderColor & 0xff) !== 0) {
164
+ writer.emit(node.x, node.y, node.w, node.h, borderColor, radius, clip);
165
+ writer.emit(
166
+ node.x + borderWidth,
167
+ node.y + borderWidth,
168
+ node.w - borderWidth * 2,
169
+ node.h - borderWidth * 2,
170
+ bg,
171
+ Math.max(0, radius - borderWidth),
172
+ clip,
173
+ );
174
+ } else {
175
+ writer.emit(node.x, node.y, node.w, node.h, bg, radius, clip);
176
+ }
177
+ }
178
+ // Anchor nodes paint nothing: they only reserve layout space for a
179
+ // native layer composited by nativeWrapper.
180
+
181
+ // Scroll containers clip their children to their own rect.
182
+ const childClip =
183
+ node.props[Prop.Overflow]! === 1
184
+ ? intersectClip(clip, { x: node.x, y: node.y, w: node.w, h: node.h })
185
+ : clip;
186
+
187
+ for (let c = node.first; c !== 0; c = tree.get(c).next) {
188
+ paintNode(writer, tree, c, childClip);
189
+ }
190
+ }
191
+
192
+ export function paint(tree: UiTree): PaintBuffer {
193
+ const writer = new InstanceWriter();
194
+ paintNode(writer, tree, tree.root, OPEN_CLIP);
195
+ return { data: writer.data, count: writer.count };
196
+ }
@@ -0,0 +1,4 @@
1
+ // Warren reactivity core — canonical source lives in src/shared/warren so
2
+ // the browser (DOM) renderer shares one implementation. This shim keeps
3
+ // main/ui-relative imports stable.
4
+ export * from "../../../shared/warren/reactive";