termrender 4.0.0__tar.gz → 4.1.0__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 (58) hide show
  1. {termrender-4.0.0 → termrender-4.1.0}/CHANGELOG.md +23 -0
  2. {termrender-4.0.0 → termrender-4.1.0}/PKG-INFO +1 -1
  3. {termrender-4.0.0 → termrender-4.1.0}/src/termrender/layout.py +4 -19
  4. termrender-4.1.0/src/termrender/renderers/CLAUDE.md +68 -0
  5. {termrender-4.0.0 → termrender-4.1.0}/src/termrender/renderers/mermaid.py +59 -17
  6. termrender-4.1.0/src/termrender/renderers/mermaid_gantt.py +222 -0
  7. termrender-4.1.0/src/termrender/renderers/mermaid_pie.py +78 -0
  8. termrender-4.1.0/tests/test_mermaid_dispatch.py +89 -0
  9. termrender-4.1.0/tests/test_mermaid_gantt.py +154 -0
  10. termrender-4.1.0/tests/test_mermaid_pie.py +92 -0
  11. termrender-4.0.0/src/termrender/renderers/CLAUDE.md +0 -58
  12. {termrender-4.0.0 → termrender-4.1.0}/.github/workflows/publish.yml +0 -0
  13. {termrender-4.0.0 → termrender-4.1.0}/.gitignore +0 -0
  14. {termrender-4.0.0 → termrender-4.1.0}/CLAUDE.md +0 -0
  15. {termrender-4.0.0 → termrender-4.1.0}/LICENSE +0 -0
  16. {termrender-4.0.0 → termrender-4.1.0}/README.md +0 -0
  17. {termrender-4.0.0 → termrender-4.1.0}/design.json +0 -0
  18. {termrender-4.0.0 → termrender-4.1.0}/pyproject.toml +0 -0
  19. {termrender-4.0.0 → termrender-4.1.0}/requirements.json +0 -0
  20. {termrender-4.0.0 → termrender-4.1.0}/scripts/build-mermaid-ascii.sh +0 -0
  21. {termrender-4.0.0 → termrender-4.1.0}/src/termrender/CLAUDE.md +0 -0
  22. {termrender-4.0.0 → termrender-4.1.0}/src/termrender/__init__.py +0 -0
  23. {termrender-4.0.0 → termrender-4.1.0}/src/termrender/__main__.py +0 -0
  24. {termrender-4.0.0 → termrender-4.1.0}/src/termrender/_bin/mermaid-ascii-darwin-arm64 +0 -0
  25. {termrender-4.0.0 → termrender-4.1.0}/src/termrender/_mermaid_bin.py +0 -0
  26. {termrender-4.0.0 → termrender-4.1.0}/src/termrender/blocks.py +0 -0
  27. {termrender-4.0.0 → termrender-4.1.0}/src/termrender/emit.py +0 -0
  28. {termrender-4.0.0 → termrender-4.1.0}/src/termrender/parser.py +0 -0
  29. {termrender-4.0.0 → termrender-4.1.0}/src/termrender/py.typed +0 -0
  30. {termrender-4.0.0 → termrender-4.1.0}/src/termrender/renderers/__init__.py +0 -0
  31. {termrender-4.0.0 → termrender-4.1.0}/src/termrender/renderers/borders.py +0 -0
  32. {termrender-4.0.0 → termrender-4.1.0}/src/termrender/renderers/charts.py +0 -0
  33. {termrender-4.0.0 → termrender-4.1.0}/src/termrender/renderers/code.py +0 -0
  34. {termrender-4.0.0 → termrender-4.1.0}/src/termrender/renderers/columns.py +0 -0
  35. {termrender-4.0.0 → termrender-4.1.0}/src/termrender/renderers/diff.py +0 -0
  36. {termrender-4.0.0 → termrender-4.1.0}/src/termrender/renderers/divider.py +0 -0
  37. {termrender-4.0.0 → termrender-4.1.0}/src/termrender/renderers/panel.py +0 -0
  38. {termrender-4.0.0 → termrender-4.1.0}/src/termrender/renderers/quote.py +0 -0
  39. {termrender-4.0.0 → termrender-4.1.0}/src/termrender/renderers/stat.py +0 -0
  40. {termrender-4.0.0 → termrender-4.1.0}/src/termrender/renderers/table.py +0 -0
  41. {termrender-4.0.0 → termrender-4.1.0}/src/termrender/renderers/text.py +0 -0
  42. {termrender-4.0.0 → termrender-4.1.0}/src/termrender/renderers/timeline.py +0 -0
  43. {termrender-4.0.0 → termrender-4.1.0}/src/termrender/renderers/tree.py +0 -0
  44. {termrender-4.0.0 → termrender-4.1.0}/src/termrender/style.py +0 -0
  45. {termrender-4.0.0 → termrender-4.1.0}/tests/__init__.py +0 -0
  46. {termrender-4.0.0 → termrender-4.1.0}/tests/test_charts.py +0 -0
  47. {termrender-4.0.0 → termrender-4.1.0}/tests/test_cli_contract.py +0 -0
  48. {termrender-4.0.0 → termrender-4.1.0}/tests/test_column_alignment.py +0 -0
  49. {termrender-4.0.0 → termrender-4.1.0}/tests/test_diff.py +0 -0
  50. {termrender-4.0.0 → termrender-4.1.0}/tests/test_inline_badge.py +0 -0
  51. {termrender-4.0.0 → termrender-4.1.0}/tests/test_linebreak.py +0 -0
  52. {termrender-4.0.0 → termrender-4.1.0}/tests/test_mermaid_compat.py +0 -0
  53. {termrender-4.0.0 → termrender-4.1.0}/tests/test_myst_gaps.py +0 -0
  54. {termrender-4.0.0 → termrender-4.1.0}/tests/test_stat.py +0 -0
  55. {termrender-4.0.0 → termrender-4.1.0}/tests/test_tasklist.py +0 -0
  56. {termrender-4.0.0 → termrender-4.1.0}/tests/test_timeline.py +0 -0
  57. {termrender-4.0.0 → termrender-4.1.0}/tests/test_variable_colons.py +0 -0
  58. {termrender-4.0.0 → termrender-4.1.0}/uv.lock +0 -0
