tesserax 0.5.1__tar.gz → 0.5.2__tar.gz

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.
Files changed (29) hide show
  1. {tesserax-0.5.1 → tesserax-0.5.2}/PKG-INFO +1 -1
  2. {tesserax-0.5.1 → tesserax-0.5.2}/docs/core.qmd +23 -3
  3. {tesserax-0.5.1 → tesserax-0.5.2}/pyproject.toml +1 -1
  4. {tesserax-0.5.1 → tesserax-0.5.2}/src/tesserax/__init__.py +1 -1
  5. {tesserax-0.5.1 → tesserax-0.5.2}/src/tesserax/base.py +114 -0
  6. {tesserax-0.5.1 → tesserax-0.5.2}/src/tesserax/layout.py +37 -58
  7. {tesserax-0.5.1 → tesserax-0.5.2}/uv.lock +1 -1
  8. tesserax-0.5.1/examples/basic.py +0 -14
  9. {tesserax-0.5.1 → tesserax-0.5.2}/.github/workflows/release.yaml +0 -0
  10. {tesserax-0.5.1 → tesserax-0.5.2}/.github/workflows/tests.yaml +0 -0
  11. {tesserax-0.5.1 → tesserax-0.5.2}/.gitignore +0 -0
  12. {tesserax-0.5.1 → tesserax-0.5.2}/.python-version +0 -0
  13. {tesserax-0.5.1 → tesserax-0.5.2}/.vscode/settings.json +0 -0
  14. {tesserax-0.5.1 → tesserax-0.5.2}/AGENT.md +0 -0
  15. {tesserax-0.5.1 → tesserax-0.5.2}/LICENSE +0 -0
  16. {tesserax-0.5.1 → tesserax-0.5.2}/README.md +0 -0
  17. {tesserax-0.5.1 → tesserax-0.5.2}/docs/.gitignore +0 -0
  18. {tesserax-0.5.1 → tesserax-0.5.2}/docs/_quarto.yml +0 -0
  19. {tesserax-0.5.1 → tesserax-0.5.2}/docs/gallery.qmd +0 -0
  20. {tesserax-0.5.1 → tesserax-0.5.2}/docs/index.qmd +0 -0
  21. {tesserax-0.5.1 → tesserax-0.5.2}/docs/styles.css +0 -0
  22. {tesserax-0.5.1 → tesserax-0.5.2}/makefile +0 -0
  23. {tesserax-0.5.1 → tesserax-0.5.2}/src/tesserax/align.py +0 -0
  24. {tesserax-0.5.1 → tesserax-0.5.2}/src/tesserax/canvas.py +0 -0
  25. {tesserax-0.5.1 → tesserax-0.5.2}/src/tesserax/core.py +0 -0
  26. {tesserax-0.5.1 → tesserax-0.5.2}/src/tesserax/py.typed +0 -0
  27. {tesserax-0.5.1 → tesserax-0.5.2}/tests/test_geometry.py +0 -0
  28. {tesserax-0.5.1 → tesserax-0.5.2}/tests/test_layout.py +0 -0
  29. {tesserax-0.5.1 → tesserax-0.5.2}/tests/test_shapes.py +0 -0
@@ -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
@@ -53,9 +53,28 @@ canvas.fit(padding=10).display()
53
53
 
54
54
  ```
55
55
 
56
+ ## Grouping and Aligning Shapes
57
+
58
+ The `Group` class acts a container that has control over its children positions, so you can align and layout shapes easily with a procedural API.
59
+
60
+ ```{python}
61
+ from tesserax import Canvas, Rect, Circle, Square, Group
62
+
63
+ with Canvas() as canvas:
64
+ with Group() as g:
65
+ Rect(100, 50, fill="lightblue")
66
+ Circle(30, fill="salmon")
67
+ Square(40, fill="pink")
68
+
69
+ g.align("vertical", "center").distribute("horizontal", gap=10)
70
+
71
+ canvas.fit(padding=10).display()
72
+
73
+ ```
74
+
56
75
  ## Simplifying with Layouts
57
76
 
58
- Manually calculating offsets (like the `150, 25` above) becomes tedious in complex diagrams. **Layouts** automate this positioning. The `Row` layout arranges its children horizontally with an optional `gap`.
77
+ Manually calculating offsets or calling procedural alignment becomes tedious in complex diagrams. **Layouts** automate this positioning. The `Row` layout arranges its children horizontally with an optional `gap`. The `Column` does so vertically.
59
78
 
60
79
  ```{python}
61
80
  from tesserax import Canvas, Rect, Circle
@@ -63,9 +82,10 @@ from tesserax.layout import Row
63
82
 
64
83
  with Canvas() as canvas:
65
84
  # Row is also a context manager
66
- with Row(gap=20):
85
+ with Row(gap=10):
67
86
  Rect(100, 50, fill="lightblue")
68
87
  Circle(30, fill="salmon")
88
+ Square(40, fill="pink")
69
89
 
70
90
  canvas.fit(padding=10).display()
71
91
 
@@ -128,7 +148,7 @@ with Canvas() as canvas:
128
148
  # Even though c1 is inside a rotated row, c1.anchor("right")
129
149
  # returns the correct global coordinate for the arrow.
130
150
  Arrow(
131
- c1.anchor("top").dy(5),
151
+ c1.anchor("top").dy(-5),
132
152
  target.anchor("left").dx(-5),
133
153
  stroke="grey",
134
154
  width=2,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tesserax"
3
- version = "0.5.1"
3
+ version = "0.5.2"
4
4
  description = "A pure-Python library for rendering professional CS graphics."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -13,4 +13,4 @@ from .base import (
13
13
  Text,
14
14
  )
15
15
 
16
- __version__ = "0.5.1"
16
+ __version__ = "0.5.2"
@@ -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 ""
@@ -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):
@@ -1790,7 +1790,7 @@ wheels = [
1790
1790
 
1791
1791
  [[package]]
1792
1792
  name = "tesserax"
1793
- version = "0.5.1"
1793
+ version = "0.5.2"
1794
1794
  source = { editable = "." }
1795
1795
 
1796
1796
  [package.dev-dependencies]
@@ -1,14 +0,0 @@
1
- from tesserax import Canvas, Rect, Arrow, Circle
2
- from tesserax.layout import Row
3
-
4
- # Initialize a canvas for an array visualization
5
- with Canvas() as canvas:
6
- # Create two objects in a row layout
7
- with Row(gap=50):
8
- circle = Circle(20)
9
- rect = Rect(40, 40)
10
-
11
- # Create a pointer using the bounds-to-bounds logic
12
- ptr = Arrow(circle.anchor("right").dx(5), rect.anchor("left").dx(-5))
13
-
14
- canvas.fit(10).save("quicksort_partition.svg")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes