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