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.
- pygpt_net/CHANGELOG.txt +4 -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/{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/item/agent.py +5 -1
- pygpt_net/item/preset.py +19 -1
- pygpt_net/provider/agents/base.py +33 -2
- pygpt_net/provider/agents/llama_index/flow_from_schema.py +92 -0
- pygpt_net/provider/agents/openai/flow_from_schema.py +96 -0
- pygpt_net/provider/core/agent/json_file.py +11 -5
- 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/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-2.6.59.dist-info → pygpt_net-2.6.60.dist-info}/METADATA +72 -2
- {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.60.dist-info}/RECORD +59 -33
- pygpt_net/core/agents/custom.py +0 -150
- pygpt_net/ui/widget/builder/editor.py +0 -2001
- {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.60.dist-info}/LICENSE +0 -0
- {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.60.dist-info}/WHEEL +0 -0
- {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.60.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,2038 @@
|
|
|
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.24 00:00:00 #
|
|
10
|
+
# ================================================== #
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
from typing import Dict, Optional, List, Tuple, Any, Union
|
|
14
|
+
|
|
15
|
+
from PySide6.QtCore import Qt, QPoint, QPointF, QRectF, QSizeF, Property, QEvent
|
|
16
|
+
from PySide6.QtGui import QAction, QColor, QUndoStack, QPalette, QCursor, QKeySequence, QShortcut
|
|
17
|
+
from PySide6.QtWidgets import (
|
|
18
|
+
QWidget, QApplication, QGraphicsView, QMenu, QMessageBox, QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox,
|
|
19
|
+
QTextEdit, QPlainTextEdit
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from pygpt_net.core.node_editor.graph import NodeGraph
|
|
23
|
+
from pygpt_net.core.node_editor.types import NodeTypeRegistry
|
|
24
|
+
from pygpt_net.core.node_editor.models import NodeModel, PropertyModel, ConnectionModel
|
|
25
|
+
|
|
26
|
+
from pygpt_net.utils import trans
|
|
27
|
+
|
|
28
|
+
from .command import AddNodeCommand, ClearGraphCommand, RewireConnectionCommand, ConnectCommand, DeleteNodeCommand, DeleteConnectionCommand
|
|
29
|
+
from .item import EdgeItem, PortItem
|
|
30
|
+
from .node import NodeItem
|
|
31
|
+
from .view import NodeGraphicsScene, NodeGraphicsView
|
|
32
|
+
from .utils import _qt_is_valid
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ------------------------ NodeEditor ------------------------
|
|
36
|
+
|
|
37
|
+
class NodeEditor(QWidget):
|
|
38
|
+
"""Main widget embedding a QGraphicsView-based node editor.
|
|
39
|
+
|
|
40
|
+
Responsibilities:
|
|
41
|
+
- Owns NodeGraph and keeps it in sync with scene items (NodeItem/EdgeItem).
|
|
42
|
+
- Manages user interactions: add/move/resize nodes, connect/rewire/delete edges.
|
|
43
|
+
- Provides save/load layout (schema-agnostic) and undo/redo integration.
|
|
44
|
+
- Applies theming and propagates style/palette to embedded editors.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def _q_set(self, name, value):
|
|
48
|
+
"""Qt property writer: set attribute and refresh theme if ready."""
|
|
49
|
+
setattr(self, name, value)
|
|
50
|
+
if not getattr(self, "_ready_for_theme", True):
|
|
51
|
+
return
|
|
52
|
+
if not getattr(self, "_alive", True):
|
|
53
|
+
return
|
|
54
|
+
if getattr(self, "view", None) is None:
|
|
55
|
+
return
|
|
56
|
+
try:
|
|
57
|
+
self._update_theme()
|
|
58
|
+
except Exception:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
def _q_set_bool(self, name, value: bool):
|
|
62
|
+
"""Qt property writer for booleans."""
|
|
63
|
+
setattr(self, name, bool(value))
|
|
64
|
+
|
|
65
|
+
# Default theme colors and interaction parameters (Qt Properties are declared below)
|
|
66
|
+
_node_bg_color = QColor(45, 45, 48)
|
|
67
|
+
_node_border_color = QColor(80, 80, 90)
|
|
68
|
+
_node_selection_color = QColor(255, 170, 0)
|
|
69
|
+
_node_title_color = QColor(60, 60, 63)
|
|
70
|
+
_port_input_color = QColor(100, 180, 255)
|
|
71
|
+
_port_output_color = QColor(140, 255, 140)
|
|
72
|
+
_port_connected_color = QColor(255, 220, 100)
|
|
73
|
+
_port_accept_color = QColor(255, 255, 140)
|
|
74
|
+
_edge_color = QColor(180, 180, 180)
|
|
75
|
+
_edge_selected_color = QColor(255, 140, 90)
|
|
76
|
+
_grid_back_color = QColor(35, 35, 38)
|
|
77
|
+
_grid_pen_color = QColor(55, 55, 60)
|
|
78
|
+
|
|
79
|
+
_port_label_color = QColor(220, 220, 220)
|
|
80
|
+
_port_capacity_color = QColor(200, 200, 200)
|
|
81
|
+
|
|
82
|
+
_edge_pick_width: float = 12.0
|
|
83
|
+
_resize_grip_margin: float = 12.0
|
|
84
|
+
_resize_grip_hit_inset: float = 5.0
|
|
85
|
+
_port_pick_radius: float = 10.0
|
|
86
|
+
_port_click_rewire_if_connected: bool = True
|
|
87
|
+
_rewire_drop_deletes: bool = True
|
|
88
|
+
|
|
89
|
+
# Expose appearance/interaction knobs as Qt Properties (useful for Designer/QML bindings)
|
|
90
|
+
nodeBackgroundColor = Property(QColor, lambda self: self._node_bg_color, lambda self, v: self._q_set("_node_bg_color", v))
|
|
91
|
+
nodeBorderColor = Property(QColor, lambda self: self._node_border_color, lambda self, v: self._q_set("_node_border_color", v))
|
|
92
|
+
nodeSelectionColor = Property(QColor, lambda self: self._node_selection_color, lambda self, v: self._q_set("_node_selection_color", v))
|
|
93
|
+
nodeTitleColor = Property(QColor, lambda self: self._node_title_color, lambda self, v: self._q_set("_node_title_color", v))
|
|
94
|
+
portInputColor = Property(QColor, lambda self: self._port_input_color, lambda self, v: self._q_set("_port_input_color", v))
|
|
95
|
+
portOutputColor = Property(QColor, lambda self: self._port_output_color, lambda self, v: self._q_set("_port_output_color", v))
|
|
96
|
+
portConnectedColor = Property(QColor, lambda self: self._port_connected_color, lambda self, v: self._q_set("_port_connected_color", v))
|
|
97
|
+
portAcceptColor = Property(QColor, lambda self: self._port_accept_color, lambda self, v: self._q_set("_port_accept_color", v))
|
|
98
|
+
edgeColor = Property(QColor, lambda self: self._edge_color, lambda self, v: self._q_set("_edge_color", v))
|
|
99
|
+
edgeSelectedColor = Property(QColor, lambda self: self._edge_selected_color, lambda self, v: self._q_set("_edge_selected_color", v))
|
|
100
|
+
gridBackColor = Property(QColor, lambda self: self._grid_back_color, lambda self, v: self._q_set("_grid_back_color", v))
|
|
101
|
+
gridPenColor = Property(QColor, lambda self: self._grid_pen_color, lambda self, v: self._q_set("_grid_pen_color", v))
|
|
102
|
+
|
|
103
|
+
portLabelColor = Property(QColor, lambda self: self._port_label_color, lambda self, v: self._q_set("_port_label_color", v))
|
|
104
|
+
portCapacityColor = Property(QColor, lambda self: self._port_capacity_color, lambda self, v: self._q_set("_port_capacity_color", v))
|
|
105
|
+
|
|
106
|
+
edgePickWidth = Property(float, lambda self: self._edge_pick_width, lambda self, v: self._set_edge_pick_width(v))
|
|
107
|
+
resizeGripMargin = Property(float, lambda self: self._resize_grip_margin, lambda self, v: self._q_set("_resize_grip_margin", float(v)))
|
|
108
|
+
resizeGripHitInset = Property(float, lambda self: self._resize_grip_hit_inset, lambda self, v: self._set_resize_grip_hit_inset(float(v)))
|
|
109
|
+
portPickRadius = Property(float, lambda self: self._port_pick_radius, lambda self, v: self._q_set("_port_pick_radius", float(v)))
|
|
110
|
+
portClickRewireIfConnected = Property(bool, lambda self: self._port_click_rewire_if_connected, lambda self, v: self._q_set_bool("_port_click_rewire_if_connected", v))
|
|
111
|
+
rewireDropDeletes = Property(bool, lambda self: self._rewire_drop_deletes, lambda self, v: self._q_set_bool("_rewire_drop_deletes", v))
|
|
112
|
+
|
|
113
|
+
def _set_resize_grip_hit_inset(self, v: float):
|
|
114
|
+
"""Setter that also re-applies size on all items to refresh hit regions."""
|
|
115
|
+
self._resize_grip_hit_inset = max(0.0, float(v))
|
|
116
|
+
for item in list(self._uuid_to_item.values()):
|
|
117
|
+
if _qt_is_valid(item):
|
|
118
|
+
item._apply_resize(item.size(), clamp=True)
|
|
119
|
+
if _qt_is_valid(self.view):
|
|
120
|
+
self.view.viewport().update()
|
|
121
|
+
|
|
122
|
+
def _set_edge_pick_width(self, v: float):
|
|
123
|
+
"""Setter that recomputes shapes for all edges (including interactive)."""
|
|
124
|
+
self._edge_pick_width = float(v) if v and v > 0 else 12.0
|
|
125
|
+
for edge in list(self._conn_uuid_to_edge.values()):
|
|
126
|
+
if _qt_is_valid(edge):
|
|
127
|
+
edge.prepareGeometryChange()
|
|
128
|
+
edge.update()
|
|
129
|
+
if getattr(self, "_interactive_edge", None) and _qt_is_valid(self._interactive_edge):
|
|
130
|
+
self._interactive_edge.prepareGeometryChange()
|
|
131
|
+
self._interactive_edge.update()
|
|
132
|
+
if _qt_is_valid(self.view):
|
|
133
|
+
self.view.viewport().update()
|
|
134
|
+
|
|
135
|
+
def __init__(self, parent: Optional[QWidget] = None, registry: Optional[NodeTypeRegistry] = None):
|
|
136
|
+
"""Initialize the editor, scene, view, graph and interaction state."""
|
|
137
|
+
super().__init__(parent)
|
|
138
|
+
self.setObjectName("NodeEditor")
|
|
139
|
+
self._debug = False
|
|
140
|
+
self._dbg("INIT NodeEditor")
|
|
141
|
+
|
|
142
|
+
self.graph = NodeGraph(registry)
|
|
143
|
+
self.scene = NodeGraphicsScene(self)
|
|
144
|
+
self.view = NodeGraphicsView(self.scene, self)
|
|
145
|
+
self.view.setGeometry(self.rect())
|
|
146
|
+
self.view.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
|
147
|
+
self.view.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
|
148
|
+
self._undo = QUndoStack(self)
|
|
149
|
+
self._uuid_to_item: Dict[str, NodeItem] = {}
|
|
150
|
+
self._conn_uuid_to_edge: Dict[str, EdgeItem] = {}
|
|
151
|
+
self._interactive_edge: Optional[EdgeItem] = None
|
|
152
|
+
self._interactive_src_port: Optional[PortItem] = None
|
|
153
|
+
self._hover_candidate: Optional[PortItem] = None
|
|
154
|
+
self._pending_node_positions: Dict[str, QPointF] = {}
|
|
155
|
+
|
|
156
|
+
self._wire_state: str = "idle"
|
|
157
|
+
self._rewire_conn_uuid: Optional[str] = None
|
|
158
|
+
self._rewire_hidden_edge: Optional[EdgeItem] = None
|
|
159
|
+
self._rewire_fixed_src: Optional[PortItem] = None
|
|
160
|
+
self._rewire_press_scene_pos: Optional[QPointF] = None
|
|
161
|
+
|
|
162
|
+
self._spawn_origin: Optional[QPointF] = None
|
|
163
|
+
self._spawn_index: int = 0
|
|
164
|
+
self._saved_drag_mode: Optional[QGraphicsView.DragMode] = None
|
|
165
|
+
|
|
166
|
+
self.scene.sceneContextRequested.connect(self._on_scene_context_menu)
|
|
167
|
+
self.graph.nodeAdded.connect(self._on_graph_node_added)
|
|
168
|
+
self.graph.nodeRemoved.connect(self._on_graph_node_removed)
|
|
169
|
+
self.graph.connectionAdded.connect(self._on_graph_connection_added)
|
|
170
|
+
self.graph.connectionRemoved.connect(self._on_graph_connection_removed)
|
|
171
|
+
self.graph.cleared.connect(self._on_graph_cleared)
|
|
172
|
+
|
|
173
|
+
self.scene.installEventFilter(self)
|
|
174
|
+
|
|
175
|
+
self._alive = True
|
|
176
|
+
self._closing = False
|
|
177
|
+
self._ready_for_theme = True
|
|
178
|
+
self.destroyed.connect(self._on_destroyed)
|
|
179
|
+
self.on_clear = None
|
|
180
|
+
|
|
181
|
+
# Tracks if a layout is being loaded; prevents auto-renumbering during import.
|
|
182
|
+
self._is_loading_layout: bool = False
|
|
183
|
+
|
|
184
|
+
# Track whether a text-like editor currently has focus (robust against QGraphicsProxyWidget)
|
|
185
|
+
self._text_input_active: bool = False
|
|
186
|
+
app = QApplication.instance()
|
|
187
|
+
if app:
|
|
188
|
+
app.focusChanged.connect(self._on_focus_changed)
|
|
189
|
+
|
|
190
|
+
# Delete shortcut handled at editor-level; never handled by the view
|
|
191
|
+
self._shortcut_delete = QShortcut(QKeySequence(Qt.Key_Delete), self)
|
|
192
|
+
self._shortcut_delete.setContext(Qt.WidgetWithChildrenShortcut)
|
|
193
|
+
self._shortcut_delete.activated.connect(self._on_delete_shortcut)
|
|
194
|
+
|
|
195
|
+
# Per-layout Base ID counters: {property_id -> {base_string -> max_suffix_used}}
|
|
196
|
+
# These counters are rebuilt on load_layout() and reset on clear.
|
|
197
|
+
self._base_id_max: Dict[str, Dict[str, int]] = {}
|
|
198
|
+
self._reset_base_id_counters()
|
|
199
|
+
|
|
200
|
+
# ---------- Debug helper ----------
|
|
201
|
+
def _dbg(self, msg: str):
|
|
202
|
+
"""Conditional debug logger (enabled by self._debug)."""
|
|
203
|
+
if self._debug:
|
|
204
|
+
print(f"[NodeEditor][{hex(id(self))}] {msg}")
|
|
205
|
+
|
|
206
|
+
# Focus tracking to detect if the user is typing in an input
|
|
207
|
+
def _is_text_entry_widget(self, w: Optional[QWidget]) -> bool:
|
|
208
|
+
"""Return True if the widget is a focused, editable text-like editor."""
|
|
209
|
+
if w is None:
|
|
210
|
+
return False
|
|
211
|
+
try:
|
|
212
|
+
if isinstance(w, (QLineEdit, QTextEdit, QPlainTextEdit)):
|
|
213
|
+
if hasattr(w, "isReadOnly") and w.isReadOnly():
|
|
214
|
+
return False
|
|
215
|
+
return True
|
|
216
|
+
if isinstance(w, QComboBox):
|
|
217
|
+
return w.isEditable() and w.hasFocus()
|
|
218
|
+
if isinstance(w, (QSpinBox, QDoubleSpinBox)):
|
|
219
|
+
return w.hasFocus()
|
|
220
|
+
except Exception:
|
|
221
|
+
return False
|
|
222
|
+
return False
|
|
223
|
+
|
|
224
|
+
def _on_focus_changed(self, old: Optional[QWidget], now: Optional[QWidget]):
|
|
225
|
+
"""Update internal flag when focus moves to/from text widgets."""
|
|
226
|
+
self._text_input_active = self._is_text_entry_widget(now)
|
|
227
|
+
|
|
228
|
+
# Backward-compatible helper used by other parts
|
|
229
|
+
def _is_text_input_focused(self) -> bool:
|
|
230
|
+
"""Return True if an input field is currently focused."""
|
|
231
|
+
return bool(self._text_input_active)
|
|
232
|
+
|
|
233
|
+
# ---------- QWidget overrides ----------
|
|
234
|
+
|
|
235
|
+
def _on_destroyed(self):
|
|
236
|
+
"""Set flags and disconnect global signals on destruction."""
|
|
237
|
+
self._alive = False
|
|
238
|
+
self._ready_for_theme = False
|
|
239
|
+
try:
|
|
240
|
+
app = QApplication.instance()
|
|
241
|
+
if app:
|
|
242
|
+
app.focusChanged.disconnect(self._on_focus_changed)
|
|
243
|
+
except Exception:
|
|
244
|
+
pass
|
|
245
|
+
|
|
246
|
+
def closeEvent(self, e):
|
|
247
|
+
"""Perform a thorough cleanup to prevent dangling Qt wrappers during shutdown."""
|
|
248
|
+
self._dbg("closeEvent -> full cleanup")
|
|
249
|
+
|
|
250
|
+
# Make the editor effectively inert for any in-flight events
|
|
251
|
+
self._closing = True
|
|
252
|
+
self._alive = False
|
|
253
|
+
self._ready_for_theme = False
|
|
254
|
+
|
|
255
|
+
# Stop listening to global focus changes
|
|
256
|
+
try:
|
|
257
|
+
app = QApplication.instance()
|
|
258
|
+
if app:
|
|
259
|
+
app.focusChanged.disconnect(self._on_focus_changed)
|
|
260
|
+
except Exception:
|
|
261
|
+
pass
|
|
262
|
+
|
|
263
|
+
# Disconnect scene signals and filters
|
|
264
|
+
try:
|
|
265
|
+
if _qt_is_valid(self.scene):
|
|
266
|
+
self.scene.sceneContextRequested.disconnect(self._on_scene_context_menu)
|
|
267
|
+
except Exception:
|
|
268
|
+
pass
|
|
269
|
+
|
|
270
|
+
# Cancel any interactive wire state before clearing items
|
|
271
|
+
self._reset_interaction_states(remove_hidden_edges=True)
|
|
272
|
+
|
|
273
|
+
try:
|
|
274
|
+
if _qt_is_valid(self.scene):
|
|
275
|
+
self.scene.removeEventFilter(self)
|
|
276
|
+
except Exception:
|
|
277
|
+
pass
|
|
278
|
+
|
|
279
|
+
# Disconnect graph signals early to prevent callbacks during teardown
|
|
280
|
+
self._disconnect_graph_signals()
|
|
281
|
+
|
|
282
|
+
# Clear undo stack to drop references to scene items and widgets
|
|
283
|
+
try:
|
|
284
|
+
if self._undo is not None:
|
|
285
|
+
self._undo.clear()
|
|
286
|
+
except Exception:
|
|
287
|
+
pass
|
|
288
|
+
|
|
289
|
+
# Proxies/widgets first (prevents QWidget-in-scene dangling)
|
|
290
|
+
self._cleanup_node_proxies()
|
|
291
|
+
|
|
292
|
+
# Remove edge items, then all remaining scene items
|
|
293
|
+
try:
|
|
294
|
+
self._remove_all_edge_items_from_scene()
|
|
295
|
+
if _qt_is_valid(self.scene):
|
|
296
|
+
self.scene.clear()
|
|
297
|
+
except Exception:
|
|
298
|
+
pass
|
|
299
|
+
|
|
300
|
+
# Detach view from scene to break mutual references
|
|
301
|
+
try:
|
|
302
|
+
if _qt_is_valid(self.view):
|
|
303
|
+
self.view.setScene(None)
|
|
304
|
+
except Exception:
|
|
305
|
+
pass
|
|
306
|
+
|
|
307
|
+
# Drop internal maps to help GC
|
|
308
|
+
try:
|
|
309
|
+
self._uuid_to_item.clear()
|
|
310
|
+
self._conn_uuid_to_edge.clear()
|
|
311
|
+
except Exception:
|
|
312
|
+
pass
|
|
313
|
+
self._interactive_edge = None
|
|
314
|
+
self._interactive_src_port = None
|
|
315
|
+
self._hover_candidate = None
|
|
316
|
+
self._pending_node_positions.clear()
|
|
317
|
+
|
|
318
|
+
# Optionally schedule deletion of scene/view
|
|
319
|
+
try:
|
|
320
|
+
if _qt_is_valid(self.scene):
|
|
321
|
+
self.scene.deleteLater()
|
|
322
|
+
except Exception:
|
|
323
|
+
pass
|
|
324
|
+
try:
|
|
325
|
+
if _qt_is_valid(self.view):
|
|
326
|
+
self.view.deleteLater()
|
|
327
|
+
except Exception:
|
|
328
|
+
pass
|
|
329
|
+
|
|
330
|
+
self.scene = None
|
|
331
|
+
self.view = None
|
|
332
|
+
|
|
333
|
+
# Force-flush all deferred deletions to avoid old wrappers lingering into the next open
|
|
334
|
+
try:
|
|
335
|
+
from PySide6.QtWidgets import QApplication
|
|
336
|
+
from PySide6.QtCore import QEvent
|
|
337
|
+
for _ in range(3):
|
|
338
|
+
QApplication.sendPostedEvents(None, QEvent.DeferredDelete)
|
|
339
|
+
QApplication.processEvents()
|
|
340
|
+
except Exception:
|
|
341
|
+
pass
|
|
342
|
+
|
|
343
|
+
super().closeEvent(e)
|
|
344
|
+
|
|
345
|
+
def _disconnect_graph_signals(self):
|
|
346
|
+
"""Disconnect all graph signals safely (ignore if already disconnected)."""
|
|
347
|
+
for signal, slot in (
|
|
348
|
+
(self.graph.nodeAdded, self._on_graph_node_added),
|
|
349
|
+
(self.graph.nodeRemoved, self._on_graph_node_removed),
|
|
350
|
+
(self.graph.connectionAdded, self._on_graph_connection_added),
|
|
351
|
+
(self.graph.connectionRemoved, self._on_graph_connection_removed),
|
|
352
|
+
(self.graph.cleared, self._on_graph_cleared),
|
|
353
|
+
):
|
|
354
|
+
try:
|
|
355
|
+
signal.disconnect(slot)
|
|
356
|
+
except Exception:
|
|
357
|
+
pass
|
|
358
|
+
|
|
359
|
+
def resizeEvent(self, e):
|
|
360
|
+
"""Keep the view sized to the editor widget."""
|
|
361
|
+
if _qt_is_valid(self.view):
|
|
362
|
+
self.view.setGeometry(self.rect())
|
|
363
|
+
super().resizeEvent(e)
|
|
364
|
+
|
|
365
|
+
def showEvent(self, e):
|
|
366
|
+
"""Cache the spawn origin and reapply stylesheets when shown."""
|
|
367
|
+
if self._spawn_origin is None and _qt_is_valid(self.view):
|
|
368
|
+
self._spawn_origin = self.view.mapToScene(self.view.viewport().rect().center())
|
|
369
|
+
self._reapply_stylesheets()
|
|
370
|
+
super().showEvent(e)
|
|
371
|
+
|
|
372
|
+
def event(self, e):
|
|
373
|
+
"""React to global style/palette changes and reset interactions on focus loss."""
|
|
374
|
+
et = e.type()
|
|
375
|
+
# Do not accept ShortcutOverride here; we rely on the editor-level QShortcut for Delete.
|
|
376
|
+
if et in (QEvent.StyleChange, QEvent.PaletteChange, QEvent.FontChange,
|
|
377
|
+
QEvent.ApplicationPaletteChange, QEvent.ApplicationFontChange):
|
|
378
|
+
self._reapply_stylesheets()
|
|
379
|
+
if et in (QEvent.FocusOut, QEvent.WindowDeactivate):
|
|
380
|
+
self._dbg("event -> focus out/window deactivate -> reset interaction")
|
|
381
|
+
self._reset_interaction_states(remove_hidden_edges=False)
|
|
382
|
+
return super().event(e)
|
|
383
|
+
|
|
384
|
+
# ---------- Public API ----------
|
|
385
|
+
|
|
386
|
+
def add_node(self, type_name: str, scene_pos: QPointF):
|
|
387
|
+
"""Add a new node of the given type at scene_pos (undoable)."""
|
|
388
|
+
self._undo.push(AddNodeCommand(self, type_name, scene_pos))
|
|
389
|
+
|
|
390
|
+
def clear(self, ask_user: bool = True):
|
|
391
|
+
"""Clear the entire editor (undoable), optionally asking the user for confirmation.
|
|
392
|
+
|
|
393
|
+
Returns:
|
|
394
|
+
bool: True if a clear operation was initiated.
|
|
395
|
+
"""
|
|
396
|
+
if not self._alive or self.scene is None or self.view is None:
|
|
397
|
+
return False
|
|
398
|
+
if ask_user and self.on_clear and callable(self.on_clear):
|
|
399
|
+
self.on_clear()
|
|
400
|
+
return
|
|
401
|
+
if ask_user:
|
|
402
|
+
reply = QMessageBox.question(self, trans("agent.builder.confirm.clear.title"),
|
|
403
|
+
trans("agent.builder.confirm.clear.msg"),
|
|
404
|
+
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
|
|
405
|
+
if reply != QMessageBox.Yes:
|
|
406
|
+
return False
|
|
407
|
+
self._undo.push(ClearGraphCommand(self))
|
|
408
|
+
return True
|
|
409
|
+
|
|
410
|
+
def undo(self):
|
|
411
|
+
"""Undo last action."""
|
|
412
|
+
self._undo.undo()
|
|
413
|
+
|
|
414
|
+
def redo(self):
|
|
415
|
+
"""Redo last undone action."""
|
|
416
|
+
self._undo.redo()
|
|
417
|
+
|
|
418
|
+
def save_layout(self) -> dict:
|
|
419
|
+
"""Serialize the current layout into a compact, value-only dict.
|
|
420
|
+
|
|
421
|
+
Notes:
|
|
422
|
+
- Property specification (type, editable, allowed_inputs/outputs, etc.) is NOT exported.
|
|
423
|
+
- Only dynamic, value-carrying properties are persisted.
|
|
424
|
+
- Registry definitions are the single source of truth for property specs during load.
|
|
425
|
+
"""
|
|
426
|
+
if not self._alive or self.scene is None or self.view is None:
|
|
427
|
+
return False
|
|
428
|
+
|
|
429
|
+
# Build compact graph payload (nodes + connections without specs)
|
|
430
|
+
compact = self._serialize_layout_compact()
|
|
431
|
+
|
|
432
|
+
# Positions and sizes come from live scene items
|
|
433
|
+
compact["positions"] = {
|
|
434
|
+
nuuid: [self._uuid_to_item[nuuid].pos().x(), self._uuid_to_item[nuuid].pos().y()]
|
|
435
|
+
for nuuid in self._uuid_to_item
|
|
436
|
+
}
|
|
437
|
+
compact["sizes"] = {
|
|
438
|
+
nuuid: [float(self._uuid_to_item[nuuid].size().width()),
|
|
439
|
+
float(self._uuid_to_item[nuuid].size().height())]
|
|
440
|
+
for nuuid in self._uuid_to_item
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
# Persist the current view (zoom and scrollbars)
|
|
444
|
+
try:
|
|
445
|
+
compact["view"] = self._save_view_state()
|
|
446
|
+
except Exception:
|
|
447
|
+
compact["view"] = {}
|
|
448
|
+
|
|
449
|
+
return compact
|
|
450
|
+
|
|
451
|
+
def load_layout(self, data: dict):
|
|
452
|
+
"""
|
|
453
|
+
Load layout using the live registry for node specs. Only values from the layout are applied,
|
|
454
|
+
and only for properties that still exist in the current spec. Everything else falls back to
|
|
455
|
+
the registry defaults. Connections are recreated only if ports still exist and can connect.
|
|
456
|
+
|
|
457
|
+
Example:
|
|
458
|
+
editor.load_layout({
|
|
459
|
+
"nodes": [
|
|
460
|
+
{"uuid": "n1", "type": "Agent", "values": {"name": "Collector"}, "pos": [100, 120], "size": [220, 180]},
|
|
461
|
+
{"uuid": "n2", "type": "Tool", "values": {"kind": "Search"}}
|
|
462
|
+
],
|
|
463
|
+
"connections": [
|
|
464
|
+
{"uuid": "c1", "src_node": "n1", "src_prop": "out", "dst_node": "n2", "dst_prop": "in"}
|
|
465
|
+
]
|
|
466
|
+
})
|
|
467
|
+
"""
|
|
468
|
+
if not self._alive or self.scene is None or self.view is None:
|
|
469
|
+
return False
|
|
470
|
+
|
|
471
|
+
# Guard: mark layout import phase to avoid auto-renumbering of Base ID on nodeAdded.
|
|
472
|
+
self._is_loading_layout = True
|
|
473
|
+
try:
|
|
474
|
+
self._dbg("load_layout -> registry-first import with value-only merge")
|
|
475
|
+
# Reset interaction and clear both scene and graph (do not reuse graph.from_dict here).
|
|
476
|
+
self._reset_interaction_states(remove_hidden_edges=True)
|
|
477
|
+
self._clear_scene_only(hard=True)
|
|
478
|
+
try:
|
|
479
|
+
self.graph.clear(silent=True)
|
|
480
|
+
except Exception:
|
|
481
|
+
# Be defensive in case .clear is not available or raises
|
|
482
|
+
self.graph.nodes = {}
|
|
483
|
+
self.graph.connections = {}
|
|
484
|
+
|
|
485
|
+
# Also reset per-layout Base ID counters; they will be rebuilt from the loaded data.
|
|
486
|
+
self._reset_base_id_counters()
|
|
487
|
+
|
|
488
|
+
# Extract normalized structures from possibly diverse layout shapes.
|
|
489
|
+
nodes_norm, conns_norm, positions, sizes = self._normalize_layout_dict(data)
|
|
490
|
+
|
|
491
|
+
# 1) Create nodes from registry spec, preserve UUID, set values that match current spec.
|
|
492
|
+
created: Dict[str, NodeModel] = {}
|
|
493
|
+
for nd in nodes_norm:
|
|
494
|
+
tname = nd.get("type")
|
|
495
|
+
nuuid = nd.get("uuid")
|
|
496
|
+
if not tname or not nuuid:
|
|
497
|
+
self._dbg(f"load_layout: skip node without type/uuid: {nd}")
|
|
498
|
+
continue
|
|
499
|
+
|
|
500
|
+
# Ensure type exists in registry.
|
|
501
|
+
spec = self.graph.registry.get(tname) if self.graph and self.graph.registry else None
|
|
502
|
+
if spec is None:
|
|
503
|
+
self._dbg(f"load_layout: skip node '{nuuid}' with unknown type '{tname}' (not in registry)")
|
|
504
|
+
continue
|
|
505
|
+
|
|
506
|
+
try:
|
|
507
|
+
node = self.graph.create_node_from_type(tname)
|
|
508
|
+
except Exception as ex:
|
|
509
|
+
self._dbg(f"load_layout: create_node_from_type failed for '{tname}': {ex}")
|
|
510
|
+
continue
|
|
511
|
+
|
|
512
|
+
# Preserve UUID and optional name from layout (if present).
|
|
513
|
+
try:
|
|
514
|
+
node.uuid = nuuid
|
|
515
|
+
except Exception:
|
|
516
|
+
pass
|
|
517
|
+
name = nd.get("name")
|
|
518
|
+
if isinstance(name, str) and name.strip():
|
|
519
|
+
try:
|
|
520
|
+
node.name = name
|
|
521
|
+
except Exception:
|
|
522
|
+
pass
|
|
523
|
+
|
|
524
|
+
# Preserve friendly node.id from layout if present (keeps per-layout numbering).
|
|
525
|
+
try:
|
|
526
|
+
fid = nd.get("id")
|
|
527
|
+
if isinstance(fid, str) and fid.strip():
|
|
528
|
+
node.id = fid.strip()
|
|
529
|
+
except Exception:
|
|
530
|
+
pass
|
|
531
|
+
|
|
532
|
+
# Apply property values only for properties that exist in the current spec.
|
|
533
|
+
values_map: Dict[str, Any] = nd.get("values", {})
|
|
534
|
+
for pid, pm in list(node.properties.items()):
|
|
535
|
+
if pid in values_map:
|
|
536
|
+
val = self._coerce_value_for_property(pm, values_map[pid])
|
|
537
|
+
try:
|
|
538
|
+
pm.value = val
|
|
539
|
+
except Exception:
|
|
540
|
+
pass
|
|
541
|
+
else:
|
|
542
|
+
# Keep default from registry-created node.
|
|
543
|
+
pass
|
|
544
|
+
|
|
545
|
+
# Schedule position (used in _on_graph_node_added) and add to graph.
|
|
546
|
+
pos_xy = positions.get(nuuid)
|
|
547
|
+
if isinstance(pos_xy, (list, tuple)) and len(pos_xy) == 2:
|
|
548
|
+
try:
|
|
549
|
+
self._pending_node_positions[nuuid] = QPointF(float(pos_xy[0]), float(pos_xy[1]))
|
|
550
|
+
except Exception:
|
|
551
|
+
pass
|
|
552
|
+
self.graph.add_node(node)
|
|
553
|
+
created[nuuid] = node
|
|
554
|
+
|
|
555
|
+
# 2) Apply sizes after items exist.
|
|
556
|
+
for nuuid, wh in sizes.items():
|
|
557
|
+
item = self._uuid_to_item.get(nuuid)
|
|
558
|
+
if item and isinstance(wh, (list, tuple)) and len(wh) == 2:
|
|
559
|
+
try:
|
|
560
|
+
w = float(wh[0]); h = float(wh[1])
|
|
561
|
+
item._apply_resize(QSizeF(w, h), clamp=True)
|
|
562
|
+
except Exception:
|
|
563
|
+
pass
|
|
564
|
+
|
|
565
|
+
# 3) Recreate connections if possible.
|
|
566
|
+
for cd in conns_norm:
|
|
567
|
+
cuuid = cd.get("uuid")
|
|
568
|
+
s_n = cd.get("src_node")
|
|
569
|
+
s_p = cd.get("src_prop")
|
|
570
|
+
d_n = cd.get("dst_node")
|
|
571
|
+
d_p = cd.get("dst_prop")
|
|
572
|
+
|
|
573
|
+
if not (s_n in created and d_n in created and isinstance(s_p, str) and isinstance(d_p, str)):
|
|
574
|
+
self._dbg(f"load_layout: skip connection with missing nodes/props: {cd}")
|
|
575
|
+
continue
|
|
576
|
+
|
|
577
|
+
# Validate ports exist in current spec.
|
|
578
|
+
if s_p not in created[s_n].properties or d_p not in created[d_n].properties:
|
|
579
|
+
self._dbg(f"load_layout: skip connection with non-existing port(s) in current spec: {cd}")
|
|
580
|
+
continue
|
|
581
|
+
|
|
582
|
+
# Check if connection is allowed in current graph state.
|
|
583
|
+
ok, reason = self.graph.can_connect((s_n, s_p), (d_n, d_p))
|
|
584
|
+
if not ok:
|
|
585
|
+
self._dbg(f"load_layout: cannot connect ({s_n}.{s_p}) -> ({d_n}.{d_p}): {reason}")
|
|
586
|
+
continue
|
|
587
|
+
|
|
588
|
+
# Try to preserve original connection UUID if available.
|
|
589
|
+
conn_model = ConnectionModel(
|
|
590
|
+
uuid=cuuid if isinstance(cuuid, str) and cuuid else None,
|
|
591
|
+
src_node=s_n, src_prop=s_p,
|
|
592
|
+
dst_node=d_n, dst_prop=d_p
|
|
593
|
+
)
|
|
594
|
+
ok_add, reason_add = self.graph.add_connection(conn_model)
|
|
595
|
+
if not ok_add:
|
|
596
|
+
# Fallback: let graph assign a fresh UUID (should rarely be needed).
|
|
597
|
+
ok2, reason2, _ = self.graph.connect((s_n, s_p), (d_n, d_p))
|
|
598
|
+
if not ok2:
|
|
599
|
+
self._dbg(f"load_layout: failed to add connection even via connect(): {reason_add} / {reason2}")
|
|
600
|
+
|
|
601
|
+
# 4) Sync ports after all nodes/connections are in place.
|
|
602
|
+
for item in self._uuid_to_item.values():
|
|
603
|
+
if _qt_is_valid(item):
|
|
604
|
+
item.update_ports_positions()
|
|
605
|
+
|
|
606
|
+
# Rebuild Base ID counters based on the just-loaded layout.
|
|
607
|
+
self._rebuild_base_id_counters()
|
|
608
|
+
|
|
609
|
+
self._reapply_stylesheets()
|
|
610
|
+
|
|
611
|
+
# Restore view state if present; otherwise center on content (backward compatible).
|
|
612
|
+
vs = self._extract_view_state(data)
|
|
613
|
+
if vs:
|
|
614
|
+
self._apply_view_state(vs)
|
|
615
|
+
else:
|
|
616
|
+
self.center_on_content()
|
|
617
|
+
return True
|
|
618
|
+
finally:
|
|
619
|
+
# Always clear the loading flag even if exceptions happen during import.
|
|
620
|
+
self._is_loading_layout = False
|
|
621
|
+
|
|
622
|
+
def export_schema(self, as_list: bool = False) -> Union[dict, List[dict]]:
|
|
623
|
+
"""Export the graph schema (delegates to NodeGraph)."""
|
|
624
|
+
if as_list:
|
|
625
|
+
return self.graph.to_list_schema()
|
|
626
|
+
return self.graph.to_schema()
|
|
627
|
+
|
|
628
|
+
def debug_state(self) -> dict:
|
|
629
|
+
"""Return a diagnostics snapshot (same as save_layout)."""
|
|
630
|
+
return self.save_layout()
|
|
631
|
+
|
|
632
|
+
def zoom_in(self):
|
|
633
|
+
"""Zoom in the view."""
|
|
634
|
+
if _qt_is_valid(self.view):
|
|
635
|
+
self.view.zoom_in()
|
|
636
|
+
|
|
637
|
+
def zoom_out(self):
|
|
638
|
+
"""Zoom out the view."""
|
|
639
|
+
if _qt_is_valid(self.view):
|
|
640
|
+
self.view.zoom_out()
|
|
641
|
+
|
|
642
|
+
# ---------- Graph <-> UI sync ----------
|
|
643
|
+
|
|
644
|
+
def _model_by_uuid(self, node_uuid: str) -> Optional[NodeModel]:
|
|
645
|
+
"""Return NodeModel by UUID from the graph."""
|
|
646
|
+
return self.graph.nodes.get(node_uuid)
|
|
647
|
+
|
|
648
|
+
def _on_graph_node_added(self, node: NodeModel):
|
|
649
|
+
"""Create a NodeItem when a NodeModel is added to the graph."""
|
|
650
|
+
if node.uuid in self._uuid_to_item:
|
|
651
|
+
return
|
|
652
|
+
|
|
653
|
+
# Do not auto-increment Base ID here to avoid double steps.
|
|
654
|
+
# Base ID assignment is handled at the command/model creation stage (e.g. AddNodeCommand).
|
|
655
|
+
pos = self._pending_node_positions.pop(node.uuid, None)
|
|
656
|
+
if pos is None:
|
|
657
|
+
pos = self._next_spawn_pos()
|
|
658
|
+
self._dbg(f"graph.nodeAdded -> add item for node={node.name}({node.uuid}) at {pos}")
|
|
659
|
+
self._add_node_item(node, pos)
|
|
660
|
+
|
|
661
|
+
# Keep Base ID counters in sync with newly added nodes.
|
|
662
|
+
self._update_base_id_counters_from_node(node)
|
|
663
|
+
|
|
664
|
+
def _on_graph_node_removed(self, node_uuid: str):
|
|
665
|
+
"""Remove the NodeItem when model is removed from the graph."""
|
|
666
|
+
self._dbg(f"graph.nodeRemoved -> remove item for node={node_uuid}")
|
|
667
|
+
item = self._uuid_to_item.pop(node_uuid, None)
|
|
668
|
+
if item and _qt_is_valid(item):
|
|
669
|
+
try:
|
|
670
|
+
item.detach_proxy_widget()
|
|
671
|
+
except Exception:
|
|
672
|
+
pass
|
|
673
|
+
try:
|
|
674
|
+
self.scene.removeItem(item)
|
|
675
|
+
except Exception:
|
|
676
|
+
pass
|
|
677
|
+
|
|
678
|
+
# Rebuild Base ID counters after removal to reflect current layout state.
|
|
679
|
+
self._rebuild_base_id_counters()
|
|
680
|
+
|
|
681
|
+
def _on_graph_connection_added(self, conn: ConnectionModel):
|
|
682
|
+
"""Create an EdgeItem when a ConnectionModel appears in the graph."""
|
|
683
|
+
self._dbg(f"graph.connectionAdded -> add edge uuid={conn.uuid} src=({conn.src_node},{conn.src_prop}) dst=({conn.dst_node},{conn.dst_prop})")
|
|
684
|
+
self._add_edge_for_connection(conn)
|
|
685
|
+
|
|
686
|
+
def _on_graph_connection_removed(self, conn_uuid: str):
|
|
687
|
+
"""Remove the EdgeItem when a connection is removed from the graph."""
|
|
688
|
+
self._dbg(f"graph.connectionRemoved -> remove edge uuid={conn_uuid}")
|
|
689
|
+
edge = self._conn_uuid_to_edge.pop(conn_uuid, None)
|
|
690
|
+
if edge:
|
|
691
|
+
edge.src_port.increment_connections(-1)
|
|
692
|
+
edge.dst_port.increment_connections(-1)
|
|
693
|
+
if _qt_is_valid(edge.src_port.node_item):
|
|
694
|
+
edge.src_port.node_item.remove_edge(edge)
|
|
695
|
+
if _qt_is_valid(edge.dst_port.node_item):
|
|
696
|
+
edge.dst_port.node_item.remove_edge(edge)
|
|
697
|
+
if _qt_is_valid(edge):
|
|
698
|
+
try:
|
|
699
|
+
self.scene.removeItem(edge)
|
|
700
|
+
except Exception:
|
|
701
|
+
pass
|
|
702
|
+
|
|
703
|
+
def _on_graph_cleared(self):
|
|
704
|
+
"""React to graph cleared by clearing the scene (items only)."""
|
|
705
|
+
self._dbg("graph.cleared -> clear scene only")
|
|
706
|
+
self._reset_interaction_states(remove_hidden_edges=True)
|
|
707
|
+
self._clear_scene_only(hard=True)
|
|
708
|
+
# Reset Base ID counters on clear so next layout starts fresh.
|
|
709
|
+
self._reset_base_id_counters()
|
|
710
|
+
|
|
711
|
+
# ---------- Scene helpers ----------
|
|
712
|
+
|
|
713
|
+
def _scene_to_global(self, scene_pos: QPointF) -> QPoint:
|
|
714
|
+
"""Convert scene coordinates to a global screen QPoint."""
|
|
715
|
+
from PySide6.QtCore import QPoint as _QPoint
|
|
716
|
+
vp_pt = self.view.mapFromScene(scene_pos)
|
|
717
|
+
if isinstance(vp_pt, QPointF):
|
|
718
|
+
vp_pt = _QPoint(int(vp_pt.x()), int(vp_pt.y()))
|
|
719
|
+
return self.view.viewport().mapToGlobal(vp_pt)
|
|
720
|
+
|
|
721
|
+
def _on_scene_context_menu(self, scene_pos: QPointF):
|
|
722
|
+
"""Show context menu for adding nodes and undo/redo/clear at empty scene position."""
|
|
723
|
+
menu = QMenu(self.window())
|
|
724
|
+
ss = self.window().styleSheet()
|
|
725
|
+
if ss:
|
|
726
|
+
menu.setStyleSheet(ss)
|
|
727
|
+
|
|
728
|
+
add_menu = menu.addMenu("Add")
|
|
729
|
+
action_by_type: Dict[QAction, str] = {}
|
|
730
|
+
for tname in self.graph.registry.types():
|
|
731
|
+
act = add_menu.addAction(tname)
|
|
732
|
+
action_by_type[act] = tname
|
|
733
|
+
|
|
734
|
+
menu.addSeparator()
|
|
735
|
+
act_undo = QAction("Undo", menu)
|
|
736
|
+
act_redo = QAction("Redo", menu)
|
|
737
|
+
act_clear = QAction("Clear", menu)
|
|
738
|
+
act_undo.setEnabled(self._undo.canUndo())
|
|
739
|
+
act_redo.setEnabled(self._undo.canRedo())
|
|
740
|
+
menu.addAction(act_undo)
|
|
741
|
+
menu.addAction(act_redo)
|
|
742
|
+
menu.addSeparator()
|
|
743
|
+
menu.addAction(act_clear)
|
|
744
|
+
|
|
745
|
+
global_pos = self._scene_to_global(scene_pos)
|
|
746
|
+
chosen = menu.exec(global_pos)
|
|
747
|
+
if chosen is None:
|
|
748
|
+
return
|
|
749
|
+
if chosen == act_undo:
|
|
750
|
+
self.undo()
|
|
751
|
+
elif chosen == act_redo:
|
|
752
|
+
self.redo()
|
|
753
|
+
elif chosen == act_clear:
|
|
754
|
+
self.clear(ask_user=True)
|
|
755
|
+
elif chosen in action_by_type:
|
|
756
|
+
type_name = action_by_type[chosen]
|
|
757
|
+
self._undo.push(AddNodeCommand(self, type_name, scene_pos))
|
|
758
|
+
|
|
759
|
+
# ---------- Add/remove nodes/edges ----------
|
|
760
|
+
|
|
761
|
+
def _add_node_model(self, node: NodeModel, scene_pos: QPointF):
|
|
762
|
+
"""Schedule a new node's position and add the model to the graph."""
|
|
763
|
+
self._pending_node_positions[node.uuid] = scene_pos
|
|
764
|
+
self.graph.add_node(node)
|
|
765
|
+
|
|
766
|
+
def _add_node_item(self, node: NodeModel, scene_pos: QPointF):
|
|
767
|
+
"""Create and place a NodeItem for the given NodeModel."""
|
|
768
|
+
item = NodeItem(self, node)
|
|
769
|
+
self.scene.addItem(item)
|
|
770
|
+
free_pos = self._find_free_position(scene_pos, item.size())
|
|
771
|
+
item.setPos(free_pos)
|
|
772
|
+
item.update_ports_positions()
|
|
773
|
+
# From now on it's safe for itemChange to touch the scene/edges
|
|
774
|
+
item.mark_ready_for_scene_ops(True)
|
|
775
|
+
|
|
776
|
+
self._uuid_to_item[node.uuid] = item
|
|
777
|
+
self._apply_styles_to_content(item._content)
|
|
778
|
+
|
|
779
|
+
def _find_free_position(self, desired: QPointF, size: QSizeF, step: int = 40, max_rings: int = 20) -> QPointF:
|
|
780
|
+
"""Find a non-overlapping position near 'desired' using a spiral search."""
|
|
781
|
+
def rect_at(p: QPointF) -> QRectF:
|
|
782
|
+
return QRectF(p.x(), p.y(), size.width(), size.height()).adjusted(1, 1, -1, -1)
|
|
783
|
+
def collides(p: QPointF) -> bool:
|
|
784
|
+
r = rect_at(p)
|
|
785
|
+
for it in self.scene.items(r, Qt.IntersectsItemBoundingRect):
|
|
786
|
+
if isinstance(it, NodeItem):
|
|
787
|
+
other = QRectF(it.scenePos().x(), it.scenePos().y(), it.size().width(), it.size().height()).adjusted(1,1,-1,-1)
|
|
788
|
+
if r.intersects(other):
|
|
789
|
+
return True
|
|
790
|
+
return False
|
|
791
|
+
|
|
792
|
+
if not collides(desired):
|
|
793
|
+
return desired
|
|
794
|
+
|
|
795
|
+
x = y = 0
|
|
796
|
+
dx, dy = 1, 0
|
|
797
|
+
segment_length = 1
|
|
798
|
+
p = QPointF(desired)
|
|
799
|
+
for ring in range(1, max_rings + 1):
|
|
800
|
+
for _ in range(2):
|
|
801
|
+
for _ in range(segment_length):
|
|
802
|
+
p = QPointF(desired.x() + x * step, desired.y() + y * step)
|
|
803
|
+
if not collides(p):
|
|
804
|
+
return p
|
|
805
|
+
x += dx; y += dy
|
|
806
|
+
dx, dy = -dy, dx
|
|
807
|
+
segment_length += 1
|
|
808
|
+
return desired
|
|
809
|
+
|
|
810
|
+
def _next_spawn_pos(self) -> QPointF:
|
|
811
|
+
"""Return next spawn position around cached origin (grid-based expansion)."""
|
|
812
|
+
if self._spawn_origin is None:
|
|
813
|
+
if not self.view.viewport().rect().isEmpty():
|
|
814
|
+
self._spawn_origin = self.view.mapToScene(self.view.viewport().rect().center())
|
|
815
|
+
else:
|
|
816
|
+
self._spawn_origin = self.scene.sceneRect().center()
|
|
817
|
+
origin = self._spawn_origin
|
|
818
|
+
step = 80
|
|
819
|
+
base_grid = [(-1, -1), (0, -1), (1, -1),
|
|
820
|
+
(-1, 0), (0, 0), (1, 0),
|
|
821
|
+
(-1, 1), (0, 1), (1, 1)]
|
|
822
|
+
ring = self._spawn_index // len(base_grid) + 1
|
|
823
|
+
gx, gy = base_grid[self._spawn_index % len(base_grid)]
|
|
824
|
+
self._spawn_index += 1
|
|
825
|
+
return QPointF(origin.x() + gx * step * ring, origin.y() + gy * step * ring)
|
|
826
|
+
|
|
827
|
+
def _remove_node_by_uuid(self, node_uuid: str):
|
|
828
|
+
"""Remove a node model by UUID (graph will emit nodeRemoved)."""
|
|
829
|
+
self.graph.remove_node(node_uuid)
|
|
830
|
+
|
|
831
|
+
def _add_edge_for_connection(self, conn: ConnectionModel):
|
|
832
|
+
"""Create an EdgeItem in the scene for a given ConnectionModel."""
|
|
833
|
+
ex = self._conn_uuid_to_edge.get(conn.uuid)
|
|
834
|
+
if ex and _qt_is_valid(ex):
|
|
835
|
+
self._dbg(f"_add_edge_for_connection: guard skip duplicate for uuid={conn.uuid}")
|
|
836
|
+
return
|
|
837
|
+
|
|
838
|
+
src_item = self._uuid_to_item.get(conn.src_node)
|
|
839
|
+
dst_item = self._uuid_to_item.get(conn.dst_node)
|
|
840
|
+
if not src_item or not dst_item:
|
|
841
|
+
self._dbg(f"_add_edge_for_connection: missing node items for conn={conn.uuid}")
|
|
842
|
+
return
|
|
843
|
+
src_port = src_item._out_ports.get(conn.src_prop)
|
|
844
|
+
dst_port = dst_item._in_ports.get(conn.dst_prop)
|
|
845
|
+
if not src_port or not dst_port:
|
|
846
|
+
self._dbg(f"_add_edge_for_connection: missing ports src={bool(src_port)} dst={bool(dst_port)}")
|
|
847
|
+
return
|
|
848
|
+
edge = EdgeItem(src_port, dst_port, temporary=False)
|
|
849
|
+
edge.update_path()
|
|
850
|
+
self.scene.addItem(edge)
|
|
851
|
+
src_item.add_edge(edge)
|
|
852
|
+
dst_item.add_edge(edge)
|
|
853
|
+
src_port.increment_connections(+1)
|
|
854
|
+
dst_port.increment_connections(+1)
|
|
855
|
+
edge._conn_uuid = conn.uuid
|
|
856
|
+
self._conn_uuid_to_edge[conn.uuid] = edge
|
|
857
|
+
self._dbg(f"_add_edge_for_connection: edge id={id(edge)} mapped to uuid={conn.uuid}")
|
|
858
|
+
|
|
859
|
+
def _remove_connection_by_uuid(self, conn_uuid: str):
|
|
860
|
+
"""Remove a connection by UUID (graph will emit connectionRemoved)."""
|
|
861
|
+
self._dbg(f"_remove_connection_by_uuid -> {conn_uuid}")
|
|
862
|
+
self.graph.remove_connection(conn_uuid)
|
|
863
|
+
|
|
864
|
+
# ---------- Theming helpers ----------
|
|
865
|
+
|
|
866
|
+
def _update_theme(self):
|
|
867
|
+
"""Refresh colors, pens, and re-apply styles to content widgets."""
|
|
868
|
+
if not getattr(self, "_alive", True):
|
|
869
|
+
return
|
|
870
|
+
if getattr(self, "_closing", False):
|
|
871
|
+
return
|
|
872
|
+
if getattr(self, "view", None) is None:
|
|
873
|
+
return
|
|
874
|
+
self._dbg("_update_theme")
|
|
875
|
+
for item in self._uuid_to_item.values():
|
|
876
|
+
if _qt_is_valid(item):
|
|
877
|
+
item._apply_resize(item.size(), clamp=True)
|
|
878
|
+
item.update()
|
|
879
|
+
for p in list(item._in_ports.values()) + list(item._out_ports.values()):
|
|
880
|
+
if _qt_is_valid(p):
|
|
881
|
+
p.notify_theme_changed()
|
|
882
|
+
for edge in self._conn_uuid_to_edge.values():
|
|
883
|
+
if _qt_is_valid(edge):
|
|
884
|
+
edge._update_pen()
|
|
885
|
+
edge.update()
|
|
886
|
+
if _qt_is_valid(self.view):
|
|
887
|
+
self.view.viewport().update()
|
|
888
|
+
self._reapply_stylesheets()
|
|
889
|
+
|
|
890
|
+
def _current_stylesheet(self) -> str:
|
|
891
|
+
"""Return the active stylesheet from window or QApplication."""
|
|
892
|
+
wnd = self.window()
|
|
893
|
+
if isinstance(wnd, QWidget) and wnd.styleSheet():
|
|
894
|
+
return wnd.styleSheet()
|
|
895
|
+
if QApplication.instance() and QApplication.instance().styleSheet():
|
|
896
|
+
return QApplication.instance().styleSheet()
|
|
897
|
+
return ""
|
|
898
|
+
|
|
899
|
+
def _current_palette(self) -> QPalette:
|
|
900
|
+
"""Return the active palette from window or QApplication."""
|
|
901
|
+
wnd = self.window()
|
|
902
|
+
if isinstance(wnd, QWidget):
|
|
903
|
+
return wnd.palette()
|
|
904
|
+
return QApplication.instance().palette() if QApplication.instance() else self.palette()
|
|
905
|
+
|
|
906
|
+
def _apply_styles_to_content(self, content_widget: QWidget):
|
|
907
|
+
"""Propagate palette and stylesheet to the embedded content widget subtree."""
|
|
908
|
+
if content_widget is None:
|
|
909
|
+
return
|
|
910
|
+
content_widget.setAttribute(Qt.WA_StyledBackground, True)
|
|
911
|
+
stylesheet = self._current_stylesheet()
|
|
912
|
+
pal = self._current_palette()
|
|
913
|
+
content_widget.setPalette(pal)
|
|
914
|
+
if stylesheet:
|
|
915
|
+
content_widget.setStyleSheet(stylesheet)
|
|
916
|
+
content_widget.ensurePolished()
|
|
917
|
+
for w in content_widget.findChildren(QWidget):
|
|
918
|
+
w.ensurePolished()
|
|
919
|
+
|
|
920
|
+
def _reapply_stylesheets(self):
|
|
921
|
+
"""Reapply palette and stylesheet to all NodeContentWidget instances."""
|
|
922
|
+
if not getattr(self, "_alive", True) or getattr(self, "_closing", False):
|
|
923
|
+
return
|
|
924
|
+
stylesheet = self._current_stylesheet()
|
|
925
|
+
pal = self._current_palette()
|
|
926
|
+
for item in self._uuid_to_item.values():
|
|
927
|
+
if item._content and _qt_is_valid(item._content):
|
|
928
|
+
item._content.setPalette(pal)
|
|
929
|
+
if stylesheet:
|
|
930
|
+
item._content.setStyleSheet(stylesheet)
|
|
931
|
+
item._content.ensurePolished()
|
|
932
|
+
for w in item._content.findChildren(QWidget):
|
|
933
|
+
w.ensurePolished()
|
|
934
|
+
|
|
935
|
+
# ---------- Edge/Port helpers + rewire ----------
|
|
936
|
+
|
|
937
|
+
def _edges_for_port(self, port: PortItem) -> List[EdgeItem]:
|
|
938
|
+
"""Return all persistent edges currently attached to a port."""
|
|
939
|
+
res: List[EdgeItem] = []
|
|
940
|
+
nitem = port.node_item
|
|
941
|
+
for e in list(nitem._edges):
|
|
942
|
+
if _qt_is_valid(e) and not e.temporary and (e.src_port is port or e.dst_port is port):
|
|
943
|
+
res.append(e)
|
|
944
|
+
for it in self.scene.items():
|
|
945
|
+
if isinstance(it, EdgeItem) and not it.temporary and (it.src_port is port or it.dst_port is port):
|
|
946
|
+
if it not in res:
|
|
947
|
+
res.append(it)
|
|
948
|
+
self._dbg(f"_edges_for_port: port={port.prop_id}/{port.side} -> {len(res)} edges")
|
|
949
|
+
return res
|
|
950
|
+
|
|
951
|
+
def _port_has_connections(self, port: PortItem) -> bool:
|
|
952
|
+
"""Return True if the port has any attached edges (fast path + scene scan)."""
|
|
953
|
+
res = (getattr(port, "_connected_count", 0) > 0) or bool(self._edges_for_port(port))
|
|
954
|
+
self._dbg(f"_port_has_connections: port={port.prop_id}/{port.side} -> {res}")
|
|
955
|
+
return res
|
|
956
|
+
|
|
957
|
+
def _choose_edge_near_cursor(self, edges: List[EdgeItem], cursor_scene: QPointF, ref_port: PortItem) -> Optional[EdgeItem]:
|
|
958
|
+
"""Pick the edge whose 'other end' is closest to the cursor (ties broken by distance)."""
|
|
959
|
+
if not edges:
|
|
960
|
+
return None
|
|
961
|
+
if len(edges) == 1:
|
|
962
|
+
return edges[0]
|
|
963
|
+
best = None
|
|
964
|
+
best_d2 = float("inf")
|
|
965
|
+
for e in edges:
|
|
966
|
+
other = e.dst_port if e.src_port is ref_port else e.src_port
|
|
967
|
+
op = other.scenePos()
|
|
968
|
+
dx = cursor_scene.x() - op.x()
|
|
969
|
+
dy = cursor_scene.y() - op.y()
|
|
970
|
+
d2 = dx*dx + dy*dy
|
|
971
|
+
if d2 < best_d2:
|
|
972
|
+
best_d2 = d2
|
|
973
|
+
best = e
|
|
974
|
+
return best
|
|
975
|
+
|
|
976
|
+
def _allowed_from_spec(self, node: NodeModel, pid: str) -> Tuple[int, int]:
|
|
977
|
+
"""Return (allowed_outputs, allowed_inputs) for a property ID using registry or model."""
|
|
978
|
+
out_allowed = 0
|
|
979
|
+
in_allowed = 0
|
|
980
|
+
try:
|
|
981
|
+
tp = node.type
|
|
982
|
+
spec_type = self.graph.registry.get(tp) if self.graph and self.graph.registry else None
|
|
983
|
+
prop_obj = None
|
|
984
|
+
if spec_type is not None:
|
|
985
|
+
for attr in ("properties", "props", "fields", "ports", "inputs", "outputs"):
|
|
986
|
+
try:
|
|
987
|
+
cont = getattr(spec_type, attr, None)
|
|
988
|
+
if isinstance(cont, dict) and pid in cont:
|
|
989
|
+
prop_obj = cont[pid]
|
|
990
|
+
break
|
|
991
|
+
except Exception:
|
|
992
|
+
pass
|
|
993
|
+
if prop_obj is None:
|
|
994
|
+
for meth in ("get_property", "property_spec", "get_prop", "prop", "property"):
|
|
995
|
+
if hasattr(spec_type, meth):
|
|
996
|
+
try:
|
|
997
|
+
prop_obj = getattr(spec_type, meth)(pid)
|
|
998
|
+
break
|
|
999
|
+
except Exception:
|
|
1000
|
+
pass
|
|
1001
|
+
def _get_int(o, k, default=0):
|
|
1002
|
+
try:
|
|
1003
|
+
v = getattr(o, k, None)
|
|
1004
|
+
if isinstance(v, int):
|
|
1005
|
+
return v
|
|
1006
|
+
except Exception:
|
|
1007
|
+
pass
|
|
1008
|
+
try:
|
|
1009
|
+
if isinstance(o, dict):
|
|
1010
|
+
v = o.get(k)
|
|
1011
|
+
if isinstance(v, int):
|
|
1012
|
+
return v
|
|
1013
|
+
except Exception:
|
|
1014
|
+
pass
|
|
1015
|
+
return default
|
|
1016
|
+
if prop_obj is not None:
|
|
1017
|
+
out_allowed = _get_int(prop_obj, "allowed_outputs", 0)
|
|
1018
|
+
in_allowed = _get_int(prop_obj, "allowed_inputs", 0)
|
|
1019
|
+
else:
|
|
1020
|
+
pm = node.properties.get(pid)
|
|
1021
|
+
if pm:
|
|
1022
|
+
out_allowed = int(getattr(pm, "allowed_outputs", 0))
|
|
1023
|
+
in_allowed = int(getattr(pm, "allowed_inputs", 0))
|
|
1024
|
+
except Exception:
|
|
1025
|
+
pm = node.properties.get(pid)
|
|
1026
|
+
if pm:
|
|
1027
|
+
out_allowed = int(getattr(pm, "allowed_outputs", 0))
|
|
1028
|
+
in_allowed = int(getattr(pm, "allowed_inputs", 0))
|
|
1029
|
+
return out_allowed, in_allowed
|
|
1030
|
+
|
|
1031
|
+
def _can_connect_during_rewire(self, src: PortItem, dst: PortItem) -> bool:
|
|
1032
|
+
"""Lightweight, allocation-free check to validate a rewire candidate."""
|
|
1033
|
+
try:
|
|
1034
|
+
src_node = self.graph.nodes.get(src.node_item.node.uuid)
|
|
1035
|
+
dst_node = self.graph.nodes.get(dst.node_item.node.uuid)
|
|
1036
|
+
if not src_node or not dst_node:
|
|
1037
|
+
return False
|
|
1038
|
+
sp = src_node.properties.get(src.prop_id)
|
|
1039
|
+
dp = dst_node.properties.get(dst.prop_id)
|
|
1040
|
+
if not sp or not dp:
|
|
1041
|
+
return False
|
|
1042
|
+
if sp.type != dp.type:
|
|
1043
|
+
return False
|
|
1044
|
+
sp_out_allowed, _ = self._allowed_from_spec(src_node, src.prop_id)
|
|
1045
|
+
_, dp_in_allowed = self._allowed_from_spec(dst_node, dst.prop_id)
|
|
1046
|
+
skip_uuid = self._rewire_conn_uuid
|
|
1047
|
+
src_count = sum(1 for c in self.graph.connections.values()
|
|
1048
|
+
if c.src_node == src.node_item.node.uuid and c.src_prop == src.prop_id and c.uuid != skip_uuid)
|
|
1049
|
+
dst_count = sum(1 for c in self.graph.connections.values()
|
|
1050
|
+
if c.dst_node == dst.node_item.node.uuid and c.dst_prop == dst.prop_id and c.uuid != skip_uuid)
|
|
1051
|
+
if isinstance(sp_out_allowed, int) and sp_out_allowed > 0 and src_count >= sp_out_allowed:
|
|
1052
|
+
return False
|
|
1053
|
+
if isinstance(dp_in_allowed, int) and dp_in_allowed > 0 and dst_count >= dp_in_allowed:
|
|
1054
|
+
return False
|
|
1055
|
+
return True
|
|
1056
|
+
except Exception:
|
|
1057
|
+
return False
|
|
1058
|
+
|
|
1059
|
+
def _can_connect_for_interaction(self, src: PortItem, dst: PortItem) -> bool:
|
|
1060
|
+
"""Check whether src -> dst would be allowed, considering current interaction mode."""
|
|
1061
|
+
if self._wire_state in ("rewiring", "rewire-primed"):
|
|
1062
|
+
return self._can_connect_during_rewire(src, dst)
|
|
1063
|
+
ok, _ = self.graph.can_connect((src.node_item.node.uuid, src.prop_id),
|
|
1064
|
+
(dst.node_item.node.uuid, dst.prop_id))
|
|
1065
|
+
return ok
|
|
1066
|
+
|
|
1067
|
+
def _find_compatible_port_at(self, scene_pos: QPointF, radius: Optional[float] = None) -> Optional[PortItem]:
|
|
1068
|
+
"""Find nearest compatible port to scene_pos when dragging an interactive wire."""
|
|
1069
|
+
if self._interactive_src_port is None:
|
|
1070
|
+
return None
|
|
1071
|
+
src = self._interactive_src_port
|
|
1072
|
+
pick_r_cfg = float(getattr(self, "_port_pick_radius", 10.0) or 10.0)
|
|
1073
|
+
pick_r = float(radius) if radius is not None else max(18.0, pick_r_cfg + 10.0)
|
|
1074
|
+
rect = QRectF(scene_pos.x() - pick_r, scene_pos.y() - pick_r, 2 * pick_r, 2 * pick_r) if False else QRectF(scene_pos.x() - pick_r, scene_pos.y() - pick_r, 2 * pick_r, 2 * pick_r)
|
|
1075
|
+
items = self.scene.items(rect, Qt.IntersectsItemShape, Qt.DescendingOrder, self.view.transform())
|
|
1076
|
+
best: Optional[PortItem] = None
|
|
1077
|
+
best_d2 = float("inf")
|
|
1078
|
+
for it in items:
|
|
1079
|
+
if not isinstance(it, PortItem) or it is src:
|
|
1080
|
+
continue
|
|
1081
|
+
a, b = self._resolve_direction(src, it)
|
|
1082
|
+
if not a or not b:
|
|
1083
|
+
continue
|
|
1084
|
+
if a.node_item.node.uuid == b.node_item.node.uuid:
|
|
1085
|
+
continue
|
|
1086
|
+
if not self._can_connect_for_interaction(a, b):
|
|
1087
|
+
continue
|
|
1088
|
+
pp = it.scenePos()
|
|
1089
|
+
dx = scene_pos.x() - pp.x()
|
|
1090
|
+
dy = scene_pos.y() - pp.y()
|
|
1091
|
+
d2 = dx * dx + dy * dy
|
|
1092
|
+
if d2 < best_d2:
|
|
1093
|
+
best_d2 = d2
|
|
1094
|
+
best = it
|
|
1095
|
+
if best:
|
|
1096
|
+
self._dbg(f"_find_compatible_port_at: FOUND port={best.prop_id}/{best.side} on node={best.node_item.node.name}")
|
|
1097
|
+
return best
|
|
1098
|
+
|
|
1099
|
+
def _enter_wire_drag_mode(self):
|
|
1100
|
+
"""Temporarily disable view drag to ensure smooth wire interaction."""
|
|
1101
|
+
try:
|
|
1102
|
+
self._saved_drag_mode = self.view.dragMode()
|
|
1103
|
+
self.view.setDragMode(QGraphicsView.NoDrag)
|
|
1104
|
+
except Exception:
|
|
1105
|
+
self._saved_drag_mode = None
|
|
1106
|
+
|
|
1107
|
+
def _leave_wire_drag_mode(self):
|
|
1108
|
+
"""Restore the view drag mode after wire interaction finishes."""
|
|
1109
|
+
if self._saved_drag_mode is not None and _qt_is_valid(self.view):
|
|
1110
|
+
try:
|
|
1111
|
+
self.view.setDragMode(self._saved_drag_mode)
|
|
1112
|
+
except Exception:
|
|
1113
|
+
pass
|
|
1114
|
+
self._saved_drag_mode = None
|
|
1115
|
+
|
|
1116
|
+
def _on_port_clicked(self, port: PortItem):
|
|
1117
|
+
"""Start drawing a new wire or prime a rewire depending on modifiers and port state.
|
|
1118
|
+
|
|
1119
|
+
Behavior:
|
|
1120
|
+
- Click: start a new connection from the clicked port.
|
|
1121
|
+
- Ctrl+Click on a connected port: prime rewire/detach, selecting the closest edge.
|
|
1122
|
+
"""
|
|
1123
|
+
self._dbg(f"_on_port_clicked: side={port.side}, prop={port.prop_id}, connected={self._port_has_connections(port)}")
|
|
1124
|
+
mods = QApplication.keyboardModifiers()
|
|
1125
|
+
# Ctrl+click on a connected port enters rewire/detach mode; plain click always starts a new connection
|
|
1126
|
+
rewire_requested = bool(mods & Qt.ControlModifier)
|
|
1127
|
+
if rewire_requested and self._port_has_connections(port):
|
|
1128
|
+
cursor_scene = self.view.mapToScene(self.view.mapFromGlobal(QCursor.pos()))
|
|
1129
|
+
edges = self._edges_for_port(port)
|
|
1130
|
+
edge = self._choose_edge_near_cursor(edges, cursor_scene, port)
|
|
1131
|
+
if edge:
|
|
1132
|
+
self._prime_rewire_from_conn(port, getattr(edge, "_conn_uuid", None), edge, cursor_scene)
|
|
1133
|
+
return
|
|
1134
|
+
self._start_draw(port)
|
|
1135
|
+
|
|
1136
|
+
def _prime_rewire_from_conn(self, origin_port: PortItem, conn_uuid: Optional[str],
|
|
1137
|
+
edge: Optional[EdgeItem], press_scene_pos: QPointF):
|
|
1138
|
+
"""Prime the 'rewire' state from an existing connection without moving yet.
|
|
1139
|
+
|
|
1140
|
+
This state transitions into actual rewiring when the cursor moves beyond a small threshold.
|
|
1141
|
+
"""
|
|
1142
|
+
if self._wire_state != "idle":
|
|
1143
|
+
return
|
|
1144
|
+
self._wire_state = "rewire-primed"
|
|
1145
|
+
self._rewire_conn_uuid = conn_uuid
|
|
1146
|
+
self._rewire_hidden_edge = edge
|
|
1147
|
+
fixed_src = origin_port if origin_port.side == "output" else (edge.src_port if edge else None)
|
|
1148
|
+
if fixed_src is None:
|
|
1149
|
+
self._wire_state = "idle"
|
|
1150
|
+
self._rewire_conn_uuid = None
|
|
1151
|
+
self._rewire_hidden_edge = None
|
|
1152
|
+
return
|
|
1153
|
+
self._rewire_fixed_src = fixed_src
|
|
1154
|
+
self._interactive_src_port = fixed_src
|
|
1155
|
+
self._rewire_press_scene_pos = QPointF(press_scene_pos)
|
|
1156
|
+
|
|
1157
|
+
def _start_draw(self, src_port: PortItem):
|
|
1158
|
+
"""Begin interactive wire drawing from a source port."""
|
|
1159
|
+
if self._wire_state != "idle":
|
|
1160
|
+
return
|
|
1161
|
+
self._wire_state = "drawing"
|
|
1162
|
+
self._interactive_src_port = src_port
|
|
1163
|
+
self._interactive_edge = EdgeItem(src_port=src_port, dst_port=src_port, temporary=True)
|
|
1164
|
+
self.scene.addItem(self._interactive_edge)
|
|
1165
|
+
self._interactive_edge.update_path(src_port.scenePos())
|
|
1166
|
+
self._enter_wire_drag_mode()
|
|
1167
|
+
|
|
1168
|
+
def _start_rewire_from_edge(self, edge: EdgeItem, cursor_scene_pos: QPointF):
|
|
1169
|
+
"""Start rewiring by hiding the original edge and creating a temporary wire."""
|
|
1170
|
+
if self._wire_state != "idle" or not _qt_is_valid(edge):
|
|
1171
|
+
return
|
|
1172
|
+
self._wire_state = "rewiring"
|
|
1173
|
+
self._rewire_hidden_edge = edge
|
|
1174
|
+
self._rewire_conn_uuid = getattr(edge, "_conn_uuid", None)
|
|
1175
|
+
self._rewire_fixed_src = edge.src_port
|
|
1176
|
+
self._interactive_src_port = edge.src_port
|
|
1177
|
+
edge.setVisible(False)
|
|
1178
|
+
self._interactive_edge = EdgeItem(src_port=edge.src_port, dst_port=edge.src_port, temporary=True)
|
|
1179
|
+
self.scene.addItem(self._interactive_edge)
|
|
1180
|
+
self._interactive_edge.update_path(end_pos=cursor_scene_pos)
|
|
1181
|
+
self._enter_wire_drag_mode()
|
|
1182
|
+
|
|
1183
|
+
def _resolve_direction(self, a: PortItem, b: PortItem) -> Tuple[Optional[PortItem], Optional[PortItem]]:
|
|
1184
|
+
"""Return (src, dst) if ports are complementary, otherwise (None, None)."""
|
|
1185
|
+
if a.side == "output" and b.side == "input":
|
|
1186
|
+
return a, b
|
|
1187
|
+
if a.side == "input" and b.side == "output":
|
|
1188
|
+
return b, a
|
|
1189
|
+
return None, None
|
|
1190
|
+
|
|
1191
|
+
def _set_hover_candidate(self, port: Optional[PortItem]):
|
|
1192
|
+
"""Highlight/unhighlight the current hover-accept candidate port."""
|
|
1193
|
+
if self._hover_candidate is port:
|
|
1194
|
+
return
|
|
1195
|
+
if self._hover_candidate is not None and _qt_is_valid(self._hover_candidate):
|
|
1196
|
+
self._hover_candidate.set_accept_highlight(False)
|
|
1197
|
+
self._hover_candidate = port
|
|
1198
|
+
if self._hover_candidate is not None and _qt_is_valid(self._hover_candidate):
|
|
1199
|
+
self._hover_candidate.set_accept_highlight(True)
|
|
1200
|
+
|
|
1201
|
+
def eventFilter(self, obj, event):
|
|
1202
|
+
"""Intercept scene mouse events to implement interactive wiring and rewiring."""
|
|
1203
|
+
if not self._alive or self.scene is None or self.view is None:
|
|
1204
|
+
return False
|
|
1205
|
+
if obj is self.scene:
|
|
1206
|
+
et = event.type()
|
|
1207
|
+
|
|
1208
|
+
# Rewire: convert primed state into actual rewiring after small cursor movement.
|
|
1209
|
+
if self._wire_state == "rewire-primed" and et == QEvent.GraphicsSceneMouseMove:
|
|
1210
|
+
pos = event.scenePos()
|
|
1211
|
+
if self._rewire_press_scene_pos is not None:
|
|
1212
|
+
dist = abs(pos.x() - self._rewire_press_scene_pos.x()) + abs(pos.y() - self._rewire_press_scene_pos.y())
|
|
1213
|
+
else:
|
|
1214
|
+
dist = 9999
|
|
1215
|
+
if dist > 6 and self._rewire_fixed_src is not None:
|
|
1216
|
+
if self._rewire_hidden_edge and _qt_is_valid(self._rewire_hidden_edge):
|
|
1217
|
+
self._rewire_hidden_edge.setVisible(False)
|
|
1218
|
+
self._interactive_edge = EdgeItem(src_port=self._rewire_fixed_src, dst_port=self._rewire_fixed_src, temporary=True)
|
|
1219
|
+
self.scene.addItem(self._interactive_edge)
|
|
1220
|
+
self._interactive_edge.update_path(end_pos=pos)
|
|
1221
|
+
self._enter_wire_drag_mode()
|
|
1222
|
+
self._wire_state = "rewiring"
|
|
1223
|
+
candidate = self._find_compatible_port_at(pos, radius=28.0)
|
|
1224
|
+
self._set_hover_candidate(candidate)
|
|
1225
|
+
return True
|
|
1226
|
+
|
|
1227
|
+
# While dragging a wire, update temporary path and hover highlight.
|
|
1228
|
+
if self._interactive_edge is not None and et == QEvent.GraphicsSceneMouseMove:
|
|
1229
|
+
pos = event.scenePos()
|
|
1230
|
+
if _qt_is_valid(self._interactive_edge):
|
|
1231
|
+
self._interactive_edge.update_path(end_pos=pos)
|
|
1232
|
+
candidate = self._find_compatible_port_at(pos, radius=28.0)
|
|
1233
|
+
self._set_hover_candidate(candidate)
|
|
1234
|
+
return True
|
|
1235
|
+
|
|
1236
|
+
# RMB cancels interactive connection (drawing or rewiring).
|
|
1237
|
+
if self._interactive_edge is not None and et == QEvent.GraphicsSceneMousePress and event.button() == Qt.RightButton:
|
|
1238
|
+
self._cancel_interactive_connection()
|
|
1239
|
+
return True
|
|
1240
|
+
|
|
1241
|
+
# LMB release: finalize interactive connection or rewire.
|
|
1242
|
+
if et == QEvent.GraphicsSceneMouseRelease and event.button() == Qt.LeftButton:
|
|
1243
|
+
if self._wire_state == "rewire-primed":
|
|
1244
|
+
self._finish_interactive_connection()
|
|
1245
|
+
return True
|
|
1246
|
+
|
|
1247
|
+
if self._interactive_edge is not None:
|
|
1248
|
+
pos = event.scenePos()
|
|
1249
|
+
target = self._find_compatible_port_at(pos, radius=48.0)
|
|
1250
|
+
if target is None and self._hover_candidate is not None:
|
|
1251
|
+
target = self._hover_candidate
|
|
1252
|
+
|
|
1253
|
+
if self._wire_state == "rewiring":
|
|
1254
|
+
if isinstance(target, PortItem) and self._rewire_fixed_src is not None:
|
|
1255
|
+
src, dst = self._resolve_direction(self._rewire_fixed_src, target)
|
|
1256
|
+
if src and dst:
|
|
1257
|
+
if self._rewire_conn_uuid and self._rewire_conn_uuid in self.graph.connections:
|
|
1258
|
+
old = self.graph.connections.get(self._rewire_conn_uuid)
|
|
1259
|
+
if old:
|
|
1260
|
+
self._undo.push(RewireConnectionCommand(self, old, src, dst))
|
|
1261
|
+
else:
|
|
1262
|
+
self._undo.push(ConnectCommand(self, src, dst))
|
|
1263
|
+
if self._rewire_hidden_edge:
|
|
1264
|
+
self._detach_edge_item(self._rewire_hidden_edge)
|
|
1265
|
+
else:
|
|
1266
|
+
# Drop on empty space: delete or detach depending on availability.
|
|
1267
|
+
if self._rewire_conn_uuid and self._rewire_conn_uuid in self.graph.connections:
|
|
1268
|
+
old = self.graph.connections.get(self._rewire_conn_uuid)
|
|
1269
|
+
if old:
|
|
1270
|
+
self._undo.push(RewireConnectionCommand(self, old, None, None))
|
|
1271
|
+
elif self._rewire_hidden_edge:
|
|
1272
|
+
self._delete_edge(self._rewire_hidden_edge)
|
|
1273
|
+
self._finish_interactive_connection()
|
|
1274
|
+
return True
|
|
1275
|
+
|
|
1276
|
+
elif self._wire_state == "drawing":
|
|
1277
|
+
if isinstance(target, PortItem) and self._interactive_src_port is not None:
|
|
1278
|
+
src, dst = self._resolve_direction(self._interactive_src_port, target)
|
|
1279
|
+
if src and dst:
|
|
1280
|
+
self._undo.push(ConnectCommand(self, src, dst))
|
|
1281
|
+
self._finish_interactive_connection()
|
|
1282
|
+
return True
|
|
1283
|
+
|
|
1284
|
+
return super().eventFilter(obj, event)
|
|
1285
|
+
|
|
1286
|
+
def _reset_interaction_states(self, remove_hidden_edges: bool):
|
|
1287
|
+
"""Reset all interactive wiring state and optionally remove hidden edges."""
|
|
1288
|
+
self._set_hover_candidate(None)
|
|
1289
|
+
if self._interactive_edge and _qt_is_valid(self._interactive_edge):
|
|
1290
|
+
try:
|
|
1291
|
+
self.scene.removeItem(self._interactive_edge)
|
|
1292
|
+
except Exception:
|
|
1293
|
+
pass
|
|
1294
|
+
self._interactive_edge = None
|
|
1295
|
+
self._interactive_src_port = None
|
|
1296
|
+
|
|
1297
|
+
if remove_hidden_edges and self._rewire_hidden_edge and _qt_is_valid(self._rewire_hidden_edge):
|
|
1298
|
+
try:
|
|
1299
|
+
self.scene.removeItem(self._rewire_hidden_edge)
|
|
1300
|
+
except Exception:
|
|
1301
|
+
pass
|
|
1302
|
+
elif self._rewire_hidden_edge and _qt_is_valid(self._rewire_hidden_edge):
|
|
1303
|
+
if self._rewire_hidden_edge.scene() is not None:
|
|
1304
|
+
self._rewire_hidden_edge.setVisible(True)
|
|
1305
|
+
|
|
1306
|
+
self._wire_state = "idle"
|
|
1307
|
+
self._rewire_conn_uuid = None
|
|
1308
|
+
self._rewire_hidden_edge = None
|
|
1309
|
+
self._rewire_fixed_src = None
|
|
1310
|
+
self._rewire_press_scene_pos = None
|
|
1311
|
+
self._leave_wire_drag_mode()
|
|
1312
|
+
|
|
1313
|
+
def _finish_interactive_connection(self):
|
|
1314
|
+
"""Finish interactive connection/rewire and restore view drag mode."""
|
|
1315
|
+
self._reset_interaction_states(remove_hidden_edges=False)
|
|
1316
|
+
|
|
1317
|
+
def _cancel_interactive_connection(self):
|
|
1318
|
+
"""Cancel interactive connection/rewire."""
|
|
1319
|
+
self._reset_interaction_states(remove_hidden_edges=False)
|
|
1320
|
+
|
|
1321
|
+
# ---------- Delete helpers ----------
|
|
1322
|
+
|
|
1323
|
+
def _on_delete_shortcut(self):
|
|
1324
|
+
"""Delete selected nodes and/or connections unless a text input is focused (undoable)."""
|
|
1325
|
+
if self._is_text_input_focused():
|
|
1326
|
+
self._dbg("Delete ignored: text input is focused")
|
|
1327
|
+
return
|
|
1328
|
+
if not _qt_is_valid(self.scene):
|
|
1329
|
+
return
|
|
1330
|
+
|
|
1331
|
+
selected = list(self.scene.selectedItems())
|
|
1332
|
+
if not selected:
|
|
1333
|
+
return
|
|
1334
|
+
|
|
1335
|
+
nodes = [it for it in selected if isinstance(it, NodeItem)]
|
|
1336
|
+
edges = [it for it in selected if isinstance(it, EdgeItem)]
|
|
1337
|
+
|
|
1338
|
+
# Filter out edges that are attached to nodes being deleted; they will be handled by the node command.
|
|
1339
|
+
if nodes and edges:
|
|
1340
|
+
node_uuids = {n.node.uuid for n in nodes}
|
|
1341
|
+
edges = [
|
|
1342
|
+
e for e in edges
|
|
1343
|
+
if _qt_is_valid(e.src_port) and _qt_is_valid(e.dst_port) and
|
|
1344
|
+
e.src_port.node_item.node.uuid not in node_uuids and
|
|
1345
|
+
e.dst_port.node_item.node.uuid not in node_uuids
|
|
1346
|
+
]
|
|
1347
|
+
|
|
1348
|
+
if not nodes and not edges:
|
|
1349
|
+
return
|
|
1350
|
+
|
|
1351
|
+
self._dbg(f"Delete shortcut -> nodes={len(nodes)}, edges={len(edges)} (undoable)")
|
|
1352
|
+
self._undo.beginMacro("Delete selection")
|
|
1353
|
+
try:
|
|
1354
|
+
for n in nodes:
|
|
1355
|
+
# Push per-node undoable deletion (restores its own connections)
|
|
1356
|
+
self._delete_node_item(n)
|
|
1357
|
+
for e in edges:
|
|
1358
|
+
self._delete_edge_undoable(e)
|
|
1359
|
+
finally:
|
|
1360
|
+
self._undo.endMacro()
|
|
1361
|
+
|
|
1362
|
+
def _delete_node_item(self, item: "NodeItem"):
|
|
1363
|
+
"""Delete a node via undoable command."""
|
|
1364
|
+
try:
|
|
1365
|
+
if _qt_is_valid(item):
|
|
1366
|
+
self._dbg(f"_delete_node_item (undoable) -> node={item.node.uuid}")
|
|
1367
|
+
self._undo.push(DeleteNodeCommand(self, item))
|
|
1368
|
+
except Exception:
|
|
1369
|
+
pass
|
|
1370
|
+
|
|
1371
|
+
def _detach_edge_item(self, edge: EdgeItem):
|
|
1372
|
+
"""Detach an edge item from scene and internal maps without touching the graph."""
|
|
1373
|
+
try:
|
|
1374
|
+
edge.src_port.increment_connections(-1)
|
|
1375
|
+
edge.dst_port.increment_connections(-1)
|
|
1376
|
+
except Exception:
|
|
1377
|
+
pass
|
|
1378
|
+
if _qt_is_valid(edge.src_port.node_item):
|
|
1379
|
+
edge.src_port.node_item.remove_edge(edge)
|
|
1380
|
+
if _qt_is_valid(edge.dst_port.node_item):
|
|
1381
|
+
edge.dst_port.node_item.remove_edge(edge)
|
|
1382
|
+
for k, v in list(self._conn_uuid_to_edge.items()):
|
|
1383
|
+
if v is edge:
|
|
1384
|
+
self._conn_uuid_to_edge.pop(k, None)
|
|
1385
|
+
if _qt_is_valid(edge):
|
|
1386
|
+
try:
|
|
1387
|
+
self.scene.removeItem(edge)
|
|
1388
|
+
except Exception:
|
|
1389
|
+
pass
|
|
1390
|
+
|
|
1391
|
+
def _delete_edge(self, edge: EdgeItem):
|
|
1392
|
+
"""Delete an edge either by graph UUID or by detaching the item if orphan."""
|
|
1393
|
+
conn_uuid = getattr(edge, "_conn_uuid", None)
|
|
1394
|
+
exists = bool(conn_uuid and conn_uuid in self.graph.connections)
|
|
1395
|
+
if exists:
|
|
1396
|
+
self._remove_connection_by_uuid(conn_uuid)
|
|
1397
|
+
else:
|
|
1398
|
+
self._detach_edge_item(edge)
|
|
1399
|
+
|
|
1400
|
+
def _delete_selected_connections(self):
|
|
1401
|
+
"""Delete all currently selected edges in the scene (undoable)."""
|
|
1402
|
+
if not _qt_is_valid(self.scene):
|
|
1403
|
+
return
|
|
1404
|
+
for it in list(self.scene.selectedItems()):
|
|
1405
|
+
if isinstance(it, EdgeItem):
|
|
1406
|
+
self._delete_edge_undoable(it)
|
|
1407
|
+
if _qt_is_valid(self.view):
|
|
1408
|
+
self.view.viewport().update()
|
|
1409
|
+
|
|
1410
|
+
def _delete_edge_undoable(self, edge: EdgeItem):
|
|
1411
|
+
"""Delete a connection through the undo stack; fallback to immediate detach for orphans."""
|
|
1412
|
+
conn_uuid = getattr(edge, "_conn_uuid", None)
|
|
1413
|
+
if conn_uuid and conn_uuid in self.graph.connections:
|
|
1414
|
+
conn = self.graph.connections[conn_uuid]
|
|
1415
|
+
self._undo.push(DeleteConnectionCommand(self, conn))
|
|
1416
|
+
else:
|
|
1417
|
+
# Orphan/temporary edge: remove immediately (not tracked in the graph)
|
|
1418
|
+
self._delete_edge(edge)
|
|
1419
|
+
|
|
1420
|
+
# ---------- Clear helpers ----------
|
|
1421
|
+
|
|
1422
|
+
def _remove_all_edge_items_from_scene(self):
|
|
1423
|
+
"""Remove every EdgeItem from the scene (used during teardown)."""
|
|
1424
|
+
if not _qt_is_valid(self.scene):
|
|
1425
|
+
return
|
|
1426
|
+
for it in list(self.scene.items()):
|
|
1427
|
+
if isinstance(it, EdgeItem):
|
|
1428
|
+
try:
|
|
1429
|
+
self.scene.removeItem(it)
|
|
1430
|
+
except Exception:
|
|
1431
|
+
pass
|
|
1432
|
+
|
|
1433
|
+
def _cleanup_node_proxies(self):
|
|
1434
|
+
"""Detach and delete QWidget proxies inside all NodeItems to prevent leaks."""
|
|
1435
|
+
for item in list(self._uuid_to_item.values()):
|
|
1436
|
+
try:
|
|
1437
|
+
if _qt_is_valid(item):
|
|
1438
|
+
# Break edges/ports and disconnect signals before tearing down widgets/proxies
|
|
1439
|
+
try:
|
|
1440
|
+
item.pre_cleanup()
|
|
1441
|
+
except Exception:
|
|
1442
|
+
pass
|
|
1443
|
+
item.detach_proxy_widget()
|
|
1444
|
+
except Exception:
|
|
1445
|
+
pass
|
|
1446
|
+
|
|
1447
|
+
def _clear_scene_only(self, hard: bool = False):
|
|
1448
|
+
"""Clear all NodeItems and EdgeItems from the scene (graph untouched unless caller decides)."""
|
|
1449
|
+
self._cleanup_node_proxies()
|
|
1450
|
+
for edge in list(self._conn_uuid_to_edge.values()):
|
|
1451
|
+
if _qt_is_valid(edge):
|
|
1452
|
+
try:
|
|
1453
|
+
self.scene.removeItem(edge)
|
|
1454
|
+
except Exception:
|
|
1455
|
+
pass
|
|
1456
|
+
self._conn_uuid_to_edge.clear()
|
|
1457
|
+
for item in list(self._uuid_to_item.values()):
|
|
1458
|
+
if _qt_is_valid(item):
|
|
1459
|
+
try:
|
|
1460
|
+
# Ensure references are broken even if proxies were not detached earlier
|
|
1461
|
+
try:
|
|
1462
|
+
item.pre_cleanup()
|
|
1463
|
+
except Exception:
|
|
1464
|
+
pass
|
|
1465
|
+
self.scene.removeItem(item)
|
|
1466
|
+
except Exception:
|
|
1467
|
+
pass
|
|
1468
|
+
self._uuid_to_item.clear()
|
|
1469
|
+
if hard:
|
|
1470
|
+
self._remove_all_edge_items_from_scene()
|
|
1471
|
+
|
|
1472
|
+
def _clear_scene_and_graph(self):
|
|
1473
|
+
"""Clear both the scene items and the graph models."""
|
|
1474
|
+
self._reset_interaction_states(remove_hidden_edges=True)
|
|
1475
|
+
self._clear_scene_only(hard=True)
|
|
1476
|
+
self.graph.clear(silent=True)
|
|
1477
|
+
|
|
1478
|
+
# ---------- Base ID auto-increment ----------
|
|
1479
|
+
|
|
1480
|
+
def _is_base_id_pid(self, pid: str, pm: PropertyModel) -> bool:
|
|
1481
|
+
"""Detect base-id-like property ids/names or explicit flag on the PropertyModel."""
|
|
1482
|
+
try:
|
|
1483
|
+
if getattr(pm, "is_base_id", False):
|
|
1484
|
+
return True
|
|
1485
|
+
except Exception:
|
|
1486
|
+
pass
|
|
1487
|
+
pid_key = (pid or "").strip().lower().replace(" ", "_")
|
|
1488
|
+
name_key = ""
|
|
1489
|
+
try:
|
|
1490
|
+
name_key = (pm.name or "").strip().lower().replace(" ", "_")
|
|
1491
|
+
except Exception:
|
|
1492
|
+
pass
|
|
1493
|
+
candidates = {"base_id", "baseid", "base", "id_base", "basename", "base_name"}
|
|
1494
|
+
return pid_key in candidates or name_key in candidates
|
|
1495
|
+
|
|
1496
|
+
def _split_base_suffix(self, text: str) -> Tuple[str, Optional[int]]:
|
|
1497
|
+
"""Split 'name_12' into ('name', 12). If no numeric suffix, return (text, None)."""
|
|
1498
|
+
if not isinstance(text, str):
|
|
1499
|
+
text = f"{text}"
|
|
1500
|
+
parts = text.rsplit("_", 1)
|
|
1501
|
+
if len(parts) == 2 and parts[1].isdigit():
|
|
1502
|
+
return parts[0], int(parts[1])
|
|
1503
|
+
return text, None
|
|
1504
|
+
|
|
1505
|
+
def _reset_base_id_counters(self):
|
|
1506
|
+
"""Reset per-layout counters for Base ID suffixes."""
|
|
1507
|
+
self._base_id_max = {}
|
|
1508
|
+
|
|
1509
|
+
def _bump_base_counter(self, prop_id: str, base: str, suffix: int):
|
|
1510
|
+
"""Ensure the internal counter for (prop_id, base) is at least suffix."""
|
|
1511
|
+
try:
|
|
1512
|
+
d = self._base_id_max.setdefault(prop_id, {})
|
|
1513
|
+
cur = int(d.get(base, 0))
|
|
1514
|
+
if int(suffix) > cur:
|
|
1515
|
+
d[base] = int(suffix)
|
|
1516
|
+
except Exception:
|
|
1517
|
+
pass
|
|
1518
|
+
|
|
1519
|
+
def _current_max_suffix_in_graph(self, prop_id: str, base: str) -> int:
|
|
1520
|
+
"""Scan the current graph and return the highest numeric suffix used for given (prop_id, base).
|
|
1521
|
+
This guards against stale in-memory counters when switching layouts without a hard reset."""
|
|
1522
|
+
max_suf = 0
|
|
1523
|
+
try:
|
|
1524
|
+
if not base:
|
|
1525
|
+
return 0
|
|
1526
|
+
for node in self.graph.nodes.values():
|
|
1527
|
+
pm = node.properties.get(prop_id)
|
|
1528
|
+
if not pm:
|
|
1529
|
+
continue
|
|
1530
|
+
raw = "" if pm.value is None else str(pm.value)
|
|
1531
|
+
b, suf = self._split_base_suffix(raw)
|
|
1532
|
+
if b == base and isinstance(suf, int):
|
|
1533
|
+
if suf > max_suf:
|
|
1534
|
+
max_suf = suf
|
|
1535
|
+
except Exception:
|
|
1536
|
+
return max_suf
|
|
1537
|
+
return max_suf
|
|
1538
|
+
|
|
1539
|
+
def _next_suffix_for_base(self, prop_id: str, base: str) -> int:
|
|
1540
|
+
"""Return next suffix to use for a given (prop_id, base) scoped to the current layout.
|
|
1541
|
+
Always computes from the live graph to avoid stale counters leaking between layouts."""
|
|
1542
|
+
try:
|
|
1543
|
+
actual_max = self._current_max_suffix_in_graph(prop_id, base)
|
|
1544
|
+
# Keep internal cache in sync with the observed state
|
|
1545
|
+
self._bump_base_counter(prop_id, base, actual_max)
|
|
1546
|
+
return max(1, actual_max + 1)
|
|
1547
|
+
except Exception:
|
|
1548
|
+
return 1
|
|
1549
|
+
|
|
1550
|
+
def _update_base_id_counters_from_node(self, node: NodeModel):
|
|
1551
|
+
"""Update counters using values from a single node."""
|
|
1552
|
+
try:
|
|
1553
|
+
for pid, pm in node.properties.items():
|
|
1554
|
+
if not self._is_base_id_pid(pid, pm):
|
|
1555
|
+
continue
|
|
1556
|
+
raw = "" if pm.value is None else str(pm.value)
|
|
1557
|
+
base, suf = self._split_base_suffix(raw)
|
|
1558
|
+
if base is None or base == "":
|
|
1559
|
+
continue
|
|
1560
|
+
if suf is None:
|
|
1561
|
+
# At least register the base with suffix 0
|
|
1562
|
+
self._bump_base_counter(pid, base, 0)
|
|
1563
|
+
else:
|
|
1564
|
+
self._bump_base_counter(pid, base, int(suf))
|
|
1565
|
+
except Exception:
|
|
1566
|
+
pass
|
|
1567
|
+
|
|
1568
|
+
def _rebuild_base_id_counters(self):
|
|
1569
|
+
"""Recompute Base ID counters by scanning the current graph."""
|
|
1570
|
+
self._reset_base_id_counters()
|
|
1571
|
+
try:
|
|
1572
|
+
for node in self.graph.nodes.values():
|
|
1573
|
+
self._update_base_id_counters_from_node(node)
|
|
1574
|
+
except Exception:
|
|
1575
|
+
pass
|
|
1576
|
+
|
|
1577
|
+
def _prepare_new_node_defaults(self, node: NodeModel):
|
|
1578
|
+
"""When adding a node, auto-increment Base ID to keep uniqueness within the layout.
|
|
1579
|
+
|
|
1580
|
+
Behavior for interactive add (not during load):
|
|
1581
|
+
- Detect base-id-like property.
|
|
1582
|
+
- Ignore any numeric suffix already present in the default.
|
|
1583
|
+
- Always assign base_{next_local_suffix}, where 'next' is computed from per-layout counters.
|
|
1584
|
+
"""
|
|
1585
|
+
try:
|
|
1586
|
+
base_pid = None
|
|
1587
|
+
for pid, pm in node.properties.items():
|
|
1588
|
+
if self._is_base_id_pid(pid, pm):
|
|
1589
|
+
base_pid = pid
|
|
1590
|
+
break
|
|
1591
|
+
if not base_pid:
|
|
1592
|
+
return
|
|
1593
|
+
|
|
1594
|
+
pm = node.properties.get(base_pid)
|
|
1595
|
+
if not pm:
|
|
1596
|
+
return
|
|
1597
|
+
|
|
1598
|
+
raw = "" if pm.value is None else str(pm.value)
|
|
1599
|
+
base, _ = self._split_base_suffix(raw)
|
|
1600
|
+
if not base:
|
|
1601
|
+
# Do not generate suffix if base is empty
|
|
1602
|
+
return
|
|
1603
|
+
|
|
1604
|
+
next_suf = self._next_suffix_for_base(base_pid, base)
|
|
1605
|
+
pm.value = f"{base}_{next_suf}"
|
|
1606
|
+
self._bump_base_counter(base_pid, base, next_suf)
|
|
1607
|
+
except Exception:
|
|
1608
|
+
pass
|
|
1609
|
+
|
|
1610
|
+
# ---------- View centering ----------
|
|
1611
|
+
|
|
1612
|
+
def content_bounding_rect(self) -> Optional[QRectF]:
|
|
1613
|
+
"""Return the bounding rect of all nodes (in scene coords), or None if empty."""
|
|
1614
|
+
rect: Optional[QRectF] = None
|
|
1615
|
+
for item in self._uuid_to_item.values():
|
|
1616
|
+
r = item.mapToScene(item.boundingRect()).boundingRect()
|
|
1617
|
+
rect = r if rect is None else rect.united(r)
|
|
1618
|
+
return rect
|
|
1619
|
+
|
|
1620
|
+
def center_on_content(self, margin: float = 80.0):
|
|
1621
|
+
"""Center the view on all content, expanding scene rect with a safety margin."""
|
|
1622
|
+
rect = self.content_bounding_rect()
|
|
1623
|
+
if rect and rect.isValid():
|
|
1624
|
+
padded = rect.adjusted(-margin, -margin, margin, margin)
|
|
1625
|
+
self.scene.setSceneRect(self.scene.sceneRect().united(padded))
|
|
1626
|
+
self.view.centerOn(rect.center())
|
|
1627
|
+
|
|
1628
|
+
def _save_view_state(self) -> dict:
|
|
1629
|
+
"""Return a serializable snapshot of the current view (zoom and scrollbars)."""
|
|
1630
|
+
if not _qt_is_valid(self.view):
|
|
1631
|
+
return {}
|
|
1632
|
+
try:
|
|
1633
|
+
return self.view.view_state()
|
|
1634
|
+
except Exception:
|
|
1635
|
+
# Fallback in case view_state() is not available
|
|
1636
|
+
try:
|
|
1637
|
+
return {
|
|
1638
|
+
"zoom": float(getattr(self.view, "_zoom", 1.0)),
|
|
1639
|
+
"h": int(self.view.horizontalScrollBar().value()),
|
|
1640
|
+
"v": int(self.view.verticalScrollBar().value()),
|
|
1641
|
+
}
|
|
1642
|
+
except Exception:
|
|
1643
|
+
return {}
|
|
1644
|
+
|
|
1645
|
+
def _apply_view_state(self, state: dict):
|
|
1646
|
+
"""Apply a previously saved view state to the graphics view."""
|
|
1647
|
+
if not _qt_is_valid(self.view) or not isinstance(state, dict):
|
|
1648
|
+
return
|
|
1649
|
+
try:
|
|
1650
|
+
self.view.set_view_state(state)
|
|
1651
|
+
except Exception:
|
|
1652
|
+
# Fallback path maintaining compatibility with minimal attributes
|
|
1653
|
+
try:
|
|
1654
|
+
z = state.get("zoom") or state.get("scale")
|
|
1655
|
+
if z is not None:
|
|
1656
|
+
self.view.resetTransform()
|
|
1657
|
+
self.view._zoom = 1.0
|
|
1658
|
+
z = max(self.view._min_zoom, min(self.view._max_zoom, float(z)))
|
|
1659
|
+
if abs(z - 1.0) > 1e-9:
|
|
1660
|
+
self.view.scale(z, z)
|
|
1661
|
+
self.view._zoom = z
|
|
1662
|
+
if state.get("h") is not None:
|
|
1663
|
+
self.view.horizontalScrollBar().setValue(int(state["h"]))
|
|
1664
|
+
if state.get("v") is not None:
|
|
1665
|
+
self.view.verticalScrollBar().setValue(int(state["v"]))
|
|
1666
|
+
except Exception:
|
|
1667
|
+
pass
|
|
1668
|
+
|
|
1669
|
+
def _extract_view_state(self, data: dict) -> Optional[dict]:
|
|
1670
|
+
"""Extract a view state from various possible blocks in layout data."""
|
|
1671
|
+
if not isinstance(data, dict):
|
|
1672
|
+
return None
|
|
1673
|
+
vs = None
|
|
1674
|
+
for key in ("view", "viewport", "camera", "view_state", "viewState"):
|
|
1675
|
+
blk = data.get(key)
|
|
1676
|
+
if isinstance(blk, dict):
|
|
1677
|
+
vs = blk
|
|
1678
|
+
break
|
|
1679
|
+
if vs is None:
|
|
1680
|
+
# Optional nested blocks
|
|
1681
|
+
for parent in ("meta", "ui"):
|
|
1682
|
+
blk = data.get(parent)
|
|
1683
|
+
if isinstance(blk, dict):
|
|
1684
|
+
for key in ("view", "viewport", "camera", "view_state", "viewState"):
|
|
1685
|
+
if isinstance(blk.get(key), dict):
|
|
1686
|
+
vs = blk.get(key)
|
|
1687
|
+
break
|
|
1688
|
+
if vs is not None:
|
|
1689
|
+
break
|
|
1690
|
+
if not isinstance(vs, dict):
|
|
1691
|
+
return None
|
|
1692
|
+
out = {}
|
|
1693
|
+
z = vs.get("zoom") or vs.get("scale") or vs.get("z")
|
|
1694
|
+
h = vs.get("h") or vs.get("hScroll") or vs.get("scrollH") or vs.get("x")
|
|
1695
|
+
v = vs.get("v") or vs.get("vScroll") or vs.get("scrollV") or vs.get("y")
|
|
1696
|
+
try:
|
|
1697
|
+
if z is not None:
|
|
1698
|
+
out["zoom"] = float(z)
|
|
1699
|
+
except Exception:
|
|
1700
|
+
pass
|
|
1701
|
+
try:
|
|
1702
|
+
if h is not None:
|
|
1703
|
+
out["h"] = int(h)
|
|
1704
|
+
except Exception:
|
|
1705
|
+
pass
|
|
1706
|
+
try:
|
|
1707
|
+
if v is not None:
|
|
1708
|
+
out["v"] = int(v)
|
|
1709
|
+
except Exception:
|
|
1710
|
+
pass
|
|
1711
|
+
return out if out else None
|
|
1712
|
+
|
|
1713
|
+
# ---------- Layout normalization helpers (registry-first import) ----------
|
|
1714
|
+
|
|
1715
|
+
def _coerce_value_for_property(self, pm: PropertyModel, value: Any) -> Any:
|
|
1716
|
+
"""Cast persisted value to the property's current type. Be permissive and safe.
|
|
1717
|
+
|
|
1718
|
+
Supported types:
|
|
1719
|
+
- int, float, bool, combo (str), str, text
|
|
1720
|
+
All other/custom types are returned unchanged.
|
|
1721
|
+
"""
|
|
1722
|
+
t = getattr(pm, "type", None)
|
|
1723
|
+
try:
|
|
1724
|
+
if t == "int":
|
|
1725
|
+
return int(value)
|
|
1726
|
+
if t == "float":
|
|
1727
|
+
return float(value)
|
|
1728
|
+
if t == "bool":
|
|
1729
|
+
if isinstance(value, bool):
|
|
1730
|
+
return value
|
|
1731
|
+
if isinstance(value, (int, float)):
|
|
1732
|
+
return bool(value)
|
|
1733
|
+
if isinstance(value, str):
|
|
1734
|
+
lv = value.strip().lower()
|
|
1735
|
+
if lv in ("1", "true", "yes", "y", "on"):
|
|
1736
|
+
return True
|
|
1737
|
+
if lv in ("0", "false", "no", "n", "off"):
|
|
1738
|
+
return False
|
|
1739
|
+
return bool(value)
|
|
1740
|
+
if t == "combo":
|
|
1741
|
+
# Keep as string; actual validation against options happens in UI.
|
|
1742
|
+
return str(value)
|
|
1743
|
+
if t in ("str", "text"):
|
|
1744
|
+
return "" if value is None else str(value)
|
|
1745
|
+
# For custom types like "flow", "memory" we keep the raw value (usually None or id).
|
|
1746
|
+
return value
|
|
1747
|
+
except Exception:
|
|
1748
|
+
return value
|
|
1749
|
+
|
|
1750
|
+
def _normalize_layout_dict(self, data: dict) -> Tuple[List[dict], List[dict], Dict[str, Tuple[float, float]], Dict[str, Tuple[float, float]]]:
|
|
1751
|
+
"""
|
|
1752
|
+
Normalize various layout shapes into:
|
|
1753
|
+
- nodes: List[ { uuid, type, name?, values:{pid:value}, id? } ]
|
|
1754
|
+
- conns: List[ { uuid?, src_node, src_prop, dst_node, dst_prop } ]
|
|
1755
|
+
- positions: { uuid: [x, y] }
|
|
1756
|
+
- sizes: { uuid: [w, h] }
|
|
1757
|
+
"""
|
|
1758
|
+
nodes_norm: List[dict] = []
|
|
1759
|
+
conns_norm: List[dict] = []
|
|
1760
|
+
positions: Dict[str, Tuple[float, float]] = {}
|
|
1761
|
+
sizes: Dict[str, Tuple[float, float]] = {}
|
|
1762
|
+
|
|
1763
|
+
# Positions / sizes blocks used by our save_layout
|
|
1764
|
+
pos_block = data.get("positions", {}) or {}
|
|
1765
|
+
siz_block = data.get("sizes", {}) or {}
|
|
1766
|
+
if isinstance(pos_block, dict):
|
|
1767
|
+
positions = pos_block
|
|
1768
|
+
if isinstance(siz_block, dict):
|
|
1769
|
+
sizes = siz_block
|
|
1770
|
+
|
|
1771
|
+
# Nodes block: accept dict keyed by uuid or list of node dicts.
|
|
1772
|
+
nodes_block = data.get("nodes")
|
|
1773
|
+
if isinstance(nodes_block, dict):
|
|
1774
|
+
for uuid_key, nd in nodes_block.items():
|
|
1775
|
+
norm = self._normalize_node_dict(uuid_key, nd)
|
|
1776
|
+
if norm:
|
|
1777
|
+
# allow node-level position/size override if embedded
|
|
1778
|
+
if isinstance(nd, dict):
|
|
1779
|
+
if "pos" in nd and isinstance(nd["pos"], (list, tuple)) and len(nd["pos"]) == 2:
|
|
1780
|
+
positions[norm["uuid"]] = nd["pos"]
|
|
1781
|
+
if "size" in nd and isinstance(nd["size"], (list, tuple)) and len(nd["size"]) == 2:
|
|
1782
|
+
sizes[norm["uuid"]] = nd["size"]
|
|
1783
|
+
nodes_norm.append(norm)
|
|
1784
|
+
elif isinstance(nodes_block, list):
|
|
1785
|
+
for nd in nodes_block:
|
|
1786
|
+
uuid_key = None
|
|
1787
|
+
if isinstance(nd, dict):
|
|
1788
|
+
uuid_key = nd.get("uuid") or nd.get("id")
|
|
1789
|
+
norm = self._normalize_node_dict(uuid_key, nd)
|
|
1790
|
+
if norm:
|
|
1791
|
+
if isinstance(nd, dict):
|
|
1792
|
+
if "pos" in nd and isinstance(nd["pos"], (list, tuple)) and len(nd["pos"]) == 2:
|
|
1793
|
+
positions[norm["uuid"]] = nd["pos"]
|
|
1794
|
+
if "size" in nd and isinstance(nd["size"], (list, tuple)) and len(nd["size"]) == 2:
|
|
1795
|
+
sizes[norm["uuid"]] = nd["size"]
|
|
1796
|
+
nodes_norm.append(norm)
|
|
1797
|
+
else:
|
|
1798
|
+
# Fallback: try graph-like root with 'graph' or 'items'
|
|
1799
|
+
for alt_key in ("graph", "items"):
|
|
1800
|
+
blk = data.get(alt_key)
|
|
1801
|
+
if isinstance(blk, list):
|
|
1802
|
+
for nd in blk:
|
|
1803
|
+
uuid_key = nd.get("uuid") if isinstance(nd, dict) else None
|
|
1804
|
+
norm = self._normalize_node_dict(uuid_key, nd)
|
|
1805
|
+
if norm:
|
|
1806
|
+
nodes_norm.append(norm)
|
|
1807
|
+
elif isinstance(blk, dict):
|
|
1808
|
+
for uuid_key, nd in blk.items():
|
|
1809
|
+
norm = self._normalize_node_dict(uuid_key, nd)
|
|
1810
|
+
if norm:
|
|
1811
|
+
nodes_norm.append(norm)
|
|
1812
|
+
|
|
1813
|
+
# Connections block: accept dict keyed by uuid or list.
|
|
1814
|
+
conns_block = data.get("connections") or data.get("edges") or data.get("links") or {}
|
|
1815
|
+
if isinstance(conns_block, dict):
|
|
1816
|
+
for cuuid, cd in conns_block.items():
|
|
1817
|
+
norm = self._normalize_conn_dict(cuuid, cd)
|
|
1818
|
+
if norm:
|
|
1819
|
+
conns_norm.append(norm)
|
|
1820
|
+
elif isinstance(conns_block, list):
|
|
1821
|
+
for cd in conns_block:
|
|
1822
|
+
cuuid = cd.get("uuid") if isinstance(cd, dict) else None
|
|
1823
|
+
norm = self._normalize_conn_dict(cuuid, cd)
|
|
1824
|
+
if norm:
|
|
1825
|
+
conns_norm.append(norm)
|
|
1826
|
+
|
|
1827
|
+
return nodes_norm, conns_norm, positions, sizes
|
|
1828
|
+
|
|
1829
|
+
def _normalize_node_dict(self, uuid_key: Optional[str], nd: Any) -> Optional[dict]:
|
|
1830
|
+
"""Normalize a node dict to {uuid, type, name?, values{}, id?}."""
|
|
1831
|
+
if not isinstance(nd, dict):
|
|
1832
|
+
return None
|
|
1833
|
+
# Prefer explicit 'uuid' or the key from dict form; 'id' may be used for friendly label
|
|
1834
|
+
nuuid = nd.get("uuid") or uuid_key or nd.get("id")
|
|
1835
|
+
tname = nd.get("type") or nd.get("type_name") or nd.get("t")
|
|
1836
|
+
if not nuuid or not tname:
|
|
1837
|
+
return None
|
|
1838
|
+
name = nd.get("name") or nd.get("title") or nd.get("label")
|
|
1839
|
+
values = self._extract_values_from_properties_block(nd)
|
|
1840
|
+
|
|
1841
|
+
# Extract friendly id if present and distinct from uuid to preserve per-layout numbering.
|
|
1842
|
+
friendly_id: Optional[str] = None
|
|
1843
|
+
try:
|
|
1844
|
+
raw_id = nd.get("id")
|
|
1845
|
+
if isinstance(raw_id, str):
|
|
1846
|
+
if (nd.get("uuid") and raw_id != nd.get("uuid")) or (uuid_key and raw_id != uuid_key) or (not nd.get("uuid") and not uuid_key):
|
|
1847
|
+
friendly_id = raw_id
|
|
1848
|
+
except Exception:
|
|
1849
|
+
friendly_id = None
|
|
1850
|
+
|
|
1851
|
+
res = {"uuid": nuuid, "type": tname, "name": name, "values": values}
|
|
1852
|
+
if friendly_id:
|
|
1853
|
+
res["id"] = friendly_id
|
|
1854
|
+
return res
|
|
1855
|
+
|
|
1856
|
+
def _extract_values_from_properties_block(self, nd: dict) -> Dict[str, Any]:
|
|
1857
|
+
"""Extract {prop_id: value} from various shapes of 'properties'."""
|
|
1858
|
+
values: Dict[str, Any] = {}
|
|
1859
|
+
block = nd.get("properties") or nd.get("props") or nd.get("fields") or {}
|
|
1860
|
+
if isinstance(block, dict):
|
|
1861
|
+
for k, v in block.items():
|
|
1862
|
+
if isinstance(v, dict):
|
|
1863
|
+
if "value" in v:
|
|
1864
|
+
values[k] = v.get("value")
|
|
1865
|
+
elif "val" in v:
|
|
1866
|
+
values[k] = v.get("val")
|
|
1867
|
+
else:
|
|
1868
|
+
# heuristics: take raw if simple type
|
|
1869
|
+
values[k] = v.get("default") if "default" in v else v.get("data") if "data" in v else None
|
|
1870
|
+
else:
|
|
1871
|
+
values[k] = v
|
|
1872
|
+
elif isinstance(block, list):
|
|
1873
|
+
for item in block:
|
|
1874
|
+
if not isinstance(item, dict):
|
|
1875
|
+
continue
|
|
1876
|
+
pid = item.get("id") or item.get("key") or item.get("name")
|
|
1877
|
+
if not pid:
|
|
1878
|
+
continue
|
|
1879
|
+
if "value" in item:
|
|
1880
|
+
values[pid] = item.get("value")
|
|
1881
|
+
elif "val" in item:
|
|
1882
|
+
values[pid] = item.get("val")
|
|
1883
|
+
elif "default" in item:
|
|
1884
|
+
values[pid] = item.get("default")
|
|
1885
|
+
return values
|
|
1886
|
+
|
|
1887
|
+
def _normalize_conn_dict(self, cuuid: Optional[str], cd: Any) -> Optional[dict]:
|
|
1888
|
+
"""Normalize a connection dict to {uuid?, src_node, src_prop, dst_node, dst_prop}."""
|
|
1889
|
+
if not isinstance(cd, dict):
|
|
1890
|
+
return None
|
|
1891
|
+
s_n = cd.get("src_node") or cd.get("source") or cd.get("from_node")
|
|
1892
|
+
d_n = cd.get("dst_node") or cd.get("target") or cd.get("to_node")
|
|
1893
|
+
s_p = cd.get("src_prop") or cd.get("src") or cd.get("from") or cd.get("out")
|
|
1894
|
+
d_p = cd.get("dst_prop") or cd.get("dst") or cd.get("to") or cd.get("in")
|
|
1895
|
+
if not (s_n and d_n and s_p and d_p):
|
|
1896
|
+
# Try nested shapes: {src:{node,prop}, dst:{node,prop}}
|
|
1897
|
+
src = cd.get("src") or cd.get("from")
|
|
1898
|
+
dst = cd.get("dst") or cd.get("to")
|
|
1899
|
+
if isinstance(src, dict) and isinstance(dst, dict):
|
|
1900
|
+
s_n = src.get("node") or src.get("uuid")
|
|
1901
|
+
s_p = src.get("prop") or src.get("port") or src.get("id")
|
|
1902
|
+
d_n = dst.get("node") or dst.get("uuid")
|
|
1903
|
+
d_p = dst.get("prop") or dst.get("port") or dst.get("id")
|
|
1904
|
+
if not (s_n and d_n and s_p and d_p):
|
|
1905
|
+
return None
|
|
1906
|
+
return {"uuid": cuuid, "src_node": s_n, "src_prop": s_p, "dst_node": d_n, "dst_prop": d_p}
|
|
1907
|
+
|
|
1908
|
+
# ---------- Compact layout serialization (value-only) ----------
|
|
1909
|
+
|
|
1910
|
+
def _registry_prop_spec(self, type_name: str, prop_id: str) -> Optional[Any]:
|
|
1911
|
+
"""Return the PropertySpec from the registry for given type/prop id, if available."""
|
|
1912
|
+
try:
|
|
1913
|
+
spec = self.graph.registry.get(type_name) if self.graph and self.graph.registry else None
|
|
1914
|
+
if not spec:
|
|
1915
|
+
return None
|
|
1916
|
+
props = getattr(spec, "properties", None)
|
|
1917
|
+
if isinstance(props, list):
|
|
1918
|
+
for ps in props:
|
|
1919
|
+
if getattr(ps, "id", None) == prop_id:
|
|
1920
|
+
return ps
|
|
1921
|
+
# Accept dict-like collections if registry exposes them
|
|
1922
|
+
for attr in ("props", "fields", "ports", "inputs", "outputs"):
|
|
1923
|
+
cont = getattr(spec, attr, None)
|
|
1924
|
+
if isinstance(cont, dict) and prop_id in cont:
|
|
1925
|
+
return cont[prop_id]
|
|
1926
|
+
except Exception:
|
|
1927
|
+
return None
|
|
1928
|
+
return None
|
|
1929
|
+
|
|
1930
|
+
def _should_persist_property(self, node: NodeModel, pm: PropertyModel) -> bool:
|
|
1931
|
+
"""Decide whether a property should be written to layout.
|
|
1932
|
+
|
|
1933
|
+
Rules:
|
|
1934
|
+
- Always include explicit base-id like fields (e.g. 'base_id') as they carry per-node state.
|
|
1935
|
+
- Include editable fields (typical user data).
|
|
1936
|
+
- Include non-editable fields only if their current value differs from registry default.
|
|
1937
|
+
- Ports (e.g., 'flow', 'memory') are skipped unless they carry a non-default value.
|
|
1938
|
+
"""
|
|
1939
|
+
try:
|
|
1940
|
+
pid = pm.id
|
|
1941
|
+
if self._is_base_id_pid(pid, pm):
|
|
1942
|
+
return True
|
|
1943
|
+
|
|
1944
|
+
# Editable properties are considered dynamic by nature
|
|
1945
|
+
if bool(getattr(pm, "editable", False)):
|
|
1946
|
+
return True
|
|
1947
|
+
|
|
1948
|
+
# Compare with registry default to detect runtime changes
|
|
1949
|
+
ps = self._registry_prop_spec(node.type, pid)
|
|
1950
|
+
default = getattr(ps, "value", None) if ps is not None else None
|
|
1951
|
+
if pm.value != default:
|
|
1952
|
+
return True
|
|
1953
|
+
|
|
1954
|
+
# Ports rarely carry a data value; skip when value equals default (usually None)
|
|
1955
|
+
return False
|
|
1956
|
+
except Exception:
|
|
1957
|
+
# Be conservative on errors: do not persist
|
|
1958
|
+
return False
|
|
1959
|
+
|
|
1960
|
+
def _serialize_layout_compact(self) -> dict:
|
|
1961
|
+
"""Build compact layout with minimal per-property data.
|
|
1962
|
+
|
|
1963
|
+
Output shape:
|
|
1964
|
+
{
|
|
1965
|
+
"nodes": {
|
|
1966
|
+
"<node_uuid>": {
|
|
1967
|
+
"uuid": "...",
|
|
1968
|
+
"id": "...",
|
|
1969
|
+
"name": "...",
|
|
1970
|
+
"type": "Flow/Agent",
|
|
1971
|
+
"properties": {
|
|
1972
|
+
"<prop_id>": {
|
|
1973
|
+
"uuid": "...",
|
|
1974
|
+
"id": "name",
|
|
1975
|
+
"name": "Name",
|
|
1976
|
+
"value": "Alice",
|
|
1977
|
+
"options": [...] # only if options differ from registry default
|
|
1978
|
+
},
|
|
1979
|
+
...
|
|
1980
|
+
}
|
|
1981
|
+
},
|
|
1982
|
+
...
|
|
1983
|
+
},
|
|
1984
|
+
"connections": {
|
|
1985
|
+
"<conn_uuid>": { "uuid": "...", "src_node": "...", "src_prop": "...", "dst_node": "...", "dst_prop": "..." },
|
|
1986
|
+
...
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
"""
|
|
1990
|
+
nodes_out: Dict[str, dict] = {}
|
|
1991
|
+
for nuuid, n in self.graph.nodes.items():
|
|
1992
|
+
props_out: Dict[str, dict] = {}
|
|
1993
|
+
for pid, pm in n.properties.items():
|
|
1994
|
+
if not isinstance(pm, PropertyModel):
|
|
1995
|
+
continue
|
|
1996
|
+
if not self._should_persist_property(n, pm):
|
|
1997
|
+
continue
|
|
1998
|
+
|
|
1999
|
+
entry = {
|
|
2000
|
+
"uuid": pm.uuid,
|
|
2001
|
+
"id": pm.id,
|
|
2002
|
+
"name": pm.name,
|
|
2003
|
+
"value": pm.value,
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
# Persist options only when they differ from registry default (dynamic options).
|
|
2007
|
+
try:
|
|
2008
|
+
cur_opts = list(getattr(pm, "options", [])) if getattr(pm, "options", None) is not None else None
|
|
2009
|
+
ps = self._registry_prop_spec(n.type, pid)
|
|
2010
|
+
def_opts = list(getattr(ps, "options", [])) if ps is not None and getattr(ps, "options", None) is not None else None
|
|
2011
|
+
if cur_opts is not None:
|
|
2012
|
+
if def_opts is None or cur_opts != def_opts:
|
|
2013
|
+
entry["options"] = cur_opts
|
|
2014
|
+
except Exception:
|
|
2015
|
+
pass
|
|
2016
|
+
|
|
2017
|
+
props_out[pid] = entry
|
|
2018
|
+
|
|
2019
|
+
nodes_out[nuuid] = {
|
|
2020
|
+
"uuid": n.uuid,
|
|
2021
|
+
"id": n.id,
|
|
2022
|
+
"name": n.name,
|
|
2023
|
+
"type": n.type,
|
|
2024
|
+
"properties": props_out,
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
# Minimal connections (dict keyed by uuid for backwards compatibility)
|
|
2028
|
+
conns_out: Dict[str, dict] = {}
|
|
2029
|
+
for cuuid, c in self.graph.connections.items():
|
|
2030
|
+
conns_out[cuuid] = {
|
|
2031
|
+
"uuid": c.uuid,
|
|
2032
|
+
"src_node": c.src_node,
|
|
2033
|
+
"src_prop": c.src_prop,
|
|
2034
|
+
"dst_node": c.dst_node,
|
|
2035
|
+
"dst_prop": c.dst_prop,
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
return {"nodes": nodes_out, "connections": conns_out}
|