codefig 0.17.0__py3-none-any.whl

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.
codefig/dim.py ADDED
@@ -0,0 +1,214 @@
1
+ """Dimension lines: ``|←— label —→|`` between any two anchors.
2
+
3
+ :class:`Dim` is an overlay item for a :class:`~codefig.edges.Diagram` (pass it in
4
+ ``edges=[...]`` like an Edge). It measures along x (``side="top"|"bottom"``) or y
5
+ (``side="left"|"right"``) between two references and is drawn ``offset`` px outside
6
+ them with thin extension (witness) lines back to the measured objects.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, List, Optional, Sequence, Tuple
12
+
13
+ from .edges import Edge, Port, Pt
14
+ from .styles import Theme
15
+
16
+ DIM_SIDES = ("top", "bottom", "left", "right")
17
+ DIM_ENDS = ("arrow", "stealth", "bar", "none")
18
+
19
+
20
+ def _node_of(ref: Any) -> Any:
21
+ if isinstance(ref, Port):
22
+ return ref.node
23
+ if hasattr(ref, "geom"):
24
+ return ref
25
+ return getattr(ref, "node", None)
26
+
27
+
28
+ def _coord(ref: Any, axis: str, which: str) -> float:
29
+ """Coordinate of ``ref`` on ``axis``; nodes / refs use their ``which`` edge."""
30
+ from .constraints import Anchor
31
+
32
+ if isinstance(ref, Anchor):
33
+ if ref.axis == axis:
34
+ return ref.value()
35
+ n = ref.node # cross axis of an x-anchor: its node's box
36
+ g = n.geom
37
+ lo, hi = (g.x, g.right) if axis == "x" else (g.y, g.bottom)
38
+ return lo if which == "start" else (hi if which == "end" else (lo + hi) / 2)
39
+ if isinstance(ref, Port):
40
+ x, y, _d = ref.resolve()
41
+ return x if axis == "x" else y
42
+ if hasattr(ref, "_anchor"):
43
+ return ref._anchor(axis, which).value()
44
+ raise TypeError(f"Dim cannot measure from {type(ref).__name__}")
45
+
46
+
47
+ class Dim(Edge):
48
+ """Dimension line with end arrows / ticks and extension lines between two anchors.
49
+
50
+ Layering: the line is an edge-like overlay (an edge crossing a **hard** node is
51
+ reported by check_overlaps(); the two measured objects are exempt). The label is
52
+ **soft** with knockout — the dimension line is cut around it.
53
+
54
+ Args:
55
+ a: Start reference — a node, a Matrix row / col / cell ref, a
56
+ ``CodeBlock.line(n)``, a :class:`~codefig.edges.Port` or an
57
+ :class:`~codefig.constraints.Anchor` (e.g. ``timeline.at(3.5)``).
58
+ For a node / ref the *start* edge (left or top) is used.
59
+ b: End reference (its *end* edge — right or bottom). Default: ``a``, i.e.
60
+ measure ``a``'s own width / height.
61
+ label: Text shown on the line (rich text allowed). ``""`` for none.
62
+ side: Where the line sits: ``"top"`` (default) / ``"bottom"`` measure along x;
63
+ ``"left"`` / ``"right"`` measure along y.
64
+ offset: Distance in px from the outermost of the two references to the line
65
+ (default 10; raised automatically so a centred label clears the objects).
66
+ ends: End markers — ``"arrow"`` (default, small filled triangles), ``"stealth"``,
67
+ ``"bar"`` (perpendicular ticks only) or ``"none"``.
68
+ extension: Draw extension (witness) lines from the objects to the line
69
+ (default ``True``).
70
+ color: Line / label colour (default ``"#333333"``).
71
+ width: Line width in px (default 1.0).
72
+ style: Line dash style as for Edge (``"dashed"`` …; default solid).
73
+ font_size: Label size in px (default ``0.85 × theme.font_size``).
74
+ label_side: ``"on"`` (centred on the line, knocked out; default for
75
+ horizontal dims), ``"above"`` / ``"below"`` / ``"left"`` / ``"right"``.
76
+ Vertical dims default to the outward side. A label wider than the line
77
+ moves outward automatically.
78
+ head_size: End-arrow size (default: the theme head; 6 px under ``classic``).
79
+ theme: Theme for defaults.
80
+
81
+ Example:
82
+ >>> from codefig import Figure, Row, Box, Diagram, Dim
83
+ >>> a, b = Box("prefill", w=120), Box("decode", w=60)
84
+ >>> d = Diagram(Row([a, b], gap=0), [Dim(a, b, "180 ms", side="top")])
85
+ >>> fig = Figure(size="auto", cols=1)
86
+ >>> _ = fig.panel("a").set(d)
87
+ >>> svg = fig.to_svg()
88
+ """
89
+
90
+ def __init__(
91
+ self,
92
+ a: Any,
93
+ b: Any = None,
94
+ label: str = "",
95
+ *,
96
+ side: str = "top",
97
+ offset: float = 10.0,
98
+ ends: str = "arrow",
99
+ extension: bool = True,
100
+ color: Optional[str] = None,
101
+ width: Optional[float] = None,
102
+ style: Any = None,
103
+ font_size: Optional[float] = None,
104
+ label_side: Optional[str] = None,
105
+ head_size: Optional[float] = None,
106
+ theme: Optional[Theme] = None,
107
+ ) -> None:
108
+ if side not in DIM_SIDES:
109
+ raise ValueError(f"Dim side must be one of {DIM_SIDES}, got {side!r}")
110
+ if ends not in DIM_ENDS:
111
+ raise ValueError(f"Dim ends must be one of {DIM_ENDS}, got {ends!r}")
112
+ b = a if b is None else b
113
+ na, nb = _node_of(a), _node_of(b)
114
+ if na is None or nb is None:
115
+ raise TypeError("Dim references must be nodes, refs, ports or anchors")
116
+ self.ref_a, self.ref_b = a, b
117
+ self.side = side
118
+ self.offset = float(offset)
119
+ self.ends = ends
120
+ self.extension = bool(extension)
121
+ horizontal = side in ("top", "bottom")
122
+ if label_side is None:
123
+ label_side = "on" if horizontal else side
124
+ if label_side == "top":
125
+ label_side = "above"
126
+ from .styles import current_theme
127
+
128
+ _th = theme or current_theme()
129
+ if _th.unit == 1.0: # classic px look
130
+ width = 1.0 if width is None else width
131
+ head_size = 6.0 if head_size is None else head_size
132
+ head = {"arrow": "triangle", "stealth": "stealth", "bar": "bar", "none": "none"}[ends]
133
+ super().__init__(
134
+ Port(na, kind="side", side=side), Port(nb, kind="side", side=side),
135
+ route="straight", label=label or None, marker_start=head != "none",
136
+ marker_end=head != "none", head=head, tail=head, color=color or "#333333",
137
+ width=width, style=style, head_size=head_size, overlap="allow", stub=0.0,
138
+ label_side=label_side, label_size=font_size, label_gap=_th.L(3.0), theme=theme,
139
+ knockout=True,
140
+ )
141
+ self.route = "straight"
142
+
143
+ # ---- geometry -------------------------------------------------------------
144
+ def _geometry(self) -> Tuple[Pt, Pt, List[Tuple[Pt, Pt]]]:
145
+ """(start, end, extension segments) in page coordinates."""
146
+ a, b, s = self.ref_a, self.ref_b, self.side
147
+ horizontal = s in ("top", "bottom")
148
+ axis, cross = ("x", "y") if horizontal else ("y", "x")
149
+ lo = _coord(a, axis, "start")
150
+ hi = _coord(b, axis, "end")
151
+ near = "start" if s in ("top", "left") else "end"
152
+ ca, cb = _coord(a, cross, near), _coord(b, cross, near)
153
+ off = self.offset
154
+ if self.label and self.label_side == "on" and horizontal:
155
+ # a centred label must clear the measured objects (half its height + 2px)
156
+ from .styles import current_theme
157
+
158
+ fs = self.label_size or (self.theme or current_theme()).font_size * 0.85
159
+ off = max(off, fs * 1.25 / 2 + 3.0)
160
+ if s in ("top", "left"):
161
+ line = min(ca, cb) - off
162
+ else:
163
+ line = max(ca, cb) + off
164
+ over = 3.0 if s in ("bottom", "right") else -3.0
165
+ gap = -2.0 if s in ("bottom", "right") else 2.0
166
+ ext: List[Tuple[Pt, Pt]] = []
167
+ if self.extension:
168
+ for v, c in ((lo, ca), (hi, cb)):
169
+ if abs(line - c) > 3.0:
170
+ p0, p1 = (c - gap, line + over)
171
+ ext.append(((v, p0), (v, p1)) if horizontal else ((p0, v), (p1, v)))
172
+ if horizontal:
173
+ return (lo, line), (hi, line), ext
174
+ return (line, lo), (line, hi), ext
175
+
176
+ def endpoints(self) -> Tuple[Tuple[float, float, str], Tuple[float, float, str]]:
177
+ (x1, y1), (x2, y2), _e = self._geometry()
178
+ if self.side in ("top", "bottom"):
179
+ return (x1, y1, "left"), (x2, y2, "right")
180
+ return (x1, y1, "up"), (x2, y2, "down")
181
+
182
+ def points(self) -> List[Pt]:
183
+ p, q, _e = self._geometry()
184
+ return [p, q]
185
+
186
+ def label_geometry(self, pts, theme=None, obstacles=None, paths=None):
187
+ """Centre the label on the line; move it outward when the line is too short."""
188
+ import math
189
+
190
+ bbox, rot, align = super().label_geometry(pts, theme, obstacles, paths)
191
+ if self.label_side == "on" and len(pts) >= 2:
192
+ L = math.hypot(pts[-1][0] - pts[0][0], pts[-1][1] - pts[0][1])
193
+ horizontal = self.side in ("top", "bottom")
194
+ along = bbox[2] if horizontal else bbox[3]
195
+ if along + 2 * 8.0 > L: # would cover the end arrows
196
+ keep = self.label_side
197
+ self.label_side = {"top": "above", "bottom": "below"}.get(self.side, self.side)
198
+ try:
199
+ bbox, rot, align = super().label_geometry(pts, theme, obstacles, paths)
200
+ finally:
201
+ self.label_side = keep
202
+ return bbox, rot, align
203
+
204
+ def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0, **kw: Any) -> None: # type: ignore[override]
205
+ from .render_svg import draw_line
206
+
207
+ super().draw(buf, ox, oy, **kw)
208
+ st = self.resolved(kw.get("theme"))
209
+ for (x1, y1), (x2, y2) in self._geometry()[2]:
210
+ draw_line(buf, x1 + ox, y1 + oy, x2 + ox, y2 + oy, stroke=st.color,
211
+ stroke_width=max(0.6, st.width * 0.8), opacity=st.opacity)
212
+
213
+
214
+ __all__ = ["Dim", "DIM_SIDES", "DIM_ENDS"]
codefig/edge_align.py ADDED
@@ -0,0 +1,231 @@
1
+ """v0.11.0: node-nudging edge alignment.
2
+
3
+ After flex layout, the constraint solver still has freedom: a child of a ``Row`` can
4
+ move vertically inside the row's padding band, a ``Col`` child horizontally, gaps
5
+ may shrink to the container ``gap``, and unconstrained siblings can shift. This pass
6
+ uses that freedom to put the two ports of a connected pair on one line so the edge
7
+ is a single straight segment instead of a Z.
8
+
9
+ For every orthogonal edge between two side ports that face each other (right→left,
10
+ bottom→top, …) and are offset by more than 0.05 pt, a *medium* relation
11
+ ``port_a.y == port_b.y`` (or ``.x``) is a candidate. Candidates are tried greedily —
12
+ short edges first, then small offsets — and one is kept only if, after solving with
13
+ everything kept so far:
14
+
15
+ * the relation really holds (stronger user constraints and the layout invariants win);
16
+ * no container grows and the content bbox does not get larger;
17
+ * no node moves by more than ``theme.align_max_shift`` (balance: a big move to fix
18
+ one edge usually skews the layout);
19
+ * the number of visible jogs over the panel's diagrams drops, and no new overlap
20
+ appears.
21
+ * (v0.12.0) no user constraint gets a larger residual, and no node that a user
22
+ equality constraint names (``same_x`` / ``same_y`` / ``align`` / ``place`` …)
23
+ moves on that constraint's axis — what you pinned stays where you pinned it.
24
+
25
+ Opt out per figure (``Figure(align_edges=False)``), per panel
26
+ (``fig.panel(..., align_edges=False)``) or per edge (``Edge(..., align=False)``).
27
+ """
28
+ from __future__ import annotations
29
+
30
+ from typing import Any, Callable, List, Optional, Sequence, Tuple
31
+
32
+ from .constraints import Anchor, Constraint, Rel, node_name, walk
33
+
34
+
35
+ def _facing_axis(a: Tuple[float, float, str], b: Tuple[float, float, str]) -> Optional[int]:
36
+ """Coordinate index that must match for a straight edge (1 = y, 0 = x), or None."""
37
+ if (a[2], b[2]) == ("right", "left") and b[0] > a[0] or \
38
+ (a[2], b[2]) == ("left", "right") and b[0] < a[0]:
39
+ return 1
40
+ if (a[2], b[2]) == ("down", "up") and b[1] > a[1] or \
41
+ (a[2], b[2]) == ("up", "down") and b[1] < a[1]:
42
+ return 0
43
+ return None
44
+
45
+
46
+ def _is_ancestor(a: Any, b: Any) -> bool:
47
+ n = getattr(b, "parent", None)
48
+ while n is not None:
49
+ if n is a:
50
+ return True
51
+ n = getattr(n, "parent", None)
52
+ return False
53
+
54
+
55
+ def _port_getter(port: Any, k: int) -> Callable[[], float]:
56
+ return lambda: float(port.resolve()[k])
57
+
58
+
59
+ def candidates(content: Any) -> List[Tuple[Tuple[float, float], Any, Constraint]]:
60
+ """(priority, edge, medium constraint) for every alignable edge under ``content``."""
61
+ from .edges import Diagram, Edge
62
+
63
+ out = []
64
+ for d in walk(content):
65
+ if not isinstance(d, Diagram):
66
+ continue
67
+ th = d.theme
68
+ max_off = th.L(float(getattr(th, "align_max_offset", 60.0)))
69
+ for e in d.edges:
70
+ if not isinstance(e, Edge) or not e.align or e.route != "ortho" or \
71
+ getattr(e, "bundle", None):
72
+ continue
73
+ if getattr(e.src, "node", None) is None or getattr(e.dst, "node", None) is None:
74
+ continue
75
+ if getattr(e, "_bare_ends", False):
76
+ e._pick_bare_sides()
77
+ na, nb = e.src.node, e.dst.node
78
+ if na is nb or _is_ancestor(na, nb) or _is_ancestor(nb, na):
79
+ continue
80
+ try:
81
+ a, b = e.src.resolve(), e.dst.resolve()
82
+ except Exception: # pragma: no cover - unresolvable custom anchor
83
+ continue
84
+ k = _facing_axis(a, b)
85
+ if k is None:
86
+ continue
87
+ off = abs(a[k] - b[k])
88
+ if not 0.05 < off <= max_off:
89
+ continue
90
+ axis = "y" if k == 1 else "x"
91
+ ra = Anchor(na, axis, getter=_port_getter(e.src, k), label="port")
92
+ rb = Anchor(nb, axis, getter=_port_getter(e.dst, k), label="port")
93
+ c = Constraint(kind="align_edge", rels=[Rel(ra, "==", rb)], strength="medium",
94
+ desc=f"align_edge({node_name(na)} → {node_name(nb)})")
95
+ length = abs(a[1 - k] - b[1 - k])
96
+ out.append(((round(length, 1), round(off, 2)), e, c))
97
+ out.sort(key=lambda t: t[0])
98
+ return out
99
+
100
+
101
+ def visible_jogs(content: Any) -> int:
102
+ """Orthogonal routes with a Z / staircase step (first and last runs parallel,
103
+ heading the same way, not collinear) over every Diagram under ``content``."""
104
+ from .edges import Diagram, _simplify_ortho
105
+
106
+ n = 0
107
+ for d in walk(content):
108
+ if not isinstance(d, Diagram):
109
+ continue
110
+ try:
111
+ routed = d._routed()[0]
112
+ except Exception: # pragma: no cover
113
+ continue
114
+ for e, pts in routed:
115
+ if getattr(e, "route", None) != "ortho":
116
+ continue
117
+ if is_jog(_simplify_ortho(pts)):
118
+ n += 1
119
+ return n
120
+
121
+
122
+ def is_jog(q: Sequence[Tuple[float, float]]) -> bool:
123
+ if len(q) < 4:
124
+ return False
125
+ (x0, y0), (x1, y1) = q[0], q[1]
126
+ (x2, y2), (x3, y3) = q[-2], q[-1]
127
+ h = abs(y0 - y1) < 1e-6 and abs(y2 - y3) < 1e-6
128
+ v = abs(x0 - x1) < 1e-6 and abs(x2 - x3) < 1e-6
129
+ if not (h or v):
130
+ return False
131
+ k = 1 if h else 0
132
+ if (q[1][1 - k] - q[0][1 - k]) * (q[-1][1 - k] - q[-2][1 - k]) <= 0:
133
+ return False
134
+ return abs(q[-1][k] - q[0][k]) >= 0.05
135
+
136
+
137
+ def snapshot(content: Any) -> dict:
138
+ return {id(n): (n.geom.x, n.geom.y, n.geom.w, n.geom.h) for n in walk(content)}
139
+
140
+
141
+ def flush_pairs(content: Any, snap: dict) -> List[Tuple[int, int, int, int]]:
142
+ """Sibling pairs whose start or end edges coincide (``(id_a, id_b, axis, end)``).
143
+
144
+ A nudge that breaks one of these is rejected: flush tops in a row of panels or
145
+ flush left edges in a column read as deliberate alignment, and moving one box
146
+ off that line to straighten an arrow makes the figure look worse, not better."""
147
+ out = []
148
+ for n in walk(content):
149
+ kids = [k for k in (n.children() if hasattr(n, "children") else [])
150
+ if id(k) in snap and getattr(k, "visible", True)]
151
+ if not kids or len(kids) > 40:
152
+ continue
153
+ gp = snap[id(n)]
154
+ for ax in (0, 1): # children pinned to one side of their parent (align="start")
155
+ offs = [(k, snap[id(k)][ax] - gp[ax],
156
+ gp[ax] + gp[ax + 2] - snap[id(k)][ax] - snap[id(k)][ax + 2]) for k in kids]
157
+ lo_s = min(o[1] for o in offs)
158
+ lo_e = min(o[2] for o in offs)
159
+ for k, so, eo in offs:
160
+ if abs(so - lo_s) < 0.05 and so < eo - 0.05:
161
+ out.append((id(n), id(k), ax, 2))
162
+ if abs(eo - lo_e) < 0.05 and eo < so - 0.05:
163
+ out.append((id(n), id(k), ax, 3))
164
+ for i, a in enumerate(kids):
165
+ ga = snap[id(a)]
166
+ for b in kids[i + 1:]:
167
+ gb = snap[id(b)]
168
+ for ax in (0, 1):
169
+ if abs(ga[ax] - gb[ax]) < 0.05:
170
+ out.append((id(a), id(b), ax, 0))
171
+ if abs(ga[ax] + ga[ax + 2] - gb[ax] - gb[ax + 2]) < 0.05:
172
+ out.append((id(a), id(b), ax, 1))
173
+ return out
174
+
175
+
176
+ def breaks_flush(pairs: Sequence[Tuple[int, int, int, int]], snap: dict,
177
+ snap0: Optional[dict] = None) -> bool:
178
+ for ia, ib, ax, end in pairs:
179
+ ga, gb = snap[ia], snap[ib]
180
+ if end >= 2: # parent ia, child ib: keep the child's offset to that side
181
+ if snap0 is not None:
182
+ p0, k0 = snap0[ia], snap0[ib]
183
+ if end == 2:
184
+ d, d0 = gb[ax] - ga[ax], k0[ax] - p0[ax]
185
+ else:
186
+ d = ga[ax] + ga[ax + 2] - gb[ax] - gb[ax + 2]
187
+ d0 = p0[ax] + p0[ax + 2] - k0[ax] - k0[ax + 2]
188
+ if abs(d - d0) > 0.05:
189
+ return True
190
+ continue
191
+ va = ga[ax] + (ga[ax + 2] if end else 0.0)
192
+ vb = gb[ax] + (gb[ax + 2] if end else 0.0)
193
+ if abs(va - vb) > 0.05:
194
+ return True
195
+ return False
196
+
197
+
198
+ def broken_sibling_pairs(pairs: Sequence[Tuple[int, int, int, int]], snap: dict
199
+ ) -> List[Tuple[int, int, int, int]]:
200
+ """The sibling entries of ``pairs`` (``end`` 0 / 1) whose shared edge ``snap`` broke."""
201
+ out = []
202
+ for ia, ib, ax, end in pairs:
203
+ if end >= 2:
204
+ continue
205
+ ga, gb = snap[ia], snap[ib]
206
+ va = ga[ax] + (ga[ax + 2] if end else 0.0)
207
+ vb = gb[ax] + (gb[ax + 2] if end else 0.0)
208
+ if abs(va - vb) > 0.05:
209
+ out.append((ia, ib, ax, end))
210
+ return out
211
+
212
+
213
+ def flush_repairs(content: Any, broken: Sequence[Tuple[int, int, int, int]]) -> list:
214
+ """v0.11.0: medium constraints that keep each broken sibling pair flush again, so a
215
+ nudge can move a node *together with* the siblings it was lined up with (a parser
216
+ and the model box beside it) instead of being rejected."""
217
+ from .constraints import Constraint, Rel
218
+
219
+ by_id = {id(n): n for n in walk(content)}
220
+ out = []
221
+ for ia, ib, ax, end in broken:
222
+ a, b = by_id.get(ia), by_id.get(ib)
223
+ if a is None or b is None:
224
+ continue
225
+ axis = "x" if ax == 0 else "y"
226
+ fr = 1.0 if end else 0.0
227
+ out.append(Constraint(kind="align_edge", rels=[Rel(Anchor(a, axis, frac=fr), "==",
228
+ Anchor(b, axis, frac=fr))],
229
+ strength="medium",
230
+ desc=f"flush({node_name(a)}, {node_name(b)})"))
231
+ return out