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.
Files changed (87) hide show
  1. pygpt_net/CHANGELOG.txt +14 -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/chat/response.py +8 -2
  6. pygpt_net/controller/presets/presets.py +121 -6
  7. pygpt_net/controller/settings/editor.py +0 -15
  8. pygpt_net/controller/settings/profile.py +16 -4
  9. pygpt_net/controller/settings/workdir.py +30 -5
  10. pygpt_net/controller/theme/common.py +4 -2
  11. pygpt_net/controller/theme/markdown.py +4 -7
  12. pygpt_net/controller/theme/theme.py +2 -1
  13. pygpt_net/controller/ui/ui.py +32 -7
  14. pygpt_net/core/agents/custom/__init__.py +7 -1
  15. pygpt_net/core/agents/custom/llama_index/factory.py +17 -6
  16. pygpt_net/core/agents/custom/llama_index/runner.py +52 -4
  17. pygpt_net/core/agents/custom/llama_index/utils.py +12 -1
  18. pygpt_net/core/agents/custom/router.py +45 -6
  19. pygpt_net/core/agents/custom/runner.py +11 -5
  20. pygpt_net/core/agents/custom/schema.py +3 -1
  21. pygpt_net/core/agents/custom/utils.py +13 -1
  22. pygpt_net/core/agents/runners/llama_workflow.py +65 -5
  23. pygpt_net/core/agents/runners/openai_workflow.py +2 -1
  24. pygpt_net/core/db/viewer.py +11 -5
  25. pygpt_net/core/node_editor/graph.py +18 -9
  26. pygpt_net/core/node_editor/models.py +9 -2
  27. pygpt_net/core/node_editor/types.py +15 -1
  28. pygpt_net/core/presets/presets.py +216 -29
  29. pygpt_net/core/render/markdown/parser.py +0 -2
  30. pygpt_net/core/render/web/renderer.py +76 -11
  31. pygpt_net/data/config/config.json +5 -6
  32. pygpt_net/data/config/models.json +3 -3
  33. pygpt_net/data/config/settings.json +2 -38
  34. pygpt_net/data/css/style.dark.css +18 -0
  35. pygpt_net/data/css/style.light.css +20 -1
  36. pygpt_net/data/locale/locale.de.ini +66 -1
  37. pygpt_net/data/locale/locale.en.ini +64 -3
  38. pygpt_net/data/locale/locale.es.ini +66 -1
  39. pygpt_net/data/locale/locale.fr.ini +66 -1
  40. pygpt_net/data/locale/locale.it.ini +66 -1
  41. pygpt_net/data/locale/locale.pl.ini +67 -2
  42. pygpt_net/data/locale/locale.uk.ini +66 -1
  43. pygpt_net/data/locale/locale.zh.ini +66 -1
  44. pygpt_net/data/locale/plugin.cmd_system.en.ini +62 -66
  45. pygpt_net/item/ctx.py +23 -1
  46. pygpt_net/provider/agents/llama_index/flow_from_schema.py +2 -2
  47. pygpt_net/provider/agents/llama_index/workflow/codeact.py +9 -6
  48. pygpt_net/provider/agents/llama_index/workflow/openai.py +38 -11
  49. pygpt_net/provider/agents/llama_index/workflow/planner.py +36 -16
  50. pygpt_net/provider/agents/llama_index/workflow/supervisor.py +60 -10
  51. pygpt_net/provider/agents/openai/agent.py +3 -1
  52. pygpt_net/provider/agents/openai/agent_b2b.py +13 -9
  53. pygpt_net/provider/agents/openai/agent_planner.py +6 -2
  54. pygpt_net/provider/agents/openai/agent_with_experts.py +4 -1
  55. pygpt_net/provider/agents/openai/agent_with_experts_feedback.py +4 -2
  56. pygpt_net/provider/agents/openai/agent_with_feedback.py +4 -2
  57. pygpt_net/provider/agents/openai/evolve.py +6 -2
  58. pygpt_net/provider/agents/openai/supervisor.py +3 -1
  59. pygpt_net/provider/api/openai/agents/response.py +1 -0
  60. pygpt_net/provider/core/config/patch.py +18 -1
  61. pygpt_net/provider/core/config/patches/patch_before_2_6_42.py +0 -6
  62. pygpt_net/tools/agent_builder/tool.py +48 -26
  63. pygpt_net/tools/agent_builder/ui/dialogs.py +36 -28
  64. pygpt_net/ui/__init__.py +2 -4
  65. pygpt_net/ui/dialog/about.py +58 -38
  66. pygpt_net/ui/dialog/db.py +142 -3
  67. pygpt_net/ui/dialog/preset.py +47 -8
  68. pygpt_net/ui/layout/toolbox/presets.py +64 -16
  69. pygpt_net/ui/main.py +2 -2
  70. pygpt_net/ui/widget/dialog/confirm.py +27 -3
  71. pygpt_net/ui/widget/dialog/db.py +0 -0
  72. pygpt_net/ui/widget/draw/painter.py +90 -1
  73. pygpt_net/ui/widget/lists/preset.py +908 -60
  74. pygpt_net/ui/widget/node_editor/command.py +10 -10
  75. pygpt_net/ui/widget/node_editor/config.py +157 -0
  76. pygpt_net/ui/widget/node_editor/editor.py +223 -153
  77. pygpt_net/ui/widget/node_editor/item.py +12 -11
  78. pygpt_net/ui/widget/node_editor/node.py +246 -13
  79. pygpt_net/ui/widget/node_editor/view.py +179 -63
  80. pygpt_net/ui/widget/tabs/output.py +1 -1
  81. pygpt_net/ui/widget/textarea/input.py +157 -23
  82. pygpt_net/utils.py +114 -2
  83. {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.62.dist-info}/METADATA +26 -100
  84. {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.62.dist-info}/RECORD +86 -85
  85. {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.62.dist-info}/LICENSE +0 -0
  86. {pygpt_net-2.6.60.dist-info → pygpt_net-2.6.62.dist-info}/WHEEL +0 -0
  87. {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.24 00:00:00 #
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 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,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 an input field is currently focused."""
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 and reapply stylesheets when shown."""
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 as ex:
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, reason_add = self.graph.add_connection(conn_model)
553
+ ok_add, _ = self.graph.add_connection(conn_model)
595
554
  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}")
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("Add")
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
- act = add_menu.addAction(tname)
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("Undo", menu)
736
- act_redo = QAction("Redo", menu)
737
- act_clear = QAction("Clear", menu)
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
- self._undo.push(AddNodeCommand(self, type_name, scene_pos))
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
- free_pos = self._find_free_position(scene_pos, item.size())
771
- item.setPos(free_pos)
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
- # From now on it's safe for itemChange to touch the scene/edges
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("Delete selection")
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