keyframes 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.
Files changed (77) hide show
  1. keyframes/__init__.py +1 -0
  2. keyframes/build.py +102 -0
  3. keyframes/check.py +107 -0
  4. keyframes/cli.py +149 -0
  5. keyframes/deck.py +67 -0
  6. keyframes/export.py +106 -0
  7. keyframes/guide/__init__.py +25 -0
  8. keyframes/guide/components/keyframes-code.md +60 -0
  9. keyframes/guide/components/keyframes-excalidraw.md +148 -0
  10. keyframes/guide/components/keyframes-mujoco.md +76 -0
  11. keyframes/guide/components/keyframes-slider.md +65 -0
  12. keyframes/guide/components/keyframes-three.md +81 -0
  13. keyframes/guide/core.md +113 -0
  14. keyframes/patch.py +77 -0
  15. keyframes/render.py +32 -0
  16. keyframes/scaffold/.gitignore +3 -0
  17. keyframes/scaffold/CLAUDE.md +1 -0
  18. keyframes/scaffold/assets/deck.css +1 -0
  19. keyframes/scaffold/assets/figure.svg +1 -0
  20. keyframes/scaffold/deck.toml +10 -0
  21. keyframes/scaffold/references/.gitkeep +0 -0
  22. keyframes/scaffold/slides/01-title.html +4 -0
  23. keyframes/scaffold/slides/02-intro.html +17 -0
  24. keyframes/server.py +204 -0
  25. keyframes/static/components/keyframes-code.js +423 -0
  26. keyframes/static/components/keyframes-excalidraw.js +189 -0
  27. keyframes/static/components/keyframes-mujoco.js +515 -0
  28. keyframes/static/components/keyframes-slider.js +255 -0
  29. keyframes/static/components/keyframes-three.js +224 -0
  30. keyframes/static/editor/editor.css +228 -0
  31. keyframes/static/editor/editor.js +594 -0
  32. keyframes/static/editor/panel.css +127 -0
  33. keyframes/static/editor/panel.js +357 -0
  34. keyframes/static/index.html +23 -0
  35. keyframes/static/runtime/component.js +126 -0
  36. keyframes/static/runtime/live.js +31 -0
  37. keyframes/static/runtime/math.js +33 -0
  38. keyframes/static/runtime/presenter.js +91 -0
  39. keyframes/static/runtime/runtime.css +91 -0
  40. keyframes/static/runtime/runtime.js +215 -0
  41. keyframes/static/runtime/steps.js +15 -0
  42. keyframes/terminal.py +144 -0
  43. keyframes/themes/default/STYLE_GUIDE.md +73 -0
  44. keyframes/themes/default/assets/arc-top.webp +0 -0
  45. keyframes/themes/default/assets/orbit-ring.webp +0 -0
  46. keyframes/themes/default/assets/streaks-top.webp +0 -0
  47. keyframes/themes/default/assets/streaks-wide.webp +0 -0
  48. keyframes/themes/default/assets/sun-top.webp +0 -0
  49. keyframes/themes/default/assets/title-orbit.webp +0 -0
  50. keyframes/themes/default/layouts/content.html +16 -0
  51. keyframes/themes/default/layouts/figure.html +7 -0
  52. keyframes/themes/default/layouts/grid-2.html +19 -0
  53. keyframes/themes/default/layouts/grid-3x2.html +28 -0
  54. keyframes/themes/default/layouts/grid-4.html +44 -0
  55. keyframes/themes/default/layouts/media-side.html +16 -0
  56. keyframes/themes/default/layouts/section-arc.html +4 -0
  57. keyframes/themes/default/layouts/section-streaks.html +4 -0
  58. keyframes/themes/default/layouts/section.html +4 -0
  59. keyframes/themes/default/layouts/split-wide.html +7 -0
  60. keyframes/themes/default/layouts/split.html +13 -0
  61. keyframes/themes/default/layouts/statement.html +4 -0
  62. keyframes/themes/default/layouts/title.html +5 -0
  63. keyframes/themes/default/layouts/two-column.html +5 -0
  64. keyframes/themes/default/theme.css +330 -0
  65. keyframes/themes/minimal/STYLE_GUIDE.md +41 -0
  66. keyframes/themes/minimal/layouts/figure.html +7 -0
  67. keyframes/themes/minimal/layouts/section.html +4 -0
  68. keyframes/themes/minimal/layouts/statement.html +3 -0
  69. keyframes/themes/minimal/layouts/title-body.html +10 -0
  70. keyframes/themes/minimal/layouts/title.html +5 -0
  71. keyframes/themes/minimal/layouts/two-column.html +11 -0
  72. keyframes/themes/minimal/theme.css +196 -0
  73. keyframes/vendor.py +181 -0
  74. keyframes-0.1.0.dist-info/METADATA +119 -0
  75. keyframes-0.1.0.dist-info/RECORD +77 -0
  76. keyframes-0.1.0.dist-info/WHEEL +4 -0
  77. keyframes-0.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,17 @@
