termrender 0.5.0__tar.gz → 0.6.1__tar.gz

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 (46) hide show
  1. {termrender-0.5.0 → termrender-0.6.1}/CHANGELOG.md +72 -0
  2. {termrender-0.5.0 → termrender-0.6.1}/PKG-INFO +1 -1
  3. {termrender-0.5.0 → termrender-0.6.1}/src/termrender/CLAUDE.md +18 -4
  4. {termrender-0.5.0 → termrender-0.6.1}/src/termrender/__main__.py +130 -12
  5. {termrender-0.5.0 → termrender-0.6.1}/src/termrender/blocks.py +8 -0
  6. {termrender-0.5.0 → termrender-0.6.1}/src/termrender/emit.py +22 -1
  7. {termrender-0.5.0 → termrender-0.6.1}/src/termrender/layout.py +36 -1
  8. {termrender-0.5.0 → termrender-0.6.1}/src/termrender/parser.py +234 -18
  9. {termrender-0.5.0 → termrender-0.6.1}/src/termrender/renderers/borders.py +19 -0
  10. termrender-0.6.1/src/termrender/renderers/charts.py +141 -0
  11. termrender-0.6.1/src/termrender/renderers/diff.py +58 -0
  12. termrender-0.6.1/src/termrender/renderers/stat.py +81 -0
  13. {termrender-0.5.0 → termrender-0.6.1}/src/termrender/renderers/table.py +7 -0
  14. {termrender-0.5.0 → termrender-0.6.1}/src/termrender/renderers/text.py +25 -4
  15. termrender-0.6.1/src/termrender/renderers/timeline.py +45 -0
  16. {termrender-0.5.0 → termrender-0.6.1}/src/termrender/style.py +7 -0
  17. termrender-0.6.1/tests/test_charts.py +117 -0
  18. termrender-0.6.1/tests/test_column_alignment.py +213 -0
  19. termrender-0.6.1/tests/test_diff.py +58 -0
  20. termrender-0.6.1/tests/test_inline_badge.py +95 -0
  21. termrender-0.6.1/tests/test_stat.py +88 -0
  22. termrender-0.6.1/tests/test_tasklist.py +82 -0
  23. termrender-0.6.1/tests/test_timeline.py +74 -0
  24. {termrender-0.5.0 → termrender-0.6.1}/tests/test_variable_colons.py +0 -16
  25. termrender-0.5.0/tests/test_column_alignment.py +0 -138
  26. {termrender-0.5.0 → termrender-0.6.1}/.github/workflows/publish.yml +0 -0
  27. {termrender-0.5.0 → termrender-0.6.1}/.gitignore +0 -0
  28. {termrender-0.5.0 → termrender-0.6.1}/CLAUDE.md +0 -0
  29. {termrender-0.5.0 → termrender-0.6.1}/LICENSE +0 -0
  30. {termrender-0.5.0 → termrender-0.6.1}/README.md +0 -0
  31. {termrender-0.5.0 → termrender-0.6.1}/design.json +0 -0
  32. {termrender-0.5.0 → termrender-0.6.1}/pyproject.toml +0 -0
  33. {termrender-0.5.0 → termrender-0.6.1}/requirements.json +0 -0
  34. {termrender-0.5.0 → termrender-0.6.1}/src/termrender/__init__.py +0 -0
  35. {termrender-0.5.0 → termrender-0.6.1}/src/termrender/py.typed +0 -0
  36. {termrender-0.5.0 → termrender-0.6.1}/src/termrender/renderers/CLAUDE.md +0 -0
  37. {termrender-0.5.0 → termrender-0.6.1}/src/termrender/renderers/__init__.py +0 -0
  38. {termrender-0.5.0 → termrender-0.6.1}/src/termrender/renderers/code.py +0 -0
  39. {termrender-0.5.0 → termrender-0.6.1}/src/termrender/renderers/columns.py +0 -0
  40. {termrender-0.5.0 → termrender-0.6.1}/src/termrender/renderers/divider.py +0 -0
  41. {termrender-0.5.0 → termrender-0.6.1}/src/termrender/renderers/mermaid.py +0 -0
  42. {termrender-0.5.0 → termrender-0.6.1}/src/termrender/renderers/panel.py +0 -0
  43. {termrender-0.5.0 → termrender-0.6.1}/src/termrender/renderers/quote.py +0 -0
  44. {termrender-0.5.0 → termrender-0.6.1}/src/termrender/renderers/tree.py +0 -0
  45. {termrender-0.5.0 → termrender-0.6.1}/tests/__init__.py +0 -0
  46. {termrender-0.5.0 → termrender-0.6.1}/tests/test_myst_gaps.py +0 -0
