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.
- echoui/__init__.py +31 -0
- echoui/__main__.py +3 -0
- echoui/a11y/__init__.py +58 -0
- echoui/animation.py +95 -0
- echoui/api/__init__.py +82 -0
- echoui/app.py +50 -0
- echoui/async_.py +17 -0
- echoui/audio/__init__.py +1 -0
- echoui/bridge/__init__.py +45 -0
- echoui/camera.py +56 -0
- echoui/canvas.py +31 -0
- echoui/chain.py +43 -0
- echoui/cli.py +148 -0
- echoui/clone.py +42 -0
- echoui/collab/__init__.py +47 -0
- echoui/compiler/__init__.py +1 -0
- echoui/compiler/analyzer.py +21 -0
- echoui/compiler/bundler.py +69 -0
- echoui/compiler/emit_web.py +162 -0
- echoui/compiler/lower.py +28 -0
- echoui/compiler/optimizer.py +9 -0
- echoui/compiler/parser.py +44 -0
- echoui/compiler/ssr.py +24 -0
- echoui/contrib/__init__.py +3 -0
- echoui/contrib/devtools.py +37 -0
- echoui/desktop/__init__.py +1 -0
- echoui/events.py +50 -0
- echoui/exceptions.py +9 -0
- echoui/forms/__init__.py +149 -0
- echoui/gestures.py +102 -0
- echoui/graphql/__init__.py +1 -0
- echoui/i18n/__init__.py +35 -0
- echoui/input.py +49 -0
- echoui/layout.py +103 -0
- echoui/media/__init__.py +1 -0
- echoui/mobile/__init__.py +1 -0
- echoui/monitor.py +17 -0
- echoui/overlay.py +80 -0
- echoui/pathfind.py +47 -0
- echoui/pen.py +40 -0
- echoui/physics.py +79 -0
- echoui/platform/__init__.py +37 -0
- echoui/plugin.py +26 -0
- echoui/print/__init__.py +23 -0
- echoui/query/__init__.py +127 -0
- echoui/raw.py +61 -0
- echoui/reactive.py +181 -0
- echoui/roles.py +36 -0
- echoui/router/__init__.py +82 -0
- echoui/rpc/__init__.py +1 -0
- echoui/rtc/__init__.py +1 -0
- echoui/runtime/__init__.py +1 -0
- echoui/screen.py +36 -0
- echoui/signals.py +37 -0
- echoui/sprite.py +140 -0
- echoui/stage.py +38 -0
- echoui/state.py +70 -0
- echoui/storage/__init__.py +116 -0
- echoui/style.py +56 -0
- echoui/svg.py +26 -0
- echoui/targets/__init__.py +3 -0
- echoui/targets/desktop.py +55 -0
- echoui/targets/gui.py +40 -0
- echoui/targets/mobile_android.py +34 -0
- echoui/targets/static.py +31 -0
- echoui/targets/tui.py +41 -0
- echoui/tasks.py +20 -0
- echoui/testing/__init__.py +121 -0
- echoui/three/__init__.py +1 -0
- echoui/tiles.py +33 -0
- echoui/time.py +20 -0
- echoui/wasm.py +15 -0
- echoui/workers.py +19 -0
- echoui-0.9.0.dist-info/METADATA +137 -0
- echoui-0.9.0.dist-info/RECORD +78 -0
- echoui-0.9.0.dist-info/WHEEL +4 -0
- echoui-0.9.0.dist-info/entry_points.txt +2 -0
- 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
echoui/a11y/__init__.py
ADDED
|
@@ -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
|
echoui/audio/__init__.py
ADDED
|
@@ -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())
|