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