electrobun 1.18.4-beta.18 → 1.18.4-beta.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/bin/electrobun.cjs +165 -0
- package/dist/api/browser/ui/__tests__/dom.test.ts +473 -0
- package/dist/api/browser/ui/__tests__/domStub.ts +218 -0
- package/dist/api/browser/ui/dom.ts +490 -0
- package/dist/api/browser/ui/index.ts +44 -0
- package/dist/api/browser/ui/jsx-dev-runtime.ts +16 -0
- package/dist/api/browser/ui/jsx-runtime.ts +56 -0
- package/dist/api/config/ElectrobunConfig.ts +33 -0
- package/dist/api/preload/.generated/compiled.ts +1 -1
- package/dist/api/preload/index.ts +2 -0
- package/dist/api/preload/uiTag.ts +45 -0
- package/dist/api/sdks/main/__tests__/utils-quit-exit-code.test.ts +44 -0
- package/dist/api/sdks/main/core/GpuWindow.ts +19 -0
- package/dist/api/sdks/main/core/Utils.ts +52 -4
- package/dist/api/sdks/main/core/WGPUView.ts +9 -0
- package/dist/api/sdks/main/entries/ui.ts +1 -0
- package/dist/api/sdks/main/proc/native.ts +187 -0
- package/dist/api/sdks/main/ui/__tests__/font.test.ts +49 -0
- package/dist/api/sdks/main/ui/__tests__/hit.test.ts +64 -0
- package/dist/api/sdks/main/ui/__tests__/jsx.test.ts +252 -0
- package/dist/api/sdks/main/ui/__tests__/layout.test.ts +159 -0
- package/dist/api/sdks/main/ui/__tests__/paint.test.ts +115 -0
- package/dist/api/sdks/main/ui/__tests__/reactive.test.ts +456 -0
- package/dist/api/sdks/main/ui/__tests__/scroll-focus-input.test.ts +298 -0
- package/dist/api/sdks/main/ui/__tests__/tree.test.ts +96 -0
- package/dist/api/sdks/main/ui/__tests__/ui.test.ts +170 -0
- package/dist/api/sdks/main/ui/elements.ts +135 -0
- package/dist/api/sdks/main/ui/font.ts +168 -0
- package/dist/api/sdks/main/ui/hit.ts +46 -0
- package/dist/api/sdks/main/ui/index.ts +71 -0
- package/dist/api/sdks/main/ui/input.ts +268 -0
- package/dist/api/sdks/main/ui/jsx-dev-runtime.ts +16 -0
- package/dist/api/sdks/main/ui/jsx-runtime.ts +136 -0
- package/dist/api/sdks/main/ui/keymap.ts +147 -0
- package/dist/api/sdks/main/ui/layout.ts +178 -0
- package/dist/api/sdks/main/ui/paint.ts +196 -0
- package/dist/api/sdks/main/ui/reactive.ts +4 -0
- package/dist/api/sdks/main/ui/renderer.ts +278 -0
- package/dist/api/sdks/main/ui/text.ts +175 -0
- package/dist/api/sdks/main/ui/textInput.ts +121 -0
- package/dist/api/sdks/main/ui/tree.ts +276 -0
- package/dist/api/sdks/main/ui/ui.ts +457 -0
- package/dist/api/sdks/main/ui/uiTagHost.ts +56 -0
- package/dist/api/sdks/main/ui/uiwindow.ts +330 -0
- package/dist/api/shared/build-dependencies.test.ts +1 -1
- package/dist/api/shared/build-dependencies.ts +4 -4
- package/dist/api/shared/linux-webkit-automation.test.ts +1 -1
- package/dist/api/shared/warren/jsx.ts +279 -0
- package/dist/api/shared/warren/reactive.ts +638 -0
- package/dist/api/shared/windows-unicode-ui.test.ts +4 -4
- package/dist/preload-full.js +35 -0
- package/dist/zig-sdk/electrobun.zig +197 -162
- package/{dash.config.ts → hutch.config.ts} +4 -3
- package/package.json +14 -2
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// Minimal DOM implementation for headless renderer tests — no third-party
|
|
2
|
+
// dependency. Implements exactly the surface Warren's DOM renderer touches;
|
|
3
|
+
// if the renderer starts using more DOM, this stub fails loudly and the new
|
|
4
|
+
// surface gets added here consciously.
|
|
5
|
+
|
|
6
|
+
export class StubNode {
|
|
7
|
+
nodeType: number;
|
|
8
|
+
ownerDocument: StubDocument | null = null;
|
|
9
|
+
parentNode: StubNode | null = null;
|
|
10
|
+
childNodes: StubNode[] = [];
|
|
11
|
+
|
|
12
|
+
constructor(nodeType: number) {
|
|
13
|
+
this.nodeType = nodeType;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
get nextSibling(): StubNode | null {
|
|
17
|
+
if (!this.parentNode) return null;
|
|
18
|
+
const siblings = this.parentNode.childNodes;
|
|
19
|
+
const idx = siblings.indexOf(this);
|
|
20
|
+
return idx >= 0 && idx + 1 < siblings.length ? siblings[idx + 1]! : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
get firstChild(): StubNode | null {
|
|
24
|
+
return this.childNodes[0] ?? null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
insertBefore(node: StubNode, before: StubNode | null): StubNode {
|
|
28
|
+
if (node.parentNode) node.parentNode.removeChild(node);
|
|
29
|
+
if (before === null) {
|
|
30
|
+
this.childNodes.push(node);
|
|
31
|
+
} else {
|
|
32
|
+
const idx = this.childNodes.indexOf(before);
|
|
33
|
+
if (idx < 0) throw new Error("stub: insertBefore anchor not a child");
|
|
34
|
+
this.childNodes.splice(idx, 0, node);
|
|
35
|
+
}
|
|
36
|
+
node.parentNode = this;
|
|
37
|
+
return node;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
appendChild(node: StubNode): StubNode {
|
|
41
|
+
return this.insertBefore(node, null);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
removeChild(node: StubNode): StubNode {
|
|
45
|
+
const idx = this.childNodes.indexOf(node);
|
|
46
|
+
if (idx < 0) throw new Error("stub: removeChild of non-child");
|
|
47
|
+
this.childNodes.splice(idx, 1);
|
|
48
|
+
node.parentNode = null;
|
|
49
|
+
return node;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class StubText extends StubNode {
|
|
54
|
+
data = "";
|
|
55
|
+
constructor() {
|
|
56
|
+
super(3);
|
|
57
|
+
}
|
|
58
|
+
get textContent(): string {
|
|
59
|
+
return this.data;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class StubComment extends StubNode {
|
|
64
|
+
data = "";
|
|
65
|
+
constructor() {
|
|
66
|
+
super(8);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
class StubClassList {
|
|
71
|
+
private owner: StubElement;
|
|
72
|
+
constructor(owner: StubElement) {
|
|
73
|
+
this.owner = owner;
|
|
74
|
+
}
|
|
75
|
+
private set(): Set<string> {
|
|
76
|
+
return new Set((this.owner.className ?? "").split(/\s+/).filter(Boolean));
|
|
77
|
+
}
|
|
78
|
+
private write(set: Set<string>): void {
|
|
79
|
+
this.owner.className = [...set].join(" ");
|
|
80
|
+
}
|
|
81
|
+
add(cls: string): void {
|
|
82
|
+
const s = this.set();
|
|
83
|
+
s.add(cls);
|
|
84
|
+
this.write(s);
|
|
85
|
+
}
|
|
86
|
+
remove(cls: string): void {
|
|
87
|
+
const s = this.set();
|
|
88
|
+
s.delete(cls);
|
|
89
|
+
this.write(s);
|
|
90
|
+
}
|
|
91
|
+
toggle(cls: string, force?: boolean): boolean {
|
|
92
|
+
const s = this.set();
|
|
93
|
+
const on = force ?? !s.has(cls);
|
|
94
|
+
if (on) s.add(cls);
|
|
95
|
+
else s.delete(cls);
|
|
96
|
+
this.write(s);
|
|
97
|
+
return on;
|
|
98
|
+
}
|
|
99
|
+
contains(cls: string): boolean {
|
|
100
|
+
return this.set().has(cls);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
class StubStyle {
|
|
105
|
+
[key: string]: unknown;
|
|
106
|
+
cssText = "";
|
|
107
|
+
setProperty(name: string, value: string): void {
|
|
108
|
+
(this as Record<string, unknown>)[name] = value;
|
|
109
|
+
}
|
|
110
|
+
removeProperty(name: string): void {
|
|
111
|
+
delete (this as Record<string, unknown>)[name];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export class StubElement extends StubNode {
|
|
116
|
+
tagName: string;
|
|
117
|
+
namespaceURI: string;
|
|
118
|
+
className = "";
|
|
119
|
+
attributes = new Map<string, string>();
|
|
120
|
+
style = new StubStyle();
|
|
121
|
+
classList = new StubClassList(this);
|
|
122
|
+
listeners = new Map<string, Array<(event: unknown) => void>>();
|
|
123
|
+
// Property-set keys land as plain fields:
|
|
124
|
+
value: unknown;
|
|
125
|
+
checked: unknown;
|
|
126
|
+
innerHTML: unknown;
|
|
127
|
+
|
|
128
|
+
constructor(tagName: string, namespaceURI = "") {
|
|
129
|
+
super(1);
|
|
130
|
+
this.tagName = tagName;
|
|
131
|
+
this.namespaceURI = namespaceURI;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
setAttribute(name: string, value: string): void {
|
|
135
|
+
this.attributes.set(name, value);
|
|
136
|
+
}
|
|
137
|
+
getAttribute(name: string): string | null {
|
|
138
|
+
return this.attributes.get(name) ?? null;
|
|
139
|
+
}
|
|
140
|
+
removeAttribute(name: string): void {
|
|
141
|
+
this.attributes.delete(name);
|
|
142
|
+
}
|
|
143
|
+
hasAttribute(name: string): boolean {
|
|
144
|
+
return this.attributes.has(name);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
addEventListener(type: string, handler: (event: unknown) => void): void {
|
|
148
|
+
const list = this.listeners.get(type) ?? [];
|
|
149
|
+
list.push(handler);
|
|
150
|
+
this.listeners.set(type, list);
|
|
151
|
+
}
|
|
152
|
+
removeEventListener(type: string, handler: (event: unknown) => void): void {
|
|
153
|
+
const list = this.listeners.get(type);
|
|
154
|
+
if (!list) return;
|
|
155
|
+
const idx = list.indexOf(handler);
|
|
156
|
+
if (idx >= 0) list.splice(idx, 1);
|
|
157
|
+
}
|
|
158
|
+
dispatch(type: string, event: unknown = { type }): void {
|
|
159
|
+
for (const handler of this.listeners.get(type) ?? []) handler(event);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Test helper: concatenated text of the subtree. */
|
|
163
|
+
get textContent(): string {
|
|
164
|
+
let out = "";
|
|
165
|
+
for (const child of this.childNodes) {
|
|
166
|
+
if (child instanceof StubText) out += child.data;
|
|
167
|
+
else if (child instanceof StubElement) out += child.textContent;
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Test helper: element children only (comments/text skipped). */
|
|
173
|
+
get children(): StubElement[] {
|
|
174
|
+
return this.childNodes.filter(
|
|
175
|
+
(n): n is StubElement => n instanceof StubElement,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export class StubDocument {
|
|
181
|
+
body: StubElement;
|
|
182
|
+
|
|
183
|
+
constructor() {
|
|
184
|
+
this.body = this.createElement("body");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private adopt<T extends StubNode>(node: T): T {
|
|
188
|
+
node.ownerDocument = this;
|
|
189
|
+
return node;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
createElement(tagName: string): StubElement {
|
|
193
|
+
return this.adopt(new StubElement(tagName));
|
|
194
|
+
}
|
|
195
|
+
createElementNS(ns: string, tagName: string): StubElement {
|
|
196
|
+
return this.adopt(new StubElement(tagName, ns));
|
|
197
|
+
}
|
|
198
|
+
createTextNode(data: string): StubText {
|
|
199
|
+
const node = this.adopt(new StubText());
|
|
200
|
+
node.data = data;
|
|
201
|
+
return node;
|
|
202
|
+
}
|
|
203
|
+
createComment(data: string): StubComment {
|
|
204
|
+
const node = this.adopt(new StubComment());
|
|
205
|
+
node.data = data;
|
|
206
|
+
return node;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** A fresh document + detached container, typed loosely for render(). */
|
|
211
|
+
export function createStubRoot(): {
|
|
212
|
+
document: StubDocument;
|
|
213
|
+
container: StubElement;
|
|
214
|
+
} {
|
|
215
|
+
const document = new StubDocument();
|
|
216
|
+
const container = document.createElement("div");
|
|
217
|
+
return { document, container };
|
|
218
|
+
}
|
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
// Warren's DOM renderer — the browser/webview twin of the GPU retained-tree
|
|
2
|
+
// renderer. Same reactivity core, same JSX semantics (components run once,
|
|
3
|
+
// reactivity is explicit via live()), rendered into real DOM nodes.
|
|
4
|
+
//
|
|
5
|
+
// The renderer never enumerates HTML tags: any tag name becomes
|
|
6
|
+
// document.createElement(tag) (createElementNS inside <svg>), Solid-style.
|
|
7
|
+
// The only per-key knowledge is the small property-vs-attribute table below.
|
|
8
|
+
//
|
|
9
|
+
// Regions (dynamic, each) are delimited by comment anchors so conditional
|
|
10
|
+
// and keyed content can live inside any parent without wrapper elements.
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
claimLive,
|
|
14
|
+
cleanup,
|
|
15
|
+
createChildScope,
|
|
16
|
+
createRoot,
|
|
17
|
+
disposeScope,
|
|
18
|
+
getOwner,
|
|
19
|
+
isLive,
|
|
20
|
+
liveScope,
|
|
21
|
+
runWithOwner,
|
|
22
|
+
signal,
|
|
23
|
+
type Accessor,
|
|
24
|
+
type LiveBinding,
|
|
25
|
+
} from "../../shared/warren/reactive";
|
|
26
|
+
import {
|
|
27
|
+
createJsxRuntime,
|
|
28
|
+
element,
|
|
29
|
+
type UIChild,
|
|
30
|
+
type UIElement,
|
|
31
|
+
type WarrenRenderer,
|
|
32
|
+
} from "../../shared/warren/jsx";
|
|
33
|
+
|
|
34
|
+
function bareFunctionError(key: string): never {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`Warren: prop "${key}" received a bare function. Wrap reactive expressions with live(() => ...) from "electrobun/browser/ui", or pass a plain value.`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Insertion point — where created nodes attach. Building is synchronous, so
|
|
42
|
+
// a single ambient {parent, before} pair (insertBefore semantics) is enough.
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
interface InsertPoint {
|
|
46
|
+
parent: Node;
|
|
47
|
+
/** Insert before this node; null appends. */
|
|
48
|
+
before: Node | null;
|
|
49
|
+
/** Namespace for created elements ("" = HTML, else SVG etc.). */
|
|
50
|
+
ns: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let insert: InsertPoint | null = null;
|
|
54
|
+
|
|
55
|
+
function requireInsert(): InsertPoint {
|
|
56
|
+
if (!insert) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
"Warren: no active mount — build DOM elements inside render(...)",
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return insert;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function withInsert<T>(point: InsertPoint, fn: () => T): T {
|
|
65
|
+
const prev = insert;
|
|
66
|
+
insert = point;
|
|
67
|
+
try {
|
|
68
|
+
return fn();
|
|
69
|
+
} finally {
|
|
70
|
+
insert = prev;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function docOf(node: Node): Document {
|
|
75
|
+
return (node.ownerDocument ?? (node as unknown as Document)) as Document;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function insertNode(node: Node): void {
|
|
79
|
+
const point = requireInsert();
|
|
80
|
+
point.parent.insertBefore(node, point.before);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The element (or container) nodes are currently attaching into — escape
|
|
85
|
+
* hatch for imperative DOM code inside a bare-function child.
|
|
86
|
+
*/
|
|
87
|
+
export function currentParent(): Node {
|
|
88
|
+
return requireInsert().parent;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Props: property-vs-attribute, class/style shapes, events, refs.
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
96
|
+
|
|
97
|
+
/** Keys that must be set as element properties, not attributes. */
|
|
98
|
+
const PROPERTY_KEYS = new Set([
|
|
99
|
+
"value",
|
|
100
|
+
"checked",
|
|
101
|
+
"selected",
|
|
102
|
+
"muted",
|
|
103
|
+
"volume",
|
|
104
|
+
"srcObject",
|
|
105
|
+
"currentTime",
|
|
106
|
+
"indeterminate",
|
|
107
|
+
"innerHTML",
|
|
108
|
+
"innerText",
|
|
109
|
+
"textContent",
|
|
110
|
+
"scrollTop",
|
|
111
|
+
"scrollLeft",
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
const ATTR_ALIASES: Record<string, string> = {
|
|
115
|
+
className: "class",
|
|
116
|
+
htmlFor: "for",
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
function applyStyle(el: any, value: unknown): void {
|
|
120
|
+
if (value == null) {
|
|
121
|
+
el.removeAttribute?.("style");
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (typeof value === "string") {
|
|
125
|
+
el.style.cssText = value;
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const style = el.style;
|
|
129
|
+
for (const [name, v] of Object.entries(value as Record<string, unknown>)) {
|
|
130
|
+
if (v == null || v === false) {
|
|
131
|
+
if (name.startsWith("--")) style.removeProperty(name);
|
|
132
|
+
else style[name] = "";
|
|
133
|
+
} else if (name.startsWith("--")) {
|
|
134
|
+
style.setProperty(name, String(v));
|
|
135
|
+
} else {
|
|
136
|
+
style[name] = String(v);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function applyClassList(el: any, value: unknown): void {
|
|
142
|
+
if (value == null) return;
|
|
143
|
+
for (const [name, on] of Object.entries(value as Record<string, unknown>)) {
|
|
144
|
+
// Multi-class keys ("a b") toggle each class, matching Solid.
|
|
145
|
+
for (const cls of name.split(/\s+/)) {
|
|
146
|
+
if (cls) el.classList.toggle(cls, !!on);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function applyProp(el: any, key: string, value: unknown): void {
|
|
152
|
+
if (key === "class" || key === "className") {
|
|
153
|
+
if (el.namespaceURI === SVG_NS) {
|
|
154
|
+
if (value == null) el.removeAttribute("class");
|
|
155
|
+
else el.setAttribute("class", String(value));
|
|
156
|
+
} else {
|
|
157
|
+
el.className = value == null ? "" : String(value);
|
|
158
|
+
}
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (key === "classList") {
|
|
162
|
+
applyClassList(el, value);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (key === "style") {
|
|
166
|
+
applyStyle(el, value);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (PROPERTY_KEYS.has(key)) {
|
|
170
|
+
el[key] = value;
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const attr = ATTR_ALIASES[key] ?? key;
|
|
174
|
+
if (value == null || value === false) {
|
|
175
|
+
el.removeAttribute(attr);
|
|
176
|
+
} else if (value === true) {
|
|
177
|
+
el.setAttribute(attr, "");
|
|
178
|
+
} else {
|
|
179
|
+
el.setAttribute(attr, String(value));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function applyProps(el: any, props: Record<string, unknown>): void {
|
|
184
|
+
for (const [key, raw] of Object.entries(props)) {
|
|
185
|
+
if (raw === undefined) continue;
|
|
186
|
+
if (key === "ref") {
|
|
187
|
+
if (typeof raw !== "function") {
|
|
188
|
+
throw new Error("Warren: ref takes a function: ref={(el) => ...}");
|
|
189
|
+
}
|
|
190
|
+
raw(el);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (key.startsWith("on") && key.length > 2) {
|
|
194
|
+
if (typeof raw !== "function") {
|
|
195
|
+
throw new Error(`Warren: handler "${key}" must be a function`);
|
|
196
|
+
}
|
|
197
|
+
el.addEventListener(key.slice(2).toLowerCase(), raw);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (isLive(raw)) {
|
|
201
|
+
claimLive(raw as LiveBinding<unknown>, (v) => applyProp(el, key, v));
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (typeof raw === "function") bareFunctionError(key);
|
|
205
|
+
applyProp(el, key, raw);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
// Renderer primitives
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
function createText(value: string | number | LiveBinding<string | number>): void {
|
|
214
|
+
const point = requireInsert();
|
|
215
|
+
const node = docOf(point.parent).createTextNode("");
|
|
216
|
+
if (isLive(value)) {
|
|
217
|
+
claimLive(value as LiveBinding<string | number>, (v) => {
|
|
218
|
+
node.data = v == null ? "" : String(v);
|
|
219
|
+
});
|
|
220
|
+
} else {
|
|
221
|
+
node.data = String(value);
|
|
222
|
+
}
|
|
223
|
+
insertNode(node);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Remove every node strictly between `start` and `end` (exclusive). */
|
|
227
|
+
function clearRange(parent: Node, start: Node, end: Node): void {
|
|
228
|
+
let node = start.nextSibling;
|
|
229
|
+
while (node && node !== end) {
|
|
230
|
+
const next = node.nextSibling;
|
|
231
|
+
parent.removeChild(node);
|
|
232
|
+
node = next;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function createDynamic(build: () => void): void {
|
|
237
|
+
const point = requireInsert();
|
|
238
|
+
const doc = docOf(point.parent);
|
|
239
|
+
const start = doc.createComment("warren");
|
|
240
|
+
const end = doc.createComment("/warren");
|
|
241
|
+
insertNode(start);
|
|
242
|
+
insertNode(end);
|
|
243
|
+
const parent = point.parent;
|
|
244
|
+
const ns = point.ns;
|
|
245
|
+
liveScope(() => {
|
|
246
|
+
clearRange(parent, start, end);
|
|
247
|
+
withInsert({ parent, before: end, ns }, build);
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
interface RowEntry {
|
|
252
|
+
scope: unknown;
|
|
253
|
+
start: Comment;
|
|
254
|
+
end: Comment;
|
|
255
|
+
setIndex: (i: number) => void;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Move the inclusive node range [entry.start, entry.end] before `before`. */
|
|
259
|
+
function moveRange(parent: Node, entry: RowEntry, before: Node | null): void {
|
|
260
|
+
let node: Node = entry.start;
|
|
261
|
+
for (;;) {
|
|
262
|
+
const next = node.nextSibling;
|
|
263
|
+
parent.insertBefore(node, before);
|
|
264
|
+
if (node === entry.end) break;
|
|
265
|
+
node = next!;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function removeRange(parent: Node, entry: RowEntry): void {
|
|
270
|
+
clearRange(parent, entry.start, entry.end);
|
|
271
|
+
parent.removeChild(entry.start);
|
|
272
|
+
parent.removeChild(entry.end);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function createEach<T>(
|
|
276
|
+
items: () => readonly T[],
|
|
277
|
+
key: (item: T, index: number) => string | number,
|
|
278
|
+
render: (item: T, index: Accessor<number>) => void,
|
|
279
|
+
): void {
|
|
280
|
+
const point = requireInsert();
|
|
281
|
+
const doc = docOf(point.parent);
|
|
282
|
+
const start = doc.createComment("warren:each");
|
|
283
|
+
const end = doc.createComment("/warren:each");
|
|
284
|
+
insertNode(start);
|
|
285
|
+
insertNode(end);
|
|
286
|
+
const parent = point.parent;
|
|
287
|
+
const ns = point.ns;
|
|
288
|
+
const hostOwner = getOwner();
|
|
289
|
+
if (!hostOwner) {
|
|
290
|
+
throw new Error("Warren: each must be created inside a reactive root");
|
|
291
|
+
}
|
|
292
|
+
// Row scopes parent under the host scope (not the diff scope), so rows
|
|
293
|
+
// survive re-runs and are torn down with the region.
|
|
294
|
+
const entries = new Map<string | number, RowEntry>();
|
|
295
|
+
cleanup(() => entries.clear());
|
|
296
|
+
|
|
297
|
+
liveScope(() => {
|
|
298
|
+
const list = items();
|
|
299
|
+
const seen = new Set<string | number>();
|
|
300
|
+
let prevEnd: Node = start;
|
|
301
|
+
|
|
302
|
+
for (let i = 0; i < list.length; i++) {
|
|
303
|
+
const item = list[i]!;
|
|
304
|
+
const k = key(item, i);
|
|
305
|
+
if (seen.has(k)) {
|
|
306
|
+
throw new Error(`Warren: <For> duplicate key "${String(k)}"`);
|
|
307
|
+
}
|
|
308
|
+
seen.add(k);
|
|
309
|
+
|
|
310
|
+
let entry = entries.get(k);
|
|
311
|
+
if (!entry) {
|
|
312
|
+
const scope = createChildScope(hostOwner);
|
|
313
|
+
const [index, setIndex] = signal(i);
|
|
314
|
+
const rowStart = doc.createComment("row");
|
|
315
|
+
const rowEnd = doc.createComment("/row");
|
|
316
|
+
parent.insertBefore(rowStart, end);
|
|
317
|
+
parent.insertBefore(rowEnd, end);
|
|
318
|
+
// runWithOwner suspends tracking, so the diff scope never tracks
|
|
319
|
+
// reads made while building a row.
|
|
320
|
+
runWithOwner(scope, () =>
|
|
321
|
+
withInsert({ parent, before: rowEnd, ns }, () =>
|
|
322
|
+
render(item, index),
|
|
323
|
+
),
|
|
324
|
+
);
|
|
325
|
+
entry = { scope, start: rowStart, end: rowEnd, setIndex };
|
|
326
|
+
entries.set(k, entry);
|
|
327
|
+
} else {
|
|
328
|
+
entry.setIndex(i);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Keep sibling order in sync with item order.
|
|
332
|
+
if (prevEnd.nextSibling !== entry.start) {
|
|
333
|
+
moveRange(parent, entry, prevEnd.nextSibling);
|
|
334
|
+
}
|
|
335
|
+
prevEnd = entry.end;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Remove rows whose keys are gone.
|
|
339
|
+
for (const [k, entry] of entries) {
|
|
340
|
+
if (!seen.has(k)) {
|
|
341
|
+
disposeScope(entry.scope);
|
|
342
|
+
removeRange(parent, entry);
|
|
343
|
+
entries.delete(k);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function createIntrinsic(type: string, props: Record<string, unknown>): void {
|
|
350
|
+
const point = requireInsert();
|
|
351
|
+
const doc = docOf(point.parent);
|
|
352
|
+
const ns = type === "svg" ? SVG_NS : point.ns;
|
|
353
|
+
const el =
|
|
354
|
+
ns !== ""
|
|
355
|
+
? doc.createElementNS(ns, type)
|
|
356
|
+
: doc.createElement(type);
|
|
357
|
+
const { children, ...rest } = props;
|
|
358
|
+
applyProps(el, rest);
|
|
359
|
+
insertNode(el);
|
|
360
|
+
if (children !== undefined) {
|
|
361
|
+
// foreignObject re-enters HTML namespace.
|
|
362
|
+
const childNs = type === "foreignObject" ? "" : ns;
|
|
363
|
+
withInsert({ parent: el, before: null, ns: childNs }, () =>
|
|
364
|
+
mountChild(children as UIChild),
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function createLiveChild(binding: LiveBinding<unknown>): void {
|
|
370
|
+
const point = requireInsert();
|
|
371
|
+
const doc = docOf(point.parent);
|
|
372
|
+
const start = doc.createComment("warren");
|
|
373
|
+
const end = doc.createComment("/warren");
|
|
374
|
+
insertNode(start);
|
|
375
|
+
insertNode(end);
|
|
376
|
+
const parent = point.parent;
|
|
377
|
+
const ns = point.ns;
|
|
378
|
+
liveScope(() => {
|
|
379
|
+
const value = binding.fn();
|
|
380
|
+
if (typeof value === "string" || typeof value === "number") {
|
|
381
|
+
// Primitive result: update a single text node in place instead of
|
|
382
|
+
// rebuilding the region.
|
|
383
|
+
const existing = start.nextSibling;
|
|
384
|
+
if (
|
|
385
|
+
existing !== null &&
|
|
386
|
+
existing !== end &&
|
|
387
|
+
existing.nodeType === 3 &&
|
|
388
|
+
existing.nextSibling === end
|
|
389
|
+
) {
|
|
390
|
+
(existing as Text).data = String(value);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
clearRange(parent, start, end);
|
|
394
|
+
parent.insertBefore(doc.createTextNode(String(value)), end);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
clearRange(parent, start, end);
|
|
398
|
+
if (value == null || typeof value === "boolean") return;
|
|
399
|
+
withInsert({ parent, before: end, ns }, () =>
|
|
400
|
+
mountChild(value as UIChild),
|
|
401
|
+
);
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const renderer: WarrenRenderer = {
|
|
406
|
+
text: createText,
|
|
407
|
+
liveChild: createLiveChild,
|
|
408
|
+
dynamic: createDynamic,
|
|
409
|
+
each: createEach,
|
|
410
|
+
intrinsic: createIntrinsic,
|
|
411
|
+
escape(fn) {
|
|
412
|
+
// A bare function child runs imperatively against the current parent;
|
|
413
|
+
// if it returns something mountable, mount it.
|
|
414
|
+
const result = fn();
|
|
415
|
+
if (result != null) mountChild(result as UIChild);
|
|
416
|
+
},
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
const runtime = createJsxRuntime(renderer);
|
|
420
|
+
export const jsx = runtime.jsx;
|
|
421
|
+
export const jsxs = runtime.jsxs;
|
|
422
|
+
export const Fragment = runtime.Fragment;
|
|
423
|
+
export const mountChild = runtime.mountChild;
|
|
424
|
+
export const Show = runtime.Show;
|
|
425
|
+
export const For = runtime.For;
|
|
426
|
+
export const Switch = runtime.Switch;
|
|
427
|
+
export { Match } from "../../shared/warren/jsx";
|
|
428
|
+
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
// Mounting
|
|
431
|
+
// ---------------------------------------------------------------------------
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Mount a Warren app into a DOM container. The container's existing content
|
|
435
|
+
* is left alone; everything Warren created is removed on dispose.
|
|
436
|
+
*/
|
|
437
|
+
export function render(
|
|
438
|
+
app: () => UIElement | UIChild | void,
|
|
439
|
+
container: Element,
|
|
440
|
+
): () => void {
|
|
441
|
+
const doc = docOf(container);
|
|
442
|
+
const start = doc.createComment("warren:root");
|
|
443
|
+
const end = doc.createComment("/warren:root");
|
|
444
|
+
container.appendChild(start);
|
|
445
|
+
container.appendChild(end);
|
|
446
|
+
let disposeRoot = () => {};
|
|
447
|
+
createRoot((dispose) => {
|
|
448
|
+
disposeRoot = dispose;
|
|
449
|
+
withInsert({ parent: container, before: end, ns: "" }, () => {
|
|
450
|
+
const result = app();
|
|
451
|
+
if (result !== undefined) mountChild(result as UIChild);
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
return () => {
|
|
455
|
+
disposeRoot();
|
|
456
|
+
clearRange(container, start, end);
|
|
457
|
+
container.removeChild(start);
|
|
458
|
+
container.removeChild(end);
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Render children into a different parent (dialogs, context menus) while
|
|
464
|
+
* keeping ownership — cleanup removes them when the owning scope goes away.
|
|
465
|
+
*/
|
|
466
|
+
export function Portal(props: {
|
|
467
|
+
mount?: Element;
|
|
468
|
+
children?: UIChild;
|
|
469
|
+
}): UIElement {
|
|
470
|
+
return element(() => {
|
|
471
|
+
const point = requireInsert();
|
|
472
|
+
const target = props.mount ?? docOf(point.parent).body;
|
|
473
|
+
if (!target) {
|
|
474
|
+
throw new Error("Warren: <Portal> needs a mount element (no body)");
|
|
475
|
+
}
|
|
476
|
+
const doc = docOf(target);
|
|
477
|
+
const start = doc.createComment("warren:portal");
|
|
478
|
+
const end = doc.createComment("/warren:portal");
|
|
479
|
+
target.appendChild(start);
|
|
480
|
+
target.appendChild(end);
|
|
481
|
+
cleanup(() => {
|
|
482
|
+
clearRange(target, start, end);
|
|
483
|
+
target.removeChild(start);
|
|
484
|
+
target.removeChild(end);
|
|
485
|
+
});
|
|
486
|
+
withInsert({ parent: target, before: end, ns: "" }, () =>
|
|
487
|
+
mountChild(props.children),
|
|
488
|
+
);
|
|
489
|
+
});
|
|
490
|
+
}
|