flyrail 0.1.0__tar.gz

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.
flyrail-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aaron Lipinski
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
flyrail-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,192 @@
1
+ Metadata-Version: 2.4
2
+ Name: flyrail
3
+ Version: 0.1.0
4
+ Summary: Bring-your-own-frontend server-driven UI core (reactpy-style API, transport agnostic)
5
+ Author-email: Aaron Lipinski <kris.lipinski@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/krisl/flyrail
8
+ Project-URL: Repository, https://github.com/krisl/flyrail
9
+ Keywords: ui,server-driven-ui,websocket,asgi
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Framework :: AsyncIO
12
+ Classifier: Topic :: Internet :: WWW/HTTP
13
+ Classifier: Typing :: Typed
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Dynamic: license-file
18
+
19
+ # flyrail
20
+
21
+ Bring-your-own-frontend server-driven UI with a reactpy-style Python API.
22
+ Python declares the UI, your React+MUI app renders it, your socket carries
23
+ it, your tick drives it.
24
+
25
+ ## Why this exists
26
+
27
+ `reactpy` owns the whole React tree and its own socket protocol. `rjsf`
28
+ owns form rendering but not arbitrary layouts. flyrail splits the problem:
29
+
30
+ - **Python** (`flyrail/`): declarative element helpers, per-session handler
31
+ registry, per-component memoisation, gated keyed-list diffing, slot
32
+ fast-path. Zero dependencies.
33
+ - **JS** (`js/`, `flyrail-renderer`): namespace component registry
34
+ (`import * as MUI`), patch applier, client store with resync, debounced
35
+ inputs. Zero dependencies (React is a peer).
36
+
37
+ See [docs/features.md](docs/features.md) for what each capability buys you,
38
+ with a runnable example each.
39
+
40
+ ## Message flow
41
+
42
+ ```
43
+ tick: render(state) -> tree -> diff -> [] | patch ops -> {chan:ui,type:patch,seq,ops}
44
+ click: {chan:ui,type:action,handlerId,event?} -> dispatch -> mutate state -> next tick emits
45
+ hot: set_slot(name, value) -> {chan:ui,type:slot} (bypasses diff)
46
+ gap: seq skip -> resync-request -> snapshot(state, seq) -> {chan:ui,type:snapshot,tree}
47
+ ```
48
+
49
+ ## Python quickstart
50
+
51
+ ```python
52
+ from flyrail import Layout, Stack, Text, Button
53
+ from flyrail.transport import ui_envelope_patch
54
+
55
+ class Panel:
56
+ def render(self, s):
57
+ return Stack(
58
+ Text(f"speed: {s.speed}"),
59
+ Button("Stop", on_click=self.stop), # like reactpy
60
+ )
61
+ def stop(self, state, event):
62
+ state.speed = 0
63
+
64
+ layout = Layout(Panel().render, allowed_types={"Stack", "Text", "Button"})
65
+ seq = 0
66
+ def on_tick(state):
67
+ global seq
68
+ if ops := layout.tick(state): # [] on idle ticks: send nothing
69
+ seq += 1
70
+ broadcast(ui_envelope_patch(ops, seq))
71
+
72
+ def on_ws(msg, state):
73
+ if msg.get("chan") == "ui" and msg.get("type") == "action":
74
+ layout.dispatch(msg["handlerId"], state, msg.get("event"))
75
+ elif msg.get("type") == "resync-request":
76
+ broadcast(layout.snapshot(state, seq + 1))
77
+ ```
78
+
79
+ ## JS quickstart
80
+
81
+ ```tsx
82
+ import * as MUI from '@mui/material';
83
+ import { createRenderer } from 'flyrail-renderer';
84
+ import { createStore } from 'flyrail-renderer/store';
85
+
86
+ const { ServerNode } = createRenderer({ ...MUI }); // auto-registered, no switch
87
+ const store = createStore({ onResync: (req) => ws.send(JSON.stringify(req)) });
88
+ ws.onmessage = (e) => store.ingest(JSON.parse(e.data));
89
+ // render store.getTree() via <ServerNode node={tree} send={...} />
90
+ ```
91
+
92
+ ## Hosting (pick one)
93
+
94
+ ```python
95
+ # 1. Fixed tick you own: Driver replaces the hand-rolled seq counter.
96
+ from flyrail import Driver
97
+ driver = Driver(layout)
98
+ def on_tick(state, version):
99
+ if env := driver.flush(state, version):
100
+ broadcast(env)
101
+
102
+ # 2. Async host: raw ASGI app, one session per connection. FastAPI:
103
+ # from fastapi import WebSocket; await create_ws_app(Panel().render)(scope, receive, send)
104
+ # (or mount in any ASGI framework; no fastapi dependency in this package)
105
+ from flyrail import create_ws_app
106
+ app = create_ws_app(Panel().render, state_factory=Sim,
107
+ on_message=lambda data, state: handle_telemetry(data))
108
+
109
+ # 3. Naive host (no loop at all): invalidate + flush around every message.
110
+ driver.invalidate()
111
+ if env := driver.flush(state):
112
+ send(env)
113
+ ```
114
+
115
+ ## Best practices
116
+
117
+ 1. **Stable keys, never indexes.** `key=item.id` on every list child, both
118
+ sides: Python handler ids embed the key, React reconciles by it. Reorder
119
+ without keys = full remount + lost focus. Keys must be unique among
120
+ siblings: duplicates share one handler id (last registration wins) and
121
+ components reject them, plain nodes do not.
122
+ 2. **Slots for tick-rate values.** Table rows, labels, progress: `Slot("rows")`
123
+ + `set_slot()` bypass the tree diff. Structure goes through patches (rare),
124
+ values through slots (every tick).
125
+ 3. **Allowlist both ends.** Python `allowed_types={...}` rejects unknown node
126
+ types; JS `isKnownComponent` renders only functions (filters MUI's
127
+ `colors`, `createTheme`, etc.). Never render `registry[arbitraryString]`.
128
+ 4. **One render per tick, send only on change.** `tick()` then `if ops:`.
129
+ Hash-gating makes idle ticks free.
130
+ 5. **Inputs debounce client-side** (150ms default). Server never sees
131
+ keystroke storms; use `flush()` on submit.
132
+ 6. **Caller-owned seq, snapshot on gaps.** The tick loop numbers patches; the
133
+ store drops stale/duplicates and answers gaps with one snapshot round-trip.
134
+ 7. **Keep `render(state)` pure and cheap.** No DB, no IO: derive from the
135
+ already-computed tick state. One `Layout` per session.
136
+ 8. **Safe event defaults, names like the DOM.** `preventDefault` is true
137
+ unless opted out (a reload is session death); `stopPropagation` stays
138
+ opt-in; `throttleMs` rate-caps sliders and guards double-submit. Same
139
+ names as the browser, defaults chosen for the socket.
140
+ 9. **Mark renders `@pure`, version host state.** Pure renders skip render
141
+ CPU when the version is unchanged (unmarked always re-render: correct by
142
+ default). `strict=True` double-renders in dev to catch nondeterminism.
143
+ 10. **Component-local UI state via hooks.** `use_state`/`use_memo` inside
144
+ `@component` bodies keeps collapsed flags and drafts out of tick state;
145
+ setters schedule through `invalidate()`. Distinct `key=` per instance,
146
+ hooks unconditional and order-stable, or it raises loudly. Effect
147
+ cleanups that raise abort the render instead of being swallowed.
148
+ 11. **Async handlers via `adispatch`.** Handlers may be `async def` (e.g.
149
+ `await db.save()` on click); sync `dispatch` refuses them loudly instead
150
+ of silently dropping the coroutine. Pattern: `await adispatch(...)`,
151
+ then `invalidate()`.
152
+
153
+ ## Docs
154
+
155
+ - [How it works](docs/how-it-works.md) — architecture, tick/action/resync
156
+ sequences, render pipeline, hook slots, scheduling, message catalog.
157
+
158
+ ## Layout
159
+
160
+ ```
161
+ flyrail/ Python core (stdlib only)
162
+ core.py Stack/Text/Button/TextField/Slot, @component, @pure
163
+ hooks.py use_state/use_memo keyed slots
164
+ layout.py registry + diff + dispatch + slots + snapshot
165
+ driver.py dirty-flag scheduling for tick/async/naive hosts
166
+ asgi.py framework-free websocket sessions
167
+ transport.py multiplex envelope + tick sketch
168
+ js/ flyrail-renderer (zero-dep ESM + tsx)
169
+ protocol.mjs envelopes, applyOps, debounce (node-tested)
170
+ store.mjs seq tracking, gap->resync, slots (node-tested)
171
+ ServerNode.tsx MUI-bound renderer (imports protocol.mjs)
172
+ example/ hmi_demo.py runnable narrative of the wire
173
+ tests/ focused unittest suites + public-API loopback
174
+ ```
175
+
176
+ ## Tests
177
+
178
+ ```
179
+ PYTHONPATH=. python3 -m unittest discover -s tests -v # 67 tests
180
+ node --test js/protocol.test.mjs js/store.test.mjs # 24 tests
181
+ PYTHONPATH=. python3 example/hmi_demo.py # narrated wire demo
182
+ ```
183
+
184
+ ## Non-goals / roadmap
185
+
186
+ - Not a form validator (use your backend validation + error slots), not a
187
+ JSON-Schema renderer (see rjsf), not a full reactpy replacement (sync
188
+ core with no async effects; bring your own frontend instead of an
189
+ owned tree).
190
+ - Roadmap: vitest + React Testing Library for `ServerNode`, `byId/order`
191
+ maps for huge reorderable lists, keystroke `ackSeq` if loss-less input sync
192
+ is ever needed, registry packaging.
@@ -0,0 +1,174 @@
1
+ # flyrail
2
+
3
+ Bring-your-own-frontend server-driven UI with a reactpy-style Python API.
4
+ Python declares the UI, your React+MUI app renders it, your socket carries
5
+ it, your tick drives it.
6
+
7
+ ## Why this exists
8
+
9
+ `reactpy` owns the whole React tree and its own socket protocol. `rjsf`
10
+ owns form rendering but not arbitrary layouts. flyrail splits the problem:
11
+
12
+ - **Python** (`flyrail/`): declarative element helpers, per-session handler
13
+ registry, per-component memoisation, gated keyed-list diffing, slot
14
+ fast-path. Zero dependencies.
15
+ - **JS** (`js/`, `flyrail-renderer`): namespace component registry
16
+ (`import * as MUI`), patch applier, client store with resync, debounced
17
+ inputs. Zero dependencies (React is a peer).
18
+
19
+ See [docs/features.md](docs/features.md) for what each capability buys you,
20
+ with a runnable example each.
21
+
22
+ ## Message flow
23
+
24
+ ```
25
+ tick: render(state) -> tree -> diff -> [] | patch ops -> {chan:ui,type:patch,seq,ops}
26
+ click: {chan:ui,type:action,handlerId,event?} -> dispatch -> mutate state -> next tick emits
27
+ hot: set_slot(name, value) -> {chan:ui,type:slot} (bypasses diff)
28
+ gap: seq skip -> resync-request -> snapshot(state, seq) -> {chan:ui,type:snapshot,tree}
29
+ ```
30
+
31
+ ## Python quickstart
32
+
33
+ ```python
34
+ from flyrail import Layout, Stack, Text, Button
35
+ from flyrail.transport import ui_envelope_patch
36
+
37
+ class Panel:
38
+ def render(self, s):
39
+ return Stack(
40
+ Text(f"speed: {s.speed}"),
41
+ Button("Stop", on_click=self.stop), # like reactpy
42
+ )
43
+ def stop(self, state, event):
44
+ state.speed = 0
45
+
46
+ layout = Layout(Panel().render, allowed_types={"Stack", "Text", "Button"})
47
+ seq = 0
48
+ def on_tick(state):
49
+ global seq
50
+ if ops := layout.tick(state): # [] on idle ticks: send nothing
51
+ seq += 1
52
+ broadcast(ui_envelope_patch(ops, seq))
53
+
54
+ def on_ws(msg, state):
55
+ if msg.get("chan") == "ui" and msg.get("type") == "action":
56
+ layout.dispatch(msg["handlerId"], state, msg.get("event"))
57
+ elif msg.get("type") == "resync-request":
58
+ broadcast(layout.snapshot(state, seq + 1))
59
+ ```
60
+
61
+ ## JS quickstart
62
+
63
+ ```tsx
64
+ import * as MUI from '@mui/material';
65
+ import { createRenderer } from 'flyrail-renderer';
66
+ import { createStore } from 'flyrail-renderer/store';
67
+
68
+ const { ServerNode } = createRenderer({ ...MUI }); // auto-registered, no switch
69
+ const store = createStore({ onResync: (req) => ws.send(JSON.stringify(req)) });
70
+ ws.onmessage = (e) => store.ingest(JSON.parse(e.data));
71
+ // render store.getTree() via <ServerNode node={tree} send={...} />
72
+ ```
73
+
74
+ ## Hosting (pick one)
75
+
76
+ ```python
77
+ # 1. Fixed tick you own: Driver replaces the hand-rolled seq counter.
78
+ from flyrail import Driver
79
+ driver = Driver(layout)
80
+ def on_tick(state, version):
81
+ if env := driver.flush(state, version):
82
+ broadcast(env)
83
+
84
+ # 2. Async host: raw ASGI app, one session per connection. FastAPI:
85
+ # from fastapi import WebSocket; await create_ws_app(Panel().render)(scope, receive, send)
86
+ # (or mount in any ASGI framework; no fastapi dependency in this package)
87
+ from flyrail import create_ws_app
88
+ app = create_ws_app(Panel().render, state_factory=Sim,
89
+ on_message=lambda data, state: handle_telemetry(data))
90
+
91
+ # 3. Naive host (no loop at all): invalidate + flush around every message.
92
+ driver.invalidate()
93
+ if env := driver.flush(state):
94
+ send(env)
95
+ ```
96
+
97
+ ## Best practices
98
+
99
+ 1. **Stable keys, never indexes.** `key=item.id` on every list child, both
100
+ sides: Python handler ids embed the key, React reconciles by it. Reorder
101
+ without keys = full remount + lost focus. Keys must be unique among
102
+ siblings: duplicates share one handler id (last registration wins) and
103
+ components reject them, plain nodes do not.
104
+ 2. **Slots for tick-rate values.** Table rows, labels, progress: `Slot("rows")`
105
+ + `set_slot()` bypass the tree diff. Structure goes through patches (rare),
106
+ values through slots (every tick).
107
+ 3. **Allowlist both ends.** Python `allowed_types={...}` rejects unknown node
108
+ types; JS `isKnownComponent` renders only functions (filters MUI's
109
+ `colors`, `createTheme`, etc.). Never render `registry[arbitraryString]`.
110
+ 4. **One render per tick, send only on change.** `tick()` then `if ops:`.
111
+ Hash-gating makes idle ticks free.
112
+ 5. **Inputs debounce client-side** (150ms default). Server never sees
113
+ keystroke storms; use `flush()` on submit.
114
+ 6. **Caller-owned seq, snapshot on gaps.** The tick loop numbers patches; the
115
+ store drops stale/duplicates and answers gaps with one snapshot round-trip.
116
+ 7. **Keep `render(state)` pure and cheap.** No DB, no IO: derive from the
117
+ already-computed tick state. One `Layout` per session.
118
+ 8. **Safe event defaults, names like the DOM.** `preventDefault` is true
119
+ unless opted out (a reload is session death); `stopPropagation` stays
120
+ opt-in; `throttleMs` rate-caps sliders and guards double-submit. Same
121
+ names as the browser, defaults chosen for the socket.
122
+ 9. **Mark renders `@pure`, version host state.** Pure renders skip render
123
+ CPU when the version is unchanged (unmarked always re-render: correct by
124
+ default). `strict=True` double-renders in dev to catch nondeterminism.
125
+ 10. **Component-local UI state via hooks.** `use_state`/`use_memo` inside
126
+ `@component` bodies keeps collapsed flags and drafts out of tick state;
127
+ setters schedule through `invalidate()`. Distinct `key=` per instance,
128
+ hooks unconditional and order-stable, or it raises loudly. Effect
129
+ cleanups that raise abort the render instead of being swallowed.
130
+ 11. **Async handlers via `adispatch`.** Handlers may be `async def` (e.g.
131
+ `await db.save()` on click); sync `dispatch` refuses them loudly instead
132
+ of silently dropping the coroutine. Pattern: `await adispatch(...)`,
133
+ then `invalidate()`.
134
+
135
+ ## Docs
136
+
137
+ - [How it works](docs/how-it-works.md) — architecture, tick/action/resync
138
+ sequences, render pipeline, hook slots, scheduling, message catalog.
139
+
140
+ ## Layout
141
+
142
+ ```
143
+ flyrail/ Python core (stdlib only)
144
+ core.py Stack/Text/Button/TextField/Slot, @component, @pure
145
+ hooks.py use_state/use_memo keyed slots
146
+ layout.py registry + diff + dispatch + slots + snapshot
147
+ driver.py dirty-flag scheduling for tick/async/naive hosts
148
+ asgi.py framework-free websocket sessions
149
+ transport.py multiplex envelope + tick sketch
150
+ js/ flyrail-renderer (zero-dep ESM + tsx)
151
+ protocol.mjs envelopes, applyOps, debounce (node-tested)
152
+ store.mjs seq tracking, gap->resync, slots (node-tested)
153
+ ServerNode.tsx MUI-bound renderer (imports protocol.mjs)
154
+ example/ hmi_demo.py runnable narrative of the wire
155
+ tests/ focused unittest suites + public-API loopback
156
+ ```
157
+
158
+ ## Tests
159
+
160
+ ```
161
+ PYTHONPATH=. python3 -m unittest discover -s tests -v # 67 tests
162
+ node --test js/protocol.test.mjs js/store.test.mjs # 24 tests
163
+ PYTHONPATH=. python3 example/hmi_demo.py # narrated wire demo
164
+ ```
165
+
166
+ ## Non-goals / roadmap
167
+
168
+ - Not a form validator (use your backend validation + error slots), not a
169
+ JSON-Schema renderer (see rjsf), not a full reactpy replacement (sync
170
+ core with no async effects; bring your own frontend instead of an
171
+ owned tree).
172
+ - Roadmap: vitest + React Testing Library for `ServerNode`, `byId/order`
173
+ maps for huge reorderable lists, keystroke `ackSeq` if loss-less input sync
174
+ is ever needed, registry packaging.
@@ -0,0 +1,13 @@
1
+ """flyrail - BYOF server-driven UI core.
2
+
3
+ Python API is reactpy-style but transport/V DOM agnostic:
4
+ - you build dict trees with helpers (Stack, Text, Button, ...)
5
+ - Layout.render(state) replaces callables with {"handlerId": ...} descriptors
6
+ """
7
+ from .core import component, pure, Slot, Stack, Text, Button, TextField
8
+ from .layout import Layout
9
+ from .driver import Driver
10
+ from .hooks import use_state, use_memo, use_effect
11
+ from .asgi import create_ws_app
12
+
13
+ __all__ = ["component", "pure", "Slot", "Stack", "Text", "Button", "TextField", "Layout", "Driver", "use_state", "use_memo", "use_effect", "create_ws_app"]
@@ -0,0 +1,77 @@
1
+ """Framework-free ASGI websocket adapter: the async-host recipe.
2
+
3
+ One state + Driver + Layout per connection; no dependency beyond stdlib.
4
+ FastAPI/Starlette/Django-Channels users mount this where they handle
5
+ websockets (see README Hosting); everyone else copies the 30-line shape.
6
+ """
7
+ from __future__ import annotations
8
+ import asyncio
9
+ import inspect
10
+ import json
11
+ from typing import Any, Callable
12
+
13
+ from .driver import Driver
14
+ from .layout import Layout
15
+
16
+
17
+ def create_ws_app(
18
+ render_fn: Callable[[Any], dict],
19
+ *,
20
+ state_factory: Callable[[], Any] = dict,
21
+ version_fn: Callable[[], Any] | None = None,
22
+ allowed_types: set[str] | None = None,
23
+ strict: bool = False,
24
+ on_message: Callable[[dict, Any], Any] | None = None,
25
+ ):
26
+ """Return an ASGI websocket app serving one flyrail session per connection."""
27
+ _version_of = version_fn or (lambda: None)
28
+
29
+ async def app(scope, receive, send):
30
+ if scope["type"] != "websocket":
31
+ raise RuntimeError("flyrail ASGI app handles websocket scope only")
32
+ layout = Layout(render_fn, allowed_types=allowed_types, strict=strict)
33
+ driver = Driver(layout)
34
+ state = state_factory()
35
+
36
+ async def send_json(env: dict) -> None:
37
+ await send({"type": "websocket.send", "text": json.dumps(env)})
38
+
39
+ await send({"type": "websocket.accept"})
40
+ snap = layout.snapshot(state, seq=1)
41
+ driver.seq = 1
42
+ await send_json(snap)
43
+
44
+ run_task = asyncio.create_task(
45
+ driver.run(lambda: state, send_json, _version_of)
46
+ )
47
+ try:
48
+ while True:
49
+ msg = await receive()
50
+ if msg["type"] == "websocket.disconnect":
51
+ break
52
+ if msg["type"] != "websocket.receive":
53
+ continue
54
+ data = json.loads(msg.get("text") or msg.get("bytes") or "{}")
55
+ if data.get("chan") != "ui":
56
+ if on_message is not None:
57
+ res = on_message(data, state)
58
+ if inspect.isawaitable(res):
59
+ await res
60
+ continue
61
+ kind = data.get("type")
62
+ if kind == "action":
63
+ await layout.adispatch(
64
+ data["handlerId"], state, data.get("event"))
65
+ driver.invalidate()
66
+ elif kind == "resync-request":
67
+ driver.seq += 1
68
+ await send_json(layout.snapshot(state, driver.seq))
69
+ # Unknown ui subtypes ignored (forward-compat, mirrors store).
70
+ finally:
71
+ run_task.cancel()
72
+ try:
73
+ await run_task
74
+ except asyncio.CancelledError:
75
+ pass
76
+
77
+ return app
@@ -0,0 +1,107 @@
1
+ """Declarative element constructors. No VDOM dependency."""
2
+ from __future__ import annotations
3
+ import functools
4
+ from typing import Any, Callable
5
+
6
+
7
+ def component(fn=None, *, key_arg: str | None = None):
8
+ """Declare a component. Calling it returns a lazy node that Layout
9
+ expands with per-instance hook state (keyed by key=, else position).
10
+ """
11
+ def wrap(f):
12
+ @functools.wraps(f)
13
+ def invoke(*args, **kwargs):
14
+ key = kwargs.pop("key", None)
15
+ return {"type": "__Component__", "fn": f, "key": key,
16
+ "args": args, "kwargs": kwargs}
17
+ invoke._is_flyrail_component = True
18
+ invoke._key_arg = key_arg
19
+ invoke._component_fn = f
20
+ return invoke
21
+ return wrap(fn) if fn else wrap
22
+
23
+
24
+ def pure(fn=None):
25
+ """Opt-in contract: output is a pure function of (arguments, hook slots).
26
+
27
+ Declares, not verifies: Layout may skip re-renders when the host version
28
+ is unchanged or when a component's arguments are unchanged, and strict mode
29
+ double-renders to spot-check. Unmarked renders always re-render. If you lie,
30
+ the stale UI is your bug.
31
+
32
+ Marks both sides of @component so either decorator order works: Layout
33
+ expands the inner function, while a host reading the flag off the name it
34
+ was given sees the wrapper.
35
+ """
36
+ def wrap(f):
37
+ f._flyrail_pure = True
38
+ inner = getattr(f, "_component_fn", None)
39
+ if inner is not None:
40
+ inner._flyrail_pure = True
41
+ return f
42
+ return wrap(fn) if fn else wrap
43
+
44
+
45
+ def _opts(event: str, prevent_default: bool, stop_propagation: bool,
46
+ throttle_ms: int | None) -> dict:
47
+ """Per-event wire options. throttleMs is omitted when unset (minimal wire)."""
48
+ opts: dict[str, Any] = {"preventDefault": prevent_default,
49
+ "stopPropagation": stop_propagation}
50
+ if throttle_ms is not None:
51
+ opts["throttleMs"] = throttle_ms
52
+ return {event: opts}
53
+
54
+
55
+ def _el(type_: str, *children: Any, key: Any = None, on_click: Callable | None = None,
56
+ event_options: dict | None = None, **props: Any) -> dict:
57
+ node: dict[str, Any] = {"type": type_, "props": props}
58
+ if key is not None:
59
+ node["key"] = key
60
+ if children:
61
+ # flatten one level of lists (for [... for ...] splats)
62
+ flat: list[Any] = []
63
+ for c in children:
64
+ if isinstance(c, list):
65
+ flat.extend(c)
66
+ else:
67
+ flat.append(c)
68
+ node["children"] = flat
69
+ if on_click is not None:
70
+ node["on_click"] = on_click
71
+ if event_options:
72
+ node["event_options"] = event_options
73
+ return node
74
+
75
+
76
+ def Stack(*children: Any, key: Any = None, **props: Any) -> dict:
77
+ return _el("Stack", *children, key=key, **props)
78
+
79
+
80
+ def Text(value: str, key: Any = None, **props: Any) -> dict:
81
+ return _el("Text", key=key, value=value, **props)
82
+
83
+
84
+ def Button(label: str, on_click: Callable | None = None, key: Any = None, *,
85
+ prevent_default: bool = True, stop_propagation: bool = False,
86
+ throttle_ms: int | None = None, **props: Any) -> dict:
87
+ return _el("Button", key=key, on_click=on_click,
88
+ event_options=_opts("on_click", prevent_default,
89
+ stop_propagation, throttle_ms) if on_click else None,
90
+ label=label, **props)
91
+
92
+
93
+ def TextField(key: Any = None, on_change: Callable | None = None, *,
94
+ prevent_default: bool = True, stop_propagation: bool = False,
95
+ throttle_ms: int | None = None, **props: Any) -> dict:
96
+ node = _el("TextField", key=key, **props)
97
+ if on_change is not None:
98
+ node["on_change"] = on_change
99
+ node["event_options"] = _opts("on_change", prevent_default,
100
+ stop_propagation, throttle_ms)
101
+ return node
102
+
103
+
104
+ def Slot(name: str, default: Any = None) -> dict:
105
+ """Hot-path placeholder. Renderer subscribes by name; tick sends slot values
106
+ directly instead of diffing the whole tree."""
107
+ return {"type": "__Slot__", "props": {"name": name, "default": default}}
@@ -0,0 +1,55 @@
1
+ """Host scheduling seam: one primitive for tick, async, and naive hosts."""
2
+ from __future__ import annotations
3
+ import asyncio
4
+ from typing import Any, Callable
5
+
6
+ from .layout import Layout
7
+
8
+
9
+ class Driver:
10
+ """Wraps Layout with dirty-flag scheduling and caller-owned seq.
11
+
12
+ Tick hosts: invalidate() per tick (or on sim change); flush(state, version).
13
+ Async hosts: invalidate() from event handlers; await run(state_fn, send).
14
+ Naive hosts: invalidate() + flush() around every message.
15
+ Thread-safe invalidate: safe to call from any thread; the run loop
16
+ (single asyncio loop assumption) wakes via call_soon_threadsafe.
17
+ """
18
+
19
+ def __init__(self, layout: Layout):
20
+ self.layout = layout
21
+ self.seq = 0
22
+ self._event = asyncio.Event()
23
+
24
+ def invalidate(self) -> None:
25
+ self.layout.invalidate()
26
+ try:
27
+ loop = asyncio.get_running_loop()
28
+ except RuntimeError:
29
+ loop = None
30
+ if loop is None:
31
+ self._event.set()
32
+ else:
33
+ loop.call_soon_threadsafe(self._event.set)
34
+
35
+ def flush(self, state: Any, version: Any = None) -> dict | None:
36
+ """Render+diff if dirty/version-changed; seq-numbered envelope or None."""
37
+ ops = self.layout.tick(state, version)
38
+ if not ops:
39
+ return None
40
+ self.seq += 1
41
+ return {"chan": "ui", "type": "patch", "seq": self.seq, "ops": ops}
42
+
43
+ async def run(
44
+ self,
45
+ state_fn: Callable[[], Any],
46
+ send: Callable[[dict], Any],
47
+ version_fn: Callable[[], Any] = lambda: None,
48
+ ) -> None:
49
+ """Never returns; cancel to stop. Bursts coalesce into one flush."""
50
+ while True:
51
+ await self._event.wait()
52
+ self._event.clear()
53
+ env = self.flush(state_fn(), version_fn())
54
+ if env is not None:
55
+ await send(env)