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/shapes/lines.py ADDED
@@ -0,0 +1,439 @@
1
+ """Polyline items and their interactive handles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+ from PySide6 import QtCore, QtGui, QtWidgets
8
+
9
+ from constants import PEN_NORMAL, PEN_SELECTED
10
+ from ..base import (
11
+ HANDLE_COLOR,
12
+ HANDLE_SIZE,
13
+ HandleAwareItemMixin,
14
+ _should_draw_selection,
15
+ snap_to_grid,
16
+ )
17
+
18
+
19
+ class LineHandle(QtWidgets.QGraphicsEllipseItem):
20
+ """Handle for line vertices and midpoints."""
21
+
22
+ def __init__(self, parent: "LineItem", index: int, is_mid: bool = False):
23
+ super().__init__(
24
+ -HANDLE_SIZE / 2.0,
25
+ -HANDLE_SIZE / 2.0,
26
+ HANDLE_SIZE,
27
+ HANDLE_SIZE,
28
+ parent,
29
+ )
30
+ self.setBrush(HANDLE_COLOR)
31
+ self.setPen(QtGui.QPen(QtCore.Qt.PenStyle.NoPen))
32
+ self.setAcceptedMouseButtons(QtCore.Qt.MouseButton.LeftButton)
33
+ self.setCursor(QtCore.Qt.CursorShape.SizeAllCursor)
34
+ self.setFlag(QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIgnoresTransformations)
35
+ self.index = index
36
+ self.is_mid = is_mid
37
+ self._parent_was_movable = False
38
+
39
+ def mousePressEvent(self, event: QtWidgets.QGraphicsSceneMouseEvent):
40
+ parent: "LineItem" = self.parentItem() # type: ignore[assignment]
41
+ if self.is_mid:
42
+ parent.insert_point(self.index + 1, self.pos())
43
+ parent._mid_handles.pop(self.index)
44
+ self.is_mid = False
45
+ parent._handles.insert(self.index + 1, self)
46
+ parent.update_handles()
47
+ flags = parent.flags()
48
+ self._parent_was_movable = bool(
49
+ flags & QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIsMovable
50
+ )
51
+ if self._parent_was_movable:
52
+ parent.setFlag(QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIsMovable, False)
53
+ parent._moving_index = self.index
54
+ event.accept()
55
+
56
+ def mouseMoveEvent(self, event: QtWidgets.QGraphicsSceneMouseEvent):
57
+ parent: "LineItem" = self.parentItem() # type: ignore[assignment]
58
+ parent._handle_move(event)
59
+
60
+ def mouseReleaseEvent(self, event: QtWidgets.QGraphicsSceneMouseEvent):
61
+ parent: "LineItem" = self.parentItem() # type: ignore[assignment]
62
+ if self._parent_was_movable:
63
+ parent.setFlag(QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIsMovable, True)
64
+ self._parent_was_movable = False
65
+ parent._moving_index = None
66
+ event.accept()
67
+
68
+
69
+ class LineItem(HandleAwareItemMixin, QtWidgets.QGraphicsPathItem):
70
+ def __init__(
71
+ self,
72
+ x: float,
73
+ y: float,
74
+ length: float | None = None,
75
+ arrow_start: bool = False,
76
+ arrow_end: bool = False,
77
+ points: list[QtCore.QPointF] | None = None,
78
+ arrow_head_length: float | None = None,
79
+ arrow_head_width: float | None = None,
80
+ ):
81
+ super().__init__()
82
+ self.setPos(x, y)
83
+ self.setFlags(
84
+ QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIsMovable
85
+ | QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIsSelectable
86
+ | QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
87
+ | QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIsFocusable
88
+ )
89
+ self.setPen(QtGui.QPen(PEN_NORMAL))
90
+ self.arrow_start = arrow_start
91
+ self.arrow_end = arrow_end
92
+ default_arrow_size = 10.0
93
+ if arrow_head_length is None:
94
+ self._arrow_head_length = default_arrow_size
95
+ else:
96
+ self._arrow_head_length = max(0.1, float(arrow_head_length))
97
+ if arrow_head_width is None:
98
+ self._arrow_head_width = default_arrow_size
99
+ else:
100
+ self._arrow_head_width = max(0.1, float(arrow_head_width))
101
+ self._selection_padding = 10.0
102
+ if points is not None:
103
+ self._points = [QtCore.QPointF(p) for p in points]
104
+ else:
105
+ self._points = [
106
+ QtCore.QPointF(0.0, 0.0),
107
+ QtCore.QPointF(length or 0.0, 0.0),
108
+ ]
109
+ self._moving_index: int | None = None
110
+ self._handles: list[LineHandle] = []
111
+ self._mid_handles: list[LineHandle] = []
112
+ self._update_path()
113
+ self.update_handles()
114
+ self.hide_handles()
115
+
116
+ def _update_length(self) -> None:
117
+ total = 0.0
118
+ for i in range(len(self._points) - 1):
119
+ total += QtCore.QLineF(self._points[i], self._points[i + 1]).length()
120
+ self._length = total
121
+
122
+ def _compute_center(self) -> QtCore.QPointF:
123
+ br = self.path().boundingRect()
124
+ return br.center()
125
+
126
+ def _update_path(self) -> None:
127
+ path = QtGui.QPainterPath(self._points[0])
128
+ for point in self._points[1:]:
129
+ path.lineTo(point)
130
+ self.setPath(path)
131
+ self._update_length()
132
+ self.setTransformOriginPoint(self._compute_center())
133
+
134
+ def insert_point(self, index: int, pos: QtCore.QPointF) -> None:
135
+ self._points.insert(index, QtCore.QPointF(pos))
136
+ self._update_path()
137
+
138
+ def _snap_position_value(self, value): # type: ignore[override]
139
+ if not isinstance(value, QtCore.QPointF):
140
+ return value
141
+
142
+ mods = QtWidgets.QApplication.keyboardModifiers()
143
+ if mods & QtCore.Qt.KeyboardModifier.AltModifier:
144
+ return value
145
+
146
+ if not (self.arrow_start or self.arrow_end):
147
+ return super()._snap_position_value(value)
148
+
149
+ tip_deltas: list[QtCore.QPointF] = []
150
+ current_scene_pos = self.scenePos()
151
+
152
+ if self.arrow_start and self._points:
153
+ start_scene = self.mapToScene(self._points[0])
154
+ tip_deltas.append(start_scene - current_scene_pos)
155
+
156
+ if self.arrow_end and self._points:
157
+ end_scene = self.mapToScene(self._points[-1])
158
+ tip_deltas.append(end_scene - current_scene_pos)
159
+
160
+ if not tip_deltas:
161
+ return super()._snap_position_value(value)
162
+
163
+ best_value: QtCore.QPointF | None = None
164
+ best_distance: float | None = None
165
+
166
+ for delta in tip_deltas:
167
+ new_tip_scene = value + delta
168
+ snapped_tip = snap_to_grid(self, new_tip_scene)
169
+ adjusted_value = snapped_tip - delta
170
+ distance = math.hypot(
171
+ adjusted_value.x() - value.x(),
172
+ adjusted_value.y() - value.y(),
173
+ )
174
+ if best_distance is None or distance < best_distance - 1e-6:
175
+ best_distance = distance
176
+ best_value = adjusted_value
177
+
178
+ if best_value is None:
179
+ return super()._snap_position_value(value)
180
+
181
+ return best_value
182
+
183
+ def _handle_move(self, event: QtWidgets.QGraphicsSceneMouseEvent) -> None:
184
+ if self._moving_index is None:
185
+ return
186
+ new_pos = event.scenePos()
187
+ if not (event.modifiers() & QtCore.Qt.KeyboardModifier.AltModifier):
188
+ new_pos = snap_to_grid(self, new_pos)
189
+ self._points[self._moving_index] = self.mapFromScene(new_pos)
190
+ self._update_path()
191
+ self.update_handles()
192
+ event.accept()
193
+
194
+ def set_arrow_start(self, value: bool) -> None:
195
+ if self.arrow_start != value:
196
+ self.prepareGeometryChange()
197
+ self.arrow_start = value
198
+ self.update()
199
+
200
+ def set_arrow_end(self, value: bool) -> None:
201
+ if self.arrow_end != value:
202
+ self.prepareGeometryChange()
203
+ self.arrow_end = value
204
+ self.update()
205
+
206
+ def arrow_head_length(self) -> float:
207
+ return self._arrow_head_length
208
+
209
+ def arrow_head_width(self) -> float:
210
+ return self._arrow_head_width
211
+
212
+ def set_arrow_head_length(self, value: float) -> None:
213
+ new_value = max(0.1, float(value))
214
+ if not math.isclose(self._arrow_head_length, new_value, rel_tol=1e-6, abs_tol=1e-6):
215
+ self.prepareGeometryChange()
216
+ self._arrow_head_length = new_value
217
+ self.update()
218
+
219
+ def set_arrow_head_width(self, value: float) -> None:
220
+ new_value = max(0.1, float(value))
221
+ if not math.isclose(self._arrow_head_width, new_value, rel_tol=1e-6, abs_tol=1e-6):
222
+ self.prepareGeometryChange()
223
+ self._arrow_head_width = new_value
224
+ self.update()
225
+
226
+ def set_pen_style(self, style: QtCore.Qt.PenStyle) -> None:
227
+ pen = QtGui.QPen(self.pen())
228
+ if pen.style() != style:
229
+ pen.setStyle(style)
230
+ if style == QtCore.Qt.PenStyle.DotLine:
231
+ pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap)
232
+ elif pen.capStyle() == QtCore.Qt.PenCapStyle.RoundCap:
233
+ pen.setCapStyle(QtCore.Qt.PenCapStyle.SquareCap)
234
+ self.setPen(pen)
235
+ self.update()
236
+
237
+ def boundingRect(self): # type: ignore[override]
238
+ rect = super().boundingRect()
239
+ padding = self._selection_padding
240
+ if self.arrow_start or self.arrow_end:
241
+ arrow_padding = max(self._arrow_head_length, self._arrow_head_width / 2.0)
242
+ padding += arrow_padding
243
+ if padding <= 0.0:
244
+ return rect
245
+ return rect.adjusted(-padding, -padding, padding, padding)
246
+
247
+ def shape(self) -> QtGui.QPainterPath: # type: ignore[override]
248
+ points = self._points
249
+
250
+ pen = self.pen()
251
+ pen_width = max(pen.widthF(), 0.1)
252
+ half_width = pen_width / 2.0 + self._selection_padding
253
+
254
+ base_path = QtGui.QPainterPath(self.path())
255
+
256
+ selection_path = QtGui.QPainterPath()
257
+ selection_path.setFillRule(QtCore.Qt.FillRule.WindingFill)
258
+
259
+ if half_width > 0.0 and len(points) >= 2:
260
+ for start_point, end_point in zip(points, points[1:]):
261
+ direction = end_point - start_point
262
+ length = math.hypot(direction.x(), direction.y())
263
+ if length <= 1e-6:
264
+ continue
265
+
266
+ unit_dir = QtCore.QPointF(direction.x() / length, direction.y() / length)
267
+ perp = QtCore.QPointF(-unit_dir.y(), unit_dir.x())
268
+
269
+ extension = QtCore.QPointF(unit_dir.x() * half_width, unit_dir.y() * half_width)
270
+ offset = QtCore.QPointF(perp.x() * half_width, perp.y() * half_width)
271
+
272
+ rect_path = QtGui.QPainterPath()
273
+ rect_path.moveTo(start_point - extension + offset)
274
+ rect_path.lineTo(start_point - extension - offset)
275
+ rect_path.lineTo(end_point + extension - offset)
276
+ rect_path.lineTo(end_point + extension + offset)
277
+ rect_path.closeSubpath()
278
+ selection_path.addPath(rect_path)
279
+
280
+ if self.arrow_start or self.arrow_end:
281
+ if len(points) >= 2:
282
+ arrow_path = QtGui.QPainterPath()
283
+ if self.arrow_start:
284
+ start_poly, _ = self._arrow_head_geometry(points[1], points[0])
285
+ arrow_path.addPolygon(start_poly)
286
+ if self.arrow_end:
287
+ end_poly, _ = self._arrow_head_geometry(points[-2], points[-1])
288
+ arrow_path.addPolygon(end_poly)
289
+
290
+ if not arrow_path.isEmpty():
291
+ selection_path.addPath(arrow_path)
292
+ if self._selection_padding > 0.0:
293
+ arrow_stroke = QtGui.QPainterPathStroker()
294
+ arrow_stroke.setCapStyle(QtCore.Qt.PenCapStyle.SquareCap)
295
+ arrow_stroke.setJoinStyle(QtCore.Qt.PenJoinStyle.MiterJoin)
296
+ arrow_stroke.setMiterLimit(4.0)
297
+ arrow_stroke.setWidth(self._selection_padding * 2.0)
298
+ selection_path.addPath(arrow_stroke.createStroke(arrow_path))
299
+
300
+ return selection_path
301
+
302
+ def update_handles(self) -> None:
303
+ while len(self._handles) < len(self._points):
304
+ handle = LineHandle(self, len(self._handles))
305
+ self._handles.append(handle)
306
+ while len(self._handles) > len(self._points):
307
+ handle = self._handles.pop()
308
+ handle.setParentItem(None)
309
+ for index, point in enumerate(self._points):
310
+ handle = self._handles[index]
311
+ handle.index = index
312
+ handle.is_mid = False
313
+ handle.setPos(point)
314
+
315
+ segments = len(self._points) - 1
316
+ while len(self._mid_handles) < segments:
317
+ handle = LineHandle(self, len(self._mid_handles), is_mid=True)
318
+ self._mid_handles.append(handle)
319
+ while len(self._mid_handles) > segments:
320
+ handle = self._mid_handles.pop()
321
+ handle.setParentItem(None)
322
+ for index in range(segments):
323
+ p1, p2 = self._points[index], self._points[index + 1]
324
+ mid = QtCore.QPointF((p1.x() + p2.x()) / 2.0, (p1.y() + p2.y()) / 2.0)
325
+ handle = self._mid_handles[index]
326
+ handle.index = index
327
+ handle.is_mid = True
328
+ handle.setPos(mid)
329
+
330
+ def show_handles(self) -> None:
331
+ self.update_handles()
332
+ for handle in self._handles + self._mid_handles:
333
+ handle.show()
334
+
335
+ def hide_handles(self) -> None:
336
+ for handle in self._handles + self._mid_handles:
337
+ handle.hide()
338
+
339
+ def _arrow_head_geometry(
340
+ self, start: QtCore.QPointF, end: QtCore.QPointF
341
+ ) -> tuple[QtGui.QPolygonF, QtCore.QPointF]:
342
+ line = QtCore.QLineF(start, end)
343
+ tip = QtCore.QPointF(end)
344
+ length = line.length()
345
+ if length <= 1e-6:
346
+ polygon = QtGui.QPolygonF([tip, tip, tip])
347
+ return polygon, tip
348
+ arrow_length = self._arrow_head_length
349
+ arrow_width = self._arrow_head_width
350
+ direction = QtCore.QPointF(end - start)
351
+ unit_dir = QtCore.QPointF(direction.x() / length, direction.y() / length)
352
+ perp = QtCore.QPointF(-unit_dir.y(), unit_dir.x())
353
+ base_center = QtCore.QPointF(
354
+ tip.x() - unit_dir.x() * arrow_length,
355
+ tip.y() - unit_dir.y() * arrow_length,
356
+ )
357
+ half_width = arrow_width / 2.0
358
+ left_point = QtCore.QPointF(
359
+ base_center.x() + perp.x() * half_width,
360
+ base_center.y() + perp.y() * half_width,
361
+ )
362
+ right_point = QtCore.QPointF(
363
+ base_center.x() - perp.x() * half_width,
364
+ base_center.y() - perp.y() * half_width,
365
+ )
366
+ polygon = QtGui.QPolygonF([tip, left_point, right_point])
367
+ return polygon, base_center
368
+
369
+ def paint(self, painter, option, widget=None):
370
+ points = self._points
371
+
372
+ arrow_polygons: list[QtGui.QPolygonF] = []
373
+ if self.arrow_start or self.arrow_end:
374
+ shaft_points = [QtCore.QPointF(p) for p in points]
375
+ if len(shaft_points) >= 2:
376
+ if self.arrow_start:
377
+ start_poly, start_base = self._arrow_head_geometry(
378
+ points[1], points[0]
379
+ )
380
+ arrow_polygons.append(start_poly)
381
+ shaft_points[0] = start_base
382
+ if self.arrow_end:
383
+ end_poly, end_base = self._arrow_head_geometry(
384
+ points[-2], points[-1]
385
+ )
386
+ arrow_polygons.append(end_poly)
387
+ shaft_points[-1] = end_base
388
+ shaft_path = QtGui.QPainterPath(shaft_points[0])
389
+ for point in shaft_points[1:]:
390
+ shaft_path.lineTo(point)
391
+ else:
392
+ shaft_path = QtGui.QPainterPath(self.path())
393
+ else:
394
+ shaft_path = QtGui.QPainterPath(self.path())
395
+
396
+ painter.save()
397
+ painter.setPen(self.pen())
398
+ painter.setBrush(self.brush())
399
+ painter.drawPath(shaft_path)
400
+ painter.restore()
401
+
402
+ if arrow_polygons:
403
+ painter.save()
404
+ arrow_pen = QtGui.QPen(self.pen())
405
+ if arrow_pen.style() != QtCore.Qt.PenStyle.SolidLine:
406
+ arrow_pen.setStyle(QtCore.Qt.PenStyle.SolidLine)
407
+ arrow_pen.setJoinStyle(QtCore.Qt.PenJoinStyle.MiterJoin)
408
+ painter.setPen(arrow_pen)
409
+ painter.setBrush(self.pen().color())
410
+ for polygon in arrow_polygons:
411
+ painter.drawPolygon(polygon)
412
+ painter.restore()
413
+
414
+ if _should_draw_selection(self):
415
+ highlight_path = self.shape()
416
+ if not highlight_path.isEmpty():
417
+ painter.save()
418
+ highlight_color = QtGui.QColor(255, 235, 59)
419
+ highlight_color.setAlpha(80)
420
+ painter.setPen(QtGui.QPen(QtCore.Qt.PenStyle.NoPen))
421
+ painter.setBrush(highlight_color)
422
+ painter.drawPath(highlight_path)
423
+ painter.restore()
424
+
425
+ painter.save()
426
+ selection_pen = QtGui.QPen(PEN_SELECTED)
427
+ selection_pen.setCapStyle(self.pen().capStyle())
428
+ selection_pen.setJoinStyle(self.pen().joinStyle())
429
+ selection_pen.setWidthF(max(selection_pen.widthF(), self.pen().widthF()))
430
+ selection_pen.setCosmetic(True)
431
+ painter.setPen(selection_pen)
432
+ painter.setBrush(QtCore.Qt.BrushStyle.NoBrush)
433
+ painter.drawPath(shaft_path)
434
+ for polygon in arrow_polygons:
435
+ painter.drawPolygon(polygon)
436
+ painter.restore()
437
+
438
+
439
+ __all__ = ["LineHandle", "LineItem"]