drawsvg-ui 0.4.0__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.
items/__init__.py ADDED
@@ -0,0 +1,66 @@
1
+ """Modularized item exports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .base import (
6
+ DIVIDER_HANDLE_COLOR,
7
+ DIVIDER_HANDLE_DIAMETER,
8
+ HANDLE_COLOR,
9
+ HANDLE_OFFSET,
10
+ HANDLE_SIZE,
11
+ HandleAwareItemMixin,
12
+ ResizableItem,
13
+ ResizeHandle,
14
+ RotationHandle,
15
+ build_curvy_bracket_path,
16
+ snap_to_grid,
17
+ _should_draw_selection,
18
+ )
19
+ from .labels import ShapeLabelMixin
20
+ from .shapes import (
21
+ BlockArrowHandle,
22
+ BlockArrowItem,
23
+ CurvyBracketItem,
24
+ DiamondItem,
25
+ EllipseItem,
26
+ LineHandle,
27
+ LineItem,
28
+ RectItem,
29
+ SplitDividerHandle,
30
+ SplitRoundedRectItem,
31
+ TriangleItem,
32
+ )
33
+ from .text import GroupItem, TextItem
34
+ from .widgets import FolderTreeBranchDot, FolderTreeItem, FolderTreeNode
35
+
36
+ __all__ = [
37
+ "BlockArrowHandle",
38
+ "BlockArrowItem",
39
+ "CurvyBracketItem",
40
+ "DiamondItem",
41
+ "DIVIDER_HANDLE_COLOR",
42
+ "DIVIDER_HANDLE_DIAMETER",
43
+ "EllipseItem",
44
+ "FolderTreeBranchDot",
45
+ "FolderTreeItem",
46
+ "FolderTreeNode",
47
+ "GroupItem",
48
+ "HANDLE_COLOR",
49
+ "HANDLE_OFFSET",
50
+ "HANDLE_SIZE",
51
+ "HandleAwareItemMixin",
52
+ "LineHandle",
53
+ "LineItem",
54
+ "RectItem",
55
+ "ResizableItem",
56
+ "ResizeHandle",
57
+ "RotationHandle",
58
+ "ShapeLabelMixin",
59
+ "SplitDividerHandle",
60
+ "SplitRoundedRectItem",
61
+ "TextItem",
62
+ "TriangleItem",
63
+ "build_curvy_bracket_path",
64
+ "snap_to_grid",
65
+ "_should_draw_selection",
66
+ ]
items/base.py ADDED
@@ -0,0 +1,606 @@
1
+ """Core utilities, mixins, and shared handles for interactive items."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from typing import Any, TYPE_CHECKING
7
+
8
+ from PySide6 import QtCore, QtGui, QtWidgets
9
+
10
+ HANDLE_COLOR = QtGui.QColor("#14b5ff")
11
+ HANDLE_SIZE = 8.0
12
+ HANDLE_OFFSET = 10.0
13
+ DIVIDER_HANDLE_COLOR = QtGui.QColor("#d28b00")
14
+ DIVIDER_HANDLE_DIAMETER = 10.0
15
+
16
+
17
+ if TYPE_CHECKING: # pragma: no cover - only for type checkers
18
+ from .shapes.polygons import DiamondItem, TriangleItem
19
+
20
+
21
+ def _origin_component(origin: Any, axis: str) -> float:
22
+ attr = getattr(origin, axis, None)
23
+ if callable(attr):
24
+ try:
25
+ return float(attr())
26
+ except TypeError:
27
+ pass
28
+ if attr is not None:
29
+ return float(attr)
30
+ return 0.0
31
+
32
+
33
+ def _grid_origin(view: QtWidgets.QGraphicsView) -> tuple[float, float]:
34
+ origin = getattr(view, "_master_origin", None)
35
+ if isinstance(origin, QtCore.QPointF):
36
+ return origin.x(), origin.y()
37
+ if origin is None:
38
+ return 0.0, 0.0
39
+ return _origin_component(origin, "x"), _origin_component(origin, "y")
40
+
41
+
42
+ def _snap_component(value: float, spacing: float, origin: float) -> float:
43
+ if spacing <= 0.0:
44
+ return value
45
+ return round((value - origin) / spacing) * spacing + origin
46
+
47
+
48
+ def snap_to_grid(item: QtWidgets.QGraphicsItem, pos: QtCore.QPointF) -> QtCore.QPointF:
49
+ scene = item.scene()
50
+ if scene:
51
+ views = scene.views()
52
+ if views:
53
+ view = views[0]
54
+ spacing = float(getattr(view, "_grid_size_min", 10.0))
55
+ ox, oy = _grid_origin(view)
56
+ x = _snap_component(pos.x(), spacing, ox)
57
+ y = _snap_component(pos.y(), spacing, oy)
58
+ return QtCore.QPointF(x, y)
59
+ return pos
60
+
61
+
62
+ def _local_axis_units(item: QtWidgets.QGraphicsItem) -> tuple[QtCore.QPointF, QtCore.QPointF]:
63
+ """Unit vectors for the local +X and +Y axes in scene space."""
64
+
65
+ transform = item.sceneTransform()
66
+ ex = transform.map(QtCore.QPointF(1, 0)) - transform.map(QtCore.QPointF(0, 0))
67
+ ey = transform.map(QtCore.QPointF(0, 1)) - transform.map(QtCore.QPointF(0, 0))
68
+ ex_len = math.hypot(ex.x(), ex.y())
69
+ ey_len = math.hypot(ey.x(), ey.y())
70
+ if ex_len == 0 or ey_len == 0:
71
+ return QtCore.QPointF(1, 0), QtCore.QPointF(0, 1)
72
+ return (
73
+ QtCore.QPointF(ex.x() / ex_len, ex.y() / ex_len),
74
+ QtCore.QPointF(ey.x() / ey_len, ey.y() / ey_len),
75
+ )
76
+
77
+
78
+ def _cursor_for_dir_rotated(direction: str, angle_deg: float) -> QtCore.Qt.CursorShape:
79
+ """Return the correct resize cursor for a rotated item."""
80
+
81
+ angle = (angle_deg % 180.0 + 180.0) % 180.0
82
+ swap_hv = 45.0 <= angle < 135.0
83
+
84
+ if direction in ("left", "right"):
85
+ return (
86
+ QtCore.Qt.CursorShape.SizeVerCursor
87
+ if swap_hv
88
+ else QtCore.Qt.CursorShape.SizeHorCursor
89
+ )
90
+ if direction in ("top", "bottom"):
91
+ return (
92
+ QtCore.Qt.CursorShape.SizeHorCursor
93
+ if swap_hv
94
+ else QtCore.Qt.CursorShape.SizeVerCursor
95
+ )
96
+
97
+ if direction in ("top_left", "bottom_right"):
98
+ return (
99
+ QtCore.Qt.CursorShape.SizeBDiagCursor
100
+ if swap_hv
101
+ else QtCore.Qt.CursorShape.SizeFDiagCursor
102
+ )
103
+ return (
104
+ QtCore.Qt.CursorShape.SizeFDiagCursor
105
+ if swap_hv
106
+ else QtCore.Qt.CursorShape.SizeBDiagCursor
107
+ )
108
+
109
+
110
+ def _has_selected_group_parent(item: QtWidgets.QGraphicsItem) -> bool:
111
+ parent = item.parentItem()
112
+ while parent is not None:
113
+ if (
114
+ isinstance(parent, QtWidgets.QGraphicsItemGroup)
115
+ and parent.data(0) == "Group"
116
+ and parent.isSelected()
117
+ ):
118
+ return True
119
+ parent = parent.parentItem()
120
+ return False
121
+
122
+
123
+ def _should_draw_selection(item: QtWidgets.QGraphicsItem) -> bool:
124
+ return item.isSelected() and not _has_selected_group_parent(item)
125
+
126
+
127
+ def build_curvy_bracket_path(w: float, h: float, hook: float) -> QtGui.QPainterPath:
128
+ """Return a right-facing curly bracket path translated to the origin."""
129
+
130
+ w = max(8.0, float(w))
131
+ h = max(40.0, float(h))
132
+ hook = max(6.0, min(float(hook), h * 0.45))
133
+
134
+ rect = QtCore.QRectF(-w / 2.0, -h / 2.0, w, h)
135
+ cx = rect.center().x()
136
+ top = rect.top()
137
+ bottom = rect.bottom()
138
+ mid = rect.center().y()
139
+
140
+ curvature = w * 0.85
141
+ depth = hook * 0.55
142
+
143
+ path = QtGui.QPainterPath()
144
+ path.moveTo(cx - w * 0.48, top + 2.0)
145
+ path.cubicTo(
146
+ cx - w * 0.48 + depth,
147
+ top + 2.0,
148
+ cx - w * 0.12,
149
+ top + hook * 0.25,
150
+ cx + 0.0,
151
+ top + hook,
152
+ )
153
+ path.cubicTo(
154
+ cx + curvature * 0.12,
155
+ top + hook + (h * 0.20),
156
+ cx + curvature * 0.18,
157
+ mid - (h * 0.08),
158
+ cx + w * 0.42,
159
+ mid - 2.0,
160
+ )
161
+ path.lineTo(cx + w * 0.50, mid)
162
+ path.lineTo(cx + w * 0.42, mid + 2.0)
163
+ path.cubicTo(
164
+ cx + curvature * 0.18,
165
+ mid + (h * 0.08),
166
+ cx + curvature * 0.12,
167
+ bottom - hook - (h * 0.20),
168
+ cx + 0.0,
169
+ bottom - hook,
170
+ )
171
+ path.cubicTo(
172
+ cx - w * 0.12,
173
+ bottom - hook * 0.25,
174
+ cx - w * 0.48 + depth,
175
+ bottom - 2.0,
176
+ cx - w * 0.48,
177
+ bottom - 2.0,
178
+ )
179
+
180
+ path.translate(w / 2.0, h / 2.0)
181
+ return path
182
+
183
+
184
+ class HandleAwareItemMixin:
185
+ """Shared ``itemChange`` implementation for items with interactive handles."""
186
+
187
+ def _snap_position_value(self, value):
188
+ if isinstance(value, QtCore.QPointF):
189
+ mods = QtWidgets.QApplication.keyboardModifiers()
190
+ if not mods & QtCore.Qt.KeyboardModifier.AltModifier:
191
+ return snap_to_grid(self, value)
192
+ return value
193
+
194
+ def _handle_selection_changed(self, selected: bool) -> None:
195
+ if selected and not _has_selected_group_parent(self):
196
+ self.show_handles()
197
+ else:
198
+ self.hide_handles()
199
+
200
+ def itemChange(self, change, value): # type: ignore[override]
201
+ if change == QtWidgets.QGraphicsItem.GraphicsItemChange.ItemPositionChange:
202
+ value = self._snap_position_value(value)
203
+ elif change == QtWidgets.QGraphicsItem.GraphicsItemChange.ItemSelectedHasChanged:
204
+ self._handle_selection_changed(bool(value))
205
+ elif change in (
206
+ QtWidgets.QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged,
207
+ QtWidgets.QGraphicsItem.GraphicsItemChange.ItemTransformHasChanged,
208
+ ):
209
+ if _should_draw_selection(self):
210
+ self.update_handles()
211
+ return super().itemChange(change, value) # type: ignore[misc]
212
+
213
+
214
+ class ResizeHandle(QtWidgets.QGraphicsEllipseItem):
215
+ """Interactive resize handle."""
216
+
217
+ def __init__(self, parent: QtWidgets.QGraphicsItem, direction: str):
218
+ super().__init__(
219
+ -HANDLE_SIZE / 2.0,
220
+ -HANDLE_SIZE / 2.0,
221
+ HANDLE_SIZE,
222
+ HANDLE_SIZE,
223
+ parent,
224
+ )
225
+ self.setBrush(HANDLE_COLOR)
226
+ self.setPen(QtGui.QPen(QtCore.Qt.PenStyle.NoPen))
227
+ self.setAcceptedMouseButtons(QtCore.Qt.MouseButton.LeftButton)
228
+ self._direction = direction
229
+ self.setFlag(QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIgnoresTransformations)
230
+
231
+ self._start_pos_scene: QtCore.QPointF | None = None
232
+ self._parent_start_pos: QtCore.QPointF | None = None
233
+ self._parent_was_movable = False
234
+ self._start_rx = 0.0
235
+ self._start_ry = 0.0
236
+ self._w0 = 0.0
237
+ self._h0 = 0.0
238
+ self._ex_u = QtCore.QPointF(1, 0)
239
+ self._ey_u = QtCore.QPointF(0, 1)
240
+
241
+ rotation = getattr(parent, "rotation", lambda: 0.0)()
242
+ self.setCursor(_cursor_for_dir_rotated(direction, rotation))
243
+
244
+ def mousePressEvent(self, event: QtWidgets.QGraphicsSceneMouseEvent):
245
+ parent = self.parentItem()
246
+ self._start_pos_scene = event.scenePos()
247
+ self._parent_start_pos = QtCore.QPointF(parent.pos())
248
+ self._ex_u, self._ey_u = _local_axis_units(parent)
249
+
250
+ if isinstance(parent, (QtWidgets.QGraphicsRectItem, QtWidgets.QGraphicsEllipseItem)):
251
+ rect = parent.rect()
252
+ self._w0, self._h0 = rect.width(), rect.height()
253
+ self._start_rx = getattr(parent, "rx", 0.0)
254
+ self._start_ry = getattr(parent, "ry", 0.0)
255
+ else:
256
+ from .shapes.polygons import DiamondItem, TriangleItem # local import to avoid cycles
257
+
258
+ if isinstance(parent, (TriangleItem, DiamondItem)):
259
+ self._w0, self._h0 = parent._w, parent._h # type: ignore[attr-defined]
260
+ else:
261
+ bounds = parent.boundingRect()
262
+ self._w0, self._h0 = bounds.width(), bounds.height()
263
+
264
+ flags = parent.flags()
265
+ self._parent_was_movable = bool(
266
+ flags & QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIsMovable
267
+ )
268
+ if self._parent_was_movable:
269
+ parent.setFlag(QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIsMovable, False)
270
+ event.accept()
271
+
272
+ def mouseMoveEvent(self, event: QtWidgets.QGraphicsSceneMouseEvent):
273
+ if self._start_pos_scene is None:
274
+ event.ignore()
275
+ return
276
+
277
+ parent = self.parentItem()
278
+ scene_pos = event.scenePos()
279
+ snap_scene = not (event.modifiers() & QtCore.Qt.KeyboardModifier.AltModifier)
280
+ if snap_scene:
281
+ scene_pos = snap_to_grid(self, scene_pos)
282
+
283
+ delta_scene = scene_pos - self._start_pos_scene
284
+ dx_local = delta_scene.x() * self._ex_u.x() + delta_scene.y() * self._ex_u.y()
285
+ dy_local = delta_scene.x() * self._ey_u.x() + delta_scene.y() * self._ey_u.y()
286
+
287
+ min_w, min_h = 10.0, 10.0
288
+
289
+ new_w, new_h = self._w0, self._h0
290
+ shift_x = 0.0
291
+ shift_y = 0.0
292
+
293
+ if self._direction == "right":
294
+ new_w = max(min_w, self._w0 + dx_local)
295
+ elif self._direction == "left":
296
+ new_w_raw = self._w0 - dx_local
297
+ new_w = max(min_w, new_w_raw)
298
+ dx_eff = self._w0 - new_w
299
+ shift_x = dx_eff
300
+ elif self._direction == "bottom":
301
+ new_h = max(min_h, self._h0 + dy_local)
302
+ elif self._direction == "top":
303
+ new_h_raw = self._h0 - dy_local
304
+ new_h = max(min_h, new_h_raw)
305
+ dy_eff = self._h0 - new_h
306
+ shift_y = dy_eff
307
+ elif self._direction == "top_left":
308
+ new_w_raw = self._w0 - dx_local
309
+ new_w = max(min_w, new_w_raw)
310
+ dx_eff = self._w0 - new_w
311
+ shift_x = dx_eff
312
+ new_h_raw = self._h0 - dy_local
313
+ new_h = max(min_h, new_h_raw)
314
+ dy_eff = self._h0 - new_h
315
+ shift_y = dy_eff
316
+ elif self._direction == "top_right":
317
+ new_w = max(min_w, self._w0 + dx_local)
318
+ new_h_raw = self._h0 - dy_local
319
+ new_h = max(min_h, new_h_raw)
320
+ dy_eff = self._h0 - new_h
321
+ shift_y = dy_eff
322
+ elif self._direction == "bottom_left":
323
+ new_w_raw = self._w0 - dx_local
324
+ new_w = max(min_w, new_w_raw)
325
+ dx_eff = self._w0 - new_w
326
+ shift_x = dx_eff
327
+ new_h = max(min_h, self._h0 + dy_local)
328
+ elif self._direction == "bottom_right":
329
+ new_w = max(min_w, self._w0 + dx_local)
330
+ new_h = max(min_h, self._h0 + dy_local)
331
+
332
+ if snap_scene:
333
+ grid = 10
334
+ scene = parent.scene()
335
+ if scene and scene.views():
336
+ grid = getattr(scene.views()[0], "_grid_size_min", 10)
337
+
338
+ def snap(value: float) -> float:
339
+ return round(value / grid) * grid
340
+
341
+ if "left" in self._direction or "right" in self._direction:
342
+ new_w = max(min_w, snap(new_w))
343
+ if self._direction in ("left", "top_left", "bottom_left"):
344
+ shift_x = self._w0 - new_w
345
+ if "top" in self._direction or "bottom" in self._direction:
346
+ new_h = max(min_h, snap(new_h))
347
+ if self._direction in ("top", "top_left", "top_right"):
348
+ shift_y = self._h0 - new_h
349
+
350
+ if isinstance(parent, QtWidgets.QGraphicsRectItem):
351
+ parent.setRect(0, 0, new_w, new_h)
352
+ if hasattr(parent, "rx") and hasattr(parent, "ry"):
353
+ sx = new_w / (self._w0 or 1.0)
354
+ sy = new_h / (self._h0 or 1.0)
355
+ scale = min(sx, sy)
356
+ max_r = min(new_w, new_h) / 2.0
357
+ new_r = min(self._start_rx, self._start_ry) * scale
358
+ parent.rx = parent.ry = min(new_r, max_r, 50.0)
359
+ elif isinstance(parent, QtWidgets.QGraphicsEllipseItem):
360
+ parent.setRect(0, 0, new_w, new_h)
361
+ elif hasattr(parent, "set_size") and callable(getattr(parent, "set_size", None)):
362
+ parent.set_size(new_w, new_h, adjust_origin=False) # type: ignore[attr-defined]
363
+ else:
364
+ bounds = parent.boundingRect()
365
+ sx = new_w / (bounds.width() or 1.0)
366
+ sy = new_h / (bounds.height() or 1.0)
367
+ parent.setScale(max(sx, sy))
368
+
369
+ if shift_x or shift_y:
370
+ delta_scene = QtCore.QPointF(
371
+ shift_x * self._ex_u.x() + shift_y * self._ey_u.x(),
372
+ shift_x * self._ex_u.y() + shift_y * self._ey_u.y(),
373
+ )
374
+ parent.setPos(self._parent_start_pos + delta_scene)
375
+ else:
376
+ parent.setPos(self._parent_start_pos)
377
+
378
+ if hasattr(parent, "update_handles"):
379
+ parent.update_handles()
380
+ event.accept()
381
+
382
+ def mouseReleaseEvent(self, event: QtWidgets.QGraphicsSceneMouseEvent):
383
+ parent = self.parentItem()
384
+ bounds = parent.boundingRect()
385
+ old_top_left = parent.mapToScene(QtCore.QPointF(0, 0))
386
+ parent.setTransformOriginPoint(bounds.center())
387
+ new_top_left = parent.mapToScene(QtCore.QPointF(0, 0))
388
+ parent.setPos(parent.pos() + (old_top_left - new_top_left))
389
+
390
+ if self._parent_was_movable:
391
+ parent.setFlag(QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIsMovable, True)
392
+ self._parent_was_movable = False
393
+
394
+ self._start_pos_scene = None
395
+ event.accept()
396
+
397
+
398
+ class RotationHandle(QtWidgets.QGraphicsPixmapItem):
399
+ """Handle for rotating an item."""
400
+
401
+ def __init__(self, parent: QtWidgets.QGraphicsItem):
402
+ pix = QtGui.QPixmap(20, 20)
403
+ pix.fill(QtCore.Qt.GlobalColor.transparent)
404
+ painter = QtGui.QPainter(pix)
405
+ painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
406
+ pen = QtGui.QPen(HANDLE_COLOR)
407
+ pen.setWidth(2)
408
+ painter.setPen(pen)
409
+ rect = QtCore.QRectF(5, 5, 10, 10)
410
+ painter.drawArc(rect, 30 * 16, 300 * 16)
411
+ path = QtGui.QPainterPath()
412
+ path.moveTo(15, 8)
413
+ path.lineTo(11, 8)
414
+ path.lineTo(13, 4)
415
+ path.closeSubpath()
416
+ painter.fillPath(path, HANDLE_COLOR)
417
+ painter.end()
418
+
419
+ super().__init__(pix, parent)
420
+ self.setOffset(-pix.width() / 2.0, -pix.height() / 2.0)
421
+ self.setShapeMode(QtWidgets.QGraphicsPixmapItem.ShapeMode.BoundingRectShape)
422
+ self.setAcceptedMouseButtons(QtCore.Qt.MouseButton.LeftButton)
423
+ self.setCursor(QtCore.Qt.CursorShape.OpenHandCursor)
424
+ self.setFlag(QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIgnoresTransformations)
425
+ self._start_angle = None
426
+ self._start_rotation = 0.0
427
+ self._center = QtCore.QPointF()
428
+ self._parent_was_movable = False
429
+ self._angle_label: QtWidgets.QGraphicsSimpleTextItem | None = None
430
+ self._angle_label_bg: QtWidgets.QGraphicsRectItem | None = None
431
+
432
+ def mousePressEvent(self, event: QtWidgets.QGraphicsSceneMouseEvent):
433
+ parent = self.parentItem()
434
+ pos = event.scenePos()
435
+ self._center = parent.sceneBoundingRect().center()
436
+ self._start_angle = math.degrees(
437
+ math.atan2(pos.y() - self._center.y(), pos.x() - self._center.x())
438
+ )
439
+ self._start_rotation = parent.rotation()
440
+ flags = parent.flags()
441
+ self._parent_was_movable = bool(
442
+ flags & QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIsMovable
443
+ )
444
+ if self._parent_was_movable:
445
+ parent.setFlag(QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIsMovable, False)
446
+ self.setCursor(QtCore.Qt.CursorShape.ClosedHandCursor)
447
+ if parent.scene():
448
+ scene = parent.scene()
449
+ if self._angle_label is None:
450
+ self._angle_label = QtWidgets.QGraphicsSimpleTextItem()
451
+ self._angle_label.setZValue(1001)
452
+ scene.addItem(self._angle_label)
453
+ if self._angle_label_bg is None:
454
+ self._angle_label_bg = QtWidgets.QGraphicsRectItem()
455
+ self._angle_label_bg.setBrush(QtGui.QColor(220, 220, 220))
456
+ self._angle_label_bg.setPen(QtGui.QPen(QtCore.Qt.PenStyle.NoPen))
457
+ self._angle_label_bg.setZValue(1000)
458
+ scene.addItem(self._angle_label_bg)
459
+ self._update_label(parent.rotation())
460
+ event.accept()
461
+
462
+ def mouseMoveEvent(self, event: QtWidgets.QGraphicsSceneMouseEvent):
463
+ if self._start_angle is None:
464
+ event.ignore()
465
+ return
466
+ pos = event.scenePos()
467
+ angle = math.degrees(
468
+ math.atan2(pos.y() - self._center.y(), pos.x() - self._center.x())
469
+ )
470
+ delta = angle - self._start_angle
471
+ parent = self.parentItem()
472
+ new_angle = self._start_rotation + delta
473
+ mods = QtWidgets.QApplication.keyboardModifiers()
474
+ if not mods & QtCore.Qt.KeyboardModifier.AltModifier:
475
+ new_angle = round(new_angle / 5.0) * 5.0
476
+ parent.setRotation(new_angle)
477
+ self._update_label(parent.rotation())
478
+ if hasattr(parent, "update_handles"):
479
+ parent.update_handles()
480
+ event.accept()
481
+
482
+ def mouseReleaseEvent(self, event: QtWidgets.QGraphicsSceneMouseEvent):
483
+ parent = self.parentItem()
484
+ if self._parent_was_movable:
485
+ parent.setFlag(QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIsMovable, True)
486
+ self._parent_was_movable = False
487
+ self._start_angle = None
488
+ if parent.scene():
489
+ scene = parent.scene()
490
+ if self._angle_label:
491
+ scene.removeItem(self._angle_label)
492
+ if self._angle_label_bg:
493
+ scene.removeItem(self._angle_label_bg)
494
+ self._angle_label = None
495
+ self._angle_label_bg = None
496
+ self.setCursor(QtCore.Qt.CursorShape.OpenHandCursor)
497
+ event.accept()
498
+
499
+ def _update_label(self, angle: float) -> None:
500
+ if not self._angle_label:
501
+ return
502
+ self._angle_label.setText(f"{angle:.1f}\N{DEGREE SIGN}")
503
+ parent = self.parentItem()
504
+ scene_rect = parent.mapToScene(parent.boundingRect()).boundingRect()
505
+ pos = QtCore.QPointF(scene_rect.center().x(), scene_rect.bottom() + 25)
506
+ br = self._angle_label.boundingRect()
507
+ self._angle_label.setPos(pos.x() - br.width() / 2.0, pos.y())
508
+ if self._angle_label_bg:
509
+ padding = 2.0
510
+ rect = QtCore.QRectF(
511
+ self._angle_label.pos().x() - padding,
512
+ self._angle_label.pos().y() - padding,
513
+ br.width() + 2 * padding,
514
+ br.height() + 2 * padding,
515
+ )
516
+ self._angle_label_bg.setRect(rect)
517
+
518
+
519
+ class ResizableItem(HandleAwareItemMixin):
520
+ """Mixin that adds eight resize handles and a rotation handle."""
521
+
522
+ def __init__(self):
523
+ self._handles: list[ResizeHandle] = []
524
+ self._rotation_handle: RotationHandle | None = None
525
+
526
+ def _handle_rect(self) -> QtCore.QRectF:
527
+ return self.boundingRect()
528
+
529
+ def _ensure_handles(self):
530
+ if self._handles:
531
+ return
532
+ directions = [
533
+ "top_left",
534
+ "top",
535
+ "top_right",
536
+ "right",
537
+ "bottom_right",
538
+ "bottom",
539
+ "bottom_left",
540
+ "left",
541
+ ]
542
+ for direction in directions:
543
+ handle = ResizeHandle(self, direction)
544
+ handle.hide()
545
+ self._handles.append(handle)
546
+ self._rotation_handle = RotationHandle(self)
547
+ self._rotation_handle.hide()
548
+
549
+ def update_handles(self):
550
+ self._ensure_handles()
551
+ rect = self._handle_rect()
552
+ if rect.isNull():
553
+ rect = self.boundingRect()
554
+ scale = self.scale() or 1.0
555
+ offset = HANDLE_OFFSET / scale
556
+ points = [
557
+ rect.topLeft() - QtCore.QPointF(offset, offset),
558
+ QtCore.QPointF(rect.center().x(), rect.top() - offset),
559
+ rect.topRight() + QtCore.QPointF(offset, -offset),
560
+ QtCore.QPointF(rect.right() + offset, rect.center().y()),
561
+ rect.bottomRight() + QtCore.QPointF(offset, offset),
562
+ QtCore.QPointF(rect.center().x(), rect.bottom() + offset),
563
+ rect.bottomLeft() + QtCore.QPointF(-offset, offset),
564
+ QtCore.QPointF(rect.left() - offset, rect.center().y()),
565
+ ]
566
+ for point, handle in zip(points, self._handles):
567
+ handle.setPos(point)
568
+ handle.setCursor(
569
+ _cursor_for_dir_rotated(
570
+ handle._direction,
571
+ getattr(self, "rotation", lambda: 0.0)(),
572
+ )
573
+ )
574
+ if self._rotation_handle:
575
+ rot_offset = (HANDLE_OFFSET + 15.0) / scale
576
+ rotation_vector = QtCore.QPointF(rot_offset, -rot_offset)
577
+ self._rotation_handle.setPos(rect.topRight() + rotation_vector)
578
+
579
+ def show_handles(self):
580
+ self.update_handles()
581
+ for handle in self._handles:
582
+ handle.show()
583
+ if self._rotation_handle:
584
+ self._rotation_handle.show()
585
+
586
+ def hide_handles(self):
587
+ for handle in self._handles:
588
+ handle.hide()
589
+ if self._rotation_handle:
590
+ self._rotation_handle.hide()
591
+
592
+
593
+ __all__ = [
594
+ "DIVIDER_HANDLE_COLOR",
595
+ "DIVIDER_HANDLE_DIAMETER",
596
+ "HANDLE_COLOR",
597
+ "HANDLE_OFFSET",
598
+ "HANDLE_SIZE",
599
+ "HandleAwareItemMixin",
600
+ "ResizableItem",
601
+ "ResizeHandle",
602
+ "RotationHandle",
603
+ "build_curvy_bracket_path",
604
+ "_should_draw_selection",
605
+ "snap_to_grid",
606
+ ]