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.
Files changed (96) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/LICENSE +21 -0
  3. package/README.md +535 -0
  4. package/bin/ge.js +112 -0
  5. package/package.json +62 -0
  6. package/python/pyeffic/__init__.py +9 -0
  7. package/python/pyeffic/__main__.py +6 -0
  8. package/python/pyeffic/analyzer.py +464 -0
  9. package/python/pyeffic/apisurface.py +238 -0
  10. package/python/pyeffic/autoselect.py +327 -0
  11. package/python/pyeffic/backends.py +87 -0
  12. package/python/pyeffic/bench.py +233 -0
  13. package/python/pyeffic/cli.py +184 -0
  14. package/python/pyeffic/compiler.py +421 -0
  15. package/python/pyeffic/config.py +383 -0
  16. package/python/pyeffic/dartgen.py +441 -0
  17. package/python/pyeffic/deploy.py +586 -0
  18. package/python/pyeffic/diagnostics.py +194 -0
  19. package/python/pyeffic/difftest.py +424 -0
  20. package/python/pyeffic/downloader.py +307 -0
  21. package/python/pyeffic/emitters/__init__.py +11 -0
  22. package/python/pyeffic/emitters/base.py +2359 -0
  23. package/python/pyeffic/emitters/cpp.py +266 -0
  24. package/python/pyeffic/emitters/csharp.py +342 -0
  25. package/python/pyeffic/emitters/dart.py +349 -0
  26. package/python/pyeffic/emitters/go.py +388 -0
  27. package/python/pyeffic/emitters/kotlin.py +314 -0
  28. package/python/pyeffic/emitters/rust.py +314 -0
  29. package/python/pyeffic/emitters/zig.py +411 -0
  30. package/python/pyeffic/ffi.py +49 -0
  31. package/python/pyeffic/frontends/__init__.py +94 -0
  32. package/python/pyeffic/frontends/hybrid.py +709 -0
  33. package/python/pyeffic/frontends/typescript.py +965 -0
  34. package/python/pyeffic/ge_cli.py +1148 -0
  35. package/python/pyeffic/golden.py +348 -0
  36. package/python/pyeffic/idents.py +206 -0
  37. package/python/pyeffic/modules.py +220 -0
  38. package/python/pyeffic/packer.py +222 -0
  39. package/python/pyeffic/pipeline.py +797 -0
  40. package/python/pyeffic/reactgen.py +966 -0
  41. package/python/pyeffic/researcher.py +177 -0
  42. package/python/pyeffic/scaffold.py +397 -0
  43. package/python/pyeffic/stdlib.py +246 -0
  44. package/python/pyeffic/styling.py +220 -0
  45. package/python/pyeffic/templates/desktop_gui/README.md +106 -0
  46. package/python/pyeffic/templates/desktop_gui/app/__init__.py +0 -0
  47. package/python/pyeffic/templates/desktop_gui/app/core/__init__.py +0 -0
  48. package/python/pyeffic/templates/desktop_gui/app/core/add.ge.py +13 -0
  49. package/python/pyeffic/templates/desktop_gui/app/core/factorial.ge.py +20 -0
  50. package/python/pyeffic/templates/desktop_gui/app/core/fibonacci.ge.py +25 -0
  51. package/python/pyeffic/templates/desktop_gui/app/core/gcd.ge.py +19 -0
  52. package/python/pyeffic/templates/desktop_gui/app/core/is_prime.ge.py +24 -0
  53. package/python/pyeffic/templates/desktop_gui/app/core/multiply.ge.py +13 -0
  54. package/python/pyeffic/templates/desktop_gui/app/core/power.ge.py +25 -0
  55. package/python/pyeffic/templates/desktop_gui/app/main.ge.py +49 -0
  56. package/python/pyeffic/templates/desktop_gui/app/memory/__init__.py +0 -0
  57. package/python/pyeffic/templates/desktop_gui/app/memory/buffer.ge.py +26 -0
  58. package/python/pyeffic/templates/desktop_gui/app/memory/limits.ge.py +47 -0
  59. package/python/pyeffic/templates/desktop_gui/app/memory/state.ge.py +44 -0
  60. package/python/pyeffic/templates/desktop_gui/app/ui/__init__.py +0 -0
  61. package/python/pyeffic/templates/desktop_gui/app/ui/layout.ge.py +64 -0
  62. package/python/pyeffic/templates/desktop_gui/app/ui/render.ge.py +87 -0
  63. package/python/pyeffic/templates/desktop_gui/app/ui/theme.ge.py +147 -0
  64. package/python/pyeffic/templates/desktop_gui/app/ui/widgets.ge.py +105 -0
  65. package/python/pyeffic/templates/desktop_gui/desktop/__init__.py +1 -0
  66. package/python/pyeffic/templates/desktop_gui/desktop/main.ge.py +258 -0
  67. package/python/pyeffic/templates/desktop_gui/ge.toml +16 -0
  68. package/python/pyeffic/templates/desktop_gui/tests/__init__.py +0 -0
  69. package/python/pyeffic/templates/desktop_gui/tests/ge_loader.py +76 -0
  70. package/python/pyeffic/templates/desktop_gui/tests/test_app.py +173 -0
  71. package/python/pyeffic/templates/web_react/README.md +115 -0
  72. package/python/pyeffic/templates/web_react/app/__init__.py +0 -0
  73. package/python/pyeffic/templates/web_react/app/core/__init__.py +0 -0
  74. package/python/pyeffic/templates/web_react/app/core/add.ge.py +9 -0
  75. package/python/pyeffic/templates/web_react/app/core/factorial.ge.py +16 -0
  76. package/python/pyeffic/templates/web_react/app/core/fibonacci.ge.py +21 -0
  77. package/python/pyeffic/templates/web_react/app/core/is_prime.ge.py +20 -0
  78. package/python/pyeffic/templates/web_react/app/core/multiply.ge.py +9 -0
  79. package/python/pyeffic/templates/web_react/app/main.ge.py +25 -0
  80. package/python/pyeffic/templates/web_react/app/memory/__init__.py +0 -0
  81. package/python/pyeffic/templates/web_react/app/memory/buffer.ge.py +25 -0
  82. package/python/pyeffic/templates/web_react/app/memory/limits.ge.py +51 -0
  83. package/python/pyeffic/templates/web_react/ge.toml +23 -0
  84. package/python/pyeffic/templates/web_react/tests/__init__.py +0 -0
  85. package/python/pyeffic/templates/web_react/tests/ge_loader.py +68 -0
  86. package/python/pyeffic/templates/web_react/tests/test_app.py +105 -0
  87. package/python/pyeffic/templates/web_react/ui/main.ge.ui +33 -0
  88. package/python/pyeffic/templates/web_react/web/__init__.py +0 -0
  89. package/python/pyeffic/templates/web_react/web/server.ge.py +78 -0
  90. package/python/pyeffic/ts2py.py +657 -0
  91. package/python/pyeffic/typecheck.py +232 -0
  92. package/python/pyeffic/ui.py +154 -0
  93. package/python/pyeffic/ui_dsl.py +618 -0
  94. package/python/pyeffic/widgets.py +87 -0
  95. package/scripts/README.md +42 -0
  96. 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