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/styles.py
ADDED
|
@@ -0,0 +1,893 @@
|
|
|
1
|
+
"""Themes: visual defaults, the active-theme context and size/stroke roles.
|
|
2
|
+
|
|
3
|
+
Two built-in themes ship (see :mod:`codefig.themes`):
|
|
4
|
+
|
|
5
|
+
* ``"paper"`` (the default since 0.6.0) — print-size research-figure look: units are
|
|
6
|
+
**pt at final print size**, three font-size roles (6 / 7 / 8 pt), strokes
|
|
7
|
+
0.6 / 0.75 / 1.2 pt, Okabe-Ito-derived fills (``oi_fill4``), stealth arrowheads.
|
|
8
|
+
* ``"classic"`` — the pre-0.6 screen look (13 px text, 2 px strokes); ``Theme()``
|
|
9
|
+
with no arguments is the classic theme, so old code that builds ``Theme(...)``
|
|
10
|
+
or ``replace(DEFAULT_THEME, ...)`` keeps its look.
|
|
11
|
+
|
|
12
|
+
Nodes do not freeze a theme at construction: ``node.theme`` is the explicitly passed
|
|
13
|
+
theme, else the *active* theme (:func:`use_theme`, entered by ``Figure`` around layout
|
|
14
|
+
and rendering), else the global default (:func:`set_theme`).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import contextvars
|
|
20
|
+
from contextlib import contextmanager
|
|
21
|
+
from dataclasses import dataclass, replace
|
|
22
|
+
from typing import Any, Dict, Iterator, List, Mapping, Optional, Sequence, Tuple, Union
|
|
23
|
+
|
|
24
|
+
PadSpec = Union[float, int, Sequence[float]]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Theme:
|
|
29
|
+
box_rounded: bool = False
|
|
30
|
+
category_stroke: Optional[float] = None
|
|
31
|
+
stroke: str = "#111111"
|
|
32
|
+
stroke_width: float = 2.0
|
|
33
|
+
font_family: str = "'Noto Sans CJK SC', 'Noto Sans CJK JP', DejaVu Sans, Helvetica, Arial, sans-serif"
|
|
34
|
+
mono_family: str = "DejaVu Sans Mono, Menlo, Consolas, monospace"
|
|
35
|
+
font_size: float = 13.0
|
|
36
|
+
typeface: str = "sans" # sans | serif — drives measurement *and* SVG font-family
|
|
37
|
+
title_size: float = 16.0
|
|
38
|
+
panel_title_size: float = 13.0
|
|
39
|
+
caption_size: float = 12.0
|
|
40
|
+
fill_default: str = "#ffffff"
|
|
41
|
+
fill_prompt: str = "#cfe8cf"
|
|
42
|
+
fill_response: str = "#f5e6a8"
|
|
43
|
+
fill_block: str = "#e8e8e8"
|
|
44
|
+
fill_attn: str = "#f5c48a"
|
|
45
|
+
fill_ffn: str = "#7eb6e8"
|
|
46
|
+
fill_q: str = "#c9b3e6"
|
|
47
|
+
fill_k: str = "#f0b080"
|
|
48
|
+
fill_v: str = "#7eb6e8"
|
|
49
|
+
highlight: str = "#c0392b"
|
|
50
|
+
# Spacing (roomier defaults — text/labels were too tight at 8)
|
|
51
|
+
pad: float = 10.0
|
|
52
|
+
fit_font_scale: float = 1.0
|
|
53
|
+
gap_scale: float = 1.0
|
|
54
|
+
pad_scale: float = 1.0
|
|
55
|
+
gap: float = 12.0
|
|
56
|
+
label_gap: float = 8.0 # Matrix/Group external label ↔ shape
|
|
57
|
+
title_gap: float = 8.0 # Container title ↔ children / border
|
|
58
|
+
text_margin: float = 2.0 # extra inset for text inside Box (on top of pad)
|
|
59
|
+
edge_label_gap: float = 6.0
|
|
60
|
+
corner_radius: float = 8.0
|
|
61
|
+
panel_gap: float = 14.0
|
|
62
|
+
panel_pad: float = 14.0
|
|
63
|
+
frame_stroke: float = 2.5
|
|
64
|
+
# Connector defaults (Edge / Arrow / LoopArrow)
|
|
65
|
+
edge_color: str = "#111111"
|
|
66
|
+
edge_opacity: float = 1.0
|
|
67
|
+
edge_width: float = 1.5
|
|
68
|
+
edge_head_size: float = 8.0
|
|
69
|
+
edge_style: str = "solid" # solid | dashed | dotted
|
|
70
|
+
edge_overlap: str = "avoid" # allow | avoid
|
|
71
|
+
edge_head: str = "triangle" # triangle|stealth|vee|open|circle|diamond|bar|square|none
|
|
72
|
+
edge_lane_gap: float = 7.0
|
|
73
|
+
# v0.7.0: how bundle= groups draw — lanes (parallel, crossing-minimised) | merge | bus
|
|
74
|
+
edge_bundle_style: str = "lanes"
|
|
75
|
+
# Arrowhead geometry defaults (None → derived from edge_head_size)
|
|
76
|
+
edge_tail: str = "none" # start-of-edge head shape
|
|
77
|
+
edge_head_length: Optional[float] = None # tip → back distance (default = head_size)
|
|
78
|
+
edge_head_width: Optional[float] = None # across the head (default = 0.9 × head_size)
|
|
79
|
+
edge_head_hollow: bool = False # outline heads filled with edge_head_fill
|
|
80
|
+
edge_head_fill: str = "#ffffff" # interior colour of hollow heads
|
|
81
|
+
# Layering policy: soft text knocks edges out; role overrides per class name
|
|
82
|
+
knockout: bool = True
|
|
83
|
+
knockout_pad: float = 1.5
|
|
84
|
+
roles: Tuple[Tuple[str, str], ...] = () # e.g. (("Text", "hard"),)
|
|
85
|
+
# ---- 0.6.0 -------------------------------------------------------------
|
|
86
|
+
name: str = "classic"
|
|
87
|
+
# Preferred sans families, first installed wins (measurement *and* SVG family).
|
|
88
|
+
# Empty → legacy DejaVu Sans behaviour.
|
|
89
|
+
font_stack: Tuple[str, ...] = ()
|
|
90
|
+
# Font-size roles; when non-empty every requested size snaps to the nearest role.
|
|
91
|
+
font_sizes: Tuple[float, ...] = ()
|
|
92
|
+
annotation_size: Optional[float] = None # small text (legend, dims, ticks); None → 0.85 × font_size
|
|
93
|
+
text_color: str = "#111111"
|
|
94
|
+
muted_color: str = "#555555"
|
|
95
|
+
# Internal default lengths (stubs, cell sizes, offsets …) are written in classic
|
|
96
|
+
# design units and multiplied by ``unit`` (classic 1.0; paper ≈ 7 pt / 13 px).
|
|
97
|
+
unit: float = 1.0
|
|
98
|
+
stroke_emph: Optional[float] = None # emphasis stroke role (highlight borders)
|
|
99
|
+
group_stroke: Optional[float] = None # dashed group / frame role
|
|
100
|
+
dash: Optional[Tuple[float, ...]] = None # "dashed" pattern; None → legacy (scaled)
|
|
101
|
+
dot: Optional[Tuple[float, ...]] = None # "dotted" pattern; None → legacy
|
|
102
|
+
box_pad: Optional[Tuple[float, float]] = None # Box (vertical, horizontal) pad; None → pad + text_margin
|
|
103
|
+
box_min_h: float = 0.0 # min height of a labelled Box
|
|
104
|
+
script_scale: float = 0.75 # sub/superscript size factor
|
|
105
|
+
script_min: float = 0.0 # sub/superscripts never smaller than this
|
|
106
|
+
palette: Tuple[str, ...] = ("#7eb6e8", "#f5c48a", "#cfe8cf", "#c9b3e6", "#f5e6a8", "#f0b080")
|
|
107
|
+
accents: Tuple[str, ...] = ("#c0392b", "#2c7fb8", "#2ca02c")
|
|
108
|
+
hatch_cycle: Tuple[str, ...] = ("", "////", "....", "xxxx", "\\\\")
|
|
109
|
+
hatch_width: float = 0.8
|
|
110
|
+
hatch_spacing: float = 5.0
|
|
111
|
+
neutral_fill: str = "#e8e8e8"
|
|
112
|
+
group_fill: str = "#f3f3f3"
|
|
113
|
+
grid_color: str = "#bdbdbd" # chart gridlines (v0.8.0): light, theme stroke width
|
|
114
|
+
# v0.9.0 chart error bars (design units, × unit): cap half-width floor and how far
|
|
115
|
+
# caps stick out beyond a line-chart marker so tight errors stay visible
|
|
116
|
+
align_max_offset: float = 60.0 # v0.11.0 edge alignment: max port offset (L units)
|
|
117
|
+
align_max_shift: float = 24.0 # … and max node move (L units)
|
|
118
|
+
align_max_grow: float = 12.0 # … and how much a panel's content may grow (L units)
|
|
119
|
+
track_grow_max: float = 1.4 # v0.10.0: align_columns(fit="auto") grows fixed cells ≤ this ×
|
|
120
|
+
stack_gap: float = 3.3 # v0.10.0: gap inside an aligned Col / Row stack (pt, paper)
|
|
121
|
+
chart_zero: Any = "auto" # v0.10.0: LineChart y axis includes 0: "auto" | True | False
|
|
122
|
+
err_cap: float = 2.2
|
|
123
|
+
err_cap_out: float = 1.4
|
|
124
|
+
band_opacity: float = 0.18 # LineChart error bands
|
|
125
|
+
max_panel_scale: float = 1.8
|
|
126
|
+
panel_frame: bool = True
|
|
127
|
+
panel_caption: str = "bottom"
|
|
128
|
+
min_stroke: float = 0.0 # floor for internal decorative strokes (pt)
|
|
129
|
+
physical: bool = False # True → units are pt at final size (no panel zoom)
|
|
130
|
+
edge_min_stroke: float = 0.8 # arrowhead outline floor (classic px)
|
|
131
|
+
|
|
132
|
+
named_accents: Tuple[Tuple[str, str], ...] = ()
|
|
133
|
+
|
|
134
|
+
def accent_names(self) -> tuple[str, ...]:
|
|
135
|
+
"""Return registered accent names in stable order."""
|
|
136
|
+
base = ("blue", "orange", "green") if self.name == "paper" else ("red", "blue", "green")
|
|
137
|
+
return tuple(dict.fromkeys((*base, *(k for k, _ in self.named_accents))))
|
|
138
|
+
|
|
139
|
+
def with_accents(self, **named_hex: str) -> "Theme":
|
|
140
|
+
"""Return a theme with additional named hexadecimal accents."""
|
|
141
|
+
import re
|
|
142
|
+
names = dict(self.named_accents)
|
|
143
|
+
for name, value in named_hex.items():
|
|
144
|
+
if not name.strip() or not isinstance(value, str) or not re.fullmatch(r"#[0-9a-fA-F]{6}", value):
|
|
145
|
+
raise ValueError(f"invalid accent {name!r}: {value!r}; expected #RRGGBB")
|
|
146
|
+
names[name.strip().lower()] = value
|
|
147
|
+
return replace(self, named_accents=tuple(names.items()))
|
|
148
|
+
|
|
149
|
+
# ---- helpers -------------------------------------------------------------
|
|
150
|
+
def accent(self, which: "int | str", *, nearest: bool = False) -> str:
|
|
151
|
+
"""An accent colour by index or by name (v0.12.0).
|
|
152
|
+
|
|
153
|
+
``which`` is an index into :attr:`accents` (cycled) or a colour name:
|
|
154
|
+
``"blue"`` / ``"orange"`` (vermillion) / ``"green"`` / ``"red"`` … — the accent whose
|
|
155
|
+
hue is nearest to that name. ``PAPER_THEME.accents`` is
|
|
156
|
+
``0 blue #0072B2, 1 orange #D55E00, 2 green #009E73`` (Okabe–Ito);
|
|
157
|
+
``CLASSIC_THEME.accents`` is ``0 red #c0392b, 1 blue #2c7fb8, 2 green #2ca02c``.
|
|
158
|
+
"""
|
|
159
|
+
import colorsys
|
|
160
|
+
import difflib
|
|
161
|
+
import warnings
|
|
162
|
+
if nearest:
|
|
163
|
+
warnings.warn("nearest=True is deprecated", DeprecationWarning, stacklevel=2)
|
|
164
|
+
elif isinstance(which, str):
|
|
165
|
+
names = dict(zip(("blue", "orange", "green") if self.name == "paper" else ("red", "blue", "green"), self.accents))
|
|
166
|
+
names.update(self.named_accents)
|
|
167
|
+
key = which.strip().lower()
|
|
168
|
+
if key in names:
|
|
169
|
+
return names[key]
|
|
170
|
+
close = difflib.get_close_matches(key, names, n=1, cutoff=0.3)
|
|
171
|
+
hint = f"; did you mean {close[0]!r}?" if close else ""
|
|
172
|
+
raise ValueError(f"unknown accent name {which!r} for theme {self.name!r}; valid names: {', '.join(names)}{hint} To add a colour use Theme.with_accents(purple='#RRGGBB') or pass a hex colour.")
|
|
173
|
+
|
|
174
|
+
acc = list(self.accents) or ["#555555"]
|
|
175
|
+
if isinstance(which, int):
|
|
176
|
+
return acc[which % len(acc)]
|
|
177
|
+
hues = {"red": 0.0, "vermillion": 0.05, "orange": 0.07, "yellow": 0.14, "green": 0.4,
|
|
178
|
+
"teal": 0.47, "cyan": 0.5, "sky": 0.55, "blue": 0.6, "purple": 0.78,
|
|
179
|
+
"magenta": 0.85, "pink": 0.92}
|
|
180
|
+
key = str(which).lower().replace("_", "").replace(" ", "")
|
|
181
|
+
if key not in hues:
|
|
182
|
+
raise ValueError(f"unknown accent name {which!r}; use an index or one of "
|
|
183
|
+
f"{sorted(hues)}")
|
|
184
|
+
want = hues[key]
|
|
185
|
+
|
|
186
|
+
def hue(c: str) -> float:
|
|
187
|
+
c = c.lstrip("#")
|
|
188
|
+
r, g, b = (int(c[i:i + 2], 16) / 255 for i in (0, 2, 4))
|
|
189
|
+
return colorsys.rgb_to_hsv(r, g, b)[0]
|
|
190
|
+
|
|
191
|
+
def dist(c: str) -> float:
|
|
192
|
+
d = abs(hue(c) - want)
|
|
193
|
+
return min(d, 1 - d)
|
|
194
|
+
return min(acc, key=dist)
|
|
195
|
+
|
|
196
|
+
def L(self, v: float) -> float:
|
|
197
|
+
"""Scale an internal design-unit length by ``unit`` (identity for classic)."""
|
|
198
|
+
return float(v) if self.unit == 1.0 else float(v) * self.unit
|
|
199
|
+
|
|
200
|
+
def F(self, v: float) -> float:
|
|
201
|
+
"""An internal font-size literal (classic px, designed against 13 px text)
|
|
202
|
+
mapped to this theme (identity for classic; snapped to a size role)."""
|
|
203
|
+
if self.unit == 1.0 and not self.font_sizes:
|
|
204
|
+
return float(v)
|
|
205
|
+
return self.snap(float(v) * self.font_size / 13.0)
|
|
206
|
+
|
|
207
|
+
def SW(self, v: float) -> float:
|
|
208
|
+
"""An internal stroke-width literal (classic px) mapped to a stroke role:
|
|
209
|
+
thin → ``stroke_width``, medium → ``edge_width``, heavy → ``emph_stroke``."""
|
|
210
|
+
if self.unit == 1.0:
|
|
211
|
+
return float(v)
|
|
212
|
+
v = float(v)
|
|
213
|
+
if v <= 0:
|
|
214
|
+
return 0.0
|
|
215
|
+
if v <= 1.05:
|
|
216
|
+
return self.stroke_width
|
|
217
|
+
if v <= 1.7:
|
|
218
|
+
return self.edge_width
|
|
219
|
+
return self.emph_stroke
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def small_size(self) -> float:
|
|
223
|
+
"""Annotation / legend / tick text size."""
|
|
224
|
+
return float(self.annotation_size if self.annotation_size is not None
|
|
225
|
+
else self.font_size * 0.85)
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def emph_stroke(self) -> float:
|
|
229
|
+
return float(self.stroke_emph if self.stroke_emph is not None else self.stroke_width * 1.25)
|
|
230
|
+
|
|
231
|
+
def head_dims(self, length: float, width: float) -> Tuple[float, float]:
|
|
232
|
+
"""An internal arrowhead literal (classic px) → (length, width): identity for
|
|
233
|
+
classic, the theme's one head size (CF-ARR-001) otherwise."""
|
|
234
|
+
if self.unit == 1.0:
|
|
235
|
+
return float(length), float(width)
|
|
236
|
+
hl = self.edge_head_length if self.edge_head_length is not None else self.edge_head_size
|
|
237
|
+
hw = self.edge_head_width if self.edge_head_width is not None else 0.9 * hl
|
|
238
|
+
return float(hl), float(hw)
|
|
239
|
+
|
|
240
|
+
def snap(self, size: float) -> float:
|
|
241
|
+
"""Snap a requested font size to the nearest size role (no-op without roles)."""
|
|
242
|
+
roles = self.font_sizes
|
|
243
|
+
if not roles:
|
|
244
|
+
return size
|
|
245
|
+
try:
|
|
246
|
+
v = float(size)
|
|
247
|
+
except (TypeError, ValueError):
|
|
248
|
+
return size
|
|
249
|
+
best = min(roles, key=lambda r: (abs(r - v * self.fit_font_scale), -r))
|
|
250
|
+
return float(best) / self.fit_font_scale
|
|
251
|
+
|
|
252
|
+
def script_size(self, size: float) -> float:
|
|
253
|
+
"""Sub/superscript size for body text of ``size``.
|
|
254
|
+
|
|
255
|
+
v0.12.0 (A14): ``script_min`` is a floor at the *printed* size — while a
|
|
256
|
+
physical figure is shrunk ×s to fit its venue width, scripts are drawn at
|
|
257
|
+
least ``script_min / s`` design units, so a shrink never pushes them under
|
|
258
|
+
the legibility floor (5.5 pt in the paper theme)."""
|
|
259
|
+
floor = self.script_min
|
|
260
|
+
k = _OUT_SCALE.get()
|
|
261
|
+
if floor > 0 and k < 0.9999:
|
|
262
|
+
# never larger than the body text itself
|
|
263
|
+
floor = min(floor * 1.02 / max(k, 0.05), max(float(size), self.script_min))
|
|
264
|
+
return max(float(size) * self.script_scale, floor)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
DEFAULT_THEME = Theme() # the classic theme (kept for backward compatibility)
|
|
268
|
+
CLASSIC_THEME = DEFAULT_THEME
|
|
269
|
+
|
|
270
|
+
PAPER_THEME = Theme(
|
|
271
|
+
name="paper",
|
|
272
|
+
stroke="#333333",
|
|
273
|
+
stroke_width=0.6,
|
|
274
|
+
font_family="'Liberation Sans', Helvetica, Arial, sans-serif",
|
|
275
|
+
mono_family="DejaVu Sans Mono, Menlo, Consolas, monospace",
|
|
276
|
+
font_size=7.0,
|
|
277
|
+
title_size=8.0,
|
|
278
|
+
panel_title_size=8.0,
|
|
279
|
+
caption_size=7.0,
|
|
280
|
+
annotation_size=6.0,
|
|
281
|
+
fill_default="#ffffff",
|
|
282
|
+
fill_prompt="#80CEB9",
|
|
283
|
+
fill_response="#F5D999",
|
|
284
|
+
fill_block="#F2F2F2",
|
|
285
|
+
fill_attn="#F5D999",
|
|
286
|
+
fill_ffn="#66AAD1",
|
|
287
|
+
fill_q="#66AAD1",
|
|
288
|
+
fill_k="#F5D999",
|
|
289
|
+
fill_v="#80CEB9",
|
|
290
|
+
highlight="#D55E00",
|
|
291
|
+
pad=4.0,
|
|
292
|
+
gap=10.5,
|
|
293
|
+
label_gap=3.5,
|
|
294
|
+
title_gap=3.5,
|
|
295
|
+
text_margin=0.0,
|
|
296
|
+
edge_label_gap=2.5,
|
|
297
|
+
corner_radius=2.5,
|
|
298
|
+
panel_gap=10.5,
|
|
299
|
+
panel_pad=4.0,
|
|
300
|
+
frame_stroke=0.6,
|
|
301
|
+
edge_color="#333333",
|
|
302
|
+
edge_width=0.75,
|
|
303
|
+
edge_head_size=4.0,
|
|
304
|
+
edge_head="stealth",
|
|
305
|
+
edge_lane_gap=3.5,
|
|
306
|
+
edge_head_length=4.0,
|
|
307
|
+
edge_head_width=3.0,
|
|
308
|
+
knockout_pad=1.0,
|
|
309
|
+
font_stack=("Helvetica", "Arial", "TeX Gyre Heros", "Liberation Sans", "Nimbus Sans",
|
|
310
|
+
"DejaVu Sans"),
|
|
311
|
+
font_sizes=(6.0, 7.0, 8.0),
|
|
312
|
+
text_color="#000000",
|
|
313
|
+
muted_color="#555555",
|
|
314
|
+
unit=0.55,
|
|
315
|
+
stroke_emph=1.2,
|
|
316
|
+
group_stroke=0.6,
|
|
317
|
+
dash=(3.0, 2.0),
|
|
318
|
+
dot=(0.75, 1.5),
|
|
319
|
+
box_pad=(3.15, 5.6),
|
|
320
|
+
box_min_h=13.3,
|
|
321
|
+
script_scale=0.8,
|
|
322
|
+
script_min=5.5,
|
|
323
|
+
palette=("#66AAD1", "#F5D999", "#80CEB9", "#FCFAD9", "#C37D41", "#B45F69"),
|
|
324
|
+
accents=("#0072B2", "#D55E00", "#009E73"),
|
|
325
|
+
hatch_width=0.5,
|
|
326
|
+
hatch_spacing=2.8,
|
|
327
|
+
neutral_fill="#F2F2F2",
|
|
328
|
+
group_fill="#FAFAFA",
|
|
329
|
+
grid_color="#BDBDBD",
|
|
330
|
+
max_panel_scale=1.0,
|
|
331
|
+
panel_frame=False,
|
|
332
|
+
panel_caption="top",
|
|
333
|
+
min_stroke=0.5,
|
|
334
|
+
physical=True,
|
|
335
|
+
edge_min_stroke=0.5,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
PAPER_THEME_015 = PAPER_THEME
|
|
339
|
+
PAPER_PALETTE = ("#4DB1F2", "#FCD188", "#8FCCA7", "#AE87A9", "#EDF6C3", "#A67167")
|
|
340
|
+
PAPER_THEME = replace(
|
|
341
|
+
PAPER_THEME_015, palette=PAPER_PALETTE,
|
|
342
|
+
fill_q=PAPER_PALETTE[0], fill_k=PAPER_PALETTE[1], fill_v=PAPER_PALETTE[2],
|
|
343
|
+
fill_attn=PAPER_PALETTE[1], fill_ffn=PAPER_PALETTE[0],
|
|
344
|
+
fill_prompt=PAPER_PALETTE[2], fill_response=PAPER_PALETTE[1],
|
|
345
|
+
box_rounded=True, category_stroke=0.45, corner_radius=3.0,
|
|
346
|
+
box_pad=(3.4, 6.0), box_min_h=14.0, group_fill="#F6F7F9",
|
|
347
|
+
)
|
|
348
|
+
THEMES: Dict[str, Theme] = {"paper": PAPER_THEME, "paper-0.15": PAPER_THEME_015, "classic": CLASSIC_THEME}
|
|
349
|
+
|
|
350
|
+
ThemeLike = Union[None, str, Theme, Mapping[str, Any]]
|
|
351
|
+
|
|
352
|
+
_OUT_SCALE: "contextvars.ContextVar[float]" = contextvars.ContextVar("codefig_out_scale",
|
|
353
|
+
default=1.0)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def use_output_scale(k: float):
|
|
357
|
+
"""Context manager: the figure-wide print scale (``Figure.global_scale``) that
|
|
358
|
+
:meth:`Theme.script_size` compensates for. Set by Figure; rarely needed directly."""
|
|
359
|
+
from contextlib import contextmanager
|
|
360
|
+
|
|
361
|
+
@contextmanager
|
|
362
|
+
def _cm():
|
|
363
|
+
tok = _OUT_SCALE.set(float(k))
|
|
364
|
+
try:
|
|
365
|
+
yield
|
|
366
|
+
finally:
|
|
367
|
+
_OUT_SCALE.reset(tok)
|
|
368
|
+
|
|
369
|
+
return _cm()
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
_ACTIVE: "contextvars.ContextVar[Optional[Theme]]" = contextvars.ContextVar(
|
|
373
|
+
"codefig_active_theme", default=None)
|
|
374
|
+
_GLOBAL = [PAPER_THEME]
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def register_theme(name: str, theme: Theme) -> Theme:
|
|
378
|
+
"""Register a named theme so ``Figure(theme=name)`` / ``use_theme(name)`` find it."""
|
|
379
|
+
THEMES[str(name)] = theme
|
|
380
|
+
return theme
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def get_theme(spec: ThemeLike = None) -> Theme:
|
|
384
|
+
"""Resolve ``None`` (active) | name | Theme | dict-of-overrides (over the active theme)."""
|
|
385
|
+
if spec is None:
|
|
386
|
+
return current_theme()
|
|
387
|
+
if isinstance(spec, Theme):
|
|
388
|
+
return spec
|
|
389
|
+
if isinstance(spec, str):
|
|
390
|
+
try:
|
|
391
|
+
return THEMES[spec]
|
|
392
|
+
except KeyError:
|
|
393
|
+
raise ValueError(f"unknown theme {spec!r}; known: {sorted(THEMES)}") from None
|
|
394
|
+
if isinstance(spec, Mapping):
|
|
395
|
+
d = dict(spec)
|
|
396
|
+
base = get_theme(d.pop("base", None))
|
|
397
|
+
return replace(base, **d)
|
|
398
|
+
raise TypeError(f"theme must be a name, Theme or dict, got {spec!r}")
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def current_theme() -> Theme:
|
|
402
|
+
"""The active theme: innermost :func:`use_theme`, else the global default."""
|
|
403
|
+
t = _ACTIVE.get()
|
|
404
|
+
return t if t is not None else _GLOBAL[0]
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def set_theme(spec: ThemeLike) -> Theme:
|
|
408
|
+
"""Set the global default theme (``"paper"`` unless changed).
|
|
409
|
+
|
|
410
|
+
Returns the *previous* global theme, so ``old = set_theme("classic")`` …
|
|
411
|
+
``set_theme(old)`` restores it. Prefer ``with use_theme(...)`` for scoped use.
|
|
412
|
+
"""
|
|
413
|
+
t = get_theme(spec)
|
|
414
|
+
prev = _GLOBAL[0]
|
|
415
|
+
_GLOBAL[0] = t
|
|
416
|
+
return prev
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
@contextmanager
|
|
420
|
+
def use_theme(spec: ThemeLike) -> Iterator[Theme]:
|
|
421
|
+
"""``with use_theme("classic"): ...`` — nodes without an explicit theme use it."""
|
|
422
|
+
t = get_theme(spec)
|
|
423
|
+
tok = _ACTIVE.set(t)
|
|
424
|
+
try:
|
|
425
|
+
yield t
|
|
426
|
+
finally:
|
|
427
|
+
_ACTIVE.reset(tok)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
class theme_default:
|
|
431
|
+
"""Descriptor: instance value if set (not None), else ``theme.<attr>`` (lazy).
|
|
432
|
+
|
|
433
|
+
``attr`` may be a field name or a callable ``theme -> value``.
|
|
434
|
+
"""
|
|
435
|
+
|
|
436
|
+
def __init__(self, attr: Any) -> None:
|
|
437
|
+
self.attr = attr
|
|
438
|
+
self.slot = ""
|
|
439
|
+
|
|
440
|
+
def __set_name__(self, owner: Any, name: str) -> None:
|
|
441
|
+
self.slot = "_td_" + name
|
|
442
|
+
|
|
443
|
+
def __get__(self, obj: Any, owner: Any = None) -> Any:
|
|
444
|
+
if obj is None:
|
|
445
|
+
return self
|
|
446
|
+
v = obj.__dict__.get(self.slot)
|
|
447
|
+
if v is not None:
|
|
448
|
+
return v
|
|
449
|
+
th = obj.theme
|
|
450
|
+
value = self.attr(th) if callable(self.attr) else getattr(th, self.attr)
|
|
451
|
+
if isinstance(self.attr, str):
|
|
452
|
+
factor = th.pad_scale if self.attr == "pad" else th.gap_scale if "gap" in self.attr else 1.0
|
|
453
|
+
if isinstance(value, (int, float)):
|
|
454
|
+
value *= factor
|
|
455
|
+
return value
|
|
456
|
+
|
|
457
|
+
def __set__(self, obj: Any, value: Any) -> None:
|
|
458
|
+
obj.__dict__[self.slot] = value
|
|
459
|
+
|
|
460
|
+
_EDGE_KEYS = {
|
|
461
|
+
"color": "edge_color",
|
|
462
|
+
"stroke": "edge_color",
|
|
463
|
+
"opacity": "edge_opacity",
|
|
464
|
+
"alpha": "edge_opacity",
|
|
465
|
+
"width": "edge_width",
|
|
466
|
+
"stroke_width": "edge_width",
|
|
467
|
+
"head_size": "edge_head_size",
|
|
468
|
+
"style": "edge_style",
|
|
469
|
+
"overlap": "edge_overlap",
|
|
470
|
+
"head": "edge_head",
|
|
471
|
+
"lane_gap": "edge_lane_gap",
|
|
472
|
+
"tail": "edge_tail",
|
|
473
|
+
"head_length": "edge_head_length",
|
|
474
|
+
"head_width": "edge_head_width",
|
|
475
|
+
"hollow": "edge_head_hollow",
|
|
476
|
+
"head_hollow": "edge_head_hollow",
|
|
477
|
+
"head_fill": "edge_head_fill",
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
_SPACING_KEYS = {
|
|
481
|
+
"pad": "pad",
|
|
482
|
+
"padding": "pad",
|
|
483
|
+
"gap": "gap",
|
|
484
|
+
"label_gap": "label_gap",
|
|
485
|
+
"text_margin": "text_margin", # Box inner text inset (on top of pad)
|
|
486
|
+
"title_gap": "title_gap",
|
|
487
|
+
"edge_label_gap": "edge_label_gap",
|
|
488
|
+
"panel_gap": "panel_gap",
|
|
489
|
+
"panel_pad": "panel_pad",
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def normalize_pad(pad: PadSpec) -> Tuple[float, float, float, float]:
|
|
494
|
+
"""CSS-like padding → (top, right, bottom, left)."""
|
|
495
|
+
if isinstance(pad, (int, float)):
|
|
496
|
+
v = float(pad)
|
|
497
|
+
return (v, v, v, v)
|
|
498
|
+
vals = [float(x) for x in pad]
|
|
499
|
+
if len(vals) == 1:
|
|
500
|
+
v = vals[0]
|
|
501
|
+
return (v, v, v, v)
|
|
502
|
+
if len(vals) == 2:
|
|
503
|
+
v, h = vals
|
|
504
|
+
return (v, h, v, h)
|
|
505
|
+
if len(vals) == 3:
|
|
506
|
+
t, h, b = vals
|
|
507
|
+
return (t, h, b, h)
|
|
508
|
+
if len(vals) == 4:
|
|
509
|
+
return (vals[0], vals[1], vals[2], vals[3])
|
|
510
|
+
raise TypeError(f"pad must be scalar or 1/2/3/4-tuple, got {pad!r}")
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def apply_edge_overrides(theme: Theme, edge: Optional[Mapping[str, Any]]) -> Theme:
|
|
514
|
+
if not edge:
|
|
515
|
+
return theme
|
|
516
|
+
updates: Dict[str, Any] = {}
|
|
517
|
+
for k, v in edge.items():
|
|
518
|
+
field = _EDGE_KEYS.get(k, k if k.startswith("edge_") else None)
|
|
519
|
+
if field is None:
|
|
520
|
+
raise TypeError(
|
|
521
|
+
f"Unknown edge style key {k!r}; expected one of {sorted(_EDGE_KEYS)}"
|
|
522
|
+
)
|
|
523
|
+
updates[field] = v
|
|
524
|
+
return replace(theme, **updates)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def apply_spacing_overrides(theme: Theme, spacing: Optional[Mapping[str, Any]]) -> Theme:
|
|
528
|
+
"""Figure(..., spacing={pad, gap, label_gap, title_gap, ...})."""
|
|
529
|
+
if not spacing:
|
|
530
|
+
return theme
|
|
531
|
+
updates: Dict[str, Any] = {}
|
|
532
|
+
for k, v in spacing.items():
|
|
533
|
+
field = _SPACING_KEYS.get(k)
|
|
534
|
+
if field is None:
|
|
535
|
+
raise TypeError(
|
|
536
|
+
f"Unknown spacing key {k!r}; expected one of {sorted(_SPACING_KEYS)}"
|
|
537
|
+
)
|
|
538
|
+
updates[field] = float(v) if not isinstance(v, (tuple, list)) else v
|
|
539
|
+
# If pad is a sequence, store the uniform average as theme.pad (scalar theme field)
|
|
540
|
+
if "pad" in updates and isinstance(updates["pad"], (tuple, list)):
|
|
541
|
+
pts = normalize_pad(updates["pad"])
|
|
542
|
+
updates["pad"] = sum(pts) / 4.0
|
|
543
|
+
return replace(theme, **updates)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
HEAD_SHAPES = ("triangle", "stealth", "vee", "open", "circle", "diamond", "bar", "square",
|
|
547
|
+
"none")
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
@dataclass(frozen=True)
|
|
551
|
+
class Head:
|
|
552
|
+
"""Arrowhead spec for one end of an Edge / Arrow.
|
|
553
|
+
|
|
554
|
+
shape: triangle | stealth (notched) | vee (two open strokes) | open (hollow
|
|
555
|
+
triangle) | circle | diamond | bar (perpendicular tick) | square | none.
|
|
556
|
+
size scales the whole head; length / width set it explicitly (px);
|
|
557
|
+
hollow=True draws an outline filled with ``fill`` (default white).
|
|
558
|
+
"""
|
|
559
|
+
|
|
560
|
+
shape: str = "triangle"
|
|
561
|
+
size: Optional[float] = None
|
|
562
|
+
length: Optional[float] = None
|
|
563
|
+
width: Optional[float] = None
|
|
564
|
+
hollow: Optional[bool] = None
|
|
565
|
+
fill: Optional[str] = None
|
|
566
|
+
|
|
567
|
+
def __post_init__(self) -> None:
|
|
568
|
+
if self.shape not in HEAD_SHAPES:
|
|
569
|
+
raise ValueError(f"head shape must be one of {HEAD_SHAPES}, got {self.shape!r}")
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
@dataclass(frozen=True)
|
|
573
|
+
class ResolvedHead:
|
|
574
|
+
shape: str
|
|
575
|
+
length: float
|
|
576
|
+
width: float
|
|
577
|
+
hollow: bool
|
|
578
|
+
fill: str
|
|
579
|
+
|
|
580
|
+
@property
|
|
581
|
+
def visible(self) -> bool:
|
|
582
|
+
return self.shape != "none" and self.length > 0
|
|
583
|
+
|
|
584
|
+
def setback(self, stroke_width: float = 1.5) -> float:
|
|
585
|
+
"""How far the shaft stops short of the tip so it never pokes through."""
|
|
586
|
+
if not self.visible:
|
|
587
|
+
return 0.0
|
|
588
|
+
L = self.length
|
|
589
|
+
if self.shape == "vee":
|
|
590
|
+
return stroke_width * 0.6
|
|
591
|
+
if self.shape == "bar":
|
|
592
|
+
return 0.0
|
|
593
|
+
if self.shape == "stealth":
|
|
594
|
+
return L * (0.95 if self.hollow else 0.55)
|
|
595
|
+
if self.hollow:
|
|
596
|
+
return L
|
|
597
|
+
return L * 0.6
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
HeadLike = Union[None, str, Head, Mapping[str, Any]]
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def as_head(spec: HeadLike) -> Optional[Head]:
|
|
604
|
+
if spec is None:
|
|
605
|
+
return None
|
|
606
|
+
if isinstance(spec, Head):
|
|
607
|
+
return spec
|
|
608
|
+
if isinstance(spec, str):
|
|
609
|
+
return Head(shape=spec)
|
|
610
|
+
if isinstance(spec, Mapping):
|
|
611
|
+
return Head(**dict(spec))
|
|
612
|
+
raise TypeError(f"head must be a shape name, Head(...) or dict, got {spec!r}")
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def resolve_head(
|
|
616
|
+
spec: HeadLike,
|
|
617
|
+
theme: "Theme",
|
|
618
|
+
*,
|
|
619
|
+
default_shape: str,
|
|
620
|
+
size: Optional[float] = None,
|
|
621
|
+
length: Optional[float] = None,
|
|
622
|
+
width: Optional[float] = None,
|
|
623
|
+
hollow: Optional[bool] = None,
|
|
624
|
+
fill: Optional[str] = None,
|
|
625
|
+
) -> ResolvedHead:
|
|
626
|
+
"""Merge a per-end spec over edge-level knobs over theme defaults."""
|
|
627
|
+
h = as_head(spec) or Head(shape=default_shape)
|
|
628
|
+
sz = h.size if h.size is not None else (size if size is not None else theme.edge_head_size)
|
|
629
|
+
sz = float(sz)
|
|
630
|
+
base_len = theme.edge_head_length if theme.edge_head_length is not None else sz
|
|
631
|
+
base_wid = theme.edge_head_width if theme.edge_head_width is not None else 0.9 * sz
|
|
632
|
+
if h.size is not None or size is not None: # explicit scale wins over theme lengths
|
|
633
|
+
base_len, base_wid = sz, 0.9 * sz
|
|
634
|
+
L = h.length if h.length is not None else (length if length is not None else base_len)
|
|
635
|
+
W = h.width if h.width is not None else (width if width is not None else base_wid)
|
|
636
|
+
shape = h.shape
|
|
637
|
+
hol = h.hollow if h.hollow is not None else (hollow if hollow is not None else theme.edge_head_hollow)
|
|
638
|
+
if shape == "open":
|
|
639
|
+
shape, hol = "triangle", True
|
|
640
|
+
if shape == "circle" and h.width is None and width is None:
|
|
641
|
+
W = L
|
|
642
|
+
return ResolvedHead(shape=shape, length=float(L), width=float(W), hollow=bool(hol),
|
|
643
|
+
fill=h.fill or fill or theme.edge_head_fill)
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
# ---- borders ---------------------------------------------------------------
|
|
647
|
+
|
|
648
|
+
BORDER_STYLES = ("solid", "dashed", "dotted", "dashdot", "none")
|
|
649
|
+
BorderLike = Union[None, str, Sequence[float]]
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def border_dasharray(border: BorderLike, stroke_width: float = 1.0) -> Optional[str]:
|
|
653
|
+
"""``border=`` value → SVG stroke-dasharray (None = solid line).
|
|
654
|
+
|
|
655
|
+
'solid' | 'dashed' | 'dotted' | 'dashdot' | custom "4 2" / (4, 2) pattern.
|
|
656
|
+
Named patterns scale with the stroke width so thick borders stay legible.
|
|
657
|
+
"""
|
|
658
|
+
if border is None or border == "solid" or border == "none":
|
|
659
|
+
return None
|
|
660
|
+
k = max(1.0, float(stroke_width or 1.0))
|
|
661
|
+
th = current_theme()
|
|
662
|
+
if border == "dashed" and th.dash is not None:
|
|
663
|
+
return " ".join(f"{v:g}" for v in th.dash)
|
|
664
|
+
if border == "dotted" and th.dot is not None:
|
|
665
|
+
return " ".join(f"{v:g}" for v in th.dot)
|
|
666
|
+
if border == "dashed":
|
|
667
|
+
return f"{4.5 * k:.2f} {3 * k:.2f}"
|
|
668
|
+
if border == "dotted":
|
|
669
|
+
return f"{1 * k:.2f} {2 * k:.2f}"
|
|
670
|
+
if border == "dashdot":
|
|
671
|
+
return f"{6 * k:.2f} {2.5 * k:.2f} {1 * k:.2f} {2.5 * k:.2f}"
|
|
672
|
+
if isinstance(border, str):
|
|
673
|
+
parts = border.replace(",", " ").split()
|
|
674
|
+
try:
|
|
675
|
+
[float(p) for p in parts]
|
|
676
|
+
except ValueError:
|
|
677
|
+
raise ValueError(
|
|
678
|
+
f"border must be one of {BORDER_STYLES} or a dash pattern like '4 2', got {border!r}"
|
|
679
|
+
) from None
|
|
680
|
+
return " ".join(parts)
|
|
681
|
+
vals = [float(v) for v in border]
|
|
682
|
+
if not vals:
|
|
683
|
+
return None
|
|
684
|
+
return " ".join(f"{v:g}" for v in vals)
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def border_visible(border: BorderLike) -> bool:
|
|
688
|
+
return border != "none"
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def validate_border(border: BorderLike) -> BorderLike:
|
|
692
|
+
border_dasharray(border, 1.0)
|
|
693
|
+
return border
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
@dataclass
|
|
697
|
+
class ResolvedEdgeStyle:
|
|
698
|
+
color: str
|
|
699
|
+
opacity: float
|
|
700
|
+
width: float
|
|
701
|
+
head_size: float
|
|
702
|
+
style: str
|
|
703
|
+
overlap: str
|
|
704
|
+
head: str
|
|
705
|
+
lane_gap: float
|
|
706
|
+
end_head: Optional[ResolvedHead] = None
|
|
707
|
+
start_head: Optional[ResolvedHead] = None
|
|
708
|
+
dash: Optional[str] = None # custom dash pattern (style=(4, 2) or "4 2")
|
|
709
|
+
|
|
710
|
+
def dasharray(self) -> Optional[str]:
|
|
711
|
+
if self.dash:
|
|
712
|
+
return self.dash
|
|
713
|
+
th = current_theme()
|
|
714
|
+
if self.style == "dashed" and th.dash is not None:
|
|
715
|
+
return " ".join(f"{v:g}" for v in th.dash)
|
|
716
|
+
if self.style == "dotted" and th.dot is not None:
|
|
717
|
+
return " ".join(f"{v:g}" for v in th.dot)
|
|
718
|
+
if self.style == "dashed":
|
|
719
|
+
return "7 4"
|
|
720
|
+
if self.style == "dotted":
|
|
721
|
+
return "1.5 3.5"
|
|
722
|
+
return None
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def resolve_edge_style(
|
|
726
|
+
theme: Theme,
|
|
727
|
+
*,
|
|
728
|
+
color: Optional[str] = None,
|
|
729
|
+
stroke: Optional[str] = None,
|
|
730
|
+
opacity: Optional[float] = None,
|
|
731
|
+
alpha: Optional[float] = None,
|
|
732
|
+
width: Optional[float] = None,
|
|
733
|
+
stroke_width: Optional[float] = None,
|
|
734
|
+
head_size: Optional[float] = None,
|
|
735
|
+
style: Optional[str] = None,
|
|
736
|
+
overlap: Optional[str] = None,
|
|
737
|
+
head: HeadLike = None,
|
|
738
|
+
tail: HeadLike = None,
|
|
739
|
+
head_length: Optional[float] = None,
|
|
740
|
+
head_width: Optional[float] = None,
|
|
741
|
+
hollow: Optional[bool] = None,
|
|
742
|
+
head_fill: Optional[str] = None,
|
|
743
|
+
) -> ResolvedEdgeStyle:
|
|
744
|
+
col = color if color is not None else stroke
|
|
745
|
+
op = opacity if opacity is not None else alpha
|
|
746
|
+
w = width if width is not None else stroke_width
|
|
747
|
+
dash = None
|
|
748
|
+
if style is not None and style not in ("solid", "dashed", "dotted"):
|
|
749
|
+
dash = border_dasharray(style, 1.0)
|
|
750
|
+
style = "custom"
|
|
751
|
+
hd = as_head(head)
|
|
752
|
+
end_head = resolve_head(hd, theme, default_shape=theme.edge_head, size=head_size,
|
|
753
|
+
length=head_length, width=head_width, hollow=hollow, fill=head_fill)
|
|
754
|
+
start_head = resolve_head(as_head(tail), theme, default_shape=theme.edge_tail,
|
|
755
|
+
size=head_size, length=head_length, width=head_width,
|
|
756
|
+
hollow=hollow, fill=head_fill)
|
|
757
|
+
rs = ResolvedEdgeStyle(
|
|
758
|
+
color=col if col is not None else theme.edge_color,
|
|
759
|
+
opacity=float(op if op is not None else theme.edge_opacity),
|
|
760
|
+
width=float(w if w is not None else theme.edge_width),
|
|
761
|
+
head_size=float(head_size if head_size is not None else theme.edge_head_size),
|
|
762
|
+
style=(style if style is not None else theme.edge_style),
|
|
763
|
+
overlap=(overlap if overlap is not None else theme.edge_overlap),
|
|
764
|
+
head=(hd.shape if hd is not None else theme.edge_head),
|
|
765
|
+
lane_gap=float(theme.edge_lane_gap),
|
|
766
|
+
end_head=end_head,
|
|
767
|
+
start_head=start_head,
|
|
768
|
+
dash=dash,
|
|
769
|
+
)
|
|
770
|
+
return rs
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def normalize_justify(justify: str) -> str:
|
|
774
|
+
return justify.replace("-", "_")
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
class themed:
|
|
778
|
+
"""Descriptor for an internal literal default resolved against the node's theme.
|
|
779
|
+
|
|
780
|
+
``themed(24.0)`` → a length (``theme.L``); ``themed(1.0, "SW")`` → a stroke
|
|
781
|
+
role; ``themed(11.0, "F")`` → a font size. Assigning ``None`` restores the
|
|
782
|
+
default; any number set explicitly is used verbatim (in the theme's units).
|
|
783
|
+
"""
|
|
784
|
+
|
|
785
|
+
def __init__(self, default: Any, kind: str = "L") -> None:
|
|
786
|
+
self.default = default
|
|
787
|
+
self.kind = kind
|
|
788
|
+
self.slot = ""
|
|
789
|
+
|
|
790
|
+
def __set_name__(self, owner: Any, name: str) -> None:
|
|
791
|
+
self.slot = "_th_" + name
|
|
792
|
+
|
|
793
|
+
def __get__(self, obj: Any, owner: Any = None) -> Any:
|
|
794
|
+
if obj is None:
|
|
795
|
+
return self
|
|
796
|
+
v = obj.__dict__.get(self.slot)
|
|
797
|
+
if v is not None:
|
|
798
|
+
return v
|
|
799
|
+
th = getattr(obj, "theme", None) or current_theme()
|
|
800
|
+
f = getattr(th, self.kind)
|
|
801
|
+
if isinstance(self.default, tuple):
|
|
802
|
+
return tuple(f(d) for d in self.default)
|
|
803
|
+
return f(self.default)
|
|
804
|
+
|
|
805
|
+
def __set__(self, obj: Any, value: Any) -> None:
|
|
806
|
+
if value is not None and isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
807
|
+
value = float(value)
|
|
808
|
+
obj.__dict__[self.slot] = value
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
# ---- categories (0.6.0) ---------------------------------------------------------
|
|
812
|
+
# ``Box(..., category="cache")``: the n-th distinct category of a figure gets
|
|
813
|
+
# ``theme.palette[n % len]``; past the palette, ``theme.hatch_cycle`` adds hatching
|
|
814
|
+
# so ≥ 5 categories stay distinguishable in grey-scale (CF-COL-003).
|
|
815
|
+
_CATS: "contextvars.ContextVar[Optional[dict]]" = contextvars.ContextVar("codefig_categories",
|
|
816
|
+
default=None)
|
|
817
|
+
_GLOBAL_CATS: dict = {}
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
@contextmanager
|
|
821
|
+
def use_categories(order: Sequence[str]) -> Iterator[dict]:
|
|
822
|
+
"""Fix the category → index map (Figure does this in panel order)."""
|
|
823
|
+
d: dict = {}
|
|
824
|
+
for c in order:
|
|
825
|
+
d.setdefault(c, len(d))
|
|
826
|
+
tok = _CATS.set(d)
|
|
827
|
+
try:
|
|
828
|
+
yield d
|
|
829
|
+
finally:
|
|
830
|
+
_CATS.reset(tok)
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def category_index(name: str) -> int:
|
|
834
|
+
d = _CATS.get()
|
|
835
|
+
if d is None:
|
|
836
|
+
d = _GLOBAL_CATS
|
|
837
|
+
if name not in d:
|
|
838
|
+
d[name] = len(d)
|
|
839
|
+
return d[name]
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def category_style(name: str, theme: Optional["Theme"] = None) -> Tuple[str, Optional[str]]:
|
|
843
|
+
"""(fill colour, hatch spec or None) for category ``name`` under ``theme``."""
|
|
844
|
+
th = theme or current_theme()
|
|
845
|
+
i = category_index(name)
|
|
846
|
+
pal = list(th.palette) or ["#e8e8e8"]
|
|
847
|
+
fill = pal[i % len(pal)]
|
|
848
|
+
cyc = list(th.hatch_cycle) or [""]
|
|
849
|
+
hatch = cyc[(i // len(pal)) % len(cyc)] if i >= len(pal) else ""
|
|
850
|
+
return fill, (hatch or None)
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def length_unit(theme: Optional["Theme"] = None) -> str:
|
|
854
|
+
"""``"pt"`` for physical themes (paper), ``"px"`` for the classic pixel theme — used in
|
|
855
|
+
warning / lint messages."""
|
|
856
|
+
th = theme or current_theme()
|
|
857
|
+
return "px" if getattr(th, "unit", 1.0) == 1.0 else "pt"
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def shade(color: str, amount: float = 0.2) -> str:
|
|
861
|
+
"""Mix ``color`` with black: ``shade("#66AAD1", 0.2)`` → a darker fill of the same hue
|
|
862
|
+
(the side face of a :class:`~codefig.solids.Cuboid`)."""
|
|
863
|
+
c = color.lstrip("#")
|
|
864
|
+
if len(c) == 3:
|
|
865
|
+
c = "".join(ch * 2 for ch in c)
|
|
866
|
+
r, g, b = (int(c[i:i + 2], 16) for i in (0, 2, 4))
|
|
867
|
+
a = min(1.0, max(0.0, float(amount)))
|
|
868
|
+
return "#" + "".join(f"{round(v * (1 - a)):02X}" for v in (r, g, b))
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def tint(color: str, amount: float = 0.5) -> str:
|
|
872
|
+
"""Mix ``color`` with white: ``tint("#66AAD1", 0.6)`` → a lighter fill of the same hue
|
|
873
|
+
(for highlight bands behind text, e.g. ``CodeBlock(highlight_lines=...)``)."""
|
|
874
|
+
c = color.lstrip("#")
|
|
875
|
+
if len(c) == 3:
|
|
876
|
+
c = "".join(ch * 2 for ch in c)
|
|
877
|
+
r, g, b = (int(c[i:i + 2], 16) for i in (0, 2, 4))
|
|
878
|
+
a = min(1.0, max(0.0, float(amount)))
|
|
879
|
+
mix = (round(v + (255 - v) * a) for v in (r, g, b))
|
|
880
|
+
return "#" + "".join(f"{v:02X}" for v in mix)
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def resolve_color(spec, theme, *, where: str):
|
|
884
|
+
"""Validate drawing colours and resolve registered accents."""
|
|
885
|
+
from .lint import parse_color
|
|
886
|
+
if spec is None or not isinstance(spec, str):
|
|
887
|
+
return spec
|
|
888
|
+
value = spec.strip().lower()
|
|
889
|
+
if value in {"none", "transparent"} or value.startswith("url(") or parse_color(value) is not None:
|
|
890
|
+
return spec
|
|
891
|
+
if value in theme.accent_names():
|
|
892
|
+
return theme.accent(value)
|
|
893
|
+
raise ValueError(f"{where}: invalid colour {spec!r}; use hex, a supported colour name, or a registered theme accent")
|