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.
properties_panel.py ADDED
@@ -0,0 +1,1406 @@
1
+ """Interactive properties panel that allows editing item attributes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import weakref
7
+ from pathlib import Path
8
+ from typing import Any, Callable, TYPE_CHECKING
9
+
10
+ from PySide6 import QtCore, QtGui, QtWidgets
11
+ from PySide6.QtUiTools import QUiLoader
12
+
13
+ from items import (
14
+ BlockArrowItem,
15
+ CurvyBracketItem,
16
+ DiamondItem,
17
+ EllipseItem,
18
+ LineItem,
19
+ RectItem,
20
+ ShapeLabelMixin,
21
+ SplitRoundedRectItem,
22
+ TextItem,
23
+ TriangleItem,
24
+ )
25
+
26
+ if TYPE_CHECKING: # pragma: no cover - only for typing
27
+ from canvas_view import CanvasView
28
+
29
+
30
+ Number = float
31
+
32
+ _UI_PATH = Path(__file__).resolve().parent / "ui" / "properties_panel.ui"
33
+
34
+ class ColorButton(QtWidgets.QToolButton):
35
+ """Tool button that displays and edits a QColor."""
36
+
37
+ colorChanged = QtCore.Signal(QtGui.QColor)
38
+
39
+ def __init__(self, color: QtGui.QColor | str | None = None, parent: QtWidgets.QWidget | None = None) -> None:
40
+ super().__init__(parent)
41
+ self._color = QtGui.QColor()
42
+ self.setAutoRaise(True)
43
+ self.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
44
+ self.setToolButtonStyle(QtCore.Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
45
+ self.setMinimumHeight(26)
46
+ self.setSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed)
47
+ self.clicked.connect(self._choose_color)
48
+ self.setColor(color or QtGui.QColor("#000000"))
49
+
50
+ def color(self) -> QtGui.QColor:
51
+ return QtGui.QColor(self._color)
52
+
53
+ def setColor(self, value: QtGui.QColor | str | None) -> None:
54
+ if isinstance(value, QtGui.QColor):
55
+ color = QtGui.QColor(value)
56
+ elif isinstance(value, str):
57
+ color = QtGui.QColor(value)
58
+ else:
59
+ color = QtGui.QColor("#00000000")
60
+ if not color.isValid():
61
+ color = QtGui.QColor("#00000000")
62
+ if self._color == color:
63
+ return
64
+ self._color = color
65
+ self._update_visuals()
66
+
67
+ def _choose_color(self) -> None:
68
+ dialog = QtWidgets.QColorDialog(self._color, self)
69
+ dialog.setOption(QtWidgets.QColorDialog.ColorDialogOption.ShowAlphaChannel, True)
70
+ if dialog.exec():
71
+ new_color = dialog.currentColor()
72
+ if new_color.isValid():
73
+ if self._color != new_color:
74
+ self._color = QtGui.QColor(new_color)
75
+ self._update_visuals()
76
+ self.colorChanged.emit(QtGui.QColor(self._color))
77
+
78
+ def _update_visuals(self) -> None:
79
+ rgba = self._color.name(QtGui.QColor.HexArgb)
80
+ text = self._color.name(QtGui.QColor.HexRgb) if self._color.alphaF() >= 0.99 else rgba
81
+ self.setText(text.upper())
82
+ luminance = 0.2126 * self._color.redF() + 0.7152 * self._color.greenF() + 0.0722 * self._color.blueF()
83
+ foreground = "#000000" if luminance > 0.6 or self._color.alphaF() < 0.5 else "#ffffff"
84
+ self.setStyleSheet(
85
+ "QToolButton {"
86
+ f"background-color: {rgba};"
87
+ "border: 1px solid #666;"
88
+ f"color: {foreground};"
89
+ "padding: 3px 8px;"
90
+ "}"
91
+ )
92
+
93
+
94
+ class PlainTextEditor(QtWidgets.QPlainTextEdit):
95
+ """Plain text editor that emits editingFinished on focus loss or Ctrl+Enter."""
96
+
97
+ editingFinished = QtCore.Signal()
98
+
99
+ def focusOutEvent(self, event: QtGui.QFocusEvent) -> None: # type: ignore[override]
100
+ super().focusOutEvent(event)
101
+ self.editingFinished.emit()
102
+
103
+ def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: # type: ignore[override]
104
+ if event.key() in (QtCore.Qt.Key.Key_Return, QtCore.Qt.Key.Key_Enter) and (
105
+ event.modifiers() & QtCore.Qt.KeyboardModifier.ControlModifier
106
+ ):
107
+ self.editingFinished.emit()
108
+ event.accept()
109
+ return
110
+ super().keyPressEvent(event)
111
+
112
+
113
+ class _PropertiesUiLoader(QUiLoader):
114
+ """QUiLoader that embeds the .ui file into an existing QWidget instance."""
115
+
116
+ def __init__(self, baseinstance: QtWidgets.QWidget) -> None:
117
+ super().__init__(baseinstance)
118
+ self._baseinstance = baseinstance
119
+ self.registerCustomWidget(ColorButton)
120
+ self.registerCustomWidget(PlainTextEditor)
121
+
122
+ def createWidget(
123
+ self,
124
+ class_name: str,
125
+ parent: QtWidgets.QWidget | None = None,
126
+ name: str = "",
127
+ ) -> QtWidgets.QWidget:
128
+ if parent is None and self._baseinstance is not None:
129
+ return self._baseinstance
130
+ widget = super().createWidget(class_name, parent, name)
131
+ if self._baseinstance is not None and name:
132
+ setattr(self._baseinstance, name, widget)
133
+ return widget
134
+
135
+
136
+ class PropertyBinding(QtCore.QObject):
137
+ """Connects a UI widget with getter/setter callables."""
138
+
139
+ def __init__(
140
+ self,
141
+ widget: QtWidgets.QWidget,
142
+ getter: Callable[[], Any],
143
+ setter: Callable[[Any], bool | None],
144
+ change_signal: QtCore.SignalInstance,
145
+ read_from_widget: Callable[[QtWidgets.QWidget], Any],
146
+ write_to_widget: Callable[[QtWidgets.QWidget, Any], None],
147
+ change_callback: Callable[[], None],
148
+ ) -> None:
149
+ super().__init__(widget)
150
+ self.widget = widget
151
+ self._getter = getter
152
+ self._setter = setter
153
+ self._read = read_from_widget
154
+ self._write = write_to_widget
155
+ self._change_callback = change_callback
156
+ self._guard = False
157
+ change_signal.connect(self._on_widget_changed)
158
+
159
+ def refresh(self) -> None:
160
+ if self.widget is None:
161
+ return
162
+ value = self._getter()
163
+ self._guard = True
164
+ try:
165
+ blocker = QtCore.QSignalBlocker(self.widget)
166
+ self._write(self.widget, value)
167
+ finally:
168
+ self._guard = False
169
+
170
+ def _on_widget_changed(self, *args: Any) -> None:
171
+ if self._guard or self.widget is None:
172
+ return
173
+ self._guard = True
174
+ try:
175
+ value = self._read(self.widget)
176
+ changed = self._setter(value)
177
+ finally:
178
+ self._guard = False
179
+ if changed is not False:
180
+ self._change_callback()
181
+
182
+
183
+ class PropertiesPanel(QtWidgets.QWidget):
184
+ """Interactive properties panel that keeps the canvas in sync with edits."""
185
+
186
+ def __init__(
187
+ self,
188
+ canvas: CanvasView | None = None,
189
+ parent: QtWidgets.QWidget | None = None,
190
+ ) -> None:
191
+ super().__init__(parent)
192
+ self.setMinimumWidth(260)
193
+ self._canvas = canvas
194
+ self._current_item: QtWidgets.QGraphicsItem | None = None
195
+ self._object_bindings: list[PropertyBinding] = []
196
+ self._text_bindings: list[PropertyBinding] = []
197
+ self._latest_object_data: dict[str, Any] = {}
198
+ self._latest_text_data: dict[str, Any] = {}
199
+ self._half_width_widgets: list[weakref.ReferenceType[QtWidgets.QWidget]] = []
200
+ self._spacer_visible = False
201
+
202
+ self._load_ui()
203
+ self.setMinimumWidth(260) # loader resets widget properties
204
+ self.setSizePolicy(
205
+ QtWidgets.QSizePolicy.Policy.Preferred,
206
+ QtWidgets.QSizePolicy.Policy.Expanding,
207
+ )
208
+ self._layout = self.layout()
209
+ if self._layout is None:
210
+ self._layout = QtWidgets.QVBoxLayout(self)
211
+ self.setLayout(self._layout)
212
+
213
+ self._empty_spacer = QtWidgets.QSpacerItem(
214
+ 0,
215
+ 0,
216
+ QtWidgets.QSizePolicy.Policy.Minimum,
217
+ QtWidgets.QSizePolicy.Policy.Expanding,
218
+ )
219
+
220
+ self._title_label = self._require_widget(QtWidgets.QLabel, "titleLabel")
221
+ title_font = self._title_label.font()
222
+ title_font.setBold(True)
223
+ self._title_label.setFont(title_font)
224
+
225
+ self._info_label = self._require_widget(QtWidgets.QLabel, "infoLabel")
226
+
227
+ self._tab_widget = self._require_widget(QtWidgets.QTabWidget, "tabWidget")
228
+ self._tab_widget.setDocumentMode(True)
229
+ self._tab_widget.setMovable(False)
230
+ self._tab_widget.setSizePolicy(
231
+ QtWidgets.QSizePolicy.Policy.Preferred,
232
+ QtWidgets.QSizePolicy.Policy.Expanding,
233
+ )
234
+ if self._layout is not None:
235
+ index = self._layout.indexOf(self._tab_widget)
236
+ if index >= 0:
237
+ self._layout.setStretch(index, 1)
238
+
239
+ self._cache_object_widgets()
240
+ self._cache_text_widgets()
241
+ self._initialize_combobox_options()
242
+ self._initialize_half_width_tracking()
243
+ self._reset_object_sections()
244
+ self._reset_text_sections()
245
+ self._set_tab_widget_active(False)
246
+ self._set_empty_spacer_visible(True)
247
+
248
+ def resizeEvent(self, event: QtGui.QResizeEvent) -> None: # type: ignore[override]
249
+ super().resizeEvent(event)
250
+ self._update_half_width_widgets()
251
+
252
+ def _load_ui(self) -> None:
253
+ loader = _PropertiesUiLoader(self)
254
+ ui_file = QtCore.QFile(str(_UI_PATH))
255
+ if not ui_file.open(QtCore.QIODevice.OpenModeFlag.ReadOnly):
256
+ raise RuntimeError(f"Could not open UI file: {_UI_PATH}")
257
+ try:
258
+ if loader.load(ui_file) is None:
259
+ raise RuntimeError(f"Failed to load UI from {_UI_PATH}")
260
+ finally:
261
+ ui_file.close()
262
+
263
+ def _require_widget(
264
+ self,
265
+ widget_type: type[QtWidgets.QWidget],
266
+ object_name: str,
267
+ ) -> QtWidgets.QWidget:
268
+ widget = self.findChild(widget_type, object_name)
269
+ if widget is None:
270
+ raise RuntimeError(f"Missing widget '{object_name}' in properties panel UI")
271
+ return widget
272
+
273
+ def _cache_object_widgets(self) -> None:
274
+ self._group_transform = self._require_widget(QtWidgets.QGroupBox, "groupTransform")
275
+ self._group_size = self._require_widget(QtWidgets.QGroupBox, "groupSize")
276
+ self._group_rounded = self._require_widget(QtWidgets.QGroupBox, "groupRoundedCorners")
277
+ self._group_sections = self._require_widget(QtWidgets.QGroupBox, "groupSections")
278
+ self._group_stroke = self._require_widget(QtWidgets.QGroupBox, "groupStroke")
279
+ self._group_fill = self._require_widget(QtWidgets.QGroupBox, "groupFill")
280
+ self._group_arrow_shape = self._require_widget(QtWidgets.QGroupBox, "groupArrowShape")
281
+ self._group_bracket = self._require_widget(QtWidgets.QGroupBox, "groupBracket")
282
+ self._group_line_arrows = self._require_widget(QtWidgets.QGroupBox, "groupLineArrows")
283
+
284
+ self._spin_pos_x = self._require_widget(QtWidgets.QDoubleSpinBox, "spinPosX")
285
+ self._spin_pos_y = self._require_widget(QtWidgets.QDoubleSpinBox, "spinPosY")
286
+ self._spin_rotation = self._require_widget(QtWidgets.QDoubleSpinBox, "spinRotation")
287
+ self._spin_scale = self._require_widget(QtWidgets.QDoubleSpinBox, "spinScale")
288
+ self._spin_z_value = self._require_widget(QtWidgets.QDoubleSpinBox, "spinZValue")
289
+ self._spin_width = self._require_widget(QtWidgets.QDoubleSpinBox, "spinWidth")
290
+ self._spin_height = self._require_widget(QtWidgets.QDoubleSpinBox, "spinHeight")
291
+ self._spin_radius_x = self._require_widget(QtWidgets.QDoubleSpinBox, "spinRadiusX")
292
+ self._spin_radius_y = self._require_widget(QtWidgets.QDoubleSpinBox, "spinRadiusY")
293
+ self._color_top_fill = self._require_widget(ColorButton, "colorTopFill")
294
+ self._color_bottom_fill = self._require_widget(ColorButton, "colorBottomFill")
295
+ self._spin_divider_ratio = self._require_widget(QtWidgets.QDoubleSpinBox, "spinDividerRatio")
296
+ self._color_stroke = self._require_widget(ColorButton, "colorStroke")
297
+ self._spin_stroke_width = self._require_widget(QtWidgets.QDoubleSpinBox, "spinStrokeWidth")
298
+ self._color_fill = self._require_widget(ColorButton, "colorFill")
299
+ self._spin_head_ratio = self._require_widget(QtWidgets.QDoubleSpinBox, "spinHeadRatio")
300
+ self._spin_shaft_ratio = self._require_widget(QtWidgets.QDoubleSpinBox, "spinShaftRatio")
301
+ self._spin_hook_depth = self._require_widget(QtWidgets.QDoubleSpinBox, "spinHookDepth")
302
+ self._check_arrow_start = self._require_widget(QtWidgets.QCheckBox, "checkArrowStart")
303
+ self._check_arrow_end = self._require_widget(QtWidgets.QCheckBox, "checkArrowEnd")
304
+ self._spin_arrow_length = self._require_widget(QtWidgets.QDoubleSpinBox, "spinArrowLength")
305
+ self._spin_arrow_width = self._require_widget(QtWidgets.QDoubleSpinBox, "spinArrowWidth")
306
+
307
+ self._object_groups = [
308
+ self._group_transform,
309
+ self._group_size,
310
+ self._group_rounded,
311
+ self._group_sections,
312
+ self._group_stroke,
313
+ self._group_fill,
314
+ self._group_arrow_shape,
315
+ self._group_bracket,
316
+ self._group_line_arrows,
317
+ ]
318
+
319
+ def _cache_text_widgets(self) -> None:
320
+ self._group_label = self._require_widget(QtWidgets.QGroupBox, "groupLabel")
321
+ self._group_text_content = self._require_widget(QtWidgets.QGroupBox, "groupTextContent")
322
+ self._group_text_format = self._require_widget(QtWidgets.QGroupBox, "groupTextFormat")
323
+
324
+ self._line_label_text = self._require_widget(QtWidgets.QLineEdit, "lineLabelText")
325
+ self._combo_label_font = self._require_widget(QtWidgets.QFontComboBox, "comboLabelFont")
326
+ self._spin_label_font_size = self._require_widget(QtWidgets.QDoubleSpinBox, "spinLabelFontSize")
327
+ self._color_label_font = self._require_widget(ColorButton, "colorLabelFont")
328
+ self._button_label_color_default = self._require_widget(QtWidgets.QToolButton, "buttonLabelColorDefault")
329
+ self._combo_label_horizontal = self._require_widget(QtWidgets.QComboBox, "comboLabelHorizontal")
330
+ self._combo_label_vertical = self._require_widget(QtWidgets.QComboBox, "comboLabelVertical")
331
+
332
+ self._plain_text_content = self._require_widget(PlainTextEditor, "plainTextContent")
333
+ self._combo_text_font = self._require_widget(QtWidgets.QFontComboBox, "comboTextFont")
334
+ self._spin_text_font_size = self._require_widget(QtWidgets.QDoubleSpinBox, "spinTextFontSize")
335
+ self._color_text_font = self._require_widget(ColorButton, "colorTextFont")
336
+ self._spin_text_padding = self._require_widget(QtWidgets.QDoubleSpinBox, "spinTextPadding")
337
+ self._combo_text_horizontal = self._require_widget(QtWidgets.QComboBox, "comboTextHorizontal")
338
+ self._combo_text_vertical = self._require_widget(QtWidgets.QComboBox, "comboTextVertical")
339
+ self._combo_text_direction = self._require_widget(QtWidgets.QComboBox, "comboTextDirection")
340
+
341
+ self._text_groups = [self._group_label, self._group_text_content, self._group_text_format]
342
+
343
+ def _initialize_combobox_options(self) -> None:
344
+ alignment_options = [("Left", "left"), ("Centered", "center"), ("Right", "right")]
345
+ vertical_options = [("Top", "top"), ("Center", "middle"), ("Bottom", "bottom")]
346
+ self._configure_combobox(self._combo_label_horizontal, alignment_options)
347
+ self._configure_combobox(self._combo_label_vertical, vertical_options)
348
+ self._configure_combobox(self._combo_text_horizontal, alignment_options)
349
+ self._configure_combobox(self._combo_text_vertical, vertical_options)
350
+ self._configure_combobox(
351
+ self._combo_text_direction,
352
+ [("Left to Right", "ltr"), ("Right to Left", "rtl")],
353
+ )
354
+
355
+ def _configure_combobox(
356
+ self,
357
+ combo: QtWidgets.QComboBox,
358
+ options: list[tuple[str, Any]],
359
+ ) -> None:
360
+ combo.clear()
361
+ combo.setInsertPolicy(QtWidgets.QComboBox.InsertPolicy.NoInsert)
362
+ combo.setSizeAdjustPolicy(QtWidgets.QComboBox.SizeAdjustPolicy.AdjustToContents)
363
+ combo.setMinimumContentsLength(1)
364
+ combo.setMaxVisibleItems(16)
365
+ combo.setEditable(False)
366
+ for text, value in options:
367
+ combo.addItem(text, value)
368
+
369
+ def _initialize_half_width_tracking(self) -> None:
370
+ widgets = [
371
+ self._line_label_text,
372
+ self._combo_label_font,
373
+ self._combo_label_horizontal,
374
+ self._combo_label_vertical,
375
+ self._combo_text_font,
376
+ self._combo_text_horizontal,
377
+ self._combo_text_vertical,
378
+ self._combo_text_direction,
379
+ ]
380
+ for widget in widgets:
381
+ self._track_half_width_widget(widget)
382
+
383
+ def _reset_object_sections(self) -> None:
384
+ for group in self._object_groups:
385
+ group.hide()
386
+
387
+ def _reset_text_sections(self) -> None:
388
+ for group in self._text_groups:
389
+ group.hide()
390
+
391
+ # ------------------------------------------------------------------ #
392
+ # Public API #
393
+ # ------------------------------------------------------------------ #
394
+
395
+ def _half_width_limit(self) -> int:
396
+ base = self.width()
397
+ if base <= 0:
398
+ base = self.minimumWidth()
399
+ if base <= 0:
400
+ base = 200
401
+ return max(40,80)
402
+
403
+ def _track_half_width_widget(self, widget: QtWidgets.QWidget) -> None:
404
+ if widget is None:
405
+ return
406
+ for ref in self._half_width_widgets:
407
+ if ref() is widget:
408
+ break
409
+ else:
410
+ self._half_width_widgets.append(weakref.ref(widget))
411
+ self._set_widget_half_width(widget)
412
+
413
+ def _set_widget_half_width(self, widget: QtWidgets.QWidget | None) -> None:
414
+ if widget is None:
415
+ return
416
+ widget.setMaximumWidth(self._half_width_limit())
417
+
418
+ def _update_half_width_widgets(self) -> None:
419
+ limit = self._half_width_limit()
420
+ alive_refs: list[weakref.ReferenceType[QtWidgets.QWidget]] = []
421
+ for ref in self._half_width_widgets:
422
+ widget = ref()
423
+ if widget is None:
424
+ continue
425
+ widget.setMaximumWidth(limit)
426
+ alive_refs.append(ref)
427
+ self._half_width_widgets = alive_refs
428
+
429
+ def clear(self) -> None:
430
+ self._title_label.setText("Properties")
431
+ self._info_label.setText("No object selected.")
432
+ self._info_label.show()
433
+ self._current_item = None
434
+ self._latest_object_data = {}
435
+ self._latest_text_data = {}
436
+ self._clear_bindings()
437
+ self._reset_object_sections()
438
+ self._reset_text_sections()
439
+ self._set_tab_widget_active(False)
440
+ self._set_empty_spacer_visible(True)
441
+
442
+ def show_multi_selection(self, count: int) -> None:
443
+ self._title_label.setText("Properties")
444
+ self._info_label.setText(f"{count} objects selected.")
445
+ self._info_label.show()
446
+ self._current_item = None
447
+ self._latest_object_data = {}
448
+ self._latest_text_data = {}
449
+ self._clear_bindings()
450
+ self._reset_object_sections()
451
+ self._reset_text_sections()
452
+ self._set_tab_widget_active(False)
453
+ self._set_empty_spacer_visible(True)
454
+
455
+ def update_snapshot(self, payload: object) -> None:
456
+ if not isinstance(payload, dict):
457
+ self.clear()
458
+ return
459
+
460
+ selection_type = payload.get("selection_type")
461
+ if selection_type == "single":
462
+ item = payload.get("item")
463
+ if not isinstance(item, QtWidgets.QGraphicsItem):
464
+ self.clear()
465
+ return
466
+ title = str(payload.get("title", "Object"))
467
+ object_data = payload.get("object_data")
468
+ if not isinstance(object_data, dict):
469
+ object_data = {}
470
+ text_data = payload.get("text_data")
471
+ if not isinstance(text_data, dict):
472
+ text_data = {}
473
+ self._show_single(item, title, object_data, text_data)
474
+ elif selection_type == "multi":
475
+ count = int(payload.get("count", 0))
476
+ self.show_multi_selection(count)
477
+ else:
478
+ self.clear()
479
+
480
+ # ------------------------------------------------------------------ #
481
+ # Internal helpers #
482
+ # ------------------------------------------------------------------ #
483
+
484
+ def _show_single(
485
+ self,
486
+ item: QtWidgets.QGraphicsItem,
487
+ title: str,
488
+ object_data: dict[str, Any],
489
+ text_data: dict[str, Any],
490
+ ) -> None:
491
+ self._title_label.setText(f"Properties – {title}")
492
+ self._info_label.hide()
493
+
494
+ if item is not self._current_item:
495
+ self._current_item = item
496
+ self._rebuild_for_item(item, object_data, text_data)
497
+ else:
498
+ self._latest_object_data = dict(object_data)
499
+ self._latest_text_data = dict(text_data)
500
+ self._refresh_active_bindings()
501
+
502
+ if hasattr(self._tab_widget, "setCurrentIndex") and self._tab_widget.currentIndex() == -1:
503
+ self._tab_widget.setCurrentIndex(0)
504
+ self._set_tab_widget_active(True)
505
+ self._set_empty_spacer_visible(False)
506
+
507
+ def _rebuild_for_item(
508
+ self,
509
+ item: QtWidgets.QGraphicsItem,
510
+ object_data: dict[str, Any],
511
+ text_data: dict[str, Any],
512
+ ) -> None:
513
+ self._latest_object_data = dict(object_data)
514
+ self._latest_text_data = dict(text_data)
515
+ self._clear_bindings()
516
+ self._reset_object_sections()
517
+ self._reset_text_sections()
518
+ self._build_object_section(item, object_data)
519
+ self._build_text_section(item)
520
+ self._update_text_tab_visibility()
521
+ self._refresh_active_bindings()
522
+
523
+ def _clear_bindings(self) -> None:
524
+ for binding in self._object_bindings:
525
+ binding.deleteLater()
526
+ for binding in self._text_bindings:
527
+ binding.deleteLater()
528
+ self._object_bindings.clear()
529
+ self._text_bindings.clear()
530
+
531
+ def _bindings_for(self, group: str) -> list[PropertyBinding]:
532
+ return self._object_bindings if group == "object" else self._text_bindings
533
+
534
+ def _refresh_active_bindings(self) -> None:
535
+ for binding in self._object_bindings:
536
+ binding.refresh()
537
+ for binding in self._text_bindings:
538
+ binding.refresh()
539
+
540
+ def _set_tab_widget_active(self, active: bool) -> None:
541
+ if self._layout is None:
542
+ return
543
+ tab_index = self._layout.indexOf(self._tab_widget)
544
+ self._tab_widget.show()
545
+ self._tab_widget.setEnabled(active)
546
+ if not active:
547
+ self._tab_widget.setCurrentIndex(0)
548
+ if tab_index >= 0:
549
+ self._layout.setStretch(tab_index, 1)
550
+ self._layout.invalidate()
551
+
552
+ def _set_empty_spacer_visible(self, visible: bool) -> None:
553
+ if self._layout is None or self._empty_spacer is None:
554
+ return
555
+ if visible and not self._spacer_visible:
556
+ self._layout.addItem(self._empty_spacer)
557
+ self._spacer_visible = True
558
+ elif not visible and self._spacer_visible:
559
+ self._layout.removeItem(self._empty_spacer)
560
+ self._spacer_visible = False
561
+
562
+ def _after_property_change(self) -> None:
563
+ if self._canvas is not None:
564
+ self._canvas.history().mark_dirty()
565
+ self._refresh_active_bindings()
566
+
567
+ @staticmethod
568
+ def _set_double_spin_value(spin: QtWidgets.QDoubleSpinBox, value: Any) -> None:
569
+ try:
570
+ target = float(value)
571
+ except (TypeError, ValueError):
572
+ return
573
+ if not math.isfinite(target):
574
+ return
575
+ precision = 10 ** (-spin.decimals())
576
+ if abs(spin.value() - target) > precision / 2.0:
577
+ spin.setValue(target)
578
+
579
+ def _set_check_state(self, box: QtWidgets.QCheckBox, value: Any) -> None:
580
+ target = bool(value)
581
+ if box.isChecked() != target:
582
+ box.setChecked(target)
583
+
584
+ @staticmethod
585
+ def _set_combo_value(combo: QtWidgets.QComboBox, value: Any) -> None:
586
+ index = combo.findData(value)
587
+ if index < 0:
588
+ return
589
+ if combo.currentIndex() != index:
590
+ combo.setCurrentIndex(index)
591
+ @staticmethod
592
+ def _set_line_edit_text(edit: QtWidgets.QLineEdit, value: Any) -> None:
593
+ text = "" if value is None else str(value)
594
+ if edit.text() != text:
595
+ edit.setText(text)
596
+
597
+ @staticmethod
598
+ def _set_plain_text(editor: PlainTextEditor, value: Any) -> None:
599
+ text = "" if value is None else str(value)
600
+ if editor.toPlainText() != text:
601
+ cursor = editor.textCursor()
602
+ position = cursor.position()
603
+ editor.blockSignals(True)
604
+ editor.setPlainText(text)
605
+ cursor = editor.textCursor()
606
+ cursor.setPosition(min(position, len(text)))
607
+ editor.setTextCursor(cursor)
608
+ editor.blockSignals(False)
609
+
610
+ def _bind_double_spin(
611
+ self,
612
+ spin: QtWidgets.QDoubleSpinBox,
613
+ getter: Callable[[], Number],
614
+ setter: Callable[[Number], bool | None],
615
+ *,
616
+ group: str = "object",
617
+ ) -> None:
618
+ binding = PropertyBinding(
619
+ spin,
620
+ getter,
621
+ setter,
622
+ spin.valueChanged,
623
+ lambda w: float(w.value()),
624
+ self._set_double_spin_value,
625
+ self._after_property_change,
626
+ )
627
+ binding.refresh()
628
+ self._bindings_for(group).append(binding)
629
+
630
+ def _bind_checkbox(
631
+ self,
632
+ box: QtWidgets.QCheckBox,
633
+ getter: Callable[[], bool],
634
+ setter: Callable[[bool], bool | None],
635
+ *,
636
+ group: str = "object",
637
+ ) -> None:
638
+ binding = PropertyBinding(
639
+ box,
640
+ getter,
641
+ setter,
642
+ box.toggled,
643
+ lambda w: bool(w.isChecked()),
644
+ self._set_check_state,
645
+ self._after_property_change,
646
+ )
647
+ binding.refresh()
648
+ self._bindings_for(group).append(binding)
649
+
650
+ def _bind_combobox(
651
+ self,
652
+ combo: QtWidgets.QComboBox,
653
+ getter: Callable[[], Any],
654
+ setter: Callable[[Any], bool | None],
655
+ *,
656
+ group: str = "object",
657
+ ) -> None:
658
+ binding = PropertyBinding(
659
+ combo,
660
+ getter,
661
+ setter,
662
+ combo.currentIndexChanged,
663
+ lambda w: w.currentData(),
664
+ self._set_combo_value,
665
+ self._after_property_change,
666
+ )
667
+ binding.refresh()
668
+ self._bindings_for(group).append(binding)
669
+
670
+ def _bind_line_edit(
671
+ self,
672
+ edit: QtWidgets.QLineEdit,
673
+ getter: Callable[[], str],
674
+ setter: Callable[[str], bool | None],
675
+ *,
676
+ group: str = "object",
677
+ ) -> None:
678
+ binding = PropertyBinding(
679
+ edit,
680
+ getter,
681
+ setter,
682
+ edit.editingFinished,
683
+ lambda w: w.text(),
684
+ self._set_line_edit_text,
685
+ self._after_property_change,
686
+ )
687
+ binding.refresh()
688
+ self._bindings_for(group).append(binding)
689
+
690
+ def _bind_plain_text(
691
+ self,
692
+ editor: PlainTextEditor,
693
+ getter: Callable[[], str],
694
+ setter: Callable[[str], bool | None],
695
+ *,
696
+ group: str = "object",
697
+ ) -> None:
698
+ binding = PropertyBinding(
699
+ editor,
700
+ getter,
701
+ setter,
702
+ editor.editingFinished,
703
+ lambda w: w.toPlainText(),
704
+ self._set_plain_text,
705
+ self._after_property_change,
706
+ )
707
+ binding.refresh()
708
+ self._bindings_for(group).append(binding)
709
+
710
+ def _bind_font_combobox(
711
+ self,
712
+ combo: QtWidgets.QFontComboBox,
713
+ getter: Callable[[], QtGui.QFont],
714
+ setter: Callable[[QtGui.QFont], bool | None],
715
+ *,
716
+ group: str = "object",
717
+ ) -> None:
718
+ binding = PropertyBinding(
719
+ combo,
720
+ getter,
721
+ setter,
722
+ combo.currentFontChanged,
723
+ lambda w: w.currentFont(),
724
+ lambda w, value: w.setCurrentFont(value),
725
+ self._after_property_change,
726
+ )
727
+ binding.refresh()
728
+ self._bindings_for(group).append(binding)
729
+
730
+ def _bind_color_field(
731
+ self,
732
+ color_button: ColorButton,
733
+ getter: Callable[[], QtGui.QColor],
734
+ setter: Callable[[QtGui.QColor], bool | None],
735
+ *,
736
+ group: str = "object",
737
+ reset_button: QtWidgets.QAbstractButton | None = None,
738
+ resetter: Callable[[], bool | None] | None = None,
739
+ ) -> None:
740
+ binding = PropertyBinding(
741
+ color_button,
742
+ getter,
743
+ setter,
744
+ color_button.colorChanged,
745
+ lambda w: w.color(),
746
+ lambda w, value: w.setColor(value),
747
+ self._after_property_change,
748
+ )
749
+ binding.refresh()
750
+ self._bindings_for(group).append(binding)
751
+
752
+ if reset_button is not None and resetter is not None:
753
+ def on_reset() -> None:
754
+ changed = resetter()
755
+ if changed is False:
756
+ binding.refresh()
757
+ else:
758
+ self._after_property_change()
759
+
760
+ previous = getattr(reset_button, "_properties_panel_reset_callback", None)
761
+ if previous is not None:
762
+ try:
763
+ reset_button.clicked.disconnect(previous)
764
+ except (TypeError, RuntimeError):
765
+ pass
766
+ reset_button._properties_panel_reset_callback = on_reset # type: ignore[attr-defined]
767
+ reset_button.clicked.connect(on_reset)
768
+
769
+ # ------------------------------------------------------------------ #
770
+ # Object section #
771
+ # ------------------------------------------------------------------ #
772
+
773
+ def _build_object_section(self, item: QtWidgets.QGraphicsItem, object_data: dict[str, Any]) -> None:
774
+ self._group_transform.show()
775
+ self._bind_double_spin(
776
+ self._spin_pos_x,
777
+ lambda: float(item.pos().x()),
778
+ lambda value: self._set_item_pos(item, x=value),
779
+ )
780
+ self._bind_double_spin(
781
+ self._spin_pos_y,
782
+ lambda: float(item.pos().y()),
783
+ lambda value: self._set_item_pos(item, y=value),
784
+ )
785
+ self._bind_double_spin(
786
+ self._spin_rotation,
787
+ lambda: float(item.rotation()),
788
+ lambda value: self._set_item_rotation(item, value),
789
+ )
790
+ self._bind_double_spin(
791
+ self._spin_scale,
792
+ lambda: float(item.scale()),
793
+ lambda value: self._set_item_scale(item, value),
794
+ )
795
+ self._bind_double_spin(
796
+ self._spin_z_value,
797
+ lambda: float(item.zValue()),
798
+ lambda value: self._set_item_z(item, value),
799
+ )
800
+
801
+ size_value = object_data.get("size")
802
+ if isinstance(size_value, (list, tuple)) and len(size_value) == 2:
803
+ self._group_size.show()
804
+ self._bind_double_spin(
805
+ self._spin_width,
806
+ lambda item=item: float(self._item_dimensions(item)[0]),
807
+ lambda value: self._set_item_size(item, width=value),
808
+ )
809
+ self._bind_double_spin(
810
+ self._spin_height,
811
+ lambda item=item: float(self._item_dimensions(item)[1]),
812
+ lambda value: self._set_item_size(item, height=value),
813
+ )
814
+
815
+ if isinstance(item, (RectItem, SplitRoundedRectItem)) or hasattr(item, "rx"):
816
+ self._group_rounded.show()
817
+ self._bind_double_spin(
818
+ self._spin_radius_x,
819
+ lambda: float(getattr(item, "rx", 0.0)),
820
+ lambda value: self._set_corner_radius(item, "rx", value),
821
+ )
822
+ self._bind_double_spin(
823
+ self._spin_radius_y,
824
+ lambda: float(getattr(item, "ry", getattr(item, "rx", 0.0))),
825
+ lambda value: self._set_corner_radius(item, "ry", value),
826
+ )
827
+
828
+ if isinstance(item, SplitRoundedRectItem):
829
+ self._group_sections.show()
830
+ self._bind_color_field(
831
+ self._color_top_fill,
832
+ lambda: item.topBrush().color(),
833
+ lambda color: self._set_split_brush_color(item, "top", color),
834
+ )
835
+ self._bind_color_field(
836
+ self._color_bottom_fill,
837
+ lambda: item.bottomBrush().color(),
838
+ lambda color: self._set_split_brush_color(item, "bottom", color),
839
+ )
840
+ self._bind_double_spin(
841
+ self._spin_divider_ratio,
842
+ lambda: float(item.divider_ratio()),
843
+ lambda value: self._set_split_ratio(item, value),
844
+ )
845
+
846
+ if hasattr(item, "pen") and not isinstance(item, TextItem):
847
+ self._group_stroke.show()
848
+ self._bind_color_field(
849
+ self._color_stroke,
850
+ lambda: item.pen().color(),
851
+ lambda color: self._set_pen_color(item, color),
852
+ )
853
+ self._bind_double_spin(
854
+ self._spin_stroke_width,
855
+ lambda: float(item.pen().widthF()),
856
+ lambda value: self._set_pen_width(item, value),
857
+ )
858
+
859
+ if hasattr(item, "brush") and not isinstance(item, LineItem):
860
+ self._group_fill.show()
861
+ self._bind_color_field(
862
+ self._color_fill,
863
+ lambda: item.brush().color(),
864
+ lambda color: self._set_brush_color(item, color),
865
+ )
866
+
867
+ if isinstance(item, BlockArrowItem):
868
+ self._group_arrow_shape.show()
869
+ self._bind_double_spin(
870
+ self._spin_head_ratio,
871
+ item.head_ratio,
872
+ lambda value: self._set_block_arrow_ratio(item, "head", value),
873
+ )
874
+ self._bind_double_spin(
875
+ self._spin_shaft_ratio,
876
+ item.shaft_ratio,
877
+ lambda value: self._set_block_arrow_ratio(item, "shaft", value),
878
+ )
879
+
880
+ if isinstance(item, CurvyBracketItem):
881
+ self._group_bracket.show()
882
+ self._bind_double_spin(
883
+ self._spin_hook_depth,
884
+ item.hook_ratio,
885
+ lambda value: self._set_bracket_hook_ratio(item, value),
886
+ )
887
+
888
+ if isinstance(item, LineItem):
889
+ self._group_line_arrows.show()
890
+ self._bind_checkbox(
891
+ self._check_arrow_start,
892
+ lambda: bool(item.arrow_start),
893
+ lambda value: self._toggle_line_arrow(item, "start", value),
894
+ )
895
+ self._bind_checkbox(
896
+ self._check_arrow_end,
897
+ lambda: bool(item.arrow_end),
898
+ lambda value: self._toggle_line_arrow(item, "end", value),
899
+ )
900
+ self._bind_double_spin(
901
+ self._spin_arrow_length,
902
+ item.arrow_head_length,
903
+ lambda value: self._set_line_arrow_metric(item, "length", value),
904
+ )
905
+ self._bind_double_spin(
906
+ self._spin_arrow_width,
907
+ item.arrow_head_width,
908
+ lambda value: self._set_line_arrow_metric(item, "width", value),
909
+ )
910
+
911
+ # ------------------------------------------------------------------ #
912
+ # Text section #
913
+ # ------------------------------------------------------------------ #
914
+
915
+ def _build_text_section(self, item: QtWidgets.QGraphicsItem) -> None:
916
+ if isinstance(item, TextItem):
917
+ self._build_text_item_section(item)
918
+ elif isinstance(item, ShapeLabelMixin):
919
+ self._build_label_section(item)
920
+
921
+ def _build_label_section(self, item: ShapeLabelMixin) -> None:
922
+ self._group_label.show()
923
+ self._bind_line_edit(
924
+ self._line_label_text,
925
+ item.label_text,
926
+ lambda value: self._set_label_text(item, value),
927
+ group="text",
928
+ )
929
+ self._combo_label_font.setCurrentFont(item.label_item().font())
930
+ self._bind_font_combobox(
931
+ self._combo_label_font,
932
+ lambda: item.label_item().font(),
933
+ lambda font: self._set_label_font_family(item, font),
934
+ group="text",
935
+ )
936
+ self._bind_double_spin(
937
+ self._spin_label_font_size,
938
+ lambda: float(self._label_font_size(item)),
939
+ lambda value: self._set_label_font_size(item, value),
940
+ group="text",
941
+ )
942
+ self._bind_color_field(
943
+ self._color_label_font,
944
+ item.label_color,
945
+ lambda color: self._set_label_color(item, color),
946
+ group="text",
947
+ reset_button=self._button_label_color_default,
948
+ resetter=lambda: self._reset_label_color(item),
949
+ )
950
+ self._bind_combobox(
951
+ self._combo_label_horizontal,
952
+ lambda: item.label_alignment()[0],
953
+ lambda value: self._set_label_alignment(item, horizontal=value),
954
+ group="text",
955
+ )
956
+ self._bind_combobox(
957
+ self._combo_label_vertical,
958
+ lambda: item.label_alignment()[1],
959
+ lambda value: self._set_label_alignment(item, vertical=value),
960
+ group="text",
961
+ )
962
+
963
+ def _build_text_item_section(self, item: TextItem) -> None:
964
+ self._group_text_content.show()
965
+ self._bind_plain_text(
966
+ self._plain_text_content,
967
+ item.toPlainText,
968
+ lambda value: self._set_text_item_content(item, value),
969
+ group="text",
970
+ )
971
+
972
+ self._group_text_format.show()
973
+ self._combo_text_font.setCurrentFont(item.font())
974
+ self._bind_font_combobox(
975
+ self._combo_text_font,
976
+ lambda: item.font(),
977
+ lambda font: self._set_text_item_font_family(item, font),
978
+ group="text",
979
+ )
980
+ self._bind_double_spin(
981
+ self._spin_text_font_size,
982
+ lambda: float(self._text_point_size(item)),
983
+ lambda value: self._set_text_point_size(item, value),
984
+ group="text",
985
+ )
986
+ self._bind_color_field(
987
+ self._color_text_font,
988
+ item.defaultTextColor,
989
+ lambda color: self._set_text_color(item, color),
990
+ group="text",
991
+ )
992
+ self._bind_double_spin(
993
+ self._spin_text_padding,
994
+ lambda: float(item.document().documentMargin()) if item.document() else 0.0,
995
+ lambda value: self._set_text_margin(item, value),
996
+ group="text",
997
+ )
998
+ self._bind_combobox(
999
+ self._combo_text_horizontal,
1000
+ lambda: item.text_alignment()[0],
1001
+ lambda value: self._set_text_alignment(item, horizontal=value),
1002
+ group="text",
1003
+ )
1004
+ self._bind_combobox(
1005
+ self._combo_text_vertical,
1006
+ lambda: item.text_alignment()[1],
1007
+ lambda value: self._set_text_alignment(item, vertical=value),
1008
+ group="text",
1009
+ )
1010
+ self._bind_combobox(
1011
+ self._combo_text_direction,
1012
+ item.text_direction,
1013
+ lambda value: self._set_text_direction(item, value),
1014
+ group="text",
1015
+ )
1016
+
1017
+ def _update_text_tab_visibility(self) -> None:
1018
+ has_text = bool(self._text_bindings)
1019
+ if hasattr(self._tab_widget, "setTabVisible"):
1020
+ self._tab_widget.setTabVisible(1, has_text)
1021
+ else:
1022
+ self._tab_widget.setTabEnabled(1, has_text)
1023
+ if not has_text and self._tab_widget.currentIndex() == 1:
1024
+ self._tab_widget.setCurrentIndex(0)
1025
+
1026
+ # ------------------------------------------------------------------ #
1027
+ # Setter helpers #
1028
+ # ------------------------------------------------------------------ #
1029
+
1030
+ @staticmethod
1031
+ def _set_item_pos(item: QtWidgets.QGraphicsItem, *, x: Number | None = None, y: Number | None = None) -> bool:
1032
+ pos = item.pos()
1033
+ target_x = float(x) if x is not None else float(pos.x())
1034
+ target_y = float(y) if y is not None else float(pos.y())
1035
+ if math.isclose(pos.x(), target_x, abs_tol=0.1) and math.isclose(pos.y(), target_y, abs_tol=0.1):
1036
+ return False
1037
+ item.setPos(target_x, target_y)
1038
+ return True
1039
+
1040
+ @staticmethod
1041
+ def _set_item_rotation(item: QtWidgets.QGraphicsItem, angle: Number) -> bool:
1042
+ target = float(angle)
1043
+ if math.isclose(item.rotation(), target, abs_tol=0.1):
1044
+ return False
1045
+ item.setRotation(target)
1046
+ return True
1047
+
1048
+ @staticmethod
1049
+ def _set_item_scale(item: QtWidgets.QGraphicsItem, scale: Number) -> bool:
1050
+ target = max(0.01, float(scale))
1051
+ if math.isclose(item.scale(), target, rel_tol=1e-3, abs_tol=1e-3):
1052
+ return False
1053
+ item.setScale(target)
1054
+ return True
1055
+
1056
+ @staticmethod
1057
+ def _set_item_z(item: QtWidgets.QGraphicsItem, z_value: Number) -> bool:
1058
+ target = float(z_value)
1059
+ if math.isclose(item.zValue(), target, abs_tol=0.01):
1060
+ return False
1061
+ item.setZValue(target)
1062
+ return True
1063
+
1064
+ def _set_item_size(
1065
+ self,
1066
+ item: QtWidgets.QGraphicsItem,
1067
+ *,
1068
+ width: Number | None = None,
1069
+ height: Number | None = None,
1070
+ ) -> bool:
1071
+ current_w, current_h = self._item_dimensions(item)
1072
+ target_w = float(width) if width is not None else current_w
1073
+ target_h = float(height) if height is not None else current_h
1074
+ target_w = max(1.0, target_w)
1075
+ target_h = max(1.0, target_h)
1076
+ if (
1077
+ math.isclose(current_w, target_w, rel_tol=1e-3, abs_tol=0.2)
1078
+ and math.isclose(current_h, target_h, rel_tol=1e-3, abs_tol=0.2)
1079
+ ):
1080
+ return False
1081
+ old_top_left = item.mapToScene(QtCore.QPointF(0.0, 0.0))
1082
+
1083
+ if isinstance(item, QtWidgets.QGraphicsRectItem):
1084
+ rect = item.rect()
1085
+ item.setRect(rect.x(), rect.y(), target_w, target_h)
1086
+ elif isinstance(item, QtWidgets.QGraphicsEllipseItem):
1087
+ rect = item.rect()
1088
+ item.setRect(rect.x(), rect.y(), target_w, target_h)
1089
+ elif isinstance(item, BlockArrowItem):
1090
+ item._w = target_w # type: ignore[assignment]
1091
+ item._h = target_h # type: ignore[assignment]
1092
+ item._update_polygon() # type: ignore[attr-defined]
1093
+ item.setTransformOriginPoint(target_w / 2.0, target_h / 2.0)
1094
+ elif hasattr(item, "set_size") and callable(getattr(item, "set_size")):
1095
+ try:
1096
+ item.set_size(target_w, target_h) # type: ignore[misc]
1097
+ except TypeError:
1098
+ item.set_size(target_w, target_h, adjust_origin=True) # type: ignore[misc]
1099
+ else:
1100
+ base_bounds = item.boundingRect()
1101
+ scale_x = target_w / (base_bounds.width() or 1.0)
1102
+ scale_y = target_h / (base_bounds.height() or 1.0)
1103
+ item.setScale(max(scale_x, scale_y))
1104
+
1105
+ new_bounds = item.boundingRect()
1106
+ item.setTransformOriginPoint(new_bounds.center())
1107
+ new_top_left = item.mapToScene(QtCore.QPointF(0.0, 0.0))
1108
+ item.setPos(item.pos() + (old_top_left - new_top_left))
1109
+ if hasattr(item, "update_handles"):
1110
+ item.update_handles()
1111
+ return True
1112
+
1113
+ @staticmethod
1114
+ def _item_dimensions(item: QtWidgets.QGraphicsItem) -> tuple[float, float]:
1115
+ if isinstance(item, QtWidgets.QGraphicsRectItem):
1116
+ rect = item.rect()
1117
+ return float(rect.width()), float(rect.height())
1118
+ if isinstance(item, QtWidgets.QGraphicsEllipseItem):
1119
+ rect = item.rect()
1120
+ return float(rect.width()), float(rect.height())
1121
+ width_attr = getattr(item, "_w", None)
1122
+ height_attr = getattr(item, "_h", None)
1123
+ if isinstance(width_attr, (int, float)) and isinstance(height_attr, (int, float)):
1124
+ return float(width_attr), float(height_attr)
1125
+ bounds = item.boundingRect()
1126
+ return float(bounds.width()), float(bounds.height())
1127
+
1128
+ @staticmethod
1129
+ def _set_corner_radius(item: Any, attr: str, value: Number) -> bool:
1130
+ target = max(0.0, float(value))
1131
+ current = float(getattr(item, attr, 0.0))
1132
+ if math.isclose(current, target, abs_tol=0.1):
1133
+ return False
1134
+ setattr(item, attr, target)
1135
+ if hasattr(item, "update"):
1136
+ item.update()
1137
+ return True
1138
+
1139
+ @staticmethod
1140
+ def _set_pen_color(item: Any, color: QtGui.QColor) -> bool:
1141
+ qcolor = QtGui.QColor(color)
1142
+ if not qcolor.isValid():
1143
+ return False
1144
+ pen = QtGui.QPen(item.pen())
1145
+ if pen.color() == qcolor:
1146
+ return False
1147
+ pen.setColor(qcolor)
1148
+ item.setPen(pen)
1149
+ return True
1150
+
1151
+ @staticmethod
1152
+ def _set_pen_width(item: Any, width: Number) -> bool:
1153
+ target = max(0.05, float(width))
1154
+ pen = QtGui.QPen(item.pen())
1155
+ if math.isclose(pen.widthF(), target, abs_tol=0.05):
1156
+ return False
1157
+ pen.setWidthF(target)
1158
+ item.setPen(pen)
1159
+ return True
1160
+
1161
+ @staticmethod
1162
+ def _set_brush_color(item: Any, color: QtGui.QColor) -> bool:
1163
+ qcolor = QtGui.QColor(color)
1164
+ if not qcolor.isValid():
1165
+ return False
1166
+ brush = QtGui.QBrush(item.brush())
1167
+ if brush.style() == QtCore.Qt.BrushStyle.NoBrush or brush.color() != qcolor:
1168
+ brush.setStyle(QtCore.Qt.BrushStyle.SolidPattern)
1169
+ brush.setColor(qcolor)
1170
+ item.setBrush(brush)
1171
+ return True
1172
+ return False
1173
+
1174
+ @staticmethod
1175
+ def _set_split_brush_color(item: SplitRoundedRectItem, which: str, color: QtGui.QColor) -> bool:
1176
+ qcolor = QtGui.QColor(color)
1177
+ if not qcolor.isValid():
1178
+ return False
1179
+ if which == "top":
1180
+ current = item.topBrush().color()
1181
+ if current == qcolor:
1182
+ return False
1183
+ item.setTopBrush(qcolor)
1184
+ else:
1185
+ current = item.bottomBrush().color()
1186
+ if current == qcolor:
1187
+ return False
1188
+ item.setBottomBrush(qcolor)
1189
+ item.update()
1190
+ return True
1191
+
1192
+ @staticmethod
1193
+ def _set_split_ratio(item: SplitRoundedRectItem, value: Number) -> bool:
1194
+ target = float(value)
1195
+ current = float(item.divider_ratio())
1196
+ if math.isclose(current, target, abs_tol=1e-3):
1197
+ return False
1198
+ item.set_divider_ratio(target)
1199
+ return True
1200
+
1201
+ @staticmethod
1202
+ def _set_block_arrow_ratio(item: BlockArrowItem, which: str, value: Number) -> bool:
1203
+ target = float(value)
1204
+ if which == "head":
1205
+ current = float(item.head_ratio())
1206
+ if math.isclose(current, target, abs_tol=1e-3):
1207
+ return False
1208
+ item.set_head_ratio(target)
1209
+ else:
1210
+ current = float(item.shaft_ratio())
1211
+ if math.isclose(current, target, abs_tol=1e-3):
1212
+ return False
1213
+ item.set_shaft_ratio(target)
1214
+ return True
1215
+
1216
+ @staticmethod
1217
+ def _set_bracket_hook_ratio(item: CurvyBracketItem, value: Number) -> bool:
1218
+ target = float(value)
1219
+ current = float(item.hook_ratio())
1220
+ if math.isclose(current, target, abs_tol=1e-3):
1221
+ return False
1222
+ item.set_hook_ratio(target)
1223
+ return True
1224
+
1225
+ @staticmethod
1226
+ def _toggle_line_arrow(item: LineItem, which: str, value: bool) -> bool:
1227
+ desired = bool(value)
1228
+ if which == "start":
1229
+ before = bool(item.arrow_start)
1230
+ if before == desired:
1231
+ return False
1232
+ item.set_arrow_start(desired)
1233
+ else:
1234
+ before = bool(item.arrow_end)
1235
+ if before == desired:
1236
+ return False
1237
+ item.set_arrow_end(desired)
1238
+ return True
1239
+
1240
+ @staticmethod
1241
+ def _set_line_arrow_metric(item: LineItem, which: str, value: Number) -> bool:
1242
+ target = max(0.1, float(value))
1243
+ if which == "length":
1244
+ before = float(item.arrow_head_length())
1245
+ if math.isclose(before, target, abs_tol=0.1):
1246
+ return False
1247
+ item.set_arrow_head_length(target)
1248
+ else:
1249
+ before = float(item.arrow_head_width())
1250
+ if math.isclose(before, target, abs_tol=0.1):
1251
+ return False
1252
+ item.set_arrow_head_width(target)
1253
+ return True
1254
+
1255
+ @staticmethod
1256
+ def _set_label_text(item: ShapeLabelMixin, value: str) -> bool:
1257
+ text = str(value)
1258
+ if item.label_text() == text:
1259
+ return False
1260
+ item.set_label_text(text)
1261
+ return True
1262
+
1263
+ @staticmethod
1264
+ def _set_label_font_family(item: ShapeLabelMixin, font: QtGui.QFont) -> bool:
1265
+ current_font = item.label_item().font()
1266
+ if current_font.family() == font.family():
1267
+ return False
1268
+ new_font = QtGui.QFont(current_font)
1269
+ new_font.setFamily(font.family())
1270
+ item.label_item().setFont(new_font)
1271
+ return True
1272
+
1273
+ @staticmethod
1274
+ def _label_font_size(item: ShapeLabelMixin) -> float:
1275
+ font = item.label_item().font()
1276
+ size = font.pixelSize()
1277
+ if size and size > 0:
1278
+ return float(size)
1279
+ point_size = font.pointSizeF()
1280
+ if point_size and point_size > 0:
1281
+ return float(point_size)
1282
+ return 16.0
1283
+
1284
+ @staticmethod
1285
+ def _set_label_font_size(item: ShapeLabelMixin, value: Number) -> bool:
1286
+ size = max(4.0, float(value))
1287
+ current = PropertiesPanel._label_font_size(item)
1288
+ if math.isclose(current, size, abs_tol=0.5):
1289
+ return False
1290
+ item.set_label_font_pixel_size(int(round(size)))
1291
+ return True
1292
+
1293
+ @staticmethod
1294
+ def _set_label_color(item: ShapeLabelMixin, color: QtGui.QColor) -> bool:
1295
+ qcolor = QtGui.QColor(color)
1296
+ if not qcolor.isValid():
1297
+ return False
1298
+ has_override = item.label_has_custom_color()
1299
+ current = item.label_color()
1300
+ if has_override and current == qcolor:
1301
+ return False
1302
+ item.set_label_color(qcolor)
1303
+ return True
1304
+
1305
+ @staticmethod
1306
+ def _reset_label_color(item: ShapeLabelMixin) -> bool:
1307
+ if not item.label_has_custom_color():
1308
+ return False
1309
+ item.reset_label_color()
1310
+ return True
1311
+
1312
+ @staticmethod
1313
+ def _set_label_alignment(
1314
+ item: ShapeLabelMixin,
1315
+ *,
1316
+ horizontal: str | None = None,
1317
+ vertical: str | None = None,
1318
+ ) -> bool:
1319
+ before_h, before_v = item.label_alignment()
1320
+ item.set_label_alignment(horizontal=horizontal, vertical=vertical)
1321
+ after_h, after_v = item.label_alignment()
1322
+ return before_h != after_h or before_v != after_v
1323
+
1324
+ @staticmethod
1325
+ def _set_text_item_content(item: TextItem, value: str) -> bool:
1326
+ text = str(value)
1327
+ if item.toPlainText() == text:
1328
+ return False
1329
+ item.setPlainText(text)
1330
+ return True
1331
+
1332
+ @staticmethod
1333
+ def _set_text_item_font_family(item: TextItem, font: QtGui.QFont) -> bool:
1334
+ current = item.font()
1335
+ if current.family() == font.family():
1336
+ return False
1337
+ new_font = QtGui.QFont(current)
1338
+ new_font.setFamily(font.family())
1339
+ item.setFont(new_font)
1340
+ return True
1341
+
1342
+ @staticmethod
1343
+ def _text_point_size(item: TextItem) -> float:
1344
+ font = item.font()
1345
+ size = font.pointSizeF()
1346
+ if size and size > 0:
1347
+ return float(size)
1348
+ pixel = font.pixelSize()
1349
+ if pixel and pixel > 0:
1350
+ return float(pixel)
1351
+ return 12.0
1352
+
1353
+ @staticmethod
1354
+ def _set_text_point_size(item: TextItem, value: Number) -> bool:
1355
+ size = max(1.0, float(value))
1356
+ current = PropertiesPanel._text_point_size(item)
1357
+ if math.isclose(current, size, abs_tol=0.4):
1358
+ return False
1359
+ font = QtGui.QFont(item.font())
1360
+ font.setPointSizeF(size)
1361
+ if font.pointSizeF() <= 0.0:
1362
+ font.setPixelSize(int(round(size)))
1363
+ item.setFont(font)
1364
+ return True
1365
+
1366
+ @staticmethod
1367
+ def _set_text_color(item: TextItem, color: QtGui.QColor) -> bool:
1368
+ qcolor = QtGui.QColor(color)
1369
+ if not qcolor.isValid():
1370
+ return False
1371
+ current = item.defaultTextColor()
1372
+ if current == qcolor:
1373
+ return False
1374
+ item.setDefaultTextColor(qcolor)
1375
+ return True
1376
+
1377
+ @staticmethod
1378
+ def _set_text_margin(item: TextItem, value: Number) -> bool:
1379
+ margin = max(0.0, float(value))
1380
+ doc = item.document()
1381
+ if doc is None:
1382
+ return False
1383
+ current = float(doc.documentMargin())
1384
+ if math.isclose(current, margin, abs_tol=0.2):
1385
+ return False
1386
+ item.set_document_margin(margin)
1387
+ return True
1388
+
1389
+ @staticmethod
1390
+ def _set_text_alignment(
1391
+ item: TextItem,
1392
+ *,
1393
+ horizontal: str | None = None,
1394
+ vertical: str | None = None,
1395
+ ) -> bool:
1396
+ before = item.text_alignment()
1397
+ item.set_text_alignment(horizontal=horizontal, vertical=vertical)
1398
+ after = item.text_alignment()
1399
+ return before != after
1400
+
1401
+ @staticmethod
1402
+ def _set_text_direction(item: TextItem, direction: str) -> bool:
1403
+ if item.text_direction() == direction:
1404
+ return False
1405
+ item.set_text_direction(direction)
1406
+ return True