spaday-codemirror 0.1.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.
@@ -0,0 +1,27 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ from spaday import ComponentPackage
5
+
6
+ from .components import SpadayCodemirror
7
+
8
+ __version__ = "0.1.0"
9
+
10
+ ASSETS_DIR = Path(__file__).parent / "extension"
11
+ MANIFEST = Path(__file__).parent / "components.cem.json"
12
+
13
+ # Exact versions of the JS libraries bundled into cdn/index.js, written by the JS build.
14
+ _VERSIONS_FILE = ASSETS_DIR / "versions.json"
15
+ VERSIONS: dict[str, str] = json.loads(_VERSIONS_FILE.read_text(encoding="utf-8")) if _VERSIONS_FILE.exists() else {}
16
+
17
+ package = ComponentPackage(
18
+ name="codemirror",
19
+ assets_dir=ASSETS_DIR,
20
+ assets=(("css", "css/index.css"), ("js", "cdn/index.js")),
21
+ components=(SpadayCodemirror,),
22
+ provides=VERSIONS,
23
+ )
24
+
25
+ CodeMirror = SpadayCodemirror
26
+
27
+ __all__ = ["ASSETS_DIR", "MANIFEST", "VERSIONS", "CodeMirror", "SpadayCodemirror", "__version__", "package"]
@@ -0,0 +1,132 @@
1
+ {
2
+ "schemaVersion": "1.0.0",
3
+ "modules": [
4
+ {
5
+ "kind": "javascript-module",
6
+ "path": "js/src/ts/index.ts",
7
+ "declarations": [
8
+ {
9
+ "kind": "class",
10
+ "name": "SpadayCodeMirror",
11
+ "tagName": "spaday-codemirror",
12
+ "customElement": true,
13
+ "summary": "CodeMirror 6 code editor.",
14
+ "attributes": [
15
+ {
16
+ "name": "doc",
17
+ "fieldName": "doc",
18
+ "type": { "text": "string" },
19
+ "default": "\"\"",
20
+ "description": "Editor contents."
21
+ },
22
+ {
23
+ "name": "language",
24
+ "fieldName": "language",
25
+ "type": { "text": "\"python\" | \"javascript\" | \"json\" | \"markdown\" | \"plain\"" },
26
+ "default": "\"plain\"",
27
+ "description": "Syntax mode."
28
+ },
29
+ {
30
+ "name": "theme",
31
+ "fieldName": "theme",
32
+ "type": { "text": "\"light\" | \"dark\"" },
33
+ "default": "\"light\"",
34
+ "description": "Color theme."
35
+ },
36
+ {
37
+ "name": "read_only",
38
+ "fieldName": "read_only",
39
+ "type": { "text": "boolean" },
40
+ "default": "false",
41
+ "description": "Disallow user edits."
42
+ },
43
+ {
44
+ "name": "line_numbers",
45
+ "fieldName": "line_numbers",
46
+ "type": { "text": "boolean" },
47
+ "default": "true",
48
+ "description": "Show the line-number gutter."
49
+ },
50
+ {
51
+ "name": "tab_size",
52
+ "fieldName": "tab_size",
53
+ "type": { "text": "number" },
54
+ "default": "4",
55
+ "description": "Tab width and indent unit, in spaces."
56
+ }
57
+ ],
58
+ "members": [
59
+ {
60
+ "kind": "field",
61
+ "name": "doc",
62
+ "type": { "text": "string" },
63
+ "default": "\"\"",
64
+ "attribute": "doc"
65
+ },
66
+ {
67
+ "kind": "field",
68
+ "name": "language",
69
+ "type": { "text": "\"python\" | \"javascript\" | \"json\" | \"markdown\" | \"plain\"" },
70
+ "default": "\"plain\"",
71
+ "attribute": "language"
72
+ },
73
+ {
74
+ "kind": "field",
75
+ "name": "theme",
76
+ "type": { "text": "\"light\" | \"dark\"" },
77
+ "default": "\"light\"",
78
+ "attribute": "theme"
79
+ },
80
+ {
81
+ "kind": "field",
82
+ "name": "read_only",
83
+ "type": { "text": "boolean" },
84
+ "default": "false",
85
+ "attribute": "read_only"
86
+ },
87
+ {
88
+ "kind": "field",
89
+ "name": "line_numbers",
90
+ "type": { "text": "boolean" },
91
+ "default": "true",
92
+ "attribute": "line_numbers"
93
+ },
94
+ {
95
+ "kind": "field",
96
+ "name": "tab_size",
97
+ "type": { "text": "number" },
98
+ "default": "4",
99
+ "attribute": "tab_size"
100
+ },
101
+ {
102
+ "kind": "field",
103
+ "name": "selection",
104
+ "type": { "text": "{ anchor: number; head?: number } | null" },
105
+ "default": "null",
106
+ "description": "Main selection; head defaults to anchor."
107
+ }
108
+ ],
109
+ "events": [
110
+ {
111
+ "name": "editor-change",
112
+ "type": { "text": "CustomEvent<{ doc: string; changes: { from: number; to: number; insert: string }[]; selection: { anchor: number; head: number } }>" },
113
+ "description": "User edited the document."
114
+ },
115
+ {
116
+ "name": "editor-selection",
117
+ "type": { "text": "CustomEvent<{ selection: { anchor: number; head: number } }>" },
118
+ "description": "User moved the selection without editing."
119
+ }
120
+ ]
121
+ }
122
+ ],
123
+ "exports": [
124
+ {
125
+ "kind": "custom-element-definition",
126
+ "name": "spaday-codemirror",
127
+ "declaration": { "name": "SpadayCodeMirror", "module": "js/src/ts/index.ts" }
128
+ }
129
+ ]
130
+ }
131
+ ]
132
+ }
@@ -0,0 +1,72 @@
1
+ # Generated by spaday.cem from components.cem.json — do not edit by hand.
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Literal
5
+
6
+ from spaday.catalog import ComponentSchema, PropertySchema
7
+ from spaday.component import Child, Component
8
+
9
+ __all__ = ["SpadayCodemirror"]
10
+
11
+
12
+ class SpadayCodemirror(Component):
13
+ """CodeMirror 6 code editor."""
14
+
15
+ tag = "spaday-codemirror"
16
+ schema = ComponentSchema(
17
+ tag="spaday-codemirror",
18
+ class_name="SpadayCodemirror",
19
+ summary="CodeMirror 6 code editor.",
20
+ props=(
21
+ PropertySchema(name="doc", kind="string", choices=(), default="", description="Editor contents."),
22
+ PropertySchema(
23
+ name="language",
24
+ kind="enum",
25
+ choices=("python", "javascript", "json", "markdown", "plain"),
26
+ default="plain",
27
+ description="Syntax mode.",
28
+ ),
29
+ PropertySchema(name="theme", kind="enum", choices=("light", "dark"), default="light", description="Color theme."),
30
+ PropertySchema(name="read_only", kind="boolean", choices=(), default="false", description="Disallow user edits."),
31
+ PropertySchema(name="line_numbers", kind="boolean", choices=(), default="true", description="Show the line-number gutter."),
32
+ PropertySchema(name="tab_size", kind="number", choices=(), default="4", description="Tab width and indent unit, in spaces."),
33
+ ),
34
+ fields=(
35
+ PropertySchema(
36
+ name="selection",
37
+ kind="json",
38
+ choices=(),
39
+ type_text="{ anchor: number; head?: number } | null",
40
+ default=None,
41
+ description="Main selection; head defaults to anchor.",
42
+ ),
43
+ ),
44
+ events=("editor-change", "editor-selection"),
45
+ slots=(),
46
+ )
47
+
48
+ def __init__(
49
+ self,
50
+ *children: Child,
51
+ key: str | None = None,
52
+ doc: str | None = None,
53
+ language: Literal["python", "javascript", "json", "markdown", "plain"] | None = None,
54
+ theme: Literal["light", "dark"] | None = None,
55
+ read_only: bool | None = None,
56
+ line_numbers: bool | None = None,
57
+ tab_size: float | None = None,
58
+ **props: Any,
59
+ ) -> None:
60
+ super().__init__(
61
+ *children,
62
+ key=key,
63
+ props={
64
+ "doc": doc,
65
+ "language": language,
66
+ "theme": theme,
67
+ "read_only": read_only,
68
+ "line_numbers": line_numbers,
69
+ "tab_size": tab_size,
70
+ },
71
+ **props,
72
+ )
@@ -0,0 +1,320 @@
1
+ """Python-authored CodeMirror page over spaday's worker protocol.
2
+
3
+ A transports-hosted ``Document`` model is the source of truth. The editable source editor sends each
4
+ user edit as a ``spaday:patch`` intent; Python recomputes metrics and a normalized preview, and returns
5
+ a component-tree patch. The same :class:`~spaday.WorkerApp` runs inside Pyodide (``worker_app``) or
6
+ behind a Starlette websocket (``app``), one app per connection.
7
+
8
+ The source editor's ``doc`` prop only changes when Python authors the text (reset / normalize), never to
9
+ echo a user edit back, so in-flight keystrokes are never overwritten by a stale round-trip.
10
+
11
+ Run: ``python -m spaday_codemirror.example`` then open http://127.0.0.1:8031/.
12
+ """
13
+
14
+ import json
15
+ from pathlib import Path
16
+
17
+ import spaday
18
+ from pydantic import BaseModel
19
+ from spaday import SendPatch, Sequence as Steps, WorkerApp, by_id, element, event_value, lit, prop
20
+ from starlette.applications import Starlette
21
+ from starlette.responses import HTMLResponse
22
+ from starlette.routing import Mount, Route, WebSocketRoute
23
+ from starlette.staticfiles import StaticFiles
24
+ from starlette.websockets import WebSocket, WebSocketDisconnect
25
+ from transports import Client, Server, Session, to_value
26
+
27
+ from . import CodeMirror, package
28
+
29
+ HOST = "127.0.0.1"
30
+ PORT = 8031
31
+ TAB_SIZE = 4
32
+ SOURCE_ID = "source-editor"
33
+
34
+ SAMPLE = """def greet(name):
35
+ \tmessage = f"hello, {name}" \n\treturn message
36
+
37
+
38
+ print(greet("spaday"))
39
+ """
40
+
41
+ GALLERY = (
42
+ {
43
+ "key": "gallery-python",
44
+ "title": "Python",
45
+ "settings": "light · line numbers · tab 4",
46
+ "props": {"language": "python", "theme": "light", "line_numbers": True, "tab_size": 4},
47
+ "doc": "def area(r):\n return 3.14159 * r ** 2\n",
48
+ },
49
+ {
50
+ "key": "gallery-javascript",
51
+ "title": "JavaScript",
52
+ "settings": "dark · line numbers · tab 2",
53
+ "props": {"language": "javascript", "theme": "dark", "line_numbers": True, "tab_size": 2},
54
+ "doc": "const area = (r) => {\n return Math.PI * r ** 2;\n};\n",
55
+ },
56
+ {
57
+ "key": "gallery-json",
58
+ "title": "JSON",
59
+ "settings": "light · read-only · tab 2",
60
+ "props": {"language": "json", "theme": "light", "read_only": True, "tab_size": 2},
61
+ "doc": '{\n "name": "spaday-codemirror",\n "private": true\n}\n',
62
+ },
63
+ {
64
+ "key": "gallery-markdown",
65
+ "title": "Markdown",
66
+ "settings": "light · no line numbers",
67
+ "props": {"language": "markdown", "theme": "light", "line_numbers": False},
68
+ "doc": "# Notes\n\n- **bold** and *italic*\n- `inline code`\n",
69
+ },
70
+ {
71
+ "key": "gallery-plain",
72
+ "title": "Plain text",
73
+ "settings": "dark · read-only · no line numbers · tab 8",
74
+ "props": {"language": "plain", "theme": "dark", "read_only": True, "line_numbers": False, "tab_size": 8},
75
+ "doc": "col\tvalue\nalpha\t1\nbeta\t2\n",
76
+ },
77
+ )
78
+
79
+ styles = """<style>
80
+ :root { color-scheme: light; }
81
+ body { margin: 0; background: #f4f6f8; color: #14212b; font: 15px/1.5 system-ui, sans-serif; }
82
+ #codemirror-example { box-sizing: border-box; max-width: 72rem; margin: 0 auto; padding: 1.5rem 1rem 3rem; }
83
+ #codemirror-example * { box-sizing: border-box; }
84
+ #codemirror-example h1 { margin: 0 0 0.25rem; font-size: 1.6rem; }
85
+ #codemirror-example h2 { margin: 2rem 0 0.75rem; font-size: 1.1rem; }
86
+ #codemirror-example .intro { margin: 0 0 1.25rem; color: #4a5a66; max-width: 46rem; }
87
+ .cm-workspace { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; }
88
+ .cm-panel { min-width: 0; padding: 0.75rem; border: 1px solid #d8e1e5; border-radius: 8px; background: #fff; }
89
+ .cm-panel h3, .cm-card h3 { margin: 0 0 0.5rem; font-size: 0.95rem; }
90
+ .cm-panel spaday-codemirror { height: 16rem; }
91
+ .cm-toolbar { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin: 0.75rem 0 0; }
92
+ .cm-toolbar button { padding: 0.4rem 0.8rem; border: 1px solid #9fb3c0; border-radius: 6px; background: #fff; font: inherit; cursor: pointer; }
93
+ .cm-toolbar button:hover { background: #eef3f6; }
94
+ .cm-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0.5rem; margin: 1rem 0 0; }
95
+ .cm-metric { min-width: 0; padding: 0.5rem 0.75rem; border-radius: 6px; background: #eef3f6; }
96
+ .cm-metric span { display: block; color: #4a5a66; font-size: 0.8rem; }
97
+ .cm-metric strong { font-size: 1.2rem; font-variant-numeric: tabular-nums; }
98
+ .cm-status { margin: 0.5rem 0 0; color: #4a5a66; overflow-wrap: anywhere; }
99
+ .cm-gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 16rem), 1fr)); gap: 0.75rem; }
100
+ .cm-card { min-width: 0; padding: 0.75rem; border: 1px solid #d8e1e5; border-radius: 8px; background: #fff; }
101
+ .cm-card p { margin: 0 0 0.5rem; color: #4a5a66; font-size: 0.8rem; }
102
+ .cm-card spaday-codemirror { height: 7.5rem; }
103
+ @media (max-width: 720px) {
104
+ .cm-workspace { grid-template-columns: minmax(0, 1fr); }
105
+ .cm-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
106
+ }
107
+ </style>"""
108
+
109
+
110
+ class Document(BaseModel):
111
+ doc: str = SAMPLE
112
+ revision: int = 0
113
+ status: str = "Ready"
114
+
115
+
116
+ def normalize(doc: str, tab_size: int = TAB_SIZE) -> str:
117
+ """Expand tabs, strip trailing whitespace, and end with exactly one newline."""
118
+ lines = [line.expandtabs(tab_size).rstrip() for line in doc.split("\n")]
119
+ text = "\n".join(lines).rstrip("\n")
120
+ return f"{text}\n" if text else ""
121
+
122
+
123
+ def metrics(doc: str) -> dict[str, int]:
124
+ return {"characters": len(doc), "lines": doc.count("\n") + 1, "words": len(doc.split())}
125
+
126
+
127
+ def python_action(action: str) -> Steps:
128
+ """Sync the browser's text as Python's baseline, then ask Python to rewrite it."""
129
+ return Steps(
130
+ SendPatch("editor", "sync", prop(by_id(SOURCE_ID), "doc")),
131
+ SendPatch("editor", "action", lit(action)),
132
+ )
133
+
134
+
135
+ class CodeMirrorExample:
136
+ """One page's state: a transports-hosted ``Document`` plus the editor text Python last authored."""
137
+
138
+ def __init__(self) -> None:
139
+ self.session = Session()
140
+ self.model_id = self.session.host(Document())
141
+ self.server = Server(self.session, default_codec="msgpack")
142
+ self.client = Client(codec="msgpack")
143
+ for frame in self.server.open("browser", "msgpack"):
144
+ self.client.recv(frame)
145
+ # The source editor's `doc` prop. Changes only when Python authors the text.
146
+ self.authored = SAMPLE
147
+ self.app = WorkerApp(self.render, self.on_intent)
148
+
149
+ @property
150
+ def document(self) -> Document:
151
+ return self.client.model(self.model_id, Document)
152
+
153
+ def _commit(self, **changes) -> None:
154
+ proposal = self.document.model_copy(update=changes)
155
+ outbound = self.client.edit(self.model_id, to_value(proposal))
156
+ for frame in self.server.recv("browser", outbound)["browser"]:
157
+ self.client.recv(frame)
158
+
159
+ def on_intent(self, intent: dict) -> None:
160
+ detail = intent["detail"]
161
+ if detail.get("model") != "editor":
162
+ return
163
+ field, value = detail.get("field"), detail.get("value")
164
+ document = self.document
165
+ if field == "doc" and isinstance(value, str) and value != document.doc:
166
+ self._commit(doc=value, revision=document.revision + 1, status=f"Edited in browser · revision {document.revision + 1}")
167
+ elif field == "sync" and isinstance(value, str):
168
+ # Adopt the browser's text as the authored baseline so the following action always patches.
169
+ self.authored = value
170
+ if value != document.doc:
171
+ self._commit(doc=value, revision=document.revision + 1)
172
+ elif field == "action" and value in ("reset", "normalize"):
173
+ target = SAMPLE if value == "reset" else normalize(document.doc)
174
+ label = "Reset" if value == "reset" else "Normalized"
175
+ if target == document.doc:
176
+ self._commit(status=f"{label} in Python · no change")
177
+ else:
178
+ self._commit(doc=target, revision=document.revision + 1, status=f"{label} in Python · revision {document.revision + 1}")
179
+ self.authored = target
180
+
181
+ def render(self):
182
+ document = self.document
183
+ counts = metrics(document.doc)
184
+ source = CodeMirror(
185
+ key=SOURCE_ID,
186
+ id=SOURCE_ID,
187
+ doc=self.authored,
188
+ language="python",
189
+ theme="light",
190
+ tab_size=TAB_SIZE,
191
+ ).on("editor-change", SendPatch("editor", "doc", event_value("doc")))
192
+ preview = CodeMirror(
193
+ key="preview-editor",
194
+ id="preview-editor",
195
+ doc=normalize(document.doc),
196
+ language="python",
197
+ theme="dark",
198
+ read_only=True,
199
+ tab_size=TAB_SIZE,
200
+ )
201
+ return (
202
+ element("main", id="codemirror-example")
203
+ .child(element("h1").text("spaday-codemirror"))
204
+ .child(
205
+ element("p")
206
+ .classes("intro")
207
+ .text("Edit the source. Each change goes to Python, which updates the model, the metrics, and the normalized preview.")
208
+ )
209
+ .child(
210
+ element("section")
211
+ .classes("cm-workspace")
212
+ .child(
213
+ element("div")
214
+ .classes("cm-panel")
215
+ .child(element("h3").text("Source · editable"))
216
+ .child(source)
217
+ .child(
218
+ element("div")
219
+ .classes("cm-toolbar")
220
+ .child(element("button", id="normalize", type="button").text("Normalize in Python").on("click", python_action("normalize")))
221
+ .child(element("button", id="reset", type="button").text("Reset").on("click", python_action("reset")))
222
+ )
223
+ )
224
+ .child(element("div").classes("cm-panel").child(element("h3").text("Normalized preview · read-only, from Python")).child(preview))
225
+ )
226
+ .child(
227
+ element("section", id="metrics")
228
+ .classes("cm-metrics")
229
+ .child(*(self._metric(name, value) for name, value in (*counts.items(), ("revision", document.revision))))
230
+ )
231
+ .child(element("p", id="status").classes("cm-status").text(document.status))
232
+ .child(element("h2").text("Gallery"))
233
+ .child(element("section", id="gallery").classes("cm-gallery").child(*(self._card(entry) for entry in GALLERY)))
234
+ )
235
+
236
+ @staticmethod
237
+ def _metric(name: str, value: int):
238
+ return (
239
+ element("div")
240
+ .classes("cm-metric")
241
+ .child(element("span").text(name.capitalize()), element("strong", id=f"metric-{name}").text(str(value)))
242
+ )
243
+
244
+ @staticmethod
245
+ def _card(entry: dict):
246
+ return (
247
+ element("article", id=entry["key"])
248
+ .classes("cm-card")
249
+ .child(element("h3").text(entry["title"]))
250
+ .child(element("p").text(entry["settings"]))
251
+ .child(CodeMirror(key=entry["key"], doc=entry["doc"], **entry["props"]))
252
+ )
253
+
254
+
255
+ example = CodeMirrorExample()
256
+ worker_app = example.app
257
+
258
+ PAGE = f"""<!doctype html>
259
+ <html lang="en">
260
+ <head>
261
+ <meta charset="utf-8" />
262
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
263
+ <title>spaday-codemirror</title>
264
+ <link rel="stylesheet" href="/components/codemirror/css/index.css" />
265
+ {styles}
266
+ </head>
267
+ <body>
268
+ <div id="example-root"></div>
269
+ <script type="module">
270
+ import {{ connectWorker, init }} from "/runtime/spaday/cdn/index.js";
271
+ import "/components/codemirror/cdn/index.js";
272
+
273
+ await init({{ module_or_path: "/runtime/spaday/pkg/spaday_bg.wasm" }});
274
+ // Present the websocket as the Worker that connectWorker expects: same snapshot/patch protocol.
275
+ const socket = new WebSocket(`${{location.protocol === "https:" ? "wss" : "ws"}}://${{location.host}}/ws`);
276
+ const opened = new Promise((resolve) => socket.addEventListener("open", resolve, {{ once: true }}));
277
+ const channel = new EventTarget();
278
+ socket.addEventListener("message", (event) => channel.dispatchEvent(new MessageEvent("message", {{ data: JSON.parse(event.data) }})));
279
+ channel.postMessage = (message) => opened.then(() => socket.send(JSON.stringify(message)));
280
+ await connectWorker(document.querySelector("#example-root"), channel).ready;
281
+ document.documentElement.dataset.ready = "true";
282
+ </script>
283
+ </body>
284
+ </html>
285
+ """
286
+
287
+
288
+ async def homepage(_request) -> HTMLResponse:
289
+ return HTMLResponse(PAGE)
290
+
291
+
292
+ async def socket(websocket: WebSocket) -> None:
293
+ await websocket.accept()
294
+ page = CodeMirrorExample()
295
+ try:
296
+ async for text in websocket.iter_text():
297
+ message = json.loads(text)
298
+ await websocket.send_text(page.app.start_json() if message.get("type") == "start" else page.app.dispatch_json(text))
299
+ except WebSocketDisconnect:
300
+ pass
301
+
302
+
303
+ app = Starlette(
304
+ routes=[
305
+ Route("/", homepage),
306
+ WebSocketRoute("/ws", socket),
307
+ Mount("/components/codemirror", StaticFiles(directory=package.assets_dir)),
308
+ Mount("/runtime/spaday", StaticFiles(directory=Path(spaday.__file__).parent / "extension")),
309
+ ]
310
+ )
311
+
312
+
313
+ def main() -> None:
314
+ import uvicorn
315
+
316
+ uvicorn.run(app, host=HOST, port=PORT)
317
+
318
+
319
+ if __name__ == "__main__":
320
+ main()