py2max 0.2.1__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.
- py2max/__init__.py +67 -0
- py2max/__main__.py +6 -0
- py2max/cli.py +1251 -0
- py2max/core/__init__.py +39 -0
- py2max/core/abstract.py +146 -0
- py2max/core/box.py +231 -0
- py2max/core/common.py +19 -0
- py2max/core/patcher.py +1658 -0
- py2max/core/patchline.py +68 -0
- py2max/exceptions.py +385 -0
- py2max/export/__init__.py +20 -0
- py2max/export/converters.py +345 -0
- py2max/export/svg.py +393 -0
- py2max/layout/__init__.py +26 -0
- py2max/layout/base.py +463 -0
- py2max/layout/flow.py +405 -0
- py2max/layout/grid.py +374 -0
- py2max/layout/matrix.py +628 -0
- py2max/log.py +338 -0
- py2max/maxref/__init__.py +78 -0
- py2max/maxref/category.py +163 -0
- py2max/maxref/db.py +1082 -0
- py2max/maxref/legacy.py +324 -0
- py2max/maxref/parser.py +703 -0
- py2max/py.typed +0 -0
- py2max/server/__init__.py +54 -0
- py2max/server/client.py +295 -0
- py2max/server/inline.py +312 -0
- py2max/server/repl.py +561 -0
- py2max/server/rpc.py +240 -0
- py2max/server/websocket.py +997 -0
- py2max/static/cola.min.js +4 -0
- py2max/static/d3.v7.min.js +2 -0
- py2max/static/dagre-bundle.js +328 -0
- py2max/static/elk.bundled.js +6663 -0
- py2max/static/index.html +168 -0
- py2max/static/interactive.html +589 -0
- py2max/static/interactive.js +2111 -0
- py2max/static/live-preview.js +324 -0
- py2max/static/svg.min.js +13 -0
- py2max/static/svg.min.js.map +1 -0
- py2max/transformers.py +168 -0
- py2max/utils.py +83 -0
- py2max-0.2.1.dist-info/METADATA +390 -0
- py2max-0.2.1.dist-info/RECORD +48 -0
- py2max-0.2.1.dist-info/WHEEL +4 -0
- py2max-0.2.1.dist-info/entry_points.txt +3 -0
- py2max-0.2.1.dist-info/licenses/LICENSE +19 -0
py2max/layout/base.py
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
"""Base layout manager for py2max patches.
|
|
2
|
+
|
|
3
|
+
This module provides the base LayoutManager class that all layout managers inherit from.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Dict, Optional, Set
|
|
7
|
+
|
|
8
|
+
from py2max.core.abstract import AbstractLayoutManager, AbstractPatcher
|
|
9
|
+
from py2max.core.common import Rect
|
|
10
|
+
from py2max.maxref import MAXCLASS_DEFAULTS
|
|
11
|
+
|
|
12
|
+
# Threshold for incremental vs full layout (30% of total objects)
|
|
13
|
+
INCREMENTAL_THRESHOLD = 0.3
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LayoutManager(AbstractLayoutManager):
|
|
17
|
+
"""Basic horizontal layout manager.
|
|
18
|
+
|
|
19
|
+
Provides simple left-to-right object positioning with wrapping.
|
|
20
|
+
This is a legacy layout manager; consider using GridLayoutManager
|
|
21
|
+
for new projects.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
parent: The parent patcher object.
|
|
25
|
+
pad: Padding between objects (default: 48.0).
|
|
26
|
+
box_width: Default object width (default: 66.0).
|
|
27
|
+
box_height: Default object height (default: 22.0).
|
|
28
|
+
comment_pad: Padding for comments (default: 2).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
DEFAULT_PAD = 1.5 * 32.0
|
|
32
|
+
DEFAULT_BOX_WIDTH = 66.0
|
|
33
|
+
DEFAULT_BOX_HEIGHT = 22.0
|
|
34
|
+
DEFAULT_COMMENT_PAD = 2
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
parent: AbstractPatcher,
|
|
39
|
+
pad: Optional[int] = None,
|
|
40
|
+
box_width: Optional[int] = None,
|
|
41
|
+
box_height: Optional[int] = None,
|
|
42
|
+
comment_pad: Optional[int] = None,
|
|
43
|
+
):
|
|
44
|
+
self.parent = parent
|
|
45
|
+
self.pad = pad or self.DEFAULT_PAD
|
|
46
|
+
self.box_width = box_width or self.DEFAULT_BOX_WIDTH
|
|
47
|
+
self.box_height = box_height or self.DEFAULT_BOX_HEIGHT
|
|
48
|
+
self.comment_pad = comment_pad or self.DEFAULT_COMMENT_PAD
|
|
49
|
+
self.x_layout_counter = 0
|
|
50
|
+
self.y_layout_counter = 0
|
|
51
|
+
self.prior_rect = None
|
|
52
|
+
self.mclass_rect = None
|
|
53
|
+
|
|
54
|
+
def get_rect_from_maxclass(self, maxclass: str) -> Optional[Rect]:
|
|
55
|
+
"""Retrieve default rectangle for a Max object class.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
maxclass: The Max object class name.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Default Rect for the object class, or None if not found.
|
|
62
|
+
"""
|
|
63
|
+
try:
|
|
64
|
+
return MAXCLASS_DEFAULTS[maxclass]["patching_rect"]
|
|
65
|
+
except KeyError:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
def get_absolute_pos(self, rect: Rect) -> Rect:
|
|
69
|
+
"""returns an absolute position for the object"""
|
|
70
|
+
x, y, w, h = rect
|
|
71
|
+
|
|
72
|
+
pad = self.pad
|
|
73
|
+
|
|
74
|
+
if x > 0.5 * self.parent.width:
|
|
75
|
+
x1 = x - (w + pad)
|
|
76
|
+
x = x1 - (x1 - self.parent.width) if x1 > self.parent.width else x1
|
|
77
|
+
else:
|
|
78
|
+
x1 = x + pad
|
|
79
|
+
|
|
80
|
+
y1 = y - (h + pad)
|
|
81
|
+
y = y1 - (y1 - self.parent.height) if y1 > self.parent.height else y1
|
|
82
|
+
|
|
83
|
+
return Rect(x, y, w, h)
|
|
84
|
+
|
|
85
|
+
def get_relative_pos(self, rect: Rect) -> Rect:
|
|
86
|
+
"""returns a relative position for the object"""
|
|
87
|
+
# Default implementation returns the same rect
|
|
88
|
+
return rect
|
|
89
|
+
|
|
90
|
+
def get_pos(self, maxclass: Optional[str] = None) -> Rect:
|
|
91
|
+
"""Get the next position for object placement.
|
|
92
|
+
|
|
93
|
+
Calculates the next position for an object based on the current
|
|
94
|
+
layout state and optional object class defaults.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
maxclass: Optional Max object class for size defaults.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Rect specifying the position and size for the next object.
|
|
101
|
+
"""
|
|
102
|
+
x = 0.0
|
|
103
|
+
y = 0.0
|
|
104
|
+
w = self.box_width # 66.0
|
|
105
|
+
h = self.box_height # 22.0
|
|
106
|
+
|
|
107
|
+
if maxclass:
|
|
108
|
+
mclass_rect = self.get_rect_from_maxclass(maxclass)
|
|
109
|
+
if mclass_rect and (mclass_rect.x or mclass_rect.y):
|
|
110
|
+
if mclass_rect.x:
|
|
111
|
+
x = float(mclass_rect.x * self.parent.width)
|
|
112
|
+
if mclass_rect.y:
|
|
113
|
+
y = float(mclass_rect.y * self.parent.height)
|
|
114
|
+
|
|
115
|
+
_rect = Rect(x, y, mclass_rect.w, mclass_rect.h)
|
|
116
|
+
return self.get_absolute_pos(_rect)
|
|
117
|
+
|
|
118
|
+
_rect = Rect(x, y, w, h)
|
|
119
|
+
return self.get_relative_pos(_rect)
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def patcher_rect(self) -> Rect:
|
|
123
|
+
"""return rect coordinates of the parent patcher"""
|
|
124
|
+
return self.parent.rect
|
|
125
|
+
|
|
126
|
+
def above(self, rect: Rect) -> Rect:
|
|
127
|
+
"""Return a position of a comment above the object"""
|
|
128
|
+
x, y, w, h = rect
|
|
129
|
+
return Rect(x, y - h, w, h)
|
|
130
|
+
|
|
131
|
+
def below(self, rect: Rect) -> Rect:
|
|
132
|
+
"""Return a position of a comment below the object"""
|
|
133
|
+
x, y, w, h = rect
|
|
134
|
+
return Rect(x, y + h, w, h)
|
|
135
|
+
|
|
136
|
+
def left(self, rect: Rect) -> Rect:
|
|
137
|
+
"""Return a position of a comment left of the object"""
|
|
138
|
+
x, y, w, h = rect
|
|
139
|
+
return Rect(x - (w + self.comment_pad), y, w, h)
|
|
140
|
+
|
|
141
|
+
def right(self, rect: Rect) -> Rect:
|
|
142
|
+
"""Return a position of a comment right of the object"""
|
|
143
|
+
x, y, w, h = rect
|
|
144
|
+
return Rect(x + (w + self.comment_pad), y, w, h)
|
|
145
|
+
|
|
146
|
+
def prevent_overlaps(self, min_gap: float = 10.0, max_iterations: int = 50) -> int:
|
|
147
|
+
"""Iteratively push overlapping objects apart.
|
|
148
|
+
|
|
149
|
+
This method should be called after layout optimization to ensure
|
|
150
|
+
no objects overlap. It uses an iterative approach where overlapping
|
|
151
|
+
objects are pushed apart in the direction of their center offset.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
min_gap: Minimum gap between objects in pixels.
|
|
155
|
+
max_iterations: Maximum number of iterations to prevent infinite loops.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
Number of iterations performed (0 if no overlaps found).
|
|
159
|
+
"""
|
|
160
|
+
objects = list(self.parent._objects.values())
|
|
161
|
+
if len(objects) < 2:
|
|
162
|
+
return 0
|
|
163
|
+
|
|
164
|
+
iterations_performed = 0
|
|
165
|
+
|
|
166
|
+
for iteration in range(max_iterations):
|
|
167
|
+
moved = False
|
|
168
|
+
|
|
169
|
+
for i, obj1 in enumerate(objects):
|
|
170
|
+
if not hasattr(obj1, "patching_rect"):
|
|
171
|
+
continue
|
|
172
|
+
r1 = obj1.patching_rect
|
|
173
|
+
|
|
174
|
+
for obj2 in objects[i + 1 :]:
|
|
175
|
+
if not hasattr(obj2, "patching_rect"):
|
|
176
|
+
continue
|
|
177
|
+
r2 = obj2.patching_rect
|
|
178
|
+
|
|
179
|
+
# Check for overlap (including minimum gap)
|
|
180
|
+
overlap_x = (r1.x < r2.x + r2.w + min_gap) and (
|
|
181
|
+
r2.x < r1.x + r1.w + min_gap
|
|
182
|
+
)
|
|
183
|
+
overlap_y = (r1.y < r2.y + r2.h + min_gap) and (
|
|
184
|
+
r2.y < r1.y + r1.h + min_gap
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
if overlap_x and overlap_y:
|
|
188
|
+
# Calculate centers
|
|
189
|
+
c1_x = r1.x + r1.w / 2
|
|
190
|
+
c1_y = r1.y + r1.h / 2
|
|
191
|
+
c2_x = r2.x + r2.w / 2
|
|
192
|
+
c2_y = r2.y + r2.h / 2
|
|
193
|
+
|
|
194
|
+
# Direction from obj2 to obj1
|
|
195
|
+
dx = c1_x - c2_x
|
|
196
|
+
dy = c1_y - c2_y
|
|
197
|
+
|
|
198
|
+
# Avoid division by zero
|
|
199
|
+
if abs(dx) < 0.1 and abs(dy) < 0.1:
|
|
200
|
+
dx = 1.0 # Default push direction
|
|
201
|
+
|
|
202
|
+
# Push amount (half the minimum gap)
|
|
203
|
+
push = min_gap / 2
|
|
204
|
+
|
|
205
|
+
# Push in the dominant direction
|
|
206
|
+
if abs(dx) > abs(dy):
|
|
207
|
+
# Push horizontally
|
|
208
|
+
push_dir = 1 if dx > 0 else -1
|
|
209
|
+
new_x1 = r1.x + push * push_dir
|
|
210
|
+
new_x2 = r2.x - push * push_dir
|
|
211
|
+
|
|
212
|
+
# Ensure bounds
|
|
213
|
+
new_x1 = max(
|
|
214
|
+
self.pad,
|
|
215
|
+
min(new_x1, self.parent.width - r1.w - self.pad),
|
|
216
|
+
)
|
|
217
|
+
new_x2 = max(
|
|
218
|
+
self.pad,
|
|
219
|
+
min(new_x2, self.parent.width - r2.w - self.pad),
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
obj1.patching_rect = Rect(new_x1, r1.y, r1.w, r1.h)
|
|
223
|
+
obj2.patching_rect = Rect(new_x2, r2.y, r2.w, r2.h)
|
|
224
|
+
else:
|
|
225
|
+
# Push vertically
|
|
226
|
+
push_dir = 1 if dy > 0 else -1
|
|
227
|
+
new_y1 = r1.y + push * push_dir
|
|
228
|
+
new_y2 = r2.y - push * push_dir
|
|
229
|
+
|
|
230
|
+
# Ensure bounds
|
|
231
|
+
new_y1 = max(
|
|
232
|
+
self.pad,
|
|
233
|
+
min(new_y1, self.parent.height - r1.h - self.pad),
|
|
234
|
+
)
|
|
235
|
+
new_y2 = max(
|
|
236
|
+
self.pad,
|
|
237
|
+
min(new_y2, self.parent.height - r2.h - self.pad),
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
obj1.patching_rect = Rect(r1.x, new_y1, r1.w, r1.h)
|
|
241
|
+
obj2.patching_rect = Rect(r2.x, new_y2, r2.w, r2.h)
|
|
242
|
+
|
|
243
|
+
moved = True
|
|
244
|
+
|
|
245
|
+
if not moved:
|
|
246
|
+
break
|
|
247
|
+
|
|
248
|
+
iterations_performed = iteration + 1
|
|
249
|
+
|
|
250
|
+
return iterations_performed
|
|
251
|
+
|
|
252
|
+
def get_connected_objects(self, obj_ids: Set[str]) -> Set[str]:
|
|
253
|
+
"""Get all objects connected to the given objects via patchlines.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
obj_ids: Set of object IDs to find connections for.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
Set of object IDs that are directly connected to the input objects.
|
|
260
|
+
"""
|
|
261
|
+
connected = set()
|
|
262
|
+
|
|
263
|
+
for line in self.parent._lines:
|
|
264
|
+
src_id, dst_id = line.src, line.dst
|
|
265
|
+
if src_id is None or dst_id is None:
|
|
266
|
+
continue
|
|
267
|
+
|
|
268
|
+
if src_id in obj_ids:
|
|
269
|
+
connected.add(dst_id)
|
|
270
|
+
if dst_id in obj_ids:
|
|
271
|
+
connected.add(src_id)
|
|
272
|
+
|
|
273
|
+
return connected
|
|
274
|
+
|
|
275
|
+
def get_affected_objects(self, changed_objects: Set[str]) -> Set[str]:
|
|
276
|
+
"""Get all objects affected by changes to the given objects.
|
|
277
|
+
|
|
278
|
+
This includes the changed objects themselves plus their immediate
|
|
279
|
+
neighbors (objects connected via patchlines).
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
changed_objects: Set of object IDs that have changed.
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
Set of all affected object IDs.
|
|
286
|
+
"""
|
|
287
|
+
affected = set(changed_objects)
|
|
288
|
+
neighbors = self.get_connected_objects(changed_objects)
|
|
289
|
+
affected.update(neighbors)
|
|
290
|
+
return affected
|
|
291
|
+
|
|
292
|
+
def should_use_incremental(self, changed_objects: Optional[Set[str]]) -> bool:
|
|
293
|
+
"""Determine whether to use incremental or full layout.
|
|
294
|
+
|
|
295
|
+
Uses incremental layout when:
|
|
296
|
+
- changed_objects is provided
|
|
297
|
+
- The number of affected objects is less than INCREMENTAL_THRESHOLD
|
|
298
|
+
of total objects
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
changed_objects: Set of changed object IDs, or None for full layout.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
True if incremental layout should be used.
|
|
305
|
+
"""
|
|
306
|
+
if changed_objects is None:
|
|
307
|
+
return False
|
|
308
|
+
|
|
309
|
+
total_objects = len(self.parent._objects)
|
|
310
|
+
if total_objects == 0:
|
|
311
|
+
return False
|
|
312
|
+
|
|
313
|
+
affected = self.get_affected_objects(changed_objects)
|
|
314
|
+
return len(affected) < total_objects * INCREMENTAL_THRESHOLD
|
|
315
|
+
|
|
316
|
+
def optimize_layout(self, changed_objects: Optional[Set[str]] = None):
|
|
317
|
+
"""Optimize the layout of objects.
|
|
318
|
+
|
|
319
|
+
If changed_objects is provided and the number of affected objects
|
|
320
|
+
is small relative to the total, only those objects and their
|
|
321
|
+
neighbors will be repositioned (incremental layout). Otherwise,
|
|
322
|
+
a full layout recalculation is performed.
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
changed_objects: Optional set of object IDs that have changed.
|
|
326
|
+
If None, performs full layout optimization.
|
|
327
|
+
|
|
328
|
+
Subclasses should override _full_layout() and optionally
|
|
329
|
+
_incremental_layout() to implement specific algorithms.
|
|
330
|
+
"""
|
|
331
|
+
if self.should_use_incremental(changed_objects):
|
|
332
|
+
# changed_objects is guaranteed non-None here since should_use_incremental
|
|
333
|
+
# returns False when changed_objects is None
|
|
334
|
+
assert changed_objects is not None
|
|
335
|
+
affected = self.get_affected_objects(changed_objects)
|
|
336
|
+
self._incremental_layout(affected)
|
|
337
|
+
else:
|
|
338
|
+
self._full_layout()
|
|
339
|
+
|
|
340
|
+
def _full_layout(self):
|
|
341
|
+
"""Perform full layout optimization.
|
|
342
|
+
|
|
343
|
+
Subclasses should override this method to implement their
|
|
344
|
+
full layout algorithm.
|
|
345
|
+
"""
|
|
346
|
+
pass
|
|
347
|
+
|
|
348
|
+
def _incremental_layout(self, affected_objects: Set[str]):
|
|
349
|
+
"""Perform incremental layout optimization for affected objects.
|
|
350
|
+
|
|
351
|
+
This method repositions only the affected objects while keeping
|
|
352
|
+
other objects in their current positions. The default implementation
|
|
353
|
+
finds optimal positions for affected objects that minimize overlap
|
|
354
|
+
with existing objects.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
affected_objects: Set of object IDs to reposition.
|
|
358
|
+
|
|
359
|
+
Subclasses can override this for more sophisticated incremental layouts.
|
|
360
|
+
"""
|
|
361
|
+
if not affected_objects:
|
|
362
|
+
return
|
|
363
|
+
|
|
364
|
+
# Get current positions of non-affected objects (these are fixed)
|
|
365
|
+
fixed_positions: Dict[str, Rect] = {}
|
|
366
|
+
for obj_id, obj in self.parent._objects.items():
|
|
367
|
+
if obj_id not in affected_objects:
|
|
368
|
+
if hasattr(obj, "patching_rect"):
|
|
369
|
+
fixed_positions[obj_id] = obj.patching_rect
|
|
370
|
+
|
|
371
|
+
# For each affected object, find a position that doesn't overlap
|
|
372
|
+
for obj_id in affected_objects:
|
|
373
|
+
if obj_id not in self.parent._objects:
|
|
374
|
+
continue
|
|
375
|
+
|
|
376
|
+
obj = self.parent._objects[obj_id]
|
|
377
|
+
if not hasattr(obj, "patching_rect"):
|
|
378
|
+
continue
|
|
379
|
+
|
|
380
|
+
current_rect = obj.patching_rect
|
|
381
|
+
|
|
382
|
+
# Try to find a nearby position that doesn't overlap
|
|
383
|
+
best_pos = self._find_non_overlapping_position(
|
|
384
|
+
current_rect, fixed_positions, affected_objects
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
if best_pos:
|
|
388
|
+
obj.patching_rect = best_pos
|
|
389
|
+
# Add to fixed positions for subsequent objects
|
|
390
|
+
fixed_positions[obj_id] = best_pos
|
|
391
|
+
|
|
392
|
+
# Final overlap prevention pass
|
|
393
|
+
self.prevent_overlaps()
|
|
394
|
+
|
|
395
|
+
def _find_non_overlapping_position(
|
|
396
|
+
self,
|
|
397
|
+
rect: Rect,
|
|
398
|
+
fixed_positions: Dict[str, Rect],
|
|
399
|
+
exclude_ids: Set[str],
|
|
400
|
+
) -> Optional[Rect]:
|
|
401
|
+
"""Find a position for rect that doesn't overlap with fixed positions.
|
|
402
|
+
|
|
403
|
+
Tries positions in a spiral pattern around the current position.
|
|
404
|
+
|
|
405
|
+
Args:
|
|
406
|
+
rect: The rectangle to position.
|
|
407
|
+
fixed_positions: Dictionary of fixed object positions.
|
|
408
|
+
exclude_ids: Object IDs to exclude from overlap checking.
|
|
409
|
+
|
|
410
|
+
Returns:
|
|
411
|
+
A non-overlapping Rect, or the original rect if no better position found.
|
|
412
|
+
"""
|
|
413
|
+
pad = self.pad
|
|
414
|
+
min_gap = 10.0
|
|
415
|
+
|
|
416
|
+
def overlaps_any(test_rect: Rect) -> bool:
|
|
417
|
+
for obj_id, fixed_rect in fixed_positions.items():
|
|
418
|
+
if obj_id in exclude_ids:
|
|
419
|
+
continue
|
|
420
|
+
# Check overlap
|
|
421
|
+
if (
|
|
422
|
+
test_rect.x < fixed_rect.x + fixed_rect.w + min_gap
|
|
423
|
+
and fixed_rect.x < test_rect.x + test_rect.w + min_gap
|
|
424
|
+
and test_rect.y < fixed_rect.y + fixed_rect.h + min_gap
|
|
425
|
+
and fixed_rect.y < test_rect.y + test_rect.h + min_gap
|
|
426
|
+
):
|
|
427
|
+
return True
|
|
428
|
+
return False
|
|
429
|
+
|
|
430
|
+
# If current position doesn't overlap, keep it
|
|
431
|
+
if not overlaps_any(rect):
|
|
432
|
+
return rect
|
|
433
|
+
|
|
434
|
+
# Try positions in a spiral pattern
|
|
435
|
+
step = pad / 2
|
|
436
|
+
max_radius = max(self.parent.width, self.parent.height) / 2
|
|
437
|
+
|
|
438
|
+
for radius in range(1, int(max_radius / step) + 1):
|
|
439
|
+
offset = radius * step
|
|
440
|
+
# Try 8 directions
|
|
441
|
+
for dx, dy in [
|
|
442
|
+
(offset, 0),
|
|
443
|
+
(-offset, 0),
|
|
444
|
+
(0, offset),
|
|
445
|
+
(0, -offset),
|
|
446
|
+
(offset, offset),
|
|
447
|
+
(-offset, offset),
|
|
448
|
+
(offset, -offset),
|
|
449
|
+
(-offset, -offset),
|
|
450
|
+
]:
|
|
451
|
+
new_x = rect.x + dx
|
|
452
|
+
new_y = rect.y + dy
|
|
453
|
+
|
|
454
|
+
# Ensure within bounds
|
|
455
|
+
new_x = max(pad, min(new_x, self.parent.width - rect.w - pad))
|
|
456
|
+
new_y = max(pad, min(new_y, self.parent.height - rect.h - pad))
|
|
457
|
+
|
|
458
|
+
test_rect = Rect(new_x, new_y, rect.w, rect.h)
|
|
459
|
+
if not overlaps_any(test_rect):
|
|
460
|
+
return test_rect
|
|
461
|
+
|
|
462
|
+
# No non-overlapping position found, return original
|
|
463
|
+
return rect
|