tern-sdk 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.
- tern_sdk/__init__.py +51 -0
- tern_sdk/helpers.py +475 -0
- tern_sdk/input.py +216 -0
- tern_sdk/keys.py +282 -0
- tern_sdk/py.typed +0 -0
- tern_sdk/reconcile.py +267 -0
- tern_sdk/session.py +923 -0
- tern_sdk/ui/__init__.py +174 -0
- tern_sdk/ui/_html.py +134 -0
- tern_sdk/ui/_kinds.py +607 -0
- tern_sdk/ui/_node.py +213 -0
- tern_sdk/ui/_shadow.py +48 -0
- tern_sdk/ui/_types.py +600 -0
- tern_sdk/wire.py +730 -0
- tern_sdk-0.1.0.dist-info/METADATA +215 -0
- tern_sdk-0.1.0.dist-info/RECORD +17 -0
- tern_sdk-0.1.0.dist-info/WHEEL +4 -0
tern_sdk/__init__.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Talk to Tern over the Tern Surface Protocol (TSP).
|
|
2
|
+
|
|
3
|
+
Six layers, each usable without the ones above it: `wire` (messages,
|
|
4
|
+
framing, chunking), `input` and `keys` (the input parser and key decoder),
|
|
5
|
+
`ui` (typed node builders and handlers), `reconcile` (views diffed into
|
|
6
|
+
frame ops), `session` (handshake, surfaces, flow control, events) and
|
|
7
|
+
`helpers` (`show`, `ask`, `plain`)."""
|
|
8
|
+
|
|
9
|
+
from . import input, keys, reconcile, session, ui, wire
|
|
10
|
+
from .helpers import Answer, Unsupported, ask, plain, show
|
|
11
|
+
from .input import DA1, Da1, InputParser
|
|
12
|
+
from .keys import Key, KeyDecoder
|
|
13
|
+
from .reconcile import AnyView, ReconcileError, View
|
|
14
|
+
from .session import Capabilities, InputItem, Session, Surface, available, connect, start
|
|
15
|
+
from .ui import Node, span
|
|
16
|
+
from .wire import (
|
|
17
|
+
AckEvent,
|
|
18
|
+
ActionEvent,
|
|
19
|
+
ActivateEvent,
|
|
20
|
+
BlobsReply,
|
|
21
|
+
Cell,
|
|
22
|
+
ChangeEvent,
|
|
23
|
+
EditEvent,
|
|
24
|
+
ErrorEvent,
|
|
25
|
+
Event,
|
|
26
|
+
FocusEvent,
|
|
27
|
+
GoneEvent,
|
|
28
|
+
HelloReply,
|
|
29
|
+
MotionEvent,
|
|
30
|
+
Reply,
|
|
31
|
+
ResizeEvent,
|
|
32
|
+
SelectEvent,
|
|
33
|
+
SendEvent,
|
|
34
|
+
ThemeEvent,
|
|
35
|
+
ToggleEvent,
|
|
36
|
+
UndoEvent,
|
|
37
|
+
UnknownEvent,
|
|
38
|
+
UnknownReply,
|
|
39
|
+
VisibleEvent,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"DA1", "AckEvent", "ActionEvent", "ActivateEvent", "Answer", "AnyView", "BlobsReply",
|
|
44
|
+
"Capabilities", "Cell", "ChangeEvent", "Da1", "EditEvent", "ErrorEvent", "Event",
|
|
45
|
+
"FocusEvent", "GoneEvent", "HelloReply", "InputItem", "InputParser", "Key", "KeyDecoder",
|
|
46
|
+
"MotionEvent", "Node", "ReconcileError", "Reply", "ResizeEvent", "SelectEvent", "SendEvent",
|
|
47
|
+
"Session", "Surface", "ThemeEvent", "ToggleEvent", "UndoEvent", "UnknownEvent",
|
|
48
|
+
"UnknownReply", "Unsupported", "View", "VisibleEvent",
|
|
49
|
+
"ask", "available", "connect", "input", "keys", "plain", "reconcile", "session",
|
|
50
|
+
"show", "span", "start", "ui", "wire",
|
|
51
|
+
] # fmt: skip
|
tern_sdk/helpers.py
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
"""One-call helpers: `show` (static output kept in the scrollback), `ask`
|
|
2
|
+
(one question, its answer returned) and `plain` (the plain-text fallback)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
import unicodedata
|
|
9
|
+
from collections.abc import Mapping, Sequence
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Final
|
|
12
|
+
|
|
13
|
+
from .keys import Key
|
|
14
|
+
from .reconcile import REGIONS, AnyView, Region, as_view
|
|
15
|
+
from .session import Session, available, connect
|
|
16
|
+
from .ui import Node
|
|
17
|
+
from .wire import ActionEvent, Json
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Unsupported(RuntimeError):
|
|
21
|
+
"""`ask` can't ask: there is no TSP here, so the program asks its own way."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Answer:
|
|
26
|
+
"""The `action` that submitted an `ask` form: node `id`, `act` and the form's `values`."""
|
|
27
|
+
|
|
28
|
+
id: str | None
|
|
29
|
+
act: str
|
|
30
|
+
values: dict[str, Json] = field(default_factory=dict)
|
|
31
|
+
event: ActionEvent | None = field(default=None, repr=False, compare=False)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def show(
|
|
35
|
+
view: AnyView,
|
|
36
|
+
*,
|
|
37
|
+
css: str | None = None,
|
|
38
|
+
fallback: str | None = None,
|
|
39
|
+
session: Session | None = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Shows a static view that stays in the scrollback like command output:
|
|
42
|
+
a `flow` surface opened with `listen: false`, its sheet, one frame and a
|
|
43
|
+
close with `keep`. Without TSP it prints `plain(view)`, or `fallback`."""
|
|
44
|
+
own = session is None
|
|
45
|
+
if session is None:
|
|
46
|
+
session = connect(paste=False, kitty=False) if available() else None
|
|
47
|
+
if session is None:
|
|
48
|
+
text = fallback if fallback is not None else plain(view)
|
|
49
|
+
sys.stdout.write(text if text.endswith("\n") or not text else text + "\n")
|
|
50
|
+
sys.stdout.flush()
|
|
51
|
+
return
|
|
52
|
+
try:
|
|
53
|
+
surface = session.open(mode="flow", listen=False)
|
|
54
|
+
if css is not None:
|
|
55
|
+
surface.stylesheet("main", css)
|
|
56
|
+
surface.render(as_view(view))
|
|
57
|
+
surface.close(keep=True)
|
|
58
|
+
finally:
|
|
59
|
+
if own:
|
|
60
|
+
session.close()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _dismisses(key: Key) -> bool:
|
|
64
|
+
return key.name == "escape" or (key.ctrl and not key.alt and not key.meta and key.name in ("c", "d"))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def ask(
|
|
68
|
+
view: AnyView,
|
|
69
|
+
*,
|
|
70
|
+
css: str | None = None,
|
|
71
|
+
submit: str = "submit",
|
|
72
|
+
session: Session | None = None,
|
|
73
|
+
) -> Answer | None:
|
|
74
|
+
"""Shows a form in a `flow` surface and returns the first `action` whose
|
|
75
|
+
`act` is `submit`, or `None` when the user presses Escape, Ctrl+C or
|
|
76
|
+
Ctrl+D. The form stays in the scrollback; handlers on its nodes run while
|
|
77
|
+
it waits. Raises `Unsupported` without TSP."""
|
|
78
|
+
own = session is None
|
|
79
|
+
if session is None:
|
|
80
|
+
session = connect() if available() else None
|
|
81
|
+
if session is None:
|
|
82
|
+
raise Unsupported("no Tern Surface Protocol on this terminal")
|
|
83
|
+
try:
|
|
84
|
+
with session.open(mode="flow") as surface:
|
|
85
|
+
if css is not None:
|
|
86
|
+
surface.stylesheet("main", css)
|
|
87
|
+
surface.render(as_view(view))
|
|
88
|
+
surface._submit = submit
|
|
89
|
+
for item in session.input():
|
|
90
|
+
if isinstance(item, Key):
|
|
91
|
+
if _dismisses(item):
|
|
92
|
+
return None
|
|
93
|
+
elif isinstance(item, ActionEvent) and item.sf == surface.id and item.act == submit:
|
|
94
|
+
return Answer(item.id, submit, dict(item.values or {}), item)
|
|
95
|
+
return None
|
|
96
|
+
finally:
|
|
97
|
+
if own:
|
|
98
|
+
session.close()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
# Plain text
|
|
103
|
+
|
|
104
|
+
_ANSI: Final = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[PX^_][^\x1b]*\x1b\\|[@-Z\\-_])")
|
|
105
|
+
_BLOCK_TAGS: Final = frozenset(
|
|
106
|
+
"div p section header footer nav aside main article figure blockquote ul ol li dl dt dd "
|
|
107
|
+
"h1 h2 h3 h4 pre hr table thead tbody tr form".split()
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def plain(view: AnyView, cols: int = 80) -> str:
|
|
112
|
+
"""Renders a view as readable plain text without escapes (fallback output,
|
|
113
|
+
not a copy of Tern's layout)."""
|
|
114
|
+
regions = as_view(view)
|
|
115
|
+
lines: list[str] = []
|
|
116
|
+
if regions is not None:
|
|
117
|
+
for name in REGIONS:
|
|
118
|
+
region = regions.get(name)
|
|
119
|
+
if region is not None:
|
|
120
|
+
lines += _render(_wire(region), max(cols, 8))
|
|
121
|
+
return "\n".join(line.rstrip() for line in lines)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _wire(region: Region) -> Mapping[str, Json]:
|
|
125
|
+
if isinstance(region, Node):
|
|
126
|
+
return region.to_json()
|
|
127
|
+
if isinstance(region, Mapping):
|
|
128
|
+
return region
|
|
129
|
+
return {"k": "col", "c": [n.to_json() for n in region]}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _width(s: str) -> int:
|
|
133
|
+
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 0 if unicodedata.combining(c) else 1 for c in s)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _pad(s: str, width: int, end: bool = False) -> str:
|
|
137
|
+
gap = " " * max(width - _width(s), 0)
|
|
138
|
+
return gap + s if end else s + gap
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _text(value: Json) -> str:
|
|
142
|
+
"""Plain text of a string or spans."""
|
|
143
|
+
if isinstance(value, str):
|
|
144
|
+
return value
|
|
145
|
+
if isinstance(value, list):
|
|
146
|
+
out = []
|
|
147
|
+
for s in value:
|
|
148
|
+
if isinstance(s, str):
|
|
149
|
+
out.append(s)
|
|
150
|
+
elif isinstance(s, Mapping) and isinstance(s.get("t"), str) and "icon" not in str(s.get("s", "")).split():
|
|
151
|
+
out.append(s["t"])
|
|
152
|
+
return "".join(out)
|
|
153
|
+
return ""
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _node_text(p: Mapping[str, Json]) -> str:
|
|
157
|
+
return _text(p["spans"]) if isinstance(p.get("spans"), list) else _text(p.get("text"))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _indent(lines: list[str], by: str = " ") -> list[str]:
|
|
161
|
+
return [by + line if line else line for line in lines]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _bar(value: Json) -> str:
|
|
165
|
+
width = 10
|
|
166
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
167
|
+
v = min(max(float(value), 0.0), 1.0)
|
|
168
|
+
filled = int(v * width + 0.5)
|
|
169
|
+
return f"[{'#' * filled}{'-' * (width - filled)}] {int(v * 100 + 0.5)}%"
|
|
170
|
+
return f"[{'-' * width}]"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _table(rows: list[list[str]], ends: Sequence[bool] = ()) -> list[str]:
|
|
174
|
+
if not rows:
|
|
175
|
+
return []
|
|
176
|
+
n = max(len(r) for r in rows)
|
|
177
|
+
widths = [max((_width(r[i]) for r in rows if i < len(r)), default=0) for i in range(n)]
|
|
178
|
+
out = []
|
|
179
|
+
for r in rows:
|
|
180
|
+
cells = [_pad(c, widths[i], i < len(ends) and ends[i]) for i, c in enumerate(r)]
|
|
181
|
+
out.append(" ".join(cells))
|
|
182
|
+
return out
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _children(node: Mapping[str, Json]) -> list[Mapping[str, Json]]:
|
|
186
|
+
c = node.get("c")
|
|
187
|
+
return [x for x in c if isinstance(x, Mapping)] if isinstance(c, list) else []
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _props(node: Mapping[str, Json]) -> Mapping[str, Json]:
|
|
191
|
+
p = node.get("p")
|
|
192
|
+
return p if isinstance(p, Mapping) else {}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _block(lines: list[str], cols: int, children: list[Mapping[str, Json]]) -> list[str]:
|
|
196
|
+
for child in children:
|
|
197
|
+
lines += _render(child, cols)
|
|
198
|
+
return lines
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _render(node: Mapping[str, Json], cols: int) -> list[str]:
|
|
202
|
+
"""A node as plain lines."""
|
|
203
|
+
k = node.get("k")
|
|
204
|
+
p = _props(node)
|
|
205
|
+
kids = _children(node)
|
|
206
|
+
if p.get("hidden") is True:
|
|
207
|
+
return []
|
|
208
|
+
match k:
|
|
209
|
+
case "col" | "status":
|
|
210
|
+
if k == "status":
|
|
211
|
+
return [" ".join(" ".join(_render(c, cols)) for c in kids)]
|
|
212
|
+
return _block([], cols, kids)
|
|
213
|
+
case "row":
|
|
214
|
+
parts = [" ".join(line for line in _render(c, cols) if line) for c in kids]
|
|
215
|
+
return [" ".join(part for part in parts if part)]
|
|
216
|
+
case "card" | "section" | "overlay":
|
|
217
|
+
head = _text(p.get("head"))
|
|
218
|
+
status = p.get("status")
|
|
219
|
+
if isinstance(status, str):
|
|
220
|
+
head = f"{head} ({status})" if head else f"({status})"
|
|
221
|
+
body = _indent(_block([], cols, kids))
|
|
222
|
+
return ([head] if head else []) + body
|
|
223
|
+
case "rule":
|
|
224
|
+
label = _text(p.get("label"))
|
|
225
|
+
if not label:
|
|
226
|
+
return ["-" * cols]
|
|
227
|
+
side = max((cols - _width(label) - 2) // 2, 2)
|
|
228
|
+
return [f"{'-' * side} {label} {'-' * side}"]
|
|
229
|
+
case "spacer":
|
|
230
|
+
return [""]
|
|
231
|
+
case "text" | "shimmer" | "seg":
|
|
232
|
+
seg = _node_text(p)
|
|
233
|
+
if k == "seg" and kids:
|
|
234
|
+
seg = " ".join([*(" ".join(_render(c, cols)) for c in kids), seg]).strip()
|
|
235
|
+
return seg.split("\n")
|
|
236
|
+
case "md" | "code" | "math" | "editor" | "input":
|
|
237
|
+
return _text(p.get("text")).split("\n")
|
|
238
|
+
case "ansi":
|
|
239
|
+
return _ANSI.sub("", _text(p.get("text"))).split("\n")
|
|
240
|
+
case "rows":
|
|
241
|
+
lines = p.get("lines")
|
|
242
|
+
return [_ANSI.sub("", s) for s in lines if isinstance(s, str)] if isinstance(lines, list) else []
|
|
243
|
+
case "diff":
|
|
244
|
+
hunks = p.get("hunks")
|
|
245
|
+
if isinstance(hunks, list):
|
|
246
|
+
return [
|
|
247
|
+
s for h in hunks if isinstance(h, Mapping) for s in (h.get("lines") or []) if isinstance(s, str)
|
|
248
|
+
]
|
|
249
|
+
return _text(p.get("text")).split("\n")
|
|
250
|
+
case "kv":
|
|
251
|
+
items = [i for i in p.get("items") or [] if isinstance(i, Mapping)]
|
|
252
|
+
pairs = [[_text(i.get("k")), _text(i.get("v"))] for i in items]
|
|
253
|
+
if p.get("layout") == "inline":
|
|
254
|
+
return [" · ".join(f"{a} {b}" for a, b in pairs)]
|
|
255
|
+
return _table(pairs)
|
|
256
|
+
case "table":
|
|
257
|
+
return _plain_table(p, cols)
|
|
258
|
+
case "tree":
|
|
259
|
+
return _tree([n for n in p.get("nodes") or [] if isinstance(n, Mapping)])
|
|
260
|
+
case "badge":
|
|
261
|
+
return [f"[{_text(p.get('text'))}]"]
|
|
262
|
+
case "kbd":
|
|
263
|
+
return ["+".join(s for s in p.get("keys") or [] if isinstance(s, str))]
|
|
264
|
+
case "icon":
|
|
265
|
+
return []
|
|
266
|
+
case "image":
|
|
267
|
+
return [f"[image: {_text(p.get('alt')) or _text(p.get('title')) or 'image'}]"]
|
|
268
|
+
case "list":
|
|
269
|
+
out = _block([], cols, kids)
|
|
270
|
+
if not out and p.get("empty") is not None:
|
|
271
|
+
return [_text(p.get("empty"))]
|
|
272
|
+
return out
|
|
273
|
+
case "item":
|
|
274
|
+
parts = [_text(p.get(f)) for f in ("label", "detail", "value")]
|
|
275
|
+
return ["- " + " ".join(x for x in parts if x)]
|
|
276
|
+
case "tabs":
|
|
277
|
+
active = p.get("active")
|
|
278
|
+
tabs = [t for t in p.get("items") or [] if isinstance(t, Mapping)]
|
|
279
|
+
return [
|
|
280
|
+
" | ".join(
|
|
281
|
+
f"[{_text(t.get('label'))}]" if t.get("id") == active else _text(t.get("label")) for t in tabs
|
|
282
|
+
)
|
|
283
|
+
]
|
|
284
|
+
case "picker":
|
|
285
|
+
title = _text(p.get("title"))
|
|
286
|
+
items = [i for i in p.get("items") or [] if isinstance(i, Mapping)]
|
|
287
|
+
return ([title] if title else []) + [f"- {_text(i.get('label')) or i.get('id')}" for i in items]
|
|
288
|
+
case "spinner":
|
|
289
|
+
return [_text(p.get("label"))]
|
|
290
|
+
case "elapsed":
|
|
291
|
+
ms = p.get("stopped", p.get("age", 0))
|
|
292
|
+
return [_elapsed(ms if isinstance(ms, (int, float)) else 0)]
|
|
293
|
+
case "rate":
|
|
294
|
+
unit = p.get("unit")
|
|
295
|
+
return [f"{p.get('value', 0)}{' ' + unit if isinstance(unit, str) else ''}"]
|
|
296
|
+
case "progress" | "meter":
|
|
297
|
+
value = p.get("value")
|
|
298
|
+
fills = p.get("parts")
|
|
299
|
+
if value is None and isinstance(fills, list):
|
|
300
|
+
value = sum(x.get("value", 0) for x in fills if isinstance(x, Mapping))
|
|
301
|
+
line = _bar(value)
|
|
302
|
+
extra = " ".join(x for x in (_text(p.get("label")), _text(p.get("total"))) if x)
|
|
303
|
+
return [f"{line} {extra}".rstrip()]
|
|
304
|
+
case "chart":
|
|
305
|
+
summary = _text(p.get("summary"))
|
|
306
|
+
return [summary] if summary else []
|
|
307
|
+
case "effort":
|
|
308
|
+
return [f"effort: {p.get('level')}"] if isinstance(p.get("level"), str) else []
|
|
309
|
+
case "toast":
|
|
310
|
+
return [" ".join(x for x in (_text(p.get("text")), _text(p.get("sub"))) if x)]
|
|
311
|
+
case "tool":
|
|
312
|
+
head = " ".join(x for x in (_text(p.get("title")) or _text(p.get("name")), _text(p.get("target"))) if x)
|
|
313
|
+
meta = [_text(m) for m in p.get("meta") or []]
|
|
314
|
+
status = p.get("status")
|
|
315
|
+
line = " ".join([head, *meta] + ([f"({status})"] if isinstance(status, str) else []))
|
|
316
|
+
return [line] + _indent(_block([], cols, kids))
|
|
317
|
+
case "agent":
|
|
318
|
+
status = p.get("status")
|
|
319
|
+
line = " ".join(
|
|
320
|
+
x
|
|
321
|
+
for x in (_text(p.get("name")), _text(p.get("task")), f"({status})" if isinstance(status, str) else "")
|
|
322
|
+
if x
|
|
323
|
+
)
|
|
324
|
+
return [line] + _indent(_block([], cols, kids))
|
|
325
|
+
case "checklist":
|
|
326
|
+
return _checklist(p)
|
|
327
|
+
case "prefs":
|
|
328
|
+
return _prefs(p)
|
|
329
|
+
case "el":
|
|
330
|
+
return _el(node, cols)
|
|
331
|
+
case _:
|
|
332
|
+
return _block([], cols, kids)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _elapsed(ms: float) -> str:
|
|
336
|
+
s = abs(ms) / 1000
|
|
337
|
+
if s < 60:
|
|
338
|
+
return f"{int(s * 10) / 10:.1f}s"
|
|
339
|
+
if s < 3600:
|
|
340
|
+
return f"{int(s // 60)}m {int(s % 60):02d}s"
|
|
341
|
+
return f"{int(s // 3600)}h {int(s % 3600 // 60):02d}m"
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _plain_table(p: Mapping[str, Json], cols: int) -> list[str]:
|
|
345
|
+
columns = [c for c in p.get("cols") or [] if isinstance(c, Mapping) and isinstance(c.get("id"), str)]
|
|
346
|
+
rows = [r for r in p.get("rows") or [] if isinstance(r, Mapping)]
|
|
347
|
+
out: list[list[str]] = []
|
|
348
|
+
if any(_text(c.get("head")) for c in columns):
|
|
349
|
+
out.append([_text(c.get("head")) for c in columns])
|
|
350
|
+
for r in rows:
|
|
351
|
+
cells = r.get("cells") if isinstance(r.get("cells"), Mapping) else {}
|
|
352
|
+
line = []
|
|
353
|
+
for c in columns:
|
|
354
|
+
cell = cells.get(c["id"]) if isinstance(cells, Mapping) else None
|
|
355
|
+
if isinstance(cell, Mapping):
|
|
356
|
+
meter = cell.get("meter")
|
|
357
|
+
value = meter.get("value") if isinstance(meter, Mapping) else None
|
|
358
|
+
parts = meter.get("parts") if isinstance(meter, Mapping) else None
|
|
359
|
+
if value is None and isinstance(parts, list):
|
|
360
|
+
value = sum(part.get("value", 0) for part in parts if isinstance(part, Mapping))
|
|
361
|
+
line.append(_bar(value))
|
|
362
|
+
else:
|
|
363
|
+
line.append(_text(cell))
|
|
364
|
+
out.append(line)
|
|
365
|
+
return _table(out, [c.get("align") == "end" for c in columns])
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _tree(nodes: list[Mapping[str, Json]], depth: int = 0) -> list[str]:
|
|
369
|
+
out = []
|
|
370
|
+
for n in nodes:
|
|
371
|
+
out.append(" " * depth + "- " + _text(n.get("label")))
|
|
372
|
+
kids = n.get("children")
|
|
373
|
+
if isinstance(kids, list):
|
|
374
|
+
out += _tree([c for c in kids if isinstance(c, Mapping)], depth + 1)
|
|
375
|
+
return out
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
_CHECK: Final = {"done": "[x]", "dropped": "[-]", "active": "[>]", "blocked": "[!]"}
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _checklist(p: Mapping[str, Json]) -> list[str]:
|
|
382
|
+
out = []
|
|
383
|
+
for phase in p.get("phases") or []:
|
|
384
|
+
if not isinstance(phase, Mapping):
|
|
385
|
+
continue
|
|
386
|
+
title = _text(phase.get("title"))
|
|
387
|
+
if title:
|
|
388
|
+
out.append(title)
|
|
389
|
+
for item in phase.get("items") or []:
|
|
390
|
+
if isinstance(item, Mapping):
|
|
391
|
+
mark = _CHECK.get(str(item.get("status")), "[ ]")
|
|
392
|
+
out.append((" " if title else "") + f"{mark} {_text(item.get('text'))}")
|
|
393
|
+
return out
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _prefs(p: Mapping[str, Json]) -> list[str]:
|
|
397
|
+
out = [_text(p.get("title")) or "Settings"]
|
|
398
|
+
for section in p.get("sections") or []:
|
|
399
|
+
if not isinstance(section, Mapping):
|
|
400
|
+
continue
|
|
401
|
+
if _text(section.get("title")):
|
|
402
|
+
out.append(_text(section.get("title")))
|
|
403
|
+
for row in section.get("rows") or []:
|
|
404
|
+
if not isinstance(row, Mapping):
|
|
405
|
+
continue
|
|
406
|
+
control = row.get("control") if isinstance(row.get("control"), Mapping) else {}
|
|
407
|
+
value = control.get("value", control.get("on", "")) if isinstance(control, Mapping) else ""
|
|
408
|
+
out.append(f" {_text(row.get('label')) or row.get('id')}: {value}")
|
|
409
|
+
return out
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _el_inline(node: Mapping[str, Json]) -> str:
|
|
413
|
+
p = _props(node)
|
|
414
|
+
if node.get("k") != "el":
|
|
415
|
+
return " ".join(_render(node, 80))
|
|
416
|
+
tag = p.get("tag", "div")
|
|
417
|
+
if tag == "input":
|
|
418
|
+
on = p.get("checked") is True
|
|
419
|
+
return ("(*)" if on else "( )") if p.get("type") == "radio" else ("[x]" if on else "[ ]")
|
|
420
|
+
text = _text(p.get("text"))
|
|
421
|
+
control = False
|
|
422
|
+
for child in _children(node):
|
|
423
|
+
piece = _el_inline(child)
|
|
424
|
+
if control and piece and text:
|
|
425
|
+
text += " "
|
|
426
|
+
text += piece
|
|
427
|
+
control = child.get("k") == "el" and _props(child).get("tag") == "input"
|
|
428
|
+
if tag == "button":
|
|
429
|
+
return f"[ {text.strip()} ]"
|
|
430
|
+
return text
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _el(node: Mapping[str, Json], cols: int) -> list[str]:
|
|
434
|
+
p = _props(node)
|
|
435
|
+
tag = p.get("tag", "div")
|
|
436
|
+
if tag not in _BLOCK_TAGS:
|
|
437
|
+
return [_el_inline(node)]
|
|
438
|
+
if tag == "hr":
|
|
439
|
+
return ["-" * cols]
|
|
440
|
+
if tag in ("table", "thead", "tbody"):
|
|
441
|
+
rows: list[list[str]] = []
|
|
442
|
+
_collect_rows(node, rows)
|
|
443
|
+
return _table(rows)
|
|
444
|
+
if tag == "tr":
|
|
445
|
+
return [" ".join(_el_inline(c) for c in _children(node))]
|
|
446
|
+
lines: list[str] = []
|
|
447
|
+
cur = _text(p.get("text"))
|
|
448
|
+
ordered = 0
|
|
449
|
+
for child in _children(node):
|
|
450
|
+
cp = _props(child)
|
|
451
|
+
if child.get("k") == "el" and cp.get("tag", "div") not in _BLOCK_TAGS:
|
|
452
|
+
piece = _el_inline(child)
|
|
453
|
+
cur = f"{cur} {piece}" if cur and piece and not cur.endswith(" ") else cur + piece
|
|
454
|
+
continue
|
|
455
|
+
if cur:
|
|
456
|
+
lines.append(cur)
|
|
457
|
+
cur = ""
|
|
458
|
+
sub = _render(child, cols)
|
|
459
|
+
if child.get("k") == "el" and cp.get("tag") == "li":
|
|
460
|
+
ordered += 1
|
|
461
|
+
bullet = f"{ordered}. " if tag == "ol" else "- "
|
|
462
|
+
sub = [bullet + sub[0], *_indent(sub[1:])] if sub else [bullet.rstrip()]
|
|
463
|
+
lines += sub
|
|
464
|
+
if cur:
|
|
465
|
+
lines.append(cur)
|
|
466
|
+
return _indent(lines) if tag in ("blockquote", "dd", "figure") else lines
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _collect_rows(node: Mapping[str, Json], rows: list[list[str]]) -> None:
|
|
470
|
+
for child in _children(node):
|
|
471
|
+
tag = _props(child).get("tag")
|
|
472
|
+
if tag == "tr":
|
|
473
|
+
rows.append([_el_inline(c) for c in _children(child)])
|
|
474
|
+
elif tag in ("thead", "tbody"):
|
|
475
|
+
_collect_rows(child, rows)
|