tesserax 0.5.1__py3-none-any.whl → 0.5.2__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.
tesserax/__init__.py CHANGED
@@ -13,4 +13,4 @@ from .base import (
13
13
  Text,
14
14
  )
15
15
 
16
- __version__ = "0.5.1"
16
+ __version__ = "0.5.2"
tesserax/base.py CHANGED
@@ -194,6 +194,69 @@ class Group(Shape):
194
194
 
195
195
  return self
196
196
 
197
+ def distribute(
198
+ self,
199
+ axis: Literal["horizontal", "vertical"],
200
+ size: float | None = None,
201
+ mode: Literal["tight", "space-between", "space-around"] = "tight",
202
+ gap: float = 0.0,
203
+ ) -> Self:
204
+ """
205
+ Distributes children along an axis using rigid or flexible spacing.
206
+ """
207
+ if not self.shapes:
208
+ return self
209
+
210
+ # 1. Reset and Measure
211
+ for s in self.shapes:
212
+ if isinstance(s, Spring):
213
+ s._size = 0.0
214
+
215
+ springs = [s for s in self.shapes if isinstance(s, Spring)]
216
+ total_flex = sum(s.flex for s in springs)
217
+ n = len(self.shapes)
218
+
219
+ # 2. Calculate Spacing Logic
220
+ is_horiz = axis == "horizontal"
221
+ rigid_total = sum(
222
+ s.local().width if is_horiz else s.local().height
223
+ for s in self.shapes
224
+ if not isinstance(s, Spring)
225
+ )
226
+
227
+ effective_gap = gap
228
+ start_offset = 0.0
229
+ spring_unit = 0.0
230
+
231
+ if size is not None:
232
+ if springs:
233
+ # Springs take all space not occupied by rigid shapes and fixed gaps
234
+ remaining = size - rigid_total - (gap * (n - 1))
235
+ spring_unit = max(0, remaining / total_flex) if total_flex > 0 else 0
236
+ elif mode == "space-between" and n > 1:
237
+ effective_gap = (size - rigid_total) / (n - 1)
238
+ elif mode == "space-around":
239
+ effective_gap = (size - rigid_total) / n
240
+ start_offset = effective_gap / 2
241
+
242
+ # 3. Apply Translations
243
+ cursor = start_offset
244
+ for s in self.shapes:
245
+ b = s.local()
246
+
247
+ if isinstance(s, Spring):
248
+ s._size = s.flex * spring_unit
249
+ cursor += s._size
250
+ else:
251
+ if is_horiz:
252
+ s.transform.tx = cursor - b.x
253
+ cursor += b.width + effective_gap
254
+ else:
255
+ s.transform.ty = cursor - b.y
256
+ cursor += b.height + effective_gap
257
+
258
+ return self
259
+
197
260
 
198
261
  class Path(Shape):
