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