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/labels.py ADDED
@@ -0,0 +1,247 @@
1
+ """Label mixins and helpers shared by shape items."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from PySide6 import QtCore, QtGui, QtWidgets
8
+ from constants import DEFAULT_TEXT_COLOR, DEFAULT_FONT_FAMILY
9
+ if TYPE_CHECKING: # pragma: no cover - only for type checkers
10
+ from .shapes.rects import RectItem
11
+
12
+
13
+ class _ShapeLabelItem(QtWidgets.QGraphicsTextItem):
14
+ def __init__(self, parent: "RectItem") -> None:
15
+ super().__init__("", parent)
16
+ self.setFont(QtGui.QFont(DEFAULT_FONT_FAMILY))
17
+ self.setDefaultTextColor(DEFAULT_TEXT_COLOR)
18
+ self.setVisible(False)
19
+ self.setAcceptedMouseButtons(QtCore.Qt.MouseButton.NoButton)
20
+ self.setTextInteractionFlags(QtCore.Qt.TextInteractionFlag.NoTextInteraction)
21
+ self.setFlag(QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIsSelectable, False)
22
+ self.setFlag(QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIsMovable, False)
23
+ self.setFlag(QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIgnoresTransformations, False)
24
+ self.setFlag(QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIsFocusable, True)
25
+ self.setZValue(1.0)
26
+ doc = self.document()
27
+ doc.setDocumentMargin(0.0)
28
+ text_option = doc.defaultTextOption()
29
+ text_option.setWrapMode(QtGui.QTextOption.WrapAtWordBoundaryOrAnywhere)
30
+ doc.setDefaultTextOption(text_option)
31
+
32
+ def focusOutEvent(self, event: QtGui.QFocusEvent) -> None: # type: ignore[override]
33
+ super().focusOutEvent(event)
34
+ parent = self.parentItem()
35
+ if isinstance(parent, ShapeLabelMixin):
36
+ parent._finish_label_edit()
37
+
38
+
39
+ class ShapeLabelMixin:
40
+ def _init_shape_label(self) -> None:
41
+ self._label_color_override: QtGui.QColor | None = None
42
+ self._label_base_color: QtGui.QColor = QtGui.QColor(DEFAULT_TEXT_COLOR)
43
+ self._label = _ShapeLabelItem(self)
44
+ self._label_h_align = "center"
45
+ self._label_v_align = "middle"
46
+ self._label_padding = 8.0
47
+ self._label.document().contentsChanged.connect(self._update_label_geometry)
48
+ self._apply_label_alignment()
49
+ self._update_label_color()
50
+ self._update_label_geometry()
51
+
52
+ def _label_base_rect(self) -> QtCore.QRectF:
53
+ raise NotImplementedError
54
+
55
+ def label_text(self) -> str:
56
+ return self._label.toPlainText()
57
+
58
+ def set_label_text(self, text: str) -> None:
59
+ self._label.setPlainText(text)
60
+ self._label.setVisible(bool(text.strip()))
61
+ self._update_label_geometry()
62
+
63
+ def has_label(self) -> bool:
64
+ return bool(self._label.toPlainText().strip())
65
+
66
+ def label_alignment(self) -> tuple[str, str]:
67
+ return self._label_h_align, self._label_v_align
68
+
69
+ def set_label_alignment(
70
+ self,
71
+ *,
72
+ horizontal: str | None = None,
73
+ vertical: str | None = None,
74
+ ) -> None:
75
+ valid_h = {"left", "center", "right"}
76
+ valid_v = {"top", "middle", "bottom"}
77
+ changed = False
78
+ if horizontal in valid_h and horizontal != self._label_h_align:
79
+ self._label_h_align = horizontal
80
+ changed = True
81
+ if vertical in valid_v and vertical != self._label_v_align:
82
+ self._label_v_align = vertical
83
+ changed = True
84
+ if changed:
85
+ self._apply_label_alignment()
86
+ self._update_label_geometry()
87
+
88
+ def edit_label(self) -> None:
89
+ self._begin_label_edit()
90
+
91
+ def set_label_font_pixel_size(self, value: float) -> None:
92
+ font = QtGui.QFont(self._label.font())
93
+ font.setPixelSize(int(value))
94
+ self._label.setFont(font)
95
+ self._update_label_geometry()
96
+
97
+ def label_color(self) -> QtGui.QColor:
98
+ return QtGui.QColor(self._label.defaultTextColor())
99
+
100
+ def label_has_custom_color(self) -> bool:
101
+ override = getattr(self, "_label_color_override", None)
102
+ return isinstance(override, QtGui.QColor) and override.isValid()
103
+
104
+ def set_label_color(self, color: QtGui.QColor | str) -> None:
105
+ qcolor = QtGui.QColor(color)
106
+ if not qcolor.isValid():
107
+ return
108
+ self._label_color_override = QtGui.QColor(qcolor)
109
+ self._label.setDefaultTextColor(self._label_color_override)
110
+
111
+ def reset_label_color(
112
+ self,
113
+ *,
114
+ update: bool = True,
115
+ base_color: QtGui.QColor | str | None = None,
116
+ ) -> None:
117
+ self._label_color_override = None
118
+ if base_color is None:
119
+ base = QtGui.QColor(DEFAULT_TEXT_COLOR)
120
+ else:
121
+ base = QtGui.QColor(base_color)
122
+ if not base.isValid():
123
+ base = QtGui.QColor(DEFAULT_TEXT_COLOR)
124
+ self._label_base_color = base
125
+ if update:
126
+ self._update_label_color()
127
+
128
+ def label_item(self) -> QtWidgets.QGraphicsTextItem:
129
+ return self._label
130
+
131
+ def _apply_label_alignment(self) -> None:
132
+ option = self._label.document().defaultTextOption()
133
+ if self._label_h_align == "left":
134
+ option.setAlignment(QtCore.Qt.AlignmentFlag.AlignLeft)
135
+ elif self._label_h_align == "right":
136
+ option.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight)
137
+ else:
138
+ option.setAlignment(QtCore.Qt.AlignmentFlag.AlignHCenter)
139
+ self._label.document().setDefaultTextOption(option)
140
+
141
+ def _label_available_rect(self) -> QtCore.QRectF:
142
+ rect = QtCore.QRectF(self._label_base_rect())
143
+ pad = self._label_padding
144
+ rect.adjust(pad, pad, -pad, -pad)
145
+ if rect.width() <= 0.0:
146
+ rect.setWidth(1.0)
147
+ if rect.height() <= 0.0:
148
+ rect.setHeight(1.0)
149
+ return rect
150
+
151
+ def _update_label_geometry(self) -> None:
152
+ rect = self._label_available_rect()
153
+ self._label.setTextWidth(rect.width())
154
+ br = self._label.boundingRect()
155
+ left = br.left()
156
+ right = br.right()
157
+ top = br.top()
158
+ bottom = br.bottom()
159
+ width = br.width()
160
+ height = br.height()
161
+
162
+ if self._label_h_align == "left":
163
+ x = rect.left() - left
164
+ elif self._label_h_align == "right":
165
+ x = rect.right() - right
166
+ else:
167
+ target_cx = rect.center().x()
168
+ x = target_cx - (left + width / 2.0)
169
+
170
+ if self._label_v_align == "top":
171
+ y = rect.top() - top
172
+ elif self._label_v_align == "bottom":
173
+ y = rect.bottom() - bottom
174
+ else:
175
+ target_cy = rect.center().y()
176
+ y = target_cy - (top + height / 2.0)
177
+
178
+ base_rect = self._label_base_rect()
179
+ min_x = base_rect.left() - left
180
+ max_x = base_rect.right() - right
181
+ min_y = base_rect.top() - top
182
+ max_y = base_rect.bottom() - bottom
183
+ x = max(min_x, min(x, max_x))
184
+ y = max(min_y, min(y, max_y))
185
+ self._label.setPos(x, y)
186
+ self._label.setVisible(self.has_label())
187
+
188
+ def _update_label_color(self) -> None:
189
+ if hasattr(self, "_label") and self._label is not None:
190
+ override = getattr(self, "_label_color_override", None)
191
+ if isinstance(override, QtGui.QColor) and override.isValid():
192
+ target = QtGui.QColor(override)
193
+ else:
194
+ base = getattr(self, "_label_base_color", QtGui.QColor(DEFAULT_TEXT_COLOR))
195
+ target = QtGui.QColor(base)
196
+ self._label.setDefaultTextColor(target)
197
+
198
+ def _begin_label_edit(self) -> None:
199
+ self._label.setVisible(True)
200
+ self._apply_label_alignment()
201
+ self._label.setTextInteractionFlags(QtCore.Qt.TextInteractionFlag.TextEditorInteraction)
202
+ self._label.setAcceptedMouseButtons(QtCore.Qt.MouseButton.LeftButton)
203
+ self._label.setFocus(QtCore.Qt.FocusReason.MouseFocusReason)
204
+ cursor = self._label.textCursor()
205
+ cursor.select(QtGui.QTextCursor.SelectionType.Document)
206
+ self._label.setTextCursor(cursor)
207
+
208
+ def _clear_label_highlight(self) -> None:
209
+ if not hasattr(self, "_label") or self._label is None:
210
+ return
211
+ cursor = self._label.textCursor()
212
+ if cursor.hasSelection():
213
+ cursor.clearSelection()
214
+ self._label.setTextCursor(cursor)
215
+ self._label.setTextInteractionFlags(QtCore.Qt.TextInteractionFlag.NoTextInteraction)
216
+ self._label.setAcceptedMouseButtons(QtCore.Qt.MouseButton.NoButton)
217
+ self._label.clearFocus()
218
+
219
+ def _finish_label_edit(self) -> None:
220
+ if not hasattr(self, "_label") or self._label is None:
221
+ return
222
+ self._clear_label_highlight()
223
+ if not self.has_label():
224
+ self._label.setVisible(False)
225
+ self._update_label_geometry()
226
+
227
+ def copy_label_from(self, other: "ShapeLabelMixin") -> None:
228
+ if not isinstance(other, ShapeLabelMixin):
229
+ return
230
+ self._label_h_align, self._label_v_align = other.label_alignment()
231
+ other_label = other.label_item()
232
+ self._label.setFont(QtGui.QFont(other_label.font()))
233
+ if other.label_has_custom_color():
234
+ self.set_label_color(other.label_color())
235
+ else:
236
+ self.reset_label_color(update=True, base_color=other_label.defaultTextColor())
237
+ self._apply_label_alignment()
238
+ self.set_label_text(other.label_text())
239
+
240
+ def itemChange(self, change, value): # type: ignore[override]
241
+ if change == QtWidgets.QGraphicsItem.GraphicsItemChange.ItemSelectedHasChanged:
242
+ if not bool(value):
243
+ self._clear_label_highlight()
244
+ return super().itemChange(change, value)
245
+
246
+
247
+ __all__ = ["ShapeLabelMixin", "_ShapeLabelItem"]
@@ -0,0 +1,20 @@
1
+ """Shape item re-exports for convenient access."""
2
+
3
+ from .curves import CurvyBracketItem, EllipseItem
4
+ from .lines import LineHandle, LineItem
5
+ from .polygons import BlockArrowHandle, BlockArrowItem, DiamondItem, TriangleItem
6
+ from .rects import RectItem, SplitDividerHandle, SplitRoundedRectItem
7
+
8
+ __all__ = [
9
+ "BlockArrowHandle",
10
+ "BlockArrowItem",
11
+ "CurvyBracketItem",
12
+ "DiamondItem",
13
+ "EllipseItem",
14
+ "LineHandle",
15
+ "LineItem",
16
+ "RectItem",
17
+ "SplitDividerHandle",
18
+ "SplitRoundedRectItem",
19
+ "TriangleItem",
20
+ ]
items/shapes/curves.py ADDED
@@ -0,0 +1,139 @@
1
+ """Curved shapes such as ellipses and curly brackets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+ from PySide6 import QtCore, QtGui, QtWidgets
8
+
9
+ from constants import DEFAULT_FILL, PEN_NORMAL, PEN_SELECTED
10
+ from ..base import ResizableItem, _should_draw_selection, build_curvy_bracket_path
11
+ from ..labels import ShapeLabelMixin
12
+
13
+
14
+ class EllipseItem(ShapeLabelMixin, ResizableItem, QtWidgets.QGraphicsEllipseItem):
15
+ def __init__(self, x, y, w, h):
16
+ QtWidgets.QGraphicsEllipseItem.__init__(self, 0, 0, w, h)
17
+ ResizableItem.__init__(self)
18
+ self.setPos(x, y)
19
+ self.setTransformOriginPoint(w / 2.0, h / 2.0)
20
+ self.setFlags(
21
+ QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIsMovable
22
+ | QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIsSelectable
23
+ | QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
24
+ | QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIsFocusable
25
+ )
26
+ self._init_shape_label()
27
+ self.setPen(PEN_NORMAL)
28
+ self.setBrush(DEFAULT_FILL)
29
+
30
+ def _label_base_rect(self) -> QtCore.QRectF:
31
+ return QtCore.QRectF(self.rect())
32
+
33
+ def setRect(self, x: float, y: float, w: float, h: float) -> None: # type: ignore[override]
34
+ QtWidgets.QGraphicsEllipseItem.setRect(self, x, y, w, h)
35
+ self._update_label_geometry()
36
+
37
+ def setPen(self, pen): # type: ignore[override]
38
+ super().setPen(pen)
39
+ self._update_label_color()
40
+
41
+ def mouseDoubleClickEvent(
42
+ self, event: QtWidgets.QGraphicsSceneMouseEvent
43
+ ) -> None: # type: ignore[override]
44
+ if event.button() == QtCore.Qt.MouseButton.LeftButton:
45
+ self._begin_label_edit()
46
+ event.accept()
47
+ return
48
+ super().mouseDoubleClickEvent(event)
49
+
50
+ def paint(self, painter, option, widget=None):
51
+ opt = QtWidgets.QStyleOptionGraphicsItem(option)
52
+ opt.state &= ~QtWidgets.QStyle.StateFlag.State_Selected
53
+ super().paint(painter, opt, widget)
54
+ if _should_draw_selection(self):
55
+ painter.save()
56
+ painter.setPen(PEN_SELECTED)
57
+ painter.setBrush(QtCore.Qt.BrushStyle.NoBrush)
58
+ painter.drawRect(self.boundingRect())
59
+ painter.restore()
60
+
61
+
62
+ class CurvyBracketItem(ResizableItem, QtWidgets.QGraphicsPathItem):
63
+ """Resizable right-facing curly bracket."""
64
+
65
+ DEFAULT_HOOK_RATIO = 0.3
66
+
67
+ def __init__(
68
+ self,
69
+ x: float,
70
+ y: float,
71
+ w: float,
72
+ h: float,
73
+ hook_ratio: float | None = None,
74
+ ) -> None:
75
+ QtWidgets.QGraphicsPathItem.__init__(self)
76
+ ResizableItem.__init__(self)
77
+ self._w = max(8.0, float(w))
78
+ self._h = max(40.0, float(h))
79
+ if hook_ratio is None:
80
+ hook_ratio = self.DEFAULT_HOOK_RATIO
81
+ self._hook_ratio = self._clamp_ratio(float(hook_ratio))
82
+ self._update_path()
83
+ self.setPos(x, y)
84
+ self.setTransformOriginPoint(self._w / 2.0, self._h / 2.0)
85
+ self.setFlags(
86
+ QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIsMovable
87
+ | QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIsSelectable
88
+ | QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
89
+ | QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIsFocusable
90
+ )
91
+ self.setPen(PEN_NORMAL)
92
+ self.setBrush(QtCore.Qt.BrushStyle.NoBrush)
93
+
94
+ def width(self) -> float:
95
+ return self._w
96
+
97
+ def height(self) -> float:
98
+ return self._h
99
+
100
+ def hook_ratio(self) -> float:
101
+ return self._hook_ratio
102
+
103
+ def set_hook_ratio(self, ratio: float) -> None:
104
+ clamped = self._clamp_ratio(ratio)
105
+ if not math.isclose(clamped, self._hook_ratio, abs_tol=1e-4):
106
+ self._hook_ratio = clamped
107
+ self._update_path()
108
+
109
+ def set_size(self, w: float, h: float, adjust_origin: bool = True) -> None:
110
+ self._w = max(8.0, float(w))
111
+ self._h = max(40.0, float(h))
112
+ self._update_path()
113
+ if adjust_origin:
114
+ self.setTransformOriginPoint(self._w / 2.0, self._h / 2.0)
115
+
116
+ def _clamp_ratio(self, ratio: float | None = None) -> float:
117
+ value = self._hook_ratio if ratio is None else float(ratio)
118
+ return max(0.08, min(0.45, value))
119
+
120
+ def _update_path(self) -> None:
121
+ self.prepareGeometryChange()
122
+ hook = self._hook_ratio * self._h
123
+ path = build_curvy_bracket_path(self._w, self._h, hook)
124
+ self.setPath(path)
125
+ self.setTransformOriginPoint(self._w / 2.0, self._h / 2.0)
126
+
127
+ def paint(self, painter, option, widget=None):
128
+ opt = QtWidgets.QStyleOptionGraphicsItem(option)
129
+ opt.state &= ~QtWidgets.QStyle.StateFlag.State_Selected
130
+ super().paint(painter, opt, widget)
131
+ if _should_draw_selection(self):
132
+ painter.save()
133
+ painter.setPen(PEN_SELECTED)
134
+ painter.setBrush(QtCore.Qt.BrushStyle.NoBrush)
135
+ painter.drawRect(self.boundingRect())
136
+ painter.restore()
137
+
138
+
139
+ __all__ = ["CurvyBracketItem", "EllipseItem"]