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/__init__.py +210 -0
- codefig/_validate.py +12 -0
- codefig/autowrap.py +277 -0
- codefig/charts.py +1806 -0
- codefig/code.py +602 -0
- codefig/compose.py +374 -0
- codefig/constraints.py +1571 -0
- codefig/dim.py +214 -0
- codefig/edge_align.py +231 -0
- codefig/edges.py +2824 -0
- codefig/export.py +204 -0
- codefig/figure.py +1923 -0
- codefig/fills.py +146 -0
- codefig/flows.py +102 -0
- codefig/glyphs.py +395 -0
- codefig/icon.py +288 -0
- codefig/icons/LICENSE-tabler-icons.txt +21 -0
- codefig/icons/__init__.py +101 -0
- codefig/icons/tabler.json +1 -0
- codefig/legend.py +483 -0
- codefig/lint.py +1737 -0
- codefig/lint_geom.py +528 -0
- codefig/node.py +864 -0
- codefig/notices.py +34 -0
- codefig/overlaps.py +392 -0
- codefig/policy.py +137 -0
- codefig/primitives.py +2721 -0
- codefig/py.typed +0 -0
- codefig/regions.py +727 -0
- codefig/render_svg.py +512 -0
- codefig/routing.py +932 -0
- codefig/shapes.py +642 -0
- codefig/solids.py +514 -0
- codefig/steps.py +203 -0
- codefig/styles.py +893 -0
- codefig/textutil.py +889 -0
- codefig/timeline.py +1288 -0
- codefig/tree.py +277 -0
- codefig/venues.py +93 -0
- codefig-0.17.0.dist-info/METADATA +542 -0
- codefig-0.17.0.dist-info/RECORD +44 -0
- codefig-0.17.0.dist-info/WHEEL +5 -0
- codefig-0.17.0.dist-info/licenses/LICENSE +21 -0
- codefig-0.17.0.dist-info/top_level.txt +1 -0
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
|
+
]
|