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,1106 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ A module containing the representation of the NodeEditor's Scene
4
+ """
5
+ import os
6
+ import sys
7
+ import orjson as json
8
+ from orjson import JSONDecodeError, OPT_INDENT_2
9
+ from collections import OrderedDict
10
+ from qtpy.QtCore import QRectF, Qt, QPoint
11
+ from qtpy.QtWidgets import QGraphicsItem
12
+ from nodeeditor.utils_no_qt import dumpException, pp
13
+ from nodeeditor.node_serializable import Serializable
14
+ from nodeeditor.node_graphics_scene import QDMGraphicsScene
15
+ from nodeeditor.node_node import Node
16
+ from nodeeditor.node_edge import Edge
17
+ from nodeeditor.node_scene_history import SceneHistory
18
+ from nodeeditor.node_scene_clipboard import SceneClipboard
19
+ from nodeeditor.node_colors_config import get_color_scheme
20
+ import nodeeditor.node_graph as node_graph
21
+
22
+ from typing import TYPE_CHECKING, List, Optional, Tuple, Any, Callable, OrderedDict as OrderedDictType, Type
23
+
24
+
25
+ if TYPE_CHECKING:
26
+ from nodeeditor.node_graphics_view import QDMGraphicsView
27
+ from nodeeditor.node_socket import Socket
28
+ NodeClassType = Callable[[dict], Type['Node']]
29
+
30
+
31
+ DEBUG_REMOVE_WARNINGS = False
32
+
33
+
34
+ class InvalidFile(Exception):
35
+ """Raised when a file cannot be loaded as a `Scene`."""
36
+
37
+
38
+ class Scene(Serializable):
39
+ """Class representing NodeEditor's `Scene`"""
40
+ historyClass = SceneHistory
41
+ clipboardClass = SceneClipboard
42
+
43
+ def __init__(self) -> None:
44
+ """
45
+ :Instance Attributes:
46
+
47
+ - **nodes** - list of `Nodes` in this `Scene`
48
+ - **edges** - list of `Edges` in this `Scene`
49
+ - **history** - Instance of :class:`~nodeeditor.node_scene_history.SceneHistory`
50
+ - **clipboard** - Instance of :class:`~nodeeditor.node_scene_clipboard.SceneClipboard`
51
+ - **scene_width** - width of this `Scene` in pixels
52
+ - **scene_height** - height of this `Scene` in pixels
53
+ """
54
+ super().__init__()
55
+ self.nodes: List[Node] = []
56
+ self.edges: List[Edge] = []
57
+ self.groups: List = [] # List of GroupNode instances
58
+
59
+ # current filename assigned to this scene
60
+ self.filename: Optional[str] = None
61
+
62
+ self.scene_width: int = 64000
63
+ self.scene_height: int = 64000
64
+
65
+ # custom flag used to suppress triggering onItemSelected which does a bunch of stuff
66
+ self._silent_selection_events: bool = False
67
+
68
+ self._has_been_modified: bool = False
69
+ self._last_selected_items: Optional[List[QGraphicsItem]] = None
70
+ self._last_selected_socket: Optional[Socket] = None
71
+ self._last_selected_edges: Optional[List[Edge]] = None
72
+
73
+ # initialize all listeners
74
+ self._has_been_modified_listeners: List[Callable[[], None]] = []
75
+ self._item_selected_listeners: List[Callable[[], None]] = []
76
+ self._items_deselected_listeners: List[Callable[[], None]] = []
77
+
78
+ # here we can store callback for retrieving the class for Nodes
79
+ self.node_class_selector: Optional['NodeClassType'] = None
80
+
81
+ self.initUI()
82
+ self.history = self.historyClass(self)
83
+ self.clipboard = self.clipboardClass(self)
84
+
85
+ self.grScene.itemSelected.connect(self.onItemSelected)
86
+ self.grScene.itemsDeselected.connect(self.onItemsDeselected)
87
+
88
+ @property
89
+ def has_been_modified(self):
90
+ """
91
+ Has this `Scene` been modified?
92
+
93
+ :getter: ``True`` if the `Scene` has been modified
94
+ :setter: set new state. Triggers `Has Been Modified` event
95
+ :type: ``bool``
96
+ """
97
+ return self._has_been_modified
98
+
99
+ @has_been_modified.setter
100
+ def has_been_modified(self, value) -> None:
101
+ if not self._has_been_modified and value:
102
+ # set it now, because we will be reading it soon
103
+ self._has_been_modified = value
104
+
105
+ # call all registered listeners
106
+ for callback in self._has_been_modified_listeners:
107
+ callback()
108
+
109
+ self._has_been_modified = value
110
+
111
+ def initUI(self) -> None:
112
+ """Set up Graphics Scene Instance"""
113
+ self.grScene = QDMGraphicsScene(self)
114
+ self.grScene.setGrScene(self.scene_width, self.scene_height)
115
+
116
+ def getNodeByID(self, node_id: int):
117
+ """
118
+ Find node in the scene according to provided `node_id`
119
+
120
+ :param node_id: ID of the node we are looking for
121
+ :type node_id: ``int``
122
+ :return: Found ``Node`` or ``None``
123
+ """
124
+ for node in self.nodes:
125
+ if node.id == node_id:
126
+ return node
127
+ return None
128
+
129
+ # ------------------------------------------------------------------
130
+ # Graph algorithms
131
+ # ------------------------------------------------------------------
132
+ # These are thin wrappers around :mod:`nodeeditor.node_graph`, which
133
+ # implements them natively. The graph is derived from this `Scene` on
134
+ # every call, so it always reflects the current connections.
135
+
136
+ def topological_order(self) -> List[Node]:
137
+ """The `Nodes` of this `Scene` in dependency order.
138
+
139
+ Every `Node` comes after the `Nodes` feeding it. `Nodes` that do not
140
+ depend on each other keep their scene order, so the result is
141
+ reproducible for a given graph.
142
+
143
+ :return: the `Nodes` in dependency order
144
+ :rtype: ``list`` of :class:`~nodeeditor.node_node.Node`
145
+ :raises :class:`~nodeeditor.node_graph.GraphCycleError` if the graph
146
+ contains a cycle
147
+ """
148
+ return node_graph.topological_order(self)
149
+
150
+ def descendants(self, node: Node) -> List[Node]:
151
+ """Every `Node` reachable downstream of `node`, excluding `node`.
152
+
153
+ Ordered breadth-first, so the nearest nodes come first. A node
154
+ reachable through several paths appears once.
155
+
156
+ :param node: the `Node` to start from
157
+ :type node: :class:`~nodeeditor.node_node.Node`
158
+ :return: the downstream `Nodes`
159
+ :rtype: ``list`` of :class:`~nodeeditor.node_node.Node`
160
+ """
161
+ return node_graph.descendants(self, node)
162
+
163
+ def ancestors(self, node: Node) -> List[Node]:
164
+ """Every `Node` feeding `node`, excluding `node` itself.
165
+
166
+ Ordered breadth-first, so the nearest nodes come first.
167
+
168
+ :param node: the `Node` to start from
169
+ :type node: :class:`~nodeeditor.node_node.Node`
170
+ :return: the upstream `Nodes`
171
+ :rtype: ``list`` of :class:`~nodeeditor.node_node.Node`
172
+ """
173
+ return node_graph.ancestors(self, node)
174
+
175
+ def components(self) -> List[List[Node]]:
176
+ """The disconnected subgraphs of this `Scene`.
177
+
178
+ Two `Nodes` belong to the same component when a chain of `Edges`
179
+ connects them, regardless of direction. A `Node` without any
180
+ connection forms a component of its own. The components come in scene
181
+ order.
182
+
183
+ :return: one list of `Nodes` per component
184
+ :rtype: ``list`` of ``list`` of :class:`~nodeeditor.node_node.Node`
185
+ """
186
+ return node_graph.components(self)
187
+
188
+ def cycles(self) -> List[List[Edge]]:
189
+ """The cycles of this `Scene`.
190
+
191
+ One representative cycle per cyclic region. Within a cycle, the
192
+ `Edges` are listed in traversal order and the last one leads back to
193
+ the node the first one started from.
194
+
195
+ :return: the cycles, each a list of `Edges`
196
+ :rtype: ``list`` of ``list`` of :class:`~nodeeditor.node_edge.Edge`
197
+ """
198
+ return node_graph.cycles(self)
199
+
200
+ def paths(self, source: Node, target: Node) -> List[List[Tuple[Node, Optional[Edge]]]]:
201
+ """Every simple path from `source` to `target`.
202
+
203
+ A path is a list of ``(node, edge)`` steps, where the `edge` of a step
204
+ is the `Edge` that led into its `Node`. The first step carries
205
+ ``None`` as its edge.
206
+
207
+ :param source: the `Node` to start from
208
+ :type source: :class:`~nodeeditor.node_node.Node`
209
+ :param target: the `Node` to reach
210
+ :type target: :class:`~nodeeditor.node_node.Node`
211
+ :return: the paths found
212
+ :rtype: ``list`` of ``list`` of ``(Node, Edge)``
213
+ """
214
+ return node_graph.paths(self, source, target)
215
+
216
+ def shortest_path(
217
+ self, source: Node, target: Node
218
+ ) -> Optional[List[Tuple[Node, Optional[Edge]]]]:
219
+ """The shortest path from `source` to `target`, counted in `Edges`.
220
+
221
+ :param source: the `Node` to start from
222
+ :type source: :class:`~nodeeditor.node_node.Node`
223
+ :param target: the `Node` to reach
224
+ :type target: :class:`~nodeeditor.node_node.Node`
225
+ :return: the shortest path, or ``None`` when `target` is unreachable
226
+ :rtype: ``list`` of ``(Node, Edge)`` or ``None``
227
+ """
228
+ return node_graph.shortest_path(self, source, target)
229
+
230
+ def setSilentSelectionEvents(self, value: bool = True) -> None:
231
+ """Calling this can suppress onItemSelected events to be triggered. This is useful when working with clipboard"""
232
+ self._silent_selection_events = value
233
+
234
+ def onItemSelected(self, silent: bool = False) -> None:
235
+ """
236
+ Handle Item selection and trigger event `Item Selected`
237
+
238
+ :param silent: If ``True`` scene's onItemSelected won't be called and history stamp not stored
239
+ :type silent: ``bool``
240
+ """
241
+ if self._silent_selection_events:
242
+ return
243
+
244
+ current_selected_items = self.getSelectedItems()
245
+ if current_selected_items != self._last_selected_items:
246
+ self._last_selected_items = current_selected_items
247
+ if not silent:
248
+ # we could create some kind of UI which could be serialized,
249
+ # therefore first run all callbacks...
250
+ for callback in self._item_selected_listeners:
251
+ callback()
252
+ # and store history as a last step always
253
+ self.history.storeHistory("Selection Changed")
254
+
255
+ def onItemsDeselected(self, silent: bool = False) -> None:
256
+ """
257
+ Handle Items deselection and trigger event `Items Deselected`
258
+
259
+ :param silent: If ``True`` scene's onItemsDeselected won't be called and history stamp not stored
260
+ :type silent: ``bool``
261
+ """
262
+ # somehow this event is being triggered when we start dragging file outside of our application
263
+ # or we just loose focus on our app? -- which does not mean we've deselected item in the scene!
264
+ # double check if the selection has actually changed, since
265
+ current_selected_items = self.getSelectedItems()
266
+ if current_selected_items == self._last_selected_items:
267
+ # print("Qt itemsDeselected Invalid Event! Ignoring")
268
+ return
269
+
270
+ self.resetLastSelectedStates()
271
+ if current_selected_items == []:
272
+ self._last_selected_items = []
273
+ if not silent:
274
+ self.history.storeHistory("Deselected Everything")
275
+ for callback in self._items_deselected_listeners:
276
+ callback()
277
+
278
+ def isModified(self) -> bool:
279
+ """Is this `Scene` dirty aka `has been modified` ?
280
+
281
+ :return: ``True`` if `Scene` has been modified
282
+ :rtype: ``bool``
283
+ """
284
+ return self.has_been_modified
285
+
286
+ def getSelectedItems(self) -> list:
287
+ """
288
+ Returns currently selected Graphics Items
289
+
290
+ :return: list of ``QGraphicsItems``
291
+ :rtype: list[QGraphicsItem]
292
+ """
293
+ return self.grScene.selectedItems()
294
+
295
+ def getSelectedNodes(self) -> list:
296
+ """
297
+ Returns currently selected Nodes
298
+
299
+ :return: list of ``Node``
300
+ :rtype: list[Node]
301
+ """
302
+ return [item.node for item in self.getSelectedItems() if hasattr(item, "node")]
303
+
304
+ def doDeselectItems(self, silent: bool = False) -> None:
305
+ """
306
+ Deselects everything in scene
307
+
308
+ :param silent: If ``True`` scene's onItemsDeselected won't be called
309
+ :type silent: ``bool``
310
+ """
311
+ for item in self.getSelectedItems():
312
+ item.setSelected(False)
313
+ if not silent:
314
+ self.onItemsDeselected()
315
+
316
+ # ------------------------------------------------------------------
317
+ # Undo / Redo entry points
318
+ # ------------------------------------------------------------------
319
+
320
+ def undo(self) -> None:
321
+ """Revert the most recent recorded change.
322
+
323
+ Routed through the scene rather than called on ``history`` directly so
324
+ subclasses can layer an alternative undo backend underneath the
325
+ default snapshot implementation.
326
+ """
327
+ self.history.undo()
328
+
329
+ def redo(self) -> None:
330
+ """Reapply the most recently reverted change."""
331
+ self.history.redo()
332
+
333
+ def canUndo(self) -> bool:
334
+ """Is there a recorded change which can be undone?
335
+
336
+ :return: ``True`` if the `Scene` history holds an undoable change
337
+ :rtype: ``bool``
338
+ """
339
+ return self.history.canUndo()
340
+
341
+ def canRedo(self) -> bool:
342
+ """Is there a reverted change which can be redone?
343
+
344
+ :return: ``True`` if the `Scene` history holds a redoable change
345
+ :rtype: ``bool``
346
+ """
347
+ return self.history.canRedo()
348
+
349
+ # our helper listener functions
350
+ def addHasBeenModifiedListener(self, callback: Callable[[], None]) -> None:
351
+ """
352
+ Register callback for `Has Been Modified` event
353
+
354
+ :param callback: callback function
355
+ """
356
+ self._has_been_modified_listeners.append(callback)
357
+
358
+ def addItemSelectedListener(self, callback: Callable[[], None]) -> None:
359
+ """
360
+ Register callback for `Item Selected` event
361
+
362
+ :param callback: callback function
363
+ """
364
+ self._item_selected_listeners.append(callback)
365
+
366
+ def addItemsDeselectedListener(self, callback: Callable[[], None]) -> None:
367
+ """
368
+ Register callback for `Items Deselected` event
369
+
370
+ :param callback: callback function
371
+ """
372
+ self._items_deselected_listeners.append(callback)
373
+
374
+ def addDragEnterListener(self, callback: Callable[[], None]) -> None:
375
+ """
376
+ Register callback for `Drag Enter` event
377
+
378
+ :param callback: callback function
379
+ """
380
+ self.getView().addDragEnterListener(callback)
381
+
382
+ def addDropListener(self, callback: Callable[[], None]) -> None:
383
+ """
384
+ Register callback for `Drop` event
385
+
386
+ :param callback: callback function
387
+ """
388
+ self.getView().addDropListener(callback)
389
+
390
+ # custom flag to detect node or edge has been selected....
391
+ def resetLastSelectedStates(self) -> None:
392
+ """Resets internal `selected flags` in all `Nodes` and `Edges` in the `Scene`"""
393
+ for node in self.nodes:
394
+ node.grNode._last_selected_state = False
395
+ for edge in self.edges:
396
+ edge.grEdge._last_selected_state = False
397
+
398
+ def getView(self) -> 'QDMGraphicsView':
399
+ """Shortcut for returning `Scene` ``QGraphicsView``
400
+
401
+ :return: ``QGraphicsView`` attached to the `Scene`
402
+ :rtype: ``QGraphicsView``
403
+ """
404
+ return self.grScene.views()[0]
405
+
406
+ def getItemAt(self, pos: 'QPoint') -> Optional['QGraphicsItem']:
407
+ """Shortcut for retrieving item at provided `Scene` position
408
+
409
+ :param pos: scene position
410
+ :type pos: ``QPointF``
411
+ :return: Qt Graphics Item at scene position
412
+ :rtype: ``QGraphicsItem``
413
+ """
414
+ return self.getView().itemAt(pos)
415
+
416
+ # ============================================================================
417
+ # Color Configuration Methods
418
+ # ============================================================================
419
+
420
+ def getColorScheme(self):
421
+ """Get the global color scheme instance used by all scenes
422
+
423
+ :return: NodeEditorColorScheme instance
424
+ """
425
+ return get_color_scheme()
426
+
427
+ def setEdgeColors(
428
+ self, default=None, selected=None, hovered=None, dragging=None
429
+ ) -> None:
430
+ """Update edge colors and refresh the scene
431
+
432
+ :param default: Default edge color (hex string, QColor, or RGB tuple)
433
+ :param selected: Selected edge color
434
+ :param hovered: Hovered edge color
435
+ :param dragging: Dragging edge color
436
+
437
+ Example::
438
+
439
+ scene.setEdgeColors(default="#FF0000", selected="#00FF00")
440
+ """
441
+ scheme = self.getColorScheme()
442
+ scheme.edges.set_all(
443
+ default=default, selected=selected, hovered=hovered, dragging=dragging
444
+ )
445
+ self._refresh_edge_colors()
446
+
447
+ def setSocketColors(self, outline=None, highlight=None, type_colors=None) -> None:
448
+ """Update socket colors and refresh the scene
449
+
450
+ :param outline: Socket outline color (hex string, QColor, or RGB tuple)
451
+ :param highlight: Socket highlight color
452
+ :param type_colors: List of colors for socket types
453
+
454
+ Example::
455
+
456
+ scene.setSocketColors(
457
+ outline="#000000",
458
+ highlight="#00FF00",
459
+ type_colors=["#FF0000", "#00FF00", "#0000FF"]
460
+ )
461
+ """
462
+ scheme = self.getColorScheme()
463
+ scheme.sockets.set_all(
464
+ outline=outline, highlight=highlight, type_colors=type_colors
465
+ )
466
+ self._refresh_socket_colors()
467
+
468
+ def setHighlightColor(self, color) -> None:
469
+ """Update the color used for highlighting edges and sockets
470
+
471
+ :param color: Highlight color (hex string, QColor, or RGB tuple)
472
+
473
+ Example::
474
+
475
+ scene.setHighlightColor("#00FF00")
476
+ """
477
+ scheme = self.getColorScheme()
478
+ scheme.edges.hovered = color
479
+ scheme.sockets.highlight = color
480
+ self._refresh_colors()
481
+
482
+ def setSocketTypeColor(self, socket_type: int, color) -> None:
483
+ """Update the color for a specific socket type
484
+
485
+ :param socket_type: Socket type index
486
+ :param color: Color to set (hex string, QColor, or RGB tuple)
487
+
488
+ Example::
489
+
490
+ scene.setSocketTypeColor(0, "#FF0000") # Set type 0 to red
491
+ """
492
+ scheme = self.getColorScheme()
493
+ scheme.sockets.set_type_color(socket_type, color)
494
+ self._refresh_socket_colors()
495
+
496
+ def _refresh_edge_colors(self) -> None:
497
+ """Refresh all edges in the scene with updated colors"""
498
+ for edge in self.edges:
499
+ if hasattr(edge, "grEdge") and edge.grEdge:
500
+ edge.grEdge.initAssets()
501
+ edge.grEdge.update()
502
+
503
+ def _refresh_socket_colors(self) -> None:
504
+ """Refresh all sockets in the scene with updated colors"""
505
+ for node in self.nodes:
506
+ if hasattr(node, "grNode") and node.grNode:
507
+ # Refresh the node graphics item (for hover colors, etc.)
508
+ node.grNode.initAssets()
509
+ # Refresh input sockets
510
+ for socket in node.inputs:
511
+ if hasattr(socket, "grSocket") and socket.grSocket:
512
+ socket.grSocket.initAssets()
513
+ socket.grSocket.update()
514
+ # Refresh output sockets
515
+ for socket in node.outputs:
516
+ if hasattr(socket, "grSocket") and socket.grSocket:
517
+ socket.grSocket.initAssets()
518
+ socket.grSocket.update()
519
+ # Update the node graphics item
520
+ node.grNode.update()
521
+
522
+ def _refresh_colors(self) -> None:
523
+ """Refresh all colors in the scene (edges, sockets, highlights)"""
524
+ self._refresh_edge_colors()
525
+ self._refresh_socket_colors()
526
+ self.grScene.update()
527
+
528
+ def addNode(self, node: Node) -> None:
529
+ """Add :class:`~nodeeditor.node_node.Node` to this `Scene`
530
+
531
+ :param node: :class:`~nodeeditor.node_node.Node` to be added to this `Scene`
532
+ :type node: :class:`~nodeeditor.node_node.Node`
533
+ """
534
+ self.nodes.append(node)
535
+
536
+ def addEdge(self, edge: Edge) -> None:
537
+ """Add :class:`~nodeeditor.node_edge.Edge` to this `Scene`
538
+
539
+ :param edge: :class:`~nodeeditor.node_edge.Edge` to be added to this `Scene`
540
+ :return: :class:`~nodeeditor.node_edge.Edge`
541
+ """
542
+ self.edges.append(edge)
543
+
544
+ def removeNode(self, node: Node) -> None:
545
+ """Remove :class:`~nodeeditor.node_node.Node` from this `Scene`
546
+
547
+ :param node: :class:`~nodeeditor.node_node.Node` to be removed from this `Scene`
548
+ :type node: :class:`~nodeeditor.node_node.Node`
549
+ """
550
+ if node in self.nodes:
551
+ self.nodes.remove(node)
552
+ else:
553
+ if DEBUG_REMOVE_WARNINGS:
554
+ print("!W:", "Scene::removeNode", "wanna remove nodeeditor", node,
555
+ "from self.nodes but it's not in the list!")
556
+
557
+ def removeEdge(self, edge: Edge) -> None:
558
+ """Remove :class:`~nodeeditor.node_edge.Edge` from this `Scene`
559
+
560
+ :param edge: :class:`~nodeeditor.node_edge.Edge` to be remove from this `Scene`
561
+ :return: :class:`~nodeeditor.node_edge.Edge`
562
+ """
563
+ if edge in self.edges:
564
+ self.edges.remove(edge)
565
+ else:
566
+ if DEBUG_REMOVE_WARNINGS:
567
+ print("!W:", "Scene::removeEdge", "wanna remove edge", edge,
568
+ "from self.edges but it's not in the list!")
569
+
570
+ def addGroup(self, group) -> None:
571
+ """Add Group to this `Scene`
572
+
573
+ :param group: Group to be added to this `Scene`
574
+ """
575
+ if group not in self.groups:
576
+ self.groups.append(group)
577
+
578
+ def removeGroup(self, group) -> None:
579
+ """Remove Group from this `Scene`
580
+
581
+ :param group: Group to be removed from this `Scene`
582
+ """
583
+ if group in self.groups:
584
+ self.groups.remove(group)
585
+ else:
586
+ if DEBUG_REMOVE_WARNINGS:
587
+ print(
588
+ "!W:",
589
+ "Scene::removeGroup",
590
+ "wanna remove group",
591
+ group,
592
+ "from self.groups but it's not in the list!",
593
+ )
594
+
595
+ def getGroupById(self, group_id: int):
596
+ """
597
+ Find the `Group` in this `Scene` according to the provided `group_id`
598
+
599
+ :param group_id: ID of the group we are looking for
600
+ :type group_id: ``int``
601
+ :return: Found `Group` or ``None``
602
+ """
603
+ for group in self.groups:
604
+ if getattr(group, 'id', None) == group_id:
605
+ return group
606
+ return None
607
+
608
+ def findCollapsedGroupForNode(self, node):
609
+ """Return collapsed Group containing `node`, or None."""
610
+ try:
611
+ grp = getattr(node, 'parent_group', None)
612
+ if grp is not None and grp in self.groups and getattr(grp, '_collapsed', False):
613
+ return grp
614
+ except Exception:
615
+ pass
616
+ return None
617
+
618
+ def findGroupForDrop(self, scenepos, exclude_nodes=None):
619
+ """Topmost smallest group whose *bounding* rect contains `scenepos`.
620
+
621
+ Uses boundingRect (not header-only shape) so drops onto the body work.
622
+ Returns None if no candidate. `exclude_nodes` skips groups that already
623
+ contain all dragged nodes (avoids redundant history stamps).
624
+ """
625
+ try:
626
+ from qtpy.QtCore import QPointF
627
+ if not isinstance(scenepos, QPointF):
628
+ try:
629
+ scenepos = QPointF(scenepos.x(), scenepos.y())
630
+ except Exception:
631
+ return None
632
+ best = None
633
+ best_area = None
634
+ exclude_ids = set()
635
+ try:
636
+ for n in list(exclude_nodes or []):
637
+ nid = getattr(n, 'id', None)
638
+ exclude_ids.add(nid if nid is not None else id(n))
639
+ except Exception:
640
+ pass
641
+ for grp in list(getattr(self, 'groups', [])):
642
+ try:
643
+ gr = grp
644
+ # map scene pos into group local coords, test full rect
645
+ try:
646
+ lp = gr.mapFromScene(scenepos)
647
+ except Exception:
648
+ continue
649
+ if not gr.rect().contains(lp):
650
+ continue
651
+ if exclude_ids:
652
+ try:
653
+ members = set()
654
+ for n in list(getattr(grp, 'child_nodes', [])):
655
+ nid = getattr(n, 'id', None)
656
+ members.add(nid if nid is not None else id(n))
657
+ if members and members.issuperset(exclude_ids):
658
+ # all dragged nodes already inside; still allow
659
+ # highlight? No - skip to avoid noop stamps.
660
+ all_inside = True
661
+ for n in list(exclude_nodes or []):
662
+ if n not in list(getattr(grp, 'child_nodes', [])):
663
+ all_inside = False
664
+ break
665
+ if all_inside:
666
+ continue
667
+ except Exception:
668
+ pass
669
+ try:
670
+ area = gr.rect().width() * gr.rect().height()
671
+ except Exception:
672
+ area = float('inf')
673
+ if best is None or area < best_area:
674
+ best, best_area = grp, area
675
+ except Exception:
676
+ continue
677
+ return best
678
+ except Exception:
679
+ return None
680
+
681
+ def dropNodesIntoGroup(self, nodes, scenepos) -> bool:
682
+ """Move `nodes` into group under `scenepos`. Returns True if changed."""
683
+ try:
684
+ grp = self.findGroupForDrop(scenepos, exclude_nodes=nodes)
685
+ if grp is None:
686
+ return False
687
+ changed = False
688
+ for node in list(nodes or []):
689
+ try:
690
+ if node is None or getattr(node, 'grNode', None) is None:
691
+ continue
692
+ if getattr(node, 'parent_group', None) is grp:
693
+ continue
694
+ grp.addNode(node)
695
+ changed = True
696
+ except Exception:
697
+ continue
698
+ if changed:
699
+ try:
700
+ if getattr(grp, '_collapsed', False):
701
+ grp._updateCollapsedSize()
702
+ grp.refreshExternalEdges()
703
+ else:
704
+ grp.updateBounds()
705
+ except Exception:
706
+ pass
707
+ try:
708
+ self.has_been_modified = True
709
+ except Exception:
710
+ pass
711
+ return changed
712
+ except Exception:
713
+ return False
714
+
715
+ def clear(self) -> None:
716
+ """Remove all `Nodes`, `Edges` and `Groups` from this `Scene`"""
717
+ while len(self.nodes) > 0:
718
+ try:
719
+ self.nodes[0].remove()
720
+ except Exception:
721
+ # ensure progress even if a node is broken
722
+ try:
723
+ self.nodes.pop(0)
724
+ except Exception:
725
+ break
726
+ # nodes detach from groups on remove; drop any remaining group graphics
727
+ for group in list(self.groups):
728
+ try:
729
+ try:
730
+ if self.grScene is not None and group in self.grScene.items():
731
+ self.grScene.removeItem(group)
732
+ except Exception:
733
+ pass
734
+ except Exception:
735
+ pass
736
+ self.groups.clear()
737
+
738
+ self.has_been_modified = False
739
+
740
+ def saveToFile(self, filename: str) -> None:
741
+ """
742
+ Save this `Scene` to the file on disk.
743
+
744
+ :param filename: where to save this scene
745
+ :type filename: ``str``
746
+ """
747
+ # orjson returns bytes, so we need to decode to str before writing
748
+ with open(filename, "w") as file:
749
+ json_str = json.dumps(
750
+ self.serialize(),
751
+ option=OPT_INDENT_2, # Use orjson's built-in indentation option
752
+ ).decode("utf-8")
753
+ file.write(json_str)
754
+ # print("saving to", filename, "was successfull.")
755
+
756
+ self.has_been_modified = False
757
+ self.filename = filename
758
+
759
+ def loadFromFile(self, filename: str):
760
+ """
761
+ Load `Scene` from a file on disk
762
+
763
+ :param filename: from what file to load the `Scene`
764
+ :type filename: ``str``
765
+ :raises: :class:`~nodeeditor.node_scene.InvalidFile` if there was an error decoding JSON file
766
+ """
767
+
768
+ with open(filename, "r") as file:
769
+ raw_data = file.read()
770
+ try:
771
+ data = json.loads(raw_data)
772
+ self.filename = filename
773
+ self.deserialize(data)
774
+ self.has_been_modified = False
775
+ except JSONDecodeError:
776
+ raise InvalidFile("%s is not a valid JSON file" %
777
+ os.path.basename(filename))
778
+ except Exception as e:
779
+ dumpException(e)
780
+
781
+ def getEdgeClass(self):
782
+ """Return the class representing Edge. Override me if needed"""
783
+ return Edge
784
+
785
+ def setNodeClassSelector(self, class_selecting_function: 'NodeClassType') -> None: # noqa
786
+ """
787
+ Set the function which decides what `Node` class to instantiate when deserializing `Scene`.
788
+ If not set, we will always instantiate :class:`~nodeeditor.node_node.Node` for each `Node` in the `Scene`
789
+
790
+ :param class_selecting_function: function which returns `Node` class type (not instance) from `Node` serialized ``dict`` data
791
+ :type class_selecting_function: ``function``
792
+ :return: Class Type of `Node` to be instantiated during deserialization
793
+ :rtype: `Node` class type
794
+ """
795
+ self.node_class_selector = class_selecting_function
796
+
797
+ def getNodeClassFromData(self, data: dict) -> Type['Node']:
798
+ """
799
+ Takes `Node` serialized data and determines which `Node Class` to instantiate according the description
800
+ in the serialized Node
801
+
802
+ :param data: serialized `Node` object data
803
+ :type data: ``dict``
804
+ :return: Instance of `Node` class to be used in this Scene
805
+ :rtype: `Node` class instance
806
+ """
807
+ # Groups are first-class (Scene.groups), never created via node path.
808
+ # Keep a guard so group payloads accidentally routed here don't crash.
809
+ if data.get("type") in ("Group", "GroupNode"):
810
+ from nodeeditor.node_group import Group
811
+
812
+ return Group # type: ignore[return-value]
813
+
814
+ return Node if self.node_class_selector is None else self.node_class_selector(data)
815
+
816
+ def serialize(self) -> OrderedDict:
817
+ """
818
+ Serialize this `Scene` into an ``OrderedDict`` for storing in memory or on disk.
819
+
820
+ :return: data serialized in ``OrderedDict``
821
+ :rtype: ``OrderedDict``
822
+ """
823
+ nodes: List[dict] = []
824
+ edges: List[dict] = []
825
+ groups: List[dict] = []
826
+ for node in self.nodes:
827
+ new_node = node.serialize()
828
+ if not any(new_node['id'] == a['id'] for a in nodes):
829
+ nodes.append(new_node)
830
+ for edge in self.edges:
831
+ new_edge = edge.serialize()
832
+ if not any(new_edge['id'] == a['id'] for a in edges):
833
+ edges.append(new_edge)
834
+ for group in self.groups:
835
+ new_group = group.serialize()
836
+ if not any(new_group["id"] == a["id"] for a in groups):
837
+ groups.append(new_group)
838
+ return OrderedDict(
839
+ [
840
+ ("id", self.id),
841
+ ("scene_width", self.scene_width),
842
+ ("scene_height", self.scene_height),
843
+ ("nodes", nodes),
844
+ ("edges", edges),
845
+ ("groups", groups),
846
+ ]
847
+ )
848
+
849
+ def deserialize(self, data: dict, hashmap: Optional[dict] = None, restore_id: bool = True, *args: Any, **kwargs: Any) -> bool:
850
+ """
851
+ Deserialize this `Scene` from a ``dict`` of serialized data.
852
+
853
+ `Nodes`, `Edges` and `Groups` already present in the `Scene` are reused,
854
+ the ones missing from the data are created and the leftover ones are removed.
855
+
856
+ :param data: Dictionary containing serialized data
857
+ :type data: ``dict``
858
+ :param hashmap: Helper dictionary containing references (by id == key) to existing objects
859
+ :type hashmap: ``dict``
860
+ :param restore_id: True if we are creating new Sockets. False is useful when loading existing
861
+ Sockets of which we want to keep the existing object's `id`.
862
+ :type restore_id: bool
863
+ :return: ``True`` if deserialization was successful, otherwise ``False``
864
+ :rtype: ``bool``
865
+ """
866
+ hashmap = hashmap or {}
867
+
868
+ if restore_id:
869
+ self.id = data['id']
870
+
871
+ # -- deserialize NODES
872
+
873
+ # Instead of recreating all the nodes, reuse existing ones...
874
+ # get list of all current nodes:
875
+ all_nodes = self.nodes.copy()
876
+
877
+ # go through deserialized nodes:
878
+ for node_data in data['nodes']:
879
+ # can we find this node in the scene?
880
+ found_node: Optional[Node] = None
881
+ for node in all_nodes:
882
+ if node.id == node_data['id']:
883
+ found_node = node
884
+ break
885
+
886
+ if not found_node:
887
+ try:
888
+ new_node = self.getNodeClassFromData(node_data)(self)
889
+ new_node.deserialize(
890
+ node_data, hashmap, restore_id, *args, **kwargs)
891
+ new_node.onDeserialized(node_data)
892
+ # print("New node for", node_data['title'])
893
+ except:
894
+ dumpException()
895
+ else:
896
+ try:
897
+ found_node.deserialize(node_data, hashmap,
898
+ restore_id, *args, **kwargs)
899
+ found_node.onDeserialized(node_data)
900
+ all_nodes.remove(found_node)
901
+ # print("Reused", node_data['title'])
902
+ except:
903
+ dumpException()
904
+
905
+ # remove nodes which are left in the scene and were NOT in the serialized data!
906
+ # that means they were not in the graph before...
907
+ while all_nodes != []:
908
+ node = all_nodes.pop()
909
+ node.remove()
910
+
911
+ # -- deserialize EDGES
912
+
913
+ # Instead of recreating all the edges, reuse existing ones...
914
+ # get list of all current edges:
915
+ all_edges = self.edges.copy()
916
+
917
+ # go through deserialized edges:
918
+ for edge_data in data['edges']:
919
+ # can we find this node in the scene?
920
+ found_edge: Optional[Edge] = None
921
+ for edge in all_edges:
922
+ if edge.id == edge_data['id']:
923
+ found_edge = edge
924
+ break
925
+
926
+ if not found_edge:
927
+ new_edge = None
928
+ try:
929
+ new_edge = self.getEdgeClass()(self)
930
+ ok = new_edge.deserialize(
931
+ edge_data, hashmap, restore_id, *args, **kwargs)
932
+ if not ok:
933
+ # clean up half-created edge so groups phase still runs
934
+ try:
935
+ try:
936
+ if getattr(new_edge, 'grEdge', None) is not None:
937
+ self.grScene.removeItem(new_edge.grEdge)
938
+ except Exception:
939
+ pass
940
+ if new_edge in self.edges:
941
+ self.edges.remove(new_edge)
942
+ except Exception:
943
+ pass
944
+ except Exception:
945
+ try:
946
+ dumpException()
947
+ except Exception:
948
+ pass
949
+ try:
950
+ if new_edge is not None:
951
+ try:
952
+ if getattr(new_edge, 'grEdge', None) is not None:
953
+ self.grScene.removeItem(new_edge.grEdge)
954
+ except Exception:
955
+ pass
956
+ if new_edge in self.edges:
957
+ self.edges.remove(new_edge)
958
+ except Exception:
959
+ pass
960
+ # print("New edge for", edge_data)
961
+ else:
962
+ try:
963
+ found_edge.deserialize(edge_data, hashmap,
964
+ restore_id, *args, **kwargs)
965
+ except Exception:
966
+ try:
967
+ dumpException()
968
+ except Exception:
969
+ pass
970
+ all_edges.remove(found_edge)
971
+
972
+ # remove nodes which are left in the scene and were NOT in the serialized data!
973
+ # that means they were not in the graph before...
974
+ while all_edges != []:
975
+ edge = all_edges.pop()
976
+ edge.remove()
977
+
978
+ # -- deserialize GROUPS (v2 clean break, best-effort old load as expanded)
979
+ all_groups = self.groups.copy()
980
+
981
+ for group_data in data.get("groups", []):
982
+ found_group = None
983
+ try:
984
+ gid = group_data.get("id", None)
985
+ except Exception:
986
+ gid = None
987
+ for group in all_groups:
988
+ if getattr(group, 'id', None) == gid:
989
+ found_group = group
990
+ break
991
+
992
+ if not found_group:
993
+ try:
994
+ from nodeeditor.node_group import Group
995
+
996
+ new_group = Group(self)
997
+ new_group.deserialize(
998
+ group_data, hashmap, restore_id, *args, **kwargs
999
+ )
1000
+ # Group.__init__ already added itself; guard double-add
1001
+ if new_group not in self.groups:
1002
+ self.groups.append(new_group)
1003
+ except Exception:
1004
+ dumpException()
1005
+ else:
1006
+ try:
1007
+ found_group.deserialize(
1008
+ group_data, hashmap, restore_id, *args, **kwargs
1009
+ )
1010
+ all_groups.remove(found_group)
1011
+ except Exception:
1012
+ dumpException()
1013
+
1014
+ while all_groups != []:
1015
+ group = all_groups.pop()
1016
+ try:
1017
+ # dispose graphics + list entry (no child deletion on undo/load)
1018
+ # clear dangling parent_group pointers first
1019
+ try:
1020
+ for n in list(getattr(group, 'child_nodes', [])):
1021
+ try:
1022
+ if getattr(n, 'parent_group', None) is group:
1023
+ n.parent_group = None
1024
+ except Exception:
1025
+ pass
1026
+ try:
1027
+ group.child_nodes.clear()
1028
+ except Exception:
1029
+ pass
1030
+ except Exception:
1031
+ pass
1032
+ try:
1033
+ if self.grScene is not None and group in self.grScene.items():
1034
+ self.grScene.removeItem(group)
1035
+ except Exception:
1036
+ pass
1037
+ if group in self.groups:
1038
+ self.groups.remove(group)
1039
+ except Exception:
1040
+ pass
1041
+
1042
+ # -- restore child node relationships for groups
1043
+ # detach all first to handle moves between groups reliably
1044
+ try:
1045
+ for grp in list(self.groups):
1046
+ try:
1047
+ for n in list(getattr(grp, 'child_nodes', [])):
1048
+ try:
1049
+ if getattr(n, 'parent_group', None) is grp:
1050
+ n.parent_group = None
1051
+ except Exception:
1052
+ pass
1053
+ grp.child_nodes.clear()
1054
+ except Exception:
1055
+ continue
1056
+ except Exception:
1057
+ pass
1058
+
1059
+ for group_data in data.get("groups", []):
1060
+ group = None
1061
+ try:
1062
+ gid = group_data.get("id", None)
1063
+ except Exception:
1064
+ gid = None
1065
+ for g in self.groups:
1066
+ if getattr(g, 'id', None) == gid:
1067
+ group = g
1068
+ break
1069
+ if group is None:
1070
+ continue
1071
+ # v2 key "children", old key "child_node_ids" (clean break: old loads expanded)
1072
+ child_ids = group_data.get(
1073
+ "children", group_data.get("child_node_ids", []))
1074
+ try:
1075
+ node_by_id = {getattr(n, 'id', None): n for n in self.nodes}
1076
+ for child_id in list(child_ids or []):
1077
+ node = node_by_id.get(child_id, None)
1078
+ if node is not None:
1079
+ group.addNode(node)
1080
+ except Exception:
1081
+ pass
1082
+ try:
1083
+ if getattr(group, '_collapsed', False):
1084
+ group.applyCollapsedAfterLoad()
1085
+ else:
1086
+ # mirror expand() visuals so internal edges don't stay hidden
1087
+ # after undo/redo from collapsed -> expanded
1088
+ try:
1089
+ group.restoreExpandedVisuals()
1090
+ except Exception:
1091
+ pass
1092
+ except Exception:
1093
+ pass
1094
+
1095
+ # final pass: collapsed proxy positions need real socket scenePos; refresh
1096
+ try:
1097
+ for grp in list(self.groups):
1098
+ try:
1099
+ if getattr(grp, '_collapsed', False):
1100
+ grp.refreshExternalEdges()
1101
+ except Exception:
1102
+ continue
1103
+ except Exception:
1104
+ pass
1105
+
1106
+ return True