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/constraints.py ADDED
@@ -0,0 +1,1571 @@
1
+ """Relational layout constraints on top of the flex model (Cassowary via kiwisolver).
2
+
3
+ Authors never write coordinates. They state relations between *anchors* of nodes
4
+ that already exist in the flex tree::
5
+
6
+ fig.constrain(
7
+ align(a.right, b.left), # a's right edge == b's left edge
8
+ same_y(q.row_anchor(5), k.row_anchor(5)), # matrix rows on one line
9
+ below(note, box, gap=8, align="center"), # TikZ ``below=of``
10
+ place(Text("hot"), near=k.row_anchor(3), side="right", offset=6),
11
+ nudge(label, dy=-2),
12
+ )
13
+
14
+ Pipeline (see ``solve_constraints``):
15
+
16
+ 1. Flex layout runs first and fixes every node's size and an initial position.
17
+ 2. Every constrained node, all of its ancestors, and the direct children of those
18
+ ancestors get translation variables ``dx, dy``; containers additionally get
19
+ growth variables ``gl, gr, gt, gb >= 0``. A node's absolute position is its flex
20
+ position plus the sum of its ancestors' translations (shifts propagate to
21
+ descendants).
22
+ 3. Layout invariants are *required*: children stay inside their (possibly grown)
23
+ parent's padding band, and Row/Col/Grid siblings never overlap along the main
24
+ axis (gaps may shrink down to the container ``gap``, never below).
25
+ 4. Every variable has a weak "stay" at 0 (the flex result); user constraints are
26
+ ``required`` | ``strong`` (default) | ``medium`` | ``weak`` and override the
27
+ stays softly. The first node of a relation is the *subject* (the one that moves);
28
+ later nodes are references and get a stiffer stay.
29
+ 5. Required conflicts raise :class:`ConstraintConflictError` naming the nodes;
30
+ soft constraints that could not be met within 0.5px emit a
31
+ :class:`ConstraintWarning`.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import warnings
37
+ from dataclasses import dataclass, field
38
+ from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple, Union
39
+
40
+ try: # pragma: no cover - import guard
41
+ import kiwisolver as _kiwi
42
+ except Exception: # pragma: no cover
43
+ _kiwi = None
44
+
45
+
46
+ class ConstraintConflictError(ValueError):
47
+ """Raised when required constraints (or layout invariants) cannot all hold."""
48
+
49
+
50
+ class ConstraintWarning(UserWarning):
51
+ """A soft (strong/medium/weak) constraint could not be satisfied exactly."""
52
+
53
+
54
+ # --------------------------------------------------------------------------- naming
55
+
56
+ def node_name(node: Any) -> str:
57
+ """Human readable node label for error messages (id > text > label > class)."""
58
+ if node is None:
59
+ return "<None>"
60
+ nid = getattr(node, "id", None)
61
+ cls = type(node).__name__
62
+ if nid:
63
+ return f"{cls}#{nid}"
64
+ for attr in ("text", "label", "title"):
65
+ v = getattr(node, attr, None)
66
+ if isinstance(v, str) and v.strip():
67
+ s = v.replace("\n", " ").strip()
68
+ if len(s) > 24:
69
+ s = s[:23] + "…"
70
+ return f"{cls}({s!r})"
71
+ return f"{cls}@{id(node) & 0xFFFF:04x}"
72
+
73
+
74
+ # --------------------------------------------------------------------------- anchors
75
+
76
+ _WHICH_FRAC = {"start": 0.0, "center": 0.5, "end": 1.0}
77
+
78
+
79
+ class Anchor:
80
+ """
81
+ One coordinate (x or y) attached to a node. No absolute numbers: its value is
82
+ whatever the layout puts there. ``anchor + 4`` adds a constant offset.
83
+
84
+ Box anchors (``frac`` set) follow container growth; internal anchors (matrix
85
+ rows/cells, ports) are read through ``getter`` from the node's geometry.
86
+ """
87
+
88
+ __slots__ = ("node", "axis", "frac", "getter", "offset", "label")
89
+
90
+ def __init__(
91
+ self,
92
+ node: Any,
93
+ axis: str,
94
+ *,
95
+ frac: Optional[float] = None,
96
+ getter: Optional[Callable[[], float]] = None,
97
+ offset: float = 0.0,
98
+ label: str = "",
99
+ ) -> None:
100
+ if axis not in ("x", "y"):
101
+ raise ValueError("axis must be 'x' or 'y'")
102
+ self.node = node
103
+ self.axis = axis
104
+ self.frac = frac
105
+ self.getter = getter
106
+ self.offset = float(offset)
107
+ self.label = label
108
+
109
+ def _copy(self, offset: float) -> "Anchor":
110
+ return Anchor(self.node, self.axis, frac=self.frac, getter=self.getter,
111
+ offset=offset, label=self.label)
112
+
113
+ def __add__(self, k: float) -> "Anchor":
114
+ return self._copy(self.offset + float(k))
115
+
116
+ def __radd__(self, k: float) -> "Anchor":
117
+ return self.__add__(k)
118
+
119
+ def __sub__(self, k: float) -> "Anchor":
120
+ return self._copy(self.offset - float(k))
121
+
122
+ def base_value(self) -> float:
123
+ """Current geometric value without the constant offset."""
124
+ if self.getter is not None:
125
+ return float(self.getter())
126
+ g = self.node.geom
127
+ if self.axis == "x":
128
+ return g.x + g.w * float(self.frac or 0.0)
129
+ return g.y + g.h * float(self.frac or 0.0)
130
+
131
+ def value(self) -> float:
132
+ """Current value (after layout / solve) including offset."""
133
+ return self.base_value() + self.offset
134
+
135
+ def describe(self) -> str:
136
+ s = f"{node_name(self.node)}.{self.label or self.axis}"
137
+ if abs(self.offset) > 1e-9:
138
+ s += f"{self.offset:+g}"
139
+ return s
140
+
141
+ def __repr__(self) -> str: # pragma: no cover
142
+ return f"Anchor({self.describe()})"
143
+
144
+
145
+ class Anchorable:
146
+ """Mixin: ``left/right/top/bottom/cx/cy`` anchors from ``_anchor(axis, which)``."""
147
+
148
+ def _anchor(self, axis: str, which: str) -> Anchor: # pragma: no cover - abstract
149
+ raise NotImplementedError
150
+
151
+ @property
152
+ def left(self) -> Anchor:
153
+ return self._anchor("x", "start")
154
+
155
+ @property
156
+ def right(self) -> Anchor:
157
+ return self._anchor("x", "end")
158
+
159
+ @property
160
+ def top(self) -> Anchor:
161
+ return self._anchor("y", "start")
162
+
163
+ @property
164
+ def bottom(self) -> Anchor:
165
+ return self._anchor("y", "end")
166
+
167
+ @property
168
+ def cx(self) -> Anchor:
169
+ return self._anchor("x", "center")
170
+
171
+ @property
172
+ def cy(self) -> Anchor:
173
+ return self._anchor("y", "center")
174
+
175
+ center_x = cx
176
+ center_y = cy
177
+
178
+ def anchor(self, name: str) -> Anchor:
179
+ """Named anchor: left|right|top|bottom|cx|cy|center_x|center_y."""
180
+ name = name.lower().replace("-", "_")
181
+ table = {
182
+ "left": ("x", "start"), "right": ("x", "end"),
183
+ "top": ("y", "start"), "bottom": ("y", "end"),
184
+ "cx": ("x", "center"), "center_x": ("x", "center"),
185
+ "cy": ("y", "center"), "center_y": ("y", "center"),
186
+ }
187
+ if name not in table:
188
+ raise ValueError(f"unknown anchor {name!r}")
189
+ return self._anchor(*table[name])
190
+
191
+
192
+ _LABELS = {("x", "start"): "left", ("x", "end"): "right", ("x", "center"): "cx",
193
+ ("y", "start"): "top", ("y", "end"): "bottom", ("y", "center"): "cy"}
194
+
195
+
196
+ def box_anchor(node: Any, axis: str, which: str) -> Anchor:
197
+ return Anchor(node, axis, frac=_WHICH_FRAC[which], label=_LABELS[(axis, which)])
198
+
199
+
200
+ class _RefBase(Anchorable):
201
+ node: Any
202
+
203
+ def _anchor(self, axis: str, which: str) -> Anchor:
204
+ lo, hi = self._span(axis)
205
+ f = _WHICH_FRAC[which]
206
+ return Anchor(
207
+ self.node, axis,
208
+ getter=lambda lo=lo, hi=hi, f=f: lo() + (hi() - lo()) * f,
209
+ label=f"{self._name()}.{_LABELS[(axis, which)]}",
210
+ )
211
+
212
+ def _span(self, axis: str) -> Tuple[Callable[[], float], Callable[[], float]]: # pragma: no cover
213
+ raise NotImplementedError
214
+
215
+ def _name(self) -> str: # pragma: no cover
216
+ return ""
217
+
218
+
219
+ class MatrixRowRef(_RefBase):
220
+ """Anchor set for one matrix row: ``cy``/``top``/``bottom`` of the row strip,
221
+ ``left``/``right``/``cx`` of the cell grid."""
222
+
223
+ def __init__(self, m: Any, row: int) -> None:
224
+ self.node, self.row = m, int(row)
225
+
226
+ def _name(self) -> str:
227
+ return f"row[{self.row}]"
228
+
229
+ def _span(self, axis: str):
230
+ m, r = self.node, self.row
231
+ if axis == "y":
232
+ return (lambda: m._row_center_y(r) - m._row_h(r) / 2, lambda: m._row_center_y(r) + m._row_h(r) / 2)
233
+ return (lambda: m._grid_origin()[0], lambda: m._grid_origin()[0] + m._grid_size()[0])
234
+
235
+
236
+ class MatrixColRef(_RefBase):
237
+ def __init__(self, m: Any, col: int) -> None:
238
+ self.node, self.col = m, int(col)
239
+
240
+ def _name(self) -> str:
241
+ return f"col[{self.col}]"
242
+
243
+ def _span(self, axis: str):
244
+ m, c = self.node, self.col
245
+ if axis == "x":
246
+ return (lambda: m._col_center_x(c) - m._col_w(c) / 2, lambda: m._col_center_x(c) + m._col_w(c) / 2)
247
+ return (lambda: m._grid_origin()[1], lambda: m._grid_origin()[1] + m._grid_size()[1])
248
+
249
+
250
+ class MatrixCellRef(_RefBase):
251
+ def __init__(self, m: Any, row: int, col: int) -> None:
252
+ self.node, self.row, self.col = m, int(row), int(col)
253
+
254
+ def _name(self) -> str:
255
+ return f"cell[{self.row},{self.col}]"
256
+
257
+ def _span(self, axis: str):
258
+ m, r, c = self.node, self.row, self.col
259
+ if axis == "x":
260
+ return (lambda: m._col_center_x(c) - m._col_w(c) / 2, lambda: m._col_center_x(c) + m._col_w(c) / 2)
261
+ return (lambda: m._row_center_y(r) - m._row_h(r) / 2, lambda: m._row_center_y(r) + m._row_h(r) / 2)
262
+
263
+
264
+ def port_anchor(port: Any, axis: str) -> Anchor:
265
+ idx = 0 if axis == "x" else 1
266
+ return Anchor(port.node, axis, getter=lambda: port.resolve()[idx],
267
+ label=f"port({port.kind}:{port.side}).{axis}")
268
+
269
+
270
+ AnchorLike = Any # Anchor | Node | MatrixRowRef | ... | Port
271
+
272
+
273
+ def as_anchor(obj: AnchorLike, axis: str, which: str = "center") -> Anchor:
274
+ """Coerce ``obj`` to an Anchor on ``axis`` (nodes/refs use ``which``)."""
275
+ if isinstance(obj, Anchor):
276
+ if obj.axis != axis:
277
+ raise ValueError(f"{obj.describe()} is an {obj.axis}-anchor; expected {axis}")
278
+ return obj
279
+ from .edges import Port
280
+
281
+ if isinstance(obj, Port):
282
+ return port_anchor(obj, axis)
283
+ if hasattr(obj, "_anchor"):
284
+ return obj._anchor(axis, which)
285
+ raise TypeError(f"cannot use {type(obj).__name__} as an anchor")
286
+
287
+
288
+ def _subject_node(obj: AnchorLike) -> Any:
289
+ return obj.node if hasattr(obj, "node") and not hasattr(obj, "geom") else obj
290
+
291
+
292
+ # --------------------------------------------------------------------------- constraints
293
+
294
+ _STRENGTH_NAMES = ("required", "strong", "medium", "weak")
295
+
296
+
297
+ def _kiwi_strength(s: Union[str, float]) -> float:
298
+ if _kiwi is None: # pragma: no cover
299
+ raise ImportError("codefig constraints need kiwisolver (pip install kiwisolver)")
300
+ if isinstance(s, (int, float)):
301
+ return float(s)
302
+ s = str(s).lower()
303
+ if s == "required":
304
+ return _kiwi.strength.required
305
+ if s == "strong":
306
+ return _kiwi.strength.strong
307
+ if s == "medium":
308
+ return _kiwi.strength.medium
309
+ if s == "weak":
310
+ # above every flex "stay" (≤10) but below medium
311
+ return _kiwi.strength.create(0, 0, 500)
312
+ raise ValueError(f"strength must be one of {_STRENGTH_NAMES} or a number, got {s!r}")
313
+
314
+
315
+ @dataclass
316
+ class Rel:
317
+ a: Anchor
318
+ op: str # == | >= | <=
319
+ b: Anchor
320
+ const: float = 0.0 # a op b + const
321
+
322
+ def residual(self) -> float:
323
+ d = self.a.value() - (self.b.value() + self.const)
324
+ if self.op == "==":
325
+ return abs(d)
326
+ if self.op == ">=":
327
+ return max(0.0, -d)
328
+ return max(0.0, d)
329
+
330
+ def describe(self) -> str:
331
+ c = f" {self.const:+g}" if abs(self.const) > 1e-9 else ""
332
+ return f"{self.a.describe()} {self.op} {self.b.describe()}{c}"
333
+
334
+ def anchors(self) -> List[Anchor]:
335
+ return [self.a, self.b]
336
+
337
+
338
+ @dataclass
339
+ class LinRel:
340
+ """Linear relation ``sum(coef * anchor) op const`` (used by distribute())."""
341
+
342
+ terms: List[Tuple[float, Anchor]]
343
+ op: str = "=="
344
+ const: float = 0.0
345
+
346
+ def residual(self) -> float:
347
+ d = sum(k * a.value() for k, a in self.terms) - self.const
348
+ if self.op == "==":
349
+ return abs(d)
350
+ if self.op == ">=":
351
+ return max(0.0, -d)
352
+ return max(0.0, d)
353
+
354
+ def describe(self) -> str:
355
+ parts = " ".join(f"{k:+g}*{a.describe()}" for k, a in self.terms)
356
+ return f"{parts} {self.op} {self.const:g}"
357
+
358
+ def anchors(self) -> List[Anchor]:
359
+ return [a for _k, a in self.terms]
360
+
361
+
362
+ @dataclass
363
+ class Constraint:
364
+ """A relational layout constraint. Build with the helper functions below."""
365
+
366
+ kind: str
367
+ rels: List[Any] = field(default_factory=list) # Rel | LinRel
368
+ strength: Union[str, float] = "strong"
369
+ subjects: List[Any] = field(default_factory=list)
370
+ refs: List[Any] = field(default_factory=list)
371
+ overlay: Optional[Any] = None # place(): node drawn on top of the flex tree
372
+ place_spec: Optional[dict] = None
373
+ tracks: Optional[dict] = None # align_columns(): shared column tracks
374
+ nudge: Tuple[float, float] = (0.0, 0.0)
375
+ size_axis: Optional[str] = None # same_width / same_height
376
+ size_nodes: List[Any] = field(default_factory=list)
377
+ desc: str = ""
378
+
379
+ def nodes(self) -> List[Any]:
380
+ out: List[Any] = []
381
+ for n in list(self.subjects) + list(self.refs) + list(self.size_nodes):
382
+ if n is not None and all(n is not m for m in out):
383
+ out.append(n)
384
+ for r in self.rels:
385
+ for n in (a.node for a in r.anchors()):
386
+ if all(n is not m for m in out):
387
+ out.append(n)
388
+ return out
389
+
390
+ def describe(self) -> str:
391
+ if self.desc:
392
+ return self.desc
393
+ return f"{self.kind}(" + "; ".join(r.describe() for r in self.rels) + ")"
394
+
395
+ def is_required(self) -> bool:
396
+ return isinstance(self.strength, str) and self.strength.lower() == "required"
397
+
398
+ def __repr__(self) -> str: # pragma: no cover
399
+ return f"<Constraint {self.describe()} [{self.strength}]>"
400
+
401
+
402
+ def _mk(kind: str, rels: List[Rel], strength: Any, subjects: List[Any], refs: List[Any],
403
+ desc: str = "") -> Constraint:
404
+ return Constraint(kind=kind, rels=rels, strength=strength, subjects=subjects,
405
+ refs=refs, desc=desc)
406
+
407
+
408
+ def align(*anchors: Anchor, offset: float = 0.0, strength: Any = "strong") -> Constraint:
409
+ """Make anchors coincide: ``align(a.right, b.left)``; ``offset`` adds a gap
410
+ between consecutive anchors (``anchors[i] == anchors[i+1] + offset``).
411
+ The first anchor's node is the subject (moves)."""
412
+ if len(anchors) < 2:
413
+ raise ValueError("align() needs at least two anchors")
414
+ if not all(isinstance(a, Anchor) for a in anchors):
415
+ raise TypeError("align() takes anchors, e.g. align(a.right, b.left)")
416
+ axis = anchors[0].axis
417
+ for a in anchors[1:]:
418
+ if a.axis != axis:
419
+ raise ValueError(f"align(): mixed axes ({anchors[0].describe()} vs {a.describe()})")
420
+ rels = [Rel(anchors[i], "==", anchors[i + 1], float(offset)) for i in range(len(anchors) - 1)]
421
+ return _mk("align", rels, strength, [anchors[0].node], [a.node for a in anchors[1:]])
422
+
423
+
424
+ def _same(axis: str, items: Sequence[AnchorLike], anchor: str, strength: Any, kind: str) -> Constraint:
425
+ if len(items) < 2:
426
+ raise ValueError(f"{kind}() needs at least two items")
427
+ which = {"center": "center", "middle": "center", "cx": "center", "cy": "center",
428
+ "left": "start", "top": "start", "start": "start",
429
+ "right": "end", "bottom": "end", "end": "end"}.get(anchor)
430
+ if which is None:
431
+ raise ValueError(f"{kind}(): unknown anchor {anchor!r}")
432
+ anchors = [as_anchor(it, axis, which) for it in items]
433
+ # first item is the subject; reverse the relation so rel.a is the mover
434
+ rels = [Rel(anchors[0], "==", anchors[i]) for i in range(1, len(anchors))]
435
+ return _mk(kind, rels, strength, [anchors[0].node], [a.node for a in anchors[1:]])
436
+
437
+
438
+ def same_x(*items: AnchorLike, anchor: str = "center", strength: Any = "strong") -> Constraint:
439
+ """Same x for nodes / anchor refs. ``anchor``: center|left|right."""
440
+ return _same("x", items, anchor, strength, "same_x")
441
+
442
+
443
+ def same_y(*items: AnchorLike, anchor: str = "center", strength: Any = "strong") -> Constraint:
444
+ """Same y (horizontal alignment) for nodes / matrix rows / cells / ports.
445
+ ``anchor``: center|top|bottom."""
446
+ return _same("y", items, anchor, strength, "same_y")
447
+
448
+
449
+ def _size_targets(nodes: Any) -> List[Any]:
450
+ """Nodes whose size a same_width / same_height sets (a deck → its front card)."""
451
+ return [getattr(n, "_size_proxy", None) or n for n in nodes]
452
+
453
+
454
+ def same_width(*nodes: Any) -> Constraint:
455
+ """All nodes get the widest member's width (resolved before flex layout).
456
+
457
+ A widened node stays where its parent's cross-axis ``align`` puts it: in a
458
+ ``Col(align="center")`` (the default) it grows about its centre, so left edges no longer
459
+ line up; use ``Col(..., align="start")`` to keep them flush.
460
+ A :class:`~codefig.compose.Stacked` deck takes part through its **front card**
461
+ (v0.13.0) — the card the reader sees and the one its ports land on — so
462
+ ``same_width(deck, row)`` makes the visible card as wide as ``row``.
463
+ """
464
+ return Constraint(kind="same_width", size_axis="w", size_nodes=_size_targets(nodes),
465
+ strength="required",
466
+ desc="same_width(" + ", ".join(node_name(n) for n in nodes) + ")")
467
+
468
+
469
+ def same_height(*nodes: Any) -> Constraint:
470
+ """All nodes get the tallest member's height (resolved before flex layout).
471
+
472
+ A :class:`~codefig.compose.Stacked` deck takes part through its front card."""
473
+ return Constraint(kind="same_height", size_axis="h", size_nodes=_size_targets(nodes),
474
+ strength="required",
475
+ desc="same_height(" + ", ".join(node_name(n) for n in nodes) + ")")
476
+
477
+
478
+ def _relative(kind: str, a: AnchorLike, b: AnchorLike, gap: float, align_: Optional[str],
479
+ strength: Any, exact: bool) -> Constraint:
480
+ op = "==" if exact else ">="
481
+ if kind == "below":
482
+ rels = [Rel(as_anchor(a, "y", "start"), op, as_anchor(b, "y", "end"), gap)]
483
+ cross = "x"
484
+ elif kind == "above":
485
+ rels = [Rel(as_anchor(a, "y", "end"), "==" if exact else "<=", as_anchor(b, "y", "start"), -gap)]
486
+ cross = "x"
487
+ elif kind == "right_of":
488
+ rels = [Rel(as_anchor(a, "x", "start"), op, as_anchor(b, "x", "end"), gap)]
489
+ cross = "y"
490
+ else: # left_of
491
+ rels = [Rel(as_anchor(a, "x", "end"), "==" if exact else "<=", as_anchor(b, "x", "start"), -gap)]
492
+ cross = "y"
493
+ if align_:
494
+ which = {"center": "center", "middle": "center", "left": "start", "top": "start",
495
+ "start": "start", "right": "end", "bottom": "end", "end": "end"}[align_]
496
+ rels.append(Rel(as_anchor(a, cross, which), "==", as_anchor(b, cross, which)))
497
+ return _mk(kind, rels, strength, [_subject_node(a)], [_subject_node(b)])
498
+
499
+
500
+ def below(a: AnchorLike, b: AnchorLike, gap: float = 8.0, *, align: Optional[str] = None,
501
+ strength: Any = "strong", exact: bool = True) -> Constraint:
502
+ """``a`` sits ``gap`` below ``b`` (TikZ ``below=gap of b``). ``exact=False`` → at least."""
503
+ return _relative("below", a, b, gap, align, strength, exact)
504
+
505
+
506
+ def above(a: AnchorLike, b: AnchorLike, gap: float = 8.0, *, align: Optional[str] = None,
507
+ strength: Any = "strong", exact: bool = True) -> Constraint:
508
+ """``a`` sits ``gap`` above ``b`` (TikZ ``above=gap of b``). ``exact=False`` → at least."""
509
+ return _relative("above", a, b, gap, align, strength, exact)
510
+
511
+
512
+ def right_of(a: AnchorLike, b: AnchorLike, gap: float = 8.0, *, align: Optional[str] = None,
513
+ strength: Any = "strong", exact: bool = True) -> Constraint:
514
+ """``a`` sits ``gap`` right of ``b`` (TikZ ``right=gap of b``)."""
515
+ return _relative("right_of", a, b, gap, align, strength, exact)
516
+
517
+
518
+ def left_of(a: AnchorLike, b: AnchorLike, gap: float = 8.0, *, align: Optional[str] = None,
519
+ strength: Any = "strong", exact: bool = True) -> Constraint:
520
+ """``a`` sits ``gap`` left of ``b`` (TikZ ``left=gap of b``)."""
521
+ return _relative("left_of", a, b, gap, align, strength, exact)
522
+
523
+
524
+ _SIDES = {
525
+ "right": (1, 0), "left": (-1, 0), "top": (0, -1), "bottom": (0, 1), "above": (0, -1),
526
+ "below": (0, 1), "top-right": (1, -1), "top-left": (-1, -1),
527
+ "bottom-right": (1, 1), "bottom-left": (-1, 1),
528
+ }
529
+
530
+
531
+ def place(node: Any, near: AnchorLike, side: str = "right", offset: float = 6.0, *,
532
+ align: Optional[str] = "center", within: Any = "parent",
533
+ strength: Any = "strong", obstacle: Any = None, inside: bool = False) -> Constraint:
534
+ """
535
+ Relative placement of ``node`` next to ``near`` (a node, matrix row/cell ref or port).
536
+
537
+ If ``node`` is not part of any layout tree it becomes an *overlay* (annotation)
538
+ drawn above the flex content of the scope; otherwise it is moved like
539
+ ``right_of``/``below``. ``side``: right|left|top|bottom|top-right|... ;
540
+ ``align`` (start|center|end) applies on the non-offset axis; ``align=None``
541
+ leaves that axis free (combine with e.g. ``same_y``).
542
+
543
+ ``within`` decides which container hosts an overlay: ``"parent"`` (default) —
544
+ the container holding ``near``, which grows so the annotation never spills
545
+ over its border; ``"scope"`` — the panel/diagram root; or an explicit node.
546
+
547
+ ``obstacle`` — the annotation's layering role (see :mod:`codefig.policy`):
548
+ ``None`` (default) keeps the class default (text annotations are *soft*:
549
+ edges may pass under them and are knocked out); ``True`` / ``"hard"`` makes
550
+ edges route around it; ``False`` / ``"none"`` lets edges and nodes pass under
551
+ it unchecked. Every role except ``"none"`` takes part in overlap resolution.
552
+ """
553
+ side_n = side.lower().replace("_", "-").replace(" ", "-")
554
+ if side_n not in _SIDES:
555
+ raise ValueError(f"place(): unknown side {side!r}")
556
+ sx, sy = _SIDES[side_n]
557
+ if inside and not (sx and sy):
558
+ raise ValueError("place(): inside=True requires top-right|top-left|bottom-right|bottom-left")
559
+ free_cross = align is None
560
+ which = {"center": "center", "middle": "center", "start": "start", "left": "start",
561
+ "top": "start", "end": "end", "right": "end", "bottom": "end"}[align or "center"]
562
+ rels: List[Rel] = []
563
+ if inside:
564
+ for ax, sign in (("x", sx), ("y", sy)):
565
+ edge = "end" if sign > 0 else "start"
566
+ rels.append(Rel(as_anchor(node, ax, edge), "==", as_anchor(near, ax, edge), -offset if sign > 0 else offset))
567
+ elif sx > 0:
568
+ rels.append(Rel(as_anchor(node, "x", "start"), "==", as_anchor(near, "x", "end"), offset))
569
+ elif sx < 0:
570
+ rels.append(Rel(as_anchor(node, "x", "end"), "==", as_anchor(near, "x", "start"), -offset))
571
+ elif not free_cross:
572
+ rels.append(Rel(as_anchor(node, "x", which), "==", as_anchor(near, "x", which)))
573
+ if inside:
574
+ pass
575
+ elif sy > 0:
576
+ rels.append(Rel(as_anchor(node, "y", "start"), "==", as_anchor(near, "y", "end"), offset))
577
+ elif sy < 0:
578
+ rels.append(Rel(as_anchor(node, "y", "end"), "==", as_anchor(near, "y", "start"), -offset))
579
+ elif not free_cross:
580
+ rels.append(Rel(as_anchor(node, "y", which), "==", as_anchor(near, "y", which)))
581
+ c = _mk("place", rels, strength, [node], [_subject_node(near)],
582
+ desc=f"place({node_name(node)}, near={node_name(_subject_node(near))}, side={side_n})")
583
+ c.overlay = node
584
+ c.place_spec = dict(near=near, sx=sx, sy=sy, offset=float(offset), which=which,
585
+ within=within, obstacle=obstacle, inside=inside)
586
+ from .policy import normalize_role
587
+
588
+ role = normalize_role(obstacle)
589
+ try:
590
+ node._cf_obstacle_role = role
591
+ node._cf_obstacle = role != "none"
592
+ except Exception: # pragma: no cover
593
+ pass
594
+ return c
595
+
596
+
597
+ _ANCHOR_WHICH = {"center": "center", "middle": "center", "cx": "center", "cy": "center",
598
+ "left": "start", "top": "start", "start": "start",
599
+ "right": "end", "bottom": "end", "end": "end"}
600
+
601
+
602
+ def distribute(*items: AnchorLike, axis: str = "x", gap: Optional[float] = None,
603
+ anchor: Optional[str] = None, between: Optional[Tuple[AnchorLike, AnchorLike]] = None,
604
+ strength: Any = "strong") -> Constraint:
605
+ """
606
+ Equal spacing of ``items`` along ``axis`` (in the given order), across
607
+ containers inside one panel.
608
+
609
+ * ``anchor=None`` (default): equal *gaps* edge-to-edge (draw.io "distribute
610
+ horizontally"); ``anchor="center"|"left"|...``: equal spacing of that anchor
611
+ (TikZ ``node distance`` measured between centers).
612
+ * ``gap=8``: every gap / spacing is exactly 8 instead of merely equal.
613
+ * ``between=(start, end)``: also spread from the ``start`` anchor/node to the
614
+ ``end`` one (gaps before the first and after the last item are equal too).
615
+
616
+ The inner items move; the first/last items (or the ``between`` refs) stay put.
617
+ """
618
+ ax = {"x": "x", "h": "x", "horizontal": "x", "y": "y", "v": "y", "vertical": "y"}.get(str(axis).lower())
619
+ if ax is None:
620
+ raise ValueError(f"distribute(): axis must be 'x' or 'y', got {axis!r}")
621
+ n = len(items)
622
+ if n < (2 if between is not None else 3) and gap is None:
623
+ raise ValueError("distribute() needs ≥3 items (or ≥2 with between=, or an explicit gap=)")
624
+ if n < 2:
625
+ raise ValueError("distribute() needs at least two items")
626
+ if anchor is not None and anchor not in _ANCHOR_WHICH:
627
+ raise ValueError(f"distribute(): unknown anchor {anchor!r}")
628
+
629
+ def lo(it: AnchorLike) -> Anchor:
630
+ return as_anchor(it, ax, _ANCHOR_WHICH[anchor] if anchor else "start")
631
+
632
+ def hi(it: AnchorLike) -> Anchor:
633
+ return as_anchor(it, ax, _ANCHOR_WHICH[anchor] if anchor else "end")
634
+
635
+ gaps: List[List[Tuple[float, Anchor]]] = []
636
+ if between is not None:
637
+ s0 = as_anchor(between[0], ax, "end") if not isinstance(between[0], Anchor) else between[0]
638
+ gaps.append([(1.0, lo(items[0])), (-1.0, s0)])
639
+ for i in range(n - 1):
640
+ gaps.append([(1.0, lo(items[i + 1])), (-1.0, hi(items[i]))])
641
+ if between is not None:
642
+ e0 = as_anchor(between[1], ax, "start") if not isinstance(between[1], Anchor) else between[1]
643
+ gaps.append([(1.0, e0), (-1.0, hi(items[-1]))])
644
+
645
+ rels: List[Any] = []
646
+ if gap is not None:
647
+ for g in gaps:
648
+ rels.append(LinRel(g, "==", float(gap)))
649
+ else:
650
+ for i in range(len(gaps) - 1):
651
+ rels.append(LinRel(gaps[i] + [(-k, a) for k, a in gaps[i + 1]], "==", 0.0))
652
+ nodes = [_subject_node(it) for it in items]
653
+ if between is not None:
654
+ subjects, refs = nodes, [_subject_node(between[0]), _subject_node(between[1])]
655
+ elif gap is not None:
656
+ subjects, refs = nodes[1:], nodes[:1]
657
+ else:
658
+ subjects, refs = nodes[1:-1], [nodes[0], nodes[-1]]
659
+ what = f"gap={gap:g}" if gap is not None else ("equal " + (f"{anchor} spacing" if anchor else "gaps"))
660
+ return _mk("distribute", rels, strength, subjects, refs,
661
+ desc=f"distribute({', '.join(node_name(x) for x in nodes)}, axis={ax}, {what})")
662
+
663
+
664
+ def allow_overlap(*nodes: Any) -> Constraint:
665
+ """Mark nodes whose overlap is intentional (e.g. a label laid over a strip).
666
+ With one node it may overlap anything; with several, only each other."""
667
+ if not nodes:
668
+ raise ValueError("allow_overlap() needs at least one node")
669
+ return Constraint(kind="allow_overlap", subjects=list(nodes), strength="weak",
670
+ desc="allow_overlap(" + ", ".join(node_name(n) for n in nodes) + ")")
671
+
672
+
673
+ def no_overlap(a: Any, b: Any, axis: str, margin: float = 2.0, strength: Any = "required") -> Constraint:
674
+ """Keep ``b`` after ``a`` along ``axis`` (used by overlap resolution)."""
675
+ rel = Rel(as_anchor(b, axis, "start"), ">=", as_anchor(a, axis, "end"), float(margin))
676
+ return _mk("no_overlap", [rel], strength, [b], [a],
677
+ desc=f"no_overlap({node_name(a)} | {node_name(b)}, axis={axis})")
678
+
679
+
680
+ def nudge(node: Any, dx: float = 0.0, dy: float = 0.0) -> Constraint:
681
+ """Small relative offset of ``node`` (and its subtree) on top of whatever the
682
+ flex layout and the other constraints decide. Parents still grow and siblings
683
+ are still pushed so the nudge never causes overlap/overflow."""
684
+ return Constraint(kind="nudge", subjects=[node], nudge=(float(dx), float(dy)),
685
+ strength="required", desc=f"nudge({node_name(node)}, dx={dx:g}, dy={dy:g})")
686
+
687
+
688
+ # --------------------------------------------------------------------------- scope mixin
689
+
690
+ class ConstraintScope:
691
+ """Mixin for Figure / Panel / Diagram: ``scope.constrain(c1, c2, ...)``."""
692
+
693
+ def _cf_constraints(self) -> List[Constraint]:
694
+ lst = self.__dict__.get("_constraints")
695
+ if lst is None:
696
+ lst = []
697
+ self.__dict__["_constraints"] = lst
698
+ return lst
699
+
700
+ def constrain(self, *cs: Union[Constraint, Iterable[Constraint]]) -> List[Constraint]:
701
+ out: List[Constraint] = []
702
+ for c in cs:
703
+ if isinstance(c, Constraint):
704
+ out.append(c)
705
+ else:
706
+ for cc in c:
707
+ if not isinstance(cc, Constraint):
708
+ raise TypeError(f"constrain() expects Constraint objects, got {type(cc).__name__}")
709
+ out.append(cc)
710
+ self._cf_constraints().extend(out)
711
+ return out
712
+
713
+ @property
714
+ def constraints(self) -> List[Constraint]:
715
+ return list(self._cf_constraints())
716
+
717
+
718
+ # --------------------------------------------------------------------------- tree helpers
719
+
720
+ def walk(node: Any) -> Iterable[Any]:
721
+ yield node
722
+ for k in node.children():
723
+ yield from walk(k)
724
+
725
+
726
+ def _track_spec(item: Any) -> Tuple[Any, int]:
727
+ if isinstance(item, tuple):
728
+ m, off = item
729
+ return m, int(off)
730
+ return item, 0
731
+
732
+
733
+ def align_columns(*items: Any, widths: str = "auto", square: Any = "auto",
734
+ fit: str = "auto", strength: Any = "strong") -> Constraint:
735
+ """Share column tracks between stacked Matrix-like nodes (Matrix, heatmaps,
736
+ TokenStrip, Table): cell *i* of every member sits exactly above / below cell
737
+ *i* of the others, whatever container they live in.
738
+
739
+ ``align_columns(attn, strip)`` — pass ``(node, k)`` to start a member at track
740
+ *k* (``align_columns(table, (strip, 1))`` skips the table's header column).
741
+ Members also accept ``(node, offset, span)``, integer ``span >= 1``.
742
+ For example, ``align_columns(fine, (coarse, 0, 2))`` covers two reference
743
+ columns and their internal gap per coarse column. Offsets use reference tracks;
744
+ blocks must fit within the reference. Coarse gaps follow the reference gap.
745
+ Before layout every member's columns get a common pitch (``widths="auto"``: one
746
+ uniform pitch if any member has fixed-width cells, else per column; ``"uniform"`` /
747
+ ``"per_column"`` force it). Members' gaps are kept (pitch = cell width + gap) and
748
+ column *centres* coincide. ``square="auto"`` keeps square-cell matrices square.
749
+ Then a ``strength`` relation lines the first shared column up horizontally.
750
+
751
+ ``fit`` (v0.10.0, uniform pitch with fixed-width members such as heatmaps):
752
+ ``"auto"`` (default) lets content-sized members (token strips) give up their cell
753
+ padding first and grows the fixed cells by at most theme ``track_grow_max``
754
+ (1.4 ×), truncating longer labels with "…"; ``"shrink"`` never grows the fixed
755
+ cells; ``"grow"`` is the v0.9.0 behaviour (fixed cells grow to the widest label).
756
+ When members are consecutive children of one ``Col`` without an explicit ``gap=``,
757
+ the gap between them tightens to theme ``stack_gap`` so the stack reads as one
758
+ grid.
759
+ """
760
+ return _align_tracks("cols", items, widths, square, fit, strength)
761
+
762
+
763
+ def align_rows(*items: Any, heights: str = "auto", square: Any = "auto",
764
+ fit: str = "auto", strength: Any = "strong") -> Constraint:
765
+ """Row counterpart of :func:`align_columns` (v0.10.0): side-by-side Matrix-like
766
+ nodes share row tracks, so row *i* of every member sits level with row *i* of the
767
+ others (``align_rows(heat_a, heat_b)``, ``align_rows(table, (heat, 1))`` to skip a
768
+ header row). ``heights`` is ``"auto"`` | ``"uniform"`` | ``"per_row"``; ``square``
769
+ and ``fit`` work as in ``align_columns``; consecutive members of one ``Row``
770
+ without an explicit ``gap=`` get theme ``stack_gap`` between them.
771
+
772
+ Members also accept ``(node, offset, span)``, integer ``span >= 1``.
773
+ ``align_rows(fine, (coarse, 0, 4))`` covers four reference rows and their
774
+ internal gaps per coarse row. Offsets use reference tracks; blocks must fit
775
+ within the reference. Coarse gaps follow the reference gap.
776
+ """
777
+ h = {"per_row": "per_column"}.get(heights, heights)
778
+ if heights not in ("auto", "uniform", "per_row"):
779
+ raise ValueError(f"align_rows(): heights={heights!r} (auto | uniform | per_row)")
780
+ return _align_tracks("rows", items, h, square, fit, strength)
781
+
782
+
783
+ def _align_tracks(axis: str, items: Sequence[Any], widths: str, square: Any, fit: str,
784
+ strength: Any) -> Constraint:
785
+ fname = "align_columns" if axis == "cols" else "align_rows"
786
+ spans = [it[2] if isinstance(it, tuple) and len(it) == 3 else 1 for it in items]
787
+ if any(type(v) is not int or v < 1 for v in spans):
788
+ raise ValueError(f"{fname}(): span must be an integer >= 1")
789
+ specs = [_track_spec(it[:2] if isinstance(it, tuple) and len(it) == 3 else it) for it in items]
790
+ if len(specs) < 2:
791
+ raise ValueError(f"{fname}() needs at least two matrices")
792
+ for m, off in specs:
793
+ if not hasattr(m, "_col_ws"):
794
+ raise TypeError(f"{fname}(): {node_name(m)} has no {'columns' if axis == 'cols' else 'rows'} "
795
+ "(Matrix / TokenStrip / Table)")
796
+ if off < 0:
797
+ raise ValueError(f"{fname}(): track offsets must be >= 0")
798
+ if widths not in ("auto", "uniform", "per_column"):
799
+ raise ValueError(f"{fname}(): widths={widths!r} (auto | uniform | per_column)")
800
+ if fit not in ("auto", "grow", "shrink"):
801
+ raise ValueError(f"{fname}(): fit={fit!r} (auto | grow | shrink)")
802
+ m0, o0 = specs[0]
803
+
804
+ def count(m: Any) -> int:
805
+ return m.cols if axis == "cols" else m.rows
806
+
807
+ if spans[0] != 1:
808
+ raise ValueError(f"{fname}(): reference span must be 1")
809
+ for (m, off), span in zip(specs, spans):
810
+ if span > 1 and (off < o0 or off - o0 + count(m) * span > count(m0)):
811
+ raise ValueError(f"{fname}(): coarse member runs past reference tracks")
812
+ rels = []
813
+ for (m, off), span in zip(specs[1:], spans[1:]):
814
+ t = max(o0, off) # first track both members cover
815
+ if t - off >= count(m) or t - o0 >= count(m0):
816
+ raise ValueError(f"{fname}(): {node_name(m)} shares no track with {node_name(m0)}")
817
+ if axis == "cols":
818
+ a = as_anchor(MatrixColRef(m, t - off), "x", "center")
819
+ b = as_anchor(MatrixColRef(m0, t - o0), "x", "center")
820
+ else:
821
+ a = as_anchor(MatrixRowRef(m, t - off), "y", "center")
822
+ b = as_anchor(MatrixRowRef(m0, t - o0), "y", "center")
823
+ if span > 1:
824
+ ref = MatrixColRef if axis == "cols" else MatrixRowRef
825
+ coord = "x" if axis == "cols" else "y"
826
+ a = as_anchor(ref(m, 0), coord, "start")
827
+ b = as_anchor(ref(m0, off - o0), coord, "start")
828
+ rels.append(Rel(a, "==", b))
829
+ c = Constraint(kind=fname, rels=rels, strength=strength,
830
+ subjects=[m for m, _ in specs[1:]], refs=[m0], size_axis=axis,
831
+ size_nodes=[m for m, _ in specs],
832
+ desc=fname + "(" + ", ".join(node_name(m) for m, _ in specs) + ")")
833
+ c.tracks = {"tracks": specs, "widths": widths, "square": square, "fit": fit, "axis": axis, "spans": spans}
834
+ return c
835
+
836
+
837
+ def _content_sized(m: Any, axis: str) -> bool:
838
+ if axis == "cols":
839
+ return m.cell_w == "content"
840
+ return m.cell_h == "content" or (m.cell_h is None and m.cell_w == "content")
841
+
842
+
843
+ def _apply_tracks(c: Constraint) -> None:
844
+ spec = c.tracks or {}
845
+ tracks = spec.get("tracks", [])
846
+ mode, square = spec.get("widths", "auto"), spec.get("square", "auto")
847
+ fit, axis = spec.get("fit", "grow"), spec.get("axis", "cols")
848
+ key = "_track" if axis == "cols" else "_track_r"
849
+ spans = spec.get("spans", [1] * len(tracks))
850
+ if any(v > 1 for v in spans):
851
+ fine = [t for t, v in zip(tracks, spans) if v == 1]
852
+ _apply_tracks(Constraint(kind=c.kind, tracks={**spec, "tracks": fine, "spans": [1] * len(fine)}))
853
+ reference, origin = tracks[0]
854
+ sizes = reference._col_ws() if axis == "cols" else reference._row_hs()
855
+ for (m, off), span in zip(tracks, spans):
856
+ if span == 1:
857
+ continue
858
+ m.gap = reference.gap
859
+ n = m.cols if axis == "cols" else m.rows
860
+ own = [sum(sizes[off-origin+j*span:off-origin+(j+1)*span])
861
+ + (span-1)*reference.gap for j in range(n)]
862
+ m.__dict__[key] = {"ws": own, "h": None, "fit": True}
863
+ m.__dict__.pop("_cw_cache", None)
864
+ m._measured = None
865
+ _tighten_stack(tracks, axis)
866
+ return
867
+ nat = []
868
+ for m, off in tracks:
869
+ m.__dict__.pop(key, None)
870
+ m.__dict__.pop("_cw_cache", None)
871
+ m._measured = None
872
+ if axis == "cols":
873
+ nat.append([w + m.gap for w in m._col_ws()])
874
+ else:
875
+ nat.append([h + m.gap for h in m._row_hs()])
876
+ m.__dict__.pop("_cw_cache", None)
877
+ if mode == "auto":
878
+ fixed = any(not _content_sized(m, axis) for m, _ in tracks)
879
+ mode = "uniform" if fixed else "per_column"
880
+ count = [(m.cols if axis == "cols" else m.rows) for m, _ in tracks]
881
+ n_tr = max(off + k for (m, off), k in zip(tracks, count))
882
+ pads: Dict[int, bool] = {}
883
+ if mode == "uniform":
884
+ p = max(max(ws) for ws in nat if ws)
885
+ fixed_p = [max(ws) for (m, _), ws in zip(tracks, nat) if ws and not _content_sized(m, axis)]
886
+ if fit != "grow" and fixed_p:
887
+ pf = max(fixed_p)
888
+ th = tracks[0][0].theme
889
+ cap = pf if fit == "shrink" else pf * float(getattr(th, "track_grow_max", 1.5))
890
+ p = max(pf, min(p, cap)) # keep natural padding while it fits the cap
891
+ for i, ((m, _), ws) in enumerate(zip(tracks, nat)):
892
+ if _content_sized(m, axis) and ws and max(ws) > p + 1e-6:
893
+ pads[i] = True # gives up padding / truncates
894
+ pitch = [p] * n_tr
895
+ else:
896
+ pitch = [0.0] * n_tr
897
+ for (m, off), ws in zip(tracks, nat):
898
+ for i, w in enumerate(ws):
899
+ pitch[off + i] = max(pitch[off + i], w)
900
+ for i, ((m, off), ws) in enumerate(zip(tracks, nat)):
901
+ k = count[i]
902
+ own = [max(0.0, pitch[off + j] - m.gap) for j in range(k)]
903
+ other = None
904
+ is_sq = m.cell_w is None and m.cell_h is None
905
+ if (square is True or (square == "auto" and is_sq)) and own and len(set(own)) == 1:
906
+ other = max(own[0], max(m._row_hs() if axis == "cols" else m._col_ws()))
907
+ if axis == "rows" and hasattr(m, "_label_w"):
908
+ # v0.11.0: square cells that follow a row pitch stay wide enough for
909
+ # their labels (at most theme track_grow_max wider than tall)
910
+ need = max((m._label_w(r, c_) for r in range(m.rows) for c_ in range(m.cols)),
911
+ default=0.0) + m.theme.L(2.0)
912
+ cap = other * float(getattr(m.theme, "track_grow_max", 1.4))
913
+ other = max(other, min(need, cap))
914
+ prev = m.__dict__.get(key)
915
+ if prev is not None: # member of several groups on this axis
916
+ own = [max(a, b) for a, b in zip(own, prev["ws"])]
917
+ other = max(other or 0.0, prev["h"] or 0.0) or None
918
+ m.__dict__[key] = {"ws": own, "h": other, "fit": bool(pads.get(i)) or (prev or {}).get("fit", False)}
919
+ m._measured = None
920
+ _tighten_stack(tracks, axis)
921
+
922
+
923
+ def _tighten_stack(tracks: Sequence[Tuple[Any, int]], axis: str) -> None:
924
+ """Consecutive members inside one Col (columns) / Row (rows) whose ``gap=`` was not
925
+ given: the gap between them becomes theme ``stack_gap``."""
926
+ from .node import Container
927
+
928
+ want = "column" if axis == "cols" else "row"
929
+ members = {id(m) for m, _ in tracks}
930
+ seen = set()
931
+ for m, _ in tracks:
932
+ par = getattr(m, "parent", None)
933
+ if not isinstance(par, Container) or par.direction != want or id(par) in seen:
934
+ continue
935
+ seen.add(id(par))
936
+ kids = list(par._kids)
937
+ g = float(getattr(m.theme, "stack_gap", m.theme.L(6.0)))
938
+ pair = par.__dict__.setdefault("_pair_gap", {})
939
+ for j in range(len(kids) - 1):
940
+ if id(kids[j]) in members and id(kids[j + 1]) in members:
941
+ pair[j] = min(g, float(par.gap))
942
+
943
+
944
+ def invalidate_tree(root: Any) -> None:
945
+ for n in walk(root):
946
+ n._measured = None
947
+
948
+
949
+ def apply_size_constraints(root: Any, constraints: Sequence[Constraint]) -> None:
950
+ """Resolve same_width / same_height before flex: every member gets the max size."""
951
+ for n in walk(root):
952
+ n._cmin_w = 0.0
953
+ n._cmin_h = 0.0
954
+ n.__dict__.pop("_track", None)
955
+ n.__dict__.pop("_track_r", None)
956
+ track_cs = [c for c in constraints if c.size_axis in ("cols", "rows")]
957
+ for c in track_cs:
958
+ _apply_tracks(c)
959
+ if track_cs:
960
+ invalidate_tree(root)
961
+ size_cs = [c for c in constraints if c.size_axis in ("w", "h")]
962
+ if not size_cs:
963
+ return
964
+ invalidate_tree(root)
965
+ for c in size_cs:
966
+ sizes = [n.measure() for n in c.size_nodes]
967
+ if c.size_axis == "w":
968
+ m = max(s[0] for s in sizes)
969
+ for n in c.size_nodes:
970
+ n._cmin_w = max(getattr(n, "_cmin_w", 0.0), m)
971
+ else:
972
+ m = max(s[1] for s in sizes)
973
+ for n in c.size_nodes:
974
+ n._cmin_h = max(getattr(n, "_cmin_h", 0.0), m)
975
+ invalidate_tree(root)
976
+
977
+
978
+ def translate_tree(node: Any, dx: float, dy: float) -> None:
979
+ for n in walk(node):
980
+ n.geom.x += dx
981
+ n.geom.y += dy
982
+
983
+
984
+ def _bands(node: Any) -> Tuple[float, float, float, float]:
985
+ """Inner padding band (left, top, right, bottom) a child must stay inside."""
986
+ from .edges import Diagram
987
+ from .node import Container
988
+ from .primitives import CircleHighlight
989
+
990
+ if isinstance(node, Container):
991
+ pt, pr, pb, pl = node.padding
992
+ th = node._title_h() if node.title else 0.0
993
+ top = pt + (th if node.title and node.title_pos == "top" else 0.0)
994
+ if node.title and node.title_pos == "inside-top":
995
+ top += node.theme.font_size + node.title_gap
996
+ bot = pb + (th if node.title and node.title_pos == "bottom" else 0.0)
997
+ return pl, top, pr, bot
998
+ if isinstance(node, Diagram):
999
+ l, t, r, b = node._loop_margins()
1000
+ return l, t, r, b
1001
+ if isinstance(node, CircleHighlight):
1002
+ p = node.hl_pad
1003
+ return p, p, p, p
1004
+ return 0.0, 0.0, 0.0, 0.0
1005
+
1006
+
1007
+ # --------------------------------------------------------------------------- solver
1008
+
1009
+ @dataclass
1010
+ class SolveResult:
1011
+ overlays: List[Any]
1012
+ shifts: Dict[int, Tuple[float, float]]
1013
+ growth: Dict[int, Tuple[float, float, float, float]]
1014
+ bbox: Tuple[float, float, float, float]
1015
+
1016
+
1017
+ def _layout_overlay(c: Constraint) -> None:
1018
+ node = c.overlay
1019
+ spec = c.place_spec or {}
1020
+ w, h = node.measure()
1021
+ near = spec["near"]
1022
+ off = spec["offset"]
1023
+ which = spec["which"]
1024
+ f = _WHICH_FRAC[which]
1025
+ nl, nr = as_anchor(near, "x", "start").value(), as_anchor(near, "x", "end").value()
1026
+ nt, nb = as_anchor(near, "y", "start").value(), as_anchor(near, "y", "end").value()
1027
+ sx, sy = spec["sx"], spec["sy"]
1028
+ if spec.get("inside"):
1029
+ x = nr - off - w if sx > 0 else nl + off
1030
+ y = nb - off - h if sy > 0 else nt + off
1031
+ node.layout(x, y, w, h)
1032
+ return
1033
+ if sx > 0:
1034
+ x = nr + off
1035
+ elif sx < 0:
1036
+ x = nl - off - w
1037
+ else:
1038
+ x = nl + (nr - nl) * f - w * f
1039
+ if sy > 0:
1040
+ y = nb + off
1041
+ elif sy < 0:
1042
+ y = nt - off - h
1043
+ else:
1044
+ y = nt + (nb - nt) * f - h * f
1045
+ node.layout(x, y, w, h)
1046
+
1047
+
1048
+ def _unit() -> str:
1049
+ from .styles import length_unit
1050
+
1051
+ return length_unit()
1052
+
1053
+
1054
+ def solve_constraints(root: Any, constraints: Sequence[Constraint], *, warn: bool = True) -> SolveResult:
1055
+ """
1056
+ Apply relational constraints to an already flex-laid-out tree rooted at ``root``.
1057
+ Mutates geometry in place (translations + container growth) and returns the
1058
+ overlays and the final content bbox. See module docstring for the model.
1059
+ """
1060
+ from .node import Container, Grid
1061
+
1062
+ if _kiwi is None: # pragma: no cover
1063
+ raise ImportError("codefig constraints need kiwisolver: pip install kiwisolver")
1064
+
1065
+ cons = [c for c in constraints
1066
+ if (not c.size_axis or c.rels) and c.kind != "allow_overlap"]
1067
+ g0 = root.geom
1068
+ if not cons:
1069
+ return SolveResult([], {}, {}, (g0.x, g0.y, g0.w, g0.h))
1070
+
1071
+ # -- tree maps -----------------------------------------------------------
1072
+ parent: Dict[int, Any] = {}
1073
+ in_tree: Dict[int, Any] = {}
1074
+ for n in walk(root):
1075
+ in_tree[id(n)] = n
1076
+ for k in n.children():
1077
+ parent[id(k)] = n
1078
+
1079
+ overlays: List[Any] = []
1080
+ for c in cons:
1081
+ if c.overlay is not None and id(c.overlay) not in in_tree:
1082
+ if all(c.overlay is not o for o in overlays):
1083
+ overlays.append(c.overlay)
1084
+ _layout_overlay(c)
1085
+ else:
1086
+ _layout_overlay(c)
1087
+ hosted: Dict[int, List[Any]] = {}
1088
+ for o in overlays:
1089
+ for n in walk(o):
1090
+ in_tree[id(n)] = n
1091
+ for n in walk(o):
1092
+ for k in n.children():
1093
+ parent[id(k)] = n
1094
+ spec = next(c.place_spec for c in cons if c.overlay is o)
1095
+ within = spec.get("within", "parent")
1096
+ if within == "scope" or within is None:
1097
+ host = root
1098
+ elif within == "parent":
1099
+ nn = _subject_node(spec["near"])
1100
+ host = parent.get(id(nn), root) if nn is not root else root
1101
+ while host is not root and not list(host.children()):
1102
+ host = parent.get(id(host), root)
1103
+ else:
1104
+ host = within
1105
+ if id(host) not in in_tree:
1106
+ raise ConstraintConflictError(f"place(within={node_name(host)}): not in this panel")
1107
+ parent[id(o)] = host
1108
+ hosted.setdefault(id(host), []).append(o)
1109
+
1110
+ # -- referenced nodes ---------------------------------------------------
1111
+ referenced: List[Any] = []
1112
+ for c in cons:
1113
+ for n in c.nodes():
1114
+ if id(n) not in in_tree:
1115
+ raise ConstraintConflictError(
1116
+ f"{c.describe()}: node {node_name(n)} is not part of this panel/diagram "
1117
+ f"(constraints cannot span panels; overlays must be placed with place())"
1118
+ )
1119
+ referenced.append(n)
1120
+
1121
+ involved: Dict[int, Any] = {}
1122
+
1123
+ def add_chain(n: Any) -> None:
1124
+ while n is not None and id(n) not in involved:
1125
+ involved[id(n)] = n
1126
+ n = parent.get(id(n))
1127
+
1128
+ add_chain(root)
1129
+ for n in referenced:
1130
+ add_chain(n)
1131
+ # containers on a referenced chain get containment / sibling invariants;
1132
+ # their direct children are involved (movable as rigid blocks) but a child
1133
+ # container off the chain is not opened up (its kids have no variables).
1134
+ chain_ids = set(involved)
1135
+ for n in list(involved.values()):
1136
+ kids = list(n.children()) + hosted.get(id(n), [])
1137
+ for k in kids:
1138
+ involved[id(k)] = k
1139
+
1140
+ nudges: Dict[int, Tuple[float, float]] = {}
1141
+ for c in cons:
1142
+ if c.kind == "nudge":
1143
+ n = c.subjects[0]
1144
+ ox, oy = nudges.get(id(n), (0.0, 0.0))
1145
+ nudges[id(n)] = (ox + c.nudge[0], oy + c.nudge[1])
1146
+
1147
+ ref_ids = {id(n) for c in cons for n in c.refs}
1148
+ subj_ids = {id(n) for c in cons if c.kind != "nudge" for n in c.subjects}
1149
+
1150
+ # -- variables & flex snapshot -------------------------------------------
1151
+ V: Dict[int, Dict[str, Any]] = {}
1152
+ flex: Dict[int, Tuple[float, float, float, float]] = {}
1153
+ for nid, n in involved.items():
1154
+ nm = node_name(n)
1155
+ d = {"dx": _kiwi.Variable(f"{nm}.dx"), "dy": _kiwi.Variable(f"{nm}.dy")}
1156
+ has_kids = nid in chain_ids and (bool(list(n.children())) or n is root or id(n) in hosted)
1157
+ if has_kids:
1158
+ for gname in ("gl", "gr", "gt", "gb"):
1159
+ d[gname] = _kiwi.Variable(f"{nm}.{gname}")
1160
+ V[nid] = d
1161
+ g = n.geom
1162
+ flex[nid] = (g.x, g.y, g.w, g.h)
1163
+
1164
+ def chain(n: Any) -> List[Any]:
1165
+ out = []
1166
+ while n is not None:
1167
+ out.append(n)
1168
+ if n is root:
1169
+ break
1170
+ n = parent.get(id(n))
1171
+ return out
1172
+
1173
+ def T(n: Any, axis: str):
1174
+ key = "dx" if axis == "x" else "dy"
1175
+ terms = [V[id(a)][key] for a in chain(n) if id(a) in V]
1176
+ expr = 0.0
1177
+ for t in terms:
1178
+ expr = expr + t
1179
+ return expr
1180
+
1181
+ def nud(n: Any, axis: str, inclusive: bool) -> float:
1182
+ idx = 0 if axis == "x" else 1
1183
+ tot = 0.0
1184
+ for a in chain(n):
1185
+ if a is n and not inclusive:
1186
+ continue
1187
+ tot += nudges.get(id(a), (0.0, 0.0))[idx]
1188
+ return tot
1189
+
1190
+ def growth(n: Any, axis: str, side: str):
1191
+ d = V.get(id(n), {})
1192
+ key = {("x", "lo"): "gl", ("x", "hi"): "gr", ("y", "lo"): "gt", ("y", "hi"): "gb"}[(axis, side)]
1193
+ return d.get(key)
1194
+
1195
+ def full_lo(n: Any, axis: str):
1196
+ x, y, w, h = flex[id(n)]
1197
+ base = x if axis == "x" else y
1198
+ e = T(n, axis) + base + nud(n, axis, True)
1199
+ gv = growth(n, axis, "lo")
1200
+ return e - gv if gv is not None else e
1201
+
1202
+ def full_hi(n: Any, axis: str):
1203
+ x, y, w, h = flex[id(n)]
1204
+ base = (x + w) if axis == "x" else (y + h)
1205
+ e = T(n, axis) + base + nud(n, axis, True)
1206
+ gv = growth(n, axis, "hi")
1207
+ return e + gv if gv is not None else e
1208
+
1209
+ def anchor_expr(a: Anchor, final: bool = False):
1210
+ # user relations see positions *before* the node's own nudge; no_overlap
1211
+ # (final=True) must see where the node really ends up
1212
+ n = a.node
1213
+ e = T(n, a.axis) + (a.base_value() + a.offset) + nud(n, a.axis, final)
1214
+ if a.frac is not None and a.getter is None:
1215
+ lo, hi = growth(n, a.axis, "lo"), growth(n, a.axis, "hi")
1216
+ if lo is not None:
1217
+ e = e - lo * (1.0 - a.frac) + hi * a.frac
1218
+ return e
1219
+
1220
+ # -- structural (required) -------------------------------------------------
1221
+ structural: List[Tuple[Any, str]] = []
1222
+ smeta: Dict[int, Tuple[str, Any, Any]] = {} # v0.13.0: why-dropped diagnostics
1223
+ REQ = _kiwi.strength.required
1224
+ rv = V[id(root)]
1225
+ structural.append(((rv["dx"] + 0.0 == 0.0) | REQ, f"{node_name(root)} (scope root) is fixed"))
1226
+ structural.append(((rv["dy"] + 0.0 == 0.0) | REQ, f"{node_name(root)} (scope root) is fixed"))
1227
+ for nid, d in V.items():
1228
+ for gname in ("gl", "gr", "gt", "gb"):
1229
+ if gname in d:
1230
+ structural.append(((d[gname] + 0.0 >= 0.0) | REQ, f"{node_name(involved[nid])} cannot shrink"))
1231
+
1232
+ for nid, p in involved.items():
1233
+ if nid not in chain_ids:
1234
+ continue
1235
+ kids = list(p.children())
1236
+ kids_all = kids + hosted.get(nid, [])
1237
+ if not kids_all:
1238
+ continue
1239
+ bl, bt, br, bb = _bands(p)
1240
+ px, py, pw, ph = flex[nid]
1241
+ pname = node_name(p)
1242
+ for k in kids_all:
1243
+ kx, ky, kw, kh = flex[id(k)]
1244
+ is_ov = any(k is o for o in overlays)
1245
+ dl = bl if is_ov else min(kx - px, bl)
1246
+ dt = bt if is_ov else min(ky - py, bt)
1247
+ dr = br if is_ov else min((px + pw) - (kx + kw), br)
1248
+ db = bb if is_ov else min((py + ph) - (ky + kh), bb)
1249
+ kname = node_name(k)
1250
+ inside = [
1251
+ ((full_lo(k, "x") - full_lo(p, "x") >= dl) | REQ, f"{kname} stays inside {pname} (left)"),
1252
+ ((full_lo(k, "y") - full_lo(p, "y") >= dt) | REQ, f"{kname} stays inside {pname} (top)"),
1253
+ ((full_hi(p, "x") - full_hi(k, "x") >= dr) | REQ, f"{kname} stays inside {pname} (right)"),
1254
+ ((full_hi(p, "y") - full_hi(k, "y") >= db) | REQ, f"{kname} stays inside {pname} (bottom)"),
1255
+ ]
1256
+ for kk, _d in inside:
1257
+ smeta[id(kk)] = ("inside", p, k)
1258
+ structural += inside
1259
+ # sibling non-overlap along main axis
1260
+ pairs: List[Tuple[Any, Any, str]] = []
1261
+ if isinstance(p, Grid):
1262
+ cols = p.cols
1263
+ for i, k in enumerate(kids):
1264
+ r, cidx = divmod(i, cols)
1265
+ if cidx + 1 < cols and i + 1 < len(kids):
1266
+ pairs.append((k, kids[i + 1], "x"))
1267
+ if i + cols < len(kids):
1268
+ pairs.append((k, kids[i + cols], "y"))
1269
+ elif isinstance(p, Container):
1270
+ axis = "x" if p.direction == "row" else "y"
1271
+ for i in range(len(kids) - 1):
1272
+ pairs.append((kids[i], kids[i + 1], axis))
1273
+ gap = float(getattr(p, "gap", 0.0) or 0.0)
1274
+ for a, b, axis in pairs:
1275
+ ax, ay, aw, ah = flex[id(a)]
1276
+ bx, by, bw, bh = flex[id(b)]
1277
+ d0 = (bx - (ax + aw)) if axis == "x" else (by - (ay + ah))
1278
+ kk = (full_lo(b, axis) - full_hi(a, axis) >= min(d0, gap)) | REQ
1279
+ smeta[id(kk)] = ("order", p, (a, b, axis, min(d0, gap)))
1280
+ structural.append((
1281
+ kk, f"{pname} keeps {node_name(a)} before {node_name(b)} (no sibling overlap)",
1282
+ ))
1283
+
1284
+ # -- stays (flex result) ---------------------------------------------------
1285
+ stays: List[Any] = []
1286
+ for nid, d in V.items():
1287
+ if nid in ref_ids and nid not in subj_ids:
1288
+ w_move = 10.0
1289
+ elif nid in subj_ids:
1290
+ w_move = 1.0
1291
+ else:
1292
+ w_move = 2.0
1293
+ st = _kiwi.strength.create(0, 0, w_move)
1294
+ # a nudged node must not "cancel" its nudge to save sibling moves:
1295
+ # its own stay on the nudged axis is medium (user strong/medium still win)
1296
+ nx, ny = nudges.get(nid, (0.0, 0.0))
1297
+ stays.append((d["dx"] + 0.0 == 0.0) | (_kiwi.strength.medium if nx else st))
1298
+ stays.append((d["dy"] + 0.0 == 0.0) | (_kiwi.strength.medium if ny else st))
1299
+ for gname in ("gl", "gr", "gt", "gb"):
1300
+ if gname in d:
1301
+ stays.append((d[gname] + 0.0 == 0.0) | _kiwi.strength.create(0, 0, 1.5))
1302
+
1303
+ # -- user constraints --------------------------------------------------------
1304
+ def rel_kiwi(r: Any, strength: float, final: bool = False):
1305
+ if isinstance(r, LinRel):
1306
+ lhs = 0.0
1307
+ for coef, a in r.terms:
1308
+ lhs = lhs + anchor_expr(a, final) * coef
1309
+ lhs = lhs + 0.0
1310
+ if r.op == "==":
1311
+ k = lhs == r.const
1312
+ elif r.op == ">=":
1313
+ k = lhs >= r.const
1314
+ else:
1315
+ k = lhs <= r.const
1316
+ return k | strength
1317
+ lhs = anchor_expr(r.a, final)
1318
+ rhs = anchor_expr(r.b, final) + r.const
1319
+ if r.op == "==":
1320
+ k = lhs == rhs
1321
+ elif r.op == ">=":
1322
+ k = lhs >= rhs
1323
+ else:
1324
+ k = lhs <= rhs
1325
+ return k | strength
1326
+
1327
+ user_req: List[Tuple[Constraint, List[Any]]] = []
1328
+ user_soft: List[Tuple[Constraint, List[Any]]] = []
1329
+ for c in cons:
1330
+ if not c.rels:
1331
+ continue
1332
+ s = _kiwi_strength(c.strength)
1333
+ ks = [rel_kiwi(r, s, c.kind == "no_overlap") for r in c.rels]
1334
+ (user_req if s >= _kiwi.strength.required else user_soft).append((c, ks))
1335
+
1336
+ solver = _kiwi.Solver()
1337
+ for k, _desc in structural:
1338
+ solver.addConstraint(k)
1339
+ added_req: List[Tuple[Constraint, List[Any]]] = []
1340
+ for c, ks in user_req:
1341
+ try:
1342
+ for k in ks:
1343
+ solver.addConstraint(k)
1344
+ except _kiwi.UnsatisfiableConstraint:
1345
+ raise ConstraintConflictError(_explain_conflict(c, ks, added_req, structural)) from None
1346
+ added_req.append((c, ks))
1347
+ for c, ks in user_soft:
1348
+ for k in ks:
1349
+ solver.addConstraint(k)
1350
+ for k in stays:
1351
+ solver.addConstraint(k)
1352
+ solver.updateVariables()
1353
+
1354
+ # -- apply -----------------------------------------------------------------
1355
+ shifts: Dict[int, Tuple[float, float]] = {}
1356
+ grow: Dict[int, Tuple[float, float, float, float]] = {}
1357
+
1358
+ def val(v: Any) -> float:
1359
+ x = float(v.value())
1360
+ return 0.0 if abs(x) < 1e-7 else x
1361
+
1362
+ def apply(n: Any, ax: float, ay: float) -> None:
1363
+ nid = id(n)
1364
+ if nid in V:
1365
+ nx, ny = nudges.get(nid, (0.0, 0.0))
1366
+ ax += val(V[nid]["dx"]) + nx
1367
+ ay += val(V[nid]["dy"]) + ny
1368
+ g = n.geom
1369
+ g.x += ax
1370
+ g.y += ay
1371
+ if nid in V and "gl" in V[nid]:
1372
+ gl, gr, gt, gb = (val(V[nid][k]) for k in ("gl", "gr", "gt", "gb"))
1373
+ if gl or gr or gt or gb:
1374
+ g.x -= gl
1375
+ g.w += gl + gr
1376
+ g.y -= gt
1377
+ g.h += gt + gb
1378
+ grow[nid] = (gl, gr, gt, gb)
1379
+ if abs(ax) > 1e-7 or abs(ay) > 1e-7:
1380
+ shifts[nid] = (ax, ay)
1381
+ for k in list(n.children()) + hosted.get(nid, []):
1382
+ apply(k, ax, ay)
1383
+
1384
+ apply(root, 0.0, 0.0)
1385
+ # composites whose decoration depends on a child's position (Repeat frames …)
1386
+ # re-sync after their children moved
1387
+ for n in involved.values():
1388
+ hook = getattr(n, "_after_solve", None)
1389
+ if hook is not None:
1390
+ hook()
1391
+
1392
+ # -- verify soft constraints --------------------------------------------------
1393
+ def own_nudge(a: Anchor) -> float:
1394
+ return nudges.get(id(a.node), (0.0, 0.0))[0 if a.axis == "x" else 1]
1395
+
1396
+ def residual(r: Any) -> float:
1397
+ # relations see positions *before* the subject's own nudge
1398
+ if isinstance(r, LinRel):
1399
+ d = sum(k * (a.value() - own_nudge(a)) for k, a in r.terms) - r.const
1400
+ if r.op == "==":
1401
+ return abs(d)
1402
+ return max(0.0, -d) if r.op == ">=" else max(0.0, d)
1403
+ d = (r.a.value() - own_nudge(r.a)) - (r.b.value() - own_nudge(r.b) + r.const)
1404
+ if r.op == "==":
1405
+ return abs(d)
1406
+ return max(0.0, -d) if r.op == ">=" else max(0.0, d)
1407
+
1408
+ for c, _ks in (user_soft if warn else []):
1409
+ if c.kind == "no_overlap":
1410
+ continue
1411
+ worst = max((residual(r) for r in c.rels), default=0.0)
1412
+ if worst > 0.5:
1413
+ # v0.13.0: say *what* blocked it and how to fix it; warn once per constraint
1414
+ # (lint lays a figure out several times)
1415
+ if getattr(c, "_cf_warned", False):
1416
+ continue
1417
+ c._cf_warned = True
1418
+ why = _why_dropped(c, [rel_kiwi(r, REQ) for r in c.rels], structural, smeta,
1419
+ [(o, [rel_kiwi(r, REQ) for r in o.rels]) for o, _oks in user_req + user_soft
1420
+ if o is not c and o.kind != "no_overlap"])
1421
+ warnings.warn(
1422
+ f"constraint {c.describe()} [{c.strength}] dropped: off by {worst:.1f}{_unit()} — {why}",
1423
+ ConstraintWarning,
1424
+ stacklevel=3,
1425
+ )
1426
+ if warn:
1427
+ _warn_left_behind(cons, parent, shifts)
1428
+
1429
+ g = root.geom
1430
+ bx0, by0, bx1, by1 = g.x, g.y, g.x + g.w, g.y + g.h
1431
+ for o in overlays:
1432
+ og = o.geom
1433
+ bx0, by0 = min(bx0, og.x), min(by0, og.y)
1434
+ bx1, by1 = max(bx1, og.x + og.w), max(by1, og.y + og.h)
1435
+ return SolveResult(overlays, shifts, grow, (bx0, by0, bx1 - bx0, by1 - by0))
1436
+
1437
+
1438
+ def _satisfiable(ks: Sequence[Any]) -> bool:
1439
+ s = _kiwi.Solver()
1440
+ try:
1441
+ for k in ks:
1442
+ s.addConstraint(k)
1443
+ except _kiwi.UnsatisfiableConstraint:
1444
+ return False
1445
+ return True
1446
+
1447
+
1448
+ def _why_dropped(c: Constraint, req_ks: List[Any], structural: List[Tuple[Any, str]],
1449
+ smeta: Dict[int, Tuple[str, Any, Any]],
1450
+ others: List[Tuple[Constraint, List[Any]]]) -> str:
1451
+ """v0.13.0: explain which layout invariant / other constraint beat a soft
1452
+ constraint, naming the container and the usual fix."""
1453
+ from .node import Container, Grid as _G
1454
+
1455
+ struct_ks = [k for k, _ in structural]
1456
+ pre = ""
1457
+ if _satisfiable(struct_ks + req_ks):
1458
+ for oc, oks in others:
1459
+ if not _satisfiable(struct_ks + req_ks + oks):
1460
+ pre = f"together with {oc.describe()} [{oc.strength}] it is impossible; "
1461
+ req_ks = req_ks + oks
1462
+ break
1463
+ else:
1464
+ return "overridden by stronger constraints (no single culprit found)"
1465
+ if not _satisfiable(req_ks):
1466
+ return pre + "drop one of them or make them agree"
1467
+ hints: List[str] = []
1468
+ for i, (k, desc) in enumerate(structural):
1469
+ rest = struct_ks[:i] + struct_ks[i + 1:]
1470
+ if not _satisfiable(rest + req_ks):
1471
+ continue
1472
+ meta = smeta.get(id(k))
1473
+ if meta is None:
1474
+ hints.append(f"{desc} — constrain nodes inside it, not the scope root")
1475
+ elif meta[0] == "order":
1476
+ p, (a, b, axis, gap) = meta[1], meta[2]
1477
+ kind = "Grid" if isinstance(p, _G) else ("Row" if axis == "x" else "Col")
1478
+ other = "Row" if axis == "y" else "Col"
1479
+ g = f" with gap {gap:g}{_unit()}" if gap > 0.5 else ""
1480
+ hints.append(
1481
+ f"{node_name(p)} stacks {node_name(a)} and {node_name(b)} along {axis}{g}, so they cannot "
1482
+ f"share that {axis} or overlap — put them in a {other} (or a Diagram) instead"
1483
+ + (f" (if they only need to come closer, lower the {kind}'s gap=)" if gap > 0.5 else "")
1484
+ if kind != "Grid" else
1485
+ f"{node_name(p)} keeps {node_name(a)} and {node_name(b)} in grid order along {axis} — "
1486
+ f"constrain the whole Grid, or build the rows as Rows")
1487
+ else:
1488
+ p, child = meta[1], meta[2]
1489
+ al = getattr(p, "align", None)
1490
+ al_s = f" and its align={al!r} places {node_name(child)}" if al not in (None, "start") else ""
1491
+ hints.append(
1492
+ f"{node_name(child)} must stay inside {node_name(p)}{al_s} — constrain {node_name(p)} "
1493
+ f"itself" + (", or give it align='start'" if al_s else "")
1494
+ + " (a container does not grow to follow a constrained child)")
1495
+ if len(hints) >= 2:
1496
+ break
1497
+ if not hints:
1498
+ hints.append("container containment / sibling order (several invariants together)")
1499
+ return pre + "blocked by layout invariant: " + "; ".join(dict.fromkeys(hints))
1500
+
1501
+
1502
+ def _warn_left_behind(cons: Sequence[Constraint], parent: Dict[int, Any],
1503
+ shifts: Dict[int, Tuple[float, float]]) -> None:
1504
+ """v0.13.0: a constraint that moves one *cell* of a Grid leaves its row (or
1505
+ column) neighbours — typically its label — behind. Warn once."""
1506
+ from .node import Grid as _G
1507
+
1508
+ for c in cons:
1509
+ if c.kind in ("nudge", "no_overlap") or getattr(c, "_cf_left_warned", False):
1510
+ continue
1511
+ for n in c.subjects + c.refs:
1512
+ p = parent.get(id(n))
1513
+ if not isinstance(p, _G):
1514
+ continue
1515
+ kids = list(p.children())
1516
+ if n not in kids:
1517
+ continue
1518
+ i = kids.index(n)
1519
+ sx, sy = shifts.get(id(n), (0.0, 0.0))
1520
+ ps = shifts.get(id(p), (0.0, 0.0))
1521
+ r, col = divmod(i, p.cols)
1522
+ for axis, own, mates in (("y", sy - ps[1], [k for j, k in enumerate(kids) if j // p.cols == r and j != i]),
1523
+ ("x", sx - ps[0], [k for j, k in enumerate(kids) if j % p.cols == col and j != i])):
1524
+ if abs(own) <= 1.0 or not mates:
1525
+ continue
1526
+ left = [m for m in mates if abs(shifts.get(id(m), (0.0, 0.0))[0 if axis == "x" else 1]
1527
+ - (ps[0] if axis == "x" else ps[1]) - own) > 1.0]
1528
+ if left:
1529
+ c._cf_left_warned = True
1530
+ warnings.warn(
1531
+ f"constraint {c.describe()} moved {node_name(n)} {own:+.1f}{_unit()} in {axis} out of its "
1532
+ f"{node_name(p)} {'row' if axis == 'y' else 'column'}; its neighbour(s) "
1533
+ f"{', '.join(node_name(m) for m in left[:3])} stayed behind — constrain them too "
1534
+ f"(e.g. same_{axis}({node_name(left[0])}, {node_name(n)})) or group the label and "
1535
+ f"the node in a {'Row' if axis == 'y' else 'Col'}",
1536
+ ConstraintWarning, stacklevel=4)
1537
+ break
1538
+ if getattr(c, "_cf_left_warned", False):
1539
+ break
1540
+
1541
+
1542
+ def _explain_conflict(c: Constraint, ks: List[Any], earlier: List[Tuple[Constraint, List[Any]]],
1543
+ structural: List[Tuple[Any, str]]) -> str:
1544
+ names = ", ".join(node_name(n) for n in c.nodes())
1545
+ head = f"unsatisfiable required constraint {c.describe()} (nodes: {names})"
1546
+ struct_ks = [k for k, _ in structural]
1547
+ if not _satisfiable(struct_ks + ks):
1548
+ culprits = []
1549
+ for i, (_k, desc) in enumerate(structural):
1550
+ rest = struct_ks[:i] + struct_ks[i + 1:]
1551
+ if _satisfiable(rest + ks):
1552
+ culprits.append(desc)
1553
+ if len(culprits) >= 3:
1554
+ break
1555
+ why = "; ".join(dict.fromkeys(culprits)) or "container containment / sibling order"
1556
+ return f"{head}: conflicts with layout invariant(s): {why}"
1557
+ for pc, pks in earlier:
1558
+ if not _satisfiable(struct_ks + pks + ks):
1559
+ pn = ", ".join(node_name(n) for n in pc.nodes())
1560
+ return f"{head}: conflicts with required constraint {pc.describe()} (nodes: {pn})"
1561
+ return f"{head}: conflicts with the combination of earlier required constraints"
1562
+
1563
+
1564
+ __all__ = [
1565
+ "Anchor", "Anchorable", "Constraint", "ConstraintConflictError", "ConstraintWarning",
1566
+ "ConstraintScope", "MatrixRowRef", "MatrixColRef", "MatrixCellRef",
1567
+ "align", "same_x", "same_y", "same_width", "same_height",
1568
+ "below", "above", "right_of", "left_of", "place", "nudge", "distribute",
1569
+ "allow_overlap", "no_overlap", "LinRel",
1570
+ "solve_constraints", "apply_size_constraints", "node_name", "align_columns", "align_rows",
1571
+ ]