@@ -1,6 +1,78 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v0.6.1 (2026-04-08)
5
+
6
+ ### Bug Fixes
7
+
8
+ - **borders**: Grow render_box to fit overflowing content and titles
9
+ ([`dc108c8`](https://github.com/CaptainCrouton89/termrender/commit/dc108c8242763828245569f719abce64b26ddf5b))
10
+
11
+ mermaid-ascii's --maxWidth is non-strict, so a child mermaid block can return lines wider than the
12
+ panel's allocated content area. Previously the side walls floated outward to accommodate the
13
+ content while the top/bottom borders stayed at the requested width, leaving corner glyphs one
14
+ column inside the side walls and producing a visibly jagged box.
15
+
16
+ render_box now measures the widest content line (and the title) and grows its effective width up
17
+ front so all four borders land at the same column. Trade-off: the box may overflow its parent
18
+ allocation, but the box itself is internally consistent.
19
+
20
+
21
+ ## v0.6.0 (2026-04-07)
22
+
23
+ ### Features
24
+
25
+ - Add diff, charts, stat, timeline, tasklist, and inline badges
26
+ ([`e14f615`](https://github.com/CaptainCrouton89/termrender/commit/e14f615ae8d0723405db61c79b0f858d7bf0f863))
27
+
28
+ New block-level directives: - :::diff — colored unified diff with +/- gutters - :::bar — multi-bar
29
+ chart with sub-cell precision via eighth blocks - :::progress — single-line progress bar (auto
30
+ color by ratio) - :::gauge — three-line meter (auto color by load threshold) - :::stat — KPI tile
31
+ with label, value, trend arrow + delta, caption - :::timeline — vertical event list with bullet
32
+ markers and connectors - :::tasklist — checkbox list (also auto-detected from any markdown list
33
+ with [x]/[ ]/[!] markers)
34
+
35
+ New inline role: - :badge[text]{color=green} — colored pill, reuses new InlineSpan fg/bg fields so
36
+ future inline roles drop in trivially.
37
+
38
+ Cross-cutting changes: - InlineSpan gained fg/bg fields; render_spans and span-slicers in text.py
39
+ and table.py honor them. - _merge_plain_spans coalesces mistune's text fragments before role
40
+ expansion (mistune splits on `[`, which would otherwise break :badge[...]). - _render_list_item
41
+ now uses visual_len(prefix) so styled checkbox prefixes don't break indent math. - STAT joins
42
+ PANEL/CALLOUT/CODE in the border-aware width path. - progress and gauge added to
43
+ _SELF_CLOSING_DIRECTIVES (atomic, no body); stat requires an explicit closer so it can hold a
44
+ caption.
45
+
46
+ 63 new tests across six test files. All 94 tests pass.
47
+
48
+ - **cli**: Add --watch mode for live re-rendering
49
+ ([`4223ad8`](https://github.com/CaptainCrouton89/termrender/commit/4223ad86805b0b3ad45450bd7ca4441a668f0e23))
50
+
51
+ Re-renders the file whenever its mtime changes, with terminal-resize detection and inline error
52
+ display so the watcher survives malformed input. Uses the alternate screen buffer so Ctrl+C
53
+ cleanly restores the prior terminal state.
54
+
55
+ Composes with --tmux: --tmux --watch points the spawned pane at the real file path (skipping the
56
+ tempfile path) so the live loop runs inside the side pane.
57
+
58
+ ### Refactoring
59
+
60
+ - **parser**: Require strictly more colons on outer fences
61
+ ([`4a501d9`](https://github.com/CaptainCrouton89/termrender/commit/4a501d917db191f758874bb6c3d922c879a763be))
62
+
63
+ Drops the depth-counter that allowed `:::outer ... :::inner ... ::: ... :::` nesting with same colon
64
+ counts. Termrender now matches the standard followed by MyST, Pandoc fenced divs,
65
+ markdown-it-container, and CommonMark fenced code blocks: an opener can only nest inside another
66
+ directive if its colon count is strictly less than the outer's.
67
+
68
+ A closer with a non-matching colon count is treated as body content and falls through to the
69
+ recursive parse(), which is what makes nested directives work in the first place.
70
+
71
+ Fixtures in test_column_alignment.py rewritten to ascending colon counts (7/6/5/4/3 for the
72
+ showpiece, 5/4/3 for columns_tree, 4/3 for panel_tree). test_same_colon_nesting_backward_compat
73
+ deleted — its behavior is no longer supported.
74
+
75
+
4
76
  ## v0.5.0 (2026-04-06)
5
77
 
6
78
  ### Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: termrender
3
- Version: 0.5.0
3
+ Version: 0.6.1
4
4
  Summary: Rich terminal rendering of directive-flavored markdown
5
5
  Project-URL: Homepage, https://github.com/CaptainCrouton89/termrender
6
6
  Project-URL: Repository, https://github.com/CaptainCrouton89/termrender
@@ -10,11 +10,11 @@
10
10
 
11
11
  `layout.py` imports `fix_mermaid_encoding` from `renderers/mermaid.py` — the only reverse dependency from layout into renderers. Reorganizing `renderers/` must account for this import. The two subprocess call sites differ: layout uses `check=True` (non-zero exit raises `CalledProcessError` → caught → raw source fallback); the renderer omits `check` (non-zero exit silently reads `stdout`, which may be empty or partial). See `renderers/CLAUDE.md` for encoding-fix details.
12
12
 
13
- ## Directive nesting: depth counter, not stack entries
13
+ ## Directive nesting: outer must have more colons than inner
14
14
 
15
15
  The parser handles two directive syntaxes through different passes: colon directives (`:::name`) are extracted in pass 1 via regex in `_split_directives`, while backtick fence directives (`` ```{name} ``) are resolved in pass 2 via mistune's AST walk in `_convert_ast`. Both paths funnel through `_directive_to_block` for block construction.
16
16
 
17
- For colon directives, the parser tracks nesting with a `depth` integer inside the top stack entry, not separate stack entries (parser.py:241–306). A closer `:::` only pops the stack when `depth == 1`; otherwise it decrements depth and treats the closer as body content. Consequence: innermost closers appear verbatim in the body of the parent and are re-parsed on the recursive `parse()` call at line 351.
17
+ For colon directives, closers are paired strictly by colon count. A closer whose colon count differs from the open directive's colon count is treated as body content; the recursive `parse()` call inside `_directive_to_block` re-parses it as an inner directive. This matches the standard rule used by MyST, Pandoc fenced divs, markdown-it-container, and CommonMark fenced code blocks: outer fences must use strictly more colons than the inner fences they wrap, making opener/closer pairing unambiguous.
18
18
 
19
19
  Max recursion depth is 50; exceeding it raises `ValueError`, not `DirectiveError`. Both the render path and `--check` path in `__main__.py` catch `ValueError` and map it to exit code 2 (`EXIT_SYNTAX`).
20
20
 
@@ -50,9 +50,23 @@ Max recursion depth is 50; exceeding it raises `ValueError`, not `DirectiveError
50
50
 
51
51
  ## `--tmux`: exit code reflects pane creation, auto-sizing runs a full render, tempfiles leak
52
52
 
53
- `__main__.py:229`: after `tmux split-window` succeeds the parent exits `EXIT_OK` immediately — the pane renders asynchronously. Render errors surface only inside the pane. `--check` is silently dropped with `--tmux` (exit at line 229 precedes the `--check` branch at line 231). Tempfile cleanup is embedded as `... | less -R; rm -f <tmpfile>` (line 214); `less` killed abnormally (SIGKILL, closed session) leaks `/tmp/termrender-*.md`.
53
+ `__main__.py:357`: after `tmux split-window` succeeds the parent exits `EXIT_OK` immediately — the pane renders asynchronously. Render errors surface only inside the pane. `--check` is silently dropped with `--tmux` (exit at line 357 precedes the `--check` branch at line 368), even though `--tmux` now runs its own `parse()` call (lines 253–266) for fail-fast syntax validation before spawning the pane. The `--check` "ok" message and exit-code contract are not honoured.
54
54
 
55
- When `--width` is omitted, `--tmux` calls `render(source, width=80, color=False)` (lines 177–185) to measure content width — mermaid subprocesses fire and `TERMRENDER_CJK` mutations apply here. Any exception silently falls back to `pane_width=80`. The pane is capped to `tmux #{pane_width} - 10`; if the tmux query fails, no cap is applied. Minimum enforced pane width is 20.
55
+ Tempfile cleanup is embedded as `... | less -R; rm -f <tmpfile>` (line 339); `less` killed abnormally (SIGKILL, closed session) leaks `/tmp/termrender-*.md`. `--tmux --watch` skips tempfile creation entirely the pane is pointed at the real file path, so no leak.
56
+
57
+ When `--width` is omitted, `--tmux` calls `render(source, width=80, color=False)` (lines 283–290) to measure content width — mermaid subprocesses fire and `TERMRENDER_CJK` mutations apply here. Any exception silently falls back to `pane_width=80`. The pane is capped to `tmux #{pane_width} - 10`; if the tmux query fails, no cap is applied. Minimum enforced pane width is 20.
58
+
59
+ ## `--watch`: re-renders on resize as well as file change; errors are inline, not fatal
60
+
61
+ `_watch_loop` (lines 99–169) polls `os.path.getmtime` every 0.2 s and also re-renders when `shutil.get_terminal_size()` changes — so a terminal resize triggers a re-render even with no file edit. `width=None` is passed to `render()` each cycle, so auto-detection always uses current pane width.
62
+
63
+ The loop catches bare `Exception` (line 142) to keep the watcher alive across render errors; errors appear as a one-line message in the pane, not as an exit. `DirectiveError`, `TerminalError`, and `ValueError` (nesting depth) are each caught separately with distinct prefixes for that same reason — the watcher never exits on render failure, only on `KeyboardInterrupt`.
64
+
65
+ The alternate screen buffer (`\033[?1049h` / `\033[?1049l`) is entered on start and restored in a `finally` block, so Ctrl+C cleanly returns to the prior terminal state. `--watch` requires a FILE argument; stdin cannot be watched (polled by path, not fd).
66
+
67
+ ## `TERMRENDER_COLOR=1` forces color when stdout is not a tty
68
+
69
+ `use_color` (lines 361–363 and 388–390) is `True` when stdout is a tty OR `TERMRENDER_COLOR == "1"`. The tmux pane command is prefixed with `TERMRENDER_COLOR=1` (lines 334, 337) so color survives the `| less -R` pipe. Setting this env var in scripts achieves the same effect — it overrides the tty check entirely.
56
70
 
57
71
  ## `_EMOJI_WIDE_RANGES` must stay sorted by codepoint
58
72
 
@@ -74,6 +74,8 @@ examples:
74
74
  echo '# Hello' | termrender Quick inline render
75
75
 
76
76
  termrender --tmux doc.md Render in a new tmux side pane
77
+ termrender --watch doc.md Live-render in current terminal
78
+ termrender --tmux --watch doc.md Live-render in a new tmux side pane
77
79
  termrender <<'EOF'
78
80
  :::panel{title="Status" color="green"}
79
81
  - All systems operational
@@ -94,6 +96,79 @@ def _error(msg: str, *, fix: str | None = None, hint: str | None = None,
94
96
  sys.exit(code)
95
97
 
96
98
 
99
+ def _watch_loop(file_path: str, *, color: bool, poll_interval: float = 0.2) -> None:
100
+ """Re-render `file_path` whenever its mtime changes.
101
+
102
+ Uses the alternate screen buffer so the prior terminal state is restored
103
+ on exit. Width is re-detected from the current terminal each render so
104
+ pane/window resizes are picked up automatically. Render errors are shown
105
+ inline rather than crashing the watcher — fix the file and save again.
106
+ """
107
+ import time
108
+
109
+ last_mtime: float | None = None
110
+ last_size: tuple[int, int] = (0, 0)
111
+
112
+ def _draw(body: str, status: str) -> None:
113
+ # \033[?25l hide cursor, \033[2J clear, \033[H home
114
+ sys.stdout.write("\033[?25l\033[2J\033[H")
115
+ sys.stdout.write(body)
116
+ if not body.endswith("\n"):
117
+ sys.stdout.write("\n")
118
+ # Status line at the bottom — dim if color is enabled
119
+ if color:
120
+ sys.stdout.write(f"\033[2m{status}\033[0m\n")
121
+ else:
122
+ sys.stdout.write(f"{status}\n")
123
+ sys.stdout.flush()
124
+
125
+ def _render_now() -> None:
126
+ try:
127
+ with open(file_path, "r") as f:
128
+ source = f.read()
129
+ except FileNotFoundError:
130
+ body = f"termrender: file not found: {file_path}\n"
131
+ except OSError as e:
132
+ body = f"termrender: cannot read {file_path}: {e}\n"
133
+ else:
134
+ try:
135
+ body = render(source, width=None, color=color)
136
+ except DirectiveError as e:
137
+ body = f"termrender: syntax error: {e}\n"
138
+ except TerminalError as e:
139
+ body = f"termrender: terminal error: {e}\n"
140
+ except ValueError as e:
141
+ body = f"termrender: nesting error: {e}\n"
142
+ except Exception as e: # noqa: BLE001 — keep watcher alive
143
+ body = f"termrender: render error: {e}\n"
144
+ _draw(body, f"watching {file_path} — Ctrl+C to exit")
145
+
146
+ # Enter alternate screen buffer
147
+ sys.stdout.write("\033[?1049h")
148
+ sys.stdout.flush()
149
+ try:
150
+ while True:
151
+ try:
152
+ mtime = os.path.getmtime(file_path)
153
+ except FileNotFoundError:
154
+ mtime = None
155
+ # Re-render on file change OR terminal resize
156
+ import shutil as _shutil
157
+ size = _shutil.get_terminal_size()
158
+ size_tuple = (size.columns, size.lines)
159
+ if mtime != last_mtime or size_tuple != last_size:
160
+ last_mtime = mtime
161
+ last_size = size_tuple
162
+ _render_now()
163
+ time.sleep(poll_interval)
164
+ except KeyboardInterrupt:
165
+ pass
166
+ finally:
167
+ # Show cursor, leave alternate screen buffer
168
+ sys.stdout.write("\033[?25h\033[?1049l")
169
+ sys.stdout.flush()
170
+
171
+
97
172
  def main() -> None:
98
173
  parser = argparse.ArgumentParser(
99
174
  prog="termrender",
@@ -136,6 +211,11 @@ def main() -> None:
136
211
  action="store_true",
137
212
  help="open rendered output in a new tmux side pane (requires tmux)",
138
213
  )
214
+ parser.add_argument(
215
+ "--watch",
216
+ action="store_true",
217
+ help="re-render whenever FILE changes on disk (requires a file argument)",
218
+ )
139
219
  parser.add_argument(
140
220
  "-V", "--version",
141
221
  action="version",
@@ -143,6 +223,14 @@ def main() -> None:
143
223
  )
144
224
  args = parser.parse_args()
145
225
 
226
+ # --watch needs a real file path to poll; stdin can't be watched.
227
+ if args.watch and args.file is None:
228
+ _error(
229
+ "--watch requires a FILE argument",
230
+ fix="pass a markdown file path; stdin cannot be watched",
231
+ hint="termrender --watch doc.md",
232
+ )
233
+
146
234
  # Determine input source
147
235
  infile = args.file if args.file is not None else sys.stdin
148
236
  if infile is sys.stdin and sys.stdin.isatty():
@@ -213,23 +301,43 @@ def main() -> None:
213
301
  pass
214
302
  pane_width = max(pane_width, 20) # absolute minimum
215
303
 
216
- # Save source so the new pane can render it
217
- with tempfile.NamedTemporaryFile(
218
- mode="w", suffix=".md", prefix="termrender-", delete=False,
219
- ) as f:
220
- f.write(source)
221
- tmpfile = f.name
304
+ # Watch mode points the new pane at the user's real file so edits
305
+ # propagate; non-watch mode snapshots source into a tempfile.
306
+ tmpfile: str | None = None
307
+ if args.watch:
308
+ # args.file is guaranteed non-None by the earlier --watch check.
309
+ source_path = args.file.name
310
+ else:
311
+ with tempfile.NamedTemporaryFile(
312
+ mode="w", suffix=".md", prefix="termrender-", delete=False,
313
+ ) as f:
314
+ f.write(source)
315
+ tmpfile = f.name
316
+ source_path = tmpfile
222
317
 
223
318
  # Rebuild command without --tmux to avoid recursion
224
- cmd_parts = ["termrender", shlex.quote(tmpfile)]
319
+ cmd_parts = ["termrender", shlex.quote(source_path)]
225
320
  if args.no_color:
226
321
  cmd_parts.append("--no-color")
227
322
  if args.cjk:
228
323
  cmd_parts.append("--cjk")
229
- cmd_parts.extend(["-w", str(pane_width)])
324
+ if args.watch:
325
+ # No -w: watcher re-detects pane width per render so resizes
326
+ # pick up automatically.
327
+ cmd_parts.append("--watch")
328
+ else:
329
+ cmd_parts.extend(["-w", str(pane_width)])
230
330
 
231
- # TERMRENDER_COLOR=1 forces color on despite stdout piping to less
232
- pane_cmd = "TERMRENDER_COLOR=1 " + " ".join(cmd_parts) + " | less -R; rm -f " + shlex.quote(tmpfile)
331
+ if args.watch:
332
+ # Watch mode owns the pane (alternate screen buffer); no less,
333
+ # no tempfile cleanup needed.
334
+ pane_cmd = "TERMRENDER_COLOR=1 " + " ".join(cmd_parts)
335
+ else:
336
+ # TERMRENDER_COLOR=1 forces color on despite stdout piping to less
337
+ pane_cmd = (
338
+ "TERMRENDER_COLOR=1 " + " ".join(cmd_parts)
339
+ + " | less -R; rm -f " + shlex.quote(source_path)
340
+ )
233
341
 
234
342
  try:
235
343
  subprocess.run(
@@ -237,15 +345,25 @@ def main() -> None:
237
345
  check=True,
238
346
  )
239
347
  except FileNotFoundError:
240
- os.unlink(tmpfile)
348
+ if tmpfile:
349
+ os.unlink(tmpfile)
241
350
  _error("tmux not found", fix="install tmux or omit --tmux")
242
351
  except subprocess.CalledProcessError:
243
- os.unlink(tmpfile)
352
+ if tmpfile:
353
+ os.unlink(tmpfile)
244
354
  _error("failed to create tmux pane",
245
355
  hint="check that tmux is running and has space for a new pane")
246
356
 
247
357
  sys.exit(EXIT_OK)
248
358
 
359
+ # --watch: live-render in the current terminal
360
+ if args.watch:
361
+ use_color = not args.no_color and (
362
+ sys.stdout.isatty() or os.environ.get("TERMRENDER_COLOR") == "1"
363
+ )
364
+ _watch_loop(args.file.name, color=use_color)
365
+ sys.exit(EXIT_OK)
366
+
249
367
  # --check: validate only, no rendering
250
368
  if args.check:
251
369
  try:
@@ -25,6 +25,12 @@ class BlockType(Enum):
25
25
  TABLE = "table"
26
26
  LIST = "list"
27
27
  LIST_ITEM = "list_item"
28
+ DIFF = "diff"
29
+ BAR = "bar"
30
+ PROGRESS = "progress"
31
+ GAUGE = "gauge"
32
+ STAT = "stat"
33
+ TIMELINE = "timeline"
28
34
 
29
35
 
30
36
  @dataclass
@@ -35,6 +41,8 @@ class InlineSpan:
35
41
  bold: bool = False
36
42
  italic: bool = False
37
43
  code: bool = False
44
+ fg: str | None = None
45
+ bg: str | None = None
38
46
 
39
47
 
40
48
  @dataclass
@@ -3,7 +3,10 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from termrender.blocks import Block, BlockType
6
- from termrender.renderers import panel, columns, tree, code, text, divider, quote, mermaid, table
6
+ from termrender.renderers import (
7
+ panel, columns, tree, code, text, divider, quote, mermaid, table,
8
+ diff, charts, stat, timeline,
9
+ )
7
10
 
8
11
 
9
12
  def emit_block(block: Block, color: bool) -> list[str]:
@@ -45,6 +48,24 @@ def emit_block(block: Block, color: bool) -> list[str]:
45
48
  case BlockType.DIVIDER:
46
49
  return divider.render(block, color)
47
50
 
51
+ case BlockType.DIFF:
52
+ return diff.render(block, color)
53
+
54
+ case BlockType.BAR:
55
+ return charts.render_bar(block, color)
56
+
57
+ case BlockType.PROGRESS:
58
+ return charts.render_progress(block, color)
59
+
60
+ case BlockType.GAUGE:
61
+ return charts.render_gauge(block, color)
62
+
63
+ case BlockType.STAT:
64
+ return stat.render(block, color, render_child=emit_block)
65
+
66
+ case BlockType.TIMELINE:
67
+ return timeline.render(block, color)
68
+
48
69
  case _:
49
70
  return []
50
71
 
@@ -17,7 +17,7 @@ def resolve_width(block: Block, available: int) -> None:
17
17
  block.width = available
18
18
 
19
19
  bt = block.type
20
- if bt in (BlockType.PANEL, BlockType.CALLOUT, BlockType.CODE):
20
+ if bt in (BlockType.PANEL, BlockType.CALLOUT, BlockType.CODE, BlockType.STAT):
21
21
  border_overhead = visual_len("│") * 2 + 2 # left border + left pad + right pad + right border
22
22
  inner = max(available - border_overhead, 1)
23
23
  for child in block.children:
@@ -160,6 +160,41 @@ def resolve_height(block: Block) -> None:
160
160
  elif bt == BlockType.QUOTE:
161
161
  block.height = sum(c.height or 0 for c in block.children) + (1 if block.attrs.get("author") or block.attrs.get("by") else 0)
162
162
 
163
+ elif bt == BlockType.DIFF:
164
+ source = block.attrs.get("source", "")
165
+ lines = source.split("\n") if source else [""]
166
+ # Drop pure-blank trailing line that comes from terminating newline
167
+ if lines and lines[-1] == "":
168
+ lines = lines[:-1]
169
+ block.height = max(len(lines), 1) + 2 # top/bottom border
170
+
171
+ elif bt == BlockType.BAR:
172
+ items = block.attrs.get("items", [])
173
+ title_h = 1 if block.attrs.get("title") else 0
174
+ block.height = max(len(items), 1) + title_h
175
+
176
+ elif bt == BlockType.PROGRESS:
177
+ block.height = 1
178
+
179
+ elif bt == BlockType.GAUGE:
180
+ # label line + bar line + value line
181
+ block.height = 3
182
+
183
+ elif bt == BlockType.STAT:
184
+ # top border + label + value + delta + caption lines + bottom border
185
+ caption_h = sum(c.height or 0 for c in block.children)
186
+ delta_h = 1 if block.attrs.get("delta") else 0
187
+ block.height = 2 + 1 + 1 + delta_h + caption_h # borders + label + value + delta + caption
188
+
189
+ elif bt == BlockType.TIMELINE:
190
+ entries = block.attrs.get("entries", [])
191
+ title_h = 1 if block.attrs.get("title") else 0
192
+ # Each entry takes 1 line + 1 connector line between entries (none after last)
193
+ if entries:
194
+ block.height = title_h + len(entries) * 2 - 1
195
+ else:
196
+ block.height = max(title_h, 1)
197
+
163
198
  else:
164
199
  block.height = sum(c.height or 0 for c in block.children)
165
200