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,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()