zettcode-tui 0.4.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.
- zettcode/tui/__init__.py +422 -0
- zettcode/tui/_compat.py +32 -0
- zettcode/tui/capabilities.py +107 -0
- zettcode/tui/clipboard.py +91 -0
- zettcode/tui/console.py +229 -0
- zettcode/tui/core/__init__.py +63 -0
- zettcode/tui/core/app.py +658 -0
- zettcode/tui/core/events.py +134 -0
- zettcode/tui/core/focus.py +85 -0
- zettcode/tui/core/geometry.py +212 -0
- zettcode/tui/core/host.py +66 -0
- zettcode/tui/core/keymap.py +218 -0
- zettcode/tui/core/scheduler.py +105 -0
- zettcode/tui/core/screen.py +80 -0
- zettcode/tui/core/theme.py +248 -0
- zettcode/tui/core/widget.py +204 -0
- zettcode/tui/gallery.py +588 -0
- zettcode/tui/glyphs.py +73 -0
- zettcode/tui/input/__init__.py +18 -0
- zettcode/tui/input/bridge.py +46 -0
- zettcode/tui/input/decoder.py +234 -0
- zettcode/tui/input/events.py +64 -0
- zettcode/tui/input/keys.py +289 -0
- zettcode/tui/input/reader.py +147 -0
- zettcode/tui/layout/__init__.py +24 -0
- zettcode/tui/layout/box.py +153 -0
- zettcode/tui/layout/overlay.py +164 -0
- zettcode/tui/layout/scroll.py +477 -0
- zettcode/tui/layout/solver.py +95 -0
- zettcode/tui/layout/spacing.py +137 -0
- zettcode/tui/py.typed +0 -0
- zettcode/tui/render/__init__.py +71 -0
- zettcode/tui/render/ansi.py +371 -0
- zettcode/tui/render/canvas.py +153 -0
- zettcode/tui/render/code.py +456 -0
- zettcode/tui/render/color.py +177 -0
- zettcode/tui/render/renderer.py +151 -0
- zettcode/tui/render/rich_text.py +80 -0
- zettcode/tui/render/style.py +121 -0
- zettcode/tui/render/sweep.py +87 -0
- zettcode/tui/render/text.py +319 -0
- zettcode/tui/runner.py +316 -0
- zettcode/tui/terminal.py +222 -0
- zettcode/tui/testing/__init__.py +6 -0
- zettcode/tui/testing/harness.py +220 -0
- zettcode/tui/testing/snapshot.py +64 -0
- zettcode/tui/theme_file.py +300 -0
- zettcode/tui/themes/ayu-mirage.toml +44 -0
- zettcode/tui/themes/catppuccin-latte.toml +44 -0
- zettcode/tui/themes/catppuccin-mocha.toml +44 -0
- zettcode/tui/themes/dracula.toml +44 -0
- zettcode/tui/themes/github-dimmed.toml +44 -0
- zettcode/tui/themes/github-light.toml +44 -0
- zettcode/tui/themes/gruvbox-dark.toml +44 -0
- zettcode/tui/themes/gruvbox-light.toml +44 -0
- zettcode/tui/themes/kanagawa.toml +44 -0
- zettcode/tui/themes/nord.toml +44 -0
- zettcode/tui/themes/one-dark.toml +44 -0
- zettcode/tui/themes/rose-pine-dawn.toml +44 -0
- zettcode/tui/themes/rose-pine.toml +44 -0
- zettcode/tui/themes/solarized-dark.toml +44 -0
- zettcode/tui/themes/solarized-light.toml +44 -0
- zettcode/tui/themes/tokyo-night.toml +44 -0
- zettcode/tui/widgets/__init__.py +121 -0
- zettcode/tui/widgets/collapsible.py +116 -0
- zettcode/tui/widgets/completion.py +211 -0
- zettcode/tui/widgets/console.py +442 -0
- zettcode/tui/widgets/dialog.py +165 -0
- zettcode/tui/widgets/diff.py +236 -0
- zettcode/tui/widgets/list.py +293 -0
- zettcode/tui/widgets/list_page.py +121 -0
- zettcode/tui/widgets/markdown.py +657 -0
- zettcode/tui/widgets/progress.py +166 -0
- zettcode/tui/widgets/rich_text.py +84 -0
- zettcode/tui/widgets/status.py +106 -0
- zettcode/tui/widgets/table.py +101 -0
- zettcode/tui/widgets/tasks.py +130 -0
- zettcode/tui/widgets/text.py +111 -0
- zettcode/tui/widgets/textarea.py +671 -0
- zettcode/tui/widgets/toast.py +137 -0
- zettcode_tui-0.4.0.dist-info/METADATA +20 -0
- zettcode_tui-0.4.0.dist-info/RECORD +83 -0
- zettcode_tui-0.4.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
"""Streaming-safe Markdown rendering.
|
|
2
|
+
|
|
3
|
+
Completed blocks are parsed once and cached; only the still-open trailing block
|
|
4
|
+
is re-parsed as text streams in. That keeps a long streamed answer linear in
|
|
5
|
+
the number of blocks instead of re-parsing the whole document every frame.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from dataclasses import replace
|
|
13
|
+
from functools import lru_cache
|
|
14
|
+
|
|
15
|
+
from zettcode.tui import ELLIPSIS, RULE, SEPARATOR
|
|
16
|
+
|
|
17
|
+
from ..core.theme import DARK, Theme
|
|
18
|
+
from ..layout.scroll import LineSource, ScrollView
|
|
19
|
+
from ..render import Canvas, Span, Style, TextLine, highlight, layout_rich_lines, wrap_spans
|
|
20
|
+
from ..render.text import display_width, wrap_words
|
|
21
|
+
|
|
22
|
+
FENCE = "```"
|
|
23
|
+
TABLE_GAP = 3
|
|
24
|
+
TABLE_MIN_COLUMN = 4
|
|
25
|
+
TABLE_MAX_COLUMN = 60
|
|
26
|
+
#: Columns a list item is inset by, relative to the prose above it, so a list
|
|
27
|
+
#: reads as content under its heading instead of another paragraph.
|
|
28
|
+
LIST_INDENT = 2
|
|
29
|
+
_BLANK = re.compile(r"\n[ \t]*\n")
|
|
30
|
+
# Underscore emphasis needs a word boundary on both sides, as CommonMark does:
|
|
31
|
+
# otherwise ``replace_in_file`` would lose the underscores that spell it.
|
|
32
|
+
# A code span is opened by a run of backticks and closed by the same run, so
|
|
33
|
+
# ````` `a`-`b` ````` can hold backticks of its own; the run must not touch a
|
|
34
|
+
# longer run on either side, which is what the lookarounds check.
|
|
35
|
+
_CODE = re.compile(r"(`+)(?!`)(.+?)(?<!`)\1(?!`)")
|
|
36
|
+
#: Inline markup other than code; code spans are matched first and hidden from
|
|
37
|
+
#: this pattern, so ``**a `b` c**`` keeps its code span instead of letting the
|
|
38
|
+
#: emphasis delimiters swallow the backticks.
|
|
39
|
+
_INLINE = re.compile(
|
|
40
|
+
r"(~~.+?~~|\*\*.+?\*\*|__.+?__|\[[^]]+\]\([^)]+\)|(?<!\*)\*[^*]+\*(?!\*)|(?<![\w_])_[^_\n]+_(?![\w_]))"
|
|
41
|
+
)
|
|
42
|
+
#: Stands in for one code span while the rest of the line is matched.
|
|
43
|
+
_CODE_MARK_TEXT = "\x00code{}\x00"
|
|
44
|
+
_CODE_MARK = re.compile(r"\x00code(\d+)\x00")
|
|
45
|
+
_HEADING = re.compile(r"^(#{1,3})\s+(.+)$")
|
|
46
|
+
_BULLET = re.compile(r"^(\s*)[-+*]\s+(.+)$")
|
|
47
|
+
_ORDERED = re.compile(r"^(\s*)(\d{1,3})[.)]\s+(.+)$")
|
|
48
|
+
_QUOTE = re.compile(r"^\s*>\s?(.*)$")
|
|
49
|
+
_RULE = re.compile(r"^\s{0,3}(?:-{3,}|\*{3,}|_{3,})\s*$")
|
|
50
|
+
# An HTML entity or numeric character reference, in the forms CommonMark
|
|
51
|
+
# recognises: a name from the HTML5 list, or a decimal or hexadecimal code
|
|
52
|
+
# point, and always a semicolon. ``&`` is the four characters that spell it.
|
|
53
|
+
_ENTITY = re.compile(r"&(?:#[0-9]{1,7}|#[xX][0-9a-fA-F]{1,6}|[a-zA-Z][a-zA-Z0-9]{1,31});")
|
|
54
|
+
#: The highest code point Unicode assigns; a reference above it names nothing.
|
|
55
|
+
_MAX_CODE_POINT = 0x10FFFF
|
|
56
|
+
#: What a reference to no character becomes: U+FFFD, the same glyph the canvas
|
|
57
|
+
#: uses for a control character it will not paint.
|
|
58
|
+
_REPLACEMENT = "\ufffd"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@lru_cache(maxsize=1)
|
|
62
|
+
def entity_table() -> dict[str, str]:
|
|
63
|
+
"""Return the HTML5 named entities, keyed without their semicolon.
|
|
64
|
+
|
|
65
|
+
The table ships with the interpreter, so it is the same list the Markdown
|
|
66
|
+
specification's reference implementation reads; only the named forms are
|
|
67
|
+
kept here, which are the ones a reference has to end with ``;``. It is
|
|
68
|
+
built on the first render that meets an ``&``, so a document without one
|
|
69
|
+
never pays for it.
|
|
70
|
+
"""
|
|
71
|
+
from html.entities import html5
|
|
72
|
+
|
|
73
|
+
return {name[:-1]: value for name, value in html5.items() if name.endswith(";")}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def decode_entities(text: str) -> str:
|
|
77
|
+
"""Return text with its entity and numeric character references decoded.
|
|
78
|
+
|
|
79
|
+
A model writes `` `` for a character it must not lose — the indent in
|
|
80
|
+
front of a heading, or an angle bracket it is writing *about* — and the six
|
|
81
|
+
characters that spell the escape are not what it meant. Only the forms
|
|
82
|
+
CommonMark recognises are decoded: a missing semicolon or an unknown name is
|
|
83
|
+
left exactly as written, and a code point that is not a character becomes
|
|
84
|
+
U+FFFD. A control character spelled this way is decoded too, and stopped
|
|
85
|
+
where every other control character is, at the canvas.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
text: One run of unmarked Markdown text; code spans and fenced code
|
|
89
|
+
never reach this function, because their escapes are literal.
|
|
90
|
+
"""
|
|
91
|
+
if "&" not in text:
|
|
92
|
+
return text
|
|
93
|
+
|
|
94
|
+
def replace_reference(match: re.Match[str]) -> str:
|
|
95
|
+
token = match.group(0)
|
|
96
|
+
if not token.startswith("&#"):
|
|
97
|
+
return entity_table().get(token[1:-1], token)
|
|
98
|
+
digits = token[2:-1]
|
|
99
|
+
code = int(digits[1:], 16) if digits[:1] in ("x", "X") else int(digits)
|
|
100
|
+
if code == 0 or code > _MAX_CODE_POINT or 0xD800 <= code <= 0xDFFF:
|
|
101
|
+
return _REPLACEMENT
|
|
102
|
+
return chr(code)
|
|
103
|
+
|
|
104
|
+
return _ENTITY.sub(replace_reference, text)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def stable_cut(text: str) -> int:
|
|
108
|
+
"""Return how much of the tail is closed and safe to cache.
|
|
109
|
+
|
|
110
|
+
A blank line ends a block, unless it sits inside an unterminated code
|
|
111
|
+
fence, so only boundaries with balanced fences count.
|
|
112
|
+
"""
|
|
113
|
+
cut = 0
|
|
114
|
+
for match in _BLANK.finditer(text):
|
|
115
|
+
boundary = match.end()
|
|
116
|
+
if text.count(FENCE, 0, boundary) % 2 == 0:
|
|
117
|
+
cut = boundary
|
|
118
|
+
return cut
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def render_markdown(text: str, width: int, theme: Theme = DARK) -> list[TextLine]:
|
|
122
|
+
"""Render Markdown source into wrapped, styled lines."""
|
|
123
|
+
if not text:
|
|
124
|
+
return []
|
|
125
|
+
width = max(1, width)
|
|
126
|
+
source = text.split("\n")
|
|
127
|
+
logical: list[TextLine] = []
|
|
128
|
+
index = 0
|
|
129
|
+
in_code = False
|
|
130
|
+
language = ""
|
|
131
|
+
while index < len(source):
|
|
132
|
+
line = source[index]
|
|
133
|
+
stripped = line.strip()
|
|
134
|
+
if stripped.startswith(FENCE):
|
|
135
|
+
if in_code:
|
|
136
|
+
in_code = False
|
|
137
|
+
language = ""
|
|
138
|
+
else:
|
|
139
|
+
in_code = True
|
|
140
|
+
# The fence label picks the scanner; it is not part of the code
|
|
141
|
+
# and is never drawn, so a block starts straight at its text.
|
|
142
|
+
language = stripped[3:].strip()
|
|
143
|
+
index += 1
|
|
144
|
+
continue
|
|
145
|
+
if in_code:
|
|
146
|
+
logical.append(TextLine(tuple(code_spans(line, language, theme))))
|
|
147
|
+
index += 1
|
|
148
|
+
continue
|
|
149
|
+
if index + 1 < len(source):
|
|
150
|
+
header = split_table_row(line)
|
|
151
|
+
alignments = table_alignments(source[index + 1])
|
|
152
|
+
if header is not None and alignments is not None and len(header) == len(alignments):
|
|
153
|
+
rows: list[list[str]] = []
|
|
154
|
+
cursor = index + 2
|
|
155
|
+
while cursor < len(source):
|
|
156
|
+
row = split_table_row(source[cursor])
|
|
157
|
+
if row is None:
|
|
158
|
+
break
|
|
159
|
+
rows.append((row + [""] * len(header))[: len(header)])
|
|
160
|
+
cursor += 1
|
|
161
|
+
logical.extend(render_table(header, alignments, rows, width, theme))
|
|
162
|
+
index = cursor
|
|
163
|
+
continue
|
|
164
|
+
if _RULE.match(line):
|
|
165
|
+
logical.append(TextLine(rule_spans(width, theme)))
|
|
166
|
+
index += 1
|
|
167
|
+
continue
|
|
168
|
+
quote = _QUOTE.match(line)
|
|
169
|
+
if quote:
|
|
170
|
+
# A quote wraps by word and keeps its bar on every row: the bar is part of the margin, not of the prose,
|
|
171
|
+
# so a wrapped answer stays inside the quotation instead of losing it after the first line.
|
|
172
|
+
logical.extend(quote_lines(quote.group(1), width, theme))
|
|
173
|
+
index += 1
|
|
174
|
+
continue
|
|
175
|
+
rendered = markdown_line(line, theme=theme)
|
|
176
|
+
if rendered is not None:
|
|
177
|
+
logical.append(rendered)
|
|
178
|
+
index += 1
|
|
179
|
+
return list(layout_rich_lines(logical, width))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def code_spans(line: str, language: str, theme: Theme) -> list[Span]:
|
|
183
|
+
"""Highlight one line of a fenced code block, with no background fill."""
|
|
184
|
+
indent = Span(" ", Style(foreground=theme.code.text))
|
|
185
|
+
return [indent, *highlight(line, language, theme.code)]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def rule_spans(width: int, theme: Theme) -> tuple[Span, ...]:
|
|
189
|
+
"""Return one horizontal rule across ``width`` cells.
|
|
190
|
+
|
|
191
|
+
A rule is decoration, but it has to stay readable: ``muted`` draws it clearly
|
|
192
|
+
while still holding it below the body text, which is what a rule is for.
|
|
193
|
+
"""
|
|
194
|
+
return (Span(RULE * max(1, width), Style(foreground=theme.muted)),)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def markdown_line(line: str, *, theme: Theme) -> TextLine | None:
|
|
198
|
+
"""Render one non-code logical line.
|
|
199
|
+
|
|
200
|
+
The returned line's metadata carries the indent its wrapped rows should
|
|
201
|
+
keep, which is how a list item stays aligned under its own text.
|
|
202
|
+
"""
|
|
203
|
+
heading = _HEADING.match(line)
|
|
204
|
+
if heading:
|
|
205
|
+
# Every level keeps the body colour: a dimmed heading read as washed-out
|
|
206
|
+
# text in the answer, and the blank line around it already separates it.
|
|
207
|
+
return TextLine(
|
|
208
|
+
tuple(inline_markdown(heading.group(2), base=Style(foreground=theme.text, bold=True), theme=theme))
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
bullet = _BULLET.match(line)
|
|
212
|
+
if bullet:
|
|
213
|
+
return list_item(f"{bullet.group(1)}{SEPARATOR} ", bullet.group(2), theme=theme)
|
|
214
|
+
|
|
215
|
+
ordered = _ORDERED.match(line)
|
|
216
|
+
if ordered:
|
|
217
|
+
return list_item(f"{ordered.group(1)}{ordered.group(2)}. ", ordered.group(3), theme=theme)
|
|
218
|
+
|
|
219
|
+
return TextLine(tuple(inline_markdown(line, theme=theme)))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def quote_lines(body: str, width: int, theme: Theme) -> list[TextLine]:
|
|
223
|
+
"""Return one quote line as rows that keep their bar, wrapped between words."""
|
|
224
|
+
room = max(1, width - 2)
|
|
225
|
+
bar = Span("\u2502 ", Style(foreground=theme.accent))
|
|
226
|
+
base = Style(foreground=theme.subtle)
|
|
227
|
+
rows = [piece for line in body.split("\n") for piece in wrap_words(line, room)] or [""]
|
|
228
|
+
return [TextLine((bar, *inline_markdown(row, base=base, theme=theme))) for row in rows]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def list_item(marker: str, body: str, *, theme: Theme) -> TextLine:
|
|
232
|
+
"""Return one list item, inset under the prose and wrapping under its text.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
marker: The item's own marker, already carrying any source indent.
|
|
236
|
+
body: Item text, rendered with the inline rules.
|
|
237
|
+
theme: Palette used for the marker and the body.
|
|
238
|
+
"""
|
|
239
|
+
prefix = " " * LIST_INDENT + marker
|
|
240
|
+
spans = (Span(prefix, Style(foreground=theme.text)), *inline_markdown(body, theme=theme))
|
|
241
|
+
return TextLine(spans, metadata=display_width(prefix))
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def inline_markdown(value: str, *, base: Style | None = None, theme: Theme = DARK) -> list[Span]:
|
|
245
|
+
"""Render inline emphasis, code, and links.
|
|
246
|
+
|
|
247
|
+
Code spans are recognised before anything else and stand in for themselves
|
|
248
|
+
while emphasis and links are matched, which is the order CommonMark
|
|
249
|
+
resolves them in: a code span is atomic, so the delimiters around it cannot
|
|
250
|
+
reach inside.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
value: One logical line of inline Markdown.
|
|
254
|
+
base: Style for unmarked text; defaults to the theme's body colour.
|
|
255
|
+
theme: Palette used for the synthesized spans.
|
|
256
|
+
"""
|
|
257
|
+
base_style = base if base is not None else Style(foreground=theme.text)
|
|
258
|
+
code: list[Span] = []
|
|
259
|
+
|
|
260
|
+
def hide(match: re.Match[str]) -> str:
|
|
261
|
+
# CommonMark strips one space on each side of the run, and only when the
|
|
262
|
+
# span is not all spaces, so `` ` ` `` keeps its single space.
|
|
263
|
+
content = match.group(2)
|
|
264
|
+
if content.startswith(" ") and content.endswith(" ") and content.strip():
|
|
265
|
+
content = content[1:-1]
|
|
266
|
+
code.append(Span(content, Style(foreground=theme.code.inline)))
|
|
267
|
+
return _CODE_MARK_TEXT.format(len(code) - 1)
|
|
268
|
+
|
|
269
|
+
masked = _CODE.sub(hide, value)
|
|
270
|
+
fragments: list[Span] = []
|
|
271
|
+
position = 0
|
|
272
|
+
for match in _INLINE.finditer(masked):
|
|
273
|
+
if match.start() > position:
|
|
274
|
+
fragments.append(Span(decode_entities(masked[position : match.start()]), base_style))
|
|
275
|
+
token = match.group(0)
|
|
276
|
+
if token.startswith("~~"):
|
|
277
|
+
fragments.extend(nested(token[2:-2], replace(base_style, strike=True), theme, code))
|
|
278
|
+
elif token.startswith(("**", "__")):
|
|
279
|
+
fragments.extend(nested(token[2:-2], replace(base_style, bold=True), theme, code))
|
|
280
|
+
elif token.startswith("["):
|
|
281
|
+
label, _, target = token[1:].partition("](")
|
|
282
|
+
fragments.extend(nested(label, Style(foreground=theme.accent_bright), theme, code))
|
|
283
|
+
# An entity in a destination is decoded where the URL is displayed,
|
|
284
|
+
# as CommonMark decodes it before using the destination.
|
|
285
|
+
fragments.append(Span(f" <{decode_entities(target[:-1])}>", Style(foreground=theme.muted)))
|
|
286
|
+
else:
|
|
287
|
+
fragments.extend(nested(token[1:-1], replace(base_style, italic=True), theme, code))
|
|
288
|
+
position = match.end()
|
|
289
|
+
if position < len(masked) or not fragments:
|
|
290
|
+
fragments.append(Span(decode_entities(masked[position:]), base_style))
|
|
291
|
+
return reveal(fragments, code)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def nested(text: str, base: Style, theme: Theme, code: Sequence[Span]) -> list[Span]:
|
|
295
|
+
"""Render the inside of one inline token, so markup can nest.
|
|
296
|
+
|
|
297
|
+
The content is always two characters shorter than the token it came from,
|
|
298
|
+
which is what keeps this recursion finite. A code span inside the token
|
|
299
|
+
already stands behind a placeholder, so it is put back here and never
|
|
300
|
+
reaches the nested call, which knows nothing of this line's code spans.
|
|
301
|
+
"""
|
|
302
|
+
pieces: list[Span] = []
|
|
303
|
+
cursor = 0
|
|
304
|
+
for match in _CODE_MARK.finditer(text):
|
|
305
|
+
pieces.extend(inline_markdown(text[cursor : match.start()], base=base, theme=theme))
|
|
306
|
+
pieces.append(code[int(match.group(1))])
|
|
307
|
+
cursor = match.end()
|
|
308
|
+
pieces.extend(inline_markdown(text[cursor:], base=base, theme=theme))
|
|
309
|
+
return pieces
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def reveal(fragments: Sequence[Span], code: Sequence[Span]) -> list[Span]:
|
|
313
|
+
"""Put the code spans back where their placeholders ended up.
|
|
314
|
+
|
|
315
|
+
A placeholder can sit inside bold or italic text, so the host span is split
|
|
316
|
+
around it and keeps its own style; the code span keeps the code colour.
|
|
317
|
+
"""
|
|
318
|
+
revealed: list[Span] = []
|
|
319
|
+
for fragment in fragments:
|
|
320
|
+
cursor = 0
|
|
321
|
+
for match in _CODE_MARK.finditer(fragment.text):
|
|
322
|
+
if match.start() > cursor:
|
|
323
|
+
revealed.append(Span(fragment.text[cursor : match.start()], fragment.style))
|
|
324
|
+
revealed.append(code[int(match.group(1))])
|
|
325
|
+
cursor = match.end()
|
|
326
|
+
if cursor < len(fragment.text):
|
|
327
|
+
revealed.append(Span(fragment.text[cursor:], fragment.style))
|
|
328
|
+
return revealed
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def split_table_row(value: str) -> list[str] | None:
|
|
332
|
+
"""Split table cells while preserving escaped and inline-code pipes."""
|
|
333
|
+
stripped = value.strip()
|
|
334
|
+
if "|" not in stripped:
|
|
335
|
+
return None
|
|
336
|
+
if stripped.startswith("|"):
|
|
337
|
+
stripped = stripped[1:]
|
|
338
|
+
if stripped.endswith("|") and not stripped.endswith("\\|"):
|
|
339
|
+
stripped = stripped[:-1]
|
|
340
|
+
|
|
341
|
+
cells: list[str] = []
|
|
342
|
+
current: list[str] = []
|
|
343
|
+
escaped = False
|
|
344
|
+
in_code = False
|
|
345
|
+
for character in stripped:
|
|
346
|
+
if escaped:
|
|
347
|
+
current.append(character if character in ("|", "\\") else f"\\{character}")
|
|
348
|
+
escaped = False
|
|
349
|
+
elif character == "\\":
|
|
350
|
+
escaped = True
|
|
351
|
+
elif character == "`":
|
|
352
|
+
in_code = not in_code
|
|
353
|
+
current.append(character)
|
|
354
|
+
elif character == "|" and not in_code:
|
|
355
|
+
cells.append("".join(current).strip())
|
|
356
|
+
current = []
|
|
357
|
+
else:
|
|
358
|
+
current.append(character)
|
|
359
|
+
if escaped:
|
|
360
|
+
current.append("\\")
|
|
361
|
+
cells.append("".join(current).strip())
|
|
362
|
+
return cells if len(cells) >= 2 else None
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def table_alignments(value: str) -> list[str] | None:
|
|
366
|
+
"""Return the alignment of every column, or None when this is not a rule."""
|
|
367
|
+
cells = split_table_row(value)
|
|
368
|
+
if cells is None:
|
|
369
|
+
return None
|
|
370
|
+
alignments: list[str] = []
|
|
371
|
+
for cell in cells:
|
|
372
|
+
marker = cell.replace(" ", "")
|
|
373
|
+
if re.fullmatch(r":-{3,}:", marker):
|
|
374
|
+
alignments.append("center")
|
|
375
|
+
elif re.fullmatch(r"-{3,}:", marker):
|
|
376
|
+
alignments.append("right")
|
|
377
|
+
elif re.fullmatch(r":?-{3,}", marker):
|
|
378
|
+
alignments.append("left")
|
|
379
|
+
else:
|
|
380
|
+
return None
|
|
381
|
+
return alignments
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def render_table(
|
|
385
|
+
header: list[str],
|
|
386
|
+
alignments: list[str],
|
|
387
|
+
rows: list[list[str]],
|
|
388
|
+
width: int,
|
|
389
|
+
theme: Theme,
|
|
390
|
+
*,
|
|
391
|
+
fixed: list[int | None] | None = None,
|
|
392
|
+
) -> list[TextLine]:
|
|
393
|
+
"""Render a borderless GFM table, wrapping cells rather than cutting them.
|
|
394
|
+
|
|
395
|
+
Args:
|
|
396
|
+
header: Header cells, one per column.
|
|
397
|
+
alignments: One ``"left"``, ``"center"``, or ``"right"`` per column.
|
|
398
|
+
rows: Body cells; short rows are padded and long ones truncated.
|
|
399
|
+
width: Total width the table may occupy, in cells.
|
|
400
|
+
theme: Palette used for the cells and the rule.
|
|
401
|
+
fixed: Per-column pinned widths; ``None`` leaves a column at its natural
|
|
402
|
+
width. Columns are dropped from the right when they cannot fit.
|
|
403
|
+
"""
|
|
404
|
+
columns = len(header)
|
|
405
|
+
while columns > 1 and columns * (TABLE_MIN_COLUMN + TABLE_GAP) - TABLE_GAP > width:
|
|
406
|
+
columns -= 1
|
|
407
|
+
dropped = len(header) - columns
|
|
408
|
+
header = header[:columns]
|
|
409
|
+
alignments = alignments[:columns]
|
|
410
|
+
rows = [row[:columns] for row in rows]
|
|
411
|
+
if dropped:
|
|
412
|
+
header[-1] = ELLIPSIS
|
|
413
|
+
rows = [row[:-1] + [ELLIPSIS] for row in rows]
|
|
414
|
+
|
|
415
|
+
widths = table_widths(header, rows)
|
|
416
|
+
for index, value in enumerate((fixed or [])[:columns]):
|
|
417
|
+
if value is not None:
|
|
418
|
+
widths[index] = max(TABLE_MIN_COLUMN, value)
|
|
419
|
+
budget = max(columns * TABLE_MIN_COLUMN, width - TABLE_GAP * (columns - 1))
|
|
420
|
+
while sum(widths) > budget:
|
|
421
|
+
largest = max(range(columns), key=widths.__getitem__)
|
|
422
|
+
if widths[largest] <= TABLE_MIN_COLUMN:
|
|
423
|
+
break
|
|
424
|
+
widths[largest] -= 1
|
|
425
|
+
|
|
426
|
+
lines = table_row(
|
|
427
|
+
[wrap_spans(inline_markdown(cell, theme=theme), widths[index]) for index, cell in enumerate(header)],
|
|
428
|
+
widths,
|
|
429
|
+
alignments,
|
|
430
|
+
theme,
|
|
431
|
+
header=True,
|
|
432
|
+
)
|
|
433
|
+
lines.append(table_rule(widths, theme))
|
|
434
|
+
for row in rows:
|
|
435
|
+
cells = [wrap_spans(inline_markdown(row[index], theme=theme), widths[index]) for index in range(columns)]
|
|
436
|
+
lines.extend(table_row(cells, widths, alignments, theme))
|
|
437
|
+
lines.append(table_rule(widths, theme))
|
|
438
|
+
return lines
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def table_rule(widths: Sequence[int], theme: Theme) -> TextLine:
|
|
442
|
+
"""Return the line under a table's header or one of its rows.
|
|
443
|
+
|
|
444
|
+
A table rule is structure, not a page divider: it is drawn per column, one
|
|
445
|
+
segment per column width, and one step brighter than the thematic break that
|
|
446
|
+
only separates sections. Every row is closed by one, so wrapped cells never
|
|
447
|
+
look like they belong to the row above.
|
|
448
|
+
"""
|
|
449
|
+
segments = (" " * TABLE_GAP).join(RULE * width for width in widths)
|
|
450
|
+
return TextLine((Span(segments, Style(foreground=theme.subtle)),))
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def table_widths(header: list[str], rows: list[list[str]]) -> list[int]:
|
|
454
|
+
"""Return a natural width per column, capped but far more generous than before."""
|
|
455
|
+
widths: list[int] = []
|
|
456
|
+
for index, cell in enumerate(header):
|
|
457
|
+
values = [cell, *(row[index] for row in rows if index < len(row))]
|
|
458
|
+
natural = max(display_width(plain_inline(value)) for value in values)
|
|
459
|
+
widths.append(max(TABLE_MIN_COLUMN, min(TABLE_MAX_COLUMN, natural)))
|
|
460
|
+
return widths
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def table_row(
|
|
464
|
+
cells: list[list[tuple[Span, ...]]],
|
|
465
|
+
widths: list[int],
|
|
466
|
+
alignments: list[str],
|
|
467
|
+
theme: Theme,
|
|
468
|
+
*,
|
|
469
|
+
header: bool = False,
|
|
470
|
+
) -> list[TextLine]:
|
|
471
|
+
"""Lay wrapped cells into one or more lines, padding each column to its width.
|
|
472
|
+
|
|
473
|
+
Args:
|
|
474
|
+
cells: Already-wrapped spans per column.
|
|
475
|
+
widths: Target width per column, in cells.
|
|
476
|
+
alignments: One alignment name per column.
|
|
477
|
+
theme: Palette used for the padding spans.
|
|
478
|
+
header: Draw the row bold.
|
|
479
|
+
"""
|
|
480
|
+
base = Style(foreground=theme.text, bold=header)
|
|
481
|
+
height = max((len(cell) for cell in cells), default=1)
|
|
482
|
+
lines: list[TextLine] = []
|
|
483
|
+
for index in range(height):
|
|
484
|
+
spans: list[Span] = []
|
|
485
|
+
for column, width in enumerate(widths):
|
|
486
|
+
if column:
|
|
487
|
+
spans.append(Span(" " * TABLE_GAP, base))
|
|
488
|
+
part = cells[column][index] if index < len(cells[column]) else ()
|
|
489
|
+
used = sum(display_width(span.text) for span in part)
|
|
490
|
+
remaining = max(0, width - used)
|
|
491
|
+
alignment = alignments[column]
|
|
492
|
+
left = remaining if alignment == "right" else remaining // 2 if alignment == "center" else 0
|
|
493
|
+
right = remaining - left
|
|
494
|
+
if left:
|
|
495
|
+
spans.append(Span(" " * left, base))
|
|
496
|
+
spans.extend(part)
|
|
497
|
+
if right and column < len(widths) - 1:
|
|
498
|
+
spans.append(Span(" " * right, base))
|
|
499
|
+
lines.append(TextLine(tuple(spans)))
|
|
500
|
+
return lines
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def plain_inline(value: str) -> str:
|
|
504
|
+
"""Return the visible text of one inline fragment, with no styling."""
|
|
505
|
+
return "".join(span.text for span in inline_markdown(value))
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
class Markdown:
|
|
509
|
+
"""Incrementally parsed Markdown with per-width line caching."""
|
|
510
|
+
|
|
511
|
+
def __init__(self, text: str = "", *, theme: Theme = DARK) -> None:
|
|
512
|
+
"""Start from empty caches, optionally parsing ``text`` up front.
|
|
513
|
+
|
|
514
|
+
Args:
|
|
515
|
+
text: Initial document; it is appended as if streamed in, so the
|
|
516
|
+
trailing block stays re-parseable.
|
|
517
|
+
theme: Palette used when rendering; the owner refreshes this before
|
|
518
|
+
painting if the theme can change.
|
|
519
|
+
"""
|
|
520
|
+
self.theme = theme
|
|
521
|
+
self._text = ""
|
|
522
|
+
self._stable_source = 0
|
|
523
|
+
self._stable: list[TextLine] = []
|
|
524
|
+
self._tail: list[TextLine] = []
|
|
525
|
+
self._width = 0
|
|
526
|
+
self._theme = theme
|
|
527
|
+
self._tail_key: tuple[int, int, int, Theme] | None = None
|
|
528
|
+
if text:
|
|
529
|
+
self.append(text)
|
|
530
|
+
|
|
531
|
+
@property
|
|
532
|
+
def text(self) -> str:
|
|
533
|
+
"""Return the full source appended so far."""
|
|
534
|
+
return self._text
|
|
535
|
+
|
|
536
|
+
def set_text(self, text: str) -> None:
|
|
537
|
+
"""Replace the document and drop every cached block."""
|
|
538
|
+
self._text = ""
|
|
539
|
+
self._stable_source = 0
|
|
540
|
+
self._stable = []
|
|
541
|
+
self._tail = []
|
|
542
|
+
self._tail_key = None
|
|
543
|
+
self.append(text)
|
|
544
|
+
|
|
545
|
+
def append(self, delta: str) -> None:
|
|
546
|
+
"""Append streamed text, invalidating only the still-open tail."""
|
|
547
|
+
if not delta:
|
|
548
|
+
return
|
|
549
|
+
self._text += delta
|
|
550
|
+
self._tail_key = None
|
|
551
|
+
|
|
552
|
+
def lines(self, width: int) -> list[TextLine]:
|
|
553
|
+
"""Return every rendered line for ``width``."""
|
|
554
|
+
self._sync(max(1, width))
|
|
555
|
+
return [*self._stable, *self._tail]
|
|
556
|
+
|
|
557
|
+
def line_count(self, width: int) -> int:
|
|
558
|
+
"""Return how many lines the document occupies at ``width``."""
|
|
559
|
+
self._sync(max(1, width))
|
|
560
|
+
return len(self._stable) + len(self._tail)
|
|
561
|
+
|
|
562
|
+
def line_at(self, index: int, width: int) -> TextLine:
|
|
563
|
+
"""Return one line, reading from the stable blocks or from the open tail."""
|
|
564
|
+
self._sync(max(1, width))
|
|
565
|
+
if 0 <= index < len(self._stable):
|
|
566
|
+
return self._stable[index]
|
|
567
|
+
return self._tail[index - len(self._stable)]
|
|
568
|
+
|
|
569
|
+
@property
|
|
570
|
+
def stable_line_count(self) -> int:
|
|
571
|
+
"""Return how many lines are already closed and safely cached."""
|
|
572
|
+
return len(self._stable)
|
|
573
|
+
|
|
574
|
+
def _sync(self, width: int) -> None:
|
|
575
|
+
"""Re-render only what changed since the last call at this width and theme.
|
|
576
|
+
|
|
577
|
+
The palette is part of the cache key: switching themes keeps the same
|
|
578
|
+
text and width, so a cache that ignored it would keep painting the old
|
|
579
|
+
colours until the document changed.
|
|
580
|
+
"""
|
|
581
|
+
if width != self._width or self.theme != self._theme:
|
|
582
|
+
self._width = width
|
|
583
|
+
self._theme = self.theme
|
|
584
|
+
self._stable = []
|
|
585
|
+
self._stable_source = 0
|
|
586
|
+
self._tail_key = None
|
|
587
|
+
cut = stable_cut(self._text[self._stable_source :])
|
|
588
|
+
if cut > 0:
|
|
589
|
+
tail = self._text[self._stable_source : self._stable_source + cut]
|
|
590
|
+
self._stable.extend(render_markdown(tail, self._width, self.theme))
|
|
591
|
+
self._stable_source += cut
|
|
592
|
+
self._tail_key = None
|
|
593
|
+
key = (len(self._text), self._stable_source, self._width, self.theme)
|
|
594
|
+
if key != self._tail_key:
|
|
595
|
+
self._tail = render_markdown(self._text[self._stable_source :], self._width, self.theme)
|
|
596
|
+
self._tail_key = key
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
class MarkdownSource(LineSource):
|
|
600
|
+
"""LineSource adapter so Markdown composes with ScrollView."""
|
|
601
|
+
|
|
602
|
+
def __init__(self, markdown: Markdown) -> None:
|
|
603
|
+
"""Wrap one Markdown document."""
|
|
604
|
+
self.markdown = markdown
|
|
605
|
+
|
|
606
|
+
def count(self, width: int) -> int:
|
|
607
|
+
"""Return the document's line count at this width."""
|
|
608
|
+
return self.markdown.line_count(width)
|
|
609
|
+
|
|
610
|
+
def line(self, index: int, width: int) -> TextLine:
|
|
611
|
+
"""Return one rendered line of the document."""
|
|
612
|
+
return self.markdown.line_at(index, width)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
class MarkdownView(ScrollView):
|
|
616
|
+
"""A scrollable Markdown document that follows the tail while streaming.
|
|
617
|
+
|
|
618
|
+
Shape::
|
|
619
|
+
|
|
620
|
+
Heading <- bold, dimmed by level
|
|
621
|
+
prose with bold, code, and links <- inline styles
|
|
622
|
+
. bullet <- bullet prefix
|
|
623
|
+
| quote <- accent bar
|
|
624
|
+
python <- fence label, muted
|
|
625
|
+
def add(a, b): <- highlighted code
|
|
626
|
+
|
|
627
|
+
Parsing is incremental: closed blocks are parsed once and cached, and only
|
|
628
|
+
the still-open tail is re-parsed as text streams in, so a long answer stays
|
|
629
|
+
linear in the number of blocks rather than the number of frames.
|
|
630
|
+
"""
|
|
631
|
+
|
|
632
|
+
def __init__(self, text: str = "", *, follow_tail: bool = True, focusable: bool = False) -> None:
|
|
633
|
+
"""Scroll a document that follows the tail while text streams in.
|
|
634
|
+
|
|
635
|
+
Args:
|
|
636
|
+
text: Initial document.
|
|
637
|
+
follow_tail: Keep the newest line visible until the reader scrolls up.
|
|
638
|
+
focusable: Allow Tab traversal to land on the view so its keys work.
|
|
639
|
+
"""
|
|
640
|
+
self.markdown = Markdown(text)
|
|
641
|
+
self.markdown_source = MarkdownSource(self.markdown)
|
|
642
|
+
super().__init__(self.markdown_source, follow_tail=follow_tail, focusable=focusable, selectable=True)
|
|
643
|
+
|
|
644
|
+
def set_text(self, text: str) -> None:
|
|
645
|
+
"""Replace the document and repaint."""
|
|
646
|
+
self.markdown.set_text(text)
|
|
647
|
+
self.invalidate()
|
|
648
|
+
|
|
649
|
+
def append(self, delta: str) -> None:
|
|
650
|
+
"""Append streamed text and repaint."""
|
|
651
|
+
self.markdown.append(delta)
|
|
652
|
+
self.invalidate()
|
|
653
|
+
|
|
654
|
+
def render(self, canvas: Canvas) -> None:
|
|
655
|
+
"""Adopt the active theme before painting the document."""
|
|
656
|
+
self.markdown.theme = self.theme
|
|
657
|
+
super().render(canvas)
|