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.
Files changed (40) hide show
  1. nodeeditor/__init__.py +93 -0
  2. nodeeditor/node_colors_config.py +343 -0
  3. nodeeditor/node_content_widget.py +129 -0
  4. nodeeditor/node_decorators.py +65 -0
  5. nodeeditor/node_edge.py +518 -0
  6. nodeeditor/node_edge_dragging.py +185 -0
  7. nodeeditor/node_edge_intersect.py +219 -0
  8. nodeeditor/node_edge_rerouting.py +216 -0
  9. nodeeditor/node_edge_snapping.py +95 -0
  10. nodeeditor/node_edge_validators.py +82 -0
  11. nodeeditor/node_editor_widget.py +257 -0
  12. nodeeditor/node_editor_window.py +448 -0
  13. nodeeditor/node_graph.py +583 -0
  14. nodeeditor/node_graphics_cutline.py +57 -0
  15. nodeeditor/node_graphics_edge.py +375 -0
  16. nodeeditor/node_graphics_edge_path.py +226 -0
  17. nodeeditor/node_graphics_node.py +693 -0
  18. nodeeditor/node_graphics_scene.py +369 -0
  19. nodeeditor/node_graphics_socket.py +262 -0
  20. nodeeditor/node_graphics_view.py +907 -0
  21. nodeeditor/node_group.py +1286 -0
  22. nodeeditor/node_group_node.py +5 -0
  23. nodeeditor/node_icon_content_widget.py +176 -0
  24. nodeeditor/node_icon_graphics_node.py +524 -0
  25. nodeeditor/node_multi_input_node.py +450 -0
  26. nodeeditor/node_node.py +848 -0
  27. nodeeditor/node_scene.py +1106 -0
  28. nodeeditor/node_scene_clipboard.py +314 -0
  29. nodeeditor/node_scene_history.py +556 -0
  30. nodeeditor/node_serializable.py +43 -0
  31. nodeeditor/node_socket.py +445 -0
  32. nodeeditor/node_socket_shapes.py +261 -0
  33. nodeeditor/py.typed +0 -0
  34. nodeeditor/qss/nodestyle.qss +3 -0
  35. nodeeditor/utils.py +108 -0
  36. nodeeditor/utils_no_qt.py +21 -0
  37. nodeeditor_qtpy-0.8.1.dist-info/METADATA +165 -0
  38. nodeeditor_qtpy-0.8.1.dist-info/RECORD +40 -0
  39. nodeeditor_qtpy-0.8.1.dist-info/WHEEL +4 -0
  40. nodeeditor_qtpy-0.8.1.dist-info/licenses/LICENCE +23 -0
@@ -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