pygpt-net 2.6.59__py3-none-any.whl → 2.6.61__py3-none-any.whl

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