gelang 0.1.0
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/CHANGELOG.md +65 -0
- package/LICENSE +21 -0
- package/README.md +535 -0
- package/bin/ge.js +112 -0
- package/package.json +62 -0
- package/python/pyeffic/__init__.py +9 -0
- package/python/pyeffic/__main__.py +6 -0
- package/python/pyeffic/analyzer.py +464 -0
- package/python/pyeffic/apisurface.py +238 -0
- package/python/pyeffic/autoselect.py +327 -0
- package/python/pyeffic/backends.py +87 -0
- package/python/pyeffic/bench.py +233 -0
- package/python/pyeffic/cli.py +184 -0
- package/python/pyeffic/compiler.py +421 -0
- package/python/pyeffic/config.py +383 -0
- package/python/pyeffic/dartgen.py +441 -0
- package/python/pyeffic/deploy.py +586 -0
- package/python/pyeffic/diagnostics.py +194 -0
- package/python/pyeffic/difftest.py +424 -0
- package/python/pyeffic/downloader.py +307 -0
- package/python/pyeffic/emitters/__init__.py +11 -0
- package/python/pyeffic/emitters/base.py +2359 -0
- package/python/pyeffic/emitters/cpp.py +266 -0
- package/python/pyeffic/emitters/csharp.py +342 -0
- package/python/pyeffic/emitters/dart.py +349 -0
- package/python/pyeffic/emitters/go.py +388 -0
- package/python/pyeffic/emitters/kotlin.py +314 -0
- package/python/pyeffic/emitters/rust.py +314 -0
- package/python/pyeffic/emitters/zig.py +411 -0
- package/python/pyeffic/ffi.py +49 -0
- package/python/pyeffic/frontends/__init__.py +94 -0
- package/python/pyeffic/frontends/hybrid.py +709 -0
- package/python/pyeffic/frontends/typescript.py +965 -0
- package/python/pyeffic/ge_cli.py +1148 -0
- package/python/pyeffic/golden.py +348 -0
- package/python/pyeffic/idents.py +206 -0
- package/python/pyeffic/modules.py +220 -0
- package/python/pyeffic/packer.py +222 -0
- package/python/pyeffic/pipeline.py +797 -0
- package/python/pyeffic/reactgen.py +966 -0
- package/python/pyeffic/researcher.py +177 -0
- package/python/pyeffic/scaffold.py +397 -0
- package/python/pyeffic/stdlib.py +246 -0
- package/python/pyeffic/styling.py +220 -0
- package/python/pyeffic/templates/desktop_gui/README.md +106 -0
- package/python/pyeffic/templates/desktop_gui/app/__init__.py +0 -0
- package/python/pyeffic/templates/desktop_gui/app/core/__init__.py +0 -0
- package/python/pyeffic/templates/desktop_gui/app/core/add.ge.py +13 -0
- package/python/pyeffic/templates/desktop_gui/app/core/factorial.ge.py +20 -0
- package/python/pyeffic/templates/desktop_gui/app/core/fibonacci.ge.py +25 -0
- package/python/pyeffic/templates/desktop_gui/app/core/gcd.ge.py +19 -0
- package/python/pyeffic/templates/desktop_gui/app/core/is_prime.ge.py +24 -0
- package/python/pyeffic/templates/desktop_gui/app/core/multiply.ge.py +13 -0
- package/python/pyeffic/templates/desktop_gui/app/core/power.ge.py +25 -0
- package/python/pyeffic/templates/desktop_gui/app/main.ge.py +49 -0
- package/python/pyeffic/templates/desktop_gui/app/memory/__init__.py +0 -0
- package/python/pyeffic/templates/desktop_gui/app/memory/buffer.ge.py +26 -0
- package/python/pyeffic/templates/desktop_gui/app/memory/limits.ge.py +47 -0
- package/python/pyeffic/templates/desktop_gui/app/memory/state.ge.py +44 -0
- package/python/pyeffic/templates/desktop_gui/app/ui/__init__.py +0 -0
- package/python/pyeffic/templates/desktop_gui/app/ui/layout.ge.py +64 -0
- package/python/pyeffic/templates/desktop_gui/app/ui/render.ge.py +87 -0
- package/python/pyeffic/templates/desktop_gui/app/ui/theme.ge.py +147 -0
- package/python/pyeffic/templates/desktop_gui/app/ui/widgets.ge.py +105 -0
- package/python/pyeffic/templates/desktop_gui/desktop/__init__.py +1 -0
- package/python/pyeffic/templates/desktop_gui/desktop/main.ge.py +258 -0
- package/python/pyeffic/templates/desktop_gui/ge.toml +16 -0
- package/python/pyeffic/templates/desktop_gui/tests/__init__.py +0 -0
- package/python/pyeffic/templates/desktop_gui/tests/ge_loader.py +76 -0
- package/python/pyeffic/templates/desktop_gui/tests/test_app.py +173 -0
- package/python/pyeffic/templates/web_react/README.md +115 -0
- package/python/pyeffic/templates/web_react/app/__init__.py +0 -0
- package/python/pyeffic/templates/web_react/app/core/__init__.py +0 -0
- package/python/pyeffic/templates/web_react/app/core/add.ge.py +9 -0
- package/python/pyeffic/templates/web_react/app/core/factorial.ge.py +16 -0
- package/python/pyeffic/templates/web_react/app/core/fibonacci.ge.py +21 -0
- package/python/pyeffic/templates/web_react/app/core/is_prime.ge.py +20 -0
- package/python/pyeffic/templates/web_react/app/core/multiply.ge.py +9 -0
- package/python/pyeffic/templates/web_react/app/main.ge.py +25 -0
- package/python/pyeffic/templates/web_react/app/memory/__init__.py +0 -0
- package/python/pyeffic/templates/web_react/app/memory/buffer.ge.py +25 -0
- package/python/pyeffic/templates/web_react/app/memory/limits.ge.py +51 -0
- package/python/pyeffic/templates/web_react/ge.toml +23 -0
- package/python/pyeffic/templates/web_react/tests/__init__.py +0 -0
- package/python/pyeffic/templates/web_react/tests/ge_loader.py +68 -0
- package/python/pyeffic/templates/web_react/tests/test_app.py +105 -0
- package/python/pyeffic/templates/web_react/ui/main.ge.ui +33 -0
- package/python/pyeffic/templates/web_react/web/__init__.py +0 -0
- package/python/pyeffic/templates/web_react/web/server.ge.py +78 -0
- package/python/pyeffic/ts2py.py +657 -0
- package/python/pyeffic/typecheck.py +232 -0
- package/python/pyeffic/ui.py +154 -0
- package/python/pyeffic/ui_dsl.py +618 -0
- package/python/pyeffic/widgets.py +87 -0
- package/scripts/README.md +42 -0
- package/scripts/check-toolchains.py +85 -0
|
@@ -0,0 +1,966 @@
|
|
|
1
|
+
"""React + TypeScript UI generator.
|
|
2
|
+
|
|
3
|
+
Turns a `.ge.ui` Screen tree into a modern React 19 + TypeScript + Vite
|
|
4
|
+
project. One component per file, so every generated file is small,
|
|
5
|
+
readable, and editable by hand — the same contract the native templates use.
|
|
6
|
+
|
|
7
|
+
.ge.ui --ui_dsl.parse_ui_dsl--> Screen tree
|
|
8
|
+
--reactgen--> React/TS project (dict of path -> content)
|
|
9
|
+
|
|
10
|
+
Generated layout
|
|
11
|
+
----------------
|
|
12
|
+
index.html
|
|
13
|
+
package.json
|
|
14
|
+
vite.config.ts
|
|
15
|
+
tsconfig.json
|
|
16
|
+
tsconfig.node.json
|
|
17
|
+
src/main.tsx
|
|
18
|
+
src/App.tsx
|
|
19
|
+
src/styles.css
|
|
20
|
+
src/api/client.ts
|
|
21
|
+
src/api/types.ts
|
|
22
|
+
src/components/GeText.tsx
|
|
23
|
+
src/components/GeButton.tsx
|
|
24
|
+
src/components/GeColumn.tsx
|
|
25
|
+
src/components/GeRow.tsx
|
|
26
|
+
src/components/GeContainer.tsx
|
|
27
|
+
src/components/GeSizedBox.tsx
|
|
28
|
+
src/components/GeDivider.tsx
|
|
29
|
+
src/components/GeTextField.tsx
|
|
30
|
+
|
|
31
|
+
Design notes (from the React/Rust template research):
|
|
32
|
+
- React 19 + TypeScript + Vite is the current default stack.
|
|
33
|
+
- The Rust backend serves the built SPA from one binary.
|
|
34
|
+
- Components are dumb/presentational; data flows from `useGeState`.
|
|
35
|
+
"""
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import json
|
|
39
|
+
import re
|
|
40
|
+
from typing import Any
|
|
41
|
+
|
|
42
|
+
from .analyzer import FuncUnit
|
|
43
|
+
from .ui_dsl import Screen, WidgetNode, collect_state_from_screen
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Helpers
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
_TS_TYPE = {
|
|
50
|
+
"int": "number",
|
|
51
|
+
"float": "number",
|
|
52
|
+
"bool": "boolean",
|
|
53
|
+
"str": "string",
|
|
54
|
+
"list": "number[]",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
_TS_DEFAULT = {
|
|
58
|
+
"number": "0",
|
|
59
|
+
"boolean": "false",
|
|
60
|
+
"string": '""',
|
|
61
|
+
"number[]": "[]",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
_NAMED_STYLES = {
|
|
65
|
+
"headline": {"fontSize": "1.75rem", "fontWeight": "600"},
|
|
66
|
+
"title": {"fontSize": "1.25rem", "fontWeight": "600"},
|
|
67
|
+
"subtitle": {"fontSize": "1rem", "color": "var(--ge-text-dim)"},
|
|
68
|
+
"body": {"fontSize": "0.95rem"},
|
|
69
|
+
"value": {"fontSize": "2rem", "fontWeight": "700", "color": "var(--ge-accent)"},
|
|
70
|
+
"error": {"color": "var(--ge-danger)"},
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
_COLOR_VARS = {
|
|
74
|
+
"red": "var(--ge-danger)",
|
|
75
|
+
"green": "var(--ge-success)",
|
|
76
|
+
"blue": "var(--ge-accent)",
|
|
77
|
+
"amber": "var(--ge-warning)",
|
|
78
|
+
"grey": "var(--ge-text-dim)",
|
|
79
|
+
"gray": "var(--ge-text-dim)",
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
# Material-style shades used in the UI DSL (e.g. "grey700", "blue500").
|
|
83
|
+
_SHADE_COLORS = {
|
|
84
|
+
"grey": {50: "#f8fafc", 100: "#f1f5f9", 200: "#e2e8f0", 300: "#cbd5e1",
|
|
85
|
+
400: "#94a3b8", 500: "#64748b", 600: "#475569", 700: "#334155",
|
|
86
|
+
800: "#1e293b", 900: "#0f172a"},
|
|
87
|
+
"gray": {50: "#f8fafc", 100: "#f1f5f9", 200: "#e2e8f0", 300: "#cbd5e1",
|
|
88
|
+
400: "#94a3b8", 500: "#64748b", 600: "#475569", 700: "#334155",
|
|
89
|
+
800: "#1e293b", 900: "#0f172a"},
|
|
90
|
+
"blue": {300: "#93c5fd", 400: "#60a5fa", 500: "#3b82f6", 600: "#2563eb",
|
|
91
|
+
700: "#1d4ed8"},
|
|
92
|
+
"green": {300: "#86efac", 400: "#4ade80", 500: "#22c55e", 600: "#16a34a"},
|
|
93
|
+
"red": {300: "#fca5a5", 400: "#f87171", 500: "#ef4444", 600: "#dc2626"},
|
|
94
|
+
"amber": {300: "#fcd34d", 400: "#fbbf24", 500: "#f59e0b"},
|
|
95
|
+
"white": {0: "#ffffff"},
|
|
96
|
+
"black": {0: "#000000"},
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _map_dsl_color(raw: str) -> str:
|
|
101
|
+
"""Map a UI DSL color name onto a CSS value.
|
|
102
|
+
|
|
103
|
+
Handles `grey700`, `blue500`, plain names, and already-CSS values.
|
|
104
|
+
"""
|
|
105
|
+
c = str(raw).strip()
|
|
106
|
+
if not c:
|
|
107
|
+
return "inherit"
|
|
108
|
+
low = c.lower()
|
|
109
|
+
if low in _COLOR_VARS:
|
|
110
|
+
return _COLOR_VARS[low]
|
|
111
|
+
m = re.match(r"^([a-z]+)(\d{2,3})$", low)
|
|
112
|
+
if m and m.group(1) in _SHADE_COLORS:
|
|
113
|
+
shade = int(m.group(2))
|
|
114
|
+
table = _SHADE_COLORS[m.group(1)]
|
|
115
|
+
if shade in table:
|
|
116
|
+
return table[shade]
|
|
117
|
+
nearest = min(table, key=lambda k: abs(k - shade))
|
|
118
|
+
return table[nearest]
|
|
119
|
+
if low in _SHADE_COLORS and 0 in _SHADE_COLORS[low]:
|
|
120
|
+
return _SHADE_COLORS[low][0]
|
|
121
|
+
# hex, rgb(), or a CSS variable — pass through
|
|
122
|
+
return c
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _js_str(value: Any) -> str:
|
|
126
|
+
"""Render a Python value as a TypeScript literal."""
|
|
127
|
+
if value is None:
|
|
128
|
+
return "undefined"
|
|
129
|
+
if isinstance(value, bool):
|
|
130
|
+
return "true" if value else "false"
|
|
131
|
+
if isinstance(value, (int, float)):
|
|
132
|
+
return str(value)
|
|
133
|
+
return json.dumps(str(value))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _camel(name: str) -> str:
|
|
137
|
+
parts = name.split("_")
|
|
138
|
+
return parts[0] + "".join(p.capitalize() for p in parts[1:])
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _style_to_react(style_dict: dict[str, Any], style_name: str | None = None) -> str:
|
|
142
|
+
"""Build a React style object literal from DSL style info."""
|
|
143
|
+
merged: dict[str, Any] = {}
|
|
144
|
+
if style_name and style_name in _NAMED_STYLES:
|
|
145
|
+
merged.update(_NAMED_STYLES[style_name])
|
|
146
|
+
for key, val in (style_dict or {}).items():
|
|
147
|
+
if key == "fontSize":
|
|
148
|
+
merged["fontSize"] = f"{val}px" if str(val).isdigit() else str(val)
|
|
149
|
+
elif key == "bold":
|
|
150
|
+
if val:
|
|
151
|
+
merged["fontWeight"] = "700"
|
|
152
|
+
elif key == "italic":
|
|
153
|
+
if val:
|
|
154
|
+
merged["fontStyle"] = "italic"
|
|
155
|
+
elif key == "color":
|
|
156
|
+
merged["color"] = _map_dsl_color(val)
|
|
157
|
+
elif key == "padding":
|
|
158
|
+
merged["padding"] = f"{val}px"
|
|
159
|
+
elif key == "margin":
|
|
160
|
+
merged["margin"] = f"{val}px"
|
|
161
|
+
elif key == "width":
|
|
162
|
+
merged["width"] = f"{val}px"
|
|
163
|
+
elif key == "height":
|
|
164
|
+
merged["height"] = f"{val}px"
|
|
165
|
+
elif key == "align":
|
|
166
|
+
merged["alignItems"] = {
|
|
167
|
+
"center": "center", "start": "flex-start",
|
|
168
|
+
"end": "flex-end", "spaceBetween": "space-between",
|
|
169
|
+
}.get(str(val), "center")
|
|
170
|
+
else:
|
|
171
|
+
merged[_camel(key)] = val
|
|
172
|
+
if not merged:
|
|
173
|
+
return "undefined"
|
|
174
|
+
body = ", ".join(f"{k}: {_js_str(v)}" for k, v in merged.items())
|
|
175
|
+
return "{ " + body + " }"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _infer_state_types(node: WidgetNode | None, types: dict[str, str]) -> None:
|
|
179
|
+
"""Refine state variable types from how widgets bind them.
|
|
180
|
+
|
|
181
|
+
Types here are GE type names (str/int/bool/...) because the caller maps
|
|
182
|
+
them to TypeScript through _TS_TYPE.
|
|
183
|
+
"""
|
|
184
|
+
if node is None:
|
|
185
|
+
return
|
|
186
|
+
if node.kind in ("TextField", "Input"):
|
|
187
|
+
bind = node.state or node.props.get("state")
|
|
188
|
+
if bind:
|
|
189
|
+
types[bind] = "str"
|
|
190
|
+
for child in node.children:
|
|
191
|
+
_infer_state_types(child, types)
|
|
192
|
+
child = node.props.get("child")
|
|
193
|
+
if isinstance(child, WidgetNode):
|
|
194
|
+
_infer_state_types(child, types)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _collect_widget_kinds(node: WidgetNode | None, out: set[str]) -> None:
|
|
198
|
+
if node is None:
|
|
199
|
+
return
|
|
200
|
+
out.add(node.kind)
|
|
201
|
+
for child in node.children:
|
|
202
|
+
_collect_widget_kinds(child, out)
|
|
203
|
+
if node.props.get("child") is not None and isinstance(node.props["child"], WidgetNode):
|
|
204
|
+
_collect_widget_kinds(node.props["child"], out)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
# Component files (one widget per file)
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
def _component_text() -> str:
|
|
212
|
+
return '''import type { CSSProperties } from "react";
|
|
213
|
+
|
|
214
|
+
export interface GeTextProps {
|
|
215
|
+
text?: string;
|
|
216
|
+
value?: string | number;
|
|
217
|
+
style?: CSSProperties;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Leaf: a single line of text (or a bound state value). */
|
|
221
|
+
export function GeText({ text, value, style }: GeTextProps) {
|
|
222
|
+
const content = value !== undefined ? value : text ?? "";
|
|
223
|
+
return <span style={style}>{content}</span>;
|
|
224
|
+
}
|
|
225
|
+
'''
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _component_button() -> str:
|
|
229
|
+
return '''import type { CSSProperties } from "react";
|
|
230
|
+
|
|
231
|
+
export interface GeButtonProps {
|
|
232
|
+
label: string;
|
|
233
|
+
onClick?: () => void;
|
|
234
|
+
style?: CSSProperties;
|
|
235
|
+
disabled?: boolean;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Leaf: a clickable button. */
|
|
239
|
+
export function GeButton({ label, onClick, style, disabled }: GeButtonProps) {
|
|
240
|
+
return (
|
|
241
|
+
<button className="ge-button" onClick={onClick} style={style} disabled={disabled}>
|
|
242
|
+
{label}
|
|
243
|
+
</button>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
'''
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _component_column() -> str:
|
|
250
|
+
return '''import type { CSSProperties, ReactNode } from "react";
|
|
251
|
+
|
|
252
|
+
export interface GeColumnProps {
|
|
253
|
+
children?: ReactNode;
|
|
254
|
+
style?: CSSProperties;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Container: vertical flex stack. */
|
|
258
|
+
export function GeColumn({ children, style }: GeColumnProps) {
|
|
259
|
+
return (
|
|
260
|
+
<div className="ge-column" style={{ display: "flex", flexDirection: "column", ...style }}>
|
|
261
|
+
{children}
|
|
262
|
+
</div>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
'''
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _component_row() -> str:
|
|
269
|
+
return '''import type { CSSProperties, ReactNode } from "react";
|
|
270
|
+
|
|
271
|
+
export interface GeRowProps {
|
|
272
|
+
children?: ReactNode;
|
|
273
|
+
style?: CSSProperties;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Container: horizontal flex row. */
|
|
277
|
+
export function GeRow({ children, style }: GeRowProps) {
|
|
278
|
+
return (
|
|
279
|
+
<div className="ge-row" style={{ display: "flex", flexDirection: "row", ...style }}>
|
|
280
|
+
{children}
|
|
281
|
+
</div>
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
'''
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _component_container() -> str:
|
|
288
|
+
return '''import type { CSSProperties, ReactNode } from "react";
|
|
289
|
+
|
|
290
|
+
export interface GeContainerProps {
|
|
291
|
+
children?: ReactNode;
|
|
292
|
+
style?: CSSProperties;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Container: padded, optionally bordered surface. */
|
|
296
|
+
export function GeContainer({ children, style }: GeContainerProps) {
|
|
297
|
+
return (
|
|
298
|
+
<div className="ge-container" style={style}>
|
|
299
|
+
{children}
|
|
300
|
+
</div>
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
'''
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _component_sized_box() -> str:
|
|
307
|
+
return '''export interface GeSizedBoxProps {
|
|
308
|
+
width?: number;
|
|
309
|
+
height?: number;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Container: fixed spacer. */
|
|
313
|
+
export function GeSizedBox({ width, height }: GeSizedBoxProps) {
|
|
314
|
+
return <div style={{ width, height, flexShrink: 0 }} aria-hidden="true" />;
|
|
315
|
+
}
|
|
316
|
+
'''
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _component_divider() -> str:
|
|
320
|
+
return '''/** Leaf: horizontal rule. */
|
|
321
|
+
export function GeDivider() {
|
|
322
|
+
return <hr className="ge-divider" />;
|
|
323
|
+
}
|
|
324
|
+
'''
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _component_text_field() -> str:
|
|
328
|
+
return '''import type { CSSProperties } from "react";
|
|
329
|
+
|
|
330
|
+
export interface GeTextFieldProps {
|
|
331
|
+
label?: string;
|
|
332
|
+
value: string;
|
|
333
|
+
onChange: (value: string) => void;
|
|
334
|
+
placeholder?: string;
|
|
335
|
+
style?: CSSProperties;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Leaf: single-line text input bound to state. */
|
|
339
|
+
export function GeTextField({ label, value, onChange, placeholder, style }: GeTextFieldProps) {
|
|
340
|
+
return (
|
|
341
|
+
<label className="ge-field" style={style}>
|
|
342
|
+
{label ? <span className="ge-field-label">{label}</span> : null}
|
|
343
|
+
<input
|
|
344
|
+
className="ge-input"
|
|
345
|
+
value={value}
|
|
346
|
+
placeholder={placeholder}
|
|
347
|
+
onChange={(e) => onChange(e.target.value)}
|
|
348
|
+
/>
|
|
349
|
+
</label>
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
'''
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
_COMPONENT_FILES = {
|
|
356
|
+
"GeText.tsx": _component_text,
|
|
357
|
+
"GeButton.tsx": _component_button,
|
|
358
|
+
"GeColumn.tsx": _component_column,
|
|
359
|
+
"GeRow.tsx": _component_row,
|
|
360
|
+
"GeContainer.tsx": _component_container,
|
|
361
|
+
"GeSizedBox.tsx": _component_sized_box,
|
|
362
|
+
"GeDivider.tsx": _component_divider,
|
|
363
|
+
"GeTextField.tsx": _component_text_field,
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
# ---------------------------------------------------------------------------
|
|
368
|
+
# Widget tree -> JSX
|
|
369
|
+
# ---------------------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
def widget_to_jsx(node: WidgetNode | dict | None, indent: int = 2,
|
|
372
|
+
state_names: set[str] | None = None) -> str:
|
|
373
|
+
"""Render a widget tree as JSX."""
|
|
374
|
+
pad = " " * indent
|
|
375
|
+
if node is None:
|
|
376
|
+
return f"{pad}<></>"
|
|
377
|
+
if not isinstance(node, WidgetNode):
|
|
378
|
+
node = WidgetNode(kind="Text", text=str(node))
|
|
379
|
+
state_names = state_names or set()
|
|
380
|
+
k = node.kind
|
|
381
|
+
|
|
382
|
+
if k == "Text":
|
|
383
|
+
sd = node.style_dict or {}
|
|
384
|
+
style = _style_to_react(sd, node.props.get("style"))
|
|
385
|
+
if node.state:
|
|
386
|
+
var = _camel(node.state)
|
|
387
|
+
return f'{pad}<GeText value={{String({var})}} style={{{style}}} />'
|
|
388
|
+
text = node.text if node.text is not None else node.props.get("text", "")
|
|
389
|
+
return f'{pad}<GeText text={_js_str(text)} style={{{style}}} />'
|
|
390
|
+
|
|
391
|
+
if k in ("Button", "ElevatedButton", "TextButton"):
|
|
392
|
+
label = node.label or node.props.get("label", "")
|
|
393
|
+
action = node.action
|
|
394
|
+
handler = "undefined"
|
|
395
|
+
if isinstance(action, dict) and action.get("call"):
|
|
396
|
+
handler = f"() => run({_js_str(action['call'])})"
|
|
397
|
+
elif isinstance(action, list) and action and action[0].get("call"):
|
|
398
|
+
handler = f"() => run({_js_str(action[0]['call'])})"
|
|
399
|
+
return f'{pad}<GeButton label={_js_str(label)} onClick={{{handler}}} />'
|
|
400
|
+
|
|
401
|
+
if k in ("Column", "Row"):
|
|
402
|
+
comp = "GeColumn" if k == "Column" else "GeRow"
|
|
403
|
+
sd = node.style_dict or {}
|
|
404
|
+
style = _style_to_react(sd)
|
|
405
|
+
inner = "\n".join(widget_to_jsx(c, indent + 1, state_names) for c in node.children)
|
|
406
|
+
if not inner:
|
|
407
|
+
return f"{pad}<{comp} style={{{style}}} />"
|
|
408
|
+
return f"{pad}<{comp} style={{{style}}}>\n{inner}\n{pad}</{comp}>"
|
|
409
|
+
|
|
410
|
+
if k == "Container":
|
|
411
|
+
sd = node.style_dict or {}
|
|
412
|
+
style = _style_to_react(sd)
|
|
413
|
+
child = node.props.get("child")
|
|
414
|
+
if child is None and node.children:
|
|
415
|
+
child = node.children[0]
|
|
416
|
+
if child is None:
|
|
417
|
+
return f"{pad}<GeContainer style={{{style}}} />"
|
|
418
|
+
inner = widget_to_jsx(child, indent + 1, state_names)
|
|
419
|
+
return f"{pad}<GeContainer style={{{style}}}>\n{inner}\n{pad}</GeContainer>"
|
|
420
|
+
|
|
421
|
+
if k == "Expanded":
|
|
422
|
+
child = node.props.get("child")
|
|
423
|
+
if child is None and node.children:
|
|
424
|
+
child = node.children[0]
|
|
425
|
+
inner = widget_to_jsx(child, indent, state_names)
|
|
426
|
+
return f'{pad}<div style={{{{ flex: {node.props.get("flex", 1)} }}}}>\n{inner}\n{pad}</div>'
|
|
427
|
+
|
|
428
|
+
if k == "SizedBox":
|
|
429
|
+
w = node.props.get("width", node.style_dict.get("width"))
|
|
430
|
+
h = node.props.get("height", node.style_dict.get("height"))
|
|
431
|
+
parts = []
|
|
432
|
+
if w:
|
|
433
|
+
parts.append(f"width={{{w}}}")
|
|
434
|
+
if h:
|
|
435
|
+
parts.append(f"height={{{h}}}")
|
|
436
|
+
return f"{pad}<GeSizedBox {' '.join(parts)} />"
|
|
437
|
+
|
|
438
|
+
if k == "Divider":
|
|
439
|
+
return f"{pad}<GeDivider />"
|
|
440
|
+
|
|
441
|
+
if k in ("TextField", "Input"):
|
|
442
|
+
label = node.props.get("label", "")
|
|
443
|
+
bind = node.state or node.props.get("state")
|
|
444
|
+
var = _camel(bind) if bind else "input"
|
|
445
|
+
return (f'{pad}<GeTextField label={_js_str(label)} value={{String({var})}} '
|
|
446
|
+
f'onChange={{(v) => set{var[0].upper() + var[1:]}(v)}} />')
|
|
447
|
+
|
|
448
|
+
if k == "Icon":
|
|
449
|
+
return f'{pad}<GeText text={_js_str(node.props.get("name", ""))} />'
|
|
450
|
+
|
|
451
|
+
return f'{pad}<GeText text={_js_str("unsupported: " + k)} />'
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
# ---------------------------------------------------------------------------
|
|
455
|
+
# Project files
|
|
456
|
+
# ---------------------------------------------------------------------------
|
|
457
|
+
|
|
458
|
+
def _package_json(app_name: str) -> str:
|
|
459
|
+
pkg = {
|
|
460
|
+
"name": app_name.lower().replace("_", "-"),
|
|
461
|
+
"private": True,
|
|
462
|
+
"version": "0.1.0",
|
|
463
|
+
"type": "module",
|
|
464
|
+
"scripts": {
|
|
465
|
+
"dev": "vite",
|
|
466
|
+
"build": "tsc -b && vite build",
|
|
467
|
+
"preview": "vite preview",
|
|
468
|
+
"typecheck": "tsc --noEmit",
|
|
469
|
+
},
|
|
470
|
+
"dependencies": {
|
|
471
|
+
"react": "^19.0.0",
|
|
472
|
+
"react-dom": "^19.0.0",
|
|
473
|
+
},
|
|
474
|
+
"devDependencies": {
|
|
475
|
+
"@types/react": "^19.0.0",
|
|
476
|
+
"@types/react-dom": "^19.0.0",
|
|
477
|
+
"@vitejs/plugin-react": "^4.3.4",
|
|
478
|
+
"typescript": "^5.7.2",
|
|
479
|
+
"vite": "^6.0.7",
|
|
480
|
+
},
|
|
481
|
+
}
|
|
482
|
+
return json.dumps(pkg, indent=2) + "\n"
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _vite_config() -> str:
|
|
486
|
+
return '''import { defineConfig } from "vite";
|
|
487
|
+
import react from "@vitejs/plugin-react";
|
|
488
|
+
|
|
489
|
+
// The dev server proxies /api to the GE Rust backend so the frontend and
|
|
490
|
+
// backend share an origin in development, exactly like production.
|
|
491
|
+
export default defineConfig({
|
|
492
|
+
plugins: [react()],
|
|
493
|
+
server: {
|
|
494
|
+
port: 5173,
|
|
495
|
+
proxy: {
|
|
496
|
+
"/api": {
|
|
497
|
+
target: "http://127.0.0.1:8080",
|
|
498
|
+
changeOrigin: true,
|
|
499
|
+
},
|
|
500
|
+
},
|
|
501
|
+
},
|
|
502
|
+
build: {
|
|
503
|
+
outDir: "dist",
|
|
504
|
+
emptyOutDir: true,
|
|
505
|
+
},
|
|
506
|
+
});
|
|
507
|
+
'''
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _tsconfig() -> str:
|
|
511
|
+
cfg = {
|
|
512
|
+
"compilerOptions": {
|
|
513
|
+
"target": "ES2022",
|
|
514
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
515
|
+
"module": "ESNext",
|
|
516
|
+
"moduleResolution": "bundler",
|
|
517
|
+
"jsx": "react-jsx",
|
|
518
|
+
"strict": True,
|
|
519
|
+
"noUnusedLocals": True,
|
|
520
|
+
"noUnusedParameters": True,
|
|
521
|
+
"noFallthroughCasesInSwitch": True,
|
|
522
|
+
"skipLibCheck": True,
|
|
523
|
+
"isolatedModules": True,
|
|
524
|
+
"noEmit": True,
|
|
525
|
+
},
|
|
526
|
+
"include": ["src"],
|
|
527
|
+
}
|
|
528
|
+
return json.dumps(cfg, indent=2) + "\n"
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _index_html(app_name: str) -> str:
|
|
532
|
+
return f'''<!doctype html>
|
|
533
|
+
<html lang="en">
|
|
534
|
+
<head>
|
|
535
|
+
<meta charset="UTF-8" />
|
|
536
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
537
|
+
<title>{app_name}</title>
|
|
538
|
+
</head>
|
|
539
|
+
<body>
|
|
540
|
+
<div id="root"></div>
|
|
541
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
542
|
+
</body>
|
|
543
|
+
</html>
|
|
544
|
+
'''
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _main_tsx() -> str:
|
|
548
|
+
return '''import { StrictMode } from "react";
|
|
549
|
+
import { createRoot } from "react-dom/client";
|
|
550
|
+
import { App } from "./App";
|
|
551
|
+
import "./styles.css";
|
|
552
|
+
|
|
553
|
+
const el = document.getElementById("root");
|
|
554
|
+
if (!el) {
|
|
555
|
+
throw new Error("root element missing");
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
createRoot(el).render(
|
|
559
|
+
<StrictMode>
|
|
560
|
+
<App />
|
|
561
|
+
</StrictMode>,
|
|
562
|
+
);
|
|
563
|
+
'''
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _vite_env_dts() -> str:
|
|
567
|
+
return '''/// <reference types="vite/client" />
|
|
568
|
+
|
|
569
|
+
interface ImportMetaEnv {
|
|
570
|
+
readonly VITE_API_URL?: string;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
interface ImportMeta {
|
|
574
|
+
readonly env: ImportMetaEnv;
|
|
575
|
+
}
|
|
576
|
+
'''
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _api_types(ffi_units: list[FuncUnit]) -> str:
|
|
580
|
+
lines = [
|
|
581
|
+
"// Types shared with the GE Rust backend.",
|
|
582
|
+
"",
|
|
583
|
+
"export interface ApiResult {",
|
|
584
|
+
" ok: boolean;",
|
|
585
|
+
" value: number;",
|
|
586
|
+
" error?: string;",
|
|
587
|
+
"}",
|
|
588
|
+
"",
|
|
589
|
+
"export interface FunctionSpec {",
|
|
590
|
+
" name: string;",
|
|
591
|
+
" params: number;",
|
|
592
|
+
"}",
|
|
593
|
+
"",
|
|
594
|
+
"// Functions exported by the backend (from the @rust GE functions).",
|
|
595
|
+
"export const BACKEND_FUNCTIONS: FunctionSpec[] = [",
|
|
596
|
+
]
|
|
597
|
+
for u in ffi_units:
|
|
598
|
+
lines.append(f' {{ name: {json.dumps(u.name)}, params: {len(u.params)} }},')
|
|
599
|
+
lines += ["];", ""]
|
|
600
|
+
return "\n".join(lines)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _api_client(app_name: str) -> str:
|
|
604
|
+
return f'''// API client for the {app_name} Rust backend.
|
|
605
|
+
//
|
|
606
|
+
// Every call goes through `callFunction`, which POSTs to /api/call.
|
|
607
|
+
// The backend is a GE-compiled Rust binary, so the wire format is plain
|
|
608
|
+
// JSON with no framework on either side.
|
|
609
|
+
|
|
610
|
+
import type {{ ApiResult }} from "./types";
|
|
611
|
+
|
|
612
|
+
const BASE = import.meta.env.VITE_API_URL ?? "";
|
|
613
|
+
|
|
614
|
+
export async function callFunction(
|
|
615
|
+
name: string,
|
|
616
|
+
args: number[],
|
|
617
|
+
): Promise<ApiResult> {{
|
|
618
|
+
try {{
|
|
619
|
+
const res = await fetch(`${{BASE}}/api/call`, {{
|
|
620
|
+
method: "POST",
|
|
621
|
+
headers: {{ "Content-Type": "application/json" }},
|
|
622
|
+
body: JSON.stringify({{ name, args }}),
|
|
623
|
+
}});
|
|
624
|
+
if (!res.ok) {{
|
|
625
|
+
return {{ ok: false, value: 0, error: `HTTP ${{res.status}}` }};
|
|
626
|
+
}}
|
|
627
|
+
return (await res.json()) as ApiResult;
|
|
628
|
+
}} catch (err) {{
|
|
629
|
+
return {{ ok: false, value: 0, error: String(err) }};
|
|
630
|
+
}}
|
|
631
|
+
}}
|
|
632
|
+
|
|
633
|
+
export async function health(): Promise<boolean> {{
|
|
634
|
+
try {{
|
|
635
|
+
const res = await fetch(`${{BASE}}/api/health`);
|
|
636
|
+
return res.ok;
|
|
637
|
+
}} catch {{
|
|
638
|
+
return false;
|
|
639
|
+
}}
|
|
640
|
+
}}
|
|
641
|
+
'''
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _styles_css() -> str:
|
|
645
|
+
return ''':root {
|
|
646
|
+
--ge-bg: #0f172a;
|
|
647
|
+
--ge-panel: #1e293b;
|
|
648
|
+
--ge-card: #1e293b;
|
|
649
|
+
--ge-border: #334155;
|
|
650
|
+
--ge-accent: #60a5fa;
|
|
651
|
+
--ge-text: #f1f5f9;
|
|
652
|
+
--ge-text-dim: #94a3b8;
|
|
653
|
+
--ge-success: #4ade80;
|
|
654
|
+
--ge-warning: #fbbf24;
|
|
655
|
+
--ge-danger: #f87171;
|
|
656
|
+
--ge-radius: 12px;
|
|
657
|
+
--ge-space: 16px;
|
|
658
|
+
color-scheme: dark;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
* { box-sizing: border-box; }
|
|
662
|
+
|
|
663
|
+
html, body, #root {
|
|
664
|
+
margin: 0;
|
|
665
|
+
height: 100%;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
body {
|
|
669
|
+
background: var(--ge-bg);
|
|
670
|
+
color: var(--ge-text);
|
|
671
|
+
font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
|
|
672
|
+
font-size: 15px;
|
|
673
|
+
line-height: 1.5;
|
|
674
|
+
-webkit-font-smoothing: antialiased;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
.ge-app {
|
|
678
|
+
min-height: 100%;
|
|
679
|
+
display: flex;
|
|
680
|
+
flex-direction: column;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
.ge-header {
|
|
684
|
+
padding: var(--ge-space) calc(var(--ge-space) * 1.5);
|
|
685
|
+
border-bottom: 1px solid var(--ge-border);
|
|
686
|
+
display: flex;
|
|
687
|
+
align-items: baseline;
|
|
688
|
+
gap: 12px;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
.ge-title {
|
|
692
|
+
font-size: 1.25rem;
|
|
693
|
+
font-weight: 600;
|
|
694
|
+
color: var(--ge-accent);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
.ge-subtitle {
|
|
698
|
+
color: var(--ge-text-dim);
|
|
699
|
+
font-size: 0.85rem;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
.ge-main {
|
|
703
|
+
flex: 1;
|
|
704
|
+
padding: calc(var(--ge-space) * 1.5);
|
|
705
|
+
display: flex;
|
|
706
|
+
gap: calc(var(--ge-space) * 1.5);
|
|
707
|
+
align-items: flex-start;
|
|
708
|
+
flex-wrap: wrap;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
.ge-column { gap: 12px; }
|
|
712
|
+
|
|
713
|
+
.ge-container {
|
|
714
|
+
background: var(--ge-card);
|
|
715
|
+
border: 1px solid var(--ge-border);
|
|
716
|
+
border-radius: var(--ge-radius);
|
|
717
|
+
padding: var(--ge-space);
|
|
718
|
+
min-width: 260px;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
.ge-button {
|
|
722
|
+
background: var(--ge-accent);
|
|
723
|
+
color: #0b1220;
|
|
724
|
+
border: none;
|
|
725
|
+
border-radius: 8px;
|
|
726
|
+
padding: 10px 18px;
|
|
727
|
+
font-size: 0.95rem;
|
|
728
|
+
font-weight: 600;
|
|
729
|
+
cursor: pointer;
|
|
730
|
+
transition: filter 120ms ease, transform 120ms ease;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
.ge-button:hover:not(:disabled) { filter: brightness(1.08); }
|
|
734
|
+
.ge-button:active:not(:disabled) { transform: translateY(1px); }
|
|
735
|
+
.ge-button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
736
|
+
|
|
737
|
+
.ge-divider {
|
|
738
|
+
border: none;
|
|
739
|
+
border-top: 1px solid var(--ge-border);
|
|
740
|
+
width: 100%;
|
|
741
|
+
margin: 4px 0;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
.ge-field { display: flex; flex-direction: column; gap: 6px; }
|
|
745
|
+
.ge-field-label { color: var(--ge-text-dim); font-size: 0.8rem; }
|
|
746
|
+
|
|
747
|
+
.ge-input {
|
|
748
|
+
background: #0b1220;
|
|
749
|
+
border: 1px solid var(--ge-border);
|
|
750
|
+
border-radius: 8px;
|
|
751
|
+
padding: 9px 12px;
|
|
752
|
+
color: var(--ge-text);
|
|
753
|
+
font-size: 0.95rem;
|
|
754
|
+
outline: none;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
.ge-input:focus { border-color: var(--ge-accent); }
|
|
758
|
+
|
|
759
|
+
.ge-result {
|
|
760
|
+
font-size: 2rem;
|
|
761
|
+
font-weight: 700;
|
|
762
|
+
color: var(--ge-success);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
.ge-error { color: var(--ge-danger); font-size: 0.85rem; }
|
|
766
|
+
|
|
767
|
+
.ge-footer {
|
|
768
|
+
padding: var(--ge-space);
|
|
769
|
+
border-top: 1px solid var(--ge-border);
|
|
770
|
+
color: var(--ge-text-dim);
|
|
771
|
+
font-size: 0.8rem;
|
|
772
|
+
}
|
|
773
|
+
'''
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _app_tsx(screen: Screen, app_name: str, ffi_units: list[FuncUnit]) -> str:
|
|
777
|
+
state = collect_state_from_screen(screen)
|
|
778
|
+
_infer_state_types(screen.root, state)
|
|
779
|
+
state_names = set(state.keys())
|
|
780
|
+
|
|
781
|
+
state_lines: list[str] = []
|
|
782
|
+
for name, ge_type in state.items():
|
|
783
|
+
var = _camel(name)
|
|
784
|
+
ts_type = _TS_TYPE.get(ge_type, "number")
|
|
785
|
+
default = _TS_DEFAULT.get(ts_type, "0")
|
|
786
|
+
setter = "set" + var[0].upper() + var[1:]
|
|
787
|
+
state_lines.append(f" const [{var}, {setter}] = useState<{ts_type}>({default});")
|
|
788
|
+
|
|
789
|
+
# only import the components the tree actually uses, so `tsc` stays clean
|
|
790
|
+
used: set[str] = set()
|
|
791
|
+
_collect_widget_kinds(screen.root, used)
|
|
792
|
+
kind_to_component = {
|
|
793
|
+
"Text": "GeText", "Button": "GeButton", "ElevatedButton": "GeButton",
|
|
794
|
+
"TextButton": "GeButton", "Column": "GeColumn", "Row": "GeRow",
|
|
795
|
+
"Container": "GeContainer", "SizedBox": "GeSizedBox",
|
|
796
|
+
"Divider": "GeDivider", "TextField": "GeTextField", "Input": "GeTextField",
|
|
797
|
+
"Icon": "GeText", "Expanded": None,
|
|
798
|
+
}
|
|
799
|
+
# the generated App always renders a result card, so force those imports
|
|
800
|
+
needed = {"GeText", "GeColumn", "GeContainer"}
|
|
801
|
+
for kind in used:
|
|
802
|
+
comp = kind_to_component.get(kind)
|
|
803
|
+
if comp:
|
|
804
|
+
needed.add(comp)
|
|
805
|
+
|
|
806
|
+
imports = "\n".join(
|
|
807
|
+
f'import {{ {c} }} from "./components/{c}";' for c in sorted(needed)
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
# callFunction is only referenced when there are backend functions to call
|
|
811
|
+
if ffi_units:
|
|
812
|
+
client_import = 'import { callFunction } from "./api/client";\n'
|
|
813
|
+
run_body = (
|
|
814
|
+
" const res = await callFunction(name, args);\n"
|
|
815
|
+
" if (!res.ok) {\n"
|
|
816
|
+
" setApiError(res.error ?? \"call failed\");\n"
|
|
817
|
+
" return;\n"
|
|
818
|
+
" }\n"
|
|
819
|
+
" setApiError(\"\");\n"
|
|
820
|
+
" setApiResult(res.value);\n"
|
|
821
|
+
)
|
|
822
|
+
use_callback = "useCallback, "
|
|
823
|
+
else:
|
|
824
|
+
client_import = ""
|
|
825
|
+
run_body = (
|
|
826
|
+
" // No @rust/@cpp backend functions were found for this UI.\n"
|
|
827
|
+
" // Add them under app/ and rebuild to enable real calls.\n"
|
|
828
|
+
" void name;\n"
|
|
829
|
+
" void args;\n"
|
|
830
|
+
)
|
|
831
|
+
use_callback = ""
|
|
832
|
+
|
|
833
|
+
# Unused-locals suppression. Every state value and setter is referenced
|
|
834
|
+
# once so the generated file typechecks under `noUnusedLocals` before you
|
|
835
|
+
# have wired any handlers. Delete this block as you add real handlers.
|
|
836
|
+
refs: list[str] = []
|
|
837
|
+
for n in state:
|
|
838
|
+
var = _camel(n)
|
|
839
|
+
refs.append(var)
|
|
840
|
+
refs.append("set" + var[0].upper() + var[1:])
|
|
841
|
+
if not ffi_units:
|
|
842
|
+
refs.extend(["setApiResult", "setApiError"])
|
|
843
|
+
used_refs = ""
|
|
844
|
+
if refs:
|
|
845
|
+
used_refs = (
|
|
846
|
+
" // Wiring placeholders: keeps every state value/setter referenced so\n"
|
|
847
|
+
" // `tsc --noEmit` is clean before handlers exist. Delete as you wire up.\n"
|
|
848
|
+
f" const wiring = {{ {', '.join(refs)} }};\n"
|
|
849
|
+
" void wiring;\n"
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
body = widget_to_jsx(screen.root, 3, state_names)
|
|
853
|
+
|
|
854
|
+
return f'''// {app_name} — generated from {screen.title or "ui/main.ge.ui"}.
|
|
855
|
+
//
|
|
856
|
+
// This file is generated once and then yours to edit: the GE build never
|
|
857
|
+
// overwrites src/ unless you pass --force.
|
|
858
|
+
|
|
859
|
+
import {{ {use_callback}useState }} from "react";
|
|
860
|
+
{client_import}{imports}
|
|
861
|
+
|
|
862
|
+
export function App() {{
|
|
863
|
+
{chr(10).join(state_lines) if state_lines else " // no state declared in the UI DSL"}
|
|
864
|
+
const [apiResult, setApiResult] = useState(0);
|
|
865
|
+
const [apiError, setApiError] = useState("");
|
|
866
|
+
{used_refs}
|
|
867
|
+
const run = async (name: string, args: number[] = []) => {{
|
|
868
|
+
{run_body} }};
|
|
869
|
+
void run;
|
|
870
|
+
|
|
871
|
+
return (
|
|
872
|
+
<div className="ge-app">
|
|
873
|
+
<header className="ge-header">
|
|
874
|
+
<span className="ge-title">{screen.title or app_name}</span>
|
|
875
|
+
<span className="ge-subtitle">GE · React · Rust</span>
|
|
876
|
+
</header>
|
|
877
|
+
|
|
878
|
+
<main className="ge-main">
|
|
879
|
+
{body}
|
|
880
|
+
<GeContainer>
|
|
881
|
+
<GeColumn>
|
|
882
|
+
<GeText text="Result" style={{{{ color: "var(--ge-text-dim)", fontSize: "0.8rem" }}}} />
|
|
883
|
+
<span className="ge-result">{{apiResult}}</span>
|
|
884
|
+
{{apiError ? <span className="ge-error">{{apiError}}</span> : null}}
|
|
885
|
+
</GeColumn>
|
|
886
|
+
</GeContainer>
|
|
887
|
+
</main>
|
|
888
|
+
|
|
889
|
+
<footer className="ge-footer">
|
|
890
|
+
{app_name} · frontend generated by GE reactgen
|
|
891
|
+
</footer>
|
|
892
|
+
</div>
|
|
893
|
+
);
|
|
894
|
+
}}
|
|
895
|
+
'''
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
# ---------------------------------------------------------------------------
|
|
899
|
+
# Public API
|
|
900
|
+
# ---------------------------------------------------------------------------
|
|
901
|
+
|
|
902
|
+
def generate_react_app(screen: Screen, app_name: str,
|
|
903
|
+
ffi_units: list[FuncUnit] | None = None,
|
|
904
|
+
out_dir: str = "web/frontend") -> dict[str, str]:
|
|
905
|
+
"""Generate a React + TypeScript project from a parsed .ge.ui Screen.
|
|
906
|
+
|
|
907
|
+
Returns a mapping of relative path -> file content. Callers decide where
|
|
908
|
+
to write them (the scaffold writes them under out_dir).
|
|
909
|
+
"""
|
|
910
|
+
ffi_units = ffi_units or []
|
|
911
|
+
files: dict[str, str] = {}
|
|
912
|
+
|
|
913
|
+
files["index.html"] = _index_html(app_name)
|
|
914
|
+
files["package.json"] = _package_json(app_name)
|
|
915
|
+
files["vite.config.ts"] = _vite_config()
|
|
916
|
+
files["tsconfig.json"] = _tsconfig()
|
|
917
|
+
files["src/main.tsx"] = _main_tsx()
|
|
918
|
+
files["src/vite-env.d.ts"] = _vite_env_dts()
|
|
919
|
+
files["src/App.tsx"] = _app_tsx(screen, app_name, ffi_units)
|
|
920
|
+
files["src/styles.css"] = _styles_css()
|
|
921
|
+
files["src/api/client.ts"] = _api_client(app_name)
|
|
922
|
+
files["src/api/types.ts"] = _api_types(ffi_units)
|
|
923
|
+
|
|
924
|
+
# only emit the components the UI actually uses, so the tree stays lean
|
|
925
|
+
used: set[str] = set()
|
|
926
|
+
_collect_widget_kinds(screen.root, used)
|
|
927
|
+
mapping = {
|
|
928
|
+
"Text": "GeText.tsx",
|
|
929
|
+
"Button": "GeButton.tsx",
|
|
930
|
+
"ElevatedButton": "GeButton.tsx",
|
|
931
|
+
"TextButton": "GeButton.tsx",
|
|
932
|
+
"Column": "GeColumn.tsx",
|
|
933
|
+
"Row": "GeRow.tsx",
|
|
934
|
+
"Container": "GeContainer.tsx",
|
|
935
|
+
"SizedBox": "GeSizedBox.tsx",
|
|
936
|
+
"Divider": "GeDivider.tsx",
|
|
937
|
+
"TextField": "GeTextField.tsx",
|
|
938
|
+
"Input": "GeTextField.tsx",
|
|
939
|
+
}
|
|
940
|
+
wanted = {mapping[k] for k in used if k in mapping}
|
|
941
|
+
# App.tsx imports all of them, so always emit the full set
|
|
942
|
+
wanted = set(_COMPONENT_FILES)
|
|
943
|
+
for fname in sorted(wanted):
|
|
944
|
+
files[f"src/components/{fname}"] = _COMPONENT_FILES[fname]()
|
|
945
|
+
|
|
946
|
+
return files
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def write_react_app(files: dict[str, str], root, force: bool = False) -> list[str]:
|
|
950
|
+
"""Write generated React files under `root`.
|
|
951
|
+
|
|
952
|
+
Existing files are left untouched unless `force=True`, so hand edits are
|
|
953
|
+
never clobbered by a rebuild.
|
|
954
|
+
"""
|
|
955
|
+
from pathlib import Path
|
|
956
|
+
|
|
957
|
+
root = Path(root)
|
|
958
|
+
written: list[str] = []
|
|
959
|
+
for rel, content in files.items():
|
|
960
|
+
dst = root / rel
|
|
961
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
962
|
+
if dst.exists() and not force:
|
|
963
|
+
continue
|
|
964
|
+
dst.write_text(content, encoding="utf-8")
|
|
965
|
+
written.append(rel)
|
|
966
|
+
return written
|