pygpt-net 2.6.60__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.
- pygpt_net/CHANGELOG.txt +7 -0
- pygpt_net/__init__.py +3 -3
- pygpt_net/controller/chat/common.py +115 -6
- pygpt_net/controller/chat/input.py +4 -1
- pygpt_net/controller/presets/presets.py +121 -6
- pygpt_net/controller/settings/editor.py +0 -15
- pygpt_net/controller/theme/markdown.py +2 -5
- pygpt_net/controller/ui/ui.py +4 -7
- pygpt_net/core/agents/custom/__init__.py +7 -1
- pygpt_net/core/agents/custom/llama_index/factory.py +17 -6
- pygpt_net/core/agents/custom/llama_index/runner.py +35 -2
- pygpt_net/core/agents/custom/llama_index/utils.py +12 -1
- pygpt_net/core/agents/custom/router.py +45 -6
- pygpt_net/core/agents/custom/runner.py +2 -1
- pygpt_net/core/agents/custom/schema.py +3 -1
- pygpt_net/core/agents/custom/utils.py +13 -1
- pygpt_net/core/db/viewer.py +11 -5
- pygpt_net/core/node_editor/graph.py +18 -9
- pygpt_net/core/node_editor/models.py +9 -2
- pygpt_net/core/node_editor/types.py +3 -1
- pygpt_net/core/presets/presets.py +216 -29
- pygpt_net/core/render/markdown/parser.py +0 -2
- pygpt_net/data/config/config.json +5 -6
- pygpt_net/data/config/models.json +3 -3
- pygpt_net/data/config/settings.json +2 -38
- pygpt_net/data/locale/locale.de.ini +64 -1
- pygpt_net/data/locale/locale.en.ini +62 -3
- pygpt_net/data/locale/locale.es.ini +64 -1
- pygpt_net/data/locale/locale.fr.ini +64 -1
- pygpt_net/data/locale/locale.it.ini +64 -1
- pygpt_net/data/locale/locale.pl.ini +65 -2
- pygpt_net/data/locale/locale.uk.ini +64 -1
- pygpt_net/data/locale/locale.zh.ini +64 -1
- pygpt_net/data/locale/plugin.cmd_system.en.ini +62 -66
- pygpt_net/provider/agents/llama_index/flow_from_schema.py +2 -2
- pygpt_net/provider/core/config/patch.py +10 -1
- pygpt_net/provider/core/config/patches/patch_before_2_6_42.py +0 -6
- pygpt_net/tools/agent_builder/tool.py +42 -26
- pygpt_net/tools/agent_builder/ui/dialogs.py +60 -11
- pygpt_net/ui/__init__.py +2 -4
- pygpt_net/ui/dialog/about.py +58 -38
- pygpt_net/ui/dialog/db.py +142 -3
- pygpt_net/ui/dialog/preset.py +47 -8
- pygpt_net/ui/layout/toolbox/presets.py +52 -16
- pygpt_net/ui/widget/dialog/db.py +0 -0
- pygpt_net/ui/widget/lists/preset.py +644 -60
- pygpt_net/ui/widget/node_editor/command.py +10 -10
- pygpt_net/ui/widget/node_editor/config.py +157 -0
- pygpt_net/ui/widget/node_editor/editor.py +183 -151
- pygpt_net/ui/widget/node_editor/item.py +12 -11
- pygpt_net/ui/widget/node_editor/node.py +267 -12
- pygpt_net/ui/widget/node_editor/view.py +180 -63
- pygpt_net/ui/widget/tabs/output.py +1 -1
- pygpt_net/ui/widget/textarea/input.py +2 -2
- pygpt_net/utils.py +114 -2
- {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.61.dist-info}/METADATA +11 -94
- {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.61.dist-info}/RECORD +59 -58
- {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.61.dist-info}/LICENSE +0 -0
- {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.61.dist-info}/WHEEL +0 -0
- {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.61.dist-info}/entry_points.txt +0 -0
|
@@ -6,17 +6,17 @@
|
|
|
6
6
|
# GitHub: https://github.com/szczyglis-dev/py-gpt #
|
|
7
7
|
# MIT License #
|
|
8
8
|
# Created By : Marcin Szczygliński #
|
|
9
|
-
# Updated Date: 2025.09.
|
|
9
|
+
# Updated Date: 2025.09.25 12:35:00 #
|
|
10
10
|
# ================================================== #
|
|
11
11
|
|
|
12
12
|
from __future__ import annotations
|
|
13
|
-
from typing import Dict, Optional, List, Tuple, Any, Union
|
|
13
|
+
from typing import Dict, Optional, List, Tuple, Any, Union, Callable
|
|
14
14
|
|
|
15
15
|
from PySide6.QtCore import Qt, QPoint, QPointF, QRectF, QSizeF, Property, QEvent
|
|
16
|
-
from PySide6.QtGui import
|
|
16
|
+
from PySide6.QtGui import QAction, QColor, QUndoStack, QPalette, QCursor, QKeySequence, QShortcut, QFont
|
|
17
17
|
from PySide6.QtWidgets import (
|
|
18
18
|
QWidget, QApplication, QGraphicsView, QMenu, QMessageBox, QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox,
|
|
19
|
-
QTextEdit, QPlainTextEdit
|
|
19
|
+
QTextEdit, QPlainTextEdit, QLabel
|
|
20
20
|
)
|
|
21
21
|
|
|
22
22
|
from pygpt_net.core.node_editor.graph import NodeGraph
|
|
@@ -28,8 +28,9 @@ from pygpt_net.utils import trans
|
|
|
28
28
|
from .command import AddNodeCommand, ClearGraphCommand, RewireConnectionCommand, ConnectCommand, DeleteNodeCommand, DeleteConnectionCommand
|
|
29
29
|
from .item import EdgeItem, PortItem
|
|
30
30
|
from .node import NodeItem
|
|
31
|
-
from .view import NodeGraphicsScene, NodeGraphicsView
|
|
31
|
+
from .view import NodeGraphicsScene, NodeGraphicsView, NodeViewOverlayControls, NodeViewStatusLabel
|
|
32
32
|
from .utils import _qt_is_valid
|
|
33
|
+
from .config import EditorConfig
|
|
33
34
|
|
|
34
35
|
|
|
35
36
|
# ------------------------ NodeEditor ------------------------
|
|
@@ -132,13 +133,28 @@ class NodeEditor(QWidget):
|
|
|
132
133
|
if _qt_is_valid(self.view):
|
|
133
134
|
self.view.viewport().update()
|
|
134
135
|
|
|
135
|
-
def __init__(self, parent: Optional[QWidget] = None, registry: Optional[NodeTypeRegistry] = None):
|
|
136
|
+
def __init__(self, parent: Optional[QWidget] = None, registry: Optional[NodeTypeRegistry] = None, config: Optional[EditorConfig] = None):
|
|
136
137
|
"""Initialize the editor, scene, view, graph and interaction state."""
|
|
137
138
|
super().__init__(parent)
|
|
138
139
|
self.setObjectName("NodeEditor")
|
|
139
140
|
self._debug = False
|
|
140
141
|
self._dbg("INIT NodeEditor")
|
|
141
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
|
+
|
|
142
158
|
self.graph = NodeGraph(registry)
|
|
143
159
|
self.scene = NodeGraphicsScene(self)
|
|
144
160
|
self.view = NodeGraphicsView(self.scene, self)
|
|
@@ -163,6 +179,14 @@ class NodeEditor(QWidget):
|
|
|
163
179
|
self._spawn_index: int = 0
|
|
164
180
|
self._saved_drag_mode: Optional[QGraphicsView.DragMode] = None
|
|
165
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
|
+
|
|
166
190
|
self.scene.sceneContextRequested.connect(self._on_scene_context_menu)
|
|
167
191
|
self.graph.nodeAdded.connect(self._on_graph_node_added)
|
|
168
192
|
self.graph.nodeRemoved.connect(self._on_graph_node_removed)
|
|
@@ -197,6 +221,17 @@ class NodeEditor(QWidget):
|
|
|
197
221
|
self._base_id_max: Dict[str, Dict[str, int]] = {}
|
|
198
222
|
self._reset_base_id_counters()
|
|
199
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
|
+
|
|
200
235
|
# ---------- Debug helper ----------
|
|
201
236
|
def _dbg(self, msg: str):
|
|
202
237
|
"""Conditional debug logger (enabled by self._debug)."""
|
|
@@ -225,9 +260,8 @@ class NodeEditor(QWidget):
|
|
|
225
260
|
"""Update internal flag when focus moves to/from text widgets."""
|
|
226
261
|
self._text_input_active = self._is_text_entry_widget(now)
|
|
227
262
|
|
|
228
|
-
# Backward-compatible helper used by other parts
|
|
229
263
|
def _is_text_input_focused(self) -> bool:
|
|
230
|
-
"""Return True if
|
|
264
|
+
"""Return True if a text-like input currently has focus."""
|
|
231
265
|
return bool(self._text_input_active)
|
|
232
266
|
|
|
233
267
|
# ---------- QWidget overrides ----------
|
|
@@ -246,65 +280,44 @@ class NodeEditor(QWidget):
|
|
|
246
280
|
def closeEvent(self, e):
|
|
247
281
|
"""Perform a thorough cleanup to prevent dangling Qt wrappers during shutdown."""
|
|
248
282
|
self._dbg("closeEvent -> full cleanup")
|
|
249
|
-
|
|
250
|
-
# Make the editor effectively inert for any in-flight events
|
|
251
283
|
self._closing = True
|
|
252
284
|
self._alive = False
|
|
253
285
|
self._ready_for_theme = False
|
|
254
|
-
|
|
255
|
-
# Stop listening to global focus changes
|
|
256
286
|
try:
|
|
257
287
|
app = QApplication.instance()
|
|
258
288
|
if app:
|
|
259
289
|
app.focusChanged.disconnect(self._on_focus_changed)
|
|
260
290
|
except Exception:
|
|
261
291
|
pass
|
|
262
|
-
|
|
263
|
-
# Disconnect scene signals and filters
|
|
264
292
|
try:
|
|
265
293
|
if _qt_is_valid(self.scene):
|
|
266
294
|
self.scene.sceneContextRequested.disconnect(self._on_scene_context_menu)
|
|
267
295
|
except Exception:
|
|
268
296
|
pass
|
|
269
|
-
|
|
270
|
-
# Cancel any interactive wire state before clearing items
|
|
271
297
|
self._reset_interaction_states(remove_hidden_edges=True)
|
|
272
|
-
|
|
273
298
|
try:
|
|
274
299
|
if _qt_is_valid(self.scene):
|
|
275
300
|
self.scene.removeEventFilter(self)
|
|
276
301
|
except Exception:
|
|
277
302
|
pass
|
|
278
|
-
|
|
279
|
-
# Disconnect graph signals early to prevent callbacks during teardown
|
|
280
303
|
self._disconnect_graph_signals()
|
|
281
|
-
|
|
282
|
-
# Clear undo stack to drop references to scene items and widgets
|
|
283
304
|
try:
|
|
284
305
|
if self._undo is not None:
|
|
285
306
|
self._undo.clear()
|
|
286
307
|
except Exception:
|
|
287
308
|
pass
|
|
288
|
-
|
|
289
|
-
# Proxies/widgets first (prevents QWidget-in-scene dangling)
|
|
290
309
|
self._cleanup_node_proxies()
|
|
291
|
-
|
|
292
|
-
# Remove edge items, then all remaining scene items
|
|
293
310
|
try:
|
|
294
311
|
self._remove_all_edge_items_from_scene()
|
|
295
312
|
if _qt_is_valid(self.scene):
|
|
296
313
|
self.scene.clear()
|
|
297
314
|
except Exception:
|
|
298
315
|
pass
|
|
299
|
-
|
|
300
|
-
# Detach view from scene to break mutual references
|
|
301
316
|
try:
|
|
302
317
|
if _qt_is_valid(self.view):
|
|
303
318
|
self.view.setScene(None)
|
|
304
319
|
except Exception:
|
|
305
320
|
pass
|
|
306
|
-
|
|
307
|
-
# Drop internal maps to help GC
|
|
308
321
|
try:
|
|
309
322
|
self._uuid_to_item.clear()
|
|
310
323
|
self._conn_uuid_to_edge.clear()
|
|
@@ -314,8 +327,6 @@ class NodeEditor(QWidget):
|
|
|
314
327
|
self._interactive_src_port = None
|
|
315
328
|
self._hover_candidate = None
|
|
316
329
|
self._pending_node_positions.clear()
|
|
317
|
-
|
|
318
|
-
# Optionally schedule deletion of scene/view
|
|
319
330
|
try:
|
|
320
331
|
if _qt_is_valid(self.scene):
|
|
321
332
|
self.scene.deleteLater()
|
|
@@ -326,11 +337,8 @@ class NodeEditor(QWidget):
|
|
|
326
337
|
self.view.deleteLater()
|
|
327
338
|
except Exception:
|
|
328
339
|
pass
|
|
329
|
-
|
|
330
340
|
self.scene = None
|
|
331
341
|
self.view = None
|
|
332
|
-
|
|
333
|
-
# Force-flush all deferred deletions to avoid old wrappers lingering into the next open
|
|
334
342
|
try:
|
|
335
343
|
from PySide6.QtWidgets import QApplication
|
|
336
344
|
from PySide6.QtCore import QEvent
|
|
@@ -339,7 +347,6 @@ class NodeEditor(QWidget):
|
|
|
339
347
|
QApplication.processEvents()
|
|
340
348
|
except Exception:
|
|
341
349
|
pass
|
|
342
|
-
|
|
343
350
|
super().closeEvent(e)
|
|
344
351
|
|
|
345
352
|
def _disconnect_graph_signals(self):
|
|
@@ -357,22 +364,26 @@ class NodeEditor(QWidget):
|
|
|
357
364
|
pass
|
|
358
365
|
|
|
359
366
|
def resizeEvent(self, e):
|
|
360
|
-
"""Keep the view sized to the editor widget."""
|
|
367
|
+
"""Keep the view sized to the editor widget and position overlays."""
|
|
361
368
|
if _qt_is_valid(self.view):
|
|
362
369
|
self.view.setGeometry(self.rect())
|
|
370
|
+
self._position_overlay_controls()
|
|
371
|
+
self._position_status_label()
|
|
363
372
|
super().resizeEvent(e)
|
|
364
373
|
|
|
365
374
|
def showEvent(self, e):
|
|
366
|
-
"""Cache the spawn origin
|
|
375
|
+
"""Cache the spawn origin, reapply stylesheets and position overlays."""
|
|
367
376
|
if self._spawn_origin is None and _qt_is_valid(self.view):
|
|
368
377
|
self._spawn_origin = self.view.mapToScene(self.view.viewport().rect().center())
|
|
369
378
|
self._reapply_stylesheets()
|
|
379
|
+
self._position_overlay_controls()
|
|
380
|
+
self._position_status_label()
|
|
381
|
+
self._update_status_label()
|
|
370
382
|
super().showEvent(e)
|
|
371
383
|
|
|
372
384
|
def event(self, e):
|
|
373
385
|
"""React to global style/palette changes and reset interactions on focus loss."""
|
|
374
386
|
et = e.type()
|
|
375
|
-
# Do not accept ShortcutOverride here; we rely on the editor-level QShortcut for Delete.
|
|
376
387
|
if et in (QEvent.StyleChange, QEvent.PaletteChange, QEvent.FontChange,
|
|
377
388
|
QEvent.ApplicationPaletteChange, QEvent.ApplicationFontChange):
|
|
378
389
|
self._reapply_stylesheets()
|
|
@@ -381,6 +392,32 @@ class NodeEditor(QWidget):
|
|
|
381
392
|
self._reset_interaction_states(remove_hidden_edges=False)
|
|
382
393
|
return super().event(e)
|
|
383
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
|
+
|
|
384
421
|
# ---------- Public API ----------
|
|
385
422
|
|
|
386
423
|
def add_node(self, type_name: str, scene_pos: QPointF):
|
|
@@ -388,11 +425,7 @@ class NodeEditor(QWidget):
|
|
|
388
425
|
self._undo.push(AddNodeCommand(self, type_name, scene_pos))
|
|
389
426
|
|
|
390
427
|
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
|
-
"""
|
|
428
|
+
"""Clear the entire editor (undoable), optionally asking the user for confirmation."""
|
|
396
429
|
if not self._alive or self.scene is None or self.view is None:
|
|
397
430
|
return False
|
|
398
431
|
if ask_user and self.on_clear and callable(self.on_clear):
|
|
@@ -416,20 +449,10 @@ class NodeEditor(QWidget):
|
|
|
416
449
|
self._undo.redo()
|
|
417
450
|
|
|
418
451
|
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
|
-
"""
|
|
452
|
+
"""Serialize the current layout into a compact, value-only dict."""
|
|
426
453
|
if not self._alive or self.scene is None or self.view is None:
|
|
427
454
|
return False
|
|
428
|
-
|
|
429
|
-
# Build compact graph payload (nodes + connections without specs)
|
|
430
455
|
compact = self._serialize_layout_compact()
|
|
431
|
-
|
|
432
|
-
# Positions and sizes come from live scene items
|
|
433
456
|
compact["positions"] = {
|
|
434
457
|
nuuid: [self._uuid_to_item[nuuid].pos().x(), self._uuid_to_item[nuuid].pos().y()]
|
|
435
458
|
for nuuid in self._uuid_to_item
|
|
@@ -439,77 +462,40 @@ class NodeEditor(QWidget):
|
|
|
439
462
|
float(self._uuid_to_item[nuuid].size().height())]
|
|
440
463
|
for nuuid in self._uuid_to_item
|
|
441
464
|
}
|
|
442
|
-
|
|
443
|
-
# Persist the current view (zoom and scrollbars)
|
|
444
465
|
try:
|
|
445
466
|
compact["view"] = self._save_view_state()
|
|
446
467
|
except Exception:
|
|
447
468
|
compact["view"] = {}
|
|
448
|
-
|
|
449
469
|
return compact
|
|
450
470
|
|
|
451
471
|
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
|
-
"""
|
|
472
|
+
"""Load layout using the live registry for node specs (value-only merge)."""
|
|
468
473
|
if not self._alive or self.scene is None or self.view is None:
|
|
469
474
|
return False
|
|
470
|
-
|
|
471
|
-
# Guard: mark layout import phase to avoid auto-renumbering of Base ID on nodeAdded.
|
|
472
475
|
self._is_loading_layout = True
|
|
473
476
|
try:
|
|
474
477
|
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
478
|
self._reset_interaction_states(remove_hidden_edges=True)
|
|
477
479
|
self._clear_scene_only(hard=True)
|
|
478
480
|
try:
|
|
479
481
|
self.graph.clear(silent=True)
|
|
480
482
|
except Exception:
|
|
481
|
-
# Be defensive in case .clear is not available or raises
|
|
482
483
|
self.graph.nodes = {}
|
|
483
484
|
self.graph.connections = {}
|
|
484
485
|
|
|
485
|
-
# Also reset per-layout Base ID counters; they will be rebuilt from the loaded data.
|
|
486
486
|
self._reset_base_id_counters()
|
|
487
|
-
|
|
488
|
-
# Extract normalized structures from possibly diverse layout shapes.
|
|
489
487
|
nodes_norm, conns_norm, positions, sizes = self._normalize_layout_dict(data)
|
|
490
488
|
|
|
491
|
-
# 1) Create nodes from registry spec, preserve UUID, set values that match current spec.
|
|
492
489
|
created: Dict[str, NodeModel] = {}
|
|
493
490
|
for nd in nodes_norm:
|
|
494
491
|
tname = nd.get("type")
|
|
495
492
|
nuuid = nd.get("uuid")
|
|
496
493
|
if not tname or not nuuid:
|
|
497
|
-
self._dbg(f"load_layout: skip node without type/uuid: {nd}")
|
|
498
494
|
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
495
|
try:
|
|
507
496
|
node = self.graph.create_node_from_type(tname)
|
|
508
|
-
except Exception
|
|
509
|
-
self._dbg(f"load_layout: create_node_from_type failed for '{tname}': {ex}")
|
|
497
|
+
except Exception:
|
|
510
498
|
continue
|
|
511
|
-
|
|
512
|
-
# Preserve UUID and optional name from layout (if present).
|
|
513
499
|
try:
|
|
514
500
|
node.uuid = nuuid
|
|
515
501
|
except Exception:
|
|
@@ -520,16 +506,12 @@ class NodeEditor(QWidget):
|
|
|
520
506
|
node.name = name
|
|
521
507
|
except Exception:
|
|
522
508
|
pass
|
|
523
|
-
|
|
524
|
-
# Preserve friendly node.id from layout if present (keeps per-layout numbering).
|
|
525
509
|
try:
|
|
526
510
|
fid = nd.get("id")
|
|
527
511
|
if isinstance(fid, str) and fid.strip():
|
|
528
512
|
node.id = fid.strip()
|
|
529
513
|
except Exception:
|
|
530
514
|
pass
|
|
531
|
-
|
|
532
|
-
# Apply property values only for properties that exist in the current spec.
|
|
533
515
|
values_map: Dict[str, Any] = nd.get("values", {})
|
|
534
516
|
for pid, pm in list(node.properties.items()):
|
|
535
517
|
if pid in values_map:
|
|
@@ -538,11 +520,6 @@ class NodeEditor(QWidget):
|
|
|
538
520
|
pm.value = val
|
|
539
521
|
except Exception:
|
|
540
522
|
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
523
|
pos_xy = positions.get(nuuid)
|
|
547
524
|
if isinstance(pos_xy, (list, tuple)) and len(pos_xy) == 2:
|
|
548
525
|
try:
|
|
@@ -552,7 +529,6 @@ class NodeEditor(QWidget):
|
|
|
552
529
|
self.graph.add_node(node)
|
|
553
530
|
created[nuuid] = node
|
|
554
531
|
|
|
555
|
-
# 2) Apply sizes after items exist.
|
|
556
532
|
for nuuid, wh in sizes.items():
|
|
557
533
|
item = self._uuid_to_item.get(nuuid)
|
|
558
534
|
if item and isinstance(wh, (list, tuple)) and len(wh) == 2:
|
|
@@ -562,53 +538,36 @@ class NodeEditor(QWidget):
|
|
|
562
538
|
except Exception:
|
|
563
539
|
pass
|
|
564
540
|
|
|
565
|
-
# 3) Recreate connections if possible.
|
|
566
541
|
for cd in conns_norm:
|
|
567
542
|
cuuid = cd.get("uuid")
|
|
568
543
|
s_n = cd.get("src_node")
|
|
569
544
|
s_p = cd.get("src_prop")
|
|
570
545
|
d_n = cd.get("dst_node")
|
|
571
546
|
d_p = cd.get("dst_prop")
|
|
572
|
-
|
|
573
547
|
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
548
|
continue
|
|
576
|
-
|
|
577
|
-
# Validate ports exist in current spec.
|
|
578
549
|
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
550
|
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))
|
|
551
|
+
ok, _ = self.graph.can_connect((s_n, s_p), (d_n, d_p))
|
|
584
552
|
if not ok:
|
|
585
|
-
self._dbg(f"load_layout: cannot connect ({s_n}.{s_p}) -> ({d_n}.{d_p}): {reason}")
|
|
586
553
|
continue
|
|
587
|
-
|
|
588
|
-
# Try to preserve original connection UUID if available.
|
|
589
554
|
conn_model = ConnectionModel(
|
|
590
555
|
uuid=cuuid if isinstance(cuuid, str) and cuuid else None,
|
|
591
556
|
src_node=s_n, src_prop=s_p,
|
|
592
557
|
dst_node=d_n, dst_prop=d_p
|
|
593
558
|
)
|
|
594
|
-
ok_add,
|
|
559
|
+
ok_add, _ = self.graph.add_connection(conn_model)
|
|
595
560
|
if not ok_add:
|
|
596
|
-
|
|
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}")
|
|
561
|
+
self.graph.connect((s_n, s_p), (d_n, d_p))
|
|
600
562
|
|
|
601
|
-
# 4) Sync ports after all nodes/connections are in place.
|
|
602
563
|
for item in self._uuid_to_item.values():
|
|
603
564
|
if _qt_is_valid(item):
|
|
604
565
|
item.update_ports_positions()
|
|
605
566
|
|
|
606
|
-
# Rebuild Base ID counters based on the just-loaded layout.
|
|
607
567
|
self._rebuild_base_id_counters()
|
|
608
|
-
|
|
609
568
|
self._reapply_stylesheets()
|
|
569
|
+
self._update_status_label()
|
|
610
570
|
|
|
611
|
-
# Restore view state if present; otherwise center on content (backward compatible).
|
|
612
571
|
vs = self._extract_view_state(data)
|
|
613
572
|
if vs:
|
|
614
573
|
self._apply_view_state(vs)
|
|
@@ -616,7 +575,6 @@ class NodeEditor(QWidget):
|
|
|
616
575
|
self.center_on_content()
|
|
617
576
|
return True
|
|
618
577
|
finally:
|
|
619
|
-
# Always clear the loading flag even if exceptions happen during import.
|
|
620
578
|
self._is_loading_layout = False
|
|
621
579
|
|
|
622
580
|
def export_schema(self, as_list: bool = False) -> Union[dict, List[dict]]:
|
|
@@ -649,17 +607,13 @@ class NodeEditor(QWidget):
|
|
|
649
607
|
"""Create a NodeItem when a NodeModel is added to the graph."""
|
|
650
608
|
if node.uuid in self._uuid_to_item:
|
|
651
609
|
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
610
|
pos = self._pending_node_positions.pop(node.uuid, None)
|
|
656
611
|
if pos is None:
|
|
657
612
|
pos = self._next_spawn_pos()
|
|
658
613
|
self._dbg(f"graph.nodeAdded -> add item for node={node.name}({node.uuid}) at {pos}")
|
|
659
614
|
self._add_node_item(node, pos)
|
|
660
|
-
|
|
661
|
-
# Keep Base ID counters in sync with newly added nodes.
|
|
662
615
|
self._update_base_id_counters_from_node(node)
|
|
616
|
+
self._update_status_label()
|
|
663
617
|
|
|
664
618
|
def _on_graph_node_removed(self, node_uuid: str):
|
|
665
619
|
"""Remove the NodeItem when model is removed from the graph."""
|
|
@@ -674,9 +628,8 @@ class NodeEditor(QWidget):
|
|
|
674
628
|
self.scene.removeItem(item)
|
|
675
629
|
except Exception:
|
|
676
630
|
pass
|
|
677
|
-
|
|
678
|
-
# Rebuild Base ID counters after removal to reflect current layout state.
|
|
679
631
|
self._rebuild_base_id_counters()
|
|
632
|
+
self._update_status_label()
|
|
680
633
|
|
|
681
634
|
def _on_graph_connection_added(self, conn: ConnectionModel):
|
|
682
635
|
"""Create an EdgeItem when a ConnectionModel appears in the graph."""
|
|
@@ -705,36 +658,40 @@ class NodeEditor(QWidget):
|
|
|
705
658
|
self._dbg("graph.cleared -> clear scene only")
|
|
706
659
|
self._reset_interaction_states(remove_hidden_edges=True)
|
|
707
660
|
self._clear_scene_only(hard=True)
|
|
708
|
-
# Reset Base ID counters on clear so next layout starts fresh.
|
|
709
661
|
self._reset_base_id_counters()
|
|
662
|
+
self._update_status_label()
|
|
710
663
|
|
|
711
664
|
# ---------- Scene helpers ----------
|
|
712
665
|
|
|
713
666
|
def _scene_to_global(self, scene_pos: QPointF) -> QPoint:
|
|
714
667
|
"""Convert scene coordinates to a global screen QPoint."""
|
|
715
|
-
from PySide6.QtCore import QPoint as _QPoint
|
|
716
668
|
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
669
|
return self.view.viewport().mapToGlobal(vp_pt)
|
|
720
670
|
|
|
721
671
|
def _on_scene_context_menu(self, scene_pos: QPointF):
|
|
722
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
|
+
|
|
723
680
|
menu = QMenu(self.window())
|
|
724
681
|
ss = self.window().styleSheet()
|
|
725
682
|
if ss:
|
|
726
683
|
menu.setStyleSheet(ss)
|
|
727
684
|
|
|
728
|
-
add_menu = menu.addMenu(
|
|
685
|
+
add_menu = menu.addMenu(self.config.menu_add())
|
|
729
686
|
action_by_type: Dict[QAction, str] = {}
|
|
730
687
|
for tname in self.graph.registry.types():
|
|
731
688
|
act = add_menu.addAction(tname)
|
|
732
689
|
action_by_type[act] = tname
|
|
733
690
|
|
|
734
691
|
menu.addSeparator()
|
|
735
|
-
act_undo = QAction(
|
|
736
|
-
act_redo = QAction(
|
|
737
|
-
act_clear = QAction(
|
|
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)
|
|
738
695
|
act_undo.setEnabled(self._undo.canUndo())
|
|
739
696
|
act_redo.setEnabled(self._undo.canRedo())
|
|
740
697
|
menu.addAction(act_undo)
|
|
@@ -756,6 +713,30 @@ class NodeEditor(QWidget):
|
|
|
756
713
|
type_name = action_by_type[chosen]
|
|
757
714
|
self._undo.push(AddNodeCommand(self, type_name, scene_pos))
|
|
758
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
|
+
|
|
759
740
|
# ---------- Add/remove nodes/edges ----------
|
|
760
741
|
|
|
761
742
|
def _add_node_model(self, node: NodeModel, scene_pos: QPointF):
|
|
@@ -767,12 +748,16 @@ class NodeEditor(QWidget):
|
|
|
767
748
|
"""Create and place a NodeItem for the given NodeModel."""
|
|
768
749
|
item = NodeItem(self, node)
|
|
769
750
|
self.scene.addItem(item)
|
|
770
|
-
|
|
771
|
-
item.setPos(
|
|
751
|
+
pos = scene_pos if not bool(self.enable_collisions) else self._find_free_position(scene_pos, item.size())
|
|
752
|
+
item.setPos(pos)
|
|
772
753
|
item.update_ports_positions()
|
|
773
|
-
|
|
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)
|
|
774
760
|
item.mark_ready_for_scene_ops(True)
|
|
775
|
-
|
|
776
761
|
self._uuid_to_item[node.uuid] = item
|
|
777
762
|
self._apply_styles_to_content(item._content)
|
|
778
763
|
|
|
@@ -788,10 +773,8 @@ class NodeEditor(QWidget):
|
|
|
788
773
|
if r.intersects(other):
|
|
789
774
|
return True
|
|
790
775
|
return False
|
|
791
|
-
|
|
792
776
|
if not collides(desired):
|
|
793
777
|
return desired
|
|
794
|
-
|
|
795
778
|
x = y = 0
|
|
796
779
|
dx, dy = 1, 0
|
|
797
780
|
segment_length = 1
|
|
@@ -834,7 +817,6 @@ class NodeEditor(QWidget):
|
|
|
834
817
|
if ex and _qt_is_valid(ex):
|
|
835
818
|
self._dbg(f"_add_edge_for_connection: guard skip duplicate for uuid={conn.uuid}")
|
|
836
819
|
return
|
|
837
|
-
|
|
838
820
|
src_item = self._uuid_to_item.get(conn.src_node)
|
|
839
821
|
dst_item = self._uuid_to_item.get(conn.dst_node)
|
|
840
822
|
if not src_item or not dst_item:
|
|
@@ -903,33 +885,55 @@ class NodeEditor(QWidget):
|
|
|
903
885
|
return wnd.palette()
|
|
904
886
|
return QApplication.instance().palette() if QApplication.instance() else self.palette()
|
|
905
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
|
+
|
|
906
898
|
def _apply_styles_to_content(self, content_widget: QWidget):
|
|
907
|
-
"""Propagate palette and stylesheet to the embedded content widget subtree."""
|
|
899
|
+
"""Propagate palette, font and stylesheet to the embedded content widget subtree."""
|
|
908
900
|
if content_widget is None:
|
|
909
901
|
return
|
|
910
902
|
content_widget.setAttribute(Qt.WA_StyledBackground, True)
|
|
911
903
|
stylesheet = self._current_stylesheet()
|
|
912
904
|
pal = self._current_palette()
|
|
905
|
+
font = self._current_font()
|
|
913
906
|
content_widget.setPalette(pal)
|
|
907
|
+
content_widget.setFont(font)
|
|
914
908
|
if stylesheet:
|
|
915
909
|
content_widget.setStyleSheet(stylesheet)
|
|
916
910
|
content_widget.ensurePolished()
|
|
917
911
|
for w in content_widget.findChildren(QWidget):
|
|
912
|
+
w.setPalette(pal)
|
|
913
|
+
w.setFont(font)
|
|
914
|
+
if stylesheet:
|
|
915
|
+
w.setStyleSheet(stylesheet)
|
|
918
916
|
w.ensurePolished()
|
|
919
917
|
|
|
920
918
|
def _reapply_stylesheets(self):
|
|
921
|
-
"""Reapply palette and stylesheet to all NodeContentWidget instances."""
|
|
919
|
+
"""Reapply palette, font and stylesheet to all NodeContentWidget instances."""
|
|
922
920
|
if not getattr(self, "_alive", True) or getattr(self, "_closing", False):
|
|
923
921
|
return
|
|
924
922
|
stylesheet = self._current_stylesheet()
|
|
925
923
|
pal = self._current_palette()
|
|
924
|
+
font = self._current_font()
|
|
926
925
|
for item in self._uuid_to_item.values():
|
|
927
926
|
if item._content and _qt_is_valid(item._content):
|
|
928
927
|
item._content.setPalette(pal)
|
|
928
|
+
item._content.setFont(font)
|
|
929
929
|
if stylesheet:
|
|
930
930
|
item._content.setStyleSheet(stylesheet)
|
|
931
931
|
item._content.ensurePolished()
|
|
932
932
|
for w in item._content.findChildren(QWidget):
|
|
933
|
+
w.setPalette(pal)
|
|
934
|
+
w.setFont(font)
|
|
935
|
+
if stylesheet:
|
|
936
|
+
w.setStyleSheet(stylesheet)
|
|
933
937
|
w.ensurePolished()
|
|
934
938
|
|
|
935
939
|
# ---------- Edge/Port helpers + rewire ----------
|
|
@@ -1349,7 +1353,7 @@ class NodeEditor(QWidget):
|
|
|
1349
1353
|
return
|
|
1350
1354
|
|
|
1351
1355
|
self._dbg(f"Delete shortcut -> nodes={len(nodes)}, edges={len(edges)} (undoable)")
|
|
1352
|
-
self._undo.beginMacro(
|
|
1356
|
+
self._undo.beginMacro(self.config.macro_delete_selection())
|
|
1353
1357
|
try:
|
|
1354
1358
|
for n in nodes:
|
|
1355
1359
|
# Push per-node undoable deletion (restores its own connections)
|
|
@@ -2035,4 +2039,32 @@ class NodeEditor(QWidget):
|
|
|
2035
2039
|
"dst_prop": c.dst_prop,
|
|
2036
2040
|
}
|
|
2037
2041
|
|
|
2038
|
-
return {"nodes": nodes_out, "connections": conns_out}
|
|
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
|