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.
Files changed (60) hide show
  1. pygpt_net/CHANGELOG.txt +7 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/controller/chat/common.py +115 -6
  4. pygpt_net/controller/chat/input.py +4 -1
  5. pygpt_net/controller/presets/presets.py +121 -6
  6. pygpt_net/controller/settings/editor.py +0 -15
  7. pygpt_net/controller/theme/markdown.py +2 -5
  8. pygpt_net/controller/ui/ui.py +4 -7
  9. pygpt_net/core/agents/custom/__init__.py +7 -1
  10. pygpt_net/core/agents/custom/llama_index/factory.py +17 -6
  11. pygpt_net/core/agents/custom/llama_index/runner.py +35 -2
  12. pygpt_net/core/agents/custom/llama_index/utils.py +12 -1
  13. pygpt_net/core/agents/custom/router.py +45 -6
  14. pygpt_net/core/agents/custom/runner.py +2 -1
  15. pygpt_net/core/agents/custom/schema.py +3 -1
  16. pygpt_net/core/agents/custom/utils.py +13 -1
  17. pygpt_net/core/db/viewer.py +11 -5
  18. pygpt_net/core/node_editor/graph.py +18 -9
  19. pygpt_net/core/node_editor/models.py +9 -2
  20. pygpt_net/core/node_editor/types.py +3 -1
  21. pygpt_net/core/presets/presets.py +216 -29
  22. pygpt_net/core/render/markdown/parser.py +0 -2
  23. pygpt_net/data/config/config.json +5 -6
  24. pygpt_net/data/config/models.json +3 -3
  25. pygpt_net/data/config/settings.json +2 -38
  26. pygpt_net/data/locale/locale.de.ini +64 -1
  27. pygpt_net/data/locale/locale.en.ini +62 -3
  28. pygpt_net/data/locale/locale.es.ini +64 -1
  29. pygpt_net/data/locale/locale.fr.ini +64 -1
  30. pygpt_net/data/locale/locale.it.ini +64 -1
  31. pygpt_net/data/locale/locale.pl.ini +65 -2
  32. pygpt_net/data/locale/locale.uk.ini +64 -1
  33. pygpt_net/data/locale/locale.zh.ini +64 -1
  34. pygpt_net/data/locale/plugin.cmd_system.en.ini +62 -66
  35. pygpt_net/provider/agents/llama_index/flow_from_schema.py +2 -2
  36. pygpt_net/provider/core/config/patch.py +10 -1
  37. pygpt_net/provider/core/config/patches/patch_before_2_6_42.py +0 -6
  38. pygpt_net/tools/agent_builder/tool.py +42 -26
  39. pygpt_net/tools/agent_builder/ui/dialogs.py +60 -11
  40. pygpt_net/ui/__init__.py +2 -4
  41. pygpt_net/ui/dialog/about.py +58 -38
  42. pygpt_net/ui/dialog/db.py +142 -3
  43. pygpt_net/ui/dialog/preset.py +47 -8
  44. pygpt_net/ui/layout/toolbox/presets.py +52 -16
  45. pygpt_net/ui/widget/dialog/db.py +0 -0
  46. pygpt_net/ui/widget/lists/preset.py +644 -60
  47. pygpt_net/ui/widget/node_editor/command.py +10 -10
  48. pygpt_net/ui/widget/node_editor/config.py +157 -0
  49. pygpt_net/ui/widget/node_editor/editor.py +183 -151
  50. pygpt_net/ui/widget/node_editor/item.py +12 -11
  51. pygpt_net/ui/widget/node_editor/node.py +267 -12
  52. pygpt_net/ui/widget/node_editor/view.py +180 -63
  53. pygpt_net/ui/widget/tabs/output.py +1 -1
  54. pygpt_net/ui/widget/textarea/input.py +2 -2
  55. pygpt_net/utils.py +114 -2
  56. {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.61.dist-info}/METADATA +11 -94
  57. {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.61.dist-info}/RECORD +59 -58
  58. {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.61.dist-info}/LICENSE +0 -0
  59. {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.61.dist-info}/WHEEL +0 -0
  60. {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.24 00:00:00 #
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 QAction, QColor, QUndoStack, QPalette, QCursor, QKeySequence, QShortcut
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 an input field is currently focused."""
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 and reapply stylesheets when shown."""
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 as ex:
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, reason_add = self.graph.add_connection(conn_model)
559
+ ok_add, _ = self.graph.add_connection(conn_model)
595
560
  if not ok_add:
596
- # Fallback: let graph assign a fresh UUID (should rarely be needed).
597
- ok2, reason2, _ = self.graph.connect((s_n, s_p), (d_n, d_p))
598
- if not ok2:
599
- self._dbg(f"load_layout: failed to add connection even via connect(): {reason_add} / {reason2}")
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("Add")
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("Undo", menu)
736
- act_redo = QAction("Redo", menu)
737
- act_clear = QAction("Clear", menu)
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
- free_pos = self._find_free_position(scene_pos, item.size())
771
- item.setPos(free_pos)
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
- # From now on it's safe for itemChange to touch the scene/edges
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("Delete selection")
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