pygpt-net 2.6.60__py3-none-any.whl → 2.6.62__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 +14 -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/chat/response.py +8 -2
- pygpt_net/controller/presets/presets.py +121 -6
- pygpt_net/controller/settings/editor.py +0 -15
- pygpt_net/controller/settings/profile.py +16 -4
- pygpt_net/controller/settings/workdir.py +30 -5
- pygpt_net/controller/theme/common.py +4 -2
- pygpt_net/controller/theme/markdown.py +4 -7
- pygpt_net/controller/theme/theme.py +2 -1
- pygpt_net/controller/ui/ui.py +32 -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 +52 -4
- 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 +11 -5
- pygpt_net/core/agents/custom/schema.py +3 -1
- pygpt_net/core/agents/custom/utils.py +13 -1
- pygpt_net/core/agents/runners/llama_workflow.py +65 -5
- pygpt_net/core/agents/runners/openai_workflow.py +2 -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 +15 -1
- pygpt_net/core/presets/presets.py +216 -29
- pygpt_net/core/render/markdown/parser.py +0 -2
- pygpt_net/core/render/web/renderer.py +76 -11
- 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/css/style.dark.css +18 -0
- pygpt_net/data/css/style.light.css +20 -1
- pygpt_net/data/locale/locale.de.ini +66 -1
- pygpt_net/data/locale/locale.en.ini +64 -3
- pygpt_net/data/locale/locale.es.ini +66 -1
- pygpt_net/data/locale/locale.fr.ini +66 -1
- pygpt_net/data/locale/locale.it.ini +66 -1
- pygpt_net/data/locale/locale.pl.ini +67 -2
- pygpt_net/data/locale/locale.uk.ini +66 -1
- pygpt_net/data/locale/locale.zh.ini +66 -1
- pygpt_net/data/locale/plugin.cmd_system.en.ini +62 -66
- pygpt_net/item/ctx.py +23 -1
- pygpt_net/provider/agents/llama_index/flow_from_schema.py +2 -2
- pygpt_net/provider/agents/llama_index/workflow/codeact.py +9 -6
- pygpt_net/provider/agents/llama_index/workflow/openai.py +38 -11
- pygpt_net/provider/agents/llama_index/workflow/planner.py +36 -16
- pygpt_net/provider/agents/llama_index/workflow/supervisor.py +60 -10
- pygpt_net/provider/agents/openai/agent.py +3 -1
- pygpt_net/provider/agents/openai/agent_b2b.py +13 -9
- pygpt_net/provider/agents/openai/agent_planner.py +6 -2
- pygpt_net/provider/agents/openai/agent_with_experts.py +4 -1
- pygpt_net/provider/agents/openai/agent_with_experts_feedback.py +4 -2
- pygpt_net/provider/agents/openai/agent_with_feedback.py +4 -2
- pygpt_net/provider/agents/openai/evolve.py +6 -2
- pygpt_net/provider/agents/openai/supervisor.py +3 -1
- pygpt_net/provider/api/openai/agents/response.py +1 -0
- pygpt_net/provider/core/config/patch.py +18 -1
- pygpt_net/provider/core/config/patches/patch_before_2_6_42.py +0 -6
- pygpt_net/tools/agent_builder/tool.py +48 -26
- pygpt_net/tools/agent_builder/ui/dialogs.py +36 -28
- 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 +64 -16
- pygpt_net/ui/main.py +2 -2
- pygpt_net/ui/widget/dialog/confirm.py +27 -3
- pygpt_net/ui/widget/dialog/db.py +0 -0
- pygpt_net/ui/widget/draw/painter.py +90 -1
- pygpt_net/ui/widget/lists/preset.py +908 -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 +223 -153
- pygpt_net/ui/widget/node_editor/item.py +12 -11
- pygpt_net/ui/widget/node_editor/node.py +246 -13
- pygpt_net/ui/widget/node_editor/view.py +179 -63
- pygpt_net/ui/widget/tabs/output.py +1 -1
- pygpt_net/ui/widget/textarea/input.py +157 -23
- pygpt_net/utils.py +114 -2
- {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.62.dist-info}/METADATA +26 -100
- {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.62.dist-info}/RECORD +86 -85
- {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.62.dist-info}/LICENSE +0 -0
- {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.62.dist-info}/WHEEL +0 -0
- {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.62.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.26 12:00: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,16 @@ 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
|
+
|
|
142
146
|
self.graph = NodeGraph(registry)
|
|
143
147
|
self.scene = NodeGraphicsScene(self)
|
|
144
148
|
self.view = NodeGraphicsView(self.scene, self)
|
|
@@ -163,6 +167,14 @@ class NodeEditor(QWidget):
|
|
|
163
167
|
self._spawn_index: int = 0
|
|
164
168
|
self._saved_drag_mode: Optional[QGraphicsView.DragMode] = None
|
|
165
169
|
|
|
170
|
+
# Collisions on/off flag
|
|
171
|
+
self.enable_collisions: bool = False
|
|
172
|
+
# Top Z counter for new nodes when collisions are disabled (keeps newest on top)
|
|
173
|
+
self._z_top: float = 2.0
|
|
174
|
+
|
|
175
|
+
# External guard for scene editing/menu (callable bool); when None -> allowed
|
|
176
|
+
self.editing_allowed: Optional[Callable[[], bool]] = None
|
|
177
|
+
|
|
166
178
|
self.scene.sceneContextRequested.connect(self._on_scene_context_menu)
|
|
167
179
|
self.graph.nodeAdded.connect(self._on_graph_node_added)
|
|
168
180
|
self.graph.nodeRemoved.connect(self._on_graph_node_removed)
|
|
@@ -197,6 +209,17 @@ class NodeEditor(QWidget):
|
|
|
197
209
|
self._base_id_max: Dict[str, Dict[str, int]] = {}
|
|
198
210
|
self._reset_base_id_counters()
|
|
199
211
|
|
|
212
|
+
# Top-right overlay controls (Grab, Zoom Out, Zoom In)
|
|
213
|
+
self._controls = NodeViewOverlayControls(self)
|
|
214
|
+
self._controls.grabToggled.connect(self._on_grab_toggled)
|
|
215
|
+
self._controls.zoomInClicked.connect(self.zoom_in)
|
|
216
|
+
self._controls.zoomOutClicked.connect(self.zoom_out)
|
|
217
|
+
self._controls.raise_()
|
|
218
|
+
|
|
219
|
+
# Bottom-left fixed status label
|
|
220
|
+
self._status = NodeViewStatusLabel(self)
|
|
221
|
+
self._status.raise_()
|
|
222
|
+
|
|
200
223
|
# ---------- Debug helper ----------
|
|
201
224
|
def _dbg(self, msg: str):
|
|
202
225
|
"""Conditional debug logger (enabled by self._debug)."""
|
|
@@ -225,9 +248,8 @@ class NodeEditor(QWidget):
|
|
|
225
248
|
"""Update internal flag when focus moves to/from text widgets."""
|
|
226
249
|
self._text_input_active = self._is_text_entry_widget(now)
|
|
227
250
|
|
|
228
|
-
# Backward-compatible helper used by other parts
|
|
229
251
|
def _is_text_input_focused(self) -> bool:
|
|
230
|
-
"""Return True if
|
|
252
|
+
"""Return True if a text-like input currently has focus."""
|
|
231
253
|
return bool(self._text_input_active)
|
|
232
254
|
|
|
233
255
|
# ---------- QWidget overrides ----------
|
|
@@ -246,65 +268,44 @@ class NodeEditor(QWidget):
|
|
|
246
268
|
def closeEvent(self, e):
|
|
247
269
|
"""Perform a thorough cleanup to prevent dangling Qt wrappers during shutdown."""
|
|
248
270
|
self._dbg("closeEvent -> full cleanup")
|
|
249
|
-
|
|
250
|
-
# Make the editor effectively inert for any in-flight events
|
|
251
271
|
self._closing = True
|
|
252
272
|
self._alive = False
|
|
253
273
|
self._ready_for_theme = False
|
|
254
|
-
|
|
255
|
-
# Stop listening to global focus changes
|
|
256
274
|
try:
|
|
257
275
|
app = QApplication.instance()
|
|
258
276
|
if app:
|
|
259
277
|
app.focusChanged.disconnect(self._on_focus_changed)
|
|
260
278
|
except Exception:
|
|
261
279
|
pass
|
|
262
|
-
|
|
263
|
-
# Disconnect scene signals and filters
|
|
264
280
|
try:
|
|
265
281
|
if _qt_is_valid(self.scene):
|
|
266
282
|
self.scene.sceneContextRequested.disconnect(self._on_scene_context_menu)
|
|
267
283
|
except Exception:
|
|
268
284
|
pass
|
|
269
|
-
|
|
270
|
-
# Cancel any interactive wire state before clearing items
|
|
271
285
|
self._reset_interaction_states(remove_hidden_edges=True)
|
|
272
|
-
|
|
273
286
|
try:
|
|
274
287
|
if _qt_is_valid(self.scene):
|
|
275
288
|
self.scene.removeEventFilter(self)
|
|
276
289
|
except Exception:
|
|
277
290
|
pass
|
|
278
|
-
|
|
279
|
-
# Disconnect graph signals early to prevent callbacks during teardown
|
|
280
291
|
self._disconnect_graph_signals()
|
|
281
|
-
|
|
282
|
-
# Clear undo stack to drop references to scene items and widgets
|
|
283
292
|
try:
|
|
284
293
|
if self._undo is not None:
|
|
285
294
|
self._undo.clear()
|
|
286
295
|
except Exception:
|
|
287
296
|
pass
|
|
288
|
-
|
|
289
|
-
# Proxies/widgets first (prevents QWidget-in-scene dangling)
|
|
290
297
|
self._cleanup_node_proxies()
|
|
291
|
-
|
|
292
|
-
# Remove edge items, then all remaining scene items
|
|
293
298
|
try:
|
|
294
299
|
self._remove_all_edge_items_from_scene()
|
|
295
300
|
if _qt_is_valid(self.scene):
|
|
296
301
|
self.scene.clear()
|
|
297
302
|
except Exception:
|
|
298
303
|
pass
|
|
299
|
-
|
|
300
|
-
# Detach view from scene to break mutual references
|
|
301
304
|
try:
|
|
302
305
|
if _qt_is_valid(self.view):
|
|
303
306
|
self.view.setScene(None)
|
|
304
307
|
except Exception:
|
|
305
308
|
pass
|
|
306
|
-
|
|
307
|
-
# Drop internal maps to help GC
|
|
308
309
|
try:
|
|
309
310
|
self._uuid_to_item.clear()
|
|
310
311
|
self._conn_uuid_to_edge.clear()
|
|
@@ -314,8 +315,6 @@ class NodeEditor(QWidget):
|
|
|
314
315
|
self._interactive_src_port = None
|
|
315
316
|
self._hover_candidate = None
|
|
316
317
|
self._pending_node_positions.clear()
|
|
317
|
-
|
|
318
|
-
# Optionally schedule deletion of scene/view
|
|
319
318
|
try:
|
|
320
319
|
if _qt_is_valid(self.scene):
|
|
321
320
|
self.scene.deleteLater()
|
|
@@ -326,11 +325,8 @@ class NodeEditor(QWidget):
|
|
|
326
325
|
self.view.deleteLater()
|
|
327
326
|
except Exception:
|
|
328
327
|
pass
|
|
329
|
-
|
|
330
328
|
self.scene = None
|
|
331
329
|
self.view = None
|
|
332
|
-
|
|
333
|
-
# Force-flush all deferred deletions to avoid old wrappers lingering into the next open
|
|
334
330
|
try:
|
|
335
331
|
from PySide6.QtWidgets import QApplication
|
|
336
332
|
from PySide6.QtCore import QEvent
|
|
@@ -339,7 +335,6 @@ class NodeEditor(QWidget):
|
|
|
339
335
|
QApplication.processEvents()
|
|
340
336
|
except Exception:
|
|
341
337
|
pass
|
|
342
|
-
|
|
343
338
|
super().closeEvent(e)
|
|
344
339
|
|
|
345
340
|
def _disconnect_graph_signals(self):
|
|
@@ -357,22 +352,26 @@ class NodeEditor(QWidget):
|
|
|
357
352
|
pass
|
|
358
353
|
|
|
359
354
|
def resizeEvent(self, e):
|
|
360
|
-
"""Keep the view sized to the editor widget."""
|
|
355
|
+
"""Keep the view sized to the editor widget and position overlays."""
|
|
361
356
|
if _qt_is_valid(self.view):
|
|
362
357
|
self.view.setGeometry(self.rect())
|
|
358
|
+
self._position_overlay_controls()
|
|
359
|
+
self._position_status_label()
|
|
363
360
|
super().resizeEvent(e)
|
|
364
361
|
|
|
365
362
|
def showEvent(self, e):
|
|
366
|
-
"""Cache the spawn origin
|
|
363
|
+
"""Cache the spawn origin, reapply stylesheets and position overlays."""
|
|
367
364
|
if self._spawn_origin is None and _qt_is_valid(self.view):
|
|
368
365
|
self._spawn_origin = self.view.mapToScene(self.view.viewport().rect().center())
|
|
369
366
|
self._reapply_stylesheets()
|
|
367
|
+
self._position_overlay_controls()
|
|
368
|
+
self._position_status_label()
|
|
369
|
+
self._update_status_label()
|
|
370
370
|
super().showEvent(e)
|
|
371
371
|
|
|
372
372
|
def event(self, e):
|
|
373
373
|
"""React to global style/palette changes and reset interactions on focus loss."""
|
|
374
374
|
et = e.type()
|
|
375
|
-
# Do not accept ShortcutOverride here; we rely on the editor-level QShortcut for Delete.
|
|
376
375
|
if et in (QEvent.StyleChange, QEvent.PaletteChange, QEvent.FontChange,
|
|
377
376
|
QEvent.ApplicationPaletteChange, QEvent.ApplicationFontChange):
|
|
378
377
|
self._reapply_stylesheets()
|
|
@@ -381,18 +380,45 @@ class NodeEditor(QWidget):
|
|
|
381
380
|
self._reset_interaction_states(remove_hidden_edges=False)
|
|
382
381
|
return super().event(e)
|
|
383
382
|
|
|
383
|
+
def _position_overlay_controls(self):
|
|
384
|
+
"""Place the top-right overlay controls with 10px margin."""
|
|
385
|
+
try:
|
|
386
|
+
if _qt_is_valid(self._controls):
|
|
387
|
+
sz = self._controls.sizeHint()
|
|
388
|
+
x = max(10, self.width() - sz.width() - 10)
|
|
389
|
+
y = 10
|
|
390
|
+
self._controls.move(x, y)
|
|
391
|
+
self._controls.raise_()
|
|
392
|
+
self._controls.show()
|
|
393
|
+
except Exception:
|
|
394
|
+
pass
|
|
395
|
+
|
|
396
|
+
def _position_status_label(self):
|
|
397
|
+
"""Place bottom-left status label with 10px margin."""
|
|
398
|
+
try:
|
|
399
|
+
if _qt_is_valid(self._status):
|
|
400
|
+
s = self._status.sizeHint()
|
|
401
|
+
x = 10
|
|
402
|
+
y = max(10, self.height() - s.height() - 10)
|
|
403
|
+
self._status.move(x, y)
|
|
404
|
+
self._status.raise_()
|
|
405
|
+
self._status.show()
|
|
406
|
+
except Exception:
|
|
407
|
+
pass
|
|
408
|
+
|
|
384
409
|
# ---------- Public API ----------
|
|
385
410
|
|
|
386
411
|
def add_node(self, type_name: str, scene_pos: QPointF):
|
|
387
412
|
"""Add a new node of the given type at scene_pos (undoable)."""
|
|
413
|
+
# Enforce optional per-type limit configured in registry.
|
|
414
|
+
if not self._can_add_node_of_type(type_name):
|
|
415
|
+
self._dbg(f"add_node blocked: type='{type_name}' reached its max per-layout limit")
|
|
416
|
+
return False
|
|
388
417
|
self._undo.push(AddNodeCommand(self, type_name, scene_pos))
|
|
418
|
+
return True
|
|
389
419
|
|
|
390
420
|
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
|
-
"""
|
|
421
|
+
"""Clear the entire editor (undoable), optionally asking the user for confirmation."""
|
|
396
422
|
if not self._alive or self.scene is None or self.view is None:
|
|
397
423
|
return False
|
|
398
424
|
if ask_user and self.on_clear and callable(self.on_clear):
|
|
@@ -405,6 +431,7 @@ class NodeEditor(QWidget):
|
|
|
405
431
|
if reply != QMessageBox.Yes:
|
|
406
432
|
return False
|
|
407
433
|
self._undo.push(ClearGraphCommand(self))
|
|
434
|
+
self._update_status_label()
|
|
408
435
|
return True
|
|
409
436
|
|
|
410
437
|
def undo(self):
|
|
@@ -416,20 +443,10 @@ class NodeEditor(QWidget):
|
|
|
416
443
|
self._undo.redo()
|
|
417
444
|
|
|
418
445
|
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
|
-
"""
|
|
446
|
+
"""Serialize the current layout into a compact, value-only dict."""
|
|
426
447
|
if not self._alive or self.scene is None or self.view is None:
|
|
427
448
|
return False
|
|
428
|
-
|
|
429
|
-
# Build compact graph payload (nodes + connections without specs)
|
|
430
449
|
compact = self._serialize_layout_compact()
|
|
431
|
-
|
|
432
|
-
# Positions and sizes come from live scene items
|
|
433
450
|
compact["positions"] = {
|
|
434
451
|
nuuid: [self._uuid_to_item[nuuid].pos().x(), self._uuid_to_item[nuuid].pos().y()]
|
|
435
452
|
for nuuid in self._uuid_to_item
|
|
@@ -439,77 +456,40 @@ class NodeEditor(QWidget):
|
|
|
439
456
|
float(self._uuid_to_item[nuuid].size().height())]
|
|
440
457
|
for nuuid in self._uuid_to_item
|
|
441
458
|
}
|
|
442
|
-
|
|
443
|
-
# Persist the current view (zoom and scrollbars)
|
|
444
459
|
try:
|
|
445
460
|
compact["view"] = self._save_view_state()
|
|
446
461
|
except Exception:
|
|
447
462
|
compact["view"] = {}
|
|
448
|
-
|
|
449
463
|
return compact
|
|
450
464
|
|
|
451
465
|
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
|
-
"""
|
|
466
|
+
"""Load layout using the live registry for node specs (value-only merge)."""
|
|
468
467
|
if not self._alive or self.scene is None or self.view is None:
|
|
469
468
|
return False
|
|
470
|
-
|
|
471
|
-
# Guard: mark layout import phase to avoid auto-renumbering of Base ID on nodeAdded.
|
|
472
469
|
self._is_loading_layout = True
|
|
473
470
|
try:
|
|
474
471
|
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
472
|
self._reset_interaction_states(remove_hidden_edges=True)
|
|
477
473
|
self._clear_scene_only(hard=True)
|
|
478
474
|
try:
|
|
479
475
|
self.graph.clear(silent=True)
|
|
480
476
|
except Exception:
|
|
481
|
-
# Be defensive in case .clear is not available or raises
|
|
482
477
|
self.graph.nodes = {}
|
|
483
478
|
self.graph.connections = {}
|
|
484
479
|
|
|
485
|
-
# Also reset per-layout Base ID counters; they will be rebuilt from the loaded data.
|
|
486
480
|
self._reset_base_id_counters()
|
|
487
|
-
|
|
488
|
-
# Extract normalized structures from possibly diverse layout shapes.
|
|
489
481
|
nodes_norm, conns_norm, positions, sizes = self._normalize_layout_dict(data)
|
|
490
482
|
|
|
491
|
-
# 1) Create nodes from registry spec, preserve UUID, set values that match current spec.
|
|
492
483
|
created: Dict[str, NodeModel] = {}
|
|
493
484
|
for nd in nodes_norm:
|
|
494
485
|
tname = nd.get("type")
|
|
495
486
|
nuuid = nd.get("uuid")
|
|
496
487
|
if not tname or not nuuid:
|
|
497
|
-
self._dbg(f"load_layout: skip node without type/uuid: {nd}")
|
|
498
|
-
continue
|
|
499
|
-
|
|
500
|
-
# Ensure type exists in registry.
|
|
501
|
-
spec = self.graph.registry.get(tname) if self.graph and self.graph.registry else None
|
|
502
|
-
if spec is None:
|
|
503
|
-
self._dbg(f"load_layout: skip node '{nuuid}' with unknown type '{tname}' (not in registry)")
|
|
504
488
|
continue
|
|
505
|
-
|
|
506
489
|
try:
|
|
507
490
|
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}")
|
|
491
|
+
except Exception:
|
|
510
492
|
continue
|
|
511
|
-
|
|
512
|
-
# Preserve UUID and optional name from layout (if present).
|
|
513
493
|
try:
|
|
514
494
|
node.uuid = nuuid
|
|
515
495
|
except Exception:
|
|
@@ -520,16 +500,12 @@ class NodeEditor(QWidget):
|
|
|
520
500
|
node.name = name
|
|
521
501
|
except Exception:
|
|
522
502
|
pass
|
|
523
|
-
|
|
524
|
-
# Preserve friendly node.id from layout if present (keeps per-layout numbering).
|
|
525
503
|
try:
|
|
526
504
|
fid = nd.get("id")
|
|
527
505
|
if isinstance(fid, str) and fid.strip():
|
|
528
506
|
node.id = fid.strip()
|
|
529
507
|
except Exception:
|
|
530
508
|
pass
|
|
531
|
-
|
|
532
|
-
# Apply property values only for properties that exist in the current spec.
|
|
533
509
|
values_map: Dict[str, Any] = nd.get("values", {})
|
|
534
510
|
for pid, pm in list(node.properties.items()):
|
|
535
511
|
if pid in values_map:
|
|
@@ -538,11 +514,6 @@ class NodeEditor(QWidget):
|
|
|
538
514
|
pm.value = val
|
|
539
515
|
except Exception:
|
|
540
516
|
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
517
|
pos_xy = positions.get(nuuid)
|
|
547
518
|
if isinstance(pos_xy, (list, tuple)) and len(pos_xy) == 2:
|
|
548
519
|
try:
|
|
@@ -552,7 +523,6 @@ class NodeEditor(QWidget):
|
|
|
552
523
|
self.graph.add_node(node)
|
|
553
524
|
created[nuuid] = node
|
|
554
525
|
|
|
555
|
-
# 2) Apply sizes after items exist.
|
|
556
526
|
for nuuid, wh in sizes.items():
|
|
557
527
|
item = self._uuid_to_item.get(nuuid)
|
|
558
528
|
if item and isinstance(wh, (list, tuple)) and len(wh) == 2:
|
|
@@ -562,53 +532,36 @@ class NodeEditor(QWidget):
|
|
|
562
532
|
except Exception:
|
|
563
533
|
pass
|
|
564
534
|
|
|
565
|
-
# 3) Recreate connections if possible.
|
|
566
535
|
for cd in conns_norm:
|
|
567
536
|
cuuid = cd.get("uuid")
|
|
568
537
|
s_n = cd.get("src_node")
|
|
569
538
|
s_p = cd.get("src_prop")
|
|
570
539
|
d_n = cd.get("dst_node")
|
|
571
540
|
d_p = cd.get("dst_prop")
|
|
572
|
-
|
|
573
541
|
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
542
|
continue
|
|
576
|
-
|
|
577
|
-
# Validate ports exist in current spec.
|
|
578
543
|
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
544
|
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))
|
|
545
|
+
ok, _ = self.graph.can_connect((s_n, s_p), (d_n, d_p))
|
|
584
546
|
if not ok:
|
|
585
|
-
self._dbg(f"load_layout: cannot connect ({s_n}.{s_p}) -> ({d_n}.{d_p}): {reason}")
|
|
586
547
|
continue
|
|
587
|
-
|
|
588
|
-
# Try to preserve original connection UUID if available.
|
|
589
548
|
conn_model = ConnectionModel(
|
|
590
549
|
uuid=cuuid if isinstance(cuuid, str) and cuuid else None,
|
|
591
550
|
src_node=s_n, src_prop=s_p,
|
|
592
551
|
dst_node=d_n, dst_prop=d_p
|
|
593
552
|
)
|
|
594
|
-
ok_add,
|
|
553
|
+
ok_add, _ = self.graph.add_connection(conn_model)
|
|
595
554
|
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}")
|
|
555
|
+
self.graph.connect((s_n, s_p), (d_n, d_p))
|
|
600
556
|
|
|
601
|
-
# 4) Sync ports after all nodes/connections are in place.
|
|
602
557
|
for item in self._uuid_to_item.values():
|
|
603
558
|
if _qt_is_valid(item):
|
|
604
559
|
item.update_ports_positions()
|
|
605
560
|
|
|
606
|
-
# Rebuild Base ID counters based on the just-loaded layout.
|
|
607
561
|
self._rebuild_base_id_counters()
|
|
608
|
-
|
|
609
562
|
self._reapply_stylesheets()
|
|
563
|
+
self._update_status_label()
|
|
610
564
|
|
|
611
|
-
# Restore view state if present; otherwise center on content (backward compatible).
|
|
612
565
|
vs = self._extract_view_state(data)
|
|
613
566
|
if vs:
|
|
614
567
|
self._apply_view_state(vs)
|
|
@@ -616,7 +569,6 @@ class NodeEditor(QWidget):
|
|
|
616
569
|
self.center_on_content()
|
|
617
570
|
return True
|
|
618
571
|
finally:
|
|
619
|
-
# Always clear the loading flag even if exceptions happen during import.
|
|
620
572
|
self._is_loading_layout = False
|
|
621
573
|
|
|
622
574
|
def export_schema(self, as_list: bool = False) -> Union[dict, List[dict]]:
|
|
@@ -649,17 +601,13 @@ class NodeEditor(QWidget):
|
|
|
649
601
|
"""Create a NodeItem when a NodeModel is added to the graph."""
|
|
650
602
|
if node.uuid in self._uuid_to_item:
|
|
651
603
|
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
604
|
pos = self._pending_node_positions.pop(node.uuid, None)
|
|
656
605
|
if pos is None:
|
|
657
606
|
pos = self._next_spawn_pos()
|
|
658
607
|
self._dbg(f"graph.nodeAdded -> add item for node={node.name}({node.uuid}) at {pos}")
|
|
659
608
|
self._add_node_item(node, pos)
|
|
660
|
-
|
|
661
|
-
# Keep Base ID counters in sync with newly added nodes.
|
|
662
609
|
self._update_base_id_counters_from_node(node)
|
|
610
|
+
self._update_status_label()
|
|
663
611
|
|
|
664
612
|
def _on_graph_node_removed(self, node_uuid: str):
|
|
665
613
|
"""Remove the NodeItem when model is removed from the graph."""
|
|
@@ -674,9 +622,8 @@ class NodeEditor(QWidget):
|
|
|
674
622
|
self.scene.removeItem(item)
|
|
675
623
|
except Exception:
|
|
676
624
|
pass
|
|
677
|
-
|
|
678
|
-
# Rebuild Base ID counters after removal to reflect current layout state.
|
|
679
625
|
self._rebuild_base_id_counters()
|
|
626
|
+
self._update_status_label()
|
|
680
627
|
|
|
681
628
|
def _on_graph_connection_added(self, conn: ConnectionModel):
|
|
682
629
|
"""Create an EdgeItem when a ConnectionModel appears in the graph."""
|
|
@@ -705,36 +652,83 @@ class NodeEditor(QWidget):
|
|
|
705
652
|
self._dbg("graph.cleared -> clear scene only")
|
|
706
653
|
self._reset_interaction_states(remove_hidden_edges=True)
|
|
707
654
|
self._clear_scene_only(hard=True)
|
|
708
|
-
# Reset Base ID counters on clear so next layout starts fresh.
|
|
709
655
|
self._reset_base_id_counters()
|
|
656
|
+
self._update_status_label()
|
|
710
657
|
|
|
711
658
|
# ---------- Scene helpers ----------
|
|
712
659
|
|
|
713
660
|
def _scene_to_global(self, scene_pos: QPointF) -> QPoint:
|
|
714
661
|
"""Convert scene coordinates to a global screen QPoint."""
|
|
715
|
-
from PySide6.QtCore import QPoint as _QPoint
|
|
716
662
|
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
663
|
return self.view.viewport().mapToGlobal(vp_pt)
|
|
720
664
|
|
|
665
|
+
# ---------- Per-type limit helpers ----------
|
|
666
|
+
|
|
667
|
+
def _type_limit(self, type_name: str) -> Optional[int]:
|
|
668
|
+
"""Return configured max_num for type or None if unlimited."""
|
|
669
|
+
spec = self.graph.registry.get(type_name) if self.graph and self.graph.registry else None
|
|
670
|
+
if not spec:
|
|
671
|
+
return None
|
|
672
|
+
try:
|
|
673
|
+
limit = getattr(spec, "max_num", None)
|
|
674
|
+
if isinstance(limit, int) and limit <= 0:
|
|
675
|
+
return None
|
|
676
|
+
return limit if isinstance(limit, int) else None
|
|
677
|
+
except Exception:
|
|
678
|
+
return None
|
|
679
|
+
|
|
680
|
+
def _count_nodes_of_type(self, type_name: str) -> int:
|
|
681
|
+
"""Count current nodes of a given type in the live graph."""
|
|
682
|
+
try:
|
|
683
|
+
return sum(1 for n in self.graph.nodes.values() if getattr(n, "type", None) == type_name)
|
|
684
|
+
except Exception:
|
|
685
|
+
return 0
|
|
686
|
+
|
|
687
|
+
def _can_add_node_of_type(self, type_name: str) -> bool:
|
|
688
|
+
"""Check whether adding another node of given type is allowed by max_num."""
|
|
689
|
+
limit = self._type_limit(type_name)
|
|
690
|
+
if limit is None:
|
|
691
|
+
return True
|
|
692
|
+
return self._count_nodes_of_type(type_name) < int(limit)
|
|
693
|
+
|
|
721
694
|
def _on_scene_context_menu(self, scene_pos: QPointF):
|
|
722
695
|
"""Show context menu for adding nodes and undo/redo/clear at empty scene position."""
|
|
696
|
+
try:
|
|
697
|
+
allowed = True if self.editing_allowed is None else bool(self.editing_allowed())
|
|
698
|
+
except Exception:
|
|
699
|
+
allowed = False
|
|
700
|
+
if not allowed:
|
|
701
|
+
return
|
|
702
|
+
|
|
723
703
|
menu = QMenu(self.window())
|
|
724
704
|
ss = self.window().styleSheet()
|
|
725
705
|
if ss:
|
|
726
706
|
menu.setStyleSheet(ss)
|
|
727
707
|
|
|
728
|
-
add_menu = menu.addMenu(
|
|
708
|
+
add_menu = menu.addMenu(self.config.menu_add())
|
|
729
709
|
action_by_type: Dict[QAction, str] = {}
|
|
730
710
|
for tname in self.graph.registry.types():
|
|
731
|
-
|
|
711
|
+
# Prefer UI-only display name; fallback to identifier (type_name)
|
|
712
|
+
try:
|
|
713
|
+
label = self.graph.registry.display_name(tname)
|
|
714
|
+
except Exception:
|
|
715
|
+
label = tname
|
|
716
|
+
act = add_menu.addAction(label)
|
|
717
|
+
# Show the underlying identifier in tooltip to make UI/ID relation explicit
|
|
718
|
+
if label != tname:
|
|
719
|
+
try:
|
|
720
|
+
act.setToolTip(tname)
|
|
721
|
+
except Exception:
|
|
722
|
+
pass
|
|
723
|
+
# Disable when limit reached (evaluated in real-time, per layout)
|
|
724
|
+
if not self._can_add_node_of_type(tname):
|
|
725
|
+
act.setEnabled(False)
|
|
732
726
|
action_by_type[act] = tname
|
|
733
727
|
|
|
734
728
|
menu.addSeparator()
|
|
735
|
-
act_undo = QAction(
|
|
736
|
-
act_redo = QAction(
|
|
737
|
-
act_clear = QAction(
|
|
729
|
+
act_undo = QAction(self.config.menu_undo(), menu)
|
|
730
|
+
act_redo = QAction(self.config.menu_redo(), menu)
|
|
731
|
+
act_clear = QAction(self.config.menu_clear(), menu)
|
|
738
732
|
act_undo.setEnabled(self._undo.canUndo())
|
|
739
733
|
act_redo.setEnabled(self._undo.canRedo())
|
|
740
734
|
menu.addAction(act_undo)
|
|
@@ -754,7 +748,32 @@ class NodeEditor(QWidget):
|
|
|
754
748
|
self.clear(ask_user=True)
|
|
755
749
|
elif chosen in action_by_type:
|
|
756
750
|
type_name = action_by_type[chosen]
|
|
757
|
-
|
|
751
|
+
# Route through editor.add_node() to enforce limit guards
|
|
752
|
+
self.add_node(type_name, scene_pos)
|
|
753
|
+
|
|
754
|
+
# ---------- Z-order helpers ----------
|
|
755
|
+
|
|
756
|
+
def raise_node_to_top(self, item: NodeItem):
|
|
757
|
+
"""Bring the given node item to the front (dynamic z-order)."""
|
|
758
|
+
if not _qt_is_valid(item):
|
|
759
|
+
return
|
|
760
|
+
if not getattr(self, "_alive", True) or getattr(self, "_closing", False):
|
|
761
|
+
return
|
|
762
|
+
try:
|
|
763
|
+
self._z_top = float(self._z_top) if isinstance(self._z_top, (int, float)) else 2.0
|
|
764
|
+
except Exception:
|
|
765
|
+
self._z_top = 2.0
|
|
766
|
+
try:
|
|
767
|
+
cur = float(item.zValue())
|
|
768
|
+
if cur > self._z_top:
|
|
769
|
+
self._z_top = cur
|
|
770
|
+
except Exception:
|
|
771
|
+
pass
|
|
772
|
+
try:
|
|
773
|
+
self._z_top += 1.0
|
|
774
|
+
item.setZValue(self._z_top)
|
|
775
|
+
except Exception:
|
|
776
|
+
pass
|
|
758
777
|
|
|
759
778
|
# ---------- Add/remove nodes/edges ----------
|
|
760
779
|
|
|
@@ -767,12 +786,16 @@ class NodeEditor(QWidget):
|
|
|
767
786
|
"""Create and place a NodeItem for the given NodeModel."""
|
|
768
787
|
item = NodeItem(self, node)
|
|
769
788
|
self.scene.addItem(item)
|
|
770
|
-
|
|
771
|
-
item.setPos(
|
|
789
|
+
pos = scene_pos if not bool(self.enable_collisions) else self._find_free_position(scene_pos, item.size())
|
|
790
|
+
item.setPos(pos)
|
|
772
791
|
item.update_ports_positions()
|
|
773
|
-
|
|
792
|
+
if not bool(self.enable_collisions):
|
|
793
|
+
try:
|
|
794
|
+
self._z_top = float(self._z_top) + 1.0
|
|
795
|
+
except Exception:
|
|
796
|
+
self._z_top = 3.0
|
|
797
|
+
item.setZValue(self._z_top)
|
|
774
798
|
item.mark_ready_for_scene_ops(True)
|
|
775
|
-
|
|
776
799
|
self._uuid_to_item[node.uuid] = item
|
|
777
800
|
self._apply_styles_to_content(item._content)
|
|
778
801
|
|
|
@@ -788,10 +811,8 @@ class NodeEditor(QWidget):
|
|
|
788
811
|
if r.intersects(other):
|
|
789
812
|
return True
|
|
790
813
|
return False
|
|
791
|
-
|
|
792
814
|
if not collides(desired):
|
|
793
815
|
return desired
|
|
794
|
-
|
|
795
816
|
x = y = 0
|
|
796
817
|
dx, dy = 1, 0
|
|
797
818
|
segment_length = 1
|
|
@@ -834,7 +855,6 @@ class NodeEditor(QWidget):
|
|
|
834
855
|
if ex and _qt_is_valid(ex):
|
|
835
856
|
self._dbg(f"_add_edge_for_connection: guard skip duplicate for uuid={conn.uuid}")
|
|
836
857
|
return
|
|
837
|
-
|
|
838
858
|
src_item = self._uuid_to_item.get(conn.src_node)
|
|
839
859
|
dst_item = self._uuid_to_item.get(conn.dst_node)
|
|
840
860
|
if not src_item or not dst_item:
|
|
@@ -903,33 +923,55 @@ class NodeEditor(QWidget):
|
|
|
903
923
|
return wnd.palette()
|
|
904
924
|
return QApplication.instance().palette() if QApplication.instance() else self.palette()
|
|
905
925
|
|
|
926
|
+
def _current_font(self) -> QFont:
|
|
927
|
+
"""Return the active application/widget font to keep NodeEditor consistent with the app."""
|
|
928
|
+
wnd = self.window()
|
|
929
|
+
if isinstance(wnd, QWidget):
|
|
930
|
+
return wnd.font()
|
|
931
|
+
app = QApplication.instance()
|
|
932
|
+
if app:
|
|
933
|
+
return app.font()
|
|
934
|
+
return self.font()
|
|
935
|
+
|
|
906
936
|
def _apply_styles_to_content(self, content_widget: QWidget):
|
|
907
|
-
"""Propagate palette and stylesheet to the embedded content widget subtree."""
|
|
937
|
+
"""Propagate palette, font and stylesheet to the embedded content widget subtree."""
|
|
908
938
|
if content_widget is None:
|
|
909
939
|
return
|
|
910
940
|
content_widget.setAttribute(Qt.WA_StyledBackground, True)
|
|
911
941
|
stylesheet = self._current_stylesheet()
|
|
912
942
|
pal = self._current_palette()
|
|
943
|
+
font = self._current_font()
|
|
913
944
|
content_widget.setPalette(pal)
|
|
945
|
+
content_widget.setFont(font)
|
|
914
946
|
if stylesheet:
|
|
915
947
|
content_widget.setStyleSheet(stylesheet)
|
|
916
948
|
content_widget.ensurePolished()
|
|
917
949
|
for w in content_widget.findChildren(QWidget):
|
|
950
|
+
w.setPalette(pal)
|
|
951
|
+
w.setFont(font)
|
|
952
|
+
if stylesheet:
|
|
953
|
+
w.setStyleSheet(stylesheet)
|
|
918
954
|
w.ensurePolished()
|
|
919
955
|
|
|
920
956
|
def _reapply_stylesheets(self):
|
|
921
|
-
"""Reapply palette and stylesheet to all NodeContentWidget instances."""
|
|
957
|
+
"""Reapply palette, font and stylesheet to all NodeContentWidget instances."""
|
|
922
958
|
if not getattr(self, "_alive", True) or getattr(self, "_closing", False):
|
|
923
959
|
return
|
|
924
960
|
stylesheet = self._current_stylesheet()
|
|
925
961
|
pal = self._current_palette()
|
|
962
|
+
font = self._current_font()
|
|
926
963
|
for item in self._uuid_to_item.values():
|
|
927
964
|
if item._content and _qt_is_valid(item._content):
|
|
928
965
|
item._content.setPalette(pal)
|
|
966
|
+
item._content.setFont(font)
|
|
929
967
|
if stylesheet:
|
|
930
968
|
item._content.setStyleSheet(stylesheet)
|
|
931
969
|
item._content.ensurePolished()
|
|
932
970
|
for w in item._content.findChildren(QWidget):
|
|
971
|
+
w.setPalette(pal)
|
|
972
|
+
w.setFont(font)
|
|
973
|
+
if stylesheet:
|
|
974
|
+
w.setStyleSheet(stylesheet)
|
|
933
975
|
w.ensurePolished()
|
|
934
976
|
|
|
935
977
|
# ---------- Edge/Port helpers + rewire ----------
|
|
@@ -1349,7 +1391,7 @@ class NodeEditor(QWidget):
|
|
|
1349
1391
|
return
|
|
1350
1392
|
|
|
1351
1393
|
self._dbg(f"Delete shortcut -> nodes={len(nodes)}, edges={len(edges)} (undoable)")
|
|
1352
|
-
self._undo.beginMacro(
|
|
1394
|
+
self._undo.beginMacro(self.config.macro_delete_selection())
|
|
1353
1395
|
try:
|
|
1354
1396
|
for n in nodes:
|
|
1355
1397
|
# Push per-node undoable deletion (restores its own connections)
|
|
@@ -2035,4 +2077,32 @@ class NodeEditor(QWidget):
|
|
|
2035
2077
|
"dst_prop": c.dst_prop,
|
|
2036
2078
|
}
|
|
2037
2079
|
|
|
2038
|
-
return {"nodes": nodes_out, "connections": conns_out}
|
|
2080
|
+
return {"nodes": nodes_out, "connections": conns_out}
|
|
2081
|
+
|
|
2082
|
+
# ---------- Overlay controls handlers ----------
|
|
2083
|
+
|
|
2084
|
+
def _on_grab_toggled(self, enabled: bool):
|
|
2085
|
+
"""Enable/disable global grab mode (left-button panning anywhere)."""
|
|
2086
|
+
if _qt_is_valid(self.view):
|
|
2087
|
+
self.view.set_global_grab_mode(bool(enabled))
|
|
2088
|
+
# Visual feedback is provided by the checkable state (style handles appearance)
|
|
2089
|
+
|
|
2090
|
+
# ---------- Status label ----------
|
|
2091
|
+
|
|
2092
|
+
def _update_status_label(self):
|
|
2093
|
+
"""Compute node counts by type and update bottom-left status label."""
|
|
2094
|
+
try:
|
|
2095
|
+
counts: Dict[str, int] = {}
|
|
2096
|
+
unknown = self.config.type_unknown()
|
|
2097
|
+
for n in self.graph.nodes.values():
|
|
2098
|
+
t = getattr(n, "type", unknown) or unknown
|
|
2099
|
+
counts[t] = counts.get(t, 0) + 1
|
|
2100
|
+
if not counts:
|
|
2101
|
+
text = self.config.status_no_nodes()
|
|
2102
|
+
else:
|
|
2103
|
+
parts = [f"{k}: {counts[k]}" for k in sorted(counts.keys(), key=lambda s: s.lower())]
|
|
2104
|
+
text = ", ".join(parts)
|
|
2105
|
+
self._status.set_text(text)
|
|
2106
|
+
self._position_status_label()
|
|
2107
|
+
except Exception:
|
|
2108
|
+
pass
|