pygpt-net 2.6.59__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.
Files changed (61) hide show
  1. pygpt_net/CHANGELOG.txt +4 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/app.py +9 -5
  4. pygpt_net/controller/__init__.py +1 -0
  5. pygpt_net/controller/presets/editor.py +442 -39
  6. pygpt_net/core/agents/custom/__init__.py +275 -0
  7. pygpt_net/core/agents/custom/debug.py +64 -0
  8. pygpt_net/core/agents/custom/factory.py +109 -0
  9. pygpt_net/core/agents/custom/graph.py +71 -0
  10. pygpt_net/core/agents/custom/llama_index/__init__.py +10 -0
  11. pygpt_net/core/agents/custom/llama_index/factory.py +89 -0
  12. pygpt_net/core/agents/custom/llama_index/router_streamer.py +106 -0
  13. pygpt_net/core/agents/custom/llama_index/runner.py +529 -0
  14. pygpt_net/core/agents/custom/llama_index/stream.py +56 -0
  15. pygpt_net/core/agents/custom/llama_index/utils.py +242 -0
  16. pygpt_net/core/agents/custom/logging.py +50 -0
  17. pygpt_net/core/agents/custom/memory.py +51 -0
  18. pygpt_net/core/agents/custom/router.py +116 -0
  19. pygpt_net/core/agents/custom/router_streamer.py +187 -0
  20. pygpt_net/core/agents/custom/runner.py +454 -0
  21. pygpt_net/core/agents/custom/schema.py +125 -0
  22. pygpt_net/core/agents/custom/utils.py +181 -0
  23. pygpt_net/core/agents/provider.py +72 -7
  24. pygpt_net/core/agents/runner.py +7 -4
  25. pygpt_net/core/agents/runners/helpers.py +1 -1
  26. pygpt_net/core/agents/runners/llama_workflow.py +3 -0
  27. pygpt_net/core/agents/runners/openai_workflow.py +8 -1
  28. pygpt_net/{ui/widget/builder → core/node_editor}/__init__.py +2 -2
  29. pygpt_net/core/{builder → node_editor}/graph.py +11 -218
  30. pygpt_net/core/node_editor/models.py +111 -0
  31. pygpt_net/core/node_editor/types.py +76 -0
  32. pygpt_net/core/node_editor/utils.py +17 -0
  33. pygpt_net/core/render/web/renderer.py +10 -8
  34. pygpt_net/data/config/config.json +3 -3
  35. pygpt_net/data/config/models.json +3 -3
  36. pygpt_net/data/locale/locale.en.ini +4 -4
  37. pygpt_net/item/agent.py +5 -1
  38. pygpt_net/item/preset.py +19 -1
  39. pygpt_net/provider/agents/base.py +33 -2
  40. pygpt_net/provider/agents/llama_index/flow_from_schema.py +92 -0
  41. pygpt_net/provider/agents/openai/flow_from_schema.py +96 -0
  42. pygpt_net/provider/core/agent/json_file.py +11 -5
  43. pygpt_net/tools/agent_builder/tool.py +217 -52
  44. pygpt_net/tools/agent_builder/ui/dialogs.py +119 -24
  45. pygpt_net/tools/agent_builder/ui/list.py +37 -10
  46. pygpt_net/ui/dialog/preset.py +16 -1
  47. pygpt_net/ui/main.py +1 -1
  48. pygpt_net/{core/builder → ui/widget/node_editor}/__init__.py +2 -2
  49. pygpt_net/ui/widget/node_editor/command.py +373 -0
  50. pygpt_net/ui/widget/node_editor/editor.py +2038 -0
  51. pygpt_net/ui/widget/node_editor/item.py +492 -0
  52. pygpt_net/ui/widget/node_editor/node.py +1205 -0
  53. pygpt_net/ui/widget/node_editor/utils.py +17 -0
  54. pygpt_net/ui/widget/node_editor/view.py +247 -0
  55. {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.60.dist-info}/METADATA +72 -2
  56. {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.60.dist-info}/RECORD +59 -33
  57. pygpt_net/core/agents/custom.py +0 -150
  58. pygpt_net/ui/widget/builder/editor.py +0 -2001
  59. {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.60.dist-info}/LICENSE +0 -0
  60. {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.60.dist-info}/WHEEL +0 -0
  61. {pygpt_net-2.6.59.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())