flowscript-vm 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.
flowscript/svg.py ADDED
@@ -0,0 +1,766 @@
1
+ """Static SVG flowcharts of a FlowScript program, for hosts that render them in their own pages.
2
+
3
+ svg = flowscript.to_svg(source) # the flowchart (every statement a node)
4
+ svg = flowscript.to_svg(source, view="states") # the state diagram
5
+ svg = flowscript.to_svg(source, state="trading") # highlight the current state
6
+
7
+ The result is a self-contained ``<svg>`` element: inline it in HTML (e.g. a Django template with
8
+ ``|safe``) or save it as a .svg file. It carries its own scoped styles, so it needs no stylesheet:
9
+
10
+ * colours are CSS custom properties (``--fs-bg``, ``--fs-fg``, ``--fs-accent``, ...; see ``THEMES``)
11
+ that the host may override, e.g. ``.fs-chart { --fs-accent: #0a84ff }``;
12
+ * ``theme="auto"`` (default) follows ``prefers-color-scheme`` and a ``data-theme="light|dark"``
13
+ attribute on an ancestor (as on ``<html>``); ``"dark"`` and ``"light"`` fix the palette;
14
+ * element ids are prefixed per chart, so several charts can share a page.
15
+
16
+ Every node keeps ``data-ids`` (AST node ids), ``data-line`` (source line) and states keep
17
+ ``data-state`` (name), so a page can add its own interactivity (highlighting, links to source).
18
+
19
+ This mirrors the debugger's chart (flowscript/debugger/static/chart.js) without the editing parts.
20
+ """
21
+
22
+ import hashlib
23
+ import math
24
+
25
+ from .printer import expr_to_str
26
+
27
+ __all__ = ["to_svg", "THEMES"]
28
+
29
+ CW = 7.23 # monospace character width at 12px
30
+ G = 24 # vertical gap between nodes
31
+ HG = 34 # horizontal gap between branches
32
+ NH = 34 # node height
33
+
34
+ # Colour tokens. "dark" is the debugger's palette; "light" is its daylight counterpart.
35
+ THEMES = {
36
+ "dark": {
37
+ "bg": "#15181e", "fg": "#d7dce5", "muted": "#7d8696", "line": "#303643", "accent": "#5aa9ff",
38
+ "cur": "#ffcc4d", "ok": "#4fd18b", "bad": "#ff6b6b", "kw": "#c792ea", "ev": "#ffcb6b",
39
+ "cmt": "#697386", "edge": "#6d7789", "stroke": "#8a94a8", "gate-text": "#e8ecf3",
40
+ "n-proc": "#2b3a55", "n-io": "#2f4a3a", "n-sub": "#3b3357", "n-dec": "#4a3b22",
41
+ "n-goto": "#5a2c3a", "n-emit": "#2a4b55", "n-timer": "#3d4423", "n-ret": "#3a3a3a",
42
+ "n-ev": "#55401f", "n-note": "#2a2d33", "n-end": "#444a55", "n-leaf": "#222833",
43
+ "n-gate": "#2a303c", "decl-bg": "#1b1f27", "state": "#3d5a80", "state-title": "#9ec5ff",
44
+ "state-fill": "rgba(90,169,255,.04)", "any": "#7a68b8", "any-title": "#c3b5ff",
45
+ "any-fill": "rgba(160,130,255,.04)", "sd-fill": "#22324a", "sd-stroke": "#5a7fb0",
46
+ "cur-fill": "rgba(255,204,77,.08)",
47
+ },
48
+ "light": {
49
+ "bg": "#ffffff", "fg": "#1f2430", "muted": "#6b7383", "line": "#d5dae2", "accent": "#1f6fd1",
50
+ "cur": "#c98a00", "ok": "#1f9d5c", "bad": "#d14343", "kw": "#8a3fb8", "ev": "#9a6700",
51
+ "cmt": "#7a8293", "edge": "#7d8696", "stroke": "#7d8696", "gate-text": "#1f2430",
52
+ "n-proc": "#dfe8f7", "n-io": "#dcefe2", "n-sub": "#e9e3f7", "n-dec": "#f6ead2",
53
+ "n-goto": "#f6dde2", "n-emit": "#d9eef2", "n-timer": "#ebefd6", "n-ret": "#e6e6e6",
54
+ "n-ev": "#f7e4c6", "n-note": "#f5f6f8", "n-end": "#d7dbe2", "n-leaf": "#f1f3f6",
55
+ "n-gate": "#e4e8ee", "decl-bg": "#f7f8fa", "state": "#8fb0d8", "state-title": "#2357a0",
56
+ "state-fill": "rgba(31,111,209,.04)", "any": "#8f7fd0", "any-title": "#5a46b0",
57
+ "any-fill": "rgba(110,90,200,.04)", "sd-fill": "#e3edf9", "sd-stroke": "#6f95c8",
58
+ "cur-fill": "rgba(201,138,0,.10)",
59
+ },
60
+ }
61
+
62
+
63
+ # ------------------------------------------------------------------------------------- helpers
64
+ def _esc(s):
65
+ return (str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
66
+ .replace('"', "&quot;"))
67
+
68
+
69
+ def _tw(s):
70
+ return len(s) * CW
71
+
72
+
73
+ def _clip(s, n=44):
74
+ return s[:n - 1] + "…" if len(s) > n else s
75
+
76
+
77
+ def _f(v):
78
+ v = round(v * 10) / 10
79
+ return str(int(v)) if v == int(v) else str(v)
80
+
81
+
82
+ def _typed_params(it):
83
+ ptypes = it.get("ptypes") or []
84
+ return ", ".join("%s %s" % (ptypes[i] if i < len(ptypes) else "int", p)
85
+ for i, p in enumerate(it.get("params") or []))
86
+
87
+
88
+ def _sub_ids(e, acc=None):
89
+ acc = [] if acc is None else acc
90
+ if not isinstance(e, dict):
91
+ return acc
92
+ if "id" in e:
93
+ acc.append(e["id"])
94
+ for k in ("left", "right", "operand", "value", "ms"):
95
+ if isinstance(e.get(k), dict):
96
+ _sub_ids(e[k], acc)
97
+ for a in e.get("args") or []:
98
+ _sub_ids(a, acc)
99
+ return acc
100
+
101
+
102
+ def _is_gate(e):
103
+ return ((e["type"] == "binary" and e["op"] in ("and", "or", "xor")) or
104
+ (e["type"] == "unary" and e["op"] == "not"))
105
+
106
+
107
+ def _gate_inputs(e, chain=None):
108
+ if e["type"] == "unary":
109
+ return [e["operand"]]
110
+ out = []
111
+
112
+ def fl(x):
113
+ if x["type"] == "binary" and x["op"] == e["op"]:
114
+ if chain is not None:
115
+ chain.append(x["id"])
116
+ fl(x["left"])
117
+ fl(x["right"])
118
+ else:
119
+ out.append(x)
120
+ fl(e)
121
+ return out
122
+
123
+
124
+ class _Layout:
125
+ """A laid-out piece: extents left/right of its centre line, height, whether it ends the flow."""
126
+
127
+ def __init__(self, l, r, h, draw, term=False):
128
+ self.l, self.r, self.h, self.draw, self.term = l, r, h, draw, term
129
+
130
+
131
+ # ------------------------------------------------------------------------------------- renderer
132
+ class _Chart:
133
+ def __init__(self, ast, prefix, state=None):
134
+ self.ast = ast
135
+ self.p = prefix
136
+ self.cur_state = state
137
+ self.out = []
138
+ self.funcs = {i["name"] for i in ast["items"] if i["type"] == "func"}
139
+
140
+ def P(self, s):
141
+ self.out.append(s)
142
+
143
+ def marker(self, name):
144
+ return "url(#%s-%s)" % (self.p, name)
145
+
146
+ # --- gates ------------------------------------------------------------------------------
147
+ def gate_tree(self, e, root=None):
148
+ root = e["id"] if root is None else root
149
+ if not _is_gate(e):
150
+ text = _clip(expr_to_str(e), 34)
151
+ w, h = _tw(text) + 18, 22
152
+
153
+ def draw(xo, ym):
154
+ self.P('<g class="nd k-leaf" data-ids="%s" data-dec="%s" data-line="%s"><title>%s</title>'
155
+ '<rect class="sh" x="%s" y="%s" width="%s" height="%s" rx="4"/>'
156
+ '<text x="%s" y="%s">%s</text></g>'
157
+ % (" ".join(map(str, _sub_ids(e))), root, e.get("line", 0), _esc(expr_to_str(e)),
158
+ _f(xo - w), _f(ym - h / 2), _f(w), h, _f(xo - w + 9), _f(ym + 4), _esc(text)))
159
+ return _Layout(w, 0, h, draw)
160
+ chain = []
161
+ inputs = _gate_inputs(e, chain)
162
+ kids = [self.gate_tree(x, root) for x in inputs]
163
+ n = len(kids)
164
+ GW = 42
165
+ GH = 26 if e["type"] == "unary" else max(30, n * 14 + 6)
166
+ stack_h = sum(c.h for c in kids) + 8 * (n - 1)
167
+ cw = max(c.l for c in kids)
168
+ op = "not" if e["type"] == "unary" else e["op"]
169
+
170
+ def draw(xo, ym):
171
+ x0, x1, y0, y1 = xo - GW, xo, ym - GH / 2, ym + GH / 2
172
+ y = ym - stack_h / 2
173
+ for k, c in enumerate(kids):
174
+ cy, cx = y + c.h / 2, x0 - 22
175
+ py = ym if n == 1 else y0 + (k + 1) * GH / (n + 1)
176
+ self.P('<path class="wire" d="M%s %s H%s V%s H%s"/>'
177
+ % (_f(cx), _f(cy), _f(cx + 11), _f(py), _f(x0 + (5 if op in ("or", "xor") else 0))))
178
+ c.draw(cx, cy)
179
+ y += c.h + 8
180
+ if op == "and":
181
+ d = "M%s %s H%s A%s %s 0 0 1 %s %s H%s Z" % (
182
+ _f(x0), _f(y0), _f(x1 - GH / 2), _f(GH / 2), _f(GH / 2), _f(x1 - GH / 2), _f(y1), _f(x0))
183
+ elif op == "not":
184
+ d = "M%s %s L%s %s L%s %s Z" % (_f(x0), _f(y0), _f(x1 - 9), _f(ym), _f(x0), _f(y1))
185
+ else:
186
+ xs = x0 + 5 if op == "xor" else x0
187
+ d = "M%s %s Q%s %s %s %s Q%s %s %s %s Q%s %s %s %s Z" % (
188
+ _f(xs), _f(y0), _f(xs + 11), _f(ym), _f(xs), _f(y1), _f(xs + GW * 0.55), _f(y1),
189
+ _f(x1), _f(ym), _f(xs + GW * 0.55), _f(y0), _f(xs), _f(y0))
190
+ extra = ""
191
+ if op == "xor":
192
+ extra = '<path class="sh" style="fill:none" d="M%s %s Q%s %s %s %s"/>' % (
193
+ _f(x0), _f(y0), _f(x0 + 11), _f(ym), _f(x0), _f(y1))
194
+ if op == "not":
195
+ extra = '<circle class="sh" cx="%s" cy="%s" r="4.5"/>' % (_f(x1 - 4.5), _f(ym))
196
+ self.P('<g class="nd k-gate" data-ids="%s" data-dec="%s" data-line="%s"><title>%s</title>'
197
+ '<path class="sh" d="%s"/>%s<text class="gate-op" x="%s" y="%s">%s</text></g>'
198
+ % (" ".join(map(str, [e["id"]] + chain)), root, e.get("line", 0),
199
+ _esc(expr_to_str(e)), d, extra, _f(x0 + (4 if op == "not" else 9)), _f(ym + 3.5),
200
+ op.upper()))
201
+ return _Layout(cw + 22 + GW, 0, max(stack_h, GH), draw)
202
+
203
+ # --- shapes -----------------------------------------------------------------------------
204
+ @staticmethod
205
+ def shape(kind, x0, y0, w, h):
206
+ x1, y1, ym = x0 + w, y0 + h, y0 + h / 2
207
+ F = _f
208
+ if kind == "io":
209
+ return '<path class="sh" d="M%s %s H%s L%s %s H%s Z"/>' % (F(x0 + 9), F(y0), F(x1), F(x1 - 9), F(y1), F(x0))
210
+ if kind == "sub":
211
+ return ('<rect class="sh" x="%s" y="%s" width="%s" height="%s"/>'
212
+ '<path class="sh" d="M%s %s V%s M%s %s V%s" style="fill:none"/>'
213
+ % (F(x0), F(y0), F(w), F(h), F(x0 + 8), F(y0), F(y1), F(x1 - 8), F(y0), F(y1)))
214
+ if kind == "emit":
215
+ return '<path class="sh" d="M%s %s H%s L%s %s L%s %s H%s Z"/>' % (
216
+ F(x0), F(y0), F(x1 - 13), F(x1), F(ym), F(x1 - 13), F(y1), F(x0))
217
+ if kind in ("goto", "ret"):
218
+ return '<rect class="sh" x="%s" y="%s" width="%s" height="%s" rx="%s"/>' % (F(x0), F(y0), F(w), F(h), F(h / 2))
219
+ if kind == "ev":
220
+ return '<path class="sh" d="M%s %s H%s L%s %s L%s %s H%s L%s %s Z"/>' % (
221
+ F(x0 + 11), F(y0), F(x1 - 11), F(x1), F(ym), F(x1 - 11), F(y1), F(x0 + 11), F(x0), F(ym))
222
+ if kind == "note":
223
+ return '<path class="sh" d="M%s %s H%s L%s %s V%s H%s Z"/>' % (
224
+ F(x0), F(y0), F(x1 - 9), F(x1), F(y0 + 9), F(y1), F(x0))
225
+ if kind == "timer":
226
+ return '<rect class="sh" x="%s" y="%s" width="%s" height="%s" rx="9"/>' % (F(x0), F(y0), F(w), F(h))
227
+ return '<rect class="sh" x="%s" y="%s" width="%s" height="%s" rx="3"/>' % (F(x0), F(y0), F(w), F(h))
228
+
229
+ @staticmethod
230
+ def note_layout(note):
231
+ if not note:
232
+ return None
233
+ lines = [_clip(l, 40) for l in str(note).split("\n")]
234
+ return {"w": max(_tw(l) for l in lines) + 16, "h": len(lines) * 14 + 8, "lines": lines}
235
+
236
+ def note_block(self, nl, x, y, node_id=None):
237
+ attrs = ' data-ids="%s"' % node_id if node_id is not None else ""
238
+ self.P('<g class="nd k-note"%s>%s%s</g>' % (
239
+ attrs, self.shape("note", x, y, nl["w"], nl["h"]),
240
+ "".join('<text x="%s" y="%s">%s</text>' % (_f(x + 8), _f(y + 16 + i * 14), _esc(l))
241
+ for i, l in enumerate(nl["lines"]))))
242
+
243
+ def draw_note(self, nl, x, y, from_x, from_y):
244
+ self.P('<path class="note-line" d="M%s %s H%s"/>' % (_f(from_x), _f(from_y), _f(x)))
245
+ self.note_block(nl, x, y)
246
+
247
+ def node(self, node, kind, text, ids=None, term=False, min_w=96, head=False):
248
+ label = _clip(text, 46)
249
+ pad = 34 if kind == "sub" else 32 if kind in ("io", "emit") else 24
250
+ w = max(min_w, _tw(label) + pad)
251
+ nl = self.note_layout(node.get("note"))
252
+ r = w / 2 + (18 + nl["w"] if nl else 0)
253
+ ids = _sub_ids(node) if ids is None else ids
254
+
255
+ def draw(cx, y):
256
+ x0 = cx - w / 2
257
+ self.P('<g class="nd k-%s%s" data-ids="%s" data-line="%s"><title>%s</title>%s'
258
+ '<text x="%s" y="%s" text-anchor="middle">%s</text></g>'
259
+ % (kind, " head" if head else "", " ".join(map(str, ids)), node.get("line", 0),
260
+ _esc(text), self.shape(kind, x0, y, w, NH), _f(cx), _f(y + 21), _esc(label)))
261
+ if nl:
262
+ self.draw_note(nl, cx + w / 2 + 18, y, cx + w / 2, y + NH / 2)
263
+ return _Layout(w / 2, r, max(NH, nl["h"] if nl else 0), draw, term)
264
+
265
+ # --- flow ---------------------------------------------------------------------------------
266
+ def arrow(self, x, y0, y1):
267
+ self.P('<path class="edge" d="M%s %s V%s" marker-end="%s"/>' % (_f(x), _f(y0), _f(y1), self.marker("arr")))
268
+
269
+ def line(self, x, y0, y1):
270
+ self.P('<path class="edge" d="M%s %s V%s"/>' % (_f(x), _f(y0), _f(y1)))
271
+
272
+ def seq(self, stmts):
273
+ items = [self.stmt(s) for s in stmts or []]
274
+ h, l, r = G, 0, 0
275
+ for it in items:
276
+ h += it.h + G
277
+ l, r = max(l, it.l), max(r, it.r)
278
+ term = bool(items) and items[-1].term
279
+
280
+ def draw(cx, y):
281
+ yy = y
282
+ for i in range(len(items) + 1):
283
+ prev_term = i > 0 and items[i - 1].term
284
+ if not prev_term:
285
+ (self.arrow if i < len(items) else self.line)(cx, yy, yy + G)
286
+ yy += G
287
+ if i < len(items):
288
+ items[i].draw(cx, yy)
289
+ yy += items[i].h
290
+ return _Layout(l, r, h, draw, term)
291
+
292
+ def stmt(self, s):
293
+ t = s["type"]
294
+ E = expr_to_str
295
+ if t == "assign":
296
+ return self.node(s, "proc", "%s = %s" % (s["target"], E(s["value"])))
297
+ if t == "local":
298
+ return self.node(s, "proc", "%s %s%s" % (s.get("vtype") or "int", s["name"],
299
+ " = " + E(s["value"]) if s.get("value") else ""))
300
+ if t == "call":
301
+ return self.node(s, "sub" if s["name"] in self.funcs else "io", E(s))
302
+ if t == "goto":
303
+ return self.node(s, "goto", "⇒ " + s["state"], term=True, min_w=80)
304
+ if t == "return":
305
+ return self.node(s, "ret", "return" + (" " + E(s["value"]) if s.get("value") else ""),
306
+ term=True, min_w=70)
307
+ if t == "emit":
308
+ args = s.get("args") or []
309
+ return self.node(s, "emit", "raise_event %s%s" % (
310
+ s["event"], "(%s)" % ", ".join(E(a) for a in args) if args else ""))
311
+ if t == "start":
312
+ return self.node(s, "timer", "⧗ start %s%s%s" % (s["timer"], " every " if s.get("every") else " ",
313
+ E(s["ms"])))
314
+ if t == "stop":
315
+ return self.node(s, "timer", "⧗ stop " + s["timer"])
316
+ if t == "comment":
317
+ return self.node({"id": s["id"], "line": s.get("line", 0)}, "note",
318
+ s["text"].split("\n")[0], ids=[s["id"]])
319
+ if t == "if":
320
+ return self.decision(s, 0)
321
+ if t == "while":
322
+ return self.while_(s)
323
+ return self.node(s, "proc", "? " + t)
324
+
325
+ def diamond_info(self, cond):
326
+ if _is_gate(cond):
327
+ gt = self.gate_tree(cond)
328
+ dw, dh = 56, 40
329
+ return {"gt": gt, "dw": dw, "dh": dh, "text": "?", "top": max(0, gt.h / 2 - dh / 2 + 4),
330
+ "gw": gt.l + 14}
331
+ text = _clip(expr_to_str(cond), 30)
332
+ return {"gt": None, "dw": max(80, _tw(text) + 46), "dh": 46, "text": text, "top": 0, "gw": 0}
333
+
334
+ def draw_diamond(self, cond, D, cx, y_top, extra_ids):
335
+ y_mid, y_bot = y_top + D["dh"] / 2, y_top + D["dh"]
336
+ ids = ([cond["id"]] if D["gt"] else _sub_ids(cond)) + list(extra_ids)
337
+ hw = D["dw"] / 2
338
+ self.P('<g class="nd k-dec" data-ids="%s" data-line="%s"><title>%s</title>'
339
+ '<path class="sh" d="M%s %s L%s %s L%s %s L%s %s Z"/>'
340
+ '<text x="%s" y="%s" text-anchor="middle">%s</text></g>'
341
+ % (" ".join(map(str, ids)), cond.get("line", 0), _esc(expr_to_str(cond)),
342
+ _f(cx), _f(y_top), _f(cx + hw), _f(y_mid), _f(cx), _f(y_bot), _f(cx - hw), _f(y_mid),
343
+ _f(cx), _f(y_mid + 4), _esc(D["text"])))
344
+ if D["gt"]:
345
+ D["gt"].draw(cx - hw - 14, y_mid)
346
+ self.P('<path class="wire" d="M%s %s H%s" marker-end="%s"/>'
347
+ % (_f(cx - hw - 14), _f(y_mid), _f(cx - hw), self.marker("arr")))
348
+ return y_mid, y_bot
349
+
350
+ def yes_no(self, cx, hw, y_mid, y_bot):
351
+ self.P('<text class="lbl-yes" x="%s" y="%s">yes</text>' % (_f(cx + 5), _f(y_bot + 11)))
352
+ self.P('<text class="lbl-no" x="%s" y="%s">no</text>' % (_f(cx + hw + 5), _f(y_mid - 5)))
353
+
354
+ def decision(self, s, i):
355
+ br = s["branches"][i]
356
+ D = self.diamond_info(br["cond"])
357
+ Y = self.seq(br["body"])
358
+ if i + 1 < len(s["branches"]):
359
+ inner = self.decision(s, i + 1)
360
+
361
+ def ndraw(cx, y, inner=inner):
362
+ self.arrow(cx, y, y + G)
363
+ inner.draw(cx, y + G)
364
+ N = _Layout(inner.l, inner.r, G + inner.h, ndraw, inner.term)
365
+ else:
366
+ N = self.seq(s.get("else_body") or [])
367
+ hw = D["dw"] / 2
368
+ xN = max(hw, Y.r) + HG + N.l
369
+ l = max(Y.l, hw + D["gw"])
370
+ r = xN + max(N.r, 8)
371
+ h = D["top"] + max(D["dh"] + Y.h, D["dh"] / 2 + N.h) + 8
372
+ nl = self.note_layout(s.get("note")) if i == 0 else None
373
+
374
+ def draw(cx, y):
375
+ y_top = y + D["top"]
376
+ if D["top"]:
377
+ self.line(cx, y, y_top)
378
+ y_mid, y_bot = self.draw_diamond(br["cond"], D, cx, y_top, [s["id"]] if i == 0 else [])
379
+ merge = y + h
380
+ Y.draw(cx, y_bot)
381
+ if not Y.term:
382
+ self.line(cx, y_bot + Y.h, merge)
383
+ self.P('<path class="edge" d="M%s %s H%s"/>' % (_f(cx + hw), _f(y_mid), _f(cx + xN)))
384
+ N.draw(cx + xN, y_mid)
385
+ if not N.term:
386
+ self.P('<path class="edge" d="M%s %s V%s H%s" marker-end="%s"/>'
387
+ % (_f(cx + xN), _f(y_mid + N.h), _f(merge), _f(cx + 1), self.marker("arr")))
388
+ self.yes_no(cx, hw, y_mid, y_bot)
389
+ if nl:
390
+ self.draw_note(nl, cx + r + 10, y_top, cx + hw, y_top + 6)
391
+ return _Layout(l, r + (nl["w"] + 20 if nl else 0), h, draw, Y.term and N.term)
392
+
393
+ def while_(self, s):
394
+ D = self.diamond_info(s["cond"])
395
+ B = self.seq(s["body"])
396
+ entry = 14
397
+ hw = D["dw"] / 2
398
+ L = max(B.l, hw + D["gw"]) + 16
399
+ R = max(hw, B.r) + 22
400
+ h = entry + D["top"] + D["dh"] + B.h + 18
401
+ nl = self.note_layout(s.get("note"))
402
+
403
+ def draw(cx, y):
404
+ y_top = y + entry + D["top"]
405
+ self.arrow(cx, y, y_top)
406
+ y_mid, y_bot = self.draw_diamond(s["cond"], D, cx, y_top, [s["id"]])
407
+ B.draw(cx, y_bot)
408
+ yb = y_bot + B.h
409
+ if not B.term:
410
+ self.P('<path class="edge back" d="M%s %s V%s H%s V%s H%s" marker-end="%s"/>'
411
+ % (_f(cx), _f(yb), _f(yb + 8), _f(cx - L), _f(y + 7), _f(cx - 2), self.marker("arr")))
412
+ self.P('<path class="edge" d="M%s %s H%s V%s H%s"/>'
413
+ % (_f(cx + hw), _f(y_mid), _f(cx + R), _f(y + h), _f(cx + 1)))
414
+ self.yes_no(cx, hw, y_mid, y_bot)
415
+ if nl:
416
+ self.draw_note(nl, cx + R + 14, y_top, cx + R, y_top + 6)
417
+ return _Layout(L + 4, R + 4 + (nl["w"] + 20 if nl else 0), h, draw)
418
+
419
+ def routine(self, node):
420
+ is_func = node["type"] == "func"
421
+ if is_func:
422
+ head = "func %s(%s)%s" % (node["name"], _typed_params(node),
423
+ " -> " + node["returns"] if node.get("returns") else "")
424
+ else:
425
+ head = "on " + node["event"] + ("(%s)" % ", ".join(node["params"]) if node.get("params") else "")
426
+ H = self.node(node, "sub" if is_func else "ev", head, ids=[], min_w=120, head=True)
427
+ B = self.seq(node["body"])
428
+ end_h = 0 if B.term else 22
429
+
430
+ def draw(cx, y):
431
+ H.draw(cx, y)
432
+ B.draw(cx, y + H.h)
433
+ if not B.term:
434
+ ye = y + H.h + B.h + 11
435
+ self.P('<g class="nd k-end" data-ids="%s" data-line="%s"><title>end of %s</title>'
436
+ '<circle class="sh" cx="%s" cy="%s" r="10"/>'
437
+ '<text class="end" x="%s" y="%s" text-anchor="middle">end</text></g>'
438
+ % (node["id"], node.get("line", 0), _esc(head), _f(cx), _f(ye), _f(cx), _f(ye + 3)))
439
+ return _Layout(max(H.l, B.l), max(H.r, B.r), H.h + B.h + end_h, draw)
440
+
441
+ # --- whole program --------------------------------------------------------------------------
442
+ def decl_lines(self):
443
+ out = []
444
+ for it in self.ast["items"]:
445
+ t = it["type"]
446
+ if t == "extern":
447
+ k, s = "extern", "%s(%s)%s" % (it["name"], _typed_params(it),
448
+ " -> " + it["returns"] if it.get("returns") else "")
449
+ elif t == "event":
450
+ k, s = "event", it["name"] + ("(%s)" % _typed_params(it) if it.get("params") else "")
451
+ elif t == "timer":
452
+ k, s = "timer", it["name"]
453
+ elif t == "const":
454
+ k, s = "const", "%s = %s" % (it["name"], expr_to_str(it["value"]))
455
+ elif t == "var":
456
+ k, s = it.get("vtype") or "int", it["name"] + (" = " + expr_to_str(it["value"]) if it.get("value") else "")
457
+ else:
458
+ continue
459
+ out.append((k, _clip(s, 38), it))
460
+ return out
461
+
462
+ def card(self, x, y, title, rows):
463
+ """A declarations card: rows of (keyword, text, node)."""
464
+ w = max([_tw(title)] + [_tw(k + " " + t) for k, t, _n in rows]) + 28
465
+ h = len(rows) * 17 + 30
466
+ self.P('<rect class="decl-box" x="%s" y="%s" width="%s" height="%s" rx="6"/>' % (_f(x), _f(y), _f(w), h))
467
+ self.P('<text class="small" x="%s" y="%s">%s</text>' % (_f(x + 10), _f(y + 16), _esc(title)))
468
+ for i, (k, t, n) in enumerate(rows):
469
+ self.P('<g class="nd decl" data-ids="%s" data-line="%s"><text x="%s" y="%s">'
470
+ '<tspan class="decl-k">%s</tspan> <tspan class="decl-n">%s</tspan></text></g>'
471
+ % (n["id"], n.get("line", 0), _f(x + 10), _f(y + 35 + i * 17), _esc(k), _esc(t)))
472
+ return w, h
473
+
474
+ def render_flow(self):
475
+ M = 24
476
+ y = M
477
+ width = 0
478
+ items = self.ast["items"]
479
+ states = [i for i in items if i["type"] == "state"]
480
+ any_state = next((i for i in items if i["type"] == "any_state"), None)
481
+ name = self.ast.get("name")
482
+ title = "flow %s%s" % (name, " v%d" % self.ast["version"] if self.ast.get("version") else "") if name else "program"
483
+ self.P('<text class="sec-title" x="%s" y="%s">%s</text>' % (M, y + 4, _esc(title)))
484
+ y += 18
485
+ x, row_h = M, 0
486
+ decl = self.decl_lines()
487
+ if decl:
488
+ w, h = self.card(x, y, "SYSTEM INTERFACE", decl)
489
+ x += w + 40
490
+ row_h = h
491
+ for nt in (i for i in items if i["type"] == "comment"):
492
+ nl = self.note_layout(nt["text"])
493
+ self.note_block(nl, x, y, nt["id"])
494
+ x += nl["w"] + 30
495
+ row_h = max(row_h, nl["h"])
496
+ for f in (i for i in items if i["type"] == "func"):
497
+ R = self.routine(f)
498
+ R.draw(x + R.l, y)
499
+ x += R.l + R.r + 40
500
+ row_h = max(row_h, R.h)
501
+ width = max(width, x)
502
+ y += row_h + 36
503
+
504
+ start = next((s for s in states if s.get("initial")), states[0] if states else None)
505
+ containers = ([(True, any_state)] if any_state else []) + [(False, s) for s in states]
506
+ for is_any, st in containers:
507
+ handlers = [self.routine(h) for h in st["handlers"] if h["type"] == "on"]
508
+ svars = [v for v in st.get("vars") or [] if v["type"] == "var"]
509
+ notes = [h for h in (st.get("vars") or []) + st["handlers"] if h["type"] == "comment"]
510
+ top, pad = y, 18
511
+ sx, sh = M + pad, 0
512
+ cur = not is_any and self.cur_state == st["name"]
513
+ title_y = top + 22
514
+ box_at = len(self.out)
515
+ self.P("")
516
+ if is_any:
517
+ self.P('<text class="state-title any-title" x="%s" y="%s">any_state</text>'
518
+ '<text class="small" x="%s" y="%s">handlers for every state · a state\'s own '
519
+ 'handler for the same event takes precedence</text>'
520
+ % (M + pad, title_y, _f(M + pad + _tw("any_state") * 1.25 + 18), title_y))
521
+ else:
522
+ is_start = st is start
523
+ self.P('<text class="state-title" x="%s" y="%s">%sstate %s%s</text>'
524
+ % (M + pad + (18 if is_start else 0), title_y, "initial " if st.get("initial") else "",
525
+ _esc(st["name"]), " ◀ current" if cur else ""))
526
+ if is_start:
527
+ self.P('<circle class="initial" cx="%s" cy="%s" r="5"><title>initial state</title></circle>'
528
+ % (M + pad + 5, title_y - 5))
529
+ iy = top + 36
530
+ if svars:
531
+ rows = [(v.get("vtype") or "int",
532
+ v["name"] + (" = " + expr_to_str(v["value"]) if v.get("value") else ""), v)
533
+ for v in svars]
534
+ w, h = self.card(sx, iy, "state variables", rows)
535
+ sx += w + 30
536
+ sh = max(sh, h)
537
+ for nt in notes:
538
+ nl = self.note_layout(nt["text"])
539
+ self.note_block(nl, sx, iy, nt["id"])
540
+ sx += nl["w"] + 30
541
+ sh = max(sh, nl["h"])
542
+ for R in handlers:
543
+ R.draw(sx + R.l, iy)
544
+ sx += R.l + R.r + 36
545
+ sh = max(sh, R.h)
546
+ bw = max(sx - M - 36 + pad + 18, 560 if is_any else 260)
547
+ bh = sh + 36 + 18
548
+ if is_any:
549
+ self.out[box_at] = ('<rect class="state-box any-box" x="%s" y="%s" width="%s" height="%s" rx="12"/>'
550
+ % (M, top, _f(bw), _f(bh)))
551
+ else:
552
+ self.out[box_at] = ('<g class="state%s" data-state="%s"><rect class="state-box" x="%s" y="%s" '
553
+ 'width="%s" height="%s" rx="12"/></g>'
554
+ % (" cur" if cur else "", _esc(st["name"]), M, top, _f(bw), _f(bh)))
555
+ width = max(width, M + bw)
556
+ y += bh + 26
557
+ return math.ceil(width + M), math.ceil(y + M)
558
+
559
+ def render_states(self):
560
+ items = self.ast["items"]
561
+ states = [i for i in items if i["type"] == "state"]
562
+ if not states:
563
+ self.P('<text class="small" x="20" y="40">This program has no states.</text>')
564
+ return 420, 80
565
+ n = len(states)
566
+ RAD = 58
567
+ ring = 0 if n <= 1 else max(150, n * (RAD * 2 + 70) / (2 * math.pi))
568
+ cx0, cy0 = ring + RAD + 150, ring + RAD + 70
569
+ pos = {}
570
+ for i, s in enumerate(states):
571
+ a = -math.pi / 2 + i * 2 * math.pi / n + (math.pi / 2 if n == 2 else 0)
572
+ pos[s["name"]] = (cx0 + ring * math.cos(a), cy0 + ring * math.sin(a))
573
+ anyp = (70, 40)
574
+ edges = {}
575
+
576
+ def gotos(stmts, acc):
577
+ for s in stmts or []:
578
+ if s["type"] == "goto":
579
+ acc.append(s)
580
+ if s["type"] == "if":
581
+ for b in s["branches"]:
582
+ gotos(b["body"], acc)
583
+ gotos(s.get("else_body"), acc)
584
+ if s["type"] == "while":
585
+ gotos(s["body"], acc)
586
+ return acc
587
+
588
+ def add(frm, to, label):
589
+ edges.setdefault((frm, to), []).append(label)
590
+
591
+ any_state = next((i for i in items if i["type"] == "any_state"), None)
592
+ any_handlers = [h for h in any_state["handlers"] if h["type"] == "on"] if any_state else []
593
+ W, H = math.ceil(cx0 * 2 + 40), math.ceil(cy0 + ring + RAD + 60)
594
+ if any_state:
595
+ self.P('<rect class="any-frame" x="8" y="12" width="%s" height="%s" rx="16">'
596
+ '<title>any_state: its handlers apply in every state</title></rect>' % (W - 16, H - 20))
597
+ for st in states:
598
+ for h in st["handlers"]:
599
+ if h["type"] == "on":
600
+ for g in gotos(h["body"], []):
601
+ add(st["name"], g["state"], h["event"])
602
+ for h in any_handlers:
603
+ for g in gotos(h["body"], []):
604
+ add("*", g["state"], h["event"])
605
+ pt = lambda name: anyp if name == "*" else pos.get(name)
606
+ arr = self.marker("arr2")
607
+ for (frm, to), labels in edges.items():
608
+ a, b = pt(frm), pt(to)
609
+ if not a or not b:
610
+ continue
611
+ label = _esc(_clip(", ".join(dict.fromkeys(labels)), 30))
612
+ if frm == to:
613
+ ax, ay = a
614
+ self.P('<path class="sd-edge" d="M%s %s C%s %s %s %s %s %s" marker-end="%s"/>'
615
+ % (_f(ax - 20), _f(ay - RAD + 4), _f(ax - 50), _f(ay - RAD - 70), _f(ax + 50),
616
+ _f(ay - RAD - 70), _f(ax + 20), _f(ay - RAD + 4), arr))
617
+ self.P('<text class="sd-label" x="%s" y="%s" text-anchor="middle">%s</text>'
618
+ % (_f(ax), _f(ay - RAD - 56), label))
619
+ continue
620
+ dx, dy = b[0] - a[0], b[1] - a[1]
621
+ d = math.hypot(dx, dy) or 1
622
+ ux, uy = dx / d, dy / d
623
+ nx, ny = -uy, ux
624
+ r0, r1 = (8 if frm == "*" else RAD), RAD + 2
625
+ sx, sy = a[0] + ux * r0 + nx * 10, a[1] + uy * r0 + ny * 10
626
+ ex, ey = b[0] - ux * r1 + nx * 10, b[1] - uy * r1 + ny * 10
627
+ mx, my = (sx + ex) / 2 + nx * 26, (sy + ey) / 2 + ny * 26
628
+ self.P('<path class="sd-edge" d="M%s %s Q%s %s %s %s" marker-end="%s"/>'
629
+ % (_f(sx), _f(sy), _f(mx), _f(my), _f(ex), _f(ey), arr))
630
+ self.P('<text class="sd-label" x="%s" y="%s" text-anchor="middle">%s</text>' % (_f(mx), _f(my), label))
631
+ if any_state:
632
+ self.P('<rect class="any-tag" x="%s" y="%s" width="100" height="28" rx="6"/>'
633
+ '<text class="any-tag-text" x="%s" y="%s" text-anchor="middle">any_state</text>'
634
+ % (anyp[0] - 50, anyp[1] - 14, anyp[0], anyp[1] + 4))
635
+ evs = " · ".join(h["event"] for h in any_handlers)
636
+ if evs:
637
+ self.P('<text class="small" x="%s" y="%s">%s</text>' % (anyp[0] + 60, anyp[1] + 4, _esc(_clip(evs, 60))))
638
+ start = next((s for s in states if s.get("initial")), states[0])
639
+ for s in states:
640
+ x, y = pos[s["name"]]
641
+ cur = self.cur_state == s["name"]
642
+ evs = " · ".join(h["event"] for h in s["handlers"] if h["type"] == "on")
643
+ self.P('<g class="nd sd-state%s" data-state="%s" data-line="%s"><circle class="sh" cx="%s" cy="%s" r="%s"/>'
644
+ '<text class="sd-name" x="%s" y="%s" text-anchor="middle">%s</text>'
645
+ '<text class="small" x="%s" y="%s" text-anchor="middle">%s</text></g>'
646
+ % (" cur" if cur else "", _esc(s["name"]), s.get("line", 0), _f(x), _f(y), RAD,
647
+ _f(x), _f(y - 4), _esc(s["name"]), _f(x), _f(y + 13), _esc(_clip(evs, 18))))
648
+ if s is start:
649
+ self.P('<circle class="initial" cx="%s" cy="%s" r="7"/><path class="sd-edge" d="M%s %s H%s" marker-end="%s"/>'
650
+ % (_f(x - RAD - 46), _f(y), _f(x - RAD - 39), _f(y), _f(x - RAD - 3), arr))
651
+ return W, H
652
+
653
+
654
+ # ------------------------------------------------------------------------------------- styles
655
+ _RULES = """
656
+ .fs-chart{background:var(--fs-bg);font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace}
657
+ .fs-chart text{font-size:12px;fill:var(--fs-fg)}
658
+ .fs-chart .small{font-size:10px;fill:var(--fs-muted)}
659
+ .fs-chart .lbl-yes{fill:var(--fs-ok);font-size:10px}.fs-chart .lbl-no{fill:var(--fs-bad);font-size:10px}
660
+ .fs-chart .edge{stroke:var(--fs-edge);stroke-width:1.4;fill:none}.fs-chart .edge.back{stroke-dasharray:4 3}
661
+ .fs-chart .wire{stroke:var(--fs-edge);stroke-width:1.2;fill:none}
662
+ .fs-chart .arrow-head{fill:var(--fs-edge)}
663
+ .fs-chart .nd>.sh{stroke:var(--fs-stroke);stroke-width:1.2}
664
+ .fs-chart .k-proc>.sh{fill:var(--fs-n-proc)}.fs-chart .k-io>.sh{fill:var(--fs-n-io)}
665
+ .fs-chart .k-sub>.sh{fill:var(--fs-n-sub)}.fs-chart .k-dec>.sh{fill:var(--fs-n-dec)}
666
+ .fs-chart .k-goto>.sh{fill:var(--fs-n-goto)}.fs-chart .k-emit>.sh{fill:var(--fs-n-emit)}
667
+ .fs-chart .k-timer>.sh{fill:var(--fs-n-timer)}.fs-chart .k-ret>.sh{fill:var(--fs-n-ret)}
668
+ .fs-chart .k-ev>.sh{fill:var(--fs-n-ev)}.fs-chart .k-end>.sh{fill:var(--fs-n-end)}
669
+ .fs-chart .k-leaf>.sh{fill:var(--fs-n-leaf)}.fs-chart .k-gate>.sh{fill:var(--fs-n-gate)}
670
+ .fs-chart .k-note>.sh{fill:var(--fs-n-note);stroke-dasharray:3 2}
671
+ .fs-chart .k-note text{fill:var(--fs-cmt);font-style:italic}
672
+ .fs-chart .note-line{stroke:var(--fs-cmt);stroke-dasharray:2 3;fill:none}
673
+ .fs-chart .gate-op{font-size:10px;fill:var(--fs-gate-text)}.fs-chart .end{font-size:10px;fill:var(--fs-muted)}
674
+ .fs-chart .sec-title{font-size:13px;font-weight:bold;fill:var(--fs-muted)}
675
+ .fs-chart .decl-box{fill:var(--fs-decl-bg);stroke:var(--fs-line)}
676
+ .fs-chart .decl-k{fill:var(--fs-kw)}.fs-chart .decl-n{fill:var(--fs-fg)}
677
+ .fs-chart .state-box{fill:var(--fs-state-fill);stroke:var(--fs-state);stroke-width:1.5}
678
+ .fs-chart .state.cur .state-box{stroke:var(--fs-cur);stroke-width:2.5;fill:var(--fs-cur-fill)}
679
+ .fs-chart .state-title{font-size:14px;font-weight:bold;fill:var(--fs-state-title)}
680
+ .fs-chart .state.cur+.state-title{fill:var(--fs-cur)}
681
+ .fs-chart .any-box{fill:var(--fs-any-fill);stroke:var(--fs-any);stroke-dasharray:6 4}
682
+ .fs-chart .any-title{fill:var(--fs-any-title)}
683
+ .fs-chart .any-frame{fill:var(--fs-any-fill);stroke:var(--fs-any);stroke-width:1.5;stroke-dasharray:6 4}
684
+ .fs-chart .any-tag{fill:var(--fs-decl-bg);stroke:var(--fs-any)}
685
+ .fs-chart .any-tag-text{font-weight:bold;fill:var(--fs-any-title)}
686
+ .fs-chart .initial{fill:var(--fs-fg)}
687
+ .fs-chart .sd-state>.sh{fill:var(--fs-sd-fill);stroke:var(--fs-sd-stroke);stroke-width:2}
688
+ .fs-chart .sd-state.cur>.sh{fill:var(--fs-cur-fill);stroke:var(--fs-cur);stroke-width:3}
689
+ .fs-chart .sd-name{font-size:13px;font-weight:bold}
690
+ .fs-chart .sd-edge{stroke:var(--fs-edge);stroke-width:1.5;fill:none}
691
+ .fs-chart .sd-label{font-size:11px;fill:var(--fs-ev)}
692
+ """
693
+
694
+
695
+ def _vars(theme):
696
+ return ";".join("--fs-%s:%s" % (k, v) for k, v in THEMES[theme].items())
697
+
698
+
699
+ def _style(theme, cls):
700
+ if theme in THEMES:
701
+ return ".%s{%s}%s" % (cls, _vars(theme), _RULES)
702
+ # auto: dark by default, light when the page prefers it (unless an ancestor says data-theme="dark"),
703
+ # and whatever an ancestor's data-theme says
704
+ return (".%s{%s}" % (cls, _vars("dark")) +
705
+ "@media (prefers-color-scheme:light){:not([data-theme=\"dark\"])>.%s,.%s{%s}"
706
+ "[data-theme=\"dark\"] .%s{%s}}" % (cls, cls, _vars("light"), cls, _vars("dark")) +
707
+ "[data-theme=\"light\"] .%s{%s}[data-theme=\"dark\"] .%s{%s}"
708
+ % (cls, _vars("light"), cls, _vars("dark")) + _RULES)
709
+
710
+
711
+ # ------------------------------------------------------------------------------------- entry point
712
+ def to_svg(program, view="flow", *, state=None, theme="auto", width=None, id_prefix=None, title=None):
713
+ """An SVG flowchart (``view="flow"``) or state diagram (``view="states"``) as a string.
714
+
715
+ program: FlowScript source text, a parsed AST (dict) or a ``Program`` from ``flowscript.compile``
716
+ (it keeps its source). A program loaded from bytecode cannot be drawn: the chart is
717
+ built from the source, which bytecode does not contain.
718
+ state: name of the current state, highlighted (e.g. a runner's live state).
719
+ theme: "auto" (follow the page), "dark" or "light".
720
+ width: None: the natural size in pixels; "100%" (or any CSS width): scale to the container.
721
+ id_prefix: prefix for element ids (default: derived from the chart, unique per program/view/state).
722
+ title: accessible title (default: the program's name).
723
+ """
724
+ ast = _ast_of(program)
725
+ if view not in ("flow", "states"):
726
+ raise ValueError("view must be 'flow' or 'states'")
727
+ if theme not in ("auto", "dark", "light"):
728
+ raise ValueError("theme must be 'auto', 'dark' or 'light'")
729
+ if id_prefix is None:
730
+ key = repr((_strip(ast), view, state)).encode("utf-8")
731
+ id_prefix = "fs" + hashlib.sha1(key).hexdigest()[:8]
732
+ chart = _Chart(ast, id_prefix, state)
733
+ W, H = chart.render_flow() if view == "flow" else chart.render_states()
734
+ cls = "fs-chart"
735
+ label = title or ("flow " + ast["name"] if ast.get("name") else "FlowScript program")
736
+ size = ('width="%s" height="%s"' % (W, H) if width is None else
737
+ 'width="%s" style="max-width:%dpx;height:auto"' % (_esc(width), W))
738
+ defs = ('<defs><marker id="{p}-arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" '
739
+ 'orient="auto-start-reverse"><path class="arrow-head" d="M0 1 L10 5 L0 9 z"/></marker>'
740
+ '<marker id="{p}-arr2" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" '
741
+ 'orient="auto"><path class="arrow-head" d="M0 1 L10 5 L0 9 z"/></marker></defs>').format(p=id_prefix)
742
+ return ('<svg xmlns="http://www.w3.org/2000/svg" class="%s" %s viewBox="0 0 %d %d" role="img" '
743
+ 'aria-label="%s" data-view="%s"><title>%s</title><style>%s</style>%s%s</svg>'
744
+ % (cls, size, W, H, _esc(label), view, _esc(label), _style(theme, cls), defs, "".join(chart.out)))
745
+
746
+
747
+ def _strip(ast):
748
+ """The AST without positions, for a stable id prefix."""
749
+ if isinstance(ast, dict):
750
+ return tuple(sorted((k, _strip(v)) for k, v in ast.items() if k not in ("id", "line")))
751
+ if isinstance(ast, list):
752
+ return tuple(_strip(v) for v in ast)
753
+ return ast
754
+
755
+
756
+ def _ast_of(program):
757
+ from .parser import parse
758
+ if isinstance(program, str):
759
+ return parse(program)
760
+ if isinstance(program, dict) and program.get("type") == "program":
761
+ return program
762
+ ast = getattr(program, "ast", None)
763
+ if ast is not None:
764
+ return ast
765
+ raise TypeError("to_svg needs FlowScript source, an AST or a Program from flowscript.compile() "
766
+ "(bytecode alone has no source to draw)")