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/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")