termrender 4.1.0__tar.gz → 4.2.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 (59) hide show
  1. {termrender-4.1.0 → termrender-4.2.0}/CHANGELOG.md +57 -0
  2. {termrender-4.1.0 → termrender-4.2.0}/PKG-INFO +1 -1
  3. {termrender-4.1.0 → termrender-4.2.0}/src/termrender/renderers/mermaid_pie.py +8 -2
  4. termrender-4.2.0/src/termrender/renderers/mermaid_sequence.py +597 -0
  5. {termrender-4.1.0 → termrender-4.2.0}/tests/test_mermaid_pie.py +10 -0
  6. termrender-4.2.0/tests/test_mermaid_sequence.py +463 -0
  7. {termrender-4.1.0 → termrender-4.2.0}/.github/workflows/publish.yml +0 -0
  8. {termrender-4.1.0 → termrender-4.2.0}/.gitignore +0 -0
  9. {termrender-4.1.0 → termrender-4.2.0}/CLAUDE.md +0 -0
  10. {termrender-4.1.0 → termrender-4.2.0}/LICENSE +0 -0
  11. {termrender-4.1.0 → termrender-4.2.0}/README.md +0 -0
  12. {termrender-4.1.0 → termrender-4.2.0}/design.json +0 -0
  13. {termrender-4.1.0 → termrender-4.2.0}/pyproject.toml +0 -0
  14. {termrender-4.1.0 → termrender-4.2.0}/requirements.json +0 -0
  15. {termrender-4.1.0 → termrender-4.2.0}/scripts/build-mermaid-ascii.sh +0 -0
  16. {termrender-4.1.0 → termrender-4.2.0}/src/termrender/CLAUDE.md +0 -0
  17. {termrender-4.1.0 → termrender-4.2.0}/src/termrender/__init__.py +0 -0
  18. {termrender-4.1.0 → termrender-4.2.0}/src/termrender/__main__.py +0 -0
  19. {termrender-4.1.0 → termrender-4.2.0}/src/termrender/_bin/mermaid-ascii-darwin-arm64 +0 -0
  20. {termrender-4.1.0 → termrender-4.2.0}/src/termrender/_mermaid_bin.py +0 -0
  21. {termrender-4.1.0 → termrender-4.2.0}/src/termrender/blocks.py +0 -0
  22. {termrender-4.1.0 → termrender-4.2.0}/src/termrender/emit.py +0 -0
  23. {termrender-4.1.0 → termrender-4.2.0}/src/termrender/layout.py +0 -0
  24. {termrender-4.1.0 → termrender-4.2.0}/src/termrender/parser.py +0 -0
  25. {termrender-4.1.0 → termrender-4.2.0}/src/termrender/py.typed +0 -0
  26. {termrender-4.1.0 → termrender-4.2.0}/src/termrender/renderers/CLAUDE.md +0 -0
  27. {termrender-4.1.0 → termrender-4.2.0}/src/termrender/renderers/__init__.py +0 -0
  28. {termrender-4.1.0 → termrender-4.2.0}/src/termrender/renderers/borders.py +0 -0
  29. {termrender-4.1.0 → termrender-4.2.0}/src/termrender/renderers/charts.py +0 -0
  30. {termrender-4.1.0 → termrender-4.2.0}/src/termrender/renderers/code.py +0 -0
  31. {termrender-4.1.0 → termrender-4.2.0}/src/termrender/renderers/columns.py +0 -0
  32. {termrender-4.1.0 → termrender-4.2.0}/src/termrender/renderers/diff.py +0 -0
  33. {termrender-4.1.0 → termrender-4.2.0}/src/termrender/renderers/divider.py +0 -0
  34. {termrender-4.1.0 → termrender-4.2.0}/src/termrender/renderers/mermaid.py +0 -0
  35. {termrender-4.1.0 → termrender-4.2.0}/src/termrender/renderers/mermaid_gantt.py +0 -0
  36. {termrender-4.1.0 → termrender-4.2.0}/src/termrender/renderers/panel.py +0 -0
  37. {termrender-4.1.0 → termrender-4.2.0}/src/termrender/renderers/quote.py +0 -0
  38. {termrender-4.1.0 → termrender-4.2.0}/src/termrender/renderers/stat.py +0 -0
  39. {termrender-4.1.0 → termrender-4.2.0}/src/termrender/renderers/table.py +0 -0
  40. {termrender-4.1.0 → termrender-4.2.0}/src/termrender/renderers/text.py +0 -0
  41. {termrender-4.1.0 → termrender-4.2.0}/src/termrender/renderers/timeline.py +0 -0
  42. {termrender-4.1.0 → termrender-4.2.0}/src/termrender/renderers/tree.py +0 -0
  43. {termrender-4.1.0 → termrender-4.2.0}/src/termrender/style.py +0 -0
  44. {termrender-4.1.0 → termrender-4.2.0}/tests/__init__.py +0 -0
  45. {termrender-4.1.0 → termrender-4.2.0}/tests/test_charts.py +0 -0
  46. {termrender-4.1.0 → termrender-4.2.0}/tests/test_cli_contract.py +0 -0
  47. {termrender-4.1.0 → termrender-4.2.0}/tests/test_column_alignment.py +0 -0
  48. {termrender-4.1.0 → termrender-4.2.0}/tests/test_diff.py +0 -0
  49. {termrender-4.1.0 → termrender-4.2.0}/tests/test_inline_badge.py +0 -0
  50. {termrender-4.1.0 → termrender-4.2.0}/tests/test_linebreak.py +0 -0
  51. {termrender-4.1.0 → termrender-4.2.0}/tests/test_mermaid_compat.py +0 -0
  52. {termrender-4.1.0 → termrender-4.2.0}/tests/test_mermaid_dispatch.py +0 -0
  53. {termrender-4.1.0 → termrender-4.2.0}/tests/test_mermaid_gantt.py +0 -0
  54. {termrender-4.1.0 → termrender-4.2.0}/tests/test_myst_gaps.py +0 -0
  55. {termrender-4.1.0 → termrender-4.2.0}/tests/test_stat.py +0 -0
  56. {termrender-4.1.0 → termrender-4.2.0}/tests/test_tasklist.py +0 -0
  57. {termrender-4.1.0 → termrender-4.2.0}/tests/test_timeline.py +0 -0
  58. {termrender-4.1.0 → termrender-4.2.0}/tests/test_variable_colons.py +0 -0
  59. {termrender-4.1.0 → termrender-4.2.0}/uv.lock +0 -0
