termrender 4.6.1__tar.gz → 4.6.3__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 (77) hide show
  1. {termrender-4.6.1 → termrender-4.6.3}/CHANGELOG.md +30 -0
  2. {termrender-4.6.1 → termrender-4.6.3}/PKG-INFO +1 -1
  3. {termrender-4.6.1 → termrender-4.6.3}/src/termrender/renderers/mermaid_flow.py +19 -1
  4. {termrender-4.6.1 → termrender-4.6.3}/src/termrender/renderers/mermaid_flow_layout.py +120 -54
  5. {termrender-4.6.1 → termrender-4.6.3}/tests/test_mermaid_flow.py +87 -2
  6. {termrender-4.6.1 → termrender-4.6.3}/tests/test_mermaid_flow_corpus.py +12 -0
  7. {termrender-4.6.1 → termrender-4.6.3}/tests/test_mermaid_flow_layout.py +108 -18
  8. {termrender-4.6.1 → termrender-4.6.3}/uv.lock +23 -0
  9. {termrender-4.6.1 → termrender-4.6.3}/.github/workflows/publish.yml +0 -0
  10. {termrender-4.6.1 → termrender-4.6.3}/.gitignore +0 -0
  11. {termrender-4.6.1 → termrender-4.6.3}/CLAUDE.md +0 -0
  12. {termrender-4.6.1 → termrender-4.6.3}/LICENSE +0 -0
  13. {termrender-4.6.1 → termrender-4.6.3}/README.md +0 -0
  14. {termrender-4.6.1 → termrender-4.6.3}/design.json +0 -0
  15. {termrender-4.6.1 → termrender-4.6.3}/pyproject.toml +0 -0
  16. {termrender-4.6.1 → termrender-4.6.3}/requirements.json +0 -0
  17. {termrender-4.6.1 → termrender-4.6.3}/scripts/build-mermaid-ascii.sh +0 -0
  18. {termrender-4.6.1 → termrender-4.6.3}/scripts/mermaid_flow_parity.py +0 -0
  19. {termrender-4.6.1 → termrender-4.6.3}/src/termrender/CLAUDE.md +0 -0
  20. {termrender-4.6.1 → termrender-4.6.3}/src/termrender/__init__.py +0 -0
  21. {termrender-4.6.1 → termrender-4.6.3}/src/termrender/__main__.py +0 -0
  22. {termrender-4.6.1 → termrender-4.6.3}/src/termrender/_bin/mermaid-ascii-darwin-arm64 +0 -0
  23. {termrender-4.6.1 → termrender-4.6.3}/src/termrender/_mermaid_bin.py +0 -0
  24. {termrender-4.6.1 → termrender-4.6.3}/src/termrender/blocks.py +0 -0
  25. {termrender-4.6.1 → termrender-4.6.3}/src/termrender/emit.py +0 -0
  26. {termrender-4.6.1 → termrender-4.6.3}/src/termrender/layout.py +0 -0
  27. {termrender-4.6.1 → termrender-4.6.3}/src/termrender/parser.py +0 -0
  28. {termrender-4.6.1 → termrender-4.6.3}/src/termrender/py.typed +0 -0
  29. {termrender-4.6.1 → termrender-4.6.3}/src/termrender/renderers/CLAUDE.md +0 -0
  30. {termrender-4.6.1 → termrender-4.6.3}/src/termrender/renderers/__init__.py +0 -0
  31. {termrender-4.6.1 → termrender-4.6.3}/src/termrender/renderers/borders.py +0 -0
  32. {termrender-4.6.1 → termrender-4.6.3}/src/termrender/renderers/charts.py +0 -0
  33. {termrender-4.6.1 → termrender-4.6.3}/src/termrender/renderers/code.py +0 -0
  34. {termrender-4.6.1 → termrender-4.6.3}/src/termrender/renderers/columns.py +0 -0
  35. {termrender-4.6.1 → termrender-4.6.3}/src/termrender/renderers/diff.py +0 -0
  36. {termrender-4.6.1 → termrender-4.6.3}/src/termrender/renderers/divider.py +0 -0
  37. {termrender-4.6.1 → termrender-4.6.3}/src/termrender/renderers/mermaid.py +0 -0
  38. {termrender-4.6.1 → termrender-4.6.3}/src/termrender/renderers/mermaid_flow_model.py +0 -0
  39. {termrender-4.6.1 → termrender-4.6.3}/src/termrender/renderers/mermaid_flow_parser.py +0 -0
  40. {termrender-4.6.1 → termrender-4.6.3}/src/termrender/renderers/mermaid_gantt.py +0 -0
  41. {termrender-4.6.1 → termrender-4.6.3}/src/termrender/renderers/mermaid_journey.py +0 -0
  42. {termrender-4.6.1 → termrender-4.6.3}/src/termrender/renderers/mermaid_mindmap.py +0 -0
  43. {termrender-4.6.1 → termrender-4.6.3}/src/termrender/renderers/mermaid_pie.py +0 -0
  44. {termrender-4.6.1 → termrender-4.6.3}/src/termrender/renderers/mermaid_prelude.py +0 -0
  45. {termrender-4.6.1 → termrender-4.6.3}/src/termrender/renderers/mermaid_sequence.py +0 -0
  46. {termrender-4.6.1 → termrender-4.6.3}/src/termrender/renderers/mermaid_timeline.py +0 -0
  47. {termrender-4.6.1 → termrender-4.6.3}/src/termrender/renderers/panel.py +0 -0
  48. {termrender-4.6.1 → termrender-4.6.3}/src/termrender/renderers/quote.py +0 -0
  49. {termrender-4.6.1 → termrender-4.6.3}/src/termrender/renderers/stat.py +0 -0
  50. {termrender-4.6.1 → termrender-4.6.3}/src/termrender/renderers/table.py +0 -0
  51. {termrender-4.6.1 → termrender-4.6.3}/src/termrender/renderers/text.py +0 -0
  52. {termrender-4.6.1 → termrender-4.6.3}/src/termrender/renderers/timeline.py +0 -0
  53. {termrender-4.6.1 → termrender-4.6.3}/src/termrender/renderers/tree.py +0 -0
  54. {termrender-4.6.1 → termrender-4.6.3}/src/termrender/style.py +0 -0
  55. {termrender-4.6.1 → termrender-4.6.3}/tests/__init__.py +0 -0
  56. {termrender-4.6.1 → termrender-4.6.3}/tests/test_charts.py +0 -0
  57. {termrender-4.6.1 → termrender-4.6.3}/tests/test_cli_contract.py +0 -0
  58. {termrender-4.6.1 → termrender-4.6.3}/tests/test_column_alignment.py +0 -0
  59. {termrender-4.6.1 → termrender-4.6.3}/tests/test_diff.py +0 -0
  60. {termrender-4.6.1 → termrender-4.6.3}/tests/test_inline_badge.py +0 -0
  61. {termrender-4.6.1 → termrender-4.6.3}/tests/test_linebreak.py +0 -0
  62. {termrender-4.6.1 → termrender-4.6.3}/tests/test_mermaid_compat.py +0 -0
  63. {termrender-4.6.1 → termrender-4.6.3}/tests/test_mermaid_dispatch.py +0 -0
  64. {termrender-4.6.1 → termrender-4.6.3}/tests/test_mermaid_flow_model.py +0 -0
  65. {termrender-4.6.1 → termrender-4.6.3}/tests/test_mermaid_flow_parser.py +0 -0
  66. {termrender-4.6.1 → termrender-4.6.3}/tests/test_mermaid_flow_shapes.py +0 -0
  67. {termrender-4.6.1 → termrender-4.6.3}/tests/test_mermaid_gantt.py +0 -0
  68. {termrender-4.6.1 → termrender-4.6.3}/tests/test_mermaid_journey.py +0 -0
  69. {termrender-4.6.1 → termrender-4.6.3}/tests/test_mermaid_mindmap.py +0 -0
  70. {termrender-4.6.1 → termrender-4.6.3}/tests/test_mermaid_pie.py +0 -0
  71. {termrender-4.6.1 → termrender-4.6.3}/tests/test_mermaid_sequence.py +0 -0
  72. {termrender-4.6.1 → termrender-4.6.3}/tests/test_mermaid_timeline.py +0 -0
  73. {termrender-4.6.1 → termrender-4.6.3}/tests/test_myst_gaps.py +0 -0
  74. {termrender-4.6.1 → termrender-4.6.3}/tests/test_stat.py +0 -0
  75. {termrender-4.6.1 → termrender-4.6.3}/tests/test_tasklist.py +0 -0
  76. {termrender-4.6.1 → termrender-4.6.3}/tests/test_timeline.py +0 -0
  77. {termrender-4.6.1 → termrender-4.6.3}/tests/test_variable_colons.py +0 -0
