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,369 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
A module containing Graphic representation of :class:`~nodeeditor.node_scene.Scene`
|
|
4
|
+
"""
|
|
5
|
+
from array import array
|
|
6
|
+
import math
|
|
7
|
+
from qtpy.QtWidgets import QGraphicsScene, QWidget
|
|
8
|
+
from qtpy.QtCore import Signal, QRectF, QLine, Qt, Property, QObject
|
|
9
|
+
from qtpy.QtGui import QColor, QPen, QFont, QPainter
|
|
10
|
+
from nodeeditor.utils import dumpException
|
|
11
|
+
from nodeeditor.node_graphics_view import STATE_STRING, DEBUG_STATE
|
|
12
|
+
|
|
13
|
+
from typing import TYPE_CHECKING, List, Mapping, Optional, Tuple, Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
#: How `QGraphicsScene` indexes its items.
|
|
17
|
+
#:
|
|
18
|
+
#: The class used to force ``NoIndex`` to dodge QTBUG-18021 (an item left
|
|
19
|
+
#: dangling after being removed while an event was being delivered, affecting
|
|
20
|
+
#: Qt 4.7 - 5.7). That bug was fixed years ago; ``NoIndex`` makes every paint
|
|
21
|
+
#: culling pass, hit test, rubber-band query and highlight scan walk the entire
|
|
22
|
+
#: item list, which is what made a few hundred nodes crawl. ``BspTreeIndex`` is
|
|
23
|
+
#: Qt's default and turns those into log-time queries.
|
|
24
|
+
DEFAULT_ITEM_INDEX_METHOD = QGraphicsScene.ItemIndexMethod.BspTreeIndex
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from nodeeditor.node_graphics_view import QDMGraphicsView
|
|
29
|
+
from nodeeditor.node_edge import Edge
|
|
30
|
+
from nodeeditor.node_socket import Socket
|
|
31
|
+
from nodeeditor.node_scene import Scene
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class QDMGraphicsScene(QGraphicsScene):
|
|
35
|
+
"""Class representing Graphic of :class:`~nodeeditor.node_scene.Scene`"""
|
|
36
|
+
#: pyqtSignal emitted when some item is selected in the `Scene`
|
|
37
|
+
itemSelected = Signal()
|
|
38
|
+
socketClicked = Signal(QObject, QObject) # (socket, node)
|
|
39
|
+
#: pyqtSignal emitted when items are deselected in the `Scene`
|
|
40
|
+
itemsDeselected = Signal()
|
|
41
|
+
|
|
42
|
+
gridColorLightChanged = Signal()
|
|
43
|
+
gridColorDarkChanged = Signal()
|
|
44
|
+
backgroundColorChanged = Signal()
|
|
45
|
+
|
|
46
|
+
#: Below this on-screen spacing (in pixels) a grid level is not drawn: its
|
|
47
|
+
#: lines are closer together than they are wide, so they read as a wash and
|
|
48
|
+
#: cost time proportional to the number of lines in view.
|
|
49
|
+
MIN_GRID_SPACING_PX = 4.0
|
|
50
|
+
|
|
51
|
+
#: Below this on-screen scale, items are asked to drop their fine detail
|
|
52
|
+
#: (see :meth:`updateDetailLod` / :meth:`setDetailLod`). At half size a
|
|
53
|
+
#: node's label text and its
|
|
54
|
+
#: status sprite are illegible, while the widget, sprite and socket draws
|
|
55
|
+
#: they cost dominate a frame once hundreds of nodes share the viewport.
|
|
56
|
+
DETAIL_LOD_THRESHOLD = 0.5
|
|
57
|
+
|
|
58
|
+
#: The parts of a node the level-of-detail switch can drop. An application
|
|
59
|
+
#: may register elements of its own (see :meth:`set_detail_policy`).
|
|
60
|
+
DETAIL_ELEMENTS = ("content", "label", "sockets")
|
|
61
|
+
|
|
62
|
+
#: How an element behaves when the canvas simplifies: always drawn, drawn
|
|
63
|
+
#: only while the zoom warrants it, or never drawn.
|
|
64
|
+
DETAIL_MODES = ("always", "auto", "never")
|
|
65
|
+
|
|
66
|
+
def __init__(self, scene: 'Scene', parent: QWidget = None) -> None:
|
|
67
|
+
"""
|
|
68
|
+
:param scene: reference to the :class:`~nodeeditor.node_scene.Scene`
|
|
69
|
+
:type scene: :class:`~nodeeditor.node_scene.Scene`
|
|
70
|
+
:param parent: parent widget
|
|
71
|
+
:type parent: QWidget
|
|
72
|
+
"""
|
|
73
|
+
super().__init__(parent)
|
|
74
|
+
|
|
75
|
+
self.scene: 'Scene' = scene
|
|
76
|
+
|
|
77
|
+
# Kept for the record: QTBUG-18021 ("reconnecting/removing edges during
|
|
78
|
+
# a mouse move leaves them in the scene") is why this used to be
|
|
79
|
+
# ``NoIndex``. The affected versions were 4.7.1 - 5.7.0; the linear
|
|
80
|
+
# index it forced is ruinous for a large graph, so the default BSP
|
|
81
|
+
# index is back (see DEFAULT_ITEM_INDEX_METHOD).
|
|
82
|
+
self.setItemIndexMethod(DEFAULT_ITEM_INDEX_METHOD)
|
|
83
|
+
|
|
84
|
+
# settings
|
|
85
|
+
self.gridSize = 20
|
|
86
|
+
self.gridSquares = 5
|
|
87
|
+
self._show_grid = True
|
|
88
|
+
|
|
89
|
+
#: Current level-of-detail state broadcast to items (see
|
|
90
|
+
#: :meth:`setDetailLod`). Starts fully detailed.
|
|
91
|
+
self._detail_visible = True
|
|
92
|
+
|
|
93
|
+
#: Zoom scale at which items are asked to drop their fine detail. Per
|
|
94
|
+
#: instance, so an application can move the point where the canvas
|
|
95
|
+
#: simplifies without touching the class default.
|
|
96
|
+
self.detail_lod_threshold = float(self.DETAIL_LOD_THRESHOLD)
|
|
97
|
+
|
|
98
|
+
#: Per-element detail modes, seeded with the library's own elements.
|
|
99
|
+
self.detail_policy: dict[str, str] = {
|
|
100
|
+
name: "auto" for name in self.DETAIL_ELEMENTS
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
#: Resolved per-element state, recomputed on every flip.
|
|
104
|
+
self._detail_state: dict[str, bool] = {}
|
|
105
|
+
self._resolve_detail_state()
|
|
106
|
+
|
|
107
|
+
self._color_background = QColor("#14171a")
|
|
108
|
+
self._color_light = QColor("#1d2225")
|
|
109
|
+
self._color_dark = QColor("#1d2225")
|
|
110
|
+
self._color_state = QColor("#ccc")
|
|
111
|
+
|
|
112
|
+
self.initAssets()
|
|
113
|
+
self.setBackgroundBrush(self._color_background)
|
|
114
|
+
|
|
115
|
+
def getGridColorLight(self) -> QColor:
|
|
116
|
+
"""Get the light grid color"""
|
|
117
|
+
print("🐍 File: nodeeditor/node_graphics_scene.py:71 | __init__ ~ getGridColorLight")
|
|
118
|
+
return self._color_light
|
|
119
|
+
|
|
120
|
+
def setGridColorLight(self, color: QColor) -> None:
|
|
121
|
+
"""Set the light grid color"""
|
|
122
|
+
print("🐍 File: nodeeditor/node_graphics_scene.py:76 | getGridColorLight ~ setGridColorLight", color)
|
|
123
|
+
self._color_light = color
|
|
124
|
+
self._pen_light.setColor(color)
|
|
125
|
+
self.update()
|
|
126
|
+
|
|
127
|
+
def getGridColorDark(self) -> QColor:
|
|
128
|
+
"""Get the dark grid color"""
|
|
129
|
+
return self._color_dark
|
|
130
|
+
|
|
131
|
+
def setGridColorDark(self, color: QColor) -> None:
|
|
132
|
+
"""Set the dark grid color"""
|
|
133
|
+
self._color_dark = color
|
|
134
|
+
self._pen_dark.setColor(color)
|
|
135
|
+
self.update()
|
|
136
|
+
|
|
137
|
+
def getBackgroundColor(self) -> QColor:
|
|
138
|
+
"""Get the background color"""
|
|
139
|
+
return self._color_background
|
|
140
|
+
|
|
141
|
+
def setBackgroundColor(self, color: QColor) -> None:
|
|
142
|
+
"""Set the background color"""
|
|
143
|
+
self._color_background = color
|
|
144
|
+
self.setBackgroundBrush(color)
|
|
145
|
+
|
|
146
|
+
gridColorLight = Property(
|
|
147
|
+
QColor, fget=getGridColorLight, fset=setGridColorLight, notify=gridColorLightChanged)
|
|
148
|
+
gridColorDark = Property(
|
|
149
|
+
QColor, fget=getGridColorDark, fset=setGridColorDark, notify=gridColorDarkChanged)
|
|
150
|
+
backgroundColor = Property(
|
|
151
|
+
QColor, fget=getBackgroundColor, fset=setBackgroundColor, notify=backgroundColorChanged)
|
|
152
|
+
|
|
153
|
+
def initAssets(self) -> None:
|
|
154
|
+
"""Initialize ``QObjects`` like ``QColor``, ``QPen`` and ``QBrush``"""
|
|
155
|
+
|
|
156
|
+
# Initialize pens and fonts
|
|
157
|
+
self._pen_light = QPen(self._color_light)
|
|
158
|
+
self._pen_light.setWidth(1)
|
|
159
|
+
self._pen_dark = QPen(self._color_dark)
|
|
160
|
+
self._pen_dark.setWidth(2)
|
|
161
|
+
|
|
162
|
+
self._pen_state = QPen(self._color_state)
|
|
163
|
+
self._font_state = QFont("Ubuntu", 16)
|
|
164
|
+
|
|
165
|
+
# the drag events won't be allowed until dragMoveEvent is overwritten
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def showGrid(self) -> bool:
|
|
169
|
+
"""
|
|
170
|
+
Get grid visibility state
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
bool: True if grid is visible, False otherwise
|
|
174
|
+
"""
|
|
175
|
+
return self._show_grid
|
|
176
|
+
|
|
177
|
+
@showGrid.setter
|
|
178
|
+
def showGrid(self, value: bool) -> None:
|
|
179
|
+
"""
|
|
180
|
+
Set grid visibility state and update the scene
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
value: True to show grid, False to hide
|
|
184
|
+
"""
|
|
185
|
+
self._show_grid = value
|
|
186
|
+
self.update() # Refresh the scene when grid visibility changes
|
|
187
|
+
|
|
188
|
+
def dragMoveEvent(self, event) -> None:
|
|
189
|
+
"""Overriden Qt's dragMoveEvent to enable Qt's Drag Events"""
|
|
190
|
+
pass
|
|
191
|
+
|
|
192
|
+
def setGrScene(self, width: int, height: int) -> None:
|
|
193
|
+
"""Set `width` and `height` of the `Graphics Scene`"""
|
|
194
|
+
self.setSceneRect(-width // 2, -height // 2, width, height)
|
|
195
|
+
|
|
196
|
+
def updateDetailLod(self, scale: float) -> bool:
|
|
197
|
+
"""Match the items' detail to `scale`; ``True`` when the state flipped.
|
|
198
|
+
|
|
199
|
+
Only a float comparison per frame; the broadcast to items runs once per
|
|
200
|
+
flip. Callers must not be inside a paint pass - see
|
|
201
|
+
:meth:`setDetailLod`.
|
|
202
|
+
"""
|
|
203
|
+
visible = scale >= self.detail_lod_threshold
|
|
204
|
+
if visible == self._detail_visible:
|
|
205
|
+
return False
|
|
206
|
+
self.setDetailLod(visible)
|
|
207
|
+
return True
|
|
208
|
+
|
|
209
|
+
def setDetailLod(self, visible: bool) -> None:
|
|
210
|
+
"""Tell every item that cares whether the current zoom warrants detail.
|
|
211
|
+
|
|
212
|
+
Never call this while the scene is being painted. Hiding an item during
|
|
213
|
+
a paint pass leaves the pixels it had already claimed on screen, and
|
|
214
|
+
those ghosts then travel with the next scroll - which is why the view
|
|
215
|
+
decides this before it paints rather than from ``drawBackground``.
|
|
216
|
+
"""
|
|
217
|
+
if visible == self._detail_visible:
|
|
218
|
+
return
|
|
219
|
+
self._detail_visible = visible
|
|
220
|
+
self._resolve_detail_state()
|
|
221
|
+
self._broadcast_detail_state()
|
|
222
|
+
|
|
223
|
+
def set_detail_policy(self, policy: Mapping[str, str]) -> None:
|
|
224
|
+
"""Say what each element may do when the canvas simplifies.
|
|
225
|
+
|
|
226
|
+
``policy`` maps an element key to ``always``, ``auto`` or ``never``.
|
|
227
|
+
``auto`` follows the zoom threshold, ``always`` keeps the element at
|
|
228
|
+
every zoom and ``never`` drops it at every zoom. Keys the library does
|
|
229
|
+
not know are kept, so an application can register an element of its own
|
|
230
|
+
and read the answer back with :meth:`detail_enabled`.
|
|
231
|
+
"""
|
|
232
|
+
cleaned: dict[str, str] = {}
|
|
233
|
+
for key, mode in dict(policy).items():
|
|
234
|
+
mode = str(mode).strip().lower()
|
|
235
|
+
if mode not in self.DETAIL_MODES:
|
|
236
|
+
print(
|
|
237
|
+
f"nodeeditor: unknown detail mode {mode!r} for {key!r}, ignored"
|
|
238
|
+
)
|
|
239
|
+
continue
|
|
240
|
+
cleaned[str(key)] = mode
|
|
241
|
+
merged = dict(self.detail_policy)
|
|
242
|
+
merged.update(cleaned)
|
|
243
|
+
if merged == self.detail_policy:
|
|
244
|
+
return
|
|
245
|
+
self.detail_policy = merged
|
|
246
|
+
self._resolve_detail_state()
|
|
247
|
+
self._broadcast_detail_state()
|
|
248
|
+
|
|
249
|
+
def detail_enabled(self, element: str) -> bool:
|
|
250
|
+
"""Whether ``element`` should be drawn at the current zoom."""
|
|
251
|
+
return self._detail_state.get(element, self._detail_visible)
|
|
252
|
+
|
|
253
|
+
def detail_state(self) -> dict[str, bool]:
|
|
254
|
+
"""The resolved per-element state, plus the zoom state under "detail"."""
|
|
255
|
+
state = dict(self._detail_state)
|
|
256
|
+
state["detail"] = self._detail_visible
|
|
257
|
+
return state
|
|
258
|
+
|
|
259
|
+
def _resolve_detail_state(self) -> None:
|
|
260
|
+
"""Turn the policy and the zoom state into one on/off map."""
|
|
261
|
+
state: dict[str, bool] = {}
|
|
262
|
+
for key, mode in self.detail_policy.items():
|
|
263
|
+
if mode == "always":
|
|
264
|
+
state[key] = True
|
|
265
|
+
elif mode == "never":
|
|
266
|
+
state[key] = False
|
|
267
|
+
else:
|
|
268
|
+
state[key] = self._detail_visible
|
|
269
|
+
self._detail_state = state
|
|
270
|
+
|
|
271
|
+
def _broadcast_detail_state(self) -> None:
|
|
272
|
+
"""Hand the resolved state to every item that cares about it."""
|
|
273
|
+
state = self.detail_state()
|
|
274
|
+
for item in self.items():
|
|
275
|
+
setter = getattr(item, "setDetailState", None)
|
|
276
|
+
if setter is not None:
|
|
277
|
+
try:
|
|
278
|
+
setter(dict(state))
|
|
279
|
+
except RuntimeError:
|
|
280
|
+
pass # C++ side already gone
|
|
281
|
+
continue
|
|
282
|
+
setter = getattr(item, "setDetailVisible", None)
|
|
283
|
+
if setter is not None:
|
|
284
|
+
try:
|
|
285
|
+
setter(self._detail_visible)
|
|
286
|
+
except RuntimeError:
|
|
287
|
+
pass # C++ side already gone
|
|
288
|
+
|
|
289
|
+
def drawBackground(self, painter: Optional[QPainter], rect: QRectF) -> None:
|
|
290
|
+
"""Draw background scene grid"""
|
|
291
|
+
super().drawBackground(painter, rect)
|
|
292
|
+
|
|
293
|
+
if painter is None:
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
if self.showGrid is False:
|
|
297
|
+
return
|
|
298
|
+
|
|
299
|
+
scale = abs(painter.transform().m11())
|
|
300
|
+
|
|
301
|
+
# Zoom level decides which grid levels are worth drawing at all. When a
|
|
302
|
+
# level's spacing collapses below MIN_GRID_SPACING_PX its lines land on
|
|
303
|
+
# top of each other, so they cost a per-line Python loop and a huge
|
|
304
|
+
# drawLines batch to produce a uniform wash. Zoomed out over a big
|
|
305
|
+
# graph that is most of a frame, so drop to the major lines and, past
|
|
306
|
+
# that, drop the grid entirely.
|
|
307
|
+
major_step = self.gridSize * self.gridSquares
|
|
308
|
+
draw_minor = self.gridSize * scale >= self.MIN_GRID_SPACING_PX
|
|
309
|
+
if major_step * scale < self.MIN_GRID_SPACING_PX:
|
|
310
|
+
return
|
|
311
|
+
|
|
312
|
+
# here we create our grid
|
|
313
|
+
left = int(math.floor(rect.left()))
|
|
314
|
+
right = int(math.ceil(rect.right()))
|
|
315
|
+
top = int(math.floor(rect.top()))
|
|
316
|
+
bottom = int(math.ceil(rect.bottom()))
|
|
317
|
+
|
|
318
|
+
# Only walk the minor step when the minor lines are actually drawn.
|
|
319
|
+
step = self.gridSize if draw_minor else major_step
|
|
320
|
+
first_left = left - (left % step)
|
|
321
|
+
first_top = top - (top % step)
|
|
322
|
+
|
|
323
|
+
# compute all lines to be drawn
|
|
324
|
+
lines_light: List[QLine] = []
|
|
325
|
+
lines_dark: List[QLine] = []
|
|
326
|
+
|
|
327
|
+
for x in range(first_left, right, step):
|
|
328
|
+
if (x % major_step != 0):
|
|
329
|
+
lines_light.append(QLine(x, top, x, bottom))
|
|
330
|
+
else:
|
|
331
|
+
lines_dark.append(QLine(x, top, x, bottom))
|
|
332
|
+
|
|
333
|
+
for y in range(first_top, bottom, step):
|
|
334
|
+
if (y % major_step != 0):
|
|
335
|
+
lines_light.append(QLine(left, y, right, y))
|
|
336
|
+
else:
|
|
337
|
+
lines_dark.append(QLine(left, y, right, y))
|
|
338
|
+
|
|
339
|
+
# The grid is axis aligned, so antialiasing cannot smooth anything; it
|
|
340
|
+
# only blends every line into its neighbours and costs time.
|
|
341
|
+
painter.save()
|
|
342
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing, False)
|
|
343
|
+
try:
|
|
344
|
+
if lines_light:
|
|
345
|
+
painter.setPen(self._pen_light)
|
|
346
|
+
painter.drawLines(lines_light)
|
|
347
|
+
if lines_dark:
|
|
348
|
+
painter.setPen(self._pen_dark)
|
|
349
|
+
painter.drawLines(lines_dark)
|
|
350
|
+
finally:
|
|
351
|
+
painter.restore()
|
|
352
|
+
|
|
353
|
+
if DEBUG_STATE:
|
|
354
|
+
try:
|
|
355
|
+
view = self.views()[0]
|
|
356
|
+
if not isinstance(view, QDMGraphicsView):
|
|
357
|
+
return
|
|
358
|
+
painter.setFont(self._font_state)
|
|
359
|
+
painter.setPen(self._pen_state)
|
|
360
|
+
painter.setRenderHint(QPainter.RenderHint.TextAntialiasing)
|
|
361
|
+
offset = 14
|
|
362
|
+
rect_state = QRectF(rect.x()+offset,
|
|
363
|
+
rect.y()+offset,
|
|
364
|
+
rect.width()-2*offset,
|
|
365
|
+
rect.height()-2*offset)
|
|
366
|
+
painter.drawText(rect_state, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop,
|
|
367
|
+
STATE_STRING[view.mode].upper())
|
|
368
|
+
except:
|
|
369
|
+
dumpException()
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
A module containing Graphics representation of a :class:`~nodeeditor.node_socket.Socket`
|
|
4
|
+
"""
|
|
5
|
+
from qtpy.QtWidgets import QGraphicsItem
|
|
6
|
+
from qtpy.QtGui import QColor, QBrush, QPen, QFont, QPainterPath
|
|
7
|
+
from qtpy.QtCore import Qt, QRectF, QPointF
|
|
8
|
+
|
|
9
|
+
from nodeeditor.node_colors_config import get_color_scheme
|
|
10
|
+
from nodeeditor.node_socket_shapes import (
|
|
11
|
+
DIRECTION_INPUT,
|
|
12
|
+
DIRECTION_OUTPUT,
|
|
13
|
+
SocketShape,
|
|
14
|
+
SocketShapeContext,
|
|
15
|
+
get_socket_shape,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from typing import TYPE_CHECKING, List, Optional, Tuple, Any
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from nodeeditor.node_graphics_view import QDMGraphicsView
|
|
23
|
+
from nodeeditor.node_edge import Edge
|
|
24
|
+
from nodeeditor.node_socket import Socket
|
|
25
|
+
|
|
26
|
+
# Deprecated: Use NodeEditorColorScheme.sockets.get_type_color() instead
|
|
27
|
+
SOCKET_COLORS = [
|
|
28
|
+
QColor("#FFFF7700"),
|
|
29
|
+
QColor("#FF52e220"),
|
|
30
|
+
QColor("#FF0056a6"),
|
|
31
|
+
QColor("#FFa86db1"),
|
|
32
|
+
QColor("#FFb54747"),
|
|
33
|
+
QColor("#FFdbe220"),
|
|
34
|
+
QColor("#FF888888"),
|
|
35
|
+
QColor("#FFFF7700"),
|
|
36
|
+
QColor("#FF52e220"),
|
|
37
|
+
QColor("#FF0056a6"),
|
|
38
|
+
QColor("#FFa86db1"),
|
|
39
|
+
QColor("#FFb54747"),
|
|
40
|
+
QColor("#FFdbe220"),
|
|
41
|
+
QColor("#FF888888"),
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class QDMGraphicsSocket(QGraphicsItem):
|
|
46
|
+
"""Class representing Graphic `Socket` in ``QGraphicsScene``"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, socket: 'Socket') -> None:
|
|
49
|
+
"""
|
|
50
|
+
:param socket: reference to :class:`~nodeeditor.node_socket.Socket`
|
|
51
|
+
:type socket: :class:`~nodeeditor.node_socket.Socket`
|
|
52
|
+
"""
|
|
53
|
+
super().__init__(socket.node.grNode)
|
|
54
|
+
|
|
55
|
+
self.socket = socket
|
|
56
|
+
|
|
57
|
+
self.isHighlighted = False
|
|
58
|
+
|
|
59
|
+
#: ``True`` while the mouse hovers this socket
|
|
60
|
+
self.hovered = False
|
|
61
|
+
|
|
62
|
+
self.radius = 6
|
|
63
|
+
self._text = ""
|
|
64
|
+
self.outline_width = 1
|
|
65
|
+
self._font: Optional[QFont] = None # Will be initialized in initAssets
|
|
66
|
+
|
|
67
|
+
#: the shape this socket is drawn (and hit-tested) with
|
|
68
|
+
self._shape: SocketShape = get_socket_shape(
|
|
69
|
+
getattr(socket, "shape", None))
|
|
70
|
+
|
|
71
|
+
self.initAssets()
|
|
72
|
+
|
|
73
|
+
# sockets take part in the hover highlight
|
|
74
|
+
self.setAcceptHoverEvents(True)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def socket_type(self):
|
|
78
|
+
"""The socket's type, used to look up its color."""
|
|
79
|
+
return self.socket.socket_type
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def direction(self) -> int:
|
|
83
|
+
"""``+1`` on an output socket, ``-1`` on an input socket.
|
|
84
|
+
|
|
85
|
+
Shapes that must not flip (the chevron) simply ignore it.
|
|
86
|
+
"""
|
|
87
|
+
return DIRECTION_INPUT if self.socket.is_input else DIRECTION_OUTPUT
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def shape(self) -> SocketShape:
|
|
91
|
+
"""The :class:`~nodeeditor.node_socket_shapes.SocketShape` in use"""
|
|
92
|
+
return self._shape
|
|
93
|
+
|
|
94
|
+
def getSocketColor(self, key):
|
|
95
|
+
"""Returns the ``QColor`` for this ``key``"""
|
|
96
|
+
scheme = get_color_scheme()
|
|
97
|
+
return scheme.sockets.get_type_color(key)
|
|
98
|
+
|
|
99
|
+
def changeSocketType(self) -> None:
|
|
100
|
+
"""Change the Socket Type"""
|
|
101
|
+
self._color_background = self.getSocketColor(self.socket_type)
|
|
102
|
+
self._brush = QBrush(self._color_background)
|
|
103
|
+
# print("Socket changed to:", self._color_background.getRgbF())
|
|
104
|
+
self.update()
|
|
105
|
+
|
|
106
|
+
def setShape(self, name: str) -> None:
|
|
107
|
+
"""Draw this socket with the shape registered under ``name``.
|
|
108
|
+
|
|
109
|
+
:param name: the registered shape name
|
|
110
|
+
"""
|
|
111
|
+
shape = get_socket_shape(name)
|
|
112
|
+
if shape is self._shape:
|
|
113
|
+
return
|
|
114
|
+
self.prepareGeometryChange()
|
|
115
|
+
self._shape = shape
|
|
116
|
+
self.update()
|
|
117
|
+
|
|
118
|
+
def initAssets(self) -> None:
|
|
119
|
+
"""Initialize ``QObjects`` like ``QColor``, ``QPen`` and ``QBrush``"""
|
|
120
|
+
|
|
121
|
+
# Get colors from global color scheme
|
|
122
|
+
scheme = get_color_scheme()
|
|
123
|
+
|
|
124
|
+
# determine socket color
|
|
125
|
+
self._color_background = self.getSocketColor(self.socket_type)
|
|
126
|
+
self._color_outline = scheme.sockets.outline
|
|
127
|
+
self._color_highlight = scheme.sockets.highlight
|
|
128
|
+
|
|
129
|
+
self._pen = QPen(self._color_outline)
|
|
130
|
+
self._pen.setWidthF(self.outline_width)
|
|
131
|
+
self._pen_highlight = QPen(self._color_highlight)
|
|
132
|
+
self._pen_highlight.setWidthF(2.0)
|
|
133
|
+
self._brush = QBrush(self._color_background)
|
|
134
|
+
|
|
135
|
+
# Initialize font and text pen
|
|
136
|
+
self._font = QFont("Ubuntu", 7)
|
|
137
|
+
self._font.setBold(True)
|
|
138
|
+
self._pen_text = QPen(Qt.GlobalColor.black)
|
|
139
|
+
|
|
140
|
+
def setText(self, text: str) -> None:
|
|
141
|
+
"""Set the text to display inside socket"""
|
|
142
|
+
self._text = text
|
|
143
|
+
self.update()
|
|
144
|
+
|
|
145
|
+
def setDetailVisible(self, visible: bool) -> None:
|
|
146
|
+
"""Show or hide this socket's circle at low zoom.
|
|
147
|
+
|
|
148
|
+
Pushed by the owning node when the scene drops to its level-of-detail
|
|
149
|
+
state. A dozen sockets per node at normal zoom costs more per frame than
|
|
150
|
+
the node itself; at that scale the circles are a couple of pixels wide
|
|
151
|
+
and cannot be aimed at, so wiring is a zoomed-in activity.
|
|
152
|
+
"""
|
|
153
|
+
self.setVisible(bool(visible))
|
|
154
|
+
|
|
155
|
+
def paint(self, painter, QStyleOptionGraphicsItem, widget=None) -> None:
|
|
156
|
+
"""Painting the socket shape and its label"""
|
|
157
|
+
ctx = SocketShapeContext(
|
|
158
|
+
painter=painter,
|
|
159
|
+
radius=self.radius,
|
|
160
|
+
center=QPointF(0, 0),
|
|
161
|
+
brush=self._brush,
|
|
162
|
+
# the hover highlight and the edge-snapping highlight share the
|
|
163
|
+
# same pen, they only differ in what turns it on
|
|
164
|
+
pen=self._pen_highlight if (self.hovered or self.isHighlighted)
|
|
165
|
+
else self._pen,
|
|
166
|
+
color=self._color_background,
|
|
167
|
+
socket=self.socket,
|
|
168
|
+
hovered=self.hovered,
|
|
169
|
+
direction=self.direction,
|
|
170
|
+
)
|
|
171
|
+
self._shape.draw(painter, ctx)
|
|
172
|
+
|
|
173
|
+
# Draw text if present
|
|
174
|
+
if self._text:
|
|
175
|
+
painter.save()
|
|
176
|
+
try:
|
|
177
|
+
# the label never spills outside the socket's own outline
|
|
178
|
+
painter.setClipPath(
|
|
179
|
+
self._shape.path(self.radius, self.direction))
|
|
180
|
+
painter.setFont(self._font)
|
|
181
|
+
painter.setPen(self._pen_text)
|
|
182
|
+
|
|
183
|
+
# Create text rect slightly smaller than socket for padding
|
|
184
|
+
painter.drawText(
|
|
185
|
+
self._textRect(),
|
|
186
|
+
Qt.AlignmentFlag.AlignCenter | Qt.AlignmentFlag.AlignVCenter,
|
|
187
|
+
self._text
|
|
188
|
+
)
|
|
189
|
+
finally:
|
|
190
|
+
painter.restore()
|
|
191
|
+
|
|
192
|
+
def _textRect(self) -> QRectF:
|
|
193
|
+
"""The rectangle the socket label is drawn in"""
|
|
194
|
+
return QRectF(
|
|
195
|
+
-self.radius + 1, # Add 1px padding
|
|
196
|
+
-self.radius + 1, # Add 1px padding
|
|
197
|
+
2 * (self.radius - 1), # Remove 2px for padding
|
|
198
|
+
2 * (self.radius - 1) # Remove 2px for padding
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def _penMargin(self) -> float:
|
|
202
|
+
"""Half the widest pen this socket can paint with, plus an antialias pixel.
|
|
203
|
+
|
|
204
|
+
A stroke reaches half its width past the path on both sides, so the
|
|
205
|
+
bounding rectangle has to grow with it or the outer band of the
|
|
206
|
+
previous paint stays on screen.
|
|
207
|
+
"""
|
|
208
|
+
widest = 0.0
|
|
209
|
+
for pen in (self._pen, self._pen_highlight):
|
|
210
|
+
if pen is not None:
|
|
211
|
+
widest = max(widest, pen.widthF())
|
|
212
|
+
return widest / 2.0 + 1.0
|
|
213
|
+
|
|
214
|
+
def shape(self) -> QPainterPath:
|
|
215
|
+
"""The outline used for hit testing.
|
|
216
|
+
|
|
217
|
+
What is drawn is what can be clicked: a click on a corner of a square
|
|
218
|
+
socket misses it, exactly like a click beside a circle does.
|
|
219
|
+
"""
|
|
220
|
+
return self._shape.path(self.radius, self.direction)
|
|
221
|
+
|
|
222
|
+
def boundingRect(self) -> QRectF:
|
|
223
|
+
"""Defining Qt' bounding rectangle"""
|
|
224
|
+
margin = self._penMargin()
|
|
225
|
+
rect = self.shape().boundingRect()
|
|
226
|
+
if self._text:
|
|
227
|
+
rect = rect.united(self._textRect())
|
|
228
|
+
return rect.adjusted(-margin, -margin, margin, margin)
|
|
229
|
+
|
|
230
|
+
def hoverEnterEvent(self, event) -> None:
|
|
231
|
+
"""Handle hover effect"""
|
|
232
|
+
self.hovered = True
|
|
233
|
+
if event is not None:
|
|
234
|
+
# keep the event here, the parent node must not light up as well
|
|
235
|
+
event.accept()
|
|
236
|
+
self.update()
|
|
237
|
+
self._updateParent()
|
|
238
|
+
|
|
239
|
+
def hoverLeaveEvent(self, event) -> None:
|
|
240
|
+
"""Handle hover effect"""
|
|
241
|
+
self.hovered = False
|
|
242
|
+
if event is not None:
|
|
243
|
+
event.accept()
|
|
244
|
+
self.update()
|
|
245
|
+
self._updateParent()
|
|
246
|
+
|
|
247
|
+
def _updateParent(self) -> None:
|
|
248
|
+
"""Repaint the node, its hover outline depends on this socket's state.
|
|
249
|
+
|
|
250
|
+
A socket sits inside the node's own hit area, so Qt reports the node
|
|
251
|
+
as hovered while the mouse is over the socket. The node then holds
|
|
252
|
+
back its hover outline, which means it has to be repainted when the
|
|
253
|
+
socket's hover state changes.
|
|
254
|
+
"""
|
|
255
|
+
parent = self.parentItem()
|
|
256
|
+
if parent is not None:
|
|
257
|
+
parent.update()
|
|
258
|
+
|
|
259
|
+
# def onSelected(self) -> None:
|
|
260
|
+
# """Handle selection event"""
|
|
261
|
+
# self.isHighlighted = True
|
|
262
|
+
# self.update()
|