echoui 0.9.0__py3-none-any.whl

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 (78) hide show
  1. echoui/__init__.py +31 -0
  2. echoui/__main__.py +3 -0
  3. echoui/a11y/__init__.py +58 -0
  4. echoui/animation.py +95 -0
  5. echoui/api/__init__.py +82 -0
  6. echoui/app.py +50 -0
  7. echoui/async_.py +17 -0
  8. echoui/audio/__init__.py +1 -0
  9. echoui/bridge/__init__.py +45 -0
  10. echoui/camera.py +56 -0
  11. echoui/canvas.py +31 -0
  12. echoui/chain.py +43 -0
  13. echoui/cli.py +148 -0
  14. echoui/clone.py +42 -0
  15. echoui/collab/__init__.py +47 -0
  16. echoui/compiler/__init__.py +1 -0
  17. echoui/compiler/analyzer.py +21 -0
  18. echoui/compiler/bundler.py +69 -0
  19. echoui/compiler/emit_web.py +162 -0
  20. echoui/compiler/lower.py +28 -0
  21. echoui/compiler/optimizer.py +9 -0
  22. echoui/compiler/parser.py +44 -0
  23. echoui/compiler/ssr.py +24 -0
  24. echoui/contrib/__init__.py +3 -0
  25. echoui/contrib/devtools.py +37 -0
  26. echoui/desktop/__init__.py +1 -0
  27. echoui/events.py +50 -0
  28. echoui/exceptions.py +9 -0
  29. echoui/forms/__init__.py +149 -0
  30. echoui/gestures.py +102 -0
  31. echoui/graphql/__init__.py +1 -0
  32. echoui/i18n/__init__.py +35 -0
  33. echoui/input.py +49 -0
  34. echoui/layout.py +103 -0
  35. echoui/media/__init__.py +1 -0
  36. echoui/mobile/__init__.py +1 -0
  37. echoui/monitor.py +17 -0
  38. echoui/overlay.py +80 -0
  39. echoui/pathfind.py +47 -0
  40. echoui/pen.py +40 -0
  41. echoui/physics.py +79 -0
  42. echoui/platform/__init__.py +37 -0
  43. echoui/plugin.py +26 -0
  44. echoui/print/__init__.py +23 -0
  45. echoui/query/__init__.py +127 -0
  46. echoui/raw.py +61 -0
  47. echoui/reactive.py +181 -0
  48. echoui/roles.py +36 -0
  49. echoui/router/__init__.py +82 -0
  50. echoui/rpc/__init__.py +1 -0
  51. echoui/rtc/__init__.py +1 -0
  52. echoui/runtime/__init__.py +1 -0
  53. echoui/screen.py +36 -0
  54. echoui/signals.py +37 -0
  55. echoui/sprite.py +140 -0
  56. echoui/stage.py +38 -0
  57. echoui/state.py +70 -0
  58. echoui/storage/__init__.py +116 -0
  59. echoui/style.py +56 -0
  60. echoui/svg.py +26 -0
  61. echoui/targets/__init__.py +3 -0
  62. echoui/targets/desktop.py +55 -0
  63. echoui/targets/gui.py +40 -0
  64. echoui/targets/mobile_android.py +34 -0
  65. echoui/targets/static.py +31 -0
  66. echoui/targets/tui.py +41 -0
  67. echoui/tasks.py +20 -0
  68. echoui/testing/__init__.py +121 -0
  69. echoui/three/__init__.py +1 -0
  70. echoui/tiles.py +33 -0
  71. echoui/time.py +20 -0
  72. echoui/wasm.py +15 -0
  73. echoui/workers.py +19 -0
  74. echoui-0.9.0.dist-info/METADATA +137 -0
  75. echoui-0.9.0.dist-info/RECORD +78 -0
  76. echoui-0.9.0.dist-info/WHEEL +4 -0
  77. echoui-0.9.0.dist-info/entry_points.txt +2 -0
  78. echoui-0.9.0.dist-info/licenses/LICENSE +21 -0
