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/_grab.py ADDED
@@ -0,0 +1,501 @@
1
+ """Scene capture and manipulation for grid_py (port of R's grid grab/force/cap).
2
+
3
+ This module provides functions for capturing, forcing, reverting, and
4
+ reordering the current grid scene:
5
+
6
+ * :func:`grid_grab` -- grab the current display list as a :class:`~._grob.GTree`.
7
+ * :func:`grid_grab_expr` -- evaluate a callable and grab the result.
8
+ * :func:`grid_force` -- force delayed grobs on the display list.
9
+ * :func:`grid_revert` -- revert previously forced grobs.
10
+ * :func:`grid_cap` -- capture the current display as a raster (NumPy array).
11
+ * :func:`grid_reorder` -- reorder children of a gTree on the display list.
12
+
13
+ References
14
+ ----------
15
+ R source: ``src/library/grid/R/grab.R`` (~248 lines)
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import copy
21
+ import warnings
22
+ from typing import (
23
+ Any,
24
+ Callable,
25
+ List,
26
+ Optional,
27
+ Sequence,
28
+ Union,
29
+ )
30
+
31
+ import numpy as np
32
+
33
+ from ._grob import (
34
+ GList,
35
+ GTree,
36
+ Grob,
37
+ force_grob,
38
+ is_grob,
39
+ reorder_grob,
40
+ )
41
+ from ._path import GPath
42
+ from ._display_list import DisplayList, DLDrawGrob
43
+ from ._state import GridState, get_state
44
+
45
+ __all__ = [
46
+ "grid_grab",
47
+ "grid_grab_expr",
48
+ "grid_force",
49
+ "grid_revert",
50
+ "grid_cap",
51
+ "grid_reorder",
52
+ ]
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Internal helpers
57
+ # ---------------------------------------------------------------------------
58
+
59
+
60
+ def _collect_dl_grobs(dl: DisplayList, warn: int = 2) -> Optional[GList]:
61
+ """Collect all grobs from the display list into a :class:`GList`.
62
+
63
+ Parameters
64
+ ----------
65
+ dl : DisplayList
66
+ The display list to scan.
67
+ warn : int
68
+ Warning level. 0 = silent, 1 = warn on definite problems,
69
+ 2 = warn on possible problems.
70
+
71
+ Returns
72
+ -------
73
+ GList or None
74
+ A :class:`GList` containing all grobs found, or ``None`` if the
75
+ display list is empty.
76
+ """
77
+ grobs: list[Grob] = []
78
+ seen_names: set[str] = set()
79
+
80
+ for item in dl:
81
+ if isinstance(item, DLDrawGrob) and item.grob is not None:
82
+ grob = item.grob
83
+ if warn >= 1:
84
+ if grob.name in seen_names:
85
+ warnings.warn(
86
+ "one or more grobs overwritten "
87
+ "(grab may not be faithful; try wrap=True)",
88
+ stacklevel=3,
89
+ )
90
+ seen_names.add(grob.name)
91
+ grobs.append(grob)
92
+
93
+ if not grobs:
94
+ return None
95
+ return GList(*grobs)
96
+
97
+
98
+ def _grab_dl(
99
+ warn: int = 2,
100
+ wrap: bool = False,
101
+ wrap_vps: bool = False,
102
+ ) -> Optional[GTree]:
103
+ """Grab the current display list as a :class:`GTree`.
104
+
105
+ Parameters
106
+ ----------
107
+ warn : int
108
+ Warning level (0, 1, or 2).
109
+ wrap : bool
110
+ If ``True``, wrap all viewport pushes and grobs.
111
+ wrap_vps : bool
112
+ If ``True``, wrap viewport operations inside recorded grobs.
113
+
114
+ Returns
115
+ -------
116
+ GTree or None
117
+ A :class:`GTree` encapsulating the scene, or ``None`` if the
118
+ display list is empty.
119
+ """
120
+ state = get_state()
121
+ dl = state.display_list
122
+
123
+ if len(dl) == 0:
124
+ return None
125
+
126
+ children = _collect_dl_grobs(dl, warn=warn)
127
+ if children is None:
128
+ return None
129
+
130
+ if wrap:
131
+ # When wrapping, deep-copy each grob so the grabbed tree is independent
132
+ wrapped: list[Grob] = []
133
+ for child in children:
134
+ wrapped.append(copy.deepcopy(child))
135
+ children = GList(*wrapped)
136
+
137
+ return GTree(children=children)
138
+
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # Public API -- grid.grab
142
+ # ---------------------------------------------------------------------------
143
+
144
+
145
+ def grid_grab(
146
+ warn: int = 2,
147
+ wrap: bool = False,
148
+ wrap_vps: bool = False,
149
+ ) -> Optional[GTree]:
150
+ """Grab the current display list as a :class:`~._grob.GTree`.
151
+
152
+ Collects all grobs from the display list into a single gTree that
153
+ represents the current scene. This is the Python equivalent of R's
154
+ ``grid.grab()``.
155
+
156
+ Parameters
157
+ ----------
158
+ warn : int, optional
159
+ Warning level controlling how aggressively potential problems are
160
+ reported.
161
+
162
+ - ``0`` -- no warnings.
163
+ - ``1`` -- warn about situations that are definitely *not* captured
164
+ faithfully (e.g. duplicated top-level grob names).
165
+ - ``2`` (default) -- additionally warn about situations that *may*
166
+ not be captured faithfully (e.g. top-level viewport pushes).
167
+ wrap : bool, optional
168
+ If ``True``, wrap all pushes and grobs in the resulting gTree so
169
+ that the grabbed tree can be replayed independently.
170
+ wrap_vps : bool, optional
171
+ If ``True``, also wrap viewport operations.
172
+
173
+ Returns
174
+ -------
175
+ GTree or None
176
+ A :class:`~._grob.GTree` encapsulating the current scene, or
177
+ ``None`` if the display list is empty.
178
+
179
+ Examples
180
+ --------
181
+ >>> tree = grid_grab()
182
+ >>> if tree is not None:
183
+ ... print(tree)
184
+ """
185
+ return _grab_dl(warn=warn, wrap=wrap, wrap_vps=wrap_vps)
186
+
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # Public API -- grid.grabExpr
190
+ # ---------------------------------------------------------------------------
191
+
192
+
193
+ def grid_grab_expr(
194
+ expr: Callable[[], Any],
195
+ warn: int = 2,
196
+ wrap: bool = False,
197
+ wrap_vps: bool = False,
198
+ width: float = 7.0,
199
+ height: float = 7.0,
200
+ ) -> Optional[GTree]:
201
+ """Evaluate *expr* and grab the resulting scene.
202
+
203
+ The callable *expr* is executed in a temporary graphics context (the
204
+ display list is cleared and restored afterwards). The scene produced
205
+ by *expr* is then captured via :func:`grid_grab`. This is the Python
206
+ equivalent of R's ``grid.grabExpr()``.
207
+
208
+ Parameters
209
+ ----------
210
+ expr : callable
211
+ A zero-argument callable that performs grid drawing operations.
212
+ warn : int, optional
213
+ Warning level (see :func:`grid_grab`).
214
+ wrap : bool, optional
215
+ Wrap mode (see :func:`grid_grab`).
216
+ wrap_vps : bool, optional
217
+ Wrap viewport operations (see :func:`grid_grab`).
218
+ width : float, optional
219
+ Nominal device width in inches (default ``7.0``). Stored on the
220
+ state but not used for physical rendering.
221
+ height : float, optional
222
+ Nominal device height in inches (default ``7.0``).
223
+
224
+ Returns
225
+ -------
226
+ GTree or None
227
+ A :class:`~._grob.GTree` encapsulating the scene drawn by *expr*,
228
+ or ``None`` if nothing was drawn.
229
+ """
230
+ state = get_state()
231
+ # Save and clear the display list
232
+ saved_items = state.display_list.get_items()
233
+ state.display_list.clear()
234
+
235
+ try:
236
+ # Run the user's drawing code
237
+ expr()
238
+ # Grab what was drawn
239
+ result = _grab_dl(warn=warn, wrap=wrap, wrap_vps=wrap_vps)
240
+ finally:
241
+ # Restore the original display list
242
+ state.display_list.clear()
243
+ for item in saved_items:
244
+ state.display_list.record(item)
245
+
246
+ return result
247
+
248
+
249
+ # ---------------------------------------------------------------------------
250
+ # Public API -- grid.force / grid.revert
251
+ # ---------------------------------------------------------------------------
252
+
253
+
254
+ def grid_force(
255
+ x: Optional[Union[Grob, GTree]] = None,
256
+ redraw: bool = True,
257
+ ) -> Optional[Union[Grob, GTree]]:
258
+ """Force delayed grobs, materialising deferred content.
259
+
260
+ If *x* is ``None``, every grob on the current display list is forced
261
+ in place. Otherwise, *x* is forced and a new (forced) copy is
262
+ returned. This is the Python equivalent of R's ``grid.force()``.
263
+
264
+ Parameters
265
+ ----------
266
+ x : Grob, GTree, or None, optional
267
+ The grob to force. ``None`` forces the entire display list.
268
+ redraw : bool, optional
269
+ If ``True`` (default) and *x* is ``None``, redraw after forcing.
270
+
271
+ Returns
272
+ -------
273
+ Grob, GTree, or None
274
+ When *x* is provided, the forced copy. When *x* is ``None``,
275
+ ``None`` is returned (the display list is modified in place).
276
+
277
+ Examples
278
+ --------
279
+ >>> forced_tree = grid_force(my_gtree)
280
+ """
281
+ if x is not None:
282
+ return force_grob(x)
283
+
284
+ # Force every grob on the display list
285
+ state = get_state()
286
+ dl = state.display_list
287
+
288
+ for item in dl:
289
+ if isinstance(item, DLDrawGrob) and item.grob is not None:
290
+ forced = force_grob(item.grob)
291
+ item.grob = forced
292
+ item.params["grob"] = forced
293
+
294
+ if redraw:
295
+ dl.replay(state)
296
+
297
+ return None
298
+
299
+
300
+ def grid_revert(
301
+ x: Optional[Union[Grob, GTree]] = None,
302
+ redraw: bool = True,
303
+ ) -> Optional[Union[Grob, GTree]]:
304
+ """Revert previously forced grobs to their original (unforced) state.
305
+
306
+ If *x* is ``None``, every grob on the display list that carries an
307
+ ``_original`` attribute (set by :func:`grid_force`) is reverted.
308
+ Otherwise, *x* itself is reverted. This is the Python equivalent of
309
+ R's ``grid.revert()``.
310
+
311
+ Parameters
312
+ ----------
313
+ x : Grob, GTree, or None, optional
314
+ The grob to revert. ``None`` reverts the entire display list.
315
+ redraw : bool, optional
316
+ If ``True`` (default) and *x* is ``None``, redraw after reverting.
317
+
318
+ Returns
319
+ -------
320
+ Grob, GTree, or None
321
+ When *x* is provided, the reverted grob (or the unchanged grob if
322
+ it was not previously forced). When *x* is ``None``, ``None`` is
323
+ returned (the display list is modified in place).
324
+ """
325
+ if x is not None:
326
+ original = getattr(x, "_original", None)
327
+ if original is not None:
328
+ return original
329
+ # For gTrees, try to revert children
330
+ if isinstance(x, GTree):
331
+ result = copy.deepcopy(x)
332
+ reverted_children: list[Grob] = []
333
+ for name in result._children_order:
334
+ child = result._children[name]
335
+ reverted = grid_revert(child)
336
+ reverted_children.append(reverted if reverted is not None else child)
337
+ result._set_children_internal(GList(*reverted_children))
338
+ return result
339
+ return x
340
+
341
+ # Revert every grob on the display list
342
+ state = get_state()
343
+ dl = state.display_list
344
+
345
+ for item in dl:
346
+ if isinstance(item, DLDrawGrob) and item.grob is not None:
347
+ original = getattr(item.grob, "_original", None)
348
+ if original is not None:
349
+ item.grob = original
350
+ item.params["grob"] = original
351
+
352
+ if redraw:
353
+ dl.replay(state)
354
+
355
+ return None
356
+
357
+
358
+ # ---------------------------------------------------------------------------
359
+ # Public API -- grid.cap
360
+ # ---------------------------------------------------------------------------
361
+
362
+
363
+ def grid_cap(native: bool = True) -> Optional[np.ndarray]:
364
+ """Capture the current display as a raster image.
365
+
366
+ This attempts to rasterise the current scene using the Cairo renderer.
367
+ This is the Python equivalent of R's ``grid.cap()``.
368
+
369
+ Parameters
370
+ ----------
371
+ native : bool, optional
372
+ If ``True`` (default), return the raster in the device's native
373
+ resolution as a NumPy array of shape ``(H, W, 4)`` (RGBA uint8).
374
+ If ``False``, return in normalised [0, 1] float64.
375
+
376
+ Returns
377
+ -------
378
+ numpy.ndarray or None
379
+ The raster image, or ``None`` if no renderer is available.
380
+ """
381
+ state = get_state()
382
+ renderer = state.get_renderer()
383
+
384
+ if renderer is None:
385
+ warnings.warn(
386
+ "no renderer available for grid_cap; returning None",
387
+ stacklevel=2,
388
+ )
389
+ return None
390
+
391
+ try:
392
+ import io
393
+ png_bytes = renderer.to_png_bytes()
394
+ # Decode PNG bytes to RGBA array
395
+ try:
396
+ from PIL import Image
397
+ img = Image.open(io.BytesIO(png_bytes)).convert("RGBA")
398
+ arr = np.asarray(img, dtype=np.uint8).copy()
399
+ except ImportError:
400
+ # Fallback: read directly from Cairo surface if ImageSurface
401
+ import cairo
402
+ surface = renderer.get_surface() if hasattr(renderer, "get_surface") else None
403
+ if surface is not None and isinstance(surface, cairo.ImageSurface):
404
+ w = surface.get_width()
405
+ h = surface.get_height()
406
+ buf = surface.get_data()
407
+ arr = np.frombuffer(bytes(buf), dtype=np.uint8).reshape(h, w, 4).copy()
408
+ # Cairo is BGRA; convert to RGBA
409
+ arr[:, :, [0, 2]] = arr[:, :, [2, 0]]
410
+ else:
411
+ warnings.warn(
412
+ "grid_cap requires Pillow for non-image surfaces",
413
+ stacklevel=2,
414
+ )
415
+ return None
416
+ if not native:
417
+ arr = arr.astype(np.float64) / 255.0
418
+ return arr
419
+ except Exception:
420
+ warnings.warn(
421
+ "failed to capture raster from renderer; returning None",
422
+ stacklevel=2,
423
+ )
424
+ return None
425
+
426
+
427
+ # ---------------------------------------------------------------------------
428
+ # Public API -- grid.reorder
429
+ # ---------------------------------------------------------------------------
430
+
431
+
432
+ def grid_reorder(
433
+ gPath: Union[str, GPath],
434
+ order: Union[List[int], List[str]],
435
+ back: bool = True,
436
+ grep: bool = False,
437
+ redraw: bool = True,
438
+ ) -> None:
439
+ """Reorder the children of a gTree on the display list.
440
+
441
+ Locates the gTree identified by *gPath* on the current display list
442
+ and reorders its children according to *order*. This is the Python
443
+ equivalent of R's ``grid.reorder()``.
444
+
445
+ Parameters
446
+ ----------
447
+ gPath : str or GPath
448
+ Path identifying the target gTree.
449
+ order : list[int] or list[str]
450
+ Indices (0-based) or names specifying the new ordering.
451
+ back : bool, optional
452
+ If ``True`` (default), specified children come first (drawn behind);
453
+ unspecified children are appended. If ``False``, unspecified
454
+ children come first; specified children are appended (drawn in
455
+ front).
456
+ grep : bool, optional
457
+ If ``True``, use regex matching on path components.
458
+ redraw : bool, optional
459
+ If ``True`` (default), redraw after reordering.
460
+
461
+ Raises
462
+ ------
463
+ TypeError
464
+ If *gPath* is invalid.
465
+ ValueError
466
+ If no gTree matching *gPath* is found, or if *order* contains
467
+ invalid names or indices.
468
+ """
469
+ if isinstance(gPath, str):
470
+ gpath = GPath(gPath)
471
+ elif isinstance(gPath, GPath):
472
+ gpath = gPath
473
+ else:
474
+ raise TypeError(f"invalid gPath: expected str or GPath, got {type(gPath).__name__}")
475
+
476
+ state = get_state()
477
+ dl = state.display_list
478
+
479
+ # Find the target gTree -- import the helper from _edit
480
+ from ._edit import _find_dl_grobs
481
+
482
+ grep_flags = [grep] * gpath.n
483
+ matches = _find_dl_grobs(dl, gpath, strict=False, grep=grep_flags, global_=False)
484
+
485
+ if not matches:
486
+ raise ValueError(f"gPath ({gpath}) does not match any grob on the display list")
487
+
488
+ _dl_idx, matched_grob = matches[0]
489
+
490
+ if not isinstance(matched_grob, GTree):
491
+ raise TypeError(
492
+ f"gPath matched '{matched_grob.name}' which is not a gTree; "
493
+ "cannot reorder"
494
+ )
495
+
496
+ # Perform the reorder in place
497
+ reordered = reorder_grob(matched_grob, order, back=back)
498
+ matched_grob._children_order = reordered._children_order
499
+
500
+ if redraw:
501
+ dl.replay(state)