pygpt-net 2.6.59__py3-none-any.whl → 2.6.60__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pygpt_net/CHANGELOG.txt +4 -0
- pygpt_net/__init__.py +3 -3
- pygpt_net/app.py +9 -5
- pygpt_net/controller/__init__.py +1 -0
- pygpt_net/controller/presets/editor.py +442 -39
- pygpt_net/core/agents/custom/__init__.py +275 -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 +89 -0
- pygpt_net/core/agents/custom/llama_index/router_streamer.py +106 -0
- pygpt_net/core/agents/custom/llama_index/runner.py +529 -0
- pygpt_net/core/agents/custom/llama_index/stream.py +56 -0
- pygpt_net/core/agents/custom/llama_index/utils.py +242 -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 +116 -0
- pygpt_net/core/agents/custom/router_streamer.py +187 -0
- pygpt_net/core/agents/custom/runner.py +454 -0
- pygpt_net/core/agents/custom/schema.py +125 -0
- pygpt_net/core/agents/custom/utils.py +181 -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/{ui/widget/builder → core/node_editor}/__init__.py +2 -2
- pygpt_net/core/{builder → node_editor}/graph.py +11 -218
- pygpt_net/core/node_editor/models.py +111 -0
- pygpt_net/core/node_editor/types.py +76 -0
- pygpt_net/core/node_editor/utils.py +17 -0
- pygpt_net/core/render/web/renderer.py +10 -8
- pygpt_net/data/config/config.json +3 -3
- pygpt_net/data/config/models.json +3 -3
- pygpt_net/data/locale/locale.en.ini +4 -4
- 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/tools/agent_builder/tool.py +217 -52
- pygpt_net/tools/agent_builder/ui/dialogs.py +119 -24
- pygpt_net/tools/agent_builder/ui/list.py +37 -10
- pygpt_net/ui/dialog/preset.py +16 -1
- pygpt_net/ui/main.py +1 -1
- 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/editor.py +2038 -0
- pygpt_net/ui/widget/node_editor/item.py +492 -0
- pygpt_net/ui/widget/node_editor/node.py +1205 -0
- pygpt_net/ui/widget/node_editor/utils.py +17 -0
- pygpt_net/ui/widget/node_editor/view.py +247 -0
- {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.60.dist-info}/METADATA +72 -2
- {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.60.dist-info}/RECORD +59 -33
- 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.60.dist-info}/LICENSE +0 -0
- {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.60.dist-info}/WHEEL +0 -0
- {pygpt_net-2.6.59.dist-info → pygpt_net-2.6.60.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
# ================================================== #
|
|
4
|
+
# This file is a part of PYGPT package #
|
|
5
|
+
# Website: https://pygpt.net #
|
|
6
|
+
# GitHub: https://github.com/szczyglis-dev/py-gpt #
|
|
7
|
+
# MIT License #
|
|
8
|
+
# Created By : Marcin Szczygliński #
|
|
9
|
+
# Updated Date: 2025.09.24 00:00:00 #
|
|
10
|
+
# ================================================== #
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
from typing import Optional, List, Tuple, Dict
|
|
14
|
+
|
|
15
|
+
from PySide6.QtCore import Qt, QPointF, QRectF, Signal
|
|
16
|
+
from PySide6.QtGui import QAction, QBrush, QColor, QPainter, QPainterPath, QPen, QPainterPathStroker, QFont
|
|
17
|
+
from PySide6.QtWidgets import (
|
|
18
|
+
QWidget, QGraphicsItem, QGraphicsPathItem, QGraphicsObject, QStyleOptionGraphicsItem, QMenu, QGraphicsSimpleTextItem
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
from .utils import _qt_is_valid
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ------------------------ Items: Port, Edge, Node ------------------------
|
|
25
|
+
|
|
26
|
+
class PortItem(QGraphicsObject):
|
|
27
|
+
"""Circular port that can initiate and accept connections.
|
|
28
|
+
|
|
29
|
+
A port is associated with a node property (prop_id) and has a side:
|
|
30
|
+
- 'input' for inbound connections
|
|
31
|
+
- 'output' for outbound connections
|
|
32
|
+
|
|
33
|
+
The item also renders a small capacity label (how many connections are allowed),
|
|
34
|
+
based on the live node type specification from the registry.
|
|
35
|
+
"""
|
|
36
|
+
radius = 6.0
|
|
37
|
+
portClicked = Signal(object) # self
|
|
38
|
+
side: str # "input" or "output"
|
|
39
|
+
|
|
40
|
+
def __init__(self, node_item: "NodeItem", prop_id: str, side: str):
|
|
41
|
+
"""Create a new port item.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
node_item: Parent NodeItem to which this port belongs.
|
|
45
|
+
prop_id: Property identifier this port represents.
|
|
46
|
+
side: 'input' or 'output'.
|
|
47
|
+
"""
|
|
48
|
+
super().__init__(node_item)
|
|
49
|
+
self.node_item = node_item
|
|
50
|
+
self.prop_id = prop_id
|
|
51
|
+
self.side = side
|
|
52
|
+
self.setAcceptHoverEvents(True)
|
|
53
|
+
self.setAcceptedMouseButtons(Qt.LeftButton)
|
|
54
|
+
self._hover = False
|
|
55
|
+
self._connected_count = 0
|
|
56
|
+
self._can_accept = False
|
|
57
|
+
self.setZValue(3)
|
|
58
|
+
|
|
59
|
+
# Small label for capacity only (IN/OUT removed from UI)
|
|
60
|
+
self._label_io = QGraphicsSimpleTextItem(self) # kept for compatibility; hidden
|
|
61
|
+
self._label_cap = QGraphicsSimpleTextItem(self)
|
|
62
|
+
|
|
63
|
+
# Fonts for capacity label
|
|
64
|
+
self._font_small = QFont()
|
|
65
|
+
self._font_small.setPixelSize(9)
|
|
66
|
+
self._font_cap_num = QFont()
|
|
67
|
+
self._font_cap_num.setPixelSize(9)
|
|
68
|
+
self._font_cap_inf = QFont()
|
|
69
|
+
self._font_cap_inf.setPixelSize(14) # slightly bigger infinity sign for readability
|
|
70
|
+
|
|
71
|
+
self._label_io.setFont(self._font_small)
|
|
72
|
+
self._label_cap.setFont(self._font_cap_num)
|
|
73
|
+
|
|
74
|
+
self._update_label_texts()
|
|
75
|
+
self._update_label_colors()
|
|
76
|
+
self._update_label_positions()
|
|
77
|
+
self._update_tooltip() # initial tooltip
|
|
78
|
+
|
|
79
|
+
def _allowed_capacity(self) -> Optional[int]:
|
|
80
|
+
"""Return allowed connection count for this port from live registry/spec."""
|
|
81
|
+
return self.node_item.allowed_capacity_for_pid(self.prop_id, self.side)
|
|
82
|
+
|
|
83
|
+
def _update_label_texts(self):
|
|
84
|
+
"""Update label texts to reflect current capacity for this port."""
|
|
85
|
+
# Hide IN/OUT label completely
|
|
86
|
+
self._label_io.setText("")
|
|
87
|
+
self._label_io.setVisible(False)
|
|
88
|
+
|
|
89
|
+
# Capacity: show max allowed for this port side from live spec
|
|
90
|
+
cap_val = self._allowed_capacity()
|
|
91
|
+
text = ""
|
|
92
|
+
if isinstance(cap_val, int) and cap_val != 0:
|
|
93
|
+
if cap_val < 0:
|
|
94
|
+
text = "\u221E"
|
|
95
|
+
else:
|
|
96
|
+
text = str(cap_val)
|
|
97
|
+
self._label_cap.setText(text)
|
|
98
|
+
# Adjust capacity font: make infinity larger, keep numbers as-is
|
|
99
|
+
if text == "\u221E":
|
|
100
|
+
self._label_cap.setFont(self._font_cap_inf)
|
|
101
|
+
else:
|
|
102
|
+
self._label_cap.setFont(self._font_cap_num)
|
|
103
|
+
|
|
104
|
+
def _update_label_colors(self):
|
|
105
|
+
"""Apply theme colors to the labels."""
|
|
106
|
+
editor = self.node_item.editor
|
|
107
|
+
self._label_io.setBrush(QBrush(editor._port_label_color))
|
|
108
|
+
self._label_cap.setBrush(QBrush(editor._port_capacity_color))
|
|
109
|
+
|
|
110
|
+
def _update_label_positions(self):
|
|
111
|
+
"""Position capacity label around the port, respecting the port side."""
|
|
112
|
+
r = self.radius
|
|
113
|
+
cap_rect = self._label_cap.boundingRect()
|
|
114
|
+
gap = 3.0
|
|
115
|
+
|
|
116
|
+
# Move capacity label up by additional ~3px (total ~7px up from original baseline)
|
|
117
|
+
dy_cap = -6.0
|
|
118
|
+
|
|
119
|
+
if self.side == "input":
|
|
120
|
+
if self._label_cap.text():
|
|
121
|
+
cap_x = -r - gap - cap_rect.width()
|
|
122
|
+
self._label_cap.setPos(cap_x, -cap_rect.height() / 2.0 + dy_cap)
|
|
123
|
+
else:
|
|
124
|
+
self._label_cap.setPos(-r - gap, -cap_rect.height() / 2.0 + dy_cap)
|
|
125
|
+
else:
|
|
126
|
+
if self._label_cap.text():
|
|
127
|
+
cap_x = r + gap
|
|
128
|
+
self._label_cap.setPos(cap_x, -cap_rect.height() / 2.0 + dy_cap)
|
|
129
|
+
else:
|
|
130
|
+
self._label_cap.setPos(r + gap, -cap_rect.height() / 2.0 + dy_cap)
|
|
131
|
+
|
|
132
|
+
def _update_tooltip(self):
|
|
133
|
+
"""Build and apply a helpful tooltip for this port.
|
|
134
|
+
|
|
135
|
+
Shows node name, port side/id, and allowed connections, with interaction hints.
|
|
136
|
+
"""
|
|
137
|
+
node_name = ""
|
|
138
|
+
try:
|
|
139
|
+
node_name = self.node_item.node.name
|
|
140
|
+
except Exception:
|
|
141
|
+
pass
|
|
142
|
+
cap_val = self._allowed_capacity()
|
|
143
|
+
if isinstance(cap_val, int):
|
|
144
|
+
if cap_val < 0:
|
|
145
|
+
cap_str = "unlimited (∞)"
|
|
146
|
+
else:
|
|
147
|
+
cap_str = str(cap_val)
|
|
148
|
+
else:
|
|
149
|
+
cap_str = "n/a"
|
|
150
|
+
tip = (
|
|
151
|
+
f"Node: {node_name}\n"
|
|
152
|
+
f"Port: {self.side.upper()} • {self.prop_id}\n"
|
|
153
|
+
f"Allowed connections: {cap_str}\n\n"
|
|
154
|
+
f"Click: start a new connection\n"
|
|
155
|
+
f"Ctrl+Click: rewire/detach existing"
|
|
156
|
+
)
|
|
157
|
+
self.setToolTip(tip)
|
|
158
|
+
try:
|
|
159
|
+
self._label_cap.setToolTip(tip)
|
|
160
|
+
except Exception:
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
def notify_theme_changed(self):
|
|
164
|
+
"""Refresh colors when global theme changes."""
|
|
165
|
+
self._update_label_colors()
|
|
166
|
+
self.update()
|
|
167
|
+
|
|
168
|
+
def boundingRect(self) -> QRectF:
|
|
169
|
+
"""Compute a bounding rect large enough for the port and its label."""
|
|
170
|
+
r = self.radius
|
|
171
|
+
cap_rect = self._label_cap.boundingRect()
|
|
172
|
+
gap = 3.0
|
|
173
|
+
cap_text = self._label_cap.text()
|
|
174
|
+
|
|
175
|
+
# Allocate space only for the capacity label (IN/OUT removed)
|
|
176
|
+
left_extra = 6.0
|
|
177
|
+
right_extra = 6.0
|
|
178
|
+
if self.side == "input" and cap_text:
|
|
179
|
+
left_extra = gap + cap_rect.width() + 4.0
|
|
180
|
+
if self.side == "output" and cap_text:
|
|
181
|
+
right_extra = gap + cap_rect.width() + 4.0
|
|
182
|
+
|
|
183
|
+
# Add a bit more vertical padding to fully cover the label lifted upwards
|
|
184
|
+
h = max(2 * r, cap_rect.height()) + 12.0
|
|
185
|
+
w = 2 * r + left_extra + right_extra
|
|
186
|
+
return QRectF(-r - left_extra, -h / 2.0, w, h)
|
|
187
|
+
|
|
188
|
+
def shape(self) -> QPainterPath:
|
|
189
|
+
"""Use a larger 'pick' radius for easier mouse interaction."""
|
|
190
|
+
pick_r = float(getattr(self.node_item.editor, "_port_pick_radius", 10.0)) or 10.0
|
|
191
|
+
p = QPainterPath()
|
|
192
|
+
p.addEllipse(QRectF(-pick_r, -pick_r, 2 * pick_r, 2 * pick_r))
|
|
193
|
+
return p
|
|
194
|
+
|
|
195
|
+
def paint(self, p: QPainter, opt: QStyleOptionGraphicsItem, widget=None):
|
|
196
|
+
"""Render the port as a filled circle with an optional highlight ring."""
|
|
197
|
+
editor = self.node_item.editor
|
|
198
|
+
base = editor._port_input_color if self.side == "input" else editor._port_output_color
|
|
199
|
+
color = editor._port_connected_color if self._connected_count > 0 else base
|
|
200
|
+
if self._hover:
|
|
201
|
+
color = color.lighter(130)
|
|
202
|
+
p.setRenderHint(QPainter.Antialiasing, True)
|
|
203
|
+
p.setPen(Qt.NoPen)
|
|
204
|
+
p.setBrush(QBrush(color))
|
|
205
|
+
p.drawEllipse(QRectF(-self.radius, -self.radius, 2 * self.radius, 2 * self.radius))
|
|
206
|
+
if self._can_accept:
|
|
207
|
+
ring = QPen(editor._port_accept_color, 3.0)
|
|
208
|
+
ring.setCosmetic(True)
|
|
209
|
+
p.setPen(ring)
|
|
210
|
+
p.setBrush(Qt.NoBrush)
|
|
211
|
+
p.drawEllipse(QRectF(-self.radius, -self.radius, 2 * self.radius, 2 * self.radius))
|
|
212
|
+
|
|
213
|
+
def hoverEnterEvent(self, e):
|
|
214
|
+
"""Enable hover flag and repaint."""
|
|
215
|
+
self._hover = True
|
|
216
|
+
self.update()
|
|
217
|
+
super().hoverEnterEvent(e)
|
|
218
|
+
|
|
219
|
+
def hoverLeaveEvent(self, e):
|
|
220
|
+
"""Disable hover flag and repaint."""
|
|
221
|
+
self._hover = False
|
|
222
|
+
self.update()
|
|
223
|
+
super().hoverLeaveEvent(e)
|
|
224
|
+
|
|
225
|
+
def mousePressEvent(self, e):
|
|
226
|
+
"""Emit portClicked on left click to begin a connection or rewire."""
|
|
227
|
+
if e.button() == Qt.LeftButton:
|
|
228
|
+
self.node_item.editor._dbg(f"Port clicked: side={self.side}, node={self.node_item.node.name}({self.node_item.node.uuid}), prop={self.prop_id}, connected_count={self._connected_count}")
|
|
229
|
+
self.portClicked.emit(self)
|
|
230
|
+
e.accept()
|
|
231
|
+
return
|
|
232
|
+
super().mousePressEvent(e)
|
|
233
|
+
|
|
234
|
+
def increment_connections(self, delta: int):
|
|
235
|
+
"""Increment or decrement the connected edges counter and repaint.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
delta: Positive to increase, negative to decrease. Result is clamped to >= 0.
|
|
239
|
+
"""
|
|
240
|
+
self._connected_count = max(0, self._connected_count + delta)
|
|
241
|
+
self.update()
|
|
242
|
+
|
|
243
|
+
def set_accept_highlight(self, enabled: bool):
|
|
244
|
+
"""Toggle a visible 'can accept connection' ring."""
|
|
245
|
+
if self._can_accept != enabled:
|
|
246
|
+
self._can_accept = enabled
|
|
247
|
+
self.update()
|
|
248
|
+
|
|
249
|
+
def update_labels(self):
|
|
250
|
+
"""Rebuild label text/position and tooltip (e.g., after spec/theme change)."""
|
|
251
|
+
self._update_label_texts()
|
|
252
|
+
self._update_label_positions()
|
|
253
|
+
self._update_tooltip()
|
|
254
|
+
self.update()
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class EdgeItem(QGraphicsPathItem):
|
|
258
|
+
"""Cubic bezier edge connecting two PortItem endpoints.
|
|
259
|
+
|
|
260
|
+
The item can be temporary (during interaction) or persistent (synced with the model).
|
|
261
|
+
It supports selection, hover highlight and a context menu for deletion.
|
|
262
|
+
"""
|
|
263
|
+
def __init__(self, src_port: PortItem, dst_port: PortItem, temporary: bool = False):
|
|
264
|
+
"""Initialize an edge between src_port and dst_port.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
src_port: Output side port.
|
|
268
|
+
dst_port: Input side port (or same as src during interactive drag).
|
|
269
|
+
temporary: When True, draws with dashed pen and does not expose context actions.
|
|
270
|
+
"""
|
|
271
|
+
super().__init__()
|
|
272
|
+
self.src_port = src_port
|
|
273
|
+
self.dst_port = dst_port
|
|
274
|
+
self.temporary = temporary
|
|
275
|
+
self.setZValue(1)
|
|
276
|
+
self.setFlag(QGraphicsItem.ItemIsSelectable, True)
|
|
277
|
+
self.setAcceptedMouseButtons(Qt.LeftButton | Qt.RightButton)
|
|
278
|
+
self._editor = self.src_port.node_item.editor
|
|
279
|
+
self._hover = False
|
|
280
|
+
self._drag_primed = False
|
|
281
|
+
self._drag_start_scene = QPointF()
|
|
282
|
+
self._update_pen()
|
|
283
|
+
|
|
284
|
+
def set_hovered(self, hovered: bool):
|
|
285
|
+
"""Set hover state and refresh pen color."""
|
|
286
|
+
if self._hover != hovered:
|
|
287
|
+
self._hover = hovered
|
|
288
|
+
self._update_pen()
|
|
289
|
+
|
|
290
|
+
def _update_pen(self):
|
|
291
|
+
"""Update the pen based on hover/selection/temporary state."""
|
|
292
|
+
color = self._editor._edge_selected_color if (self._hover or self.isSelected()) else self._editor._edge_color
|
|
293
|
+
pen = QPen(color)
|
|
294
|
+
pen.setWidthF(2.0 if not self.temporary else 1.5)
|
|
295
|
+
pen.setStyle(Qt.SolidLine if not self.temporary else Qt.DashLine)
|
|
296
|
+
pen.setCosmetic(True)
|
|
297
|
+
self.setPen(pen)
|
|
298
|
+
self.update()
|
|
299
|
+
|
|
300
|
+
def itemChange(self, change, value):
|
|
301
|
+
"""Refresh pen when selection state changes."""
|
|
302
|
+
if change == QGraphicsItem.ItemSelectedHasChanged:
|
|
303
|
+
self._update_pen()
|
|
304
|
+
return super().itemChange(change, value)
|
|
305
|
+
|
|
306
|
+
def shape(self) -> QPainterPath:
|
|
307
|
+
"""Return an inflated path for comfortable picking/selection."""
|
|
308
|
+
stroker = QPainterPathStroker()
|
|
309
|
+
width = float(getattr(self._editor, "_edge_pick_width", 12.0) or 12.0)
|
|
310
|
+
stroker.setWidth(width)
|
|
311
|
+
stroker.setCapStyle(Qt.RoundCap)
|
|
312
|
+
stroker.setJoinStyle(Qt.RoundJoin)
|
|
313
|
+
return stroker.createStroke(self.path())
|
|
314
|
+
|
|
315
|
+
def update_path(self, end_pos: Optional[QPointF] = None):
|
|
316
|
+
"""Recompute the cubic path between endpoints.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
end_pos: Optional scene position for the free endpoint while dragging.
|
|
320
|
+
When None, uses dst_port scenePos().
|
|
321
|
+
|
|
322
|
+
Notes:
|
|
323
|
+
Control points are placed horizontally at 50% of the delta X for a classic bezier look.
|
|
324
|
+
"""
|
|
325
|
+
try:
|
|
326
|
+
sp = getattr(self, "src_port", None)
|
|
327
|
+
dp = getattr(self, "dst_port", None)
|
|
328
|
+
if not _qt_is_valid(sp):
|
|
329
|
+
return
|
|
330
|
+
p0 = sp.scenePos()
|
|
331
|
+
if end_pos is not None:
|
|
332
|
+
p1 = end_pos
|
|
333
|
+
else:
|
|
334
|
+
if not _qt_is_valid(dp):
|
|
335
|
+
return
|
|
336
|
+
p1 = dp.scenePos()
|
|
337
|
+
|
|
338
|
+
dx = abs(p1.x() - p0.x())
|
|
339
|
+
c1 = QPointF(p0.x() + dx * 0.5, p0.y())
|
|
340
|
+
c2 = QPointF(p1.x() - dx * 0.5, p1.y())
|
|
341
|
+
p = QPainterPath()
|
|
342
|
+
p.moveTo(p0)
|
|
343
|
+
p.cubicTo(c1, c2, p1)
|
|
344
|
+
self.setPath(p)
|
|
345
|
+
except Exception:
|
|
346
|
+
# Fail-safe during GC/teardown
|
|
347
|
+
return
|
|
348
|
+
|
|
349
|
+
def contextMenuEvent(self, event):
|
|
350
|
+
"""Show a context menu to delete this connection (only for persistent edges)."""
|
|
351
|
+
if self.temporary:
|
|
352
|
+
return
|
|
353
|
+
self._editor._dbg(f"Edge context menu on edge id={id(self)}")
|
|
354
|
+
menu = QMenu(self._editor.window())
|
|
355
|
+
ss = self._editor.window().styleSheet()
|
|
356
|
+
if ss:
|
|
357
|
+
menu.setStyleSheet(ss)
|
|
358
|
+
act_del = QAction("Delete connection", menu)
|
|
359
|
+
menu.addAction(act_del)
|
|
360
|
+
chosen = menu.exec(event.screenPos())
|
|
361
|
+
if chosen == act_del:
|
|
362
|
+
self._editor._dbg(f"Context DELETE on edge id={id(self)} (undoable)")
|
|
363
|
+
self._editor._delete_edge_undoable(self)
|
|
364
|
+
|
|
365
|
+
def mousePressEvent(self, e):
|
|
366
|
+
"""Prime dragging on left-click, enabling rewire from edge."""
|
|
367
|
+
if not self.temporary and e.button() == Qt.LeftButton:
|
|
368
|
+
if not self.isSelected():
|
|
369
|
+
self.setSelected(True)
|
|
370
|
+
self._drag_primed = True
|
|
371
|
+
self._drag_start_scene = e.scenePos()
|
|
372
|
+
self._editor._dbg(f"Edge LMB press -> primed drag, edge id={id(self)}")
|
|
373
|
+
e.accept()
|
|
374
|
+
return
|
|
375
|
+
super().mousePressEvent(e)
|
|
376
|
+
|
|
377
|
+
def mouseMoveEvent(self, e):
|
|
378
|
+
"""When drag threshold is exceeded, start interactive rewire from edge."""
|
|
379
|
+
if not self.temporary and self._drag_primed:
|
|
380
|
+
dist = abs(e.scenePos().x() - self._drag_start_scene.x()) + abs(e.scenePos().y() - self._drag_start_scene.y())
|
|
381
|
+
if dist > 6 and getattr(self._editor, "_wire_state", "idle") == "idle":
|
|
382
|
+
self._editor._dbg(f"Edge drag start -> begin REWIRE from EDGE (move dst), edge id={id(self)}")
|
|
383
|
+
self._editor._start_rewire_from_edge(self, e.scenePos())
|
|
384
|
+
self._drag_primed = False
|
|
385
|
+
e.accept()
|
|
386
|
+
return
|
|
387
|
+
super().mouseMoveEvent(e)
|
|
388
|
+
|
|
389
|
+
def mouseReleaseEvent(self, e):
|
|
390
|
+
"""Reset the primed state on release."""
|
|
391
|
+
self._drag_primed = False
|
|
392
|
+
super().mouseReleaseEvent(e)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
class NodeOverlayItem(QGraphicsItem):
|
|
396
|
+
"""Lightweight overlay drawn above the proxy widget to add subtle guides.
|
|
397
|
+
|
|
398
|
+
The overlay inspects the positions of property editors inside NodeContentWidget and
|
|
399
|
+
draws separators between rows to improve readability.
|
|
400
|
+
"""
|
|
401
|
+
def __init__(self, node_item: "NodeItem"):
|
|
402
|
+
"""Create an overlay attached to the given NodeItem."""
|
|
403
|
+
super().__init__(node_item)
|
|
404
|
+
self.node_item = node_item
|
|
405
|
+
self.setAcceptedMouseButtons(Qt.NoButton)
|
|
406
|
+
self.setAcceptHoverEvents(False)
|
|
407
|
+
self.setZValue(2.1)
|
|
408
|
+
# Track which property row is hovered to paint a subtle background
|
|
409
|
+
self._hover_pid: Optional[str] = None
|
|
410
|
+
|
|
411
|
+
def boundingRect(self) -> QRectF:
|
|
412
|
+
"""Compute the overlay rect (content area below the title)."""
|
|
413
|
+
ni = self.node_item
|
|
414
|
+
hit = ni._effective_hit_margin()
|
|
415
|
+
return QRectF(0.0, float(ni._title_height),
|
|
416
|
+
max(0.0, ni.size().width() - hit),
|
|
417
|
+
max(0.0, ni.size().height() - ni._title_height - hit))
|
|
418
|
+
|
|
419
|
+
def set_hover_pid(self, pid: Optional[str]):
|
|
420
|
+
"""Set hovered property id to be highlighted."""
|
|
421
|
+
if self._hover_pid != pid:
|
|
422
|
+
self._hover_pid = pid
|
|
423
|
+
self.update()
|
|
424
|
+
|
|
425
|
+
def paint(self, p: QPainter, opt: QStyleOptionGraphicsItem, widget=None):
|
|
426
|
+
"""Draw full-width separators and a hover background that snaps to separators (no vertical gaps)."""
|
|
427
|
+
ni = self.node_item
|
|
428
|
+
if not _qt_is_valid(ni):
|
|
429
|
+
return
|
|
430
|
+
p.setRenderHint(QPainter.Antialiasing, True)
|
|
431
|
+
|
|
432
|
+
base = QColor(ni.editor._node_border_color)
|
|
433
|
+
br = self.boundingRect()
|
|
434
|
+
left = br.left() # full width
|
|
435
|
+
right = br.right() # full width
|
|
436
|
+
|
|
437
|
+
try:
|
|
438
|
+
# Collect editor widgets -> rows and keep property order
|
|
439
|
+
editors: List[Tuple[str, QWidget]] = []
|
|
440
|
+
for pid in ni.node.properties.keys():
|
|
441
|
+
w = ni._content._editors.get(pid)
|
|
442
|
+
if isinstance(w, QWidget) and _qt_is_valid(w):
|
|
443
|
+
editors.append((pid, w))
|
|
444
|
+
if not editors:
|
|
445
|
+
return
|
|
446
|
+
|
|
447
|
+
proxy_off = ni._proxy.pos()
|
|
448
|
+
rows: List[Tuple[str, float, float]] = []
|
|
449
|
+
for pid, w in editors:
|
|
450
|
+
geo = w.geometry()
|
|
451
|
+
top = float(proxy_off.y()) + float(geo.y())
|
|
452
|
+
bottom = top + float(geo.height())
|
|
453
|
+
rows.append((pid, top, bottom))
|
|
454
|
+
|
|
455
|
+
# Compute separator lines at mid-gap between consecutive rows (clamped to overlay bounds)
|
|
456
|
+
seps: List[float] = []
|
|
457
|
+
for i in range(len(rows) - 1):
|
|
458
|
+
_, _, bottom_a = rows[i]
|
|
459
|
+
_, top_b, _ = rows[i + 1]
|
|
460
|
+
y = (bottom_a + top_b) * 0.5
|
|
461
|
+
if br.top() <= y <= br.bottom():
|
|
462
|
+
seps.append(y)
|
|
463
|
+
|
|
464
|
+
# Build vertical zones per row: from previous separator to next separator (or overlay edges)
|
|
465
|
+
zones: Dict[str, Tuple[float, float]] = {}
|
|
466
|
+
for i, (pid, _top, _bottom) in enumerate(rows):
|
|
467
|
+
z_top = br.top() if i == 0 else (seps[i - 1] if (i - 1) < len(seps) else br.top())
|
|
468
|
+
z_bot = br.bottom() if i == len(rows) - 1 else (seps[i] if i < len(seps) else br.bottom())
|
|
469
|
+
# Ensure within overlay bounds
|
|
470
|
+
z_top = max(br.top(), min(z_top, br.bottom()))
|
|
471
|
+
z_bot = max(br.top(), min(z_bot, br.bottom()))
|
|
472
|
+
if z_bot > z_top:
|
|
473
|
+
zones[pid] = (z_top, z_bot)
|
|
474
|
+
|
|
475
|
+
# 1) Hover background: full width, vertically up to separators (no gap)
|
|
476
|
+
if self._hover_pid and self._hover_pid in zones:
|
|
477
|
+
y1, y2 = zones[self._hover_pid]
|
|
478
|
+
hl = QColor(base.lighter(170))
|
|
479
|
+
hl.setAlpha(25) # subtle
|
|
480
|
+
p.fillRect(QRectF(left, y1, right - left, y2 - y1), hl)
|
|
481
|
+
|
|
482
|
+
# 2) Full-width separators
|
|
483
|
+
sep = QColor(base.lighter(160))
|
|
484
|
+
sep.setAlpha(130)
|
|
485
|
+
pen_sep = QPen(sep, 1.25, Qt.SolidLine)
|
|
486
|
+
pen_sep.setCosmetic(True)
|
|
487
|
+
p.setPen(pen_sep)
|
|
488
|
+
for y in seps:
|
|
489
|
+
if br.top() + 0.5 <= y <= br.bottom() - 0.5:
|
|
490
|
+
p.drawLine(QPointF(left, y), QPointF(right, y))
|
|
491
|
+
except Exception:
|
|
492
|
+
pass
|