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.
- pygpt_net/CHANGELOG.txt +11 -0
- pygpt_net/__init__.py +3 -3
- pygpt_net/app.py +9 -5
- pygpt_net/controller/__init__.py +1 -0
- pygpt_net/controller/chat/common.py +115 -6
- pygpt_net/controller/chat/input.py +4 -1
- pygpt_net/controller/presets/editor.py +442 -39
- pygpt_net/controller/presets/presets.py +121 -6
- pygpt_net/controller/settings/editor.py +0 -15
- pygpt_net/controller/theme/markdown.py +2 -5
- pygpt_net/controller/ui/ui.py +4 -7
- pygpt_net/core/agents/custom/__init__.py +281 -0
- pygpt_net/core/agents/custom/debug.py +64 -0
- pygpt_net/core/agents/custom/factory.py +109 -0
- pygpt_net/core/agents/custom/graph.py +71 -0
- pygpt_net/core/agents/custom/llama_index/__init__.py +10 -0
- pygpt_net/core/agents/custom/llama_index/factory.py +100 -0
- pygpt_net/core/agents/custom/llama_index/router_streamer.py +106 -0
- pygpt_net/core/agents/custom/llama_index/runner.py +562 -0
- pygpt_net/core/agents/custom/llama_index/stream.py +56 -0
- pygpt_net/core/agents/custom/llama_index/utils.py +253 -0
- pygpt_net/core/agents/custom/logging.py +50 -0
- pygpt_net/core/agents/custom/memory.py +51 -0
- pygpt_net/core/agents/custom/router.py +155 -0
- pygpt_net/core/agents/custom/router_streamer.py +187 -0
- pygpt_net/core/agents/custom/runner.py +455 -0
- pygpt_net/core/agents/custom/schema.py +127 -0
- pygpt_net/core/agents/custom/utils.py +193 -0
- pygpt_net/core/agents/provider.py +72 -7
- pygpt_net/core/agents/runner.py +7 -4
- pygpt_net/core/agents/runners/helpers.py +1 -1
- pygpt_net/core/agents/runners/llama_workflow.py +3 -0
- pygpt_net/core/agents/runners/openai_workflow.py +8 -1
- pygpt_net/core/db/viewer.py +11 -5
- pygpt_net/{ui/widget/builder → core/node_editor}/__init__.py +2 -2
- pygpt_net/core/{builder → node_editor}/graph.py +28 -226
- pygpt_net/core/node_editor/models.py +118 -0
- pygpt_net/core/node_editor/types.py +78 -0
- pygpt_net/core/node_editor/utils.py +17 -0
- pygpt_net/core/presets/presets.py +216 -29
- pygpt_net/core/render/markdown/parser.py +0 -2
- pygpt_net/core/render/web/renderer.py +10 -8
- pygpt_net/data/config/config.json +5 -6
- pygpt_net/data/config/models.json +3 -3
- pygpt_net/data/config/settings.json +2 -38
- pygpt_net/data/locale/locale.de.ini +64 -1
- pygpt_net/data/locale/locale.en.ini +63 -4
- pygpt_net/data/locale/locale.es.ini +64 -1
- pygpt_net/data/locale/locale.fr.ini +64 -1
- pygpt_net/data/locale/locale.it.ini +64 -1
- pygpt_net/data/locale/locale.pl.ini +65 -2
- pygpt_net/data/locale/locale.uk.ini +64 -1
- pygpt_net/data/locale/locale.zh.ini +64 -1
- pygpt_net/data/locale/plugin.cmd_system.en.ini +62 -66
- pygpt_net/item/agent.py +5 -1
- pygpt_net/item/preset.py +19 -1
- pygpt_net/provider/agents/base.py +33 -2
- pygpt_net/provider/agents/llama_index/flow_from_schema.py +92 -0
- pygpt_net/provider/agents/openai/flow_from_schema.py +96 -0
- pygpt_net/provider/core/agent/json_file.py +11 -5
- pygpt_net/provider/core/config/patch.py +10 -1
- pygpt_net/provider/core/config/patches/patch_before_2_6_42.py +0 -6
- pygpt_net/tools/agent_builder/tool.py +233 -52
- pygpt_net/tools/agent_builder/ui/dialogs.py +172 -28
- pygpt_net/tools/agent_builder/ui/list.py +37 -10
- pygpt_net/ui/__init__.py +2 -4
- pygpt_net/ui/dialog/about.py +58 -38
- pygpt_net/ui/dialog/db.py +142 -3
- pygpt_net/ui/dialog/preset.py +62 -8
- pygpt_net/ui/layout/toolbox/presets.py +52 -16
- pygpt_net/ui/main.py +1 -1
- pygpt_net/ui/widget/dialog/db.py +0 -0
- pygpt_net/ui/widget/lists/preset.py +644 -60
- pygpt_net/{core/builder → ui/widget/node_editor}/__init__.py +2 -2
- pygpt_net/ui/widget/node_editor/command.py +373 -0
- pygpt_net/ui/widget/node_editor/config.py +157 -0
- pygpt_net/ui/widget/node_editor/editor.py +2070 -0
- pygpt_net/ui/widget/node_editor/item.py +493 -0
- pygpt_net/ui/widget/node_editor/node.py +1460 -0
- pygpt_net/ui/widget/node_editor/utils.py +17 -0
- pygpt_net/ui/widget/node_editor/view.py +364 -0
- pygpt_net/ui/widget/tabs/output.py +1 -1
- pygpt_net/ui/widget/textarea/input.py +2 -2
- pygpt_net/utils.py +114 -2
- {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.61.dist-info}/METADATA +80 -93
- {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.61.dist-info}/RECORD +88 -61
- pygpt_net/core/agents/custom.py +0 -150
- pygpt_net/ui/widget/builder/editor.py +0 -2001
- {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.61.dist-info}/LICENSE +0 -0
- {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.61.dist-info}/WHEEL +0 -0
- {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)
|