termrender 4.10.1__tar.gz → 4.10.2__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.10.1 → termrender-4.10.2}/CHANGELOG.md +8 -0
- {termrender-4.10.1 → termrender-4.10.2}/PKG-INFO +1 -1
- {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/mermaid_flow_layout.py +175 -70
- {termrender-4.10.1 → termrender-4.10.2}/tests/test_mermaid_flow_layout.py +63 -0
- {termrender-4.10.1 → termrender-4.10.2}/.github/workflows/publish.yml +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/.gitignore +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/CLAUDE.md +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/LICENSE +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/README.md +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/design.json +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/pyproject.toml +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/requirements.json +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/src/termrender/CLAUDE.md +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/src/termrender/__init__.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/src/termrender/__main__.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/src/termrender/blocks.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/src/termrender/emit.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/src/termrender/layout.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/src/termrender/parser.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/src/termrender/py.typed +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/CLAUDE.md +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/__init__.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/borders.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/charts.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/code.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/columns.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/diff.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/divider.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/mermaid.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/mermaid_class.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/mermaid_er.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/mermaid_flow.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/mermaid_flow_model.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/mermaid_flow_parser.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/mermaid_gantt.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/mermaid_journey.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/mermaid_mindmap.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/mermaid_pie.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/mermaid_prelude.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/mermaid_sequence.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/mermaid_state.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/mermaid_timeline.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/panel.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/quote.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/stat.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/table.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/text.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/timeline.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/src/termrender/renderers/tree.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/src/termrender/style.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/tests/__init__.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/tests/test_charts.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/tests/test_cli_contract.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/tests/test_column_alignment.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/tests/test_diff.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/tests/test_inline_badge.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/tests/test_linebreak.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/tests/test_mermaid_class.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/tests/test_mermaid_dispatch.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/tests/test_mermaid_er.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/tests/test_mermaid_flow.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/tests/test_mermaid_flow_corpus.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/tests/test_mermaid_flow_model.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/tests/test_mermaid_flow_parser.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/tests/test_mermaid_flow_shapes.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/tests/test_mermaid_gantt.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/tests/test_mermaid_journey.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/tests/test_mermaid_mindmap.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/tests/test_mermaid_pie.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/tests/test_mermaid_sequence.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/tests/test_mermaid_state.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/tests/test_mermaid_timeline.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/tests/test_myst_gaps.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/tests/test_stat.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/tests/test_tasklist.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/tests/test_timeline.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/tests/test_variable_colons.py +0 -0
- {termrender-4.10.1 → termrender-4.10.2}/uv.lock +0 -0
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## v4.10.2 (2026-07-07)
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
- **mermaid-flow**: Split entry anchors and cjk labels
|
|
9
|
+
([`0d5160d`](https://github.com/crouton-labs/termrender/commit/0d5160d18f719f3045e552236e38b640ab967b13))
|
|
10
|
+
|
|
11
|
+
|
|
4
12
|
## v4.10.1 (2026-07-07)
|
|
5
13
|
|
|
6
14
|
### Bug Fixes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: termrender
|
|
3
|
-
Version: 4.10.
|
|
3
|
+
Version: 4.10.2
|
|
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
|
|
@@ -1488,69 +1488,40 @@ def _classify_edge(
|
|
|
1488
1488
|
return "forward" if forward else "back"
|
|
1489
1489
|
|
|
1490
1490
|
|
|
1491
|
-
def
|
|
1491
|
+
def _spread_group_anchors(
|
|
1492
1492
|
rects: dict[str, BoxRect],
|
|
1493
1493
|
direction: Direction,
|
|
1494
1494
|
edges: list[FlowEdge],
|
|
1495
|
-
|
|
1495
|
+
node_by_id: dict[str, "FlowNode"],
|
|
1496
|
+
groups: dict[tuple[str, str], list[int]],
|
|
1497
|
+
is_marked,
|
|
1498
|
+
other_end,
|
|
1496
1499
|
) -> dict[int, tuple[int, int]]:
|
|
1497
|
-
"""
|
|
1498
|
-
|
|
1499
|
-
side)``
|
|
1500
|
-
spread
|
|
1501
|
-
:func:`_side_interior_span`) — but
|
|
1502
|
-
|
|
1503
|
-
``
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
would only change this module's own aesthetic choice of where the fan
|
|
1510
|
-
visually splits, not fix anything, and would break that look for every
|
|
1511
|
-
existing fan-out/merge golden. A marker glyph, unlike a plain line
|
|
1512
|
-
junction, has no such union — two different marker glyphs landing on
|
|
1513
|
-
one cell just silently lose all but the last-drawn one, which is the
|
|
1514
|
-
actual defect this closes. (The sibling label-collision defect — two
|
|
1515
|
-
edges from a shared exit tying for "longest straight run" so a second
|
|
1516
|
-
label lands on cells the first already claimed — is fixed separately,
|
|
1517
|
-
in :func:`_longest_segment`'s tie-break, since it needs no anchor
|
|
1518
|
-
change at all.) Returns a dict keyed by index into ``edges``; an edge
|
|
1519
|
-
absent from it keeps the engine's single fixed exit anchor
|
|
1520
|
-
(:func:`_forward_exit`/:func:`_lane_anchor`) unchanged. Same-rank
|
|
1521
|
-
edges and self-loops are never grouped here — same-rank anchors are
|
|
1522
|
-
already per-pair (:func:`_facing_anchor`, dynamic per destination, not
|
|
1523
|
-
a fixed shared side), and self-loops already stack via their own
|
|
1524
|
-
``self_loop_counter``-driven reach.
|
|
1500
|
+
"""Shared spreading logic behind :func:`_allocate_edge_anchors`'s two
|
|
1501
|
+
passes (source exits, destination entries): for each ``(node_id,
|
|
1502
|
+
side)`` group of 2+ edges that share a border side at ``node_id``,
|
|
1503
|
+
spread their anchor points along that side's usable span (see
|
|
1504
|
+
:func:`_side_interior_span`) — but only when 2+ of the group's edges
|
|
1505
|
+
are ``is_marked`` (carry an arrow marker on the end at ``node_id``).
|
|
1506
|
+
``other_end(edge)`` gives the node id at the *other* end of an edge,
|
|
1507
|
+
used only to order the spread left-to-right/top-to-bottom by where
|
|
1508
|
+
each edge's far endpoint sits off-axis, so the spread reads in the
|
|
1509
|
+
same visual order the paths already fan out in. Returns a dict keyed
|
|
1510
|
+
by index into ``edges``; groups left ungrouped (size < 2) or
|
|
1511
|
+
under-marked (< 2 marked) are entirely absent from it.
|
|
1525
1512
|
"""
|
|
1526
|
-
node_by_id = {n.id: n for n in (nodes or [])}
|
|
1527
|
-
groups: dict[tuple[str, str], list[int]] = defaultdict(list)
|
|
1528
|
-
for i, e in enumerate(edges):
|
|
1529
|
-
if e.src == e.dst:
|
|
1530
|
-
continue
|
|
1531
|
-
kind = _classify_edge(rects, direction, e)
|
|
1532
|
-
if kind == "forward":
|
|
1533
|
-
groups[(e.src, _forward_exit_side(direction))].append(i)
|
|
1534
|
-
elif kind == "back":
|
|
1535
|
-
groups[(e.src, _lane_side(direction))].append(i)
|
|
1536
|
-
# "same-rank" / None (dangling): no override, unchanged behavior.
|
|
1537
|
-
|
|
1538
1513
|
overrides: dict[int, tuple[int, int]] = {}
|
|
1539
1514
|
for (node_id, side), idxs in groups.items():
|
|
1540
1515
|
if len(idxs) < 2:
|
|
1541
1516
|
continue
|
|
1542
|
-
marked = [
|
|
1543
|
-
i
|
|
1544
|
-
for i in idxs
|
|
1545
|
-
if edges[i].src_arrow or edges[i].src_arrow_kind != "default"
|
|
1546
|
-
]
|
|
1517
|
+
marked = [i for i in idxs if is_marked(edges[i])]
|
|
1547
1518
|
if len(marked) < 2:
|
|
1548
1519
|
continue
|
|
1549
1520
|
rect = rects.get(node_id)
|
|
1550
1521
|
if rect is None:
|
|
1551
1522
|
continue
|
|
1552
1523
|
ordered = sorted(
|
|
1553
|
-
idxs, key=lambda i: _abstract(direction, rects[edges[i]
|
|
1524
|
+
idxs, key=lambda i: _abstract(direction, rects[other_end(edges[i])].center)[1]
|
|
1554
1525
|
)
|
|
1555
1526
|
lo, hi = _side_span_for_node(node_by_id.get(node_id), rect, side)
|
|
1556
1527
|
spread = _spread_points(lo, hi, len(ordered))
|
|
@@ -1559,6 +1530,94 @@ def _allocate_edge_anchors(
|
|
|
1559
1530
|
return overrides
|
|
1560
1531
|
|
|
1561
1532
|
|
|
1533
|
+
def _allocate_edge_anchors(
|
|
1534
|
+
rects: dict[str, BoxRect],
|
|
1535
|
+
direction: Direction,
|
|
1536
|
+
edges: list[FlowEdge],
|
|
1537
|
+
nodes: list[FlowNode] | None = None,
|
|
1538
|
+
) -> tuple[dict[int, tuple[int, int]], dict[int, tuple[int, int]]]:
|
|
1539
|
+
"""Pre-pass over every edge: group forward/back edges by the border
|
|
1540
|
+
side they share with sibling edges at the *same* node, on **both**
|
|
1541
|
+
ends independently — exit side at the source (``(src, exit side)`` for
|
|
1542
|
+
forward edges, ``(src, lane side)`` for back-edges) and entry side at
|
|
1543
|
+
the destination (``(dst, entry side)`` for forward edges, ``(dst, lane
|
|
1544
|
+
side)`` for back-edges, since a back-edge's destination anchor is also
|
|
1545
|
+
:func:`_lane_anchor`, the same physical side as its source anchor).
|
|
1546
|
+
Within each group, spread the anchor points along that side's usable
|
|
1547
|
+
span (see :func:`_side_interior_span`) — but **only** when 2+ of the
|
|
1548
|
+
group's edges carry an arrow marker on the end at that shared node.
|
|
1549
|
+
The two ends use different tests, deliberately not symmetric in *how*
|
|
1550
|
+
they detect a marker even though the spreading they trigger is: an
|
|
1551
|
+
exit group tests ``src_arrow or src_arrow_kind != "default"`` (a
|
|
1552
|
+
source-side arrowhead of any kind is already rare — most edges have
|
|
1553
|
+
none — so its mere presence is already a meaningful signal), while an
|
|
1554
|
+
entry group tests only ``dst_arrow_kind != "default"`` (a
|
|
1555
|
+
destination-side arrowhead is the *default* for an ordinary ``-->``
|
|
1556
|
+
edge — ``dst_arrow`` is ``True`` almost everywhere — so testing its
|
|
1557
|
+
bare presence would spread every ordinary fan-in; only a non-default
|
|
1558
|
+
*kind*, the UML composition/aggregation diamond case documented in
|
|
1559
|
+
``mermaid_class.py``, is the actual collision this closes). Left
|
|
1560
|
+
stacked on one shared anchor otherwise (the overwhelming majority of
|
|
1561
|
+
groups): a plain, markerless
|
|
1562
|
+
multi-edge fan-out (``A-->B; A-->C``) or fan-in (``A-->C; B-->C``)
|
|
1563
|
+
*relies* on sharing one exit/entry cell for its trunk-then-tee look
|
|
1564
|
+
(draw_segment's junction bitmask resolves the shared point into a
|
|
1565
|
+
single ┬/┴, not two disjoint stubs) — spreading it would only change
|
|
1566
|
+
this module's own aesthetic choice of where the fan visually splits,
|
|
1567
|
+
not fix anything, and would break that look for every existing fan-
|
|
1568
|
+
out/merge golden. A marker glyph, unlike a plain line junction, has no
|
|
1569
|
+
such union — two different marker glyphs landing on one cell just
|
|
1570
|
+
silently lose all but the last-drawn one, which is the actual defect
|
|
1571
|
+
this closes, on whichever end (source or destination) carries the
|
|
1572
|
+
colliding markers. (The sibling label-collision defect — two edges
|
|
1573
|
+
from a shared exit tying for "longest straight run" so a second label
|
|
1574
|
+
lands on cells the first already claimed — is fixed separately, in
|
|
1575
|
+
:func:`_longest_segment`'s tie-break, since it needs no anchor change
|
|
1576
|
+
at all.) Returns a ``(exit_overrides, entry_overrides)`` pair, each a
|
|
1577
|
+
dict keyed by index into ``edges``; an edge absent from one keeps the
|
|
1578
|
+
engine's single fixed anchor on that end (:func:`_forward_exit`/
|
|
1579
|
+
:func:`_forward_entry`/:func:`_lane_anchor`) unchanged. Same-rank
|
|
1580
|
+
edges and self-loops are never grouped here — same-rank anchors are
|
|
1581
|
+
already per-pair (:func:`_facing_anchor`, dynamic per destination, not
|
|
1582
|
+
a fixed shared side), and self-loops already stack via their own
|
|
1583
|
+
``self_loop_counter``-driven reach.
|
|
1584
|
+
"""
|
|
1585
|
+
node_by_id = {n.id: n for n in (nodes or [])}
|
|
1586
|
+
exit_groups: dict[tuple[str, str], list[int]] = defaultdict(list)
|
|
1587
|
+
entry_groups: dict[tuple[str, str], list[int]] = defaultdict(list)
|
|
1588
|
+
for i, e in enumerate(edges):
|
|
1589
|
+
if e.src == e.dst:
|
|
1590
|
+
continue
|
|
1591
|
+
kind = _classify_edge(rects, direction, e)
|
|
1592
|
+
if kind == "forward":
|
|
1593
|
+
exit_groups[(e.src, _forward_exit_side(direction))].append(i)
|
|
1594
|
+
entry_groups[(e.dst, _forward_entry_side(direction))].append(i)
|
|
1595
|
+
elif kind == "back":
|
|
1596
|
+
exit_groups[(e.src, _lane_side(direction))].append(i)
|
|
1597
|
+
entry_groups[(e.dst, _lane_side(direction))].append(i)
|
|
1598
|
+
# "same-rank" / None (dangling): no override, unchanged behavior.
|
|
1599
|
+
|
|
1600
|
+
exit_overrides = _spread_group_anchors(
|
|
1601
|
+
rects,
|
|
1602
|
+
direction,
|
|
1603
|
+
edges,
|
|
1604
|
+
node_by_id,
|
|
1605
|
+
exit_groups,
|
|
1606
|
+
lambda e: e.src_arrow or e.src_arrow_kind != "default",
|
|
1607
|
+
lambda e: e.dst,
|
|
1608
|
+
)
|
|
1609
|
+
entry_overrides = _spread_group_anchors(
|
|
1610
|
+
rects,
|
|
1611
|
+
direction,
|
|
1612
|
+
edges,
|
|
1613
|
+
node_by_id,
|
|
1614
|
+
entry_groups,
|
|
1615
|
+
lambda e: e.dst_arrow_kind != "default",
|
|
1616
|
+
lambda e: e.src,
|
|
1617
|
+
)
|
|
1618
|
+
return exit_overrides, entry_overrides
|
|
1619
|
+
|
|
1620
|
+
|
|
1562
1621
|
def _abstract(direction: Direction, point: tuple[int, int]) -> tuple[int, int]:
|
|
1563
1622
|
"""``point`` decomposed into (primary, secondary) = (rank-axis coord,
|
|
1564
1623
|
off-axis coord) — the coordinate frame the path-shape helpers reason
|
|
@@ -1681,6 +1740,53 @@ def _reserve_label_margin(canvas: Canvas, x: int, y: int) -> None:
|
|
|
1681
1740
|
canvas.set_char(x, y, canvas.get_char(x, y), reserve=True)
|
|
1682
1741
|
|
|
1683
1742
|
|
|
1743
|
+
def _label_cell_widths(label: str) -> list[int]:
|
|
1744
|
+
"""Per-character visual width of ``label``, as successive
|
|
1745
|
+
:func:`~termrender.style.visual_len` prefix deltas — a wide
|
|
1746
|
+
CJK/fullwidth character reports ``2`` here without this module
|
|
1747
|
+
needing to duplicate ``visual_len``'s own east-asian-width table (the
|
|
1748
|
+
deltas telescope to ``visual_len(label)`` by construction, so this
|
|
1749
|
+
always agrees with the total cell count :func:`_draw_label_on_segment`
|
|
1750
|
+
already reserves for the label)."""
|
|
1751
|
+
widths = []
|
|
1752
|
+
prev = 0
|
|
1753
|
+
for i in range(1, len(label) + 1):
|
|
1754
|
+
cur = visual_len(label[: i])
|
|
1755
|
+
widths.append(cur - prev)
|
|
1756
|
+
prev = cur
|
|
1757
|
+
return widths
|
|
1758
|
+
|
|
1759
|
+
|
|
1760
|
+
def _write_label_run(
|
|
1761
|
+
canvas: Canvas, start: int, row: int, label: str, check_reserved: bool
|
|
1762
|
+
) -> None:
|
|
1763
|
+
"""Write ``label`` into row ``row`` starting at column ``start``,
|
|
1764
|
+
advancing the write cursor by each character's own visual width (see
|
|
1765
|
+
:func:`_label_cell_widths`) instead of one grid cell per Python code
|
|
1766
|
+
point. A single-width character still occupies just its one cell; a
|
|
1767
|
+
wide CJK/fullwidth character's glyph lands in its first cell only,
|
|
1768
|
+
and the *next* cell it visually covers is marked reserved and left
|
|
1769
|
+
blank — without this, that second cell stays unreserved and a
|
|
1770
|
+
connector glyph drawn earlier (edges draw before labels, see
|
|
1771
|
+
:func:`layout_flowgraph`) remains visible inside the label's own
|
|
1772
|
+
visual width. ``check_reserved`` mirrors the two call sites' existing
|
|
1773
|
+
behavior: the "clear run found" placement writes unconditionally (the
|
|
1774
|
+
whole run was already verified clear by the caller), the
|
|
1775
|
+
overflow-tolerated fallback placement instead skips any individual
|
|
1776
|
+
cell that's already reserved, one cell at a time (never overwriting a
|
|
1777
|
+
box).
|
|
1778
|
+
"""
|
|
1779
|
+
x = start
|
|
1780
|
+
for ch, w in zip(label, _label_cell_widths(label)):
|
|
1781
|
+
if not check_reserved or not canvas.is_reserved(x, row):
|
|
1782
|
+
canvas.set_char(x, row, ch, reserve=True)
|
|
1783
|
+
for extra in range(1, w):
|
|
1784
|
+
cx = x + extra
|
|
1785
|
+
if not check_reserved or not canvas.is_reserved(cx, row):
|
|
1786
|
+
canvas.set_char(cx, row, "", reserve=True)
|
|
1787
|
+
x += w
|
|
1788
|
+
|
|
1789
|
+
|
|
1684
1790
|
def _draw_label_on_segment(
|
|
1685
1791
|
canvas: Canvas, a: tuple[int, int], b: tuple[int, int], label: str
|
|
1686
1792
|
) -> None:
|
|
@@ -1717,15 +1823,12 @@ def _draw_label_on_segment(
|
|
|
1717
1823
|
for start in _label_positions(length, center, lo, hi):
|
|
1718
1824
|
run = cells(start)
|
|
1719
1825
|
if not any(canvas.is_reserved(x, y) for x, y in run):
|
|
1720
|
-
|
|
1721
|
-
canvas.set_char(px, py, ch, reserve=True)
|
|
1826
|
+
_write_label_run(canvas, start, row, label, check_reserved=False)
|
|
1722
1827
|
_reserve_label_margin(canvas, start - 1, row)
|
|
1723
1828
|
_reserve_label_margin(canvas, start + length, row)
|
|
1724
1829
|
return
|
|
1725
1830
|
start = max(lo, min(center - length // 2, hi - length + 1))
|
|
1726
|
-
|
|
1727
|
-
if not canvas.is_reserved(px, py):
|
|
1728
|
-
canvas.set_char(px, py, ch, reserve=True)
|
|
1831
|
+
_write_label_run(canvas, start, row, label, check_reserved=True)
|
|
1729
1832
|
return
|
|
1730
1833
|
|
|
1731
1834
|
# Vertical segment: the label still reads horizontally, centered on
|
|
@@ -1745,14 +1848,11 @@ def _draw_label_on_segment(
|
|
|
1745
1848
|
for row in _label_positions(1, center_row, lo, hi):
|
|
1746
1849
|
run = row_cells(row)
|
|
1747
1850
|
if not any(canvas.is_reserved(x, y) for x, y in run):
|
|
1748
|
-
|
|
1749
|
-
canvas.set_char(px, py, ch, reserve=True)
|
|
1851
|
+
_write_label_run(canvas, start_x, row, label, check_reserved=False)
|
|
1750
1852
|
_reserve_label_margin(canvas, start_x - 1, row)
|
|
1751
1853
|
_reserve_label_margin(canvas, start_x + length, row)
|
|
1752
1854
|
return
|
|
1753
|
-
|
|
1754
|
-
if not canvas.is_reserved(px, py):
|
|
1755
|
-
canvas.set_char(px, py, ch, reserve=True)
|
|
1855
|
+
_write_label_run(canvas, start_x, center_row, label, check_reserved=True)
|
|
1756
1856
|
|
|
1757
1857
|
|
|
1758
1858
|
def _draw_polyline(canvas: Canvas, points: list[tuple[int, int]], style: EdgeStyle) -> None:
|
|
@@ -1809,6 +1909,7 @@ def _route_edge_path(
|
|
|
1809
1909
|
lane_counter: list[int],
|
|
1810
1910
|
self_loop_counter: dict[str, int],
|
|
1811
1911
|
exit_anchor: tuple[int, int] | None = None,
|
|
1912
|
+
entry_anchor: tuple[int, int] | None = None,
|
|
1812
1913
|
) -> list[tuple[int, int]] | None:
|
|
1813
1914
|
"""Compute one edge's polyline points: anchors chosen by relative rank
|
|
1814
1915
|
position, an L/Z/C path shape. Pure path computation only — drawing is
|
|
@@ -1818,13 +1919,14 @@ def _route_edge_path(
|
|
|
1818
1919
|
return ``None`` (defensive no-op — the parser guarantees valid
|
|
1819
1920
|
endpoints; this module never crashes on one).
|
|
1820
1921
|
|
|
1821
|
-
``exit_anchor``, when given (from
|
|
1822
|
-
|
|
1823
|
-
:func:`_forward_exit`/:func:`
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1922
|
+
``exit_anchor``/``entry_anchor``, when given (from
|
|
1923
|
+
:func:`_allocate_edge_anchors`), replace the src/dst border point that
|
|
1924
|
+
would otherwise come from :func:`_forward_exit`/:func:`_forward_entry`/
|
|
1925
|
+
:func:`_lane_anchor` — used only when this edge's exit/entry side is
|
|
1926
|
+
shared with a sibling edge that also carries an arrow marker on that
|
|
1927
|
+
same end; ``None`` (the overwhelming common case, for either) reproduces
|
|
1928
|
+
the original single-anchor behavior exactly on that end. Same-rank
|
|
1929
|
+
edges and self-loops ignore both (see :func:`_allocate_edge_anchors`'s
|
|
1828
1930
|
docstring for why).
|
|
1829
1931
|
"""
|
|
1830
1932
|
src_rect = rects.get(edge.src)
|
|
@@ -1844,7 +1946,8 @@ def _route_edge_path(
|
|
|
1844
1946
|
|
|
1845
1947
|
if kind == "forward":
|
|
1846
1948
|
exit_pt = exit_anchor if exit_anchor is not None else _forward_exit(direction, src_rect)
|
|
1847
|
-
|
|
1949
|
+
entry_pt = entry_anchor if entry_anchor is not None else _forward_entry(direction, dst_rect)
|
|
1950
|
+
return _z_path(direction, exit_pt, entry_pt)
|
|
1848
1951
|
|
|
1849
1952
|
lane = lane_counter[0]
|
|
1850
1953
|
lane_counter[0] += 1
|
|
@@ -1854,9 +1957,8 @@ def _route_edge_path(
|
|
|
1854
1957
|
+ lane * _LANE_GAP
|
|
1855
1958
|
)
|
|
1856
1959
|
exit_pt = exit_anchor if exit_anchor is not None else _lane_anchor(direction, src_rect)
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
)
|
|
1960
|
+
entry_pt = entry_anchor if entry_anchor is not None else _lane_anchor(direction, dst_rect)
|
|
1961
|
+
return _lane_path(direction, exit_pt, entry_pt, lane_secondary)
|
|
1860
1962
|
|
|
1861
1963
|
|
|
1862
1964
|
# --------------------------------------------------------------------------
|
|
@@ -1933,7 +2035,9 @@ def layout_flowgraph(g: FlowGraph, width: int) -> list[str]:
|
|
|
1933
2035
|
# a cell — labels are only ever safe once no more lines are coming.
|
|
1934
2036
|
lane_counter = [0]
|
|
1935
2037
|
self_loop_counter: dict[str, int] = {}
|
|
1936
|
-
exit_overrides = _allocate_edge_anchors(
|
|
2038
|
+
exit_overrides, entry_overrides = _allocate_edge_anchors(
|
|
2039
|
+
rects, g.direction, g.edges, g.nodes
|
|
2040
|
+
)
|
|
1937
2041
|
edge_paths: list[tuple[FlowEdge, list[tuple[int, int]]]] = []
|
|
1938
2042
|
for i, e in enumerate(g.edges):
|
|
1939
2043
|
points = _route_edge_path(
|
|
@@ -1943,6 +2047,7 @@ def layout_flowgraph(g: FlowGraph, width: int) -> list[str]:
|
|
|
1943
2047
|
lane_counter,
|
|
1944
2048
|
self_loop_counter,
|
|
1945
2049
|
exit_overrides.get(i),
|
|
2050
|
+
entry_overrides.get(i),
|
|
1946
2051
|
)
|
|
1947
2052
|
if points is not None:
|
|
1948
2053
|
edge_paths.append((e, points))
|
|
@@ -474,3 +474,66 @@ def test_duplicate_edges_and_empty_labels_render_without_raising():
|
|
|
474
474
|
assert lines, "a well-typed non-empty graph must still render"
|
|
475
475
|
text = "\n".join(lines)
|
|
476
476
|
assert text.count("\u250c") == 2, "expected exactly the two declared node boxes"
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
# --------------------------------------------------------------------------
|
|
480
|
+
# Destination-side marker anchors (regression: _allocate_edge_anchors used to
|
|
481
|
+
# spread shared *source* exits only, so 2+ edges entering one node with
|
|
482
|
+
# different destination-end markers all landed on the single fixed
|
|
483
|
+
# _forward_entry anchor and silently lost all but the last-drawn glyph)
|
|
484
|
+
# --------------------------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def test_two_destination_side_markers_into_one_node_both_survive():
|
|
488
|
+
g = FlowGraph(
|
|
489
|
+
direction=Direction.TB,
|
|
490
|
+
nodes=[_node("A"), _node("B"), _node("C")],
|
|
491
|
+
edges=[
|
|
492
|
+
_edge("A", "C", dst_arrow_kind="diamond_filled"),
|
|
493
|
+
_edge("B", "C", dst_arrow_kind="diamond_hollow"),
|
|
494
|
+
],
|
|
495
|
+
)
|
|
496
|
+
lines = layout_flowgraph(g, width=80)
|
|
497
|
+
text = "\n".join(lines)
|
|
498
|
+
assert "\u25c6" in text, "diamond_filled destination marker must survive"
|
|
499
|
+
assert "\u25c7" in text, "diamond_hollow destination marker must survive"
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def test_plain_arrow_fan_in_still_shares_one_entry_anchor():
|
|
503
|
+
# Guards the deliberate scoping this fix must not break: an ordinary
|
|
504
|
+
# fan-in (no non-default dst_arrow_kind — an everyday "-->" edge
|
|
505
|
+
# already sets dst_arrow=True, so the trigger must be the *kind*, not
|
|
506
|
+
# bare arrow presence) still lands on one shared entry cell so
|
|
507
|
+
# draw_segment's junction bitmask resolves it into a single ┬/┴ trunk
|
|
508
|
+
# rather than two disjoint stubs either side of the node.
|
|
509
|
+
g = FlowGraph(
|
|
510
|
+
direction=Direction.TB,
|
|
511
|
+
nodes=[_node("A"), _node("B"), _node("C")],
|
|
512
|
+
edges=[_edge("A", "C"), _edge("B", "C")],
|
|
513
|
+
)
|
|
514
|
+
lines = layout_flowgraph(g, width=80)
|
|
515
|
+
text = "\n".join(lines)
|
|
516
|
+
assert "\u252c" in text or "\u2534" in text, "plain fan-in should keep its shared trunk look"
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
# --------------------------------------------------------------------------
|
|
520
|
+
# CJK edge labels (regression: label placement measured visual_len(label)
|
|
521
|
+
# for reservation/search but wrote/reserved one grid cell per Python code
|
|
522
|
+
# point, so a wide CJK label under-reserved and a connector glyph already
|
|
523
|
+
# drawn on the segment stayed visible inside the label's own visual width)
|
|
524
|
+
# --------------------------------------------------------------------------
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def test_cjk_edge_label_has_no_connector_bleed_inside_its_width():
|
|
528
|
+
g = FlowGraph(
|
|
529
|
+
direction=Direction.TB,
|
|
530
|
+
nodes=[_node("A"), _node("B")],
|
|
531
|
+
edges=[_edge("A", "B", label="\u4f60\u597d")], # "你好"
|
|
532
|
+
)
|
|
533
|
+
lines = layout_flowgraph(g, width=80)
|
|
534
|
+
label_row = next(line for line in lines if "\u4f60\u597d" in line)
|
|
535
|
+
assert not _BOX_GLYPH_RE.search(label_row), (
|
|
536
|
+
"a connector/box glyph landed inside the CJK label's own visual width: "
|
|
537
|
+
f"{label_row!r}"
|
|
538
|
+
)
|
|
539
|
+
assert label_row.strip() == "\u4f60\u597d"
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|