@@ -1,6 +1,29 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v4.1.0 (2026-07-06)
5
+
6
+ ### Features
7
+
8
+ - **renderers**: Dispatch mermaid by type; native pie and gantt renderers
9
+ ([`08decd1`](https://github.com/crouton-labs/termrender/commit/08decd191c7b17680a8785037b87807d18959de5))
10
+
11
+ mermaid.py becomes a first-line type dispatcher: pie and gantt now get dedicated native Python
12
+ renderers instead of falling through to the vendored Go mermaid-ascii binary (which just echoed
13
+ them as plaintext). Every other type's degradation is unchanged byte-for-byte, including
14
+ flowchart/graph routing to the Go binary.
15
+
16
+ - mermaid_pie.py: parses the pie grammar and renders a labeled horizontal bar chart with
17
+ percentages, reusing charts.py's render_bar. - mermaid_gantt.py: parses the core gantt grammar
18
+ (dateFormat, sections, tasks with dates/durations/after-deps) and renders section-grouped rows
19
+ with time-span bars scaled to the overall date range. Malformed lines and unresolvable tasks
20
+ degrade gracefully instead of crashing. - layout.py's height pass now shares the same
21
+ render_mermaid_lines dispatch mermaid.py uses, removing a duplicated subprocess call.
22
+
23
+ Tests: parser/layout coverage for both new renderers plus dispatcher routing tests
24
+ (test_mermaid_pie.py, test_mermaid_gantt.py, test_mermaid_dispatch.py).
25
+
26
+
4
27
  ## v4.0.0 (2026-07-06)
5
28
 
6
29
  ### Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: termrender
3
- Version: 4.0.0
3
+ Version: 4.1.0
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
@@ -2,11 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import subprocess
6
-
7
- from termrender._mermaid_bin import mermaid_ascii_bin
8
5
  from termrender.blocks import Block, BlockType
9
- from termrender.renderers.mermaid import fix_mermaid_encoding, preprocess_mermaid_for_ascii
6
+ from termrender.renderers.mermaid import render_mermaid_lines
10
7
  from termrender.style import wrap_text, visual_len
11
8
 
12
9
 
@@ -145,21 +142,9 @@ def resolve_height(block: Block) -> None:
145
142
 
146
143
  elif bt == BlockType.MERMAID:
147
144
  source = block.attrs.get("source", "") or _plain_text(block.text)
148
- rendered = source # fallback
149
- try:
150
- result = subprocess.run(
151
- [mermaid_ascii_bin(), "-f", "-", "-y", "1"],
152
- input=preprocess_mermaid_for_ascii(source),
153
- capture_output=True,
154
- text=True,
155
- check=True,
156
- timeout=30,
157
- )
158
- rendered = fix_mermaid_encoding(result.stdout)
159
- except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
160
- pass
161
- block.attrs["_rendered"] = rendered
162
- block.height = len(rendered.split("\n")) if rendered else 1
145
+ lines = render_mermaid_lines(source, width)
146
+ block.attrs["_rendered"] = "\n".join(lines)
147
+ block.height = len(lines) if lines else 1
163
148
 
164
149
  elif bt == BlockType.QUOTE:
165
150
  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)
@@ -0,0 +1,68 @@
1
+ # Renderers
2
+
3
+ ## Renderer signature convention
4
+ Two signatures exist — not enforced by typing, but the dispatcher expects them:
5
+ - **Leaf renderers** (`divider.py`, `tree.py`): `render(block, color) -> list[str]`
6
+ - **Container renderers** (`panel.py`, `quote.py`, `code.py`, `columns.py`): `render(block, color, render_child) -> list[str]`
7
+
8
+ `render_child` is the engine's dispatch function; call it to recurse into child blocks rather than calling sibling renderers directly.
9
+
10
+ ## `render_box` — width is total, not content
11
+ `borders.py:render_box(content_lines, width, ...)` — `width` is the **full** box width including the two `│` border columns. Content width is `width - 2*border_v - 2` (border + 1-space padding each side). Passing inner/content width here produces double-shrinkage.
12
+
13
+ Used by `panel.py` and `code.py` (with `dim=True`).
14
+
15
+ If `content_lines` is empty, `render_box` inserts one blank interior line — output is always ≥ 3 lines (top border + 1 blank + bottom border). Callers computing expected height from content line count must add 3, not 2.
16
+
17
+ When `title` is provided, it is **left-anchored**, not centered: `┌─ TITLE ───┐`. The format is `"─ " + title + " "` flush-left, then fill dashes to `┐`. There is no API for a right-anchored or centered title.
18
+
19
+ ## EAW-dynamic guide characters in `tree.py`
20
+ `_guide_chars()` is called at render time (not module init) because `visual_len("├── ")` differs under East Asian Width ambiguous=2 mode. Under that setting `"│"` is width 2 while `"─"` is width 1, so the four guide prefixes have inconsistent visual widths. `_guide_chars()` normalizes all four to the visual width of `"├── "` at runtime, picking up whatever EAW setting is active. The same EAW issue drives the dynamic `border_v`/`dash_v` math in `render_box`.
21
+
22
+ ## `tree.py` — source goes in `attrs["source"]`, indent is auto-detected
23
+ Tree content comes from `block.attrs["source"]` (a raw multi-line string), not `block.text` or `block.children`. `_detect_indent` sniffs the first indented line: returns 2 for ≤2 spaces, the raw value for 3–4 spaces, and clamps to 4 for anything larger. A tree where the first sniffed indent is 5 or 6 spaces gets clamped to 4 — subsequent lines compute `depth = spaces // 4`, skipping depth levels silently (e.g. 6-space children land at depth 1, but 12-space grandchildren land at depth 3, not 2). `[x]` and `[!]` status markers in labels are replaced with styled Unicode symbols; `**bold**` and `*italic*` inline markdown is also parsed.
24
+
25
+ Depth-0 items (no leading spaces) render with no guide prefix at all — a flat source is a plain list, no tree lines.
26
+
27
+ `block.attrs["color"]` sets the guide-line color (the `├──` connectors); it is distinct from the `color: bool` render parameter and only styles guides, not labels.
28
+
29
+ ## `divider.py` — always dim, no color path
30
+ `render()` hardcodes `dim=True`; there is no way to pass a custom color through the leaf signature. A colored divider requires a different renderer or direct `render_box` usage.
31
+
32
+ Optional centered label comes from `block.attrs["label"]` (not `block.text`). `visual_center(inner, w, "─")` handles the fill — the label is padded as `" {label} "` before centering, so the label always has at least one space of breathing room from the surrounding dashes.
33
+
34
+ ## `quote.py` — dual attribution keys; EAW-sensitive bar width
35
+ Attribution line is rendered for `block.attrs["author"]` **or** `block.attrs["by"]`; `"author"` takes precedence. Missing both suppresses the attribution line entirely.
36
+
37
+ `bar_width = visual_len("│") + 1` — under EAW ambiguous=2, `"│"` is width 2 so `bar_width` becomes 3, shrinking `inner_w` by one extra column relative to default EAW. The bar itself is correct visually, but layout that pre-computes quote inner width assuming `bar_width == 2` will be off by 1 under EAW=2.
38
+
39
+ ## `panel.py` — callouts delegate through a proxy Block
40
+ `render_callout` patches `title`, `color`, and `type=BlockType.PANEL` into a new `Block` instance and calls `render()` on it. The original block is not mutated. The callout type string (`"info"`, `"warning"`, `"error"`, `"success"`) is only used to look up `_CALLOUT_MAP`; an unknown type falls back to blue `ℹ`.
41
+
42
+ ## `mermaid.py` — first-line type dispatcher, not a single renderer
43
+
44
+ `mermaid.py`'s `render(block, color)` (leaf signature, no `render_child` — the parent `CLAUDE.md` incorrectly lists mermaid as a Container renderer) is a thin wrapper: it reads `block.attrs["_rendered"]` if layout already precomputed it, else calls `render_mermaid_lines(source, width)`, then pads every line to `block.width` with `visual_ljust`. All the type-routing logic lives in `render_mermaid_lines`, shared with `layout.py`'s height pass (see below) so there is exactly one dispatch path, not two.
45
+
46
+ **Dispatch is by first non-blank line, case-insensitive** (`_first_line_type`): `pie` and `gantt` route to the native Python renderers in `mermaid_pie.py` / `mermaid_gantt.py`. Every other type — `graph`/`flowchart`, `sequenceDiagram`, `classDiagram`, `stateDiagram`, `erDiagram`, `journey`, `mindmap`, … — routes to `_render_via_binary`, the pre-existing single path through the vendored Go `mermaid-ascii` binary. **Only `pie` and `gantt` have native renderers; every other type's degradation is byte-for-byte what it was before this dispatcher existed** — do not add a bordered fallback panel for them, since crouter's attach viewer detects render failure by the *absence* of box-drawing glyphs in the output, and a decorative border would defeat that.
47
+
48
+ **`_render_via_binary`** (used for flowchart/graph/sequence/everything-without-a-native-renderer): normalizes via `preprocess_mermaid_for_ascii`, shells out with `check=True`, and undoes the Latin-1 double-encoding via `fix_mermaid_encoding` (`mermaid-ascii` misreads UTF-8 input as Latin-1 and re-encodes, corrupting multi-byte characters — e.g. `→` becomes `â\x86\x92`; `text.encode("latin-1").decode("utf-8")` reverses it, and on failure silently returns the corrupted string). Any failure — `CalledProcessError` (including a non-zero exit, now actually checked via `check=True`), missing binary, or timeout — degrades to the raw source text. A labeled back-edge (`X -->|lbl| Y` in `graph LR`) panics the binary on both the PyPI 1.2.0 wheel and vendored master; the resulting non-zero exit degrades the same way.
49
+
50
+ **`visual_ljust` pads but never truncates**: every output line (from any type — binary or native) is padded to `block.width` in the outer `render()`. Lines wider than `block.width` overflow without clipping. `mermaid-ascii` has no width-control flag (`--maxWidth`/`-w` do not exist — the call passes only `-f - -y 1`), so wide flowchart/sequence output silently exceeds the layout boundary; the native pie/gantt renderers respect `width` by construction (they size their own bars to it) and don't have this problem.
51
+
52
+ Layout (`layout.py::resolve_height`, `BlockType.MERMAID` branch) precomputes `block.attrs["_rendered"]` by calling the same `render_mermaid_lines`, so `mermaid.py`'s `render()` only recomputes when that key is absent (e.g. calling `render()` on a block that skipped the layout pass).
53
+
54
+ **Flowchart node shapes are normalized to rectangles**: `preprocess_mermaid_for_ascii` rewrites every non-rectangle flowchart node shape (`{rhombus}`, `[(cylinder)]`, `((circle))`, `([stadium])`, `{{hexagon}}`, `[[subroutine]]`, `[/parallelogram/]`) to the `id[label]` form via `_FLOWCHART_SHAPE_SUBS`, because mermaid-ascii's `parseNode` only recognizes `[text]` rectangles — other shapes otherwise leak raw delimiters or the bare node id into the box. This is a permanent workaround: node shapes are unsupported on current upstream master (pin `6fffb8e`) and the 6 newer master commits are arrow-parsing only, so don't expect a pin bump to remove the need. The backend draws a rectangle regardless, so only the label text matters.
55
+
56
+ **Binary resolution**: the executable comes from `_mermaid_bin.mermaid_ascii_bin()` — the vendored `_bin/mermaid-ascii-<os>-<arch>` (pinned upstream master `6fffb8e`, built via `scripts/build-mermaid-ascii.sh`) if present for the platform, else `mermaid-ascii` on PATH (the PyPI wheel, capped at 1.2.0).
57
+
58
+ ## `mermaid_pie.py` — re-expressed as a bar chart, not a text circle
59
+
60
+ A text pie chart is worse than a labeled bar chart at the same information density (per the ratified plan), so `parse_pie` extracts `(title, items)` from the `pie [showData]` / `title …` / `"label" : value` grammar and `render` hands the items to `charts.py::render_bar` via a synthetic `Block(type=BlockType.BAR, ...)` — no duplicated bar-drawing logic. Each item's `unit` attr is set to the pre-formatted percentage string (e.g. `" (40.0%)"`) so `render_bar`'s existing value-formatting appends it for free. Called with `color=False` always — mermaid output is monochrome by design (the attach viewer relies on `--color on == --color off`). Unparseable/empty input degrades to the raw source lines, same contract as the binary path.
61
+
62
+ ## `mermaid_gantt.py` — dates resolved via a running `last_end` cursor, not a strict grammar
63
+
64
+ `parse_gantt` handles `dateFormat` (translated to a `strptime` pattern via a token map: `YYYY`→`%Y`, `MM`→`%m`, etc.), `title`, `section`, and task lines split on the first `:` into a comma-separated token list. Each token is classified in order — status keyword (`done`/`active`/`crit`/`milestone`, ignored) → `after <id...>` dependency → duration (`\d+[dwhms]`) → date (via the resolved `strptime` format) → bare identifier — so token order in the source doesn't matter, only which shape each token matches.
65
+
66
+ **Missing start dates fall back to `last_end`**, a running cursor updated after every successfully-placed task (mirroring mermaid's own default-start-follows-previous-task semantics): a bare `Task2 :5d` with no date and no `after` picks up where the previous task left off; an `after <id>` referencing an id that was never seen falls back the same way rather than dropping the task. A task with no start anchor at all (first task in the diagram, no date, no `after`) is skipped — degrade, don't crash.
67
+
68
+ `render` scales every task's `(start, end)` into a ratio over the full `[min_start, max_end]` span and draws a `░`/`█` track via `_draw_span`; section names are their own row, un-indented, with all task rows below sharing one global label-column width for alignment across sections. Diagrams with zero resolved tasks (parse produced no sections) degrade to the raw source lines.
@@ -7,6 +7,7 @@ import subprocess
7
7
 
8
8
  from termrender._mermaid_bin import mermaid_ascii_bin
9
9
  from termrender.blocks import Block
10
+ from termrender.renderers import mermaid_gantt, mermaid_pie
10
11
  from termrender.style import visual_ljust
11
12
 
12
13
 
@@ -116,6 +117,59 @@ def preprocess_mermaid_for_ascii(source: str) -> str:
116
117
  return "\n".join(out)
117
118
 
118
119
 
120
+ def _first_line_type(source: str) -> str:
121
+ """Return the lowercased first non-blank line's leading keyword.
122
+
123
+ This is the dispatch key: mermaid identifies a diagram's type from its
124
+ first line (``graph``/``flowchart``, ``pie``, ``gantt``, ``sequenceDiagram``,
125
+ ``classDiagram``, …). Returns ``""`` for an empty/blank source.
126
+ """
127
+ first = next((l.strip() for l in source.splitlines() if l.strip()), "")
128
+ return first.lower()
129
+
130
+
131
+ def _render_via_binary(source: str) -> str:
132
+ """Render via the vendored mermaid-ascii binary, falling back to source.
133
+
134
+ This is the pre-existing path for every type without a native Python
135
+ renderer (flowchart/graph, sequence, class, state, ER, …): normalize
136
+ what mermaid-ascii can parse, shell out, and undo its Latin-1
137
+ double-encoding. Any failure (missing binary, timeout, non-zero exit,
138
+ a parser panic on unsupported input) degrades to the raw source text —
139
+ mermaid-ascii has no notion of "partial" output, so there is no middle
140
+ ground between a full render and falling back.
141
+ """
142
+ try:
143
+ result = subprocess.run(
144
+ [mermaid_ascii_bin(), "-f", "-", "-y", "1"],
145
+ input=preprocess_mermaid_for_ascii(source),
146
+ capture_output=True,
147
+ text=True,
148
+ check=True,
149
+ timeout=30,
150
+ )
151
+ return fix_mermaid_encoding(result.stdout)
152
+ except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired, OSError):
153
+ return source
154
+
155
+
156
+ def render_mermaid_lines(source: str, width: int) -> list[str]:
157
+ """Dispatch a mermaid source to its type's renderer and return raw lines.
158
+
159
+ ``pie`` and ``gantt`` get dedicated native Python renderers. Every other
160
+ type (flowchart/graph, sequence, class, state, ER, journey, mindmap, …)
161
+ keeps today's exact path: the vendored Go mermaid-ascii binary, which
162
+ echoes unparseable/unsupported source back verbatim. Lines are returned
163
+ unpadded; callers apply width padding uniformly.
164
+ """
165
+ diagram_type = _first_line_type(source)
166
+ if diagram_type.startswith("pie"):
167
+ return mermaid_pie.render(source, width)
168
+ if diagram_type.startswith("gantt"):
169
+ return mermaid_gantt.render(source, width)
170
+ return _render_via_binary(source).split("\n")
171
+
172
+
119
173
  def render(block: Block, color: bool) -> list[str]:
120
174
  """Render a mermaid diagram from pre-rendered or on-the-fly ASCII output."""
121
175
  w = block.width
@@ -123,20 +177,8 @@ def render(block: Block, color: bool) -> list[str]:
123
177
 
124
178
  if rendered is None:
125
179
  source = block.attrs.get("source", "")
126
- try:
127
- result = subprocess.run(
128
- [mermaid_ascii_bin(), "-f", "-", "-y", "1"],
129
- input=preprocess_mermaid_for_ascii(source),
130
- capture_output=True,
131
- text=True,
132
- timeout=30,
133
- )
134
- rendered = fix_mermaid_encoding(result.stdout)
135
- except Exception:
136
- rendered = source
137
-
138
- lines: list[str] = []
139
- for raw_line in rendered.split("\n"):
140
- lines.append(visual_ljust(raw_line, w))
141
-
142
- return lines
180
+ raw_lines = render_mermaid_lines(source, w or 60)
181
+ else:
182
+ raw_lines = rendered.split("\n")
183
+
184
+ return [visual_ljust(raw_line, w) for raw_line in raw_lines]
@@ -0,0 +1,222 @@
1
+ """Native renderer for mermaid ``gantt`` diagrams.
2
+
3
+ Mermaid gantt grammar (core subset)::
4
+
5
+ gantt
6
+ title A Gantt Diagram
7
+ dateFormat YYYY-MM-DD
8
+ section Section A
9
+ Task1 :a1, 2024-01-01, 30d
10
+ Task2 :after a1, 20d
11
+ section Section B
12
+ Task3 : 2024-02-01, 2024-02-10
13
+
14
+ Rendered as section-grouped rows with a horizontal time-span bar per task,
15
+ scaled to the overall date range — the gantt analogue of a timeline, per the
16
+ ratified plan in ``mermaid-inline-rendering.md``.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ from datetime import datetime, timedelta
23
+
24
+ from termrender.style import visual_len, visual_ljust
25
+
26
+ _HEADER_RE = re.compile(r"^\s*gantt\b", re.IGNORECASE)
27
+ _TITLE_RE = re.compile(r"^\s*title\s+(.*\S)\s*$", re.IGNORECASE)
28
+ _DATEFORMAT_RE = re.compile(r"^\s*dateFormat\s+(\S+)\s*$", re.IGNORECASE)
29
+ _SECTION_RE = re.compile(r"^\s*section\s+(.*\S)\s*$", re.IGNORECASE)
30
+ _SKIP_RE = re.compile(
31
+ r"^\s*(?:excludes|includes|axisFormat|todayMarker|weekend|tickInterval)\b",
32
+ re.IGNORECASE,
33
+ )
34
+ _STATUS_TOKENS = {"done", "active", "crit", "milestone", "vert"}
35
+ _DURATION_RE = re.compile(r"^(\d+(?:\.\d+)?)\s*(d|w|h|m|s)$", re.IGNORECASE)
36
+ _ID_RE = re.compile(r"^[A-Za-z_][\w-]*$")
37
+ _UNIT_SECONDS = {"s": 1, "m": 60, "h": 3600, "d": 86400, "w": 604800}
38
+
39
+ # Mermaid dateFormat tokens, longest-first so e.g. "YYYY" isn't partially
40
+ # consumed by a "YY" replacement first.
41
+ _DATE_TOKEN_MAP = [
42
+ ("YYYY", "%Y"),
43
+ ("YY", "%y"),
44
+ ("MM", "%m"),
45
+ ("DD", "%d"),
46
+ ("HH", "%H"),
47
+ ("mm", "%M"),
48
+ ("ss", "%S"),
49
+ ]
50
+
51
+
52
+ def _strptime_format(date_format: str) -> str:
53
+ """Translate a mermaid ``dateFormat`` token string to a strptime pattern."""
54
+ out = date_format
55
+ for token, code in _DATE_TOKEN_MAP:
56
+ out = out.replace(token, code)
57
+ return out
58
+
59
+
60
+ def _parse_date(token: str, fmt: str) -> datetime | None:
61
+ try:
62
+ return datetime.strptime(token, fmt)
63
+ except ValueError:
64
+ return None
65
+
66
+
67
+ def parse_gantt(source: str) -> dict:
68
+ """Parse the core mermaid gantt grammar into a title + section/task tree.
69
+
70
+ Handles ``dateFormat``, ``title``, ``section``, and task lines of the
71
+ form ``name : [status,] [id,] [start-date|after id,] duration-or-end``.
72
+ Lines that don't match a recognized shape (``excludes``, ``axisFormat``,
73
+ a task with no resolvable start anchor, …) are skipped rather than
74
+ raising — agents emit a wide variety of gantt dialects and an unparseable
75
+ line must degrade, not crash.
76
+
77
+ Returns ``{"title": str | None, "sections": [{"name": str | None,
78
+ "tasks": [{"label": str, "start": datetime, "end": datetime}]}]}``.
79
+ Sections with no successfully parsed tasks are dropped.
80
+ """
81
+ fmt = "%Y-%m-%d"
82
+ title: str | None = None
83
+ sections: list[dict] = [{"name": None, "tasks": []}]
84
+ ids: dict[str, datetime] = {}
85
+ last_end: datetime | None = None
86
+
87
+ for line in source.splitlines():
88
+ if not line.strip() or _HEADER_RE.match(line) or _SKIP_RE.match(line):
89
+ continue
90
+
91
+ m = _TITLE_RE.match(line)
92
+ if m:
93
+ title = m.group(1)
94
+ continue
95
+
96
+ m = _DATEFORMAT_RE.match(line)
97
+ if m:
98
+ fmt = _strptime_format(m.group(1))
99
+ continue
100
+
101
+ m = _SECTION_RE.match(line)
102
+ if m:
103
+ sections.append({"name": m.group(1), "tasks": []})
104
+ continue
105
+
106
+ if ":" not in line:
107
+ continue
108
+ label, _, spec = line.partition(":")
109
+ label = label.strip()
110
+ if not label:
111
+ continue
112
+
113
+ task_id: str | None = None
114
+ start: datetime | None = None
115
+ end: datetime | None = None
116
+ duration: timedelta | None = None
117
+ after_ids: list[str] = []
118
+
119
+ for raw_tok in spec.split(","):
120
+ tok = raw_tok.strip()
121
+ if not tok:
122
+ continue
123
+ low = tok.lower()
124
+ if low in _STATUS_TOKENS:
125
+ continue
126
+ if low.startswith("after "):
127
+ after_ids.extend(tok[len("after "):].split())
128
+ continue
129
+ dm = _DURATION_RE.match(tok)
130
+ if dm:
131
+ amount, unit = float(dm.group(1)), dm.group(2).lower()
132
+ duration = timedelta(seconds=amount * _UNIT_SECONDS[unit])
133
+ continue
134
+ dt = _parse_date(tok, fmt)
135
+ if dt is not None:
136
+ if start is None:
137
+ start = dt
138
+ else:
139
+ end = dt
140
+ continue
141
+ if task_id is None and _ID_RE.match(tok):
142
+ task_id = tok
143
+ continue
144
+ # Unrecognized token (e.g. an unmapped status keyword): ignore
145
+ # and keep scanning the remaining tokens on this line.
146
+
147
+ if start is None:
148
+ if after_ids:
149
+ resolved = [ids[i] for i in after_ids if i in ids]
150
+ start = max(resolved) if resolved else last_end
151
+ else:
152
+ start = last_end
153
+
154
+ if start is None:
155
+ # No date, no "after" dependency, no prior task to anchor to —
156
+ # this task can't be placed on the timeline. Skip it.
157
+ continue
158
+
159
+ if end is None:
160
+ end = start + duration if duration is not None else start + timedelta(days=1)
161
+ if end < start:
162
+ end = start
163
+
164
+ sections[-1]["tasks"].append({"label": label, "start": start, "end": end})
165
+ last_end = end
166
+ if task_id:
167
+ ids[task_id] = end
168
+
169
+ sections = [s for s in sections if s["tasks"]]
170
+ return {"title": title, "sections": sections}
171
+
172
+
173
+ def _draw_span(width: int, start_ratio: float, end_ratio: float) -> str:
174
+ """Draw a ``width``-wide track with a filled span from start to end ratio."""
175
+ if width <= 0:
176
+ return ""
177
+ start_col = max(0, min(int(round(start_ratio * width)), width))
178
+ end_col = max(start_col + 1, min(int(round(end_ratio * width)), width))
179
+ return "░" * start_col + "█" * (end_col - start_col) + "░" * (width - end_col)
180
+
181
+
182
+ def _fmt_range(task: dict) -> str:
183
+ start, end = task["start"], task["end"]
184
+ if start.date() == end.date():
185
+ return start.strftime("%Y-%m-%d")
186
+ return f"{start:%Y-%m-%d}\u2192{end:%Y-%m-%d}"
187
+
188
+
189
+ def render(source: str, width: int) -> list[str]:
190
+ """Render mermaid gantt syntax as section-grouped rows with time-span bars.
191
+
192
+ Diagrams with no parseable tasks degrade to the raw source text.
193
+ """
194
+ parsed = parse_gantt(source)
195
+ tasks = [t for s in parsed["sections"] for t in s["tasks"]]
196
+ if not tasks:
197
+ return source.splitlines() or [""]
198
+
199
+ min_start = min(t["start"] for t in tasks)
200
+ max_end = max(t["end"] for t in tasks)
201
+ span = (max_end - min_start).total_seconds() or 1.0
202
+
203
+ label_w = max(visual_len(t["label"]) for t in tasks)
204
+ date_w = max(visual_len(_fmt_range(t)) for t in tasks)
205
+ bar_w = max(width - label_w - date_w - 4, 5)
206
+
207
+ lines: list[str] = []
208
+ if parsed["title"]:
209
+ lines.append(visual_ljust(parsed["title"], width))
210
+
211
+ for section in parsed["sections"]:
212
+ if section["name"]:
213
+ lines.append(visual_ljust(section["name"], width))
214
+ for t in section["tasks"]:
215
+ start_ratio = (t["start"] - min_start).total_seconds() / span
216
+ end_ratio = (t["end"] - min_start).total_seconds() / span
217
+ bar = _draw_span(bar_w, start_ratio, end_ratio)
218
+ label = visual_ljust(t["label"], label_w)
219
+ date_str = _fmt_range(t).rjust(date_w)
220
+ lines.append(visual_ljust(f"{label} {bar} {date_str}", width))
221
+
222
+ return lines
@@ -0,0 +1,78 @@
1
+ """Native renderer for mermaid ``pie`` diagrams.
2
+
3
+ Mermaid pie grammar (subset)::
4
+
5
+ pie [showData]
6
+ title An Optional Title
7
+ "Label A" : 40
8
+ "Label B" : 60
9
+
10
+ A text pie chart is worse than a labeled bar chart at the same information
11
+ density, so pie diagrams are re-expressed as a horizontal bar chart (see the
12
+ ratified plan in ``mermaid-inline-rendering.md``), reusing ``charts.py``'s
13
+ ``render_bar`` so the visual style matches the rest of termrender's charts.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+
20
+ from termrender.blocks import Block, BlockType
21
+ from termrender.renderers.charts import render_bar
22
+
23
+ _TITLE_RE = re.compile(r"^\s*title\s+(.*\S)\s*$", re.IGNORECASE)
24
+ _DATA_RE = re.compile(r'^\s*"([^"]*)"\s*:\s*(-?\d+(?:\.\d+)?)\s*$')
25
+ _HEADER_RE = re.compile(r"^\s*pie\b", re.IGNORECASE)
26
+
27
+
28
+ def parse_pie(source: str) -> tuple[str | None, list[dict]]:
29
+ """Parse mermaid pie syntax into ``(title, items)``.
30
+
31
+ ``items`` is a list of ``{"label": str, "value": float}`` dicts, in
32
+ source order. Lines that match neither the header, ``title``, nor a
33
+ ``"label" : value`` data line are silently skipped — pragmatic parsing
34
+ per the "never crash" contract, not a full grammar.
35
+ """
36
+ title: str | None = None
37
+ items: list[dict] = []
38
+ for line in source.splitlines():
39
+ if not line.strip():
40
+ continue
41
+ if _HEADER_RE.match(line):
42
+ continue
43
+ m = _TITLE_RE.match(line)
44
+ if m:
45
+ title = m.group(1)
46
+ continue
47
+ m = _DATA_RE.match(line)
48
+ if m:
49
+ items.append({"label": m.group(1), "value": float(m.group(2))})
50
+ return title, items
51
+
52
+
53
+ def render(source: str, width: int) -> list[str]:
54
+ """Render mermaid pie syntax as a labeled horizontal bar chart.
55
+
56
+ Each bar's value is annotated with its percentage share of the total.
57
+ Diagrams with no parseable data lines degrade to the raw source text.
58
+ """
59
+ title, items = parse_pie(source)
60
+ if not items:
61
+ return source.splitlines() or [""]
62
+
63
+ total = sum(it["value"] for it in items) or 1.0
64
+ bar_items = [
65
+ {
66
+ "label": it["label"],
67
+ "value": it["value"],
68
+ "unit": f" ({it['value'] / total * 100:.1f}%)",
69
+ }
70
+ for it in items
71
+ ]
72
+
73
+ block = Block(
74
+ type=BlockType.BAR,
75
+ attrs={"items": bar_items, "title": title, "color": "cyan"},
76
+ width=width,
77
+ )
78
+ return render_bar(block, color=False)
@@ -0,0 +1,89 @@
1
+ import unittest
2
+ from unittest import mock
3
+
4
+ from termrender.renderers import mermaid
5
+
6
+
7
+ class TestMermaidDispatch(unittest.TestCase):
8
+ """Routing tests for mermaid.py's first-line type dispatcher.
9
+
10
+ These mock the per-type renderers/binary call so they verify *routing*
11
+ only, independent of the actual rendering logic (covered by
12
+ test_mermaid_pie.py, test_mermaid_gantt.py, and test_mermaid_compat.py).
13
+ """
14
+
15
+ def test_pie_routes_to_native_pie_renderer(self):
16
+ with mock.patch(
17
+ "termrender.renderers.mermaid.mermaid_pie.render", return_value=["PIE"]
18
+ ) as pie_mock, mock.patch(
19
+ "termrender.renderers.mermaid._render_via_binary"
20
+ ) as binary_mock:
21
+ lines = mermaid.render_mermaid_lines('pie\n "A" : 1', 40)
22
+ self.assertEqual(lines, ["PIE"])
23
+ pie_mock.assert_called_once()
24
+ binary_mock.assert_not_called()
25
+
26
+ def test_gantt_routes_to_native_gantt_renderer(self):
27
+ with mock.patch(
28
+ "termrender.renderers.mermaid.mermaid_gantt.render", return_value=["GANTT"]
29
+ ) as gantt_mock, mock.patch(
30
+ "termrender.renderers.mermaid._render_via_binary"
31
+ ) as binary_mock:
32
+ lines = mermaid.render_mermaid_lines("gantt\n dateFormat YYYY-MM-DD", 40)
33
+ self.assertEqual(lines, ["GANTT"])
34
+ gantt_mock.assert_called_once()
35
+ binary_mock.assert_not_called()
36
+
37
+ def test_graph_routes_to_binary_fallback(self):
38
+ with mock.patch(
39
+ "termrender.renderers.mermaid._render_via_binary", return_value="RENDERED"
40
+ ) as binary_mock:
41
+ lines = mermaid.render_mermaid_lines("graph LR\nA-->B", 40)
42
+ self.assertEqual(lines, ["RENDERED"])
43
+ binary_mock.assert_called_once()
44
+
45
+ def test_flowchart_routes_to_binary_fallback(self):
46
+ with mock.patch(
47
+ "termrender.renderers.mermaid._render_via_binary", return_value="RENDERED"
48
+ ) as binary_mock:
49
+ mermaid.render_mermaid_lines("flowchart TD\nA-->B", 40)
50
+ binary_mock.assert_called_once()
51
+
52
+ def test_sequence_routes_to_binary_fallback(self):
53
+ with mock.patch(
54
+ "termrender.renderers.mermaid._render_via_binary", return_value="RENDERED"
55
+ ) as binary_mock:
56
+ mermaid.render_mermaid_lines("sequenceDiagram\n A->>B: hi", 40)
57
+ binary_mock.assert_called_once()
58
+
59
+ def test_unsupported_type_routes_to_binary_fallback(self):
60
+ for source in (
61
+ "classDiagram\n Animal <|-- Duck",
62
+ "stateDiagram-v2\n [*] --> Idle",
63
+ "erDiagram\n A ||--o{ B : has",
64
+ "journey\n title My journey",
65
+ "mindmap\n root",
66
+ ):
67
+ with mock.patch(
68
+ "termrender.renderers.mermaid._render_via_binary", return_value="X"
69
+ ) as binary_mock:
70
+ mermaid.render_mermaid_lines(source, 40)
71
+ binary_mock.assert_called_once()
72
+
73
+ def test_type_sniff_is_case_insensitive(self):
74
+ with mock.patch(
75
+ "termrender.renderers.mermaid.mermaid_pie.render", return_value=["PIE"]
76
+ ) as pie_mock:
77
+ mermaid.render_mermaid_lines('PIE\n "A" : 1', 40)
78
+ pie_mock.assert_called_once()
79
+
80
+ def test_type_sniff_skips_leading_blank_lines(self):
81
+ with mock.patch(
82
+ "termrender.renderers.mermaid.mermaid_gantt.render", return_value=["G"]
83
+ ) as gantt_mock:
84
+ mermaid.render_mermaid_lines("\n\n gantt\n dateFormat YYYY-MM-DD", 40)
85
+ gantt_mock.assert_called_once()
86
+
87
+
88
+ if __name__ == "__main__":
89
+ unittest.main()
@@ -0,0 +1,154 @@
1
+ import unittest
2
+ from datetime import datetime
3
+
4
+ from termrender import render
5
+ from termrender.renderers.mermaid_gantt import parse_gantt, render as render_gantt
6
+ from termrender.style import visual_len
7
+
8
+
9
+ class TestParseGantt(unittest.TestCase):
10
+
11
+ def test_parses_title_and_dateformat(self):
12
+ src = "gantt\n title Release Plan\n dateFormat YYYY-MM-DD\n Task1 :2024-01-01, 5d"
13
+ parsed = parse_gantt(src)
14
+ self.assertEqual(parsed["title"], "Release Plan")
15
+
16
+ def test_start_date_plus_duration(self):
17
+ src = "gantt\n dateFormat YYYY-MM-DD\n Task1 :2024-01-01, 5d"
18
+ parsed = parse_gantt(src)
19
+ task = parsed["sections"][0]["tasks"][0]
20
+ self.assertEqual(task["start"], datetime(2024, 1, 1))
21
+ self.assertEqual(task["end"], datetime(2024, 1, 6))
22
+
23
+ def test_two_explicit_dates(self):
24
+ src = "gantt\n dateFormat YYYY-MM-DD\n Task1 :2024-01-01, 2024-01-10"
25
+ parsed = parse_gantt(src)
26
+ task = parsed["sections"][0]["tasks"][0]
27
+ self.assertEqual(task["start"], datetime(2024, 1, 1))
28
+ self.assertEqual(task["end"], datetime(2024, 1, 10))
29
+
30
+ def test_after_dependency_chains_from_prior_task(self):
31
+ src = (
32
+ "gantt\n"
33
+ " dateFormat YYYY-MM-DD\n"
34
+ " Task1 :a1, 2024-01-01, 5d\n"
35
+ " Task2 :after a1, 3d\n"
36
+ )
37
+ parsed = parse_gantt(src)
38
+ tasks = parsed["sections"][0]["tasks"]
39
+ self.assertEqual(tasks[1]["start"], datetime(2024, 1, 6))
40
+ self.assertEqual(tasks[1]["end"], datetime(2024, 1, 9))
41
+
42
+ def test_sections_grouped(self):
43
+ src = (
44
+ "gantt\n"
45
+ " dateFormat YYYY-MM-DD\n"
46
+ " section Design\n"
47
+ " Spec :2024-01-01, 5d\n"
48
+ " section Build\n"
49
+ " Implement :2024-01-10, 5d\n"
50
+ )
51
+ parsed = parse_gantt(src)
52
+ names = [s["name"] for s in parsed["sections"]]
53
+ self.assertEqual(names, ["Design", "Build"])
54
+
55
+ def test_status_tokens_ignored(self):
56
+ src = "gantt\n dateFormat YYYY-MM-DD\n Task1 :done, crit, 2024-01-01, 5d"
57
+ parsed = parse_gantt(src)
58
+ task = parsed["sections"][0]["tasks"][0]
59
+ self.assertEqual(task["start"], datetime(2024, 1, 1))
60
+
61
+ def test_unresolvable_task_skipped_not_crashed(self):
62
+ src = "gantt\n dateFormat YYYY-MM-DD\n NoAnchor :after nonexistent, 5d"
63
+ parsed = parse_gantt(src)
64
+ self.assertEqual(parsed["sections"], [])
65
+
66
+ def test_excludes_and_axisformat_ignored(self):
67
+ src = (
68
+ "gantt\n"
69
+ " dateFormat YYYY-MM-DD\n"
70
+ " excludes weekends\n"
71
+ " axisFormat %m/%d\n"
72
+ " Task1 :2024-01-01, 5d\n"
73
+ )
74
+ parsed = parse_gantt(src)
75
+ self.assertEqual(len(parsed["sections"][0]["tasks"]), 1)
76
+
77
+ def test_garbage_line_does_not_crash(self):
78
+ src = "gantt\n dateFormat YYYY-MM-DD\n this has no colon\n Task1 :2024-01-01, 5d"
79
+ parsed = parse_gantt(src)
80
+ self.assertEqual(len(parsed["sections"][0]["tasks"]), 1)
81
+
82
+ def test_custom_dateformat(self):
83
+ src = "gantt\n dateFormat DD/MM/YYYY\n Task1 :01/02/2024, 5d"
84
+ parsed = parse_gantt(src)
85
+ task = parsed["sections"][0]["tasks"][0]
86
+ self.assertEqual(task["start"], datetime(2024, 2, 1))
87
+
88
+
89
+ class TestRenderGantt(unittest.TestCase):
90
+
91
+ def test_renders_bars_for_each_task(self):
92
+ src = (
93
+ "gantt\n"
94
+ " dateFormat YYYY-MM-DD\n"
95
+ " Task1 :2024-01-01, 5d\n"
96
+ " Task2 :2024-01-06, 5d\n"
97
+ )
98
+ lines = render_gantt(src, width=70)
99
+ joined = "\n".join(lines)
100
+ self.assertIn("Task1", joined)
101
+ self.assertIn("Task2", joined)
102
+ self.assertIn("█", joined)
103
+ self.assertIn("░", joined)
104
+
105
+ def test_section_headers_appear(self):
106
+ src = (
107
+ "gantt\n"
108
+ " dateFormat YYYY-MM-DD\n"
109
+ " section Design\n"
110
+ " Spec :2024-01-01, 5d\n"
111
+ )
112
+ lines = render_gantt(src, width=70)
113
+ self.assertTrue(any("Design" in ln for ln in lines))
114
+
115
+ def test_no_tasks_degrades_to_source(self):
116
+ src = "gantt\n title nothing here\n"
117
+ lines = render_gantt(src, width=40)
118
+ self.assertEqual(lines, src.splitlines())
119
+
120
+ def test_no_ansi_when_monochrome(self):
121
+ src = "gantt\n dateFormat YYYY-MM-DD\n Task1 :2024-01-01, 5d"
122
+ lines = render_gantt(src, width=40)
123
+ self.assertNotIn("\x1b[", "\n".join(lines))
124
+
125
+ def test_full_pipeline_renders_gantt(self):
126
+ src = (
127
+ "```mermaid\n"
128
+ "gantt\n"
129
+ " dateFormat YYYY-MM-DD\n"
130
+ " section Build\n"
131
+ " Implement :2024-01-01, 10d\n"
132
+ "```"
133
+ )
134
+ out = render(src, width=60, color=False)
135
+ self.assertIn("Build", out)
136
+ self.assertIn("Implement", out)
137
+ self.assertIn("█", out)
138
+
139
+ def test_full_pipeline_widths_match(self):
140
+ src = (
141
+ "```mermaid\n"
142
+ "gantt\n"
143
+ " dateFormat YYYY-MM-DD\n"
144
+ " Task1 :2024-01-01, 5d\n"
145
+ "```"
146
+ )
147
+ out = render(src, width=55, color=False)
148
+ for ln in out.split("\n"):
149
+ if ln:
150
+ self.assertEqual(visual_len(ln), 55)
151
+
152
+
153
+ if __name__ == "__main__":
154
+ unittest.main()
@@ -0,0 +1,92 @@
1
+ import unittest
2
+
3
+ from termrender import render
4
+ from termrender.renderers.mermaid_pie import parse_pie, render as render_pie
5
+ from termrender.style import visual_len
6
+
7
+
8
+ class TestParsePie(unittest.TestCase):
9
+
10
+ def test_parses_labels_and_values(self):
11
+ src = 'pie\n "Bugs" : 40\n "Features" : 60'
12
+ title, items = parse_pie(src)
13
+ self.assertIsNone(title)
14
+ self.assertEqual(items, [
15
+ {"label": "Bugs", "value": 40.0},
16
+ {"label": "Features", "value": 60.0},
17
+ ])
18
+
19
+ def test_parses_title(self):
20
+ src = "pie\n title Key Findings\n"
21
+ title, items = parse_pie(src)
22
+ self.assertEqual(title, "Key Findings")
23
+
24
+ def test_show_data_flag_ignored(self):
25
+ src = 'pie showData\n "A" : 1\n "B" : 2'
26
+ title, items = parse_pie(src)
27
+ self.assertEqual(len(items), 2)
28
+
29
+ def test_float_values(self):
30
+ src = 'pie\n "A" : 12.5\n "B" : 87.5'
31
+ _, items = parse_pie(src)
32
+ self.assertEqual(items[0]["value"], 12.5)
33
+
34
+ def test_unparseable_lines_skipped(self):
35
+ src = 'pie\n this is not a data line\n "A" : 5'
36
+ _, items = parse_pie(src)
37
+ self.assertEqual(items, [{"label": "A", "value": 5.0}])
38
+
39
+ def test_empty_pie_yields_no_items(self):
40
+ _, items = parse_pie("pie\n")
41
+ self.assertEqual(items, [])
42
+
43
+
44
+ class TestRenderPie(unittest.TestCase):
45
+
46
+ def test_renders_bars_with_percentages(self):
47
+ src = 'pie\n "Bugs" : 40\n "Features" : 60'
48
+ lines = render_pie(src, width=60)
49
+ joined = "\n".join(lines)
50
+ self.assertIn("Bugs", joined)
51
+ self.assertIn("Features", joined)
52
+ self.assertIn("40.0%", joined)
53
+ self.assertIn("60.0%", joined)
54
+ self.assertIn("█", joined)
55
+
56
+ def test_largest_value_gets_full_bar(self):
57
+ src = 'pie\n "A" : 100\n "B" : 50'
58
+ lines = render_pie(src, width=60)
59
+ self.assertIn("█" * 10, lines[0]) # line for A should be mostly full
60
+
61
+ def test_title_rendered_above_bars(self):
62
+ src = 'pie\n title My Title\n "A" : 1'
63
+ lines = render_pie(src, width=40)
64
+ self.assertIn("My Title", lines[0])
65
+
66
+ def test_no_data_degrades_to_source(self):
67
+ src = "pie\n"
68
+ lines = render_pie(src, width=40)
69
+ self.assertEqual(lines, ["pie"])
70
+
71
+ def test_no_ansi_when_monochrome(self):
72
+ src = 'pie\n "A" : 1\n "B" : 2'
73
+ lines = render_pie(src, width=40)
74
+ self.assertNotIn("\x1b[", "\n".join(lines))
75
+
76
+ def test_full_pipeline_renders_pie(self):
77
+ src = '```mermaid\npie\n title Findings\n "Bugs" : 40\n "Docs" : 60\n```'
78
+ out = render(src, width=60, color=False)
79
+ self.assertIn("Findings", out)
80
+ self.assertIn("Bugs", out)
81
+ self.assertIn("█", out)
82
+
83
+ def test_full_pipeline_widths_match(self):
84
+ src = '```mermaid\npie\n "A" : 1\n "B" : 2\n```'
85
+ out = render(src, width=50, color=False)
86
+ for ln in out.split("\n"):
87
+ if ln:
88
+ self.assertEqual(visual_len(ln), 50)
89
+
90
+
91
+ if __name__ == "__main__":
92
+ unittest.main()
@@ -1,58 +0,0 @@
1
- # Renderers
2
-
3
- ## Renderer signature convention
4
- Two signatures exist — not enforced by typing, but the dispatcher expects them:
5
- - **Leaf renderers** (`divider.py`, `tree.py`): `render(block, color) -> list[str]`
6
- - **Container renderers** (`panel.py`, `quote.py`, `code.py`, `columns.py`): `render(block, color, render_child) -> list[str]`
7
-
8
- `render_child` is the engine's dispatch function; call it to recurse into child blocks rather than calling sibling renderers directly.
9
-
10
- ## `render_box` — width is total, not content
11
- `borders.py:render_box(content_lines, width, ...)` — `width` is the **full** box width including the two `│` border columns. Content width is `width - 2*border_v - 2` (border + 1-space padding each side). Passing inner/content width here produces double-shrinkage.
12
-
13
- Used by `panel.py` and `code.py` (with `dim=True`).
14
-
15
- If `content_lines` is empty, `render_box` inserts one blank interior line — output is always ≥ 3 lines (top border + 1 blank + bottom border). Callers computing expected height from content line count must add 3, not 2.
16
-
17
- When `title` is provided, it is **left-anchored**, not centered: `┌─ TITLE ───┐`. The format is `"─ " + title + " "` flush-left, then fill dashes to `┐`. There is no API for a right-anchored or centered title.
18
-
19
- ## EAW-dynamic guide characters in `tree.py`
20
- `_guide_chars()` is called at render time (not module init) because `visual_len("├── ")` differs under East Asian Width ambiguous=2 mode. Under that setting `"│"` is width 2 while `"─"` is width 1, so the four guide prefixes have inconsistent visual widths. `_guide_chars()` normalizes all four to the visual width of `"├── "` at runtime, picking up whatever EAW setting is active. The same EAW issue drives the dynamic `border_v`/`dash_v` math in `render_box`.
21
-
22
- ## `tree.py` — source goes in `attrs["source"]`, indent is auto-detected
23
- Tree content comes from `block.attrs["source"]` (a raw multi-line string), not `block.text` or `block.children`. `_detect_indent` sniffs the first indented line: returns 2 for ≤2 spaces, the raw value for 3–4 spaces, and clamps to 4 for anything larger. A tree where the first sniffed indent is 5 or 6 spaces gets clamped to 4 — subsequent lines compute `depth = spaces // 4`, skipping depth levels silently (e.g. 6-space children land at depth 1, but 12-space grandchildren land at depth 3, not 2). `[x]` and `[!]` status markers in labels are replaced with styled Unicode symbols; `**bold**` and `*italic*` inline markdown is also parsed.
24
-
25
- Depth-0 items (no leading spaces) render with no guide prefix at all — a flat source is a plain list, no tree lines.
26
-
27
- `block.attrs["color"]` sets the guide-line color (the `├──` connectors); it is distinct from the `color: bool` render parameter and only styles guides, not labels.
28
-
29
- ## `divider.py` — always dim, no color path
30
- `render()` hardcodes `dim=True`; there is no way to pass a custom color through the leaf signature. A colored divider requires a different renderer or direct `render_box` usage.
31
-
32
- Optional centered label comes from `block.attrs["label"]` (not `block.text`). `visual_center(inner, w, "─")` handles the fill — the label is padded as `" {label} "` before centering, so the label always has at least one space of breathing room from the surrounding dashes.
33
-
34
- ## `quote.py` — dual attribution keys; EAW-sensitive bar width
35
- Attribution line is rendered for `block.attrs["author"]` **or** `block.attrs["by"]`; `"author"` takes precedence. Missing both suppresses the attribution line entirely.
36
-
37
- `bar_width = visual_len("│") + 1` — under EAW ambiguous=2, `"│"` is width 2 so `bar_width` becomes 3, shrinking `inner_w` by one extra column relative to default EAW. The bar itself is correct visually, but layout that pre-computes quote inner width assuming `bar_width == 2` will be off by 1 under EAW=2.
38
-
39
- ## `panel.py` — callouts delegate through a proxy Block
40
- `render_callout` patches `title`, `color`, and `type=BlockType.PANEL` into a new `Block` instance and calls `render()` on it. The original block is not mutated. The callout type string (`"info"`, `"warning"`, `"error"`, `"success"`) is only used to look up `_CALLOUT_MAP`; an unknown type falls back to blue `ℹ`.
41
-
42
- ## `mermaid.py` — leaf signature (not container), encoding fix, no returncode check, no truncation
43
-
44
- **Leaf signature, not container**: `render(block, color)` — no `render_child`. The parent `CLAUDE.md` incorrectly lists mermaid as a Container renderer.
45
-
46
- **`fix_mermaid_encoding`**: `mermaid-ascii` misreads UTF-8 input as Latin-1 and re-encodes, corrupting multi-byte characters (e.g. `→` becomes `â\x86\x92`). The fix is `text.encode("latin-1").decode("utf-8")`; on failure it silently returns the corrupted string — callers cannot distinguish fixed vs. corrupt output.
47
-
48
- **`except Exception` swallows everything**: timeout, missing tool, `MemoryError` — all fall back to `rendered = source` (the raw mermaid source text) silently. Returncode is also never checked; a non-zero exit still reads `result.stdout`, producing a blank-line block if stdout was empty. If both `_rendered` and `source` are absent, empty string is sent to `mermaid-ascii`.
49
-
50
- **`visual_ljust` pads but never truncates**: every output line is padded to `block.width`. Lines wider than `block.width` overflow without clipping. If `mermaid-ascii` emits a diagram wider than the allocated block, it silently exceeds the layout boundary.
51
-
52
- **Trailing blank line**: `rendered.split("\n")` on output ending with `\n` (typical subprocess output) produces a trailing empty string, which becomes a `block.width`-wide blank line appended to every mermaid block.
53
-
54
- Layout pre-renders into `block.attrs["_rendered"]` (see `src/termrender/CLAUDE.md`); this renderer re-runs the subprocess only when that key is absent.
55
-
56
- **Flowchart node shapes are normalized to rectangles**: `preprocess_mermaid_for_ascii` rewrites every non-rectangle flowchart node shape (`{rhombus}`, `[(cylinder)]`, `((circle))`, `([stadium])`, `{{hexagon}}`, `[[subroutine]]`, `[/parallelogram/]`) to the `id[label]` form via `_FLOWCHART_SHAPE_SUBS`, because mermaid-ascii's `parseNode` only recognizes `[text]` rectangles — other shapes otherwise leak raw delimiters or the bare node id into the box. This is a permanent workaround: node shapes are unsupported on current upstream master (pin `6fffb8e`) and the 6 newer master commits are arrow-parsing only, so don't expect a pin bump to remove the need. The backend draws a rectangle regardless, so only the label text matters.
57
-
58
- **Binary resolution & no width flag**: the executable comes from `_mermaid_bin.mermaid_ascii_bin()` — the vendored `_bin/mermaid-ascii-<os>-<arch>` (pinned upstream master `6fffb8e`, built via `scripts/build-mermaid-ascii.sh`) if present for the platform, else `mermaid-ascii` on PATH (the PyPI wheel, capped at 1.2.0). **`mermaid-ascii` has no width-control flag** (never has — `--maxWidth`/`-w` do not exist); the call passes only `-f - -y 1`, so `block.width` does not constrain the diagram and wide output overflows (compounding the no-truncation gotcha above). A labeled back-edge (`X -->|lbl| Y` in `graph LR`) panics the binary on **both** 1.2.0 and master; the non-zero exit is caught and the block degrades to raw source.
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes