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,907 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ A module containing `Graphics View` for NodeEditor
4
+ """
5
+ from qtpy.QtWidgets import QGraphicsView, QApplication, QWidget, QGraphicsItem
6
+ from qtpy.QtCore import Signal, QPoint, Qt, QEvent, QPointF, QRectF
7
+ from qtpy.QtGui import QPainter, QDragEnterEvent, QDropEvent, QMouseEvent, QKeyEvent, QWheelEvent, QInputEvent
8
+
9
+ from nodeeditor import _QT_API_NAME as QT_API
10
+ from nodeeditor.node_graphics_socket import QDMGraphicsSocket
11
+ from nodeeditor.node_graphics_edge import QDMGraphicsEdge
12
+ from nodeeditor.node_edge_dragging import EdgeDragging
13
+ from nodeeditor.node_edge_rerouting import EdgeRerouting
14
+ from nodeeditor.node_edge_intersect import EdgeIntersect
15
+ from nodeeditor.node_edge_snapping import EdgeSnapping
16
+ from nodeeditor.node_graphics_cutline import QDMCutLine
17
+ from nodeeditor.utils import dumpException, pp, isCTRLPressed, isSHIFTPressed, isALTPressed
18
+
19
+ from typing import TYPE_CHECKING, List, Optional
20
+
21
+ if TYPE_CHECKING:
22
+ from nodeeditor.node_node import Node
23
+ from nodeeditor.node_graphics_scene import QDMGraphicsScene
24
+
25
+ MODE_NOOP = 1 #: Mode representing ready state
26
+ MODE_EDGE_DRAG = 2 #: Mode representing when we drag edge state
27
+ MODE_EDGE_CUT = 3 #: Mode representing when we draw a cutting edge
28
+ MODE_EDGES_REROUTING = 4 #: Mode representing when we re-route existing edges
29
+ #: Mode representing when we drag a node to calculate dropping on intersecting edge
30
+ MODE_NODE_DRAG = 5
31
+
32
+ STATE_STRING = ['', 'Noop', 'Edge Drag',
33
+ 'Edge Cut', 'Edge Rerouting', 'Node Drag']
34
+
35
+ #: Distance when click on socket to enable `Drag Edge`
36
+ EDGE_DRAG_START_THRESHOLD = 50
37
+
38
+ #: Enable UnrealEngine style rerouting
39
+ EDGE_REROUTING_UE = True
40
+
41
+ #: Socket snapping distance
42
+ EDGE_SNAPPING_RADIUS = 24
43
+ #: Enable socket snapping feature
44
+ EDGE_SNAPPING = True
45
+
46
+ #: How the view invalidates its viewport after a change.
47
+ #:
48
+ #: ``FullViewportUpdate`` repaints every visible item on every change, and the
49
+ #: old mouse-move path forced one on *every* move (clearing socket highlights).
50
+ #: ``BoundingRectViewportUpdate`` repaints the union of the rectangles that
51
+ #: actually changed, so dragging a node at a zoomed-out fit costs a third of the
52
+ #: edge paints FullViewportUpdate does (36 vs 119 per pass at 120 nodes) and
53
+ #: measures 2.2x faster per step; panning and repainting are within noise of
54
+ #: ``MinimalViewportUpdate``. It is the wider of the two cheap modes on purpose:
55
+ #: every node's look is an embedded ``QGraphicsProxyWidget``, and the narrow
56
+ #: mode's region bookkeeping is what Qt documents as the one that can leave
57
+ #: widget trails across a scroll.
58
+ DEFAULT_VIEWPORT_UPDATE_MODE = (
59
+ QGraphicsView.ViewportUpdateMode.BoundingRectViewportUpdate
60
+ )
61
+
62
+ DEBUG = False
63
+ DEBUG_MMB_SCENE_ITEMS = False
64
+ DEBUG_MMB_LAST_SELECTIONS = False
65
+ DEBUG_EDGE_INTERSECT = False
66
+ DEBUG_STATE = False
67
+
68
+
69
+ class QDMGraphicsView(QGraphicsView):
70
+ """Class representing NodeEditor's `Graphics View`"""
71
+ #: pyqtSignal emitted when cursor position on the `Scene` has changed
72
+ scenePosChanged = Signal(int, int)
73
+
74
+ def __init__(self, grScene: 'QDMGraphicsScene', parent: QWidget = None) -> None:
75
+ """
76
+ :param grScene: reference to the :class:`~nodeeditor.node_graphics_scene.QDMGraphicsScene`
77
+ :type grScene: :class:`~nodeeditor.node_graphics_scene.QDMGraphicsScene`
78
+ :param parent: parent widget
79
+ :type parent: ``QWidget``
80
+
81
+ :Instance Attributes:
82
+
83
+ - **grScene** - reference to the :class:`~nodeeditor.node_graphics_scene.QDMGraphicsScene`
84
+ - **mode** - state of the `Graphics View`
85
+ - **zoomInFactor**- ``float`` - zoom step scaling, default 1.25
86
+ - **zoomClamp** - ``bool`` - do we clamp zooming or is it infinite?
87
+ - **zoom** - current zoom step
88
+ - **zoomStep** - ``int`` - the relative zoom step when zooming in/out
89
+ - **zoomRange** - ``[min, max]``
90
+
91
+ """
92
+ super().__init__(parent)
93
+ self.grScene = grScene
94
+
95
+ self.initUI()
96
+
97
+ self.setScene(self.grScene)
98
+
99
+ self.mode = MODE_NOOP
100
+ self.editingFlag = False
101
+ self.rubberBandDraggingRectangle = False
102
+
103
+ #: The socket currently drawn highlighted while CTRL-snapping, if any.
104
+ #: Tracked so a mouse-move can clear the previous one instead of
105
+ #: scanning a 248-unit square of the scene and repainting the viewport.
106
+ self._highlighted_socket: QDMGraphicsSocket | None = None
107
+
108
+ # edge dragging
109
+ self.dragging = EdgeDragging(self)
110
+
111
+ # edges re-routing
112
+ self.rerouting = EdgeRerouting(self)
113
+
114
+ # drop a node on an existing edge
115
+ self.edgeIntersect = EdgeIntersect(self)
116
+
117
+ # edge snapping
118
+ self.snapping = EdgeSnapping(
119
+ self, snapping_radius=EDGE_SNAPPING_RADIUS)
120
+
121
+ # cutline
122
+ self.cutline = QDMCutLine()
123
+ self.grScene.addItem(self.cutline)
124
+
125
+ self.last_scene_mouse_position = QPointF(0, 0)
126
+ self.zoomInFactor = 1.25
127
+ self.zoomClamp = True
128
+ self.zoom = 10
129
+ self.zoomStep = 1
130
+ self.zoomRange = [0, 10]
131
+
132
+ # listeners
133
+ self._drag_enter_listeners: list = []
134
+ self._drop_listeners: list = []
135
+
136
+ def initUI(self) -> None:
137
+ """Set up this ``QGraphicsView``"""
138
+ # self.setRenderHints(QPainter.Antialiasing | QPainter.HighQualityAntialiasing | QPainter.TextAntialiasing | QPainter.SmoothPixmapTransform)
139
+ self.setRenderHints(
140
+ QPainter.Antialiasing | QPainter.TextAntialiasing | QPainter.SmoothPixmapTransform)
141
+
142
+ self.setViewportUpdateMode(DEFAULT_VIEWPORT_UPDATE_MODE)
143
+
144
+ self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
145
+ self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
146
+
147
+ self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse)
148
+ self.setDragMode(QGraphicsView.RubberBandDrag)
149
+
150
+ # enable dropping
151
+ self.setAcceptDrops(True)
152
+
153
+ def isSnappingEnabled(self, event: 'QInputEvent' = None) -> bool:
154
+ """Returns ``True`` if snapping is currently enabled"""
155
+ return EDGE_SNAPPING and isCTRLPressed(event) if event else True
156
+
157
+ def resetMode(self) -> None:
158
+ """Helper function to re-set the grView's State Machine state to the default"""
159
+ self.mode = MODE_NOOP
160
+
161
+ def dragEnterEvent(self, event: Optional[QDragEnterEvent]) -> None:
162
+ """Trigger our registered `Drag Enter` events"""
163
+ for callback in self._drag_enter_listeners:
164
+ callback(event)
165
+
166
+ def dropEvent(self, event: Optional[QDropEvent]) -> None:
167
+ """Trigger our registered `Drop` events"""
168
+ for callback in self._drop_listeners:
169
+ callback(event)
170
+
171
+ def addDragEnterListener(self, callback: 'function') -> None:
172
+ """
173
+ Register callback for `Drag Enter` event
174
+
175
+ :param callback: callback function
176
+ """
177
+ self._drag_enter_listeners.append(callback)
178
+
179
+ def addDropListener(self, callback: 'function') -> None:
180
+ """
181
+ Register callback for `Drop` event
182
+
183
+ :param callback: callback function
184
+ """
185
+ self._drop_listeners.append(callback)
186
+
187
+ def mousePressEvent(self, event: Optional[QMouseEvent]) -> None:
188
+ """Dispatch Qt's mousePress event to corresponding function below"""
189
+ if event is None:
190
+ return
191
+ if event.button() == Qt.MouseButton.MiddleButton:
192
+ self.middleMouseButtonPress(event)
193
+ elif event.button() == Qt.MouseButton.LeftButton:
194
+ self.leftMouseButtonPress(event)
195
+ elif event.button() == Qt.MouseButton.RightButton:
196
+ self.rightMouseButtonPress(event)
197
+ else:
198
+ super().mousePressEvent(event)
199
+
200
+ def mouseReleaseEvent(self, event: Optional[QMouseEvent]) -> None:
201
+ """Dispatch Qt's mouseRelease event to corresponding function below"""
202
+ if event is None:
203
+ return
204
+ if self.mode == MODE_EDGE_DRAG:
205
+ item = self.getItemAtClick(event)
206
+ if item is None: # Clicked on empty space
207
+ self.dragging.cancelDragEdge()
208
+ return
209
+ if event.button() == Qt.MouseButton.MiddleButton:
210
+ self.middleMouseButtonRelease(event)
211
+ elif event.button() == Qt.MouseButton.LeftButton:
212
+ self.leftMouseButtonRelease(event)
213
+ elif event.button() == Qt.MouseButton.RightButton:
214
+ self.rightMouseButtonRelease(event)
215
+ else:
216
+ super().mouseReleaseEvent(event)
217
+
218
+ def middleMouseButtonPress(self, event: QMouseEvent) -> None:
219
+ """When Middle mouse button was pressed"""
220
+
221
+ item = self.getItemAtClick(event)
222
+
223
+ # debug printout
224
+ if DEBUG_MMB_SCENE_ITEMS:
225
+ if isinstance(item, QDMGraphicsEdge):
226
+ print("MMB DEBUG:", item.edge, "\n\t",
227
+ item.edge.grEdge if item.edge.grEdge is not None else None)
228
+ return
229
+
230
+ if isinstance(item, QDMGraphicsSocket):
231
+ print("MMB DEBUG:", item.socket, "socket_type:", item.socket.socket_type,
232
+ "has edges:", "no" if item.socket.edges == [] else "")
233
+ if item.socket.edges:
234
+ for edge in item.socket.edges:
235
+ print("\t", edge)
236
+ return
237
+
238
+ if DEBUG_MMB_SCENE_ITEMS and (item is None or self.mode == MODE_EDGES_REROUTING):
239
+ print("SCENE:")
240
+ print(" Nodes:")
241
+ for node in self.grScene.scene.nodes:
242
+ print("\t", node)
243
+ print(" Edges:")
244
+ for edge in self.grScene.scene.edges:
245
+ print("\t", edge, "\n\t\tgrEdge:",
246
+ edge.grEdge if edge.grEdge is not None else None)
247
+
248
+ if isCTRLPressed(event):
249
+ print(" Graphic Items in GraphicScene:")
250
+ for item in self.grScene.items():
251
+ print(' ', item)
252
+
253
+ if DEBUG_MMB_LAST_SELECTIONS and isSHIFTPressed(event):
254
+ print("scene _last_selected_items:",
255
+ self.grScene.scene._last_selected_items)
256
+ return
257
+
258
+ # faking events for enable MMB dragging the scene
259
+ if QT_API in ("pyqt5", "pyside2"):
260
+ releaseEvent = QMouseEvent(
261
+ QEvent.Type.MouseButtonRelease,
262
+ event.localPos(), # type: ignore
263
+ event.screenPos(), # type: ignore
264
+ Qt.MouseButton.LeftButton,
265
+ Qt.MouseButton.NoButton,
266
+ event.modifiers())
267
+
268
+ elif QT_API in ("pyqt6", "pyside6"):
269
+ releaseEvent = QMouseEvent(
270
+ QEvent.Type.MouseButtonRelease,
271
+ # ----------------- Change it to localPos if dont work.----------------------
272
+ event.position(),
273
+ # ---------------------------------------
274
+ Qt.MouseButton.LeftButton,
275
+ Qt.MouseButton.NoButton,
276
+ event.modifiers())
277
+
278
+ super().mouseReleaseEvent(releaseEvent)
279
+ self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag)
280
+
281
+ if QT_API in ("pyqt5", "pyside2"):
282
+ fakeEvent = QMouseEvent(
283
+ event.type(),
284
+ event.localPos(), # type: ignore
285
+ event.screenPos(), # type: ignore
286
+ Qt.MouseButton.LeftButton,
287
+ event.buttons() | Qt.MouseButton.LeftButton,
288
+ event.modifiers())
289
+ elif QT_API in ("pyqt6", "pyside6"):
290
+ fakeEvent = QMouseEvent(
291
+ event.type(),
292
+ event.position(),
293
+ Qt.MouseButton.LeftButton,
294
+ event.buttons() | Qt.MouseButton.LeftButton,
295
+ event.modifiers())
296
+ super().mousePressEvent(fakeEvent)
297
+
298
+ def middleMouseButtonRelease(self, event: QMouseEvent) -> None:
299
+ """When Middle mouse button was released"""
300
+ if QT_API in ("pyqt5", "pyside2"):
301
+ fakeEvent = QMouseEvent(
302
+ event.type(),
303
+ event.localPos(), # type: ignore
304
+ event.screenPos(), # type: ignore
305
+ Qt.MouseButton.LeftButton,
306
+ event.buttons() & ~Qt.MouseButton.LeftButton,
307
+ event.modifiers())
308
+ elif QT_API in ("pyqt6", "pyside6"):
309
+ fakeEvent = QMouseEvent(
310
+ event.type(),
311
+ event.position(),
312
+ Qt.MouseButton.LeftButton,
313
+ event.buttons() & ~Qt.MouseButton.LeftButton,
314
+ event.modifiers())
315
+ super().mouseReleaseEvent(fakeEvent)
316
+ self.setDragMode(QGraphicsView.DragMode.RubberBandDrag)
317
+
318
+ def leftMouseButtonPress(self, event: QMouseEvent) -> None:
319
+ """When Left mouse button was pressed"""
320
+
321
+ # get the item we clicked on
322
+ item = self.getItemAtClick(event)
323
+
324
+ if item is None:
325
+ if isCTRLPressed(event):
326
+ self.mode = MODE_EDGE_CUT
327
+ if QT_API in ("pyqt5", "pyside2"):
328
+ fakeEvent = QMouseEvent(
329
+ QEvent.Type.MouseButtonRelease,
330
+ event.localPos(), # type: ignore
331
+ event.screenPos(), # type: ignore
332
+ Qt.MouseButton.LeftButton,
333
+ Qt.MouseButton.NoButton,
334
+ event.modifiers())
335
+ elif QT_API in ("pyqt6", "pyside6"):
336
+ fakeEvent = QMouseEvent(
337
+ QEvent.Type.MouseButtonRelease,
338
+ event.position(),
339
+ Qt.MouseButton.LeftButton,
340
+ Qt.MouseButton.NoButton,
341
+ event.modifiers())
342
+ super().mouseReleaseEvent(fakeEvent)
343
+ QApplication.setOverrideCursor(Qt.CursorShape.CrossCursor)
344
+ return
345
+ else:
346
+ self.rubberBandDraggingRectangle = True
347
+
348
+ # we store the position of last LMB click
349
+ self.last_lmb_click_scene_pos = self.mapToScene(event.pos())
350
+
351
+ # if DEBUG: print("LMB Click on", item, self.debug_modifiers(event))
352
+
353
+ # logic - Shift + LMB Node
354
+ if hasattr(item, "node") or isinstance(item, QDMGraphicsEdge) or item is None:
355
+ if isSHIFTPressed(event):
356
+ event.ignore()
357
+ if QT_API in ("pyqt5", "pyside2"):
358
+ fakeEvent = QMouseEvent(
359
+ QEvent.Type.MouseButtonPress,
360
+ event.localPos(), # type: ignore
361
+ event.screenPos(), # type: ignore
362
+ Qt.MouseButton.LeftButton,
363
+ event.buttons() | Qt.MouseButton.LeftButton,
364
+ event.modifiers() | Qt.KeyboardModifier.ControlModifier)
365
+ elif QT_API in ("pyqt6", "pyside6"):
366
+ fakeEvent = QMouseEvent(
367
+ QEvent.Type.MouseButtonPress,
368
+ event.position(),
369
+ Qt.MouseButton.LeftButton,
370
+ event.buttons() | Qt.MouseButton.LeftButton,
371
+ event.modifiers() | Qt.KeyboardModifier.ControlModifier)
372
+ super().mousePressEvent(fakeEvent)
373
+ return
374
+
375
+ if hasattr(item, "node"):
376
+ if DEBUG_EDGE_INTERSECT:
377
+ print('View::leftMouseButtonPress - Start dragging a node')
378
+ if self.mode == MODE_NOOP:
379
+ self.mode = MODE_NODE_DRAG
380
+ self.edgeIntersect.enterState(item.node)
381
+ if DEBUG_EDGE_INTERSECT:
382
+ print(">> edgeIntersect start:",
383
+ self.edgeIntersect.draggedNode)
384
+
385
+ # support for snapping
386
+ if self.isSnappingEnabled(event):
387
+ item = self.snapping.getSnappedSocketItem(event)
388
+
389
+ if isinstance(item, QDMGraphicsSocket):
390
+
391
+ if isALTPressed(event) and self.mode == MODE_NOOP:
392
+ # ALT + LMB on a socket to see the data processed by that node.
393
+ socket = item.socket
394
+ node = socket.node
395
+ # Emit signal with both socket and node
396
+ self.grScene.socketClicked.emit(socket, node)
397
+
398
+ return
399
+
400
+ if self.mode == MODE_NOOP and isCTRLPressed(event):
401
+ socket = item.socket
402
+ if socket.hasAnyEdge():
403
+ self.mode = MODE_EDGES_REROUTING
404
+ self.rerouting.startRerouting(socket)
405
+ return
406
+
407
+ if self.mode == MODE_NOOP:
408
+ self.mode = MODE_EDGE_DRAG
409
+ self.dragging.edgeDragStart(item)
410
+ return
411
+
412
+ if self.mode == MODE_EDGE_DRAG and item is not None:
413
+ res = self.dragging.edgeDragEnd(item)
414
+ if res:
415
+ return
416
+
417
+ super().mousePressEvent(event)
418
+
419
+ def leftMouseButtonRelease(self, event: QMouseEvent) -> None:
420
+ """When Left mouse button was released"""
421
+
422
+ # get the item on which we release the mouse button on
423
+ item = self.getItemAtClick(event)
424
+
425
+ try:
426
+ # logic - Shift + LMB release (add selection)
427
+ if hasattr(item, "node") or isinstance(item, QDMGraphicsEdge) or item is None:
428
+ if isSHIFTPressed(event):
429
+ event.ignore()
430
+ if QT_API in ("pyqt5", "pyside2"):
431
+ fakeEvent = QMouseEvent(
432
+ event.type(),
433
+ event.localPos(), # type: ignore
434
+ event.screenPos(), # type: ignore
435
+ Qt.LeftButton, # type: ignore
436
+ Qt.NoButton, # type: ignore
437
+ event.modifiers() | Qt.ControlModifier # type: ignore
438
+ )
439
+ elif QT_API in ("pyqt6", "pyside6"):
440
+ fakeEvent = QMouseEvent(
441
+ event.type(),
442
+ event.position(),
443
+ Qt.MouseButton.LeftButton,
444
+ Qt.MouseButton.NoButton,
445
+ event.modifiers() | Qt.KeyboardModifier.ControlModifier)
446
+ super().mouseReleaseEvent(fakeEvent)
447
+ return
448
+
449
+ if self.mode == MODE_EDGE_DRAG:
450
+ if self.distanceBetweenClickAndReleaseIsOff(event):
451
+ if self.isSnappingEnabled(event):
452
+ item = self.snapping.getSnappedSocketItem(event)
453
+ if item is not None:
454
+ res = self.dragging.edgeDragEnd(item)
455
+ if res:
456
+ return
457
+
458
+ if self.mode == MODE_EDGES_REROUTING:
459
+ if self.isSnappingEnabled(event):
460
+ item = self.snapping.getSnappedSocketItem(event)
461
+
462
+ if not EDGE_REROUTING_UE:
463
+ # version 2 -- more consistent with the nodeeditor?
464
+ if not self.rerouting.first_mb_release:
465
+ # for confirmation of first MB release
466
+ self.rerouting.first_mb_release = True
467
+ # skip any re-routing until first MB was released
468
+ return
469
+
470
+ self.rerouting.stopRerouting(
471
+ item.socket if isinstance(item, QDMGraphicsSocket) else None)
472
+
473
+ # don't forget to end the REROUTING MODE
474
+
475
+ self.mode = MODE_NOOP
476
+
477
+ if self.mode == MODE_EDGE_CUT:
478
+ self.cutIntersectingEdges()
479
+ self.cutline.line_points = []
480
+ self.cutline.update()
481
+ QApplication.setOverrideCursor(Qt.CursorShape.ArrowCursor)
482
+ self.mode = MODE_NOOP
483
+ return
484
+
485
+ if self.mode == MODE_NODE_DRAG:
486
+ scenepos = self.mapToScene(event.pos())
487
+ self.edgeIntersect.leaveState(scenepos.x(), scenepos.y())
488
+ try:
489
+ self._dropNodesIntoGroupAt(scenepos)
490
+ except Exception:
491
+ pass
492
+ try:
493
+ self._clearGroupDropHighlight()
494
+ except Exception:
495
+ pass
496
+ self.mode = MODE_NOOP
497
+ self.update()
498
+
499
+ if self.rubberBandDraggingRectangle:
500
+ self.rubberBandDraggingRectangle = False
501
+ current_selected_items = self.grScene.selectedItems()
502
+
503
+ if current_selected_items != self.grScene.scene._last_selected_items:
504
+ if current_selected_items == []:
505
+ self.grScene.itemsDeselected.emit()
506
+ else:
507
+ self.grScene.itemSelected.emit()
508
+ self.grScene.scene._last_selected_items = current_selected_items
509
+
510
+ # the rubber band rectangle doesn't disappear without handling the event
511
+ super().mouseReleaseEvent(event)
512
+ return
513
+
514
+ # otherwise deselect everything
515
+ if item is None:
516
+ self.grScene.itemsDeselected.emit()
517
+
518
+ except:
519
+ dumpException()
520
+
521
+ super().mouseReleaseEvent(event)
522
+
523
+ def rightMouseButtonPress(self, event: QMouseEvent) -> None:
524
+ """When Right mouse button was pressed"""
525
+ super().mousePressEvent(event)
526
+
527
+ def rightMouseButtonRelease(self, event: QMouseEvent) -> None:
528
+ """When Right mouse button was release"""
529
+
530
+ # cannot be because with dragging RMB we spawn Create New Node Context Menu
531
+ # However, you could use this if you want to cancel with RMB
532
+ # if self.mode == MODE_EDGE_DRAG:
533
+ # self.dragging.edgeDragEnd(None)
534
+ # return
535
+
536
+ super().mouseReleaseEvent(event)
537
+
538
+ def mouseMoveEvent(self, event: Optional[QMouseEvent]) -> None:
539
+ """Overriden Qt's ``mouseMoveEvent`` handling Scene/View logic"""
540
+
541
+ if event is None:
542
+ return
543
+
544
+ scenepos: QPointF = self.mapToScene(event.pos())
545
+
546
+ try:
547
+ # Hover highlight is a CTRL-snapping affordance only. The old code
548
+ # scanned a 248x248-unit square for sockets on every single move
549
+ # and, when it found any, repainted the whole viewport - which is
550
+ # what made panning a large graph stutter. Track the one socket
551
+ # that can be highlighted and repaint just that item.
552
+ if self.isSnappingEnabled(event):
553
+ gr_socket, scenepos = self.snapping.getSnappedToSocketPosition(
554
+ scenepos)
555
+ self._setHighlightedSocket(
556
+ gr_socket if isinstance(gr_socket, QDMGraphicsSocket) else None)
557
+ else:
558
+ self._setHighlightedSocket(None)
559
+
560
+ if self.mode == MODE_EDGE_DRAG:
561
+ self.dragging.updateDestination(scenepos.x(), scenepos.y())
562
+
563
+ if self.mode == MODE_NODE_DRAG:
564
+ self.edgeIntersect.update(scenepos.x(), scenepos.y())
565
+ try:
566
+ self._updateGroupDropHighlight(scenepos)
567
+ except Exception:
568
+ pass
569
+
570
+ if self.mode == MODE_EDGES_REROUTING:
571
+ self.rerouting.updateScenePos(scenepos.x(), scenepos.y())
572
+
573
+ if self.mode == MODE_EDGE_CUT and self.cutline is not None:
574
+ self.cutline.line_points.append(scenepos)
575
+ self.cutline.update()
576
+
577
+ except Exception as e:
578
+ dumpException()
579
+
580
+ self.last_scene_mouse_position = scenepos
581
+
582
+ self.scenePosChanged.emit(int(scenepos.x()), int(scenepos.y()))
583
+
584
+ super().mouseMoveEvent(event)
585
+
586
+ def keyPressEvent(self, event: Optional[QKeyEvent]) -> None:
587
+ """
588
+ .. note::
589
+ This overridden Qt's method was used for handling key shortcuts, before we implemented proper
590
+ ``QWindow`` with Actions and Menu. Still the commented code serves as an example on how to handle
591
+ key presses without Qt's framework for Actions and shortcuts. There is also an example on
592
+ how to solve the problem when a Node contains Text/LineEdit and we press the `Delete`
593
+ key (also serving to delete `Node`)
594
+
595
+ :param event: Qt's Key event
596
+ :type event: ``QKeyEvent``
597
+ :return:
598
+ """
599
+
600
+ if event.key() == Qt.Key.Key_Escape:
601
+ if self.mode == MODE_EDGE_DRAG:
602
+ self.dragging.cancelDragEdge()
603
+ return
604
+
605
+ super().keyPressEvent(event)
606
+
607
+ def cutIntersectingEdges(self) -> None:
608
+ """Compare which `Edges` intersect with current `Cut line` and delete them safely"""
609
+ for ix in range(len(self.cutline.line_points) - 1):
610
+ p1 = self.cutline.line_points[ix]
611
+ p2 = self.cutline.line_points[ix + 1]
612
+
613
+ # @TODO: we could collect all touched nodes, and notify them once after all edges removed
614
+ # we could cut 3 edges leading to a single nodeeditor this will notify it 3x
615
+ # maybe we could use some Notifier class with methods collect() and dispatch()
616
+ for edge in self.grScene.scene.edges.copy():
617
+ try:
618
+ gr = getattr(edge, 'grEdge', None)
619
+ if gr is None:
620
+ continue
621
+ # never cut hidden internal edges of collapsed groups
622
+ try:
623
+ if not gr.isVisible():
624
+ continue
625
+ except Exception:
626
+ pass
627
+ if gr.intersectsWith(p1, p2):
628
+ edge.remove()
629
+ except Exception:
630
+ continue
631
+ self.grScene.scene.history.storeHistory(
632
+ "Delete cutted edges", setModified=True)
633
+
634
+ def _setHighlightedSocket(self, gr_socket: Optional['QDMGraphicsSocket']) -> None:
635
+ """Highlight at most one socket, repainting only the two it touches.
636
+
637
+ ``getSnappedToSocketPosition`` sets ``isHighlighted`` on the socket it
638
+ finds; this method keeps that flag in step with what was highlighted
639
+ last move, so removing the highlight is one repaint of one small item
640
+ rather than a full-viewport redraw.
641
+ """
642
+ if gr_socket is self._highlighted_socket:
643
+ return
644
+
645
+ previous = self._highlighted_socket
646
+ self._highlighted_socket = gr_socket
647
+
648
+ if previous is not None:
649
+ try:
650
+ previous.isHighlighted = False
651
+ previous.update()
652
+ except RuntimeError:
653
+ pass # C++ side already gone
654
+
655
+ if gr_socket is not None:
656
+ gr_socket.isHighlighted = True
657
+ gr_socket.update()
658
+
659
+ def setSocketHighlights(self, scenepos: QPointF, highlighted: bool = True, radius: float = 50):
660
+ """Set/disable socket highlights in Scene area defined by `scenepos` and `radius`"""
661
+ scanrect = QRectF(
662
+ scenepos.x() - radius,
663
+ scenepos.y() - radius,
664
+ radius * 2,
665
+ radius * 2
666
+ )
667
+ # Get all items and filter for sockets
668
+ scene_items = self.grScene.items(scanrect)
669
+ socket_items: List[QDMGraphicsSocket] = [
670
+ item for item in scene_items if isinstance(item, QDMGraphicsSocket)
671
+ ]
672
+
673
+ # Set highlight state
674
+ for grSocket in socket_items:
675
+ grSocket.isHighlighted = highlighted
676
+
677
+ return socket_items
678
+
679
+ def _draggedNodes(self):
680
+ """Nodes being dragged (selected .node items, fallback to edgeIntersect)."""
681
+ nodes = []
682
+ try:
683
+ for item in list(self.grScene.selectedItems()):
684
+ try:
685
+ n = getattr(item, 'node', None)
686
+ if n is not None and n not in nodes:
687
+ nodes.append(n)
688
+ except Exception:
689
+ continue
690
+ except Exception:
691
+ pass
692
+ if not nodes:
693
+ try:
694
+ dn = getattr(getattr(self, 'edgeIntersect', None), 'draggedNode', None)
695
+ if dn is not None:
696
+ nodes = [dn]
697
+ except Exception:
698
+ pass
699
+ return nodes
700
+
701
+ def _clearGroupDropHighlight(self) -> None:
702
+ try:
703
+ scene = self.grScene.scene
704
+ for grp in list(getattr(scene, 'groups', [])):
705
+ try:
706
+ if getattr(grp, '_drop_highlight', False):
707
+ grp.setDropHighlight(False)
708
+ except Exception:
709
+ continue
710
+ except Exception:
711
+ pass
712
+
713
+ def _updateGroupDropHighlight(self, scenepos) -> None:
714
+ try:
715
+ scene = self.grScene.scene
716
+ nodes = self._draggedNodes()
717
+ if not nodes:
718
+ self._clearGroupDropHighlight()
719
+ return
720
+ try:
721
+ target = scene.findGroupForDrop(scenepos, exclude_nodes=nodes)
722
+ except Exception:
723
+ target = None
724
+ for grp in list(getattr(scene, 'groups', [])):
725
+ try:
726
+ grp.setDropHighlight(grp is target)
727
+ except Exception:
728
+ continue
729
+ except Exception:
730
+ pass
731
+
732
+ def _dropNodesIntoGroupAt(self, scenepos) -> bool:
733
+ try:
734
+ scene = self.grScene.scene
735
+ nodes = self._draggedNodes()
736
+ if not nodes:
737
+ return False
738
+ changed = scene.dropNodesIntoGroup(nodes, scenepos)
739
+ if changed:
740
+ try:
741
+ scene.history.storeHistory("Moved nodes into group", setModified=True)
742
+ except Exception:
743
+ pass
744
+ return changed
745
+ except Exception:
746
+ return False
747
+
748
+ def deleteSelected(self) -> None:
749
+ """Delete selected items. Delete-key on a Group = delete Group + Children."""
750
+ try:
751
+ from nodeeditor.node_group import Group
752
+ except Exception:
753
+ Group = None # type: ignore
754
+
755
+ selected = list(self.grScene.selectedItems())
756
+ groups = [it for it in selected if Group is not None and isinstance(it, Group)]
757
+ # delete groups + children first
758
+ for grp in groups:
759
+ try:
760
+ grp.deleteWithChildren()
761
+ except Exception:
762
+ continue
763
+ # delete remaining nodes/edges that still exist (nodes from deleted groups are gone)
764
+ scene = self.grScene.scene
765
+ for item in selected:
766
+ try:
767
+ if Group is not None and isinstance(item, Group):
768
+ continue
769
+ if isinstance(item, QDMGraphicsEdge):
770
+ try:
771
+ if getattr(item, 'edge', None) in scene.edges:
772
+ item.edge.remove()
773
+ except Exception:
774
+ pass
775
+ elif hasattr(item, 'node'):
776
+ try:
777
+ if getattr(item, 'node', None) in scene.nodes:
778
+ item.node.remove()
779
+ except Exception:
780
+ pass
781
+ except Exception:
782
+ continue
783
+ self.grScene.scene.history.storeHistory(
784
+ "Delete selected", setModified=True)
785
+
786
+ def debug_modifiers(self, event):
787
+ """Helper function get string if we hold Ctrl, Shift or Alt modifier keys"""
788
+ out = "MODS: "
789
+ if isSHIFTPressed(event):
790
+ out += "SHIFT "
791
+ if isCTRLPressed(event):
792
+ out += "CTRL "
793
+ if isALTPressed(event):
794
+ out += "ALT "
795
+ return out
796
+
797
+ def getItemAtClick(self, event: QMouseEvent) -> Optional['QGraphicsItem']:
798
+ """Return the object on which we've clicked/release mouse button
799
+
800
+ :param event: Qt's mouse or key event
801
+ :type event: ``QEvent``
802
+ :return: ``QGraphicsItem`` which the mouse event happened or ``None``
803
+ """
804
+ pos = event.pos()
805
+ obj = self.itemAt(pos)
806
+ return obj
807
+
808
+ def distanceBetweenClickAndReleaseIsOff(self, event: QMouseEvent) -> bool:
809
+ """ Measures if we are too far from the last Mouse button click scene position.
810
+ This is used for detection if we release too far after we clicked on a `Socket`
811
+
812
+ :param event: Qt's mouse event
813
+ :type event: ``QMouseEvent``
814
+ :return: ``True`` if we released too far from where we clicked before
815
+ """
816
+ new_lmb_release_scene_pos = self.mapToScene(event.pos())
817
+ dist_scene = new_lmb_release_scene_pos - self.last_lmb_click_scene_pos
818
+ edge_drag_threshold_sq = EDGE_DRAG_START_THRESHOLD*EDGE_DRAG_START_THRESHOLD
819
+ return (dist_scene.x()*dist_scene.x() + dist_scene.y()*dist_scene.y()) > edge_drag_threshold_sq
820
+
821
+ def zoomIn(self):
822
+ """Step the zoom level up and return the factor to scale the scene by.
823
+
824
+ :return: the zoom factor to apply
825
+ :rtype: ``float``
826
+ """
827
+ # self.scale(self.zoomInFactor, self.zoomInFactor)
828
+ self.zoom += self.zoomStep
829
+ return self.zoomInFactor
830
+
831
+ def zoomOut(self):
832
+ """Step the zoom level down and return the factor to scale the scene by.
833
+
834
+ :return: the zoom factor to apply
835
+ :rtype: ``float``
836
+ """
837
+ # self.scale(1 / self.zoomInFactor, 1 / self.zoomInFactor)
838
+ self.zoom -= self.zoomStep
839
+ return 1 / self.zoomInFactor
840
+
841
+ def applyZoom(self, zoomFactor: float) -> None:
842
+ """Scale the scene by ``zoomFactor``, keeping the zoom level inside the configured range.
843
+
844
+ :param zoomFactor: the factor to scale the scene by
845
+ :type zoomFactor: ``float``
846
+ """
847
+ clamped = False
848
+ if self.zoom < self.zoomRange[0]:
849
+ self.zoom, clamped = self.zoomRange[0], True
850
+ if self.zoom > self.zoomRange[1]:
851
+ self.zoom, clamped = self.zoomRange[1], True
852
+ # set scene scale
853
+ if not clamped or self.zoomClamp is False:
854
+ self.scale(zoomFactor, zoomFactor)
855
+ self._syncDetailLod()
856
+
857
+ def _syncDetailLod(self) -> None:
858
+ """Match the scene's item detail to the view's current zoom.
859
+
860
+ Called before the scene is painted, never from inside the paint pass:
861
+ hiding an item while Qt is painting leaves the pixels it already claimed
862
+ on screen, and those ghosts then drift with every scroll.
863
+ """
864
+ gr_scene = self.scene()
865
+ updater = getattr(gr_scene, "updateDetailLod", None)
866
+ if updater is None:
867
+ return
868
+ if updater(abs(self.transform().m11())):
869
+ # The flip changed which items are visible: repaint so the area the
870
+ # now hidden items occupied is cleared in this pass, not the next.
871
+ self.viewport().update()
872
+
873
+ def paintEvent(self, event) -> None:
874
+ """Sync the level of detail with the zoom, then paint the scene."""
875
+ self._syncDetailLod()
876
+ super().paintEvent(event)
877
+
878
+ def wheelEvent(self, event: Optional[QWheelEvent]) -> None:
879
+ """overridden Qt's ``wheelEvent``. This handles zooming"""
880
+ if event is None:
881
+ return
882
+
883
+ if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
884
+ # calculate our zoom Factor
885
+ zoomOutFactor = 1 / self.zoomInFactor
886
+
887
+ # calculate zoom
888
+ if event.angleDelta().y() > 0:
889
+ zoomFactor = self.zoomIn()
890
+ else:
891
+ zoomFactor = self.zoomOut()
892
+
893
+ self.applyZoom(zoomFactor)
894
+
895
+ elif event.modifiers() == Qt.KeyboardModifier.ShiftModifier:
896
+ # Redirect vertical scrolling to horizontal scrolling
897
+ scroll_amount = event.angleDelta().y() # Get the vertical delta
898
+ scroll_bar = self.horizontalScrollBar()
899
+ if scroll_bar is not None:
900
+ scroll_bar.setValue(scroll_bar.value() - scroll_amount)
901
+ # self.horizontalScrollBar().setValue(
902
+ # self.horizontalScrollBar().value() - scroll_amount)
903
+ # self.horizontalScrollBar().setValue(2)
904
+
905
+ else:
906
+ # Normal scrolling
907
+ super().wheelEvent(event)