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/_layout.py ADDED
@@ -0,0 +1,593 @@
1
+ """Layout system for grid_py -- Python port of R's ``grid::grid.layout``.
2
+
3
+ This module provides the :class:`GridLayout` class and associated accessor
4
+ functions that mirror R's ``grid.layout()`` constructor and its companion
5
+ helper functions (``layout.nrow``, ``layout.ncol``, etc.).
6
+
7
+ A layout partitions a rectangular region into a grid of rows and columns
8
+ whose sizes may be expressed in any unit supported by the grid unit system.
9
+ The *respect* mechanism allows certain cells to maintain their aspect ratio
10
+ when the viewport is resized.
11
+
12
+ References
13
+ ----------
14
+ R source: ``src/library/grid/R/layout.R``
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Any, Dict, Optional, Sequence, Tuple, Union
20
+
21
+ import numpy as np
22
+
23
+ from ._just import valid_just
24
+ from ._units import Unit, is_unit
25
+
26
+ __all__ = [
27
+ "GridLayout",
28
+ "layout_nrow",
29
+ "layout_ncol",
30
+ "layout_widths",
31
+ "layout_heights",
32
+ "layout_respect",
33
+ "layout_region",
34
+ ]
35
+
36
+
37
+ class GridLayout:
38
+ """A grid layout specification.
39
+
40
+ Divides a rectangular region into *nrow* rows and *ncol* columns whose
41
+ dimensions are given by *widths* and *heights* (as :class:`Unit` objects).
42
+
43
+ Parameters
44
+ ----------
45
+ nrow : int
46
+ Number of rows.
47
+ ncol : int
48
+ Number of columns.
49
+ widths : Unit or None
50
+ Column widths. If *None* (the default), each column receives equal
51
+ ``"null"`` space (``Unit([1]*ncol, "null")``).
52
+ heights : Unit or None
53
+ Row heights. If *None* (the default), each row receives equal
54
+ ``"null"`` space (``Unit([1]*nrow, "null")``).
55
+ default_units : str
56
+ Unit type applied to *widths* / *heights* when they are supplied as
57
+ plain numeric values rather than :class:`Unit` objects.
58
+ respect : bool or numpy.ndarray
59
+ If ``False`` (the default), no aspect-ratio constraints are applied.
60
+ If ``True``, all null-unit cells are respected. An *nrow* x *ncol*
61
+ integer matrix (``numpy.ndarray``) selects individual cells to respect.
62
+ just : str or sequence of str
63
+ Justification of the layout within its parent viewport. Accepted
64
+ values follow :func:`._just.valid_just` conventions (e.g.
65
+ ``"centre"``, ``"left"``, ``["right", "top"]``).
66
+ """
67
+
68
+ __slots__ = (
69
+ "_nrow",
70
+ "_ncol",
71
+ "_widths",
72
+ "_heights",
73
+ "_respect",
74
+ "_valid_respect",
75
+ "_respect_mat",
76
+ "_just",
77
+ "_valid_just",
78
+ )
79
+
80
+ def __init__(
81
+ self,
82
+ nrow: int = 1,
83
+ ncol: int = 1,
84
+ widths: Optional[Unit] = None,
85
+ heights: Optional[Unit] = None,
86
+ default_units: str = "null",
87
+ respect: Union[bool, np.ndarray] = False,
88
+ just: Union[str, Sequence[str]] = "centre",
89
+ ) -> None:
90
+ self._nrow: int = int(nrow)
91
+ self._ncol: int = int(ncol)
92
+
93
+ # -- widths ----------------------------------------------------------
94
+ if widths is None:
95
+ self._widths: Unit = Unit([1.0] * self._ncol, "null")
96
+ elif is_unit(widths):
97
+ self._widths = widths
98
+ else:
99
+ self._widths = Unit(widths, default_units)
100
+
101
+ # -- heights ---------------------------------------------------------
102
+ if heights is None:
103
+ self._heights: Unit = Unit([1.0] * self._nrow, "null")
104
+ elif is_unit(heights):
105
+ self._heights = heights
106
+ else:
107
+ self._heights = Unit(heights, default_units)
108
+
109
+ # -- respect ---------------------------------------------------------
110
+ if isinstance(respect, np.ndarray):
111
+ respect_arr = np.asarray(respect, dtype=np.int32)
112
+ if respect_arr.shape != (self._nrow, self._ncol):
113
+ raise ValueError(
114
+ "'respect' must be logical or an 'nrow' by 'ncol' matrix; "
115
+ f"got shape {respect_arr.shape}, expected "
116
+ f"({self._nrow}, {self._ncol})"
117
+ )
118
+ self._respect_mat: np.ndarray = respect_arr
119
+ # R stores integer 2 to signal "matrix mode"
120
+ self._respect: Union[bool, np.ndarray] = respect
121
+ self._valid_respect: int = 2
122
+ elif respect:
123
+ self._respect_mat = np.zeros(
124
+ (self._nrow, self._ncol), dtype=np.int32
125
+ )
126
+ self._respect = True
127
+ self._valid_respect = 1
128
+ else:
129
+ self._respect_mat = np.zeros(
130
+ (self._nrow, self._ncol), dtype=np.int32
131
+ )
132
+ self._respect = False
133
+ self._valid_respect = 0
134
+
135
+ # -- justification ---------------------------------------------------
136
+ self._just = just
137
+ self._valid_just: Tuple[float, float] = valid_just(just)
138
+
139
+ # --------------------------------------------------------------------- #
140
+ # Properties #
141
+ # --------------------------------------------------------------------- #
142
+
143
+ @property
144
+ def nrow(self) -> int:
145
+ """Number of rows in the layout."""
146
+ return self._nrow
147
+
148
+ @property
149
+ def ncol(self) -> int:
150
+ """Number of columns in the layout."""
151
+ return self._ncol
152
+
153
+ @property
154
+ def widths(self) -> Unit:
155
+ """Column widths as a :class:`Unit`."""
156
+ return self._widths
157
+
158
+ @property
159
+ def heights(self) -> Unit:
160
+ """Row heights as a :class:`Unit`."""
161
+ return self._heights
162
+
163
+ @property
164
+ def respect(self) -> Union[bool, np.ndarray]:
165
+ """Respect specification.
166
+
167
+ Returns ``False`` (no respect), ``True`` (full respect), or an
168
+ *nrow* x *ncol* integer matrix indicating per-cell respect.
169
+ """
170
+ if self._valid_respect == 0:
171
+ return False
172
+ if self._valid_respect == 1:
173
+ return True
174
+ return self._respect_mat
175
+
176
+ @property
177
+ def respect_mat(self) -> np.ndarray:
178
+ """The *nrow* x *ncol* integer matrix of per-cell respect flags."""
179
+ return self._respect_mat
180
+
181
+ @property
182
+ def dim(self) -> Tuple[int, int]:
183
+ """Layout dimensions as ``(nrow, ncol)``."""
184
+ return (self._nrow, self._ncol)
185
+
186
+ # --------------------------------------------------------------------- #
187
+ # Dunder methods #
188
+ # --------------------------------------------------------------------- #
189
+
190
+ def __repr__(self) -> str:
191
+ return (
192
+ f"GridLayout(nrow={self._nrow}, ncol={self._ncol}, "
193
+ f"widths={self._widths!r}, heights={self._heights!r}, "
194
+ f"respect={self._respect!r}, just={self._just!r})"
195
+ )
196
+
197
+
198
+ # ======================================================================= #
199
+ # Module-level accessor functions #
200
+ # ======================================================================= #
201
+
202
+
203
+ def layout_nrow(layout: GridLayout) -> int:
204
+ """Return the number of rows in *layout*.
205
+
206
+ Parameters
207
+ ----------
208
+ layout : GridLayout
209
+ A grid layout object.
210
+
211
+ Returns
212
+ -------
213
+ int
214
+ Number of rows.
215
+ """
216
+ return layout.nrow
217
+
218
+
219
+ def layout_ncol(layout: GridLayout) -> int:
220
+ """Return the number of columns in *layout*.
221
+
222
+ Parameters
223
+ ----------
224
+ layout : GridLayout
225
+ A grid layout object.
226
+
227
+ Returns
228
+ -------
229
+ int
230
+ Number of columns.
231
+ """
232
+ return layout.ncol
233
+
234
+
235
+ def layout_widths(layout: GridLayout) -> Unit:
236
+ """Return the column widths of *layout*.
237
+
238
+ Parameters
239
+ ----------
240
+ layout : GridLayout
241
+ A grid layout object.
242
+
243
+ Returns
244
+ -------
245
+ Unit
246
+ Column widths.
247
+ """
248
+ return layout.widths
249
+
250
+
251
+ def layout_heights(layout: GridLayout) -> Unit:
252
+ """Return the row heights of *layout*.
253
+
254
+ Parameters
255
+ ----------
256
+ layout : GridLayout
257
+ A grid layout object.
258
+
259
+ Returns
260
+ -------
261
+ Unit
262
+ Row heights.
263
+ """
264
+ return layout.heights
265
+
266
+
267
+ def layout_respect(layout: GridLayout) -> Union[bool, np.ndarray]:
268
+ """Return the respect specification of *layout*.
269
+
270
+ Parameters
271
+ ----------
272
+ layout : GridLayout
273
+ A grid layout object.
274
+
275
+ Returns
276
+ -------
277
+ bool or numpy.ndarray
278
+ ``False`` for no respect, ``True`` for full respect, or an
279
+ *nrow* x *ncol* integer matrix for per-cell respect.
280
+ """
281
+ return layout.respect
282
+
283
+
284
+ # ======================================================================= #
285
+ # Three-phase layout algorithm (mirrors R layout.c:calcViewportLayout) #
286
+ # ======================================================================= #
287
+
288
+
289
+ def _col_respected(col: int, layout: GridLayout) -> bool:
290
+ """Check if column *col* (0-based) has any respected cell.
291
+
292
+ Mirrors R ``layout.c:colRespected`` (lines 121-133).
293
+ """
294
+ if layout._valid_respect == 1:
295
+ return True
296
+ if layout._valid_respect == 2:
297
+ return bool(np.any(layout._respect_mat[:, col] != 0))
298
+ return False
299
+
300
+
301
+ def _row_respected(row: int, layout: GridLayout) -> bool:
302
+ """Check if row *row* (0-based) has any respected cell.
303
+
304
+ Mirrors R ``layout.c:rowRespected`` (lines 135-147).
305
+ """
306
+ if layout._valid_respect == 1:
307
+ return True
308
+ if layout._valid_respect == 2:
309
+ return bool(np.any(layout._respect_mat[row, :] != 0))
310
+ return False
311
+
312
+
313
+ def _calc_layout_sizes(
314
+ layout: GridLayout,
315
+ parent_w_px: float,
316
+ parent_h_px: float,
317
+ dpi: float = 150.0,
318
+ ) -> Tuple[list, list]:
319
+ """Three-phase layout negotiation algorithm.
320
+
321
+ Mirrors R ``layout.c:calcViewportLayout`` (lines 492-591).
322
+
323
+ Phase 1: Allocate absolute (non-null) units to device pixels.
324
+ Phase 2: Allocate respected null units with unified aspect-ratio scale.
325
+ Phase 3: Distribute remaining space among unrespected null units.
326
+
327
+ Parameters
328
+ ----------
329
+ layout : GridLayout
330
+ The layout specification.
331
+ parent_w_px, parent_h_px : float
332
+ Available space in device pixels.
333
+ dpi : float
334
+ Device resolution in dots per inch.
335
+
336
+ Returns
337
+ -------
338
+ (col_widths, row_heights) : (list[float], list[float])
339
+ Allocated sizes in device pixels.
340
+ """
341
+ from ._units import _INCHES_PER
342
+
343
+ ncol = layout.nrow if False else layout.ncol # kept explicit for clarity
344
+ nrow = layout.nrow
345
+ ncol = layout.ncol
346
+ widths = layout.widths
347
+ heights = layout.heights
348
+
349
+ col_sizes = [0.0] * ncol
350
+ row_sizes = [0.0] * nrow
351
+ relative_w = [False] * ncol
352
+ relative_h = [False] * nrow
353
+
354
+ reduced_w = parent_w_px
355
+ reduced_h = parent_h_px
356
+
357
+ # ---- Phase 1: allocate absolute units (layout.c:allocateKnownWidths) --
358
+ # R resolves ALL non-null units to absolute device pixels in this phase.
359
+ # Compound units (sum/min/max), contextual units (lines/char/snpc),
360
+ # and string/grob metric units must be resolved via the renderer.
361
+ from ._state import get_state as _get_state
362
+ _renderer = _get_state().get_renderer()
363
+
364
+ def _resolve_unit_to_px(unit_obj, idx, axis, parent_px):
365
+ """Resolve a single unit element to device pixels."""
366
+ utype = unit_obj._units[idx] if idx < len(unit_obj._units) else "null"
367
+ val = float(unit_obj._values[idx])
368
+ if utype == "null":
369
+ return None # null → flex
370
+ if utype == "npc":
371
+ return val * parent_px
372
+ if utype in _INCHES_PER:
373
+ return val * _INCHES_PER[utype] * dpi
374
+ # Compound or contextual unit: resolve via renderer
375
+ if _renderer is not None:
376
+ from ._units import Unit
377
+ elem = Unit(val, utype,
378
+ data=unit_obj._data[idx] if unit_obj._data else None)
379
+ inches = _renderer._resolve_to_inches(elem, axis, True)
380
+ return inches * dpi
381
+ return None # no renderer → treat as null
382
+
383
+ for i in range(ncol):
384
+ px = _resolve_unit_to_px(widths, i, "x", parent_w_px)
385
+ if px is None:
386
+ relative_w[i] = True
387
+ else:
388
+ col_sizes[i] = px
389
+ reduced_w -= px
390
+
391
+ for j in range(nrow):
392
+ px = _resolve_unit_to_px(heights, j, "y", parent_h_px)
393
+ if px is None:
394
+ relative_h[j] = True
395
+ else:
396
+ row_sizes[j] = px
397
+ reduced_h -= px
398
+
399
+ reduced_w = max(reduced_w, 0.0)
400
+ reduced_h = max(reduced_h, 0.0)
401
+
402
+ # ---- Phase 2: allocate respected null units (layout.c:allocateRespected)
403
+ if layout._valid_respect > 0 and (reduced_w > 0 or reduced_h > 0):
404
+ sum_w = sum(
405
+ float(widths._values[i])
406
+ for i in range(ncol)
407
+ if relative_w[i] and _col_respected(i, layout)
408
+ )
409
+ sum_h = sum(
410
+ float(heights._values[j])
411
+ for j in range(nrow)
412
+ if relative_h[j] and _row_respected(j, layout)
413
+ )
414
+
415
+ temp_w = reduced_w
416
+ temp_h = reduced_h
417
+
418
+ if sum_w > 0 or sum_h > 0:
419
+ # Determine limiting dimension (layout.c:221)
420
+ if temp_h * sum_w > sum_h * temp_w:
421
+ denom, mult = sum_w, temp_w
422
+ else:
423
+ denom, mult = sum_h, temp_h
424
+
425
+ for i in range(ncol):
426
+ if relative_w[i] and _col_respected(i, layout):
427
+ # Special case: sumHeight==0 (layout.c:240-243)
428
+ d, m = denom, mult
429
+ if sum_h == 0:
430
+ d, m = sum_w, temp_w
431
+ if d > 0:
432
+ col_sizes[i] = float(widths._values[i]) / d * m
433
+ reduced_w -= col_sizes[i]
434
+
435
+ for j in range(nrow):
436
+ if relative_h[j] and _row_respected(j, layout):
437
+ d, m = denom, mult
438
+ if sum_w == 0:
439
+ d, m = sum_h, temp_h
440
+ if d > 0:
441
+ row_sizes[j] = float(heights._values[j]) / d * m
442
+ reduced_h -= row_sizes[j]
443
+ else:
444
+ # No respect or no remaining space: respected nulls get 0
445
+ for i in range(ncol):
446
+ if relative_w[i] and _col_respected(i, layout):
447
+ col_sizes[i] = 0.0
448
+ for j in range(nrow):
449
+ if relative_h[j] and _row_respected(j, layout):
450
+ row_sizes[j] = 0.0
451
+
452
+ reduced_w = max(reduced_w, 0.0)
453
+ reduced_h = max(reduced_h, 0.0)
454
+
455
+ # ---- Phase 3: allocate unrespected null units (layout.c:allocateRemaining)
456
+ sum_unresp_w = sum(
457
+ float(widths._values[i])
458
+ for i in range(ncol)
459
+ if relative_w[i] and not _col_respected(i, layout)
460
+ )
461
+ if sum_unresp_w > 0:
462
+ for i in range(ncol):
463
+ if relative_w[i] and not _col_respected(i, layout):
464
+ col_sizes[i] = reduced_w * float(widths._values[i]) / sum_unresp_w
465
+ else:
466
+ for i in range(ncol):
467
+ if relative_w[i] and not _col_respected(i, layout):
468
+ col_sizes[i] = 0.0
469
+
470
+ sum_unresp_h = sum(
471
+ float(heights._values[j])
472
+ for j in range(nrow)
473
+ if relative_h[j] and not _row_respected(j, layout)
474
+ )
475
+ if sum_unresp_h > 0:
476
+ for j in range(nrow):
477
+ if relative_h[j] and not _row_respected(j, layout):
478
+ row_sizes[j] = reduced_h * float(heights._values[j]) / sum_unresp_h
479
+ else:
480
+ for j in range(nrow):
481
+ if relative_h[j] and not _row_respected(j, layout):
482
+ row_sizes[j] = 0.0
483
+
484
+ return col_sizes, row_sizes
485
+
486
+
487
+ def layout_region(
488
+ layout: GridLayout,
489
+ row: Union[int, Sequence[int]],
490
+ col: Union[int, Sequence[int]],
491
+ parent_w_px: Optional[float] = None,
492
+ parent_h_px: Optional[float] = None,
493
+ dpi: float = 150.0,
494
+ ) -> Dict[str, Unit]:
495
+ """Compute the region occupied by a range of layout cells.
496
+
497
+ Mirrors R's ``layoutRegion`` function. Row and column indices are
498
+ **1-based** (following R convention). A single integer selects one
499
+ row/column; a two-element sequence ``[start, end]`` selects an inclusive
500
+ range.
501
+
502
+ When *parent_w_px* and *parent_h_px* are provided, the full three-phase
503
+ layout negotiation algorithm is used (matching R's ``calcViewportLayout``
504
+ in ``layout.c``), correctly handling mixed units (``cm`` + ``null`` +
505
+ ``npc``), the ``respect`` parameter, and per-cell respect matrices.
506
+
507
+ When these parameters are omitted, a simplified proportional allocation
508
+ is used (backward compatible; only correct for uniform ``null`` units).
509
+
510
+ Parameters
511
+ ----------
512
+ layout : GridLayout
513
+ A grid layout object.
514
+ row : int or sequence of int
515
+ 1-based row index or ``[start, end]`` range (inclusive).
516
+ col : int or sequence of int
517
+ 1-based column index or ``[start, end]`` range (inclusive).
518
+ parent_w_px : float or None
519
+ Parent viewport width in device pixels. When provided, enables the
520
+ full layout algorithm.
521
+ parent_h_px : float or None
522
+ Parent viewport height in device pixels.
523
+ dpi : float
524
+ Device resolution (dots per inch).
525
+
526
+ Returns
527
+ -------
528
+ dict
529
+ Dictionary with keys ``"left"``, ``"bottom"``, ``"width"``,
530
+ ``"height"``, each containing a :class:`Unit` in ``"npc"`` units.
531
+ """
532
+ # Normalise to (start, end) -- 1-based inclusive
533
+ if isinstance(row, (int, np.integer)):
534
+ row_start, row_end = int(row), int(row)
535
+ else:
536
+ row_seq = list(row)
537
+ row_start = int(row_seq[0])
538
+ row_end = int(row_seq[-1]) if len(row_seq) > 1 else row_start
539
+
540
+ if isinstance(col, (int, np.integer)):
541
+ col_start, col_end = int(col), int(col)
542
+ else:
543
+ col_seq = list(col)
544
+ col_start = int(col_seq[0])
545
+ col_end = int(col_seq[-1]) if len(col_seq) > 1 else col_start
546
+
547
+ # Convert to 0-based indices
548
+ r0 = row_start - 1
549
+ r1 = row_end # exclusive upper bound
550
+ c0 = col_start - 1
551
+ c1 = col_end
552
+
553
+ # -- Full three-phase algorithm when parent dimensions available --------
554
+ if parent_w_px is not None and parent_h_px is not None:
555
+ col_widths, row_heights = _calc_layout_sizes(
556
+ layout, parent_w_px, parent_h_px, dpi,
557
+ )
558
+ total_w = sum(col_widths) or 1.0
559
+ total_h = sum(row_heights) or 1.0
560
+
561
+ left_frac = sum(col_widths[:c0]) / total_w
562
+ width_frac = sum(col_widths[c0:c1]) / total_w
563
+ top_frac = sum(row_heights[:r0]) / total_h
564
+ height_frac = sum(row_heights[r0:r1]) / total_h
565
+ bottom_frac = 1.0 - top_frac - height_frac
566
+
567
+ return {
568
+ "left": Unit(left_frac, "npc"),
569
+ "bottom": Unit(bottom_frac, "npc"),
570
+ "width": Unit(width_frac, "npc"),
571
+ "height": Unit(height_frac, "npc"),
572
+ }
573
+
574
+ # -- Simplified proportional fallback (null units only) ----------------
575
+ w_vals = np.asarray(layout.widths._values, dtype=np.float64)
576
+ h_vals = np.asarray(layout.heights._values, dtype=np.float64)
577
+
578
+ total_w = w_vals.sum() or 1.0
579
+ total_h = h_vals.sum() or 1.0
580
+
581
+ left_frac = w_vals[:c0].sum() / total_w
582
+ width_frac = w_vals[c0:c1].sum() / total_w
583
+
584
+ top_frac = h_vals[:r0].sum() / total_h
585
+ height_frac = h_vals[r0:r1].sum() / total_h
586
+ bottom_frac = 1.0 - top_frac - height_frac
587
+
588
+ return {
589
+ "left": Unit(left_frac, "npc"),
590
+ "bottom": Unit(bottom_frac, "npc"),
591
+ "width": Unit(width_frac, "npc"),
592
+ "height": Unit(height_frac, "npc"),
593
+ }