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.
- {termrender-4.3.2 → termrender-4.4.0}/CHANGELOG.md +11 -0
- {termrender-4.3.2 → termrender-4.4.0}/PKG-INFO +2 -1
- {termrender-4.3.2 → termrender-4.4.0}/pyproject.toml +1 -0
- termrender-4.4.0/src/termrender/renderers/mermaid_flow_layout.py +602 -0
- termrender-4.4.0/src/termrender/renderers/mermaid_flow_parser.py +406 -0
- termrender-4.4.0/tests/test_mermaid_flow_layout.py +386 -0
- termrender-4.4.0/tests/test_mermaid_flow_parser.py +356 -0
- {termrender-4.3.2 → termrender-4.4.0}/.github/workflows/publish.yml +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/.gitignore +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/CLAUDE.md +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/LICENSE +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/README.md +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/design.json +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/requirements.json +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/scripts/build-mermaid-ascii.sh +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/src/termrender/CLAUDE.md +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/src/termrender/__init__.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/src/termrender/__main__.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/src/termrender/_bin/mermaid-ascii-darwin-arm64 +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/src/termrender/_mermaid_bin.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/src/termrender/blocks.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/src/termrender/emit.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/src/termrender/layout.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/src/termrender/parser.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/src/termrender/py.typed +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/CLAUDE.md +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/__init__.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/borders.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/charts.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/code.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/columns.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/diff.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/divider.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/mermaid.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/mermaid_flow_model.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/mermaid_gantt.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/mermaid_journey.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/mermaid_mindmap.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/mermaid_pie.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/mermaid_prelude.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/mermaid_sequence.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/mermaid_timeline.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/panel.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/quote.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/stat.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/table.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/text.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/timeline.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/src/termrender/renderers/tree.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/src/termrender/style.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/tests/__init__.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/tests/test_charts.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/tests/test_cli_contract.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/tests/test_column_alignment.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/tests/test_diff.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/tests/test_inline_badge.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/tests/test_linebreak.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/tests/test_mermaid_compat.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/tests/test_mermaid_dispatch.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/tests/test_mermaid_flow_model.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/tests/test_mermaid_gantt.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/tests/test_mermaid_journey.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/tests/test_mermaid_mindmap.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/tests/test_mermaid_pie.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/tests/test_mermaid_sequence.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/tests/test_mermaid_timeline.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/tests/test_myst_gaps.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/tests/test_stat.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/tests/test_tasklist.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/tests/test_timeline.py +0 -0
- {termrender-4.3.2 → termrender-4.4.0}/tests/test_variable_colons.py +0 -0
- {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
|
+
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
|
|
@@ -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 []
|