nodeeditor-qtpy 0.8.1__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.
- nodeeditor/__init__.py +93 -0
- nodeeditor/node_colors_config.py +343 -0
- nodeeditor/node_content_widget.py +129 -0
- nodeeditor/node_decorators.py +65 -0
- nodeeditor/node_edge.py +518 -0
- nodeeditor/node_edge_dragging.py +185 -0
- nodeeditor/node_edge_intersect.py +219 -0
- nodeeditor/node_edge_rerouting.py +216 -0
- nodeeditor/node_edge_snapping.py +95 -0
- nodeeditor/node_edge_validators.py +82 -0
- nodeeditor/node_editor_widget.py +257 -0
- nodeeditor/node_editor_window.py +448 -0
- nodeeditor/node_graph.py +583 -0
- nodeeditor/node_graphics_cutline.py +57 -0
- nodeeditor/node_graphics_edge.py +375 -0
- nodeeditor/node_graphics_edge_path.py +226 -0
- nodeeditor/node_graphics_node.py +693 -0
- nodeeditor/node_graphics_scene.py +369 -0
- nodeeditor/node_graphics_socket.py +262 -0
- nodeeditor/node_graphics_view.py +907 -0
- nodeeditor/node_group.py +1286 -0
- nodeeditor/node_group_node.py +5 -0
- nodeeditor/node_icon_content_widget.py +176 -0
- nodeeditor/node_icon_graphics_node.py +524 -0
- nodeeditor/node_multi_input_node.py +450 -0
- nodeeditor/node_node.py +848 -0
- nodeeditor/node_scene.py +1106 -0
- nodeeditor/node_scene_clipboard.py +314 -0
- nodeeditor/node_scene_history.py +556 -0
- nodeeditor/node_serializable.py +43 -0
- nodeeditor/node_socket.py +445 -0
- nodeeditor/node_socket_shapes.py +261 -0
- nodeeditor/py.typed +0 -0
- nodeeditor/qss/nodestyle.qss +3 -0
- nodeeditor/utils.py +108 -0
- nodeeditor/utils_no_qt.py +21 -0
- nodeeditor_qtpy-0.8.1.dist-info/METADATA +165 -0
- nodeeditor_qtpy-0.8.1.dist-info/RECORD +40 -0
- nodeeditor_qtpy-0.8.1.dist-info/WHEEL +4 -0
- nodeeditor_qtpy-0.8.1.dist-info/licenses/LICENCE +23 -0
nodeeditor/node_group.py
ADDED
|
@@ -0,0 +1,1286 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Reliable ground-up grouping system.
|
|
4
|
+
|
|
5
|
+
Design notes (v2, clean break):
|
|
6
|
+
- Group is a pure visual container (QGraphicsRectItem + Serializable), NOT a Node.
|
|
7
|
+
- Nodes keep their positions/sizes at all times. Collapse only hides/shows,
|
|
8
|
+
never resizes/scales nodes. This removes the fragile 0.2/scale(0)/width==5 hacks.
|
|
9
|
+
- Logical Edges never mutate on collapse. Only their graphics endpoints are
|
|
10
|
+
overridden to per-edge stub rows on the collapsed box.
|
|
11
|
+
- Auto-fit + header-drag only. Child moves auto-expand, never auto-remove.
|
|
12
|
+
- Serialization is versioned (version=2, type="Group", key "children").
|
|
13
|
+
Old files (type="GroupNode", "child_node_ids") load best-effort as expanded.
|
|
14
|
+
- Delete-key semantics (handled in view): delete group + children.
|
|
15
|
+
Context menu offers Ungroup (delete only the group, keep nodes).
|
|
16
|
+
- Stub stacking: per-edge rows, left=incoming (end inside), right=outgoing (start inside).
|
|
17
|
+
"""
|
|
18
|
+
from qtpy.QtCore import QRectF, Qt, QPointF
|
|
19
|
+
from qtpy.QtGui import QColor, QPainter, QPainterPath, QPen, QBrush, QCursor
|
|
20
|
+
from qtpy.QtWidgets import QGraphicsRectItem, QGraphicsItem, QMenu, QInputDialog, QColorDialog
|
|
21
|
+
|
|
22
|
+
from nodeeditor.node_serializable import Serializable
|
|
23
|
+
from nodeeditor.utils_no_qt import dumpException
|
|
24
|
+
|
|
25
|
+
from typing import TYPE_CHECKING, List, Optional, Dict, Tuple, Set
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from nodeeditor.node_scene import Scene
|
|
29
|
+
from nodeeditor.node_node import Node
|
|
30
|
+
from nodeeditor.node_edge import Edge
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
GROUP_SERIALIZATION_VERSION = 2
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Group(Serializable, QGraphicsRectItem):
|
|
37
|
+
"""Visual container grouping Nodes together."""
|
|
38
|
+
|
|
39
|
+
TITLE_BAR_HEIGHT = 30
|
|
40
|
+
PADDING = 20
|
|
41
|
+
ROW_HEIGHT = 22
|
|
42
|
+
MIN_WIDTH = 180
|
|
43
|
+
MIN_HEIGHT_EXPANDED = 100
|
|
44
|
+
COLLAPSED_WIDTH = 180
|
|
45
|
+
TOGGLE_SIZE = 20
|
|
46
|
+
|
|
47
|
+
def __init__(self, scene: 'Scene', title: str = "Group",
|
|
48
|
+
x: float = 0, y: float = 0,
|
|
49
|
+
width: float = 200, height: float = 150) -> None:
|
|
50
|
+
"""Create a group and register it with the scene.
|
|
51
|
+
|
|
52
|
+
:param scene: the :class:`~nodeeditor.node_scene.Scene` the group belongs to
|
|
53
|
+
:param title: title shown in the group's title bar
|
|
54
|
+
:param x: initial x position in scene coordinates
|
|
55
|
+
:param y: initial y position in scene coordinates
|
|
56
|
+
:param width: initial width of the group's box
|
|
57
|
+
:param height: initial height of the group's box
|
|
58
|
+
"""
|
|
59
|
+
QGraphicsRectItem.__init__(self, 0, 0, width, height)
|
|
60
|
+
Serializable.__init__(self)
|
|
61
|
+
|
|
62
|
+
self.scene: 'Scene' = scene
|
|
63
|
+
self.title: str = title
|
|
64
|
+
|
|
65
|
+
self._collapsed: bool = False
|
|
66
|
+
self.child_nodes: List['Node'] = []
|
|
67
|
+
|
|
68
|
+
self._color = QColor(100, 100, 100, 200)
|
|
69
|
+
self._title_color = QColor(255, 255, 255)
|
|
70
|
+
self._border_color = QColor(50, 50, 50)
|
|
71
|
+
self._border_width = 2
|
|
72
|
+
|
|
73
|
+
self._hover_header: bool = False
|
|
74
|
+
self._can_move: bool = False
|
|
75
|
+
self._moved: bool = False
|
|
76
|
+
self._last_scene_pos: Optional[QPointF] = None
|
|
77
|
+
self._toggle_rect = QRectF()
|
|
78
|
+
self._drop_highlight: bool = False
|
|
79
|
+
|
|
80
|
+
self.setPos(x, y)
|
|
81
|
+
self.setZValue(-1)
|
|
82
|
+
self.setPen(QPen(self._border_color, self._border_width))
|
|
83
|
+
self.setBrush(QBrush(self._color))
|
|
84
|
+
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable, True)
|
|
85
|
+
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable, True)
|
|
86
|
+
self.setAcceptHoverEvents(True)
|
|
87
|
+
|
|
88
|
+
# Register with scene (guard against double-add during deserialize reuse)
|
|
89
|
+
if self not in self.scene.groups:
|
|
90
|
+
self.scene.addGroup(self)
|
|
91
|
+
try:
|
|
92
|
+
if self not in self.scene.grScene.items():
|
|
93
|
+
self.scene.grScene.addItem(self)
|
|
94
|
+
except Exception:
|
|
95
|
+
# grScene may not exist yet in some test setups; ignore
|
|
96
|
+
try:
|
|
97
|
+
self.scene.grScene.addItem(self)
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
# ------------------------------------------------------------------
|
|
102
|
+
# membership
|
|
103
|
+
# ------------------------------------------------------------------
|
|
104
|
+
def addNode(self, node: 'Node') -> None:
|
|
105
|
+
"""Add a node to the group.
|
|
106
|
+
|
|
107
|
+
A node belongs to at most one group, so adding it here detaches it
|
|
108
|
+
from the group it was in before.
|
|
109
|
+
|
|
110
|
+
:param node: the :class:`~nodeeditor.node_node.Node` to add
|
|
111
|
+
"""
|
|
112
|
+
if node is None:
|
|
113
|
+
return
|
|
114
|
+
if node in self.child_nodes:
|
|
115
|
+
return
|
|
116
|
+
# single-parent invariant: move between groups
|
|
117
|
+
try:
|
|
118
|
+
old = getattr(node, 'parent_group', None)
|
|
119
|
+
except Exception:
|
|
120
|
+
old = None
|
|
121
|
+
if old is not None and old is not self:
|
|
122
|
+
try:
|
|
123
|
+
old.removeNode(node)
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
126
|
+
self.child_nodes.append(node)
|
|
127
|
+
try:
|
|
128
|
+
node.parent_group = self
|
|
129
|
+
except Exception:
|
|
130
|
+
pass
|
|
131
|
+
# ensure single connection for auto-expand
|
|
132
|
+
try:
|
|
133
|
+
try:
|
|
134
|
+
node.positionChanged.disconnect(self.onChildMoved)
|
|
135
|
+
except Exception:
|
|
136
|
+
pass
|
|
137
|
+
node.positionChanged.connect(self.onChildMoved)
|
|
138
|
+
except Exception:
|
|
139
|
+
pass
|
|
140
|
+
try:
|
|
141
|
+
self.scene.has_been_modified = True
|
|
142
|
+
except Exception:
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
def removeNode(self, node: 'Node', update_bounds: bool = True) -> None:
|
|
146
|
+
"""Detach a node from the group, keeping the node itself.
|
|
147
|
+
|
|
148
|
+
:param node: the node to detach from the group
|
|
149
|
+
:param update_bounds: recalculate the group box afterwards
|
|
150
|
+
"""
|
|
151
|
+
if node not in self.child_nodes:
|
|
152
|
+
return
|
|
153
|
+
try:
|
|
154
|
+
try:
|
|
155
|
+
node.positionChanged.disconnect(self.onChildMoved)
|
|
156
|
+
except Exception:
|
|
157
|
+
pass
|
|
158
|
+
except Exception:
|
|
159
|
+
pass
|
|
160
|
+
try:
|
|
161
|
+
self.child_nodes.remove(node)
|
|
162
|
+
except ValueError:
|
|
163
|
+
pass
|
|
164
|
+
try:
|
|
165
|
+
if getattr(node, 'parent_group', None) is self:
|
|
166
|
+
node.parent_group = None
|
|
167
|
+
except Exception:
|
|
168
|
+
pass
|
|
169
|
+
try:
|
|
170
|
+
self.scene.has_been_modified = True
|
|
171
|
+
except Exception:
|
|
172
|
+
pass
|
|
173
|
+
if update_bounds:
|
|
174
|
+
try:
|
|
175
|
+
if self._collapsed:
|
|
176
|
+
self._updateCollapsedSize()
|
|
177
|
+
self.refreshExternalEdges()
|
|
178
|
+
else:
|
|
179
|
+
# don't auto-shrink on removal jitter; only ensure sane size
|
|
180
|
+
pass
|
|
181
|
+
except Exception:
|
|
182
|
+
pass
|
|
183
|
+
|
|
184
|
+
def getChildNodes(self) -> List['Node']:
|
|
185
|
+
"""Return a copy of the list of nodes in the group."""
|
|
186
|
+
return list(self.child_nodes)
|
|
187
|
+
|
|
188
|
+
def contains(self, node: 'Node') -> bool:
|
|
189
|
+
"""Return whether the given node is a member of this group."""
|
|
190
|
+
return node in self.child_nodes
|
|
191
|
+
|
|
192
|
+
def isCollapsed(self) -> bool:
|
|
193
|
+
"""Return whether the group is currently collapsed."""
|
|
194
|
+
return self._collapsed
|
|
195
|
+
|
|
196
|
+
# ------------------------------------------------------------------
|
|
197
|
+
# bounds: auto-fit + header drag
|
|
198
|
+
# ------------------------------------------------------------------
|
|
199
|
+
def calculateBounds(self) -> QRectF:
|
|
200
|
+
"""Return the rectangle that tightly fits the child nodes.
|
|
201
|
+
|
|
202
|
+
The result is at least :attr:`MIN_WIDTH` by :attr:`MIN_HEIGHT_EXPANDED`
|
|
203
|
+
and leaves :attr:`PADDING` around the children.
|
|
204
|
+
"""
|
|
205
|
+
if not self.child_nodes:
|
|
206
|
+
p = self.pos()
|
|
207
|
+
r = self.rect()
|
|
208
|
+
w = max(r.width(), float(self.MIN_WIDTH))
|
|
209
|
+
h = max(r.height(), float(self.MIN_HEIGHT_EXPANDED))
|
|
210
|
+
return QRectF(p.x(), p.y(), w, h)
|
|
211
|
+
min_x = float('inf')
|
|
212
|
+
min_y = float('inf')
|
|
213
|
+
max_x = float('-inf')
|
|
214
|
+
max_y = float('-inf')
|
|
215
|
+
valid = False
|
|
216
|
+
for node in self.child_nodes:
|
|
217
|
+
try:
|
|
218
|
+
gr = getattr(node, 'grNode', None)
|
|
219
|
+
if gr is None:
|
|
220
|
+
continue
|
|
221
|
+
np = node.pos
|
|
222
|
+
try:
|
|
223
|
+
lb = gr.boundingRect()
|
|
224
|
+
except Exception:
|
|
225
|
+
continue
|
|
226
|
+
min_x = min(min_x, np.x() + lb.left())
|
|
227
|
+
min_y = min(min_y, np.y() + lb.top())
|
|
228
|
+
max_x = max(max_x, np.x() + lb.right())
|
|
229
|
+
max_y = max(max_y, np.y() + lb.bottom())
|
|
230
|
+
valid = True
|
|
231
|
+
except Exception:
|
|
232
|
+
continue
|
|
233
|
+
if not valid:
|
|
234
|
+
p = self.pos()
|
|
235
|
+
return QRectF(p.x(), p.y(), 200, 150)
|
|
236
|
+
pad = float(self.PADDING)
|
|
237
|
+
left = min_x - pad
|
|
238
|
+
top = min_y - pad - float(self.TITLE_BAR_HEIGHT)
|
|
239
|
+
right = max_x + pad
|
|
240
|
+
bottom = max_y + pad
|
|
241
|
+
w = max(right - left, float(self.MIN_WIDTH))
|
|
242
|
+
h = max(bottom - top, float(self.MIN_HEIGHT_EXPANDED))
|
|
243
|
+
return QRectF(left, top, w, h)
|
|
244
|
+
|
|
245
|
+
def updateBounds(self) -> None:
|
|
246
|
+
"""Recompute expanded bounds to tightly fit children."""
|
|
247
|
+
if self._collapsed:
|
|
248
|
+
self._updateCollapsedSize()
|
|
249
|
+
return
|
|
250
|
+
try:
|
|
251
|
+
bbox = self.calculateBounds()
|
|
252
|
+
self.setRect(0, 0, bbox.width(), bbox.height())
|
|
253
|
+
self.setPos(bbox.topLeft())
|
|
254
|
+
except Exception:
|
|
255
|
+
dumpException()
|
|
256
|
+
|
|
257
|
+
def onChildMoved(self, node: 'Node') -> None:
|
|
258
|
+
"""Recalculate the group boundary whenever a child node moves.
|
|
259
|
+
|
|
260
|
+
Tight refit (may grow, shrink, or shift the origin). Never removes
|
|
261
|
+
nodes — use Ungroup/detach explicitly. Collapsed groups ignore child
|
|
262
|
+
moves (children are hidden and move with the container instead).
|
|
263
|
+
"""
|
|
264
|
+
try:
|
|
265
|
+
if self._collapsed:
|
|
266
|
+
return
|
|
267
|
+
if node not in self.child_nodes:
|
|
268
|
+
return
|
|
269
|
+
if getattr(node, 'grNode', None) is None:
|
|
270
|
+
return
|
|
271
|
+
self.updateBounds()
|
|
272
|
+
except Exception:
|
|
273
|
+
pass
|
|
274
|
+
|
|
275
|
+
# ------------------------------------------------------------------
|
|
276
|
+
# edge classification + per-edge stub rows
|
|
277
|
+
# ------------------------------------------------------------------
|
|
278
|
+
def _classifyEdges(self) -> Tuple[List['Edge'], List['Edge'], List['Edge']]:
|
|
279
|
+
"""Return (incoming, outgoing, internal). Incoming=end inside, outgoing=start inside."""
|
|
280
|
+
# Use persistent node.id (not id(node)) so classification survives
|
|
281
|
+
# deserialize detach/relink, GC id() reuse, and stale socket.node refs.
|
|
282
|
+
child_set: Set[int] = set()
|
|
283
|
+
for n in self.child_nodes:
|
|
284
|
+
try:
|
|
285
|
+
nid = getattr(n, 'id', None)
|
|
286
|
+
child_set.add(nid if nid is not None else id(n))
|
|
287
|
+
except Exception:
|
|
288
|
+
continue
|
|
289
|
+
seen: Dict[int, 'Edge'] = {}
|
|
290
|
+
try:
|
|
291
|
+
for node in self.child_nodes:
|
|
292
|
+
try:
|
|
293
|
+
sockets = list(getattr(node, 'inputs', [])) + list(getattr(node, 'outputs', []))
|
|
294
|
+
except Exception:
|
|
295
|
+
continue
|
|
296
|
+
for sock in sockets:
|
|
297
|
+
try:
|
|
298
|
+
for e in list(getattr(sock, 'edges', [])):
|
|
299
|
+
if e is None:
|
|
300
|
+
continue
|
|
301
|
+
seen[id(e)] = e
|
|
302
|
+
except Exception:
|
|
303
|
+
continue
|
|
304
|
+
except Exception:
|
|
305
|
+
pass
|
|
306
|
+
incoming: List['Edge'] = []
|
|
307
|
+
outgoing: List['Edge'] = []
|
|
308
|
+
internal: List['Edge'] = []
|
|
309
|
+
for e in seen.values():
|
|
310
|
+
try:
|
|
311
|
+
s = getattr(e, 'start_socket', None)
|
|
312
|
+
t = getattr(e, 'end_socket', None)
|
|
313
|
+
sn = getattr(s, 'node', None) if s is not None else None
|
|
314
|
+
en = getattr(t, 'node', None) if t is not None else None
|
|
315
|
+
try:
|
|
316
|
+
sn_key = getattr(sn, 'id', None) if sn is not None else None
|
|
317
|
+
sn_key = sn_key if sn_key is not None else (id(sn) if sn is not None else None)
|
|
318
|
+
except Exception:
|
|
319
|
+
sn_key = None
|
|
320
|
+
try:
|
|
321
|
+
en_key = getattr(en, 'id', None) if en is not None else None
|
|
322
|
+
en_key = en_key if en_key is not None else (id(en) if en is not None else None)
|
|
323
|
+
except Exception:
|
|
324
|
+
en_key = None
|
|
325
|
+
s_in = sn_key is not None and sn_key in child_set
|
|
326
|
+
e_in = en_key is not None and en_key in child_set
|
|
327
|
+
if s_in and e_in:
|
|
328
|
+
internal.append(e)
|
|
329
|
+
elif (not s_in) and e_in:
|
|
330
|
+
incoming.append(e)
|
|
331
|
+
elif s_in and (not e_in):
|
|
332
|
+
outgoing.append(e)
|
|
333
|
+
else:
|
|
334
|
+
continue
|
|
335
|
+
except Exception:
|
|
336
|
+
continue
|
|
337
|
+
try:
|
|
338
|
+
incoming.sort(key=lambda e: getattr(e, 'id', 0))
|
|
339
|
+
outgoing.sort(key=lambda e: getattr(e, 'id', 0))
|
|
340
|
+
internal.sort(key=lambda e: getattr(e, 'id', 0))
|
|
341
|
+
except Exception:
|
|
342
|
+
pass
|
|
343
|
+
return incoming, outgoing, internal
|
|
344
|
+
|
|
345
|
+
def externalEdges(self) -> Tuple[List['Edge'], List['Edge']]:
|
|
346
|
+
"""Return the edges that cross the group boundary.
|
|
347
|
+
|
|
348
|
+
:return: a tuple of (incoming, outgoing) edges, where an incoming edge
|
|
349
|
+
ends inside the group and an outgoing edge starts inside it
|
|
350
|
+
"""
|
|
351
|
+
inc, out, _ = self._classifyEdges()
|
|
352
|
+
return inc, out
|
|
353
|
+
|
|
354
|
+
def _updateCollapsedSize(self) -> None:
|
|
355
|
+
try:
|
|
356
|
+
inc, out, _ = self._classifyEdges()
|
|
357
|
+
rows = max(len(inc), len(out), 1)
|
|
358
|
+
h = float(self.TITLE_BAR_HEIGHT) + 10.0 + rows * float(self.ROW_HEIGHT) + 10.0
|
|
359
|
+
self.setRect(0, 0, float(self.COLLAPSED_WIDTH), h)
|
|
360
|
+
except Exception:
|
|
361
|
+
try:
|
|
362
|
+
self.setRect(0, 0, float(self.COLLAPSED_WIDTH),
|
|
363
|
+
float(self.TITLE_BAR_HEIGHT) + 42.0)
|
|
364
|
+
except Exception:
|
|
365
|
+
pass
|
|
366
|
+
|
|
367
|
+
def stubPosFor(self, edge: 'Edge') -> Optional[QPointF]:
|
|
368
|
+
"""Scene position of the stub for `edge` on THIS collapsed group, or None."""
|
|
369
|
+
try:
|
|
370
|
+
if not self._collapsed:
|
|
371
|
+
return None
|
|
372
|
+
inc, out, _ = self._classifyEdges()
|
|
373
|
+
# find row index deterministically
|
|
374
|
+
try:
|
|
375
|
+
if edge in inc:
|
|
376
|
+
idx = inc.index(edge)
|
|
377
|
+
gp = self.pos()
|
|
378
|
+
y = gp.y() + float(self.TITLE_BAR_HEIGHT) + 10.0 + idx * float(self.ROW_HEIGHT) + float(self.ROW_HEIGHT) / 2.0
|
|
379
|
+
return QPointF(gp.x(), y)
|
|
380
|
+
if edge in out:
|
|
381
|
+
idx = out.index(edge)
|
|
382
|
+
gp = self.pos()
|
|
383
|
+
y = gp.y() + float(self.TITLE_BAR_HEIGHT) + 10.0 + idx * float(self.ROW_HEIGHT) + float(self.ROW_HEIGHT) / 2.0
|
|
384
|
+
return QPointF(gp.x() + self.rect().width(), y)
|
|
385
|
+
except ValueError:
|
|
386
|
+
return None
|
|
387
|
+
return None
|
|
388
|
+
except Exception:
|
|
389
|
+
return None
|
|
390
|
+
|
|
391
|
+
def refreshExternalEdges(self) -> None:
|
|
392
|
+
"""Show or hide edges according to the collapsed state.
|
|
393
|
+
|
|
394
|
+
External edges are rerouted to their stub rows on the collapsed box,
|
|
395
|
+
internal edges are hidden while the group is collapsed.
|
|
396
|
+
"""
|
|
397
|
+
try:
|
|
398
|
+
inc, out, internal = self._classifyEdges()
|
|
399
|
+
for e in inc + out:
|
|
400
|
+
try:
|
|
401
|
+
if getattr(e, 'grEdge', None) is not None:
|
|
402
|
+
try:
|
|
403
|
+
e.grEdge.show()
|
|
404
|
+
except Exception:
|
|
405
|
+
pass
|
|
406
|
+
e.updatePositions()
|
|
407
|
+
except Exception:
|
|
408
|
+
continue
|
|
409
|
+
for e in internal:
|
|
410
|
+
try:
|
|
411
|
+
if getattr(e, 'grEdge', None) is not None:
|
|
412
|
+
if self._collapsed:
|
|
413
|
+
try:
|
|
414
|
+
e.grEdge.hide()
|
|
415
|
+
except Exception:
|
|
416
|
+
pass
|
|
417
|
+
else:
|
|
418
|
+
try:
|
|
419
|
+
e.grEdge.show()
|
|
420
|
+
except Exception:
|
|
421
|
+
pass
|
|
422
|
+
e.updatePositions()
|
|
423
|
+
except Exception:
|
|
424
|
+
continue
|
|
425
|
+
except Exception:
|
|
426
|
+
pass
|
|
427
|
+
|
|
428
|
+
# ------------------------------------------------------------------
|
|
429
|
+
# collapse / expand (no node resize, positions preserved)
|
|
430
|
+
# ------------------------------------------------------------------
|
|
431
|
+
def collapse(self) -> None:
|
|
432
|
+
"""Collapse the group into its title bar.
|
|
433
|
+
|
|
434
|
+
Child nodes and the edges between them are hidden, the box shrinks to
|
|
435
|
+
the collapsed width, and the edges crossing the boundary are rerouted
|
|
436
|
+
to stub rows. Node positions are preserved, so expanding restores the
|
|
437
|
+
graph exactly as it was.
|
|
438
|
+
"""
|
|
439
|
+
if self._collapsed:
|
|
440
|
+
return
|
|
441
|
+
self._collapsed = True
|
|
442
|
+
try:
|
|
443
|
+
for node in list(self.child_nodes):
|
|
444
|
+
try:
|
|
445
|
+
gr = getattr(node, 'grNode', None)
|
|
446
|
+
if gr is not None:
|
|
447
|
+
gr.hide()
|
|
448
|
+
except Exception:
|
|
449
|
+
continue
|
|
450
|
+
_, _, internal = self._classifyEdges()
|
|
451
|
+
for e in internal:
|
|
452
|
+
try:
|
|
453
|
+
if getattr(e, 'grEdge', None) is not None:
|
|
454
|
+
e.grEdge.hide()
|
|
455
|
+
except Exception:
|
|
456
|
+
continue
|
|
457
|
+
self._updateCollapsedSize()
|
|
458
|
+
self.refreshExternalEdges()
|
|
459
|
+
try:
|
|
460
|
+
self.scene.has_been_modified = True
|
|
461
|
+
except Exception:
|
|
462
|
+
pass
|
|
463
|
+
self.update()
|
|
464
|
+
except Exception:
|
|
465
|
+
dumpException()
|
|
466
|
+
|
|
467
|
+
def expand(self) -> None:
|
|
468
|
+
"""Expand the group, restoring the hidden nodes and edges.
|
|
469
|
+
|
|
470
|
+
The children reappear at the positions they had before the group was
|
|
471
|
+
collapsed.
|
|
472
|
+
"""
|
|
473
|
+
if not self._collapsed:
|
|
474
|
+
return
|
|
475
|
+
self._collapsed = False
|
|
476
|
+
try:
|
|
477
|
+
for node in list(self.child_nodes):
|
|
478
|
+
try:
|
|
479
|
+
gr = getattr(node, 'grNode', None)
|
|
480
|
+
if gr is not None:
|
|
481
|
+
gr.show()
|
|
482
|
+
except Exception:
|
|
483
|
+
continue
|
|
484
|
+
# show all connected edges; positions recomputed from real sockets
|
|
485
|
+
try:
|
|
486
|
+
for node in list(self.child_nodes):
|
|
487
|
+
try:
|
|
488
|
+
sockets = list(getattr(node, 'inputs', [])) + list(getattr(node, 'outputs', []))
|
|
489
|
+
except Exception:
|
|
490
|
+
continue
|
|
491
|
+
for sock in sockets:
|
|
492
|
+
try:
|
|
493
|
+
for e in list(getattr(sock, 'edges', [])):
|
|
494
|
+
try:
|
|
495
|
+
if getattr(e, 'grEdge', None) is not None:
|
|
496
|
+
e.grEdge.show()
|
|
497
|
+
except Exception:
|
|
498
|
+
pass
|
|
499
|
+
except Exception:
|
|
500
|
+
continue
|
|
501
|
+
try:
|
|
502
|
+
node.updateConnectedEdges()
|
|
503
|
+
except Exception:
|
|
504
|
+
pass
|
|
505
|
+
except Exception:
|
|
506
|
+
pass
|
|
507
|
+
self.updateBounds()
|
|
508
|
+
try:
|
|
509
|
+
self.scene.has_been_modified = True
|
|
510
|
+
except Exception:
|
|
511
|
+
pass
|
|
512
|
+
self.update()
|
|
513
|
+
except Exception:
|
|
514
|
+
dumpException()
|
|
515
|
+
|
|
516
|
+
def restoreExpandedVisuals(self) -> None:
|
|
517
|
+
"""Idempotent expanded visuals for undo/redo/load (mirrors expand without toggling)."""
|
|
518
|
+
try:
|
|
519
|
+
for node in list(self.child_nodes):
|
|
520
|
+
try:
|
|
521
|
+
gr = getattr(node, 'grNode', None)
|
|
522
|
+
if gr is not None:
|
|
523
|
+
gr.show()
|
|
524
|
+
except Exception:
|
|
525
|
+
continue
|
|
526
|
+
try:
|
|
527
|
+
for node in list(self.child_nodes):
|
|
528
|
+
try:
|
|
529
|
+
sockets = list(getattr(node, 'inputs', [])) + list(getattr(node, 'outputs', []))
|
|
530
|
+
except Exception:
|
|
531
|
+
continue
|
|
532
|
+
for sock in sockets:
|
|
533
|
+
try:
|
|
534
|
+
for e in list(getattr(sock, 'edges', [])):
|
|
535
|
+
try:
|
|
536
|
+
if getattr(e, 'grEdge', None) is not None:
|
|
537
|
+
e.grEdge.show()
|
|
538
|
+
except Exception:
|
|
539
|
+
pass
|
|
540
|
+
except Exception:
|
|
541
|
+
continue
|
|
542
|
+
try:
|
|
543
|
+
node.updateConnectedEdges()
|
|
544
|
+
except Exception:
|
|
545
|
+
pass
|
|
546
|
+
except Exception:
|
|
547
|
+
pass
|
|
548
|
+
try:
|
|
549
|
+
self.updateBounds()
|
|
550
|
+
except Exception:
|
|
551
|
+
pass
|
|
552
|
+
try:
|
|
553
|
+
self.refreshExternalEdges()
|
|
554
|
+
except Exception:
|
|
555
|
+
pass
|
|
556
|
+
try:
|
|
557
|
+
self.update()
|
|
558
|
+
except Exception:
|
|
559
|
+
pass
|
|
560
|
+
except Exception:
|
|
561
|
+
pass
|
|
562
|
+
|
|
563
|
+
def toggleCollapse(self) -> None:
|
|
564
|
+
"""Collapse the group if it is expanded, expand it otherwise."""
|
|
565
|
+
if self._collapsed:
|
|
566
|
+
self.expand()
|
|
567
|
+
else:
|
|
568
|
+
self.collapse()
|
|
569
|
+
|
|
570
|
+
def setCollapsed(self, collapsed: bool) -> None:
|
|
571
|
+
"""Collapse or expand the group.
|
|
572
|
+
|
|
573
|
+
:param collapsed: True to collapse the group, False to expand it
|
|
574
|
+
"""
|
|
575
|
+
if collapsed:
|
|
576
|
+
self.collapse()
|
|
577
|
+
else:
|
|
578
|
+
self.expand()
|
|
579
|
+
|
|
580
|
+
# ------------------------------------------------------------------
|
|
581
|
+
# title + colors (rename via double-click header, recolor via menu)
|
|
582
|
+
# ------------------------------------------------------------------
|
|
583
|
+
GROUP_COLOR_PRESETS = {
|
|
584
|
+
"Gray": (QColor(100, 100, 100, 200), QColor(60, 60, 60)),
|
|
585
|
+
"Blue": (QColor(70, 110, 180, 200), QColor(45, 70, 120)),
|
|
586
|
+
"Green": (QColor(80, 160, 100, 200), QColor(45, 95, 60)),
|
|
587
|
+
"Purple": (QColor(140, 100, 180, 200), QColor(85, 60, 110)),
|
|
588
|
+
"Orange": (QColor(200, 130, 60, 200), QColor(120, 75, 35)),
|
|
589
|
+
"Red": (QColor(180, 80, 80, 200), QColor(110, 45, 45)),
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
def setTitle(self, title: str) -> bool:
|
|
593
|
+
"""Set group title. Returns True if changed."""
|
|
594
|
+
try:
|
|
595
|
+
title = (title or "").strip()
|
|
596
|
+
if not title or title == self.title:
|
|
597
|
+
return False
|
|
598
|
+
self.title = title
|
|
599
|
+
try:
|
|
600
|
+
self.scene.has_been_modified = True
|
|
601
|
+
except Exception:
|
|
602
|
+
pass
|
|
603
|
+
try:
|
|
604
|
+
self.update()
|
|
605
|
+
except Exception:
|
|
606
|
+
pass
|
|
607
|
+
return True
|
|
608
|
+
except Exception:
|
|
609
|
+
return False
|
|
610
|
+
|
|
611
|
+
def renameInteractive(self, parent=None) -> bool:
|
|
612
|
+
"""Open rename dialog. Returns True if renamed (caller stores history)."""
|
|
613
|
+
try:
|
|
614
|
+
text, ok = QInputDialog.getText(parent, "Rename Group", "Group title:", text=self.title)
|
|
615
|
+
if not ok:
|
|
616
|
+
return False
|
|
617
|
+
return self.setTitle(text)
|
|
618
|
+
except Exception:
|
|
619
|
+
return False
|
|
620
|
+
|
|
621
|
+
def setColors(self, color=None, title_color=None, border_color=None) -> bool:
|
|
622
|
+
"""Set group colors. Any None arg is left unchanged. Returns True if changed."""
|
|
623
|
+
changed = False
|
|
624
|
+
try:
|
|
625
|
+
if color is not None:
|
|
626
|
+
c = QColor(color) if not isinstance(color, QColor) else color
|
|
627
|
+
if c.isValid() and c != self._color:
|
|
628
|
+
self._color = c
|
|
629
|
+
changed = True
|
|
630
|
+
if title_color is not None:
|
|
631
|
+
c = QColor(title_color) if not isinstance(title_color, QColor) else title_color
|
|
632
|
+
if c.isValid() and c != self._title_color:
|
|
633
|
+
self._title_color = c
|
|
634
|
+
changed = True
|
|
635
|
+
if border_color is not None:
|
|
636
|
+
c = QColor(border_color) if not isinstance(border_color, QColor) else border_color
|
|
637
|
+
if c.isValid() and c != self._border_color:
|
|
638
|
+
self._border_color = c
|
|
639
|
+
changed = True
|
|
640
|
+
if changed:
|
|
641
|
+
try:
|
|
642
|
+
self.setPen(QPen(self._border_color, self._border_width))
|
|
643
|
+
self.setBrush(QBrush(self._color))
|
|
644
|
+
except Exception:
|
|
645
|
+
pass
|
|
646
|
+
try:
|
|
647
|
+
self.scene.has_been_modified = True
|
|
648
|
+
except Exception:
|
|
649
|
+
pass
|
|
650
|
+
try:
|
|
651
|
+
self.update()
|
|
652
|
+
except Exception:
|
|
653
|
+
pass
|
|
654
|
+
return changed
|
|
655
|
+
except Exception:
|
|
656
|
+
return False
|
|
657
|
+
|
|
658
|
+
def recolorInteractive(self, parent=None) -> bool:
|
|
659
|
+
"""Open color dialog for fill color. Returns True if changed."""
|
|
660
|
+
try:
|
|
661
|
+
c = QColorDialog.getColor(self._color, parent, "Group fill color")
|
|
662
|
+
if not c.isValid():
|
|
663
|
+
return False
|
|
664
|
+
return self.setColors(color=c)
|
|
665
|
+
except Exception:
|
|
666
|
+
return False
|
|
667
|
+
|
|
668
|
+
def setDropHighlight(self, enabled: bool) -> None:
|
|
669
|
+
"""Highlight the group while nodes are dragged over it.
|
|
670
|
+
|
|
671
|
+
:param enabled: whether the drop highlight should be shown
|
|
672
|
+
"""
|
|
673
|
+
try:
|
|
674
|
+
if self._drop_highlight != bool(enabled):
|
|
675
|
+
self._drop_highlight = bool(enabled)
|
|
676
|
+
self.update()
|
|
677
|
+
except Exception:
|
|
678
|
+
pass
|
|
679
|
+
|
|
680
|
+
# ------------------------------------------------------------------
|
|
681
|
+
# removal: Delete-key = delete children; context menu Ungroup = keep nodes
|
|
682
|
+
# ------------------------------------------------------------------
|
|
683
|
+
def ungroup(self) -> None:
|
|
684
|
+
"""Delete only the group, keep all child nodes (explicit, no auto-remove elsewhere)."""
|
|
685
|
+
try:
|
|
686
|
+
if self._collapsed:
|
|
687
|
+
# show children first so positions/edges restore correctly
|
|
688
|
+
self.expand()
|
|
689
|
+
except Exception:
|
|
690
|
+
pass
|
|
691
|
+
for node in list(self.child_nodes):
|
|
692
|
+
try:
|
|
693
|
+
self.removeNode(node, update_bounds=False)
|
|
694
|
+
except Exception:
|
|
695
|
+
continue
|
|
696
|
+
self.dispose()
|
|
697
|
+
|
|
698
|
+
def deleteWithChildren(self) -> None:
|
|
699
|
+
"""Delete-key semantics: delete container AND all child nodes (and their edges)."""
|
|
700
|
+
for node in list(self.child_nodes):
|
|
701
|
+
try:
|
|
702
|
+
# Node.remove() detaches from group itself; guard double-remove
|
|
703
|
+
try:
|
|
704
|
+
node.remove()
|
|
705
|
+
except Exception:
|
|
706
|
+
try:
|
|
707
|
+
self.removeNode(node, update_bounds=False)
|
|
708
|
+
except Exception:
|
|
709
|
+
pass
|
|
710
|
+
except Exception:
|
|
711
|
+
continue
|
|
712
|
+
self.child_nodes.clear()
|
|
713
|
+
self.dispose()
|
|
714
|
+
|
|
715
|
+
def dispose(self) -> None:
|
|
716
|
+
"""Remove the group from the scene and from the graphics scene.
|
|
717
|
+
|
|
718
|
+
This is the low-level removal used by :meth:`ungroup` and
|
|
719
|
+
:meth:`deleteWithChildren`.
|
|
720
|
+
"""
|
|
721
|
+
try:
|
|
722
|
+
try:
|
|
723
|
+
if self.scene.grScene is not None and self in self.scene.grScene.items():
|
|
724
|
+
self.scene.grScene.removeItem(self)
|
|
725
|
+
except Exception:
|
|
726
|
+
pass
|
|
727
|
+
except Exception:
|
|
728
|
+
pass
|
|
729
|
+
try:
|
|
730
|
+
self.scene.removeGroup(self)
|
|
731
|
+
except Exception:
|
|
732
|
+
pass
|
|
733
|
+
|
|
734
|
+
# ------------------------------------------------------------------
|
|
735
|
+
# interaction: header-only drag, painted toggle, hover, context menu
|
|
736
|
+
# ------------------------------------------------------------------
|
|
737
|
+
def _toggleRectLocal(self) -> QRectF:
|
|
738
|
+
try:
|
|
739
|
+
w = self.rect().width()
|
|
740
|
+
s = float(self.TOGGLE_SIZE)
|
|
741
|
+
return QRectF(w - s - 6.0, (float(self.TITLE_BAR_HEIGHT) - s) / 2.0, s, s)
|
|
742
|
+
except Exception:
|
|
743
|
+
return QRectF()
|
|
744
|
+
|
|
745
|
+
def shape(self):
|
|
746
|
+
"""Hit-testing only: expanded = header (+toggle) only, collapsed = full rect.
|
|
747
|
+
|
|
748
|
+
boundingRect()/paint() stay full so the background still draws; only
|
|
749
|
+
clicks/selection/rubber-band/itemAt ignore the expanded body, making it
|
|
750
|
+
behave like plain canvas.
|
|
751
|
+
"""
|
|
752
|
+
try:
|
|
753
|
+
path = QPainterPath()
|
|
754
|
+
# WindingFill: overlapping subpaths (toggle lives inside the header
|
|
755
|
+
# rect) must UNION, not punch an odd-even hole that swallows clicks.
|
|
756
|
+
try:
|
|
757
|
+
path.setFillRule(Qt.FillRule.WindingFill)
|
|
758
|
+
except Exception:
|
|
759
|
+
pass
|
|
760
|
+
r = self.rect()
|
|
761
|
+
if self._collapsed:
|
|
762
|
+
path.addRoundedRect(0, 0, r.width(), r.height(), 8.0, 8.0)
|
|
763
|
+
else:
|
|
764
|
+
path.addRect(0, 0, r.width(), float(self.TITLE_BAR_HEIGHT))
|
|
765
|
+
try:
|
|
766
|
+
tog = self._toggleRectLocal()
|
|
767
|
+
if tog.isValid():
|
|
768
|
+
path.addRect(tog)
|
|
769
|
+
except Exception:
|
|
770
|
+
pass
|
|
771
|
+
return path
|
|
772
|
+
except Exception:
|
|
773
|
+
return super().shape()
|
|
774
|
+
|
|
775
|
+
def mousePressEvent(self, event) -> None:
|
|
776
|
+
"""Handle a press on the group.
|
|
777
|
+
|
|
778
|
+
The collapse toggle is handled first; a press on the title bar
|
|
779
|
+
afterwards starts a drag of the group and its children.
|
|
780
|
+
"""
|
|
781
|
+
try:
|
|
782
|
+
lp = event.pos()
|
|
783
|
+
except Exception:
|
|
784
|
+
super().mousePressEvent(event)
|
|
785
|
+
return
|
|
786
|
+
# toggle button first
|
|
787
|
+
try:
|
|
788
|
+
if self._toggleRectLocal().contains(lp):
|
|
789
|
+
self.toggleCollapse()
|
|
790
|
+
try:
|
|
791
|
+
self.scene.history.storeHistory(
|
|
792
|
+
"Collapsed group" if self._collapsed else "Expanded group",
|
|
793
|
+
setModified=True)
|
|
794
|
+
except Exception:
|
|
795
|
+
pass
|
|
796
|
+
try:
|
|
797
|
+
event.accept()
|
|
798
|
+
except Exception:
|
|
799
|
+
pass
|
|
800
|
+
return
|
|
801
|
+
except Exception:
|
|
802
|
+
pass
|
|
803
|
+
try:
|
|
804
|
+
if lp.y() < float(self.TITLE_BAR_HEIGHT):
|
|
805
|
+
self._can_move = True
|
|
806
|
+
self._moved = False
|
|
807
|
+
try:
|
|
808
|
+
self._last_scene_pos = self.mapToScene(lp)
|
|
809
|
+
except Exception:
|
|
810
|
+
self._last_scene_pos = None
|
|
811
|
+
try:
|
|
812
|
+
# Exclusive selection (unless Shift): a header click must NOT
|
|
813
|
+
# leave nodes + group jointly selected, otherwise a later
|
|
814
|
+
# node drag moves the group along (and a group drag
|
|
815
|
+
# double-moves selected children via Qt's selection move).
|
|
816
|
+
mods = None
|
|
817
|
+
try:
|
|
818
|
+
mods = event.modifiers()
|
|
819
|
+
except Exception:
|
|
820
|
+
mods = None
|
|
821
|
+
additive = False
|
|
822
|
+
try:
|
|
823
|
+
additive = bool(mods is not None and (mods & Qt.KeyboardModifier.ShiftModifier))
|
|
824
|
+
except Exception:
|
|
825
|
+
additive = False
|
|
826
|
+
if not additive:
|
|
827
|
+
try:
|
|
828
|
+
for it in list(self.scene.grScene.selectedItems()):
|
|
829
|
+
try:
|
|
830
|
+
if it is not self:
|
|
831
|
+
it.setSelected(False)
|
|
832
|
+
except Exception:
|
|
833
|
+
continue
|
|
834
|
+
except Exception:
|
|
835
|
+
pass
|
|
836
|
+
self.setSelected(True)
|
|
837
|
+
try:
|
|
838
|
+
self.scene._last_selected_items = self.scene.getSelectedItems()
|
|
839
|
+
except Exception:
|
|
840
|
+
pass
|
|
841
|
+
except Exception:
|
|
842
|
+
pass
|
|
843
|
+
super().mousePressEvent(event)
|
|
844
|
+
else:
|
|
845
|
+
# Expanded body is click-through: behave as if the group is not
|
|
846
|
+
# there (plain canvas -> deselect, rubber-band, canvas menu).
|
|
847
|
+
# Children are in front (z) so child clicks still hit children.
|
|
848
|
+
self._can_move = False
|
|
849
|
+
self._last_scene_pos = None
|
|
850
|
+
try:
|
|
851
|
+
event.ignore()
|
|
852
|
+
except Exception:
|
|
853
|
+
pass
|
|
854
|
+
return
|
|
855
|
+
except Exception:
|
|
856
|
+
try:
|
|
857
|
+
super().mousePressEvent(event)
|
|
858
|
+
except Exception:
|
|
859
|
+
pass
|
|
860
|
+
|
|
861
|
+
def mouseDoubleClickEvent(self, event) -> None:
|
|
862
|
+
"""Rename the group when its title bar is double-clicked."""
|
|
863
|
+
# Double-click header renames; body is click-through like press.
|
|
864
|
+
try:
|
|
865
|
+
lp = event.pos()
|
|
866
|
+
if self._toggleRectLocal().contains(lp):
|
|
867
|
+
return
|
|
868
|
+
if lp.y() < float(self.TITLE_BAR_HEIGHT) or self._collapsed:
|
|
869
|
+
if self.renameInteractive():
|
|
870
|
+
try:
|
|
871
|
+
self.scene.history.storeHistory("Renamed group", setModified=True)
|
|
872
|
+
except Exception:
|
|
873
|
+
pass
|
|
874
|
+
try:
|
|
875
|
+
event.accept()
|
|
876
|
+
except Exception:
|
|
877
|
+
pass
|
|
878
|
+
return
|
|
879
|
+
try:
|
|
880
|
+
event.ignore()
|
|
881
|
+
except Exception:
|
|
882
|
+
pass
|
|
883
|
+
except Exception:
|
|
884
|
+
pass
|
|
885
|
+
|
|
886
|
+
def mouseMoveEvent(self, event) -> None:
|
|
887
|
+
"""Move the group and its children while the title bar is dragged."""
|
|
888
|
+
if not self._can_move:
|
|
889
|
+
return
|
|
890
|
+
try:
|
|
891
|
+
try:
|
|
892
|
+
new_scene = self.mapToScene(event.pos())
|
|
893
|
+
except Exception:
|
|
894
|
+
super().mouseMoveEvent(event)
|
|
895
|
+
return
|
|
896
|
+
if self._last_scene_pos is None:
|
|
897
|
+
self._last_scene_pos = new_scene
|
|
898
|
+
super().mouseMoveEvent(event)
|
|
899
|
+
return
|
|
900
|
+
delta = new_scene - self._last_scene_pos
|
|
901
|
+
self._last_scene_pos = new_scene
|
|
902
|
+
try:
|
|
903
|
+
if not delta.isNull():
|
|
904
|
+
self._moved = True
|
|
905
|
+
except Exception:
|
|
906
|
+
self._moved = True
|
|
907
|
+
# move hidden or visible children by same delta to preserve layout,
|
|
908
|
+
# skipping selected ones: Qt's selection move already shifts them
|
|
909
|
+
# (otherwise selected children move 2x per drag).
|
|
910
|
+
for node in list(self.child_nodes):
|
|
911
|
+
try:
|
|
912
|
+
try:
|
|
913
|
+
gr = getattr(node, 'grNode', None)
|
|
914
|
+
if gr is not None and gr.isSelected():
|
|
915
|
+
continue
|
|
916
|
+
except Exception:
|
|
917
|
+
pass
|
|
918
|
+
p = node.pos
|
|
919
|
+
node.setPos(p.x() + delta.x(), p.y() + delta.y())
|
|
920
|
+
except Exception:
|
|
921
|
+
continue
|
|
922
|
+
super().mouseMoveEvent(event)
|
|
923
|
+
# keep external stubs glued while dragging collapsed group
|
|
924
|
+
if self._collapsed:
|
|
925
|
+
self.refreshExternalEdges()
|
|
926
|
+
except Exception:
|
|
927
|
+
try:
|
|
928
|
+
super().mouseMoveEvent(event)
|
|
929
|
+
except Exception:
|
|
930
|
+
pass
|
|
931
|
+
|
|
932
|
+
def mouseReleaseEvent(self, event) -> None:
|
|
933
|
+
"""Finish dragging the group and store the move in the history."""
|
|
934
|
+
moved = self._can_move
|
|
935
|
+
actually_moved = bool(moved and getattr(self, '_moved', False))
|
|
936
|
+
if not moved:
|
|
937
|
+
# Press was ignored (expanded body click-through): don't let
|
|
938
|
+
# release select the group; let view handle rubber-band/deselect.
|
|
939
|
+
self._can_move = False
|
|
940
|
+
self._last_scene_pos = None
|
|
941
|
+
try:
|
|
942
|
+
self._moved = False
|
|
943
|
+
except Exception:
|
|
944
|
+
pass
|
|
945
|
+
try:
|
|
946
|
+
event.ignore()
|
|
947
|
+
except Exception:
|
|
948
|
+
pass
|
|
949
|
+
return
|
|
950
|
+
self._can_move = False
|
|
951
|
+
self._last_scene_pos = None
|
|
952
|
+
try:
|
|
953
|
+
self._moved = False
|
|
954
|
+
except Exception:
|
|
955
|
+
pass
|
|
956
|
+
try:
|
|
957
|
+
super().mouseReleaseEvent(event)
|
|
958
|
+
except Exception:
|
|
959
|
+
pass
|
|
960
|
+
if actually_moved:
|
|
961
|
+
try:
|
|
962
|
+
# children setPos already updated edges; ensure stubs + history.
|
|
963
|
+
# No history stamp for a plain header click without movement.
|
|
964
|
+
if self._collapsed:
|
|
965
|
+
self.refreshExternalEdges()
|
|
966
|
+
self.scene.history.storeHistory("Moved group", setModified=True)
|
|
967
|
+
except Exception:
|
|
968
|
+
pass
|
|
969
|
+
|
|
970
|
+
def hoverEnterEvent(self, event) -> None:
|
|
971
|
+
"""Highlight the title bar when the mouse moves onto it."""
|
|
972
|
+
try:
|
|
973
|
+
lp = event.pos()
|
|
974
|
+
self._hover_header = lp.y() < float(self.TITLE_BAR_HEIGHT)
|
|
975
|
+
if self._hover_header:
|
|
976
|
+
self.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
|
|
977
|
+
self.update()
|
|
978
|
+
except Exception:
|
|
979
|
+
pass
|
|
980
|
+
try:
|
|
981
|
+
super().hoverEnterEvent(event)
|
|
982
|
+
except Exception:
|
|
983
|
+
pass
|
|
984
|
+
|
|
985
|
+
def hoverMoveEvent(self, event) -> None:
|
|
986
|
+
"""Track the mouse over the title bar to keep the hover state."""
|
|
987
|
+
try:
|
|
988
|
+
lp = event.pos()
|
|
989
|
+
in_header = lp.y() < float(self.TITLE_BAR_HEIGHT)
|
|
990
|
+
if in_header != self._hover_header:
|
|
991
|
+
self._hover_header = in_header
|
|
992
|
+
self.update()
|
|
993
|
+
if in_header:
|
|
994
|
+
self.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
|
|
995
|
+
else:
|
|
996
|
+
self.setCursor(QCursor(Qt.CursorShape.ArrowCursor))
|
|
997
|
+
except Exception:
|
|
998
|
+
pass
|
|
999
|
+
try:
|
|
1000
|
+
super().hoverMoveEvent(event)
|
|
1001
|
+
except Exception:
|
|
1002
|
+
pass
|
|
1003
|
+
|
|
1004
|
+
def hoverLeaveEvent(self, event) -> None:
|
|
1005
|
+
"""Remove the title bar highlight when the mouse leaves it."""
|
|
1006
|
+
self._hover_header = False
|
|
1007
|
+
try:
|
|
1008
|
+
self.setCursor(QCursor(Qt.CursorShape.ArrowCursor))
|
|
1009
|
+
except Exception:
|
|
1010
|
+
pass
|
|
1011
|
+
try:
|
|
1012
|
+
self.update()
|
|
1013
|
+
except Exception:
|
|
1014
|
+
pass
|
|
1015
|
+
try:
|
|
1016
|
+
super().hoverLeaveEvent(event)
|
|
1017
|
+
except Exception:
|
|
1018
|
+
pass
|
|
1019
|
+
|
|
1020
|
+
def contextMenuEvent(self, event) -> None:
|
|
1021
|
+
"""Show the group context menu.
|
|
1022
|
+
|
|
1023
|
+
The menu offers collapsing, renaming, the colour presets, ungrouping
|
|
1024
|
+
and deleting the group together with its children.
|
|
1025
|
+
"""
|
|
1026
|
+
try:
|
|
1027
|
+
# Expanded body is click-through: let canvas context menu handle it.
|
|
1028
|
+
# (shape() already excludes the body; this is belt-and-suspenders.)
|
|
1029
|
+
try:
|
|
1030
|
+
lp = event.pos()
|
|
1031
|
+
if (not self._collapsed and lp.y() >= float(self.TITLE_BAR_HEIGHT)
|
|
1032
|
+
and not self._toggleRectLocal().contains(lp)):
|
|
1033
|
+
try:
|
|
1034
|
+
event.ignore()
|
|
1035
|
+
except Exception:
|
|
1036
|
+
pass
|
|
1037
|
+
return
|
|
1038
|
+
except Exception:
|
|
1039
|
+
pass
|
|
1040
|
+
menu = QMenu()
|
|
1041
|
+
toggle_act = menu.addAction("Expand" if self._collapsed else "Collapse")
|
|
1042
|
+
rename_act = menu.addAction("Rename…")
|
|
1043
|
+
color_menu = menu.addMenu("Set color")
|
|
1044
|
+
color_actions = {}
|
|
1045
|
+
for name in self.GROUP_COLOR_PRESETS:
|
|
1046
|
+
color_actions[color_menu.addAction(name)] = name
|
|
1047
|
+
custom_color_act = color_menu.addAction("Custom…")
|
|
1048
|
+
ungroup_act = menu.addAction("Ungroup (keep nodes)")
|
|
1049
|
+
delete_act = menu.addAction("Delete Group + Children")
|
|
1050
|
+
try:
|
|
1051
|
+
action = menu.exec(event.screenPos())
|
|
1052
|
+
except Exception:
|
|
1053
|
+
action = None
|
|
1054
|
+
if action == toggle_act:
|
|
1055
|
+
self.toggleCollapse()
|
|
1056
|
+
try:
|
|
1057
|
+
self.scene.history.storeHistory(
|
|
1058
|
+
"Collapsed group" if self._collapsed else "Expanded group",
|
|
1059
|
+
setModified=True)
|
|
1060
|
+
except Exception:
|
|
1061
|
+
pass
|
|
1062
|
+
elif action == rename_act:
|
|
1063
|
+
if self.renameInteractive():
|
|
1064
|
+
try:
|
|
1065
|
+
self.scene.history.storeHistory("Renamed group", setModified=True)
|
|
1066
|
+
except Exception:
|
|
1067
|
+
pass
|
|
1068
|
+
elif action in color_actions:
|
|
1069
|
+
try:
|
|
1070
|
+
fill, _title = self.GROUP_COLOR_PRESETS[color_actions[action]]
|
|
1071
|
+
if self.setColors(color=fill):
|
|
1072
|
+
self.scene.history.storeHistory("Recolored group", setModified=True)
|
|
1073
|
+
except Exception:
|
|
1074
|
+
pass
|
|
1075
|
+
elif action == custom_color_act:
|
|
1076
|
+
if self.recolorInteractive():
|
|
1077
|
+
try:
|
|
1078
|
+
self.scene.history.storeHistory("Recolored group", setModified=True)
|
|
1079
|
+
except Exception:
|
|
1080
|
+
pass
|
|
1081
|
+
elif action == ungroup_act:
|
|
1082
|
+
self.ungroup()
|
|
1083
|
+
try:
|
|
1084
|
+
self.scene.history.storeHistory("Ungrouped", setModified=True)
|
|
1085
|
+
except Exception:
|
|
1086
|
+
pass
|
|
1087
|
+
elif action == delete_act:
|
|
1088
|
+
self.deleteWithChildren()
|
|
1089
|
+
try:
|
|
1090
|
+
self.scene.history.storeHistory("Deleted group", setModified=True)
|
|
1091
|
+
except Exception:
|
|
1092
|
+
pass
|
|
1093
|
+
except Exception:
|
|
1094
|
+
dumpException()
|
|
1095
|
+
|
|
1096
|
+
def paint(self, painter: QPainter, option, widget=None) -> None:
|
|
1097
|
+
"""Paint the group box, its title bar and the collapse toggle."""
|
|
1098
|
+
try:
|
|
1099
|
+
rect = self.rect()
|
|
1100
|
+
painter.setPen(self.pen())
|
|
1101
|
+
painter.setBrush(self.brush())
|
|
1102
|
+
painter.drawRoundedRect(rect, 8.0, 8.0)
|
|
1103
|
+
|
|
1104
|
+
title_rect = QRectF(rect.x(), rect.y(), rect.width(), float(self.TITLE_BAR_HEIGHT))
|
|
1105
|
+
title_col = QColor(80, 80, 80) if self._hover_header else QColor(60, 60, 60)
|
|
1106
|
+
painter.fillRect(title_rect, QBrush(title_col))
|
|
1107
|
+
if self._hover_header:
|
|
1108
|
+
painter.setPen(QPen(QColor(255, 200, 0), 2))
|
|
1109
|
+
painter.drawRect(title_rect)
|
|
1110
|
+
painter.setPen(self.pen())
|
|
1111
|
+
|
|
1112
|
+
painter.setPen(QPen(self._title_color))
|
|
1113
|
+
label = self.title + (" [Collapsed]" if self._collapsed else "")
|
|
1114
|
+
painter.drawText(title_rect,
|
|
1115
|
+
int(Qt.AlignmentFlag.AlignCenter | Qt.AlignmentFlag.AlignVCenter),
|
|
1116
|
+
label)
|
|
1117
|
+
|
|
1118
|
+
# toggle button (painted, no proxy widget)
|
|
1119
|
+
tog = self._toggleRectLocal()
|
|
1120
|
+
self._toggle_rect = tog
|
|
1121
|
+
painter.setPen(QPen(QColor(200, 200, 200)))
|
|
1122
|
+
painter.setBrush(QBrush(QColor(85, 85, 85)))
|
|
1123
|
+
painter.drawRect(tog)
|
|
1124
|
+
painter.setPen(QPen(QColor(255, 255, 255)))
|
|
1125
|
+
painter.drawText(tog, int(Qt.AlignmentFlag.AlignCenter), "+" if self._collapsed else "\u2212")
|
|
1126
|
+
|
|
1127
|
+
# drop-target highlight (drag node into group)
|
|
1128
|
+
if self._drop_highlight:
|
|
1129
|
+
try:
|
|
1130
|
+
hl = QPen(QColor(255, 200, 0), 3)
|
|
1131
|
+
hl.setStyle(Qt.PenStyle.DashLine)
|
|
1132
|
+
painter.setPen(hl)
|
|
1133
|
+
painter.setBrush(Qt.BrushStyle.NoBrush)
|
|
1134
|
+
painter.drawRoundedRect(rect.adjusted(1, 1, -1, -1), 8.0, 8.0)
|
|
1135
|
+
painter.setPen(self.pen())
|
|
1136
|
+
except Exception:
|
|
1137
|
+
pass
|
|
1138
|
+
|
|
1139
|
+
# per-edge stub dots on collapsed box
|
|
1140
|
+
if self._collapsed:
|
|
1141
|
+
try:
|
|
1142
|
+
inc, out, _ = self._classifyEdges()
|
|
1143
|
+
painter.setPen(Qt.PenStyle.NoPen)
|
|
1144
|
+
painter.setBrush(QBrush(QColor(255, 255, 255)))
|
|
1145
|
+
r = 3.0
|
|
1146
|
+
for e in inc:
|
|
1147
|
+
p = self.stubPosFor(e)
|
|
1148
|
+
if p is None:
|
|
1149
|
+
continue
|
|
1150
|
+
lp = self.mapFromScene(p)
|
|
1151
|
+
painter.drawEllipse(lp, r, r)
|
|
1152
|
+
for e in out:
|
|
1153
|
+
p = self.stubPosFor(e)
|
|
1154
|
+
if p is None:
|
|
1155
|
+
continue
|
|
1156
|
+
lp = self.mapFromScene(p)
|
|
1157
|
+
painter.drawEllipse(lp, r, r)
|
|
1158
|
+
except Exception:
|
|
1159
|
+
pass
|
|
1160
|
+
except Exception:
|
|
1161
|
+
pass
|
|
1162
|
+
|
|
1163
|
+
# ------------------------------------------------------------------
|
|
1164
|
+
# serialization v2 (clean break, best-effort old load as expanded)
|
|
1165
|
+
# ------------------------------------------------------------------
|
|
1166
|
+
def serialize(self) -> dict:
|
|
1167
|
+
"""Return the group as a JSON-serializable dictionary.
|
|
1168
|
+
|
|
1169
|
+
The format is versioned with :data:`GROUP_SERIALIZATION_VERSION`.
|
|
1170
|
+
"""
|
|
1171
|
+
try:
|
|
1172
|
+
p = self.pos()
|
|
1173
|
+
r = self.rect()
|
|
1174
|
+
except Exception:
|
|
1175
|
+
p = QPointF(0, 0)
|
|
1176
|
+
r = QRectF(0, 0, 200, 150)
|
|
1177
|
+
return {
|
|
1178
|
+
"id": self.id,
|
|
1179
|
+
"type": "Group",
|
|
1180
|
+
"version": GROUP_SERIALIZATION_VERSION,
|
|
1181
|
+
"title": self.title,
|
|
1182
|
+
"x": p.x(),
|
|
1183
|
+
"y": p.y(),
|
|
1184
|
+
"w": r.width(),
|
|
1185
|
+
"h": r.height(),
|
|
1186
|
+
"collapsed": bool(self._collapsed),
|
|
1187
|
+
"children": [getattr(n, 'id', None) for n in self.child_nodes if getattr(n, 'id', None) is not None],
|
|
1188
|
+
"style": {
|
|
1189
|
+
"color": self._color.getRgb(),
|
|
1190
|
+
"title_color": self._title_color.getRgb(),
|
|
1191
|
+
"border_color": self._border_color.getRgb(),
|
|
1192
|
+
},
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
def deserialize(self, data: dict, hashmap: Optional[dict] = None, restore_id: bool = True) -> bool:
|
|
1196
|
+
"""Restore the group from a dictionary created by :meth:`serialize`.
|
|
1197
|
+
|
|
1198
|
+
Files written by the old ``GroupNode`` format are loaded as expanded.
|
|
1199
|
+
|
|
1200
|
+
:param data: the serialized group
|
|
1201
|
+
:param hashmap: mapping of ids to objects, used to relink the children
|
|
1202
|
+
:param restore_id: whether the id stored in the file is restored
|
|
1203
|
+
:return: whether the group was restored
|
|
1204
|
+
"""
|
|
1205
|
+
try:
|
|
1206
|
+
if restore_id and 'id' in data:
|
|
1207
|
+
self.id = data['id']
|
|
1208
|
+
self.title = data.get('title', 'Group')
|
|
1209
|
+
# clean break: old payloads load as expanded
|
|
1210
|
+
is_old = data.get('type') == 'GroupNode' or ('children' not in data and 'child_node_ids' in data)
|
|
1211
|
+
if is_old:
|
|
1212
|
+
self._collapsed = False
|
|
1213
|
+
else:
|
|
1214
|
+
self._collapsed = bool(data.get('collapsed', data.get('is_collapsed', False)))
|
|
1215
|
+
try:
|
|
1216
|
+
if 'x' in data and 'y' in data:
|
|
1217
|
+
self.setPos(float(data['x']), float(data['y']))
|
|
1218
|
+
w = float(data.get('w', data.get('width', 200)))
|
|
1219
|
+
h = float(data.get('h', data.get('height', 150)))
|
|
1220
|
+
self.setRect(0, 0, w, h)
|
|
1221
|
+
except Exception:
|
|
1222
|
+
pass
|
|
1223
|
+
try:
|
|
1224
|
+
style = data.get('style', {})
|
|
1225
|
+
if isinstance(style, dict):
|
|
1226
|
+
if 'color' in style and isinstance(style['color'], (list, tuple)):
|
|
1227
|
+
self._color = QColor(*style['color'])
|
|
1228
|
+
if 'title_color' in style and isinstance(style['title_color'], (list, tuple)):
|
|
1229
|
+
self._title_color = QColor(*style['title_color'])
|
|
1230
|
+
if 'border_color' in style and isinstance(style['border_color'], (list, tuple)):
|
|
1231
|
+
self._border_color = QColor(*style['border_color'])
|
|
1232
|
+
else:
|
|
1233
|
+
# old flat keys
|
|
1234
|
+
if 'color' in data and isinstance(data['color'], (list, tuple)):
|
|
1235
|
+
self._color = QColor(*data['color'])
|
|
1236
|
+
if 'title_color' in data and isinstance(data['title_color'], (list, tuple)):
|
|
1237
|
+
self._title_color = QColor(*data['title_color'])
|
|
1238
|
+
if 'border_color' in data and isinstance(data['border_color'], (list, tuple)):
|
|
1239
|
+
self._border_color = QColor(*data['border_color'])
|
|
1240
|
+
try:
|
|
1241
|
+
self.setPen(QPen(self._border_color, self._border_width))
|
|
1242
|
+
self.setBrush(QBrush(self._color))
|
|
1243
|
+
except Exception:
|
|
1244
|
+
pass
|
|
1245
|
+
except Exception:
|
|
1246
|
+
pass
|
|
1247
|
+
# children linked by Scene after all groups deserialized
|
|
1248
|
+
return True
|
|
1249
|
+
except Exception as e:
|
|
1250
|
+
dumpException(e)
|
|
1251
|
+
return False
|
|
1252
|
+
|
|
1253
|
+
def applyCollapsedAfterLoad(self) -> None:
|
|
1254
|
+
"""Apply collapsed visuals after Scene linked children (no history)."""
|
|
1255
|
+
try:
|
|
1256
|
+
if not self._collapsed:
|
|
1257
|
+
return
|
|
1258
|
+
if not self.child_nodes:
|
|
1259
|
+
self._updateCollapsedSize()
|
|
1260
|
+
return
|
|
1261
|
+
for node in list(self.child_nodes):
|
|
1262
|
+
try:
|
|
1263
|
+
gr = getattr(node, 'grNode', None)
|
|
1264
|
+
if gr is not None:
|
|
1265
|
+
gr.hide()
|
|
1266
|
+
except Exception:
|
|
1267
|
+
continue
|
|
1268
|
+
try:
|
|
1269
|
+
_, _, internal = self._classifyEdges()
|
|
1270
|
+
for e in internal:
|
|
1271
|
+
try:
|
|
1272
|
+
if getattr(e, 'grEdge', None) is not None:
|
|
1273
|
+
e.grEdge.hide()
|
|
1274
|
+
except Exception:
|
|
1275
|
+
continue
|
|
1276
|
+
except Exception:
|
|
1277
|
+
pass
|
|
1278
|
+
self._updateCollapsedSize()
|
|
1279
|
+
self.refreshExternalEdges()
|
|
1280
|
+
self.update()
|
|
1281
|
+
except Exception:
|
|
1282
|
+
pass
|
|
1283
|
+
|
|
1284
|
+
|
|
1285
|
+
# Backward-compatible alias (old imports keep working)
|
|
1286
|
+
GroupNode = Group
|