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/compose.py ADDED
@@ -0,0 +1,374 @@
1
+ """Repetition idioms (v0.4.0): Stacked decks, "× N" repeats, and "0 … N-1" series.
2
+
3
+ ``Stacked(node, n=3)`` — the node drawn as the front card of a deck (instances /
4
+ replicas / multi-head), back cards offset up-right by default.
5
+ ``Repeat(node, n=12)`` — the node framed (or bracketed) with a "× N" annotation,
6
+ Transformer style.
7
+ ``Series(a, b, c)`` — items in a row / column with an ellipsis before the last one,
8
+ for "Worker 0, Worker 1, …, Worker N-1".
9
+
10
+ All three are ordinary nodes (flex layout, constraints, ports); none takes coordinates.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any, List, Optional, Sequence, Tuple, Union
16
+
17
+ from .node import Container, Node, Row
18
+ from .primitives import Ellipsis, Text
19
+ from .styles import themed
20
+ from .textutil import measure_text
21
+
22
+ _OFFSETS = {"up-right": (1, -1), "down-right": (1, 1), "up-left": (-1, -1), "down-left": (-1, 1),
23
+ "right": (1, 0), "down": (0, 1), "up": (0, -1), "left": (-1, 0)}
24
+
25
+
26
+ class Stacked(Node):
27
+ """A deck of ``n`` cards: ``node`` in front, ``n - 1`` copies peeking out behind.
28
+
29
+ offset: px per card (number, or ``(dx, dy)``); direction: up-right | down-right |
30
+ up-left | down-left | right | down | …. copies: "blank" (default — the card's frame
31
+ and fill only, like replicated instances), "full" (each copy redrawn, contents and
32
+ all), or "outline" (frame with no fill). ``back_fill`` overrides the copies' fill.
33
+ ``deck.port(side)`` lands on the FRONT card (v0.7.0) — the card the reader sees —
34
+ so ``Edge(deck, x)`` / ``deck.port("right")`` start at the front card's outline, not
35
+ at the edge of the peeking back cards; ``deck.deck_port(side)`` is the old
36
+ whole-stack bounding-box port. ``back_tint`` (default 0.25 per card, ``0`` = off)
37
+ lightens each card further back so the front card reads as the object and the
38
+ copies as depth; ``back_stroke_width`` defaults to the thin theme stroke.
39
+ Role: inherits the front card's (hard for Box / shapes, background for Group).
40
+ ``same_width(deck, x)`` / ``same_height`` size the front card (v0.13.0).
41
+ """
42
+
43
+ def __init__(self, node: Node, n: int = 3, *,
44
+ offset: Union[float, Tuple[float, float], None] = None,
45
+ direction: str = "up-right", copies: str = "blank",
46
+ back_fill: Optional[Any] = None, back_stroke: Optional[str] = None,
47
+ back_tint: float = 0.25, back_stroke_width: Optional[float] = None,
48
+ **kwargs: Any) -> None:
49
+ if n < 1:
50
+ raise ValueError("Stacked needs n >= 1")
51
+ if copies not in ("blank", "full", "outline"):
52
+ raise ValueError("copies must be blank|full|outline")
53
+ if direction not in _OFFSETS:
54
+ raise ValueError(f"direction must be one of {tuple(_OFFSETS)}")
55
+ self._role_explicit = None
56
+ super().__init__(**kwargs)
57
+ self.node = node
58
+ node.parent = self
59
+ self.n = int(n)
60
+ self._offset = offset
61
+ self._direction = direction
62
+ self.copies = copies
63
+ self.back_fill = back_fill
64
+ self.back_stroke = back_stroke
65
+ self.back_tint = float(back_tint)
66
+ self.back_stroke_width = back_stroke_width
67
+
68
+ def port(self, side: str = "right", **kw: Any):
69
+ """Port in line with the FRONT card; same arguments as :meth:`Node.port`.
70
+
71
+ On the sides the back cards do not peek out of, it is the front card's own
72
+ outline. On the sides they do (``right`` / ``top`` for the default up-right
73
+ deck) it sits on the outermost card, level with the front card's port, so an
74
+ edge attaches to the deck without crossing the copies.
75
+ """
76
+ if side in ("left", "right", "top", "bottom") and set(kw) <= {"at"}:
77
+ from .edges import Port
78
+
79
+ return Port(self, kind="side", side=side, at=float(kw.get("at", 0.5)))
80
+ return self.node.port(side, **kw)
81
+
82
+ def resolve_port_side(self, side: str, at: float = 0.5):
83
+ """(x, y, direction) of :meth:`port` — used by :class:`~codefig.edges.Port`."""
84
+ fg = self.node.geom
85
+ k = self.n - 1
86
+ at = max(0.0, min(1.0, at))
87
+ ex, ey = self.dx * k, self.dy * k # rearmost card offset from the front card
88
+ if side == "right":
89
+ return fg.right + max(0.0, ex), fg.y + fg.h * at, "right"
90
+ if side == "left":
91
+ return fg.x + min(0.0, ex), fg.y + fg.h * at, "left"
92
+ if side == "top":
93
+ return fg.x + fg.w * at, fg.y + min(0.0, ey), "up"
94
+ return fg.x + fg.w * at, fg.bottom + max(0.0, ey), "down"
95
+
96
+ @property
97
+ def _size_proxy(self) -> Node:
98
+ """same_width / same_height size the front card (v0.13.0)."""
99
+ return self.node
100
+
101
+ def deck_port(self, side: str = "right", **kw: Any):
102
+ """Port on the whole deck's bounding box (front + peeking back cards)."""
103
+ return Node.port(self, side, **kw)
104
+
105
+ def _offset_xy(self) -> Tuple[float, float]:
106
+ off = self._offset
107
+ if off is None: # theme length: 7 px classic, ≈ 3.9 pt paper
108
+ off = self.theme.L(7.0)
109
+ if isinstance(off, (int, float)):
110
+ sx, sy = _OFFSETS[self._direction]
111
+ return sx * float(off), sy * float(off)
112
+ return float(off[0]), float(off[1])
113
+
114
+ @property
115
+ def dx(self) -> float:
116
+ """Per-card x offset (from ``offset`` / ``direction``; default a theme length)."""
117
+ return self._offset_xy()[0]
118
+
119
+ @property
120
+ def dy(self) -> float:
121
+ """Per-card y offset."""
122
+ return self._offset_xy()[1]
123
+
124
+ # role follows the front card unless set explicitly
125
+ @property
126
+ def obstacle(self) -> Optional[str]:
127
+ if self._role_explicit is not None:
128
+ return self._role_explicit
129
+ from .policy import role_of
130
+
131
+ return role_of(self.node) if hasattr(self, "node") else None
132
+
133
+ @obstacle.setter
134
+ def obstacle(self, v: Optional[str]) -> None:
135
+ self._role_explicit = v
136
+
137
+ def children(self) -> Sequence[Node]:
138
+ return [self.node]
139
+
140
+ def invalidate(self) -> None:
141
+ super().invalidate()
142
+ if hasattr(self, "node"):
143
+ self.node.invalidate()
144
+
145
+ def _measure(self) -> Tuple[float, float]:
146
+ w, h = self.node.measure()
147
+ k = self.n - 1
148
+ return w + abs(self.dx) * k, h + abs(self.dy) * k
149
+
150
+ def _layout_children(self) -> None:
151
+ g = self.geom
152
+ cw, ch = self.node.measure()
153
+ k = self.n - 1
154
+ x = g.x + (abs(self.dx) * k if self.dx < 0 else 0.0)
155
+ y = g.y + (abs(self.dy) * k if self.dy < 0 else 0.0)
156
+ self.node.layout(x, y, min(cw, g.w - abs(self.dx) * k) if g.w > 0 else cw,
157
+ min(ch, g.h - abs(self.dy) * k) if g.h > 0 else ch)
158
+
159
+ def _draw_card(self, buf: List[str], x: float, y: float, w: float, h: float,
160
+ depth: int = 1) -> None:
161
+ from .render_svg import draw_rect
162
+ from .styles import tint
163
+
164
+ nd = self.node
165
+ fill = self.back_fill if self.back_fill is not None else getattr(nd, "fill", None)
166
+ if self.copies == "outline":
167
+ fill = "none"
168
+ if fill is None:
169
+ fill = "#ffffff"
170
+ if (self.back_tint > 0 and isinstance(fill, str) and fill.startswith("#")
171
+ and len(fill) in (4, 7)):
172
+ fill = tint(fill, min(0.85, self.back_tint * depth))
173
+ stroke = self.back_stroke or getattr(nd, "stroke", None) or self.theme.stroke
174
+ sw = (self.back_stroke_width if self.back_stroke_width is not None
175
+ else (getattr(nd, "stroke_width", None) or self.theme.stroke_width))
176
+ if hasattr(nd, "_draw_frame") and hasattr(nd, "_path"): # a Shape: same outline
177
+ from .render_svg import draw_path
178
+
179
+ draw_path(buf, nd._path(x, y, w, h), fill=fill, stroke=stroke, stroke_width=sw)
180
+ if hasattr(nd, "_decorate"):
181
+ nd._decorate(buf, x, y, w, h)
182
+ return
183
+ r = 0.0
184
+ if getattr(nd, "rounded", False):
185
+ r = getattr(nd, "radius", None) or self.theme.corner_radius
186
+ draw_rect(buf, x, y, w, h, fill=fill, stroke=stroke, stroke_width=sw, radius=r,
187
+ dasharray=nd._dash(sw) if hasattr(nd, "_dash") else None)
188
+
189
+ def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
190
+ ng = self.node.geom
191
+ for k in range(self.n - 1, 0, -1):
192
+ if self.copies == "full":
193
+ self.node.draw(buf, ox + self.dx * k, oy + self.dy * k)
194
+ else:
195
+ self._draw_card(buf, ng.x + ox + self.dx * k, ng.y + oy + self.dy * k, ng.w, ng.h,
196
+ depth=k)
197
+ self.node.draw(buf, ox, oy)
198
+
199
+
200
+ class Repeat(Node):
201
+ """``node`` repeated N times, shown once with an "× N" mark (Transformer "N×").
202
+
203
+ style: "frame" (rounded dashed frame around the node, label beside it), "bracket"
204
+ (a square bracket on ``side`` with the label next to it) or "label" (just the text).
205
+ side: right | left | top | bottom. label: default "× {n}" (pass e.g. "N×" or "L layers").
206
+ The label is a real soft Text child (knockout / overlap aware); the frame is
207
+ background (edges may cross it, like a Group).
208
+ """
209
+
210
+ def __init__(self, node: Node, n: Any = "N", *, label: Optional[str] = None,
211
+ style: str = "frame", side: str = "right", gap: Optional[float] = None,
212
+ pad: Optional[float] = None, color: str = "#555555",
213
+ font_size: Optional[float] = None, border: Any = "dashed",
214
+ radius: Optional[float] = None, fill: Optional[Any] = None,
215
+ weight: str = "bold", **kwargs: Any) -> None:
216
+ if style not in ("frame", "bracket", "label"):
217
+ raise ValueError("style must be frame|bracket|label")
218
+ if side not in ("right", "left", "top", "bottom"):
219
+ raise ValueError("side must be right|left|top|bottom")
220
+ super().__init__(border=border if style == "frame" else None, **kwargs)
221
+ self.node = node
222
+ node.parent = self
223
+ self.n = n
224
+ self.style = style
225
+ self.side = side
226
+ self.gap = gap
227
+ self._pad = pad
228
+ self.color = color
229
+ self.radius = radius
230
+ self.fill = fill
231
+ txt = label if label is not None else f"× {n}"
232
+ self.label = Text(txt, font_size=font_size, weight=weight, color=color,
233
+ theme=self._theme)
234
+ self.label.parent = self
235
+
236
+ gap = themed(6.0)
237
+ radius = themed(6.0)
238
+ frame_width = themed(1.2, "SW")
239
+
240
+ @property
241
+ def pad(self) -> float:
242
+ """Frame padding around the node (theme length; 0 unless ``style="frame"``)."""
243
+ if self.style != "frame":
244
+ return 0.0
245
+ return float(self._pad) if self._pad is not None else self.theme.L(6.0)
246
+
247
+ def children(self) -> Sequence[Node]:
248
+ return [self.node, self.label]
249
+
250
+ def invalidate(self) -> None:
251
+ super().invalidate()
252
+ if hasattr(self, "node"):
253
+ self.node.invalidate()
254
+ self.label.invalidate()
255
+
256
+ def _bracket_w(self) -> float:
257
+ return self.theme.L(7.0) if self.style == "bracket" else 0.0
258
+
259
+ def _measure(self) -> Tuple[float, float]:
260
+ cw, ch = self.node.measure()
261
+ lw, lh = self.label.measure()
262
+ fw, fh = cw + 2 * self.pad, ch + 2 * self.pad
263
+ bw = self._bracket_w()
264
+ if self.side in ("left", "right"):
265
+ ext = bw + (self.gap if bw else 0) + self.gap + lw
266
+ return fw + ext, max(fh, lh)
267
+ ext = bw + (self.gap if bw else 0) + self.gap + lh
268
+ return max(fw, lw), fh + ext
269
+
270
+ def _frame_rect(self) -> Tuple[float, float, float, float]:
271
+ g = self.geom
272
+ cw, ch = self.node.measure()
273
+ fw, fh = cw + 2 * self.pad, ch + 2 * self.pad
274
+ lw, lh = self.label.measure()
275
+ ext_h = self._bracket_w() + (self.gap if self._bracket_w() else 0) + self.gap
276
+ if self.side == "right":
277
+ return g.x, g.y + (g.h - fh) / 2, fw, fh
278
+ if self.side == "left":
279
+ return g.x + ext_h + lw, g.y + (g.h - fh) / 2, fw, fh
280
+ if self.side == "bottom":
281
+ return g.x + (g.w - fw) / 2, g.y, fw, fh
282
+ return g.x + (g.w - fw) / 2, g.y + ext_h + lh, fw, fh
283
+
284
+ def _layout_children(self) -> None:
285
+ self._follow = False
286
+ fx, fy, fw, fh = self._frame_rect()
287
+ cw, ch = self.node.measure()
288
+ self.node.layout(fx + self.pad, fy + self.pad, cw, ch)
289
+ self._place_label(fx, fy, fw, fh)
290
+
291
+ def _after_solve(self) -> None:
292
+ """Constraints moved the repeated node: frame and label follow it."""
293
+ g = self.node.geom
294
+ fr = (g.x - self.pad, g.y - self.pad, g.w + 2 * self.pad, g.h + 2 * self.pad)
295
+ if max(abs(a - b) for a, b in zip(fr, self._frame_rect())) < 1e-6:
296
+ return
297
+ self._follow = True
298
+ self._place_label(*fr)
299
+
300
+ def _drawn_frame(self) -> Tuple[float, float, float, float]:
301
+ if not getattr(self, "_follow", False):
302
+ return self._frame_rect()
303
+ g = self.node.geom # later whole-subtree translations move this too
304
+ return g.x - self.pad, g.y - self.pad, g.w + 2 * self.pad, g.h + 2 * self.pad
305
+
306
+ def _place_label(self, fx: float, fy: float, fw: float, fh: float) -> None:
307
+ lw, lh = self.label.measure()
308
+ bw = self._bracket_w()
309
+ step = bw + (self.gap if bw else 0) + self.gap
310
+ if self.side == "right":
311
+ self.label.layout(fx + fw + step, fy + (fh - lh) / 2, lw, lh)
312
+ elif self.side == "left":
313
+ self.label.layout(fx - step - lw, fy + (fh - lh) / 2, lw, lh)
314
+ elif self.side == "bottom":
315
+ self.label.layout(fx + (fw - lw) / 2, fy + fh + step, lw, lh)
316
+ else:
317
+ self.label.layout(fx + (fw - lw) / 2, fy - step - lh, lw, lh)
318
+
319
+ def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
320
+ from .render_svg import draw_path, draw_rect
321
+
322
+ fx, fy, fw, fh = self._drawn_frame()
323
+ fx, fy = fx + ox, fy + oy
324
+ if self.style == "frame":
325
+ draw_rect(buf, fx, fy, fw, fh, fill=self.fill if self.fill is not None else "none",
326
+ stroke=self.color, stroke_width=self.frame_width, radius=self.radius,
327
+ dasharray=self._dash(self.frame_width))
328
+ elif self.style == "bracket":
329
+ b, t = self._bracket_w(), self.theme.L(3.0)
330
+ if self.side in ("right", "left"):
331
+ x0 = fx + fw + self.gap if self.side == "right" else fx - self.gap
332
+ s = 1 if self.side == "right" else -1
333
+ d = (f"M {x0:.2f},{fy + t:.2f} L {x0 + s * b:.2f},{fy + t:.2f} "
334
+ f"L {x0 + s * b:.2f},{fy + fh - t:.2f} L {x0:.2f},{fy + fh - t:.2f}")
335
+ else:
336
+ y0 = fy + fh + self.gap if self.side == "bottom" else fy - self.gap
337
+ s = 1 if self.side == "bottom" else -1
338
+ d = (f"M {fx + t:.2f},{y0:.2f} L {fx + t:.2f},{y0 + s * b:.2f} "
339
+ f"L {fx + fw - t:.2f},{y0 + s * b:.2f} L {fx + fw - t:.2f},{y0:.2f}")
340
+ draw_path(buf, d, stroke=self.color, stroke_width=self.theme.SW(1.3))
341
+ self.node.draw(buf, ox, oy)
342
+ self.label.draw(buf, ox, oy)
343
+
344
+
345
+ class Series(Row):
346
+ """Items with an ellipsis before the last: ``Series(w0, w1, wN)`` → w0 w1 … wN.
347
+
348
+ direction: "row" (default, "⋯") or "column" ("⋮"). ``ellipsis=False`` drops the
349
+ mark; ``at`` = index the ellipsis is inserted before (default: last item).
350
+ Takes every Row / Col keyword (gap, align, …). Layout-only role, like Row.
351
+ """
352
+
353
+ def __init__(self, *items: Any, ellipsis: bool = True, at: Optional[int] = None,
354
+ direction: str = "row", ellipsis_text: Optional[str] = None,
355
+ ellipsis_size: Optional[float] = None, ellipsis_color: str = "#444444",
356
+ **kwargs: Any) -> None:
357
+ if len(items) == 1 and isinstance(items[0], (list, tuple)):
358
+ items = tuple(items[0])
359
+ if direction not in ("row", "column", "col"):
360
+ raise ValueError("direction must be row|column")
361
+ kids: List[Node] = list(items)
362
+ self.ellipsis_node: Optional[Node] = None
363
+ if ellipsis and len(kids) >= 2:
364
+ vertical = direction != "row"
365
+ txt = ellipsis_text or ("⋮" if vertical else "⋯")
366
+ self.ellipsis_node = Ellipsis(txt, font_size=ellipsis_size or 16, color=ellipsis_color)
367
+ i = len(kids) - 1 if at is None else at
368
+ kids.insert(i, self.ellipsis_node)
369
+ kwargs.setdefault("align", "center")
370
+ kwargs["direction"] = "row" if direction == "row" else "column"
371
+ super().__init__(kids, **kwargs)
372
+
373
+
374
+ __all__ = ["Repeat", "Series", "Stacked"]