figkit 0.1.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.
- figkit/__init__.py +114 -0
- figkit/audit.py +611 -0
- figkit/colors.py +304 -0
- figkit/component.py +110 -0
- figkit/components.py +727 -0
- figkit/connectors.py +689 -0
- figkit/core.py +1021 -0
- figkit/export.py +301 -0
- figkit/figure.py +312 -0
- figkit/fonts.py +462 -0
- figkit/frame.py +376 -0
- figkit/geom.py +497 -0
- figkit/image.py +308 -0
- figkit/layout.py +529 -0
- figkit/mathtext.py +320 -0
- figkit/paint.py +136 -0
- figkit/py.typed +0 -0
- figkit/shapes.py +764 -0
- figkit/style.py +502 -0
- figkit/svgdoc.py +133 -0
- figkit/svgpath.py +470 -0
- figkit/text.py +625 -0
- figkit/themes.py +163 -0
- figkit-0.1.0.dist-info/METADATA +277 -0
- figkit-0.1.0.dist-info/RECORD +28 -0
- figkit-0.1.0.dist-info/WHEEL +5 -0
- figkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- figkit-0.1.0.dist-info/top_level.txt +1 -0
figkit/colors.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Colour parsing, manipulation and colormaps (no third-party deps)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import colorsys
|
|
6
|
+
import math
|
|
7
|
+
import re
|
|
8
|
+
from typing import Sequence
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"parse_color", "to_hex", "rgba", "mix", "lighten", "darken",
|
|
12
|
+
"saturate", "desaturate", "alpha", "contrast_color", "colormap",
|
|
13
|
+
"PALETTES", "COLORMAPS",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
# A small set of useful named colours (CSS names people actually reach for).
|
|
17
|
+
NAMED = {
|
|
18
|
+
"transparent": (0, 0, 0, 0.0), "none": None,
|
|
19
|
+
"black": (0, 0, 0), "white": (255, 255, 255),
|
|
20
|
+
"red": (255, 0, 0), "green": (0, 128, 0), "blue": (0, 0, 255),
|
|
21
|
+
"yellow": (255, 255, 0), "orange": (255, 165, 0), "purple": (128, 0, 128),
|
|
22
|
+
"gray": (128, 128, 128), "grey": (128, 128, 128),
|
|
23
|
+
"lightgray": (211, 211, 211), "lightgrey": (211, 211, 211),
|
|
24
|
+
"darkgray": (169, 169, 169), "darkgrey": (169, 169, 169),
|
|
25
|
+
"whitesmoke": (245, 245, 245), "gainsboro": (220, 220, 220),
|
|
26
|
+
"silver": (192, 192, 192), "dimgray": (105, 105, 105),
|
|
27
|
+
"navy": (0, 0, 128), "teal": (0, 128, 128), "olive": (128, 128, 0),
|
|
28
|
+
"maroon": (128, 0, 0), "lime": (0, 255, 0), "aqua": (0, 255, 255),
|
|
29
|
+
"cyan": (0, 255, 255), "magenta": (255, 0, 255), "fuchsia": (255, 0, 255),
|
|
30
|
+
"pink": (255, 192, 203), "brown": (165, 42, 42), "gold": (255, 215, 0),
|
|
31
|
+
"beige": (245, 245, 220), "ivory": (255, 255, 240), "khaki": (240, 230, 140),
|
|
32
|
+
"salmon": (250, 128, 114), "coral": (255, 127, 80), "tomato": (255, 99, 71),
|
|
33
|
+
"crimson": (220, 20, 60), "indigo": (75, 0, 130), "violet": (238, 130, 238),
|
|
34
|
+
"steelblue": (70, 130, 180), "skyblue": (135, 206, 235),
|
|
35
|
+
"lightblue": (173, 216, 230), "royalblue": (65, 105, 225),
|
|
36
|
+
"cornflowerblue": (100, 149, 237), "midnightblue": (25, 25, 112),
|
|
37
|
+
"seagreen": (46, 139, 87), "forestgreen": (34, 139, 34),
|
|
38
|
+
"lightgreen": (144, 238, 144), "darkgreen": (0, 100, 0),
|
|
39
|
+
"slategray": (112, 128, 144), "lightslategray": (119, 136, 153),
|
|
40
|
+
"aliceblue": (240, 248, 255), "lavender": (230, 230, 250),
|
|
41
|
+
"linen": (250, 240, 230), "snow": (255, 250, 250),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
_HEX_RE = re.compile(r"^#([0-9a-fA-F]{3,8})$")
|
|
45
|
+
_FUNC_RE = re.compile(r"^(rgba?|hsla?)\(([^)]*)\)$", re.I)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def parse_color(c) -> tuple | None:
|
|
49
|
+
"""Parse any colour spec into ``(r, g, b, a)`` with 0-255 ints and 0-1 alpha.
|
|
50
|
+
|
|
51
|
+
Returns ``None`` for ``"none"``/``None`` (meaning "do not paint").
|
|
52
|
+
Accepts: hex (``#rgb``, ``#rgba``, ``#rrggbb``, ``#rrggbbaa``), CSS
|
|
53
|
+
``rgb()``/``rgba()``/``hsl()``/``hsla()``, named colours, ``(r, g, b)`` and
|
|
54
|
+
``(r, g, b, a)`` tuples.
|
|
55
|
+
"""
|
|
56
|
+
if c is None:
|
|
57
|
+
return None
|
|
58
|
+
if isinstance(c, (tuple, list)):
|
|
59
|
+
vals = list(c)
|
|
60
|
+
if len(vals) == 3:
|
|
61
|
+
return (_i(vals[0]), _i(vals[1]), _i(vals[2]), 1.0)
|
|
62
|
+
if len(vals) == 4:
|
|
63
|
+
return (_i(vals[0]), _i(vals[1]), _i(vals[2]), float(vals[3]))
|
|
64
|
+
raise ValueError(f"bad colour tuple: {c!r}")
|
|
65
|
+
s = str(c).strip()
|
|
66
|
+
if not s:
|
|
67
|
+
return None
|
|
68
|
+
low = s.lower()
|
|
69
|
+
if low == "none":
|
|
70
|
+
return None
|
|
71
|
+
if low in NAMED:
|
|
72
|
+
v = NAMED[low]
|
|
73
|
+
if v is None:
|
|
74
|
+
return None
|
|
75
|
+
return (v[0], v[1], v[2], v[3] if len(v) > 3 else 1.0)
|
|
76
|
+
m = _HEX_RE.match(s)
|
|
77
|
+
if m:
|
|
78
|
+
h = m.group(1)
|
|
79
|
+
if len(h) == 3:
|
|
80
|
+
r, g, b = (int(ch * 2, 16) for ch in h)
|
|
81
|
+
return (r, g, b, 1.0)
|
|
82
|
+
if len(h) == 4:
|
|
83
|
+
r, g, b, a = (int(ch * 2, 16) for ch in h)
|
|
84
|
+
return (r, g, b, a / 255.0)
|
|
85
|
+
if len(h) == 6:
|
|
86
|
+
return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), 1.0)
|
|
87
|
+
if len(h) == 8:
|
|
88
|
+
return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16),
|
|
89
|
+
int(h[6:8], 16) / 255.0)
|
|
90
|
+
raise ValueError(f"bad hex colour: {s!r}")
|
|
91
|
+
m = _FUNC_RE.match(s)
|
|
92
|
+
if m:
|
|
93
|
+
kind = m.group(1).lower()
|
|
94
|
+
parts = [p.strip() for p in re.split(r"[,\s/]+", m.group(2)) if p.strip()]
|
|
95
|
+
if kind.startswith("rgb"):
|
|
96
|
+
r, g, b = (_pct(p, 255) for p in parts[:3])
|
|
97
|
+
a = _alpha(parts[3]) if len(parts) > 3 else 1.0
|
|
98
|
+
return (_i(r), _i(g), _i(b), a)
|
|
99
|
+
hh = float(parts[0].rstrip("deg")) / 360.0
|
|
100
|
+
ss = _pct(parts[1], 1.0)
|
|
101
|
+
ll = _pct(parts[2], 1.0)
|
|
102
|
+
a = _alpha(parts[3]) if len(parts) > 3 else 1.0
|
|
103
|
+
r, g, b = colorsys.hls_to_rgb(hh % 1.0, ll, ss)
|
|
104
|
+
return (_i(r * 255), _i(g * 255), _i(b * 255), a)
|
|
105
|
+
raise ValueError(f"cannot parse colour {c!r}")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _i(v) -> int:
|
|
109
|
+
return max(0, min(255, int(round(float(v)))))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _pct(tok: str, scale: float) -> float:
|
|
113
|
+
tok = str(tok)
|
|
114
|
+
if tok.endswith("%"):
|
|
115
|
+
return float(tok[:-1]) / 100.0 * scale
|
|
116
|
+
return float(tok)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _alpha(tok: str) -> float:
|
|
120
|
+
tok = str(tok)
|
|
121
|
+
if tok.endswith("%"):
|
|
122
|
+
return float(tok[:-1]) / 100.0
|
|
123
|
+
return float(tok)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def to_hex(c, keep_alpha: bool = True) -> str:
|
|
127
|
+
"""Render a colour as a hex string (``#rrggbb`` or ``#rrggbbaa``)."""
|
|
128
|
+
p = parse_color(c)
|
|
129
|
+
if p is None:
|
|
130
|
+
return "none"
|
|
131
|
+
r, g, b, a = p
|
|
132
|
+
if keep_alpha and a < 1.0:
|
|
133
|
+
return f"#{r:02x}{g:02x}{b:02x}{_i(a * 255):02x}"
|
|
134
|
+
return f"#{r:02x}{g:02x}{b:02x}"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def rgba(c, a: float) -> str:
|
|
138
|
+
"""Return colour ``c`` with alpha replaced by ``a``."""
|
|
139
|
+
return alpha(c, a)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def alpha(c, a: float) -> str:
|
|
143
|
+
"""Return colour ``c`` with its alpha replaced by ``a`` (0..1)."""
|
|
144
|
+
p = parse_color(c)
|
|
145
|
+
if p is None:
|
|
146
|
+
return "none"
|
|
147
|
+
r, g, b, _ = p
|
|
148
|
+
return f"rgba({r},{g},{b},{round(float(a), 4)})"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def mix(c1, c2, t: float = 0.5) -> str:
|
|
152
|
+
"""Linear blend: ``t=0`` gives ``c1``, ``t=1`` gives ``c2``."""
|
|
153
|
+
a = parse_color(c1) or (0, 0, 0, 0.0)
|
|
154
|
+
b = parse_color(c2) or (0, 0, 0, 0.0)
|
|
155
|
+
t = max(0.0, min(1.0, float(t)))
|
|
156
|
+
out = tuple(_i(a[i] + (b[i] - a[i]) * t) for i in range(3))
|
|
157
|
+
aa = a[3] + (b[3] - a[3]) * t
|
|
158
|
+
return to_hex((out[0], out[1], out[2], aa))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def lighten(c, amount: float = 0.2) -> str:
|
|
162
|
+
"""Move a colour toward white (``amount`` in 0..1)."""
|
|
163
|
+
return mix(c, "#ffffff", amount)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def darken(c, amount: float = 0.2) -> str:
|
|
167
|
+
"""Move a colour toward black (``amount`` in 0..1)."""
|
|
168
|
+
return mix(c, "#000000", amount)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _hls(c):
|
|
172
|
+
p = parse_color(c) or (0, 0, 0, 1.0)
|
|
173
|
+
h, l, s = colorsys.rgb_to_hls(p[0] / 255, p[1] / 255, p[2] / 255)
|
|
174
|
+
return h, l, s, p[3]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def saturate(c, amount: float = 0.2) -> str:
|
|
178
|
+
"""Increase saturation by ``amount`` (0..1); negative desaturates."""
|
|
179
|
+
h, l, s, a = _hls(c)
|
|
180
|
+
r, g, b = colorsys.hls_to_rgb(h, l, max(0.0, min(1.0, s + amount)))
|
|
181
|
+
return to_hex((r * 255, g * 255, b * 255, a))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def desaturate(c, amount: float = 0.2) -> str:
|
|
185
|
+
"""Wash a colour out toward grey by ``amount`` (0..1)."""
|
|
186
|
+
return saturate(c, -amount)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def luminance(c) -> float:
|
|
190
|
+
"""Relative luminance per WCAG."""
|
|
191
|
+
p = parse_color(c)
|
|
192
|
+
if p is None:
|
|
193
|
+
return 1.0
|
|
194
|
+
|
|
195
|
+
def ch(v):
|
|
196
|
+
v = v / 255.0
|
|
197
|
+
return v / 12.92 if v <= 0.03928 else ((v + 0.055) / 1.055) ** 2.4
|
|
198
|
+
|
|
199
|
+
return 0.2126 * ch(p[0]) + 0.7152 * ch(p[1]) + 0.0722 * ch(p[2])
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def contrast_color(bg, dark="#111111", light="#ffffff") -> str:
|
|
203
|
+
"""Pick whichever of ``dark``/``light`` reads better on ``bg``."""
|
|
204
|
+
return dark if luminance(bg) > 0.45 else light
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# --------------------------------------------------------------------------
|
|
208
|
+
# Palettes
|
|
209
|
+
# --------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
PALETTES = {
|
|
212
|
+
# Muted, print-friendly default: works well for ML paper figures.
|
|
213
|
+
"figkit": ["#4C72B0", "#DD8452", "#55A868", "#C44E52", "#8172B3",
|
|
214
|
+
"#937860", "#DA8BC3", "#8C8C8C", "#CCB974", "#64B5CD"],
|
|
215
|
+
"tab10": ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd",
|
|
216
|
+
"#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf"],
|
|
217
|
+
"pastel": ["#a1c9f4", "#ffb482", "#8de5a1", "#ff9f9b", "#d0bbff",
|
|
218
|
+
"#debb9b", "#fab0e4", "#cfcfcf", "#fffea3", "#b9f2f0"],
|
|
219
|
+
"bold": ["#7F3C8D", "#11A579", "#3969AC", "#F2B701", "#E73F74",
|
|
220
|
+
"#80BA5A", "#E68310", "#008695", "#CF1C90", "#f97b72"],
|
|
221
|
+
"grays": ["#111111", "#444444", "#777777", "#999999", "#bbbbbb", "#dddddd"],
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
# Compact colormap definitions: anchor stops that get interpolated.
|
|
225
|
+
COLORMAPS = {
|
|
226
|
+
"viridis": ["#440154", "#414487", "#2a788e", "#22a884", "#7ad151", "#fde725"],
|
|
227
|
+
"plasma": ["#0d0887", "#6a00a8", "#b12a90", "#e16462", "#fca636", "#f0f921"],
|
|
228
|
+
"magma": ["#000004", "#3b0f70", "#8c2981", "#de4968", "#fe9f6d", "#fcfdbf"],
|
|
229
|
+
"inferno": ["#000004", "#420a68", "#932667", "#dd513a", "#fca50a", "#fcffa4"],
|
|
230
|
+
"cividis": ["#00224e", "#123570", "#3b496c", "#575d6d", "#707173", "#8a8678",
|
|
231
|
+
"#a59c74", "#c3b369", "#e1cc55", "#fee838"],
|
|
232
|
+
"gray": ["#000000", "#ffffff"],
|
|
233
|
+
"grey": ["#000000", "#ffffff"],
|
|
234
|
+
"grays": ["#ffffff", "#111111"],
|
|
235
|
+
"greys": ["#ffffff", "#111111"],
|
|
236
|
+
"blues": ["#f7fbff", "#deebf7", "#c6dbef", "#9ecae1", "#6baed6",
|
|
237
|
+
"#4292c6", "#2171b5", "#08519c", "#08306b"],
|
|
238
|
+
"reds": ["#fff5f0", "#fee0d2", "#fcbba1", "#fc9272", "#fb6a4a",
|
|
239
|
+
"#ef3b2c", "#cb181d", "#a50f15", "#67000d"],
|
|
240
|
+
"greens": ["#f7fcf5", "#e5f5e0", "#c7e9c0", "#a1d99b", "#74c476",
|
|
241
|
+
"#41ab5d", "#238b45", "#006d2c", "#00441b"],
|
|
242
|
+
"oranges": ["#fff5eb", "#fee6ce", "#fdd0a2", "#fdae6b", "#fd8d3c",
|
|
243
|
+
"#f16913", "#d94801", "#a63603", "#7f2704"],
|
|
244
|
+
"purples": ["#fcfbfd", "#efedf5", "#dadaeb", "#bcbddc", "#9e9ac8",
|
|
245
|
+
"#807dba", "#6a51a3", "#54278f", "#3f007d"],
|
|
246
|
+
# Diverging
|
|
247
|
+
"coolwarm": ["#3b4cc0", "#7b9ff9", "#c0d4f5", "#f2cbb7", "#ee8468", "#b40426"],
|
|
248
|
+
"rdbu": ["#67001f", "#d6604d", "#f7f7f7", "#4393c3", "#053061"],
|
|
249
|
+
"bwr": ["#0000ff", "#ffffff", "#ff0000"],
|
|
250
|
+
"spectral": ["#9e0142", "#f46d43", "#fee08b", "#e6f598", "#66c2a5", "#5e4fa2"],
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def colormap(name_or_stops, t: float) -> str:
|
|
255
|
+
"""Sample a colormap at ``t`` in ``[0, 1]``.
|
|
256
|
+
|
|
257
|
+
``name_or_stops`` is a colormap name (see :data:`COLORMAPS`), a list of
|
|
258
|
+
colour stops, or a callable ``t -> colour``.
|
|
259
|
+
"""
|
|
260
|
+
if callable(name_or_stops):
|
|
261
|
+
return name_or_stops(t)
|
|
262
|
+
if isinstance(name_or_stops, str):
|
|
263
|
+
key = name_or_stops.lower()
|
|
264
|
+
rev = key.endswith("_r")
|
|
265
|
+
if rev:
|
|
266
|
+
key = key[:-2]
|
|
267
|
+
stops = COLORMAPS.get(key)
|
|
268
|
+
if stops is None:
|
|
269
|
+
raise KeyError(f"unknown colormap {name_or_stops!r}; "
|
|
270
|
+
f"available: {sorted(COLORMAPS)}")
|
|
271
|
+
if rev:
|
|
272
|
+
stops = list(reversed(stops))
|
|
273
|
+
else:
|
|
274
|
+
stops = list(name_or_stops)
|
|
275
|
+
if not stops:
|
|
276
|
+
return "#000000"
|
|
277
|
+
if len(stops) == 1:
|
|
278
|
+
return to_hex(stops[0])
|
|
279
|
+
t = 0.0 if t is None or math.isnan(t) else max(0.0, min(1.0, float(t)))
|
|
280
|
+
pos = t * (len(stops) - 1)
|
|
281
|
+
i = min(int(pos), len(stops) - 2)
|
|
282
|
+
return mix(stops[i], stops[i + 1], pos - i)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def palette(name="figkit", n: int = None) -> list:
|
|
286
|
+
"""Return a categorical palette, cycled/truncated to ``n`` entries."""
|
|
287
|
+
cols = PALETTES.get(name) if isinstance(name, str) else list(name)
|
|
288
|
+
if cols is None:
|
|
289
|
+
raise KeyError(f"unknown palette {name!r}; available: {sorted(PALETTES)}")
|
|
290
|
+
if n is None:
|
|
291
|
+
return list(cols)
|
|
292
|
+
return [cols[i % len(cols)] for i in range(n)]
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def normalize(values: Sequence, vmin=None, vmax=None) -> list:
|
|
296
|
+
"""Scale values into ``[0, 1]`` (used for heatmaps / colour coding)."""
|
|
297
|
+
vals = [float(v) for v in values]
|
|
298
|
+
if not vals:
|
|
299
|
+
return []
|
|
300
|
+
lo = min(vals) if vmin is None else float(vmin)
|
|
301
|
+
hi = max(vals) if vmax is None else float(vmax)
|
|
302
|
+
if hi == lo:
|
|
303
|
+
return [0.5] * len(vals)
|
|
304
|
+
return [(v - lo) / (hi - lo) for v in vals]
|
figkit/component.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Reusable components with named anchors.
|
|
2
|
+
|
|
3
|
+
A plain function returning a :class:`~figkit.core.Group` already gets you a
|
|
4
|
+
reusable unit. What it cannot do is *name* the interesting points on that
|
|
5
|
+
unit, so callers end up reaching into ``group.children[1]``. A
|
|
6
|
+
:class:`Component` builds itself and publishes the anchors that matter:
|
|
7
|
+
|
|
8
|
+
class PortBlock(Component):
|
|
9
|
+
def build(self, label):
|
|
10
|
+
body = Box(label, w=120)
|
|
11
|
+
self.expose("port", body.e)
|
|
12
|
+
self.expose("body", body)
|
|
13
|
+
return [body]
|
|
14
|
+
|
|
15
|
+
block = PortBlock("filter")
|
|
16
|
+
arrow(source.e, block.port) # a live anchor, like any other
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from .core import Element, Group
|
|
22
|
+
|
|
23
|
+
__all__ = ["Component"]
|
|
24
|
+
|
|
25
|
+
#: Constructor arguments that belong to the Group, not to ``build()``.
|
|
26
|
+
_GROUP_KEYS = ("style", "classes", "theme", "name", "z", "visible", "opacity",
|
|
27
|
+
"clip", "audit", "add")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Component(Group):
|
|
31
|
+
"""Base class for a reusable group that publishes named anchors.
|
|
32
|
+
|
|
33
|
+
Subclasses implement :meth:`build`, returning the elements to hold, and
|
|
34
|
+
call :meth:`expose` for anything callers should be able to point at.
|
|
35
|
+
Group keyword arguments (``name``, ``z``, ``theme``, ``style``, …) are
|
|
36
|
+
understood by the constructor and never reach ``build()``.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, *args, **kwargs):
|
|
40
|
+
group_kwargs = {k: kwargs.pop(k) for k in _GROUP_KEYS if k in kwargs}
|
|
41
|
+
self._exposed: dict = {}
|
|
42
|
+
super().__init__(**group_kwargs)
|
|
43
|
+
children = self.build(*args, **kwargs)
|
|
44
|
+
if children is None:
|
|
45
|
+
children = []
|
|
46
|
+
elif isinstance(children, Element):
|
|
47
|
+
children = [children]
|
|
48
|
+
self.add(*children)
|
|
49
|
+
|
|
50
|
+
# -- subclass hook ---------------------------------------------------
|
|
51
|
+
def build(self, *args, **kwargs):
|
|
52
|
+
"""Create and return this component's elements.
|
|
53
|
+
|
|
54
|
+
Called once from the constructor with whatever positional and keyword
|
|
55
|
+
arguments were not consumed as group options.
|
|
56
|
+
"""
|
|
57
|
+
raise NotImplementedError(
|
|
58
|
+
f"{type(self).__name__} must implement build()")
|
|
59
|
+
|
|
60
|
+
# -- named anchors ---------------------------------------------------
|
|
61
|
+
def expose(self, name: str, target) -> "Component":
|
|
62
|
+
"""Publish ``target`` under ``name``.
|
|
63
|
+
|
|
64
|
+
``target`` may be an :class:`~figkit.core.Anchor` (``body.e``), an
|
|
65
|
+
element (exposed as itself), or a callable returning either — use a
|
|
66
|
+
callable when the answer depends on state that changes later.
|
|
67
|
+
"""
|
|
68
|
+
if name in _RESERVED:
|
|
69
|
+
raise ValueError(f"{name!r} is reserved; pick another anchor name")
|
|
70
|
+
self._exposed[str(name)] = target
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def exposed(self) -> tuple:
|
|
75
|
+
"""The names this component publishes."""
|
|
76
|
+
return tuple(self._exposed)
|
|
77
|
+
|
|
78
|
+
def anchor(self, name: str):
|
|
79
|
+
"""Named anchors first, then the usual bounding-box anchors."""
|
|
80
|
+
if name in self._exposed:
|
|
81
|
+
return self._resolve(name)
|
|
82
|
+
return super().anchor(name)
|
|
83
|
+
|
|
84
|
+
def _resolve(self, name: str):
|
|
85
|
+
target = self._exposed[name]
|
|
86
|
+
return target() if callable(target) else target
|
|
87
|
+
|
|
88
|
+
def __getattr__(self, name: str):
|
|
89
|
+
# only reached when normal attribute lookup fails
|
|
90
|
+
exposed = self.__dict__.get("_exposed")
|
|
91
|
+
if exposed and name in exposed:
|
|
92
|
+
target = exposed[name]
|
|
93
|
+
return target() if callable(target) else target
|
|
94
|
+
raise AttributeError(
|
|
95
|
+
f"{type(self).__name__!r} object has no attribute {name!r}")
|
|
96
|
+
|
|
97
|
+
def __repr__(self) -> str:
|
|
98
|
+
bb = self.bbox
|
|
99
|
+
names = ", ".join(self.exposed)
|
|
100
|
+
return (f"<{type(self).__name__} {len(self._children)} children "
|
|
101
|
+
f"[{names}] x={bb.x:.4g} y={bb.y:.4g} "
|
|
102
|
+
f"w={bb.w:.4g} h={bb.h:.4g}>")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
#: Names that would shadow the Element API if exposed.
|
|
106
|
+
_RESERVED = frozenset({
|
|
107
|
+
"n", "s", "e", "w", "ne", "nw", "se", "sw", "c", "center", "bbox", "parent",
|
|
108
|
+
"style", "theme", "children", "point", "top", "bottom", "left", "right",
|
|
109
|
+
"build", "expose", "exposed", "anchor", "add", "move", "at",
|
|
110
|
+
})
|