@@ -1,6 +1,36 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v4.6.3 (2026-07-07)
5
+
6
+ ### Bug Fixes
7
+
8
+ - Commit uv.lock resolution for grandalf runtime dependency
9
+ ([`0b2a39e`](https://github.com/crouton-labs/termrender/commit/0b2a39ee4440a907c309328920062e543fd5e47e))
10
+
11
+
12
+ ## v4.6.2 (2026-07-07)
13
+
14
+ ### Bug Fixes
15
+
16
+ - **mermaid-flow**: Preserve edge labels, render self-loops, sanitize degraded echo
17
+ ([`263100f`](https://github.com/crouton-labs/termrender/commit/263100f16e1a7f0044497f2dedd0fd10f0831626))
18
+
19
+ - Router now draws all edge polylines+arrowheads first, then all edge labels last (reserving each
20
+ label's cells, plus a margin), so a later edge's line can never silently erase an earlier edge's
21
+ label and two labels sharing a lane render with visible separation instead of concatenating. -
22
+ Rank-band gaps widen to fit an adjacent-rank edge's label in full (_rank_gap_overrides), fixing
23
+ short LR/forward-adjacent labeled edges (e.g. "hello") that were previously clipped to a couple of
24
+ characters. - Raw-echo degradation now strips any box-drawing/geometric glyph (\u2500-\u259f,
25
+ \u25a0-\u25ff) present in the malformed source itself, so a degraded echo can never be misdetected
26
+ as a successful render. - Hardened green-theatre tests: self-loop tests now assert real loop
27
+ geometry (arrowhead + glyphs past the box border) instead of "non-empty output"; the
28
+ edge-visibility test now inspects the guaranteed-clear inter-box gap instead of accepting
29
+ box-border glyphs; cycle/labeled-back-edge/direction/never-raises tests gained genuine
30
+ topology/degradation assertions in place of crash-only checks. - Updated the two corpus goldens
31
+ (pipe-label, inline-label) whose rendered height changed under the new label-aware gap widening.
32
+
33
+
4
34
  ## v4.6.1 (2026-07-07)
5
35
 
6
36
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: termrender
3
- Version: 4.6.1
3
+ Version: 4.6.3
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
@@ -54,11 +54,21 @@ Known degradations (by design, not bugs)
54
54
 
55
55
  from __future__ import annotations
56
56
 
57
+ import re
58
+
57
59
  from termrender.renderers.mermaid_flow_layout import layout_flowgraph
58
60
  from termrender.renderers.mermaid_flow_parser import FlowchartError, parse
59
61
 
60
62
  __all__ = ["render_flowchart"]
61
63
 
64
+ # Same ranges the downstream attach viewer uses to detect render *success*
65
+ # (see the module docstring's "Degradation contract"). The raw-echo path
66
+ # must never contain one of these, even when the malformed/degenerate
67
+ # source itself happens to contain a literal box-drawing or geometric
68
+ # glyph (e.g. hand-typed "\u250c" in otherwise-non-flowchart text) — otherwise a
69
+ # failed render could be misdetected as a successful one.
70
+ _GLYPH_RANGE_RE = re.compile("[\u2500-\u259f\u25a0-\u25ff]")
71
+
62
72
 
63
73
  def render_flowchart(source: str, width: int) -> list[str]:
64
74
  """Render a mermaid ``graph``/``flowchart`` source to unicode lines.
@@ -113,4 +123,12 @@ def render_flowchart(source: str, width: int) -> list[str]:
113
123
 
114
124
 
115
125
  def _raw_echo(source: str) -> list[str]:
116
- return [line.rstrip() for line in source.splitlines()]
126
+ """Plain echo of ``source``'s lines, with any box-drawing/geometric
127
+ glyph (the same ``\\u2500-\\u259f``/``\\u25a0-\\u25ff`` ranges the
128
+ downstream viewer checks for) replaced by ``?`` — the degradation
129
+ contract requires NO such glyph survive, even one present verbatim in
130
+ malformed/degenerate source, not just glyphs this renderer would have
131
+ drawn itself."""
132
+ return [
133
+ _GLYPH_RANGE_RE.sub("?", line.rstrip()) for line in source.splitlines()
134
+ ]
@@ -721,6 +721,38 @@ def _box_dims(label: str, shape: NodeShape = NodeShape.RECT) -> tuple[int, int]:
721
721
  _GAP_X = 3 # minimum border-to-border gap between boxes in one rank/band
722
722
  _ROW_GAP = 2 # minimum gap between rank bands
723
723
  _COMPONENT_GUTTER = 4 # gap between independently-laid-out components
724
+ _LABEL_GAP_PAD = 1 # cells of breathing room either side of a label's own text
725
+ # when it forces a rank-band gap wider than _ROW_GAP.
726
+
727
+
728
+ def _rank_gap_overrides(
729
+ edges: list[FlowEdge], rank_of: dict[str, int]
730
+ ) -> dict[int, int]:
731
+ """Minimum inter-rank-band gap keyed by the *lower* rank of each
732
+ adjacent-rank transition, widened past ``_ROW_GAP`` wherever a labeled
733
+ edge directly connects that transition's two ranks. A forward edge
734
+ between adjacent ranks routes as a single straight run (or a Z path
735
+ that still crosses the same inter-rank band) whose only clear space is
736
+ this gap — at the base ``_ROW_GAP`` a label wider than a couple of
737
+ cells has nowhere to go but onto the boxes it connects, which is
738
+ exactly the short LR/adjacent-rank clipped-label bug this closes.
739
+ Non-adjacent-rank edges (back-edges, multi-rank spans) don't constrain
740
+ the gap here — they route through their own side lane, not this band.
741
+ """
742
+ overrides: dict[int, int] = {}
743
+ for e in edges:
744
+ if e.src == e.dst or not e.label:
745
+ continue
746
+ r_src = rank_of.get(e.src)
747
+ r_dst = rank_of.get(e.dst)
748
+ if r_src is None or r_dst is None:
749
+ continue
750
+ lo_r, hi_r = (r_src, r_dst) if r_src <= r_dst else (r_dst, r_src)
751
+ if hi_r - lo_r != 1:
752
+ continue
753
+ needed = visual_len(e.label) + 2 * _LABEL_GAP_PAD
754
+ overrides[lo_r] = max(overrides.get(lo_r, _ROW_GAP), needed)
755
+ return overrides
724
756
 
725
757
 
726
758
  def _native_extents(
@@ -780,12 +812,15 @@ def _place_nodes(
780
812
  for v in comp_vertices:
781
813
  ranks[sug.grx[v].rank].append(v)
782
814
 
815
+ rank_of = {v.data: sug.grx[v].rank for v in comp_vertices}
816
+ gap_overrides = _rank_gap_overrides(edges, rank_of)
817
+
783
818
  band_top_for_rank: dict[int, int] = {}
784
819
  cursor = 0
785
820
  for r in sorted(ranks):
786
821
  band_top_for_rank[r] = cursor
787
822
  max_h = max(native[v.data][1] for v in ranks[r])
788
- cursor += max_h + _ROW_GAP
823
+ cursor += max_h + gap_overrides.get(r, _ROW_GAP)
789
824
 
790
825
  min_x = min(v.view.xy[0] for v in comp_vertices)
791
826
  provisional = {v: round(v.view.xy[0] - min_x) for v in comp_vertices}
@@ -1214,6 +1249,17 @@ def _label_positions(length: int, center: int, lo: int, hi: int) -> list[int]:
1214
1249
  return order
1215
1250
 
1216
1251
 
1252
+ def _reserve_label_margin(canvas: Canvas, x: int, y: int) -> None:
1253
+ """Mark one cell just past a label's edge as reserved, without
1254
+ altering whatever character (if any) already occupies it — a one-cell
1255
+ buffer so a second label placed on an adjacent/overlapping segment
1256
+ (e.g. two back-edge labels sharing a row) lands with visible separation
1257
+ instead of butting directly up against the first label's text."""
1258
+ if x < 0 or y < 0:
1259
+ return
1260
+ canvas.set_char(x, y, canvas.get_char(x, y), reserve=True)
1261
+
1262
+
1217
1263
  def _draw_label_on_segment(
1218
1264
  canvas: Canvas, a: tuple[int, int], b: tuple[int, int], label: str
1219
1265
  ) -> None:
@@ -1226,7 +1272,12 @@ def _draw_label_on_segment(
1226
1272
  self-loop label placement. Shifts along the search axis to the nearest
1227
1273
  span/row fully clear of a reserved (box) cell; if none exists, writes
1228
1274
  at the ideal placement anyway (overflow tolerated) but skips individual
1229
- reserved cells — a label never corrupts a box.
1275
+ reserved cells — a label never corrupts a box. Every written label cell
1276
+ is itself marked reserved (see :func:`layout_flowgraph`'s two-pass draw
1277
+ order): labels draw only after every edge's line+arrowheads are already
1278
+ on the canvas, so nothing draws over a label afterwards, and a *later*
1279
+ label's own clear-run search treats an earlier label's cells the same
1280
+ as a box's — skip, don't overwrite.
1230
1281
  """
1231
1282
  length = visual_len(label)
1232
1283
  if length <= 0:
@@ -1246,12 +1297,14 @@ def _draw_label_on_segment(
1246
1297
  run = cells(start)
1247
1298
  if not any(canvas.is_reserved(x, y) for x, y in run):
1248
1299
  for (px, py), ch in zip(run, label):
1249
- canvas.draw_glyph(px, py, ch)
1300
+ canvas.set_char(px, py, ch, reserve=True)
1301
+ _reserve_label_margin(canvas, start - 1, row)
1302
+ _reserve_label_margin(canvas, start + length, row)
1250
1303
  return
1251
1304
  start = max(lo, min(center - length // 2, hi - length + 1))
1252
1305
  for (px, py), ch in zip(cells(start), label):
1253
1306
  if not canvas.is_reserved(px, py):
1254
- canvas.draw_glyph(px, py, ch)
1307
+ canvas.set_char(px, py, ch, reserve=True)
1255
1308
  return
1256
1309
 
1257
1310
  # Vertical segment: the label still reads horizontally, centered on
@@ -1272,11 +1325,13 @@ def _draw_label_on_segment(
1272
1325
  run = row_cells(row)
1273
1326
  if not any(canvas.is_reserved(x, y) for x, y in run):
1274
1327
  for (px, py), ch in zip(run, label):
1275
- canvas.draw_glyph(px, py, ch)
1328
+ canvas.set_char(px, py, ch, reserve=True)
1329
+ _reserve_label_margin(canvas, start_x - 1, row)
1330
+ _reserve_label_margin(canvas, start_x + length, row)
1276
1331
  return
1277
1332
  for (px, py), ch in zip(row_cells(center_row), label):
1278
1333
  if not canvas.is_reserved(px, py):
1279
- canvas.draw_glyph(px, py, ch)
1334
+ canvas.set_char(px, py, ch, reserve=True)
1280
1335
 
1281
1336
 
1282
1337
  def _draw_polyline(canvas: Canvas, points: list[tuple[int, int]], style: EdgeStyle) -> None:
@@ -1322,67 +1377,61 @@ def _self_loop_points(
1322
1377
  return [(ax, lo), (far, lo), (far, hi), (ax, hi)]
1323
1378
 
1324
1379
 
1325
- def _route_edge(
1326
- canvas: Canvas,
1380
+ def _route_edge_path(
1327
1381
  rects: dict[str, BoxRect],
1328
1382
  direction: Direction,
1329
1383
  edge: FlowEdge,
1330
1384
  lane_counter: list[int],
1331
1385
  self_loop_counter: dict[str, int],
1332
- ) -> None:
1333
- """Draw one edge's full orthogonal path: anchors chosen by relative
1334
- rank position, an L/Z/C path shape, arrowheads, and an edge label.
1335
- Dangling node-id references are a defensive no-op (the parser
1336
- guarantees valid endpoints; this module never crashes on one).
1386
+ ) -> list[tuple[int, int]] | None:
1387
+ """Compute one edge's polyline points: anchors chosen by relative rank
1388
+ position, an L/Z/C path shape. Pure path computation only — drawing is
1389
+ the caller's job, split into two passes across *all* edges (see
1390
+ :func:`layout_flowgraph`) so that no edge's line ever overwrites
1391
+ another edge's already-placed label. Dangling node-id references
1392
+ return ``None`` (defensive no-op — the parser guarantees valid
1393
+ endpoints; this module never crashes on one).
1337
1394
  """
1338
1395
  src_rect = rects.get(edge.src)
1339
1396
  dst_rect = rects.get(edge.dst)
1340
1397
  if src_rect is None or dst_rect is None:
1341
- return
1398
+ return None
1342
1399
 
1343
1400
  if edge.src == edge.dst:
1344
1401
  lane = self_loop_counter.get(edge.src, 0)
1345
1402
  self_loop_counter[edge.src] = lane + 1
1346
- points = _self_loop_points(direction, src_rect, lane)
1347
- else:
1348
- src_extent = _rank_extent(direction, src_rect)
1349
- dst_extent = _rank_extent(direction, dst_rect)
1350
- same_rank = not (dst_extent[1] < src_extent[0] or src_extent[1] < dst_extent[0])
1403
+ return _self_loop_points(direction, src_rect, lane)
1404
+
1405
+ src_extent = _rank_extent(direction, src_rect)
1406
+ dst_extent = _rank_extent(direction, dst_rect)
1407
+ same_rank = not (dst_extent[1] < src_extent[0] or src_extent[1] < dst_extent[0])
1408
+
1409
+ if same_rank:
1410
+ return [_facing_anchor(src_rect, dst_rect), _facing_anchor(dst_rect, src_rect)]
1411
+
1412
+ src_center = (src_extent[0] + src_extent[1]) / 2
1413
+ dst_center = (dst_extent[0] + dst_extent[1]) / 2
1414
+ forward = (dst_center > src_center) == _forward_increasing(direction)
1415
+ if forward:
1416
+ return _z_path(
1417
+ direction,
1418
+ _forward_exit(direction, src_rect),
1419
+ _forward_entry(direction, dst_rect),
1420
+ )
1351
1421
 
1352
- if same_rank:
1353
- points = [_facing_anchor(src_rect, dst_rect), _facing_anchor(dst_rect, src_rect)]
1354
- else:
1355
- src_center = (src_extent[0] + src_extent[1]) / 2
1356
- dst_center = (dst_extent[0] + dst_extent[1]) / 2
1357
- forward = (dst_center > src_center) == _forward_increasing(direction)
1358
- if forward:
1359
- points = _z_path(
1360
- direction,
1361
- _forward_exit(direction, src_rect),
1362
- _forward_entry(direction, dst_rect),
1363
- )
1364
- else:
1365
- lane = lane_counter[0]
1366
- lane_counter[0] += 1
1367
- lane_secondary = (
1368
- _lane_secondary_base(direction, src_rect, dst_rect)
1369
- + _LANE_MARGIN
1370
- + lane * _LANE_GAP
1371
- )
1372
- points = _lane_path(
1373
- direction,
1374
- _lane_anchor(direction, src_rect),
1375
- _lane_anchor(direction, dst_rect),
1376
- lane_secondary,
1377
- )
1378
-
1379
- _draw_polyline(canvas, points, edge.style)
1380
- _draw_arrowheads(canvas, points, edge)
1381
-
1382
- if edge.label:
1383
- seg = _longest_segment(points)
1384
- if seg is not None:
1385
- _draw_label_on_segment(canvas, *seg, edge.label)
1422
+ lane = lane_counter[0]
1423
+ lane_counter[0] += 1
1424
+ lane_secondary = (
1425
+ _lane_secondary_base(direction, src_rect, dst_rect)
1426
+ + _LANE_MARGIN
1427
+ + lane * _LANE_GAP
1428
+ )
1429
+ return _lane_path(
1430
+ direction,
1431
+ _lane_anchor(direction, src_rect),
1432
+ _lane_anchor(direction, dst_rect),
1433
+ lane_secondary,
1434
+ )
1386
1435
 
1387
1436
 
1388
1437
  # --------------------------------------------------------------------------
@@ -1451,10 +1500,27 @@ def layout_flowgraph(g: FlowGraph, width: int) -> list[str]:
1451
1500
  rect = rects.get(n.id)
1452
1501
  if rect is not None:
1453
1502
  canvas.draw_box(rect, n.shape, n.label)
1503
+ # Two passes across *all* edges, not one pass per edge: every
1504
+ # edge's line + arrowheads draw first, then every edge's label
1505
+ # draws last (see design doc). Interleaving line-then-label per
1506
+ # edge (the old order) let a later edge's line silently erase an
1507
+ # earlier edge's already-placed label whenever their paths shared
1508
+ # a cell — labels are only ever safe once no more lines are coming.
1454
1509
  lane_counter = [0]
1455
1510
  self_loop_counter: dict[str, int] = {}
1511
+ edge_paths: list[tuple[FlowEdge, list[tuple[int, int]]]] = []
1456
1512
  for e in g.edges:
1457
- _route_edge(canvas, rects, g.direction, e, lane_counter, self_loop_counter)
1513
+ points = _route_edge_path(rects, g.direction, e, lane_counter, self_loop_counter)
1514
+ if points is not None:
1515
+ edge_paths.append((e, points))
1516
+ for e, points in edge_paths:
1517
+ _draw_polyline(canvas, points, e.style)
1518
+ _draw_arrowheads(canvas, points, e)
1519
+ for e, points in edge_paths:
1520
+ if e.label:
1521
+ seg = _longest_segment(points)
1522
+ if seg is not None:
1523
+ _draw_label_on_segment(canvas, *seg, e.label)
1458
1524
  return canvas.to_lines()
1459
1525
  except Exception:
1460
1526
  return []
@@ -67,6 +67,33 @@ def test_inline_label_edge_shows_label_text():
67
67
  assert "go now" in text
68
68
 
69
69
 
70
+ def test_multiple_labeled_back_edges_do_not_erase_each_other():
71
+ # Regression: the router used to draw each edge's line+label inline in
72
+ # declaration order, so a later edge's line could silently overwrite an
73
+ # earlier edge's already-placed label. Both r1 and r2 must survive, and
74
+ # with real separation — not just "r1" as a bare substring of a
75
+ # concatenated "r2r1" run.
76
+ source = "graph TD\nA-->B\nB-->C\nC-->|r1|A\nC-->|r2|B\n"
77
+ lines = render_flowchart(source, width=80)
78
+ assert lines
79
+ text = "\n".join(lines)
80
+ assert "r1" in text
81
+ assert "r2" in text
82
+ assert "r1r2" not in text and "r2r1" not in text
83
+
84
+
85
+ def test_lr_short_edge_label_renders_in_full():
86
+ # Regression: the horizontal label placer used to constrain a label to
87
+ # the clear run between two adjacent reserved box borders, clipping
88
+ # "hello" down to just "el". The rank-band gap now widens to fit the
89
+ # label (see mermaid_flow_layout._rank_gap_overrides).
90
+ source = "graph LR\nA -->|hello| B\n"
91
+ lines = render_flowchart(source, width=80)
92
+ assert lines
93
+ text = "\n".join(lines)
94
+ assert "hello" in text
95
+
96
+
70
97
  # --------------------------------------------------------------------------
71
98
  # Labeled back-edge cycle — the exact shape that panics the Go binary
72
99
  # --------------------------------------------------------------------------
@@ -90,6 +117,40 @@ def test_labeled_back_edge_cycle_renders_all_boxes_label_and_arrow():
90
117
  assert row_a < row_b < row_c
91
118
 
92
119
 
120
+ # --------------------------------------------------------------------------
121
+ # Self-loop — a node edge back to itself
122
+ # --------------------------------------------------------------------------
123
+
124
+
125
+ def test_self_loop_renders_visible_loop_and_arrowhead():
126
+ # A renderer that silently dropped self-loops would still pass a bare
127
+ # "non-empty, contains A and B" smoke check — assert the loop's real
128
+ # geometry: an arrowhead, and glyphs extending past the node's own box
129
+ # border (a dropped loop renders only the bare box).
130
+ source = "graph TD\nA-->B\nA-->A\n"
131
+ lines = render_flowchart(source, width=80)
132
+ assert lines
133
+ text = "\n".join(lines)
134
+ assert "A" in text and "B" in text
135
+ assert _ARROW_RE.search(text)
136
+ box_right_col = max(line.index("\u2510") for line in lines if "\u2510" in line)
137
+ assert any(len(line.rstrip()) > box_right_col + 1 for line in lines), (
138
+ "expected loop glyphs extending past a node box's right border"
139
+ )
140
+
141
+
142
+ def test_labeled_self_loop_renders_loop_arrowhead_and_label():
143
+ source = "graph TD\nA-->|again|A\n"
144
+ lines = render_flowchart(source, width=80)
145
+ assert lines
146
+ text = "\n".join(lines)
147
+ assert "A" in text
148
+ assert "again" in text
149
+ assert _ARROW_RE.search(text)
150
+ box_right_col = max(line.index("\u2510") for line in lines if "\u2510" in line)
151
+ assert any(len(line.rstrip()) > box_right_col + 1 for line in lines)
152
+
153
+
93
154
  # --------------------------------------------------------------------------
94
155
  # Multi-parent DAG — both edges attach to the shared child
95
156
  # --------------------------------------------------------------------------
@@ -161,8 +222,32 @@ def test_empty_body_headed_diagram_raw_echoes():
161
222
  assert not _BOX_GLYPH_RE.search(text)
162
223
 
163
224
 
164
- def test_render_flowchart_never_raises_on_garbage_input():
165
- # Defensive: assorted odd inputs must never raise, always return a list.
225
+ def test_degraded_echo_sanitizes_box_glyphs_present_in_raw_source():
226
+ # Regression: the raw-echo path used to be a plain rstrip of the
227
+ # source, so a literal box-drawing/geometric glyph typed into
228
+ # otherwise-malformed source (e.g. a stray "\u250c") survived into the
229
+ # echo, which the downstream attach viewer could misdetect as a
230
+ # successful render. The echo must now contain NONE of those glyphs.
231
+ source = "not a diagram\n\u250c\n"
232
+ lines = render_flowchart(source, width=80)
233
+ text = "\n".join(lines)
234
+ assert not _BOX_GLYPH_RE.search(text)
235
+ assert lines[0] == "not a diagram"
236
+ assert "\u250c" not in lines[1]
237
+
238
+
239
+ def test_render_flowchart_never_raises_and_degrades_cleanly_on_garbage_input():
240
+ # Defensive: assorted odd inputs must never raise. A bare
241
+ # "isinstance(lines, list)" would also accept a renderer that returned
242
+ # `[]`, a raw echo, or a bogus boxed diagram for every case — assert
243
+ # the actual degradation semantics instead: whichever inputs don't
244
+ # render real box glyphs must be an *exact* rstripped echo of the
245
+ # source (the load-bearing contract), not merely "some list".
166
246
  for source in ("", "\n\n\n", "graph", "flowchart LR\n", "graph TD\nA-->\n"):
167
247
  lines = render_flowchart(source, width=80)
168
248
  assert isinstance(lines, list)
249
+ text = "\n".join(lines)
250
+ if not _BOX_GLYPH_RE.search(text):
251
+ assert lines == [line.rstrip() for line in source.splitlines()], (
252
+ f"degraded output for {source!r} must be an exact raw echo, got {lines!r}"
253
+ )
@@ -195,12 +195,17 @@ def test_edge_style_bidirectional_golden():
195
195
 
196
196
 
197
197
  def test_pipe_label_golden():
198
+ # The rank-band gap widens to fit the label in full (see
199
+ # mermaid_flow_layout._rank_gap_overrides) — a 2-char label needs a
200
+ # taller gap than the base _ROW_GAP alone provides.
198
201
  assert _lines("graph TD\nA-->|hi|B\n") == [
199
202
  "┌───┐",
200
203
  "│ A │",
201
204
  "└───┘",
205
+ " │",
202
206
  " hi",
203
207
  " │",
208
+ " │",
204
209
  "┌─▼─┐",
205
210
  "│ B │",
206
211
  "└───┘",
@@ -208,12 +213,19 @@ def test_pipe_label_golden():
208
213
 
209
214
 
210
215
  def test_inline_label_golden():
216
+ # Same rank-band widening as above, scaled to the longer "go now" label.
211
217
  assert _lines("graph TD\nA -- go now --> B\n") == [
212
218
  "┌───┐",
213
219
  "│ A │",
214
220
  "└───┘",
221
+ " │",
222
+ " │",
223
+ " │",
215
224
  "go now",
216
225
  " │",
226
+ " │",
227
+ " │",
228
+ " │",
217
229
  "┌─▼─┐",
218
230
  "│ B │",
219
231
  "└───┘",
@@ -27,6 +27,14 @@ from termrender.renderers.mermaid_flow_model import (
27
27
  )
28
28
 
29
29
  _BOX_GLYPH_RE = re.compile(r"[\u2500-\u259F\u25A0-\u25FF]")
30
+ _ARROW_RE = re.compile(r"[\u25b2\u25bc\u25b6\u25c0]") # ▲▼▶◀
31
+
32
+
33
+ def _row_of(lines: list[str], label: str) -> int:
34
+ for i, line in enumerate(lines):
35
+ if label in line:
36
+ return i
37
+ raise AssertionError(f"label {label!r} not found in rendered output: {lines!r}")
30
38
 
31
39
 
32
40
  def _node(id_: str, label: str | None = None, shape: NodeShape = NodeShape.RECT) -> FlowNode:
@@ -167,6 +175,10 @@ def test_wide_fanout_siblings_do_not_overlap():
167
175
 
168
176
 
169
177
  def test_cycle_lays_out_without_hanging():
178
+ # Real topology, not just "didn't hang": the forward chain A -> B -> C
179
+ # still orders top-to-bottom despite the C -> A back-edge, and a
180
+ # back-edge arrowhead actually lands (somewhere) rather than being
181
+ # silently dropped.
170
182
  g = FlowGraph(
171
183
  direction=Direction.TB,
172
184
  nodes=[_node("A"), _node("B"), _node("C")],
@@ -178,10 +190,17 @@ def test_cycle_lays_out_without_hanging():
178
190
  for label in ("A", "B", "C"):
179
191
  assert label in text
180
192
  assert any(_BOX_GLYPH_RE.search(line) for line in lines)
193
+ assert _ARROW_RE.search(text)
194
+ assert _row_of(lines, "A") < _row_of(lines, "B") < _row_of(lines, "C")
181
195
 
182
196
 
183
197
  def test_labeled_back_edge_cycle_does_not_crash():
184
- # The exact shape that panics the Go binary: a labeled back-edge.
198
+ # The exact shape that panics the Go binary: a labeled back-edge. Real
199
+ # assertions: the "retry" label itself survives, an arrowhead lands,
200
+ # and the forward chain's top-to-bottom order is unaffected by the
201
+ # back-edge — a renderer that silently dropped the labeled back-edge
202
+ # (but kept the two forward edges) would otherwise still pass this test
203
+ # if it only checked "didn't crash".
185
204
  g = FlowGraph(
186
205
  direction=Direction.TB,
187
206
  nodes=[_node("A"), _node("B"), _node("C")],
@@ -193,10 +212,19 @@ def test_labeled_back_edge_cycle_does_not_crash():
193
212
  )
194
213
  lines = layout_flowgraph(g, width=80)
195
214
  assert lines
215
+ text = "\n".join(lines)
196
216
  assert any(_BOX_GLYPH_RE.search(line) for line in lines)
217
+ assert "retry" in text
218
+ assert _ARROW_RE.search(text)
219
+ assert _row_of(lines, "A") < _row_of(lines, "B") < _row_of(lines, "C")
197
220
 
198
221
 
199
- def test_self_loop_does_not_crash():
222
+ def test_self_loop_renders_visible_loop_and_arrowhead():
223
+ # A renderer that silently dropped every self-loop would still pass a
224
+ # bare "non-empty output containing A and B" check — assert the loop's
225
+ # actual geometry instead: an arrowhead, and line glyphs extending
226
+ # visibly past the node box's own border (a dropped self-loop renders
227
+ # only the bare box, whose lines never exceed its own right border).
200
228
  g = FlowGraph(
201
229
  direction=Direction.TB,
202
230
  nodes=[_node("A"), _node("B")],
@@ -206,6 +234,27 @@ def test_self_loop_does_not_crash():
206
234
  assert lines
207
235
  text = "\n".join(lines)
208
236
  assert "A" in text and "B" in text
237
+ assert _ARROW_RE.search(text)
238
+ box_right_col = max(line.index("\u2510") for line in lines if "\u2510" in line)
239
+ assert any(len(line.rstrip()) > box_right_col + 1 for line in lines), (
240
+ "expected loop glyphs extending past a node box's right border"
241
+ )
242
+
243
+
244
+ def test_labeled_self_loop_renders_loop_arrowhead_and_label():
245
+ g = FlowGraph(
246
+ direction=Direction.TB,
247
+ nodes=[_node("A")],
248
+ edges=[_edge("A", "A", label="again")],
249
+ )
250
+ lines = layout_flowgraph(g, width=80)
251
+ assert lines
252
+ text = "\n".join(lines)
253
+ assert "A" in text
254
+ assert "again" in text
255
+ assert _ARROW_RE.search(text)
256
+ box_right_col = max(line.index("\u2510") for line in lines if "\u2510" in line)
257
+ assert any(len(line.rstrip()) > box_right_col + 1 for line in lines)
209
258
 
210
259
 
211
260
  # --------------------------------------------------------------------------
@@ -214,7 +263,11 @@ def test_self_loop_does_not_crash():
214
263
 
215
264
 
216
265
  @pytest.mark.parametrize("direction", [Direction.TB, Direction.LR, Direction.RL, Direction.BT])
217
- def test_all_directions_render_without_crashing(direction):
266
+ def test_all_directions_place_nodes_along_the_correct_axis(direction):
267
+ # A direction-blind renderer (e.g. one that ignored LR/RL/BT and always
268
+ # laid out top-to-bottom) would still pass a bare "doesn't crash, all
269
+ # labels present" check for every direction — assert each direction's
270
+ # actual rank-flow axis and sign instead.
218
271
  g = FlowGraph(
219
272
  direction=direction,
220
273
  nodes=[_node("A"), _node("B"), _node("C")],
@@ -222,11 +275,27 @@ def test_all_directions_render_without_crashing(direction):
222
275
  )
223
276
  lines = layout_flowgraph(g, width=80)
224
277
  assert lines
225
- text = "\n".join(lines)
226
- for label in ("A", "B", "C"):
227
- assert label in text
228
278
  assert any(_BOX_GLYPH_RE.search(line) for line in lines)
229
279
 
280
+ row: dict[str, int] = {}
281
+ col: dict[str, int] = {}
282
+ for label in ("A", "B", "C"):
283
+ for i, line in enumerate(lines):
284
+ if label in line:
285
+ row[label] = i
286
+ col[label] = line.index(label)
287
+ break
288
+ assert label in row, f"label {label!r} not found in rendered output: {lines!r}"
289
+
290
+ if direction is Direction.TB:
291
+ assert row["A"] < row["B"] < row["C"]
292
+ elif direction is Direction.BT:
293
+ assert row["A"] > row["B"] > row["C"]
294
+ elif direction is Direction.LR:
295
+ assert col["A"] < col["B"] < col["C"]
296
+ else: # Direction.RL
297
+ assert col["A"] > col["B"] > col["C"]
298
+
230
299
 
231
300
  def test_lr_direction_no_overlap():
232
301
  from termrender.renderers.mermaid_flow_layout import _place_nodes
@@ -278,18 +347,32 @@ def test_fully_isolated_nodes_no_edges():
278
347
 
279
348
 
280
349
  def test_edges_draw_visible_line_glyphs_between_boxes():
281
- g = FlowGraph(
282
- direction=Direction.TB,
283
- nodes=[_node("A"), _node("B")],
284
- edges=[_edge("A", "B")],
285
- )
350
+ # The old version of this test accepted plain box-border glyphs
351
+ # (┌┐└┘) in its allowed set, so it passed even for a graph with NO
352
+ # edge at all (the two node boxes alone contain those glyphs). Tighten
353
+ # by inspecting the *inter-box gap rows* specifically — guaranteed
354
+ # clear of any box by construction (the layout enforces a non-zero
355
+ # rank gap) — and require a routed line/arrow glyph there; a deleted
356
+ # edge router would leave those rows blank.
357
+ from termrender.renderers.mermaid_flow_layout import _place_nodes
358
+
359
+ nodes = [_node("A"), _node("B")]
360
+ edges = [_edge("A", "B")]
361
+ rects = _place_nodes(nodes, edges, Direction.TB)
362
+ a, b = rects["A"], rects["B"]
363
+ gap_lo, gap_hi = a.y + a.h, b.y
364
+ assert gap_hi > gap_lo, "expected a real inter-rank gap between the two boxes"
365
+
366
+ g = FlowGraph(direction=Direction.TB, nodes=nodes, edges=edges)
286
367
  lines = layout_flowgraph(g, width=80)
287
- text = "\n".join(lines)
288
- # A vertical or horizontal line-run glyph should appear somewhere
289
- # between the two boxes (not just the box borders themselves).
368
+ gap_rows = lines[gap_lo:gap_hi]
369
+ assert gap_rows, "expected rendered rows in the inter-box gap"
290
370
  line_glyphs = {"\u2502", "\u2500", "\u250c", "\u2510", "\u2514", "\u2518",
291
- "\u251c", "\u2524", "\u252c", "\u2534", "\u253c"}
292
- assert any(ch in text for ch in line_glyphs)
371
+ "\u251c", "\u2524", "\u252c", "\u2534", "\u253c",
372
+ "\u25b2", "\u25bc", "\u25b6", "\u25c0"}
373
+ assert any(ch in row for row in gap_rows for ch in line_glyphs), (
374
+ f"expected a routed edge glyph strictly between the two boxes, got {gap_rows!r}"
375
+ )
293
376
 
294
377
 
295
378
  def test_dangling_edge_reference_is_ignored_not_raised():
@@ -375,8 +458,12 @@ def test_boxrect_anchor_points():
375
458
  # --------------------------------------------------------------------------
376
459
 
377
460
 
378
- def test_never_raises_on_odd_but_well_typed_input():
379
- # Empty label, width-1 canvases, duplicate edges should degrade, not raise.
461
+ def test_duplicate_edges_and_empty_labels_render_without_raising():
462
+ # Empty label, width-1 canvases, duplicate edges must never raise
463
+ # and, since this is otherwise a perfectly well-typed two-node graph,
464
+ # it should still render its two real boxes rather than silently
465
+ # degrading (a bare "isinstance(lines, list)" would also accept an
466
+ # empty-list degradation or a raised-then-caught garbage result).
380
467
  g = FlowGraph(
381
468
  direction=Direction.TB,
382
469
  nodes=[_node("A", label=""), _node("B", label="")],
@@ -384,3 +471,6 @@ def test_never_raises_on_odd_but_well_typed_input():
384
471
  )
385
472
  lines = layout_flowgraph(g, width=1)
386
473
  assert isinstance(lines, list)
474
+ assert lines, "a well-typed non-empty graph must still render"
475
+ text = "\n".join(lines)
476
+ assert text.count("\u250c") == 2, "expected exactly the two declared node boxes"
@@ -2,6 +2,18 @@ version = 1
2
2
  revision = 3
3
3
  requires-python = ">=3.10"
4
4
 
5
+ [[package]]
6
+ name = "grandalf"
7
+ version = "0.8"
8
+ source = { registry = "https://pypi.org/simple" }
9
+ dependencies = [
10
+ { name = "pyparsing" },
11
+ ]
12
+ sdist = { url = "https://files.pythonhosted.org/packages/95/0e/4ac934b416857969f9135dec17ac80660634327e003a870835dd1f382659/grandalf-0.8.tar.gz", hash = "sha256:2813f7aab87f0d20f334a3162ccfbcbf085977134a17a5b516940a93a77ea974", size = 38128, upload-time = "2023-01-26T07:37:06.668Z" }
13
+ wheels = [
14
+ { url = "https://files.pythonhosted.org/packages/61/30/44c7eb0a952478dbb5f2f67df806686d6a7e4b19f6204e091c4f49dc7c69/grandalf-0.8-py3-none-any.whl", hash = "sha256:793ca254442f4a79252ea9ff1ab998e852c1e071b863593e5383afee906b4185", size = 41802, upload-time = "2023-01-10T15:16:19.753Z" },
15
+ ]
16
+
5
17
  [[package]]
6
18
  name = "mermaid-ascii"
7
19
  version = "1.2.0"
@@ -45,10 +57,20 @@ wheels = [
45
57
  { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
46
58
  ]
47
59
 
60
+ [[package]]
61
+ name = "pyparsing"
62
+ version = "3.3.2"
63
+ source = { registry = "https://pypi.org/simple" }
64
+ sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" }
65
+ wheels = [
66
+ { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
67
+ ]
68
+
48
69
  [[package]]
49
70
  name = "termrender"
50
71
  source = { editable = "." }
51
72
  dependencies = [
73
+ { name = "grandalf" },
52
74
  { name = "mermaid-ascii" },
53
75
  { name = "mistune" },
54
76
  { name = "pygments" },
@@ -56,6 +78,7 @@ dependencies = [
56
78
 
57
79
  [package.metadata]
58
80
  requires-dist = [
81
+ { name = "grandalf", specifier = ">=0.8" },
59
82
  { name = "mermaid-ascii", specifier = ">=1.2" },
60
83
  { name = "mistune", specifier = ">=3.0" },
61
84
  { name = "pygments", specifier = ">=2.0" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes