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,445 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ A module containing NodeEditor's class for representing Socket and Socket Position Constants.
4
+ """
5
+ from collections import OrderedDict
6
+ from qtpy.QtCore import QObject
7
+
8
+ from nodeeditor.node_serializable import Serializable
9
+ from nodeeditor.node_graphics_socket import QDMGraphicsSocket
10
+
11
+
12
+ from typing import TYPE_CHECKING, List, Optional, Tuple, Any, Callable, TypedDict
13
+
14
+
15
+ if TYPE_CHECKING:
16
+ from nodeeditor.node_graphics_view import QDMGraphicsView
17
+ from nodeeditor.node_scene import Scene
18
+ from nodeeditor.node_edge import Edge
19
+ from nodeeditor.node_node import Node
20
+
21
+
22
+ LEFT_TOP = 1 #:
23
+ LEFT_CENTER = 2 #:
24
+ LEFT_BOTTOM = 3 #:
25
+ RIGHT_TOP = 4 #:
26
+ RIGHT_CENTER = 5 #:
27
+ RIGHT_BOTTOM = 6 #:
28
+
29
+
30
+ DEBUG = False
31
+ DEBUG_REMOVE_WARNINGS = False
32
+
33
+
34
+ class Socket(QObject, Serializable):
35
+ """Class representing Socket."""
36
+
37
+ Socket_GR_Class = QDMGraphicsSocket
38
+
39
+ """Class representing Socket."""
40
+
41
+ def __init__(self, node: 'Node', index: int = 0, position: int = LEFT_TOP, socket_type: int = 1, multi_edges: bool = True,
42
+ count_on_this_node_side: int = 1, is_input: bool = False) -> None:
43
+ """
44
+ :param node: reference to the :class:`~nodeeditor.node_node.Node` containing this `Socket`
45
+ :type node: :class:`~nodeeditor.node_node.Node`
46
+ :param index: Current index of this socket in the position
47
+ :type index: ``int``
48
+ :param position: Socket position. See :ref:`socket-position-constants`
49
+ :param socket_type: Constant defining type(color) of this socket
50
+ :param multi_edges: Can this socket have multiple `Edges` connected?
51
+ :type multi_edges: ``bool``
52
+ :param count_on_this_node_side: number of total sockets on this position
53
+ :type count_on_this_node_side: ``int``
54
+ :param is_input: Is this an input `Socket`?
55
+ :type is_input: ``bool``
56
+
57
+ :Instance Attributes:
58
+
59
+ - **node** - reference to the :class:`~nodeeditor.node_node.Node` containing this `Socket`
60
+ - **edges** - list of `Edges` connected to this `Socket`
61
+ - **grSocket** - reference to the :class:`~nodeeditor.node_graphics_socket.QDMGraphicsSocket`
62
+ - **position** - Socket position. See :ref:`socket-position-constants`
63
+ - **index** - Current index of this socket in the position
64
+ - **socket_type** - Constant defining type(color) of this socket
65
+ - **count_on_this_node_side** - number of sockets on this position
66
+ - **is_multi_edges** - ``True`` if `Socket` can contain multiple `Edges`
67
+ - **is_input** - ``True`` if this socket serves for Input
68
+ - **is_output** - ``True`` if this socket serves for Output
69
+ """
70
+ super().__init__() # Initialize QObject
71
+ super(Serializable).__init__() # Initialize Serializable
72
+
73
+ self.node = node
74
+ self.position = position
75
+ self.index = index
76
+ self.socket_type = socket_type
77
+ self.count_on_this_node_side = count_on_this_node_side
78
+ self.is_multi_edges = multi_edges
79
+ self.is_input = is_input
80
+ self.is_output = not self.is_input
81
+
82
+ #: name of the shape this socket is drawn with. Set by the owning node
83
+ #: (see :meth:`~nodeeditor.node_node.Node.getSocketShapeName`), so it
84
+ #: is code, never graph file data.
85
+ self.shape = self._resolveShapeName(socket_type, is_input)
86
+
87
+ if DEBUG:
88
+ print("Socket -- creating with", self.index,
89
+ self.position, "for nodeeditor", self.node)
90
+
91
+ self.grSocket = self.__class__.Socket_GR_Class(self)
92
+
93
+ self.setSocketPosition()
94
+
95
+ self.edges: List['Edge'] = []
96
+
97
+ def __str__(self) -> str:
98
+ """Return a short identifying string of this `Socket`."""
99
+ return "<Socket #%d %s %s..%s>" % (
100
+ self.index, "ME" if self.is_multi_edges else "SE", hex(id(self))[
101
+ 2:5], hex(id(self))[-3:]
102
+ )
103
+
104
+ def _resolveShapeName(self, socket_type, is_input: bool) -> str:
105
+ """Ask the owning node which shape a socket of this type should use.
106
+
107
+ :param socket_type: the socket's type
108
+ :param is_input: ``True`` for an input socket
109
+ :return: the registered shape name
110
+ :rtype: ``str``
111
+ """
112
+ getter = getattr(self.node, "getSocketShapeName", None)
113
+ if getter is None:
114
+ return "circle"
115
+ try:
116
+ return getter(socket_type, is_input) or "circle"
117
+ except Exception:
118
+ return "circle"
119
+
120
+ def delete(self) -> None:
121
+ """Delete this `Socket` from graphics scene for sure"""
122
+ self.grSocket.setParentItem(None)
123
+ self.node.scene.grScene.removeItem(self.grSocket)
124
+ del self.grSocket
125
+
126
+ def changeSocketType(self, new_socket_type: int) -> bool:
127
+ """
128
+ Change the Socket Type
129
+
130
+ :param new_socket_type: new socket type
131
+ :type new_socket_type: ``int``
132
+ :return: Returns ``True`` if the socket type was actually changed
133
+ :rtype: ``bool``
134
+ """
135
+ if self.socket_type != new_socket_type:
136
+ self.socket_type = new_socket_type
137
+ self.grSocket.changeSocketType()
138
+ # the shape follows the socket type
139
+ self.shape = self._resolveShapeName(
140
+ new_socket_type, self.is_input)
141
+ self.grSocket.setShape(self.shape)
142
+ return True
143
+ return False
144
+
145
+ def setSocketPosition(self) -> None:
146
+ """Helper function to set `Graphics Socket` position. Exact socket position is calculated
147
+ inside :class:`~nodeeditor.node_node.Node`."""
148
+ self.grSocket.setPos(*self.node.getSocketPosition(self.index,
149
+ self.position, self.count_on_this_node_side))
150
+
151
+ def getSocketPosition(self):
152
+ """
153
+ :return: Returns this `Socket` position according to the implementation stored in
154
+ :class:`~nodeeditor.node_node.Node`
155
+ :rtype: ``x, y`` position
156
+ """
157
+ if DEBUG:
158
+ print(" GSP: ", self.index, self.position, "nodeeditor:", self.node)
159
+ res = self.node.getSocketPosition(
160
+ self.index, self.position, self.count_on_this_node_side)
161
+ if DEBUG:
162
+ print(" res", res)
163
+ return res
164
+
165
+ def hasAnyEdge(self) -> bool:
166
+ """
167
+ Returns ``True`` if any :class:`~nodeeditor.node_edge.Edge` is connected to this socket
168
+
169
+ :return: ``True`` if any :class:`~nodeeditor.node_edge.Edge` is connected to this socket
170
+ :rtype: ``bool``
171
+ """
172
+ return len(self.edges) > 0
173
+
174
+ def isConnected(self, edge: 'Edge') -> bool:
175
+ """
176
+ Returns ``True`` if :class:`~nodeeditor.node_edge.Edge` is connected to this `Socket`
177
+
178
+ :param edge: :class:`~nodeeditor.node_edge.Edge` to check if it is connected to this `Socket`
179
+ :type edge: :class:`~nodeeditor.node_edge.Edge`
180
+ :return: ``True`` if `Edge` is connected to this socket
181
+ :rtype: ``bool``
182
+ """
183
+ return edge in self.edges
184
+
185
+ def addEdge(self, edge: 'Edge', index: Optional[int] = None) -> None:
186
+ """
187
+ Append an Edge to the list of connected Edges
188
+
189
+ On an **input** socket the `Edge` also gets a *read position* (see
190
+ :attr:`~nodeeditor.node_edge.Edge.input_index`). Pass ``index`` to
191
+ place it at a specific slot; when ``index`` is ``None`` a value
192
+ already set on the `Edge` (``Edge(..., input_index=n)`` or
193
+ deserialization) is used, and otherwise the `Edge` is appended, i.e.
194
+ it is read last. Output sockets are not ordered and ignore ``index``.
195
+
196
+ :param edge: :class:`~nodeeditor.node_edge.Edge` to connect to this `Socket`
197
+ :type edge: :class:`~nodeeditor.node_edge.Edge`
198
+ :param index: 0-based read position on an input socket or ``None`` to
199
+ append at the end (default: order of connection)
200
+ :type index: ``int`` or ``None``
201
+ """
202
+ if edge in self.edges:
203
+ return
204
+
205
+ if not self.is_input:
206
+ # output sockets keep plain connection order
207
+ self.edges.append(edge)
208
+ return
209
+
210
+ if index is None:
211
+ index = getattr(edge, "input_index", -1)
212
+
213
+ try:
214
+ index = int(index)
215
+ except (TypeError, ValueError):
216
+ index = -1
217
+
218
+ if 0 <= index <= len(self.edges):
219
+ self.edges.insert(index, edge)
220
+ else:
221
+ self.edges.append(edge)
222
+ self._reindexEdges()
223
+
224
+ def removeEdge(self, edge: 'Edge') -> None:
225
+ """
226
+ Disconnect passed :class:`~nodeeditor.node_edge.Edge` from this `Socket`
227
+ :param edge: :class:`~nodeeditor.node_edge.Edge` to disconnect
228
+ :type edge: :class:`~nodeeditor.node_edge.Edge`
229
+ """
230
+ if edge in self.edges:
231
+ self.edges.remove(edge)
232
+ if self.is_input:
233
+ # close the gap the removed edge left behind
234
+ self._reindexEdges()
235
+ else:
236
+ if DEBUG_REMOVE_WARNINGS:
237
+ print("!W:", "Socket::removeEdge", "wanna remove edge", edge,
238
+ "from self.edges but it's not in the list!")
239
+
240
+ def removeAllEdges(self, silent: bool = False) -> None:
241
+ """Disconnect all `Edges` from this `Socket`"""
242
+ while self.edges:
243
+ edge = self.edges.pop(0)
244
+ if silent:
245
+ edge.remove(silent_for_socket=self)
246
+ else:
247
+ edge.remove() # just remove all with notifications
248
+ if self.is_input:
249
+ self._reindexEdges()
250
+
251
+ # ordered input helpers
252
+
253
+ def _reindexEdges(self) -> None:
254
+ """
255
+ Compact the read order of this **input** `Socket`.
256
+
257
+ Writes ``0..len(edges)-1`` back onto every connected `Edge` so read
258
+ positions are always gap free. No-op for output sockets, whose `Edges`
259
+ carry no meaningful :attr:`~nodeeditor.node_edge.Edge.input_index`.
260
+ """
261
+ if not self.is_input:
262
+ return
263
+ for position, edge in enumerate(self.edges):
264
+ edge.input_index = position
265
+
266
+ def orderedEdges(self) -> List['Edge']:
267
+ """
268
+ Connected `Edges` in read order.
269
+
270
+ On an input `Socket` this is the connection order maintained by
271
+ :meth:`addEdge`; on an output `Socket` it is the plain connection list.
272
+
273
+ :return: `Edges` connected to this `Socket`, first read position first
274
+ :rtype: List[:class:`~nodeeditor.node_edge.Edge`]
275
+ """
276
+ if not self.is_input:
277
+ return list(self.edges)
278
+ # stable sort: edges sharing an input_index keep their relative order
279
+ return sorted(self.edges, key=lambda edge: edge.input_index)
280
+
281
+ def edgeIndex(self, edge: 'Edge') -> int:
282
+ """
283
+ Read position of ``edge`` on this input `Socket`.
284
+
285
+ :param edge: :class:`~nodeeditor.node_edge.Edge` to look up
286
+ :type edge: :class:`~nodeeditor.node_edge.Edge`
287
+ :return: 0-based read position or ``-1`` if not connected to this `Socket`
288
+ :rtype: ``int``
289
+ """
290
+ if edge not in self.edges:
291
+ return -1
292
+ return self.orderedEdges().index(edge)
293
+
294
+ def compactEdgeOrder(self) -> bool:
295
+ """
296
+ Re-sort and renumber this input `Socket`'s `Edges` to a gap free
297
+ ``0..n-1`` sequence. Useful after assigning
298
+ :attr:`~nodeeditor.node_edge.Edge.input_index` directly from code.
299
+
300
+ :return: ``True`` if anything actually changed
301
+ :rtype: ``bool``
302
+ """
303
+ if not self.is_input:
304
+ return False
305
+ ordered = self.orderedEdges()
306
+ changed = ordered != self.edges
307
+ self.edges[:] = ordered
308
+ for position, edge in enumerate(ordered):
309
+ if edge.input_index != position:
310
+ changed = True
311
+ edge.input_index = position
312
+ return changed
313
+
314
+ def setEdgeOrder(self, edges: List['Edge']) -> bool:
315
+ """
316
+ Set the read order of the `Edges` connected to this input `Socket`.
317
+
318
+ ``edges`` may be a partial list; connected `Edges` missing from it are
319
+ appended afterwards keeping their current relative order. Duplicates
320
+ and `Edges` of other sockets are ignored, so passing a stale or
321
+ reordered reference is safe.
322
+
323
+ :param edges: `Edges` of this socket, first read position first
324
+ :type edges: List[:class:`~nodeeditor.node_edge.Edge`]
325
+ :return: ``True`` if the order changed
326
+ :rtype: ``bool``
327
+ """
328
+ if not self.is_input:
329
+ return False
330
+
331
+ ordered: List['Edge'] = []
332
+ for edge in edges or []:
333
+ # skip foreign edges and duplicates instead of failing, so a stale
334
+ # reference to a disconnected edge cannot corrupt the order
335
+ if edge in self.edges and edge not in ordered:
336
+ ordered.append(edge)
337
+ ordered += [edge for edge in self.orderedEdges() if edge not in ordered]
338
+
339
+ if ordered == self.orderedEdges():
340
+ return False
341
+ self.edges[:] = ordered
342
+ self._reindexEdges()
343
+ return True
344
+
345
+ def moveEdgeTo(self, edge: 'Edge', position: int) -> bool:
346
+ """
347
+ Move ``edge`` to read position ``position`` on this input `Socket`.
348
+
349
+ :param edge: :class:`~nodeeditor.node_edge.Edge` to move
350
+ :type edge: :class:`~nodeeditor.node_edge.Edge`
351
+ :param position: target read position
352
+ :type position: ``int``
353
+ :return: ``True`` if the order changed
354
+ :rtype: ``bool``
355
+ """
356
+ if not self.is_input or edge not in self.edges:
357
+ return False
358
+ try:
359
+ position = int(position)
360
+ except (TypeError, ValueError):
361
+ return False
362
+
363
+ ordered = self.orderedEdges()
364
+ if not 0 <= position < len(ordered):
365
+ return False
366
+ ordered.remove(edge)
367
+ ordered.insert(position, edge)
368
+ self.edges[:] = ordered
369
+ self._reindexEdges()
370
+ return True
371
+
372
+ def swapEdges(self, edge_a: 'Edge', edge_b: 'Edge') -> bool:
373
+ """
374
+ Swap the read positions of two `Edges` of this input `Socket`.
375
+
376
+ :param edge_a: first :class:`~nodeeditor.node_edge.Edge` to swap
377
+ :type edge_a: :class:`~nodeeditor.node_edge.Edge`
378
+ :param edge_b: second :class:`~nodeeditor.node_edge.Edge` to swap
379
+ :type edge_b: :class:`~nodeeditor.node_edge.Edge`
380
+ :return: ``True`` if the order changed
381
+ :rtype: ``bool``
382
+ """
383
+ if not self.is_input or edge_a not in self.edges or edge_b not in self.edges:
384
+ return False
385
+ ordered = self.orderedEdges()
386
+ first, second = ordered.index(edge_a), ordered.index(edge_b)
387
+ if first == second:
388
+ return False
389
+ ordered[first], ordered[second] = ordered[second], ordered[first]
390
+ self.edges[:] = ordered
391
+ self._reindexEdges()
392
+ return True
393
+
394
+ def determineMultiEdges(self, data: dict) -> bool:
395
+ """
396
+ Deserialization helper function. In our tutorials we created a new version of graph data format.
397
+ This function is here to help solve the issue of opening older files in the newer format.
398
+ If the 'multi_edges' param is missing in the dictionary, we determine if this `Socket`
399
+ should support multiple `Edges`.
400
+
401
+ :param data: `Socket` data in ``dict`` format for deserialization
402
+ :type data: ``dict``
403
+ :return: ``True`` if this `Socket` should support multi_edges
404
+ """
405
+ if 'multi_edges' in data:
406
+ return data['multi_edges']
407
+ else:
408
+ # probably older version of file, make RIGHT socket multiedged by default
409
+ return data['position'] in (RIGHT_BOTTOM, RIGHT_TOP)
410
+
411
+ def serialize(self) -> OrderedDict:
412
+ """
413
+ Serialize this `Socket` into an ``OrderedDict`` for storing in memory or on disk.
414
+
415
+ :return: data serialized in ``OrderedDict``
416
+ :rtype: ``OrderedDict``
417
+ """
418
+ return OrderedDict([
419
+ ('id', self.id),
420
+ ('index', self.index),
421
+ ('multi_edges', self.is_multi_edges),
422
+ ('position', self.position),
423
+ ('socket_type', self.socket_type),
424
+ ])
425
+
426
+ def deserialize(self, data: dict, hashmap: dict = {}, restore_id: bool = True) -> bool:
427
+ """
428
+ Deserialize this `Socket` from a ``dict`` of serialized data.
429
+
430
+ :param data: Dictionary containing serialized data
431
+ :type data: ``dict``
432
+ :param hashmap: Helper dictionary containing references (by id == key) to existing objects
433
+ :type hashmap: ``dict``
434
+ :param restore_id: True if we are creating new Sockets. False is useful when loading existing
435
+ Sockets of which we want to keep the existing object's `id`.
436
+ :type restore_id: bool
437
+ :return: ``True`` if deserialization was successful, otherwise ``False``
438
+ :rtype: ``bool``
439
+ """
440
+ if restore_id:
441
+ self.id = data['id']
442
+ self.is_multi_edges = self.determineMultiEdges(data)
443
+ self.changeSocketType(data['socket_type'])
444
+ hashmap[data['id']] = self
445
+ return True
@@ -0,0 +1,261 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ A module containing the Socket Shape system.
4
+
5
+ A socket shape decides two things: how a :class:`~nodeeditor.node_socket.Socket`
6
+ is painted, and which area of it counts as a hit. Four presets ship with the
7
+ library (``circle``, ``square``, ``triangle``, ``chevron``) and applications
8
+ can register their own.
9
+
10
+ Shapes are named per socket type by the owning node (see
11
+ :meth:`~nodeeditor.node_node.Node.getSocketShapeName`), so they are declared in
12
+ code and never written to the graph file.
13
+ """
14
+ from qtpy.QtCore import QPointF
15
+ from qtpy.QtGui import QPainterPath, QPolygonF
16
+
17
+ from typing import Any, Callable, Dict, List, Optional, Tuple
18
+
19
+
20
+ #: Direction handed to a shape for an output socket (right hand side of a node)
21
+ DIRECTION_OUTPUT = 1
22
+
23
+ #: Direction handed to a shape for an input socket (left hand side of a node)
24
+ DIRECTION_INPUT = -1
25
+
26
+ #: Name of the shape every socket falls back to
27
+ DEFAULT_SHAPE = "circle"
28
+
29
+
30
+ class SocketShapeContext:
31
+ """Everything a shape needs while it paints itself."""
32
+
33
+ __slots__ = ("painter", "radius", "center", "brush", "pen", "color",
34
+ "socket", "hovered", "direction")
35
+
36
+ def __init__(self, painter, radius: float, center: QPointF, brush, pen,
37
+ color, socket=None, hovered: bool = False,
38
+ direction: int = DIRECTION_OUTPUT) -> None:
39
+ """
40
+ :param painter: the `QPainter` the shape draws into
41
+ :param radius: the socket's radius in scene units
42
+ :param center: the socket's center in item coordinates
43
+ :param brush: the fill brush (the socket's type color)
44
+ :param pen: the outline pen (already switched for the hover state)
45
+ :param color: the socket's fill color
46
+ :param socket: the owning :class:`~nodeeditor.node_socket.Socket`
47
+ :param hovered: ``True`` while the mouse hovers the socket
48
+ :param direction: ``+1`` for output sockets, ``-1`` for input sockets
49
+ """
50
+ self.painter = painter
51
+ self.radius = radius
52
+ self.center = center
53
+ self.brush = brush
54
+ self.pen = pen
55
+ self.color = color
56
+ self.socket = socket
57
+ self.hovered = hovered
58
+ self.direction = direction
59
+
60
+
61
+ class SocketShape:
62
+ """Base class for socket shapes.
63
+
64
+ Subclasses describe their outline as a list of points normalized against the
65
+ socket radius, and get the hit-test path and the painting for free.
66
+ """
67
+
68
+ #: Mirrors the outline for input sockets when ``True``.
69
+ mirrors_input: bool = True
70
+
71
+ def points(self) -> List[Tuple[float, float]]:
72
+ """The outline as normalized points, in units of the socket radius.
73
+
74
+ :return: list of ``(x, y)`` pairs
75
+ """
76
+ raise NotImplementedError
77
+
78
+ def path(self, radius: float, direction: int = DIRECTION_OUTPUT) -> QPainterPath:
79
+ """The outline as a path, centered on the item origin.
80
+
81
+ :param radius: the socket's radius in scene units
82
+ :param direction: ``+1`` for output sockets, ``-1`` for input sockets
83
+ :return: the outline path
84
+ """
85
+ pts = self.points()
86
+ if self.mirrors_input and direction < 0:
87
+ pts = [(-x, y) for x, y in pts]
88
+ path = QPainterPath()
89
+ path.addPolygon(QPolygonF(
90
+ [QPointF(x * radius, y * radius) for x, y in pts]))
91
+ path.closeSubpath()
92
+ return path
93
+
94
+ def draw(self, painter, ctx: SocketShapeContext) -> None:
95
+ """Paint the shape.
96
+
97
+ :param painter: the `QPainter` to draw into
98
+ :param ctx: the :class:`SocketShapeContext` for this paint
99
+ """
100
+ painter.setBrush(ctx.brush)
101
+ painter.setPen(ctx.pen)
102
+ painter.drawPath(self.path(ctx.radius, ctx.direction))
103
+
104
+
105
+ class CircleShape(SocketShape):
106
+ """The classic socket: a filled circle."""
107
+
108
+ def path(self, radius: float, direction: int = DIRECTION_OUTPUT) -> QPainterPath:
109
+ """The circle as a path, centered on the item origin.
110
+
111
+ :param radius: the socket's radius in scene units
112
+ :param direction: ignored, a circle looks the same on both sides
113
+ :return: the outline path
114
+ """
115
+ path = QPainterPath()
116
+ path.addEllipse(QPointF(0, 0), radius, radius)
117
+ return path
118
+
119
+
120
+ class SquareShape(SocketShape):
121
+ """A square socket.
122
+
123
+ The half side is ``0.85 * radius``, which reads as the same weight as the
124
+ circle next to it.
125
+ """
126
+
127
+ HALF_SIDE = 0.85
128
+
129
+ def points(self) -> List[Tuple[float, float]]:
130
+ """The square's four corners, in units of the socket radius.
131
+
132
+ :return: list of ``(x, y)`` pairs
133
+ """
134
+ h = self.HALF_SIDE
135
+ return [(-h, -h), (h, -h), (h, h), (-h, h)]
136
+
137
+
138
+ class TriangleShape(SocketShape):
139
+ """A triangular socket.
140
+
141
+ The tip points away from the node: right on an output socket, left on an
142
+ input socket.
143
+ """
144
+
145
+ def points(self) -> List[Tuple[float, float]]:
146
+ """The triangle's three corners, in units of the socket radius.
147
+
148
+ The tip points right, so the outline mirrors on input sockets.
149
+
150
+ :return: list of ``(x, y)`` pairs
151
+ """
152
+ return [(-0.8, -0.9), (0.9, 0.0), (-0.8, 0.9)]
153
+
154
+
155
+ class ChevronShape(SocketShape):
156
+ """A chevron socket: the concave hexagon, pointing right.
157
+
158
+ The shape never mirrors, on either side of a node. A chevron on an input
159
+ socket aims its tip into the node, so the two sides of a graph read as one
160
+ left-to-right flow: in, processed, out.
161
+ """
162
+
163
+ mirrors_input = False
164
+
165
+ def points(self) -> List[Tuple[float, float]]:
166
+ """The chevron's six corners, in units of the socket radius.
167
+
168
+ :return: list of ``(x, y)`` pairs
169
+ """
170
+ return [(0.847, 0.0), (0.284, 1.0), (-0.847, 1.0),
171
+ (-0.331, 0.0), (-0.847, -1.0), (0.284, -1.0)]
172
+
173
+
174
+ class CallbackShape(SocketShape):
175
+ """Wraps a plain ``fn(painter, ctx)`` callback as a shape.
176
+
177
+ A callback only knows how to draw, not what its outline is, so the hit
178
+ area falls back to the circle.
179
+ """
180
+
181
+ def __init__(self, func: Callable) -> None:
182
+ """
183
+ :param func: called as ``func(painter, ctx)`` to paint the socket
184
+ """
185
+ self.func = func
186
+
187
+ def path(self, radius: float, direction: int = DIRECTION_OUTPUT) -> QPainterPath:
188
+ """The circle used as the hit area of a callback shape.
189
+
190
+ :param radius: the socket's radius in scene units
191
+ :param direction: ignored, the fallback outline is always a circle
192
+ :return: the outline path
193
+ """
194
+ path = QPainterPath()
195
+ path.addEllipse(QPointF(0, 0), radius, radius)
196
+ return path
197
+
198
+ def draw(self, painter, ctx: SocketShapeContext) -> None:
199
+ """Paint the shape by handing the paint over to the wrapped callback.
200
+
201
+ :param painter: the `QPainter` to draw into
202
+ :param ctx: the :class:`SocketShapeContext` for this paint
203
+ """
204
+ self.func(painter, ctx)
205
+
206
+
207
+ #: All registered socket shapes, by name
208
+ SOCKET_SHAPES: Dict[str, SocketShape] = {}
209
+
210
+
211
+ def register_socket_shape(name: str, shape) -> SocketShape:
212
+ """Register a socket shape under ``name``.
213
+
214
+ ``shape`` may be a :class:`SocketShape` instance, a :class:`SocketShape`
215
+ subclass, or a plain callable ``fn(painter, ctx)``. Registering an existing
216
+ name replaces the old shape.
217
+
218
+ :param name: the name nodes use to select this shape
219
+ :param shape: the shape, its class, or a drawing callback
220
+ :return: the registered shape
221
+ """
222
+ if isinstance(shape, type):
223
+ if not issubclass(shape, SocketShape):
224
+ raise TypeError(
225
+ "socket shape class must inherit from SocketShape, got %r"
226
+ % (shape,))
227
+ shape = shape()
228
+
229
+ if not isinstance(shape, SocketShape):
230
+ if callable(shape):
231
+ shape = CallbackShape(shape)
232
+ else:
233
+ raise TypeError(
234
+ "socket shape must be a SocketShape, a SocketShape subclass or "
235
+ "a callable, got %r" % (shape,))
236
+
237
+ SOCKET_SHAPES[name] = shape
238
+ return shape
239
+
240
+
241
+ def get_socket_shape(name: Optional[str]) -> SocketShape:
242
+ """The shape registered under ``name``.
243
+
244
+ Unknown names fall back to the circle rather than raising, so a typo in a
245
+ node's shape map cannot break a graph.
246
+
247
+ :param name: the registered shape name
248
+ :return: the shape to draw with
249
+ """
250
+ if isinstance(name, str):
251
+ shape = SOCKET_SHAPES.get(name)
252
+ if shape is not None:
253
+ return shape
254
+ return SOCKET_SHAPES[DEFAULT_SHAPE]
255
+
256
+
257
+ # the presets
258
+ register_socket_shape(DEFAULT_SHAPE, CircleShape())
259
+ register_socket_shape("square", SquareShape())
260
+ register_socket_shape("triangle", TriangleShape())
261
+ register_socket_shape("chevron", ChevronShape())