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/node.py ADDED
@@ -0,0 +1,864 @@
1
+ """Scene-graph node base and layout boxes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
8
+
9
+ from .styles import Theme, current_theme, normalize_justify, normalize_pad, theme_default
10
+
11
+
12
+ @dataclass
13
+ class BoxGeom:
14
+ x: float = 0.0
15
+ y: float = 0.0
16
+ w: float = 0.0
17
+ h: float = 0.0
18
+
19
+ @property
20
+ def cx(self) -> float:
21
+ return self.x + self.w / 2
22
+
23
+ @property
24
+ def cy(self) -> float:
25
+ return self.y + self.h / 2
26
+
27
+ @property
28
+ def right(self) -> float:
29
+ return self.x + self.w
30
+
31
+ @property
32
+ def bottom(self) -> float:
33
+ return self.y + self.h
34
+
35
+
36
+ SizeSpec = Union[float, int, str, None] # number | "content" | "fill" | None
37
+
38
+
39
+ def _parse_size_spec(spec: SizeSpec, content: float) -> Tuple[float, bool]:
40
+ """
41
+ Resolve a size spec against content measure.
42
+
43
+ Returns (resolved_size, wants_fill).
44
+ "content"/None → content; number → that size; "fill" → content + fill flag.
45
+ """
46
+ if spec is None or spec == "content":
47
+ return float(content), False
48
+ if isinstance(spec, str):
49
+ s = spec.strip().lower()
50
+ if s == "content":
51
+ return float(content), False
52
+ if s == "fill":
53
+ return float(content), True
54
+ try:
55
+ return float(s), False
56
+ except ValueError:
57
+ return float(content), False
58
+ return float(spec), False
59
+
60
+
61
+ def _clamp(v: float, lo: float, hi: Optional[float]) -> float:
62
+ v = max(float(lo), float(v))
63
+ if hi is not None:
64
+ v = min(float(hi), v)
65
+ return v
66
+
67
+
68
+ from .constraints import Anchor, Anchorable, box_anchor
69
+
70
+
71
+ class Node(Anchorable, ABC):
72
+ """Base drawable / layoutable node. Public API never takes absolute coords."""
73
+
74
+ def __init__(
75
+ self,
76
+ *,
77
+ min_w: float = 0.0,
78
+ min_h: float = 0.0,
79
+ max_w: Optional[float] = None,
80
+ max_h: Optional[float] = None,
81
+ w: SizeSpec = None,
82
+ h: SizeSpec = None,
83
+ aspect: Optional[float] = None,
84
+ grow: float = 0.0,
85
+ shrink: float = 1.0,
86
+ align_self: Optional[str] = None,
87
+ id: Optional[str] = None,
88
+ theme: Optional[Theme] = None,
89
+ obstacle: Any = None,
90
+ knockout: Optional[bool] = None,
91
+ border: Any = None,
92
+ legend: Optional[Any] = None,
93
+ category: Optional[str] = None,
94
+ lint_ignore: Optional[Sequence[str]] = None,
95
+ auto_wrap: bool = True,
96
+ **kwargs: Any,
97
+ ) -> None:
98
+ if kwargs:
99
+ import inspect
100
+ from difflib import get_close_matches
101
+ valid = sorted({k for cls in type(self).__mro__ for k in inspect.signature(cls.__init__).parameters if k not in {"self", "kwargs"}})
102
+ key = next(iter(kwargs))
103
+ semantic = {"style": "border", "padding_x": "padding", "padding_y": "padding"}
104
+ close = [semantic[key]] if key in semantic and semantic[key] in valid else get_close_matches(key, valid, n=1)
105
+ hint = f"; did you mean {close[0]!r}?" if close else ""
106
+ raise TypeError(f"{type(self).__name__}: unexpected keyword {key!r}{hint}; valid parameters: {valid}")
107
+ # obstacle: layering role — "hard" | "soft" | "background" | "none" (True →
108
+ # hard, False → none, None → class default; see codefig.policy). With
109
+ # "none" edges may route across this node's own area (its children are
110
+ # still obstacles) — for background regions / bands.
111
+ from .policy import normalize_role
112
+ from .styles import validate_border
113
+
114
+ self.obstacle = normalize_role(obstacle)
115
+ # knockout: soft text crossed by an edge clips the edge around itself
116
+ self.knockout = knockout
117
+ # border: 'solid' | 'dashed' | 'dotted' | 'dashdot' | 'none' | "4 2" | (4, 2)
118
+ self.border = validate_border(border)
119
+ # legend: label collected by an auto Legend (fill / hatch / border of this node)
120
+ self.legend = legend
121
+ self.min_w = float(min_w)
122
+ self.min_h = float(min_h)
123
+ self.max_w = float(max_w) if max_w is not None else None
124
+ self.max_h = float(max_h) if max_h is not None else None
125
+ self.size_w = w # content|fill|number
126
+ self.size_h = h
127
+ self.aspect = float(aspect) if aspect is not None else None
128
+ self.grow = grow
129
+ self.shrink = shrink
130
+ self.align_self = align_self # start|center|end|stretch|baseline|optical|None
131
+ self.id = id
132
+ self._theme: Optional[Theme] = theme
133
+ # category: semantic class → Figure assigns palette fill (+ hatch past the
134
+ # palette) and an auto legend label (see Figure / lint CF-COL-004)
135
+ self.category = category
136
+ # lint_ignore: rule ids (or prefixes like "CF-TXT") this node is exempt from
137
+ self.lint_ignore = ((lint_ignore,) if isinstance(lint_ignore, str)
138
+ else tuple(lint_ignore or ()))
139
+ # auto_wrap (v0.14.0): False keeps this label on one line in the figure's
140
+ # wrap-before-shrink pass (codefig.autowrap)
141
+ self.auto_wrap = bool(auto_wrap)
142
+ self.geom = BoxGeom()
143
+ self.parent: Optional[Node] = None
144
+ self._measured: Optional[Tuple[float, float]] = None
145
+ self._measured_theme: Optional[Theme] = None
146
+ # fill flags set during measure — parent grow treats these as flex
147
+ self._fill_w = False
148
+ self._fill_h = False
149
+
150
+ def __setattr__(self, name, value):
151
+ if name in {"fill", "stroke", "color", "text_color", "op_color"}:
152
+ from .styles import resolve_color
153
+ value = resolve_color(value, self.theme, where=f"{type(self).__name__}({name}=...)")
154
+ object.__setattr__(self, name, value)
155
+
156
+ @property
157
+ def theme(self) -> Theme:
158
+ """Explicit ``theme=`` if given, else the active theme (see ``use_theme``)."""
159
+ t = self.__dict__.get("_theme")
160
+ return t if t is not None else current_theme()
161
+
162
+ @theme.setter
163
+ def theme(self, value: Optional[Theme]) -> None:
164
+ self.__dict__["_theme"] = value
165
+
166
+ def measure(self) -> Tuple[float, float]:
167
+ if self._measured is not None and self.__dict__.get("_measured_theme") is not self.theme:
168
+ self._measured = None
169
+ if self._measured is None:
170
+ self._measured_theme = self.theme
171
+ cw, ch = self._measure()
172
+ w, fill_w = _parse_size_spec(self.size_w, cw)
173
+ h, fill_h = _parse_size_spec(self.size_h, ch)
174
+ self._fill_w = fill_w
175
+ self._fill_h = fill_h
176
+ if fill_w and self.grow <= 0:
177
+ self.grow = max(self.grow, 1.0)
178
+ if fill_h and self.grow <= 0:
179
+ self.grow = max(self.grow, 1.0)
180
+ # aspect lock: width/height = aspect (w/h)
181
+ if self.aspect is not None and self.aspect > 0:
182
+ # Prefer locking the unspecified / content axis
183
+ if self.size_w is not None and self.size_w != "content" and self.size_w != "fill" and (
184
+ self.size_h is None or self.size_h == "content"
185
+ ):
186
+ h = w / self.aspect
187
+ elif self.size_h is not None and self.size_h != "content" and self.size_h != "fill" and (
188
+ self.size_w is None or self.size_w == "content"
189
+ ):
190
+ w = h * self.aspect
191
+ else:
192
+ # both content or both set: fit the larger content into aspect
193
+ if w / self.aspect >= h:
194
+ h = w / self.aspect
195
+ else:
196
+ w = h * self.aspect
197
+ w = _clamp(w, self.min_w, self.max_w)
198
+ h = _clamp(h, self.min_h, self.max_h)
199
+ # same_width / same_height constraints (resolved before flex)
200
+ w = max(w, getattr(self, "_cmin_w", 0.0))
201
+ h = max(h, getattr(self, "_cmin_h", 0.0))
202
+ self._measured = (w, h)
203
+ return self._measured
204
+
205
+ def baseline_offset(self) -> float:
206
+ """
207
+ Distance from the top of this node's measured box to its primary text baseline.
208
+
209
+ Used by Row/Col `align="baseline"`. Default ≈ optical mid of the box.
210
+ Text/Box override with real text metrics.
211
+ """
212
+ _w, h = self.measure()
213
+ return h * 0.65
214
+
215
+ def optical_nudge(self) -> float:
216
+ """
217
+ Extra cross-axis shift for `align="optical"` (fraction of height).
218
+
219
+ Rounded / pill boxes look heavier; nudge them up slightly so visual mass
220
+ lines up with sharp rectangles. Default 0; Box overrides when rounded.
221
+ """
222
+ return 0.0
223
+
224
+ def invalidate(self) -> None:
225
+ self._measured = None
226
+
227
+ @abstractmethod
228
+ def _measure(self) -> Tuple[float, float]:
229
+ ...
230
+
231
+ def layout(self, x: float, y: float, w: float, h: float) -> None:
232
+ self.geom = BoxGeom(x, y, w, h)
233
+ self._layout_children()
234
+
235
+ def _layout_children(self) -> None:
236
+ pass
237
+
238
+ @abstractmethod
239
+ def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
240
+ ...
241
+
242
+ def children(self) -> Sequence[Node]:
243
+ return ()
244
+
245
+ def _dash(self, stroke_width: float = 1.0) -> Optional[str]:
246
+ """SVG dasharray for this node's ``border=`` (None = solid)."""
247
+ from .styles import border_dasharray
248
+
249
+ return border_dasharray(self.border, stroke_width)
250
+
251
+ def _border_stroke(self, stroke: Optional[str]) -> Optional[str]:
252
+ return "none" if self.border == "none" else stroke
253
+
254
+ def _anchor(self, axis: str, which: str) -> Anchor:
255
+ """Box anchor for constraints (``node.left`` / ``.right`` / ``.cy`` …)."""
256
+ return box_anchor(self, axis, which)
257
+
258
+ def port(self, side: str = "right", *, at: float = 0.5, align: Any = None,
259
+ snap: bool = False, offset: float = 0.0, slide: float = 0.0) -> "Port":
260
+ """Side anchor (left|right|top|bottom|center). `at` is fraction along the side.
261
+
262
+ ``align=`` another anchor / port / node puts the port on this side *in line
263
+ with* it (same x for top/bottom ports, same y for left/right) — e.g. the
264
+ landing point of a vertical drop under a source; ``offset`` (pt) shifts it along
265
+ the side — also on a plain side port since v0.13.0 (``port("top", offset=-6)``;
266
+ an offset port never slides). For N side-by-side arrows between two nodes
267
+ prefer ``Edge(a, b, parallel=N)``. ``snap=True`` snaps to the nearest cell /
268
+ bar centre of a Matrix / BarChart so connectors land on a cell instead of a
269
+ hand-picked index.
270
+
271
+ ``slide`` (v0.11.0) lets an explicit ``at=`` move by up to that fraction of the
272
+ side when that straightens the edge (``port("right", at=0.3, slide=0.1)`` may
273
+ land anywhere in 0.2–0.4). Plain centre ports already slide by default.
274
+ """
275
+ if not 0.0 <= float(slide) <= 1.0:
276
+ raise ValueError(f"port(): slide={slide!r} must be in [0, 1]")
277
+ from .edges import Port
278
+
279
+ if align is not None:
280
+ return Port(self, kind="aligned", side=side, at=at, align_ref=align, snap=snap,
281
+ offset=float(offset))
282
+ return Port(self, kind="side", side=side, at=at, slide=float(slide), offset=float(offset))
283
+
284
+
285
+ def _align_cross(
286
+ align: str,
287
+ container_cross: float,
288
+ child_cross: float,
289
+ start: float,
290
+ *,
291
+ child: Optional["Node"] = None,
292
+ container_baseline: Optional[float] = None,
293
+ ) -> float:
294
+ """
295
+ Cross-axis placement.
296
+
297
+ align:
298
+ start|center|end|stretch — box edges
299
+ baseline — match text baselines (Row); needs child + container_baseline
300
+ optical — center, then apply child.optical_nudge()
301
+ """
302
+ if align == "baseline" and child is not None and container_baseline is not None:
303
+ return start + container_baseline - child.baseline_offset()
304
+ if align == "optical":
305
+ y = start + (container_cross - child_cross) / 2
306
+ if child is not None:
307
+ y -= child.optical_nudge()
308
+ return y
309
+ if align == "center":
310
+ return start + (container_cross - child_cross) / 2
311
+ if align == "end":
312
+ return start + (container_cross - child_cross)
313
+ # start / stretch
314
+ return start
315
+
316
+
317
+ class Container(Node):
318
+ """Flex-like container: direction row or column.
319
+
320
+ font_size sets the title size, snapped to the theme roles (6/7/8 pt for paper).
321
+ text_color sets the title colour; children retain their own typography."""
322
+
323
+ gap = theme_default("gap")
324
+ title_gap = theme_default("title_gap")
325
+
326
+ def _title_font(self):
327
+ return self.theme.snap(self.title_font_size) if self.title_font_size is not None else self.theme.font_size
328
+
329
+ def __init__(
330
+ self,
331
+ kids: Optional[Sequence[Node]] = None,
332
+ *,
333
+ direction: str = "column", # row | column
334
+ gap: Optional[float] = None,
335
+ pad: Union[float, int, Sequence[float]] = 0.0,
336
+ padding: Optional[Union[float, int, Sequence[float]]] = None,
337
+ align: str = "center", # cross-axis: start|center|end|stretch|baseline|optical
338
+ justify: str = "start", # start|center|end|space_between|space_around|space_evenly
339
+ equal: bool = False, # children share main-axis size (= max content)
340
+ wrap: bool = False,
341
+ fill: Optional[str] = None,
342
+ stroke: Optional[str] = None,
343
+ stroke_width: Optional[float] = None,
344
+ rounded: bool = False,
345
+ radius: Optional[float] = None,
346
+ title: Optional[str] = None,
347
+ title_pos: str = "top", # top | bottom | inside-top (v0.13: "-left"/"-right" suffix)
348
+ title_gap: Optional[float] = None,
349
+ pattern: Optional[Any] = None,
350
+ title_align: str = "center", # v0.12: left|center|right (start/end aliases)
351
+ font_size: Optional[float] = None,
352
+ text_color: Optional[str] = None,
353
+ title_wrap: bool = True, # v0.12: wrap a title wider than the content
354
+ **kwargs: Any,
355
+ ) -> None:
356
+ super().__init__(**kwargs)
357
+ self.title_font_size = font_size
358
+ self.title_color = text_color
359
+ # v0.13.0: title_pos="top-left" / "bottom-right" … = title_pos + title_align
360
+ if isinstance(title_pos, str) and "-" in title_pos and title_pos != "inside-top":
361
+ tp, _, al = title_pos.rpartition("-")
362
+ if tp in ("top", "bottom", "inside-top") and al in ("left", "center", "right", "start", "end"):
363
+ title_pos = tp
364
+ title_align = al
365
+ from ._validate import check_enum
366
+ title_pos = check_enum("Container(title_pos)", title_pos, ("top", "bottom", "inside-top"))
367
+ ta = {"start": "left", "end": "right", "middle": "center"}.get(title_align, title_align)
368
+ from ._validate import check_enum
369
+ ta = check_enum("node(ta)", ta, ("left", "center", "right"))
370
+ self.title_align = ta
371
+ self.title_wrap = bool(title_wrap)
372
+ self.direction = direction
373
+ # gap defaults to theme.gap when omitted (resolved lazily)
374
+ self.gap = None if gap is None else float(gap)
375
+ raw_pad = padding if padding is not None else pad
376
+ self.padding = normalize_pad(raw_pad) # (t, r, b, l)
377
+ self.pad = sum(self.padding) / 4.0 # scalar average (BC)
378
+ self.align = align
379
+ self.justify = normalize_justify(justify)
380
+ self.equal = bool(equal)
381
+ self.wrap = wrap
382
+ self.fill = fill
383
+ if stroke is None and self.border not in (None, "none", "solid"):
384
+ stroke = "#555555" # border= alone gives a visible (dashed/dotted) frame
385
+ self.stroke = stroke
386
+ self.stroke_width = stroke_width
387
+ self.rounded = rounded
388
+ self.radius = radius
389
+ self.title = title
390
+ self.title_pos = title_pos
391
+ self.title_gap = None if title_gap is None else float(title_gap)
392
+ self.pattern = pattern
393
+ self._kids: List[Node] = []
394
+ if kids:
395
+ for k in kids:
396
+ self.add(k)
397
+
398
+ def add(self, node: Node) -> Node:
399
+ node.parent = self
400
+ self._kids.append(node)
401
+ self.invalidate()
402
+ return node
403
+
404
+ def __iadd__(self, node: Node) -> "Container":
405
+ self.add(node)
406
+ return self
407
+
408
+ def children(self) -> Sequence[Node]:
409
+ return self._kids
410
+
411
+ def _title_lines(self) -> List[str]:
412
+ """v0.12.0: the title's drawn lines — explicit ``\n`` breaks, plus greedy word
413
+ wrapping when ``title_wrap`` and the title is wider than the content (set by
414
+ ``_measure``), so a long title neither widens the row nor overlaps a child."""
415
+ if not self.title:
416
+ return []
417
+ cached = self.__dict__.get("_tl_cache")
418
+ if cached is not None and cached[0] == self.title:
419
+ return list(cached[1])
420
+ return str(self.title).split("\n")
421
+
422
+ def _wrap_title(self, content_w: float) -> List[str]:
423
+ from .textutil import measure_text
424
+
425
+ fs = self._title_font()
426
+ lines = str(self.title).split("\n")
427
+ widest = max((measure_text(ln, fs, weight="bold")[0] for ln in lines), default=0.0)
428
+ # wrap only when the title would widen the container noticeably
429
+ if self.title_wrap and content_w > 0 and widest > content_w * 1.15 + fs:
430
+ out: List[str] = []
431
+ content_w = max(content_w, widest / 2.0 + fs) # at most ~2 lines per break
432
+ for ln in lines:
433
+ if measure_text(ln, fs, weight="bold")[0] <= content_w + 0.5 or " " not in ln:
434
+ out.append(ln)
435
+ continue
436
+ cur = ""
437
+ for word in ln.split(" "):
438
+ cand = (cur + " " + word) if cur else word
439
+ if cur and measure_text(cand, fs, weight="bold")[0] > content_w + 0.5:
440
+ out.append(cur)
441
+ cur = word
442
+ else:
443
+ cur = cand
444
+ if cur:
445
+ out.append(cur)
446
+ lines = out
447
+ self.__dict__["_tl_cache"] = (self.title, list(lines))
448
+ return lines
449
+
450
+ def _title_line_h(self) -> float:
451
+ return self._title_font() * 1.2
452
+
453
+ def _title_h(self) -> float:
454
+ """Space reserved for title band (font + title_gap); v0.12.0: one line per
455
+ title line (multi-line titles no longer overlap the first child)."""
456
+ if not self.title:
457
+ return 0.0
458
+ n = max(1, len(self._title_lines()))
459
+ return self._title_font() * 1.35 + (n - 1) * self._title_line_h() + self.title_gap
460
+
461
+ def _slot_lines(self):
462
+ """Pack an automatically constrained row without changing child order."""
463
+ limit = self.__dict__.get("_slot_width")
464
+ if self.direction != "row" or limit is None:
465
+ return None
466
+ lines, line, used = [], [], 0.0
467
+ for kid in self._kids:
468
+ w, _ = kid.measure()
469
+ if line and used + self.gap + w > limit:
470
+ lines.append(line)
471
+ line, used = [], 0.0
472
+ used += (self.gap if line else 0.0) + w
473
+ line.append(kid)
474
+ if line:
475
+ lines.append(line)
476
+ return lines if len(lines) > 1 else None
477
+
478
+ def _measure(self) -> Tuple[float, float]:
479
+ pt, pr, pb, pl = self.padding
480
+ if self.title:
481
+ if self._kids:
482
+ kw = [k.measure()[0] for k in self._kids]
483
+ if self.direction == "row":
484
+ cw = sum(kw) + self._gaps_total(len(self._kids))
485
+ else:
486
+ cw = max(kw)
487
+ else:
488
+ cw = 0.0
489
+ self._wrap_title(cw)
490
+ th = self._title_h() if self.title_pos != "inside-top" else 0.0
491
+ if not self._kids:
492
+ return pl + pr, pt + pb + th
493
+
494
+ sizes = [list(k.measure()) for k in self._kids]
495
+ if self.equal and sizes:
496
+ if self.direction == "row":
497
+ mw = max(s[0] for s in sizes)
498
+ for s in sizes:
499
+ s[0] = mw
500
+ else:
501
+ mh = max(s[1] for s in sizes)
502
+ for s in sizes:
503
+ s[1] = mh
504
+ gap_total = self._gaps_total(len(self._kids))
505
+ if self.direction == "row":
506
+ w = sum(s[0] for s in sizes) + gap_total + pl + pr
507
+ h = (max(s[1] for s in sizes) if sizes else 0) + pt + pb + th
508
+ else:
509
+ w = (max(s[0] for s in sizes) if sizes else 0) + pl + pr
510
+ h = sum(s[1] for s in sizes) + gap_total + pt + pb + th
511
+ lines = self._slot_lines()
512
+ if lines:
513
+ w = max(sum(k.measure()[0] for k in line) + self.gap * (len(line) - 1)
514
+ for line in lines) + pl + pr
515
+ h = sum(max(k.measure()[1] for k in line) for line in lines) + self.gap * (len(lines) - 1) + pt + pb + th
516
+ if self.title:
517
+ from .textutil import measure_text
518
+
519
+ tw = max((measure_text(ln, self._title_font(), weight="bold")[0]
520
+ for ln in self._title_lines()), default=0.0)
521
+ w = max(w, tw + pl + pr)
522
+ return w, h
523
+
524
+ def _layout_children(self) -> None:
525
+ g = self.geom
526
+ pt, pr, pb, pl = self.padding
527
+ th = self._title_h()
528
+ title_top = th if self.title and self.title_pos == "top" else 0.0
529
+ title_bot = th if self.title and self.title_pos == "bottom" else 0.0
530
+ # inside-top title still sits in the pad band; kids start below title_gap
531
+ inside_extra = (
532
+ self._title_font() + self.title_gap
533
+ + (max(1, len(self._title_lines())) - 1) * self._title_line_h()
534
+ if self.title and self.title_pos == "inside-top"
535
+ else 0.0
536
+ )
537
+ inner_x = g.x + pl
538
+ inner_y = g.y + pt + title_top + inside_extra
539
+ inner_w = max(0.0, g.w - pl - pr)
540
+ inner_h = max(0.0, g.h - pt - pb - title_top - title_bot - inside_extra)
541
+
542
+ if not self._kids:
543
+ return
544
+
545
+ lines = self._slot_lines()
546
+ if lines:
547
+ cy = inner_y
548
+ for line in lines:
549
+ height = max(k.measure()[1] for k in line)
550
+ width = sum(k.measure()[0] for k in line) + self.gap * (len(line) - 1)
551
+ cx = inner_x + max(0.0, (inner_w - width) / 2)
552
+ for kid in line:
553
+ kw, kh = kid.measure()
554
+ kid.layout(cx, cy + (height - kh) / 2, kw, kh)
555
+ cx += kw + self.gap
556
+ cy += height + self.gap
557
+ return
558
+ sizes = [list(k.measure()) for k in self._kids]
559
+ n = len(self._kids)
560
+ main_axis = "row" if self.direction == "row" else "col"
561
+ if self.equal and sizes:
562
+ if main_axis == "row":
563
+ mw = max(s[0] for s in sizes)
564
+ for s in sizes:
565
+ s[0] = mw
566
+ else:
567
+ mh = max(s[1] for s in sizes)
568
+ for s in sizes:
569
+ s[1] = mh
570
+ main_sizes = [s[0] if main_axis == "row" else s[1] for s in sizes]
571
+ inner_main = inner_w if main_axis == "row" else inner_h
572
+
573
+ # grow/shrink first (ignore justify spacing for space_* modes)
574
+ free_for_grow = inner_main - sum(main_sizes) - self._gaps_total(n)
575
+ self._distribute(free_for_grow, main_sizes, self._kids)
576
+
577
+ # justify → offset + per-step gap
578
+ step_gap, offset = self._justify_spacing(inner_main, main_sizes)
579
+ pair = self._pair_gaps() if self.justify in ("start", "center", "end") else {}
580
+
581
+ # Shared baseline for align=baseline (Row: cross is vertical)
582
+ container_baseline = None
583
+ if self.align == "baseline" and main_axis == "row":
584
+ container_baseline = max(
585
+ (kid.baseline_offset() for kid in self._kids), default=0.0
586
+ )
587
+
588
+ if main_axis == "row":
589
+ cx = inner_x + offset
590
+ for i, kid in enumerate(self._kids):
591
+ mw = main_sizes[i]
592
+ ch = sizes[i][1]
593
+ align = kid.align_self or self.align
594
+ if align == "stretch":
595
+ ch = inner_h
596
+ cy = _align_cross(
597
+ align, inner_h, ch, inner_y,
598
+ child=kid, container_baseline=container_baseline,
599
+ )
600
+ # Clamp into inner band when baseline would overflow
601
+ if align == "baseline":
602
+ cy = min(max(cy, inner_y), inner_y + max(0.0, inner_h - ch))
603
+ kid.layout(cx, cy, mw, ch)
604
+ cx += mw + pair.get(i, step_gap)
605
+ else:
606
+ cy = inner_y + offset
607
+ for i, kid in enumerate(self._kids):
608
+ mh = main_sizes[i]
609
+ cw = sizes[i][0]
610
+ align = kid.align_self or self.align
611
+ if align == "stretch":
612
+ cw = inner_w
613
+ cx = _align_cross(
614
+ align, inner_w, cw, inner_x, child=kid,
615
+ )
616
+ kid.layout(cx, cy, cw, mh)
617
+ cy += mh + pair.get(i, step_gap)
618
+
619
+ def _justify_spacing(
620
+ self, inner_main: float, main_sizes: List[float]
621
+ ) -> Tuple[float, float]:
622
+ """Return (gap_between_children, start_offset)."""
623
+ n = len(main_sizes)
624
+ total = sum(main_sizes)
625
+ j = self.justify
626
+ if j in ("space_between",) and n > 1:
627
+ free = inner_main - total
628
+ return max(0.0, free / (n - 1)), 0.0
629
+ if j in ("space_around",) and n > 0:
630
+ free = inner_main - total
631
+ gap = max(0.0, free / n)
632
+ return gap, gap / 2.0
633
+ if j in ("space_evenly",) and n > 0:
634
+ free = inner_main - total
635
+ gap = max(0.0, free / (n + 1))
636
+ return gap, gap
637
+ # start | center | end — use declared gap
638
+ used = total + self._gaps_total(n)
639
+ offset = 0.0
640
+ if j == "center":
641
+ offset = (inner_main - used) / 2
642
+ elif j == "end":
643
+ offset = inner_main - used
644
+ return self.gap, max(0.0, offset)
645
+
646
+ def _pair_gaps(self) -> Dict[int, float]:
647
+ """v0.10.0: per-pair gap overrides ``{i: gap between child i and i + 1}``, set by
648
+ layout helpers (e.g. ``align_columns`` tightens the gap inside an aligned stack)
649
+ only when the container's own ``gap=`` was not given."""
650
+ if self.__dict__.get("_td_gap") is not None and not self.__dict__.get("_pair_gap_force"):
651
+ return {}
652
+ return {i: g for i, g in (self.__dict__.get("_pair_gap") or {}).items()
653
+ if 0 <= i < len(self._kids) - 1}
654
+
655
+ def _gaps_total(self, n: int) -> float:
656
+ pair = self._pair_gaps() if self.justify in ("start", "center", "end") else {}
657
+ return sum(pair.get(i, self.gap) for i in range(max(0, n - 1)))
658
+
659
+ def _distribute(self, free: float, mains: List[float], kids: List[Node]) -> None:
660
+ # space_* justify handles leftover free itself — skip grow when those are set
661
+ if self.justify in ("space_between", "space_around", "space_evenly"):
662
+ if free < 0:
663
+ shrinks = [k.shrink * mains[i] for i, k in enumerate(kids)]
664
+ total = sum(shrinks)
665
+ if total > 0:
666
+ for i, s in enumerate(shrinks):
667
+ mains[i] = max(0.0, mains[i] + free * (s / total))
668
+ return
669
+ if free > 0:
670
+ grows = [k.grow for k in kids]
671
+ total = sum(grows)
672
+ if total > 0:
673
+ for i, g in enumerate(grows):
674
+ mains[i] += free * (g / total)
675
+ elif free < 0:
676
+ shrinks = [k.shrink * mains[i] for i, k in enumerate(kids)]
677
+ total = sum(shrinks)
678
+ if total > 0:
679
+ for i, s in enumerate(shrinks):
680
+ mains[i] = max(0.0, mains[i] + free * (s / total))
681
+
682
+ def _title_layout(self, ox: float = 0.0, oy: float = 0.0) -> List[Tuple[str, float, float, float]]:
683
+ """``[(line, x, baseline_y, width)]`` of the drawn title lines."""
684
+ from .textutil import measure_text
685
+
686
+ if not self.title:
687
+ return []
688
+ g = self.geom
689
+ x, y = g.x + ox, g.y + oy
690
+ th = self.theme
691
+ pt, pr, pb, pl = self.padding
692
+ lines = self._title_lines()
693
+ lh = self._title_line_h()
694
+ n = len(lines)
695
+ # title baseline: pad + font, with title_gap separating title from kids/border
696
+ if self.title_pos == "bottom":
697
+ ty0 = y + g.h - pb - self.title_gap * 0.25 - (n - 1) * lh
698
+ else:
699
+ ty0 = y + pt + self._title_font()
700
+ inset = max(pl, self._title_font() * 0.5) if (self.stroke or self.fill) else pl
701
+ out = []
702
+ for i, ln in enumerate(lines):
703
+ tw, _ = measure_text(ln, self._title_font(), weight="bold")
704
+ if self.title_align == "left":
705
+ tx = x + inset
706
+ elif self.title_align == "right":
707
+ tx = x + g.w - inset - tw
708
+ else:
709
+ tx = x + (g.w - tw) / 2
710
+ out.append((ln, tx, ty0 + i * lh, tw))
711
+ return out
712
+
713
+ def title_boxes(self) -> List[Tuple[float, float, float, float]]:
714
+ """v0.12: page boxes ``(x, y, w, h)`` of the title lines (empty without a title)."""
715
+ fs = self._title_font()
716
+ return [(tx, tyb - fs * 0.8, tw, fs) for _ln, tx, tyb, tw in self._title_layout()]
717
+
718
+ def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
719
+ from .render_svg import draw_rect, draw_text
720
+
721
+ g = self.geom
722
+ x, y = g.x + ox, g.y + oy
723
+ th = self.theme
724
+ sw = self.stroke_width if self.stroke_width is not None else th.stroke_width
725
+ r = (
726
+ (self.radius if self.radius is not None else th.corner_radius)
727
+ if self.rounded
728
+ else 0.0
729
+ )
730
+ stroke = self._border_stroke(self.stroke) if self.stroke else None
731
+ has_stroke = bool(stroke and stroke != "none")
732
+ # v0.12.0: fill (and pattern) first, children next, the frame stroke LAST —
733
+ # child boxes no longer paint over a Col(stroke=) / Group(pad=0) outline.
734
+ if self.fill:
735
+ draw_rect(buf, x, y, g.w, g.h, fill=self.fill, stroke="none",
736
+ stroke_width=0, radius=r)
737
+ if getattr(self, "pattern", None) is not None:
738
+ from .primitives import _paint_node_pattern
739
+
740
+ _paint_node_pattern(buf, self.pattern, x, y, g.w, g.h)
741
+ if self.title:
742
+ for ln, tx, tyb, _tw in self._title_layout(ox, oy):
743
+ draw_text(buf, ln, tx, tyb, font_size=self._title_font(), weight="bold",
744
+ fill=getattr(self, "title_color", None) or "#111111")
745
+
746
+ for kid in self._kids:
747
+ kid.draw(buf, ox, oy)
748
+ if has_stroke:
749
+ draw_rect(
750
+ buf, x, y, g.w, g.h, fill="none", stroke=stroke, stroke_width=sw,
751
+ radius=r, dasharray=self._dash(sw),
752
+ )
753
+
754
+
755
+ class Row(Container):
756
+ """Lay children out left → right: ``Row([a, b, c], gap=6, align="center")``.
757
+
758
+ Keywords (all optional): ``gap`` (pt, default ``theme.gap``), ``pad``,
759
+ ``align`` (cross axis: start|center|end|stretch|baseline|optical),
760
+ ``justify`` (start|center|end|space_between|…), ``equal`` (children share the widest
761
+ width), ``fill``/``stroke``/``rounded``/``title`` (draw the row as a titled frame).
762
+ """
763
+
764
+ def __init__(self, kids: Optional[Sequence[Node]] = None, **kwargs: Any) -> None:
765
+ kwargs.setdefault("direction", "row")
766
+ super().__init__(kids, **kwargs)
767
+
768
+
769
+ class Col(Container):
770
+ """Lay children out top → bottom: ``Col([a, b], gap=4)``. Same keywords as :class:`Row`."""
771
+
772
+ def __init__(self, kids: Optional[Sequence[Node]] = None, **kwargs: Any) -> None:
773
+ kwargs.setdefault("direction", "column")
774
+ super().__init__(kids, **kwargs)
775
+
776
+
777
+ class Stack(Col):
778
+ """Vertical stack — Col with align=center by default. Accepts align/justify/gap/pad."""
779
+
780
+ def __init__(self, kids: Optional[Sequence[Node]] = None, **kwargs: Any) -> None:
781
+ kwargs.setdefault("align", "center")
782
+ super().__init__(kids, **kwargs)
783
+
784
+
785
+ class Grid(Container):
786
+ """Simple fixed-column grid; ``row_gap`` / ``col_gap`` override ``gap`` per axis."""
787
+
788
+ def __init__(
789
+ self,
790
+ kids: Optional[Sequence[Node]] = None,
791
+ *,
792
+ cols: int = 2,
793
+ gap: Optional[float] = None,
794
+ pad: Union[float, int, Sequence[float]] = 0.0,
795
+ align: str = "stretch",
796
+ justify: str = "start",
797
+ equal_cols: bool = False,
798
+ equal_rows: bool = False,
799
+ row_gap: Optional[float] = None,
800
+ col_gap: Optional[float] = None,
801
+ **kwargs: Any,
802
+ ) -> None:
803
+ # Grid overrides Container layout; still stores kids via Container.
804
+ super().__init__(
805
+ kids, direction="row", gap=gap, pad=pad, align=align, justify=justify, **kwargs
806
+ )
807
+ self.cols = max(1, cols)
808
+ # v0.8.0: separate spacing between rows / columns (default: ``gap``)
809
+ self.row_gap = None if row_gap is None else float(row_gap)
810
+ self.col_gap = None if col_gap is None else float(col_gap)
811
+ self.equal_cols = bool(equal_cols)
812
+ self.equal_rows = bool(equal_rows)
813
+
814
+ def _rg(self) -> float:
815
+ return float(self.gap) if self.row_gap is None else self.row_gap
816
+
817
+ def _cg(self) -> float:
818
+ return float(self.gap) if self.col_gap is None else self.col_gap
819
+
820
+ def _measure(self) -> Tuple[float, float]:
821
+ pt, pr, pb, pl = self.padding
822
+ if not self._kids:
823
+ return pl + pr, pt + pb
824
+ sizes = [k.measure() for k in self._kids]
825
+ cols = self.cols
826
+ rows = (len(sizes) + cols - 1) // cols
827
+ col_w = [0.0] * cols
828
+ row_h = [0.0] * rows
829
+ for i, (w, h) in enumerate(sizes):
830
+ r, c = divmod(i, cols)
831
+ col_w[c] = max(col_w[c], w)
832
+ row_h[r] = max(row_h[r], h)
833
+ if self.equal_cols and col_w:
834
+ mw = max(col_w)
835
+ col_w = [mw] * cols
836
+ if self.equal_rows and row_h:
837
+ mh = max(row_h)
838
+ row_h = [mh] * rows
839
+ w = sum(col_w) + self._cg() * (cols - 1) + pl + pr
840
+ h = sum(row_h) + self._rg() * (rows - 1) + pt + pb
841
+ self._col_w = col_w
842
+ self._row_h = row_h
843
+ return w, h
844
+
845
+ def _layout_children(self) -> None:
846
+ self.measure() # ensure _col_w/_row_h
847
+ g = self.geom
848
+ pt, pr, pb, pl = self.padding
849
+ cols = self.cols
850
+ x0 = g.x + pl
851
+ y0 = g.y + pt
852
+ for i, kid in enumerate(self._kids):
853
+ r, c = divmod(i, cols)
854
+ cx = x0 + sum(self._col_w[:c]) + self._cg() * c
855
+ cy = y0 + sum(self._row_h[:r]) + self._rg() * r
856
+ cw, ch = self._col_w[c], self._row_h[r]
857
+ mw, mh = kid.measure()
858
+ align = kid.align_self or self.align
859
+ if align == "stretch":
860
+ kid.layout(cx, cy, cw, ch)
861
+ else:
862
+ lx = _align_cross(align if align != "stretch" else "start", cw, mw, cx)
863
+ ly = _align_cross("center", ch, mh, cy)
864
+ kid.layout(lx, ly, mw, mh)