nodeeditor-qtpy 0.8.1__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.
Files changed (40) hide show
  1. nodeeditor/__init__.py +93 -0
  2. nodeeditor/node_colors_config.py +343 -0
  3. nodeeditor/node_content_widget.py +129 -0
  4. nodeeditor/node_decorators.py +65 -0
  5. nodeeditor/node_edge.py +518 -0
  6. nodeeditor/node_edge_dragging.py +185 -0
  7. nodeeditor/node_edge_intersect.py +219 -0
  8. nodeeditor/node_edge_rerouting.py +216 -0
  9. nodeeditor/node_edge_snapping.py +95 -0
  10. nodeeditor/node_edge_validators.py +82 -0
  11. nodeeditor/node_editor_widget.py +257 -0
  12. nodeeditor/node_editor_window.py +448 -0
  13. nodeeditor/node_graph.py +583 -0
  14. nodeeditor/node_graphics_cutline.py +57 -0
  15. nodeeditor/node_graphics_edge.py +375 -0
  16. nodeeditor/node_graphics_edge_path.py +226 -0
  17. nodeeditor/node_graphics_node.py +693 -0
  18. nodeeditor/node_graphics_scene.py +369 -0
  19. nodeeditor/node_graphics_socket.py +262 -0
  20. nodeeditor/node_graphics_view.py +907 -0
  21. nodeeditor/node_group.py +1286 -0
  22. nodeeditor/node_group_node.py +5 -0
  23. nodeeditor/node_icon_content_widget.py +176 -0
  24. nodeeditor/node_icon_graphics_node.py +524 -0
  25. nodeeditor/node_multi_input_node.py +450 -0
  26. nodeeditor/node_node.py +848 -0
  27. nodeeditor/node_scene.py +1106 -0
  28. nodeeditor/node_scene_clipboard.py +314 -0
  29. nodeeditor/node_scene_history.py +556 -0
  30. nodeeditor/node_serializable.py +43 -0
  31. nodeeditor/node_socket.py +445 -0
  32. nodeeditor/node_socket_shapes.py +261 -0
  33. nodeeditor/py.typed +0 -0
  34. nodeeditor/qss/nodestyle.qss +3 -0
  35. nodeeditor/utils.py +108 -0
  36. nodeeditor/utils_no_qt.py +21 -0
  37. nodeeditor_qtpy-0.8.1.dist-info/METADATA +165 -0
  38. nodeeditor_qtpy-0.8.1.dist-info/RECORD +40 -0
  39. nodeeditor_qtpy-0.8.1.dist-info/WHEEL +4 -0
  40. nodeeditor_qtpy-0.8.1.dist-info/licenses/LICENCE +23 -0