echoui/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ """EchoUI public API surface."""
2
+
3
+ from echoui.app import App
4
+ from echoui.events import on
5
+ from echoui.layout import button, col, row, text
6
+ from echoui.reactive import computed
7
+ from echoui.screen import Screen
8
+ from echoui.signals import signal
9
+ from echoui.sprite import Sprite
10
+ from echoui.stage import Stage
11
+ from echoui.state import Store
12
+ from echoui.style import style
13
+
14
+ __version__ = "0.9.0"
15
+
16
+ __all__ = [
17
+ "App",
18
+ "Screen",
19
+ "Stage",
20
+ "Sprite",
21
+ "Store",
22
+ "computed",
23
+ "signal",
24
+ "row",
25
+ "col",
26
+ "text",
27
+ "button",
28
+ "style",
29
+ "on",
30
+ "__version__",
31
+ ]
echoui/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from echoui.cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,58 @@
1
+ """Accessibility helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict
6
+
7
+ from echoui.sprite import IRNode
8
+
9
+
10
+ def aria(label: str, *, role: str | None = None, describedby: str | None = None) -> Dict[str, str]:
11
+ attrs: Dict[str, str] = {"aria-label": label}
12
+ if role:
13
+ attrs["role"] = role
14
+ if describedby:
15
+ attrs["aria-describedby"] = describedby
16
+ return attrs
17
+
18
+
19
+ def labelled(node: IRNode, label: str) -> IRNode:
20
+ node.props.update(aria(label))
21
+ return node
22
+
23
+
24
+ def focus_trap(enabled: bool = True) -> Dict[str, Any]:
25
+ return {"data-focus-trap": enabled}
26
+
27
+
28
+ def skip_link(target: str, label: str = "Skip to content") -> IRNode:
29
+ return IRNode("link", props={"href": target, "text": label, "class": "skip-link"})
30
+
31
+
32
+ def live_region(polite: bool = True) -> Dict[str, str]:
33
+ return {"aria-live": "polite" if polite else "assertive"}
34
+
35
+
36
+ def announce(message: str, *, polite: bool = True) -> Dict[str, str]:
37
+ attrs = live_region(polite=polite)
38
+ attrs["data-announce"] = message
39
+ return attrs
40
+
41
+
42
+ def a11y_audit(root: IRNode) -> list[str]:
43
+ """Rule-based a11y hints (not a WCAG certification)."""
44
+ issues: list[str] = []
45
+ interactive = {"button", "input", "link"}
46
+
47
+ def walk(node: IRNode) -> None:
48
+ props = node.props
49
+ if node.role in interactive:
50
+ if not props.get("aria-label") and not props.get("label") and not props.get("text"):
51
+ issues.append(f"{node.id}: interactive role '{node.role}' missing label")
52
+ if node.role == "image" and not props.get("alt"):
53
+ issues.append(f"{node.id}: image missing alt text")
54
+ for child in node.children:
55
+ walk(child)
56
+
57
+ walk(root)
58
+ return issues
echoui/animation.py ADDED
@@ -0,0 +1,95 @@
1
+ """Tween, spring, and keyframe animations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Callable, Dict, List, Optional
8
+
9
+
10
+ @dataclass
11
+ class Tween:
12
+ start: float
13
+ end: float
14
+ duration: float
15
+ elapsed: float = 0
16
+ on_update: Optional[Callable[[float], None]] = None
17
+ easing: Callable[[float], float] = lambda t: t
18
+
19
+ def tick(self, dt: float) -> bool:
20
+ self.elapsed = min(self.duration, self.elapsed + dt)
21
+ t = self.elapsed / self.duration if self.duration else 1
22
+ val = self.start + (self.end - self.start) * self.easing(t)
23
+ if self.on_update:
24
+ self.on_update(val)
25
+ return self.elapsed >= self.duration
26
+
27
+
28
+ @dataclass
29
+ class Spring:
30
+ target: float
31
+ current: float = 0
32
+ velocity: float = 0
33
+ stiffness: float = 180
34
+ damping: float = 12
35
+ on_update: Optional[Callable[[float], None]] = None
36
+
37
+ def tick(self, dt: float) -> bool:
38
+ force = -self.stiffness * (self.current - self.target)
39
+ damp = -self.damping * self.velocity
40
+ accel = force + damp
41
+ self.velocity += accel * dt
42
+ self.current += self.velocity * dt
43
+ if self.on_update:
44
+ self.on_update(self.current)
45
+ return abs(self.current - self.target) < 0.01 and abs(self.velocity) < 0.01
46
+
47
+
48
+ @dataclass
49
+ class Keyframe:
50
+ offset: float
51
+ props: Dict[str, Any]
52
+
53
+
54
+ @dataclass
55
+ class Keyframes:
56
+ frames: List[Keyframe] = field(default_factory=list)
57
+ duration: float = 1.0
58
+ elapsed: float = 0
59
+
60
+ def tick(self, dt: float) -> bool:
61
+ self.elapsed = min(self.duration, self.elapsed + dt)
62
+ return self.elapsed >= self.duration
63
+
64
+ def value_at(self, prop: str) -> Any:
65
+ if not self.frames:
66
+ return None
67
+ return self.frames[-1].props.get(prop)
68
+
69
+
70
+ @dataclass
71
+ class Timeline:
72
+ tweens: List[Tween] = field(default_factory=list)
73
+
74
+ def add(self, tween: Tween) -> "Timeline":
75
+ self.tweens.append(tween)
76
+ return self
77
+
78
+ def tick(self, dt: float) -> bool:
79
+ return all(t.tick(dt) for t in self.tweens)
80
+
81
+
82
+ def tween(start: float, end: float, duration: float, **kwargs: Any) -> Tween:
83
+ return Tween(start=start, end=end, duration=duration, **kwargs)
84
+
85
+
86
+ def spring(target: float, **kwargs: Any) -> Spring:
87
+ return Spring(target=target, **kwargs)
88
+
89
+
90
+ def keyframes(*frames: Keyframe, duration: float = 1.0) -> Keyframes:
91
+ return Keyframes(frames=list(frames), duration=duration)
92
+
93
+
94
+ def ease_out(t: float) -> float:
95
+ return 1 - math.pow(1 - t, 3)
echoui/api/__init__.py ADDED
@@ -0,0 +1,82 @@
1
+ """HTTP and WebSocket client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Callable, Dict, List, Optional
8
+
9
+ try:
10
+ import aiohttp
11
+ except ImportError:
12
+ aiohttp = None # type: ignore[assignment]
13
+
14
+ InterceptorFn = Callable[[Dict[str, Any]], Dict[str, Any]]
15
+
16
+
17
+ @dataclass
18
+ class ApiClient:
19
+ base_url: str = ""
20
+ timeout: float = 30.0
21
+ retries: int = 1
22
+ interceptors: List[InterceptorFn] = field(default_factory=list)
23
+ _session: Any = field(default=None, repr=False)
24
+
25
+ async def _get_session(self) -> Any:
26
+ if aiohttp is None:
27
+ raise ImportError("aiohttp required; pip install echoui[web]")
28
+ if self._session is None or self._session.closed:
29
+ self._session = aiohttp.ClientSession(
30
+ timeout=aiohttp.ClientTimeout(total=self.timeout)
31
+ )
32
+ return self._session
33
+
34
+ async def close(self) -> None:
35
+ if self._session and not self._session.closed:
36
+ await self._session.close()
37
+
38
+ async def get(self, path: str, **kwargs: Any) -> Any:
39
+ return await self._request("GET", path, **kwargs)
40
+
41
+ async def post(self, path: str, **data: Any) -> Any:
42
+ return await self._request("POST", path, json=data)
43
+
44
+ async def upload(self, path: str, field: str, data: bytes, filename: str = "file") -> Any:
45
+ if aiohttp is None:
46
+ raise ImportError("aiohttp required")
47
+ session = await self._get_session()
48
+ form = aiohttp.FormData()
49
+ form.add_field(field, data, filename=filename)
50
+ url = self.base_url + path
51
+ async with session.post(url, data=form) as resp:
52
+ return await resp.json()
53
+
54
+ async def download(self, path: str) -> bytes:
55
+ session = await self._get_session()
56
+ url = self.base_url + path
57
+ async with session.get(url) as resp:
58
+ return await resp.read()
59
+
60
+ async def _request(self, method: str, path: str, **kwargs: Any) -> Any:
61
+ ctx: Dict[str, Any] = {"method": method, "path": path, **kwargs}
62
+ for ic in self.interceptors:
63
+ ctx = ic(ctx)
64
+ session = await self._get_session()
65
+ url = self.base_url + path
66
+ last_err: Optional[Exception] = None
67
+ for attempt in range(self.retries + 1):
68
+ try:
69
+ async with session.request(method, url, **kwargs) as resp:
70
+ resp.raise_for_status()
71
+ ct = resp.headers.get("Content-Type", "")
72
+ if "json" in ct:
73
+ return await resp.json()
74
+ return await resp.text()
75
+ except Exception as e:
76
+ last_err = e
77
+ if attempt < self.retries:
78
+ await asyncio.sleep(0.1 * (attempt + 1))
79
+ raise last_err # type: ignore[misc]
80
+
81
+
82
+ api = ApiClient()
echoui/app.py ADDED
@@ -0,0 +1,50 @@
1
+ """Application root: screens, compile, and dev entry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, List, Optional, Type
6
+
7
+ from echoui.screen import Screen
8
+ from echoui.sprite import reset_id_gen
9
+
10
+
11
+ class App:
12
+ def __init__(
13
+ self,
14
+ screens: List[Type[Screen]],
15
+ initial: Optional[str] = None,
16
+ theme: str = "light",
17
+ color_scheme: str = "auto",
18
+ title: str = "EchoUI App",
19
+ ) -> None:
20
+ self.screens = {s.name or s.__name__: s for s in screens}
21
+ self.initial = initial or (screens[0].name or screens[0].__name__)
22
+ self.theme = theme
23
+ self.color_scheme = color_scheme
24
+ self.title = title
25
+ self._current = self.initial
26
+
27
+ def switch_screen(self, name: str, effect: str = "none", duration: float = 0.3) -> None:
28
+ if name not in self.screens:
29
+ raise KeyError(name)
30
+ self._current = name
31
+
32
+ def build_ir(self) -> Dict[str, Any]:
33
+ reset_id_gen()
34
+ screen_cls = self.screens[self._current]
35
+ screen = screen_cls()
36
+ return {
37
+ "app": {"title": self.title, "theme": self.theme, "initial": self.initial},
38
+ "screen": screen.to_ir().to_dict(),
39
+ "screens": {n: sc().to_ir().to_dict() for n, sc in self.screens.items()},
40
+ }
41
+
42
+ def compile(self, target: str = "web", out_dir: str = "dist/web", **kwargs: Any) -> str:
43
+ from echoui.compiler.bundler import build_target
44
+
45
+ return build_target(self, target=target, out_dir=out_dir, **kwargs)
46
+
47
+ def run(self, host: str = "127.0.0.1", port: int = 7999) -> None:
48
+ from echoui.cli import dev_server
49
+
50
+ dev_server(self, host=host, port=port)
echoui/async_.py ADDED
@@ -0,0 +1,17 @@
1
+ """Async helpers for non-blocking UI work (PLAN § async)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Awaitable, Callable, TypeVar
7
+
8
+ T = TypeVar("T")
9
+
10
+
11
+ async def run_in_executor(fn: Callable[[], T]) -> T:
12
+ loop = asyncio.get_running_loop()
13
+ return await loop.run_in_executor(None, fn)
14
+
15
+
16
+ async def defer(coro: Awaitable[T]) -> T:
17
+ return await coro
@@ -0,0 +1 @@
1
+ """EchoUI package: audio."""
@@ -0,0 +1,45 @@
1
+ """Native and web escape bridges."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Callable, Dict, Optional
6
+
7
+
8
+ class WebApi:
9
+ """Browser API bridge for escape layer."""
10
+
11
+ def __init__(self) -> None:
12
+ self._listeners: Dict[str, list[Callable[..., None]]] = {}
13
+
14
+ def add_event_listener(self, event: str, handler: Callable[..., None]) -> None:
15
+ self._listeners.setdefault(event, []).append(handler)
16
+
17
+ def dispatch(self, event: str, **payload: Any) -> None:
18
+ for h in self._listeners.get(event, []):
19
+ h(**payload)
20
+
21
+ def local_storage_get(self, key: str) -> Optional[str]:
22
+ from echoui.storage import local
23
+
24
+ return local().get(key)
25
+
26
+ def local_storage_set(self, key: str, value: str) -> None:
27
+ from echoui.storage import local
28
+
29
+ local().set(key, value)
30
+
31
+
32
+ def web_api() -> WebApi:
33
+ return WebApi()
34
+
35
+
36
+ def os_api() -> Any:
37
+ from echoui.exceptions import UnsupportedCapability
38
+
39
+ raise UnsupportedCapability("os_api is not available on this target")
40
+
41
+
42
+ def gpu_api() -> Any:
43
+ from echoui.exceptions import UnsupportedCapability
44
+
45
+ raise UnsupportedCapability("gpu_api is not available on this target")
echoui/camera.py ADDED
@@ -0,0 +1,56 @@
1
+ """Camera for free-layout scenes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, Optional, Tuple
7
+
8
+
9
+ @dataclass
10
+ class Camera:
11
+ x: float = 0
12
+ y: float = 0
13
+ zoom: float = 1.0
14
+ rotation: float = 0
15
+ follow_target: Optional[str] = None
16
+ lerp: float = 0.1
17
+ deadzone: Tuple[float, float] = (0, 0)
18
+ bounds: Optional[Tuple[float, float, float, float]] = None
19
+ _shake_t: float = 0
20
+ _shake_mag: float = 0
21
+
22
+ def follow(self, target: Any, *, lerp: float = 0.1, deadzone: Tuple[float, float] = (0, 0)) -> "Camera":
23
+ name = target if isinstance(target, str) else getattr(target, "id", str(target))
24
+ self.follow_target = name
25
+ self.lerp = lerp
26
+ self.deadzone = deadzone
27
+ return self
28
+
29
+ def zoom_to(self, zoom: float, duration: float = 0) -> "Camera":
30
+ self.zoom = zoom
31
+ return self
32
+
33
+ def shake(self, magnitude: float, duration: float) -> "Camera":
34
+ self._shake_mag = magnitude
35
+ self._shake_t = duration
36
+ return self
37
+
38
+ def tick(self, dt: float, targets: dict[str, tuple[float, float]]) -> None:
39
+ if self.follow_target and self.follow_target in targets:
40
+ tx, ty = targets[self.follow_target]
41
+ self.x += (tx - self.x) * self.lerp
42
+ self.y += (ty - self.y) * self.lerp
43
+ if self._shake_t > 0:
44
+ self._shake_t = max(0, self._shake_t - dt)
45
+
46
+ def to_dict(self) -> dict[str, Any]:
47
+ return {
48
+ "x": self.x,
49
+ "y": self.y,
50
+ "zoom": self.zoom,
51
+ "rotation": self.rotation,
52
+ "follow": self.follow_target,
53
+ "lerp": self.lerp,
54
+ "deadzone": self.deadzone,
55
+ "bounds": self.bounds,
56
+ }
echoui/canvas.py ADDED
@@ -0,0 +1,31 @@
1
+ """Canvas rendering helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Dict, List
7
+
8
+ from echoui.sprite import IRNode
9
+
10
+
11
+ @dataclass
12
+ class CanvasLayer:
13
+ width: int = 800
14
+ height: int = 600
15
+ commands: List[Dict[str, Any]] = field(default_factory=list)
16
+
17
+ def clear(self, color: str = "#000000") -> None:
18
+ self.commands.append({"op": "clear", "color": color})
19
+
20
+ def fill_rect(self, x: float, y: float, w: float, h: float, color: str) -> None:
21
+ self.commands.append({"op": "fillRect", "x": x, "y": y, "w": w, "h": h, "color": color})
22
+
23
+ def draw_text(self, text: str, x: float, y: float, *, color: str = "#fff", size: int = 16) -> None:
24
+ self.commands.append({"op": "text", "text": text, "x": x, "y": y, "color": color, "size": size})
25
+
26
+ def to_ir(self) -> IRNode:
27
+ return IRNode("canvas", props={"width": self.width, "height": self.height, "commands": self.commands})
28
+
29
+
30
+ def canvas(width: int = 800, height: int = 600) -> CanvasLayer:
31
+ return CanvasLayer(width=width, height=height)
echoui/chain.py ADDED
@@ -0,0 +1,43 @@
1
+ """Awaitable motion chains for sprites."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Any, Callable, List
7
+
8
+
9
+ class MotionChain:
10
+ def __init__(self, sprite: Any) -> None:
11
+ self._sprite = sprite
12
+ self._steps: List[Callable[[], Any]] = []
13
+
14
+ def glide_to(self, x: float, y: float, duration: float) -> "MotionChain":
15
+ sprite = self._sprite
16
+
17
+ async def step() -> None:
18
+ start_x, start_y = sprite.x, sprite.y
19
+ frames = max(1, int(duration * 60))
20
+ for i in range(1, frames + 1):
21
+ t = i / frames
22
+ sprite.x = start_x + (x - start_x) * t
23
+ sprite.y = start_y + (y - start_y) * t
24
+ await asyncio.sleep(duration / frames)
25
+
26
+ self._steps.append(step)
27
+ return self
28
+
29
+ def then_(self, other: "MotionChain | Callable[[], Any]") -> "MotionChain":
30
+ if isinstance(other, MotionChain):
31
+ self._steps.extend(other._steps)
32
+ else:
33
+ self._steps.append(other)
34
+ return self
35
+
36
+ def __await__(self) -> Any:
37
+ return self._run().__await__()
38
+
39
+ async def _run(self) -> None:
40
+ for step in self._steps:
41
+ result = step()
42
+ if asyncio.iscoroutine(result):
43
+ await result
echoui/cli.py ADDED
@@ -0,0 +1,148 @@
1
+ """EchoUI command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from echoui import __version__
10
+
11
+
12
+ def main(argv: list[str] | None = None) -> int:
13
+ parser = argparse.ArgumentParser(prog="echoui", description="EchoUI build tool")
14
+ sub = parser.add_subparsers(dest="cmd")
15
+
16
+ sub.add_parser("version", help="Print version")
17
+
18
+ new_p = sub.add_parser("new", help="Create a new EchoUI project")
19
+ new_p.add_argument("name", nargs="?", default="my-app")
20
+
21
+ dev_p = sub.add_parser("dev", help="Start dev server")
22
+ dev_p.add_argument("--target", default="web")
23
+ dev_p.add_argument("--port", type=int, default=7999)
24
+ dev_p.add_argument("--host", default="127.0.0.1")
25
+ dev_p.add_argument("entry", nargs="?", default="main.py")
26
+
27
+ build_p = sub.add_parser("build", help="Build for target")
28
+ build_p.add_argument("--target", default="web", choices=["web", "static", "tui", "desktop", "gui"])
29
+ build_p.add_argument("--out", default=None)
30
+ build_p.add_argument("entry", nargs="?", default="main.py")
31
+
32
+ sub.add_parser("check", help="Validate project structure")
33
+
34
+ args = parser.parse_args(argv)
35
+ if args.cmd == "version" or args.cmd is None:
36
+ print(f"echoui {__version__}")
37
+ return 0
38
+ if args.cmd == "new":
39
+ return cmd_new(args.name)
40
+ if args.cmd == "dev":
41
+ return cmd_dev(args.entry, host=args.host, port=args.port, target=args.target)
42
+ if args.cmd == "build":
43
+ return cmd_build(args.entry, target=args.target, out=args.out)
44
+ if args.cmd == "check":
45
+ return cmd_check()
46
+ parser.print_help()
47
+ return 1
48
+
49
+
50
+ def cmd_new(name: str) -> int:
51
+ root = Path(name)
52
+ if root.exists():
53
+ print(f"Directory exists: {name}")
54
+ return 1
55
+ root.mkdir()
56
+ (root / "main.py").write_text(_COUNTER_TEMPLATE, encoding="utf-8")
57
+ print(f"Created {name}/main.py")
58
+ return 0
59
+
60
+
61
+ def cmd_build(entry: str, *, target: str, out: str | None) -> int:
62
+ app = _load_app(entry)
63
+ out_dir = out or f"dist/{target}"
64
+ path = app.compile(target=target, out_dir=out_dir)
65
+ print(f"Built {target} -> {path}")
66
+ return 0
67
+
68
+
69
+ def cmd_dev(entry: str, *, host: str, port: int, target: str) -> int:
70
+ app = _load_app(entry)
71
+ dev_server(app, host=host, port=port)
72
+ return 0
73
+
74
+
75
+ def cmd_check() -> int:
76
+ ok = True
77
+ for p in ("main.py", "echoui"):
78
+ if not Path(p).exists():
79
+ print(f"Missing: {p}")
80
+ ok = False
81
+ if ok:
82
+ print("check: ok")
83
+ return 0 if ok else 1
84
+
85
+
86
+ def _load_app(entry: str) -> Any:
87
+ import importlib.util
88
+
89
+ path = Path(entry).resolve()
90
+ spec = importlib.util.spec_from_file_location("echoui_entry", path)
91
+ if spec is None or spec.loader is None:
92
+ raise RuntimeError(f"Cannot load {entry}")
93
+ mod = importlib.util.module_from_spec(spec)
94
+ spec.loader.exec_module(mod)
95
+ if not hasattr(mod, "app"):
96
+ raise RuntimeError(f"{entry} must define `app`")
97
+ return mod.app
98
+
99
+
100
+ def dev_server(app: Any, host: str = "127.0.0.1", port: int = 7999) -> None:
101
+ from aiohttp import web
102
+
103
+ out = Path("dist/web")
104
+ app.compile(target="web", out_dir=str(out))
105
+
106
+ async def index(_req: web.Request) -> web.FileResponse:
107
+ return web.FileResponse(out / "index.html")
108
+
109
+ async def static_files(request: web.Request) -> web.FileResponse:
110
+ return web.FileResponse(out / request.match_info["path"])
111
+
112
+ async def action(request: web.Request) -> web.Response:
113
+ body = await request.json()
114
+ parsed = __import__("echoui.compiler.parser", fromlist=["parse_app"]).parse_app(app)
115
+ handler = parsed["handlers"].get(body.get("handler", ""))
116
+ if handler:
117
+ handler()
118
+ app.compile(target="web", out_dir=str(out))
119
+ return web.json_response({"ok": True})
120
+
121
+ srv = web.Application()
122
+ srv.router.add_get("/", index)
123
+ srv.router.add_post("/api/action", action)
124
+ srv.router.add_get("/{path}", static_files)
125
+ print(f"EchoUI dev server http://{host}:{port}")
126
+ web.run_app(srv, host=host, port=port, print=None)
127
+
128
+
129
+ _COUNTER_TEMPLATE = '''from echoui import App, Screen, Store, col, text, button
130
+
131
+ class CounterStore(Store):
132
+ count: int = 0
133
+
134
+ store = CounterStore()
135
+
136
+ class Counter(Screen):
137
+ def build(self):
138
+ return col(
139
+ text(lambda: f"Count: {store.count}"),
140
+ button("+1", on_click=lambda: setattr(store, "count", store.count + 1)),
141
+ )
142
+
143
+ app = App(screens=[Counter], initial="Counter")
144
+ '''
145
+
146
+
147
+ if __name__ == "__main__":
148
+ raise SystemExit(main())