@@ -1,6 +1,63 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v4.2.0 (2026-07-06)
5
+
6
+ ### Bug Fixes
7
+
8
+ - **mermaid-sequence**: Reject dash-adjacent punctuation as part of an id
9
+ ([`612f36d`](https://github.com/crouton-labs/termrender/commit/612f36d14d68e6c51f4ac6d16f0ae837e44ddd01))
10
+
11
+ Reviewer found: near-miss arrow syntax with no sequence-diagram equivalent (e.g. flowchart-only
12
+ `A-.->B`, or garbage like `A==>>B`) was silently absorbed into the source id ("A-.") instead of
13
+ degrading to a plain line, because the arrow regex's identifier token was \S+/\S+? (anything
14
+ non-space) and would backtrack through dash/dot punctuation until some later '>' happened to
15
+ complete a valid-looking arrow.
16
+
17
+ Fix: identifiers now exclude '-' (plus ':', '+', whitespace) — dash is reserved for the arrow
18
+ itself, so an id is the maximal run up to the first dash, eliminating the backtracking ambiguity
19
+ entirely. Also documents the pre-existing (accepted, mirrors wrap_text()'s CJK bug elsewhere in
20
+ termrender) character-index vs visual-width limitation in the module docstring, per independent
21
+ review (non-blocking, not fixed here — same class of fix as the documented wrap_text() constraint,
22
+ out of proportion for a renderer not yet wired into the dispatcher).
23
+
24
+ Adds 2 regression tests for the repro cases. 191 tests pass (full suite, up from 189).
25
+
26
+ ### Features
27
+
28
+ - Add native ASCII sequence-diagram renderer for mermaid
29
+ ([`ad63977`](https://github.com/crouton-labs/termrender/commit/ad639774edcbcd744ba7f2183c4f5d8c83630af0))
30
+
31
+ Standalone module (src/termrender/renderers/mermaid_sequence.py) exposing render_sequence(source,
32
+ width) -> list[str]. Not wired into the mermaid.py dispatcher yet (a later integration phase owns
33
+ that).
34
+
35
+ Layout: participant boxes across the top/bottom, vertical lifelines, messages as horizontal arrows
36
+ in declaration order, self-messages as a loop-back. Column spacing is a 1D constraint pass driven
37
+ by participant label widths and the messages crossing each gap (no 2D layout search needed since
38
+ message order already fixes the vertical axis).
39
+
40
+ Grammar: participant/actor with 'as' aliases plus implicit participants from first use; all 8 arrow
41
+ forms (->, -->, ->>, -->>, -x, --x, -), --)) with distinct line style (solid/dashed) and arrowhead
42
+ glyph per marker; Note over/left of/right of; autonumber (start/step); loop/alt/opt/par/
43
+ critical/break/rect/box render as labeled separator bands (else/and/ option as mid-band
44
+ separators); activate/deactivate (including the +/- arrow shorthand) and <br/> are tolerated and
45
+ flattened; any other unrecognized or malformed line (stray else/end, unknown directives) degrades
46
+ to a plain text line rather than raising.
47
+
48
+ Tests: tests/test_mermaid_sequence.py, 40 cases covering golden output, all arrow variants, aliases,
49
+ implicit participant ordering, notes, autonumber, block nesting, degradation paths, and the
50
+ no-trailing-whitespace contract. Full suite (189 tests) still green.
51
+
52
+
53
+ ## v4.1.1 (2026-07-06)
54
+
55
+ ### Bug Fixes
56
+
57
+ - **mermaid**: Honor inline title on pie header line
58
+ ([`37b4c37`](https://github.com/crouton-labs/termrender/commit/37b4c37581046d7a9b327f0e06f3ea5a5ca921d3))
59
+
60
+
4
61
  ## v4.1.0 (2026-07-06)
5
62
 
6
63
  ### Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: termrender
3
- Version: 4.1.0
3
+ Version: 4.2.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
@@ -22,7 +22,7 @@ from termrender.renderers.charts import render_bar
22
22
 
23
23
  _TITLE_RE = re.compile(r"^\s*title\s+(.*\S)\s*$", re.IGNORECASE)
24
24
  _DATA_RE = re.compile(r'^\s*"([^"]*)"\s*:\s*(-?\d+(?:\.\d+)?)\s*$')
25
- _HEADER_RE = re.compile(r"^\s*pie\b", re.IGNORECASE)
25
+ _HEADER_RE = re.compile(r"^\s*pie\b(?:\s+showData\b)?\s*(.*)$", re.IGNORECASE)
26
26
 
27
27
 
28
28
  def parse_pie(source: str) -> tuple[str | None, list[dict]]:
@@ -38,7 +38,13 @@ def parse_pie(source: str) -> tuple[str | None, list[dict]]:
38
38
  for line in source.splitlines():
39
39
  if not line.strip():
40
40
  continue
41
- if _HEADER_RE.match(line):
41
+ m = _HEADER_RE.match(line)
42
+ if m:
43
+ # Mermaid allows the title inline on the header line:
44
+ # ``pie title X`` / ``pie showData title X``.
45
+ tm = _TITLE_RE.match(m.group(1))
46
+ if tm:
47
+ title = tm.group(1)
42
48
  continue
43
49
  m = _TITLE_RE.match(line)
44
50
  if m:
@@ -0,0 +1,597 @@
1
+ """Native ASCII renderer for mermaid ``sequenceDiagram`` sources.
2
+
3
+ Standalone module: exposes a single pure function, ``render_sequence``.
4
+ Not wired into the ``mermaid.py`` dispatcher yet (a later integration
5
+ phase owns that); this module has no dependency on ``Block`` or the
6
+ rest of the renderer pipeline, and nothing here is imported elsewhere.
7
+
8
+ Layout model
9
+ ------------
10
+ Participants become columns (a lifeline ``│`` per column, boxed labels
11
+ at top and bottom). Messages render in declaration order, top to bottom,
12
+ as horizontal arrows between two columns; a message from/to the same
13
+ column becomes a small loop drawn to the right of its lifeline. Column
14
+ spacing is computed once, bottom-up from participant label widths and
15
+ the messages that cross each gap (a message spanning two adjacent
16
+ columns needs a wide-enough gap; a message spanning several columns
17
+ distributes the requirement across the gaps it crosses) — this is a 1D
18
+ constraint pass, not a 2D layout search: message order already fixes
19
+ the vertical axis, and declaration order fixes the column axis.
20
+
21
+ Grammar supported
22
+ ------------------
23
+ ``participant``/``actor`` (with ``as`` aliases) and implicit participants
24
+ introduced by first use in a message or note; all eight core arrow forms
25
+ (``->``, ``-->``, ``->>``, ``-->>``, ``-x``, ``--x``, ``-)``, ``--)``);
26
+ ``Note over/left of/right of``; ``autonumber`` (with optional start/step);
27
+ ``activate``/``deactivate`` (including the ``+``/``-`` arrow-decoration
28
+ shorthand) and ``<br/>`` are tolerated and flattened (no visual effect).
29
+ ``loop``/``alt``/``opt``/``par``/``critical``/``break``/``rect``/``box``
30
+ open a horizontal separator band (with ``else``/``and``/``option`` as a
31
+ mid-band separator inside one); a stray separator/``end`` with no open
32
+ block, or any other unrecognized line, degrades to a plain text line
33
+ rather than raising.
34
+
35
+ Known degradations (by design, not bugs)
36
+ -----------------------------------------
37
+ - A ``Note left of`` the first participant, or block/plain labels wider
38
+ than the diagram, may overflow the canvas to the left/right; overflow
39
+ is acceptable (matching the existing flowchart renderer's contract),
40
+ never corruption or a crash.
41
+ - ``Note over``/``left of``/``right of`` with more than one participant
42
+ listed uses the first-and-last (or first, for left/right of) named
43
+ participant only — mirrors the simplification already used by the
44
+ Go-backed renderer's ``Note`` preprocessing.
45
+ - Participants are never created/destroyed mid-diagram; every lifeline
46
+ spans the full height of the diagram.
47
+ - Column/row placement is character-index-based, not visual-width-based
48
+ (``_Row`` indexes by ``len()``, not ``visual_len()``); wide (CJK/full-width)
49
+ participant labels or message text will misalign columns. This mirrors
50
+ the pre-existing ``wrap_text()`` CJK limitation elsewhere in termrender
51
+ (see the root CLAUDE.md); fixing it here requires the same visual-width
52
+ rework, out of scope for this renderer alone.
53
+ """
54
+
55
+ from __future__ import annotations
56
+
57
+ import re
58
+ from dataclasses import dataclass
59
+
60
+ from termrender.style import visual_center, visual_len
61
+
62
+ __all__ = ["render_sequence", "SequenceDiagramError"]
63
+
64
+
65
+ class SequenceDiagramError(Exception):
66
+ """Raised when source cannot be parsed as a mermaid sequence diagram."""
67
+
68
+
69
+ # --------------------------------------------------------------------------
70
+ # Parsed event model
71
+ # --------------------------------------------------------------------------
72
+
73
+
74
+ @dataclass
75
+ class Participant:
76
+ id: str
77
+ label: str
78
+
79
+
80
+ @dataclass
81
+ class Arrow:
82
+ source: str
83
+ target: str
84
+ dashed: bool
85
+ head_kind: str # "filled" | "open" | "lost" | "async"
86
+ text: str
87
+ number: int | None = None
88
+
89
+
90
+ @dataclass
91
+ class NoteEvent:
92
+ participants: list[str]
93
+ position: str # "over" | "left" | "right"
94
+ text: str
95
+
96
+
97
+ @dataclass
98
+ class BlockBoundary:
99
+ kind: str # "start" | "sep" | "end"
100
+ label: str
101
+
102
+
103
+ @dataclass
104
+ class PlainLabel:
105
+ text: str
106
+
107
+
108
+ Event = Arrow | NoteEvent | BlockBoundary | PlainLabel
109
+
110
+
111
+ # --------------------------------------------------------------------------
112
+ # Grammar
113
+ # --------------------------------------------------------------------------
114
+
115
+ _COMMENT_RE = re.compile(r"^%%")
116
+ _BR_RE = re.compile(r"<br\s*/?>", re.IGNORECASE)
117
+ _AUTONUMBER_RE = re.compile(r"^autonumber(?:\s+(\d+))?(?:\s+(\d+))?\s*$", re.IGNORECASE)
118
+ _ACTIVATE_RE = re.compile(r"^(?:activate|deactivate)\s+\S+\s*$", re.IGNORECASE)
119
+ _PARTICIPANT_RE = re.compile(
120
+ r"^(?:participant|actor)\s+(\S+)(?:\s+as\s+(.+))?\s*$", re.IGNORECASE
121
+ )
122
+ _NOTE_RE = re.compile(
123
+ r"^[Nn]ote\s+(over|left\s+of|right\s+of)\s+([^:]+?)\s*:\s*(.*)$"
124
+ )
125
+ _BLOCK_START_RE = re.compile(
126
+ r"^(loop|alt|opt|par|critical|break|rect|box)\b\s*(.*)$", re.IGNORECASE
127
+ )
128
+ _BLOCK_SEP_RE = re.compile(r"^(else|and|option)\b\s*(.*)$", re.IGNORECASE)
129
+ _BLOCK_END_RE = re.compile(r"^end\s*$", re.IGNORECASE)
130
+ # Arrow forms: "-{1,2}" dash count marks solid vs dashed; the marker after it
131
+ # picks the arrowhead. Handles all 8 combinations: ->, -->, ->>, -->>, -x,
132
+ # --x, -), --). An optional +/- right after the arrow (activate/deactivate
133
+ # shorthand) is captured and discarded.
134
+ #
135
+ # Identifiers deliberately exclude '-' (and ':', '+', whitespace): dash is
136
+ # reserved for the arrow itself, so an id token is the maximal run up to the
137
+ # first dash. Without this exclusion, non-mermaid dash punctuation next to a
138
+ # real arrow char (e.g. "A-.->B", a flowchart-only dotted arrow with no
139
+ # sequence-diagram equivalent) gets silently absorbed into the id instead of
140
+ # failing to match and degrading to a plain line.
141
+ _ID = r"[^\s:+-]+"
142
+ _ARROW_RE = re.compile(
143
+ rf"^({_ID})\s*(-{{1,2}})(>>|>|x|\))\s*([+-]?)({_ID})\s*:\s*(.*)$"
144
+ )
145
+
146
+ _HEAD_KIND = {">>": "filled", ">": "open", "x": "lost", ")": "async"}
147
+ _HEAD_GLYPH = {
148
+ "filled": {"right": ">", "left": "<"},
149
+ "open": {"right": "\u203a", "left": "\u2039"}, # › ‹
150
+ "lost": {"right": "\u2717", "left": "\u2717"}, # ✗
151
+ "async": {"right": ")", "left": "("},
152
+ }
153
+ _LINE_CHAR = {False: "\u2500", True: "\u254c"} # solid ─ / dashed ╌
154
+
155
+ _MIN_ARROW_GAP = 6
156
+ _MIN_SELF_GAP = 6
157
+ _BOX_PAD = 3
158
+
159
+
160
+ def _flatten(text: str) -> str:
161
+ return _BR_RE.sub(" / ", text) if text else ""
162
+
163
+
164
+ def _format_label(ev: Arrow) -> str:
165
+ text = _flatten(ev.text)
166
+ if ev.number is not None:
167
+ return f"{ev.number}: {text}" if text else f"{ev.number}:"
168
+ return text
169
+
170
+
171
+ def _note_position(raw: str) -> str:
172
+ low = raw.lower()
173
+ if low == "over":
174
+ return "over"
175
+ return "left" if "left" in low else "right"
176
+
177
+
178
+ def _parse(lines: list[str]) -> tuple[list[Participant], list[Event]]:
179
+ participants: dict[str, Participant] = {}
180
+ events: list[Event] = []
181
+ stack: list[str] = []
182
+ seen_header = False
183
+ autonumber_active = False
184
+ autonumber_next = 1
185
+ autonumber_step = 1
186
+
187
+ def register(pid: str) -> None:
188
+ if pid not in participants:
189
+ participants[pid] = Participant(id=pid, label=pid)
190
+
191
+ for raw_line in lines:
192
+ line = raw_line.strip()
193
+ if not line:
194
+ continue
195
+ if not seen_header:
196
+ if line.lower().startswith("sequencediagram"):
197
+ seen_header = True
198
+ continue
199
+ if _COMMENT_RE.match(line):
200
+ continue
201
+
202
+ m = _AUTONUMBER_RE.match(line)
203
+ if m:
204
+ autonumber_active = True
205
+ if m.group(1):
206
+ autonumber_next = int(m.group(1))
207
+ if m.group(2):
208
+ autonumber_step = int(m.group(2))
209
+ continue
210
+
211
+ if _ACTIVATE_RE.match(line):
212
+ continue
213
+
214
+ m = _PARTICIPANT_RE.match(line)
215
+ if m:
216
+ pid, alias = m.group(1), m.group(2)
217
+ label = alias.strip() if alias else pid
218
+ if pid in participants:
219
+ if alias:
220
+ participants[pid].label = label
221
+ else:
222
+ participants[pid] = Participant(id=pid, label=label)
223
+ continue
224
+
225
+ m = _NOTE_RE.match(line)
226
+ if m:
227
+ position_raw, who, text = m.group(1), m.group(2), m.group(3)
228
+ who_ids = [w.strip() for w in who.split(",") if w.strip()]
229
+ for w in who_ids:
230
+ register(w)
231
+ events.append(
232
+ NoteEvent(
233
+ participants=who_ids,
234
+ position=_note_position(position_raw),
235
+ text=_flatten(text),
236
+ )
237
+ )
238
+ continue
239
+
240
+ m = _BLOCK_START_RE.match(line)
241
+ if m:
242
+ keyword, rest = m.group(1), m.group(2).strip()
243
+ label = f"{keyword} {rest}".strip() if rest else keyword
244
+ stack.append(label)
245
+ events.append(BlockBoundary(kind="start", label=label))
246
+ continue
247
+
248
+ m = _BLOCK_SEP_RE.match(line)
249
+ if m:
250
+ keyword, rest = m.group(1), m.group(2).strip()
251
+ label = f"{keyword} {rest}".strip() if rest else keyword
252
+ if stack:
253
+ events.append(BlockBoundary(kind="sep", label=label))
254
+ else:
255
+ events.append(PlainLabel(text=_flatten(line)))
256
+ continue
257
+
258
+ if _BLOCK_END_RE.match(line):
259
+ if stack:
260
+ stack.pop()
261
+ events.append(BlockBoundary(kind="end", label=""))
262
+ continue
263
+
264
+ m = _ARROW_RE.match(line)
265
+ if m:
266
+ source, dashes, marker, _decor, target, text = m.groups()
267
+ register(source)
268
+ register(target)
269
+ number = None
270
+ if autonumber_active:
271
+ number = autonumber_next
272
+ autonumber_next += autonumber_step
273
+ events.append(
274
+ Arrow(
275
+ source=source,
276
+ target=target,
277
+ dashed=len(dashes) == 2,
278
+ head_kind=_HEAD_KIND[marker],
279
+ text=text.strip(),
280
+ number=number,
281
+ )
282
+ )
283
+ continue
284
+
285
+ events.append(PlainLabel(text=_flatten(line)))
286
+
287
+ return list(participants.values()), events
288
+
289
+
290
+ # --------------------------------------------------------------------------
291
+ # Column layout
292
+ # --------------------------------------------------------------------------
293
+
294
+
295
+ def _layout_columns(
296
+ participants: list[Participant], events: list[Event]
297
+ ) -> tuple[list[int], list[int]]:
298
+ n = len(participants)
299
+ if n == 0:
300
+ return [], []
301
+
302
+ id_index = {p.id: i for i, p in enumerate(participants)}
303
+ box_widths = [visual_len(p.label) + 4 for p in participants]
304
+ gaps = [
305
+ box_widths[i] // 2 + box_widths[i + 1] // 2 + _BOX_PAD for i in range(n - 1)
306
+ ]
307
+
308
+ for ev in events:
309
+ if not isinstance(ev, Arrow):
310
+ continue
311
+ if ev.source not in id_index or ev.target not in id_index:
312
+ continue
313
+ si, ti = id_index[ev.source], id_index[ev.target]
314
+ label_text = _format_label(ev)
315
+ if si == ti:
316
+ if si < n - 1:
317
+ required = max(visual_len(label_text) + 4, _MIN_SELF_GAP)
318
+ gaps[si] = max(gaps[si], required)
319
+ continue
320
+
321
+ lo, hi = (si, ti) if si < ti else (ti, si)
322
+ required = max(visual_len(label_text) + 2, _MIN_ARROW_GAP)
323
+ if hi == lo + 1:
324
+ gaps[lo] = max(gaps[lo], required)
325
+ else:
326
+ span = range(lo, hi)
327
+ count = hi - lo
328
+ current = sum(gaps[i] for i in span)
329
+ if current < required:
330
+ deficit = required - current
331
+ share, rem = divmod(deficit, count)
332
+ for j, i in enumerate(span):
333
+ gaps[i] += share + (1 if j < rem else 0)
334
+
335
+ centers = [0] * n
336
+ centers[0] = box_widths[0] // 2
337
+ for i in range(1, n):
338
+ centers[i] = centers[i - 1] + gaps[i - 1]
339
+ return centers, box_widths
340
+
341
+
342
+ # --------------------------------------------------------------------------
343
+ # Row rendering
344
+ # --------------------------------------------------------------------------
345
+
346
+
347
+ class _Row:
348
+ """A growable line of characters, addressed by absolute column."""
349
+
350
+ __slots__ = ("chars",)
351
+
352
+ def __init__(self, width: int) -> None:
353
+ self.chars: list[str] = [" "] * max(width, 0)
354
+
355
+ def set(self, x: int, s: str) -> None:
356
+ if x < 0 or not s:
357
+ return
358
+ end = x + len(s)
359
+ if end > len(self.chars):
360
+ self.chars.extend([" "] * (end - len(self.chars)))
361
+ for i, ch in enumerate(s):
362
+ self.chars[x + i] = ch
363
+
364
+ def to_string(self) -> str:
365
+ return "".join(self.chars).rstrip()
366
+
367
+
368
+ def _lifeline_row(diagram_width: int, centers: list[int]) -> _Row:
369
+ row = _Row(diagram_width)
370
+ for x in centers:
371
+ row.set(x, "\u2502") # │
372
+ return row
373
+
374
+
375
+ def _participant_box(width: int, label: str) -> tuple[str, str, str]:
376
+ inner = max(width - 2, 0)
377
+ top = "\u250c" + "\u2500" * inner + "\u2510" # ┌───┐
378
+ mid = "\u2502" + visual_center(label, inner) + "\u2502"
379
+ bot = "\u2514" + "\u2500" * inner + "\u2518" # └───┘
380
+ return top, mid, bot
381
+
382
+
383
+ def _header_rows(
384
+ participants: list[Participant],
385
+ box_lefts: list[int],
386
+ box_widths: list[int],
387
+ diagram_width: int,
388
+ ) -> tuple[str, str, str]:
389
+ top_row = _Row(diagram_width)
390
+ mid_row = _Row(diagram_width)
391
+ bot_row = _Row(diagram_width)
392
+ for p, left, w in zip(participants, box_lefts, box_widths):
393
+ top, mid, bot = _participant_box(w, p.label)
394
+ top_row.set(left, top)
395
+ mid_row.set(left, mid)
396
+ bot_row.set(left, bot)
397
+ return top_row.to_string(), mid_row.to_string(), bot_row.to_string()
398
+
399
+
400
+ def _line_row(
401
+ diagram_width: int,
402
+ centers: list[int],
403
+ lo: int,
404
+ hi: int,
405
+ rightwards: bool,
406
+ dashed: bool,
407
+ head_kind: str,
408
+ ) -> str:
409
+ row = _lifeline_row(diagram_width, centers)
410
+ line_char = _LINE_CHAR[dashed]
411
+ x0, x1 = centers[lo], centers[hi]
412
+ for x in range(x0, x1 + 1):
413
+ row.set(x, line_char)
414
+ glyphs = _HEAD_GLYPH[head_kind]
415
+ if rightwards:
416
+ row.set(x1, glyphs["right"])
417
+ else:
418
+ row.set(x0, glyphs["left"])
419
+ return row.to_string()
420
+
421
+
422
+ def _label_row(
423
+ diagram_width: int, centers: list[int], lo: int, hi: int, label_text: str
424
+ ) -> str | None:
425
+ if not label_text:
426
+ return None
427
+ row = _lifeline_row(diagram_width, centers)
428
+ span_width = centers[hi] - centers[lo] + 1
429
+ row.set(centers[lo], visual_center(label_text, span_width))
430
+ return row.to_string()
431
+
432
+
433
+ def _self_loop_rows(
434
+ diagram_width: int,
435
+ centers: list[int],
436
+ idx_s: int,
437
+ label_text: str,
438
+ dashed: bool,
439
+ head_kind: str,
440
+ ) -> list[str]:
441
+ x = centers[idx_s]
442
+ line_char = _LINE_CHAR[dashed]
443
+ glyphs = _HEAD_GLYPH[head_kind]
444
+
445
+ out_row = _lifeline_row(diagram_width, centers)
446
+ out_row.set(x, line_char + "\u256e") # ╮ (round down-right corner)
447
+
448
+ mid_row = _lifeline_row(diagram_width, centers)
449
+ label_part = ("\u2502 " + label_text) if label_text else "\u2502"
450
+ mid_row.set(x + 1, label_part)
451
+
452
+ in_row = _lifeline_row(diagram_width, centers)
453
+ in_row.set(x, glyphs["left"] + "\u256f") # ╯ (round up-left corner)
454
+
455
+ return [out_row.to_string(), mid_row.to_string(), in_row.to_string()]
456
+
457
+
458
+ def _note_box_rows(
459
+ diagram_width: int, centers: list[int], box_left: int, box_width: int, text: str
460
+ ) -> list[str]:
461
+ top, mid, bot = _participant_box(box_width, text)
462
+ rows = []
463
+ for content in (top, mid, bot):
464
+ row = _lifeline_row(diagram_width, centers)
465
+ row.set(max(box_left, 0), content)
466
+ rows.append(row.to_string())
467
+ return rows
468
+
469
+
470
+ def _band_row(diagram_width: int, kind: str, label: str) -> str:
471
+ if kind == "end":
472
+ fill = max(diagram_width - 2, 0)
473
+ return "\u2514" + "\u2500" * fill + "\u2518" # └───┘
474
+ left, right = ("\u250c", "\u2510") if kind == "start" else ("\u251c", "\u2524")
475
+ prefix = f"\u2500 {label} " if label else "\u2500 "
476
+ fill = max(diagram_width - 2 - visual_len(prefix), 0)
477
+ return left + prefix + "\u2500" * fill + right
478
+
479
+
480
+ def _plain_label_row(diagram_width: int, text: str) -> str:
481
+ row = _Row(diagram_width)
482
+ content = visual_center(text, diagram_width) if diagram_width > 0 else text
483
+ row.set(0, content)
484
+ return row.to_string()
485
+
486
+
487
+ def _render_arrow(
488
+ ev: Arrow, centers: list[int], id_index: dict[str, int], diagram_width: int
489
+ ) -> list[str]:
490
+ si, ti = id_index[ev.source], id_index[ev.target]
491
+ label_text = _format_label(ev)
492
+
493
+ if si == ti:
494
+ return _self_loop_rows(diagram_width, centers, si, label_text, ev.dashed, ev.head_kind)
495
+
496
+ lo, hi = (si, ti) if si < ti else (ti, si)
497
+ rightwards = si < ti
498
+ rows: list[str] = []
499
+ label_row = _label_row(diagram_width, centers, lo, hi, label_text)
500
+ if label_row is not None:
501
+ rows.append(label_row)
502
+ rows.append(_line_row(diagram_width, centers, lo, hi, rightwards, ev.dashed, ev.head_kind))
503
+ return rows
504
+
505
+
506
+ def _render_note(
507
+ ev: NoteEvent, centers: list[int], id_index: dict[str, int], diagram_width: int
508
+ ) -> list[str]:
509
+ idxs = [id_index[p] for p in ev.participants if p in id_index]
510
+ if not idxs:
511
+ return [_plain_label_row(diagram_width, f"Note: {ev.text}")]
512
+ lo, hi = min(idxs), max(idxs)
513
+ text = ev.text
514
+ box_width = visual_len(text) + 4
515
+
516
+ if ev.position == "over":
517
+ span_center = (centers[lo] + centers[hi]) // 2
518
+ box_width = max(box_width, centers[hi] - centers[lo] + 4)
519
+ box_left = span_center - box_width // 2
520
+ elif ev.position == "left":
521
+ box_left = centers[lo] - 2 - box_width
522
+ else: # "right"
523
+ box_left = centers[lo] + 2
524
+
525
+ return _note_box_rows(diagram_width, centers, box_left, box_width, text)
526
+
527
+
528
+ def _render_event(
529
+ ev: Event, centers: list[int], id_index: dict[str, int], diagram_width: int
530
+ ) -> list[str]:
531
+ if isinstance(ev, Arrow):
532
+ return _render_arrow(ev, centers, id_index, diagram_width)
533
+ if isinstance(ev, NoteEvent):
534
+ return _render_note(ev, centers, id_index, diagram_width)
535
+ if isinstance(ev, BlockBoundary):
536
+ return [_band_row(diagram_width, ev.kind, ev.label)]
537
+ if isinstance(ev, PlainLabel):
538
+ return [_plain_label_row(diagram_width, ev.text)]
539
+ raise SequenceDiagramError(f"unrenderable event: {ev!r}")
540
+
541
+
542
+ # --------------------------------------------------------------------------
543
+ # Public entry point
544
+ # --------------------------------------------------------------------------
545
+
546
+
547
+ def render_sequence(source: str, width: int) -> list[str]:
548
+ """Render a mermaid ``sequenceDiagram`` source to ASCII lines.
549
+
550
+ Args:
551
+ source: The mermaid fence body; its first non-blank line must
552
+ start with ``sequenceDiagram``.
553
+ width: Advisory terminal width. Sequence diagrams have an
554
+ intrinsic width driven by participant labels and message
555
+ text; this function does not wrap or truncate to fit —
556
+ overflow beyond ``width`` is acceptable, matching the
557
+ existing flowchart renderer's contract.
558
+
559
+ Returns:
560
+ Rendered lines: monochrome unicode box-drawing, no ANSI, no
561
+ significant trailing whitespace.
562
+
563
+ Raises:
564
+ SequenceDiagramError: If ``source`` is not a mermaid sequence
565
+ diagram at all (missing the ``sequenceDiagram`` header).
566
+ """
567
+ del width # advisory only; diagrams have intrinsic width (see docstring)
568
+
569
+ lines = source.splitlines()
570
+ first = next((line.strip() for line in lines if line.strip()), "")
571
+ if not first.lower().startswith("sequencediagram"):
572
+ raise SequenceDiagramError(
573
+ "not a mermaid sequence diagram: source must start with 'sequenceDiagram'"
574
+ )
575
+
576
+ participants, events = _parse(lines)
577
+ centers, box_widths = _layout_columns(participants, events)
578
+ box_lefts = [c - w // 2 for c, w in zip(centers, box_widths)]
579
+ diagram_width = (box_lefts[-1] + box_widths[-1]) if participants else 0
580
+
581
+ out: list[str] = []
582
+ header: tuple[str, str, str] | None = None
583
+ if participants:
584
+ header = _header_rows(participants, box_lefts, box_widths, diagram_width)
585
+ out.extend(header)
586
+ out.append(_lifeline_row(diagram_width, centers).to_string())
587
+
588
+ id_index = {p.id: i for i, p in enumerate(participants)}
589
+ for ev in events:
590
+ out.extend(_render_event(ev, centers, id_index, diagram_width))
591
+ if participants:
592
+ out.append(_lifeline_row(diagram_width, centers).to_string())
593
+
594
+ if header is not None:
595
+ out.extend(header)
596
+
597
+ return out