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/_state.py ADDED
@@ -0,0 +1,683 @@
1
+ """Global state management for grid_py (port of R's grid C-level state).
2
+
3
+ This module provides the :class:`GridState` singleton that manages the
4
+ viewport tree, display list, graphical-parameter inheritance stack, and
5
+ the binding to a rendering backend (:class:`GridRenderer` subclass).
6
+ It replaces the C-level ``GridState`` struct found in R's *grid* package.
7
+
8
+ .. note::
9
+ Viewport classes are **not** imported here to avoid circular
10
+ dependencies. Viewport references are stored and manipulated via
11
+ duck typing (any object with ``name``, ``parent``, ``children``, and
12
+ ``layout_pos`` attributes is accepted).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import copy
18
+ from collections import deque
19
+ from typing import Any, Deque, Dict, List, Optional, Sequence, Tuple, Union
20
+
21
+ import numpy as np
22
+
23
+ from ._gpar import Gpar
24
+ from ._display_list import DisplayList
25
+
26
+ __all__ = ["GridState", "get_state"]
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Default device dimensions (≈ 7 in ≈ 17.78 cm)
30
+ # ---------------------------------------------------------------------------
31
+
32
+ _DEFAULT_DEVICE_WIDTH_CM: float = 17.78
33
+ _DEFAULT_DEVICE_HEIGHT_CM: float = 17.78
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Helpers
38
+ # ---------------------------------------------------------------------------
39
+
40
+ def _make_root_viewport() -> Any:
41
+ """Create a minimal root viewport dict (duck-typed).
42
+
43
+ A "real" viewport object will replace this once
44
+ :meth:`GridState.reset` or :meth:`GridState.push_viewport` is
45
+ called with an actual viewport instance. The dict is used only as
46
+ the initial sentinel so that the tree is never ``None``.
47
+
48
+ Returns
49
+ -------
50
+ dict
51
+ A mapping that quacks like a viewport for bootstrap purposes.
52
+ """
53
+ return {
54
+ "name": "ROOT",
55
+ "parent": None,
56
+ "children": [],
57
+ "layout_pos": None,
58
+ "gpar": Gpar(),
59
+ "rotation": 0.0,
60
+ "transform": np.eye(3, dtype=np.float64),
61
+ }
62
+
63
+
64
+ def _vp_attr(vp: Any, attr: str, default: Any = None) -> Any:
65
+ """Retrieve an attribute from a viewport, supporting both objects and dicts.
66
+
67
+ Parameters
68
+ ----------
69
+ vp : Any
70
+ Viewport object or dict.
71
+ attr : str
72
+ Attribute / key name.
73
+ default : Any, optional
74
+ Fallback value.
75
+
76
+ Returns
77
+ -------
78
+ Any
79
+ """
80
+ if isinstance(vp, dict):
81
+ return vp.get(attr, default)
82
+ return getattr(vp, attr, default)
83
+
84
+
85
+ def _vp_set_attr(vp: Any, attr: str, value: Any) -> None:
86
+ """Set an attribute on a viewport, supporting both objects and dicts.
87
+
88
+ Parameters
89
+ ----------
90
+ vp : Any
91
+ Viewport object or dict.
92
+ attr : str
93
+ Attribute / key name.
94
+ value : Any
95
+ Value to assign.
96
+ """
97
+ if isinstance(vp, dict):
98
+ vp[attr] = value
99
+ else:
100
+ setattr(vp, attr, value)
101
+
102
+
103
+ def _vp_children(vp: Any) -> list:
104
+ """Return the children list of a viewport.
105
+
106
+ Parameters
107
+ ----------
108
+ vp : Any
109
+ Viewport object or dict.
110
+
111
+ Returns
112
+ -------
113
+ list
114
+ The children list (never ``None``). If the attribute is absent
115
+ or ``None``, an empty list is returned.
116
+ """
117
+ result = _vp_attr(vp, "children", None)
118
+ if result is None:
119
+ # Initialise an empty list on the viewport so future appends persist.
120
+ result = []
121
+ _vp_set_attr(vp, "children", result)
122
+ return result
123
+
124
+
125
+ def _vp_name(vp: Any) -> str:
126
+ """Return the name of a viewport.
127
+
128
+ Parameters
129
+ ----------
130
+ vp : Any
131
+ Viewport object or dict.
132
+
133
+ Returns
134
+ -------
135
+ str
136
+ """
137
+ return _vp_attr(vp, "name", "")
138
+
139
+
140
+ def _vp_parent(vp: Any) -> Any:
141
+ """Return the parent of a viewport.
142
+
143
+ Parameters
144
+ ----------
145
+ vp : Any
146
+ Viewport object or dict.
147
+
148
+ Returns
149
+ -------
150
+ Any
151
+ """
152
+ return _vp_attr(vp, "parent", None)
153
+
154
+
155
+ # ---------------------------------------------------------------------------
156
+ # GridState
157
+ # ---------------------------------------------------------------------------
158
+
159
+ class GridState:
160
+ """Singleton holding the global grid graphics state.
161
+
162
+ Manages the viewport tree, display list, graphical-parameter
163
+ inheritance stack, and the connection to a rendering backend
164
+ (``Figure`` / ``Axes``).
165
+
166
+ Attributes
167
+ ----------
168
+ _vp_tree : Any
169
+ The root viewport (pushed viewport representing the device).
170
+ _current_vp : Any
171
+ Reference to the currently active viewport.
172
+ _display_list : list[Any]
173
+ Recorded drawing operations.
174
+ _dl_on : bool
175
+ Whether display-list recording is enabled.
176
+ _gpar_stack : list[Gpar]
177
+ Stack of graphical parameter objects for inheritance.
178
+ _device_width_cm : float
179
+ Device width in centimetres.
180
+ _device_height_cm : float
181
+ Device height in centimetres.
182
+ _renderer : Optional[Any]
183
+ :class:`GridRenderer` subclass instance (or ``None``).
184
+
185
+ Examples
186
+ --------
187
+ >>> state = GridState()
188
+ >>> state.current_viewport()["name"]
189
+ 'ROOT'
190
+ """
191
+
192
+ # ---- class-level singleton bookkeeping --------------------------------
193
+
194
+ _instance: Optional["GridState"] = None
195
+
196
+ def __new__(cls) -> "GridState":
197
+ if cls._instance is None:
198
+ inst = super().__new__(cls)
199
+ inst._initialized = False
200
+ cls._instance = inst
201
+ return cls._instance
202
+
203
+ # ---- initialisation ---------------------------------------------------
204
+
205
+ def __init__(self) -> None:
206
+ if self._initialized:
207
+ return
208
+ self._initialized: bool = True
209
+ self._init_defaults()
210
+
211
+ def _init_defaults(self) -> None:
212
+ """Set every slot to its default value."""
213
+ self._vp_tree: Any = _make_root_viewport()
214
+ self._current_vp: Any = self._vp_tree
215
+ self._display_list: DisplayList = DisplayList()
216
+ self._dl_on: bool = True
217
+ self._gpar_stack: List[Gpar] = [Gpar()]
218
+ self._device_width_cm: float = _DEFAULT_DEVICE_WIDTH_CM
219
+ self._device_height_cm: float = _DEFAULT_DEVICE_HEIGHT_CM
220
+ self._renderer: Optional[Any] = None
221
+ # GSS_SCALE: zoom factor for physical units (R unit.c:804-814)
222
+ # R grid state slot 15. Default 1.0, set by grid.newpage(zoom=).
223
+ self._scale: float = 1.0
224
+ # GSS_GROUPS: group registry for define/use (R grid.h:63, state.c:51)
225
+ # Maps group name → dict with keys: ref, xy, xyin, wh, r, etc.
226
+ self._groups: Dict[str, Any] = {}
227
+
228
+ # ---- reset ------------------------------------------------------------
229
+
230
+ def reset(self) -> None:
231
+ """Clear all state and reinitialise to the default root viewport.
232
+
233
+ This is the equivalent of ``grid.newpage()`` in R.
234
+ """
235
+ self._init_defaults()
236
+
237
+ # ---- viewport tree manipulation ---------------------------------------
238
+
239
+ def push_viewport(self, vp: Any) -> None:
240
+ """Push a viewport onto the tree as a child of the current viewport.
241
+
242
+ Parameters
243
+ ----------
244
+ vp : Any
245
+ A viewport-like object. Must expose ``name``, ``parent``,
246
+ and ``children`` attributes (or dict keys).
247
+ """
248
+ _vp_set_attr(vp, "parent", self._current_vp)
249
+ children = _vp_children(self._current_vp)
250
+ if children is None:
251
+ children = []
252
+ _vp_set_attr(self._current_vp, "children", children)
253
+ children.append(vp)
254
+ self._current_vp = vp
255
+
256
+ def pop_viewport(self, n: int = 1) -> None:
257
+ """Pop *n* viewports, navigating back toward the root.
258
+
259
+ Parameters
260
+ ----------
261
+ n : int, optional
262
+ Number of levels to pop (default ``1``). If *n* equals ``0``
263
+ the navigation returns to the root viewport.
264
+
265
+ Raises
266
+ ------
267
+ ValueError
268
+ If *n* is negative or greater than the current depth.
269
+ """
270
+ if n < 0:
271
+ raise ValueError(f"'n' must be non-negative, got {n}")
272
+ if n == 0:
273
+ # Pop to root.
274
+ self._current_vp = self._vp_tree
275
+ return
276
+ for _ in range(n):
277
+ parent = _vp_parent(self._current_vp)
278
+ if parent is None:
279
+ raise ValueError(
280
+ "Cannot pop past the root viewport."
281
+ )
282
+ # Remove the current viewport from its parent's children.
283
+ parent_children = _vp_children(parent)
284
+ try:
285
+ parent_children.remove(self._current_vp)
286
+ except ValueError:
287
+ pass
288
+ self._current_vp = parent
289
+
290
+ def up_viewport(self, n: int = 1) -> None:
291
+ """Navigate up *n* levels without removing viewports from the tree.
292
+
293
+ Parameters
294
+ ----------
295
+ n : int, optional
296
+ Number of levels to ascend (default ``1``). ``0`` navigates
297
+ to the root.
298
+
299
+ Raises
300
+ ------
301
+ ValueError
302
+ If *n* is negative or exceeds the current depth.
303
+ """
304
+ if n < 0:
305
+ raise ValueError(f"'n' must be non-negative, got {n}")
306
+ if n == 0:
307
+ self._current_vp = self._vp_tree
308
+ return
309
+ for _ in range(n):
310
+ parent = _vp_parent(self._current_vp)
311
+ if parent is None:
312
+ raise ValueError(
313
+ "Cannot navigate above the root viewport."
314
+ )
315
+ self._current_vp = parent
316
+
317
+ def down_viewport(self, name: str, strict: bool = False) -> int:
318
+ """Navigate down to a named viewport (breadth-first search).
319
+
320
+ The search starts from the **children** of the current viewport.
321
+
322
+ Parameters
323
+ ----------
324
+ name : str
325
+ Name of the target viewport.
326
+ strict : bool, optional
327
+ If ``True`` the name must match exactly; otherwise a
328
+ case-insensitive match is attempted after an exact match
329
+ fails.
330
+
331
+ Returns
332
+ -------
333
+ int
334
+ The depth (number of levels descended) to reach the target.
335
+
336
+ Raises
337
+ ------
338
+ LookupError
339
+ If no matching viewport is found.
340
+ """
341
+ depth = self._search_down(self._current_vp, name, strict)
342
+ if depth is None:
343
+ raise LookupError(
344
+ f"Viewport '{name}' not found below the current viewport."
345
+ )
346
+ return depth
347
+
348
+ def _search_down(
349
+ self, start: Any, name: str, strict: bool
350
+ ) -> Optional[int]:
351
+ """BFS helper for :meth:`down_viewport`.
352
+
353
+ Returns the depth on success, or ``None`` on failure. As a
354
+ side-effect, ``_current_vp`` is updated to point to the found
355
+ viewport.
356
+ """
357
+ queue: Deque[Tuple[Any, int]] = deque()
358
+ for child in _vp_children(start):
359
+ queue.append((child, 1))
360
+
361
+ while queue:
362
+ vp, d = queue.popleft()
363
+ vp_n = _vp_name(vp)
364
+ if vp_n == name or (not strict and vp_n.lower() == name.lower()):
365
+ self._current_vp = vp
366
+ return d
367
+ for child in _vp_children(vp):
368
+ queue.append((child, d + 1))
369
+ return None
370
+
371
+ def seek_viewport(self, name: str) -> int:
372
+ """Global search for a named viewport starting from the root.
373
+
374
+ If found, ``_current_vp`` is set to the matching viewport and
375
+ the absolute depth from the root is returned.
376
+
377
+ Parameters
378
+ ----------
379
+ name : str
380
+ Viewport name to search for.
381
+
382
+ Returns
383
+ -------
384
+ int
385
+ Depth from the root to the found viewport.
386
+
387
+ Raises
388
+ ------
389
+ LookupError
390
+ If no matching viewport is found anywhere in the tree.
391
+ """
392
+ result = self._search_down(self._vp_tree, name, strict=False)
393
+ if result is None:
394
+ raise LookupError(
395
+ f"Viewport '{name}' not found in the viewport tree."
396
+ )
397
+ return result
398
+
399
+ # ---- viewport queries -------------------------------------------------
400
+
401
+ def current_viewport(self) -> Any:
402
+ """Return the currently active viewport.
403
+
404
+ Returns
405
+ -------
406
+ Any
407
+ The active viewport object (or dict).
408
+ """
409
+ return self._current_vp
410
+
411
+ def current_vp_path(self) -> str:
412
+ """Return the ``/``-separated path from root to the current viewport.
413
+
414
+ Returns
415
+ -------
416
+ str
417
+ E.g. ``"ROOT/panel/strip"``.
418
+ """
419
+ parts: List[str] = []
420
+ vp: Any = self._current_vp
421
+ while vp is not None:
422
+ parts.append(_vp_name(vp))
423
+ vp = _vp_parent(vp)
424
+ parts.reverse()
425
+ return "/".join(parts)
426
+
427
+ def current_vp_tree(self) -> Any:
428
+ """Return the root of the entire viewport tree.
429
+
430
+ Returns
431
+ -------
432
+ Any
433
+ """
434
+ return self._vp_tree
435
+
436
+ def current_transform(self) -> np.ndarray:
437
+ """Return the cumulative 3x3 transformation matrix for the current viewport.
438
+
439
+ The matrix is accumulated by multiplying transforms from the
440
+ root down to the current viewport.
441
+
442
+ Returns
443
+ -------
444
+ numpy.ndarray
445
+ A 3x3 ``float64`` transformation matrix.
446
+ """
447
+ matrices: List[np.ndarray] = []
448
+ vp: Any = self._current_vp
449
+ while vp is not None:
450
+ t = _vp_attr(vp, "transform", None)
451
+ if t is not None:
452
+ matrices.append(np.asarray(t, dtype=np.float64))
453
+ vp = _vp_parent(vp)
454
+ matrices.reverse()
455
+
456
+ result = np.eye(3, dtype=np.float64)
457
+ for m in matrices:
458
+ result = result @ m
459
+ return result
460
+
461
+ def current_rotation(self) -> float:
462
+ """Return the cumulative rotation angle (degrees) at the current viewport.
463
+
464
+ Returns
465
+ -------
466
+ float
467
+ The sum of ``rotation`` attributes from root to current viewport.
468
+ """
469
+ total: float = 0.0
470
+ vp: Any = self._current_vp
471
+ while vp is not None:
472
+ total += float(_vp_attr(vp, "rotation", 0.0))
473
+ vp = _vp_parent(vp)
474
+ return total
475
+
476
+ def current_parent(self) -> Any:
477
+ """Return the parent of the current viewport.
478
+
479
+ Returns
480
+ -------
481
+ Any
482
+ Parent viewport, or ``None`` if at the root.
483
+ """
484
+ return _vp_parent(self._current_vp)
485
+
486
+ # ---- gpar management --------------------------------------------------
487
+
488
+ def get_gpar(self) -> Gpar:
489
+ """Return the current (top-of-stack) graphical parameters.
490
+
491
+ Returns
492
+ -------
493
+ Gpar
494
+ """
495
+ return self._gpar_stack[-1]
496
+
497
+ def set_gpar(self, gp: Gpar) -> None:
498
+ """Push a :class:`Gpar` onto the parameter stack.
499
+
500
+ Parameters
501
+ ----------
502
+ gp : Gpar
503
+ Graphical parameters to make current.
504
+ """
505
+ self._gpar_stack.append(gp)
506
+
507
+ def replace_gpar(self, gp: Gpar) -> None:
508
+ """Replace the current (top-of-stack) graphical parameters.
509
+
510
+ Unlike :meth:`set_gpar`, this does **not** grow the stack; it
511
+ overwrites the most-recent entry. This mirrors R's
512
+ ``C_setGPar`` which is a simple slot replacement on the device
513
+ state, used by viewport push/pop/up/down to update gpar without
514
+ creating a new stack frame.
515
+ """
516
+ self._gpar_stack[-1] = gp
517
+
518
+ # ---- display list -----------------------------------------------------
519
+
520
+ @property
521
+ def display_list(self) -> DisplayList:
522
+ """Return the current :class:`DisplayList` object.
523
+
524
+ Returns
525
+ -------
526
+ DisplayList
527
+ """
528
+ return self._display_list
529
+
530
+ @display_list.setter
531
+ def display_list(self, value: DisplayList) -> None:
532
+ """Replace the current display list.
533
+
534
+ Parameters
535
+ ----------
536
+ value : DisplayList
537
+ The new display list.
538
+ """
539
+ self._display_list = value
540
+
541
+ def record(self, op: Any) -> None:
542
+ """Append an operation to the display list (if recording is on).
543
+
544
+ Parameters
545
+ ----------
546
+ op : Any
547
+ A drawable / grob operation.
548
+ """
549
+ if self._dl_on:
550
+ self._display_list.record(op)
551
+
552
+ def get_display_list(self) -> DisplayList:
553
+ """Return the current display list.
554
+
555
+ Returns
556
+ -------
557
+ DisplayList
558
+ """
559
+ return self._display_list
560
+
561
+ def set_display_list_on(self, on: bool) -> None:
562
+ """Enable or disable display-list recording.
563
+
564
+ Parameters
565
+ ----------
566
+ on : bool
567
+ ``True`` to enable, ``False`` to disable.
568
+ """
569
+ self._dl_on = bool(on)
570
+
571
+ # ---- group registry (R GSS_GROUPS, grid.h:63) -------------------------
572
+
573
+ def record_group(self, name: str, group_data: Dict[str, Any]) -> None:
574
+ """Register a group definition for later reuse.
575
+
576
+ Port of R ``recordGroup()`` (group.R:65-104).
577
+
578
+ Parameters
579
+ ----------
580
+ name : str
581
+ Group name (must match the DefineGrob name).
582
+ group_data : dict
583
+ Group metadata including ``ref`` (renderer-specific handle),
584
+ ``xy``, ``xyin``, ``wh``, ``r`` (rotation).
585
+ """
586
+ self._groups[name] = group_data
587
+
588
+ def lookup_group(self, name: str) -> Optional[Dict[str, Any]]:
589
+ """Look up a previously defined group.
590
+
591
+ Port of R ``lookupGroup()`` (group.R:106-110).
592
+
593
+ Parameters
594
+ ----------
595
+ name : str
596
+ Group name.
597
+
598
+ Returns
599
+ -------
600
+ dict or None
601
+ Group metadata, or ``None`` if not found.
602
+ """
603
+ return self._groups.get(name)
604
+
605
+ def clear_groups(self) -> None:
606
+ """Remove all group definitions."""
607
+ self._groups.clear()
608
+
609
+ # ---- device binding ---------------------------------------------------
610
+
611
+ def init_device(
612
+ self,
613
+ renderer: Any,
614
+ width_cm: Optional[float] = None,
615
+ height_cm: Optional[float] = None,
616
+ ) -> None:
617
+ """Bind the state to a rendering backend.
618
+
619
+ If ``width_cm`` / ``height_cm`` are not supplied, they are read
620
+ from the renderer's own ``width_in`` / ``height_in`` fields so the
621
+ unit system uses the same canvas the renderer is drawing on. This
622
+ avoids a systematic mismatch where the state defaulted to a 7-inch
623
+ square canvas while the renderer was actually 6×4 inches, causing
624
+ ``strwidth`` / ``strheight`` to be converted to native coordinates
625
+ against the wrong reference width.
626
+
627
+ Parameters
628
+ ----------
629
+ renderer : GridRenderer
630
+ A :class:`GridRenderer` subclass instance (e.g.
631
+ ``CairoRenderer`` or ``WebRenderer``).
632
+ width_cm, height_cm : float, optional
633
+ Device dimensions in centimetres. If ``None``, pulled from
634
+ the renderer's own ``width_in`` / ``height_in`` when
635
+ available, otherwise defaulted to ``_DEFAULT_DEVICE_*_CM``.
636
+ """
637
+ self._renderer = renderer
638
+ if width_cm is None:
639
+ w_in = float(getattr(renderer, "width_in", 0.0) or 0.0)
640
+ width_cm = w_in * 2.54 if w_in > 0 else _DEFAULT_DEVICE_WIDTH_CM
641
+ if height_cm is None:
642
+ h_in = float(getattr(renderer, "height_in", 0.0) or 0.0)
643
+ height_cm = h_in * 2.54 if h_in > 0 else _DEFAULT_DEVICE_HEIGHT_CM
644
+ self._device_width_cm = float(width_cm)
645
+ self._device_height_cm = float(height_cm)
646
+
647
+ def get_renderer(self) -> Any:
648
+ """Return the current rendering backend.
649
+
650
+ Returns
651
+ -------
652
+ GridRenderer or None
653
+ The renderer, or ``None`` if :meth:`init_device` has not
654
+ been called.
655
+ """
656
+ return self._renderer
657
+
658
+ def get_device(self) -> Tuple[Any, Any]:
659
+ """Backward-compatible accessor.
660
+
661
+ Returns ``(renderer, renderer)`` so that code using
662
+ ``fig, ax = state.get_device()`` still works during the
663
+ transition. Both elements are the renderer (or ``None``).
664
+ """
665
+ return (self._renderer, self._renderer)
666
+
667
+
668
+ # ---------------------------------------------------------------------------
669
+ # Module-level singleton & accessor
670
+ # ---------------------------------------------------------------------------
671
+
672
+ _state: GridState = GridState()
673
+
674
+
675
+ def get_state() -> GridState:
676
+ """Return the module-level :class:`GridState` singleton.
677
+
678
+ Returns
679
+ -------
680
+ GridState
681
+ The global state instance.
682
+ """
683
+ return _state