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.
- {tesserax-0.5.1 → tesserax-0.5.2}/PKG-INFO +1 -1
- {tesserax-0.5.1 → tesserax-0.5.2}/docs/core.qmd +23 -3
- {tesserax-0.5.1 → tesserax-0.5.2}/pyproject.toml +1 -1
- {tesserax-0.5.1 → tesserax-0.5.2}/src/tesserax/__init__.py +1 -1
- {tesserax-0.5.1 → tesserax-0.5.2}/src/tesserax/base.py +114 -0
- {tesserax-0.5.1 → tesserax-0.5.2}/src/tesserax/layout.py +37 -58
- {tesserax-0.5.1 → tesserax-0.5.2}/uv.lock +1 -1
- tesserax-0.5.1/examples/basic.py +0 -14
- {tesserax-0.5.1 → tesserax-0.5.2}/.github/workflows/release.yaml +0 -0
- {tesserax-0.5.1 → tesserax-0.5.2}/.github/workflows/tests.yaml +0 -0
- {tesserax-0.5.1 → tesserax-0.5.2}/.gitignore +0 -0
- {tesserax-0.5.1 → tesserax-0.5.2}/.python-version +0 -0
- {tesserax-0.5.1 → tesserax-0.5.2}/.vscode/settings.json +0 -0
- {tesserax-0.5.1 → tesserax-0.5.2}/AGENT.md +0 -0
- {tesserax-0.5.1 → tesserax-0.5.2}/LICENSE +0 -0
- {tesserax-0.5.1 → tesserax-0.5.2}/README.md +0 -0
- {tesserax-0.5.1 → tesserax-0.5.2}/docs/.gitignore +0 -0
- {tesserax-0.5.1 → tesserax-0.5.2}/docs/_quarto.yml +0 -0
- {tesserax-0.5.1 → tesserax-0.5.2}/docs/gallery.qmd +0 -0
- {tesserax-0.5.1 → tesserax-0.5.2}/docs/index.qmd +0 -0
- {tesserax-0.5.1 → tesserax-0.5.2}/docs/styles.css +0 -0
- {tesserax-0.5.1 → tesserax-0.5.2}/makefile +0 -0
- {tesserax-0.5.1 → tesserax-0.5.2}/src/tesserax/align.py +0 -0
- {tesserax-0.5.1 → tesserax-0.5.2}/src/tesserax/canvas.py +0 -0
- {tesserax-0.5.1 → tesserax-0.5.2}/src/tesserax/core.py +0 -0
- {tesserax-0.5.1 → tesserax-0.5.2}/src/tesserax/py.typed +0 -0
- {tesserax-0.5.1 → tesserax-0.5.2}/tests/test_geometry.py +0 -0
- {tesserax-0.5.1 → tesserax-0.5.2}/tests/test_layout.py +0 -0
- {tesserax-0.5.1 → tesserax-0.5.2}/tests/test_shapes.py +0 -0
|
@@ -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
|
|
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=
|
|
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,
|
|
@@ -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.
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
#
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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):
|
tesserax-0.5.1/examples/basic.py
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|