1
+ <section data-layout="split">
2
+ <h2 class="title" data-block="title" data-x="88" data-y="72" data-w="800" data-h="170">The claim this slide makes, as one sentence</h2>
3
+ <div class="body" data-block="points" data-x="88" data-y="270" data-w="800" data-h="560">
4
+ Lead-in that sets up the figure:
5
+ <ul class="small">
6
+ <li>Supporting point with a number: 87% vs 41%</li>
7
+ <li>Second supporting point</li>
8
+ </ul>
9
+ <p class="lead" data-step="1">Takeaway, revealed last.</p>
10
+ </div>
11
+ <div class="media figure" data-block="figure" data-x="1010" data-y="180" data-w="860" data-h="600">
12
+ <img class="contain" src="assets/figure.svg" alt="">
13
+ </div>
14
+ <p class="caption" data-block="caption" data-x="1010" data-y="788" data-w="860" data-h="90">What the figure shows · simulation, 100 trials</p>
15
+ <p class="cite" data-block="cite" data-x="88" data-y="975" data-w="800" data-h="60">Author et al. Venue Year</p>
16
+ <aside class="notes">Point at the figure first. Mention the main caveat. End on why it matters.</aside>
17
+ </section>
keyframes/server.py ADDED
@@ -0,0 +1,204 @@
1
+ import asyncio
2
+ import contextlib
3
+ import json
4
+ import socket
5
+ import threading
6
+ import time
7
+ from pathlib import Path
8
+
9
+ import httpx
10
+ from starlette.applications import Starlette
11
+ from starlette.requests import Request
12
+ from starlette.responses import HTMLResponse, JSONResponse, Response
13
+ from starlette.routing import Mount, Route, WebSocketRoute
14
+ from starlette.staticfiles import StaticFiles
15
+ from starlette.websockets import WebSocket, WebSocketDisconnect
16
+ from watchfiles import Change, awatch
17
+
18
+ from .deck import STATIC_DIR, Deck, DeckError
19
+ from .patch import geometry_attrs, patch_blocks
20
+ from .render import render_index, render_slide
21
+ from .terminal import TerminalManager
22
+ from .vendor import VendorCache
23
+
24
+ IGNORED_DIRS = {".keyframes", "dist", ".git", ".venv", "node_modules", "__pycache__"}
25
+
26
+
27
+ class NoCacheStatic(StaticFiles):
28
+ def file_response(self, *args, **kwargs):
29
+ response = super().file_response(*args, **kwargs)
30
+ response.headers["Cache-Control"] = "no-store"
31
+ return response
32
+
33
+
34
+ class LiveReload:
35
+ """Watches the deck and pushes changes to connected browsers, ignoring our own writes."""
36
+
37
+ def __init__(self, deck: Deck):
38
+ self.deck = deck
39
+ self.clients: set[WebSocket] = set()
40
+ self.own_writes: dict[Path, str] = {}
41
+
42
+ def write(self, path: Path, content: str):
43
+ self.own_writes[path] = content
44
+ path.write_text(content)
45
+
46
+ async def broadcast(self, message: dict):
47
+ for ws in list(self.clients):
48
+ try:
49
+ await ws.send_text(json.dumps(message))
50
+ except Exception:
51
+ self.clients.discard(ws)
52
+
53
+ def _message_for(self, change: Change, path: Path) -> dict | None:
54
+ if path.parent == self.deck.slides_dir and path.suffix == ".html":
55
+ if change != Change.modified:
56
+ return {"type": "reload"}
57
+ content = path.read_text()
58
+ if self.own_writes.pop(path, None) == content:
59
+ return None
60
+ return {"type": "slide", "name": path.stem, "html": render_slide(path.stem, content)}
61
+ if path.suffix == ".css":
62
+ return {"type": "css"}
63
+ return {"type": "reload"}
64
+
65
+ async def watch(self):
66
+ watched = [self.deck.root, self.deck.theme_dir]
67
+ async for changes in awatch(*watched, watch_filter=self._watch_filter):
68
+ messages = []
69
+ for change, raw in changes:
70
+ message = self._message_for(change, Path(raw))
71
+ if message and message not in messages:
72
+ messages.append(message)
73
+ if any(m["type"] == "reload" for m in messages):
74
+ messages = [{"type": "reload"}]
75
+ for message in messages:
76
+ await self.broadcast(message)
77
+
78
+ def _watch_filter(self, change: Change, raw: str) -> bool:
79
+ return not (IGNORED_DIRS & set(Path(raw).parts)) and not raw.endswith("~")
80
+
81
+
82
+ def same_origin(headers) -> bool:
83
+ """Blocks other websites from driving the local editor APIs (and its terminals)."""
84
+ origin = headers.get("origin")
85
+ return origin is None or origin.split("://", 1)[-1] == headers.get("host")
86
+
87
+
88
+ def create_app(deck: Deck, mode: str = "dev") -> Starlette:
89
+ vendor = VendorCache(deck.cache_dir)
90
+ live = LiveReload(deck)
91
+ terminals = TerminalManager(str(deck.root))
92
+
93
+ async def index(request: Request):
94
+ return HTMLResponse(render_index(deck, mode), headers={"Cache-Control": "no-store"})
95
+
96
+ async def vendor_file(request: Request):
97
+ host = request.path_params["host"]
98
+ path = "/" + request.path_params["path"]
99
+ try:
100
+ entry = await vendor.get(host, path, request.url.query)
101
+ except PermissionError as e:
102
+ return Response(str(e), status_code=403)
103
+ except httpx.HTTPError as e:
104
+ return Response(f"Vendor fetch failed: {e}", status_code=502)
105
+ return Response(
106
+ entry.body,
107
+ media_type=entry.content_type,
108
+ headers={"Cache-Control": "public, max-age=31536000, immutable"},
109
+ )
110
+
111
+ async def save_geometry(request: Request):
112
+ if not same_origin(request.headers):
113
+ return JSONResponse({"error": "cross-origin request"}, status_code=403)
114
+ payload = await request.json()
115
+ try:
116
+ path = deck.slide_path(payload["slide"])
117
+ updates = {bid: geometry_attrs(g) for bid, g in payload["blocks"].items()}
118
+ live.write(path, patch_blocks(path.read_text(), updates))
119
+ except (DeckError, KeyError, ValueError) as e:
120
+ return JSONResponse({"error": str(e)}, status_code=400)
121
+ return JSONResponse({"ok": True})
122
+
123
+ async def source(request: Request):
124
+ if not same_origin(request.headers):
125
+ return JSONResponse({"error": "cross-origin request"}, status_code=403)
126
+ try:
127
+ path = deck.slide_path(request.path_params["slide"])
128
+ except DeckError as e:
129
+ return JSONResponse({"error": str(e)}, status_code=404)
130
+ if request.method == "PUT":
131
+ path.write_text((await request.body()).decode())
132
+ return JSONResponse({"ok": True})
133
+ return Response(path.read_text(), media_type="text/plain", headers={"Cache-Control": "no-store"})
134
+
135
+ async def info(request: Request):
136
+ return JSONResponse({"terminals": list(terminals.commands)})
137
+
138
+ async def terminal(ws: WebSocket):
139
+ if not same_origin(ws.headers):
140
+ await ws.close(code=4403)
141
+ return
142
+ await terminals.serve(ws, ws.path_params["name"])
143
+
144
+ async def websocket(ws: WebSocket):
145
+ await ws.accept()
146
+ live.clients.add(ws)
147
+ try:
148
+ while True:
149
+ await ws.receive_text()
150
+ except WebSocketDisconnect:
151
+ live.clients.discard(ws)
152
+
153
+ @contextlib.asynccontextmanager
154
+ async def lifespan(app):
155
+ task = asyncio.create_task(live.watch()) if mode == "dev" else None
156
+ yield
157
+ if task:
158
+ task.cancel()
159
+ terminals.stop_all()
160
+ await vendor.close()
161
+
162
+ dev_routes = [
163
+ Route("/_api/geometry", save_geometry, methods=["POST"]),
164
+ Route("/_api/source/{slide}", source, methods=["GET", "PUT"]),
165
+ Route("/_api/info", info),
166
+ WebSocketRoute("/_api/live", websocket),
167
+ WebSocketRoute("/_api/term/{name}", terminal),
168
+ ]
169
+ routes = [
170
+ Route("/", index),
171
+ *(dev_routes if mode == "dev" else []),
172
+ Route("/_vendor/{host}/{path:path}", vendor_file),
173
+ Mount("/_keyframes", NoCacheStatic(directory=STATIC_DIR)),
174
+ Mount("/_theme", NoCacheStatic(directory=deck.theme_dir)),
175
+ Mount("/", NoCacheStatic(directory=deck.root)),
176
+ ]
177
+ return Starlette(routes=routes, lifespan=lifespan)
178
+
179
+
180
+ def free_port(start: int) -> int:
181
+ for port in range(start, start + 50):
182
+ with socket.socket() as s:
183
+ if s.connect_ex(("127.0.0.1", port)) != 0:
184
+ return port
185
+ raise RuntimeError("No free port found")
186
+
187
+
188
+ @contextlib.contextmanager
189
+ def background_server(deck: Deck, mode: str = "present"):
190
+ import uvicorn
191
+
192
+ port = free_port(8900)
193
+ server = uvicorn.Server(
194
+ uvicorn.Config(create_app(deck, mode), host="127.0.0.1", port=port, log_level="warning")
195
+ )
196
+ thread = threading.Thread(target=server.run, daemon=True)
197
+ thread.start()
198
+ while not server.started:
199
+ time.sleep(0.05)
200
+ try:
201
+ yield f"http://127.0.0.1:{port}/"
202
+ finally:
203
+ server.should_exit = True
204
+ thread.join()
@@ -0,0 +1,423 @@
1
+ import { SwComponent } from "../runtime/component.js";
2
+
3
+ const LANGUAGES = {
4
+ python: () => import("@codemirror/lang-python").then((m) => m.python()),
5
+ javascript: () => import("@codemirror/lang-javascript").then((m) => m.javascript()),
6
+ js: () => LANGUAGES.javascript(),
7
+ typescript: () => import("@codemirror/lang-javascript").then((m) => m.javascript({ typescript: true })),
8
+ ts: () => LANGUAGES.typescript(),
9
+ jsx: () => import("@codemirror/lang-javascript").then((m) => m.javascript({ jsx: true })),
10
+ tsx: () => import("@codemirror/lang-javascript").then((m) => m.javascript({ jsx: true, typescript: true })),
11
+ cpp: () => import("@codemirror/lang-cpp").then((m) => m.cpp()),
12
+ c: () => LANGUAGES.cpp(),
13
+ rust: () => import("@codemirror/lang-rust").then((m) => m.rust()),
14
+ json: () => import("@codemirror/lang-json").then((m) => m.json()),
15
+ css: () => import("@codemirror/lang-css").then((m) => m.css()),
16
+ html: () => import("@codemirror/lang-html").then((m) => m.html()),
17
+ };
18
+
19
+ const RUNNERS = { python: runPython, javascript: runJavaScript, js: runJavaScript };
20
+
21
+ const STYLE = `
22
+ keyframes-code {
23
+ display: flex;
24
+ flex-direction: column;
25
+ width: 100%;
26
+ height: 100%;
27
+ box-sizing: border-box;
28
+ overflow: hidden;
29
+ text-align: left;
30
+ color: var(--fg);
31
+ background: var(--surface);
32
+ border: 1px solid var(--border);
33
+ border-radius: 16px;
34
+ font-family: var(--font-mono);
35
+ font-size: var(--keyframes-code-size, 26px);
36
+ line-height: 1.55;
37
+ font-variant-ligatures: none;
38
+ }
39
+ keyframes-code > .keyframes-code-editor { flex: 1 1 auto; min-height: 0; }
40
+ keyframes-code > .keyframes-code-editor > pre { margin: 0; padding: 0.8em 1.1em; font: inherit; white-space: pre; overflow: hidden; }
41
+ keyframes-code .cm-editor { height: 100%; }
42
+ keyframes-code .keyframes-dim { opacity: 0.3; transition: opacity 0.3s ease; }
43
+ keyframes-code .keyframes-hl { background: color-mix(in srgb, var(--accent) 9%, transparent); transition: opacity 0.3s ease; }
44
+ keyframes-code > .keyframes-code-bar {
45
+ display: flex;
46
+ align-items: center;
47
+ gap: 0.8em;
48
+ padding: 0.4em 0.5em 0.4em 1.1em;
49
+ border-top: 1px solid var(--border);
50
+ font: 600 0.62em var(--font-sans);
51
+ color: var(--muted);
52
+ }
53
+ keyframes-code .keyframes-code-lang { text-transform: uppercase; letter-spacing: 0.1em; }
54
+ keyframes-code .keyframes-code-hint { margin-left: auto; font-weight: 400; }
55
+ keyframes-code .keyframes-code-run {
56
+ font: inherit;
57
+ color: #fff;
58
+ background: var(--accent);
59
+ border: 0;
60
+ border-radius: 8px;
61
+ padding: 0.35em 1.1em;
62
+ cursor: pointer;
63
+ }
64
+ keyframes-code .keyframes-code-run:disabled { opacity: 0.55; cursor: progress; }
65
+ keyframes-code > .keyframes-code-output {
66
+ flex: 0 0 var(--keyframes-code-output, 30%);
67
+ margin: 0;
68
+ padding: 0.6em 1.1em;
69
+ overflow: auto;
70
+ box-sizing: border-box;
71
+ background: var(--bg);
72
+ border-top: 1px solid var(--border);
73
+ font: inherit;
74
+ font-size: 0.8em;
75
+ white-space: pre-wrap;
76
+ overflow-wrap: anywhere;
77
+ }
78
+ keyframes-code .keyframes-code-output .err { color: #c62828; }
79
+ keyframes-code .keyframes-code-output .status { color: var(--muted); font-style: italic; }
80
+ `;
81
+
82
+ let codeMirror = null;
83
+ let pyodide = null;
84
+ let python = null;
85
+ let pythonQueue = Promise.resolve();
86
+
87
+ function loadCodeMirror() {
88
+ codeMirror ??= Promise.all([
89
+ import("@codemirror/state"),
90
+ import("@codemirror/view"),
91
+ import("@codemirror/commands"),
92
+ import("@codemirror/language"),
93
+ import("@lezer/highlight"),
94
+ ]).then(([state, view, commands, language, highlight]) => ({ state, view, commands, language, highlight }));
95
+ return codeMirror;
96
+ }
97
+
98
+ function loadPython() {
99
+ pyodide ??= import("pyodide")
100
+ .then((m) => m.loadPyodide())
101
+ .then((py) => (python = py))
102
+ .catch((e) => {
103
+ pyodide = null;
104
+ throw e;
105
+ });
106
+ return pyodide;
107
+ }
108
+
109
+ function injectStyle() {
110
+ if (document.getElementById("keyframes-code-style")) return;
111
+ const style = document.createElement("style");
112
+ style.id = "keyframes-code-style";
113
+ style.textContent = STYLE;
114
+ document.head.append(style);
115
+ }
116
+
117
+ function readSource(el) {
118
+ const script = el.querySelector(':scope > script[type="text/plain"]');
119
+ const text = script ? script.textContent.replaceAll("<\\/script>", "</script>") : el.textContent;
120
+ return dedent(text);
121
+ }
122
+
123
+ function dedent(text) {
124
+ const lines = text.replace(/\r\n?/g, "\n").split("\n");
125
+ while (lines.length && !lines[0].trim()) lines.shift();
126
+ while (lines.length && !lines.at(-1).trim()) lines.pop();
127
+ const indents = lines.filter((l) => l.trim()).map((l) => l.match(/^[ \t]*/)[0].length);
128
+ const indent = indents.length ? Math.min(...indents) : 0;
129
+ return lines.map((l) => l.slice(indent).trimEnd()).join("\n");
130
+ }
131
+
132
+ function parseHighlight(spec) {
133
+ if (!spec) return [];
134
+ return spec.split("|").map((segment) => {
135
+ if (!segment.trim()) return null;
136
+ return segment.split(",").map((part) => {
137
+ const [a, b = a] = part.split("-").map(Number);
138
+ return [a, b];
139
+ });
140
+ });
141
+ }
142
+
143
+ function element(tag, className, text = "") {
144
+ const el = document.createElement(tag);
145
+ el.className = className;
146
+ el.textContent = text;
147
+ return el;
148
+ }
149
+
150
+ function format(value) {
151
+ if (typeof value === "string") return value;
152
+ if (value instanceof Error) return `${value.name}: ${value.message}`;
153
+ try {
154
+ return JSON.stringify(value) ?? String(value);
155
+ } catch {
156
+ return String(value);
157
+ }
158
+ }
159
+
160
+ const SANDBOX = `<script>
161
+ const fmt = ${format.toString()};
162
+ const send = (kind, args) => parent.postMessage({ kind, text: args.map(fmt).join(" ") }, "*");
163
+ for (const [name, kind] of [["log", "out"], ["info", "out"], ["debug", "out"], ["warn", "err"], ["error", "err"]]) {
164
+ console[name] = (...args) => send(kind, args);
165
+ }
166
+ addEventListener("error", (e) => send("err", [e.error ?? e.message]));
167
+ addEventListener("unhandledrejection", (e) => send("err", [e.reason]));
168
+ addEventListener("message", async ({ data }) => {
169
+ try {
170
+ await new (async () => {}).constructor(data)();
171
+ } catch (e) {
172
+ send("err", [e]);
173
+ }
174
+ parent.postMessage({ kind: "done" }, "*");
175
+ });
176
+ <\/script>`;
177
+
178
+ async function runJavaScript(code, { print, host }) {
179
+ const frame = document.createElement("iframe");
180
+ frame.sandbox = "allow-scripts";
181
+ frame.hidden = true;
182
+ frame.srcdoc = SANDBOX;
183
+ const previous = host.querySelector(":scope > iframe");
184
+ previous?.stop.abort();
185
+ previous?.remove();
186
+ frame.stop = new AbortController();
187
+ host.append(frame);
188
+ await new Promise((resolve) => frame.addEventListener("load", resolve, { once: true }));
189
+ const done = new Promise((resolve) => {
190
+ const listen = ({ source, data }) => {
191
+ if (source !== frame.contentWindow) return;
192
+ if (data.kind === "done") resolve();
193
+ else print(data.text, data.kind);
194
+ };
195
+ addEventListener("message", listen, { signal: frame.stop.signal });
196
+ });
197
+ frame.contentWindow.postMessage(code, "*");
198
+ await done;
199
+ }
200
+
201
+ function cleanTraceback(message) {
202
+ const lines = message.trimEnd().split("\n");
203
+ const start = lines.findIndex((l) => l.includes('File "<exec>"'));
204
+ return start < 0 ? message : [lines[0], ...lines.slice(start)].join("\n");
205
+ }
206
+
207
+ async function runPython(code, { print, status, packages }) {
208
+ if (!python) status("Loading Python…");
209
+ const py = await loadPython();
210
+ const messageCallback = (msg) => status(msg);
211
+ if (packages.length) await py.loadPackage(packages, { messageCallback });
212
+ await py.loadPackagesFromImports(code, { messageCallback });
213
+ status(null);
214
+ py.setStdout({ batched: (s) => print(s, "out") });
215
+ py.setStderr({ batched: (s) => print(s, "err") });
216
+ const globals = py.toPy({});
217
+ try {
218
+ const result = await py.runPythonAsync(code, { globals });
219
+ if (result !== undefined) print(String(result), "out");
220
+ result?.destroy?.();
221
+ } catch (e) {
222
+ print(cleanTraceback(String(e.message ?? e)), "err");
223
+ } finally {
224
+ globals.destroy();
225
+ }
226
+ }
227
+
228
+ class SwCode extends SwComponent {
229
+ #code = null;
230
+ #view = null;
231
+ #highlight = [];
232
+ #step = 0;
233
+ #decorations = null;
234
+ #shell = null;
235
+ #running = false;
236
+
237
+ get lang() {
238
+ return (this.getAttribute("lang") || "text").toLowerCase();
239
+ }
240
+
241
+ get code() {
242
+ return this.#view ? this.#view.state.doc.toString() : this.#code;
243
+ }
244
+
245
+ connectedCallback() {
246
+ if (this.#shell) return;
247
+ injectStyle();
248
+ this.#code = readSource(this);
249
+ this.#highlight = parseHighlight(this.getAttribute("highlight"));
250
+ const size = this.getAttribute("font-size");
251
+ if (size) this.style.setProperty("--keyframes-code-size", /^\d+(\.\d+)?$/.test(size) ? `${size}px` : size);
252
+ this.#shell = { editor: element("div", "keyframes-code-editor") };
253
+ this.#shell.editor.append(element("pre", "", this.#code));
254
+ const parts = [this.#shell.editor];
255
+ if (this.hasAttribute("run") && RUNNERS[this.lang]) parts.push(...this.#buildRunner());
256
+ const markers = this.#highlight.slice(1).map((_, i) => {
257
+ const marker = element("span", "keyframes-code-step");
258
+ marker.hidden = true;
259
+ marker.dataset.step = String(i + 1);
260
+ return marker;
261
+ });
262
+ this.replaceChildren(...parts, ...markers);
263
+ }
264
+
265
+ #buildRunner() {
266
+ const bar = element("div", "keyframes-code-bar");
267
+ const button = element("button", "keyframes-code-run", "â–¶ Run");
268
+ button.addEventListener("mousedown", (e) => e.preventDefault());
269
+ button.addEventListener("click", () => this.run());
270
+ bar.append(element("span", "keyframes-code-lang", this.lang), element("span", "keyframes-code-hint", "⇧↵ to run"), button);
271
+ const output = element("pre", "keyframes-code-output");
272
+ output.append(element("span", "status", "Output appears here"));
273
+ Object.assign(this.#shell, { button, output });
274
+ return [bar, output];
275
+ }
276
+
277
+ async init() {
278
+ if (this.hasAttribute("run") && !RUNNERS[this.lang]) {
279
+ throw new Error(`run supports lang="python" or lang="javascript", not "${this.lang}"`);
280
+ }
281
+ const [cm, language] = await Promise.all([loadCodeMirror(), LANGUAGES[this.lang]?.()]);
282
+ const { EditorState, Compartment, Prec } = cm.state;
283
+ const { EditorView, keymap, lineNumbers, drawSelection } = cm.view;
284
+ const { defaultKeymap, history, historyKeymap, indentWithTab } = cm.commands;
285
+ const { syntaxHighlighting, bracketMatching, indentOnInput } = cm.language;
286
+ const readonly = this.hasAttribute("readonly");
287
+ this.#decorations = new Compartment();
288
+ const runKeys = [
289
+ { key: "Mod-Enter", run: () => (this.run(), true) },
290
+ { key: "Shift-Enter", run: () => (this.run(), true) },
291
+ ];
292
+ const extensions = [
293
+ theme(EditorView),
294
+ syntaxHighlighting(highlightStyle(cm)),
295
+ drawSelection(),
296
+ bracketMatching(),
297
+ indentOnInput(),
298
+ history(),
299
+ EditorState.tabSize.of(4),
300
+ Prec.highest(keymap.of([...(this.#shell.button ? runKeys : []), { key: "Escape", run: blur }])),
301
+ keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
302
+ this.#decorations.of(this.#lineDecorations(cm)),
303
+ language ?? [],
304
+ this.hasAttribute("line-numbers") ? lineNumbers() : [],
305
+ readonly ? [EditorState.readOnly.of(true), EditorView.editable.of(false)] : [],
306
+ ];
307
+ this.#view = new EditorView({ doc: this.#code, extensions });
308
+ this.#shell.editor.replaceChildren(this.#view.dom);
309
+ }
310
+
311
+ #lineDecorations(cm, segment = this.#highlight[Math.min(this.#step, this.#highlight.length - 1)]) {
312
+ if (!segment) return [];
313
+ const { Decoration, EditorView } = cm.view;
314
+ const hl = Decoration.line({ class: "keyframes-hl" });
315
+ const dim = Decoration.line({ class: "keyframes-dim" });
316
+ return EditorView.decorations.of((view) => {
317
+ const doc = view.state.doc;
318
+ const marks = [];
319
+ for (let n = 1; n <= doc.lines; n++) {
320
+ const on = segment.some(([a, b]) => n >= a && n <= b);
321
+ marks.push((on ? hl : dim).range(doc.line(n).from));
322
+ }
323
+ return Decoration.set(marks);
324
+ });
325
+ }
326
+
327
+ async #setDecorations(segment) {
328
+ const cm = await loadCodeMirror();
329
+ this.#view?.dispatch({ effects: this.#decorations.reconfigure(this.#lineDecorations(cm, segment)) });
330
+ }
331
+
332
+ step(n) {
333
+ this.#step = n;
334
+ if (this.#view && this.#highlight.length) this.#setDecorations();
335
+ }
336
+
337
+ async snapshot() {
338
+ const { output } = this.#shell;
339
+ if (output?.querySelector(":scope > .status:only-child")) output.hidden = true;
340
+ if (this.#view && this.#highlight.length) await this.#setDecorations(null);
341
+ }
342
+
343
+ destroy() {
344
+ this.#code = this.#view.state.doc.toString();
345
+ this.#view.destroy();
346
+ this.#view = null;
347
+ this.#shell.editor.replaceChildren(element("pre", "", this.#code));
348
+ }
349
+
350
+ async run() {
351
+ const { button, output } = this.#shell;
352
+ if (this.#running || !button) return;
353
+ this.#running = true;
354
+ button.disabled = true;
355
+ output.replaceChildren();
356
+ let statusLine = null;
357
+ const status = (text) => {
358
+ statusLine?.remove();
359
+ statusLine = text ? output.appendChild(element("span", "status", `${text}\n`)) : null;
360
+ };
361
+ const print = (text, kind) => {
362
+ output.append(element("span", kind, `${text}\n`));
363
+ output.scrollTop = output.scrollHeight;
364
+ };
365
+ const packages = (this.getAttribute("packages") || "").split(/[\s,]+/).filter(Boolean);
366
+ const runner = RUNNERS[this.lang];
367
+ const job = () => runner(this.code, { print, status, packages, host: this });
368
+ try {
369
+ status("Running…");
370
+ if (runner === runPython) await (pythonQueue = pythonQueue.then(job, job));
371
+ else await job();
372
+ status(null);
373
+ if (!output.childNodes.length) output.append(element("span", "status", "(no output)"));
374
+ } catch (e) {
375
+ status(null);
376
+ print(String(e?.message ?? e), "err");
377
+ } finally {
378
+ this.#running = false;
379
+ button.disabled = false;
380
+ }
381
+ }
382
+ }
383
+
384
+ function blur(view) {
385
+ view.contentDOM.blur();
386
+ return true;
387
+ }
388
+
389
+ function theme(EditorView) {
390
+ return EditorView.theme({
391
+ "&": { height: "100%", backgroundColor: "transparent", color: "var(--fg)", fontSize: "inherit" },
392
+ "&.cm-focused": { outline: "none" },
393
+ ".cm-scroller": { fontFamily: "var(--font-mono)", lineHeight: "1.55" },
394
+ ".cm-content": { padding: "0.8em 0", caretColor: "var(--accent)" },
395
+ ".cm-line": { padding: "0 1.1em" },
396
+ ".cm-cursor, .cm-dropCursor": { borderLeftColor: "var(--accent)", borderLeftWidth: "2px" },
397
+ "&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, ::selection":
398
+ { backgroundColor: "color-mix(in srgb, var(--accent) 20%, transparent)" },
399
+ ".cm-gutters": { backgroundColor: "transparent", color: "var(--muted)", border: "none", opacity: "0.6" },
400
+ ".cm-lineNumbers .cm-gutterElement": { padding: "0 0.2em 0 0.9em", minWidth: "1.6em" },
401
+ "&.cm-focused .cm-matchingBracket": { backgroundColor: "color-mix(in srgb, var(--accent-2) 25%, transparent)" },
402
+ });
403
+ }
404
+
405
+ function highlightStyle(cm) {
406
+ const { tags: t } = cm.highlight;
407
+ return cm.language.HighlightStyle.define([
408
+ { tag: [t.keyword, t.controlKeyword, t.moduleKeyword, t.operatorKeyword, t.definitionKeyword], color: "#7c3aed", fontWeight: "600" },
409
+ { tag: [t.string, t.special(t.string), t.regexp], color: "#0f7b4a" },
410
+ { tag: [t.number, t.bool, t.null, t.atom], color: "#c2570c" },
411
+ { tag: [t.comment, t.lineComment, t.blockComment], color: "var(--muted)", fontStyle: "italic" },
412
+ { tag: [t.function(t.variableName), t.function(t.propertyName)], color: "var(--accent)" },
413
+ { tag: [t.definition(t.variableName), t.definition(t.function(t.variableName))], color: "var(--accent)", fontWeight: "600" },
414
+ { tag: [t.className, t.typeName, t.namespace], color: "#b45309" },
415
+ { tag: [t.self, t.special(t.variableName)], color: "#be185d" },
416
+ { tag: [t.propertyName, t.attributeName], color: "#0e7490" },
417
+ { tag: [t.tagName], color: "#7c3aed" },
418
+ { tag: [t.operator, t.punctuation, t.bracket], color: "#5d6470" },
419
+ { tag: t.invalid, color: "#c62828" },
420
+ ]);
421
+ }
422
+
423
+ customElements.define("keyframes-code", SwCode);