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.
canvas_view.py ADDED
@@ -0,0 +1,2506 @@
1
+ import json
2
+ import math
3
+ from collections.abc import Mapping
4
+ from typing import Any, Callable
5
+
6
+ from PySide6 import QtCore, QtGui, QtWidgets
7
+ from PySide6.QtGui import QTransform
8
+
9
+ from constants import PALETTE_MIME, SHAPES, DEFAULTS
10
+ from items import (
11
+ BlockArrowItem,
12
+ CurvyBracketItem,
13
+ DiamondItem,
14
+ EllipseItem,
15
+ FolderTreeItem,
16
+ GroupItem,
17
+ LineItem,
18
+ RectItem,
19
+ ResizableItem,
20
+ ResizeHandle,
21
+ RotationHandle,
22
+ ShapeLabelMixin,
23
+ SplitRoundedRectItem,
24
+ TextItem,
25
+ TriangleItem,
26
+ )
27
+
28
+ A4_WIDTH_MM = 210
29
+ A4_HEIGHT_MM = 297
30
+ SCREEN_DPI = 96 # Typical desktop DPI
31
+
32
+
33
+ def _enum_to_int(value: Any) -> int:
34
+ """Return an integer value for Qt enum instances."""
35
+ if hasattr(value, "value"):
36
+ value = value.value
37
+ return int(value)
38
+
39
+
40
+ def mm_to_px(mm: float, dpi: float = SCREEN_DPI) -> float:
41
+ return mm / 25.4 * dpi
42
+
43
+
44
+ def _snap_coordinate(value: float, spacing: float, origin: float) -> float:
45
+ if spacing <= 0.0:
46
+ return value
47
+ return round((value - origin) / spacing) * spacing + origin
48
+
49
+
50
+ def _color_to_data(color: QtGui.QColor) -> dict[str, float | str]:
51
+ return {"name": color.name(), "alpha": float(color.alphaF())}
52
+
53
+
54
+ def _color_from_data(data: Mapping[str, Any] | None) -> QtGui.QColor:
55
+ name = "#000000"
56
+ alpha = 1.0
57
+ if data:
58
+ name = str(data.get("name", name))
59
+ alpha = float(data.get("alpha", alpha))
60
+ color = QtGui.QColor(name)
61
+ color.setAlphaF(alpha)
62
+ return color
63
+
64
+
65
+ def _brush_to_data(brush: QtGui.QBrush) -> dict[str, Any]:
66
+ style = _enum_to_int(brush.style())
67
+ data: dict[str, Any] = {"style": style}
68
+ if style != _enum_to_int(QtCore.Qt.BrushStyle.NoBrush):
69
+ data["color"] = _color_to_data(brush.color())
70
+ return data
71
+
72
+
73
+ def _brush_from_data(data: Mapping[str, Any] | None) -> QtGui.QBrush:
74
+ default_style = _enum_to_int(QtCore.Qt.BrushStyle.NoBrush)
75
+ style_val = int(data.get("style", default_style)) if data else default_style
76
+ brush = QtGui.QBrush(QtCore.Qt.BrushStyle(style_val))
77
+ if style_val != _enum_to_int(QtCore.Qt.BrushStyle.NoBrush) and data is not None:
78
+ color_data = data.get("color")
79
+ if isinstance(color_data, Mapping):
80
+ brush.setColor(_color_from_data(color_data))
81
+ return brush
82
+
83
+
84
+ def _pen_to_data(pen: QtGui.QPen) -> dict[str, Any]:
85
+ data: dict[str, Any] = {
86
+ "color": _color_to_data(pen.color()),
87
+ "width": float(pen.widthF()),
88
+ "style": _enum_to_int(pen.style()),
89
+ "cap": _enum_to_int(pen.capStyle()),
90
+ "join": _enum_to_int(pen.joinStyle()),
91
+ "cosmetic": bool(pen.isCosmetic()),
92
+ }
93
+ pattern = pen.dashPattern()
94
+ if pattern:
95
+ data["dash"] = [float(value) for value in pattern]
96
+ return data
97
+
98
+
99
+ def _pen_from_data(data: Mapping[str, Any] | None) -> QtGui.QPen:
100
+ pen = QtGui.QPen()
101
+ if not data:
102
+ return pen
103
+ color_data = data.get("color")
104
+ if isinstance(color_data, Mapping):
105
+ pen.setColor(_color_from_data(color_data))
106
+ if "width" in data:
107
+ pen.setWidthF(float(data["width"]))
108
+ if "style" in data:
109
+ try:
110
+ pen.setStyle(QtCore.Qt.PenStyle(int(data["style"])))
111
+ except ValueError:
112
+ pass
113
+ if "cap" in data:
114
+ try:
115
+ pen.setCapStyle(QtCore.Qt.PenCapStyle(int(data["cap"])))
116
+ except ValueError:
117
+ pass
118
+ if "join" in data:
119
+ try:
120
+ pen.setJoinStyle(QtCore.Qt.PenJoinStyle(int(data["join"])))
121
+ except ValueError:
122
+ pass
123
+ if "cosmetic" in data:
124
+ pen.setCosmetic(bool(data["cosmetic"]))
125
+ if pen.style() == QtCore.Qt.PenStyle.CustomDashLine and "dash" in data:
126
+ pattern = data.get("dash")
127
+ if isinstance(pattern, (list, tuple)):
128
+ pen.setDashPattern([float(value) for value in pattern])
129
+ return pen
130
+
131
+
132
+ def _describe_font_size(font: QtGui.QFont) -> str | None:
133
+ pixel_size = font.pixelSize()
134
+ if pixel_size and pixel_size > 0:
135
+ return f"{pixel_size:g} px"
136
+ point_size_f = font.pointSizeF()
137
+ if point_size_f and point_size_f > 0:
138
+ if abs(point_size_f - round(point_size_f)) < 0.01:
139
+ return f"{int(round(point_size_f))} pt"
140
+ return f"{point_size_f:.1f} pt"
141
+ point_size = font.pointSize()
142
+ if point_size and point_size > 0:
143
+ return f"{point_size:g} pt"
144
+ return None
145
+
146
+
147
+ def _serialize_shape_label(item: ShapeLabelMixin) -> dict[str, Any] | None:
148
+ if not isinstance(item, ShapeLabelMixin):
149
+ return None
150
+ label = item.label_item()
151
+ data: dict[str, Any] = {
152
+ "text": item.label_text(),
153
+ "alignment": list(item.label_alignment()),
154
+ "font": label.font().toString(),
155
+ "color": _color_to_data(label.defaultTextColor()),
156
+ }
157
+ font_size = _describe_font_size(label.font())
158
+ if font_size:
159
+ data["font_size"] = font_size
160
+ if item.label_has_custom_color():
161
+ data["color_override"] = True
162
+ return data
163
+
164
+
165
+ def _apply_shape_label(item: ShapeLabelMixin, data: Mapping[str, Any] | None) -> None:
166
+ if not data:
167
+ return
168
+ text = data.get("text")
169
+ if isinstance(text, str):
170
+ item.set_label_text(text)
171
+ alignment = data.get("alignment")
172
+ if isinstance(alignment, (list, tuple)) and len(alignment) == 2:
173
+ horizontal, vertical = alignment
174
+ item.set_label_alignment(horizontal=str(horizontal), vertical=str(vertical))
175
+ font_data = data.get("font")
176
+ if isinstance(font_data, str):
177
+ font = QtGui.QFont()
178
+ font.fromString(font_data)
179
+ item.label_item().setFont(font)
180
+ color_data = data.get("color")
181
+ color_override = bool(data.get("color_override", False))
182
+ if isinstance(color_data, Mapping):
183
+ color = _color_from_data(color_data)
184
+ if color_override:
185
+ item.set_label_color(color)
186
+ else:
187
+ item.reset_label_color(update=True, base_color=color)
188
+
189
+
190
+ class SceneHistory(QtCore.QObject):
191
+ historyChanged = QtCore.Signal(bool, bool)
192
+
193
+ def __init__(self, view: "CanvasView", *, max_states: int = 50) -> None:
194
+ super().__init__(view)
195
+ self._view = view
196
+ self._max_states = max(1, int(max_states))
197
+ self._states: list[str] = []
198
+ self._index = -1
199
+ self._ignore_changes = False
200
+ self._timer = QtCore.QTimer(self)
201
+ self._timer.setSingleShot(True)
202
+ self._timer.setInterval(250)
203
+ self._timer.timeout.connect(self._capture_snapshot)
204
+ scene = view.scene()
205
+ if scene is not None:
206
+ scene.changed.connect(self._on_scene_changed)
207
+
208
+ def capture_initial_state(self) -> None:
209
+ self._states.clear()
210
+ self._index = -1
211
+ self._capture_snapshot(force=True)
212
+ self._notify()
213
+
214
+ def mark_dirty(self) -> None:
215
+ if self._ignore_changes:
216
+ return
217
+ self._timer.start()
218
+
219
+ def capture_now(self) -> None:
220
+ self._capture_snapshot()
221
+
222
+ def undo(self) -> None:
223
+ if not self.can_undo():
224
+ return
225
+ self._index -= 1
226
+ self._apply_current_state()
227
+ self._notify()
228
+
229
+ def redo(self) -> None:
230
+ if not self.can_redo():
231
+ return
232
+ self._index += 1
233
+ self._apply_current_state()
234
+ self._notify()
235
+
236
+ def can_undo(self) -> bool:
237
+ return self._index > 0
238
+
239
+ def can_redo(self) -> bool:
240
+ return 0 <= self._index < len(self._states) - 1
241
+
242
+ def _notify(self) -> None:
243
+ self.historyChanged.emit(self.can_undo(), self.can_redo())
244
+
245
+ def _on_scene_changed(self, _region: list[QtCore.QRectF]) -> None: # type: ignore[override]
246
+ if self._ignore_changes:
247
+ return
248
+ self._timer.start()
249
+
250
+ def _serialize_state(self) -> str:
251
+ state = self._view._serialize_scene_state()
252
+ return json.dumps(state, sort_keys=True, separators=(",", ":"))
253
+
254
+ def _capture_snapshot(self, force: bool = False) -> None:
255
+ if self._ignore_changes:
256
+ return
257
+ state_str = self._serialize_state()
258
+ if not force and self._index >= 0 and self._states[self._index] == state_str:
259
+ return
260
+ if self._index < len(self._states) - 1:
261
+ self._states = self._states[: self._index + 1]
262
+ self._states.append(state_str)
263
+ if len(self._states) > self._max_states:
264
+ overflow = len(self._states) - self._max_states
265
+ self._states = self._states[overflow:]
266
+ self._index = len(self._states) - 1
267
+ else:
268
+ self._index = len(self._states) - 1
269
+ self._notify()
270
+
271
+ def _apply_current_state(self) -> None:
272
+ if not (0 <= self._index < len(self._states)):
273
+ return
274
+ state_str = self._states[self._index]
275
+ state = json.loads(state_str)
276
+ self._ignore_changes = True
277
+ self._timer.stop()
278
+ try:
279
+ self._view._restore_scene_state(state)
280
+ finally:
281
+ self._ignore_changes = False
282
+
283
+ # Minimum mouse movement (in scene coordinates) required before
284
+ # showing duplicates when Ctrl+dragging selected items.
285
+ DUPLICATE_DRAG_THRESHOLD = 10.0
286
+
287
+
288
+ class CornerRadiusDialog(QtWidgets.QDialog):
289
+ valueChanged = QtCore.Signal(int)
290
+
291
+ def __init__(self, radius: float, parent=None):
292
+ super().__init__(parent)
293
+ self.setWindowTitle("Corner radius")
294
+
295
+ layout = QtWidgets.QFormLayout(self)
296
+
297
+ self.slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal)
298
+ self.slider.setRange(0, 50)
299
+ self.slider.setValue(int(radius))
300
+ self.slider.setTracking(True)
301
+ self.label = QtWidgets.QLabel(str(int(radius)))
302
+ self.label.setFixedWidth(40)
303
+ radius_layout = QtWidgets.QHBoxLayout()
304
+ radius_layout.addWidget(self.slider)
305
+ radius_layout.addWidget(self.label)
306
+ layout.addRow("radius", radius_layout)
307
+
308
+ buttons = QtWidgets.QDialogButtonBox(
309
+ QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel
310
+ )
311
+ buttons.accepted.connect(self.accept)
312
+ buttons.rejected.connect(self.reject)
313
+ layout.addRow(buttons)
314
+
315
+ self.slider.valueChanged.connect(self._on_slider_value_changed)
316
+
317
+ def value(self) -> int:
318
+ return self.slider.value()
319
+
320
+ def _on_slider_value_changed(self, value: int) -> None:
321
+ self.label.setText(str(value))
322
+ self.valueChanged.emit(value)
323
+
324
+
325
+ class OpacityDialog(QtWidgets.QDialog):
326
+ valueChanged = QtCore.Signal(float)
327
+
328
+ def __init__(self, value: float, parent=None):
329
+ super().__init__(parent)
330
+ self.setWindowTitle("Fill opacity")
331
+
332
+ self._value = value
333
+
334
+ layout = QtWidgets.QVBoxLayout(self)
335
+
336
+ slider_layout = QtWidgets.QHBoxLayout()
337
+ self.slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal)
338
+ self.slider.setRange(0, 100)
339
+ self.slider.setValue(round(value * 100))
340
+ self.slider.setTracking(True)
341
+ slider_layout.addWidget(self.slider, stretch=1)
342
+
343
+ self.display = QtWidgets.QLineEdit(f"{value:.2f}")
344
+ self.display.setReadOnly(True)
345
+ self.display.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight)
346
+ self.display.setFixedWidth(60)
347
+ slider_layout.addWidget(self.display)
348
+
349
+ layout.addLayout(slider_layout)
350
+
351
+ buttons = QtWidgets.QDialogButtonBox(
352
+ QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel
353
+ )
354
+ buttons.accepted.connect(self.accept)
355
+ buttons.rejected.connect(self.reject)
356
+ layout.addWidget(buttons)
357
+
358
+ self.slider.valueChanged.connect(self._on_slider_value_changed)
359
+
360
+ def _on_slider_value_changed(self, slider_value: int) -> None:
361
+ self._value = slider_value / 100.0
362
+ self.display.setText(f"{self._value:.2f}")
363
+ self.valueChanged.emit(self._value)
364
+
365
+ def value(self) -> float:
366
+ return self._value
367
+
368
+
369
+ class TrackingScene(QtWidgets.QGraphicsScene):
370
+ """QGraphicsScene that keeps strong refs to added items."""
371
+
372
+ def __init__(self, parent=None):
373
+ super().__init__(parent)
374
+ self._owned_items: set[QtWidgets.QGraphicsItem] = set()
375
+
376
+ def addItem(self, item: QtWidgets.QGraphicsItem) -> None: # type: ignore[override]
377
+ super().addItem(item)
378
+ self._owned_items.add(item)
379
+
380
+ def removeItem(self, item: QtWidgets.QGraphicsItem) -> None: # type: ignore[override]
381
+ super().removeItem(item)
382
+ self._owned_items.discard(item)
383
+
384
+ def clear(self) -> None: # type: ignore[override]
385
+ super().clear()
386
+ self._owned_items.clear()
387
+
388
+
389
+ class A4PageItem(QtWidgets.QGraphicsRectItem):
390
+ """QGraphicsRectItem representing a single A4 page with a grid."""
391
+
392
+ def __init__(
393
+ self,
394
+ width: float,
395
+ height: float,
396
+ *,
397
+ margin_mm: float = 12.0,
398
+ grid_px: int = 50,
399
+ subgrid_px: int = 10,
400
+ index: tuple[int, int] = (0, 0),
401
+ master_origin: QtCore.QPointF = QtCore.QPointF(),
402
+ ):
403
+ super().__init__(0.0, 0.0, width, height)
404
+ self.setBrush(QtGui.QBrush(QtCore.Qt.GlobalColor.white))
405
+ self.setPen(QtCore.Qt.PenStyle.NoPen)
406
+ self.setFlag(QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIsSelectable, False)
407
+ self.setFlag(QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIsMovable, False)
408
+ self.setZValue(-100)
409
+
410
+ margin_px = mm_to_px(margin_mm)
411
+ self._margins = QtCore.QMarginsF(margin_px, margin_px, margin_px, margin_px)
412
+ self._grid_visible = True
413
+ self._grid_px = max(1, grid_px)
414
+ self._subgrid_px = max(1, subgrid_px)
415
+ self.index: tuple[int, int] = index
416
+ self._master_origin = QtCore.QPointF(master_origin)
417
+ self._transition_edges: set[str] = set()
418
+ self._neighbors: dict[str, bool] = {
419
+ "left": False,
420
+ "right": False,
421
+ "top": False,
422
+ "bottom": False,
423
+ }
424
+ self._outline_pen = QtGui.QPen(QtGui.QColor(0, 0, 0, 120))
425
+ self._outline_pen.setStyle(QtCore.Qt.PenStyle.DashLine)
426
+ self._outline_pen.setWidthF(0)
427
+ self._outline_pen.setCapStyle(QtCore.Qt.PenCapStyle.FlatCap)
428
+ self._outline_pen.setJoinStyle(QtCore.Qt.PenJoinStyle.MiterJoin)
429
+
430
+ def set_grid_spacing(self, grid_px: int, subgrid_px: int) -> None:
431
+ self._grid_px = max(1, grid_px)
432
+ self._subgrid_px = max(1, subgrid_px)
433
+ self.update()
434
+
435
+ def set_grid_visible(self, visible: bool) -> None:
436
+ self._grid_visible = visible
437
+ self.update()
438
+
439
+ def set_master_origin(self, origin: QtCore.QPointF) -> None:
440
+ if self._master_origin == origin:
441
+ return
442
+ self._master_origin = QtCore.QPointF(origin)
443
+ self.update()
444
+
445
+ def set_transition_edges(self, edges: set[str]) -> None:
446
+ if self._transition_edges == edges:
447
+ return
448
+ self._transition_edges = set(edges)
449
+ self.update()
450
+
451
+ def set_outline_neighbors(
452
+ self, *, left: bool, right: bool, top: bool, bottom: bool
453
+ ) -> None:
454
+ if (
455
+ self._neighbors["left"] == left
456
+ and self._neighbors["right"] == right
457
+ and self._neighbors["top"] == top
458
+ and self._neighbors["bottom"] == bottom
459
+ ):
460
+ return
461
+ self._neighbors["left"] = left
462
+ self._neighbors["right"] = right
463
+ self._neighbors["top"] = top
464
+ self._neighbors["bottom"] = bottom
465
+ self.update()
466
+
467
+ def paint(
468
+ self,
469
+ painter: QtGui.QPainter,
470
+ option: QtWidgets.QStyleOptionGraphicsItem,
471
+ widget=None,
472
+ ) -> None:
473
+ super().paint(painter, option, widget)
474
+
475
+ page_rect = self.rect()
476
+
477
+ painter.save()
478
+ painter.setBrush(QtCore.Qt.BrushStyle.NoBrush)
479
+
480
+ outline_vertical = QtGui.QPen(self._outline_pen)
481
+ outline_horizontal = QtGui.QPen(self._outline_pen)
482
+
483
+ dash_pattern = outline_vertical.dashPattern()
484
+ dash_period = sum(dash_pattern) if dash_pattern else 0.0
485
+ scene_pos = self.scenePos()
486
+
487
+ if dash_period > 0.0:
488
+ vertical_offset = math.fmod(
489
+ scene_pos.y() - self._master_origin.y(), dash_period
490
+ )
491
+ horizontal_offset = math.fmod(
492
+ scene_pos.x() - self._master_origin.x(), dash_period
493
+ )
494
+ if vertical_offset < 0.0:
495
+ vertical_offset += dash_period
496
+ if horizontal_offset < 0.0:
497
+ horizontal_offset += dash_period
498
+ outline_vertical.setDashOffset(vertical_offset)
499
+ outline_horizontal.setDashOffset(horizontal_offset)
500
+
501
+ transition_vertical = QtGui.QPen(outline_vertical)
502
+ transition_horizontal = QtGui.QPen(outline_horizontal)
503
+ transition_vertical.setColor(QtGui.QColor(0, 0, 0, 160))
504
+ transition_horizontal.setColor(QtGui.QColor(0, 0, 0, 160))
505
+
506
+ has_left_neighbor = self._neighbors["left"]
507
+ has_right_neighbor = self._neighbors["right"]
508
+ has_top_neighbor = self._neighbors["top"]
509
+ has_bottom_neighbor = self._neighbors["bottom"]
510
+
511
+ edge_segments: list[tuple[str, QtCore.QPointF, QtCore.QPointF]] = []
512
+ if not has_left_neighbor:
513
+ edge_segments.append(
514
+ (
515
+ "left",
516
+ QtCore.QPointF(page_rect.left(), page_rect.top()),
517
+ QtCore.QPointF(page_rect.left(), page_rect.bottom()),
518
+ )
519
+ )
520
+ edge_segments.append(
521
+ (
522
+ "right",
523
+ QtCore.QPointF(page_rect.right(), page_rect.top()),
524
+ QtCore.QPointF(page_rect.right(), page_rect.bottom()),
525
+ )
526
+ )
527
+ if not has_top_neighbor:
528
+ edge_segments.append(
529
+ (
530
+ "top",
531
+ QtCore.QPointF(page_rect.left(), page_rect.top()),
532
+ QtCore.QPointF(page_rect.right(), page_rect.top()),
533
+ )
534
+ )
535
+ edge_segments.append(
536
+ (
537
+ "bottom",
538
+ QtCore.QPointF(page_rect.left(), page_rect.bottom()),
539
+ QtCore.QPointF(page_rect.right(), page_rect.bottom()),
540
+ )
541
+ )
542
+
543
+ for edge, start, end in edge_segments:
544
+ if edge in ("left", "right"):
545
+ pen = (
546
+ transition_vertical
547
+ if edge in self._transition_edges
548
+ else outline_vertical
549
+ )
550
+ else:
551
+ pen = (
552
+ transition_horizontal
553
+ if edge in self._transition_edges
554
+ else outline_horizontal
555
+ )
556
+ painter.setPen(pen)
557
+ painter.drawLine(start, end)
558
+
559
+ painter.restore()
560
+
561
+ if not self._grid_visible:
562
+ return
563
+
564
+ painter.save()
565
+ painter.setClipRect(page_rect)
566
+
567
+ origin_scene = self.scenePos()
568
+ master_origin = self._master_origin
569
+
570
+ def _first_position(
571
+ spacing: float,
572
+ orientation: str,
573
+ ) -> float:
574
+ if spacing <= 0:
575
+ return 0.0
576
+ if orientation == "vertical":
577
+ start = page_rect.left()
578
+ offset = (origin_scene.x() - master_origin.x()) % spacing
579
+ else:
580
+ start = page_rect.top()
581
+ offset = (origin_scene.y() - master_origin.y()) % spacing
582
+ if math.isclose(offset, spacing, abs_tol=1e-6) or math.isclose(offset, 0.0, abs_tol=1e-6):
583
+ offset = 0.0
584
+ return start + (spacing - offset) % spacing
585
+
586
+ def _draw_lines(spacing: float, orientation: str, skip_main: bool) -> None:
587
+ if spacing <= 0:
588
+ return
589
+ if orientation == "vertical":
590
+ start = page_rect.left()
591
+ end = page_rect.right()
592
+ origin_value = origin_scene.x()
593
+ first = _first_position(spacing, orientation)
594
+ pos = first
595
+ while pos <= end + 0.5:
596
+ if skip_main:
597
+ scene_value = origin_value + (pos - start)
598
+ distance = scene_value - master_origin.x()
599
+ nearest = round(distance / self._grid_px) * self._grid_px
600
+ if math.isclose(distance, nearest, abs_tol=0.3):
601
+ pos += spacing
602
+ continue
603
+ painter.drawLine(pos, page_rect.top(), pos, page_rect.bottom())
604
+ pos += spacing
605
+ else:
606
+ start = page_rect.top()
607
+ end = page_rect.bottom()
608
+ origin_value = origin_scene.y()
609
+ first = _first_position(spacing, orientation)
610
+ pos = first
611
+ while pos <= end + 0.5:
612
+ if skip_main:
613
+ scene_value = origin_value + (pos - start)
614
+ distance = scene_value - master_origin.y()
615
+ nearest = round(distance / self._grid_px) * self._grid_px
616
+ if math.isclose(distance, nearest, abs_tol=0.3):
617
+ pos += spacing
618
+ continue
619
+ painter.drawLine(page_rect.left(), pos, page_rect.right(), pos)
620
+ pos += spacing
621
+
622
+ subgrid_pen = QtGui.QPen(QtGui.QColor(0, 0, 0, 30))
623
+ subgrid_pen.setWidthF(0)
624
+ painter.setPen(subgrid_pen)
625
+
626
+ _draw_lines(self._subgrid_px, "vertical", True)
627
+ _draw_lines(self._subgrid_px, "horizontal", True)
628
+
629
+ grid_pen = QtGui.QPen(QtGui.QColor(0, 0, 0, 80))
630
+ grid_pen.setWidthF(0)
631
+ painter.setPen(grid_pen)
632
+
633
+ _draw_lines(self._grid_px, "vertical", False)
634
+ _draw_lines(self._grid_px, "horizontal", False)
635
+
636
+ painter.restore()
637
+
638
+
639
+ class CanvasView(QtWidgets.QGraphicsView):
640
+ gridVisibilityChanged = QtCore.Signal(bool)
641
+ selectionSnapshotChanged = QtCore.Signal(dict)
642
+
643
+ def __init__(self, parent=None):
644
+ super().__init__(parent)
645
+ self.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing, True)
646
+ self.setDragMode(QtWidgets.QGraphicsView.DragMode.RubberBandDrag)
647
+ # Style the selection rubber band with a blue dashed outline
648
+ self.viewport().setStyleSheet(
649
+ "QRubberBand { border: 1px dashed #14b5ff; }"
650
+ )
651
+ self.setAcceptDrops(True)
652
+ self.setFocusPolicy(QtCore.Qt.FocusPolicy.StrongFocus)
653
+ # Keep the view centered when splitter widths change so the canvas does not drift.
654
+ self.setResizeAnchor(QtWidgets.QGraphicsView.ViewportAnchor.AnchorViewCenter)
655
+ self.setViewportUpdateMode(
656
+ QtWidgets.QGraphicsView.ViewportUpdateMode.FullViewportUpdate
657
+ )
658
+
659
+ scene = TrackingScene(self)
660
+ self._scene_padding = 200
661
+ scene.setSceneRect(
662
+ -self._scene_padding,
663
+ -self._scene_padding,
664
+ self._scene_padding * 2,
665
+ self._scene_padding * 2,
666
+ )
667
+ scene.changed.connect(self._update_scene_rect)
668
+ self.setScene(scene)
669
+ self.setBackgroundBrush(QtGui.QColor("#f0f0f0"))
670
+ self._grid_size = 50
671
+ self._grid_size_min = 10
672
+ self._show_grid = True
673
+ self._page_width = mm_to_px(A4_WIDTH_MM, SCREEN_DPI)
674
+ self._page_height = mm_to_px(A4_HEIGHT_MM, SCREEN_DPI)
675
+ self._master_index: tuple[int, int] = (0, 0)
676
+ self._pages: dict[tuple[int, int], A4PageItem] = {}
677
+ self._master_origin = self._page_top_left_for_index(self._master_index)
678
+ self._page_item = self._create_page_item(self._master_index)
679
+ self._pages[self._master_index] = self._page_item
680
+ scene.addItem(self._page_item)
681
+ self._update_transition_edges()
682
+ QtCore.QTimer.singleShot(0, self._fit_view_to_page)
683
+ self._update_scene_rect()
684
+
685
+ self._panning = False
686
+ self._pan_start = QtCore.QPointF()
687
+ self._prev_drag_mode = self.dragMode()
688
+ self._right_button_pressed = False
689
+ self._suppress_context_menu = False
690
+
691
+ self._history = SceneHistory(self)
692
+ self._history.capture_initial_state()
693
+
694
+ scene.selectionChanged.connect(self._notify_selection_snapshot)
695
+ scene.changed.connect(self._on_scene_contents_changed)
696
+ self._notify_selection_snapshot()
697
+
698
+ def history(self) -> SceneHistory:
699
+ return self._history
700
+
701
+ def undo(self) -> None:
702
+ self._history.undo()
703
+
704
+ def redo(self) -> None:
705
+ self._history.redo()
706
+
707
+ # --- Serialization helpers for undo/redo ---
708
+ def _is_serializable_item(self, item: QtWidgets.QGraphicsItem) -> bool:
709
+ if isinstance(item, A4PageItem):
710
+ return False
711
+ name = item.__class__.__name__
712
+ if name.endswith("Handle"):
713
+ return False
714
+ if isinstance(item, QtWidgets.QGraphicsItemGroup) and name == "QGraphicsItemGroup":
715
+ return False
716
+ return True
717
+
718
+ def _item_sort_key(self, item: QtWidgets.QGraphicsItem) -> tuple[float, str, float, float]:
719
+ shape = str(item.data(0)) if item.data(0) else item.__class__.__name__
720
+ pos = item.pos()
721
+ return (
722
+ round(float(item.zValue()), 6),
723
+ shape,
724
+ round(float(pos.x()), 6),
725
+ round(float(pos.y()), 6),
726
+ )
727
+
728
+ def _serialize_scene_state(self) -> dict[str, Any]:
729
+ scene = self.scene()
730
+ if scene is None:
731
+ return {"items": [], "grid_visible": bool(self._show_grid)}
732
+ items = [
733
+ item
734
+ for item in scene.items()
735
+ if self._is_serializable_item(item) and item.parentItem() is None
736
+ ]
737
+ items.sort(key=self._item_sort_key)
738
+ return {
739
+ "items": [self._serialize_item(item) for item in items],
740
+ "grid_visible": bool(self._show_grid),
741
+ }
742
+
743
+ def _serialize_item(self, item: QtWidgets.QGraphicsItem) -> dict[str, Any]:
744
+ shape_value = item.data(0)
745
+ shape = str(shape_value) if shape_value else item.__class__.__name__
746
+ base: dict[str, Any] = {
747
+ "shape": shape,
748
+ "class": item.__class__.__name__,
749
+ "pos": [float(item.pos().x()), float(item.pos().y())],
750
+ "rotation": float(item.rotation()),
751
+ "scale": float(item.scale()),
752
+ "z": float(item.zValue()),
753
+ }
754
+
755
+ if isinstance(item, RectItem):
756
+ rect = item.rect()
757
+ base["size"] = [float(rect.width()), float(rect.height())]
758
+ base["rx"] = float(getattr(item, "rx", 0.0))
759
+ base["ry"] = float(getattr(item, "ry", 0.0))
760
+ base["pen"] = _pen_to_data(item.pen())
761
+ base["brush"] = _brush_to_data(item.brush())
762
+ label = _serialize_shape_label(item)
763
+ if label:
764
+ base["label"] = label
765
+ elif isinstance(item, SplitRoundedRectItem):
766
+ rect = item.rect()
767
+ base["size"] = [float(rect.width()), float(rect.height())]
768
+ base["rx"] = float(getattr(item, "rx", 0.0))
769
+ base["ry"] = float(getattr(item, "ry", 0.0))
770
+ base["divider_ratio"] = float(item.divider_ratio())
771
+ base["pen"] = _pen_to_data(item.pen())
772
+ base["bottom_brush"] = _brush_to_data(item.bottomBrush())
773
+ base["top_brush"] = _brush_to_data(item.topBrush())
774
+ elif isinstance(item, EllipseItem):
775
+ rect = item.rect()
776
+ base["size"] = [float(rect.width()), float(rect.height())]
777
+ base["pen"] = _pen_to_data(item.pen())
778
+ base["brush"] = _brush_to_data(item.brush())
779
+ label = _serialize_shape_label(item)
780
+ if label:
781
+ base["label"] = label
782
+ elif isinstance(item, TriangleItem):
783
+ rect = item.boundingRect()
784
+ base["size"] = [float(rect.width()), float(rect.height())]
785
+ base["pen"] = _pen_to_data(item.pen())
786
+ base["brush"] = _brush_to_data(item.brush())
787
+ elif isinstance(item, DiamondItem):
788
+ rect = item.boundingRect()
789
+ base["size"] = [float(rect.width()), float(rect.height())]
790
+ base["pen"] = _pen_to_data(item.pen())
791
+ base["brush"] = _brush_to_data(item.brush())
792
+ label = _serialize_shape_label(item)
793
+ if label:
794
+ base["label"] = label
795
+ elif isinstance(item, BlockArrowItem):
796
+ rect = item.boundingRect()
797
+ base["size"] = [float(rect.width()), float(rect.height())]
798
+ base["pen"] = _pen_to_data(item.pen())
799
+ base["brush"] = _brush_to_data(item.brush())
800
+ base["head_ratio"] = float(item.head_ratio())
801
+ base["shaft_ratio"] = float(item.shaft_ratio())
802
+ elif isinstance(item, LineItem):
803
+ points = getattr(item, "_points", [])
804
+ base["points"] = [
805
+ [float(point.x()), float(point.y())]
806
+ for point in points
807
+ ]
808
+ base["arrow_start"] = bool(getattr(item, "arrow_start", False))
809
+ base["arrow_end"] = bool(getattr(item, "arrow_end", False))
810
+ length_getter = getattr(item, "arrow_head_length", None)
811
+ width_getter = getattr(item, "arrow_head_width", None)
812
+ if callable(length_getter) and callable(width_getter):
813
+ base["arrow_head"] = {
814
+ "length": float(length_getter()),
815
+ "width": float(width_getter()),
816
+ }
817
+ base["pen"] = _pen_to_data(item.pen())
818
+ elif isinstance(item, CurvyBracketItem):
819
+ base["size"] = [float(item.width()), float(item.height())]
820
+ base["hook_ratio"] = float(item.hook_ratio())
821
+ base["pen"] = _pen_to_data(item.pen())
822
+ elif isinstance(item, TextItem):
823
+ rect = item.boundingRect()
824
+ base["size"] = [float(rect.width()), float(rect.height())]
825
+ base["text"] = item.toPlainText()
826
+ base["font"] = item.font().toString()
827
+ font_size = _describe_font_size(item.font())
828
+ if font_size:
829
+ base["font_size"] = font_size
830
+ base["color"] = _color_to_data(item.defaultTextColor())
831
+ doc = item.document()
832
+ if doc is not None:
833
+ base["document_margin"] = float(doc.documentMargin())
834
+ h_align, v_align = item.text_alignment()
835
+ base["alignment"] = [h_align, v_align]
836
+ base["direction"] = item.text_direction()
837
+ elif isinstance(item, FolderTreeItem):
838
+ base["structure"] = item.structure()
839
+ elif isinstance(item, GroupItem):
840
+ children = [
841
+ child
842
+ for child in item.childItems()
843
+ if self._is_serializable_item(child)
844
+ ]
845
+ children.sort(key=self._item_sort_key)
846
+ base["children"] = [self._serialize_item(child) for child in children]
847
+ else:
848
+ width, height = self._item_dimensions(item)
849
+ base["size"] = [width, height]
850
+ return base
851
+
852
+ def _format_property_name(self, key: str) -> str:
853
+ if not key:
854
+ return ""
855
+ parts = key.split("_")
856
+ return " ".join(part.capitalize() if part else "" for part in parts)
857
+
858
+ def _format_property_value(self, value: Any) -> str:
859
+ if isinstance(value, float):
860
+ return f"{value:.2f}"
861
+ if isinstance(value, bool):
862
+ return "True" if value else "False"
863
+ if isinstance(value, (list, tuple)):
864
+ return ", ".join(self._format_property_value(v) for v in value)
865
+ if isinstance(value, Mapping):
866
+ return "; ".join(
867
+ f"{self._format_property_name(str(k))}: {self._format_property_value(v)}"
868
+ for k, v in value.items()
869
+ )
870
+ return str(value)
871
+
872
+ def _build_properties_for_item(
873
+ self, item: QtWidgets.QGraphicsItem
874
+ ) -> tuple[
875
+ str,
876
+ list[tuple[str, str]],
877
+ list[tuple[str, str]] | None,
878
+ dict[str, Any],
879
+ dict[str, Any] | None,
880
+ ]:
881
+ data = self._serialize_item(item)
882
+ title = str(data.get("shape", item.__class__.__name__))
883
+
884
+ object_data = dict(data)
885
+ text_data: Mapping[str, Any] | dict[str, Any] | None = None
886
+
887
+ if isinstance(item, ShapeLabelMixin):
888
+ label_data = object_data.pop("label", None)
889
+ if isinstance(label_data, Mapping):
890
+ text_data = dict(label_data)
891
+ elif isinstance(item, TextItem):
892
+ text_keys = (
893
+ "text",
894
+ "font",
895
+ "font_size",
896
+ "color",
897
+ "document_margin",
898
+ "alignment",
899
+ "direction",
900
+ )
901
+ text_section: dict[str, Any] = {}
902
+ for key in text_keys:
903
+ if key in object_data:
904
+ text_section[key] = object_data.pop(key)
905
+ if text_section:
906
+ text_data = text_section
907
+
908
+ object_properties: list[tuple[str, str]] = []
909
+ for key, value in object_data.items():
910
+ if key == "shape":
911
+ continue
912
+ object_properties.append(
913
+ (self._format_property_name(str(key)), self._format_property_value(value))
914
+ )
915
+
916
+ text_properties: list[tuple[str, str]] | None = None
917
+ if text_data:
918
+ text_properties = []
919
+ for key, value in text_data.items():
920
+ text_properties.append(
921
+ (self._format_property_name(str(key)), self._format_property_value(value))
922
+ )
923
+
924
+ plain_text_data: dict[str, Any] | None = dict(text_data) if text_data else None
925
+
926
+ return title, object_properties, text_properties, object_data, plain_text_data
927
+
928
+ def _build_selection_snapshot(self) -> dict[str, Any]:
929
+ scene = self.scene()
930
+ if scene is None:
931
+ return {"selection_type": "none"}
932
+ selected = [
933
+ item
934
+ for item in scene.selectedItems()
935
+ if self._is_serializable_item(item)
936
+ ]
937
+ if len(selected) == 1:
938
+ item = selected[0]
939
+ (
940
+ title,
941
+ object_props,
942
+ text_props,
943
+ object_data,
944
+ text_data,
945
+ ) = self._build_properties_for_item(item)
946
+ return {
947
+ "selection_type": "single",
948
+ "title": title,
949
+ "properties": object_props,
950
+ "text_properties": text_props,
951
+ "item": item,
952
+ "object_data": object_data,
953
+ "text_data": text_data,
954
+ }
955
+ if selected:
956
+ return {"selection_type": "multi", "count": len(selected)}
957
+ return {"selection_type": "none"}
958
+
959
+ @staticmethod
960
+ def _item_dimensions(item: QtWidgets.QGraphicsItem) -> tuple[float, float]:
961
+ if isinstance(item, QtWidgets.QGraphicsRectItem):
962
+ rect = item.rect()
963
+ return float(rect.width()), float(rect.height())
964
+ if isinstance(item, QtWidgets.QGraphicsEllipseItem):
965
+ rect = item.rect()
966
+ return float(rect.width()), float(rect.height())
967
+ width_attr = getattr(item, "_w", None)
968
+ height_attr = getattr(item, "_h", None)
969
+ if isinstance(width_attr, (int, float)) and isinstance(height_attr, (int, float)):
970
+ return float(width_attr), float(height_attr)
971
+ bounds = item.boundingRect()
972
+ return float(bounds.width()), float(bounds.height())
973
+
974
+ def _notify_selection_snapshot(self) -> None:
975
+ payload = self._build_selection_snapshot()
976
+ self.selectionSnapshotChanged.emit(payload)
977
+
978
+ def _on_scene_contents_changed(self, _changes) -> None:
979
+ scene = self.scene()
980
+ if scene is None:
981
+ return
982
+ if any(self._is_serializable_item(item) for item in scene.selectedItems()):
983
+ self._notify_selection_snapshot()
984
+
985
+ def _apply_item_transform(self, item: QtWidgets.QGraphicsItem, data: Mapping[str, Any]) -> None:
986
+ pos = data.get("pos", [0.0, 0.0])
987
+ if isinstance(pos, (list, tuple)) and len(pos) == 2:
988
+ item.setPos(float(pos[0]), float(pos[1]))
989
+ rotation = data.get("rotation")
990
+ if rotation is not None:
991
+ item.setRotation(float(rotation))
992
+ scale = data.get("scale")
993
+ if scale is not None:
994
+ item.setScale(float(scale))
995
+ z_val = data.get("z")
996
+ if z_val is not None:
997
+ item.setZValue(float(z_val))
998
+
999
+ def _instantiate_item(self, data: Mapping[str, Any]) -> QtWidgets.QGraphicsItem | None:
1000
+ shape = str(data.get("shape", ""))
1001
+ size = data.get("size")
1002
+ width = height = None
1003
+ if isinstance(size, (list, tuple)) and len(size) == 2:
1004
+ width = float(size[0])
1005
+ height = float(size[1])
1006
+ pen_data = data.get("pen") if isinstance(data, Mapping) else None
1007
+ brush_data = data.get("brush") if isinstance(data, Mapping) else None
1008
+ item: QtWidgets.QGraphicsItem | None = None
1009
+
1010
+ if shape in ("Rectangle", "Rounded Rectangle"):
1011
+ if width is None or height is None:
1012
+ width, height = DEFAULTS.get(shape, (160.0, 100.0))
1013
+ rx = float(data.get("rx", 0.0))
1014
+ ry = float(data.get("ry", rx))
1015
+ item = RectItem(0.0, 0.0, width, height, rx, ry)
1016
+ item.setPen(_pen_from_data(pen_data))
1017
+ item.setBrush(_brush_from_data(brush_data))
1018
+ label_data = data.get("label") if isinstance(data, Mapping) else None
1019
+ if label_data:
1020
+ _apply_shape_label(item, label_data) # type: ignore[arg-type]
1021
+ elif shape == "Split Rounded Rectangle":
1022
+ if width is None or height is None:
1023
+ width, height = DEFAULTS.get(shape, (180.0, 120.0))
1024
+ rx = float(data.get("rx", 0.0))
1025
+ ry = float(data.get("ry", rx))
1026
+ item = SplitRoundedRectItem(0.0, 0.0, width, height, rx, ry)
1027
+ item.setPen(_pen_from_data(pen_data))
1028
+ bottom_data = data.get("bottom_brush") if isinstance(data, Mapping) else None
1029
+ top_data = data.get("top_brush") if isinstance(data, Mapping) else None
1030
+ item.setBottomBrush(_brush_from_data(bottom_data))
1031
+ item.setTopBrush(_brush_from_data(top_data))
1032
+ divider = data.get("divider_ratio")
1033
+ if divider is not None:
1034
+ item.set_divider_ratio(float(divider))
1035
+ elif shape in ("Ellipse", "Circle"):
1036
+ if width is None or height is None:
1037
+ width, height = DEFAULTS.get(shape, (160.0, 100.0))
1038
+ item = EllipseItem(0.0, 0.0, width, height)
1039
+ item.setPen(_pen_from_data(pen_data))
1040
+ item.setBrush(_brush_from_data(brush_data))
1041
+ elif shape == "Triangle":
1042
+ if width is None or height is None:
1043
+ width, height = DEFAULTS.get(shape, (160.0, 100.0))
1044
+ item = TriangleItem(0.0, 0.0, width, height)
1045
+ item.setPen(_pen_from_data(pen_data))
1046
+ item.setBrush(_brush_from_data(brush_data))
1047
+ elif shape == "Diamond":
1048
+ if width is None or height is None:
1049
+ width, height = DEFAULTS.get(shape, (140.0, 140.0))
1050
+ item = DiamondItem(0.0, 0.0, width, height)
1051
+ item.setPen(_pen_from_data(pen_data))
1052
+ item.setBrush(_brush_from_data(brush_data))
1053
+ label_data = data.get("label") if isinstance(data, Mapping) else None
1054
+ if label_data:
1055
+ _apply_shape_label(item, label_data)
1056
+ elif shape == "Block Arrow":
1057
+ if width is None or height is None:
1058
+ width, height = DEFAULTS.get(shape, (200.0, 120.0))
1059
+ item = BlockArrowItem(0.0, 0.0, width, height)
1060
+ item.setPen(_pen_from_data(pen_data))
1061
+ item.setBrush(_brush_from_data(brush_data))
1062
+ head_ratio = data.get("head_ratio")
1063
+ if head_ratio is not None:
1064
+ item.set_head_ratio(float(head_ratio))
1065
+ shaft_ratio = data.get("shaft_ratio")
1066
+ if shaft_ratio is not None:
1067
+ item.set_shaft_ratio(float(shaft_ratio))
1068
+ elif shape in ("Line", "Arrow"):
1069
+ points_raw = data.get("points")
1070
+ points: list[QtCore.QPointF] = []
1071
+ if isinstance(points_raw, list):
1072
+ for point in points_raw:
1073
+ if isinstance(point, (list, tuple)) and len(point) == 2:
1074
+ points.append(QtCore.QPointF(float(point[0]), float(point[1])))
1075
+ arrow_start = bool(data.get("arrow_start", False))
1076
+ arrow_end = bool(data.get("arrow_end", False))
1077
+ arrow_head_data = data.get("arrow_head")
1078
+ arrow_head_length: float | None = None
1079
+ arrow_head_width: float | None = None
1080
+ if isinstance(arrow_head_data, Mapping):
1081
+ length_value = arrow_head_data.get("length")
1082
+ width_value = arrow_head_data.get("width")
1083
+ if length_value is not None:
1084
+ arrow_head_length = float(length_value)
1085
+ if width_value is not None:
1086
+ arrow_head_width = float(width_value)
1087
+ item = LineItem(
1088
+ 0.0,
1089
+ 0.0,
1090
+ points=points or None,
1091
+ arrow_start=arrow_start,
1092
+ arrow_end=arrow_end,
1093
+ arrow_head_length=arrow_head_length,
1094
+ arrow_head_width=arrow_head_width,
1095
+ )
1096
+ item.setPen(_pen_from_data(pen_data))
1097
+ elif shape == "Curvy Right Bracket":
1098
+ if width is None or height is None:
1099
+ width, height = DEFAULTS.get(shape, (80.0, 160.0))
1100
+ hook_ratio = float(data.get("hook_ratio", CurvyBracketItem.DEFAULT_HOOK_RATIO))
1101
+ item = CurvyBracketItem(0.0, 0.0, width, height, hook_ratio)
1102
+ item.setPen(_pen_from_data(pen_data))
1103
+ item.setBrush(_brush_from_data(brush_data))
1104
+ elif shape == "Text":
1105
+ if width is None or height is None:
1106
+ width, height = DEFAULTS.get(shape, (100.0, 30.0))
1107
+ item = TextItem(0.0, 0.0, width, height)
1108
+ text_value = data.get("text")
1109
+ if isinstance(text_value, str):
1110
+ item.setPlainText(text_value)
1111
+ font_value = data.get("font")
1112
+ if isinstance(font_value, str):
1113
+ font = QtGui.QFont()
1114
+ font.fromString(font_value)
1115
+ item.setFont(font)
1116
+ color_value = data.get("color")
1117
+ if isinstance(color_value, Mapping):
1118
+ item.setDefaultTextColor(_color_from_data(color_value))
1119
+ margin_value = data.get("document_margin")
1120
+ if margin_value is not None:
1121
+ item.set_document_margin(float(margin_value))
1122
+ alignment = data.get("alignment")
1123
+ if isinstance(alignment, (list, tuple)) and len(alignment) == 2:
1124
+ item.set_text_alignment(horizontal=str(alignment[0]), vertical=str(alignment[1]))
1125
+ direction = data.get("direction")
1126
+ if isinstance(direction, str):
1127
+ item.set_text_direction(direction)
1128
+ elif shape == "Folder Tree":
1129
+ structure = data.get("structure")
1130
+ if not isinstance(structure, Mapping):
1131
+ structure = None
1132
+ item = FolderTreeItem(0.0, 0.0, 0.0, 0.0, structure=structure) # type: ignore[arg-type]
1133
+ elif shape == "Group":
1134
+ item = GroupItem()
1135
+ else:
1136
+ return None
1137
+
1138
+ if shape and shape != "Group" and item is not None:
1139
+ item.setData(0, shape)
1140
+ return item
1141
+
1142
+ def _restore_group_children(
1143
+ self,
1144
+ group: GroupItem,
1145
+ children_data: list[Mapping[str, Any]],
1146
+ ) -> None:
1147
+ scene = self.scene()
1148
+ if scene is None:
1149
+ return
1150
+ for child_data in children_data:
1151
+ if not isinstance(child_data, Mapping):
1152
+ continue
1153
+ child = self._instantiate_item(child_data)
1154
+ if child is None:
1155
+ continue
1156
+ scene.addItem(child)
1157
+ group.addToGroup(child)
1158
+ self._apply_item_transform(child, child_data)
1159
+ child.setSelected(False)
1160
+ child.setFlag(
1161
+ QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIsSelectable,
1162
+ False,
1163
+ )
1164
+ child.setFlag(
1165
+ QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIsMovable,
1166
+ False,
1167
+ )
1168
+ if isinstance(child, ResizableItem):
1169
+ child.hide_handles()
1170
+ if isinstance(child, GroupItem):
1171
+ sub_children = child_data.get("children")
1172
+ if isinstance(sub_children, list):
1173
+ self._restore_group_children(child, sub_children)
1174
+
1175
+ def _restore_scene_state(self, state: Mapping[str, Any]) -> None:
1176
+ scene = self.scene()
1177
+ if scene is None:
1178
+ return
1179
+ self.clear_canvas()
1180
+ restored: list[QtWidgets.QGraphicsItem] = []
1181
+ items_data = state.get("items") if isinstance(state, Mapping) else None
1182
+ if isinstance(items_data, list):
1183
+ for data in items_data:
1184
+ if not isinstance(data, Mapping):
1185
+ continue
1186
+ item = self._instantiate_item(data)
1187
+ if item is None:
1188
+ continue
1189
+ scene.addItem(item)
1190
+ if isinstance(item, GroupItem):
1191
+ children = data.get("children")
1192
+ if isinstance(children, list):
1193
+ self._restore_group_children(item, children)
1194
+ self._apply_item_transform(item, data)
1195
+ restored.append(item)
1196
+ self._ensure_pages_for_items(restored)
1197
+ scene.clearSelection()
1198
+ grid_visible = bool(state.get("grid_visible", self._show_grid))
1199
+ self._show_grid = grid_visible
1200
+ for page in self._pages.values():
1201
+ page.set_grid_visible(grid_visible)
1202
+ self.viewport().update()
1203
+ self._update_scene_rect()
1204
+ self.gridVisibilityChanged.emit(self._show_grid)
1205
+
1206
+ def _page_top_left_for_index(self, index: tuple[int, int]) -> QtCore.QPointF:
1207
+ row, col = index
1208
+ base_x = -self._page_width / 2.0
1209
+ base_y = -self._page_height / 2.0
1210
+ return QtCore.QPointF(
1211
+ base_x + col * self._page_width,
1212
+ base_y + row * self._page_height,
1213
+ )
1214
+
1215
+ def _page_index_for_point(self, point: QtCore.QPointF) -> tuple[int, int]:
1216
+ base_x = -self._page_width / 2.0
1217
+ base_y = -self._page_height / 2.0
1218
+ col = math.floor((point.x() - base_x) / self._page_width)
1219
+ row = math.floor((point.y() - base_y) / self._page_height)
1220
+ return (row, col)
1221
+
1222
+ def _create_page_item(self, index: tuple[int, int]) -> A4PageItem:
1223
+ page = A4PageItem(
1224
+ self._page_width,
1225
+ self._page_height,
1226
+ margin_mm=12.0,
1227
+ grid_px=self._grid_size,
1228
+ subgrid_px=self._grid_size_min,
1229
+ index=index,
1230
+ master_origin=self._master_origin,
1231
+ )
1232
+ top_left = self._page_top_left_for_index(index)
1233
+ page.setPos(top_left)
1234
+ page.set_grid_visible(self._show_grid)
1235
+ return page
1236
+
1237
+ def _add_page(
1238
+ self, index: tuple[int, int], *, update_edges: bool = True
1239
+ ) -> A4PageItem:
1240
+ existing = self._pages.get(index)
1241
+ if existing is not None:
1242
+ existing.set_master_origin(self._master_origin)
1243
+ return existing
1244
+ page = self._create_page_item(index)
1245
+ self.scene().addItem(page)
1246
+ self._pages[index] = page
1247
+ if update_edges:
1248
+ self._update_transition_edges()
1249
+ return page
1250
+
1251
+ def _ensure_pages_between_master(self, target_index: tuple[int, int]) -> None:
1252
+ master_row, master_col = self._master_index
1253
+ target_row, target_col = target_index
1254
+ min_row = min(master_row, target_row)
1255
+ max_row = max(master_row, target_row)
1256
+ min_col = min(master_col, target_col)
1257
+ max_col = max(master_col, target_col)
1258
+
1259
+ added_any = False
1260
+ for row in range(min_row, max_row + 1):
1261
+ for col in range(min_col, max_col + 1):
1262
+ index = (row, col)
1263
+ if index not in self._pages:
1264
+ added_any = True
1265
+ self._add_page(index, update_edges=False)
1266
+ if added_any:
1267
+ self._update_transition_edges()
1268
+
1269
+ def _update_transition_edges(self) -> None:
1270
+ master_page = self._pages.get(self._master_index)
1271
+ if master_page is None:
1272
+ return
1273
+ for (row, col), page in self._pages.items():
1274
+ page.set_outline_neighbors(
1275
+ left=(row, col - 1) in self._pages,
1276
+ right=(row, col + 1) in self._pages,
1277
+ top=(row - 1, col) in self._pages,
1278
+ bottom=(row + 1, col) in self._pages,
1279
+ )
1280
+ master_edges: set[str] = set()
1281
+ master_row, master_col = self._master_index
1282
+ for (row, col), page in self._pages.items():
1283
+ if (row, col) == self._master_index:
1284
+ continue
1285
+ edges: set[str] = set()
1286
+ if row == master_row:
1287
+ if col == master_col + 1:
1288
+ edges.add("left")
1289
+ master_edges.add("right")
1290
+ elif col == master_col - 1:
1291
+ edges.add("right")
1292
+ master_edges.add("left")
1293
+ if col == master_col:
1294
+ if row == master_row + 1:
1295
+ edges.add("top")
1296
+ master_edges.add("bottom")
1297
+ elif row == master_row - 1:
1298
+ edges.add("bottom")
1299
+ master_edges.add("top")
1300
+ page.set_transition_edges(edges)
1301
+ master_page.set_transition_edges(master_edges)
1302
+
1303
+ def _ensure_page_for_item(
1304
+ self, item: QtWidgets.QGraphicsItem, drop_reference: QtCore.QPointF | None
1305
+ ) -> A4PageItem:
1306
+ rect = item.sceneBoundingRect()
1307
+ page: A4PageItem | None = None
1308
+ for existing in self._pages.values():
1309
+ page_rect = existing.mapRectToScene(existing.rect())
1310
+ if page_rect.contains(rect):
1311
+ page = existing
1312
+ break
1313
+
1314
+ indices_to_connect: set[tuple[int, int]] = set()
1315
+
1316
+ if page is None:
1317
+ reference = drop_reference if drop_reference is not None else rect.center()
1318
+ index = self._page_index_for_point(reference)
1319
+ page = self._add_page(index)
1320
+ indices_to_connect.add(index)
1321
+ if not page.mapRectToScene(page.rect()).contains(rect):
1322
+ center_index = self._page_index_for_point(rect.center())
1323
+ page = self._add_page(center_index)
1324
+ indices_to_connect.add(center_index)
1325
+ if page is not None:
1326
+ indices_to_connect.add(page.index)
1327
+
1328
+ for index in indices_to_connect:
1329
+ self._ensure_pages_between_master(index)
1330
+
1331
+ self._prune_empty_pages()
1332
+ self._update_scene_rect()
1333
+ assert page is not None
1334
+ return page
1335
+
1336
+ def _collect_canvas_content_items(self) -> list[QtWidgets.QGraphicsItem]:
1337
+ scene = self.scene()
1338
+ if scene is None:
1339
+ return []
1340
+ items: list[QtWidgets.QGraphicsItem] = []
1341
+ for item in scene.items():
1342
+ if isinstance(item, A4PageItem):
1343
+ continue
1344
+ if item.__class__.__name__.endswith("Handle"):
1345
+ continue
1346
+ items.append(item)
1347
+ return items
1348
+
1349
+ def _prune_empty_pages(self) -> bool:
1350
+ scene = self.scene()
1351
+ if scene is None:
1352
+ return False
1353
+ content_items = self._collect_canvas_content_items()
1354
+ master_row, master_col = self._master_index
1355
+
1356
+ content_indices: set[tuple[int, int]] = set()
1357
+ for index, page in self._pages.items():
1358
+ page_rect = page.mapRectToScene(page.rect())
1359
+ has_item = any(
1360
+ item.sceneBoundingRect().intersects(page_rect)
1361
+ for item in content_items
1362
+ if item.scene() is scene
1363
+ )
1364
+ if has_item:
1365
+ content_indices.add(index)
1366
+
1367
+ required_indices: set[tuple[int, int]] = {self._master_index}
1368
+ for row, col in content_indices:
1369
+ min_row = min(master_row, row)
1370
+ max_row = max(master_row, row)
1371
+ min_col = min(master_col, col)
1372
+ max_col = max(master_col, col)
1373
+ for r in range(min_row, max_row + 1):
1374
+ for c in range(min_col, max_col + 1):
1375
+ required_indices.add((r, c))
1376
+
1377
+ removed = False
1378
+ for index, page in list(self._pages.items()):
1379
+ if index in required_indices:
1380
+ continue
1381
+ self._pages.pop(index, None)
1382
+ scene.removeItem(page)
1383
+ removed = True
1384
+ if removed:
1385
+ self._update_transition_edges()
1386
+ return removed
1387
+
1388
+ def _ensure_pages_for_items(
1389
+ self, items: list[QtWidgets.QGraphicsItem]
1390
+ ) -> None:
1391
+ for item in items:
1392
+ if isinstance(item, A4PageItem):
1393
+ continue
1394
+ if item.__class__.__name__.endswith("Handle"):
1395
+ continue
1396
+ if item.parentItem() is not None:
1397
+ continue
1398
+ self._ensure_page_for_item(item, item.sceneBoundingRect().center())
1399
+
1400
+ def _fit_view_to_page(self) -> None:
1401
+ if self._page_item is None:
1402
+ return
1403
+ page_scene_rect = self._page_item.mapRectToScene(self._page_item.rect())
1404
+ padded = page_scene_rect.adjusted(-80, -80, 80, 80)
1405
+ if padded.isValid() and padded.width() > 0 and padded.height() > 0:
1406
+ self.fitInView(padded, QtCore.Qt.AspectRatioMode.KeepAspectRatio)
1407
+ self.centerOn(self._page_item)
1408
+
1409
+ def clear_canvas(self):
1410
+ """Remove all items from the scene."""
1411
+ scene = self.scene()
1412
+ for item in list(scene.items()):
1413
+ if isinstance(item, A4PageItem):
1414
+ continue
1415
+ if item.__class__.__name__.endswith("Handle"):
1416
+ continue
1417
+ if item.parentItem() is not None:
1418
+ continue
1419
+ scene.removeItem(item)
1420
+ self._prune_empty_pages()
1421
+ self._update_scene_rect()
1422
+
1423
+ def ensure_pages_for_scene_items(self) -> None:
1424
+ """Ensure every top-level scene item has an A4 page beneath it."""
1425
+ scene = self.scene()
1426
+ if scene is None:
1427
+ return
1428
+ self._ensure_pages_for_items(scene.items())
1429
+
1430
+ def drawBackground(self, painter: QtGui.QPainter, rect: QtCore.QRectF):
1431
+ super().drawBackground(painter, rect)
1432
+
1433
+ def set_grid_visible(self, visible: bool):
1434
+ self._show_grid = visible
1435
+ for page in self._pages.values():
1436
+ page.set_grid_visible(visible)
1437
+ self.viewport().update()
1438
+ if hasattr(self, "_history"):
1439
+ self._history.mark_dirty()
1440
+ self.gridVisibilityChanged.emit(self._show_grid)
1441
+
1442
+ def _update_scene_rect(self):
1443
+ scene = self.scene()
1444
+ padding = self._scene_padding
1445
+ items_rect = scene.itemsBoundingRect()
1446
+ viewport_rect = self.mapToScene(self.viewport().rect()).boundingRect()
1447
+ if items_rect.isNull():
1448
+ combined = viewport_rect
1449
+ else:
1450
+ combined = items_rect.united(viewport_rect)
1451
+ if combined.isNull():
1452
+ new_rect = QtCore.QRectF(
1453
+ -padding,
1454
+ -padding,
1455
+ padding * 2,
1456
+ padding * 2,
1457
+ )
1458
+ else:
1459
+ new_rect = combined.adjusted(-padding, -padding, padding, padding)
1460
+ if new_rect != scene.sceneRect():
1461
+ scene.setSceneRect(new_rect)
1462
+ # ensure newly exposed areas are repainted so drag handles don't leave trails
1463
+ self.viewport().update()
1464
+
1465
+ def resizeEvent(self, event: QtGui.QResizeEvent):
1466
+ """Ensure scene rect grows with the view."""
1467
+ super().resizeEvent(event)
1468
+ self._update_scene_rect()
1469
+
1470
+ def add_shape(
1471
+ self,
1472
+ shape: str,
1473
+ scene_pos: QtCore.QPointF,
1474
+ snap_to_grid: bool = True,
1475
+ ) -> QtWidgets.QGraphicsItem | None:
1476
+ normalized = shape.strip()
1477
+ if normalized not in SHAPES:
1478
+ return None
1479
+
1480
+ x = scene_pos.x()
1481
+ y = scene_pos.y()
1482
+ w, h = DEFAULTS[normalized]
1483
+
1484
+ if snap_to_grid:
1485
+ size = self._grid_size
1486
+ origin = self._master_origin
1487
+ if isinstance(origin, QtCore.QPointF):
1488
+ origin_x, origin_y = origin.x(), origin.y()
1489
+ else:
1490
+ origin_x = float(getattr(origin, "x", 0.0))
1491
+ origin_y = float(getattr(origin, "y", 0.0))
1492
+ x = _snap_coordinate(x, size, origin_x)
1493
+ y = _snap_coordinate(y, size, origin_y)
1494
+ if normalized in ("Line", "Arrow"):
1495
+ w = round(w / size) * size
1496
+
1497
+ drop_reference = QtCore.QPointF(x + w / 2.0, y + h / 2.0)
1498
+
1499
+ if normalized == "Rectangle":
1500
+ item = RectItem(x, y, w, h)
1501
+ elif normalized == "Rounded Rectangle":
1502
+ item = RectItem(x, y, w, h, 15.0, 15.0)
1503
+ elif normalized == "Split Rounded Rectangle":
1504
+ item = SplitRoundedRectItem(x, y, w, h, 15.0, 15.0)
1505
+ elif normalized in ("Circle", "Ellipse"):
1506
+ item = EllipseItem(x, y, w, h)
1507
+ elif normalized == "Triangle":
1508
+ item = TriangleItem(x, y, w, h)
1509
+ elif normalized == "Diamond":
1510
+ item = DiamondItem(x, y, w, h)
1511
+ elif normalized == "Line":
1512
+ item = LineItem(x, y, w)
1513
+ elif normalized == "Arrow":
1514
+ item = LineItem(x, y, w, arrow_end=True)
1515
+ elif normalized == "Block Arrow":
1516
+ item = BlockArrowItem(x, y, w, h)
1517
+ elif normalized == "Curvy Right Bracket":
1518
+ item = CurvyBracketItem(x, y, w, h)
1519
+ elif normalized == "Text":
1520
+ item = TextItem(x, y, w, h)
1521
+ elif normalized == "Folder Tree":
1522
+ item = FolderTreeItem(x, y, w, h)
1523
+ else:
1524
+ return None
1525
+
1526
+ item.setData(0, normalized)
1527
+ self.scene().addItem(item)
1528
+ item.setSelected(True)
1529
+ self._ensure_page_for_item(item, drop_reference)
1530
+ self._update_scene_rect()
1531
+ return item
1532
+
1533
+ def add_shape_at_view_center(self, shape: str) -> QtWidgets.QGraphicsItem | None:
1534
+ normalized = shape.strip()
1535
+ if normalized not in SHAPES:
1536
+ return None
1537
+
1538
+ center = self.mapToScene(self.viewport().rect().center())
1539
+ w, h = DEFAULTS[normalized]
1540
+ if normalized in ("Line", "Arrow"):
1541
+ pos = QtCore.QPointF(center.x() - w / 2.0, center.y())
1542
+ else:
1543
+ pos = QtCore.QPointF(center.x() - w / 2.0, center.y() - h / 2.0)
1544
+ return self.add_shape(normalized, pos, snap_to_grid=False)
1545
+
1546
+ # --- Drag and drop from the palette ---
1547
+ def dragEnterEvent(self, event: QtGui.QDragEnterEvent):
1548
+ md = event.mimeData()
1549
+ if md.hasFormat(PALETTE_MIME) or md.hasText():
1550
+ event.acceptProposedAction()
1551
+ else:
1552
+ super().dragEnterEvent(event)
1553
+
1554
+ def dragMoveEvent(self, event: QtGui.QDragMoveEvent):
1555
+ if event.mimeData().hasFormat(PALETTE_MIME) or event.mimeData().hasText():
1556
+ event.acceptProposedAction()
1557
+ else:
1558
+ super().dragMoveEvent(event)
1559
+
1560
+ def dropEvent(self, event: QtGui.QDropEvent):
1561
+ md = event.mimeData()
1562
+ text = ""
1563
+ if md.hasFormat(PALETTE_MIME):
1564
+ text = str(bytes(md.data(PALETTE_MIME)).decode("utf-8"))
1565
+ elif md.hasText():
1566
+ text = md.text()
1567
+
1568
+ shape = text.strip()
1569
+ if shape not in SHAPES:
1570
+ super().dropEvent(event)
1571
+ return
1572
+
1573
+ scene_pos = self.mapToScene(event.position().toPoint())
1574
+ snap = not (
1575
+ event.keyboardModifiers() & QtCore.Qt.KeyboardModifier.AltModifier
1576
+ )
1577
+ item = self.add_shape(shape, scene_pos, snap_to_grid=snap)
1578
+ if item is not None:
1579
+ event.acceptProposedAction()
1580
+ else:
1581
+ super().dropEvent(event)
1582
+
1583
+ # --- Duplicate selected items with Ctrl+drag ---
1584
+ def mousePressEvent(self, event: QtGui.QMouseEvent):
1585
+ if event.button() == QtCore.Qt.MouseButton.MiddleButton:
1586
+ self._panning = True
1587
+ self._pan_start = event.position()
1588
+ self._prev_drag_mode = self.dragMode()
1589
+ self.setDragMode(QtWidgets.QGraphicsView.DragMode.NoDrag)
1590
+ self.viewport().setCursor(QtCore.Qt.CursorShape.ClosedHandCursor)
1591
+ event.accept()
1592
+ return
1593
+ if event.button() == QtCore.Qt.MouseButton.RightButton:
1594
+ self._right_button_pressed = True
1595
+ self._pan_start = event.position()
1596
+ self._prev_drag_mode = self.dragMode()
1597
+ self._suppress_context_menu = False
1598
+ event.accept()
1599
+ return
1600
+ if event.button() == QtCore.Qt.MouseButton.LeftButton:
1601
+ mods = event.modifiers()
1602
+ if mods & (
1603
+ QtCore.Qt.KeyboardModifier.ControlModifier
1604
+ | QtCore.Qt.KeyboardModifier.ShiftModifier
1605
+ ):
1606
+ item = self.itemAt(event.pos())
1607
+ if item:
1608
+ if (
1609
+ mods & QtCore.Qt.KeyboardModifier.ControlModifier
1610
+ and item.isSelected()
1611
+ ):
1612
+ selected = self.scene().selectedItems()
1613
+ if selected:
1614
+ # Store initial state but postpone cloning until the mouse
1615
+ # has moved far enough to avoid duplicates appearing in place.
1616
+ self._dup_source = list(selected)
1617
+ self._dup_items = None
1618
+ self._dup_orig = None
1619
+ self._dup_start = self.mapToScene(
1620
+ event.position().toPoint()
1621
+ )
1622
+ event.accept()
1623
+ return
1624
+ else:
1625
+ item.setSelected(True)
1626
+ event.accept()
1627
+ return
1628
+ super().mousePressEvent(event)
1629
+
1630
+ def mouseMoveEvent(self, event: QtGui.QMouseEvent):
1631
+
1632
+ item = self.itemAt(event.position().toPoint())
1633
+ if item and item.flags() & QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIsMovable:
1634
+ self.viewport().setCursor(QtCore.Qt.CursorShape.SizeAllCursor)
1635
+ else:
1636
+ self.viewport().setCursor(QtCore.Qt.CursorShape.ArrowCursor)
1637
+
1638
+ if self._panning or self._right_button_pressed:
1639
+ delta = event.position() - self._pan_start
1640
+ if (
1641
+ self._right_button_pressed
1642
+ and not self._panning
1643
+ and delta.manhattanLength() > 0
1644
+ ):
1645
+ self._panning = True
1646
+ self.setDragMode(QtWidgets.QGraphicsView.DragMode.NoDrag)
1647
+ self.viewport().setCursor(
1648
+ QtCore.Qt.CursorShape.ClosedHandCursor
1649
+ )
1650
+ if self._panning:
1651
+ self._pan_start = event.position()
1652
+ hbar = self.horizontalScrollBar()
1653
+ vbar = self.verticalScrollBar()
1654
+ hbar.setValue(hbar.value() - int(delta.x()))
1655
+ vbar.setValue(vbar.value() - int(delta.y()))
1656
+ event.accept()
1657
+ return
1658
+ if getattr(self, "_dup_source", None):
1659
+ pos = self.mapToScene(event.position().toPoint())
1660
+ delta = pos - self._dup_start
1661
+ if self._dup_items is None:
1662
+ # Only create clones after surpassing the threshold.
1663
+ if delta.manhattanLength() < DUPLICATE_DRAG_THRESHOLD:
1664
+ event.accept()
1665
+ return
1666
+ self._dup_items = []
1667
+ self._dup_orig = []
1668
+ for it in self._dup_source:
1669
+ clone = self._clone_item(it)
1670
+ if clone:
1671
+ self.scene().addItem(clone)
1672
+ self._dup_items.append(clone)
1673
+ self._dup_orig.append(clone.pos())
1674
+ self.scene().clearSelection()
1675
+ for it in self._dup_items:
1676
+ it.setSelected(True)
1677
+ for it, start in zip(self._dup_items, self._dup_orig):
1678
+ it.setPos(start + delta)
1679
+ event.accept()
1680
+ return
1681
+ super().mouseMoveEvent(event)
1682
+
1683
+ def mouseReleaseEvent(self, event: QtGui.QMouseEvent):
1684
+ if event.button() == QtCore.Qt.MouseButton.RightButton:
1685
+ if self._panning:
1686
+ self._panning = False
1687
+ self._right_button_pressed = False
1688
+ self.setDragMode(self._prev_drag_mode)
1689
+ self.viewport().setCursor(
1690
+ QtCore.Qt.CursorShape.ArrowCursor
1691
+ )
1692
+ self._suppress_context_menu = True
1693
+ event.accept()
1694
+ return
1695
+ if self._right_button_pressed:
1696
+ self._right_button_pressed = False
1697
+ event.accept()
1698
+ return
1699
+ if (
1700
+ event.button() == QtCore.Qt.MouseButton.MiddleButton and self._panning
1701
+ ):
1702
+ self._panning = False
1703
+ self.setDragMode(self._prev_drag_mode)
1704
+ self.viewport().setCursor(QtCore.Qt.CursorShape.ArrowCursor)
1705
+ event.accept()
1706
+ return
1707
+ if event.button() == QtCore.Qt.MouseButton.LeftButton:
1708
+ if getattr(self, "_dup_items", None):
1709
+ self._dup_items = []
1710
+ self._dup_orig = []
1711
+ self._dup_source = []
1712
+ self._ensure_pages_for_items(self.scene().selectedItems())
1713
+ event.accept()
1714
+ return
1715
+ if getattr(self, "_dup_source", None):
1716
+ # Ctrl+click without enough movement -> no duplication
1717
+ self._dup_source = []
1718
+ event.accept()
1719
+ return
1720
+ super().mouseReleaseEvent(event)
1721
+ if event.button() == QtCore.Qt.MouseButton.LeftButton:
1722
+ self._ensure_pages_for_items(self.scene().selectedItems())
1723
+
1724
+ def _clone_item(self, item: QtWidgets.QGraphicsItem):
1725
+ if isinstance(item, RectItem):
1726
+ r = item.rect()
1727
+ clone = RectItem(item.x(), item.y(), r.width(), r.height(), getattr(item, "rx", 0.0), getattr(item, "ry", 0.0))
1728
+ clone.setBrush(item.brush())
1729
+ clone.setPen(item.pen())
1730
+ elif isinstance(item, SplitRoundedRectItem):
1731
+ r = item.rect()
1732
+ clone = SplitRoundedRectItem(
1733
+ item.x(),
1734
+ item.y(),
1735
+ r.width(),
1736
+ r.height(),
1737
+ getattr(item, "rx", 0.0),
1738
+ getattr(item, "ry", 0.0),
1739
+ )
1740
+ clone.setTopBrush(item.topBrush())
1741
+ clone.setBottomBrush(item.bottomBrush())
1742
+ clone.set_divider_ratio(item.divider_ratio())
1743
+ clone.setPen(item.pen())
1744
+ elif isinstance(item, EllipseItem):
1745
+ r = item.rect()
1746
+ clone = EllipseItem(item.x(), item.y(), r.width(), r.height())
1747
+ clone.setBrush(item.brush())
1748
+ clone.setPen(item.pen())
1749
+ elif isinstance(item, TriangleItem):
1750
+ br = item.boundingRect()
1751
+ clone = TriangleItem(item.x(), item.y(), br.width(), br.height())
1752
+ clone.setBrush(item.brush())
1753
+ clone.setPen(item.pen())
1754
+ elif isinstance(item, DiamondItem):
1755
+ br = item.boundingRect()
1756
+ clone = DiamondItem(item.x(), item.y(), br.width(), br.height())
1757
+ clone.setBrush(item.brush())
1758
+ clone.setPen(item.pen())
1759
+ elif isinstance(item, LineItem):
1760
+ clone = LineItem(
1761
+ item.x(),
1762
+ item.y(),
1763
+ points=[QtCore.QPointF(p) for p in item._points],
1764
+ arrow_start=getattr(item, "arrow_start", False),
1765
+ arrow_end=getattr(item, "arrow_end", False),
1766
+ arrow_head_length=getattr(item, "arrow_head_length", lambda: 10.0)(),
1767
+ arrow_head_width=getattr(item, "arrow_head_width", lambda: 10.0)(),
1768
+ )
1769
+ clone.setPen(item.pen())
1770
+ elif isinstance(item, TextItem):
1771
+ br = item.boundingRect()
1772
+ clone = TextItem(item.x(), item.y(), br.width(), br.height())
1773
+ clone.setPlainText(item.toPlainText())
1774
+ clone.setFont(item.font())
1775
+ clone.setDefaultTextColor(item.defaultTextColor())
1776
+ doc = item.document()
1777
+ if doc is not None:
1778
+ clone.set_document_margin(doc.documentMargin())
1779
+ h_align, v_align = item.text_alignment()
1780
+ clone.set_text_alignment(horizontal=h_align, vertical=v_align)
1781
+ clone.set_text_direction(item.text_direction())
1782
+ clone.setScale(item.scale())
1783
+ br = clone.boundingRect()
1784
+ clone.setTransformOriginPoint(br.width() / 2.0, br.height() / 2.0)
1785
+ elif isinstance(item, FolderTreeItem):
1786
+ clone = FolderTreeItem(item.x(), item.y(), 0.0, 0.0, structure=item.structure())
1787
+ clone.setScale(item.scale())
1788
+ else:
1789
+ return None
1790
+ if isinstance(item, ShapeLabelMixin) and isinstance(clone, ShapeLabelMixin):
1791
+ clone.copy_label_from(item)
1792
+ clone.setRotation(item.rotation())
1793
+ clone.setData(0, item.data(0))
1794
+ return clone
1795
+
1796
+ # --- Mouse wheel zooming and scrolling ---
1797
+ def wheelEvent(self, event: QtGui.QWheelEvent):
1798
+ mods = event.modifiers()
1799
+ if mods & (
1800
+ QtCore.Qt.KeyboardModifier.ControlModifier
1801
+ | QtCore.Qt.KeyboardModifier.AltModifier
1802
+ ):
1803
+ anchor = self.transformationAnchor()
1804
+ self.setTransformationAnchor(
1805
+ QtWidgets.QGraphicsView.ViewportAnchor.AnchorUnderMouse
1806
+ )
1807
+ delta = event.angleDelta().y()
1808
+ if delta == 0:
1809
+ delta = event.angleDelta().x()
1810
+ factor = 1.2 if delta > 0 else 1 / 1.2
1811
+ self.scale(factor, factor)
1812
+ self.setTransformationAnchor(anchor)
1813
+ self._update_scene_rect()
1814
+ event.accept()
1815
+ return
1816
+ super().wheelEvent(event)
1817
+
1818
+ def _group_selected_items(self):
1819
+ selected = self.scene().selectedItems()
1820
+ if len(selected) < 2:
1821
+ return
1822
+
1823
+ br = QtCore.QRectF()
1824
+ for it in selected:
1825
+ br = br.united(it.sceneBoundingRect())
1826
+
1827
+ group = GroupItem()
1828
+ group.setPos(br.topLeft())
1829
+ self.scene().addItem(group)
1830
+
1831
+ for it in selected:
1832
+ group.addToGroup(it)
1833
+ it.setSelected(False)
1834
+ it.setFlag(
1835
+ QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIsSelectable, False
1836
+ )
1837
+ it.setFlag(
1838
+ QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIsMovable, False
1839
+ )
1840
+ if isinstance(it, ResizableItem):
1841
+ it.hide_handles()
1842
+
1843
+ group.setTransformOriginPoint(group.boundingRect().center())
1844
+ group.setSelected(True)
1845
+ group.update_handles()
1846
+ self._update_scene_rect()
1847
+
1848
+ def _ungroup_selected_items(self):
1849
+ selected = self.scene().selectedItems()
1850
+ changed = False
1851
+ for it in selected:
1852
+ if isinstance(it, GroupItem):
1853
+ it.setSelected(False)
1854
+ children = [
1855
+ c
1856
+ for c in it.childItems()
1857
+ if not isinstance(c, (ResizeHandle, RotationHandle))
1858
+ ]
1859
+ for child in children:
1860
+ it.removeFromGroup(child)
1861
+ child.setFlag(
1862
+ QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIsSelectable,
1863
+ True,
1864
+ )
1865
+ child.setFlag(
1866
+ QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIsMovable,
1867
+ True,
1868
+ )
1869
+ child.setSelected(False)
1870
+ self.scene().removeItem(it)
1871
+ changed = True
1872
+ if changed:
1873
+ self.scene().clearSelection()
1874
+ self._prune_empty_pages()
1875
+ self._update_scene_rect()
1876
+
1877
+ # --- Keyboard shortcut to delete selected items ---
1878
+ def keyPressEvent(self, event: QtGui.QKeyEvent):
1879
+ if event.key() == QtCore.Qt.Key.Key_Delete:
1880
+ selected = self.scene().selectedItems()
1881
+ if selected:
1882
+ for it in selected:
1883
+ self.scene().removeItem(it)
1884
+ self._prune_empty_pages()
1885
+ self._update_scene_rect()
1886
+ event.accept()
1887
+ return
1888
+ mods = event.modifiers()
1889
+ if mods & QtCore.Qt.KeyboardModifier.ControlModifier:
1890
+ if event.key() == QtCore.Qt.Key.Key_Z:
1891
+ if mods & QtCore.Qt.KeyboardModifier.ShiftModifier:
1892
+ self.redo()
1893
+ else:
1894
+ self.undo()
1895
+ event.accept()
1896
+ return
1897
+ if event.key() == QtCore.Qt.Key.Key_Y:
1898
+ self.redo()
1899
+ event.accept()
1900
+ return
1901
+ if (
1902
+ event.key() == QtCore.Qt.Key.Key_G
1903
+ and mods == QtCore.Qt.KeyboardModifier.ControlModifier
1904
+ ):
1905
+ self._group_selected_items()
1906
+ event.accept()
1907
+ return
1908
+ if (
1909
+ event.key() == QtCore.Qt.Key.Key_G
1910
+ and mods
1911
+ == (
1912
+ QtCore.Qt.KeyboardModifier.ControlModifier
1913
+ | QtCore.Qt.KeyboardModifier.ShiftModifier
1914
+ )
1915
+ ):
1916
+ self._ungroup_selected_items()
1917
+ event.accept()
1918
+ return
1919
+ super().keyPressEvent(event)
1920
+
1921
+ # --- Alignment helpers ---
1922
+ def _align_items(self, items, mode: str):
1923
+ brs = [it.sceneBoundingRect() for it in items]
1924
+ if mode == "grid":
1925
+ size = self._grid_size
1926
+ for it, br in zip(items, brs):
1927
+ new_x = round(br.left() / size) * size
1928
+ new_y = round(br.top() / size) * size
1929
+ it.moveBy(new_x - br.left(), new_y - br.top())
1930
+ return
1931
+ if mode == "left":
1932
+ target = min(br.left() for br in brs)
1933
+ for it, br in zip(items, brs):
1934
+ it.moveBy(target - br.left(), 0)
1935
+ elif mode == "hcenter":
1936
+ target = sum(br.center().x() for br in brs) / len(brs)
1937
+ for it, br in zip(items, brs):
1938
+ it.moveBy(target - br.center().x(), 0)
1939
+ elif mode == "right":
1940
+ target = max(br.right() for br in brs)
1941
+ for it, br in zip(items, brs):
1942
+ it.moveBy(target - br.right(), 0)
1943
+ elif mode == "top":
1944
+ target = min(br.top() for br in brs)
1945
+ for it, br in zip(items, brs):
1946
+ it.moveBy(0, target - br.top())
1947
+ elif mode == "vcenter":
1948
+ target = sum(br.center().y() for br in brs) / len(brs)
1949
+ for it, br in zip(items, brs):
1950
+ it.moveBy(0, target - br.center().y())
1951
+ elif mode == "bottom":
1952
+ target = max(br.bottom() for br in brs)
1953
+ for it, br in zip(items, brs):
1954
+ it.moveBy(0, target - br.bottom())
1955
+
1956
+ def _create_color_action(
1957
+ self,
1958
+ menu: QtWidgets.QMenu,
1959
+ text: str,
1960
+ title: str,
1961
+ color_getter: Callable[[], QtGui.QColor],
1962
+ color_setter: Callable[[QtGui.QColor], None],
1963
+ ) -> tuple[QtGui.QAction, Callable[[], None]]:
1964
+ action = menu.addAction(text)
1965
+
1966
+ def callback() -> None:
1967
+ color = QtWidgets.QColorDialog.getColor(color_getter(), self, title)
1968
+ if color.isValid():
1969
+ color_setter(color)
1970
+
1971
+ return action, callback
1972
+
1973
+ def _create_double_action(
1974
+ self,
1975
+ menu: QtWidgets.QMenu,
1976
+ text: str,
1977
+ dialog_title: str,
1978
+ label: str,
1979
+ value_getter: Callable[[], float],
1980
+ value_setter: Callable[[float], None],
1981
+ minimum: float,
1982
+ maximum: float,
1983
+ decimals: int,
1984
+ ) -> tuple[QtGui.QAction, Callable[[], None]]:
1985
+ action = menu.addAction(text)
1986
+
1987
+ def callback() -> None:
1988
+ value, ok = QtWidgets.QInputDialog.getDouble(
1989
+ self,
1990
+ dialog_title,
1991
+ label,
1992
+ value_getter(),
1993
+ minimum,
1994
+ maximum,
1995
+ decimals,
1996
+ )
1997
+ if ok:
1998
+ value_setter(value)
1999
+
2000
+ return action, callback
2001
+
2002
+ def _add_shape_label_actions(
2003
+ self, menu: QtWidgets.QMenu, item: ShapeLabelMixin
2004
+ ) -> dict[QtGui.QAction, Callable[[], None]]:
2005
+ actions: dict[QtGui.QAction, Callable[[], None]] = {}
2006
+ label_menu = menu.addMenu("Label")
2007
+ edit_action = label_menu.addAction("Edit text")
2008
+ actions[edit_action] = lambda item=item: item.edit_label()
2009
+
2010
+ has_label = item.has_label()
2011
+
2012
+ color_action, color_callback = self._create_color_action(
2013
+ label_menu,
2014
+ "Set font color...",
2015
+ "Label font color",
2016
+ lambda item=item: item.label_color(),
2017
+ lambda color, item=item: item.set_label_color(color),
2018
+ )
2019
+ color_action.setEnabled(has_label)
2020
+ actions[color_action] = color_callback
2021
+
2022
+ font_action = label_menu.addAction("Set font size...")
2023
+ font_action.setEnabled(has_label)
2024
+
2025
+ def font_callback(item=item) -> None:
2026
+ label_item = item.label_item()
2027
+ if label_item is None:
2028
+ return
2029
+ font = QtGui.QFont(label_item.font())
2030
+ pixel_size = float(font.pixelSize())
2031
+ if pixel_size <= 0.0:
2032
+ point_size = font.pointSizeF()
2033
+ if point_size > 0.0:
2034
+ pixel_size = point_size
2035
+ if pixel_size <= 0.0:
2036
+ pixel_size = QtGui.QFontMetricsF(font).height()
2037
+ value, ok = QtWidgets.QInputDialog.getDouble(
2038
+ self,
2039
+ "Label font size",
2040
+ "Font size (px):",
2041
+ max(1.0, pixel_size),
2042
+ 1.0,
2043
+ 500.0,
2044
+ 1,
2045
+ )
2046
+ if ok:
2047
+ item.set_label_font_pixel_size(value)
2048
+
2049
+ actions[font_action] = font_callback
2050
+
2051
+ label_menu.addSeparator()
2052
+ h_menu = label_menu.addMenu("Horizontal")
2053
+ v_menu = label_menu.addMenu("Vertical")
2054
+ h_menu.setEnabled(has_label)
2055
+ v_menu.setEnabled(has_label)
2056
+ h_align, v_align = item.label_alignment()
2057
+ for title, key in (("Left", "left"), ("Center", "center"), ("Right", "right")):
2058
+ action = h_menu.addAction(title)
2059
+ action.setCheckable(True)
2060
+ action.setChecked(h_align == key)
2061
+ actions[action] = lambda item=item, key=key: item.set_label_alignment(horizontal=key)
2062
+ for title, key in (("Top", "top"), ("Middle", "middle"), ("Bottom", "bottom")):
2063
+ action = v_menu.addAction(title)
2064
+ action.setCheckable(True)
2065
+ action.setChecked(v_align == key)
2066
+ actions[action] = lambda item=item, key=key: item.set_label_alignment(vertical=key)
2067
+ return actions
2068
+ def _create_shape_style_actions(
2069
+ self, menu: QtWidgets.QMenu, item: QtWidgets.QGraphicsItem
2070
+ ) -> dict[QtGui.QAction, Callable[[], None]]:
2071
+ actions: dict[QtGui.QAction, Callable[[], None]] = {}
2072
+
2073
+ if isinstance(item, ShapeLabelMixin):
2074
+ label_actions = self._add_shape_label_actions(menu, item)
2075
+ actions.update(label_actions)
2076
+ menu.addSeparator()
2077
+
2078
+ def add_fill_actions() -> None:
2079
+ fill_action, fill_callback = self._create_color_action(
2080
+ menu,
2081
+ "Set fill color…",
2082
+ "Fill color",
2083
+ lambda item=item: item.brush().color(),
2084
+ lambda color, item=item: item.setBrush(color),
2085
+ )
2086
+ actions[fill_action] = fill_callback
2087
+
2088
+ def opacity_getter(item=item) -> float:
2089
+ brush = item.brush()
2090
+ if brush.style() == QtCore.Qt.BrushStyle.NoBrush:
2091
+ return 1.0
2092
+ return brush.color().alphaF()
2093
+
2094
+ def opacity_setter(value: float, item=item) -> None:
2095
+ brush = item.brush()
2096
+ color = brush.color()
2097
+ color.setAlphaF(value)
2098
+ item.setBrush(color)
2099
+
2100
+ opacity_action = menu.addAction("Set fill opacity…")
2101
+
2102
+ def opacity_callback(item=item) -> None:
2103
+ initial_opacity = opacity_getter()
2104
+ dialog = OpacityDialog(initial_opacity, self)
2105
+
2106
+ def handle_value_changed(value: float, item=item) -> None:
2107
+ opacity_setter(value, item)
2108
+
2109
+ dialog.valueChanged.connect(handle_value_changed)
2110
+ result = dialog.exec()
2111
+ if result == QtWidgets.QDialog.DialogCode.Accepted:
2112
+ opacity_setter(dialog.value(), item)
2113
+ else:
2114
+ opacity_setter(initial_opacity, item)
2115
+
2116
+ actions[opacity_action] = opacity_callback
2117
+
2118
+ def add_stroke_actions() -> None:
2119
+ def stroke_color_setter(color: QtGui.QColor, item=item) -> None:
2120
+ pen = item.pen()
2121
+ pen.setColor(color)
2122
+ item.setPen(pen)
2123
+ item.update()
2124
+
2125
+ stroke_action, stroke_callback = self._create_color_action(
2126
+ menu,
2127
+ "Set stroke color…",
2128
+ "Stroke color",
2129
+ lambda item=item: item.pen().color(),
2130
+ stroke_color_setter,
2131
+ )
2132
+ actions[stroke_action] = stroke_callback
2133
+
2134
+ def width_setter(value: float, item=item) -> None:
2135
+ pen = item.pen()
2136
+ pen.setWidthF(value)
2137
+ item.setPen(pen)
2138
+ item.update()
2139
+
2140
+ width_action, width_callback = self._create_double_action(
2141
+ menu,
2142
+ "Set stroke width…",
2143
+ "Stroke width",
2144
+ "Width:",
2145
+ lambda item=item: item.pen().widthF(),
2146
+ width_setter,
2147
+ 0.1,
2148
+ 50.0,
2149
+ 1,
2150
+ )
2151
+ actions[width_action] = width_callback
2152
+
2153
+ def add_corner_action() -> None:
2154
+ corner_action = menu.addAction("Set corner radius…")
2155
+
2156
+ def corner_callback(item=item) -> None:
2157
+ initial_rx = item.rx
2158
+ initial_ry = item.ry
2159
+ dlg = CornerRadiusDialog(item.rx, self)
2160
+
2161
+ def handle_value_changed(value: int, item=item) -> None:
2162
+ radius = min(float(value), 50.0)
2163
+ item.rx = item.ry = radius
2164
+ item.update()
2165
+
2166
+ dlg.valueChanged.connect(handle_value_changed)
2167
+ result = dlg.exec()
2168
+ if result == QtWidgets.QDialog.DialogCode.Accepted:
2169
+ handle_value_changed(dlg.value())
2170
+ else:
2171
+ item.rx = initial_rx
2172
+ item.ry = initial_ry
2173
+ item.update()
2174
+
2175
+ actions[corner_action] = corner_callback
2176
+
2177
+ def add_arrow_actions() -> None:
2178
+ start_action = menu.addAction("Show start arrowhead")
2179
+ start_action.setCheckable(True)
2180
+ start_action.setChecked(getattr(item, "arrow_start", False))
2181
+
2182
+ def start_callback(action=start_action, item=item) -> None:
2183
+ item.set_arrow_start(action.isChecked())
2184
+
2185
+ actions[start_action] = start_callback
2186
+
2187
+ end_action = menu.addAction("Show end arrowhead")
2188
+ end_action.setCheckable(True)
2189
+ end_action.setChecked(getattr(item, "arrow_end", False))
2190
+
2191
+ def end_callback(action=end_action, item=item) -> None:
2192
+ item.set_arrow_end(action.isChecked())
2193
+
2194
+ actions[end_action] = end_callback
2195
+
2196
+ length_getter = getattr(item, "arrow_head_length", None)
2197
+ width_getter = getattr(item, "arrow_head_width", None)
2198
+ length_setter = getattr(item, "set_arrow_head_length", None)
2199
+ width_setter = getattr(item, "set_arrow_head_width", None)
2200
+ if all(callable(fn) for fn in (length_getter, width_getter, length_setter, width_setter)):
2201
+ style_menu = menu.addMenu("Arrowhead style")
2202
+ height_action, height_callback = self._create_double_action(
2203
+ style_menu,
2204
+ "Set arrowhead height...",
2205
+ "Arrowhead height",
2206
+ "Height:",
2207
+ lambda item=item, getter=length_getter: getter(),
2208
+ lambda value, item=item, setter=length_setter: setter(value),
2209
+ 1.0,
2210
+ 500.0,
2211
+ 1,
2212
+ )
2213
+ actions[height_action] = height_callback
2214
+
2215
+ width_action, width_callback = self._create_double_action(
2216
+ style_menu,
2217
+ "Set arrowhead width...",
2218
+ "Arrowhead width",
2219
+ "Width:",
2220
+ lambda item=item, getter=width_getter: getter(),
2221
+ lambda value, item=item, setter=width_setter: setter(value),
2222
+ 1.0,
2223
+ 500.0,
2224
+ 1,
2225
+ )
2226
+ actions[width_action] = width_callback
2227
+
2228
+ def add_line_style_actions() -> None:
2229
+ style_menu = menu.addMenu("Line style")
2230
+ action_group = QtGui.QActionGroup(menu)
2231
+ action_group.setExclusive(True)
2232
+ current_style = item.pen().style()
2233
+
2234
+ def make_action(text: str, style: QtCore.Qt.PenStyle) -> None:
2235
+ act = style_menu.addAction(text)
2236
+ act.setCheckable(True)
2237
+ act.setChecked(current_style == style)
2238
+ action_group.addAction(act)
2239
+
2240
+ def callback(item=item, style=style) -> None:
2241
+ setter = getattr(item, "set_pen_style", None)
2242
+ if callable(setter):
2243
+ setter(style)
2244
+ else:
2245
+ pen = QtGui.QPen(item.pen())
2246
+ pen.setStyle(style)
2247
+ item.setPen(pen)
2248
+ item.update()
2249
+
2250
+ actions[act] = callback
2251
+
2252
+ make_action("Solid", QtCore.Qt.PenStyle.SolidLine)
2253
+ make_action("Dashed", QtCore.Qt.PenStyle.DashLine)
2254
+ make_action("Dotted", QtCore.Qt.PenStyle.DotLine)
2255
+
2256
+ def add_text_actions() -> None:
2257
+ text_color_action, text_color_callback = self._create_color_action(
2258
+ menu,
2259
+ "Set text color...",
2260
+ "Text color",
2261
+ lambda item=item: item.defaultTextColor(),
2262
+ lambda color, item=item: item.setDefaultTextColor(color),
2263
+ )
2264
+ actions[text_color_action] = text_color_callback
2265
+
2266
+ def font_size_setter(value: float, item=item) -> None:
2267
+ font = item.font()
2268
+ font.setPointSizeF(value)
2269
+ item.setFont(font)
2270
+
2271
+ font_size_action, font_size_callback = self._create_double_action(
2272
+ menu,
2273
+ "Set font size...",
2274
+ "Font size",
2275
+ "Size:",
2276
+ lambda item=item: item.font().pointSizeF(),
2277
+ font_size_setter,
2278
+ 1.0,
2279
+ 500.0,
2280
+ 1,
2281
+ )
2282
+ actions[font_size_action] = font_size_callback
2283
+
2284
+ align_menu = menu.addMenu("Alignment")
2285
+ h_menu = align_menu.addMenu("Horizontal")
2286
+ v_menu = align_menu.addMenu("Vertical")
2287
+ h_align, v_align = item.text_alignment()
2288
+ for title, key in (("Left", "left"), ("Center", "center"), ("Right", "right")):
2289
+ action = h_menu.addAction(title)
2290
+ action.setCheckable(True)
2291
+ action.setChecked(h_align == key)
2292
+ actions[action] = lambda item=item, key=key: item.set_text_alignment(horizontal=key)
2293
+ for title, key in (("Top", "top"), ("Middle", "middle"), ("Bottom", "bottom")):
2294
+ action = v_menu.addAction(title)
2295
+ action.setCheckable(True)
2296
+ action.setChecked(v_align == key)
2297
+ actions[action] = lambda item=item, key=key: item.set_text_alignment(vertical=key)
2298
+
2299
+ dir_menu = align_menu.addMenu("Direction")
2300
+ current_dir = item.text_direction()
2301
+ for title, key in (("Left to right", "ltr"), ("Right to left", "rtl")):
2302
+ action = dir_menu.addAction(title)
2303
+ action.setCheckable(True)
2304
+ action.setChecked(current_dir == key)
2305
+ actions[action] = lambda item=item, key=key: item.set_text_direction(key)
2306
+
2307
+ def add_split_rect_fill_actions() -> None:
2308
+ split_item: SplitRoundedRectItem = item # type: ignore[assignment]
2309
+
2310
+ top_action, top_callback = self._create_color_action(
2311
+ menu,
2312
+ "Set top fill color…",
2313
+ "Top fill color",
2314
+ lambda item=split_item: item.topBrush().color(),
2315
+ lambda color, item=split_item: item.setTopBrush(color),
2316
+ )
2317
+ actions[top_action] = top_callback
2318
+
2319
+ def top_opacity_getter(item=split_item) -> float:
2320
+ brush = item.topBrush()
2321
+ if brush.style() == QtCore.Qt.BrushStyle.NoBrush:
2322
+ return 1.0
2323
+ return brush.color().alphaF()
2324
+
2325
+ def top_opacity_setter(value: float, item=split_item) -> None:
2326
+ brush = item.topBrush()
2327
+ color = brush.color()
2328
+ color.setAlphaF(value)
2329
+ item.setTopBrush(color)
2330
+
2331
+ top_opacity_action = menu.addAction("Set top fill opacity…")
2332
+
2333
+ def top_opacity_callback(item=split_item) -> None:
2334
+ initial_opacity = top_opacity_getter()
2335
+ dialog = OpacityDialog(initial_opacity, self)
2336
+
2337
+ def handle_value_changed(value: float, item=item) -> None:
2338
+ top_opacity_setter(value, item)
2339
+
2340
+ dialog.valueChanged.connect(handle_value_changed)
2341
+ result = dialog.exec()
2342
+ if result == QtWidgets.QDialog.DialogCode.Accepted:
2343
+ top_opacity_setter(dialog.value(), item)
2344
+ else:
2345
+ top_opacity_setter(initial_opacity, item)
2346
+
2347
+ actions[top_opacity_action] = top_opacity_callback
2348
+
2349
+ bottom_action, bottom_callback = self._create_color_action(
2350
+ menu,
2351
+ "Set bottom fill color…",
2352
+ "Bottom fill color",
2353
+ lambda item=split_item: item.bottomBrush().color(),
2354
+ lambda color, item=split_item: item.setBottomBrush(color),
2355
+ )
2356
+ actions[bottom_action] = bottom_callback
2357
+
2358
+ def bottom_opacity_getter(item=split_item) -> float:
2359
+ brush = item.bottomBrush()
2360
+ if brush.style() == QtCore.Qt.BrushStyle.NoBrush:
2361
+ return 1.0
2362
+ return brush.color().alphaF()
2363
+
2364
+ def bottom_opacity_setter(value: float, item=split_item) -> None:
2365
+ brush = item.bottomBrush()
2366
+ color = brush.color()
2367
+ color.setAlphaF(value)
2368
+ item.setBottomBrush(color)
2369
+
2370
+ bottom_opacity_action = menu.addAction("Set bottom fill opacity…")
2371
+
2372
+ def bottom_opacity_callback(item=split_item) -> None:
2373
+ initial_opacity = bottom_opacity_getter()
2374
+ dialog = OpacityDialog(initial_opacity, self)
2375
+
2376
+ def handle_value_changed(value: float, item=item) -> None:
2377
+ bottom_opacity_setter(value, item)
2378
+
2379
+ dialog.valueChanged.connect(handle_value_changed)
2380
+ result = dialog.exec()
2381
+ if result == QtWidgets.QDialog.DialogCode.Accepted:
2382
+ bottom_opacity_setter(dialog.value(), item)
2383
+ else:
2384
+ bottom_opacity_setter(initial_opacity, item)
2385
+
2386
+ actions[bottom_opacity_action] = bottom_opacity_callback
2387
+
2388
+ if isinstance(item, RectItem):
2389
+ add_fill_actions()
2390
+ add_corner_action()
2391
+ menu.addSeparator()
2392
+ add_stroke_actions()
2393
+ elif isinstance(item, SplitRoundedRectItem):
2394
+ add_split_rect_fill_actions()
2395
+ add_corner_action()
2396
+ menu.addSeparator()
2397
+ add_stroke_actions()
2398
+ elif isinstance(item, (QtWidgets.QGraphicsEllipseItem, TriangleItem, DiamondItem)):
2399
+ add_fill_actions()
2400
+ menu.addSeparator()
2401
+ add_stroke_actions()
2402
+ elif isinstance(item, LineItem):
2403
+ add_arrow_actions()
2404
+ add_line_style_actions()
2405
+ menu.addSeparator()
2406
+ add_stroke_actions()
2407
+ elif isinstance(item, TextItem):
2408
+ add_text_actions()
2409
+ else:
2410
+ add_stroke_actions()
2411
+
2412
+ return actions
2413
+
2414
+ # --- Context menu for adjusting colors and line width ---
2415
+ def contextMenuEvent(self, event: QtGui.QContextMenuEvent):
2416
+ if self._suppress_context_menu:
2417
+ self._suppress_context_menu = False
2418
+ event.accept()
2419
+ return
2420
+ pos = event.pos()
2421
+ item = self.itemAt(pos)
2422
+ if isinstance(item, QtWidgets.QGraphicsTextItem):
2423
+ parent = item.parentItem()
2424
+ if isinstance(parent, ShapeLabelMixin) and getattr(parent, 'label_item', lambda: None)() is item:
2425
+ item = parent
2426
+ if not item:
2427
+ menu = QtWidgets.QMenu(self)
2428
+ reset_act = menu.addAction("Reset zoom")
2429
+ action = menu.exec(event.globalPos())
2430
+ if action is reset_act:
2431
+ self.resetTransform()
2432
+ else:
2433
+ super().contextMenuEvent(event)
2434
+ return
2435
+
2436
+ menu = QtWidgets.QMenu(self)
2437
+
2438
+ selected = self.scene().selectedItems()
2439
+ group_act = ungroup_act = None
2440
+ if len(selected) >= 2:
2441
+ group_act = menu.addAction("Group")
2442
+ if any(isinstance(it, GroupItem) for it in selected):
2443
+ ungroup_act = menu.addAction("Ungroup")
2444
+ if group_act or ungroup_act:
2445
+ menu.addSeparator()
2446
+
2447
+ align_actions = {}
2448
+ if len(selected) >= 2:
2449
+ align_menu = menu.addMenu("Align")
2450
+ align_actions[align_menu.addAction("Left")] = "left"
2451
+ align_actions[align_menu.addAction("Center")] = "hcenter"
2452
+ align_actions[align_menu.addAction("Right")] = "right"
2453
+ align_menu.addSeparator()
2454
+ align_actions[align_menu.addAction("Top")] = "top"
2455
+ align_actions[align_menu.addAction("Middle")] = "vcenter"
2456
+ align_actions[align_menu.addAction("Bottom")] = "bottom"
2457
+ align_menu.addSeparator()
2458
+ align_actions[align_menu.addAction("Snap to grid")] = "grid"
2459
+ menu.addSeparator()
2460
+
2461
+ style_actions = self._create_shape_style_actions(menu, item)
2462
+ if style_actions:
2463
+ menu.addSeparator()
2464
+
2465
+ back1_act = menu.addAction("Send backward")
2466
+ front1_act = menu.addAction("Bring forward")
2467
+ menu.addSeparator()
2468
+ back_act = menu.addAction("Send to back")
2469
+ front_act = menu.addAction("Bring to front")
2470
+
2471
+ action = menu.exec(event.globalPos())
2472
+ if not action:
2473
+ super().contextMenuEvent(event)
2474
+ return
2475
+
2476
+ if action in align_actions:
2477
+ self._align_items(selected, align_actions[action])
2478
+ elif action is group_act:
2479
+ self._group_selected_items()
2480
+ elif action is ungroup_act:
2481
+ self._ungroup_selected_items()
2482
+ else:
2483
+ style_callback = style_actions.get(action)
2484
+ if style_callback:
2485
+ style_callback()
2486
+ elif action in (back1_act, front1_act, back_act, front_act):
2487
+ scene = self.scene()
2488
+ items = [
2489
+ it
2490
+ for it in scene.items()
2491
+ if it.data(0) in SHAPES or isinstance(it, GroupItem)
2492
+ ]
2493
+ items.sort(key=lambda it: it.zValue())
2494
+ idx = items.index(item)
2495
+ if action == back1_act and idx > 0:
2496
+ items[idx - 1], items[idx] = items[idx], items[idx - 1]
2497
+ elif action == front1_act and idx < len(items) - 1:
2498
+ items[idx + 1], items[idx] = items[idx], items[idx + 1]
2499
+ elif action == back_act:
2500
+ items.insert(0, items.pop(idx))
2501
+ elif action == front_act:
2502
+ items.append(items.pop(idx))
2503
+ for z, it in enumerate(items):
2504
+ it.setZValue(z)
2505
+ else:
2506
+ super().contextMenuEvent(event)