termrender 4.4.0__tar.gz → 4.5.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 (74) hide show
  1. {termrender-4.4.0 → termrender-4.5.0}/CHANGELOG.md +22 -0
  2. {termrender-4.4.0 → termrender-4.5.0}/PKG-INFO +1 -1
  3. termrender-4.5.0/src/termrender/renderers/mermaid_flow.py +110 -0
  4. {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/mermaid_flow_layout.py +378 -28
  5. termrender-4.5.0/tests/test_mermaid_flow.py +168 -0
  6. {termrender-4.4.0 → termrender-4.5.0}/.github/workflows/publish.yml +0 -0
  7. {termrender-4.4.0 → termrender-4.5.0}/.gitignore +0 -0
  8. {termrender-4.4.0 → termrender-4.5.0}/CLAUDE.md +0 -0
  9. {termrender-4.4.0 → termrender-4.5.0}/LICENSE +0 -0
  10. {termrender-4.4.0 → termrender-4.5.0}/README.md +0 -0
  11. {termrender-4.4.0 → termrender-4.5.0}/design.json +0 -0
  12. {termrender-4.4.0 → termrender-4.5.0}/pyproject.toml +0 -0
  13. {termrender-4.4.0 → termrender-4.5.0}/requirements.json +0 -0
  14. {termrender-4.4.0 → termrender-4.5.0}/scripts/build-mermaid-ascii.sh +0 -0
  15. {termrender-4.4.0 → termrender-4.5.0}/src/termrender/CLAUDE.md +0 -0
  16. {termrender-4.4.0 → termrender-4.5.0}/src/termrender/__init__.py +0 -0
  17. {termrender-4.4.0 → termrender-4.5.0}/src/termrender/__main__.py +0 -0
  18. {termrender-4.4.0 → termrender-4.5.0}/src/termrender/_bin/mermaid-ascii-darwin-arm64 +0 -0
  19. {termrender-4.4.0 → termrender-4.5.0}/src/termrender/_mermaid_bin.py +0 -0
  20. {termrender-4.4.0 → termrender-4.5.0}/src/termrender/blocks.py +0 -0
  21. {termrender-4.4.0 → termrender-4.5.0}/src/termrender/emit.py +0 -0
  22. {termrender-4.4.0 → termrender-4.5.0}/src/termrender/layout.py +0 -0
  23. {termrender-4.4.0 → termrender-4.5.0}/src/termrender/parser.py +0 -0
  24. {termrender-4.4.0 → termrender-4.5.0}/src/termrender/py.typed +0 -0
  25. {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/CLAUDE.md +0 -0
  26. {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/__init__.py +0 -0
  27. {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/borders.py +0 -0
  28. {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/charts.py +0 -0
  29. {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/code.py +0 -0
  30. {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/columns.py +0 -0
  31. {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/diff.py +0 -0
  32. {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/divider.py +0 -0
  33. {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/mermaid.py +0 -0
  34. {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/mermaid_flow_model.py +0 -0
  35. {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/mermaid_flow_parser.py +0 -0
  36. {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/mermaid_gantt.py +0 -0
  37. {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/mermaid_journey.py +0 -0
  38. {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/mermaid_mindmap.py +0 -0
  39. {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/mermaid_pie.py +0 -0
  40. {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/mermaid_prelude.py +0 -0
  41. {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/mermaid_sequence.py +0 -0
  42. {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/mermaid_timeline.py +0 -0
  43. {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/panel.py +0 -0
  44. {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/quote.py +0 -0
  45. {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/stat.py +0 -0
  46. {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/table.py +0 -0
  47. {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/text.py +0 -0
  48. {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/timeline.py +0 -0
  49. {termrender-4.4.0 → termrender-4.5.0}/src/termrender/renderers/tree.py +0 -0
  50. {termrender-4.4.0 → termrender-4.5.0}/src/termrender/style.py +0 -0
  51. {termrender-4.4.0 → termrender-4.5.0}/tests/__init__.py +0 -0
  52. {termrender-4.4.0 → termrender-4.5.0}/tests/test_charts.py +0 -0
  53. {termrender-4.4.0 → termrender-4.5.0}/tests/test_cli_contract.py +0 -0
  54. {termrender-4.4.0 → termrender-4.5.0}/tests/test_column_alignment.py +0 -0
  55. {termrender-4.4.0 → termrender-4.5.0}/tests/test_diff.py +0 -0
  56. {termrender-4.4.0 → termrender-4.5.0}/tests/test_inline_badge.py +0 -0
  57. {termrender-4.4.0 → termrender-4.5.0}/tests/test_linebreak.py +0 -0
  58. {termrender-4.4.0 → termrender-4.5.0}/tests/test_mermaid_compat.py +0 -0
  59. {termrender-4.4.0 → termrender-4.5.0}/tests/test_mermaid_dispatch.py +0 -0
  60. {termrender-4.4.0 → termrender-4.5.0}/tests/test_mermaid_flow_layout.py +0 -0
  61. {termrender-4.4.0 → termrender-4.5.0}/tests/test_mermaid_flow_model.py +0 -0
  62. {termrender-4.4.0 → termrender-4.5.0}/tests/test_mermaid_flow_parser.py +0 -0
  63. {termrender-4.4.0 → termrender-4.5.0}/tests/test_mermaid_gantt.py +0 -0
  64. {termrender-4.4.0 → termrender-4.5.0}/tests/test_mermaid_journey.py +0 -0
  65. {termrender-4.4.0 → termrender-4.5.0}/tests/test_mermaid_mindmap.py +0 -0
  66. {termrender-4.4.0 → termrender-4.5.0}/tests/test_mermaid_pie.py +0 -0
  67. {termrender-4.4.0 → termrender-4.5.0}/tests/test_mermaid_sequence.py +0 -0
  68. {termrender-4.4.0 → termrender-4.5.0}/tests/test_mermaid_timeline.py +0 -0
  69. {termrender-4.4.0 → termrender-4.5.0}/tests/test_myst_gaps.py +0 -0
  70. {termrender-4.4.0 → termrender-4.5.0}/tests/test_stat.py +0 -0
  71. {termrender-4.4.0 → termrender-4.5.0}/tests/test_tasklist.py +0 -0
  72. {termrender-4.4.0 → termrender-4.5.0}/tests/test_timeline.py +0 -0
  73. {termrender-4.4.0 → termrender-4.5.0}/tests/test_variable_colons.py +0 -0
  74. {termrender-4.4.0 → termrender-4.5.0}/uv.lock +0 -0
@@ -1,6 +1,28 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v4.5.0 (2026-07-06)
5
+
6
+ ### Features
7
+
8
+ - Add flowchart edge router and public render_flowchart
9
+ ([`a1b46cf`](https://github.com/crouton-labs/termrender/commit/a1b46cf11607ec6b31ea8fe1b4d90b11aafff10f))
10
+
11
+ - Replace the placeholder edge drawing in mermaid_flow_layout.py with the full orthogonal router:
12
+ rank-relative endpoint anchors (forward/same-rank/ back-edge), L/Z staircase paths for forward
13
+ edges, growing side-lane C-paths for back-edges so they never overlay forward edges, arrowheads
14
+ chosen from final-segment direction, edge labels centered on the longest straight run (shifting
15
+ off reserved cells), and self-loops modeled on the sequence renderer's self-message loop. - Add
16
+ mermaid_flow.py exposing the public, pure, never-raising render_flowchart(source, width) ->
17
+ list[str], wiring parse -> layout_flowgraph -> lines behind the three-case degradation contract
18
+ (no header, zero nodes, unexpected layout exception -> raw echo). - Add
19
+ tests/test_mermaid_flow.py: real end-to-end geometry/topology assertions, including the labeled
20
+ back-edge cycle that panics the Go binary, multi-parent attachment, headless edges, and echo
21
+ degradation.
22
+
23
+ Unwired: nothing imports mermaid_flow yet (later phase's cutover).
24
+
25
+
4
26
  ## v4.4.0 (2026-07-06)
5
27
 
6
28
  ### Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: termrender
3
- Version: 4.4.0
3
+ Version: 4.5.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
@@ -0,0 +1,110 @@
1
+ """Public entry point for the native mermaid flowchart/graph renderer.
2
+
3
+ Standalone module: exposes a single pure function, :func:`render_flowchart`.
4
+ Not yet wired into ``mermaid.py``'s dispatcher (a later, orchestrator-owned
5
+ phase) — nothing in this package imports it. This module is a thin
6
+ orchestrator over the parser and layout engine; it owns none of the grammar
7
+ or layout logic itself, only header sniffing and the degradation contract.
8
+
9
+ Layout model
10
+ ------------
11
+ ``render_flowchart`` is ``parse -> layout_flowgraph -> lines``, wrapped in a
12
+ degradation guard (see *Degradation contract* below). :func:`~termrender.
13
+ renderers.mermaid_flow_parser.parse` turns mermaid source into a
14
+ :class:`~termrender.renderers.mermaid_flow_model.FlowGraph`;
15
+ :func:`~termrender.renderers.mermaid_flow_layout.layout_flowgraph` lays it
16
+ out with ``grandalf`` (pure-Python Sugiyama) and rasterizes it — bordered
17
+ rectangle nodes, an orthogonal edge router (L/Z paths for forward edges,
18
+ growing side lanes for back-edges/cycles, arrowheads, edge labels,
19
+ self-loops) — onto a char grid. See ``mermaid_flow_parser.py`` and
20
+ ``mermaid_flow_layout.py``'s module docstrings for the full grammar and
21
+ routing model; this module covers only the public contract.
22
+
23
+ Grammar supported
24
+ ------------------
25
+ Header: ``graph``/``flowchart`` + direction (``TD``/``TB``/``LR``/``RL``/
26
+ ``BT``). Node shapes: ``[rect]``, ``(round)``, ``([stadium])``,
27
+ ``[(cylinder)]``, ``((circle))``, ``{diamond}``, ``{{hexagon}}``,
28
+ ``[[subroutine]]``, ``[/parallelogram/]`` — this phase renders every shape
29
+ as a plain rectangle (distinct shape borders are a later phase; only the
30
+ label text is load-bearing here). Edges: ``-->``, ``---``, ``-.->``, ``==>``
31
+ (and bidirectional forms), with ``|label|`` and inline ``A -- text --> B``
32
+ labels, and ``&`` fan-out. ``subgraph ... end`` blocks parse but are not yet
33
+ rendered as frames (nodes inside them still place and render normally).
34
+ ``class``/``classDef``/``style``/``click``/``linkStyle`` lines are ignored.
35
+
36
+ Known degradations (by design, not bugs)
37
+ -----------------------------------------
38
+ - Node shapes other than a plain rectangle border are not yet visually
39
+ distinct (Phase 4 of this renderer's build).
40
+ - Subgraph blocks parse but are not yet drawn as enclosing frames (Phase 4).
41
+ - See ``mermaid_flow_layout.py``'s module docstring for the edge-routing
42
+ degradations (dense-graph crossings, label-lane overlap, CJK wrapping,
43
+ minimum-box-size self-loop cosmetics).
44
+ - ``width`` is advisory only: like the other native renderers in this
45
+ package, this renderer sizes to content and may overflow a narrow
46
+ terminal rather than truncate the diagram.
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ from termrender.renderers.mermaid_flow_layout import layout_flowgraph
52
+ from termrender.renderers.mermaid_flow_parser import FlowchartError, parse
53
+
54
+ __all__ = ["render_flowchart"]
55
+
56
+
57
+ def render_flowchart(source: str, width: int) -> list[str]:
58
+ """Render a mermaid ``graph``/``flowchart`` source to unicode lines.
59
+
60
+ Pure and total: never raises. Degrades to a raw echo of ``source``
61
+ (each line ``\\n``-split and ``.rstrip()``-ed, containing no
62
+ box-drawing glyphs) exactly when:
63
+
64
+ 1. The source isn't a flowchart at all (no ``graph``/``flowchart``
65
+ header on its first non-blank, post-prelude line) — :func:`parse`
66
+ raises :class:`FlowchartError`.
67
+ 2. Parsing succeeds but yields zero nodes (an empty or comment-only
68
+ diagram body) — nothing to draw.
69
+ 3. Any unexpected exception escapes :func:`layout_flowgraph` — a
70
+ defensive catch-all; this should never happen for well-typed input,
71
+ but the guarantee is "never crash, degrade" regardless.
72
+
73
+ Otherwise, returns the rendered diagram: guaranteed to contain unicode
74
+ box-drawing glyphs (every node is a bordered box), never ANSI escapes.
75
+ The distinction matters downstream — code that detects render success
76
+ by the *presence* of box-drawing glyphs relies on the echo path never
77
+ emitting any.
78
+
79
+ Args:
80
+ source: The mermaid fence body (with or without the surrounding
81
+ fence markers — only the text between them).
82
+ width: Advisory terminal width. See the module docstring's
83
+ "Known degradations" for why this is advisory rather than a
84
+ hard wrap.
85
+
86
+ Returns:
87
+ Rendered lines on success, or a raw-echo of ``source`` on any of
88
+ the three degradation conditions above.
89
+ """
90
+ try:
91
+ graph = parse(source)
92
+ except FlowchartError:
93
+ return _raw_echo(source)
94
+
95
+ if not graph.nodes:
96
+ return _raw_echo(source)
97
+
98
+ try:
99
+ lines = layout_flowgraph(graph, width)
100
+ except Exception:
101
+ return _raw_echo(source)
102
+
103
+ if not lines:
104
+ return _raw_echo(source)
105
+
106
+ return lines
107
+
108
+
109
+ def _raw_echo(source: str) -> list[str]:
110
+ return [line.rstrip() for line in source.splitlines()]
@@ -42,18 +42,39 @@ direction. A cyclic :class:`FlowGraph` (e.g. ``A->B->C->A``) lays out and
42
42
  renders without any manual cycle-breaking — confirmed empirically against
43
43
  grandalf 0.8 (see the design doc's "grandalf adapter recipe" section).
44
44
 
45
- Grammar / scope this phase
46
- ---------------------------
45
+ Edge routing
46
+ ------------
47
47
  This module renders every node as a bordered **rectangle** regardless of its
48
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`.
49
+ stadium, circle, ...) and subgraph frames (:meth:`Canvas.draw_frame` exists
50
+ but :func:`layout_flowgraph` does not yet call it) are a later phase.
51
+
52
+ Edges get the full orthogonal router. Per edge, endpoint anchors are chosen
53
+ by the two boxes' *relative rank position* (their spans along the rank axis
54
+ either overlap same rank or one is strictly ahead of the other, a test
55
+ that holds regardless of direction because rank bands are always separated
56
+ by a clear gap): a forward edge exits/enters the border facing the rank-flow
57
+ direction (``bottom_mid``/``top_mid`` for TB, mirrored/transposed for
58
+ BT/LR/RL); a same-rank edge uses the two boxes' facing side mids; a back-edge
59
+ (destination at an earlier rank — the cycle case) exits/enters the side
60
+ perpendicular to the rank axis and routes through a growing side lane so
61
+ stacked back-edges never collide (the right side for TB/BT, the bottom side
62
+ for LR/RL, per the axis swap). Forward paths are a single straight run when
63
+ the two anchors already share a column/row, else a Z/staircase through the
64
+ midpoint of the clear inter-rank band. Arrowheads (``▼▲▶◀``) are chosen from
65
+ the final segment's direction of travel and overwrite the border cell they
66
+ land on. Edge labels center on the path's longest straight run, shifting
67
+ along it to the nearest cell span clear of any box if the ideal midpoint
68
+ lands on one. Self-loops (``src == dst``, excluded from the grandalf graph)
69
+ draw a small loop off the same side used for back-edge lanes, stacking
70
+ outward per repeated self-loop on one node.
71
+
72
+ The router draws only axis-aligned L/Z/C paths and does no global crossing
73
+ minimization or obstacle avoidance — a path that would cross another box
74
+ simply has those cells skipped by :meth:`Canvas.draw_segment` (reserved
75
+ cells are never overwritten by a line). This matches the medium's ceiling
76
+ for small (≤~20 node) agent-emitted graphs; see the design doc's
77
+ "Orthogonal edge routing" section for the full rationale.
57
78
 
58
79
  Known degradations (by design, not bugs)
59
80
  -----------------------------------------
@@ -62,8 +83,13 @@ Known degradations (by design, not bugs)
62
83
  root ``CLAUDE.md``) — CJK/wide-glyph labels may wrap at the wrong point.
63
84
  Box *dimensions*, however, are always computed from :func:`visual_len` of
64
85
  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.
86
+ - Dense graphs may show edge-line crossings, and two edge labels sharing a
87
+ crowded lane may overlap each other even after the nearest-clear-run
88
+ shift — an accepted limit of the medium, not a routing bug.
89
+ - A self-loop or back-edge on/into a minimum-size box (``_MIN_BOX_W``/
90
+ ``_MIN_BOX_H``) may land its arrowhead on a corner glyph rather than a
91
+ straight border cell, since a 1-interior-row/column box has no other
92
+ distinct anchor point to use — cosmetic only.
67
93
  - An edge referencing a node id absent from the graph (a malformed/hand-built
68
94
  :class:`FlowGraph`) is silently skipped rather than raised — the parser is
69
95
  expected to guarantee valid endpoints, but this module never crashes on a
@@ -521,12 +547,33 @@ def _place_nodes(
521
547
 
522
548
 
523
549
  # --------------------------------------------------------------------------
524
- # --- router (placeholder — full orthogonal router is a later phase) ---
550
+ # --- router ---
525
551
  # --------------------------------------------------------------------------
526
552
 
553
+ # The full orthogonal router. Endpoint anchors are chosen from each edge's
554
+ # *relative rank position* (forward / same-rank / back-edge); forward edges
555
+ # route a straight or Z-shaped (staircase) path through the clear inter-rank
556
+ # band, same-rank edges route between facing side mids, and back-edges route
557
+ # through a growing side lane so they never overlay forward edges. See the
558
+ # design doc's "Orthogonal edge routing" section for the full rationale —
559
+ # this module implements it without re-deciding any of it.
560
+
561
+ _LANE_GAP = 2 # spacing between stacked back-edge side lanes
562
+ _LANE_MARGIN = 2 # gap from the involved boxes' far edge to the first lane
563
+ _LOOP_REACH = 3 # base horizontal/vertical extent of a self-loop
564
+
565
+ _ARROW_GLYPHS: dict[tuple[int, int], str] = {
566
+ (1, 0): "\u25b6", # ▶
567
+ (-1, 0): "\u25c0", # ◀
568
+ (0, 1): "\u25bc", # ▼
569
+ (0, -1): "\u25b2", # ▲
570
+ }
571
+
527
572
 
528
573
  def _facing_anchor(this: BoxRect, other: BoxRect) -> tuple[int, int]:
529
- """Pick the border midpoint of ``this`` box facing toward ``other``."""
574
+ """Pick the border midpoint of ``this`` box facing toward ``other`` —
575
+ used for same-rank edges, where neither box is "ahead" of the other
576
+ along the rank axis."""
530
577
  tcx, tcy = this.center
531
578
  ocx, ocy = other.center
532
579
  dx, dy = ocx - tcx, ocy - tcy
@@ -535,27 +582,328 @@ def _facing_anchor(this: BoxRect, other: BoxRect) -> tuple[int, int]:
535
582
  return this.right_mid if dx >= 0 else this.left_mid
536
583
 
537
584
 
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.
585
+ def _arrow_glyph(frm: tuple[int, int], to: tuple[int, int]) -> str:
586
+ """``▼▲▶◀`` for the direction of travel from ``frm`` to ``to`` — the
587
+ glyph an arrowhead landing at ``to`` should show."""
588
+ dx, dy = to[0] - frm[0], to[1] - frm[1]
589
+ if dx == 0 and dy == 0:
590
+ return _ARROW_GLYPHS[(0, 1)]
591
+ if abs(dx) >= abs(dy):
592
+ return _ARROW_GLYPHS[(1 if dx > 0 else -1, 0)]
593
+ return _ARROW_GLYPHS[(0, 1 if dy > 0 else -1)]
594
+
595
+
596
+ def _rank_is_horizontal(direction: Direction) -> bool:
597
+ """True when the rank-flow axis is x (LR/RL) rather than y (TB/BT)."""
598
+ return direction in (Direction.LR, Direction.RL)
599
+
600
+
601
+ def _forward_increasing(direction: Direction) -> bool:
602
+ """True when rank number increases with increasing coordinate along the
603
+ rank axis (TB: increasing y; LR: increasing x) — false for BT/RL, whose
604
+ adapter coordinate transform mirrors that axis (see module docstring)."""
605
+ return direction in (Direction.TB, Direction.LR)
606
+
607
+
608
+ def _rank_extent(direction: Direction, rect: BoxRect) -> tuple[int, int]:
609
+ """``rect``'s [min, max] span along the rank axis. Two boxes in the same
610
+ rank always have overlapping spans (the adapter places a whole rank in
611
+ one top/bottom-aligned band); boxes in different ranks never do, since
612
+ ``_ROW_GAP``/``_GAP_X`` guarantee a clear gap between bands — so span
613
+ overlap is a direction-agnostic same-rank test."""
614
+ if _rank_is_horizontal(direction):
615
+ return (rect.x, rect.x + rect.w - 1)
616
+ return (rect.y, rect.y + rect.h - 1)
617
+
618
+
619
+ def _forward_exit(direction: Direction, rect: BoxRect) -> tuple[int, int]:
620
+ """``rect``'s border anchor facing *downstream* along the rank axis."""
621
+ horiz = _rank_is_horizontal(direction)
622
+ forward = _forward_increasing(direction)
623
+ if horiz:
624
+ return rect.right_mid if forward else rect.left_mid
625
+ return rect.bottom_mid if forward else rect.top_mid
626
+
627
+
628
+ def _forward_entry(direction: Direction, rect: BoxRect) -> tuple[int, int]:
629
+ """``rect``'s border anchor facing *upstream* — the mirror image of
630
+ :func:`_forward_exit`, used on the destination side of a forward edge."""
631
+ horiz = _rank_is_horizontal(direction)
632
+ forward = _forward_increasing(direction)
633
+ if horiz:
634
+ return rect.left_mid if forward else rect.right_mid
635
+ return rect.top_mid if forward else rect.bottom_mid
636
+
637
+
638
+ def _lane_anchor(direction: Direction, rect: BoxRect) -> tuple[int, int]:
639
+ """``rect``'s border anchor on the side used for back-edge side lanes
640
+ and self-loops: the side perpendicular to the rank axis and away from
641
+ it — the right side for TB/BT, the bottom side for LR/RL ("the right
642
+ lane becomes the bottom lane" after LR/RL's axis swap)."""
643
+ return rect.bottom_mid if _rank_is_horizontal(direction) else rect.right_mid
644
+
645
+
646
+ def _abstract(direction: Direction, point: tuple[int, int]) -> tuple[int, int]:
647
+ """``point`` decomposed into (primary, secondary) = (rank-axis coord,
648
+ off-axis coord) — the coordinate frame the path-shape helpers reason
649
+ in, direction-agnostically. Inverse: :func:`_real`."""
650
+ x, y = point
651
+ return (x, y) if _rank_is_horizontal(direction) else (y, x)
652
+
653
+
654
+ def _real(direction: Direction, primary: int, secondary: int) -> tuple[int, int]:
655
+ """Inverse of :func:`_abstract`: (primary, secondary) back to real
656
+ ``(x, y)`` canvas coordinates."""
657
+ return (primary, secondary) if _rank_is_horizontal(direction) else (secondary, primary)
658
+
659
+
660
+ def _z_path(
661
+ direction: Direction, exit_pt: tuple[int, int], entry_pt: tuple[int, int]
662
+ ) -> list[tuple[int, int]]:
663
+ """Forward-edge path: a single straight run when both anchors share the
664
+ off-axis coordinate, else a Z/staircase — out of ``exit_pt``, across at
665
+ the midpoint between the two ranks' facing borders, into ``entry_pt``."""
666
+ p0, s0 = _abstract(direction, exit_pt)
667
+ p1, s1 = _abstract(direction, entry_pt)
668
+ if s0 == s1:
669
+ return [exit_pt, entry_pt]
670
+ mid_p = (p0 + p1) // 2
671
+ return [exit_pt, _real(direction, mid_p, s0), _real(direction, mid_p, s1), entry_pt]
672
+
673
+
674
+ def _lane_path(
675
+ direction: Direction,
676
+ exit_pt: tuple[int, int],
677
+ entry_pt: tuple[int, int],
678
+ lane_secondary: int,
679
+ ) -> list[tuple[int, int]]:
680
+ """Back-edge C-path: out of ``exit_pt`` to the side lane, along the lane,
681
+ back into ``entry_pt`` — never overlaying the forward Z-paths, which
682
+ stay within the involved boxes' own span on this axis."""
683
+ p0, s0 = _abstract(direction, exit_pt)
684
+ p1, s1 = _abstract(direction, entry_pt)
685
+ del s0, s1
686
+ return [
687
+ exit_pt,
688
+ _real(direction, p0, lane_secondary),
689
+ _real(direction, p1, lane_secondary),
690
+ entry_pt,
691
+ ]
692
+
693
+
694
+ def _lane_secondary_base(direction: Direction, a: BoxRect, b: BoxRect) -> int:
695
+ """Starting side-lane coordinate just past the two involved boxes' far
696
+ edge on the off-axis — the base that :func:`_route_edge` adds
697
+ ``_LANE_MARGIN + lane * _LANE_GAP`` to, per back-edge, to avoid stacking
698
+ lanes."""
699
+ if _rank_is_horizontal(direction):
700
+ return max(a.y + a.h - 1, b.y + b.h - 1)
701
+ return max(a.x + a.w - 1, b.x + b.w - 1)
702
+
703
+
704
+ def _segment_length(a: tuple[int, int], b: tuple[int, int]) -> int:
705
+ return abs(a[0] - b[0]) + abs(a[1] - b[1])
706
+
707
+
708
+ def _longest_segment(
709
+ points: list[tuple[int, int]],
710
+ ) -> tuple[tuple[int, int], tuple[int, int]] | None:
711
+ """The longest straight run in a polyline — where an edge label is
712
+ centered."""
713
+ segments = list(zip(points, points[1:]))
714
+ if not segments:
715
+ return None
716
+ return max(segments, key=lambda seg: _segment_length(*seg))
717
+
540
718
 
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
- docstringand are a later phase's job to draw; a dangling reference
544
- is a defensive no-op, never a crash).
719
+ def _label_positions(length: int, center: int, lo: int, hi: int) -> list[int]:
720
+ """Candidate run-start offsets within ``[lo, hi]``, nearest-``center``
721
+ firsthow a label shifts off a reserved cell to the nearest clear
722
+ run along its segment."""
723
+ span = max(hi - lo + 1, 1)
724
+ if hi - lo + 1 >= length:
725
+ ideal = max(lo, min(center - length // 2, hi - length + 1))
726
+ else:
727
+ ideal = lo
728
+ order = [ideal]
729
+ seen = {ideal}
730
+ for d in range(1, span + 1):
731
+ for cand in (ideal + d, ideal - d):
732
+ if cand not in seen:
733
+ order.append(cand)
734
+ seen.add(cand)
735
+ return order
736
+
737
+
738
+ def _draw_label_on_segment(
739
+ canvas: Canvas, a: tuple[int, int], b: tuple[int, int], label: str
740
+ ) -> None:
741
+ """Center ``label`` on the straight run ``a``-``b``. A label always
742
+ *reads* horizontally (measured with :func:`visual_len`): on a
743
+ horizontal segment it centers along the segment's own x-span at the
744
+ segment's row; on a vertical segment (e.g. a back-edge's side lane) it
745
+ still reads horizontally, centered on the segment's column, at a row
746
+ chosen from the segment's y-span — matching the sequence renderer's
747
+ self-loop label placement. Shifts along the search axis to the nearest
748
+ span/row fully clear of a reserved (box) cell; if none exists, writes
749
+ at the ideal placement anyway (overflow tolerated) but skips individual
750
+ reserved cells — a label never corrupts a box.
545
751
  """
546
- if edge.src == edge.dst:
752
+ length = visual_len(label)
753
+ if length <= 0:
754
+ return
755
+ (x0, y0), (x1, y1) = a, b
756
+
757
+ if y0 == y1:
758
+ # Horizontal segment: search along x for a clear run at row y0.
759
+ lo, hi = (x0, x1) if x0 <= x1 else (x1, x0)
760
+ row = y0
761
+ center = (lo + hi) // 2
762
+
763
+ def cells(start: int) -> list[tuple[int, int]]:
764
+ return [(start + i, row) for i in range(length)]
765
+
766
+ for start in _label_positions(length, center, lo, hi):
767
+ run = cells(start)
768
+ if not any(canvas.is_reserved(x, y) for x, y in run):
769
+ for (px, py), ch in zip(run, label):
770
+ canvas.draw_glyph(px, py, ch)
771
+ return
772
+ start = max(lo, min(center - length // 2, hi - length + 1))
773
+ for (px, py), ch in zip(cells(start), label):
774
+ if not canvas.is_reserved(px, py):
775
+ canvas.draw_glyph(px, py, ch)
547
776
  return
777
+
778
+ # Vertical segment: the label still reads horizontally, centered on
779
+ # column x0; search along y for a row whose horizontal run is clear.
780
+ lo, hi = (y0, y1) if y0 <= y1 else (y1, y0)
781
+ col = x0
782
+ # Clamp rather than let the run go negative: a negative x is simply
783
+ # unaddressable (Canvas has no left margin to grow into), which would
784
+ # silently drop the label's leading characters instead of overflowing
785
+ # visibly to the right like every other overflow case in this module.
786
+ start_x = max(0, col - length // 2)
787
+
788
+ def row_cells(row: int) -> list[tuple[int, int]]:
789
+ return [(start_x + i, row) for i in range(length)]
790
+
791
+ center_row = (lo + hi) // 2
792
+ for row in _label_positions(1, center_row, lo, hi):
793
+ run = row_cells(row)
794
+ if not any(canvas.is_reserved(x, y) for x, y in run):
795
+ for (px, py), ch in zip(run, label):
796
+ canvas.draw_glyph(px, py, ch)
797
+ return
798
+ for (px, py), ch in zip(row_cells(center_row), label):
799
+ if not canvas.is_reserved(px, py):
800
+ canvas.draw_glyph(px, py, ch)
801
+
802
+
803
+ def _draw_polyline(canvas: Canvas, points: list[tuple[int, int]], style: EdgeStyle) -> None:
804
+ for (x0, y0), (x1, y1) in zip(points, points[1:]):
805
+ canvas.draw_segment(x0, y0, x1, y1, style)
806
+
807
+
808
+ def _draw_arrowheads(canvas: Canvas, points: list[tuple[int, int]], edge: FlowEdge) -> None:
809
+ if edge.dst_arrow:
810
+ canvas.draw_glyph(*points[-1], _arrow_glyph(points[-2], points[-1]))
811
+ if edge.src_arrow:
812
+ canvas.draw_glyph(*points[0], _arrow_glyph(points[1], points[0]))
813
+
814
+
815
+ def _self_loop_points(
816
+ direction: Direction, rect: BoxRect, lane: int
817
+ ) -> list[tuple[int, int]]:
818
+ """A small loop off the box's lane side — the flowchart analogue of the
819
+ sequence renderer's self-message loop (``_self_loop_rows``). Uses the
820
+ box's *full* border range (corners included) rather than only interior
821
+ rows/columns so it stays correct even at ``_MIN_BOX_W``/``_MIN_BOX_H``,
822
+ where there is only one interior row/column to work with."""
823
+ reach = _LOOP_REACH + lane * 2
824
+ ax, ay = _lane_anchor(direction, rect)
825
+ if _rank_is_horizontal(direction):
826
+ full_lo, full_hi = rect.x, rect.x + rect.w - 1
827
+ lo = max(full_lo, ax - 1)
828
+ hi = min(full_hi, ax + 1)
829
+ if hi <= lo:
830
+ hi = min(full_hi, lo + 1)
831
+ if hi <= lo:
832
+ lo = max(full_lo, hi - 1)
833
+ far = ay + reach
834
+ return [(lo, ay), (lo, far), (hi, far), (hi, ay)]
835
+ full_lo, full_hi = rect.y, rect.y + rect.h - 1
836
+ lo = max(full_lo, ay - 1)
837
+ hi = min(full_hi, ay + 1)
838
+ if hi <= lo:
839
+ hi = min(full_hi, lo + 1)
840
+ if hi <= lo:
841
+ lo = max(full_lo, hi - 1)
842
+ far = ax + reach
843
+ return [(ax, lo), (far, lo), (far, hi), (ax, hi)]
844
+
845
+
846
+ def _route_edge(
847
+ canvas: Canvas,
848
+ rects: dict[str, BoxRect],
849
+ direction: Direction,
850
+ edge: FlowEdge,
851
+ lane_counter: list[int],
852
+ self_loop_counter: dict[str, int],
853
+ ) -> None:
854
+ """Draw one edge's full orthogonal path: anchors chosen by relative
855
+ rank position, an L/Z/C path shape, arrowheads, and an edge label.
856
+ Dangling node-id references are a defensive no-op (the parser
857
+ guarantees valid endpoints; this module never crashes on one).
858
+ """
548
859
  src_rect = rects.get(edge.src)
549
860
  dst_rect = rects.get(edge.dst)
550
861
  if src_rect is None or dst_rect is None:
551
862
  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)
863
+
864
+ if edge.src == edge.dst:
865
+ lane = self_loop_counter.get(edge.src, 0)
866
+ self_loop_counter[edge.src] = lane + 1
867
+ points = _self_loop_points(direction, src_rect, lane)
556
868
  else:
557
- canvas.draw_segment(x0, y0, x0, y1, edge.style)
558
- canvas.draw_segment(x0, y1, x1, y1, edge.style)
869
+ src_extent = _rank_extent(direction, src_rect)
870
+ dst_extent = _rank_extent(direction, dst_rect)
871
+ same_rank = not (dst_extent[1] < src_extent[0] or src_extent[1] < dst_extent[0])
872
+
873
+ if same_rank:
874
+ points = [_facing_anchor(src_rect, dst_rect), _facing_anchor(dst_rect, src_rect)]
875
+ else:
876
+ src_center = (src_extent[0] + src_extent[1]) / 2
877
+ dst_center = (dst_extent[0] + dst_extent[1]) / 2
878
+ forward = (dst_center > src_center) == _forward_increasing(direction)
879
+ if forward:
880
+ points = _z_path(
881
+ direction,
882
+ _forward_exit(direction, src_rect),
883
+ _forward_entry(direction, dst_rect),
884
+ )
885
+ else:
886
+ lane = lane_counter[0]
887
+ lane_counter[0] += 1
888
+ lane_secondary = (
889
+ _lane_secondary_base(direction, src_rect, dst_rect)
890
+ + _LANE_MARGIN
891
+ + lane * _LANE_GAP
892
+ )
893
+ points = _lane_path(
894
+ direction,
895
+ _lane_anchor(direction, src_rect),
896
+ _lane_anchor(direction, dst_rect),
897
+ lane_secondary,
898
+ )
899
+
900
+ _draw_polyline(canvas, points, edge.style)
901
+ _draw_arrowheads(canvas, points, edge)
902
+
903
+ if edge.label:
904
+ seg = _longest_segment(points)
905
+ if seg is not None:
906
+ _draw_label_on_segment(canvas, *seg, edge.label)
559
907
 
560
908
 
561
909
  # --------------------------------------------------------------------------
@@ -595,8 +943,10 @@ def layout_flowgraph(g: FlowGraph, width: int) -> list[str]:
595
943
  rect = rects.get(n.id)
596
944
  if rect is not None:
597
945
  canvas.draw_box(rect, n.shape, n.label)
946
+ lane_counter = [0]
947
+ self_loop_counter: dict[str, int] = {}
598
948
  for e in g.edges:
599
- _draw_edge_stub(canvas, rects, e)
949
+ _route_edge(canvas, rects, g.direction, e, lane_counter, self_loop_counter)
600
950
  return canvas.to_lines()
601
951
  except Exception:
602
952
  return []
@@ -0,0 +1,168 @@
1
+ """End-to-end tests for the public ``render_flowchart`` entry point.
2
+
3
+ These exercise real mermaid source strings through the full pipeline
4
+ (``parse -> layout_flowgraph -> lines``) and assert genuine rendered
5
+ geometry/topology — arrowhead glyphs, edge labels, row ordering, and the
6
+ degradation contract — not merely "no exception raised".
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+
13
+ from termrender.renderers.mermaid_flow import render_flowchart
14
+
15
+ _BOX_GLYPH_RE = re.compile(r"[\u2500-\u259F\u25A0-\u25FF]")
16
+ _ARROW_RE = re.compile(r"[\u25b2\u25bc\u25b6\u25c0]") # ▲▼▶◀
17
+
18
+
19
+ def _row_of(lines: list[str], label: str) -> int:
20
+ for i, line in enumerate(lines):
21
+ if label in line:
22
+ return i
23
+ raise AssertionError(f"label {label!r} not found in rendered output: {lines!r}")
24
+
25
+
26
+ # --------------------------------------------------------------------------
27
+ # Forward DAG — arrowheads point down into children
28
+ # --------------------------------------------------------------------------
29
+
30
+
31
+ def test_forward_dag_renders_with_arrowheads_into_children():
32
+ source = "graph TD\nA-->B\nA-->C\n"
33
+ lines = render_flowchart(source, width=80)
34
+ assert lines
35
+ text = "\n".join(lines)
36
+ for label in ("A", "B", "C"):
37
+ assert label in text
38
+ assert any(_BOX_GLYPH_RE.search(line) for line in lines)
39
+ # Real topology: A is above both its children.
40
+ row_a = _row_of(lines, "A")
41
+ assert row_a < _row_of(lines, "B")
42
+ assert row_a < _row_of(lines, "C")
43
+ # A down-pointing arrowhead lands on a child's top border.
44
+ assert "\u25bc" in text
45
+
46
+
47
+ # --------------------------------------------------------------------------
48
+ # Edge label — |label| form
49
+ # --------------------------------------------------------------------------
50
+
51
+
52
+ def test_pipe_label_edge_shows_label_text():
53
+ source = "graph TD\nA-->|hello|B\n"
54
+ lines = render_flowchart(source, width=80)
55
+ assert lines
56
+ text = "\n".join(lines)
57
+ assert "A" in text and "B" in text
58
+ assert "hello" in text
59
+ assert any(_BOX_GLYPH_RE.search(line) for line in lines)
60
+
61
+
62
+ def test_inline_label_edge_shows_label_text():
63
+ source = "graph TD\nA -- go now --> B\n"
64
+ lines = render_flowchart(source, width=80)
65
+ assert lines
66
+ text = "\n".join(lines)
67
+ assert "go now" in text
68
+
69
+
70
+ # --------------------------------------------------------------------------
71
+ # Labeled back-edge cycle — the exact shape that panics the Go binary
72
+ # --------------------------------------------------------------------------
73
+
74
+
75
+ def test_labeled_back_edge_cycle_renders_all_boxes_label_and_arrow():
76
+ source = "graph TD\nA-->B\nB-->C\nC-->|retry|A\n"
77
+ lines = render_flowchart(source, width=80)
78
+ assert lines, "must render, never raise or degrade to echo"
79
+ text = "\n".join(lines)
80
+ for label in ("A", "B", "C"):
81
+ assert label in text
82
+ assert "retry" in text
83
+ assert any(_BOX_GLYPH_RE.search(line) for line in lines)
84
+ # An arrowhead lands somewhere (the back-edge's ◀/▲ into A, or the
85
+ # forward edges' ▼ into B/C) — at minimum the diagram is not headless.
86
+ assert _ARROW_RE.search(text)
87
+ # Real topology check: the forward chain A -> B -> C still orders
88
+ # top-to-bottom despite the labeled back-edge C -> A.
89
+ row_a, row_b, row_c = _row_of(lines, "A"), _row_of(lines, "B"), _row_of(lines, "C")
90
+ assert row_a < row_b < row_c
91
+
92
+
93
+ # --------------------------------------------------------------------------
94
+ # Multi-parent DAG — both edges attach to the shared child
95
+ # --------------------------------------------------------------------------
96
+
97
+
98
+ def test_multi_parent_dag_attaches_both_edges_to_shared_child():
99
+ source = "graph TD\nA-->C\nB-->C\n"
100
+ lines = render_flowchart(source, width=80)
101
+ assert lines
102
+ text = "\n".join(lines)
103
+ for label in ("A", "B", "C"):
104
+ assert label in text
105
+ row_a, row_b, row_c = _row_of(lines, "A"), _row_of(lines, "B"), _row_of(lines, "C")
106
+ assert row_a < row_c
107
+ assert row_b < row_c
108
+
109
+ # Real geometry: A and B sit side by side in the same rank (row), and
110
+ # each routes its own vertical leg down toward C — the inter-rank gap
111
+ # rows must show line glyphs at (at least) two distinct columns,
112
+ # evidencing two separate incoming connections rather than one shared
113
+ # trunk that happens to touch both labels.
114
+ assert row_a == row_b
115
+ gap_lo, gap_hi = min(row_a, row_b) + 1, row_c
116
+ vertical_cols: set[int] = set()
117
+ for row in range(gap_lo, gap_hi):
118
+ if row < 0 or row >= len(lines):
119
+ continue
120
+ for col, ch in enumerate(lines[row]):
121
+ if ch in "\u2502\u2514\u2518\u250c\u2510\u251c\u2524\u252c\u2534\u253c":
122
+ vertical_cols.add(col)
123
+ assert len(vertical_cols) >= 2, (
124
+ f"expected >=2 distinct incoming columns into C, got {vertical_cols}"
125
+ )
126
+
127
+
128
+ # --------------------------------------------------------------------------
129
+ # Headless edge — `---` draws a line with no arrowhead
130
+ # --------------------------------------------------------------------------
131
+
132
+
133
+ def test_headless_edge_has_no_arrowhead():
134
+ source = "graph TD\nA---B\n"
135
+ lines = render_flowchart(source, width=80)
136
+ assert lines
137
+ text = "\n".join(lines)
138
+ assert "A" in text and "B" in text
139
+ assert any(_BOX_GLYPH_RE.search(line) for line in lines)
140
+ assert not _ARROW_RE.search(text), "`---` must draw a plain line, no arrowhead"
141
+
142
+
143
+ # --------------------------------------------------------------------------
144
+ # Degradation contract
145
+ # --------------------------------------------------------------------------
146
+
147
+
148
+ def test_malformed_input_raw_echoes_with_no_box_glyphs():
149
+ source = "not a diagram\njust some text\n"
150
+ lines = render_flowchart(source, width=80)
151
+ assert lines == ["not a diagram", "just some text"]
152
+ text = "\n".join(lines)
153
+ assert not _BOX_GLYPH_RE.search(text), "raw echo must contain no box-drawing glyphs"
154
+
155
+
156
+ def test_empty_body_headed_diagram_raw_echoes():
157
+ source = "graph TD\n%% just a comment, no nodes\n"
158
+ lines = render_flowchart(source, width=80)
159
+ assert lines == ["graph TD", "%% just a comment, no nodes"]
160
+ text = "\n".join(lines)
161
+ assert not _BOX_GLYPH_RE.search(text)
162
+
163
+
164
+ def test_render_flowchart_never_raises_on_garbage_input():
165
+ # Defensive: assorted odd inputs must never raise, always return a list.
166
+ for source in ("", "\n\n\n", "graph", "flowchart LR\n", "graph TD\nA-->\n"):
167
+ lines = render_flowchart(source, width=80)
168
+ assert isinstance(lines, list)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes