rgrid-python 4.5.3__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.
- grid_py/__init__.py +340 -0
- grid_py/_arrow.py +331 -0
- grid_py/_clippath.py +170 -0
- grid_py/_colour.py +815 -0
- grid_py/_coords.py +1534 -0
- grid_py/_curve.py +1668 -0
- grid_py/_display_list.py +507 -0
- grid_py/_draw.py +1397 -0
- grid_py/_edit.py +756 -0
- grid_py/_font_metrics.py +319 -0
- grid_py/_gpar.py +572 -0
- grid_py/_grab.py +501 -0
- grid_py/_grob.py +1377 -0
- grid_py/_group.py +798 -0
- grid_py/_highlevel.py +2176 -0
- grid_py/_just.py +361 -0
- grid_py/_layout.py +593 -0
- grid_py/_ls.py +895 -0
- grid_py/_mask.py +196 -0
- grid_py/_path.py +414 -0
- grid_py/_patterns.py +1049 -0
- grid_py/_primitives.py +2198 -0
- grid_py/_renderer_base.py +1184 -0
- grid_py/_scene_graph.py +248 -0
- grid_py/_size.py +1352 -0
- grid_py/_state.py +683 -0
- grid_py/_transforms.py +448 -0
- grid_py/_typeset.py +384 -0
- grid_py/_units.py +1924 -0
- grid_py/_utils.py +310 -0
- grid_py/_viewport.py +1649 -0
- grid_py/_vp_calc.py +970 -0
- grid_py/py.typed +0 -0
- grid_py/renderer.py +1762 -0
- grid_py/renderer_web.py +764 -0
- grid_py/resources/d3.v7.min.js +2 -0
- grid_py/resources/gridpy.css +80 -0
- grid_py/resources/gridpy.js +813 -0
- rgrid_python-4.5.3.dist-info/METADATA +489 -0
- rgrid_python-4.5.3.dist-info/RECORD +42 -0
- rgrid_python-4.5.3.dist-info/WHEEL +4 -0
- rgrid_python-4.5.3.dist-info/licenses/LICENSE +3 -0
grid_py/_grob.py
ADDED
|
@@ -0,0 +1,1377 @@
|
|
|
1
|
+
"""Grob base classes for grid_py (port of R's grid ``grob`` system).
|
|
2
|
+
|
|
3
|
+
This module provides the core graphical-object hierarchy:
|
|
4
|
+
|
|
5
|
+
* :class:`Grob` -- base class for all graphical objects.
|
|
6
|
+
* :class:`GTree` -- a grob that contains child grobs.
|
|
7
|
+
* :class:`GList` -- a flat container of grobs.
|
|
8
|
+
* :class:`GEdit` / :class:`GEditList` -- edit specifications.
|
|
9
|
+
|
|
10
|
+
Together with a suite of free functions for constructing, querying, and
|
|
11
|
+
mutating grob trees, these classes form the backbone of the grid_py scene
|
|
12
|
+
graph, closely mirroring R's *grid* package.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import copy
|
|
18
|
+
import itertools
|
|
19
|
+
import warnings
|
|
20
|
+
from collections import OrderedDict
|
|
21
|
+
from typing import (
|
|
22
|
+
Any,
|
|
23
|
+
Dict,
|
|
24
|
+
Iterable,
|
|
25
|
+
Iterator,
|
|
26
|
+
List,
|
|
27
|
+
Optional,
|
|
28
|
+
Sequence,
|
|
29
|
+
Tuple,
|
|
30
|
+
Union,
|
|
31
|
+
overload,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
from ._gpar import Gpar
|
|
35
|
+
from ._path import GPath
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"Grob",
|
|
39
|
+
"GTree",
|
|
40
|
+
"GList",
|
|
41
|
+
"GEdit",
|
|
42
|
+
"GEditList",
|
|
43
|
+
"grob_tree",
|
|
44
|
+
"grob_name",
|
|
45
|
+
"is_grob",
|
|
46
|
+
"get_grob",
|
|
47
|
+
"set_grob",
|
|
48
|
+
"add_grob",
|
|
49
|
+
"remove_grob",
|
|
50
|
+
"edit_grob",
|
|
51
|
+
"force_grob",
|
|
52
|
+
"set_children",
|
|
53
|
+
"reorder_grob",
|
|
54
|
+
"apply_edit",
|
|
55
|
+
"apply_edits",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Auto-name counter (mirrors R's ``grobAutoName`` closure)
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
_auto_name_counter: int = 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _reset_auto_name() -> None:
|
|
66
|
+
"""Reset the global auto-name counter (useful for testing)."""
|
|
67
|
+
global _auto_name_counter
|
|
68
|
+
_auto_name_counter = 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _auto_name(prefix: str = "GRID", suffix: str = "GROB") -> str:
|
|
72
|
+
"""Generate a unique grob name like ``GRID.GROB.1``.
|
|
73
|
+
|
|
74
|
+
Parameters
|
|
75
|
+
----------
|
|
76
|
+
prefix : str
|
|
77
|
+
Leading part of the name.
|
|
78
|
+
suffix : str
|
|
79
|
+
Middle part of the name (typically the grob class).
|
|
80
|
+
|
|
81
|
+
Returns
|
|
82
|
+
-------
|
|
83
|
+
str
|
|
84
|
+
"""
|
|
85
|
+
global _auto_name_counter
|
|
86
|
+
_auto_name_counter += 1
|
|
87
|
+
return f"{prefix}.{suffix}.{_auto_name_counter}"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
# grob_name (public helper)
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def grob_name(grob: Optional["Grob"] = None, prefix: str = "GRID") -> str:
|
|
96
|
+
"""Return an auto-generated grob name.
|
|
97
|
+
|
|
98
|
+
Parameters
|
|
99
|
+
----------
|
|
100
|
+
grob : Grob or None
|
|
101
|
+
If supplied, the grob's class name is used as the suffix.
|
|
102
|
+
prefix : str
|
|
103
|
+
Leading part of the generated name.
|
|
104
|
+
|
|
105
|
+
Returns
|
|
106
|
+
-------
|
|
107
|
+
str
|
|
108
|
+
A unique name such as ``"GRID.rect.3"``.
|
|
109
|
+
|
|
110
|
+
Raises
|
|
111
|
+
------
|
|
112
|
+
TypeError
|
|
113
|
+
If *grob* is not ``None`` and not a :class:`Grob`.
|
|
114
|
+
"""
|
|
115
|
+
if grob is None:
|
|
116
|
+
return _auto_name(prefix)
|
|
117
|
+
if not isinstance(grob, Grob):
|
|
118
|
+
raise TypeError("invalid 'grob' argument")
|
|
119
|
+
suffix = grob._grid_class if grob._grid_class else type(grob).__name__
|
|
120
|
+
return _auto_name(prefix, suffix)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
# is_grob
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def is_grob(x: Any) -> bool:
|
|
129
|
+
"""Return ``True`` if *x* is a :class:`Grob` (or subclass).
|
|
130
|
+
|
|
131
|
+
Parameters
|
|
132
|
+
----------
|
|
133
|
+
x : Any
|
|
134
|
+
|
|
135
|
+
Returns
|
|
136
|
+
-------
|
|
137
|
+
bool
|
|
138
|
+
"""
|
|
139
|
+
return isinstance(x, Grob)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# Grob
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class Grob:
|
|
148
|
+
"""Base class for all graphical objects.
|
|
149
|
+
|
|
150
|
+
This is the Python equivalent of R's ``grob`` S3 class. Every grob has
|
|
151
|
+
a *name* (auto-generated when omitted), an optional *gp* (:class:`Gpar`),
|
|
152
|
+
and an optional *vp* (viewport or viewport path). Subclasses add
|
|
153
|
+
domain-specific fields and override the various ``*_details`` hooks.
|
|
154
|
+
|
|
155
|
+
Parameters
|
|
156
|
+
----------
|
|
157
|
+
name : str or None
|
|
158
|
+
Unique name. Auto-generated (e.g. ``"GRID.grob.1"``) when ``None``.
|
|
159
|
+
gp : Gpar or None
|
|
160
|
+
Graphical parameters.
|
|
161
|
+
vp : object or None
|
|
162
|
+
Viewport (duck-typed to avoid circular imports).
|
|
163
|
+
_grid_class : str or None
|
|
164
|
+
R-style class tag (e.g. ``"rect"``, ``"circle"``).
|
|
165
|
+
**kwargs
|
|
166
|
+
Arbitrary grob-specific fields stored as instance attributes.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
# We allow arbitrary attributes via __dict__, but declare the core
|
|
170
|
+
# slots here for documentation.
|
|
171
|
+
|
|
172
|
+
def __init__(
|
|
173
|
+
self,
|
|
174
|
+
name: Optional[str] = None,
|
|
175
|
+
gp: Optional[Gpar] = None,
|
|
176
|
+
vp: Optional[Any] = None,
|
|
177
|
+
_grid_class: Optional[str] = None,
|
|
178
|
+
**kwargs: Any,
|
|
179
|
+
) -> None:
|
|
180
|
+
self._grid_class: str = _grid_class or "grob"
|
|
181
|
+
# Store extra fields first so checkNameSlot can use _grid_class
|
|
182
|
+
for key, value in kwargs.items():
|
|
183
|
+
setattr(self, key, value)
|
|
184
|
+
self._name: Optional[str] = name
|
|
185
|
+
self._gp: Optional[Gpar] = gp
|
|
186
|
+
self._vp: Optional[Any] = vp
|
|
187
|
+
# Validate and fill defaults
|
|
188
|
+
self._validate()
|
|
189
|
+
|
|
190
|
+
# -- validation --------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
def _validate(self) -> None:
|
|
193
|
+
"""Run validation (mirrors R ``validGrob.grob``)."""
|
|
194
|
+
self.valid_details()
|
|
195
|
+
# Auto-name
|
|
196
|
+
if self._name is None:
|
|
197
|
+
self._name = _auto_name(suffix=self._grid_class)
|
|
198
|
+
else:
|
|
199
|
+
self._name = str(self._name)
|
|
200
|
+
# Check gp – auto-convert dict to Gpar for convenience
|
|
201
|
+
if self._gp is not None and not isinstance(self._gp, Gpar):
|
|
202
|
+
if isinstance(self._gp, dict):
|
|
203
|
+
self._gp = Gpar(**self._gp)
|
|
204
|
+
else:
|
|
205
|
+
raise TypeError("invalid 'gp' slot: expected Gpar, dict, or None")
|
|
206
|
+
# Check vp (duck typed -- accept anything with a reasonable interface)
|
|
207
|
+
if self._vp is not None:
|
|
208
|
+
self._vp = self._check_vp(self._vp)
|
|
209
|
+
|
|
210
|
+
@staticmethod
|
|
211
|
+
def _check_vp(vp: Any) -> Any:
|
|
212
|
+
"""Validate the vp slot (duck-typed viewport check).
|
|
213
|
+
|
|
214
|
+
Parameters
|
|
215
|
+
----------
|
|
216
|
+
vp : Any
|
|
217
|
+
|
|
218
|
+
Returns
|
|
219
|
+
-------
|
|
220
|
+
object
|
|
221
|
+
The validated viewport (possibly wrapped as a VpPath for strings).
|
|
222
|
+
"""
|
|
223
|
+
if vp is None:
|
|
224
|
+
return None
|
|
225
|
+
# Accept anything that quacks like a viewport or vpPath.
|
|
226
|
+
# If a plain string is given, try to wrap it in a VpPath (mirror R
|
|
227
|
+
# behaviour). Import lazily to avoid circular imports.
|
|
228
|
+
if isinstance(vp, str):
|
|
229
|
+
from ._path import VpPath
|
|
230
|
+
return VpPath(vp)
|
|
231
|
+
return vp
|
|
232
|
+
|
|
233
|
+
# -- properties --------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
@property
|
|
236
|
+
def name(self) -> str:
|
|
237
|
+
"""The grob's unique name.
|
|
238
|
+
|
|
239
|
+
Returns
|
|
240
|
+
-------
|
|
241
|
+
str
|
|
242
|
+
"""
|
|
243
|
+
return self._name # type: ignore[return-value]
|
|
244
|
+
|
|
245
|
+
@name.setter
|
|
246
|
+
def name(self, value: str) -> None:
|
|
247
|
+
self._name = str(value)
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def gp(self) -> Optional[Gpar]:
|
|
251
|
+
"""Graphical parameters.
|
|
252
|
+
|
|
253
|
+
Returns
|
|
254
|
+
-------
|
|
255
|
+
Gpar or None
|
|
256
|
+
"""
|
|
257
|
+
return self._gp
|
|
258
|
+
|
|
259
|
+
@gp.setter
|
|
260
|
+
def gp(self, value: Optional[Gpar]) -> None:
|
|
261
|
+
if value is not None and not isinstance(value, Gpar):
|
|
262
|
+
raise TypeError("invalid 'gp' slot: expected Gpar or None")
|
|
263
|
+
self._gp = value
|
|
264
|
+
|
|
265
|
+
@property
|
|
266
|
+
def vp(self) -> Optional[Any]:
|
|
267
|
+
"""Viewport associated with this grob.
|
|
268
|
+
|
|
269
|
+
Returns
|
|
270
|
+
-------
|
|
271
|
+
object or None
|
|
272
|
+
"""
|
|
273
|
+
return self._vp
|
|
274
|
+
|
|
275
|
+
@vp.setter
|
|
276
|
+
def vp(self, value: Any) -> None:
|
|
277
|
+
self._vp = self._check_vp(value)
|
|
278
|
+
|
|
279
|
+
# -- hook methods (override in subclasses) -----------------------------
|
|
280
|
+
|
|
281
|
+
def draw_details(self, recording: bool = True) -> None:
|
|
282
|
+
"""Perform class-specific drawing.
|
|
283
|
+
|
|
284
|
+
Override in subclasses to implement actual rendering.
|
|
285
|
+
|
|
286
|
+
Parameters
|
|
287
|
+
----------
|
|
288
|
+
recording : bool
|
|
289
|
+
Whether the drawing should be recorded on the display list.
|
|
290
|
+
"""
|
|
291
|
+
|
|
292
|
+
def pre_draw_details(self) -> None:
|
|
293
|
+
"""Pre-draw hook (called before ``draw_details``).
|
|
294
|
+
|
|
295
|
+
Override to set up any state that ``draw_details`` requires.
|
|
296
|
+
"""
|
|
297
|
+
|
|
298
|
+
def post_draw_details(self) -> None:
|
|
299
|
+
"""Post-draw hook (called after ``draw_details``).
|
|
300
|
+
|
|
301
|
+
Override to tear down state created by ``pre_draw_details``.
|
|
302
|
+
"""
|
|
303
|
+
|
|
304
|
+
def valid_details(self) -> None:
|
|
305
|
+
"""Validate class-specific slots.
|
|
306
|
+
|
|
307
|
+
Override in subclasses to perform additional validation. Called
|
|
308
|
+
during construction and after editing. Modify ``self`` in place or
|
|
309
|
+
raise on invalid state.
|
|
310
|
+
"""
|
|
311
|
+
|
|
312
|
+
def make_content(self) -> "Grob":
|
|
313
|
+
"""Create or transform the drawing content.
|
|
314
|
+
|
|
315
|
+
Override to lazily materialise content just before drawing.
|
|
316
|
+
|
|
317
|
+
Returns
|
|
318
|
+
-------
|
|
319
|
+
Grob
|
|
320
|
+
Typically ``self``, possibly modified.
|
|
321
|
+
"""
|
|
322
|
+
return self
|
|
323
|
+
|
|
324
|
+
def make_context(self) -> "Grob":
|
|
325
|
+
"""Create or transform the drawing context (viewport, etc.).
|
|
326
|
+
|
|
327
|
+
Override to lazily adjust the viewport or gp before drawing.
|
|
328
|
+
|
|
329
|
+
Returns
|
|
330
|
+
-------
|
|
331
|
+
Grob
|
|
332
|
+
Typically ``self``, possibly modified.
|
|
333
|
+
"""
|
|
334
|
+
return self
|
|
335
|
+
|
|
336
|
+
def edit_details(self, **kwargs: Any) -> "Grob":
|
|
337
|
+
"""Hook called after attributes are updated via :func:`edit_grob`.
|
|
338
|
+
|
|
339
|
+
Override to perform additional bookkeeping when edits occur.
|
|
340
|
+
|
|
341
|
+
Parameters
|
|
342
|
+
----------
|
|
343
|
+
**kwargs
|
|
344
|
+
The name-value pairs that were applied.
|
|
345
|
+
|
|
346
|
+
Returns
|
|
347
|
+
-------
|
|
348
|
+
Grob
|
|
349
|
+
``self`` (possibly modified).
|
|
350
|
+
"""
|
|
351
|
+
return self
|
|
352
|
+
|
|
353
|
+
def width_details(self) -> Any:
|
|
354
|
+
"""Return the width of this grob in its own coordinate system.
|
|
355
|
+
|
|
356
|
+
Returns
|
|
357
|
+
-------
|
|
358
|
+
object
|
|
359
|
+
Implementation-dependent width representation.
|
|
360
|
+
"""
|
|
361
|
+
return None
|
|
362
|
+
|
|
363
|
+
def height_details(self) -> Any:
|
|
364
|
+
"""Return the height of this grob in its own coordinate system.
|
|
365
|
+
|
|
366
|
+
Returns
|
|
367
|
+
-------
|
|
368
|
+
object
|
|
369
|
+
Implementation-dependent height representation.
|
|
370
|
+
"""
|
|
371
|
+
return None
|
|
372
|
+
|
|
373
|
+
def x_details(self, theta: float = 0.0) -> Any:
|
|
374
|
+
"""Return the x-location at angle *theta*.
|
|
375
|
+
|
|
376
|
+
Parameters
|
|
377
|
+
----------
|
|
378
|
+
theta : float
|
|
379
|
+
Angle in degrees.
|
|
380
|
+
|
|
381
|
+
Returns
|
|
382
|
+
-------
|
|
383
|
+
object
|
|
384
|
+
"""
|
|
385
|
+
return None
|
|
386
|
+
|
|
387
|
+
def y_details(self, theta: float = 0.0) -> Any:
|
|
388
|
+
"""Return the y-location at angle *theta*.
|
|
389
|
+
|
|
390
|
+
Parameters
|
|
391
|
+
----------
|
|
392
|
+
theta : float
|
|
393
|
+
Angle in degrees.
|
|
394
|
+
|
|
395
|
+
Returns
|
|
396
|
+
-------
|
|
397
|
+
object
|
|
398
|
+
"""
|
|
399
|
+
return None
|
|
400
|
+
|
|
401
|
+
def ascent_details(self) -> Any:
|
|
402
|
+
"""Return the typographic ascent of this grob.
|
|
403
|
+
|
|
404
|
+
Returns
|
|
405
|
+
-------
|
|
406
|
+
object
|
|
407
|
+
"""
|
|
408
|
+
return None
|
|
409
|
+
|
|
410
|
+
def descent_details(self) -> Any:
|
|
411
|
+
"""Return the typographic descent of this grob.
|
|
412
|
+
|
|
413
|
+
Returns
|
|
414
|
+
-------
|
|
415
|
+
object
|
|
416
|
+
"""
|
|
417
|
+
return None
|
|
418
|
+
|
|
419
|
+
def grob_coords(self, closed: bool = True) -> Any:
|
|
420
|
+
"""Return coordinates for this grob.
|
|
421
|
+
|
|
422
|
+
Parameters
|
|
423
|
+
----------
|
|
424
|
+
closed : bool
|
|
425
|
+
Whether to return coordinates for a closed shape.
|
|
426
|
+
|
|
427
|
+
Returns
|
|
428
|
+
-------
|
|
429
|
+
object
|
|
430
|
+
"""
|
|
431
|
+
return None
|
|
432
|
+
|
|
433
|
+
def grob_points(self, closed: bool = True) -> Any:
|
|
434
|
+
"""Return points for this grob.
|
|
435
|
+
|
|
436
|
+
Parameters
|
|
437
|
+
----------
|
|
438
|
+
closed : bool
|
|
439
|
+
Whether to return points for a closed shape.
|
|
440
|
+
|
|
441
|
+
Returns
|
|
442
|
+
-------
|
|
443
|
+
object
|
|
444
|
+
"""
|
|
445
|
+
return None
|
|
446
|
+
|
|
447
|
+
# -- dunder methods ----------------------------------------------------
|
|
448
|
+
|
|
449
|
+
def __repr__(self) -> str:
|
|
450
|
+
return f"{self._grid_class}[{self._name}]"
|
|
451
|
+
|
|
452
|
+
def __str__(self) -> str:
|
|
453
|
+
return self.__repr__()
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
# ---------------------------------------------------------------------------
|
|
457
|
+
# GList
|
|
458
|
+
# ---------------------------------------------------------------------------
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
class GList:
|
|
462
|
+
"""A flat container of :class:`Grob` objects.
|
|
463
|
+
|
|
464
|
+
Mirrors R's ``gList`` class. Duplicate names are allowed at this level
|
|
465
|
+
but will be disambiguated when the list is attached to a :class:`GTree`.
|
|
466
|
+
|
|
467
|
+
Parameters
|
|
468
|
+
----------
|
|
469
|
+
*grobs : Grob
|
|
470
|
+
Zero or more grob instances.
|
|
471
|
+
|
|
472
|
+
Raises
|
|
473
|
+
------
|
|
474
|
+
TypeError
|
|
475
|
+
If any element is not a :class:`Grob` (or ``None`` / ``GList``,
|
|
476
|
+
which are flattened/ignored).
|
|
477
|
+
"""
|
|
478
|
+
|
|
479
|
+
__slots__ = ("_grobs",)
|
|
480
|
+
|
|
481
|
+
def __init__(self, *grobs: Union["Grob", "GList", None]) -> None:
|
|
482
|
+
flat: list[Grob] = []
|
|
483
|
+
for g in grobs:
|
|
484
|
+
if g is None:
|
|
485
|
+
continue
|
|
486
|
+
if isinstance(g, GList):
|
|
487
|
+
flat.extend(g._grobs)
|
|
488
|
+
elif isinstance(g, Grob):
|
|
489
|
+
flat.append(g)
|
|
490
|
+
else:
|
|
491
|
+
raise TypeError(f"only Grob instances allowed in GList, got {type(g).__name__}")
|
|
492
|
+
self._grobs: list[Grob] = flat
|
|
493
|
+
|
|
494
|
+
# -- sequence protocol -------------------------------------------------
|
|
495
|
+
|
|
496
|
+
def __len__(self) -> int:
|
|
497
|
+
return len(self._grobs)
|
|
498
|
+
|
|
499
|
+
def __iter__(self) -> Iterator[Grob]:
|
|
500
|
+
return iter(self._grobs)
|
|
501
|
+
|
|
502
|
+
def __getitem__(self, index: Union[int, slice]) -> Union[Grob, "GList"]:
|
|
503
|
+
result = self._grobs[index]
|
|
504
|
+
if isinstance(index, slice):
|
|
505
|
+
gl = GList.__new__(GList)
|
|
506
|
+
gl._grobs = list(result)
|
|
507
|
+
return gl
|
|
508
|
+
return result
|
|
509
|
+
|
|
510
|
+
def __setitem__(self, index: int, value: Grob) -> None:
|
|
511
|
+
if not isinstance(value, Grob):
|
|
512
|
+
raise TypeError(f"only Grob instances allowed in GList, got {type(value).__name__}")
|
|
513
|
+
self._grobs[index] = value
|
|
514
|
+
|
|
515
|
+
def append(self, grob: Grob) -> None:
|
|
516
|
+
"""Append a grob to this list.
|
|
517
|
+
|
|
518
|
+
Parameters
|
|
519
|
+
----------
|
|
520
|
+
grob : Grob
|
|
521
|
+
"""
|
|
522
|
+
if not isinstance(grob, Grob):
|
|
523
|
+
raise TypeError(f"only Grob instances allowed in GList, got {type(grob).__name__}")
|
|
524
|
+
self._grobs.append(grob)
|
|
525
|
+
|
|
526
|
+
# -- dunder methods ----------------------------------------------------
|
|
527
|
+
|
|
528
|
+
def __repr__(self) -> str:
|
|
529
|
+
inner = ", ".join(str(g) for g in self._grobs)
|
|
530
|
+
return f"({inner})"
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
# ---------------------------------------------------------------------------
|
|
534
|
+
# GTree
|
|
535
|
+
# ---------------------------------------------------------------------------
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
class GTree(Grob):
|
|
539
|
+
"""A grob that contains child grobs.
|
|
540
|
+
|
|
541
|
+
This is the Python equivalent of R's ``gTree`` S3 class. Children are
|
|
542
|
+
stored in an ordered dictionary keyed by name, with a separate
|
|
543
|
+
``children_order`` list controlling the draw order.
|
|
544
|
+
|
|
545
|
+
Parameters
|
|
546
|
+
----------
|
|
547
|
+
children : GList or None
|
|
548
|
+
Initial set of child grobs.
|
|
549
|
+
name : str or None
|
|
550
|
+
Unique name (auto-generated if ``None``).
|
|
551
|
+
gp : Gpar or None
|
|
552
|
+
Graphical parameters.
|
|
553
|
+
vp : object or None
|
|
554
|
+
Viewport.
|
|
555
|
+
children_order : list[str] or None
|
|
556
|
+
Explicit draw order; derived from *children* if ``None``.
|
|
557
|
+
_grid_class : str or None
|
|
558
|
+
R-style class tag.
|
|
559
|
+
**kwargs
|
|
560
|
+
Extra grob-specific fields.
|
|
561
|
+
"""
|
|
562
|
+
|
|
563
|
+
def __init__(
|
|
564
|
+
self,
|
|
565
|
+
children: Optional[GList] = None,
|
|
566
|
+
name: Optional[str] = None,
|
|
567
|
+
gp: Optional[Gpar] = None,
|
|
568
|
+
vp: Optional[Any] = None,
|
|
569
|
+
children_order: Optional[List[str]] = None,
|
|
570
|
+
_grid_class: Optional[str] = None,
|
|
571
|
+
**kwargs: Any,
|
|
572
|
+
) -> None:
|
|
573
|
+
# Initialise children storage *before* parent __init__ so that
|
|
574
|
+
# _validate can inspect it if needed.
|
|
575
|
+
self._children: OrderedDict[str, Grob] = OrderedDict()
|
|
576
|
+
self._children_order: list[str] = []
|
|
577
|
+
super().__init__(
|
|
578
|
+
name=name,
|
|
579
|
+
gp=gp,
|
|
580
|
+
vp=vp,
|
|
581
|
+
_grid_class=_grid_class or "gTree",
|
|
582
|
+
**kwargs,
|
|
583
|
+
)
|
|
584
|
+
# Populate children using the canonical setter
|
|
585
|
+
self._set_children_internal(children)
|
|
586
|
+
# Override order if explicitly provided
|
|
587
|
+
if children_order is not None:
|
|
588
|
+
self._children_order = list(children_order)
|
|
589
|
+
|
|
590
|
+
# -- internal helpers --------------------------------------------------
|
|
591
|
+
|
|
592
|
+
def _set_children_internal(self, children: Optional[GList]) -> None:
|
|
593
|
+
"""Populate children dict and order from a GList.
|
|
594
|
+
|
|
595
|
+
Duplicate names are disambiguated with numeric suffixes
|
|
596
|
+
(matching R's gTree behaviour).
|
|
597
|
+
"""
|
|
598
|
+
if children is not None and not isinstance(children, GList):
|
|
599
|
+
raise TypeError("'children' must be a GList or None")
|
|
600
|
+
self._children = OrderedDict()
|
|
601
|
+
self._children_order = []
|
|
602
|
+
if children is not None:
|
|
603
|
+
name_counts: dict = {}
|
|
604
|
+
for child in children:
|
|
605
|
+
if child is not None:
|
|
606
|
+
base = child.name
|
|
607
|
+
if base in self._children:
|
|
608
|
+
count = name_counts.get(base, 1)
|
|
609
|
+
while f"{base}.{count}" in self._children:
|
|
610
|
+
count += 1
|
|
611
|
+
name_counts[base] = count + 1
|
|
612
|
+
unique_name = f"{base}.{count}"
|
|
613
|
+
child = copy.copy(child)
|
|
614
|
+
child._name = unique_name
|
|
615
|
+
self._children[child.name] = child
|
|
616
|
+
self._children_order.append(child.name)
|
|
617
|
+
|
|
618
|
+
# -- child accessors (mirror R API) ------------------------------------
|
|
619
|
+
|
|
620
|
+
def get_children(self) -> GList:
|
|
621
|
+
"""Return a :class:`GList` of this tree's children.
|
|
622
|
+
|
|
623
|
+
Returns
|
|
624
|
+
-------
|
|
625
|
+
GList
|
|
626
|
+
"""
|
|
627
|
+
gl = GList.__new__(GList)
|
|
628
|
+
gl._grobs = [self._children[n] for n in self._children_order]
|
|
629
|
+
return gl
|
|
630
|
+
|
|
631
|
+
def set_children(self, gl: GList) -> None:
|
|
632
|
+
"""Replace all children with *gl*.
|
|
633
|
+
|
|
634
|
+
Parameters
|
|
635
|
+
----------
|
|
636
|
+
gl : GList
|
|
637
|
+
"""
|
|
638
|
+
self._set_children_internal(gl)
|
|
639
|
+
|
|
640
|
+
def n_children(self) -> int:
|
|
641
|
+
"""Return the number of children.
|
|
642
|
+
|
|
643
|
+
Returns
|
|
644
|
+
-------
|
|
645
|
+
int
|
|
646
|
+
"""
|
|
647
|
+
return len(self._children_order)
|
|
648
|
+
|
|
649
|
+
def add_child(self, child: Grob) -> None:
|
|
650
|
+
"""Add or replace a child grob.
|
|
651
|
+
|
|
652
|
+
If a child with the same name already exists, the old position is
|
|
653
|
+
removed and the child is **appended to the end** of the draw order.
|
|
654
|
+
This matches R's ``addToGTree`` (``grob.R:1208-1217``).
|
|
655
|
+
|
|
656
|
+
Parameters
|
|
657
|
+
----------
|
|
658
|
+
child : Grob
|
|
659
|
+
"""
|
|
660
|
+
if not isinstance(child, Grob):
|
|
661
|
+
raise TypeError("can only add a Grob to a GTree")
|
|
662
|
+
cname = child.name
|
|
663
|
+
self._children[cname] = child
|
|
664
|
+
# R: if (old.pos <- match(...)) childrenOrder <- childrenOrder[-old.pos]
|
|
665
|
+
# childrenOrder <- c(childrenOrder, child$name)
|
|
666
|
+
if cname in self._children_order:
|
|
667
|
+
self._children_order.remove(cname)
|
|
668
|
+
self._children_order.append(cname)
|
|
669
|
+
|
|
670
|
+
def remove_child(self, name: str) -> None:
|
|
671
|
+
"""Remove the child with the given *name*.
|
|
672
|
+
|
|
673
|
+
Parameters
|
|
674
|
+
----------
|
|
675
|
+
name : str
|
|
676
|
+
|
|
677
|
+
Raises
|
|
678
|
+
------
|
|
679
|
+
KeyError
|
|
680
|
+
If no child with *name* exists.
|
|
681
|
+
"""
|
|
682
|
+
if name not in self._children:
|
|
683
|
+
raise KeyError(f"child '{name}' not found")
|
|
684
|
+
del self._children[name]
|
|
685
|
+
self._children_order = [n for n in self._children_order if n != name]
|
|
686
|
+
|
|
687
|
+
def get_child(self, name: str) -> Grob:
|
|
688
|
+
"""Return the child with the given *name*.
|
|
689
|
+
|
|
690
|
+
Parameters
|
|
691
|
+
----------
|
|
692
|
+
name : str
|
|
693
|
+
|
|
694
|
+
Returns
|
|
695
|
+
-------
|
|
696
|
+
Grob
|
|
697
|
+
|
|
698
|
+
Raises
|
|
699
|
+
------
|
|
700
|
+
KeyError
|
|
701
|
+
If no child with *name* exists.
|
|
702
|
+
"""
|
|
703
|
+
if name not in self._children:
|
|
704
|
+
raise KeyError(f"child '{name}' not found")
|
|
705
|
+
return self._children[name]
|
|
706
|
+
|
|
707
|
+
def set_child(self, name: str, child: Grob) -> None:
|
|
708
|
+
"""Replace an existing child by name.
|
|
709
|
+
|
|
710
|
+
Parameters
|
|
711
|
+
----------
|
|
712
|
+
name : str
|
|
713
|
+
Name of the child to replace. Must exist.
|
|
714
|
+
child : Grob
|
|
715
|
+
Replacement grob. Its name should match *name*.
|
|
716
|
+
|
|
717
|
+
Raises
|
|
718
|
+
------
|
|
719
|
+
KeyError
|
|
720
|
+
If no child with *name* exists.
|
|
721
|
+
ValueError
|
|
722
|
+
If ``child.name != name``.
|
|
723
|
+
"""
|
|
724
|
+
if name not in self._children:
|
|
725
|
+
raise KeyError(f"child '{name}' not found")
|
|
726
|
+
if child.name != name:
|
|
727
|
+
raise ValueError(
|
|
728
|
+
f"new grob name ('{child.name}') does not match existing name ('{name}')"
|
|
729
|
+
)
|
|
730
|
+
self._children[name] = child
|
|
731
|
+
|
|
732
|
+
# -- override draw details for gTree -----------------------------------
|
|
733
|
+
|
|
734
|
+
def edit_details(self, **kwargs: Any) -> "GTree":
|
|
735
|
+
"""Disallow direct editing of ``children`` or ``children_order``.
|
|
736
|
+
|
|
737
|
+
Parameters
|
|
738
|
+
----------
|
|
739
|
+
**kwargs
|
|
740
|
+
The name-value pairs that were applied.
|
|
741
|
+
|
|
742
|
+
Returns
|
|
743
|
+
-------
|
|
744
|
+
GTree
|
|
745
|
+
|
|
746
|
+
Raises
|
|
747
|
+
------
|
|
748
|
+
ValueError
|
|
749
|
+
If ``"children"`` or ``"children_order"`` appear in *kwargs*.
|
|
750
|
+
"""
|
|
751
|
+
forbidden = {"children", "children_order", "_children", "_children_order"}
|
|
752
|
+
if forbidden.intersection(kwargs):
|
|
753
|
+
raise ValueError(
|
|
754
|
+
"it is invalid to directly edit the 'children' or "
|
|
755
|
+
"'children_order' slot; use add_child / remove_child instead"
|
|
756
|
+
)
|
|
757
|
+
return self
|
|
758
|
+
|
|
759
|
+
# -- repr --------------------------------------------------------------
|
|
760
|
+
|
|
761
|
+
def __repr__(self) -> str:
|
|
762
|
+
child_strs = ", ".join(self._children_order)
|
|
763
|
+
return f"{self._grid_class}[{self._name}]({child_strs})"
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
# ---------------------------------------------------------------------------
|
|
767
|
+
# GEdit / GEditList
|
|
768
|
+
# ---------------------------------------------------------------------------
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
class GEdit:
|
|
772
|
+
"""An edit specification storing parameter name-value pairs.
|
|
773
|
+
|
|
774
|
+
Mirrors R's ``gEdit()`` constructor.
|
|
775
|
+
|
|
776
|
+
Parameters
|
|
777
|
+
----------
|
|
778
|
+
**kwargs
|
|
779
|
+
Attribute names and their new values.
|
|
780
|
+
"""
|
|
781
|
+
|
|
782
|
+
__slots__ = ("_specs",)
|
|
783
|
+
|
|
784
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
785
|
+
self._specs: dict[str, Any] = dict(kwargs)
|
|
786
|
+
|
|
787
|
+
@property
|
|
788
|
+
def specs(self) -> dict[str, Any]:
|
|
789
|
+
"""The stored name-value pairs.
|
|
790
|
+
|
|
791
|
+
Returns
|
|
792
|
+
-------
|
|
793
|
+
dict[str, Any]
|
|
794
|
+
"""
|
|
795
|
+
return dict(self._specs)
|
|
796
|
+
|
|
797
|
+
def __repr__(self) -> str:
|
|
798
|
+
items = ", ".join(f"{k}={v!r}" for k, v in self._specs.items())
|
|
799
|
+
return f"GEdit({items})"
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
class GEditList:
|
|
803
|
+
"""A list of :class:`GEdit` objects.
|
|
804
|
+
|
|
805
|
+
Parameters
|
|
806
|
+
----------
|
|
807
|
+
*edits : GEdit
|
|
808
|
+
One or more edit specifications.
|
|
809
|
+
|
|
810
|
+
Raises
|
|
811
|
+
------
|
|
812
|
+
TypeError
|
|
813
|
+
If any element is not a :class:`GEdit`.
|
|
814
|
+
"""
|
|
815
|
+
|
|
816
|
+
__slots__ = ("_edits",)
|
|
817
|
+
|
|
818
|
+
def __init__(self, *edits: GEdit) -> None:
|
|
819
|
+
for e in edits:
|
|
820
|
+
if not isinstance(e, GEdit):
|
|
821
|
+
raise TypeError(f"GEditList can only contain GEdit objects, got {type(e).__name__}")
|
|
822
|
+
self._edits: tuple[GEdit, ...] = tuple(edits)
|
|
823
|
+
|
|
824
|
+
def __len__(self) -> int:
|
|
825
|
+
return len(self._edits)
|
|
826
|
+
|
|
827
|
+
def __iter__(self) -> Iterator[GEdit]:
|
|
828
|
+
return iter(self._edits)
|
|
829
|
+
|
|
830
|
+
def __repr__(self) -> str:
|
|
831
|
+
inner = ", ".join(repr(e) for e in self._edits)
|
|
832
|
+
return f"GEditList({inner})"
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
# ---------------------------------------------------------------------------
|
|
836
|
+
# Free functions -- construction helpers
|
|
837
|
+
# ---------------------------------------------------------------------------
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def grob_tree(*args: Grob, name: Optional[str] = None,
|
|
841
|
+
gp: Optional[Gpar] = None,
|
|
842
|
+
vp: Optional[Any] = None) -> GTree:
|
|
843
|
+
"""Convenience constructor for a :class:`GTree` wrapping *args*.
|
|
844
|
+
|
|
845
|
+
Parameters
|
|
846
|
+
----------
|
|
847
|
+
*args : Grob
|
|
848
|
+
Child grobs.
|
|
849
|
+
name : str or None
|
|
850
|
+
Name for the tree (auto-generated if ``None``).
|
|
851
|
+
gp : Gpar or None
|
|
852
|
+
Graphical parameters.
|
|
853
|
+
vp : object or None
|
|
854
|
+
Viewport.
|
|
855
|
+
|
|
856
|
+
Returns
|
|
857
|
+
-------
|
|
858
|
+
GTree
|
|
859
|
+
"""
|
|
860
|
+
return GTree(children=GList(*args), name=name, gp=gp, vp=vp)
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
# ---------------------------------------------------------------------------
|
|
864
|
+
# get / set / add / remove / edit
|
|
865
|
+
# ---------------------------------------------------------------------------
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def _resolve_path(path: Union[str, GPath]) -> GPath:
|
|
869
|
+
"""Ensure *path* is a :class:`GPath`."""
|
|
870
|
+
if isinstance(path, str):
|
|
871
|
+
return GPath(path)
|
|
872
|
+
if isinstance(path, GPath):
|
|
873
|
+
return path
|
|
874
|
+
raise TypeError(f"invalid path type: {type(path).__name__}")
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def _name_match(pattern: str, name: str, grep: bool) -> bool:
|
|
878
|
+
"""Check if *pattern* matches *name*.
|
|
879
|
+
|
|
880
|
+
Port of R ``grob.R:641-648 nameMatch()``.
|
|
881
|
+
When *grep* is ``True``, *pattern* is treated as a regex.
|
|
882
|
+
"""
|
|
883
|
+
if grep:
|
|
884
|
+
import re
|
|
885
|
+
return bool(re.search(pattern, name))
|
|
886
|
+
return pattern == name
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def _name_positions(pattern: str, names: list, grep: bool) -> list:
|
|
890
|
+
"""Return indices where *pattern* matches within *names*.
|
|
891
|
+
|
|
892
|
+
Port of R ``grob.R:653-662 namePos()``.
|
|
893
|
+
When *grep* is ``True``, may return multiple positions.
|
|
894
|
+
"""
|
|
895
|
+
if grep:
|
|
896
|
+
import re
|
|
897
|
+
return [i for i, n in enumerate(names) if re.search(pattern, n)]
|
|
898
|
+
else:
|
|
899
|
+
for i, n in enumerate(names):
|
|
900
|
+
if n == pattern:
|
|
901
|
+
return [i]
|
|
902
|
+
return []
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def get_grob(
|
|
906
|
+
gtree: GTree,
|
|
907
|
+
path: Union[str, GPath],
|
|
908
|
+
strict: bool = False,
|
|
909
|
+
grep: bool = False,
|
|
910
|
+
global_: bool = False,
|
|
911
|
+
) -> Union[Grob, GList]:
|
|
912
|
+
"""Retrieve a child grob from *gtree* by *path*.
|
|
913
|
+
|
|
914
|
+
Port of R ``getGrob()`` (grob.R:370-388).
|
|
915
|
+
|
|
916
|
+
Parameters
|
|
917
|
+
----------
|
|
918
|
+
gtree : GTree
|
|
919
|
+
The tree to search.
|
|
920
|
+
path : str or GPath
|
|
921
|
+
Name or path to the desired child.
|
|
922
|
+
strict : bool
|
|
923
|
+
If ``True``, path must match the full tree structure exactly.
|
|
924
|
+
grep : bool
|
|
925
|
+
If ``True``, use regex matching for names.
|
|
926
|
+
global_ : bool
|
|
927
|
+
If ``True``, return all matches as a :class:`GList`.
|
|
928
|
+
|
|
929
|
+
Returns
|
|
930
|
+
-------
|
|
931
|
+
Grob or GList
|
|
932
|
+
"""
|
|
933
|
+
if not isinstance(gtree, GTree):
|
|
934
|
+
raise TypeError("can only get a child from a GTree")
|
|
935
|
+
gpath = _resolve_path(path)
|
|
936
|
+
|
|
937
|
+
if gpath.n == 1 and not grep and not global_:
|
|
938
|
+
# Fast path: exact single-name lookup
|
|
939
|
+
return gtree.get_child(gpath.name)
|
|
940
|
+
|
|
941
|
+
if gpath.n == 1 and not grep and not global_:
|
|
942
|
+
# Fast path: exact single-name lookup
|
|
943
|
+
return gtree.get_child(gpath.name)
|
|
944
|
+
|
|
945
|
+
# Multi-depth without grep: walk the tree with proper errors
|
|
946
|
+
if not grep and not global_ and gpath.n > 1:
|
|
947
|
+
current: Grob = gtree
|
|
948
|
+
for component in gpath.components[:-1]:
|
|
949
|
+
if not isinstance(current, GTree):
|
|
950
|
+
raise KeyError(
|
|
951
|
+
f"'{component}' is not a GTree; cannot descend further (non-GTree)"
|
|
952
|
+
)
|
|
953
|
+
current = current.get_child(component)
|
|
954
|
+
if not isinstance(current, GTree):
|
|
955
|
+
raise KeyError(
|
|
956
|
+
f"cannot get child '{gpath.name}' from a non-GTree grob"
|
|
957
|
+
)
|
|
958
|
+
return current.get_child(gpath.name)
|
|
959
|
+
|
|
960
|
+
# General case: search children with grep/global support
|
|
961
|
+
results = _get_grob_from_gpath(gtree, None, gpath, strict, grep, global_)
|
|
962
|
+
|
|
963
|
+
if results is None:
|
|
964
|
+
raise KeyError(f"Grob '{path}' not found")
|
|
965
|
+
|
|
966
|
+
if global_ and isinstance(results, list):
|
|
967
|
+
return GList(*results)
|
|
968
|
+
return results
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
def _get_grob_from_gpath(grob, pathsofar, gpath, strict, grep, global_):
|
|
972
|
+
"""Recursive grob search -- port of R ``getGrobFromGPath`` (grob.R:758-862)."""
|
|
973
|
+
if isinstance(grob, GTree):
|
|
974
|
+
# Check if the gTree itself matches (depth 1 path)
|
|
975
|
+
if gpath.n == 1 and _name_match(gpath.name, grob.name, grep):
|
|
976
|
+
return grob
|
|
977
|
+
|
|
978
|
+
# Search children
|
|
979
|
+
found_list = []
|
|
980
|
+
for child_name in grob._children_order:
|
|
981
|
+
child = grob._children.get(child_name)
|
|
982
|
+
if child is None:
|
|
983
|
+
continue
|
|
984
|
+
|
|
985
|
+
if not strict and gpath.n == 1:
|
|
986
|
+
# Non-strict, single name: check children directly, then recurse
|
|
987
|
+
if _name_match(gpath.name, child_name, grep):
|
|
988
|
+
if not global_:
|
|
989
|
+
return child
|
|
990
|
+
found_list.append(child)
|
|
991
|
+
else:
|
|
992
|
+
# Recurse into child
|
|
993
|
+
result = _get_grob_from_gpath(
|
|
994
|
+
child, child_name, gpath, strict, grep, global_)
|
|
995
|
+
if result is not None:
|
|
996
|
+
if not global_:
|
|
997
|
+
return result
|
|
998
|
+
if isinstance(result, list):
|
|
999
|
+
found_list.extend(result)
|
|
1000
|
+
else:
|
|
1001
|
+
found_list.append(result)
|
|
1002
|
+
else:
|
|
1003
|
+
# Strict or multi-depth path
|
|
1004
|
+
if gpath.n == 1:
|
|
1005
|
+
if _name_match(gpath.name, child_name, grep):
|
|
1006
|
+
if not global_:
|
|
1007
|
+
return child
|
|
1008
|
+
found_list.append(child)
|
|
1009
|
+
elif isinstance(child, GTree):
|
|
1010
|
+
# Multi-depth: check if first path component matches
|
|
1011
|
+
if _name_match(gpath.components[0], child_name, grep):
|
|
1012
|
+
sub_path = GPath(*gpath.components[1:])
|
|
1013
|
+
result = _get_grob_from_gpath(
|
|
1014
|
+
child, child_name, sub_path, strict, grep, global_)
|
|
1015
|
+
if result is not None:
|
|
1016
|
+
if not global_:
|
|
1017
|
+
return result
|
|
1018
|
+
if isinstance(result, list):
|
|
1019
|
+
found_list.extend(result)
|
|
1020
|
+
else:
|
|
1021
|
+
found_list.append(result)
|
|
1022
|
+
|
|
1023
|
+
if found_list:
|
|
1024
|
+
return found_list if global_ else found_list[0]
|
|
1025
|
+
return None
|
|
1026
|
+
|
|
1027
|
+
elif isinstance(grob, Grob):
|
|
1028
|
+
if gpath.n == 1 and _name_match(gpath.name, grob.name, grep):
|
|
1029
|
+
return grob
|
|
1030
|
+
return None
|
|
1031
|
+
|
|
1032
|
+
return None
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def set_grob(gtree: GTree, path: Union[str, GPath], value: Grob) -> GTree:
|
|
1036
|
+
"""Return a copy of *gtree* with the child at *path* replaced by *value*.
|
|
1037
|
+
|
|
1038
|
+
Parameters
|
|
1039
|
+
----------
|
|
1040
|
+
gtree : GTree
|
|
1041
|
+
The tree to modify.
|
|
1042
|
+
path : str or GPath
|
|
1043
|
+
Name or path identifying the child to replace.
|
|
1044
|
+
value : Grob
|
|
1045
|
+
The replacement grob.
|
|
1046
|
+
|
|
1047
|
+
Returns
|
|
1048
|
+
-------
|
|
1049
|
+
GTree
|
|
1050
|
+
A shallow copy with the replacement applied.
|
|
1051
|
+
|
|
1052
|
+
Raises
|
|
1053
|
+
------
|
|
1054
|
+
TypeError
|
|
1055
|
+
If *gtree* is not a :class:`GTree` or *value* is not a :class:`Grob`.
|
|
1056
|
+
KeyError
|
|
1057
|
+
If the path does not identify an existing child.
|
|
1058
|
+
"""
|
|
1059
|
+
if not isinstance(gtree, GTree):
|
|
1060
|
+
raise TypeError("can only set a child on a GTree")
|
|
1061
|
+
if not isinstance(value, Grob):
|
|
1062
|
+
raise TypeError("replacement must be a Grob")
|
|
1063
|
+
gpath = _resolve_path(path)
|
|
1064
|
+
result = copy.copy(gtree)
|
|
1065
|
+
result._children = OrderedDict(gtree._children)
|
|
1066
|
+
result._children_order = list(gtree._children_order)
|
|
1067
|
+
|
|
1068
|
+
if gpath.n == 1:
|
|
1069
|
+
if gpath.name not in result._children:
|
|
1070
|
+
raise KeyError(f"child '{gpath.name}' not found")
|
|
1071
|
+
if value.name != gpath.name:
|
|
1072
|
+
raise ValueError(
|
|
1073
|
+
f"new grob name ('{value.name}') does not match path ('{gpath.name}')"
|
|
1074
|
+
)
|
|
1075
|
+
result._children[gpath.name] = value
|
|
1076
|
+
return result
|
|
1077
|
+
# Multi-depth: recursively copy inner gTrees
|
|
1078
|
+
first = gpath.components[0]
|
|
1079
|
+
rest = GPath(*gpath.components[1:])
|
|
1080
|
+
child = result._children.get(first)
|
|
1081
|
+
if child is None:
|
|
1082
|
+
raise KeyError(f"child '{first}' not found")
|
|
1083
|
+
if not isinstance(child, GTree):
|
|
1084
|
+
raise TypeError(f"child '{first}' is not a GTree; cannot descend further")
|
|
1085
|
+
result._children[first] = set_grob(child, rest, value)
|
|
1086
|
+
return result
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
def add_grob(gtree: GTree, child: Grob, name: Optional[str] = None) -> GTree:
|
|
1090
|
+
"""Return a copy of *gtree* with *child* added.
|
|
1091
|
+
|
|
1092
|
+
Parameters
|
|
1093
|
+
----------
|
|
1094
|
+
gtree : GTree
|
|
1095
|
+
The tree to add to.
|
|
1096
|
+
child : Grob
|
|
1097
|
+
The child to add.
|
|
1098
|
+
name : str or None
|
|
1099
|
+
Override name for the child (uses ``child.name`` if ``None``).
|
|
1100
|
+
|
|
1101
|
+
Returns
|
|
1102
|
+
-------
|
|
1103
|
+
GTree
|
|
1104
|
+
A shallow copy with the new child appended.
|
|
1105
|
+
"""
|
|
1106
|
+
if not isinstance(gtree, GTree):
|
|
1107
|
+
raise TypeError("can only add a child to a GTree")
|
|
1108
|
+
if not isinstance(child, Grob):
|
|
1109
|
+
raise TypeError("child must be a Grob")
|
|
1110
|
+
result = copy.copy(gtree)
|
|
1111
|
+
result._children = OrderedDict(gtree._children)
|
|
1112
|
+
result._children_order = list(gtree._children_order)
|
|
1113
|
+
if name is not None:
|
|
1114
|
+
child = copy.copy(child)
|
|
1115
|
+
child.name = name
|
|
1116
|
+
result.add_child(child)
|
|
1117
|
+
return result
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
def remove_grob(gtree: GTree, name: str) -> GTree:
|
|
1121
|
+
"""Return a copy of *gtree* with the named child removed.
|
|
1122
|
+
|
|
1123
|
+
Parameters
|
|
1124
|
+
----------
|
|
1125
|
+
gtree : GTree
|
|
1126
|
+
The tree to modify.
|
|
1127
|
+
name : str
|
|
1128
|
+
Name of the child to remove.
|
|
1129
|
+
|
|
1130
|
+
Returns
|
|
1131
|
+
-------
|
|
1132
|
+
GTree
|
|
1133
|
+
A shallow copy with the child removed.
|
|
1134
|
+
"""
|
|
1135
|
+
if not isinstance(gtree, GTree):
|
|
1136
|
+
raise TypeError("can only remove a child from a GTree")
|
|
1137
|
+
result = copy.copy(gtree)
|
|
1138
|
+
result._children = OrderedDict(gtree._children)
|
|
1139
|
+
result._children_order = list(gtree._children_order)
|
|
1140
|
+
result.remove_child(name)
|
|
1141
|
+
return result
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
def _edit_this_grob(grob: Grob, specs: dict[str, Any]) -> Grob:
|
|
1145
|
+
"""Apply *specs* to *grob* in place and revalidate (internal).
|
|
1146
|
+
|
|
1147
|
+
Parameters
|
|
1148
|
+
----------
|
|
1149
|
+
grob : Grob
|
|
1150
|
+
specs : dict
|
|
1151
|
+
|
|
1152
|
+
Returns
|
|
1153
|
+
-------
|
|
1154
|
+
Grob
|
|
1155
|
+
"""
|
|
1156
|
+
for key, value in specs.items():
|
|
1157
|
+
if not key:
|
|
1158
|
+
continue
|
|
1159
|
+
if key == "gp":
|
|
1160
|
+
# Special handling: merge gpar
|
|
1161
|
+
if value is None:
|
|
1162
|
+
grob._gp = None
|
|
1163
|
+
elif grob._gp is not None:
|
|
1164
|
+
# Merge new gp on top of existing
|
|
1165
|
+
grob._gp = grob._gp.merge(value) if hasattr(grob._gp, "merge") else value
|
|
1166
|
+
else:
|
|
1167
|
+
grob._gp = value
|
|
1168
|
+
elif key == "name":
|
|
1169
|
+
grob._name = str(value) if value is not None else None
|
|
1170
|
+
elif key == "vp":
|
|
1171
|
+
grob._vp = Grob._check_vp(value)
|
|
1172
|
+
elif hasattr(grob, key) or hasattr(grob, f"_{key}"):
|
|
1173
|
+
setattr(grob, key, value)
|
|
1174
|
+
else:
|
|
1175
|
+
warnings.warn(f"slot '{key}' not found", stacklevel=3)
|
|
1176
|
+
# Re-validate
|
|
1177
|
+
grob.valid_details()
|
|
1178
|
+
grob.edit_details(**specs)
|
|
1179
|
+
return grob
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
def edit_grob(grob: Grob, **kwargs: Any) -> Grob:
|
|
1183
|
+
"""Return an edited copy of *grob*.
|
|
1184
|
+
|
|
1185
|
+
Parameters
|
|
1186
|
+
----------
|
|
1187
|
+
grob : Grob
|
|
1188
|
+
The grob to edit.
|
|
1189
|
+
**kwargs
|
|
1190
|
+
Attribute name-value pairs to update.
|
|
1191
|
+
|
|
1192
|
+
Returns
|
|
1193
|
+
-------
|
|
1194
|
+
Grob
|
|
1195
|
+
A (deep) copy with the edits applied.
|
|
1196
|
+
"""
|
|
1197
|
+
result = copy.deepcopy(grob)
|
|
1198
|
+
return _edit_this_grob(result, kwargs)
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
def force_grob(grob: Grob) -> Grob:
|
|
1202
|
+
"""Force evaluation of a (possibly delayed) grob.
|
|
1203
|
+
|
|
1204
|
+
This calls :meth:`~Grob.make_context` and :meth:`~Grob.make_content`
|
|
1205
|
+
without actually drawing, capturing any modifications those hooks make.
|
|
1206
|
+
If the grob is unchanged, the original object is returned.
|
|
1207
|
+
|
|
1208
|
+
Parameters
|
|
1209
|
+
----------
|
|
1210
|
+
grob : Grob
|
|
1211
|
+
The grob to force.
|
|
1212
|
+
|
|
1213
|
+
Returns
|
|
1214
|
+
-------
|
|
1215
|
+
Grob
|
|
1216
|
+
The forced grob, possibly with a ``_original`` attribute storing
|
|
1217
|
+
the pre-force state.
|
|
1218
|
+
"""
|
|
1219
|
+
original = grob
|
|
1220
|
+
x = copy.deepcopy(grob)
|
|
1221
|
+
x = x.make_context()
|
|
1222
|
+
x = x.make_content()
|
|
1223
|
+
# For gTree, also force children
|
|
1224
|
+
if isinstance(x, GTree):
|
|
1225
|
+
forced_children: list[Grob] = []
|
|
1226
|
+
for name in x._children_order:
|
|
1227
|
+
forced_children.append(force_grob(x._children[name]))
|
|
1228
|
+
x._set_children_internal(GList(*forced_children))
|
|
1229
|
+
# If anything changed, stash the original
|
|
1230
|
+
# (We can't do an identity check after deepcopy, so we always store.)
|
|
1231
|
+
x._original = original # type: ignore[attr-defined]
|
|
1232
|
+
return x
|
|
1233
|
+
|
|
1234
|
+
|
|
1235
|
+
def set_children(gtree: GTree, children: GList) -> GTree:
|
|
1236
|
+
"""Return a copy of *gtree* with its children replaced.
|
|
1237
|
+
|
|
1238
|
+
Parameters
|
|
1239
|
+
----------
|
|
1240
|
+
gtree : GTree
|
|
1241
|
+
The tree to modify.
|
|
1242
|
+
children : GList
|
|
1243
|
+
The new children.
|
|
1244
|
+
|
|
1245
|
+
Returns
|
|
1246
|
+
-------
|
|
1247
|
+
GTree
|
|
1248
|
+
A shallow copy with the new children set.
|
|
1249
|
+
"""
|
|
1250
|
+
if not isinstance(gtree, GTree):
|
|
1251
|
+
raise TypeError("can only set children on a GTree")
|
|
1252
|
+
result = copy.copy(gtree)
|
|
1253
|
+
result._set_children_internal(children)
|
|
1254
|
+
return result
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
def reorder_grob(gtree: GTree, order: Union[List[int], List[str]],
|
|
1258
|
+
back: bool = True) -> GTree:
|
|
1259
|
+
"""Return a copy of *gtree* with children reordered.
|
|
1260
|
+
|
|
1261
|
+
Parameters
|
|
1262
|
+
----------
|
|
1263
|
+
gtree : GTree
|
|
1264
|
+
The tree to reorder.
|
|
1265
|
+
order : list[int] or list[str]
|
|
1266
|
+
Indices (0-based) or names specifying the new front-of-order.
|
|
1267
|
+
back : bool
|
|
1268
|
+
If ``True`` (default), the specified children come first (back,
|
|
1269
|
+
i.e. drawn first / behind); unspecified children are appended.
|
|
1270
|
+
If ``False``, unspecified children come first; specified are appended
|
|
1271
|
+
(drawn last / in front).
|
|
1272
|
+
|
|
1273
|
+
Returns
|
|
1274
|
+
-------
|
|
1275
|
+
GTree
|
|
1276
|
+
A shallow copy with reordered ``children_order``.
|
|
1277
|
+
|
|
1278
|
+
Raises
|
|
1279
|
+
------
|
|
1280
|
+
ValueError
|
|
1281
|
+
If *order* contains invalid names or indices.
|
|
1282
|
+
"""
|
|
1283
|
+
if not isinstance(gtree, GTree):
|
|
1284
|
+
raise TypeError("can only reorder children of a GTree")
|
|
1285
|
+
result = copy.copy(gtree)
|
|
1286
|
+
result._children = OrderedDict(gtree._children)
|
|
1287
|
+
old_order = list(gtree._children_order)
|
|
1288
|
+
n = len(old_order)
|
|
1289
|
+
|
|
1290
|
+
# Deduplicate while preserving order
|
|
1291
|
+
seen: set[Any] = set()
|
|
1292
|
+
unique_order: list[Any] = []
|
|
1293
|
+
for o in order:
|
|
1294
|
+
if o not in seen:
|
|
1295
|
+
unique_order.append(o)
|
|
1296
|
+
seen.add(o)
|
|
1297
|
+
|
|
1298
|
+
# Convert to integer indices
|
|
1299
|
+
int_indices: list[int] = []
|
|
1300
|
+
for o in unique_order:
|
|
1301
|
+
if isinstance(o, str):
|
|
1302
|
+
try:
|
|
1303
|
+
idx = old_order.index(o)
|
|
1304
|
+
except ValueError:
|
|
1305
|
+
raise ValueError(f"child name '{o}' not found in children_order")
|
|
1306
|
+
int_indices.append(idx)
|
|
1307
|
+
elif isinstance(o, int):
|
|
1308
|
+
if o < 0 or o >= n:
|
|
1309
|
+
raise ValueError(f"index {o} out of range [0, {n})")
|
|
1310
|
+
int_indices.append(o)
|
|
1311
|
+
else:
|
|
1312
|
+
raise TypeError(f"order elements must be int or str, got {type(o).__name__}")
|
|
1313
|
+
|
|
1314
|
+
specified = [old_order[i] for i in int_indices]
|
|
1315
|
+
rest = [old_order[i] for i in range(n) if i not in set(int_indices)]
|
|
1316
|
+
|
|
1317
|
+
if back:
|
|
1318
|
+
new_order = specified + rest
|
|
1319
|
+
else:
|
|
1320
|
+
new_order = rest + specified
|
|
1321
|
+
|
|
1322
|
+
result._children_order = new_order
|
|
1323
|
+
return result
|
|
1324
|
+
|
|
1325
|
+
|
|
1326
|
+
# ---------------------------------------------------------------------------
|
|
1327
|
+
# apply_edit / apply_edits
|
|
1328
|
+
# ---------------------------------------------------------------------------
|
|
1329
|
+
|
|
1330
|
+
|
|
1331
|
+
def apply_edit(grob: Grob, edit: Optional[GEdit]) -> Grob:
|
|
1332
|
+
"""Apply a single :class:`GEdit` to *grob*.
|
|
1333
|
+
|
|
1334
|
+
Parameters
|
|
1335
|
+
----------
|
|
1336
|
+
grob : Grob
|
|
1337
|
+
The target grob.
|
|
1338
|
+
edit : GEdit or None
|
|
1339
|
+
The edit to apply. ``None`` is a no-op.
|
|
1340
|
+
|
|
1341
|
+
Returns
|
|
1342
|
+
-------
|
|
1343
|
+
Grob
|
|
1344
|
+
An edited copy (or the original if *edit* is ``None``).
|
|
1345
|
+
"""
|
|
1346
|
+
if edit is None:
|
|
1347
|
+
return grob
|
|
1348
|
+
if not isinstance(edit, GEdit):
|
|
1349
|
+
raise TypeError("invalid edit: expected GEdit")
|
|
1350
|
+
return edit_grob(grob, **edit._specs)
|
|
1351
|
+
|
|
1352
|
+
|
|
1353
|
+
def apply_edits(grob: Grob, edits: Optional[Union[GEdit, GEditList]]) -> Grob:
|
|
1354
|
+
"""Apply one or more edits to *grob*.
|
|
1355
|
+
|
|
1356
|
+
Parameters
|
|
1357
|
+
----------
|
|
1358
|
+
grob : Grob
|
|
1359
|
+
The target grob.
|
|
1360
|
+
edits : GEdit, GEditList, or None
|
|
1361
|
+
The edit(s) to apply. ``None`` is a no-op.
|
|
1362
|
+
|
|
1363
|
+
Returns
|
|
1364
|
+
-------
|
|
1365
|
+
Grob
|
|
1366
|
+
An edited copy (or the original if *edits* is ``None``).
|
|
1367
|
+
"""
|
|
1368
|
+
if edits is None:
|
|
1369
|
+
return grob
|
|
1370
|
+
if isinstance(edits, GEdit):
|
|
1371
|
+
return apply_edit(grob, edits)
|
|
1372
|
+
if isinstance(edits, GEditList):
|
|
1373
|
+
result = grob
|
|
1374
|
+
for e in edits:
|
|
1375
|
+
result = apply_edits(result, e)
|
|
1376
|
+
return result
|
|
1377
|
+
raise TypeError("invalid edits: expected GEdit, GEditList, or None")
|