199
262
  """
@@ -457,3 +520,54 @@ class Text(Shape):
457
520
  f'fill="{self.fill}" text-anchor="{self._anchor}" dominant-baseline="middle">'
458
521
  f"{self.content}</text>"
459
522
  )
523
+
524
+
525
+ class Spacer(Shape):
526
+ """
527
+ An invisible rectangular shape used to reserve fixed space in layouts.
528
+ """
529
+
530
+ def __init__(self, w: float, h: float) -> None:
531
+ super().__init__()
532
+ self.w, self.h = w, h
533
+
534
+ def local(self) -> Bounds:
535
+ return Bounds(0, 0, self.w, self.h)
536
+
537
+ def _render(self) -> str:
538
+ return ""
539
+
540
+
541
+ class Ghost(Shape):
542
+ """
543
+ A shape that proxies the bounds of a target shape without rendering.
544
+ """
545
+
546
+ def __init__(self, target: Shape) -> None:
547
+ super().__init__()
548
+ self.target = target
549
+
550
+ def local(self) -> Bounds:
551
+ """Returns the current local bounds of the target shape."""
552
+ return self.target.local()
553
+
554
+ def _render(self) -> str:
555
+ return ""
556
+
557
+
558
+ class Spring(Shape):
559
+ """
560
+ A flexible spacer that expands to fill available space in layouts.
561
+ """
562
+
563
+ def __init__(self, flex: float = 1.0) -> None:
564
+ super().__init__()
565
+ self.flex = flex
566
+ self._size: float = 0.0
567
+
568
+ def local(self) -> Bounds:
569
+ # Returns a 0-width/height bound unless size is set by distribute()
570
+ return Bounds(0, 0, self._size, self._size)
571
+
572
+ def _render(self) -> str:
573
+ return ""
tesserax/layout.py CHANGED
@@ -2,7 +2,7 @@ from abc import abstractmethod
2
2
  from collections import defaultdict
3
3
  import math
4
4
  from typing import Literal, Self
5
- from .core import Shape, Bounds
5
+ from .core import Anchor, Shape, Bounds
6
6
  from .base import Group
7
7
 
8
8
 
@@ -32,81 +32,60 @@ class Row(Layout):
32
32
  self,
33
33
  shapes: list[Shape] | None = None,
34
34
  align: Align = "middle",
35
- gap: float = 0,
35
+ gap: float = 0.0,
36
+ width: float | None = None,
37
+ mode: Literal["tight", "space-between", "space-around"] = "tight",
36
38
  ) -> None:
37
- self.align = align
39
+ self.align_mode = align
38
40
  self.gap = gap
41
+ self.width = width
42
+ self.mode = mode
39
43
  super().__init__(shapes)
40
44
 
41
45
  def do_layout(self) -> None:
42
- if not self.shapes:
43
- return
44
-
45
- # 1. First pass: Reset transforms so we get pure local bounds
46
- for s in self.shapes:
47
- s.transform.reset()
46
+ # Map Row's 'align' terminology to the Group's 'anchor' terminology
47
+ anchor_map: dict[Align, Anchor] = {
48
+ "start": "top",
49
+ "middle": "center",
50
+ "end": "bottom",
51
+ }
48
52
 
49
- # 2. Calculate offsets based on the 'clean' shapes
50
- max_h = max(s.local().height for s in self.shapes)
51
- current_x = 0.0
53
+ # 1. Distribute along the flow axis (X)
54
+ self.distribute(
55
+ axis="horizontal", size=self.width, mode=self.mode, gap=self.gap
56
+ )
52
57
 
53
- for shape in self.shapes:
54
- b = shape.local()
58
+ # 2. Align along the cross axis (Y)
59
+ self.align(axis="vertical", anchor=anchor_map[self.align_mode])
55
60
 
56
- # Calculate Y based on baseline
57
- match self.align:
58
- case "start":
59
- dy = -b.y
60
- case "middle":
61
- dy = (max_h / 2) - (b.y + b.height / 2)
62
- case "end":
63
- dy = max_h - (b.y + b.height)
64
- case _:
65
- dy = 0
66
61
 
67
- # 3. Apply the strict layout position
68
- shape.transform.tx = current_x - b.x
69
- shape.transform.ty = dy
70
-
71
- current_x += b.width + self.gap
72
-
73
-
74
- class Column(Row):
62
+ class Column(Layout):
75
63
  def __init__(
76
64
  self,
77
65
  shapes: list[Shape] | None = None,
78
66
  align: Align = "middle",
79
- gap: float = 0,
67
+ gap: float = 0.0,
68
+ height: float | None = None,
69
+ mode: Literal["tight", "space-between", "space-around"] = "tight",
80
70
  ) -> None:
81
- super().__init__(shapes, align, gap)
71
+ self.align_mode = align
72
+ self.gap = gap
73
+ self.height = height
74
+ self.mode = mode
75
+ super().__init__(shapes)
82
76
 
83
77
  def do_layout(self) -> None:
84
- if not self.shapes:
85
- return
86
-
87
- for s in self.shapes:
88
- s.transform.reset()
89
-
90
- max_w = max(s.local().width for s in self.shapes)
91
- current_y = 0.0
92
-
93
- for shape in self.shapes:
94
- b = shape.local()
95
-
96
- match self.align:
97
- case "start":
98
- dx = -b.x
99
- case "end":
100
- dx = max_w - (b.x + b.width)
101
- case "middle":
102
- dx = (max_w / 2) - (b.x + b.width / 2)
103
- case _:
104
- dx = 0
78
+ anchor_map: dict[Align, Anchor] = {
79
+ "start": "left",
80
+ "middle": "center",
81
+ "end": "right",
82
+ }
105
83
 
106
- shape.transform.tx = dx
107
- shape.transform.ty = current_y - b.y
84
+ # 1. Distribute along the flow axis (Y)
85
+ self.distribute(axis="vertical", size=self.height, mode=self.mode, gap=self.gap)
108
86
 
109
- current_y += b.height + self.gap
87
+ # 2. Align along the cross axis (X)
88
+ self.align(axis="horizontal", anchor=anchor_map[self.align_mode])
110
89
 
111
90
 
112
91
  class ForceLayout(Layout):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tesserax
3
- Version: 0.5.1
3
+ Version: 0.5.2
4
4
  Summary: A pure-Python library for rendering professional CS graphics.
5
5
  Author-email: Alejandro Piad <apiad@apiad.net>
6
6
  License-File: LICENSE
@@ -0,0 +1,11 @@
1
+ tesserax/__init__.py,sha256=r9-tqABA4B8owCxvcG1eUMAup6fqWqpTPVr2_JGg0Q0,224
2
+ tesserax/align.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ tesserax/base.py,sha256=rIQ4ihHCDxFsYcQRqzAxs6jbCsFeR8B8uJDi4eAHXOs,17695
4
+ tesserax/canvas.py,sha256=uY-L3DvIDwciDWQ1l9QLEH-unVUk8Am0k1ykeMIxqG0,3663
5
+ tesserax/core.py,sha256=7t_UxMl0cRJYspCIYPPUc0aXIdqcaHa_o01MrM_9IaY,6180
6
+ tesserax/layout.py,sha256=fPAycn4J3XT0J7L_BGy8Tmw58KoMjR5lQsiIdpkXIsY,11257
7
+ tesserax/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ tesserax-0.5.2.dist-info/METADATA,sha256=rcct3LcEqwxUVk14VjyK9NTeOFFJc4GVsPmANrwn6h0,7320
9
+ tesserax-0.5.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
10
+ tesserax-0.5.2.dist-info/licenses/LICENSE,sha256=MqaKRdqJfordIyistXMUlS7MVRj7fnJABGUD0Q3Fy14,1071
11
+ tesserax-0.5.2.dist-info/RECORD,,
@@ -1,11 +0,0 @@
1
- tesserax/__init__.py,sha256=XitPTo-_7JHiHKO9XipLgu_HnijDja0q_lmeTLfaUeQ,224
2
- tesserax/align.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
- tesserax/base.py,sha256=o7ZQ0ED7EPPCvas78OBq4fE5w38iWdMwl7WYxzFlWyw,14404
4
- tesserax/canvas.py,sha256=uY-L3DvIDwciDWQ1l9QLEH-unVUk8Am0k1ykeMIxqG0,3663
5
- tesserax/core.py,sha256=7t_UxMl0cRJYspCIYPPUc0aXIdqcaHa_o01MrM_9IaY,6180
6
- tesserax/layout.py,sha256=C_enRYUUizCQpnQy_MK8hPbZUT7gJ7sa8l7mJwp_Kak,11591
7
- tesserax/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
- tesserax-0.5.1.dist-info/METADATA,sha256=8mfM0_-XroM93xd13EO6x_FAsmZnBNKD8RMIXBVR4CE,7320
9
- tesserax-0.5.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
10
- tesserax-0.5.1.dist-info/licenses/LICENSE,sha256=MqaKRdqJfordIyistXMUlS7MVRj7fnJABGUD0Q3Fy14,1071
11
- tesserax-0.5.1.dist-info/RECORD,,