pygpt-net 2.6.59__py3-none-any.whl → 2.6.60__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 (61) hide show
  1. pygpt_net/CHANGELOG.txt +4 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/app.py +9 -5
  4. pygpt_net/controller/__init__.py +1 -0
  5. pygpt_net/controller/presets/editor.py +442 -39
  6. pygpt_net/core/agents/custom/__init__.py +275 -0
  7. pygpt_net/core/agents/custom/debug.py +64 -0
  8. pygpt_net/core/agents/custom/factory.py +109 -0
  9. pygpt_net/core/agents/custom/graph.py +71 -0
  10. pygpt_net/core/agents/custom/llama_index/__init__.py +10 -0
  11. pygpt_net/core/agents/custom/llama_index/factory.py +89 -0
  12. pygpt_net/core/agents/custom/llama_index/router_streamer.py +106 -0
  13. pygpt_net/core/agents/custom/llama_index/runner.py +529 -0
  14. pygpt_net/core/agents/custom/llama_index/stream.py +56 -0
  15. pygpt_net/core/agents/custom/llama_index/utils.py +242 -0
  16. pygpt_net/core/agents/custom/logging.py +50 -0
  17. pygpt_net/core/agents/custom/memory.py +51 -0
  18. pygpt_net/core/agents/custom/router.py +116 -0
  19. pygpt_net/core/agents/custom/router_streamer.py +187 -0
  20. pygpt_net/core/agents/custom/runner.py +454 -0
  21. pygpt_net/core/agents/custom/schema.py +125 -0
  22. pygpt_net/core/agents/custom/utils.py +181 -0
  23. pygpt_net/core/agents/provider.py +72 -7
  24. pygpt_net/core/agents/runner.py +7 -4
  25. pygpt_net/core/agents/runners/helpers.py +1 -1
  26. pygpt_net/core/agents/runners/llama_workflow.py +3 -0
  27. pygpt_net/core/agents/runners/openai_workflow.py +8 -1
  28. pygpt_net/{ui/widget/builder → core/node_editor}/__init__.py +2 -2
  29. pygpt_net/core/{builder → node_editor}/graph.py +11 -218
  30. pygpt_net/core/node_editor/models.py +111 -0
  31. pygpt_net/core/node_editor/types.py +76 -0
  32. pygpt_net/core/node_editor/utils.py +17 -0
  33. pygpt_net/core/render/web/renderer.py +10 -8
  34. pygpt_net/data/config/config.json +3 -3
  35. pygpt_net/data/config/models.json +3 -3
  36. pygpt_net/data/locale/locale.en.ini +4 -4
  37. pygpt_net/item/agent.py +5 -1
  38. pygpt_net/item/preset.py +19 -1
  39. pygpt_net/provider/agents/base.py +33 -2
  40. pygpt_net/provider/agents/llama_index/flow_from_schema.py +92 -0
  41. pygpt_net/provider/agents/openai/flow_from_schema.py +96 -0
  42. pygpt_net/provider/core/agent/json_file.py +11 -5
  43. pygpt_net/tools/agent_builder/tool.py +217 -52
  44. pygpt_net/tools/agent_builder/ui/dialogs.py +119 -24
  45. pygpt_net/tools/agent_builder/ui/list.py +37 -10
  46. pygpt_net/ui/dialog/preset.py +16 -1
  47. pygpt_net/ui/main.py +1 -1
  48. pygpt_net/{core/builder → ui/widget/node_editor}/__init__.py +2 -2
  49. pygpt_net/ui/widget/node_editor/command.py +373 -0
  50. pygpt_net/ui/widget/node_editor/editor.py +2038 -0
  51. pygpt_net/ui/widget/node_editor/item.py +492 -0
  52. pygpt_net/ui/widget/node_editor/node.py +1205 -0
  53. pygpt_net/ui/widget/node_editor/utils.py +17 -0
  54. pygpt_net/ui/widget/node_editor/view.py +247 -0
  55. {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.60.dist-info}/METADATA +72 -2
  56. {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.60.dist-info}/RECORD +59 -33
  57. pygpt_net/core/agents/custom.py +0 -150
  58. pygpt_net/ui/widget/builder/editor.py +0 -2001
  59. {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.60.dist-info}/LICENSE +0 -0
  60. {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.60.dist-info}/WHEEL +0 -0
  61. {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.60.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,1205 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # ================================================== #
4
+ # This file is a part of PYGPT package #
5
+ # Website: https://pygpt.net #
6
+ # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
+ # MIT License #
8
+ # Created By : Marcin Szczygliński #
9
+ # Updated Date: 2025.09.24 00:00:00 #
10
+ # ================================================== #
11
+
12
+ from __future__ import annotations
13
+ import re
14
+ from typing import Dict, Optional, List, Any
15
+
16
+ from PySide6.QtCore import Qt, QPointF, QRectF, QSizeF, Signal,QEvent
17
+ from PySide6.QtGui import QAction, QBrush, QColor, QPainter, QPainterPath, QPen
18
+ from PySide6.QtWidgets import (
19
+ QWidget, QApplication, QGraphicsItem, QGraphicsWidget, QGraphicsProxyWidget, QStyleOptionGraphicsItem,
20
+ QMenu, QFormLayout, QLineEdit, QSpinBox, QDoubleSpinBox, QCheckBox, QComboBox, QLabel, QTextEdit, QPlainTextEdit
21
+ )
22
+
23
+ from pygpt_net.core.node_editor.graph import NodeGraph
24
+ from pygpt_net.core.node_editor.models import NodeModel, PropertyModel
25
+
26
+ from .command import MoveNodeCommand, ResizeNodeCommand
27
+ from .item import EdgeItem, PortItem, NodeOverlayItem
28
+ from .utils import _qt_is_valid
29
+
30
+ from PySide6.QtCore import Qt, Signal, QEvent
31
+ from PySide6.QtGui import QFontMetrics, QKeyEvent, QGuiApplication
32
+ from PySide6.QtWidgets import QTextEdit, QFrame
33
+
34
+ class SingleLineTextEdit(QTextEdit):
35
+ editingFinished = Signal()
36
+
37
+ def __init__(self, parent=None):
38
+ super().__init__(parent)
39
+ self.setObjectName("lineEditLike")
40
+ self.setAcceptRichText(False)
41
+ self.setLineWrapMode(QTextEdit.NoWrap)
42
+ self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
43
+ self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
44
+ self.setTabChangesFocus(True)
45
+ self.setFrameStyle(QFrame.NoFrame)
46
+ self.document().setDocumentMargin(2)
47
+ self._update_height()
48
+
49
+ def _update_height(self):
50
+ fm = QFontMetrics(self.font())
51
+ h = fm.lineSpacing() + 8
52
+ self.setFixedHeight(h)
53
+
54
+ def changeEvent(self, e):
55
+ super().changeEvent(e)
56
+ if e.type() in (QEvent.FontChange, QEvent.ApplicationFontChange):
57
+ self._update_height()
58
+
59
+ def keyPressEvent(self, e: QKeyEvent):
60
+ k = e.key()
61
+ if k in (Qt.Key_Return, Qt.Key_Enter):
62
+ self.clearFocus()
63
+ self.editingFinished.emit()
64
+ e.accept()
65
+ return
66
+ if k == Qt.Key_Backtab:
67
+ self.focusPreviousChild()
68
+ e.accept()
69
+ return
70
+ super().keyPressEvent(e)
71
+ self._strip_newlines()
72
+
73
+ def insertFromMimeData(self, source):
74
+ text = source.text().replace("\r", " ").replace("\n", " ")
75
+ self.insertPlainText(text)
76
+
77
+ def _strip_newlines(self):
78
+ t = self.toPlainText()
79
+ t2 = t.replace("\r", " ").replace("\n", " ")
80
+ if t != t2:
81
+ pos = self.textCursor().position()
82
+ self.blockSignals(True)
83
+ self.setPlainText(t2)
84
+ self.blockSignals(False)
85
+ c = self.textCursor()
86
+ c.setPosition(min(pos, len(t2)))
87
+ self.setTextCursor(c)
88
+
89
+
90
+ class NodeContentWidget(QWidget):
91
+ """Form-like widget that renders property editors for a node.
92
+
93
+ The widget builds appropriate Qt editors based on PropertyModel.type:
94
+ - "str": QLineEdit
95
+ - "text": QTextEdit
96
+ - "int": QSpinBox
97
+ - "float": QDoubleSpinBox
98
+ - "bool": QCheckBox
99
+ - "combo": QComboBox
100
+ - other: QLabel (disabled)
101
+
102
+ It emits valueChanged(prop_id, value) when a field is edited.
103
+
104
+ Notes:
105
+ - For Base ID-like properties, the field is read-only; a composed display value is shown.
106
+ - ShortcutOverride is handled so typing in editors does not trigger scene shortcuts.
107
+
108
+ Signal:
109
+ valueChanged(str, object): Emitted when the value of a property changes.
110
+ """
111
+ valueChanged = Signal(str, object)
112
+
113
+ def __init__(self, node: NodeModel, graph: NodeGraph, editor: "NodeEditor", parent: Optional[QWidget] = None):
114
+ """Build all property editors from the node model/specification."""
115
+ super().__init__(parent)
116
+ self.node = node
117
+ self.graph = graph
118
+ self.editor = editor
119
+ self.setObjectName("NodeContentWidget")
120
+ self.setAttribute(Qt.WA_StyledBackground, True)
121
+
122
+ self.form = QFormLayout(self)
123
+ self.form.setContentsMargins(8, 8, 8, 8)
124
+ self.form.setSpacing(6)
125
+ self._editors: Dict[str, QWidget] = {}
126
+ for pid, pm in node.properties.items():
127
+ editable = self._editable_from_spec(pid, pm)
128
+ w: QWidget
129
+ if pm.type == "str":
130
+ te = SingleLineTextEdit()
131
+ te.setFocusPolicy(Qt.StrongFocus)
132
+
133
+ txt = ""
134
+ if pm.value is not None:
135
+ try:
136
+ txt = str(pm.value)
137
+ except Exception:
138
+ txt = f"{pm.value}"
139
+
140
+ if self._is_base_id_property(pid, pm):
141
+ txt = self._compose_base_id_display(pid, txt)
142
+ te.setReadOnly(True)
143
+ te.setFocusPolicy(Qt.NoFocus)
144
+ else:
145
+ te.setReadOnly(not editable)
146
+
147
+ te.setPlainText(txt)
148
+ te.textChanged.connect(lambda pid=pid, te=te: self.valueChanged.emit(pid, te.toPlainText()))
149
+ te.editingFinished.connect(lambda pid=pid, te=te: self.valueChanged.emit(pid, te.toPlainText()))
150
+ w = te
151
+ elif pm.type == "text":
152
+ te = QTextEdit()
153
+ te.setFocusPolicy(Qt.StrongFocus)
154
+ if pm.value is not None:
155
+ te.setPlainText(str(pm.value))
156
+ te.setReadOnly(not editable)
157
+ te.textChanged.connect(lambda pid=pid, te=te: self.valueChanged.emit(pid, te.toPlainText()))
158
+ w = te
159
+ elif pm.type == "int":
160
+ w = QSpinBox()
161
+ w.setFocusPolicy(Qt.StrongFocus)
162
+ w.setRange(-10**9, 10**9)
163
+ if pm.value is not None:
164
+ w.setValue(int(pm.value))
165
+ w.setEnabled(editable)
166
+ w.valueChanged.connect(lambda v, pid=pid: self.valueChanged.emit(pid, int(v)))
167
+ elif pm.type == "float":
168
+ w = QDoubleSpinBox()
169
+ w.setFocusPolicy(Qt.StrongFocus)
170
+ w.setDecimals(4)
171
+ w.setRange(-1e12, 1e12)
172
+ if pm.value is not None:
173
+ w.setValue(float(pm.value))
174
+ w.setEnabled(editable)
175
+ w.valueChanged.connect(lambda v, pid=pid: self.valueChanged.emit(pid, float(v)))
176
+ elif pm.type == "bool":
177
+ w = QCheckBox()
178
+ w.setFocusPolicy(Qt.StrongFocus)
179
+ if pm.value:
180
+ w.setChecked(bool(pm.value))
181
+ w.setEnabled(editable)
182
+ w.toggled.connect(lambda v, pid=pid: self.valueChanged.emit(pid, bool(v)))
183
+ elif pm.type == "combo":
184
+ w = QComboBox()
185
+ w.setFocusPolicy(Qt.StrongFocus)
186
+ for opt in (pm.options or []):
187
+ w.addItem(opt)
188
+ if pm.value is not None and pm.value in (pm.options or []):
189
+ w.setCurrentText(pm.value)
190
+ w.setEnabled(editable)
191
+ w.currentTextChanged.connect(lambda v, pid=pid: self.valueChanged.emit(pid, v))
192
+ else:
193
+ # Render a read-only capacity placeholder for port-like properties ("input/output").
194
+ cap_text = self._capacity_text_for_property(pid, pm)
195
+ w = QLabel(cap_text)
196
+ w.setEnabled(False)
197
+ w.setToolTip(f"Allowed connections (IN/OUT): {cap_text}")
198
+
199
+ name = self._display_name_for_property(pid, pm)
200
+ if name == "Base ID":
201
+ name = "ID"
202
+ self.form.addRow(name, w)
203
+ self._editors[pid] = w
204
+
205
+ def event(self, e):
206
+ """
207
+ Filter ShortcutOverride so editor keystrokes are not eaten by the scene.
208
+
209
+ Allows space, backspace and delete to be used inside text-like editors.
210
+ """
211
+ if e.type() == QEvent.ShortcutOverride:
212
+ key = getattr(e, "key", lambda: None)()
213
+ if key in (Qt.Key_Space, Qt.Key_Backspace, Qt.Key_Delete):
214
+ fw = QApplication.focusWidget()
215
+ if fw and (fw is self or self.isAncestorOf(fw)):
216
+ try:
217
+ if isinstance(fw, (QLineEdit, QTextEdit, QPlainTextEdit)):
218
+ if not (hasattr(fw, "isReadOnly") and fw.isReadOnly()):
219
+ e.accept()
220
+ return True
221
+ if isinstance(fw, QComboBox) and fw.isEditable():
222
+ e.accept()
223
+ return True
224
+ if isinstance(fw, (QSpinBox, QDoubleSpinBox)) and fw.hasFocus():
225
+ e.accept()
226
+ return True
227
+ except Exception:
228
+ pass
229
+ return super().event(e)
230
+
231
+ def _display_name_for_property(self, pid: str, pm: PropertyModel) -> str:
232
+ """Resolve a human-friendly label for a property from the registry or fallback to model.
233
+
234
+ Strategy:
235
+ 1) Try different attribute containers in the type spec (properties, props, fields, ports, inputs, outputs)
236
+ looking for name/label/title.
237
+ 2) Try getter-like methods on the type spec (get_property, property_spec, get_prop, prop, property).
238
+ 3) Fallback to PropertyModel.name, then to the pid.
239
+
240
+ Returns:
241
+ Display name string.
242
+
243
+ Example:
244
+ If registry spec has:
245
+ spec.properties["text_input"] = {"name": "Text", "editable": True}
246
+ then the display name is "Text".
247
+ """
248
+ try:
249
+ spec = self.graph.registry.get(self.node.type)
250
+ if spec is not None:
251
+ def _extract_name(obj) -> Optional[str]:
252
+ if obj is None:
253
+ return None
254
+ for key in ("name", "label", "title"):
255
+ try:
256
+ val = getattr(obj, key, None)
257
+ if val:
258
+ return str(val)
259
+ except Exception:
260
+ pass
261
+ try:
262
+ if isinstance(obj, dict) and obj.get(key):
263
+ return str(obj[key])
264
+ except Exception:
265
+ pass
266
+ return None
267
+ for attr in ("properties", "props", "fields", "ports", "inputs", "outputs"):
268
+ try:
269
+ cont = getattr(spec, attr, None)
270
+ if isinstance(cont, dict) and pid in cont:
271
+ n = _extract_name(cont[pid])
272
+ if n:
273
+ return n
274
+ except Exception:
275
+ pass
276
+ for meth in ("get_property", "property_spec", "get_prop", "prop", "property"):
277
+ if hasattr(spec, meth):
278
+ try:
279
+ n = _extract_name(getattr(spec, meth)(pid))
280
+ if n:
281
+ return n
282
+ except Exception:
283
+ pass
284
+ except Exception:
285
+ pass
286
+ try:
287
+ if getattr(pm, "name", None):
288
+ return str(pm.name)
289
+ except Exception:
290
+ pass
291
+ return pid
292
+
293
+ def _editable_from_spec(self, pid: str, pm: PropertyModel) -> bool:
294
+ """Return whether the given property is editable, based on spec or model default.
295
+
296
+ Resolution order:
297
+ - spec.properties[pid].editable (or alias keys)
298
+ - getattr(spec, method)(pid).editable
299
+ - PropertyModel.editable (default True)
300
+
301
+ Returns:
302
+ bool: True when editable.
303
+
304
+ Caveat:
305
+ This is UI-only; the graph validation rules still apply when saving/applying values.
306
+ """
307
+ try:
308
+ spec = self.graph.registry.get(self.node.type)
309
+ if spec is not None:
310
+ for attr in ("properties", "props", "fields"):
311
+ try:
312
+ cont = getattr(spec, attr, None)
313
+ if isinstance(cont, dict) and pid in cont:
314
+ obj = cont[pid]
315
+ val = None
316
+ try:
317
+ val = getattr(obj, "editable", None)
318
+ except Exception:
319
+ pass
320
+ if val is None and isinstance(obj, dict):
321
+ val = obj.get("editable")
322
+ if isinstance(val, bool):
323
+ return val
324
+ except Exception:
325
+ pass
326
+ for meth in ("get_property", "property_spec", "get_prop", "prop", "property"):
327
+ if hasattr(spec, meth):
328
+ try:
329
+ obj = getattr(spec, meth)(pid)
330
+ val = None
331
+ try:
332
+ val = getattr(obj, "editable", None)
333
+ except Exception:
334
+ pass
335
+ if val is None and isinstance(obj, dict):
336
+ val = obj.get("editable")
337
+ if isinstance(val, bool):
338
+ return val
339
+ except Exception:
340
+ pass
341
+ except Exception:
342
+ pass
343
+ return bool(getattr(pm, "editable", True))
344
+
345
+ def _capacity_text_for_property(self, pid: str, pm: PropertyModel) -> str:
346
+ """Return 'IN/OUT' capacity text using live spec (fallback to PropertyModel)."""
347
+ try:
348
+ # NodeEditor helper returns (out_allowed, in_allowed)
349
+ out_allowed, in_allowed = self.editor._allowed_from_spec(self.node, pid)
350
+ except Exception:
351
+ # Fallback to model fields when spec is unavailable
352
+ try:
353
+ out_allowed = int(getattr(pm, "allowed_outputs", 0) or 0)
354
+ except Exception:
355
+ out_allowed = 0
356
+ try:
357
+ in_allowed = int(getattr(pm, "allowed_inputs", 0) or 0)
358
+ except Exception:
359
+ in_allowed = 0
360
+
361
+ def fmt(v: object) -> str:
362
+ try:
363
+ iv = int(v)
364
+ except Exception:
365
+ return "-"
366
+ if iv < 0:
367
+ return "\u221E"
368
+ if iv == 0:
369
+ return "-"
370
+ return str(iv)
371
+
372
+ # Display order: INPUT/OUTPUT
373
+ return f"{fmt(in_allowed)}/{fmt(out_allowed)}"
374
+
375
+ # --- Base ID helpers (UI-only) ---
376
+
377
+ def _is_base_id_property(self, pid: str, pm: PropertyModel) -> bool:
378
+ """Heuristically detect 'Base ID'-like properties by flags, pid or name."""
379
+ try:
380
+ if getattr(pm, "is_base_id", False):
381
+ return True
382
+ except Exception:
383
+ pass
384
+ name_key = (pm.name or "").strip().lower().replace(" ", "_") if hasattr(pm, "name") else ""
385
+ pid_key = (pid or "").strip().lower().replace(" ", "_")
386
+ candidates = {"base_id", "baseid", "base", "id_base", "basename", "base_name"}
387
+ return pid_key in candidates or name_key in candidates
388
+
389
+ def _compose_base_id_display(self, pid: str, base_value: str) -> str:
390
+ """Compose a read-only display for 'Base ID' editor.
391
+
392
+ If the base value already ends with _<digits> then it is returned unchanged.
393
+ Otherwise, try to find an existing suffix from other properties/attrs and append it.
394
+
395
+ Returns:
396
+ str: Display value with a reusable or inferred suffix if applicable.
397
+
398
+ Example:
399
+ base_value='agent' and node.meta['index']=3 -> 'agent_3'
400
+ """
401
+ if not isinstance(base_value, str):
402
+ base_value = f"{base_value}"
403
+ if re.match(r".+_\d+$", base_value):
404
+ return base_value
405
+ suffix = self._resolve_existing_suffix(pid, base_value)
406
+ if suffix is None:
407
+ return base_value
408
+ try:
409
+ s = int(str(suffix))
410
+ return f"{base_value}_{s}"
411
+ except Exception:
412
+ s = f"{suffix}".strip()
413
+ if not s:
414
+ return base_value
415
+ s = s.lstrip("_")
416
+ return f"{base_value}_{s}"
417
+
418
+ def _resolve_existing_suffix(self, pid: str, base_value: str) -> Optional[Any]:
419
+ """Try to reuse a numeric or string suffix from the node/graph context.
420
+
421
+ Checks in order:
422
+ - Sibling properties: id_suffix, suffix, index, seq, order, counter, num
423
+ - Node attributes: idIndex, human_index, friendly_index
424
+ - Dict-like containers: meta, data, extras, extra, ctx
425
+ - Graph-level similarity: for nodes with the same base value, uses a stable
426
+ enumeration order to compute an index (1-based).
427
+
428
+ Args:
429
+ pid: Property id considered a 'base' key.
430
+ base_value: Base string without suffix.
431
+
432
+ Returns:
433
+ Optional[Any]: The found suffix, or None if nothing suitable is detected.
434
+ """
435
+ prop_keys = ("id_suffix", "suffix", "index", "seq", "order", "counter", "num")
436
+ for key in prop_keys:
437
+ try:
438
+ pm = self.node.properties.get(key)
439
+ if pm is not None and pm.value not in (None, ""):
440
+ return pm.value
441
+
442
+ except Exception:
443
+ pass
444
+
445
+ attr_keys = prop_keys + ("idIndex", "human_index", "friendly_index")
446
+ for key in attr_keys:
447
+ try:
448
+ if hasattr(self.node, key):
449
+ val = getattr(self.node, key)
450
+ if val not in (None, ""):
451
+ return val
452
+ except Exception:
453
+ pass
454
+ for dict_name in ("meta", "data", "extras", "extra", "ctx"):
455
+ try:
456
+ d = getattr(self.node, dict_name, None)
457
+ if isinstance(d, dict):
458
+ for k in prop_keys:
459
+ if k in d and d[k] not in (None, ""):
460
+ return d[k]
461
+ except Exception:
462
+ pass
463
+
464
+ try:
465
+ if self.graph and hasattr(self.graph, "nodes") and isinstance(self.graph.nodes, dict):
466
+ def _base_of(n: NodeModel) -> Optional[str]:
467
+ try:
468
+ ppm = n.properties.get(pid)
469
+ return None if ppm is None or ppm.value is None else str(ppm.value)
470
+ except Exception:
471
+ return None
472
+ same = [n for n in self.graph.nodes.values() if _base_of(n) == base_value]
473
+ if not same:
474
+ return None
475
+ same_sorted = sorted(same, key=lambda n: getattr(n, "uuid", ""))
476
+ idx = next((i for i, n in enumerate(same_sorted) if getattr(n, "uuid", None) == getattr(self.node, "uuid", None)), None)
477
+ if idx is not None:
478
+ return idx + 1
479
+ except Exception:
480
+ pass
481
+
482
+ return None
483
+
484
+
485
+ class NodeItem(QGraphicsWidget):
486
+ """Rounded node with title and ports aligned to property rows.
487
+
488
+ The node embeds a QWidget (NodeContentWidget) via QGraphicsProxyWidget to render
489
+ property editors. Input/output ports are aligned with editor rows for easy wiring.
490
+ """
491
+
492
+ def __init__(self, editor: "NodeEditor", node: NodeModel):
493
+ """Construct a node item and build its child content/ports.
494
+
495
+ Args:
496
+ editor: Owning NodeEditor instance.
497
+ node: Backing NodeModel with properties and type.
498
+ """
499
+ super().__init__()
500
+ self.editor = editor
501
+ self.graph = editor.graph
502
+ self.node = node
503
+ self.setFlag(QGraphicsItem.ItemIsMovable, True)
504
+ self.setFlag(QGraphicsItem.ItemIsSelectable, True)
505
+ self.setFlag(QGraphicsItem.ItemSendsGeometryChanges, True)
506
+ self.setZValue(2)
507
+ self._title_height = 24
508
+ self._radius = 6
509
+ self._proxy = QGraphicsProxyWidget(self)
510
+ self._content = NodeContentWidget(node, self.graph, self.editor)
511
+ self._content.setMouseTracking(True)
512
+ self._content.setAttribute(Qt.WA_Hover, True)
513
+ self._proxy.setWidget(self._content)
514
+ self._proxy.setAcceptHoverEvents(True)
515
+ self._proxy.installEventFilter(self)
516
+ self._proxy.setPos(0, self._title_height)
517
+ self._content.installEventFilter(self)
518
+ self._content.valueChanged.connect(self._on_value_changed)
519
+ self._in_ports: Dict[str, PortItem] = {}
520
+ self._out_ports: Dict[str, PortItem] = {}
521
+ self._edges: List[EdgeItem] = []
522
+ self._prev_pos = self.pos()
523
+
524
+ self._dragging = False
525
+ self._overlaps = False
526
+ self._start_pos = QPointF(self.pos())
527
+ self._last_valid_pos = QPointF(self.pos())
528
+ self._z_before_drag = self.zValue()
529
+
530
+ self.setAcceptHoverEvents(True)
531
+ self.setFiltersChildEvents(True)
532
+ self._resizing: bool = False
533
+ self._resize_mode: str = "none"
534
+ self._resize_press_local = QPointF()
535
+ self._resize_start_size = QSizeF()
536
+ self._hover_resize_mode: str = "none"
537
+
538
+ self._overlay = NodeOverlayItem(self)
539
+
540
+ # Enable scene-dependent logic only when safely added to the scene
541
+ self._ready_scene_ops: bool = False
542
+
543
+ self._sync_size()
544
+ self._build_ports()
545
+ self._sync_size()
546
+
547
+ # ---------- Spec helpers ----------
548
+ def _get_prop_spec(self, pid: str) -> Optional[Any]:
549
+ """Return a property specification object from the registry for this node type.
550
+
551
+ Searches common containers and getter methods to be resilient to spec shapes.
552
+ """
553
+ try:
554
+ reg = self.graph.registry
555
+ if not reg:
556
+ return None
557
+ type_spec = reg.get(self.node.type)
558
+ if not type_spec:
559
+ return None
560
+
561
+ for attr in ("properties", "props", "fields", "ports", "inputs", "outputs"):
562
+ try:
563
+ cont = getattr(type_spec, attr, None)
564
+ if isinstance(cont, dict) and pid in cont:
565
+ return cont[pid]
566
+ except Exception:
567
+ pass
568
+ for meth in ("get_property", "property_spec", "get_prop", "prop", "property"):
569
+ if hasattr(type_spec, meth):
570
+ try:
571
+ return getattr(type_spec, meth)(pid)
572
+ except Exception:
573
+ pass
574
+ except Exception:
575
+ pass
576
+ return None
577
+
578
+ def _spec_int(self, obj: Any, key: str, default: Optional[int]) -> Optional[int]:
579
+ """Get an integer attribute/key from a spec object or dict, with default."""
580
+ if obj is None:
581
+ return default
582
+ try:
583
+ v = getattr(obj, key, None)
584
+ if isinstance(v, int):
585
+ return v
586
+ except Exception:
587
+ pass
588
+ try:
589
+ if isinstance(obj, dict):
590
+ v = obj.get(key)
591
+ if isinstance(v, int):
592
+ return v
593
+ except Exception:
594
+ pass
595
+ return default
596
+
597
+ def allowed_capacity_for_pid(self, pid: str, side: str) -> Optional[int]:
598
+ """Return allowed connection capacity for a given property id and side ('input'/'output')."""
599
+ spec = self._get_prop_spec(pid)
600
+ if side == "input":
601
+ return self._spec_int(spec, "allowed_inputs", self._pm_allowed(pid, inputs=True))
602
+ else:
603
+ return self._spec_int(spec, "allowed_outputs", self._pm_allowed(pid, inputs=False))
604
+
605
+ def _pm_allowed(self, pid: str, inputs: bool) -> Optional[int]:
606
+ """Fallback to the PropertyModel's allowed_inputs/allowed_outputs if spec is absent."""
607
+ pm = self.node.properties.get(pid)
608
+ if not pm:
609
+ return None
610
+ return int(getattr(pm, "allowed_inputs" if inputs else "allowed_outputs", 0))
611
+
612
+ # ---------- Size helpers ----------
613
+ def _effective_hit_margin(self) -> float:
614
+ """Return the effective resize-grip 'hit' margin (visual margin minus inset)."""
615
+ margin = float(getattr(self.editor, "_resize_grip_margin", 12.0) or 12.0)
616
+ inset = float(getattr(self.editor, "_resize_grip_hit_inset", 3.0) or 0.0)
617
+ hit = max(4.0, margin - inset)
618
+ return hit
619
+
620
+ def _min_size_from_content(self) -> QSizeF:
621
+ """Compute the minimal node size based on content size plus paddings and grips."""
622
+ csz = self._content.sizeHint() if _qt_is_valid(self._content) else QSizeF(120, 40)
623
+ hit = self._effective_hit_margin()
624
+ w = max(80.0, float(csz.width()) + 16.0 + hit)
625
+ h_auto = float(csz.height()) + 16.0 + float(self._title_height) + hit
626
+ h = max(float(self._title_height + 16 + hit), h_auto)
627
+ return QSizeF(w, h)
628
+
629
+ def _apply_resize(self, new_size: QSizeF, clamp: bool = True):
630
+ """Resize the node and child proxy, optionally clamping to min size.
631
+
632
+ Args:
633
+ new_size: Requested QSizeF.
634
+ clamp: If True, clamp to the minimal size computed from content.
635
+
636
+ Side effects:
637
+ - Resizes QGraphicsWidget and its proxy widget.
638
+ - Recomputes port positions and updates overlay.
639
+ """
640
+ hit = self._effective_hit_margin()
641
+ minsz = self._min_size_from_content() if clamp else QSizeF(0.0, 0.0)
642
+ w = max(minsz.width(), float(new_size.width()))
643
+ h = max(minsz.height(), float(new_size.height()))
644
+ if _qt_is_valid(self._overlay):
645
+ try:
646
+ self._overlay.prepareGeometryChange()
647
+ except Exception:
648
+ pass
649
+ self.resize(QSizeF(w, h))
650
+ ph = max(0.0, h - self._title_height - hit)
651
+ pw = max(0.0, w - hit)
652
+ if _qt_is_valid(self._proxy):
653
+ try:
654
+ self._proxy.resize(pw, ph)
655
+ if _qt_is_valid(self._content) and self._content.layout():
656
+ try:
657
+ self._content.layout().activate()
658
+ except Exception:
659
+ pass
660
+ except Exception:
661
+ pass
662
+ self.update_ports_positions()
663
+ if _qt_is_valid(self._overlay):
664
+ try:
665
+ self._overlay.update()
666
+ except Exception:
667
+ pass
668
+ self.update()
669
+
670
+ def _sync_size(self):
671
+ """Size the node to fit content with paddings and current hit margin."""
672
+ csz = self._content.sizeHint()
673
+ hit = self._effective_hit_margin()
674
+ w = float(csz.width()) + 16.0 + hit
675
+ auto_h = float(csz.height()) + 16.0 + float(self._title_height) + hit
676
+ h = max(auto_h, float(self._title_height + 16 + hit))
677
+ if _qt_is_valid(self._overlay):
678
+ try:
679
+ self._overlay.prepareGeometryChange()
680
+ except Exception:
681
+ pass
682
+ self.resize(QSizeF(w, h))
683
+ self._proxy.resize(max(0.0, w - hit), max(0.0, h - self._title_height - hit))
684
+
685
+ # ---------- Port alignment ----------
686
+
687
+ def _compute_port_y_for_pid(self, pid: str) -> float:
688
+ """Compute the Y position for the port corresponding to a property id.
689
+
690
+ Aligns ports to editor rows; for multi-line editors (QTextEdit) uses top edge.
691
+ """
692
+ try:
693
+ w = self._content._editors.get(pid)
694
+ if w is not None and isinstance(w, QWidget):
695
+ geo = w.geometry()
696
+ base_y = float(self._proxy.pos().y()) + float(geo.y())
697
+ if isinstance(w, QTextEdit):
698
+ return base_y
699
+ return base_y + float(geo.height()) * 0.5
700
+ except Exception:
701
+ pass
702
+
703
+ idx = 0
704
+ try:
705
+ keys = list(self.node.properties.keys())
706
+ idx = keys.index(pid)
707
+ except Exception:
708
+ pass
709
+ yoff = self._title_height + 8 + 24 * idx
710
+ return float(yoff + 8)
711
+
712
+ def _build_ports(self):
713
+ """Create input/output PortItem(s) according to allowed capacities in the spec."""
714
+ for pid, pm in self.node.properties.items():
715
+ cap_in = self.allowed_capacity_for_pid(pid, "input")
716
+ cap_out = self.allowed_capacity_for_pid(pid, "output")
717
+ if isinstance(cap_in, int) and cap_in != 0:
718
+ p = PortItem(self, pid, "input")
719
+ p.setPos(-10, self._compute_port_y_for_pid(pid))
720
+ p.portClicked.connect(self.editor._on_port_clicked)
721
+ self._in_ports[pid] = p
722
+ if isinstance(cap_out, int) and cap_out != 0:
723
+ p = PortItem(self, pid, "output")
724
+ p.setPos(self.size().width() + 10, self._compute_port_y_for_pid(pid))
725
+ p.portClicked.connect(self.editor._on_port_clicked)
726
+ self._out_ports[pid] = p
727
+ self.update_ports_positions()
728
+
729
+ def update_ports_positions(self):
730
+ """Reposition ports along the left/right edges and refresh attached edges."""
731
+ for pid in self.node.properties.keys():
732
+ y = self._compute_port_y_for_pid(pid)
733
+ if pid in self._in_ports:
734
+ self._in_ports[pid].setPos(-10, y)
735
+ self._in_ports[pid].update_labels()
736
+ if pid in self._out_ports:
737
+ self._out_ports[pid].setPos(self.size().width() + 10, y)
738
+ self._out_ports[pid].update_labels()
739
+ for e in self._edges:
740
+ e.update_path()
741
+ if _qt_is_valid(self._overlay):
742
+ try:
743
+ self._overlay.update()
744
+ except Exception:
745
+ pass
746
+
747
+ def add_edge(self, edge: EdgeItem):
748
+ """Track an edge that connects to this node."""
749
+ if edge not in self._edges:
750
+ self._edges.append(edge)
751
+
752
+ def remove_edge(self, edge: EdgeItem):
753
+ """Stop tracking an edge (called before edge removal)."""
754
+ if edge in self._edges:
755
+ self._edges.remove(edge)
756
+
757
+ def boundingRect(self) -> QRectF:
758
+ """Return the node's local bounding rect."""
759
+ return QRectF(0, 0, self.size().width(), self.size().height())
760
+
761
+ def _hit_resize_zone(self, pos: QPointF) -> str:
762
+ """Return which resize zone is hit: 'right', 'bottom', 'corner', or 'none'."""
763
+ w = self.size().width()
764
+ h = self.size().height()
765
+ hit = self._effective_hit_margin()
766
+ right = (w - hit) <= pos.x() <= w
767
+ bottom = (h - hit) <= pos.y() <= h
768
+ if right and bottom:
769
+ return "corner"
770
+ if right:
771
+ return "right"
772
+ if bottom:
773
+ return "bottom"
774
+ return "none"
775
+
776
+ def _pid_from_content_y(self, y_local: float) -> Optional[str]:
777
+ """Map a local Y (NodeItem coords) to property id of the hovered editor row."""
778
+ try:
779
+ proxy_off = self._proxy.pos()
780
+ for pid, w in self._content._editors.items():
781
+ if not _qt_is_valid(w):
782
+ continue
783
+ geo = w.geometry()
784
+ top = float(proxy_off.y()) + float(geo.y())
785
+ bottom = top + float(geo.height())
786
+ if top <= y_local <= bottom:
787
+ return pid
788
+ except Exception:
789
+ pass
790
+ return None
791
+
792
+ def paint(self, p: QPainter, opt: QStyleOptionGraphicsItem, widget=None):
793
+ """Draw rounded background, title bar, border, resize hint, and node title."""
794
+ r = self.boundingRect()
795
+ path = QPainterPath()
796
+ path.addRoundedRect(r, self._radius, self._radius)
797
+ p.setRenderHint(QPainter.Antialiasing, True)
798
+
799
+ bg = self.editor._node_bg_color
800
+ try:
801
+ spec = self.graph.registry.get(self.node.type)
802
+ if spec and getattr(spec, "bg_color", None):
803
+ c = QColor(spec.bg_color)
804
+ if c.isValid():
805
+ bg = c
806
+ except Exception:
807
+ pass
808
+
809
+ border = self.editor._node_border_color
810
+ if self.isSelected():
811
+ border = self.editor._node_selection_color
812
+ p.fillPath(path, QBrush(bg))
813
+ pen = QPen(border)
814
+ pen.setWidthF(1.5)
815
+ p.setPen(pen)
816
+ p.drawPath(path)
817
+ title_rect = QRectF(r.left(), r.top(), r.width(), self._title_height)
818
+ p.fillRect(title_rect, self.editor._node_title_color)
819
+ p.setPen(QPen(Qt.white))
820
+ p.drawText(title_rect.adjusted(8, 0, -8, 0), Qt.AlignVCenter | Qt.AlignLeft, self.node.name)
821
+
822
+ hit = self._effective_hit_margin()
823
+ mode = self._resize_mode if self._resizing else self._hover_resize_mode
824
+ hl = QColor(border)
825
+ hl.setAlpha(110)
826
+ if mode in ("right", "corner"):
827
+ p.fillRect(QRectF(r.right() - hit, r.top() + self._title_height, hit, r.height() - self._title_height), hl)
828
+ if mode in ("bottom", "corner"):
829
+ p.fillRect(QRectF(r.left(), r.bottom() - hit, r.width(), hit), hl)
830
+
831
+ tri_color = border.lighter(150)
832
+ tri_color.setAlpha(140)
833
+ corner = QPainterPath()
834
+ corner.moveTo(r.right() - hit + 1, r.bottom() - 1)
835
+ corner.lineTo(r.right() - 1, r.bottom() - hit + 1)
836
+ corner.lineTo(r.right() - 1, r.bottom() - 1)
837
+ corner.closeSubpath()
838
+ p.fillPath(corner, tri_color)
839
+
840
+ hatch_pen = QPen(border.lighter(170), 1.6)
841
+ hatch_pen.setCosmetic(True)
842
+ p.setPen(hatch_pen)
843
+ x1 = r.right() - 5
844
+ y1 = r.bottom() - 1
845
+ for i in range(3):
846
+ p.drawLine(QPointF(x1 - 5 * i, y1), QPointF(r.right() - 1, r.bottom() - 5 - 5 * i))
847
+
848
+ def _apply_hover_from_pos(self, pos: QPointF):
849
+ """Update cursor and highlight for resize zones based on a local position.
850
+
851
+ Also sets a 'move' cursor (SizeAll) when hovering over the draggable area
852
+ of the node (title bar and non-interactive content gaps).
853
+ """
854
+ if self._resizing:
855
+ return
856
+
857
+ mode = self._hit_resize_zone(pos)
858
+ self._hover_resize_mode = mode
859
+
860
+ # Resize cursors have priority
861
+ if mode == "corner":
862
+ self.setCursor(Qt.SizeFDiagCursor)
863
+ elif mode == "right":
864
+ self.setCursor(Qt.SizeHorCursor)
865
+ elif mode == "bottom":
866
+ self.setCursor(Qt.SizeVerCursor)
867
+ else:
868
+ # Show move cursor on the title bar or non-interactive content area
869
+ show_move = False
870
+
871
+ # Title bar (always draggable)
872
+ if pos.y() <= self._title_height:
873
+ show_move = True
874
+ else:
875
+ # Inside content: only if not hovering an interactive editor
876
+ if _qt_is_valid(self._content) and _qt_is_valid(self._proxy):
877
+ local_in_content = pos - self._proxy.pos()
878
+ cx = int(local_in_content.x())
879
+ cy = int(local_in_content.y())
880
+ if 0 <= cx < int(self._content.width()) and 0 <= cy < int(self._content.height()):
881
+ hovered = self._content.childAt(cx, cy)
882
+ # Treat labels/empty gaps as draggable area (safe to move)
883
+ if hovered is None or isinstance(hovered, QLabel):
884
+ show_move = True
885
+
886
+ if show_move:
887
+ self.setCursor(Qt.SizeAllCursor) # 4-way move cursor
888
+ else:
889
+ self.unsetCursor()
890
+
891
+ self.update()
892
+
893
+ def hoverMoveEvent(self, event):
894
+ """While panning is active, suppress resize hints; otherwise update hover state."""
895
+ view = self.editor.view
896
+ if getattr(view, "_panning", False):
897
+ self._hover_resize_mode = "none"
898
+ self.unsetCursor()
899
+ self.update()
900
+ return super().hoverMoveEvent(event)
901
+
902
+ self._apply_hover_from_pos(event.pos())
903
+ super().hoverMoveEvent(event)
904
+
905
+ def hoverLeaveEvent(self, event):
906
+ """Reset hover-related visuals when cursor leaves the item."""
907
+ if not self._resizing:
908
+ self._hover_resize_mode = "none"
909
+ self.unsetCursor()
910
+ self.update()
911
+ super().hoverLeaveEvent(event)
912
+
913
+ def mousePressEvent(self, event):
914
+ """Start resize when pressing the proper zone; otherwise begin move drag."""
915
+ if event.button() == Qt.LeftButton:
916
+ mode = self._hit_resize_zone(event.pos())
917
+ if mode != "none":
918
+ self._resizing = True
919
+ self._resize_mode = mode
920
+ self._resize_press_local = QPointF(event.pos())
921
+ self._resize_start_size = QSizeF(self.size())
922
+ self._z_before_drag = self.zValue()
923
+ self.setZValue(self._z_before_drag + 100)
924
+ if mode == "corner":
925
+ self.setCursor(Qt.SizeFDiagCursor)
926
+ elif mode == "right":
927
+ self.setCursor(Qt.SizeHorCursor)
928
+ else:
929
+ self.setCursor(Qt.SizeVerCursor)
930
+ event.accept()
931
+ return
932
+
933
+ self._dragging = True
934
+ self._overlaps = False
935
+ self._start_pos = QPointF(self.pos())
936
+ self._last_valid_pos = QPointF(self.pos())
937
+ self._z_before_drag = self.zValue()
938
+ self.setZValue(self._z_before_drag + 100)
939
+ super().mousePressEvent(event)
940
+
941
+ def mouseMoveEvent(self, event):
942
+ """Resize while in resize mode; otherwise update hover visual and defer to base."""
943
+ if self._resizing:
944
+ dx = float(event.pos().x() - self._resize_press_local.x())
945
+ dy = float(event.pos().y() - self._resize_press_local.y())
946
+ w = self._resize_start_size.width()
947
+ h = self._resize_start_size.height()
948
+ if self._resize_mode in ("right", "corner"):
949
+ w = self._resize_start_size.width() + dx
950
+ if self._resize_mode in ("bottom", "corner"):
951
+ h = self._resize_start_size.height() + dy
952
+ self._apply_resize(QSizeF(w, h), clamp=True)
953
+ event.accept()
954
+ return
955
+ else:
956
+ self._apply_hover_from_pos(event.pos())
957
+ super().mouseMoveEvent(event)
958
+
959
+ def mouseReleaseEvent(self, event):
960
+ """Finalize resize/move and push corresponding undo commands when needed."""
961
+ if self._resizing and event.button() == Qt.LeftButton:
962
+ self._resizing = False
963
+ self.setZValue(self._z_before_drag)
964
+ self._apply_hover_from_pos(event.pos())
965
+ new_size = QSizeF(self.size())
966
+ if abs(new_size.width() - self._resize_start_size.width()) > 0.5 or \
967
+ abs(new_size.height() - self._resize_start_size.height()) > 0.5:
968
+ self.editor._undo.push(ResizeNodeCommand(self, self._resize_start_size, new_size))
969
+ self._resize_mode = "none"
970
+ event.accept()
971
+ return
972
+
973
+ if self._dragging and event.button() == Qt.LeftButton:
974
+ if self._overlaps:
975
+ self.setPos(self._last_valid_pos)
976
+ else:
977
+ if self.pos() != self._start_pos:
978
+ self.editor._undo.push(MoveNodeCommand(self, self._start_pos, self.pos()))
979
+ self.setOpacity(1.0)
980
+ self.setZValue(self._z_before_drag)
981
+ self._dragging = False
982
+ super().mouseReleaseEvent(event)
983
+
984
+ def eventFilter(self, obj, e):
985
+ """Filter events on proxy/content to keep hover/ports/overlay in sync."""
986
+ et = e.type()
987
+ try:
988
+ if obj is self._proxy and et in (QEvent.GraphicsSceneMouseMove, QEvent.GraphicsSceneHoverMove):
989
+ local = self.mapFromScene(e.scenePos())
990
+ self._apply_hover_from_pos(local)
991
+ # Row hover is controlled from content events; proxy move only updates resize hints.
992
+ return False
993
+
994
+ if obj is self._proxy and et in (QEvent.GraphicsSceneResize,):
995
+ self.update_ports_positions()
996
+ if _qt_is_valid(self._overlay):
997
+ self._overlay.update()
998
+ return False
999
+
1000
+ if obj is self._proxy and et == QEvent.GraphicsSceneHoverLeave:
1001
+ if not self._resizing:
1002
+ self._hover_resize_mode = "none"
1003
+ self.unsetCursor()
1004
+ self.update()
1005
+ # Clear row hover when leaving proxy area
1006
+ if _qt_is_valid(self._overlay):
1007
+ try:
1008
+ self._overlay.set_hover_pid(None)
1009
+ except Exception:
1010
+ pass
1011
+ return False
1012
+
1013
+ if obj is self._content and et in (QEvent.MouseMove, QEvent.HoverMove, QEvent.Enter):
1014
+ # Track pointer over content to drive row hover highlight
1015
+ if hasattr(e, "position"):
1016
+ p = e.position()
1017
+ px, py = float(p.x()), float(p.y())
1018
+ elif hasattr(e, "pos"):
1019
+ p = e.pos()
1020
+ px, py = float(p.x()), float(p.y())
1021
+ else:
1022
+ px = py = 0.0
1023
+ base = self._proxy.pos()
1024
+ local = QPointF(base.x() + px, base.y() + py)
1025
+
1026
+ self._apply_hover_from_pos(local)
1027
+
1028
+ if _qt_is_valid(self._overlay):
1029
+ try:
1030
+ pid = self._pid_from_content_y(local.y())
1031
+ self._overlay.set_hover_pid(pid)
1032
+ except Exception:
1033
+ pass
1034
+ return False
1035
+
1036
+ if obj is self._content and et in (QEvent.Leave, QEvent.HoverLeave):
1037
+ # Clear row hover when leaving the content area
1038
+ if _qt_is_valid(self._overlay):
1039
+ try:
1040
+ self._overlay.set_hover_pid(None)
1041
+ except Exception:
1042
+ pass
1043
+ return False
1044
+
1045
+ if obj is self._content and et in (QEvent.Resize, QEvent.LayoutRequest):
1046
+ self.update_ports_positions()
1047
+ if _qt_is_valid(self._overlay):
1048
+ self._overlay.update()
1049
+ return False
1050
+ except Exception:
1051
+ pass
1052
+ return False
1053
+
1054
+ def itemChange(self, change, value):
1055
+ """Handle collision hints during move and update edge paths after movement."""
1056
+ # Avoid touching the scene during construction/destruction/GC or while editor is closing
1057
+ if change == QGraphicsItem.ItemPositionChange:
1058
+ try:
1059
+ if not getattr(self, "_ready_scene_ops", False):
1060
+ return value
1061
+ ed = getattr(self, "editor", None)
1062
+ if ed is None or not getattr(ed, "_alive", True) or getattr(ed, "_closing", False):
1063
+ return value
1064
+ # Only evaluate overlaps while actively dragging (safe user interaction only)
1065
+ if not getattr(self, "_dragging", False):
1066
+ return value
1067
+
1068
+ sc = self.scene()
1069
+ if sc is None or not _qt_is_valid(sc):
1070
+ return value
1071
+
1072
+ new_pos: QPointF = value
1073
+ new_rect = QRectF(new_pos.x(), new_pos.y(), self.size().width(), self.size().height())
1074
+ overlap = False
1075
+ for it in sc.items(new_rect, Qt.IntersectsItemBoundingRect):
1076
+ if it is self:
1077
+ continue
1078
+ if isinstance(it, NodeItem):
1079
+ other = QRectF(it.scenePos().x(), it.scenePos().y(), it.size().width(), it.size().height())
1080
+ if new_rect.adjusted(1, 1, -1, -1).intersects(other.adjusted(1, 1, -1, -1)):
1081
+ overlap = True
1082
+ break
1083
+ self._overlaps = overlap
1084
+ try:
1085
+ self.setOpacity(0.5 if overlap else 1.0)
1086
+ except Exception:
1087
+ pass
1088
+ if not overlap:
1089
+ self._last_valid_pos = new_pos
1090
+ except Exception:
1091
+ return value
1092
+ return value
1093
+
1094
+ if change == QGraphicsItem.ItemPositionHasChanged:
1095
+ # Update edge paths only in safe, live state
1096
+ try:
1097
+ if not getattr(self, "_ready_scene_ops", False):
1098
+ return super().itemChange(change, value)
1099
+ ed = getattr(self, "editor", None)
1100
+ if ed is None or not getattr(ed, "_alive", True) or getattr(ed, "_closing", False):
1101
+ return super().itemChange(change, value)
1102
+ self._prev_pos = self.pos()
1103
+ for e in list(self._edges):
1104
+ if not _qt_is_valid(e):
1105
+ continue
1106
+ # Deep validity check for ports before path update
1107
+ sp = getattr(e, "src_port", None)
1108
+ dp = getattr(e, "dst_port", None)
1109
+ if not (_qt_is_valid(sp) and _qt_is_valid(dp)):
1110
+ continue
1111
+ if not (_qt_is_valid(getattr(sp, "node_item", None)) and _qt_is_valid(getattr(dp, "node_item", None))):
1112
+ continue
1113
+ e.update_path()
1114
+ except Exception:
1115
+ pass
1116
+ return super().itemChange(change, value)
1117
+
1118
+ return super().itemChange(change, value)
1119
+
1120
+ def contextMenuEvent(self, event):
1121
+ """Provide simple node context menu (rename/delete)."""
1122
+ menu = QMenu(self.editor.window())
1123
+ ss = self.editor.window().styleSheet()
1124
+ if ss:
1125
+ menu.setStyleSheet(ss)
1126
+ act_rename = QAction("Rename", menu)
1127
+ act_delete = QAction("Delete", menu)
1128
+ menu.addAction(act_rename)
1129
+ menu.addSeparator()
1130
+ menu.addAction(act_delete)
1131
+ chosen = menu.exec(event.screenPos())
1132
+ if chosen == act_rename:
1133
+ from PySide6.QtWidgets import QInputDialog
1134
+ new_name, ok = QInputDialog.getText(self.editor.window(), "Rename Node", "Name:", text=self.node.name)
1135
+ if ok and new_name:
1136
+ self.node.name = new_name
1137
+ self.update()
1138
+ elif chosen == act_delete:
1139
+ self.editor._delete_node_item(self)
1140
+
1141
+ def _on_value_changed(self, prop_id: str, value):
1142
+ """Persist value into the graph model when an editor changed."""
1143
+ self.graph.set_property_value(self.node.uuid, prop_id, value)
1144
+
1145
+ def detach_proxy_widget(self):
1146
+ """Detach and delete inner QWidget safely (used during teardown)."""
1147
+ try:
1148
+ if hasattr(self, "_proxy") and _qt_is_valid(self._proxy):
1149
+ try:
1150
+ self._proxy.removeEventFilter(self)
1151
+ except Exception:
1152
+ pass
1153
+ if hasattr(self, "_content") and _qt_is_valid(self._content):
1154
+ try:
1155
+ self._content.removeEventFilter(self)
1156
+ except Exception:
1157
+ pass
1158
+ try:
1159
+ self._content.valueChanged.disconnect(self._on_value_changed)
1160
+ except Exception:
1161
+ pass
1162
+
1163
+ if hasattr(self, "_proxy") and _qt_is_valid(self._proxy):
1164
+ try:
1165
+ self._proxy.setWidget(None)
1166
+ except Exception:
1167
+ pass
1168
+ if hasattr(self, "_content") and _qt_is_valid(self._content):
1169
+ try:
1170
+ self._content.setParent(None)
1171
+ self._content.deleteLater()
1172
+ except Exception:
1173
+ pass
1174
+ except Exception:
1175
+ pass
1176
+
1177
+ def pre_cleanup(self):
1178
+ """Break internal references and signals to avoid accessing deleted Qt objects."""
1179
+ try:
1180
+ # Disconnect port signals and drop references
1181
+ for p in list(self._in_ports.values()) + list(self._out_ports.values()):
1182
+ try:
1183
+ if _qt_is_valid(p):
1184
+ p.portClicked.disconnect(self.editor._on_port_clicked)
1185
+ except Exception:
1186
+ pass
1187
+ self._in_ports.clear()
1188
+ self._out_ports.clear()
1189
+ except Exception:
1190
+ pass
1191
+ try:
1192
+ # Break edges' back-references to ports
1193
+ for e in list(self._edges):
1194
+ try:
1195
+ e.src_port = None
1196
+ e.dst_port = None
1197
+ except Exception:
1198
+ pass
1199
+ self._edges.clear()
1200
+ except Exception:
1201
+ pass
1202
+
1203
+ def mark_ready_for_scene_ops(self, ready: bool = True):
1204
+ """Enable/disable scene-dependent operations (collision checks, edge updates)."""
1205
+ self._ready_scene_ops = bool(ready)