@@ -0,0 +1,693 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ A module containing Graphics representation of :class:`~nodeeditor.node_node.Node`
4
+ """
5
+ from qtpy.QtWidgets import QGraphicsItem, QWidget, QGraphicsTextItem, QGraphicsSceneHoverEvent
6
+ from qtpy.QtGui import QFont, QColor, QPen, QBrush, QPainterPath, QTextCursor
7
+ from qtpy.QtCore import Qt, QRectF
8
+
9
+ from typing import TYPE_CHECKING, List, Mapping, Optional, Tuple, Any
10
+
11
+ from nodeeditor.node_colors_config import get_color_scheme
12
+
13
+
14
+ if TYPE_CHECKING:
15
+ from nodeeditor.node_graphics_view import QDMGraphicsView
16
+ from nodeeditor.node_edge import Edge
17
+ from nodeeditor.node_socket import Socket
18
+ from nodeeditor.node_node import Node
19
+
20
+
21
+ class QDMGraphicsNodeLabel(QGraphicsTextItem):
22
+ """Single-line editable textbox floating above a `QDMGraphicsNode`.
23
+
24
+ Parented to the graphics node, so Qt moves it with the node for free.
25
+ Click to focus/edit; ``Enter`` commits, ``Escape`` reverts, focus-out commits.
26
+ """
27
+
28
+ def __init__(self, grNode: 'QDMGraphicsNode', parent: QGraphicsItem = None) -> None:
29
+ """
30
+ :param grNode: reference to the owning :class:`~nodeeditor.node_graphics_node.QDMGraphicsNode`
31
+ :type grNode: :class:`~nodeeditor.node_graphics_node.QDMGraphicsNode`
32
+ :param parent: parent widget
33
+ :type parent: QWidget
34
+
35
+ :Instance Attributes:
36
+
37
+ - **grNode** - reference to the owning :class:`~nodeeditor.node_graphics_node.QDMGraphicsNode`
38
+ """
39
+ super().__init__(parent if parent is not None else grNode)
40
+ self._grNode: 'QDMGraphicsNode' = grNode
41
+ self._last_text: str = ""
42
+ self._bg_color = QColor("#E3212121")
43
+ self._border_color = QColor("#FF5A5A5A")
44
+ self._pad_x: float = 6.0
45
+ self._pad_y: float = 3.0
46
+ self._radius: float = 6.0
47
+ self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable, False)
48
+ self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsFocusable, True)
49
+ self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable, False)
50
+ self.setTextInteractionFlags(Qt.TextInteractionFlag.TextEditorInteraction)
51
+ self.setZValue(1)
52
+ try:
53
+ self.document().contentsChanged.connect(self._onContentsChanged)
54
+ except Exception:
55
+ pass
56
+
57
+ @staticmethod
58
+ def sanitize(text: str) -> str:
59
+ """Force single-line plain text."""
60
+ if text is None:
61
+ return ""
62
+ return str(text).replace("\r", " ").replace("\n", " ").strip()
63
+
64
+ def setLabelColors(self, bg: Optional[QColor] = None, border: Optional[QColor] = None) -> None:
65
+ """Set the background and border colors of the label box.
66
+
67
+ :param bg: background color of the label box
68
+ :type bg: QColor
69
+ :param border: border color of the label box
70
+ :type border: QColor
71
+ """
72
+ if bg is not None:
73
+ self._bg_color = bg
74
+ if border is not None:
75
+ self._border_color = border
76
+ self.update()
77
+
78
+ def focusInEvent(self, event) -> None:
79
+ """Remember the current text and switch the scene into editing mode.
80
+
81
+ :param event: Qt's focus event
82
+ :type event: QFocusEvent
83
+ """
84
+ self._last_text = self.toPlainText()
85
+ self._setEditing(True)
86
+ super().focusInEvent(event)
87
+
88
+ def focusOutEvent(self, event) -> None:
89
+ """Commit the edited text and leave editing mode.
90
+
91
+ :param event: Qt's focus event
92
+ :type event: QFocusEvent
93
+ """
94
+ self._commit()
95
+ self._setEditing(False)
96
+ super().focusOutEvent(event)
97
+
98
+ def keyPressEvent(self, event) -> None:
99
+ """Commit on ``Return``/``Enter``, revert the text on ``Escape``.
100
+
101
+ :param event: Qt's key event
102
+ :type event: QKeyEvent
103
+ """
104
+ try:
105
+ key = event.key()
106
+ except Exception:
107
+ return super().keyPressEvent(event)
108
+ if key in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
109
+ event.accept()
110
+ self.clearFocus()
111
+ return
112
+ if key == Qt.Key.Key_Escape:
113
+ try:
114
+ self.setPlainText(self._last_text)
115
+ except Exception:
116
+ pass
117
+ event.accept()
118
+ self.clearFocus()
119
+ return
120
+ super().keyPressEvent(event)
121
+
122
+ def mousePressEvent(self, event) -> None:
123
+ """Take the keyboard focus when the label is clicked.
124
+
125
+ :param event: Qt's mouse event
126
+ :type event: QMouseEvent
127
+ """
128
+ try:
129
+ if not self.hasFocus():
130
+ self.setFocus(Qt.FocusReason.MouseFocusReason)
131
+ except Exception:
132
+ pass
133
+ super().mousePressEvent(event)
134
+
135
+ def mouseDoubleClickEvent(self, event) -> None:
136
+ """Select the whole text on a double click, so typing replaces it.
137
+
138
+ :param event: Qt's mouse event
139
+ :type event: QMouseEvent
140
+ """
141
+ try:
142
+ if not self.hasFocus():
143
+ self.setFocus(Qt.FocusReason.MouseFocusReason)
144
+ cursor = self.textCursor()
145
+ cursor.select(QTextCursor.SelectionType.Document)
146
+ self.setTextCursor(cursor)
147
+ except Exception:
148
+ pass
149
+ super().mouseDoubleClickEvent(event)
150
+
151
+ def paint(self, painter, option, widget=None) -> None:
152
+ """Draw the rounded background box behind the label text."""
153
+ rect = self.boundingRect().adjusted(-self._pad_x, -self._pad_y, self._pad_x, self._pad_y)
154
+ painter.setPen(QPen(self._border_color, 1.0))
155
+ painter.setBrush(QBrush(self._bg_color))
156
+ painter.drawRoundedRect(rect, self._radius, self._radius)
157
+ super().paint(painter, option, widget)
158
+
159
+ def _onContentsChanged(self) -> None:
160
+ try:
161
+ self._grNode._updateLabelPos()
162
+ except Exception:
163
+ pass
164
+
165
+ def _commit(self) -> None:
166
+ text = self.sanitize(self.toPlainText())
167
+ if text != self.toPlainText():
168
+ try:
169
+ self.document().blockSignals(True)
170
+ self.setPlainText(text)
171
+ finally:
172
+ try:
173
+ self.document().blockSignals(False)
174
+ except Exception:
175
+ pass
176
+ try:
177
+ self._grNode._updateLabelPos()
178
+ except Exception:
179
+ pass
180
+ try:
181
+ self._grNode._onLabelEdited(text)
182
+ except Exception:
183
+ pass
184
+
185
+ def _setEditing(self, value: bool) -> None:
186
+ try:
187
+ view = self._grNode.node.scene.getView()
188
+ view.editingFlag = value
189
+ except Exception:
190
+ pass
191
+
192
+
193
+ class QDMGraphicsNode(QGraphicsItem):
194
+ """Class describing Graphics representation of :class:`~nodeeditor.node_node.Node`"""
195
+
196
+ def __init__(self, node: 'Node', parent: QGraphicsItem = None) -> None:
197
+ """
198
+ :param node: reference to :class:`~nodeeditor.node_node.Node`
199
+ :type node: :class:`~nodeeditor.node_node.Node`
200
+ :param parent: parent widget
201
+ :type parent: QWidget
202
+
203
+ :Instance Attributes:
204
+
205
+ - **node** - reference to :class:`~nodeeditor.node_node.Node`
206
+ """
207
+ super().__init__(parent)
208
+ self.node: 'Node' = node
209
+
210
+ # init our flags
211
+ self.hovered: bool = False
212
+ self._was_moved: bool = False
213
+ self._last_selected_state: bool = False
214
+
215
+ self.initSizes()
216
+ self.initAssets()
217
+ self.initUI()
218
+
219
+ @property
220
+ def content(self):
221
+ """Reference to `Node Content`"""
222
+ return self.node.content if self.node else None
223
+
224
+ @property
225
+ def title(self):
226
+ """title of this `Node`
227
+
228
+ :getter: current Graphics Node title
229
+ :setter: stores and make visible the new title
230
+ :type: str
231
+ """
232
+ return self._title
233
+
234
+ @title.setter
235
+ def title(self, value) -> None:
236
+ self._title = value
237
+ self.title_item.setPlainText(self._title)
238
+
239
+ def initUI(self) -> None:
240
+ """Set up this ``QGraphicsItem``"""
241
+ self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
242
+ self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable)
243
+ # Qt then tells us when it has moved the item, which is how the
244
+ # connected edges are refreshed without rescanning the whole scene.
245
+ self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)
246
+ self.setAcceptHoverEvents(True)
247
+
248
+ # init title
249
+ self.initTitle()
250
+ # self.title = self.node.title
251
+
252
+ self.initContent()
253
+ self.initLabel()
254
+
255
+ def initSizes(self) -> None:
256
+ """Set up internal attributes like `width`, `height`, etc."""
257
+ self.width: float = 180
258
+ self.height: float = 240
259
+ self.edge_roundness = 10.0
260
+ self.edge_padding = 10
261
+ self.title_height = 24
262
+ self.title_horizontal_padding = 4.0
263
+ self.title_vertical_padding = 4.0
264
+ self.label_offset: float = 10.0
265
+ self._label_visible: bool = False
266
+ #: Level-of-detail state pushed by the scene (see
267
+ #: ``QDMGraphicsScene.setDetailLod``). False below the scene's
268
+ #: threshold, when labels, content widgets and sockets are skipped.
269
+ self._detail_visible: bool = True
270
+ #: Resolved per-element state pushed by the scene (see
271
+ #: ``QDMGraphicsScene.set_detail_policy``). Each flag says whether
272
+ #: that element may be drawn right now.
273
+ self._content_visible: bool = True
274
+ self._sockets_visible: bool = True
275
+ self._label_enabled: bool = True
276
+
277
+ def initAssets(self) -> None:
278
+ """Initialize ``QObjects`` like ``QColor``, ``QPen`` and ``QBrush``"""
279
+ self._title_color = Qt.GlobalColor.white
280
+ self._title_font = QFont("Ubuntu", 10)
281
+
282
+ # Get colors from global color scheme
283
+ scheme = get_color_scheme()
284
+ self._color = QColor("#7F000000")
285
+ self._color_selected = QColor("#FFFFA637")
286
+ self._color_hovered = scheme.edges.hovered
287
+
288
+ self._pen_default = QPen(self._color)
289
+ self._pen_default.setWidthF(2.0)
290
+ self._pen_selected = QPen(self._color_selected)
291
+ self._pen_selected.setWidthF(2.0)
292
+ self._pen_hovered = QPen(self._color_hovered)
293
+ self._pen_hovered.setWidthF(3.0)
294
+
295
+ self._brush_title = QBrush(QColor("#FF313131"))
296
+ self._brush_background = QBrush(QColor("#E3212121"))
297
+
298
+ self._label_font = QFont("Ubuntu", 9)
299
+ self._label_color = QColor("#EEEEEE")
300
+
301
+ def onSelected(self) -> None:
302
+ """Our event handling when the node was selected"""
303
+ self.node.scene.grScene.itemSelected.emit()
304
+
305
+ def doSelect(self, new_state: bool = True) -> None:
306
+ """Safe version of selecting the `Graphics Node`. Takes care about the selection state flag used internally
307
+
308
+ :param new_state: ``True`` to select, ``False`` to deselect
309
+ :type new_state: ``bool``
310
+ """
311
+ self.setSelected(new_state)
312
+ self._last_selected_state = new_state
313
+ if new_state:
314
+ self.onSelected()
315
+
316
+ def mousePressEvent(self, event) -> None:
317
+ """Keep the default Qt behavior, so the node can be moved and selected.
318
+
319
+ :param event: Qt's mouse event
320
+ :type event: QMouseEvent
321
+ """
322
+ # Collapsed groups hide child grNodes, so no special guard needed.
323
+ # Keep default Qt behavior (move/select).
324
+ super().mousePressEvent(event)
325
+
326
+ def itemChange(self, change, value):
327
+ """Refresh this node's edges when Qt moves it.
328
+
329
+ Qt moves the dragged node - and every other node in the selection -
330
+ itself. Reacting to the position change Qt already reports updates the
331
+ edges of exactly the nodes that moved, replacing the old per-mouse-move
332
+ scan of every node in the scene.
333
+ """
334
+ if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
335
+ node = getattr(self, "node", None)
336
+ if node is not None:
337
+ try:
338
+ node.updateConnectedEdges()
339
+ except (AttributeError, RuntimeError):
340
+ pass # socket list not built yet, or C++ side gone
341
+ elif change == QGraphicsItem.GraphicsItemChange.ItemSceneHasChanged:
342
+ # A node added while the view is already zoomed out must start in
343
+ # the scene's current level-of-detail state, not fully detailed:
344
+ # loading a big workflow while zoomed out would otherwise draw every
345
+ # new node's content widget until the next zoom change.
346
+ scene = value if value is not None else self.scene()
347
+ state = getattr(scene, "detail_state", None)
348
+ if callable(state):
349
+ self.setDetailState(state())
350
+ else:
351
+ lod = getattr(scene, "_detail_visible", True)
352
+ if lod != getattr(self, "_detail_visible", True):
353
+ self.setDetailVisible(lod)
354
+ elif change == QGraphicsItem.GraphicsItemChange.ItemChildAddedChange:
355
+ # Sockets and the content proxy are created after this node joined
356
+ # the scene, so the scene's one-off broadcast never reached them;
357
+ # without this a node built while zoomed out draws fully detailed
358
+ # until the next zoom change.
359
+ self._applyDetailToChild(value)
360
+ return super().itemChange(change, value)
361
+
362
+ def mouseMoveEvent(self, event) -> None:
363
+ """Overridden event to detect that we moved with this `Node`"""
364
+ super().mouseMoveEvent(event)
365
+ # Connected edges follow via itemChange(); no scene-wide scan here.
366
+ self._was_moved = True
367
+
368
+ def mouseReleaseEvent(self, event) -> None:
369
+ """Overriden event to handle when we moved, selected or deselected this `Node`"""
370
+ super().mouseReleaseEvent(event)
371
+
372
+ # handle when grNode moved
373
+ if self._was_moved:
374
+ try:
375
+ self.node.positionChanged.emit(self.node)
376
+ except Exception:
377
+ pass
378
+
379
+ self._was_moved = False
380
+ self.node.scene.history.storeHistory(
381
+ "Node moved", setModified=True)
382
+
383
+ self.node.scene.resetLastSelectedStates()
384
+ self.doSelect() # also trigger itemSelected when node was moved
385
+
386
+ # we need to store the last selected state, because moving does also select the nodes
387
+ self.node.scene._last_selected_items = self.node.scene.getSelectedItems()
388
+
389
+ # Emit signal to notify that node position has changed (for group boundary checks)
390
+
391
+ # now we want to skip storing selection
392
+ return
393
+
394
+ # handle when grNode was clicked on
395
+ if self._last_selected_state != self.isSelected() or self.node.scene._last_selected_items != self.node.scene.getSelectedItems():
396
+ self.node.scene.resetLastSelectedStates()
397
+ self._last_selected_state = self.isSelected()
398
+ self.onSelected()
399
+
400
+ def mouseDoubleClickEvent(self, event) -> None:
401
+ """Overriden event for doubleclick. Resend to `Node::onDoubleClicked`"""
402
+ self.node.onDoubleClicked(event)
403
+
404
+ def hoverEnterEvent(self, event: Optional['QGraphicsSceneHoverEvent']) -> None:
405
+ """Handle hover effect"""
406
+ self.hovered = True
407
+ self.update()
408
+
409
+ def hoverLeaveEvent(self, event: Optional['QGraphicsSceneHoverEvent']) -> None:
410
+ """Handle hover effect"""
411
+ self.hovered = False
412
+ self.update()
413
+
414
+ def boundingRect(self) -> QRectF:
415
+ """Defining Qt' bounding rectangle"""
416
+ return QRectF(
417
+ 0,
418
+ 0,
419
+ self.width,
420
+ self.height
421
+ ).normalized()
422
+
423
+ def initTitle(self) -> None:
424
+ """Set up the title Graphics representation: font, color, position, etc."""
425
+ self.title_item = QGraphicsTextItem(self)
426
+ self.title = self.node.title
427
+ self.title_item.node = self.node
428
+ self.title_item.setDefaultTextColor(self._title_color)
429
+ self.title_item.setFont(self._title_font)
430
+
431
+ # Calculate horizontal and vertical center positions
432
+ horizontal_center = (self.boundingRect().width(
433
+ ) - self.title_item.boundingRect().width()) / 2
434
+ vertical_center = (self.title_height -
435
+ self.title_item.boundingRect().height()) / 2
436
+
437
+ self.title_item.setPos(horizontal_center, vertical_center)
438
+
439
+ def initContent(self) -> None:
440
+ """Set up the `grContent` - ``QGraphicsProxyWidget`` to have a container for `Graphics Content`"""
441
+ if self.content is not None:
442
+ self.content.setGeometry(self.edge_padding, self.title_height + self.edge_padding,
443
+ self.width - 2 * self.edge_padding, self.height - 2 * self.edge_padding - self.title_height)
444
+
445
+ # get the QGraphicsProxyWidget when inserted into the grScene
446
+ self.grContent = self.node.scene.grScene.addWidget(self.content)
447
+ self.grContent.node = self.node
448
+ self.grContent.setParentItem(self)
449
+
450
+ def initLabel(self) -> None:
451
+ """Create the floating single-line label above the node (hidden until text is set)."""
452
+ self.label_item = QDMGraphicsNodeLabel(self, self)
453
+ self.label_item.setFont(self._label_font)
454
+ self.label_item.setDefaultTextColor(self._label_color)
455
+ self._label_visible = False
456
+ self.label_item.setVisible(False)
457
+ self._updateLabelPos()
458
+
459
+ def setLabelText(self, text: str) -> None:
460
+ """Set label text (single-line). Empty text hides the label. No history stored."""
461
+ text = QDMGraphicsNodeLabel.sanitize(text)
462
+ self.label_item.setPlainText(text)
463
+ if text:
464
+ self._label_visible = True
465
+ self._refreshLabelVisibility()
466
+ self._updateLabelPos()
467
+
468
+ def labelText(self) -> str:
469
+ """Return current label text."""
470
+ try:
471
+ return self.label_item.toPlainText()
472
+ except Exception:
473
+ return ""
474
+
475
+ def setLabelVisible(self, visible: bool) -> None:
476
+ """Show/hide the label. Hidden when text is empty regardless."""
477
+ self._label_visible = bool(visible)
478
+ self._refreshLabelVisibility()
479
+
480
+ def isLabelVisible(self) -> bool:
481
+ """``True`` while the label is requested visible and carries text."""
482
+ return bool(self._label_visible and bool(self.labelText()))
483
+
484
+ def setLabelOffset(self, offset: float) -> None:
485
+ """Gap in px between node top and label bottom."""
486
+ self.label_offset = float(offset)
487
+ self._updateLabelPos()
488
+
489
+ def labelOffset(self) -> float:
490
+ """Return the gap in px between node top and label bottom."""
491
+ return float(self.label_offset)
492
+
493
+ def setLabelColor(self, color) -> None:
494
+ """Set the text color of the label.
495
+
496
+ :param color: the new text color
497
+ :type color: QColor
498
+ """
499
+ self._label_color = QColor(color) if not isinstance(color, QColor) else color
500
+ self.label_item.setDefaultTextColor(self._label_color)
501
+
502
+ def setLabelFont(self, font: QFont) -> None:
503
+ """Set the font of the label and move the label to its new position.
504
+
505
+ :param font: the new font
506
+ :type font: QFont
507
+ """
508
+ self._label_font = font
509
+ self.label_item.setFont(font)
510
+ self._updateLabelPos()
511
+
512
+ def setLabelBackground(self, bg, border=None) -> None:
513
+ """Set the background and border colors of the label box.
514
+
515
+ :param bg: background color of the label box
516
+ :type bg: QColor
517
+ :param border: border color of the label box
518
+ :type border: QColor
519
+ """
520
+ bg = QColor(bg) if not isinstance(bg, QColor) else bg
521
+ b = QColor(border) if border is not None and not isinstance(border, QColor) else border
522
+ self.label_item.setLabelColors(bg=bg, border=b)
523
+
524
+ def setDetailState(self, state: Mapping[str, bool]) -> None:
525
+ """Apply a resolved per-element detail state.
526
+
527
+ The scene resolves its detail policy into a mapping of element key to
528
+ "draw it" and hands the whole mapping over, so an application can add
529
+ elements of its own to the same broadcast and read them back here.
530
+ The zoom state itself rides along under the ``detail`` key.
531
+
532
+ Every child is pushed, not only the ones whose flag changed: a socket
533
+ or a content proxy built after the last flip has never seen the state.
534
+ """
535
+ self._detail_visible = bool(state.get("detail", self._detail_visible))
536
+ self._content_visible = bool(state.get("content", True))
537
+ self._sockets_visible = bool(state.get("sockets", True))
538
+ self._label_enabled = bool(state.get("label", True))
539
+
540
+ self._refreshLabelVisibility()
541
+
542
+ # The content proxy, the sockets and any other child item that wants to
543
+ # fade out too.
544
+ for child in self.childItems():
545
+ self._applyDetailToChild(child)
546
+
547
+ def setDetailVisible(self, visible: bool) -> None:
548
+ """Show or hide this node's fine detail at low zoom.
549
+
550
+ The legacy entry point, kept for callers outside the scene: it drops
551
+ the content widget, the floating label and the socket circles together.
552
+ The scene uses :meth:`setDetailState` instead, which resolves each of
553
+ those separately.
554
+ """
555
+ visible = bool(visible)
556
+ self.setDetailState(
557
+ {
558
+ "detail": visible,
559
+ "content": visible,
560
+ "sockets": visible,
561
+ "label": visible,
562
+ }
563
+ )
564
+
565
+ def _applyDetailToChild(self, child) -> None:
566
+ """Push this node's detail state onto one child item.
567
+
568
+ Sockets own a ``setDetailVisible``; the content proxy widget does not,
569
+ so it is matched by identity against ``grContent``.
570
+ """
571
+ setter = getattr(child, "setDetailVisible", None)
572
+ if setter is not None:
573
+ setter(self._sockets_visible)
574
+ elif child is getattr(self, "grContent", None):
575
+ child.setVisible(self._content_visible)
576
+
577
+ def syncDetailVisibility(self) -> None:
578
+ """Push the scene's current detail state onto every child item.
579
+
580
+ The sockets and the content proxy are built during ``Node.__init__``,
581
+ after Qt already reported this node joining the scene, so the scene's
582
+ one-off broadcast never reached them - and Qt's
583
+ ``ItemChildAddedChange`` hands over a base-class wrapper for an item
584
+ still inside its own constructor, which has no ``setDetailVisible``.
585
+ Called from ``Node.__init__`` once the node is fully built.
586
+ """
587
+ scene = self.scene()
588
+ state = getattr(scene, "detail_state", None)
589
+ if callable(state):
590
+ self.setDetailState(state())
591
+ return
592
+ self._detail_visible = getattr(scene, "_detail_visible", True)
593
+ self._refreshLabelVisibility()
594
+ for child in self.childItems():
595
+ self._applyDetailToChild(child)
596
+
597
+ def _refreshLabelVisibility(self) -> None:
598
+ try:
599
+ self.label_item.setVisible(bool(
600
+ self._label_visible
601
+ and bool(self.label_item.toPlainText())
602
+ and self._label_enabled))
603
+ except Exception:
604
+ pass
605
+
606
+ def _updateLabelPos(self) -> None:
607
+ """Center label horizontally above the node."""
608
+ try:
609
+ w = self.label_item.boundingRect().width()
610
+ h = self.label_item.boundingRect().height()
611
+ self.label_item.setPos((self.width - w) / 2, -h - self.label_offset)
612
+ except Exception:
613
+ pass
614
+
615
+ def _onLabelEdited(self, text: str) -> None:
616
+ """Called by label item on user commit. Syncs visibility, emits signal, stores history."""
617
+ text = QDMGraphicsNodeLabel.sanitize(text)
618
+ if text:
619
+ self._label_visible = True
620
+ self._refreshLabelVisibility()
621
+ self._updateLabelPos()
622
+ try:
623
+ self.node.labelChanged.emit(text)
624
+ except Exception:
625
+ pass
626
+ try:
627
+ self.node.scene.history.storeHistory("Node label changed", setModified=True)
628
+ except Exception:
629
+ pass
630
+
631
+ def _isSocketHovered(self) -> bool:
632
+ """``True`` while the mouse is over one of this node's sockets.
633
+
634
+ A socket lives inside the node's own hit area, so Qt reports the node
635
+ as hovered while the mouse is over the socket. The socket's highlight
636
+ is the more precise of the two, so the node holds back its hover
637
+ outline until the mouse leaves the socket.
638
+ """
639
+ node = getattr(self, "node", None)
640
+ if node is None:
641
+ return False
642
+ try:
643
+ sockets = list(node.inputs) + list(node.outputs)
644
+ except (AttributeError, TypeError):
645
+ return False
646
+ for socket in sockets:
647
+ gr_socket = getattr(socket, "grSocket", None)
648
+ if gr_socket is not None and getattr(gr_socket, "hovered", False):
649
+ return True
650
+ return False
651
+
652
+ def paint(self, painter, QStyleOptionGraphicsItem, widget=None) -> None:
653
+ """Painting the rounded rectanglar `Node`"""
654
+ # title
655
+ path_title = QPainterPath()
656
+ path_title.setFillRule(Qt.WindingFill)
657
+ path_title.addRoundedRect(
658
+ 0, 0, self.width, self.title_height, self.edge_roundness, self.edge_roundness)
659
+ path_title.addRect(0, self.title_height - self.edge_roundness,
660
+ self.edge_roundness, self.edge_roundness)
661
+ path_title.addRect(self.width - self.edge_roundness, self.title_height -
662
+ self.edge_roundness, self.edge_roundness, self.edge_roundness)
663
+ painter.setPen(Qt.NoPen)
664
+ painter.setBrush(self._brush_title)
665
+ painter.drawPath(path_title.simplified())
666
+
667
+ # content
668
+ path_content = QPainterPath()
669
+ path_content.setFillRule(Qt.WindingFill)
670
+ path_content.addRoundedRect(0, self.title_height, self.width, self.height -
671
+ self.title_height, self.edge_roundness, self.edge_roundness)
672
+ path_content.addRect(0, self.title_height,
673
+ self.edge_roundness, self.edge_roundness)
674
+ path_content.addRect(self.width - self.edge_roundness,
675
+ self.title_height, self.edge_roundness, self.edge_roundness)
676
+ painter.setPen(Qt.NoPen)
677
+ painter.setBrush(self._brush_background)
678
+ painter.drawPath(path_content.simplified())
679
+
680
+ # outline
681
+ path_outline = QPainterPath()
682
+ path_outline.addRoundedRect(-1, -1, self.width+2,
683
+ self.height+2, self.edge_roundness, self.edge_roundness)
684
+ painter.setBrush(Qt.NoBrush)
685
+ if self.hovered and not self._isSocketHovered():
686
+ painter.setPen(self._pen_hovered)
687
+ painter.drawPath(path_outline.simplified())
688
+ painter.setPen(self._pen_default)
689
+ painter.drawPath(path_outline.simplified())
690
+ else:
691
+ painter.setPen(
692
+ self._pen_default if not self.isSelected() else self._pen_selected)
693
+ painter.drawPath(path_outline.simplified())