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.
- nodeeditor/__init__.py +93 -0
- nodeeditor/node_colors_config.py +343 -0
- nodeeditor/node_content_widget.py +129 -0
- nodeeditor/node_decorators.py +65 -0
- nodeeditor/node_edge.py +518 -0
- nodeeditor/node_edge_dragging.py +185 -0
- nodeeditor/node_edge_intersect.py +219 -0
- nodeeditor/node_edge_rerouting.py +216 -0
- nodeeditor/node_edge_snapping.py +95 -0
- nodeeditor/node_edge_validators.py +82 -0
- nodeeditor/node_editor_widget.py +257 -0
- nodeeditor/node_editor_window.py +448 -0
- nodeeditor/node_graph.py +583 -0
- nodeeditor/node_graphics_cutline.py +57 -0
- nodeeditor/node_graphics_edge.py +375 -0
- nodeeditor/node_graphics_edge_path.py +226 -0
- nodeeditor/node_graphics_node.py +693 -0
- nodeeditor/node_graphics_scene.py +369 -0
- nodeeditor/node_graphics_socket.py +262 -0
- nodeeditor/node_graphics_view.py +907 -0
- nodeeditor/node_group.py +1286 -0
- nodeeditor/node_group_node.py +5 -0
- nodeeditor/node_icon_content_widget.py +176 -0
- nodeeditor/node_icon_graphics_node.py +524 -0
- nodeeditor/node_multi_input_node.py +450 -0
- nodeeditor/node_node.py +848 -0
- nodeeditor/node_scene.py +1106 -0
- nodeeditor/node_scene_clipboard.py +314 -0
- nodeeditor/node_scene_history.py +556 -0
- nodeeditor/node_serializable.py +43 -0
- nodeeditor/node_socket.py +445 -0
- nodeeditor/node_socket_shapes.py +261 -0
- nodeeditor/py.typed +0 -0
- nodeeditor/qss/nodestyle.qss +3 -0
- nodeeditor/utils.py +108 -0
- nodeeditor/utils_no_qt.py +21 -0
- nodeeditor_qtpy-0.8.1.dist-info/METADATA +165 -0
- nodeeditor_qtpy-0.8.1.dist-info/RECORD +40 -0
- nodeeditor_qtpy-0.8.1.dist-info/WHEEL +4 -0
- 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())
|