termrender 4.3.2__tar.gz → 4.4.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 (72) hide show
  1. {termrender-4.3.2 → termrender-4.4.0}/CHANGELOG.md +11 -0
  2. {termrender-4.3.2 → termrender-4.4.0}/PKG-INFO +2 -1
  3. {termrender-4.3.2 → termrender-4.4.0}/pyproject.toml +1 -0
  4. termrender-4.4.0/src/termrender/renderers/mermaid_flow_layout.py +602 -0
  5. termrender-4.4.0/src/termrender/renderers/mermaid_flow_parser.py +406 -0
  6. termrender-4.4.0/tests/test_mermaid_flow_layout.py +386 -0
  7. termrender-4.4.0/tests/test_mermaid_flow_parser.py +356 -0
  8. {termrender-4.3.2 → termrender-4.4.0}/.github/workflows/publish.yml +0 -0
  9. {termrender-4.3.2 → termrender-4.4.0}/.gitignore +0 -0
  10. {termrender-4.3.2 → termrender-4.4.0}/CLAUDE.md +0 -0
  11. {termrender-4.3.2 → termrender-4.4.0}/LICENSE +0 -0
  12. {termrender-4.3.2 → termrender-4.4.0}/README.md +0 -0
  13. {termrender-4.3.2 → termrender-4.4.0}/design.json +0 -0
  14. {termrender-4.3.2 → termrender-4.4.0}/requirements.json +0 -0
  15. {termrender-4.3.2 → termrender-4.4.0}/scripts/build-mermaid-ascii.sh +0 -0
  16. {termrender-4.3.2 → termrender-4.4.0}/src/termrender/CLAUDE.md +0 -0
  17. {termrender-4.3.2 → termrender-4.4.0}/src/termrender/__init__.py +0 -0
  18. {termrender-4.3.2 → termrender-4.4.0}/src/termrender/__main__.py +0 -0
  19. {termrender-4.3.2 → termrender-4.4.0}/src/termrender/_bin/mermaid-ascii-darwin-arm64 +0 -0
  20. {termrender-4.3.2 → termrender-4.4.0}/src/termrender/_mermaid_bin.py +0 -0
  21. {termrender-4.3.2 → termrender-4.4.0}/src/termrender/blocks.py +0 -0
  22. {termrender-4.3.2 → termrender-4.4.0}/src/termrender/emit.py +0 -0
  23. {termrender-4.3.2 → termrender-4.4.0}/src/termrender/layout.py +0 -0
  24. {termrender-4.3.2 → termrender-4.4.0}/src/termrender/parser.py +0 -0
  25. {termrender-4.3.2 → termrender-4.4.0}/src/termrender/py.typed +0 -0
  26. {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/CLAUDE.md +0 -0
  27. {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/__init__.py +0 -0
  28. {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/borders.py +0 -0
  29. {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/charts.py +0 -0
  30. {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/code.py +0 -0
  31. {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/columns.py +0 -0
  32. {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/diff.py +0 -0
  33. {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/divider.py +0 -0
  34. {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/mermaid.py +0 -0
  35. {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/mermaid_flow_model.py +0 -0
  36. {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/mermaid_gantt.py +0 -0
  37. {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/mermaid_journey.py +0 -0
  38. {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/mermaid_mindmap.py +0 -0
  39. {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/mermaid_pie.py +0 -0
  40. {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/mermaid_prelude.py +0 -0
  41. {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/mermaid_sequence.py +0 -0
  42. {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/mermaid_timeline.py +0 -0
  43. {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/panel.py +0 -0
  44. {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/quote.py +0 -0
  45. {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/stat.py +0 -0
  46. {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/table.py +0 -0
  47. {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/text.py +0 -0
  48. {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/timeline.py +0 -0
  49. {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/tree.py +0 -0
  50. {termrender-4.3.2 → termrender-4.4.0}/src/termrender/style.py +0 -0
  51. {termrender-4.3.2 → termrender-4.4.0}/tests/__init__.py +0 -0
  52. {termrender-4.3.2 → termrender-4.4.0}/tests/test_charts.py +0 -0
  53. {termrender-4.3.2 → termrender-4.4.0}/tests/test_cli_contract.py +0 -0
  54. {termrender-4.3.2 → termrender-4.4.0}/tests/test_column_alignment.py +0 -0
  55. {termrender-4.3.2 → termrender-4.4.0}/tests/test_diff.py +0 -0
  56. {termrender-4.3.2 → termrender-4.4.0}/tests/test_inline_badge.py +0 -0
  57. {termrender-4.3.2 → termrender-4.4.0}/tests/test_linebreak.py +0 -0
  58. {termrender-4.3.2 → termrender-4.4.0}/tests/test_mermaid_compat.py +0 -0
  59. {termrender-4.3.2 → termrender-4.4.0}/tests/test_mermaid_dispatch.py +0 -0
  60. {termrender-4.3.2 → termrender-4.4.0}/tests/test_mermaid_flow_model.py +0 -0
  61. {termrender-4.3.2 → termrender-4.4.0}/tests/test_mermaid_gantt.py +0 -0
  62. {termrender-4.3.2 → termrender-4.4.0}/tests/test_mermaid_journey.py +0 -0
  63. {termrender-4.3.2 → termrender-4.4.0}/tests/test_mermaid_mindmap.py +0 -0
  64. {termrender-4.3.2 → termrender-4.4.0}/tests/test_mermaid_pie.py +0 -0
  65. {termrender-4.3.2 → termrender-4.4.0}/tests/test_mermaid_sequence.py +0 -0
  66. {termrender-4.3.2 → termrender-4.4.0}/tests/test_mermaid_timeline.py +0 -0
  67. {termrender-4.3.2 → termrender-4.4.0}/tests/test_myst_gaps.py +0 -0
  68. {termrender-4.3.2 → termrender-4.4.0}/tests/test_stat.py +0 -0
  69. {termrender-4.3.2 → termrender-4.4.0}/tests/test_tasklist.py +0 -0
  70. {termrender-4.3.2 → termrender-4.4.0}/tests/test_timeline.py +0 -0
  71. {termrender-4.3.2 → termrender-4.4.0}/tests/test_variable_colons.py +0 -0
  72. {termrender-4.3.2 → termrender-4.4.0}/uv.lock +0 -0
@@ -1,6 +1,17 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v4.4.0 (2026-07-06)
5
+
6
+ ### Features
7
+
8
+ - Add flowchart layout engine core (grandalf adapter + canvas rasterizer)
9
+ ([`23e26e9`](https://github.com/crouton-labs/termrender/commit/23e26e9c3228bfac302c270b4c0c5ffe880bfc20))
10
+
11
+ - Add flowchart mermaid parser
12
+ ([`c9c80c3`](https://github.com/crouton-labs/termrender/commit/c9c80c30ed638c3532ef9464ce1cb7f4af4fa3ce))
13
+
14
+
4
15
  ## v4.3.2 (2026-07-06)
5
16
 
6
17
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: termrender
3
- Version: 4.3.2
3
+ Version: 4.4.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
@@ -21,6 +21,7 @@ Classifier: Programming Language :: Python :: 3.13
21
21
  Classifier: Topic :: Terminals
22
22
  Classifier: Topic :: Text Processing :: Markup :: Markdown
23
23
  Requires-Python: >=3.10
24
+ Requires-Dist: grandalf>=0.8
24
25
  Requires-Dist: mermaid-ascii>=1.2
25
26
  Requires-Dist: mistune>=3.0
26
27
  Requires-Dist: pygments>=2.0
@@ -30,6 +30,7 @@ dependencies = [
30
30
  "mistune>=3.0",
31
31
  "mermaid-ascii>=1.2",
32
32
  "pygments>=2.0",
33
+ "grandalf>=0.8",
33
34
  ]
34
35
 
35
36
  [project.urls]
@@ -0,0 +1,602 @@
1
+ """Layout + rasterization engine for the native mermaid flowchart renderer.
2
+
3
+ Public entry point: :func:`layout_flowgraph` — turns a parsed
4
+ :class:`~termrender.renderers.mermaid_flow_model.FlowGraph` into a char grid
5
+ (``list[str]``). This module owns four concerns, marked by section comments
6
+ below: the grandalf adapter (node coordinates), the :class:`Canvas` grid
7
+ (shared mutable rasterization surface), box rasterization, and edge routing.
8
+ See ``flow-design.md`` (this renderer's design-of-record, referenced from the
9
+ orchestrator context dir) for the full architecture; this docstring covers
10
+ only what a reader of this file needs.
11
+
12
+ Layout model
13
+ ------------
14
+ Node positions come from ``grandalf`` (pure-Python Sugiyama layered layout,
15
+ v0.8) — this module does no layering or crossing-minimization itself. One
16
+ :class:`grandalf.graphs.Vertex` is built per :class:`FlowNode`, sized via
17
+ ``VertexViewer(w, h)`` in *cell* units so grandalf's coordinates come out
18
+ directly in char space; one :class:`grandalf.graphs.Edge` per
19
+ :class:`FlowEdge` (self-loops excluded — they add no ranking information).
20
+ Each connected component (``Graph.C``) is laid out independently, then
21
+ stacked left-to-right in the direction-neutral "native" coordinate frame
22
+ (grandalf always lays out top-to-bottom internally). grandalf's per-vertex
23
+ floats are snapped to a clean non-overlapping integer cell grid: nodes are
24
+ grouped into rank *bands* (rows for TB, later transposed for LR/RL), and
25
+ within a band sorted by grandalf's provisional column and nudged apart to
26
+ guarantee a minimum gap — this preserves grandalf's crossing-minimized
27
+ *ordering* without trusting its raw floats for exact spacing.
28
+
29
+ Rank-flow direction (``TB``/``BT``/``LR``/``RL``) is never passed to
30
+ grandalf (it has no such parameter) — it is a post-hoc coordinate transform
31
+ applied to the final integer (col, band) placement: identity for TB, a
32
+ vertical mirror for BT, an axis transpose for LR, and transpose + horizontal
33
+ mirror for RL. For LR/RL the adapter feeds grandalf *swapped* box dimensions
34
+ (``w=box_h, h=box_w``) so its in-layer spacing math reserves the right
35
+ amount of room along each post-transpose axis.
36
+
37
+ **Cycle handling requires no pre-processing.** grandalf's ``init_all()``
38
+ detects strongly-connected components and marks back-edges ``feedback=True``
39
+ internally for ranking purposes only; it never mutates the edge objects we
40
+ hand it, and we always draw using the model's original ``src -> dst``
41
+ direction. A cyclic :class:`FlowGraph` (e.g. ``A->B->C->A``) lays out and
42
+ renders without any manual cycle-breaking — confirmed empirically against
43
+ grandalf 0.8 (see the design doc's "grandalf adapter recipe" section).
44
+
45
+ Grammar / scope this phase
46
+ ---------------------------
47
+ This module renders every node as a bordered **rectangle** regardless of its
48
+ declared :class:`NodeShape` — distinct shape borders (diamond, round,
49
+ stadium, circle, ...) are a later phase. Edges are drawn as a **placeholder**
50
+ straight line or simple two-segment L path between each pair's facing border
51
+ midpoints — no arrowheads, no back-edge side-lane routing, no edge labels;
52
+ the full orthogonal router (arrowheads, labels, back-edge lanes, self-loops)
53
+ is a later phase built on top of the :class:`Canvas`/:class:`BoxRect`
54
+ exported here. Subgraph frames (:meth:`Canvas.draw_frame`) are implemented
55
+ as part of the shared grid contract but not yet invoked by
56
+ :func:`layout_flowgraph`.
57
+
58
+ Known degradations (by design, not bugs)
59
+ -----------------------------------------
60
+ - Node labels wrap via :func:`termrender.style.wrap_text`, which measures
61
+ with ``len()`` internally (a pre-existing termrender limitation, see the
62
+ root ``CLAUDE.md``) — CJK/wide-glyph labels may wrap at the wrong point.
63
+ Box *dimensions*, however, are always computed from :func:`visual_len` of
64
+ the wrapped lines, so the box itself is never too narrow for its content.
65
+ - Dense graphs may show edge-line crossings and label overlap once the real
66
+ router lands; this phase draws no labels at all.
67
+ - An edge referencing a node id absent from the graph (a malformed/hand-built
68
+ :class:`FlowGraph`) is silently skipped rather than raised — the parser is
69
+ expected to guarantee valid endpoints, but this module never crashes on a
70
+ dangling reference.
71
+ """
72
+
73
+ from __future__ import annotations
74
+
75
+ from collections import defaultdict
76
+ from dataclasses import dataclass
77
+
78
+ from grandalf.graphs import Edge, Graph, Vertex
79
+ from grandalf.layouts import SugiyamaLayout, VertexViewer
80
+
81
+ from termrender.renderers.mermaid_flow_model import (
82
+ Direction,
83
+ EdgeStyle,
84
+ FlowEdge,
85
+ FlowGraph,
86
+ FlowNode,
87
+ NodeShape,
88
+ )
89
+ from termrender.style import visual_center, visual_len, wrap_text
90
+
91
+ __all__ = ["layout_flowgraph", "Canvas", "BoxRect"]
92
+
93
+
94
+ # --------------------------------------------------------------------------
95
+ # --- canvas ---
96
+ # --------------------------------------------------------------------------
97
+
98
+ # Direction bitmask for line-glyph junction resolution (see Canvas._write_line_cell).
99
+ _UP, _DOWN, _LEFT, _RIGHT = 1, 2, 4, 8
100
+
101
+ _JUNCTIONS: dict[int, str] = {
102
+ 0: " ",
103
+ _UP: "\u2502",
104
+ _DOWN: "\u2502",
105
+ _UP | _DOWN: "\u2502",
106
+ _LEFT: "\u2500",
107
+ _RIGHT: "\u2500",
108
+ _LEFT | _RIGHT: "\u2500",
109
+ _DOWN | _RIGHT: "\u250c",
110
+ _DOWN | _LEFT: "\u2510",
111
+ _UP | _RIGHT: "\u2514",
112
+ _UP | _LEFT: "\u2518",
113
+ _UP | _DOWN | _RIGHT: "\u251c",
114
+ _UP | _DOWN | _LEFT: "\u2524",
115
+ _DOWN | _LEFT | _RIGHT: "\u252c",
116
+ _UP | _LEFT | _RIGHT: "\u2534",
117
+ _UP | _DOWN | _LEFT | _RIGHT: "\u253c",
118
+ }
119
+
120
+ _STYLE_V = {
121
+ EdgeStyle.SOLID: "\u2502",
122
+ EdgeStyle.THICK: "\u2503",
123
+ EdgeStyle.DOTTED: "\u254e",
124
+ }
125
+ _STYLE_H = {
126
+ EdgeStyle.SOLID: "\u2500",
127
+ EdgeStyle.THICK: "\u2501",
128
+ EdgeStyle.DOTTED: "\u254c",
129
+ }
130
+ _STRAIGHT_BITS = {
131
+ 0,
132
+ _UP,
133
+ _DOWN,
134
+ _UP | _DOWN,
135
+ _LEFT,
136
+ _RIGHT,
137
+ _LEFT | _RIGHT,
138
+ }
139
+
140
+
141
+ class Canvas:
142
+ """Mutable 2D char grid shared by the rasterizer and the edge router.
143
+
144
+ Two parallel planes: ``grid`` (the visible characters) and a boolean
145
+ "reserved" plane marking box interiors + borders, which the router must
146
+ never overwrite via :meth:`draw_segment` (but :meth:`draw_glyph` may,
147
+ e.g. an arrowhead landing on a border cell). Line-drawing state (which
148
+ directions a cell connects, and which :class:`EdgeStyle` drew it) is
149
+ tracked internally so intersecting segments compose into the correct
150
+ box-drawing junction glyph rather than clobbering each other.
151
+ """
152
+
153
+ def __init__(self, width: int, height: int) -> None:
154
+ self._width = max(width, 0)
155
+ self._height = max(height, 0)
156
+ self.grid: list[list[str]] = [[" "] * self._width for _ in range(self._height)]
157
+ self.reserved: list[list[bool]] = [
158
+ [False] * self._width for _ in range(self._height)
159
+ ]
160
+ self._bits: dict[tuple[int, int], int] = {}
161
+ self._style: dict[tuple[int, int], EdgeStyle | None] = {}
162
+
163
+ def ensure(self, x: int, y: int) -> None:
164
+ if x < 0 or y < 0:
165
+ return
166
+ if y >= len(self.grid):
167
+ for _ in range(y + 1 - len(self.grid)):
168
+ self.grid.append([" "] * self._width)
169
+ self.reserved.append([False] * self._width)
170
+ self._height = len(self.grid)
171
+ if x >= self._width:
172
+ new_width = x + 1
173
+ for row in self.grid:
174
+ row.extend([" "] * (new_width - len(row)))
175
+ for row in self.reserved:
176
+ row.extend([False] * (new_width - len(row)))
177
+ self._width = new_width
178
+
179
+ def set_char(self, x: int, y: int, ch: str, *, reserve: bool = False) -> None:
180
+ if x < 0 or y < 0:
181
+ return
182
+ self.ensure(x, y)
183
+ self.grid[y][x] = ch
184
+ if reserve:
185
+ self.reserved[y][x] = True
186
+
187
+ def get_char(self, x: int, y: int) -> str:
188
+ if y < 0 or x < 0 or y >= len(self.grid) or x >= len(self.grid[y]):
189
+ return " "
190
+ return self.grid[y][x]
191
+
192
+ def is_reserved(self, x: int, y: int) -> bool:
193
+ if y < 0 or x < 0 or y >= len(self.reserved) or x >= len(self.reserved[y]):
194
+ return False
195
+ return self.reserved[y][x]
196
+
197
+ def draw_box(self, rect: "BoxRect", shape: NodeShape, label: str) -> None:
198
+ """Draw a bordered box + centered wrapped label; reserve every cell.
199
+
200
+ ``shape`` is accepted for interface stability with later phases —
201
+ this phase draws every shape as a plain rectangle (distinct shape
202
+ borders are a later phase's responsibility).
203
+ """
204
+ del shape
205
+ x, y, w, h = rect.x, rect.y, rect.w, rect.h
206
+ if w <= 0 or h <= 0:
207
+ return
208
+
209
+ right, bottom = x + w - 1, y + h - 1
210
+ self.set_char(x, y, "\u250c", reserve=True)
211
+ self.set_char(right, y, "\u2510", reserve=True)
212
+ self.set_char(x, bottom, "\u2514", reserve=True)
213
+ self.set_char(right, bottom, "\u2518", reserve=True)
214
+ for cx in range(x + 1, right):
215
+ self.set_char(cx, y, "\u2500", reserve=True)
216
+ self.set_char(cx, bottom, "\u2500", reserve=True)
217
+ for cy in range(y + 1, bottom):
218
+ self.set_char(x, cy, "\u2502", reserve=True)
219
+ self.set_char(right, cy, "\u2502", reserve=True)
220
+ for cx in range(x + 1, right):
221
+ self.set_char(cx, cy, " ", reserve=True)
222
+
223
+ inner_w, inner_h = w - 2, h - 2
224
+ if inner_w <= 0 or inner_h <= 0:
225
+ return
226
+ lines = wrap_text(label or "", inner_w)
227
+ top_line = y + 1 + max(0, (inner_h - len(lines)) // 2)
228
+ for i, line in enumerate(lines[:inner_h]):
229
+ centered = visual_center(line, inner_w)
230
+ for j, ch in enumerate(centered):
231
+ self.set_char(x + 1 + j, top_line + i, ch, reserve=True)
232
+
233
+ def draw_frame(self, rect: "BoxRect", title: str) -> None:
234
+ """Draw a subgraph enclosure: light border, left-anchored title.
235
+
236
+ Frame cells are NOT reserved — nodes and edges live inside a
237
+ subgraph frame; only the four border runs are drawn.
238
+ """
239
+ x, y, w, h = rect.x, rect.y, rect.w, rect.h
240
+ if w < 2 or h < 2:
241
+ return
242
+ inner = w - 2
243
+ prefix = f"\u2500 {title} " if title else "\u2500 "
244
+ fill = max(inner - visual_len(prefix), 0)
245
+ top = "\u250c" + prefix + "\u2500" * fill + "\u2510"
246
+ bottom = "\u2514" + "\u2500" * inner + "\u2518"
247
+ for i, ch in enumerate(top[:w]):
248
+ self.set_char(x + i, y, ch)
249
+ for i, ch in enumerate(bottom[:w]):
250
+ self.set_char(x + i, y + h - 1, ch)
251
+ for cy in range(y + 1, y + h - 1):
252
+ self.set_char(x, cy, "\u2502")
253
+ self.set_char(x + w - 1, cy, "\u2502")
254
+
255
+ def _write_line_cell(self, x: int, y: int, bits: int, style: EdgeStyle) -> None:
256
+ if self.is_reserved(x, y):
257
+ return
258
+ self.ensure(x, y)
259
+ key = (x, y)
260
+ existing_bits = self._bits.get(key, 0)
261
+ combined = existing_bits | bits
262
+ if existing_bits == 0:
263
+ new_style: EdgeStyle | None = style
264
+ else:
265
+ prev_style = self._style.get(key)
266
+ new_style = (
267
+ prev_style
268
+ if prev_style == style and combined in _STRAIGHT_BITS
269
+ else None
270
+ )
271
+ self._bits[key] = combined
272
+ self._style[key] = new_style
273
+ if new_style is not None and combined in _STRAIGHT_BITS:
274
+ if combined & (_LEFT | _RIGHT) and not combined & (_UP | _DOWN):
275
+ glyph = _STYLE_H[new_style]
276
+ elif combined & (_UP | _DOWN):
277
+ glyph = _STYLE_V[new_style]
278
+ else:
279
+ glyph = _JUNCTIONS[0]
280
+ else:
281
+ glyph = _JUNCTIONS.get(combined, "\u253c")
282
+ self.grid[y][x] = glyph
283
+
284
+ def draw_segment(
285
+ self, x0: int, y0: int, x1: int, y1: int, style: EdgeStyle = EdgeStyle.SOLID
286
+ ) -> None:
287
+ """Draw one straight H or V run (``x0 == x1`` or ``y0 == y1``).
288
+
289
+ Not axis-aligned input is a caller-contract violation and is
290
+ silently ignored (this module's only caller always supplies
291
+ axis-aligned runs) rather than raised.
292
+ """
293
+ if x0 == x1:
294
+ ylo, yhi = (y0, y1) if y0 <= y1 else (y1, y0)
295
+ for y in range(ylo, yhi + 1):
296
+ bits = 0
297
+ if y > ylo:
298
+ bits |= _UP
299
+ if y < yhi:
300
+ bits |= _DOWN
301
+ self._write_line_cell(x0, y, bits, style)
302
+ elif y0 == y1:
303
+ xlo, xhi = (x0, x1) if x0 <= x1 else (x1, x0)
304
+ for x in range(xlo, xhi + 1):
305
+ bits = 0
306
+ if x > xlo:
307
+ bits |= _LEFT
308
+ if x < xhi:
309
+ bits |= _RIGHT
310
+ self._write_line_cell(x, y0, bits, style)
311
+ # else: diagonal request — not axis-aligned, ignored (total function).
312
+
313
+ def draw_glyph(self, x: int, y: int, ch: str) -> None:
314
+ """Unconditional single-glyph write (arrowheads, corners, labels).
315
+
316
+ Overwrites unconditionally — does not participate in the line-join
317
+ bitmask system, and does not skip reserved cells (an arrowhead is
318
+ meant to visibly land on a box border).
319
+ """
320
+ if x < 0 or y < 0:
321
+ return
322
+ self.ensure(x, y)
323
+ self.grid[y][x] = ch
324
+
325
+ def to_lines(self) -> list[str]:
326
+ lines = ["".join(row).rstrip() for row in self.grid]
327
+ while lines and lines[-1] == "":
328
+ lines.pop()
329
+ return lines
330
+
331
+
332
+ @dataclass
333
+ class BoxRect:
334
+ """A placed node's rectangle: top-left cell + extents, plus border
335
+ anchor points used by the edge router. Engine-internal (not part of the
336
+ parser/model contract)."""
337
+
338
+ x: int
339
+ y: int
340
+ w: int
341
+ h: int
342
+
343
+ @property
344
+ def top_mid(self) -> tuple[int, int]:
345
+ return (self.x + self.w // 2, self.y)
346
+
347
+ @property
348
+ def bottom_mid(self) -> tuple[int, int]:
349
+ return (self.x + self.w // 2, self.y + self.h - 1)
350
+
351
+ @property
352
+ def left_mid(self) -> tuple[int, int]:
353
+ return (self.x, self.y + self.h // 2)
354
+
355
+ @property
356
+ def right_mid(self) -> tuple[int, int]:
357
+ return (self.x + self.w - 1, self.y + self.h // 2)
358
+
359
+ @property
360
+ def center(self) -> tuple[int, int]:
361
+ return (self.x + self.w // 2, self.y + self.h // 2)
362
+
363
+
364
+ # --------------------------------------------------------------------------
365
+ # --- rasterizer sizing ---
366
+ # --------------------------------------------------------------------------
367
+
368
+ _MAX_LABEL_CONTENT_WIDTH = 20
369
+ _MIN_BOX_W = 5
370
+ _MIN_BOX_H = 3
371
+
372
+
373
+ def _box_dims(label: str) -> tuple[int, int]:
374
+ """Content-driven box extents (cells), used both to size the grandalf
375
+ ``VertexViewer`` and later to actually draw the box — sizing always goes
376
+ through :func:`visual_len` so wide glyphs are never under-reserved (see
377
+ the design doc's CJK open risk)."""
378
+ lines = wrap_text(label or "", _MAX_LABEL_CONTENT_WIDTH) or [""]
379
+ content_w = max((visual_len(line) for line in lines), default=0)
380
+ w = max(content_w + 4, _MIN_BOX_W)
381
+ h = max(len(lines) + 2, _MIN_BOX_H)
382
+ return w, h
383
+
384
+
385
+ # --------------------------------------------------------------------------
386
+ # --- adapter ---
387
+ # --------------------------------------------------------------------------
388
+
389
+ _GAP_X = 3 # minimum border-to-border gap between boxes in one rank/band
390
+ _ROW_GAP = 2 # minimum gap between rank bands
391
+ _COMPONENT_GUTTER = 4 # gap between independently-laid-out components
392
+
393
+
394
+ def _native_extents(
395
+ direction: Direction, box_w: int, box_h: int
396
+ ) -> tuple[int, int]:
397
+ """Dimensions fed to grandalf's VertexViewer, and used for our own
398
+ band/column snap math. TB/BT lay out in their real orientation; LR/RL
399
+ swap w/h before layout so grandalf's in-layer spacing reserves the right
400
+ amount of room along each post-transpose axis (see module docstring)."""
401
+ if direction in (Direction.LR, Direction.RL):
402
+ return box_h, box_w
403
+ return box_w, box_h
404
+
405
+
406
+ def _place_nodes(
407
+ nodes: list[FlowNode], edges: list[FlowEdge], direction: Direction
408
+ ) -> dict[str, BoxRect]:
409
+ dims = {n.id: _box_dims(n.label) for n in nodes}
410
+ native = {
411
+ n.id: _native_extents(direction, *dims[n.id]) for n in nodes
412
+ }
413
+
414
+ vertices: dict[str, Vertex] = {}
415
+ for n in nodes:
416
+ v = Vertex(n.id)
417
+ nw, nh = native[n.id]
418
+ v.view = VertexViewer(w=nw, h=nh)
419
+ vertices[n.id] = v
420
+
421
+ grandalf_edges = [
422
+ Edge(vertices[e.src], vertices[e.dst])
423
+ for e in edges
424
+ if e.src != e.dst and e.src in vertices and e.dst in vertices
425
+ ]
426
+
427
+ graph = Graph(list(vertices.values()), grandalf_edges)
428
+
429
+ # node_id -> (col, band_top) in the shared native (pre-transform) frame.
430
+ placements: dict[str, tuple[int, int]] = {}
431
+ running_x_offset = 0
432
+
433
+ for component in graph.C:
434
+ comp_vertices = list(component.sV)
435
+ if not comp_vertices:
436
+ continue
437
+ sug = SugiyamaLayout(component)
438
+ sug.xspace = _GAP_X
439
+ sug.yspace = _ROW_GAP
440
+ sug.init_all()
441
+ sug.draw()
442
+
443
+ ranks: dict[int, list[Vertex]] = defaultdict(list)
444
+ for v in comp_vertices:
445
+ ranks[sug.grx[v].rank].append(v)
446
+
447
+ band_top_for_rank: dict[int, int] = {}
448
+ cursor = 0
449
+ for r in sorted(ranks):
450
+ band_top_for_rank[r] = cursor
451
+ max_h = max(native[v.data][1] for v in ranks[r])
452
+ cursor += max_h + _ROW_GAP
453
+
454
+ min_x = min(v.view.xy[0] for v in comp_vertices)
455
+ provisional = {v: round(v.view.xy[0] - min_x) for v in comp_vertices}
456
+
457
+ actual_col: dict[Vertex, int] = {}
458
+ for r in sorted(ranks):
459
+ row_nodes = sorted(ranks[r], key=lambda v: provisional[v])
460
+ prev_col: int | None = None
461
+ prev_w = 0
462
+ for v in row_nodes:
463
+ nw = native[v.data][0]
464
+ if prev_col is None:
465
+ col = provisional[v]
466
+ else:
467
+ min_col = prev_col + prev_w // 2 + nw // 2 + _GAP_X
468
+ col = max(provisional[v], min_col)
469
+ actual_col[v] = col
470
+ prev_col, prev_w = col, nw
471
+
472
+ min_col = min(actual_col.values())
473
+ if min_col < 0:
474
+ for v in actual_col:
475
+ actual_col[v] -= min_col
476
+
477
+ comp_width = max(
478
+ actual_col[v] + native[v.data][0] // 2 for v in comp_vertices
479
+ )
480
+ offset = running_x_offset
481
+ for v in comp_vertices:
482
+ col = actual_col[v] + offset
483
+ band_top = band_top_for_rank[sug.grx[v].rank]
484
+ placements[v.data] = (col, band_top)
485
+ running_x_offset += comp_width + _COMPONENT_GUTTER
486
+
487
+ if not placements:
488
+ return {}
489
+
490
+ band_axis_total = max(
491
+ band_top + native[node_id][1] for node_id, (_, band_top) in placements.items()
492
+ )
493
+
494
+ rects: dict[str, BoxRect] = {}
495
+ for n in nodes:
496
+ if n.id not in placements:
497
+ continue
498
+ col, band_top = placements[n.id]
499
+ native_w, native_h = native[n.id]
500
+ box_w, box_h = dims[n.id]
501
+ center = col - native_w // 2
502
+ if direction is Direction.TB:
503
+ x, y = center, band_top
504
+ elif direction is Direction.BT:
505
+ x, y = center, band_axis_total - (band_top + native_h)
506
+ elif direction is Direction.LR:
507
+ x, y = band_top, center
508
+ else: # Direction.RL
509
+ x, y = band_axis_total - (band_top + native_h), center
510
+ rects[n.id] = BoxRect(x=x, y=y, w=box_w, h=box_h)
511
+
512
+ min_x = min(r.x for r in rects.values())
513
+ min_y = min(r.y for r in rects.values())
514
+ shift_x = -min_x if min_x < 0 else 0
515
+ shift_y = -min_y if min_y < 0 else 0
516
+ if shift_x or shift_y:
517
+ for node_id, r in rects.items():
518
+ rects[node_id] = BoxRect(x=r.x + shift_x, y=r.y + shift_y, w=r.w, h=r.h)
519
+
520
+ return rects
521
+
522
+
523
+ # --------------------------------------------------------------------------
524
+ # --- router (placeholder — full orthogonal router is a later phase) ---
525
+ # --------------------------------------------------------------------------
526
+
527
+
528
+ def _facing_anchor(this: BoxRect, other: BoxRect) -> tuple[int, int]:
529
+ """Pick the border midpoint of ``this`` box facing toward ``other``."""
530
+ tcx, tcy = this.center
531
+ ocx, ocy = other.center
532
+ dx, dy = ocx - tcx, ocy - tcy
533
+ if abs(dy) >= abs(dx):
534
+ return this.bottom_mid if dy >= 0 else this.top_mid
535
+ return this.right_mid if dx >= 0 else this.left_mid
536
+
537
+
538
+ def _draw_edge_stub(canvas: Canvas, rects: dict[str, BoxRect], edge: FlowEdge) -> None:
539
+ """Draw a placeholder straight/L-shaped line between two placed boxes.
540
+
541
+ Self-loops and edges referencing an unplaced node id are silently
542
+ skipped (self-loops are excluded from the grandalf graph — see module
543
+ docstring — and are a later phase's job to draw; a dangling reference
544
+ is a defensive no-op, never a crash).
545
+ """
546
+ if edge.src == edge.dst:
547
+ return
548
+ src_rect = rects.get(edge.src)
549
+ dst_rect = rects.get(edge.dst)
550
+ if src_rect is None or dst_rect is None:
551
+ return
552
+ x0, y0 = _facing_anchor(src_rect, dst_rect)
553
+ x1, y1 = _facing_anchor(dst_rect, src_rect)
554
+ if x0 == x1 or y0 == y1:
555
+ canvas.draw_segment(x0, y0, x1, y1, edge.style)
556
+ else:
557
+ canvas.draw_segment(x0, y0, x0, y1, edge.style)
558
+ canvas.draw_segment(x0, y1, x1, y1, edge.style)
559
+
560
+
561
+ # --------------------------------------------------------------------------
562
+ # --- public entry point ---
563
+ # --------------------------------------------------------------------------
564
+
565
+
566
+ def layout_flowgraph(g: FlowGraph, width: int) -> list[str]:
567
+ """Lay out and rasterize a parsed flowchart to char-grid lines.
568
+
569
+ Total function: never raises for well-typed input. An empty graph (no
570
+ nodes) returns ``[]`` — the orchestrator (a later phase's
571
+ ``render_flowchart``) treats that as "nothing to draw" and degrades to
572
+ a raw echo.
573
+
574
+ Args:
575
+ g: Parsed flowchart.
576
+ width: Advisory terminal width — unused. Like the other native
577
+ renderers in this package, layout sizes to content and may
578
+ overflow; wrapping happens at the node-label level, not by
579
+ constraining the overall canvas.
580
+
581
+ Returns:
582
+ Rendered lines: monochrome unicode box-drawing, no ANSI.
583
+ """
584
+ del width
585
+ if not g.nodes:
586
+ return []
587
+ try:
588
+ rects = _place_nodes(g.nodes, g.edges, g.direction)
589
+ if not rects:
590
+ return []
591
+ max_x = max(r.x + r.w for r in rects.values())
592
+ max_y = max(r.y + r.h for r in rects.values())
593
+ canvas = Canvas(max_x, max_y)
594
+ for n in g.nodes:
595
+ rect = rects.get(n.id)
596
+ if rect is not None:
597
+ canvas.draw_box(rect, n.shape, n.label)
598
+ for e in g.edges:
599
+ _draw_edge_stub(canvas, rects, e)
600
+ return canvas.to_lines()
601
+ except Exception:
602
+ return []