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.
Files changed (83) hide show
  1. zettcode/tui/__init__.py +422 -0
  2. zettcode/tui/_compat.py +32 -0
  3. zettcode/tui/capabilities.py +107 -0
  4. zettcode/tui/clipboard.py +91 -0
  5. zettcode/tui/console.py +229 -0
  6. zettcode/tui/core/__init__.py +63 -0
  7. zettcode/tui/core/app.py +658 -0
  8. zettcode/tui/core/events.py +134 -0
  9. zettcode/tui/core/focus.py +85 -0
  10. zettcode/tui/core/geometry.py +212 -0
  11. zettcode/tui/core/host.py +66 -0
  12. zettcode/tui/core/keymap.py +218 -0
  13. zettcode/tui/core/scheduler.py +105 -0
  14. zettcode/tui/core/screen.py +80 -0
  15. zettcode/tui/core/theme.py +248 -0
  16. zettcode/tui/core/widget.py +204 -0
  17. zettcode/tui/gallery.py +588 -0
  18. zettcode/tui/glyphs.py +73 -0
  19. zettcode/tui/input/__init__.py +18 -0
  20. zettcode/tui/input/bridge.py +46 -0
  21. zettcode/tui/input/decoder.py +234 -0
  22. zettcode/tui/input/events.py +64 -0
  23. zettcode/tui/input/keys.py +289 -0
  24. zettcode/tui/input/reader.py +147 -0
  25. zettcode/tui/layout/__init__.py +24 -0
  26. zettcode/tui/layout/box.py +153 -0
  27. zettcode/tui/layout/overlay.py +164 -0
  28. zettcode/tui/layout/scroll.py +477 -0
  29. zettcode/tui/layout/solver.py +95 -0
  30. zettcode/tui/layout/spacing.py +137 -0
  31. zettcode/tui/py.typed +0 -0
  32. zettcode/tui/render/__init__.py +71 -0
  33. zettcode/tui/render/ansi.py +371 -0
  34. zettcode/tui/render/canvas.py +153 -0
  35. zettcode/tui/render/code.py +456 -0
  36. zettcode/tui/render/color.py +177 -0
  37. zettcode/tui/render/renderer.py +151 -0
  38. zettcode/tui/render/rich_text.py +80 -0
  39. zettcode/tui/render/style.py +121 -0
  40. zettcode/tui/render/sweep.py +87 -0
  41. zettcode/tui/render/text.py +319 -0
  42. zettcode/tui/runner.py +316 -0
  43. zettcode/tui/terminal.py +222 -0
  44. zettcode/tui/testing/__init__.py +6 -0
  45. zettcode/tui/testing/harness.py +220 -0
  46. zettcode/tui/testing/snapshot.py +64 -0
  47. zettcode/tui/theme_file.py +300 -0
  48. zettcode/tui/themes/ayu-mirage.toml +44 -0
  49. zettcode/tui/themes/catppuccin-latte.toml +44 -0
  50. zettcode/tui/themes/catppuccin-mocha.toml +44 -0
  51. zettcode/tui/themes/dracula.toml +44 -0
  52. zettcode/tui/themes/github-dimmed.toml +44 -0
  53. zettcode/tui/themes/github-light.toml +44 -0
  54. zettcode/tui/themes/gruvbox-dark.toml +44 -0
  55. zettcode/tui/themes/gruvbox-light.toml +44 -0
  56. zettcode/tui/themes/kanagawa.toml +44 -0
  57. zettcode/tui/themes/nord.toml +44 -0
  58. zettcode/tui/themes/one-dark.toml +44 -0
  59. zettcode/tui/themes/rose-pine-dawn.toml +44 -0
  60. zettcode/tui/themes/rose-pine.toml +44 -0
  61. zettcode/tui/themes/solarized-dark.toml +44 -0
  62. zettcode/tui/themes/solarized-light.toml +44 -0
  63. zettcode/tui/themes/tokyo-night.toml +44 -0
  64. zettcode/tui/widgets/__init__.py +121 -0
  65. zettcode/tui/widgets/collapsible.py +116 -0
  66. zettcode/tui/widgets/completion.py +211 -0
  67. zettcode/tui/widgets/console.py +442 -0
  68. zettcode/tui/widgets/dialog.py +165 -0
  69. zettcode/tui/widgets/diff.py +236 -0
  70. zettcode/tui/widgets/list.py +293 -0
  71. zettcode/tui/widgets/list_page.py +121 -0
  72. zettcode/tui/widgets/markdown.py +657 -0
  73. zettcode/tui/widgets/progress.py +166 -0
  74. zettcode/tui/widgets/rich_text.py +84 -0
  75. zettcode/tui/widgets/status.py +106 -0
  76. zettcode/tui/widgets/table.py +101 -0
  77. zettcode/tui/widgets/tasks.py +130 -0
  78. zettcode/tui/widgets/text.py +111 -0
  79. zettcode/tui/widgets/textarea.py +671 -0
  80. zettcode/tui/widgets/toast.py +137 -0
  81. zettcode_tui-0.4.0.dist-info/METADATA +20 -0
  82. zettcode_tui-0.4.0.dist-info/RECORD +83 -0
  83. 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. ``&amp`` 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 ``&nbsp;`` 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)