pygpt-net 2.6.58__py3-none-any.whl → 2.6.60__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.
- pygpt_net/CHANGELOG.txt +10 -0
- pygpt_net/__init__.py +3 -3
- pygpt_net/app.py +9 -5
- pygpt_net/controller/__init__.py +1 -0
- pygpt_net/controller/presets/editor.py +442 -39
- pygpt_net/core/agents/custom/__init__.py +275 -0
- pygpt_net/core/agents/custom/debug.py +64 -0
- pygpt_net/core/agents/custom/factory.py +109 -0
- pygpt_net/core/agents/custom/graph.py +71 -0
- pygpt_net/core/agents/custom/llama_index/__init__.py +10 -0
- pygpt_net/core/agents/custom/llama_index/factory.py +89 -0
- pygpt_net/core/agents/custom/llama_index/router_streamer.py +106 -0
- pygpt_net/core/agents/custom/llama_index/runner.py +529 -0
- pygpt_net/core/agents/custom/llama_index/stream.py +56 -0
- pygpt_net/core/agents/custom/llama_index/utils.py +242 -0
- pygpt_net/core/agents/custom/logging.py +50 -0
- pygpt_net/core/agents/custom/memory.py +51 -0
- pygpt_net/core/agents/custom/router.py +116 -0
- pygpt_net/core/agents/custom/router_streamer.py +187 -0
- pygpt_net/core/agents/custom/runner.py +454 -0
- pygpt_net/core/agents/custom/schema.py +125 -0
- pygpt_net/core/agents/custom/utils.py +181 -0
- pygpt_net/core/agents/provider.py +72 -7
- pygpt_net/core/agents/runner.py +7 -4
- pygpt_net/core/agents/runners/helpers.py +1 -1
- pygpt_net/core/agents/runners/llama_workflow.py +3 -0
- pygpt_net/core/agents/runners/openai_workflow.py +8 -1
- pygpt_net/core/filesystem/parser.py +37 -24
- pygpt_net/{ui/widget/builder → core/node_editor}/__init__.py +2 -2
- pygpt_net/core/{builder → node_editor}/graph.py +11 -218
- pygpt_net/core/node_editor/models.py +111 -0
- pygpt_net/core/node_editor/types.py +76 -0
- pygpt_net/core/node_editor/utils.py +17 -0
- pygpt_net/core/render/web/renderer.py +10 -8
- pygpt_net/data/config/config.json +3 -3
- pygpt_net/data/config/models.json +3 -3
- pygpt_net/data/locale/locale.en.ini +4 -4
- pygpt_net/data/locale/plugin.cmd_system.en.ini +68 -0
- pygpt_net/item/agent.py +5 -1
- pygpt_net/item/preset.py +19 -1
- pygpt_net/plugin/cmd_system/config.py +377 -1
- pygpt_net/plugin/cmd_system/plugin.py +52 -8
- pygpt_net/plugin/cmd_system/runner.py +508 -32
- pygpt_net/plugin/cmd_system/winapi.py +481 -0
- pygpt_net/plugin/cmd_system/worker.py +88 -15
- pygpt_net/provider/agents/base.py +33 -2
- pygpt_net/provider/agents/llama_index/flow_from_schema.py +92 -0
- pygpt_net/provider/agents/llama_index/workflow/supervisor.py +0 -0
- pygpt_net/provider/agents/openai/flow_from_schema.py +96 -0
- pygpt_net/provider/core/agent/json_file.py +11 -5
- pygpt_net/provider/llms/openai.py +6 -4
- pygpt_net/tools/agent_builder/tool.py +217 -52
- pygpt_net/tools/agent_builder/ui/dialogs.py +119 -24
- pygpt_net/tools/agent_builder/ui/list.py +37 -10
- pygpt_net/tools/code_interpreter/ui/html.py +2 -1
- pygpt_net/ui/dialog/preset.py +16 -1
- pygpt_net/ui/main.py +1 -1
- pygpt_net/{core/builder → ui/widget/node_editor}/__init__.py +2 -2
- pygpt_net/ui/widget/node_editor/command.py +373 -0
- pygpt_net/ui/widget/node_editor/editor.py +2038 -0
- pygpt_net/ui/widget/node_editor/item.py +492 -0
- pygpt_net/ui/widget/node_editor/node.py +1205 -0
- pygpt_net/ui/widget/node_editor/utils.py +17 -0
- pygpt_net/ui/widget/node_editor/view.py +247 -0
- pygpt_net/ui/widget/textarea/web.py +1 -1
- {pygpt_net-2.6.58.dist-info → pygpt_net-2.6.60.dist-info}/METADATA +135 -61
- {pygpt_net-2.6.58.dist-info → pygpt_net-2.6.60.dist-info}/RECORD +69 -42
- pygpt_net/core/agents/custom.py +0 -150
- pygpt_net/ui/widget/builder/editor.py +0 -2001
- {pygpt_net-2.6.58.dist-info → pygpt_net-2.6.60.dist-info}/LICENSE +0 -0
- {pygpt_net-2.6.58.dist-info → pygpt_net-2.6.60.dist-info}/WHEEL +0 -0
- {pygpt_net-2.6.58.dist-info → pygpt_net-2.6.60.dist-info}/entry_points.txt +0 -0
|
@@ -1,2001 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
# -*- coding: utf-8 -*-
|
|
3
|
-
# ================================================== #
|
|
4
|
-
# This file is a part of PYGPT package #
|
|
5
|
-
# Website: https://pygpt.net #
|
|
6
|
-
# GitHub: https://github.com/szczyglis-dev/py-gpt #
|
|
7
|
-
# MIT License #
|
|
8
|
-
# Created By : Marcin Szczygliński #
|
|
9
|
-
# Updated Date: 2025.09.19 00:00:00 #
|
|
10
|
-
# ================================================== #
|
|
11
|
-
|
|
12
|
-
from __future__ import annotations
|
|
13
|
-
from typing import Dict, Optional, List, Tuple, Any
|
|
14
|
-
from PySide6.QtCore import Qt, QPoint, QPointF, QRectF, QSizeF, QObject, Signal, Property, QEvent
|
|
15
|
-
from PySide6.QtGui import (
|
|
16
|
-
QAction, QBrush, QColor, QPainter, QPainterPath, QPen, QTransform,
|
|
17
|
-
QUndoStack, QUndoCommand, QPalette, QPainterPathStroker, QCursor,
|
|
18
|
-
QKeySequence, QShortcut, QFont
|
|
19
|
-
)
|
|
20
|
-
from PySide6.QtWidgets import (
|
|
21
|
-
QWidget, QApplication, QGraphicsView, QGraphicsScene, QGraphicsItem, QGraphicsPathItem,
|
|
22
|
-
QGraphicsObject, QGraphicsWidget, QGraphicsProxyWidget, QStyleOptionGraphicsItem,
|
|
23
|
-
QMenu, QMessageBox, QFormLayout, QLineEdit, QSpinBox, QDoubleSpinBox,
|
|
24
|
-
QCheckBox, QComboBox, QLabel, QGraphicsSimpleTextItem, QTextEdit
|
|
25
|
-
)
|
|
26
|
-
|
|
27
|
-
# Safety: check C++ pointer validity to avoid calling methods on deleted Qt objects
|
|
28
|
-
try:
|
|
29
|
-
from shiboken6 import isValid as _qt_is_valid
|
|
30
|
-
except Exception:
|
|
31
|
-
def _qt_is_valid(obj) -> bool:
|
|
32
|
-
return obj is not None
|
|
33
|
-
|
|
34
|
-
from pygpt_net.core.builder.graph import NodeGraph, NodeTypeRegistry, NodeModel, PropertyModel, ConnectionModel
|
|
35
|
-
from pygpt_net.utils import trans
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
# ------------------------ Graphics View / Scene ------------------------
|
|
39
|
-
|
|
40
|
-
class NodeGraphicsView(QGraphicsView):
|
|
41
|
-
"""Zoomable, pannable view with a grid background and extra shortcuts."""
|
|
42
|
-
def __init__(self, scene: QGraphicsScene, parent: Optional[QWidget] = None):
|
|
43
|
-
super().__init__(scene, parent)
|
|
44
|
-
self.setRenderHints(self.renderHints() |
|
|
45
|
-
QPainter.Antialiasing | QPainter.TextAntialiasing | QPainter.SmoothPixmapTransform)
|
|
46
|
-
self.setViewportUpdateMode(QGraphicsView.FullViewportUpdate)
|
|
47
|
-
self.setDragMode(QGraphicsView.RubberBandDrag)
|
|
48
|
-
self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse)
|
|
49
|
-
self.setResizeAnchor(QGraphicsView.AnchorViewCenter)
|
|
50
|
-
self.setFocusPolicy(Qt.StrongFocus)
|
|
51
|
-
|
|
52
|
-
self._zoom = 1.0
|
|
53
|
-
self._zoom_step = 1.15
|
|
54
|
-
self._min_zoom = 0.2
|
|
55
|
-
self._max_zoom = 3.0
|
|
56
|
-
|
|
57
|
-
self._panning = False
|
|
58
|
-
self._space_panning = False
|
|
59
|
-
self._last_pan_pos = None
|
|
60
|
-
|
|
61
|
-
def drawBackground(self, painter: QPainter, rect: QRectF):
|
|
62
|
-
parent_editor = self.parent() # NodeEditor
|
|
63
|
-
color_back = getattr(parent_editor, "_grid_back_color", QColor(35, 35, 38))
|
|
64
|
-
color_pen = getattr(parent_editor, "_grid_pen_color", QColor(55, 55, 60))
|
|
65
|
-
painter.fillRect(rect, color_back)
|
|
66
|
-
pen = QPen(color_pen)
|
|
67
|
-
pen.setWidthF(1.0)
|
|
68
|
-
painter.setPen(pen)
|
|
69
|
-
grid = 20
|
|
70
|
-
left = int(rect.left()) - (int(rect.left()) % grid)
|
|
71
|
-
top = int(rect.top()) - (int(rect.top()) % grid)
|
|
72
|
-
x = left
|
|
73
|
-
while x < rect.right():
|
|
74
|
-
painter.drawLine(x, rect.top(), x, rect.bottom())
|
|
75
|
-
x += grid
|
|
76
|
-
y = top
|
|
77
|
-
while y < rect.bottom():
|
|
78
|
-
painter.drawLine(rect.left(), y, rect.right(), y)
|
|
79
|
-
y += grid
|
|
80
|
-
|
|
81
|
-
def keyPressEvent(self, e):
|
|
82
|
-
if e.key() == Qt.Key_Escape:
|
|
83
|
-
editor = self.parent() # NodeEditor
|
|
84
|
-
if editor and getattr(editor, "_wire_state", "idle") != "idle":
|
|
85
|
-
editor._dbg("ESC -> cancel interactive wire")
|
|
86
|
-
editor._cancel_interactive_connection()
|
|
87
|
-
e.accept()
|
|
88
|
-
return
|
|
89
|
-
|
|
90
|
-
if e.key() == Qt.Key_Space and not self._space_panning:
|
|
91
|
-
self._space_panning = True
|
|
92
|
-
self.setCursor(Qt.OpenHandCursor)
|
|
93
|
-
e.accept()
|
|
94
|
-
return
|
|
95
|
-
if e.key() in (Qt.Key_Delete, Qt.Key_Backspace):
|
|
96
|
-
editor = self.parent() # NodeEditor
|
|
97
|
-
if editor and self.scene():
|
|
98
|
-
editor._dbg("Key DEL/BACKSPACE pressed -> delete selected connections")
|
|
99
|
-
editor._delete_selected_connections()
|
|
100
|
-
e.accept()
|
|
101
|
-
return
|
|
102
|
-
super().keyPressEvent(e)
|
|
103
|
-
|
|
104
|
-
def keyReleaseEvent(self, e):
|
|
105
|
-
if e.key() == Qt.Key_Space and self._space_panning:
|
|
106
|
-
self._space_panning = False
|
|
107
|
-
if not self._panning:
|
|
108
|
-
self.setCursor(Qt.ArrowCursor)
|
|
109
|
-
e.accept()
|
|
110
|
-
return
|
|
111
|
-
super().keyReleaseEvent(e)
|
|
112
|
-
|
|
113
|
-
def wheelEvent(self, e):
|
|
114
|
-
if e.modifiers() & Qt.ControlModifier:
|
|
115
|
-
self._apply_zoom(self._zoom_step if e.angleDelta().y() > 0 else 1.0 / self._zoom_step)
|
|
116
|
-
e.accept()
|
|
117
|
-
return
|
|
118
|
-
super().wheelEvent(e)
|
|
119
|
-
|
|
120
|
-
def mousePressEvent(self, e):
|
|
121
|
-
if e.button() == Qt.MiddleButton or (e.button() == Qt.LeftButton and self._space_panning):
|
|
122
|
-
self._panning = True
|
|
123
|
-
self._last_pan_pos = e.position()
|
|
124
|
-
self.setCursor(Qt.ClosedHandCursor)
|
|
125
|
-
e.accept()
|
|
126
|
-
return
|
|
127
|
-
super().mousePressEvent(e)
|
|
128
|
-
|
|
129
|
-
def mouseMoveEvent(self, e):
|
|
130
|
-
if self._panning and self._last_pan_pos is not None:
|
|
131
|
-
delta = e.position() - self._last_pan_pos
|
|
132
|
-
self._last_pan_pos = e.position()
|
|
133
|
-
self.horizontalScrollBar().setValue(self.horizontalScrollBar().value() - int(delta.x()))
|
|
134
|
-
self.verticalScrollBar().setValue(self.verticalScrollBar().value() - int(delta.y()))
|
|
135
|
-
e.accept()
|
|
136
|
-
return
|
|
137
|
-
super().mouseMoveEvent(e)
|
|
138
|
-
|
|
139
|
-
def mouseReleaseEvent(self, e):
|
|
140
|
-
if self._panning and e.button() in (Qt.MiddleButton, Qt.LeftButton):
|
|
141
|
-
self._panning = False
|
|
142
|
-
self.setCursor(Qt.OpenHandCursor if self._space_panning else Qt.ArrowCursor)
|
|
143
|
-
e.accept()
|
|
144
|
-
return
|
|
145
|
-
super().mouseReleaseEvent(e)
|
|
146
|
-
|
|
147
|
-
def zoom_in(self):
|
|
148
|
-
self._apply_zoom(self._zoom_step)
|
|
149
|
-
|
|
150
|
-
def zoom_out(self):
|
|
151
|
-
self._apply_zoom(1.0 / self._zoom_step)
|
|
152
|
-
|
|
153
|
-
def _apply_zoom(self, factor: float):
|
|
154
|
-
new_zoom = self._zoom * factor
|
|
155
|
-
if not (self._min_zoom <= new_zoom <= self._max_zoom):
|
|
156
|
-
return
|
|
157
|
-
self._zoom = new_zoom
|
|
158
|
-
self.scale(factor, factor)
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
class NodeGraphicsScene(QGraphicsScene):
|
|
162
|
-
sceneContextRequested = Signal(QPointF)
|
|
163
|
-
def __init__(self, parent: Optional[QObject] = None):
|
|
164
|
-
super().__init__(parent)
|
|
165
|
-
self.setSceneRect(-5000, -5000, 10000, 10000)
|
|
166
|
-
|
|
167
|
-
def contextMenuEvent(self, event):
|
|
168
|
-
transform = self.views()[0].transform() if self.views() else QTransform()
|
|
169
|
-
item = self.itemAt(event.scenePos(), transform)
|
|
170
|
-
if item is None:
|
|
171
|
-
self.sceneContextRequested.emit(event.scenePos())
|
|
172
|
-
event.accept()
|
|
173
|
-
return
|
|
174
|
-
super().contextMenuEvent(event)
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
# ------------------------ Items: Port, Edge, Node ------------------------
|
|
178
|
-
|
|
179
|
-
class PortItem(QGraphicsObject):
|
|
180
|
-
radius = 6.0
|
|
181
|
-
portClicked = Signal(object) # self
|
|
182
|
-
side: str # "input" or "output"
|
|
183
|
-
|
|
184
|
-
def __init__(self, node_item: "NodeItem", prop_id: str, side: str):
|
|
185
|
-
super().__init__(node_item)
|
|
186
|
-
self.node_item = node_item
|
|
187
|
-
self.prop_id = prop_id
|
|
188
|
-
self.side = side
|
|
189
|
-
self.setAcceptHoverEvents(True)
|
|
190
|
-
self.setAcceptedMouseButtons(Qt.LeftButton)
|
|
191
|
-
self._hover = False
|
|
192
|
-
self._connected_count = 0
|
|
193
|
-
self._can_accept = False
|
|
194
|
-
self.setZValue(3)
|
|
195
|
-
|
|
196
|
-
# Small labels for IN/OUT and capacity
|
|
197
|
-
self._label_io = QGraphicsSimpleTextItem(self)
|
|
198
|
-
self._label_cap = QGraphicsSimpleTextItem(self)
|
|
199
|
-
self._font_small = QFont()
|
|
200
|
-
self._font_small.setPixelSize(9)
|
|
201
|
-
self._label_io.setFont(self._font_small)
|
|
202
|
-
self._label_cap.setFont(self._font_small)
|
|
203
|
-
self._update_label_texts()
|
|
204
|
-
self._update_label_colors()
|
|
205
|
-
self._update_label_positions()
|
|
206
|
-
|
|
207
|
-
def _update_label_texts(self):
|
|
208
|
-
# IN/OUT
|
|
209
|
-
self._label_io.setText("IN" if self.side == "input" else "OUT")
|
|
210
|
-
# Capacity: show max allowed for this port side
|
|
211
|
-
pm: PropertyModel = self.node_item.node.properties.get(self.prop_id)
|
|
212
|
-
cap_val = None
|
|
213
|
-
if pm:
|
|
214
|
-
if self.side == "input":
|
|
215
|
-
cap_val = pm.allowed_inputs
|
|
216
|
-
else:
|
|
217
|
-
cap_val = pm.allowed_outputs
|
|
218
|
-
text = ""
|
|
219
|
-
if isinstance(cap_val, int) and cap_val != 0:
|
|
220
|
-
if cap_val < 0:
|
|
221
|
-
text = "\u221E"
|
|
222
|
-
else:
|
|
223
|
-
text = str(cap_val)
|
|
224
|
-
self._label_cap.setText(text)
|
|
225
|
-
|
|
226
|
-
def _update_label_colors(self):
|
|
227
|
-
editor: NodeEditor = self.node_item.editor
|
|
228
|
-
self._label_io.setBrush(QBrush(editor._port_label_color))
|
|
229
|
-
self._label_cap.setBrush(QBrush(editor._port_capacity_color))
|
|
230
|
-
|
|
231
|
-
def _update_label_positions(self):
|
|
232
|
-
r = self.radius
|
|
233
|
-
# Capacity above the port, slightly left/right
|
|
234
|
-
cap_rect = self._label_cap.boundingRect()
|
|
235
|
-
io_rect = self._label_io.boundingRect()
|
|
236
|
-
gap = 3.0
|
|
237
|
-
|
|
238
|
-
if self.side == "input":
|
|
239
|
-
# IO label left of the port
|
|
240
|
-
self._label_io.setPos(-r - gap - io_rect.width(), -io_rect.height() / 2.0)
|
|
241
|
-
# Cap above-left
|
|
242
|
-
self._label_cap.setPos(-r - gap - cap_rect.width(), -r - cap_rect.height() - 1.0)
|
|
243
|
-
else:
|
|
244
|
-
# IO label right of the port
|
|
245
|
-
self._label_io.setPos(r + gap, -io_rect.height() / 2.0)
|
|
246
|
-
# Cap above-right
|
|
247
|
-
self._label_cap.setPos(r + gap, -r - cap_rect.height() - 1.0)
|
|
248
|
-
|
|
249
|
-
def notify_theme_changed(self):
|
|
250
|
-
self._update_label_colors()
|
|
251
|
-
self.update()
|
|
252
|
-
|
|
253
|
-
def boundingRect(self) -> QRectF:
|
|
254
|
-
# Expand bounding rect to include labels
|
|
255
|
-
r = self.radius
|
|
256
|
-
cap_rect = self._label_cap.boundingRect()
|
|
257
|
-
io_rect = self._label_io.boundingRect()
|
|
258
|
-
extra_w = max(cap_rect.width(), io_rect.width()) + 8.0
|
|
259
|
-
w = 2 * r + 2 * extra_w
|
|
260
|
-
h = 2 * r + cap_rect.height() + 8.0
|
|
261
|
-
return QRectF(-w/2.0, -h/2.0, w, h)
|
|
262
|
-
|
|
263
|
-
def shape(self) -> QPainterPath:
|
|
264
|
-
pick_r = float(getattr(self.node_item.editor, "_port_pick_radius", 10.0)) or 10.0
|
|
265
|
-
p = QPainterPath()
|
|
266
|
-
p.addEllipse(QRectF(-pick_r, -pick_r, 2 * pick_r, 2 * pick_r))
|
|
267
|
-
return p
|
|
268
|
-
|
|
269
|
-
def paint(self, p: QPainter, opt: QStyleOptionGraphicsItem, widget=None):
|
|
270
|
-
editor: NodeEditor = self.node_item.editor
|
|
271
|
-
base = editor._port_input_color if self.side == "input" else editor._port_output_color
|
|
272
|
-
color = editor._port_connected_color if self._connected_count > 0 else base
|
|
273
|
-
if self._hover:
|
|
274
|
-
color = color.lighter(130)
|
|
275
|
-
p.setRenderHint(QPainter.Antialiasing, True)
|
|
276
|
-
p.setPen(Qt.NoPen)
|
|
277
|
-
p.setBrush(QBrush(color))
|
|
278
|
-
p.drawEllipse(QRectF(-self.radius, -self.radius, 2 * self.radius, 2 * self.radius))
|
|
279
|
-
if self._can_accept:
|
|
280
|
-
ring = QPen(editor._port_accept_color, 3.0)
|
|
281
|
-
ring.setCosmetic(True)
|
|
282
|
-
p.setPen(ring)
|
|
283
|
-
p.setBrush(Qt.NoBrush)
|
|
284
|
-
p.drawEllipse(QRectF(-self.radius, -self.radius, 2 * self.radius, 2 * self.radius))
|
|
285
|
-
# Labels are child items; positions recalculated if needed
|
|
286
|
-
# Nothing else to paint here.
|
|
287
|
-
|
|
288
|
-
def hoverEnterEvent(self, e):
|
|
289
|
-
self._hover = True
|
|
290
|
-
self.update()
|
|
291
|
-
super().hoverEnterEvent(e)
|
|
292
|
-
|
|
293
|
-
def hoverLeaveEvent(self, e):
|
|
294
|
-
self._hover = False
|
|
295
|
-
self.update()
|
|
296
|
-
super().hoverLeaveEvent(e)
|
|
297
|
-
|
|
298
|
-
def mousePressEvent(self, e):
|
|
299
|
-
if e.button() == Qt.LeftButton:
|
|
300
|
-
self.node_item.editor._dbg(f"Port clicked: side={self.side}, node={self.node_item.node.name}({self.node_item.node.uuid}), prop={self.prop_id}, connected_count={self._connected_count}")
|
|
301
|
-
self.portClicked.emit(self)
|
|
302
|
-
e.accept()
|
|
303
|
-
return
|
|
304
|
-
super().mousePressEvent(e)
|
|
305
|
-
|
|
306
|
-
def increment_connections(self, delta: int):
|
|
307
|
-
self._connected_count = max(0, self._connected_count + delta)
|
|
308
|
-
self.update()
|
|
309
|
-
|
|
310
|
-
def set_accept_highlight(self, enabled: bool):
|
|
311
|
-
if self._can_accept != enabled:
|
|
312
|
-
self._can_accept = enabled
|
|
313
|
-
self.update()
|
|
314
|
-
|
|
315
|
-
# Keep labels aligned when port moves due to node resize
|
|
316
|
-
def update_labels(self):
|
|
317
|
-
self._update_label_texts()
|
|
318
|
-
self._update_label_positions()
|
|
319
|
-
self.update()
|
|
320
|
-
|
|
321
|
-
class EdgeItem(QGraphicsPathItem):
|
|
322
|
-
def __init__(self, src_port: PortItem, dst_port: PortItem, temporary: bool = False):
|
|
323
|
-
super().__init__()
|
|
324
|
-
self.src_port = src_port
|
|
325
|
-
self.dst_port = dst_port
|
|
326
|
-
self.temporary = temporary
|
|
327
|
-
self.setZValue(1)
|
|
328
|
-
self.setFlag(QGraphicsItem.ItemIsSelectable, True)
|
|
329
|
-
self.setAcceptedMouseButtons(Qt.LeftButton | Qt.RightButton)
|
|
330
|
-
self._editor = self.src_port.node_item.editor
|
|
331
|
-
self._hover = False
|
|
332
|
-
# drag priming (for rewire by dragging the edge)
|
|
333
|
-
self._drag_primed = False
|
|
334
|
-
self._drag_start_scene = QPointF()
|
|
335
|
-
self._update_pen()
|
|
336
|
-
|
|
337
|
-
def set_hovered(self, hovered: bool):
|
|
338
|
-
if self._hover != hovered:
|
|
339
|
-
self._hover = hovered
|
|
340
|
-
self._update_pen()
|
|
341
|
-
|
|
342
|
-
def _update_pen(self):
|
|
343
|
-
color = self._editor._edge_selected_color if (self._hover or self.isSelected()) else self._editor._edge_color
|
|
344
|
-
pen = QPen(color)
|
|
345
|
-
pen.setWidthF(2.0 if not self.temporary else 1.5)
|
|
346
|
-
pen.setStyle(Qt.SolidLine if not self.temporary else Qt.DashLine)
|
|
347
|
-
pen.setCosmetic(True)
|
|
348
|
-
self.setPen(pen)
|
|
349
|
-
self.update()
|
|
350
|
-
|
|
351
|
-
def itemChange(self, change, value):
|
|
352
|
-
if change == QGraphicsItem.ItemSelectedHasChanged:
|
|
353
|
-
self._update_pen()
|
|
354
|
-
return super().itemChange(change, value)
|
|
355
|
-
|
|
356
|
-
def shape(self) -> QPainterPath:
|
|
357
|
-
stroker = QPainterPathStroker()
|
|
358
|
-
width = float(getattr(self._editor, "_edge_pick_width", 12.0) or 12.0)
|
|
359
|
-
stroker.setWidth(width)
|
|
360
|
-
stroker.setCapStyle(Qt.RoundCap)
|
|
361
|
-
stroker.setJoinStyle(Qt.RoundJoin)
|
|
362
|
-
return stroker.createStroke(self.path())
|
|
363
|
-
|
|
364
|
-
def update_path(self, end_pos: Optional[QPointF] = None):
|
|
365
|
-
p = QPainterPath()
|
|
366
|
-
p0 = self.src_port.scenePos()
|
|
367
|
-
p1 = end_pos if end_pos is not None else self.dst_port.scenePos()
|
|
368
|
-
dx = abs(p1.x() - p0.x())
|
|
369
|
-
c1 = QPointF(p0.x() + dx * 0.5, p0.y())
|
|
370
|
-
c2 = QPointF(p1.x() - dx * 0.5, p1.y())
|
|
371
|
-
p.moveTo(p0)
|
|
372
|
-
p.cubicTo(c1, c2, p1)
|
|
373
|
-
self.setPath(p)
|
|
374
|
-
|
|
375
|
-
def contextMenuEvent(self, event):
|
|
376
|
-
if self.temporary:
|
|
377
|
-
return
|
|
378
|
-
self._editor._dbg(f"Edge context menu on edge id={id(self)}")
|
|
379
|
-
menu = QMenu(self._editor.window())
|
|
380
|
-
ss = self._editor.window().styleSheet()
|
|
381
|
-
if ss:
|
|
382
|
-
menu.setStyleSheet(ss)
|
|
383
|
-
act_del = QAction("Delete connection", menu)
|
|
384
|
-
menu.addAction(act_del)
|
|
385
|
-
chosen = menu.exec(event.screenPos())
|
|
386
|
-
if chosen == act_del:
|
|
387
|
-
self._editor._dbg(f"Context DELETE on edge id={id(self)}")
|
|
388
|
-
self._editor._delete_edge(self)
|
|
389
|
-
|
|
390
|
-
# --- drag-based rewire priming on the edge ---
|
|
391
|
-
|
|
392
|
-
def mousePressEvent(self, e):
|
|
393
|
-
if not self.temporary and e.button() == Qt.LeftButton:
|
|
394
|
-
if not self.isSelected():
|
|
395
|
-
self.setSelected(True)
|
|
396
|
-
self._drag_primed = True
|
|
397
|
-
self._drag_start_scene = e.scenePos()
|
|
398
|
-
self._editor._dbg(f"Edge LMB press -> primed drag, edge id={id(self)}")
|
|
399
|
-
e.accept()
|
|
400
|
-
return
|
|
401
|
-
super().mousePressEvent(e)
|
|
402
|
-
|
|
403
|
-
def mouseMoveEvent(self, e):
|
|
404
|
-
if not self.temporary and self._drag_primed:
|
|
405
|
-
dist = abs(e.scenePos().x() - self._drag_start_scene.x()) + abs(e.scenePos().y() - self._drag_start_scene.y())
|
|
406
|
-
if dist > 6 and getattr(self._editor, "_wire_state", "idle") == "idle":
|
|
407
|
-
self._editor._dbg(f"Edge drag start -> begin REWIRE from EDGE (move dst), edge id={id(self)}")
|
|
408
|
-
self._editor._start_rewire_from_edge(self, e.scenePos())
|
|
409
|
-
self._drag_primed = False
|
|
410
|
-
e.accept()
|
|
411
|
-
return
|
|
412
|
-
super().mouseMoveEvent(e)
|
|
413
|
-
|
|
414
|
-
def mouseReleaseEvent(self, e):
|
|
415
|
-
self._drag_primed = False
|
|
416
|
-
super().mouseReleaseEvent(e)
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
class NodeContentWidget(QWidget):
|
|
420
|
-
valueChanged = Signal(str, object)
|
|
421
|
-
def __init__(self, node: NodeModel, graph: NodeGraph, editor: "NodeEditor", parent: Optional[QWidget] = None):
|
|
422
|
-
super().__init__(parent)
|
|
423
|
-
self.node = node
|
|
424
|
-
self.graph = graph
|
|
425
|
-
self.editor = editor
|
|
426
|
-
self.setObjectName("NodeContentWidget")
|
|
427
|
-
self.setAttribute(Qt.WA_StyledBackground, True)
|
|
428
|
-
|
|
429
|
-
self.form = QFormLayout(self)
|
|
430
|
-
self.form.setContentsMargins(8, 8, 8, 8)
|
|
431
|
-
self.form.setSpacing(6)
|
|
432
|
-
self._editors: Dict[str, QWidget] = {}
|
|
433
|
-
for pid, pm in node.properties.items():
|
|
434
|
-
w: QWidget
|
|
435
|
-
if pm.type == "str":
|
|
436
|
-
w = QLineEdit()
|
|
437
|
-
if pm.value is not None:
|
|
438
|
-
w.setText(str(pm.value))
|
|
439
|
-
w.setReadOnly(not pm.editable)
|
|
440
|
-
w.textEdited.connect(lambda v, pid=pid: self.valueChanged.emit(pid, v))
|
|
441
|
-
elif pm.type == "text":
|
|
442
|
-
# Multiline text editor (textarea)
|
|
443
|
-
te = QTextEdit()
|
|
444
|
-
if pm.value is not None:
|
|
445
|
-
te.setPlainText(str(pm.value))
|
|
446
|
-
te.setReadOnly(not pm.editable)
|
|
447
|
-
# Emit value on change (debounced nature of QTextEdit is fine here)
|
|
448
|
-
te.textChanged.connect(lambda pid=pid, te=te: self.valueChanged.emit(pid, te.toPlainText()))
|
|
449
|
-
w = te
|
|
450
|
-
elif pm.type == "int":
|
|
451
|
-
w = QSpinBox()
|
|
452
|
-
w.setRange(-10**9, 10**9)
|
|
453
|
-
if pm.value is not None:
|
|
454
|
-
w.setValue(int(pm.value))
|
|
455
|
-
w.setEnabled(pm.editable)
|
|
456
|
-
w.valueChanged.connect(lambda v, pid=pid: self.valueChanged.emit(pid, int(v)))
|
|
457
|
-
elif pm.type == "float":
|
|
458
|
-
w = QDoubleSpinBox()
|
|
459
|
-
w.setDecimals(4)
|
|
460
|
-
w.setRange(-1e12, 1e12)
|
|
461
|
-
if pm.value is not None:
|
|
462
|
-
w.setValue(float(pm.value))
|
|
463
|
-
w.setEnabled(pm.editable)
|
|
464
|
-
w.valueChanged.connect(lambda v, pid=pid: self.valueChanged.emit(pid, float(v)))
|
|
465
|
-
elif pm.type == "bool":
|
|
466
|
-
w = QCheckBox()
|
|
467
|
-
if pm.value:
|
|
468
|
-
w.setChecked(bool(pm.value))
|
|
469
|
-
w.setEnabled(pm.editable)
|
|
470
|
-
w.toggled.connect(lambda v, pid=pid: self.valueChanged.emit(pid, bool(v)))
|
|
471
|
-
elif pm.type == "combo":
|
|
472
|
-
w = QComboBox()
|
|
473
|
-
for opt in (pm.options or []):
|
|
474
|
-
w.addItem(opt)
|
|
475
|
-
if pm.value is not None and pm.value in (pm.options or []):
|
|
476
|
-
w.setCurrentText(pm.value)
|
|
477
|
-
w.setEnabled(pm.editable)
|
|
478
|
-
w.currentTextChanged.connect(lambda v, pid=pid: self.valueChanged.emit(pid, v))
|
|
479
|
-
else:
|
|
480
|
-
# Non-editable or slot types -> just a placeholder label
|
|
481
|
-
w = QLabel("-")
|
|
482
|
-
w.setEnabled(False)
|
|
483
|
-
self.form.addRow(pm.name, w)
|
|
484
|
-
self._editors[pid] = w
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
class NodeItem(QGraphicsWidget):
|
|
488
|
-
"""Rounded node with title and ports aligned to property rows."""
|
|
489
|
-
def __init__(self, editor: "NodeEditor", node: NodeModel):
|
|
490
|
-
super().__init__()
|
|
491
|
-
self.editor = editor
|
|
492
|
-
self.graph = editor.graph
|
|
493
|
-
self.node = node
|
|
494
|
-
self.setFlag(QGraphicsItem.ItemIsMovable, True)
|
|
495
|
-
self.setFlag(QGraphicsItem.ItemIsSelectable, True)
|
|
496
|
-
self.setFlag(QGraphicsItem.ItemSendsGeometryChanges, True)
|
|
497
|
-
self.setZValue(2)
|
|
498
|
-
self._title_height = 24
|
|
499
|
-
self._radius = 6
|
|
500
|
-
self._proxy = QGraphicsProxyWidget(self)
|
|
501
|
-
self._content = NodeContentWidget(node, self.graph, self.editor)
|
|
502
|
-
self._content.setMouseTracking(True)
|
|
503
|
-
self._content.setAttribute(Qt.WA_Hover, True)
|
|
504
|
-
self._proxy.setWidget(self._content)
|
|
505
|
-
self._proxy.setAcceptHoverEvents(True)
|
|
506
|
-
self._proxy.installEventFilter(self)
|
|
507
|
-
self._proxy.setPos(0, self._title_height)
|
|
508
|
-
self._content.installEventFilter(self)
|
|
509
|
-
self._content.valueChanged.connect(self._on_value_changed)
|
|
510
|
-
self._in_ports: Dict[str, PortItem] = {}
|
|
511
|
-
self._out_ports: Dict[str, PortItem] = {}
|
|
512
|
-
self._edges: List[EdgeItem] = []
|
|
513
|
-
self._prev_pos = self.pos()
|
|
514
|
-
|
|
515
|
-
self._dragging = False
|
|
516
|
-
self._overlaps = False
|
|
517
|
-
self._start_pos = QPointF(self.pos())
|
|
518
|
-
self._last_valid_pos = QPointF(self.pos())
|
|
519
|
-
self._z_before_drag = self.zValue()
|
|
520
|
-
|
|
521
|
-
# --- Resize state ---
|
|
522
|
-
self.setAcceptHoverEvents(True)
|
|
523
|
-
self.setFiltersChildEvents(True) # receive hover also when over children (proxy/ports)
|
|
524
|
-
self._resizing: bool = False
|
|
525
|
-
self._resize_mode: str = "none" # "none" | "right" | "bottom" | "corner"
|
|
526
|
-
self._resize_press_local = QPointF()
|
|
527
|
-
self._resize_start_size = QSizeF()
|
|
528
|
-
self._hover_resize_mode: str = "none"
|
|
529
|
-
|
|
530
|
-
self._sync_size()
|
|
531
|
-
self._build_ports()
|
|
532
|
-
self._sync_size()
|
|
533
|
-
|
|
534
|
-
# ---------- Size helpers ----------
|
|
535
|
-
def _effective_hit_margin(self) -> float:
|
|
536
|
-
# Active resize zone width computed from visual margin minus hit inset
|
|
537
|
-
margin = float(getattr(self.editor, "_resize_grip_margin", 12.0) or 12.0)
|
|
538
|
-
inset = float(getattr(self.editor, "_resize_grip_hit_inset", 3.0) or 0.0)
|
|
539
|
-
hit = max(4.0, margin - inset) # never less than 4px
|
|
540
|
-
return hit
|
|
541
|
-
|
|
542
|
-
def _min_size_from_content(self) -> QSizeF:
|
|
543
|
-
csz = self._content.sizeHint() if _qt_is_valid(self._content) else QSizeF(120, 40)
|
|
544
|
-
hit = self._effective_hit_margin()
|
|
545
|
-
w = max(80.0, float(csz.width()) + 16.0 + hit)
|
|
546
|
-
h_auto = float(csz.height()) + 16.0 + float(self._title_height) + hit
|
|
547
|
-
h = max(float(self._title_height + 16 + hit), h_auto)
|
|
548
|
-
return QSizeF(w, h)
|
|
549
|
-
|
|
550
|
-
def _apply_resize(self, new_size: QSizeF, clamp: bool = True):
|
|
551
|
-
hit = self._effective_hit_margin()
|
|
552
|
-
minsz = self._min_size_from_content() if clamp else QSizeF(0.0, 0.0)
|
|
553
|
-
w = max(minsz.width(), float(new_size.width()))
|
|
554
|
-
h = max(minsz.height(), float(new_size.height()))
|
|
555
|
-
self.resize(QSizeF(w, h))
|
|
556
|
-
# Right and bottom gutters = 'hit' (active zone and space for indicators)
|
|
557
|
-
ph = max(0.0, h - self._title_height - hit)
|
|
558
|
-
pw = max(0.0, w - hit)
|
|
559
|
-
if _qt_is_valid(self._proxy):
|
|
560
|
-
try:
|
|
561
|
-
self._proxy.resize(pw, ph)
|
|
562
|
-
except Exception:
|
|
563
|
-
pass
|
|
564
|
-
self.update_ports_positions()
|
|
565
|
-
self.update()
|
|
566
|
-
|
|
567
|
-
def _sync_size(self):
|
|
568
|
-
csz = self._content.sizeHint()
|
|
569
|
-
hit = self._effective_hit_margin()
|
|
570
|
-
w = float(csz.width()) + 16.0 + hit
|
|
571
|
-
auto_h = float(csz.height()) + 16.0 + float(self._title_height) + hit
|
|
572
|
-
h = max(auto_h, float(self._title_height + 16 + hit))
|
|
573
|
-
self.resize(QSizeF(w, h))
|
|
574
|
-
self._proxy.resize(max(0.0, w - hit), max(0.0, h - self._title_height - hit))
|
|
575
|
-
|
|
576
|
-
def _build_ports(self):
|
|
577
|
-
yoff = self._title_height + 8
|
|
578
|
-
row_h = 24
|
|
579
|
-
for pid, pm in self.node.properties.items():
|
|
580
|
-
if pm.allowed_inputs != 0:
|
|
581
|
-
p = PortItem(self, pid, "input")
|
|
582
|
-
p.setPos(-10, yoff + 8)
|
|
583
|
-
p.portClicked.connect(self.editor._on_port_clicked)
|
|
584
|
-
self._in_ports[pid] = p
|
|
585
|
-
if pm.allowed_outputs != 0:
|
|
586
|
-
p = PortItem(self, pid, "output")
|
|
587
|
-
p.setPos(self.size().width() + 10, yoff + 8)
|
|
588
|
-
p.portClicked.connect(self.editor._on_port_clicked)
|
|
589
|
-
self._out_ports[pid] = p
|
|
590
|
-
yoff += row_h
|
|
591
|
-
|
|
592
|
-
def update_ports_positions(self):
|
|
593
|
-
yoff = self._title_height + 8
|
|
594
|
-
row_h = 24
|
|
595
|
-
for pid in self.node.properties.keys():
|
|
596
|
-
if pid in self._in_ports:
|
|
597
|
-
self._in_ports[pid].setPos(-10, yoff + 8)
|
|
598
|
-
self._in_ports[pid].update_labels()
|
|
599
|
-
if pid in self._out_ports:
|
|
600
|
-
self._out_ports[pid].setPos(self.size().width() + 10, yoff + 8)
|
|
601
|
-
self._out_ports[pid].update_labels()
|
|
602
|
-
yoff += row_h
|
|
603
|
-
for e in self._edges:
|
|
604
|
-
e.update_path()
|
|
605
|
-
|
|
606
|
-
def add_edge(self, edge: EdgeItem):
|
|
607
|
-
if edge not in self._edges:
|
|
608
|
-
self._edges.append(edge)
|
|
609
|
-
|
|
610
|
-
def remove_edge(self, edge: EdgeItem):
|
|
611
|
-
if edge in self._edges:
|
|
612
|
-
self._edges.remove(edge)
|
|
613
|
-
|
|
614
|
-
def boundingRect(self) -> QRectF:
|
|
615
|
-
return QRectF(0, 0, self.size().width(), self.size().height())
|
|
616
|
-
|
|
617
|
-
def _hit_resize_zone(self, pos: QPointF) -> str:
|
|
618
|
-
w = self.size().width()
|
|
619
|
-
h = self.size().height()
|
|
620
|
-
hit = self._effective_hit_margin()
|
|
621
|
-
right = (w - hit) <= pos.x() <= w
|
|
622
|
-
bottom = (h - hit) <= pos.y() <= h
|
|
623
|
-
if right and bottom:
|
|
624
|
-
return "corner"
|
|
625
|
-
if right:
|
|
626
|
-
return "right"
|
|
627
|
-
if bottom:
|
|
628
|
-
return "bottom"
|
|
629
|
-
return "none"
|
|
630
|
-
|
|
631
|
-
def paint(self, p: QPainter, opt: QStyleOptionGraphicsItem, widget=None):
|
|
632
|
-
r = self.boundingRect()
|
|
633
|
-
path = QPainterPath()
|
|
634
|
-
path.addRoundedRect(r, self._radius, self._radius)
|
|
635
|
-
p.setRenderHint(QPainter.Antialiasing, True)
|
|
636
|
-
|
|
637
|
-
# Per-type background color override
|
|
638
|
-
bg = self.editor._node_bg_color
|
|
639
|
-
try:
|
|
640
|
-
spec = self.graph.registry.get(self.node.type)
|
|
641
|
-
if spec and spec.bg_color:
|
|
642
|
-
c = QColor(spec.bg_color)
|
|
643
|
-
if c.isValid():
|
|
644
|
-
bg = c
|
|
645
|
-
except Exception:
|
|
646
|
-
pass
|
|
647
|
-
|
|
648
|
-
border = self.editor._node_border_color
|
|
649
|
-
if self.isSelected():
|
|
650
|
-
border = self.editor._node_selection_color
|
|
651
|
-
p.fillPath(path, QBrush(bg))
|
|
652
|
-
pen = QPen(border)
|
|
653
|
-
pen.setWidthF(1.5)
|
|
654
|
-
p.setPen(pen)
|
|
655
|
-
p.drawPath(path)
|
|
656
|
-
title_rect = QRectF(r.left(), r.top(), r.width(), self._title_height)
|
|
657
|
-
p.fillRect(title_rect, self.editor._node_title_color)
|
|
658
|
-
p.setPen(QPen(Qt.white))
|
|
659
|
-
p.drawText(title_rect.adjusted(8, 0, -8, 0), Qt.AlignVCenter | Qt.AlignLeft, self.node.name)
|
|
660
|
-
|
|
661
|
-
# --- Resize indicator and hover highlight ---
|
|
662
|
-
hit = self._effective_hit_margin()
|
|
663
|
-
mode = self._resize_mode if self._resizing else self._hover_resize_mode
|
|
664
|
-
hl = QColor(border)
|
|
665
|
-
hl.setAlpha(110)
|
|
666
|
-
if mode in ("right", "corner"):
|
|
667
|
-
p.fillRect(QRectF(r.right() - hit, r.top() + self._title_height, hit, r.height() - self._title_height), hl)
|
|
668
|
-
if mode in ("bottom", "corner"):
|
|
669
|
-
p.fillRect(QRectF(r.left(), r.bottom() - hit, r.width(), hit), hl)
|
|
670
|
-
|
|
671
|
-
# Corner triangle + hatch (drawn inside the "hit" zone)
|
|
672
|
-
tri_color = border.lighter(150)
|
|
673
|
-
tri_color.setAlpha(140)
|
|
674
|
-
corner = QPainterPath()
|
|
675
|
-
corner.moveTo(r.right() - hit + 1, r.bottom() - 1)
|
|
676
|
-
corner.lineTo(r.right() - 1, r.bottom() - hit + 1)
|
|
677
|
-
corner.lineTo(r.right() - 1, r.bottom() - 1)
|
|
678
|
-
corner.closeSubpath()
|
|
679
|
-
p.fillPath(corner, tri_color)
|
|
680
|
-
|
|
681
|
-
hatch_pen = QPen(border.lighter(170), 1.6)
|
|
682
|
-
hatch_pen.setCosmetic(True)
|
|
683
|
-
p.setPen(hatch_pen)
|
|
684
|
-
x1 = r.right() - 5
|
|
685
|
-
y1 = r.bottom() - 1
|
|
686
|
-
for i in range(3):
|
|
687
|
-
p.drawLine(QPointF(x1 - 5 * i, y1), QPointF(r.right() - 1, r.bottom() - 5 - 5 * i))
|
|
688
|
-
|
|
689
|
-
# ---------- Mouse/hover for drag vs resize ----------
|
|
690
|
-
|
|
691
|
-
def _apply_hover_from_pos(self, pos: QPointF):
|
|
692
|
-
mode = self._hit_resize_zone(pos)
|
|
693
|
-
if mode != self._hover_resize_mode and not self._resizing:
|
|
694
|
-
self._hover_resize_mode = mode
|
|
695
|
-
if mode == "corner":
|
|
696
|
-
self.setCursor(Qt.SizeFDiagCursor)
|
|
697
|
-
elif mode == "right":
|
|
698
|
-
self.setCursor(Qt.SizeHorCursor)
|
|
699
|
-
elif mode == "bottom":
|
|
700
|
-
self.setCursor(Qt.SizeVerCursor)
|
|
701
|
-
else:
|
|
702
|
-
self.unsetCursor()
|
|
703
|
-
self.update()
|
|
704
|
-
|
|
705
|
-
def hoverMoveEvent(self, event):
|
|
706
|
-
view = self.editor.view
|
|
707
|
-
if getattr(view, "_space_panning", False) or getattr(view, "_panning", False):
|
|
708
|
-
self._hover_resize_mode = "none"
|
|
709
|
-
self.unsetCursor()
|
|
710
|
-
self.update()
|
|
711
|
-
return super().hoverMoveEvent(event)
|
|
712
|
-
|
|
713
|
-
self._apply_hover_from_pos(event.pos())
|
|
714
|
-
super().hoverMoveEvent(event)
|
|
715
|
-
|
|
716
|
-
def hoverLeaveEvent(self, event):
|
|
717
|
-
if not self._resizing:
|
|
718
|
-
self._hover_resize_mode = "none"
|
|
719
|
-
self.unsetCursor()
|
|
720
|
-
self.update()
|
|
721
|
-
super().hoverLeaveEvent(event)
|
|
722
|
-
|
|
723
|
-
def mousePressEvent(self, event):
|
|
724
|
-
if event.button() == Qt.LeftButton:
|
|
725
|
-
mode = self._hit_resize_zone(event.pos())
|
|
726
|
-
if mode != "none":
|
|
727
|
-
# start resize
|
|
728
|
-
self._resizing = True
|
|
729
|
-
self._resize_mode = mode
|
|
730
|
-
self._resize_press_local = QPointF(event.pos())
|
|
731
|
-
self._resize_start_size = QSizeF(self.size())
|
|
732
|
-
self._z_before_drag = self.zValue()
|
|
733
|
-
self.setZValue(self._z_before_drag + 100)
|
|
734
|
-
if mode == "corner":
|
|
735
|
-
self.setCursor(Qt.SizeFDiagCursor)
|
|
736
|
-
elif mode == "right":
|
|
737
|
-
self.setCursor(Qt.SizeHorCursor)
|
|
738
|
-
else:
|
|
739
|
-
self.setCursor(Qt.SizeVerCursor)
|
|
740
|
-
event.accept()
|
|
741
|
-
return
|
|
742
|
-
|
|
743
|
-
# else -> start normal drag
|
|
744
|
-
self._dragging = True
|
|
745
|
-
self._overlaps = False
|
|
746
|
-
self._start_pos = QPointF(self.pos())
|
|
747
|
-
self._last_valid_pos = QPointF(self.pos())
|
|
748
|
-
self._z_before_drag = self.zValue()
|
|
749
|
-
self.setZValue(self._z_before_drag + 100)
|
|
750
|
-
super().mousePressEvent(event)
|
|
751
|
-
|
|
752
|
-
def mouseMoveEvent(self, event):
|
|
753
|
-
if self._resizing:
|
|
754
|
-
dx = float(event.pos().x() - self._resize_press_local.x())
|
|
755
|
-
dy = float(event.pos().y() - self._resize_press_local.y())
|
|
756
|
-
w = self._resize_start_size.width()
|
|
757
|
-
h = self._resize_start_size.height()
|
|
758
|
-
if self._resize_mode in ("right", "corner"):
|
|
759
|
-
w = self._resize_start_size.width() + dx
|
|
760
|
-
if self._resize_mode in ("bottom", "corner"):
|
|
761
|
-
h = self._resize_start_size.height() + dy
|
|
762
|
-
self._apply_resize(QSizeF(w, h), clamp=True)
|
|
763
|
-
event.accept()
|
|
764
|
-
return
|
|
765
|
-
else:
|
|
766
|
-
# even when hoverMove isn't delivered (e.g., over children), child filters will end up here
|
|
767
|
-
self._apply_hover_from_pos(event.pos())
|
|
768
|
-
super().mouseMoveEvent(event)
|
|
769
|
-
|
|
770
|
-
def mouseReleaseEvent(self, event):
|
|
771
|
-
if self._resizing and event.button() == Qt.LeftButton:
|
|
772
|
-
self._resizing = False
|
|
773
|
-
self.setZValue(self._z_before_drag)
|
|
774
|
-
# refresh hover after finishing resize (cursor may still be over the item)
|
|
775
|
-
self._apply_hover_from_pos(event.pos())
|
|
776
|
-
new_size = QSizeF(self.size())
|
|
777
|
-
if abs(new_size.width() - self._resize_start_size.width()) > 0.5 or \
|
|
778
|
-
abs(new_size.height() - self._resize_start_size.height()) > 0.5:
|
|
779
|
-
self.editor._undo.push(ResizeNodeCommand(self, self._resize_start_size, new_size))
|
|
780
|
-
self._resize_mode = "none"
|
|
781
|
-
event.accept()
|
|
782
|
-
return
|
|
783
|
-
|
|
784
|
-
if self._dragging and event.button() == Qt.LeftButton:
|
|
785
|
-
if self._overlaps:
|
|
786
|
-
self.setPos(self._last_valid_pos)
|
|
787
|
-
else:
|
|
788
|
-
if self.pos() != self._start_pos:
|
|
789
|
-
self.editor._undo.push(MoveNodeCommand(self, self._start_pos, self.pos()))
|
|
790
|
-
self.setOpacity(1.0)
|
|
791
|
-
self.setZValue(self._z_before_drag)
|
|
792
|
-
self._dragging = False
|
|
793
|
-
super().mouseReleaseEvent(event)
|
|
794
|
-
|
|
795
|
-
def eventFilter(self, obj, e):
|
|
796
|
-
et = e.type()
|
|
797
|
-
try:
|
|
798
|
-
# 1) Mouse move over QGraphicsProxyWidget (content area)
|
|
799
|
-
if obj is self._proxy and et in (QEvent.GraphicsSceneMouseMove, QEvent.GraphicsSceneHoverMove):
|
|
800
|
-
local = self.mapFromScene(e.scenePos())
|
|
801
|
-
self._apply_hover_from_pos(local)
|
|
802
|
-
return False
|
|
803
|
-
if obj is self._proxy and et == QEvent.GraphicsSceneHoverLeave:
|
|
804
|
-
# leaving proxy -> if not resizing, clear hover
|
|
805
|
-
if not self._resizing:
|
|
806
|
-
self._hover_resize_mode = "none"
|
|
807
|
-
self.unsetCursor()
|
|
808
|
-
self.update()
|
|
809
|
-
return False
|
|
810
|
-
|
|
811
|
-
# 2) Mouse move over inner QWidget (inside the proxy)
|
|
812
|
-
if obj is self._content and et in (QEvent.MouseMove, QEvent.HoverMove, QEvent.Enter):
|
|
813
|
-
# map QPoint from QWidget to NodeItem local coordinates
|
|
814
|
-
if hasattr(e, "position"): # Qt6 mouse event
|
|
815
|
-
p = e.position()
|
|
816
|
-
px, py = float(p.x()), float(p.y())
|
|
817
|
-
elif hasattr(e, "pos"):
|
|
818
|
-
p = e.pos()
|
|
819
|
-
px, py = float(p.x()), float(p.y())
|
|
820
|
-
else:
|
|
821
|
-
px = py = 0.0
|
|
822
|
-
base = self._proxy.pos()
|
|
823
|
-
local = QPointF(base.x() + px, base.y() + py)
|
|
824
|
-
self._apply_hover_from_pos(local)
|
|
825
|
-
return False
|
|
826
|
-
except Exception:
|
|
827
|
-
pass
|
|
828
|
-
return False
|
|
829
|
-
|
|
830
|
-
def itemChange(self, change, value):
|
|
831
|
-
if change == QGraphicsItem.ItemPositionChange:
|
|
832
|
-
new_pos: QPointF = value
|
|
833
|
-
sc = self.scene()
|
|
834
|
-
if sc is not None:
|
|
835
|
-
new_rect = QRectF(new_pos.x(), new_pos.y(), self.size().width(), self.size().height())
|
|
836
|
-
overlap = False
|
|
837
|
-
for it in sc.items(new_rect, Qt.IntersectsItemBoundingRect):
|
|
838
|
-
if it is self:
|
|
839
|
-
continue
|
|
840
|
-
if isinstance(it, NodeItem):
|
|
841
|
-
other = QRectF(it.scenePos().x(), it.scenePos().y(), it.size().width(), it.size().height())
|
|
842
|
-
if new_rect.adjusted(1, 1, -1, -1).intersects(other.adjusted(1, 1, -1, -1)):
|
|
843
|
-
overlap = True
|
|
844
|
-
break
|
|
845
|
-
self._overlaps = overlap
|
|
846
|
-
self.setOpacity(0.5 if overlap else 1.0)
|
|
847
|
-
if not overlap:
|
|
848
|
-
self._last_valid_pos = new_pos
|
|
849
|
-
return value
|
|
850
|
-
if change == QGraphicsItem.ItemPositionHasChanged:
|
|
851
|
-
self._prev_pos = self.pos()
|
|
852
|
-
for e in list(self._edges):
|
|
853
|
-
e.update_path()
|
|
854
|
-
return super().itemChange(change, value)
|
|
855
|
-
|
|
856
|
-
def contextMenuEvent(self, event):
|
|
857
|
-
menu = QMenu(self.editor.window())
|
|
858
|
-
ss = self.editor.window().styleSheet()
|
|
859
|
-
if ss:
|
|
860
|
-
menu.setStyleSheet(ss)
|
|
861
|
-
act_rename = QAction("Rename", menu)
|
|
862
|
-
act_delete = QAction("Delete", menu)
|
|
863
|
-
menu.addAction(act_rename)
|
|
864
|
-
menu.addSeparator()
|
|
865
|
-
menu.addAction(act_delete)
|
|
866
|
-
chosen = menu.exec(event.screenPos())
|
|
867
|
-
if chosen == act_rename:
|
|
868
|
-
from PySide6.QtWidgets import QInputDialog
|
|
869
|
-
new_name, ok = QInputDialog.getText(self.editor.window(), "Rename Node", "Name:", text=self.node.name)
|
|
870
|
-
if ok and new_name:
|
|
871
|
-
self.node.name = new_name
|
|
872
|
-
self.update()
|
|
873
|
-
elif chosen == act_delete:
|
|
874
|
-
self.editor._delete_node_item(self)
|
|
875
|
-
|
|
876
|
-
def _on_value_changed(self, prop_id: str, value):
|
|
877
|
-
self.graph.set_property_value(self.node.uuid, prop_id, value)
|
|
878
|
-
|
|
879
|
-
# ---- Safe detach for proxy/widget to avoid crashes on dialog close ----
|
|
880
|
-
def detach_proxy_widget(self):
|
|
881
|
-
try:
|
|
882
|
-
if hasattr(self, "_proxy") and _qt_is_valid(self._proxy):
|
|
883
|
-
try:
|
|
884
|
-
self._proxy.setWidget(None)
|
|
885
|
-
except Exception:
|
|
886
|
-
pass
|
|
887
|
-
if hasattr(self, "_content") and _qt_is_valid(self._content):
|
|
888
|
-
try:
|
|
889
|
-
self._content.setParent(None)
|
|
890
|
-
self._content.deleteLater()
|
|
891
|
-
except Exception:
|
|
892
|
-
pass
|
|
893
|
-
except Exception:
|
|
894
|
-
pass
|
|
895
|
-
|
|
896
|
-
# ------------------------ Undo/Redo Commands ------------------------
|
|
897
|
-
|
|
898
|
-
class AddNodeCommand(QUndoCommand):
|
|
899
|
-
def __init__(self, editor: "NodeEditor", type_name: str, scene_pos: QPointF):
|
|
900
|
-
super().__init__(f"Add {type_name}")
|
|
901
|
-
self.editor = editor
|
|
902
|
-
self.type_name = type_name
|
|
903
|
-
self.scene_pos = scene_pos
|
|
904
|
-
self._node_uuid: Optional[str] = None
|
|
905
|
-
|
|
906
|
-
def redo(self):
|
|
907
|
-
if self._node_uuid is None:
|
|
908
|
-
node = self.editor.graph.create_node_from_type(self.type_name)
|
|
909
|
-
self._node_uuid = node.uuid
|
|
910
|
-
self.editor._add_node_model(node, self.scene_pos)
|
|
911
|
-
else:
|
|
912
|
-
node = self.editor._model_by_uuid(self._node_uuid)
|
|
913
|
-
self.editor._add_node_item(node, self.scene_pos)
|
|
914
|
-
|
|
915
|
-
def undo(self):
|
|
916
|
-
if self._node_uuid:
|
|
917
|
-
self.editor._remove_node_by_uuid(self._node_uuid)
|
|
918
|
-
|
|
919
|
-
class MoveNodeCommand(QUndoCommand):
|
|
920
|
-
def __init__(self, item: NodeItem, old_pos: QPointF, new_pos: QPointF):
|
|
921
|
-
super().__init__("Move Node")
|
|
922
|
-
self.item = item
|
|
923
|
-
self.old_pos = old_pos
|
|
924
|
-
self.new_pos = new_pos
|
|
925
|
-
|
|
926
|
-
def redo(self):
|
|
927
|
-
self.item.setPos(self.new_pos)
|
|
928
|
-
|
|
929
|
-
def undo(self):
|
|
930
|
-
self.item.setPos(self.old_pos)
|
|
931
|
-
|
|
932
|
-
class ResizeNodeCommand(QUndoCommand):
|
|
933
|
-
def __init__(self, item: NodeItem, old_size: QSizeF, new_size: QSizeF):
|
|
934
|
-
super().__init__("Resize Node")
|
|
935
|
-
self.item = item
|
|
936
|
-
self.old_size = QSizeF(old_size)
|
|
937
|
-
self.new_size = QSizeF(new_size)
|
|
938
|
-
|
|
939
|
-
def redo(self):
|
|
940
|
-
self.item._apply_resize(self.new_size, clamp=True)
|
|
941
|
-
|
|
942
|
-
def undo(self):
|
|
943
|
-
self.item._apply_resize(self.old_size, clamp=True)
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
class ConnectCommand(QUndoCommand):
|
|
947
|
-
def __init__(self, editor: "NodeEditor", src: PortItem, dst: PortItem):
|
|
948
|
-
super().__init__("Connect")
|
|
949
|
-
self.editor = editor
|
|
950
|
-
self.src_port = src
|
|
951
|
-
self.dst_port = dst
|
|
952
|
-
self.src_node = src.node_item.node.uuid
|
|
953
|
-
self.src_prop = src.prop_id
|
|
954
|
-
self.dst_node = dst.node_item.node.uuid
|
|
955
|
-
self.dst_prop = dst.prop_id
|
|
956
|
-
self._conn_uuid: Optional[str] = None
|
|
957
|
-
|
|
958
|
-
def redo(self):
|
|
959
|
-
if self._conn_uuid is None:
|
|
960
|
-
ok, reason, conn = self.editor.graph.connect(
|
|
961
|
-
(self.src_node, self.src_prop), (self.dst_node, self.dst_prop)
|
|
962
|
-
)
|
|
963
|
-
self.editor._dbg(f"ConnectCommand.redo (new) -> ok={ok}, reason='{reason}', new_uuid={(conn.uuid if ok else None)}")
|
|
964
|
-
if ok:
|
|
965
|
-
self._conn_uuid = conn.uuid
|
|
966
|
-
else:
|
|
967
|
-
conn = ConnectionModel(
|
|
968
|
-
uuid=self._conn_uuid,
|
|
969
|
-
src_node=self.src_node, src_prop=self.src_prop,
|
|
970
|
-
dst_node=self.dst_node, dst_prop=self.dst_prop
|
|
971
|
-
)
|
|
972
|
-
ok, reason = self.editor.graph.add_connection(conn)
|
|
973
|
-
self.editor._dbg(f"ConnectCommand.redo (restore) -> ok={ok}, reason='{reason}', uuid={self._conn_uuid}")
|
|
974
|
-
|
|
975
|
-
def undo(self):
|
|
976
|
-
if self._conn_uuid:
|
|
977
|
-
self.editor._dbg(f"ConnectCommand.undo -> remove uuid={self._conn_uuid}")
|
|
978
|
-
self.editor._remove_connection_by_uuid(self._conn_uuid)
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
class RewireConnectionCommand(QUndoCommand):
|
|
982
|
-
"""Replace an existing connection with a new one (or delete it). Single undoable step."""
|
|
983
|
-
def __init__(self, editor: "NodeEditor",
|
|
984
|
-
old_conn: ConnectionModel,
|
|
985
|
-
new_src: Optional[PortItem],
|
|
986
|
-
new_dst: Optional[PortItem]):
|
|
987
|
-
title = "Delete Connection" if (new_src is None or new_dst is None) else "Rewire Connection"
|
|
988
|
-
super().__init__(title)
|
|
989
|
-
self.editor = editor
|
|
990
|
-
self.old_conn_data = old_conn.to_dict()
|
|
991
|
-
self.old_uuid = old_conn.uuid
|
|
992
|
-
self.new_src = new_src
|
|
993
|
-
self.new_dst = new_dst
|
|
994
|
-
self._new_uuid: Optional[str] = None
|
|
995
|
-
self._applied = False
|
|
996
|
-
|
|
997
|
-
def redo(self):
|
|
998
|
-
self.editor._dbg(f"RewireCommand.redo -> remove old={self.old_uuid}, new={'DELETE' if (self.new_src is None or self.new_dst is None) else 'CONNECT'}")
|
|
999
|
-
self.editor._remove_connection_by_uuid(self.old_uuid)
|
|
1000
|
-
if self.new_src is not None and self.new_dst is not None:
|
|
1001
|
-
ok, reason, conn = self.editor.graph.connect(
|
|
1002
|
-
(self.new_src.node_item.node.uuid, self.new_src.prop_id),
|
|
1003
|
-
(self.new_dst.node_item.node.uuid, self.new_dst.prop_id)
|
|
1004
|
-
)
|
|
1005
|
-
self.editor._dbg(f"RewireCommand.redo connect -> ok={ok}, reason='{reason}', new_uuid={(conn.uuid if ok else None)}")
|
|
1006
|
-
if not ok:
|
|
1007
|
-
old = ConnectionModel.from_dict(self.old_conn_data)
|
|
1008
|
-
self.editor.graph.add_connection(old)
|
|
1009
|
-
self._applied = False
|
|
1010
|
-
return
|
|
1011
|
-
self._new_uuid = conn.uuid
|
|
1012
|
-
self._applied = True
|
|
1013
|
-
|
|
1014
|
-
def undo(self):
|
|
1015
|
-
self.editor._dbg(f"RewireCommand.undo -> restore old={self.old_uuid}, remove new={self._new_uuid}")
|
|
1016
|
-
if not self._applied:
|
|
1017
|
-
return
|
|
1018
|
-
if self._new_uuid:
|
|
1019
|
-
self.editor._remove_connection_by_uuid(self._new_uuid)
|
|
1020
|
-
old = ConnectionModel.from_dict(self.old_conn_data)
|
|
1021
|
-
self.editor.graph.add_connection(old)
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
class ClearGraphCommand(QUndoCommand):
|
|
1025
|
-
def __init__(self, editor: "NodeEditor"):
|
|
1026
|
-
super().__init__("Clear")
|
|
1027
|
-
self.editor = editor
|
|
1028
|
-
self._snapshot: Optional[dict] = None
|
|
1029
|
-
|
|
1030
|
-
def redo(self):
|
|
1031
|
-
if self._snapshot is None:
|
|
1032
|
-
self._snapshot = self.editor.graph.to_dict()
|
|
1033
|
-
self.editor._dbg("ClearGraph.redo -> clearing scene+graph")
|
|
1034
|
-
self.editor._clear_scene_and_graph()
|
|
1035
|
-
|
|
1036
|
-
def undo(self):
|
|
1037
|
-
self._dbg = self.editor._dbg
|
|
1038
|
-
self._dbg("ClearGraph.undo -> restoring snapshot")
|
|
1039
|
-
if self._snapshot:
|
|
1040
|
-
self.editor.load_layout(self._snapshot)
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
# ------------------------ NodeEditor ------------------------
|
|
1044
|
-
|
|
1045
|
-
class NodeEditor(QWidget):
|
|
1046
|
-
"""Main widget embedding a QGraphicsView-based node editor."""
|
|
1047
|
-
def _q_set(self, name, value):
|
|
1048
|
-
setattr(self, name, value)
|
|
1049
|
-
self._update_theme()
|
|
1050
|
-
|
|
1051
|
-
def _q_set_bool(self, name, value: bool):
|
|
1052
|
-
setattr(self, name, bool(value))
|
|
1053
|
-
|
|
1054
|
-
_node_bg_color = QColor(45, 45, 48)
|
|
1055
|
-
_node_border_color = QColor(80, 80, 90)
|
|
1056
|
-
_node_selection_color = QColor(255, 170, 0)
|
|
1057
|
-
_node_title_color = QColor(60, 60, 63)
|
|
1058
|
-
_port_input_color = QColor(100, 180, 255)
|
|
1059
|
-
_port_output_color = QColor(140, 255, 140)
|
|
1060
|
-
_port_connected_color = QColor(255, 220, 100)
|
|
1061
|
-
_port_accept_color = QColor(255, 255, 140)
|
|
1062
|
-
_edge_color = QColor(180, 180, 180)
|
|
1063
|
-
_edge_selected_color = QColor(255, 140, 90)
|
|
1064
|
-
_grid_back_color = QColor(35, 35, 38)
|
|
1065
|
-
_grid_pen_color = QColor(55, 55, 60)
|
|
1066
|
-
|
|
1067
|
-
# New: colors for tiny port labels
|
|
1068
|
-
_port_label_color = QColor(220, 220, 220)
|
|
1069
|
-
_port_capacity_color = QColor(200, 200, 200)
|
|
1070
|
-
|
|
1071
|
-
_edge_pick_width: float = 12.0
|
|
1072
|
-
_resize_grip_margin: float = 12.0
|
|
1073
|
-
_resize_grip_hit_inset: float = 5.0
|
|
1074
|
-
_port_pick_radius: float = 10.0
|
|
1075
|
-
_port_click_rewire_if_connected: bool = True
|
|
1076
|
-
_rewire_drop_deletes: bool = True
|
|
1077
|
-
|
|
1078
|
-
nodeBackgroundColor = Property(QColor, lambda self: self._node_bg_color, lambda self, v: self._q_set("_node_bg_color", v))
|
|
1079
|
-
nodeBorderColor = Property(QColor, lambda self: self._node_border_color, lambda self, v: self._q_set("_node_border_color", v))
|
|
1080
|
-
nodeSelectionColor = Property(QColor, lambda self: self._node_selection_color, lambda self, v: self._q_set("_node_selection_color", v))
|
|
1081
|
-
nodeTitleColor = Property(QColor, lambda self: self._node_title_color, lambda self, v: self._q_set("_node_title_color", v))
|
|
1082
|
-
portInputColor = Property(QColor, lambda self: self._port_input_color, lambda self, v: self._q_set("_port_input_color", v))
|
|
1083
|
-
portOutputColor = Property(QColor, lambda self: self._port_output_color, lambda self, v: self._q_set("_port_output_color", v))
|
|
1084
|
-
portConnectedColor = Property(QColor, lambda self: self._port_connected_color, lambda self, v: self._q_set("_port_connected_color", v))
|
|
1085
|
-
portAcceptColor = Property(QColor, lambda self: self._port_accept_color, lambda self, v: self._q_set("_port_accept_color", v))
|
|
1086
|
-
edgeColor = Property(QColor, lambda self: self._edge_color, lambda self, v: self._q_set("_edge_color", v))
|
|
1087
|
-
edgeSelectedColor = Property(QColor, lambda self: self._edge_selected_color, lambda self, v: self._q_set("_edge_selected_color", v))
|
|
1088
|
-
gridBackColor = Property(QColor, lambda self: self._grid_back_color, lambda self, v: self._q_set("_grid_back_color", v))
|
|
1089
|
-
gridPenColor = Property(QColor, lambda self: self._grid_pen_color, lambda self, v: self._q_set("_grid_pen_color", v))
|
|
1090
|
-
|
|
1091
|
-
# New: expose colors for labels
|
|
1092
|
-
portLabelColor = Property(QColor, lambda self: self._port_label_color, lambda self, v: self._q_set("_port_label_color", v))
|
|
1093
|
-
portCapacityColor = Property(QColor, lambda self: self._port_capacity_color, lambda self, v: self._q_set("_port_capacity_color", v))
|
|
1094
|
-
|
|
1095
|
-
edgePickWidth = Property(float, lambda self: self._edge_pick_width, lambda self, v: self._set_edge_pick_width(v))
|
|
1096
|
-
resizeGripMargin = Property(float, lambda self: self._resize_grip_margin, lambda self, v: self._q_set("_resize_grip_margin", float(v)))
|
|
1097
|
-
resizeGripHitInset = Property(float, lambda self: self._resize_grip_hit_inset, lambda self, v: self._set_resize_grip_hit_inset(float(v)))
|
|
1098
|
-
portPickRadius = Property(float, lambda self: self._port_pick_radius, lambda self, v: self._q_set("_port_pick_radius", float(v)))
|
|
1099
|
-
portClickRewireIfConnected = Property(bool, lambda self: self._port_click_rewire_if_connected, lambda self, v: self._q_set_bool("_port_click_rewire_if_connected", v))
|
|
1100
|
-
rewireDropDeletes = Property(bool, lambda self: self._rewire_drop_deletes, lambda self, v: self._q_set_bool("_rewire_drop_deletes", v))
|
|
1101
|
-
|
|
1102
|
-
def _set_resize_grip_hit_inset(self, v: float):
|
|
1103
|
-
self._resize_grip_hit_inset = max(0.0, float(v))
|
|
1104
|
-
# recompute proxy sizes/gutters
|
|
1105
|
-
for item in list(self._uuid_to_item.values()):
|
|
1106
|
-
if _qt_is_valid(item):
|
|
1107
|
-
item._apply_resize(item.size(), clamp=True)
|
|
1108
|
-
self.view.viewport().update()
|
|
1109
|
-
|
|
1110
|
-
def _set_edge_pick_width(self, v: float):
|
|
1111
|
-
self._edge_pick_width = float(v) if v and v > 0 else 12.0
|
|
1112
|
-
for edge in list(self._conn_uuid_to_edge.values()):
|
|
1113
|
-
if _qt_is_valid(edge):
|
|
1114
|
-
edge.prepareGeometryChange()
|
|
1115
|
-
edge.update()
|
|
1116
|
-
if getattr(self, "_interactive_edge", None) and _qt_is_valid(self._interactive_edge):
|
|
1117
|
-
self._interactive_edge.prepareGeometryChange()
|
|
1118
|
-
self._interactive_edge.update()
|
|
1119
|
-
self.view.viewport().update()
|
|
1120
|
-
|
|
1121
|
-
def __init__(self, parent: Optional[QWidget] = None, registry: Optional[NodeTypeRegistry] = None):
|
|
1122
|
-
super().__init__(parent)
|
|
1123
|
-
self.setObjectName("NodeEditor")
|
|
1124
|
-
self._debug = False # DEBUG toggle
|
|
1125
|
-
self._dbg("INIT NodeEditor")
|
|
1126
|
-
|
|
1127
|
-
self.graph = NodeGraph(registry)
|
|
1128
|
-
self.scene = NodeGraphicsScene(self)
|
|
1129
|
-
self.view = NodeGraphicsView(self.scene, self)
|
|
1130
|
-
self.view.setGeometry(self.rect())
|
|
1131
|
-
self.view.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
|
1132
|
-
self.view.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
|
1133
|
-
self._undo = QUndoStack(self)
|
|
1134
|
-
self._uuid_to_item: Dict[str, NodeItem] = {}
|
|
1135
|
-
self._conn_uuid_to_edge: Dict[str, EdgeItem] = {}
|
|
1136
|
-
self._interactive_edge: Optional[EdgeItem] = None
|
|
1137
|
-
self._interactive_src_port: Optional[PortItem] = None
|
|
1138
|
-
self._hover_candidate: Optional[PortItem] = None
|
|
1139
|
-
self._pending_node_positions: Dict[str, QPointF] = {}
|
|
1140
|
-
|
|
1141
|
-
# Wire interaction state: idle | drawing | rewire-primed | rewiring
|
|
1142
|
-
self._wire_state: str = "idle"
|
|
1143
|
-
self._rewire_conn_uuid: Optional[str] = None
|
|
1144
|
-
self._rewire_hidden_edge: Optional[EdgeItem] = None
|
|
1145
|
-
self._rewire_fixed_src: Optional[PortItem] = None
|
|
1146
|
-
self._rewire_press_scene_pos: Optional[QPointF] = None
|
|
1147
|
-
|
|
1148
|
-
self._spawn_origin: Optional[QPointF] = None
|
|
1149
|
-
self._spawn_index: int = 0
|
|
1150
|
-
|
|
1151
|
-
self._saved_drag_mode: Optional[QGraphicsView.DragMode] = None
|
|
1152
|
-
|
|
1153
|
-
self._shortcut_delete = QShortcut(QKeySequence(Qt.Key_Delete), self)
|
|
1154
|
-
self._shortcut_delete.setContext(Qt.ApplicationShortcut)
|
|
1155
|
-
self._shortcut_delete.activated.connect(self._delete_selected_connections)
|
|
1156
|
-
self._shortcut_backspace = QShortcut(QKeySequence(Qt.Key_Backspace), self)
|
|
1157
|
-
self._shortcut_backspace.setContext(Qt.ApplicationShortcut)
|
|
1158
|
-
self._shortcut_backspace.activated.connect(self._delete_selected_connections)
|
|
1159
|
-
|
|
1160
|
-
self.scene.sceneContextRequested.connect(self._on_scene_context_menu)
|
|
1161
|
-
self.graph.nodeAdded.connect(self._on_graph_node_added)
|
|
1162
|
-
self.graph.nodeRemoved.connect(self._on_graph_node_removed)
|
|
1163
|
-
self.graph.connectionAdded.connect(self._on_graph_connection_added)
|
|
1164
|
-
self.graph.connectionRemoved.connect(self._on_graph_connection_removed)
|
|
1165
|
-
self.graph.cleared.connect(self._on_graph_cleared)
|
|
1166
|
-
|
|
1167
|
-
self.scene.installEventFilter(self)
|
|
1168
|
-
|
|
1169
|
-
self._alive = True
|
|
1170
|
-
self.destroyed.connect(self._on_destroyed)
|
|
1171
|
-
|
|
1172
|
-
self.on_clear = None
|
|
1173
|
-
|
|
1174
|
-
# ---------- Debug helper ----------
|
|
1175
|
-
def _dbg(self, msg: str):
|
|
1176
|
-
if self._debug:
|
|
1177
|
-
print(f"[NodeEditor][{hex(id(self))}] {msg}")
|
|
1178
|
-
|
|
1179
|
-
# ---------- QWidget overrides ----------
|
|
1180
|
-
|
|
1181
|
-
def _on_destroyed(self):
|
|
1182
|
-
self._alive = False
|
|
1183
|
-
try:
|
|
1184
|
-
if _qt_is_valid(self.scene):
|
|
1185
|
-
self.scene.removeEventFilter(self)
|
|
1186
|
-
except Exception:
|
|
1187
|
-
pass
|
|
1188
|
-
self._cleanup_node_proxies()
|
|
1189
|
-
self._disconnect_graph_signals()
|
|
1190
|
-
|
|
1191
|
-
def closeEvent(self, e):
|
|
1192
|
-
self._dbg("closeEvent -> full cleanup")
|
|
1193
|
-
try:
|
|
1194
|
-
self._shortcut_delete.setEnabled(False)
|
|
1195
|
-
self._shortcut_backspace.setEnabled(False)
|
|
1196
|
-
except Exception:
|
|
1197
|
-
pass
|
|
1198
|
-
self._reset_interaction_states(remove_hidden_edges=True)
|
|
1199
|
-
try:
|
|
1200
|
-
self.scene.removeEventFilter(self)
|
|
1201
|
-
except Exception:
|
|
1202
|
-
pass
|
|
1203
|
-
self._disconnect_graph_signals()
|
|
1204
|
-
self._cleanup_node_proxies()
|
|
1205
|
-
try:
|
|
1206
|
-
self.view.setScene(None)
|
|
1207
|
-
except Exception:
|
|
1208
|
-
pass
|
|
1209
|
-
try:
|
|
1210
|
-
self._remove_all_edge_items_from_scene()
|
|
1211
|
-
self.scene.clear()
|
|
1212
|
-
except Exception:
|
|
1213
|
-
pass
|
|
1214
|
-
self._alive = False
|
|
1215
|
-
super().closeEvent(e)
|
|
1216
|
-
|
|
1217
|
-
def _disconnect_graph_signals(self):
|
|
1218
|
-
for signal, slot in (
|
|
1219
|
-
(self.graph.nodeAdded, self._on_graph_node_added),
|
|
1220
|
-
(self.graph.nodeRemoved, self._on_graph_node_removed),
|
|
1221
|
-
(self.graph.connectionAdded, self._on_graph_connection_added),
|
|
1222
|
-
(self.graph.connectionRemoved, self._on_graph_connection_removed),
|
|
1223
|
-
(self.graph.cleared, self._on_graph_cleared),
|
|
1224
|
-
):
|
|
1225
|
-
try:
|
|
1226
|
-
signal.disconnect(slot)
|
|
1227
|
-
except Exception:
|
|
1228
|
-
pass
|
|
1229
|
-
|
|
1230
|
-
def resizeEvent(self, e):
|
|
1231
|
-
self.view.setGeometry(self.rect())
|
|
1232
|
-
super().resizeEvent(e)
|
|
1233
|
-
|
|
1234
|
-
def showEvent(self, e):
|
|
1235
|
-
if self._spawn_origin is None and self.view:
|
|
1236
|
-
self._spawn_origin = self.view.mapToScene(self.view.viewport().rect().center())
|
|
1237
|
-
self._reapply_stylesheets()
|
|
1238
|
-
super().showEvent(e)
|
|
1239
|
-
|
|
1240
|
-
def event(self, e):
|
|
1241
|
-
et = e.type()
|
|
1242
|
-
if et in (QEvent.StyleChange, QEvent.PaletteChange, QEvent.FontChange,
|
|
1243
|
-
QEvent.ApplicationPaletteChange, QEvent.ApplicationFontChange):
|
|
1244
|
-
self._reapply_stylesheets()
|
|
1245
|
-
if et in (QEvent.FocusOut, QEvent.WindowDeactivate):
|
|
1246
|
-
self._dbg("event -> focus out/window deactivate -> reset interaction")
|
|
1247
|
-
self._reset_interaction_states(remove_hidden_edges=False)
|
|
1248
|
-
return super().event(e)
|
|
1249
|
-
|
|
1250
|
-
# ---------- Public API ----------
|
|
1251
|
-
|
|
1252
|
-
def add_node(self, type_name: str, scene_pos: QPointF):
|
|
1253
|
-
self._undo.push(AddNodeCommand(self, type_name, scene_pos))
|
|
1254
|
-
|
|
1255
|
-
def clear(self, ask_user: bool = True):
|
|
1256
|
-
if ask_user and self.on_clear and callable(self.on_clear):
|
|
1257
|
-
self.on_clear()
|
|
1258
|
-
return
|
|
1259
|
-
if ask_user:
|
|
1260
|
-
reply = QMessageBox.question(self, trans("agent.builder.confirm.clear.title"),
|
|
1261
|
-
trans("agent.builder.confirm.clear.msg"),
|
|
1262
|
-
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
|
|
1263
|
-
if reply != QMessageBox.Yes:
|
|
1264
|
-
return False
|
|
1265
|
-
self._undo.push(ClearGraphCommand(self))
|
|
1266
|
-
return True
|
|
1267
|
-
|
|
1268
|
-
def undo(self):
|
|
1269
|
-
self._undo.undo()
|
|
1270
|
-
|
|
1271
|
-
def redo(self):
|
|
1272
|
-
self._undo.redo()
|
|
1273
|
-
|
|
1274
|
-
def save_layout(self) -> dict:
|
|
1275
|
-
"""Serialize graph plus per-node positions and sizes."""
|
|
1276
|
-
data = self.graph.to_dict()
|
|
1277
|
-
data["positions"] = {
|
|
1278
|
-
nuuid: [self._uuid_to_item[nuuid].pos().x(), self._uuid_to_item[nuuid].pos().y()]
|
|
1279
|
-
for nuuid in self._uuid_to_item
|
|
1280
|
-
}
|
|
1281
|
-
data["sizes"] = {
|
|
1282
|
-
nuuid: [float(self._uuid_to_item[nuuid].size().width()),
|
|
1283
|
-
float(self._uuid_to_item[nuuid].size().height())]
|
|
1284
|
-
for nuuid in self._uuid_to_item
|
|
1285
|
-
}
|
|
1286
|
-
return data
|
|
1287
|
-
|
|
1288
|
-
def load_layout(self, data: dict):
|
|
1289
|
-
"""Reset and restore graph + positions and (optionally) sizes."""
|
|
1290
|
-
self._dbg("load_layout -> reset and from_dict")
|
|
1291
|
-
self._reset_interaction_states(remove_hidden_edges=True)
|
|
1292
|
-
self._clear_scene_only(hard=True)
|
|
1293
|
-
self.graph.from_dict(data)
|
|
1294
|
-
|
|
1295
|
-
positions = data.get("positions", {})
|
|
1296
|
-
sizes = data.get("sizes", {})
|
|
1297
|
-
|
|
1298
|
-
# Apply sizes first (so port geometry is correct), then positions.
|
|
1299
|
-
for nuuid, wh in sizes.items():
|
|
1300
|
-
item = self._uuid_to_item.get(nuuid)
|
|
1301
|
-
if item and isinstance(wh, (list, tuple)) and len(wh) == 2:
|
|
1302
|
-
try:
|
|
1303
|
-
w = float(wh[0]); h = float(wh[1])
|
|
1304
|
-
item._apply_resize(QSizeF(w, h), clamp=True)
|
|
1305
|
-
except Exception:
|
|
1306
|
-
pass
|
|
1307
|
-
|
|
1308
|
-
for nuuid, xy in positions.items():
|
|
1309
|
-
item = self._uuid_to_item.get(nuuid)
|
|
1310
|
-
if item and isinstance(xy, (list, tuple)) and len(xy) == 2:
|
|
1311
|
-
try:
|
|
1312
|
-
item.setPos(QPointF(float(xy[0]), float(xy[1])))
|
|
1313
|
-
except Exception:
|
|
1314
|
-
pass
|
|
1315
|
-
|
|
1316
|
-
# Make sure edges are updated to final geometry.
|
|
1317
|
-
for item in self._uuid_to_item.values():
|
|
1318
|
-
if _qt_is_valid(item):
|
|
1319
|
-
item.update_ports_positions()
|
|
1320
|
-
|
|
1321
|
-
self._reapply_stylesheets()
|
|
1322
|
-
self.center_on_content()
|
|
1323
|
-
|
|
1324
|
-
def export_schema(self) -> dict:
|
|
1325
|
-
return self.graph.to_schema()
|
|
1326
|
-
|
|
1327
|
-
# New: export to requested agent schema (list)
|
|
1328
|
-
def export_agent_schema(self) -> List[dict]:
|
|
1329
|
-
return self.graph.to_agent_schema()
|
|
1330
|
-
|
|
1331
|
-
def debug_state(self) -> dict:
|
|
1332
|
-
return self.save_layout()
|
|
1333
|
-
|
|
1334
|
-
def zoom_in(self):
|
|
1335
|
-
self.view.zoom_in()
|
|
1336
|
-
|
|
1337
|
-
def zoom_out(self):
|
|
1338
|
-
self.view.zoom_out()
|
|
1339
|
-
|
|
1340
|
-
# ---------- Graph <-> UI sync ----------
|
|
1341
|
-
|
|
1342
|
-
def _model_by_uuid(self, node_uuid: str) -> Optional[NodeModel]:
|
|
1343
|
-
return self.graph.nodes.get(node_uuid)
|
|
1344
|
-
|
|
1345
|
-
def _on_graph_node_added(self, node: NodeModel):
|
|
1346
|
-
if node.uuid in self._uuid_to_item:
|
|
1347
|
-
return
|
|
1348
|
-
pos = self._pending_node_positions.pop(node.uuid, None)
|
|
1349
|
-
if pos is None:
|
|
1350
|
-
pos = self._next_spawn_pos()
|
|
1351
|
-
self._dbg(f"graph.nodeAdded -> add item for node={node.name}({node.uuid}) at {pos}")
|
|
1352
|
-
self._add_node_item(node, pos)
|
|
1353
|
-
|
|
1354
|
-
def _on_graph_node_removed(self, node_uuid: str):
|
|
1355
|
-
self._dbg(f"graph.nodeRemoved -> remove item for node={node_uuid}")
|
|
1356
|
-
item = self._uuid_to_item.pop(node_uuid, None)
|
|
1357
|
-
if item and _qt_is_valid(item):
|
|
1358
|
-
try:
|
|
1359
|
-
self.scene.removeItem(item)
|
|
1360
|
-
except Exception:
|
|
1361
|
-
pass
|
|
1362
|
-
|
|
1363
|
-
def _on_graph_connection_added(self, conn: ConnectionModel):
|
|
1364
|
-
self._dbg(f"graph.connectionAdded -> add edge uuid={conn.uuid} src=({conn.src_node},{conn.src_prop}) dst=({conn.dst_node},{conn.dst_prop})")
|
|
1365
|
-
self._add_edge_for_connection(conn)
|
|
1366
|
-
|
|
1367
|
-
def _on_graph_connection_removed(self, conn_uuid: str):
|
|
1368
|
-
self._dbg(f"graph.connectionRemoved -> remove edge uuid={conn_uuid}")
|
|
1369
|
-
edge = self._conn_uuid_to_edge.pop(conn_uuid, None)
|
|
1370
|
-
if edge:
|
|
1371
|
-
edge.src_port.increment_connections(-1)
|
|
1372
|
-
edge.dst_port.increment_connections(-1)
|
|
1373
|
-
if _qt_is_valid(edge.src_port.node_item):
|
|
1374
|
-
edge.src_port.node_item.remove_edge(edge)
|
|
1375
|
-
if _qt_is_valid(edge.dst_port.node_item):
|
|
1376
|
-
edge.dst_port.node_item.remove_edge(edge)
|
|
1377
|
-
if _qt_is_valid(edge):
|
|
1378
|
-
try:
|
|
1379
|
-
self.scene.removeItem(edge)
|
|
1380
|
-
except Exception:
|
|
1381
|
-
pass
|
|
1382
|
-
|
|
1383
|
-
def _on_graph_cleared(self):
|
|
1384
|
-
self._dbg("graph.cleared -> clear scene only")
|
|
1385
|
-
self._reset_interaction_states(remove_hidden_edges=True)
|
|
1386
|
-
self._clear_scene_only(hard=True)
|
|
1387
|
-
|
|
1388
|
-
# ---------- Scene helpers ----------
|
|
1389
|
-
|
|
1390
|
-
def _scene_to_global(self, scene_pos: QPointF) -> QPoint:
|
|
1391
|
-
from PySide6.QtCore import QPoint as _QPoint
|
|
1392
|
-
vp_pt = self.view.mapFromScene(scene_pos)
|
|
1393
|
-
if isinstance(vp_pt, QPointF):
|
|
1394
|
-
vp_pt = _QPoint(int(vp_pt.x()), int(vp_pt.y()))
|
|
1395
|
-
return self.view.viewport().mapToGlobal(vp_pt)
|
|
1396
|
-
|
|
1397
|
-
def _on_scene_context_menu(self, scene_pos: QPointF):
|
|
1398
|
-
menu = QMenu(self.window())
|
|
1399
|
-
ss = self.window().styleSheet()
|
|
1400
|
-
if ss:
|
|
1401
|
-
menu.setStyleSheet(ss)
|
|
1402
|
-
|
|
1403
|
-
add_menu = menu.addMenu("Add")
|
|
1404
|
-
action_by_type: Dict[QAction, str] = {}
|
|
1405
|
-
for tname in self.graph.registry.types():
|
|
1406
|
-
act = add_menu.addAction(tname)
|
|
1407
|
-
action_by_type[act] = tname
|
|
1408
|
-
|
|
1409
|
-
menu.addSeparator()
|
|
1410
|
-
act_undo = QAction("Undo", menu)
|
|
1411
|
-
act_redo = QAction("Redo", menu)
|
|
1412
|
-
act_clear = QAction("Clear", menu)
|
|
1413
|
-
act_undo.setEnabled(self._undo.canUndo())
|
|
1414
|
-
act_redo.setEnabled(self._undo.canRedo())
|
|
1415
|
-
menu.addAction(act_undo)
|
|
1416
|
-
menu.addAction(act_redo)
|
|
1417
|
-
menu.addSeparator()
|
|
1418
|
-
menu.addAction(act_clear)
|
|
1419
|
-
|
|
1420
|
-
global_pos = self._scene_to_global(scene_pos)
|
|
1421
|
-
chosen = menu.exec(global_pos)
|
|
1422
|
-
if chosen is None:
|
|
1423
|
-
return
|
|
1424
|
-
if chosen == act_undo:
|
|
1425
|
-
self.undo()
|
|
1426
|
-
elif chosen == act_redo:
|
|
1427
|
-
self.redo()
|
|
1428
|
-
elif chosen == act_clear:
|
|
1429
|
-
self.clear(ask_user=True)
|
|
1430
|
-
elif chosen in action_by_type:
|
|
1431
|
-
type_name = action_by_type[chosen]
|
|
1432
|
-
self._undo.push(AddNodeCommand(self, type_name, scene_pos))
|
|
1433
|
-
|
|
1434
|
-
# ---------- Add/remove nodes/edges ----------
|
|
1435
|
-
|
|
1436
|
-
def _add_node_model(self, node: NodeModel, scene_pos: QPointF):
|
|
1437
|
-
self._pending_node_positions[node.uuid] = scene_pos
|
|
1438
|
-
self.graph.add_node(node)
|
|
1439
|
-
|
|
1440
|
-
def _add_node_item(self, node: NodeModel, scene_pos: QPointF):
|
|
1441
|
-
item = NodeItem(self, node)
|
|
1442
|
-
self.scene.addItem(item)
|
|
1443
|
-
free_pos = self._find_free_position(scene_pos, item.size())
|
|
1444
|
-
item.setPos(free_pos)
|
|
1445
|
-
item.update_ports_positions()
|
|
1446
|
-
self._uuid_to_item[node.uuid] = item
|
|
1447
|
-
self._apply_styles_to_content(item._content)
|
|
1448
|
-
|
|
1449
|
-
def _find_free_position(self, desired: QPointF, size: QSizeF, step: int = 40, max_rings: int = 20) -> QPointF:
|
|
1450
|
-
def rect_at(p: QPointF) -> QRectF:
|
|
1451
|
-
return QRectF(p.x(), p.y(), size.width(), size.height()).adjusted(1, 1, -1, -1)
|
|
1452
|
-
def collides(p: QPointF) -> bool:
|
|
1453
|
-
r = rect_at(p)
|
|
1454
|
-
for it in self.scene.items(r, Qt.IntersectsItemBoundingRect):
|
|
1455
|
-
if isinstance(it, NodeItem):
|
|
1456
|
-
other = QRectF(it.scenePos().x(), it.scenePos().y(), it.size().width(), it.size().height()).adjusted(1,1,-1,-1)
|
|
1457
|
-
if r.intersects(other):
|
|
1458
|
-
return True
|
|
1459
|
-
return False
|
|
1460
|
-
|
|
1461
|
-
if not collides(desired):
|
|
1462
|
-
return desired
|
|
1463
|
-
|
|
1464
|
-
x = y = 0
|
|
1465
|
-
dx, dy = 1, 0
|
|
1466
|
-
segment_length = 1
|
|
1467
|
-
p = QPointF(desired)
|
|
1468
|
-
for ring in range(1, max_rings + 1):
|
|
1469
|
-
for _ in range(2):
|
|
1470
|
-
for _ in range(segment_length):
|
|
1471
|
-
p = QPointF(desired.x() + x * step, desired.y() + y * step)
|
|
1472
|
-
if not collides(p):
|
|
1473
|
-
return p
|
|
1474
|
-
x += dx; y += dy
|
|
1475
|
-
dx, dy = -dy, dx
|
|
1476
|
-
segment_length += 1
|
|
1477
|
-
return desired
|
|
1478
|
-
|
|
1479
|
-
def _next_spawn_pos(self) -> QPointF:
|
|
1480
|
-
if self._spawn_origin is None:
|
|
1481
|
-
if not self.view.viewport().rect().isEmpty():
|
|
1482
|
-
self._spawn_origin = self.view.mapToScene(self.view.viewport().rect().center())
|
|
1483
|
-
else:
|
|
1484
|
-
self._spawn_origin = self.scene.sceneRect().center()
|
|
1485
|
-
origin = self._spawn_origin
|
|
1486
|
-
step = 80
|
|
1487
|
-
base_grid = [(-1, -1), (0, -1), (1, -1),
|
|
1488
|
-
(-1, 0), (0, 0), (1, 0),
|
|
1489
|
-
(-1, 1), (0, 1), (1, 1)]
|
|
1490
|
-
ring = self._spawn_index // len(base_grid) + 1
|
|
1491
|
-
gx, gy = base_grid[self._spawn_index % len(base_grid)]
|
|
1492
|
-
self._spawn_index += 1
|
|
1493
|
-
return QPointF(origin.x() + gx * step * ring, origin.y() + gy * step * ring)
|
|
1494
|
-
|
|
1495
|
-
def _remove_node_by_uuid(self, node_uuid: str):
|
|
1496
|
-
self.graph.remove_node(node_uuid)
|
|
1497
|
-
|
|
1498
|
-
def _add_edge_for_connection(self, conn: ConnectionModel):
|
|
1499
|
-
# Anti-dup guard
|
|
1500
|
-
ex = self._conn_uuid_to_edge.get(conn.uuid)
|
|
1501
|
-
if ex and _qt_is_valid(ex):
|
|
1502
|
-
self._dbg(f"_add_edge_for_connection: guard skip duplicate for uuid={conn.uuid}")
|
|
1503
|
-
return
|
|
1504
|
-
|
|
1505
|
-
src_item = self._uuid_to_item.get(conn.src_node)
|
|
1506
|
-
dst_item = self._uuid_to_item.get(conn.dst_node)
|
|
1507
|
-
if not src_item or not dst_item:
|
|
1508
|
-
self._dbg(f"_add_edge_for_connection: missing node items for conn={conn.uuid}")
|
|
1509
|
-
return
|
|
1510
|
-
src_port = src_item._out_ports.get(conn.src_prop)
|
|
1511
|
-
dst_port = dst_item._in_ports.get(conn.dst_prop)
|
|
1512
|
-
if not src_port or not dst_port:
|
|
1513
|
-
self._dbg(f"_add_edge_for_connection: missing ports src={bool(src_port)} dst={bool(dst_port)}")
|
|
1514
|
-
return
|
|
1515
|
-
edge = EdgeItem(src_port, dst_port, temporary=False)
|
|
1516
|
-
edge.update_path()
|
|
1517
|
-
self.scene.addItem(edge)
|
|
1518
|
-
src_item.add_edge(edge)
|
|
1519
|
-
dst_item.add_edge(edge)
|
|
1520
|
-
src_port.increment_connections(+1)
|
|
1521
|
-
dst_port.increment_connections(+1)
|
|
1522
|
-
edge._conn_uuid = conn.uuid
|
|
1523
|
-
self._conn_uuid_to_edge[conn.uuid] = edge
|
|
1524
|
-
self._dbg(f"_add_edge_for_connection: edge id={id(edge)} mapped to uuid={conn.uuid}")
|
|
1525
|
-
|
|
1526
|
-
def _remove_connection_by_uuid(self, conn_uuid: str):
|
|
1527
|
-
self._dbg(f"_remove_connection_by_uuid -> {conn_uuid}")
|
|
1528
|
-
self.graph.remove_connection(conn_uuid)
|
|
1529
|
-
|
|
1530
|
-
# ---------- Theming helpers ----------
|
|
1531
|
-
|
|
1532
|
-
def _update_theme(self):
|
|
1533
|
-
self._dbg("_update_theme")
|
|
1534
|
-
for item in self._uuid_to_item.values():
|
|
1535
|
-
if _qt_is_valid(item):
|
|
1536
|
-
item._apply_resize(item.size(), clamp=True)
|
|
1537
|
-
item.update()
|
|
1538
|
-
# Update port labels colors
|
|
1539
|
-
for p in list(item._in_ports.values()) + list(item._out_ports.values()):
|
|
1540
|
-
if _qt_is_valid(p):
|
|
1541
|
-
p.notify_theme_changed()
|
|
1542
|
-
for edge in self._conn_uuid_to_edge.values():
|
|
1543
|
-
if _qt_is_valid(edge):
|
|
1544
|
-
edge._update_pen()
|
|
1545
|
-
edge.update()
|
|
1546
|
-
self.view.viewport().update()
|
|
1547
|
-
self._reapply_stylesheets()
|
|
1548
|
-
|
|
1549
|
-
def _current_stylesheet(self) -> str:
|
|
1550
|
-
wnd = self.window()
|
|
1551
|
-
if isinstance(wnd, QWidget) and wnd.styleSheet():
|
|
1552
|
-
return wnd.styleSheet()
|
|
1553
|
-
if QApplication.instance() and QApplication.instance().styleSheet():
|
|
1554
|
-
return QApplication.instance().styleSheet()
|
|
1555
|
-
return ""
|
|
1556
|
-
|
|
1557
|
-
def _current_palette(self) -> QPalette:
|
|
1558
|
-
wnd = self.window()
|
|
1559
|
-
if isinstance(wnd, QWidget):
|
|
1560
|
-
return wnd.palette()
|
|
1561
|
-
return QApplication.instance().palette() if QApplication.instance() else self.palette()
|
|
1562
|
-
|
|
1563
|
-
def _apply_styles_to_content(self, content_widget: QWidget):
|
|
1564
|
-
if content_widget is None:
|
|
1565
|
-
return
|
|
1566
|
-
content_widget.setAttribute(Qt.WA_StyledBackground, True)
|
|
1567
|
-
stylesheet = self._current_stylesheet()
|
|
1568
|
-
pal = self._current_palette()
|
|
1569
|
-
content_widget.setPalette(pal)
|
|
1570
|
-
if stylesheet:
|
|
1571
|
-
content_widget.setStyleSheet(stylesheet)
|
|
1572
|
-
content_widget.ensurePolished()
|
|
1573
|
-
for w in content_widget.findChildren(QWidget):
|
|
1574
|
-
w.ensurePolished()
|
|
1575
|
-
|
|
1576
|
-
def _reapply_stylesheets(self):
|
|
1577
|
-
stylesheet = self._current_stylesheet()
|
|
1578
|
-
pal = self._current_palette()
|
|
1579
|
-
for item in self._uuid_to_item.values():
|
|
1580
|
-
if item._content and _qt_is_valid(item._content):
|
|
1581
|
-
item._content.setPalette(pal)
|
|
1582
|
-
if stylesheet:
|
|
1583
|
-
item._content.setStyleSheet(stylesheet)
|
|
1584
|
-
item._content.ensurePolished()
|
|
1585
|
-
for w in item._content.findChildren(QWidget):
|
|
1586
|
-
w.ensurePolished()
|
|
1587
|
-
|
|
1588
|
-
# ---------- Edge/Port helpers + rewire-aware validation ----------
|
|
1589
|
-
|
|
1590
|
-
def _edges_for_port(self, port: PortItem) -> List[EdgeItem]:
|
|
1591
|
-
res: List[EdgeItem] = []
|
|
1592
|
-
nitem = port.node_item
|
|
1593
|
-
for e in list(nitem._edges):
|
|
1594
|
-
if _qt_is_valid(e) and not e.temporary and (e.src_port is port or e.dst_port is port):
|
|
1595
|
-
res.append(e)
|
|
1596
|
-
for it in self.scene.items():
|
|
1597
|
-
if isinstance(it, EdgeItem) and not it.temporary and (it.src_port is port or it.dst_port is port):
|
|
1598
|
-
if it not in res:
|
|
1599
|
-
res.append(it)
|
|
1600
|
-
self._dbg(f"_edges_for_port: port={port.prop_id}/{port.side} -> {len(res)} edges")
|
|
1601
|
-
return res
|
|
1602
|
-
|
|
1603
|
-
def _port_has_connections(self, port: PortItem) -> bool:
|
|
1604
|
-
res = (getattr(port, "_connected_count", 0) > 0) or bool(self._edges_for_port(port))
|
|
1605
|
-
self._dbg(f"_port_has_connections: port={port.prop_id}/{port.side} -> {res}")
|
|
1606
|
-
return res
|
|
1607
|
-
|
|
1608
|
-
def _choose_edge_near_cursor(self, edges: List[EdgeItem], cursor_scene: QPointF, ref_port: PortItem) -> Optional[EdgeItem]:
|
|
1609
|
-
if not edges:
|
|
1610
|
-
return None
|
|
1611
|
-
if len(edges) == 1:
|
|
1612
|
-
return edges[0]
|
|
1613
|
-
best = None
|
|
1614
|
-
best_d2 = float("inf")
|
|
1615
|
-
for e in edges:
|
|
1616
|
-
other = e.dst_port if e.src_port is ref_port else e.src_port
|
|
1617
|
-
op = other.scenePos()
|
|
1618
|
-
dx = cursor_scene.x() - op.x()
|
|
1619
|
-
dy = cursor_scene.y() - op.y()
|
|
1620
|
-
d2 = dx*dx + dy*dy
|
|
1621
|
-
if d2 < best_d2:
|
|
1622
|
-
best_d2 = d2
|
|
1623
|
-
best = e
|
|
1624
|
-
return best
|
|
1625
|
-
|
|
1626
|
-
def _can_connect_during_rewire(self, src: PortItem, dst: PortItem) -> bool:
|
|
1627
|
-
try:
|
|
1628
|
-
src_node = self.graph.nodes.get(src.node_item.node.uuid)
|
|
1629
|
-
dst_node = self.graph.nodes.get(dst.node_item.node.uuid)
|
|
1630
|
-
if not src_node or not dst_node:
|
|
1631
|
-
return False
|
|
1632
|
-
sp = src_node.properties.get(src.prop_id)
|
|
1633
|
-
dp = dst_node.properties.get(dst.prop_id)
|
|
1634
|
-
if not sp or not dp:
|
|
1635
|
-
return False
|
|
1636
|
-
if sp.allowed_outputs == 0 or dp.allowed_inputs == 0:
|
|
1637
|
-
return False
|
|
1638
|
-
if sp.type != dp.type:
|
|
1639
|
-
return False
|
|
1640
|
-
skip_uuid = self._rewire_conn_uuid
|
|
1641
|
-
src_count = sum(1 for c in self.graph.connections.values()
|
|
1642
|
-
if
|
|
1643
|
-
c.src_node == src.node_item.node.uuid and c.src_prop == src.prop_id and c.uuid != skip_uuid)
|
|
1644
|
-
dst_count = sum(1 for c in self.graph.connections.values()
|
|
1645
|
-
if
|
|
1646
|
-
c.dst_node == dst.node_item.node.uuid and c.dst_prop == dst.prop_id and c.uuid != skip_uuid)
|
|
1647
|
-
if sp.allowed_outputs > 0 and src_count >= sp.allowed_outputs:
|
|
1648
|
-
return False
|
|
1649
|
-
if dp.allowed_inputs > 0 and dst_count >= dp.allowed_inputs:
|
|
1650
|
-
return False
|
|
1651
|
-
return True
|
|
1652
|
-
except Exception:
|
|
1653
|
-
return False
|
|
1654
|
-
|
|
1655
|
-
def _can_connect_for_interaction(self, src: PortItem, dst: PortItem) -> bool:
|
|
1656
|
-
if self._wire_state in ("rewiring", "rewire-primed"):
|
|
1657
|
-
return self._can_connect_during_rewire(src, dst)
|
|
1658
|
-
ok, _ = self.graph.can_connect((src.node_item.node.uuid, src.prop_id),
|
|
1659
|
-
(dst.node_item.node.uuid, dst.prop_id))
|
|
1660
|
-
return ok
|
|
1661
|
-
|
|
1662
|
-
def _find_compatible_port_at(self, scene_pos: QPointF, radius: Optional[float] = None) -> Optional[PortItem]:
|
|
1663
|
-
if self._interactive_src_port is None:
|
|
1664
|
-
return None
|
|
1665
|
-
src = self._interactive_src_port
|
|
1666
|
-
pick_r_cfg = float(getattr(self, "_port_pick_radius", 10.0) or 10.0)
|
|
1667
|
-
pick_r = float(radius) if radius is not None else max(18.0, pick_r_cfg + 10.0)
|
|
1668
|
-
rect = QRectF(scene_pos.x() - pick_r, scene_pos.y() - pick_r, 2 * pick_r, 2 * pick_r)
|
|
1669
|
-
items = self.scene.items(rect, Qt.IntersectsItemShape, Qt.DescendingOrder, self.view.transform())
|
|
1670
|
-
best: Optional[PortItem] = None
|
|
1671
|
-
best_d2 = float("inf")
|
|
1672
|
-
for it in items:
|
|
1673
|
-
if not isinstance(it, PortItem) or it is src:
|
|
1674
|
-
continue
|
|
1675
|
-
a, b = self._resolve_direction(src, it)
|
|
1676
|
-
if not a or not b:
|
|
1677
|
-
continue
|
|
1678
|
-
if a.node_item.node.uuid == b.node_item.node.uuid:
|
|
1679
|
-
continue
|
|
1680
|
-
if not self._can_connect_for_interaction(a, b):
|
|
1681
|
-
continue
|
|
1682
|
-
pp = it.scenePos()
|
|
1683
|
-
dx = scene_pos.x() - pp.x()
|
|
1684
|
-
dy = scene_pos.y() - pp.y()
|
|
1685
|
-
d2 = dx * dx + dy * dy
|
|
1686
|
-
if d2 < best_d2:
|
|
1687
|
-
best_d2 = d2
|
|
1688
|
-
best = it
|
|
1689
|
-
if best:
|
|
1690
|
-
self._dbg(
|
|
1691
|
-
f"_find_compatible_port_at: FOUND port={best.prop_id}/{best.side} on node={best.node_item.node.name}")
|
|
1692
|
-
return best
|
|
1693
|
-
|
|
1694
|
-
# ---------- Wire interaction (new + rewire) ----------
|
|
1695
|
-
|
|
1696
|
-
def _enter_wire_drag_mode(self):
|
|
1697
|
-
try:
|
|
1698
|
-
self._saved_drag_mode = self.view.dragMode()
|
|
1699
|
-
self.view.setDragMode(QGraphicsView.NoDrag)
|
|
1700
|
-
except Exception:
|
|
1701
|
-
self._saved_drag_mode = None
|
|
1702
|
-
|
|
1703
|
-
def _leave_wire_drag_mode(self):
|
|
1704
|
-
if self._saved_drag_mode is not None:
|
|
1705
|
-
try:
|
|
1706
|
-
self.view.setDragMode(self._saved_drag_mode)
|
|
1707
|
-
except Exception:
|
|
1708
|
-
pass
|
|
1709
|
-
self._saved_drag_mode = None
|
|
1710
|
-
|
|
1711
|
-
def _on_port_clicked(self, port: PortItem):
|
|
1712
|
-
self._dbg(
|
|
1713
|
-
f"_on_port_clicked: side={port.side}, prop={port.prop_id}, connected={self._port_has_connections(port)}")
|
|
1714
|
-
mods = QApplication.keyboardModifiers()
|
|
1715
|
-
force_new = bool(mods & Qt.ShiftModifier)
|
|
1716
|
-
if not force_new and self._port_has_connections(port):
|
|
1717
|
-
cursor_scene = self.view.mapToScene(self.view.mapFromGlobal(QCursor.pos()))
|
|
1718
|
-
edges = self._edges_for_port(port)
|
|
1719
|
-
edge = self._choose_edge_near_cursor(edges, cursor_scene, port)
|
|
1720
|
-
if edge:
|
|
1721
|
-
self._prime_rewire_from_conn(port, getattr(edge, "_conn_uuid", None), edge, cursor_scene)
|
|
1722
|
-
return
|
|
1723
|
-
self._start_draw(port)
|
|
1724
|
-
|
|
1725
|
-
def _prime_rewire_from_conn(self, origin_port: PortItem, conn_uuid: Optional[str],
|
|
1726
|
-
edge: Optional[EdgeItem], press_scene_pos: QPointF):
|
|
1727
|
-
if self._wire_state != "idle":
|
|
1728
|
-
return
|
|
1729
|
-
self._wire_state = "rewire-primed"
|
|
1730
|
-
self._rewire_conn_uuid = conn_uuid
|
|
1731
|
-
self._rewire_hidden_edge = edge
|
|
1732
|
-
fixed_src = origin_port if origin_port.side == "output" else (edge.src_port if edge else None)
|
|
1733
|
-
if fixed_src is None:
|
|
1734
|
-
self._wire_state = "idle"
|
|
1735
|
-
self._rewire_conn_uuid = None
|
|
1736
|
-
self._rewire_hidden_edge = None
|
|
1737
|
-
return
|
|
1738
|
-
self._rewire_fixed_src = fixed_src
|
|
1739
|
-
self._interactive_src_port = fixed_src
|
|
1740
|
-
self._rewire_press_scene_pos = QPointF(press_scene_pos)
|
|
1741
|
-
|
|
1742
|
-
def _start_draw(self, src_port: PortItem):
|
|
1743
|
-
if self._wire_state != "idle":
|
|
1744
|
-
return
|
|
1745
|
-
self._wire_state = "drawing"
|
|
1746
|
-
self._interactive_src_port = src_port
|
|
1747
|
-
self._interactive_edge = EdgeItem(src_port=src_port, dst_port=src_port, temporary=True)
|
|
1748
|
-
self.scene.addItem(self._interactive_edge)
|
|
1749
|
-
self._interactive_edge.update_path(src_port.scenePos())
|
|
1750
|
-
self._enter_wire_drag_mode()
|
|
1751
|
-
|
|
1752
|
-
def _start_rewire_from_edge(self, edge: EdgeItem, cursor_scene_pos: QPointF):
|
|
1753
|
-
if self._wire_state != "idle" or not _qt_is_valid(edge):
|
|
1754
|
-
return
|
|
1755
|
-
self._wire_state = "rewiring"
|
|
1756
|
-
self._rewire_hidden_edge = edge
|
|
1757
|
-
self._rewire_conn_uuid = getattr(edge, "_conn_uuid", None)
|
|
1758
|
-
self._rewire_fixed_src = edge.src_port
|
|
1759
|
-
self._interactive_src_port = edge.src_port
|
|
1760
|
-
edge.setVisible(False)
|
|
1761
|
-
self._interactive_edge = EdgeItem(src_port=edge.src_port, dst_port=edge.src_port, temporary=True)
|
|
1762
|
-
self.scene.addItem(self._interactive_edge)
|
|
1763
|
-
self._interactive_edge.update_path(end_pos=cursor_scene_pos)
|
|
1764
|
-
self._enter_wire_drag_mode()
|
|
1765
|
-
|
|
1766
|
-
def _resolve_direction(self, a: PortItem, b: PortItem) -> Tuple[Optional[PortItem], Optional[PortItem]]:
|
|
1767
|
-
if a.side == "output" and b.side == "input":
|
|
1768
|
-
return a, b
|
|
1769
|
-
if a.side == "input" and b.side == "output":
|
|
1770
|
-
return b, a
|
|
1771
|
-
return None, None
|
|
1772
|
-
|
|
1773
|
-
def _set_hover_candidate(self, port: Optional[PortItem]):
|
|
1774
|
-
if self._hover_candidate is port:
|
|
1775
|
-
return
|
|
1776
|
-
if self._hover_candidate is not None and _qt_is_valid(self._hover_candidate):
|
|
1777
|
-
self._hover_candidate.set_accept_highlight(False)
|
|
1778
|
-
self._hover_candidate = port
|
|
1779
|
-
if self._hover_candidate is not None and _qt_is_valid(self._hover_candidate):
|
|
1780
|
-
self._hover_candidate.set_accept_highlight(True)
|
|
1781
|
-
|
|
1782
|
-
def eventFilter(self, obj, event):
|
|
1783
|
-
if not self._alive:
|
|
1784
|
-
return False
|
|
1785
|
-
if obj is self.scene:
|
|
1786
|
-
et = event.type()
|
|
1787
|
-
|
|
1788
|
-
if self._wire_state == "rewire-primed" and et == QEvent.GraphicsSceneMouseMove:
|
|
1789
|
-
pos = event.scenePos()
|
|
1790
|
-
if self._rewire_press_scene_pos is not None:
|
|
1791
|
-
dist = abs(pos.x() - self._rewire_press_scene_pos.x()) + abs(
|
|
1792
|
-
pos.y() - self._rewire_press_scene_pos.y())
|
|
1793
|
-
else:
|
|
1794
|
-
dist = 9999
|
|
1795
|
-
if dist > 6 and self._rewire_fixed_src is not None:
|
|
1796
|
-
if self._rewire_hidden_edge and _qt_is_valid(self._rewire_hidden_edge):
|
|
1797
|
-
self._rewire_hidden_edge.setVisible(False)
|
|
1798
|
-
self._interactive_edge = EdgeItem(src_port=self._rewire_fixed_src,
|
|
1799
|
-
dst_port=self._rewire_fixed_src,
|
|
1800
|
-
temporary=True)
|
|
1801
|
-
self.scene.addItem(self._interactive_edge)
|
|
1802
|
-
self._interactive_edge.update_path(end_pos=pos)
|
|
1803
|
-
self._enter_wire_drag_mode()
|
|
1804
|
-
self._wire_state = "rewiring"
|
|
1805
|
-
candidate = self._find_compatible_port_at(pos, radius=28.0)
|
|
1806
|
-
self._set_hover_candidate(candidate)
|
|
1807
|
-
return True
|
|
1808
|
-
|
|
1809
|
-
if self._interactive_edge is not None and et == QEvent.GraphicsSceneMouseMove:
|
|
1810
|
-
pos = event.scenePos()
|
|
1811
|
-
if _qt_is_valid(self._interactive_edge):
|
|
1812
|
-
self._interactive_edge.update_path(end_pos=pos)
|
|
1813
|
-
candidate = self._find_compatible_port_at(pos, radius=28.0)
|
|
1814
|
-
self._set_hover_candidate(candidate)
|
|
1815
|
-
return True
|
|
1816
|
-
|
|
1817
|
-
if self._interactive_edge is not None and et == QEvent.GraphicsSceneMousePress and event.button() == Qt.RightButton:
|
|
1818
|
-
self._cancel_interactive_connection()
|
|
1819
|
-
return True
|
|
1820
|
-
|
|
1821
|
-
if et == QEvent.GraphicsSceneMouseRelease and event.button() == Qt.LeftButton:
|
|
1822
|
-
if self._wire_state == "rewire-primed":
|
|
1823
|
-
self._finish_interactive_connection()
|
|
1824
|
-
return True
|
|
1825
|
-
|
|
1826
|
-
if self._interactive_edge is not None:
|
|
1827
|
-
pos = event.scenePos()
|
|
1828
|
-
target = self._find_compatible_port_at(pos, radius=48.0)
|
|
1829
|
-
if target is None and self._hover_candidate is not None:
|
|
1830
|
-
target = self._hover_candidate
|
|
1831
|
-
|
|
1832
|
-
if self._wire_state == "rewiring":
|
|
1833
|
-
if isinstance(target, PortItem) and self._rewire_fixed_src is not None:
|
|
1834
|
-
src, dst = self._resolve_direction(self._rewire_fixed_src, target)
|
|
1835
|
-
if src and dst:
|
|
1836
|
-
if self._rewire_conn_uuid and self._rewire_conn_uuid in self.graph.connections:
|
|
1837
|
-
old = self.graph.connections.get(self._rewire_conn_uuid)
|
|
1838
|
-
if old:
|
|
1839
|
-
self._undo.push(RewireConnectionCommand(self, old, src, dst))
|
|
1840
|
-
else:
|
|
1841
|
-
self._undo.push(ConnectCommand(self, src, dst))
|
|
1842
|
-
if self._rewire_hidden_edge:
|
|
1843
|
-
self._detach_edge_item(self._rewire_hidden_edge)
|
|
1844
|
-
else:
|
|
1845
|
-
if self._rewire_conn_uuid and self._rewire_conn_uuid in self.graph.connections:
|
|
1846
|
-
old = self.graph.connections.get(self._rewire_conn_uuid)
|
|
1847
|
-
if old:
|
|
1848
|
-
self._undo.push(RewireConnectionCommand(self, old, None, None))
|
|
1849
|
-
elif self._rewire_hidden_edge:
|
|
1850
|
-
self._delete_edge(self._rewire_hidden_edge)
|
|
1851
|
-
self._finish_interactive_connection()
|
|
1852
|
-
return True
|
|
1853
|
-
|
|
1854
|
-
elif self._wire_state == "drawing":
|
|
1855
|
-
if isinstance(target, PortItem) and self._interactive_src_port is not None:
|
|
1856
|
-
src, dst = self._resolve_direction(self._interactive_src_port, target)
|
|
1857
|
-
if src and dst:
|
|
1858
|
-
self._undo.push(ConnectCommand(self, src, dst))
|
|
1859
|
-
self._finish_interactive_connection()
|
|
1860
|
-
return True
|
|
1861
|
-
|
|
1862
|
-
return super().eventFilter(obj, event)
|
|
1863
|
-
|
|
1864
|
-
# -------- Interaction reset helpers --------
|
|
1865
|
-
|
|
1866
|
-
def _reset_interaction_states(self, remove_hidden_edges: bool):
|
|
1867
|
-
self._set_hover_candidate(None)
|
|
1868
|
-
if self._interactive_edge and _qt_is_valid(self._interactive_edge):
|
|
1869
|
-
try:
|
|
1870
|
-
self.scene.removeItem(self._interactive_edge)
|
|
1871
|
-
except Exception:
|
|
1872
|
-
pass
|
|
1873
|
-
self._interactive_edge = None
|
|
1874
|
-
self._interactive_src_port = None
|
|
1875
|
-
|
|
1876
|
-
if remove_hidden_edges and self._rewire_hidden_edge and _qt_is_valid(self._rewire_hidden_edge):
|
|
1877
|
-
try:
|
|
1878
|
-
self.scene.removeItem(self._rewire_hidden_edge)
|
|
1879
|
-
except Exception:
|
|
1880
|
-
pass
|
|
1881
|
-
elif self._rewire_hidden_edge and _qt_is_valid(self._rewire_hidden_edge):
|
|
1882
|
-
if self._rewire_hidden_edge.scene() is not None:
|
|
1883
|
-
self._rewire_hidden_edge.setVisible(True)
|
|
1884
|
-
|
|
1885
|
-
self._wire_state = "idle"
|
|
1886
|
-
self._rewire_conn_uuid = None
|
|
1887
|
-
self._rewire_hidden_edge = None
|
|
1888
|
-
self._rewire_fixed_src = None
|
|
1889
|
-
self._rewire_press_scene_pos = None
|
|
1890
|
-
self._leave_wire_drag_mode()
|
|
1891
|
-
|
|
1892
|
-
def _finish_interactive_connection(self):
|
|
1893
|
-
self._reset_interaction_states(remove_hidden_edges=False)
|
|
1894
|
-
|
|
1895
|
-
def _cancel_interactive_connection(self):
|
|
1896
|
-
self._reset_interaction_states(remove_hidden_edges=False)
|
|
1897
|
-
|
|
1898
|
-
# ---------- Delete helpers ----------
|
|
1899
|
-
|
|
1900
|
-
def _delete_node_item(self, item: "NodeItem"):
|
|
1901
|
-
try:
|
|
1902
|
-
if _qt_is_valid(item):
|
|
1903
|
-
self._dbg(f"_delete_node_item -> node={item.node.uuid}")
|
|
1904
|
-
self._remove_node_by_uuid(item.node.uuid)
|
|
1905
|
-
except Exception:
|
|
1906
|
-
pass
|
|
1907
|
-
|
|
1908
|
-
def _detach_edge_item(self, edge: EdgeItem):
|
|
1909
|
-
try:
|
|
1910
|
-
edge.src_port.increment_connections(-1)
|
|
1911
|
-
edge.dst_port.increment_connections(-1)
|
|
1912
|
-
except Exception:
|
|
1913
|
-
pass
|
|
1914
|
-
if _qt_is_valid(edge.src_port.node_item):
|
|
1915
|
-
edge.src_port.node_item.remove_edge(edge)
|
|
1916
|
-
if _qt_is_valid(edge.dst_port.node_item):
|
|
1917
|
-
edge.dst_port.node_item.remove_edge(edge)
|
|
1918
|
-
for k, v in list(self._conn_uuid_to_edge.items()):
|
|
1919
|
-
if v is edge:
|
|
1920
|
-
self._conn_uuid_to_edge.pop(k, None)
|
|
1921
|
-
if _qt_is_valid(edge):
|
|
1922
|
-
try:
|
|
1923
|
-
self.scene.removeItem(edge)
|
|
1924
|
-
except Exception:
|
|
1925
|
-
pass
|
|
1926
|
-
|
|
1927
|
-
def _delete_edge(self, edge: EdgeItem):
|
|
1928
|
-
conn_uuid = getattr(edge, "_conn_uuid", None)
|
|
1929
|
-
exists = bool(conn_uuid and conn_uuid in self.graph.connections)
|
|
1930
|
-
if exists:
|
|
1931
|
-
self._remove_connection_by_uuid(conn_uuid)
|
|
1932
|
-
else:
|
|
1933
|
-
self._detach_edge_item(edge)
|
|
1934
|
-
|
|
1935
|
-
def _delete_selected_connections(self):
|
|
1936
|
-
if not _qt_is_valid(self.scene):
|
|
1937
|
-
return
|
|
1938
|
-
for it in list(self.scene.selectedItems()):
|
|
1939
|
-
if isinstance(it, EdgeItem):
|
|
1940
|
-
self._delete_edge(it)
|
|
1941
|
-
self.view.viewport().update()
|
|
1942
|
-
|
|
1943
|
-
# ---------- Clear helpers required by load/close ----------
|
|
1944
|
-
|
|
1945
|
-
def _remove_all_edge_items_from_scene(self):
|
|
1946
|
-
if not _qt_is_valid(self.scene):
|
|
1947
|
-
return
|
|
1948
|
-
for it in list(self.scene.items()):
|
|
1949
|
-
if isinstance(it, EdgeItem):
|
|
1950
|
-
try:
|
|
1951
|
-
self.scene.removeItem(it)
|
|
1952
|
-
except Exception:
|
|
1953
|
-
pass
|
|
1954
|
-
|
|
1955
|
-
def _cleanup_node_proxies(self):
|
|
1956
|
-
for item in list(self._uuid_to_item.values()):
|
|
1957
|
-
try:
|
|
1958
|
-
if _qt_is_valid(item):
|
|
1959
|
-
item.detach_proxy_widget()
|
|
1960
|
-
except Exception:
|
|
1961
|
-
pass
|
|
1962
|
-
|
|
1963
|
-
def _clear_scene_only(self, hard: bool = False):
|
|
1964
|
-
self._cleanup_node_proxies()
|
|
1965
|
-
for edge in list(self._conn_uuid_to_edge.values()):
|
|
1966
|
-
if _qt_is_valid(edge):
|
|
1967
|
-
try:
|
|
1968
|
-
self.scene.removeItem(edge)
|
|
1969
|
-
except Exception:
|
|
1970
|
-
pass
|
|
1971
|
-
self._conn_uuid_to_edge.clear()
|
|
1972
|
-
for item in list(self._uuid_to_item.values()):
|
|
1973
|
-
if _qt_is_valid(item):
|
|
1974
|
-
try:
|
|
1975
|
-
self.scene.removeItem(item)
|
|
1976
|
-
except Exception:
|
|
1977
|
-
pass
|
|
1978
|
-
self._uuid_to_item.clear()
|
|
1979
|
-
if hard:
|
|
1980
|
-
self._remove_all_edge_items_from_scene()
|
|
1981
|
-
|
|
1982
|
-
def _clear_scene_and_graph(self):
|
|
1983
|
-
self._reset_interaction_states(remove_hidden_edges=True)
|
|
1984
|
-
self._clear_scene_only(hard=True)
|
|
1985
|
-
self.graph.clear(silent=True)
|
|
1986
|
-
|
|
1987
|
-
# ---------- View centering ----------
|
|
1988
|
-
|
|
1989
|
-
def content_bounding_rect(self) -> Optional[QRectF]:
|
|
1990
|
-
rect: Optional[QRectF] = None
|
|
1991
|
-
for item in self._uuid_to_item.values():
|
|
1992
|
-
r = item.mapToScene(item.boundingRect()).boundingRect()
|
|
1993
|
-
rect = r if rect is None else rect.united(r)
|
|
1994
|
-
return rect
|
|
1995
|
-
|
|
1996
|
-
def center_on_content(self, margin: float = 80.0):
|
|
1997
|
-
rect = self.content_bounding_rect()
|
|
1998
|
-
if rect and rect.isValid():
|
|
1999
|
-
padded = rect.adjusted(-margin, -margin, margin, margin)
|
|
2000
|
-
self.scene.setSceneRect(self.scene.sceneRect().united(padded))
|
|
2001
|
-
self.view.centerOn(rect.center())
|