chartflow 0.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 (79) hide show
  1. chartflow/__init__.py +28 -0
  2. chartflow/_verify_importexport.py +169 -0
  3. chartflow/edit/__init__.py +119 -0
  4. chartflow/edit/code_editor.py +1007 -0
  5. chartflow/edit/code_folder.py +523 -0
  6. chartflow/edit/completer.py +2652 -0
  7. chartflow/edit/context_analyzer.py +521 -0
  8. chartflow/edit/demo.py +469 -0
  9. chartflow/edit/line_number_area.py +548 -0
  10. chartflow/edit/minimap.py +581 -0
  11. chartflow/edit/python_editor.py +509 -0
  12. chartflow/edit/syntax_highlighter.py +291 -0
  13. chartflow/edit/test_editor.py +190 -0
  14. chartflow/flow/__init__.py +31 -0
  15. chartflow/flow/_demo.py +98 -0
  16. chartflow/flow/chart.py +1101 -0
  17. chartflow/flow/editor.py +375 -0
  18. chartflow/flow/flow.py +6 -0
  19. chartflow/flow/qchart.py +1250 -0
  20. chartflow/flow/test/__init__.py +0 -0
  21. chartflow/flow/test/test_chart.py +537 -0
  22. chartflow/flow/test_decorator_sync.py +102 -0
  23. chartflow/fsm/__init__.py +84 -0
  24. chartflow/fsm/decorators.py +395 -0
  25. chartflow/fsm/demo.py +381 -0
  26. chartflow/fsm/demo_pyqt6.py +881 -0
  27. chartflow/fsm/demo_tree.py +46 -0
  28. chartflow/fsm/logic.py +447 -0
  29. chartflow/fsm/meta.py +569 -0
  30. chartflow/fsm/scheduler.py +427 -0
  31. chartflow/fsm/stage.py +2079 -0
  32. chartflow/fsm/stdio.py +66 -0
  33. chartflow/fsm/test/__init__.py +7 -0
  34. chartflow/fsm/test/test_decorators.py +210 -0
  35. chartflow/fsm/test/test_events.py +202 -0
  36. chartflow/fsm/test/test_integration.py +596 -0
  37. chartflow/fsm/test/test_logic.py +371 -0
  38. chartflow/fsm/test/test_meta.py +192 -0
  39. chartflow/fsm/test/test_runtime_param.py +336 -0
  40. chartflow/fsm/test/test_scheduler.py +280 -0
  41. chartflow/fsm/test/test_stage.py +314 -0
  42. chartflow/fsm/test_behavior_events.py +263 -0
  43. chartflow/fsm/test_nested_parallel.py +80 -0
  44. chartflow/fsm/test_stage_spec.py +448 -0
  45. chartflow/fsm/utils.py +34 -0
  46. chartflow/node/__init__.py +66 -0
  47. chartflow/node/_node_base.py +727 -0
  48. chartflow/node/_node_interaction.py +488 -0
  49. chartflow/node/_node_interface.py +426 -0
  50. chartflow/node/_node_markers.py +648 -0
  51. chartflow/node/_node_ports.py +536 -0
  52. chartflow/node/_port_interface.py +111 -0
  53. chartflow/node/arrow_item.py +287 -0
  54. chartflow/node/color_utils.py +113 -0
  55. chartflow/node/connection_item.py +939 -0
  56. chartflow/node/demo.py +258 -0
  57. chartflow/node/demo_arrow_drag.py +46 -0
  58. chartflow/node/demo_decorator.py +63 -0
  59. chartflow/node/demo_ports.py +69 -0
  60. chartflow/node/enums.py +35 -0
  61. chartflow/node/example.py +84 -0
  62. chartflow/node/layout_utils.py +307 -0
  63. chartflow/node/main_window.py +196 -0
  64. chartflow/node/menu_bar.py +240 -0
  65. chartflow/node/node_canvas.py +1730 -0
  66. chartflow/node/node_item.py +754 -0
  67. chartflow/node/popmenu.py +472 -0
  68. chartflow/node/port_item.py +591 -0
  69. chartflow/node/qnode_editor.py +73 -0
  70. chartflow/node/selection_rect.py +53 -0
  71. chartflow/node/style_panel.py +615 -0
  72. chartflow/node/styles.py +63 -0
  73. chartflow/node/test_qnode.py +2336 -0
  74. chartflow/showcase.py +528 -0
  75. chartflow/theme.py +508 -0
  76. chartflow-0.1.dist-info/METADATA +23 -0
  77. chartflow-0.1.dist-info/RECORD +79 -0
  78. chartflow-0.1.dist-info/WHEEL +5 -0
  79. chartflow-0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1730 @@
1
+ """
2
+ 节点画布模块 - 主视图组件
3
+ 处理所有的用户交互:拖拽、选择、连接、右键菜单等
4
+ """
5
+
6
+ from chartflow.node.enums import NodeShape
7
+ import random
8
+ from chartflow.node.layout_utils import DenseLayoutEngine
9
+ import os
10
+ from PyQt6.QtGui import QColor
11
+
12
+
13
+ import math
14
+ import json
15
+ from typing import Optional, List, Dict, Any
16
+ from dataclasses import dataclass, field
17
+
18
+ from PyQt6.QtWidgets import (
19
+ QGraphicsView, QGraphicsScene, QGraphicsItem,
20
+ QGraphicsPathItem, QMessageBox, QApplication
21
+ )
22
+ from PyQt6.QtCore import Qt, QPointF, QRectF, pyqtSignal
23
+ from PyQt6.QtGui import (
24
+ QPainter, QBrush, QPen, QColor, QPainterPath, QTransform,
25
+ QContextMenuEvent, QMouseEvent, QKeyEvent, QWheelEvent
26
+ )
27
+
28
+ from chartflow.node.node_item import QNodeItem
29
+ from chartflow.node.connection_item import ConnectionItem
30
+ from chartflow.node.arrow_item import ArrowItem
31
+ from chartflow.node.port_item import QNodePort
32
+ from chartflow.node.selection_rect import SelectionRect
33
+ from chartflow.node.style_panel import StylePanel
34
+ from chartflow.node.enums import NodeShape, ConnectionType
35
+ from chartflow.node.layout_utils import DenseLayoutEngine, ViewportFitter
36
+ from chartflow.node.popmenu import QCanvasPopMenu, QLinePopMenu, QNodePopMenu
37
+
38
+
39
+ def _build_temp_bezier(source_pos: QPointF, scene_pos: QPointF) -> QPainterPath:
40
+ """构造拖拽时的临时贝塞尔虚线(从 source_pos 指向 scene_pos)。
41
+
42
+ node 外环拖拽与 port 拖拽共用, 控制点算法与
43
+ ConnectionItem._updateBezierPath 保持一致。
44
+ """
45
+ path = QPainterPath()
46
+ path.moveTo(source_pos)
47
+
48
+ dx = scene_pos.x() - source_pos.x()
49
+ dy = scene_pos.y() - source_pos.y()
50
+ distance = math.sqrt(dx * dx + dy * dy)
51
+ offset = min(distance * 0.5, 150)
52
+
53
+ if abs(dx) > abs(dy):
54
+ ctrl1 = QPointF(source_pos.x() + offset * (1 if dx > 0 else -1), source_pos.y())
55
+ ctrl2 = QPointF(scene_pos.x() - offset * (1 if dx > 0 else -1), scene_pos.y())
56
+ else:
57
+ ctrl1 = QPointF(source_pos.x(), source_pos.y() + offset * (1 if dy > 0 else -1))
58
+ ctrl2 = QPointF(scene_pos.x(), scene_pos.y() - offset * (1 if dy > 0 else -1))
59
+
60
+ path.cubicTo(ctrl1, ctrl2, scene_pos)
61
+ return path
62
+
63
+
64
+ def _resolve_port(node, side, name):
65
+ """按 (side, name) 在 node._port_items 中找回对应的 ``QNodePort``。
66
+
67
+ 用于 importData 还原 port↔port 连线。side/name 任一缺失、或该侧无
68
+ 匹配 name 的端口时返回 None(连接退化为 node 边缘锚定)。
69
+ """
70
+ if not side or not name:
71
+ return None
72
+ items = node._port_items.get(side, [])
73
+ for it in items:
74
+ if it.spec.name == name:
75
+ return it
76
+ return None
77
+
78
+
79
+ # 跨节点可连接性(linkables)的全部合法键。按连线两端点类型分类:
80
+ # - 两端均无 port -> "node->node"
81
+ # - 仅目标端有 port -> "node->port"
82
+ # - 仅源端有 port -> "port->node"
83
+ # - 两端均 port, 按 side 组合 -> "{src_side}->{tgt_side}"
84
+ # (left->right / right->left / left->left / right->right)
85
+ # 仅作用于跨节点连线(同节点自指由各节点 selfrefs 控制)。
86
+ _LINKABLES_KEYS = (
87
+ "node->node",
88
+ "node->port",
89
+ "port->node",
90
+ "left->right",
91
+ "right->left",
92
+ "left->left",
93
+ "right->right",
94
+ )
95
+
96
+ # 默认值 = 既有逻辑: 端口异极(left↔right)允许, 同极(left->left / right->right)
97
+ # 禁止, 含/不含 node 的端点组合均允许。
98
+ _LINKABLES_DEFAULTS: Dict[str, bool] = {
99
+ "node->node": True,
100
+ "node->port": True,
101
+ "port->node": True,
102
+ "left->right": True,
103
+ "right->left": True,
104
+ "left->left": False,
105
+ "right->right": False,
106
+ }
107
+
108
+
109
+ def _linkable_key(source_port, target_port) -> Optional[str]:
110
+ """将一条跨节点连线的两端点类型分类为 linkables 键。
111
+
112
+ Args:
113
+ source_port: 源端 QNodePort, None 表示锚定到节点边缘。
114
+ target_port: 目标端 QNodePort, None 表示锚定到节点边缘。
115
+
116
+ Returns:
117
+ linkables 键字符串(见 _LINKABLES_KEYS); 端点 side 非 left/right 时
118
+ 返回 None(理论上不会发生, 视为不可分类)。
119
+ """
120
+ if source_port is None and target_port is None:
121
+ return "node->node"
122
+ if source_port is None:
123
+ return "node->port"
124
+ if target_port is None:
125
+ return "port->node"
126
+ key = f"{source_port.side}->{target_port.side}"
127
+ return key if key in _LINKABLES_DEFAULTS else None
128
+
129
+
130
+ @dataclass
131
+ class AtomOperation:
132
+ """原子操作 - 记录单个可追踪的修改"""
133
+ NULL: str = field(default="</$NULL>", repr=False)
134
+
135
+ old: Any = None
136
+ new: Any = None
137
+ jpath: List[str] = field(default_factory=list)
138
+ act: bool = True
139
+
140
+ def __postInit(self):
141
+ """初始化后将 NULL 字符串转为常量引用"""
142
+ if self.old == "</$NULL>":
143
+ self.old = AtomOperation.NULL
144
+ if self.new == "</$NULL>":
145
+ self.new = AtomOperation.NULL
146
+
147
+
148
+ class QNodeCanvas(QGraphicsView):
149
+ """
150
+ 节点画布 - 主视图组件
151
+ 处理所有的用户交互:拖拽、选择、连接、右键菜单等
152
+ """
153
+
154
+ nodeSelected = pyqtSignal(object) # 节点选中信号
155
+ connectionCreated = pyqtSignal(object) # 连接创建信号
156
+
157
+ def __init__(self, parent: Optional['QWidget'] = None) -> None:
158
+ super().__init__(parent)
159
+
160
+ # 创建场景
161
+ self._scene: QGraphicsScene = QGraphicsScene(self)
162
+ self._scene.setSceneRect(-5000, -5000, 10000, 10000)
163
+ self.setScene(self._scene)
164
+
165
+ # 连接场景的选择改变信号
166
+ self._scene.selectionChanged.connect(self._onSelectionChanged)
167
+
168
+ # 设置渲染选项
169
+ self.setRenderHints(QPainter.RenderHint.Antialiasing | QPainter.RenderHint.SmoothPixmapTransform)
170
+ self.setViewportUpdateMode(QGraphicsView.ViewportUpdateMode.FullViewportUpdate)
171
+ self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
172
+ self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
173
+ self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
174
+ self.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorViewCenter)
175
+
176
+ # 拖拽模式
177
+ self.setDragMode(QGraphicsView.DragMode.NoDrag)
178
+
179
+ # 状态变量
180
+ self._nodes: List[QNodeItem] = []
181
+ self._connections: List[ConnectionItem] = []
182
+ self._start_node: Optional[QNodeItem] = None # 起点节点
183
+ self._drag_start_pos: Optional[QPointF] = None
184
+ self._temp_connection: Optional[QGraphicsPathItem] = None
185
+ self._source_node_for_connection: Optional[QNodeItem] = None
186
+ self._selection_rect: Optional[SelectionRect] = None
187
+ self._selection_start_pos: Optional[QPointF] = None
188
+ self._is_panning: bool = False
189
+ self._pan_start_pos: Optional[QPointF] = None
190
+ self._space_panning: bool = False # 按住 Space + 左键拖拽 = 平移(中键不可用时的可靠替代)
191
+ self._is_creating_connection: bool = False
192
+
193
+ # port 连线拖拽状态(port 优先级高于 node 外环)
194
+ self._is_creating_port_connection: bool = False
195
+ self._source_port_for_connection: Optional[QNodePort] = None
196
+ self._temp_port_connection: Optional[QGraphicsPathItem] = None
197
+ # 连线拖拽中光标命中的目标 port(画布接管移动事件, hover 不再由场景派发,
198
+ # 故由画布手动追踪并驱动目标 port 的描边/label)
199
+ self._hovered_target_port: Optional[QNodePort] = None
200
+ # 连线拖拽中光标命中的目标 node(未命中 port 时, 让被捕获的节点描边)
201
+ self._hovered_target_node: Optional[QNodeItem] = None
202
+
203
+ # 框选状态(修改:不需要按alt,直接拖动即可框选)
204
+ self._is_selecting: bool = False
205
+
206
+ # 箭头拖拽状态
207
+ self._dragged_arrow: Optional[ArrowItem] = None
208
+ self._original_connection: Optional[ConnectionItem] = None
209
+ self._original_is_bidirectional: bool = False
210
+ self._temp_new_connection: Optional[QGraphicsPathItem] = None
211
+
212
+ # 修改追踪系统
213
+ self._changed: bool = False
214
+ self._json_file_path: Optional[str] = None
215
+
216
+ # 跨节点可连接性规则: 控制不同节点之间哪些端点类型组合允许建线。
217
+ # 与各节点的 selfrefs(同节点自指)互补 —— linkables 只作用于跨节点连线。
218
+ # 默认值 = 既有逻辑(见 _LINKABLES_DEFAULTS); 修改后(通过 linkables 赋值
219
+ # 或 setLinkables)会自动移除已不合规的连接。
220
+ self._linkables: Dict[str, bool] = dict(_LINKABLES_DEFAULTS)
221
+
222
+ # 拖拽位置追踪
223
+ self._drag_start_positions: Dict[int, QPointF] = {}
224
+
225
+ # 创建样式面板(初始隐藏)
226
+ self._style_panel: StylePanel = StylePanel(self)
227
+ self._style_panel.setGeometry(
228
+ 10, self.height() - StylePanel.PANEL_HEIGHT - 10,
229
+ self.width() - 20, StylePanel.PANEL_HEIGHT,
230
+ )
231
+ self._style_panel.hide()
232
+
233
+ # 连接样式改变信号
234
+ self._style_panel.styleChanged.connect(self._onStyleChanged)
235
+
236
+ # 设置背景
237
+ self._setupBackground()
238
+
239
+ # 启用鼠标跟踪
240
+ self.setMouseTracking(True)
241
+
242
+ # 接受键盘焦点,使 Space+左键平移等键盘交互可用
243
+ self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
244
+
245
+ # 右键菜单(空白画布菜单长期持有; 连线菜单每次右键重构, 见 contextMenuEvent)
246
+ self.menu: QCanvasPopMenu = QCanvasPopMenu(self, self)
247
+ self.lmenu: Optional[QLinePopMenu] = None
248
+
249
+ def _setupBackground(self) -> None:
250
+ """设置画布背景"""
251
+ # Use the centralized theme color so the canvas harmonizes with the
252
+ # surrounding chrome; fall back to the legacy light gray if the theme
253
+ # module is unavailable.
254
+ try:
255
+ from chartflow.theme import canvasBackground as _canvas_bg
256
+ bg = _canvas_bg()
257
+ except Exception:
258
+ bg = QColor("#F0F0F0")
259
+ self.setBackgroundBrush(QBrush(bg))
260
+
261
+ @property
262
+ def changed(self) -> bool:
263
+ """画布是否有未保存的修改"""
264
+ return self._changed
265
+
266
+ @changed.setter
267
+ def changed(self, value: bool) -> None:
268
+ """设置修改状态,自动更新窗口标题"""
269
+ if self._changed != value:
270
+ self._changed = value
271
+ self._updateWindowTitle()
272
+
273
+ def _updateWindowTitle(self) -> None:
274
+ """更新窗口标题显示"""
275
+ parent = self.parent()
276
+ while parent and parent.parent() is not None:
277
+ parent = parent.parent()
278
+
279
+ if parent and hasattr(parent, 'setWindowTitle'):
280
+ software_name = "NodeCanvasWidget"
281
+
282
+ if self._json_file_path:
283
+ file_name = os.path.basename(self._json_file_path)
284
+ else:
285
+ file_name = "New Graph"
286
+
287
+ title = f"{software_name} - {file_name}"
288
+ if self._changed:
289
+ title += " *"
290
+
291
+ parent.setWindowTitle(title)
292
+
293
+ def setJsonFilePath(self, file_path: Optional[str]) -> None:
294
+ """设置当前 JSON 文件路径"""
295
+ if self._json_file_path != file_path:
296
+ self._json_file_path = file_path
297
+ self._updateWindowTitle()
298
+
299
+ def getJsonFilePath(self) -> Optional[str]:
300
+ """获取当前 JSON 文件路径"""
301
+ return self._json_file_path
302
+
303
+ def resetChangedState(self) -> None:
304
+ """重置修改状态(Save 成功后调用)"""
305
+ self.changed = False
306
+
307
+ def _onSelectionChanged(self) -> None:
308
+ """选择改变时更新样式面板"""
309
+ selected_nodes = [item for item in self._scene.selectedItems() if isinstance(item, QNodeItem)]
310
+ self._style_panel.setSelectedNodes(selected_nodes)
311
+ self.nodeSelected.emit(selected_nodes[0] if selected_nodes else None)
312
+
313
+ def _onStyleChanged(self) -> None:
314
+ """样式改变时设置修改状态"""
315
+ self.changed = True
316
+
317
+ @property
318
+ def nodes(self) -> List[QNodeItem]:
319
+ """获取所有节点"""
320
+ return self._nodes.copy()
321
+
322
+ @property
323
+ def connections(self) -> List[ConnectionItem]:
324
+ """获取所有连接"""
325
+ return self._connections.copy()
326
+
327
+ # ------------------------------------------------------------------ #
328
+ # 跨节点可连接性(linkables)
329
+ # ------------------------------------------------------------------ #
330
+ @property
331
+ def linkables(self) -> Dict[str, bool]:
332
+ """跨节点可连接性规则: 控制不同节点之间哪些端点类型组合允许建线。
333
+
334
+ 与各节点的 ``selfrefs``(同节点自指)互补 —— linkables 只作用于
335
+ **跨节点**连线(同节点自指由 source 节点的 selfrefs 控制)。
336
+
337
+ 七个键(默认值 = 既有逻辑):
338
+
339
+ - ``"node->node"`` (默认 True): 源节点边缘 → 目标节点边缘。
340
+ - ``"node->port"`` (默认 True): 源节点边缘 → 目标 port。
341
+ - ``"port->node"`` (默认 True): 源 port → 目标节点边缘。
342
+ - ``"left->right"`` (默认 True): 源 left port → 目标 right port。
343
+ - ``"right->left"`` (默认 True): 源 right port → 目标 left port。
344
+ - ``"left->left"`` (默认 False): 源 left port → 目标 left port。
345
+ - ``"right->right"`` (默认 False): 源 right port → 目标 right port。
346
+
347
+ 可读可写:
348
+
349
+ - **读取**返回内部 dict(**非副本**), 可直接改值, 但直接改值**不会**
350
+ 自动清理已有连接(与 selfrefs 一致)。若需改值后立即清理, 改完调用
351
+ :meth:`enforceLinkables`, 或改用整体赋值/setLinkables。
352
+ - **整体赋值**(``canvas.linkables = {...}``)或 :meth:`setLinkables`
353
+ 会校验键集、合并未知键的默认值, 并**自动移除**已不合规的连接::
354
+
355
+ canvas.linkables = {"left->left": True, "right->right": False}
356
+ # 部分修改 + 立即清理:
357
+ canvas.setLinkables(left__right=False)
358
+ """
359
+ return self._linkables
360
+
361
+ @linkables.setter
362
+ def linkables(self, value: Dict[str, bool]) -> None:
363
+ """整体设置 linkables(覆盖式): 校验后自动清理不合规连接。"""
364
+ self._applyLinkables(value, merge=False)
365
+ self.enforceLinkables()
366
+
367
+ def setLinkables(self, merge: bool = True, **kwargs: bool) -> None:
368
+ """设置 linkables, 默认**合并**(只改传入的键, 其余保持原值)。
369
+
370
+ Args:
371
+ merge: True=部分修改(默认); False=整体覆盖(未给出的键取默认值)。
372
+ **kwargs: 要改的键值, 如 ``left__right=False``。
373
+ (Python 标识符不能含 ``->``, 用双下划线 ``__`` 代替,
374
+ 例如 ``left__right`` 对应键 ``"left->right"``。)
375
+
376
+ 修改后自动调用 :meth:`enforceLinkables` 清理不合规连接。
377
+ """
378
+ updates: Dict[str, bool] = {}
379
+ for k, v in kwargs.items():
380
+ key = k.replace("__", "->")
381
+ if key not in _LINKABLES_DEFAULTS:
382
+ raise KeyError(f"未知 linkables 键: {k!r} (-> {key!r})")
383
+ updates[key] = bool(v)
384
+ self._applyLinkables(updates if merge else {**_LINKABLES_DEFAULTS, **updates},
385
+ merge=merge)
386
+ self.enforceLinkables()
387
+
388
+ def _applyLinkables(self, value: Dict[str, bool], merge: bool) -> None:
389
+ """校验并写入 _linkables。merge=True 时保留未给出的键。"""
390
+ if not isinstance(value, dict):
391
+ raise TypeError(f"linkables 必须是 dict,得到 {type(value).__name__}")
392
+ for k, v in value.items():
393
+ if k not in _LINKABLES_DEFAULTS:
394
+ raise KeyError(f"未知 linkables 键: {k!r}")
395
+ if not isinstance(v, bool):
396
+ raise TypeError(f"linkables[{k!r}] 必须是 bool,得到 {type(v).__name__}")
397
+ if merge:
398
+ self._linkables.update(value)
399
+ else:
400
+ # 整体覆盖: 以默认值打底, 再叠加传入项, 保证 7 个键齐全
401
+ self._linkables = {**_LINKABLES_DEFAULTS, **value}
402
+
403
+ def isLinkable(self, source_node: QNodeItem, target_node: QNodeItem,
404
+ source_port=None, target_port=None) -> bool:
405
+ """判定一条(潜在的)跨节点连线是否符合当前 linkables 规则。
406
+
407
+ 同节点自指不在 linkables 范畴(由 source 节点 selfrefs 控制), 直接
408
+ 返回 True, 交由 addConnection 的自指分支裁决。
409
+ """
410
+ if source_node is target_node:
411
+ return True
412
+ key = _linkable_key(source_port, target_port)
413
+ if key is None:
414
+ return False
415
+ return self._linkables.get(key, _LINKABLES_DEFAULTS.get(key, False))
416
+
417
+ def enforceLinkables(self) -> List[ConnectionItem]:
418
+ """按当前 linkables 规则清理**跨节点**连线, 移除已不合规者。
419
+
420
+ 同节点自指不受 linkables 影响(由 selfrefs 控制), 跳过。
421
+
422
+ Returns:
423
+ 被移除的连接列表(便于测试/日志)。
424
+ """
425
+ removed: List[ConnectionItem] = []
426
+ for conn in self._connections[:]: # connections 副本, 可安全边删边遍历
427
+ if conn.sourceNode is conn.targetNode:
428
+ continue # 自指: 由 selfrefs 管, linkables 不干预
429
+ if not self.isLinkable(conn.sourceNode, conn.targetNode,
430
+ conn.sourcePort, conn.targetPort):
431
+ self.removeConnection(conn)
432
+ removed.append(conn)
433
+ return removed
434
+
435
+ @property
436
+ def startNode(self) -> Optional[QNodeItem]:
437
+ """获取起点节点"""
438
+ return self._start_node
439
+
440
+ @startNode.setter
441
+ def startNode(self, node: Optional[QNodeItem]) -> None:
442
+ """设置起点节点"""
443
+ # 清除旧的起点标记
444
+ if self._start_node is not None and self._start_node != node:
445
+ self._start_node.setStartMarkerVisible(False)
446
+
447
+ self._start_node = node
448
+
449
+ # 设置新的起点标记
450
+ if node is not None:
451
+ node.setStartMarkerVisible(True)
452
+
453
+ def addNode(self, name: str, position: Optional[QPointF] = None,
454
+ shape: NodeShape = NodeShape.ELLIPSE) -> QNodeItem:
455
+ """
456
+ 添加一个新节点
457
+
458
+ Args:
459
+ name: 节点名称
460
+ position: 节点位置,默认为画布中心
461
+ shape: 节点形状
462
+
463
+ Returns:
464
+ 创建的节点项
465
+ """
466
+ if position is None:
467
+ position = self.mapToScene(self.viewport().rect().center())
468
+
469
+ node = QNodeItem(name, position, shape)
470
+ self._scene.addItem(node)
471
+ self._nodes.append(node)
472
+
473
+ # 连接节点信号
474
+ node.editRequested.connect(self._onNodeEditRequested)
475
+
476
+ # 注入节点右键菜单(此时 node 已加入 self._scene, canvas 引用可用)
477
+ node.menu = QNodePopMenu(self, node)
478
+
479
+ self.changed = True
480
+ return node
481
+
482
+ def removeNode(self, node: QNodeItem) -> None:
483
+ """移除节点"""
484
+ if node not in self._nodes:
485
+ return
486
+
487
+ # 移除相关连接
488
+ connections_to_remove = [conn for conn in self._connections
489
+ if conn.sourceNode == node or conn.targetNode == node]
490
+ for conn in connections_to_remove:
491
+ self.removeConnection(conn)
492
+
493
+ # 从场景中移除
494
+ self._scene.removeItem(node)
495
+ self._nodes.remove(node)
496
+
497
+ # 如果删除的是起点节点,清除起点标记
498
+ if self._start_node == node:
499
+ self._start_node = None
500
+
501
+ self.changed = True
502
+
503
+ def _flashReject(self, source_node, target_node, source_port, target_port) -> None:
504
+ """规则拒绝(selfrefs/linkables/connectable)时, 闪烁两端 node/port。
505
+
506
+ 某端绑定 port 则闪 port, 否则闪 node。供 addConnection 在不合规拒绝点调用。
507
+ """
508
+ src_item = source_port if source_port is not None else source_node
509
+ tgt_item = target_port if target_port is not None else target_node
510
+ if src_item is not None:
511
+ src_item.flashError()
512
+ if tgt_item is not None:
513
+ tgt_item.flashError()
514
+
515
+ def addConnection(self, source_node: QNodeItem, target_node: QNodeItem,
516
+ is_bidirectional: bool = False,
517
+ connection_type: ConnectionType = ConnectionType.BEZIER,
518
+ *, check_partners: bool = True,
519
+ source_port=None, target_port=None) -> Optional[ConnectionItem]:
520
+ """
521
+ 添加两个节点之间的连接
522
+
523
+ Args:
524
+ source_node: 源节点
525
+ target_node: 目标节点
526
+ is_bidirectional: 是否是双向连接
527
+ connection_type: 连线类型
528
+ check_partners: 是否检查源节点的伙伴节点(拖拽箭头时为False)
529
+ source_port: 源端端口(QNodePort), 端点锚定到该 port; None=节点边缘
530
+ target_port: 目标端端口(QNodePort), 端点锚定到该 port; None=节点边缘
531
+
532
+ Returns:
533
+ 创建的连接线项,如果连接已存在则返回已存在的连接;
534
+ 若是 port 自指(任意一端为 port 且两端同节点)则返回 None。
535
+ """
536
+ # 自指规则(同节点): 按 source_node.selfrefs 判定 6 类自指是否允许。
537
+ # - node->node: 两端均无 port
538
+ # - port->port: 两端均 port 且同极/同一 port(不含异极)
539
+ # - node->port: 源无 port、目标有 port
540
+ # - port->node: 源有 port、目标无 port
541
+ # - left->right: 源=left port、目标=right port
542
+ # - right->left: 源=right port、目标=left port
543
+ # 被允许的自指由 ConnectionItem 绘制为环/椭圆环。
544
+ if source_node is target_node:
545
+ sr = source_node.selfrefs
546
+ if source_port is None and target_port is None:
547
+ # node→node 自环
548
+ if not sr.get("node->node", True):
549
+ self._flashReject(source_node, target_node, source_port, target_port)
550
+ return None
551
+ elif source_port is not None and target_port is not None:
552
+ # port→port: 异极按方向(left->right / right->left), 同极/同 port 走 port->port
553
+ if source_port.side != target_port.side:
554
+ key = "right->left" if source_port.side == "right" else "left->right"
555
+ if not sr.get(key, True):
556
+ self._flashReject(source_node, target_node, source_port, target_port)
557
+ return None
558
+ else:
559
+ if not sr.get("port->port", False):
560
+ self._flashReject(source_node, target_node, source_port, target_port)
561
+ return None
562
+ elif source_port is not None:
563
+ # port→node (源 port、目标节点边缘)
564
+ if not sr.get("port->node", False):
565
+ self._flashReject(source_node, target_node, source_port, target_port)
566
+ return None
567
+ else: # target_port is not None
568
+ # node→port (源节点边缘、目标 port)
569
+ if not sr.get("node->port", False):
570
+ self._flashReject(source_node, target_node, source_port, target_port)
571
+ return None
572
+
573
+ # 跨节点可连接性规则(linkables): 按 (源端点类型, 目标端点类型) 分类,
574
+ # 对照 canvas.linkables 判定是否允许。默认值等价于旧逻辑(同极 port 禁止、
575
+ # 异极 port / 含 node 的端点组合允许); 同节点自指已由上面 selfrefs 处理。
576
+ if source_node is not target_node:
577
+ if not self.isLinkable(source_node, target_node, source_port, target_port):
578
+ self._flashReject(source_node, target_node, source_port, target_port)
579
+ return None
580
+
581
+ # 任一端点不可连线则拒绝(拖拽创建 / 箭头重连 / 导入都经过此处)
582
+ if not source_node.isConnectable() or not target_node.isConnectable():
583
+ self._flashReject(source_node, target_node, source_port, target_port)
584
+ return None
585
+
586
+ # 检查是否已存在连接(按 (node, port) 精确判重; port 为 None 时退化为 node 级)
587
+ # NOTE: 自指(right→left 与 left→right 等)是相互独立的边, 不做反向合并,
588
+ # 故仅对非自指连线执行"反向连接转双向"。
589
+ is_selfref = source_node is target_node
590
+ for conn in self._connections:
591
+ # 1. 检查源端点到目标端点的直接连接(严格重复)
592
+ same_forward = (
593
+ conn.sourceNode == source_node and conn.sourcePort is source_port
594
+ and conn.targetNode == target_node and conn.targetPort is target_port
595
+ )
596
+ if same_forward:
597
+ conn.flashError() # 闪烁显示错误
598
+ return conn
599
+
600
+ # 2. 检查反向连接(目标到源的连接)— 自指连线跳过(方向独立, 不合并)
601
+ if is_selfref:
602
+ continue
603
+ same_reverse = (
604
+ conn.sourceNode == target_node and conn.sourcePort is target_port
605
+ and conn.targetNode == source_node and conn.targetPort is source_port
606
+ )
607
+ if same_reverse:
608
+ # 反向连接已存在,将其转换为双向连线
609
+ if conn.isBidirectional:
610
+ conn.flashError() # 已经是双向,闪烁错误
611
+ return conn
612
+ conn.isBidirectional = True
613
+ conn.updatePath()
614
+ self.changed = True
615
+ return conn
616
+
617
+ connection = ConnectionItem(
618
+ source_node, target_node, is_bidirectional, connection_type,
619
+ source_port=source_port, target_port=target_port,
620
+ )
621
+ self._scene.addItem(connection)
622
+ self._connections.append(connection)
623
+
624
+ # 添加箭头到场景
625
+ self._scene.addItem(connection.targetArrow)
626
+ if connection.sourceArrow:
627
+ self._scene.addItem(connection.sourceArrow)
628
+
629
+ # 设置箭头拖拽回调
630
+ connection.setArrowDragCallbacks(
631
+ started=self._onArrowDragStarted,
632
+ moved=self._onArrowDragMoved,
633
+ finished=self._onArrowDragFinished
634
+ )
635
+
636
+ self.changed = True
637
+ self.connectionCreated.emit(connection)
638
+ return connection
639
+
640
+ def removeConnection(self, connection: ConnectionItem) -> None:
641
+ """移除连接"""
642
+ if connection not in self._connections:
643
+ return
644
+
645
+ # 移除箭头
646
+ connection.removeArrows()
647
+
648
+ # 从场景中移除
649
+ self._scene.removeItem(connection)
650
+ self._connections.remove(connection)
651
+
652
+ self.changed = True
653
+
654
+ def _onNodeEditRequested(self, node: QNodeItem) -> None:
655
+ """节点编辑请求回调"""
656
+ # 如果节点有自定义编辑器,打开它
657
+ if node.editorClass:
658
+ editor = node.editorClass(node)
659
+ editor.setWindowTitle(editor.getTitle())
660
+ editor.show()
661
+
662
+ def _updateHoveredTargetPort(self, new_target: Optional[QNodePort]) -> None:
663
+ """连线拖拽中更新光标命中的目标 port 高亮(描边 + label)。
664
+
665
+ 连线拖拽期间场景 hover 派发被压制, 故由画布手动追踪并驱动目标 port
666
+ 的描边。仅在目标变化时刷新, 离开旧目标时清除其高亮态。
667
+ """
668
+ if new_target is self._hovered_target_port:
669
+ return
670
+ if self._hovered_target_port is not None:
671
+ self._hovered_target_port.setDropTarget(False)
672
+ self._hovered_target_port = new_target
673
+ if new_target is not None:
674
+ new_target.setDropTarget(True)
675
+
676
+ def _updateHoveredTargetNode(self, new_target: Optional[QNodeItem]) -> None:
677
+ """连线拖拽中更新光标命中的目标节点高亮(描边)。
678
+
679
+ 与 port 落点高亮互斥: 命中 port 时由调用方传 ``None`` 取消节点高亮。
680
+ 仅在目标变化时刷新, 离开旧目标时清除其描边态。
681
+ """
682
+ if new_target is self._hovered_target_node:
683
+ return
684
+ if self._hovered_target_node is not None:
685
+ self._hovered_target_node.setDropTarget(False)
686
+ self._hovered_target_node = new_target
687
+ if new_target is not None:
688
+ new_target.setDropTarget(True)
689
+
690
+ def _onPortConnectionDragStart(self, port_graphic: QNodePort, scene_pos: QPointF) -> None:
691
+ """port 被左键按下: 开始 port 连线拖拽。
692
+
693
+ 镜像 node 外环拖拽的初始化, 但起点端点是 port 而非 node。
694
+ 后续的 move/release 由本视图的 mouseMoveEvent/mouseReleaseEvent 接管。
695
+ """
696
+ self._is_creating_port_connection = True
697
+ self._source_port_for_connection = port_graphic
698
+ self._drag_start_pos = scene_pos
699
+
700
+ # 创建临时虚线连接线(与 node 拖拽一致)
701
+ self._temp_port_connection = QGraphicsPathItem()
702
+ pen = QPen(QColor("#666666"), 2, Qt.PenStyle.DashLine)
703
+ self._temp_port_connection.setPen(pen)
704
+ self._temp_port_connection.setZValue(5)
705
+ self._scene.addItem(self._temp_port_connection)
706
+
707
+ def _onArrowDragStarted(self, pos: QPointF) -> None:
708
+ """箭头拖拽开始回调"""
709
+ # 找到当前被拖拽的箭头
710
+ for conn in self._connections:
711
+ for arrow in conn.arrows:
712
+ if arrow.isDragging:
713
+ self._dragged_arrow = arrow
714
+ self._original_connection = arrow.connection
715
+ self._original_is_bidirectional = arrow.connection.isBidirectional
716
+
717
+ # 创建临时连接线
718
+ self._temp_new_connection = QGraphicsPathItem()
719
+ pen = QPen(QColor("#666666"), 2, Qt.PenStyle.DashLine)
720
+ self._temp_new_connection.setPen(pen)
721
+ self._temp_new_connection.setZValue(5)
722
+ self._scene.addItem(self._temp_new_connection)
723
+ return
724
+
725
+ def _onArrowDragMoved(self, pos: QPointF) -> None:
726
+ """箭头拖拽移动回调"""
727
+ if self._dragged_arrow and self._temp_new_connection and self._original_connection:
728
+ # 根据拖拽的箭头类型确定虚线起点
729
+ if self._dragged_arrow.isSourceArrow:
730
+ # 拖拽源端箭头(反向箭头),从目标节点中心发出
731
+ start_pos = self._original_connection.targetNode.sceneBoundingRect().center()
732
+ else:
733
+ # 拖拽目标端箭头(正向箭头),从源节点中心发出
734
+ start_pos = self._original_connection.sourceNode.sceneBoundingRect().center()
735
+
736
+ path = QPainterPath()
737
+ path.moveTo(start_pos)
738
+ path.lineTo(pos)
739
+ self._temp_new_connection.setPath(path)
740
+
741
+ def _onArrowDragFinished(self, pos: QPointF, is_success: bool) -> None:
742
+ """箭头拖拽结束回调"""
743
+ # 移除临时连接线
744
+ if self._temp_new_connection:
745
+ self._scene.removeItem(self._temp_new_connection)
746
+ self._temp_new_connection = None
747
+
748
+ if self._dragged_arrow and self._original_connection:
749
+ connection = self._original_connection
750
+
751
+ if is_success:
752
+ # 查找目标节点
753
+ item = self._scene.itemAt(pos, QTransform())
754
+ target_node = None
755
+ while item:
756
+ if isinstance(item, QNodeItem):
757
+ target_node = item
758
+ break
759
+ item = item.parentItem()
760
+
761
+ if target_node:
762
+ # 确定新连接的源节点和目标节点
763
+ # 从箭头的起点创建新连接
764
+ # isSourceArrow=False: targetArrow 从 sourceNode 发出 -> 新源节点是 sourceNode
765
+ # isSourceArrow=True: sourceArrow 从 targetNode 发出 -> 新源节点是 targetNode
766
+ if self._dragged_arrow.isSourceArrow:
767
+ # 拖拽的是源端箭头(从 targetNode 发出,指向 sourceNode)
768
+ # 新源节点是 targetNode
769
+ new_source = connection.targetNode
770
+ new_target = target_node
771
+ else:
772
+ # 拖拽的是目标端箭头(从 sourceNode 发出,指向 targetNode)
773
+ # 新源节点是 sourceNode
774
+ new_source = connection.sourceNode
775
+ new_target = target_node
776
+
777
+ # 检查是否是同一连接
778
+ if new_source != new_target:
779
+ # 保存原始连接的状态
780
+ orig_source = connection.sourceNode
781
+ orig_target = connection.targetNode
782
+ orig_is_source_arrow = self._dragged_arrow.isSourceArrow
783
+ orig_is_bidirectional = self._original_is_bidirectional
784
+
785
+ # 检查是否拖拽回同一个目标(连接未改变)
786
+ same_target = (new_source == orig_source and new_target == orig_target) or \
787
+ (orig_is_bidirectional and new_source == orig_target and new_target == orig_source)
788
+ if same_target:
789
+ # 拖拽回同一目标,不做任何操作
790
+ new_conn = None
791
+ else:
792
+ # 检查目标连接是否已存在
793
+ target_exists = any(
794
+ c.sourceNode == new_source and c.targetNode == new_target
795
+ for c in self._connections
796
+ )
797
+ if target_exists:
798
+ # 目标连接已存在,闪烁错误但不删除原连接
799
+ connection.flashError()
800
+ new_conn = None
801
+ else:
802
+ # 先尝试创建新连接(如果失败会闪烁)
803
+ # 拖拽箭头时,不检查源节点的双向伙伴(checkPartners=False)
804
+ new_conn = self.addConnection(new_source, new_target, check_partners=False)
805
+
806
+ if new_conn is not None and new_conn != connection:
807
+ # 新连接创建成功
808
+ # 处理原始连接:如果是双向连接,保留未拖拽方向的连接
809
+ if orig_is_bidirectional:
810
+ # 双向连接需要特殊处理:保留未拖拽方向的连接
811
+ if orig_is_source_arrow:
812
+ # 拖拽的是源端箭头 (B->A)
813
+ # 原连接: targetArrow=A->B, sourceArrow=B->A
814
+ # 需要保留: A->B (targetArrow)
815
+ # 移除: B->A (sourceArrow)
816
+ # 直接设置为单向即可,会移除 sourceArrow
817
+ connection.isBidirectional = False
818
+ else:
819
+ # 拖拽的是目标端箭头 (A->B)
820
+ # 原连接: targetArrow=A->B, sourceArrow=B->A
821
+ # 需要保留: B->A (sourceArrow)
822
+ # 移除: A->B (targetArrow)
823
+ # 先交换方向,使要保留的箭头成为 targetArrow
824
+ connection.swapDirection()
825
+ # 然后设置为单向,这会移除 sourceArrow (原 targetArrow)
826
+ connection.isBidirectional = False
827
+ else:
828
+ # 单向连接,直接删除
829
+ self.removeConnection(connection)
830
+ # 如果 new_conn == connection,说明是已存在的连接被返回
831
+ # addConnection 内部已经闪烁了已存在的连接,这里不再闪烁
832
+ else:
833
+ # 连接到同一节点,恢复并闪烁
834
+ connection.flashError()
835
+ else:
836
+ # 没有连接到节点,恢复并闪烁
837
+ connection.flashError()
838
+ else:
839
+ # 连接失败,恢复并闪烁
840
+ connection.flashError()
841
+
842
+ self._dragged_arrow = None
843
+ self._original_connection = None
844
+ self._original_is_bidirectional = False
845
+
846
+ def mousePressEvent(self, event: QMouseEvent) -> None:
847
+ """鼠标按下事件"""
848
+ scene_pos = self.mapToScene(event.pos())
849
+
850
+ # 检查是否有节点正在编辑名称
851
+ for node in self._nodes:
852
+ if node.isEditingName:
853
+ # 检查点击是否在代理控件内
854
+ proxy = getattr(node, '_name_edit_proxy', None)
855
+ if proxy and proxy.sceneBoundingRect().contains(scene_pos):
856
+ # 点击在编辑区域内,不处理
857
+ break
858
+ else:
859
+ # 点击在编辑区域外,完成编辑
860
+ node._finishNameEdit()
861
+ # 继续处理其他鼠标事件
862
+ break
863
+
864
+ if event.button() == Qt.MouseButton.MiddleButton:
865
+ # 中键按下 - 开始平移
866
+ self._is_panning = True
867
+ self._pan_start_pos = event.pos()
868
+ self.setCursor(Qt.CursorShape.ClosedHandCursor)
869
+ event.accept()
870
+ return
871
+
872
+ elif self._space_panning and event.button() == Qt.MouseButton.LeftButton:
873
+ # 空格 + 左键拖拽 - 平移(轨迹板 / 无中键设备的可靠平移方式)
874
+ self._is_panning = True
875
+ self._pan_start_pos = event.pos()
876
+ self.setCursor(Qt.CursorShape.ClosedHandCursor)
877
+ event.accept()
878
+ return
879
+
880
+ elif event.button() == Qt.MouseButton.LeftButton:
881
+ # 获取点击位置的图元
882
+ items = self._scene.items(scene_pos)
883
+ mods = event.modifiers()
884
+
885
+ # Alt+左键 = 平移画布(Alt 在本组件全局无冲突)
886
+ if mods & Qt.KeyboardModifier.AltModifier:
887
+ self._is_panning = True
888
+ self._pan_start_pos = event.pos()
889
+ self.setCursor(Qt.CursorShape.ClosedHandCursor)
890
+ event.accept()
891
+ return
892
+
893
+ # 检查是否点击了箭头
894
+ arrow_clicked = False
895
+ for item in items:
896
+ if isinstance(item, ArrowItem):
897
+ arrow_clicked = True
898
+ break
899
+
900
+ if not arrow_clicked:
901
+ # port 优先(port z=90 高于 node z=50): 仅当光标落在某 port 的 dot
902
+ # 实际几何(shape() 故意放大到 12px 以便 hover, 会误吞节点外环点击)
903
+ # 上时, 才发起 port 连线拖拽; 否则继续判定 node 边缘/内部/框选。
904
+ port_on_dot = None
905
+ for item in items:
906
+ if isinstance(item, QNodePort) and item.isPointOnDot(scene_pos):
907
+ port_on_dot = item
908
+ break
909
+ if port_on_dot is not None:
910
+ # 命中 port dot 本身: 转发按下事件给场景 -> 派发给该 QNodePort
911
+ # -> QNodePort.mousePressEvent -> _onPortConnectionDragStart
912
+ super().mousePressEvent(event)
913
+ return
914
+
915
+ # 检查是否点击了节点(用于创建连接)
916
+ node_clicked = None
917
+ for item in items:
918
+ if isinstance(item, QNodeItem):
919
+ node_clicked = item
920
+ break
921
+
922
+ if node_clicked:
923
+ # 检查是否点击在节点边缘(用于创建连接)
924
+ if self._is_on_node_edge(node_clicked, scene_pos):
925
+ # 从边缘拖拽 - 创建连接
926
+ self._is_creating_connection = True
927
+ self._source_node_for_connection = node_clicked
928
+ self._drag_start_pos = scene_pos
929
+
930
+ # 创建临时连接线
931
+ self._temp_connection = QGraphicsPathItem()
932
+ pen = QPen(QColor("#666666"), 2, Qt.PenStyle.DashLine)
933
+ self._temp_connection.setPen(pen)
934
+ self._temp_connection.setZValue(5)
935
+ self._scene.addItem(self._temp_connection)
936
+
937
+ event.accept()
938
+ return
939
+ else:
940
+ # 从内部拖拽 - 移动节点
941
+ # 记录拖拽起始位置用于移动追踪
942
+ self._drag_start_positions[id(node_clicked)] = node_clicked.pos()
943
+ # 调用父类处理拖拽
944
+ super().mousePressEvent(event)
945
+ return
946
+ else:
947
+ # 命中检测未返回节点: 光标可能落在节点 shape() 之外、但仍处
948
+ # 于"外环"边缘区域(椭圆/矩形外 0~5% 容差, _is_on_node_edge 判定)。
949
+ # 遍历所有节点做边缘判定, 命中则发起 node→? 连线, 而非框选。
950
+ edge_node = None
951
+ for node in self._nodes:
952
+ if self._is_on_node_edge(node, scene_pos):
953
+ edge_node = node
954
+ break
955
+
956
+ if edge_node is not None:
957
+ # 从外环拖拽 - 创建连接
958
+ self._is_creating_connection = True
959
+ self._source_node_for_connection = edge_node
960
+ self._drag_start_pos = scene_pos
961
+
962
+ # 创建临时连接线
963
+ self._temp_connection = QGraphicsPathItem()
964
+ pen = QPen(QColor("#666666"), 2, Qt.PenStyle.DashLine)
965
+ self._temp_connection.setPen(pen)
966
+ self._temp_connection.setZValue(5)
967
+ self._scene.addItem(self._temp_connection)
968
+
969
+ event.accept()
970
+ return
971
+
972
+ # 没有点击节点,开始框选
973
+ self._is_selecting = True
974
+ self._selection_start_pos = scene_pos
975
+
976
+ # 创建框选矩形
977
+ self._selection_rect = SelectionRect()
978
+ self._selection_rect.setRect(QRectF(scene_pos, scene_pos))
979
+ self._scene.addItem(self._selection_rect)
980
+
981
+ # 如果没有按住Ctrl,清除当前选中
982
+ if not (event.modifiers() & Qt.KeyboardModifier.ControlModifier):
983
+ for node in self._nodes:
984
+ node.setSelected(False)
985
+
986
+ event.accept()
987
+ return
988
+
989
+ super().mousePressEvent(event)
990
+
991
+ def mouseMoveEvent(self, event: QMouseEvent) -> None:
992
+ """鼠标移动事件"""
993
+ scene_pos = self.mapToScene(event.pos())
994
+
995
+ # 悬停光标:Alt 按住时,光标在节点上显示「调整大小」光标,空白处显示抓手
996
+ self._updateHoverCursor(event)
997
+
998
+ if self._is_panning and self._pan_start_pos is not None:
999
+ # 平移画布(注意:QPoint(0,0) 在 Python 中为假,必须用 is not None 判断,
1000
+ # 否则在视口左上角附近按下中键时 _pan_start_pos 为 QPoint(0,0) 会被误判为空)
1001
+ delta = event.pos() - self._pan_start_pos
1002
+ self.horizontalScrollBar().setValue(
1003
+ self.horizontalScrollBar().value() - delta.x()
1004
+ )
1005
+ self.verticalScrollBar().setValue(
1006
+ self.verticalScrollBar().value() - delta.y()
1007
+ )
1008
+ self._pan_start_pos = event.pos()
1009
+ event.accept()
1010
+ return
1011
+
1012
+ elif self._is_creating_port_connection and self._temp_port_connection:
1013
+ # port 连线拖拽: 虚线从起点 port 中心跟随光标
1014
+ if self._source_port_for_connection is not None:
1015
+ source_pos = self._source_port_for_connection.scenePos()
1016
+ self._temp_port_connection.setPath(_build_temp_bezier(source_pos, scene_pos))
1017
+ # 手动追踪光标下的目标 port/node(场景 hover 派发已被压制)。
1018
+ # 不排除起点 port 及其所属节点: 允许 port→port / port→node 自指
1019
+ # (鼠标离开起点后再次回到起点 port/节点时, 能作为落点创建自指)。
1020
+ # 是否允许由 addConnection 的 selfrefs 规则把关。
1021
+ new_target = None
1022
+ new_target_node = None
1023
+ source_node = (self._source_port_for_connection._node
1024
+ if self._source_port_for_connection is not None else None)
1025
+ for item in self._scene.items(scene_pos):
1026
+ if isinstance(item, QNodePort):
1027
+ new_target = item
1028
+ break
1029
+ if isinstance(item, QNodeItem):
1030
+ new_target_node = item
1031
+ break
1032
+ self._updateHoveredTargetPort(new_target)
1033
+ # 未命中 port 时, 让被捕获的目标节点也描边(port 优先, 两者不同时高亮)
1034
+ if new_target is not None:
1035
+ self._updateHoveredTargetNode(None)
1036
+ else:
1037
+ self._updateHoveredTargetNode(new_target_node)
1038
+ event.accept()
1039
+ return
1040
+
1041
+ elif self._is_creating_connection and self._temp_connection:
1042
+ # 更新临时连接线(从源节点中心出发)
1043
+ if self._source_node_for_connection:
1044
+ source_pos = self._source_node_for_connection.sceneBoundingRect().center()
1045
+ self._temp_connection.setPath(_build_temp_bezier(source_pos, scene_pos))
1046
+ # 手动追踪光标下的目标(edge 连线拖拽场景 hover 同样被压制)。
1047
+ # 与 port→? 拖拽对称: 优先命中 port dot 本身(isPointOnDot, 避免 12px
1048
+ # 放大命中区误吞节点外环), 命中 port 则只高亮 port; 否则再判 NodeItem,
1049
+ # 允许落在源节点自身(node→node 自环); 最后兜底外环容差区域。
1050
+ new_target_port = None
1051
+ new_target_node = None
1052
+ for item in self._scene.items(scene_pos):
1053
+ if isinstance(item, QNodePort) and item.isPointOnDot(scene_pos):
1054
+ new_target_port = item
1055
+ break
1056
+ if isinstance(item, QNodeItem):
1057
+ new_target_node = item
1058
+ break
1059
+ if new_target_node is None:
1060
+ for node in self._nodes:
1061
+ if self._is_on_node_edge(node, scene_pos):
1062
+ new_target_node = node
1063
+ break
1064
+ # port 与 node 高亮互斥: 命中 port 时取消节点高亮
1065
+ if new_target_port is not None:
1066
+ self._updateHoveredTargetNode(None)
1067
+ else:
1068
+ self._updateHoveredTargetPort(None)
1069
+ self._updateHoveredTargetNode(new_target_node)
1070
+ self._updateHoveredTargetPort(new_target_port)
1071
+ event.accept()
1072
+ return
1073
+
1074
+ elif self._is_selecting and self._selection_rect:
1075
+ # 更新框选矩形
1076
+ rect = QRectF(self._selection_start_pos, scene_pos).normalized()
1077
+ self._selection_rect.setRect(rect)
1078
+
1079
+ # 选中矩形内的节点
1080
+ for node in self._nodes:
1081
+ node_rect = node.sceneBoundingRect()
1082
+ if rect.intersects(node_rect):
1083
+ node.setSelected(True)
1084
+ else:
1085
+ # 如果没有按住Ctrl,取消选中矩形外的节点
1086
+ if not (event.modifiers() & Qt.KeyboardModifier.ControlModifier):
1087
+ node.setSelected(False)
1088
+
1089
+ event.accept()
1090
+ return
1091
+
1092
+ super().mouseMoveEvent(event)
1093
+
1094
+ def mouseReleaseEvent(self, event: QMouseEvent) -> None:
1095
+ """鼠标释放事件"""
1096
+ scene_pos = self.mapToScene(event.pos())
1097
+
1098
+ # 结束平移:中键释放,或 (空格/Alt+) 左键释放
1099
+ if event.button() == Qt.MouseButton.MiddleButton or (
1100
+ self._is_panning and event.button() == Qt.MouseButton.LeftButton
1101
+ ):
1102
+ self._is_panning = False
1103
+ self._pan_start_pos = None
1104
+ # 释放后按当前修饰键重新计算光标(Alt 仍在按则按节点位置显示)
1105
+ self._updateHoverCursor(event)
1106
+ event.accept()
1107
+ return
1108
+
1109
+ elif event.button() == Qt.MouseButton.LeftButton:
1110
+ if self._is_creating_port_connection:
1111
+ # 完成 port 连线拖拽
1112
+ if self._temp_port_connection:
1113
+ self._scene.removeItem(self._temp_port_connection)
1114
+ self._temp_port_connection = None
1115
+
1116
+ src_port = self._source_port_for_connection
1117
+ # 命中检测: 光标是否落在 port 上(优先用拖拽中追踪到的目标)。
1118
+ # 不排除起点 port: 允许 port→port 同 port 自指(离开起点再回来)。
1119
+ # 用 isPointOnDot 精确命中(与 node 边缘拖拽一致, 避免 12px 放大区误吞)。
1120
+ target_port = self._hovered_target_port
1121
+ if target_port is None and src_port is not None:
1122
+ for item in self._scene.items(scene_pos):
1123
+ if isinstance(item, QNodePort) and item.isPointOnDot(scene_pos):
1124
+ target_port = item
1125
+ break
1126
+
1127
+ if target_port is not None and src_port is not None:
1128
+ # 创建 port→port 连线(端点锚定到两 port 中心; 同 port 时自指)
1129
+ self.addConnection(
1130
+ src_port._node, target_port._node,
1131
+ source_port=src_port, target_port=target_port,
1132
+ )
1133
+ elif src_port is not None:
1134
+ # 未命中 port: 检查是否落在某个节点(含外环) -> 创建 port→node 连线。
1135
+ # 不排除起点 port 所属节点: 允许 port→node 同节点自指。
1136
+ target_node = None
1137
+ for item in self._scene.items(scene_pos):
1138
+ if isinstance(item, QNodeItem):
1139
+ target_node = item
1140
+ break
1141
+ # 外环容差: 光标落在节点 shape() 外的边缘区域
1142
+ if target_node is None:
1143
+ for node in self._nodes:
1144
+ if self._is_on_node_edge(node, scene_pos):
1145
+ target_node = node
1146
+ break
1147
+ if target_node is not None:
1148
+ self.addConnection(
1149
+ src_port._node, target_node,
1150
+ source_port=src_port, target_port=None,
1151
+ )
1152
+
1153
+ # 清理: 无论成功失败都清除起点 port 的 active 描边态
1154
+ if src_port is not None:
1155
+ src_port.setActive(False)
1156
+ # 清除目标 port 的落点高亮态
1157
+ self._updateHoveredTargetPort(None)
1158
+ # 清除目标节点的落点描边态
1159
+ self._updateHoveredTargetNode(None)
1160
+ self._is_creating_port_connection = False
1161
+ self._source_port_for_connection = None
1162
+ self._drag_start_pos = None
1163
+ event.accept()
1164
+ return
1165
+
1166
+ elif self._is_creating_connection:
1167
+ # 完成连接创建
1168
+ if self._temp_connection:
1169
+ self._scene.removeItem(self._temp_connection)
1170
+ self._temp_connection = None
1171
+
1172
+ src_node = self._source_node_for_connection
1173
+ items = self._scene.items(scene_pos)
1174
+
1175
+ # 优先命中 port dot 本身(isPointOnDot, 避免 12px 放大命中区误吞):
1176
+ # 命中则创建 node→port 连接(source_port=None, target_port=port),
1177
+ # 与 port→? 拖拽对称。
1178
+ target_port = self._hovered_target_port
1179
+ if target_port is None and src_node is not None:
1180
+ for item in items:
1181
+ if isinstance(item, QNodePort) and item.isPointOnDot(scene_pos):
1182
+ target_port = item
1183
+ break
1184
+
1185
+ if target_port is not None and src_node is not None:
1186
+ self.addConnection(
1187
+ src_node, target_port._node,
1188
+ source_port=None, target_port=target_port,
1189
+ )
1190
+ else:
1191
+ # 未命中 port: 查找目标节点(允许落在源节点自身 -> node→node 自环)
1192
+ target_node = None
1193
+ for item in items:
1194
+ if isinstance(item, QNodeItem):
1195
+ target_node = item
1196
+ break
1197
+ # 外环容差: 光标可能落在节点 shape() 之外的边缘区域
1198
+ # (_is_on_node_edge 判定), 此时 scene.items 不返回节点, 需遍历补判
1199
+ if target_node is None:
1200
+ for node in self._nodes:
1201
+ if self._is_on_node_edge(node, scene_pos):
1202
+ target_node = node
1203
+ break
1204
+
1205
+ if target_node is not None and src_node is not None:
1206
+ # 创建连接(同节点时 addConnection 允许并绘制为自环)
1207
+ self.addConnection(src_node, target_node)
1208
+
1209
+ # 清除拖拽期间的落点高亮态(port 与 node)
1210
+ self._updateHoveredTargetPort(None)
1211
+ self._updateHoveredTargetNode(None)
1212
+ self._is_creating_connection = False
1213
+ self._source_node_for_connection = None
1214
+ self._drag_start_pos = None
1215
+ event.accept()
1216
+ return
1217
+
1218
+ elif self._is_selecting:
1219
+ # 完成框选
1220
+ if self._selection_rect:
1221
+ self._scene.removeItem(self._selection_rect)
1222
+ self._selection_rect = None
1223
+
1224
+ self._is_selecting = False
1225
+ self._selection_start_pos = None
1226
+ event.accept()
1227
+ return
1228
+
1229
+ super().mouseReleaseEvent(event)
1230
+
1231
+ def _updateHoverCursor(self, event: QMouseEvent) -> None:
1232
+ """根据当前修饰键与光标位置刷新画布光标。
1233
+
1234
+ - 平移进行中:不动(保持 ClosedHandCursor)
1235
+ - Alt 按住 + 光标在节点内:SizeAllCursor(调整大小样式)
1236
+ - Alt 按住 + 空白处:OpenHandCursor(提示可平移)
1237
+ - 空格平移模式:OpenHandCursor
1238
+ - 其它:ArrowCursor
1239
+ """
1240
+ if self._is_panning:
1241
+ return # 平移中保持抓手闭合光标
1242
+ if event.modifiers() & Qt.KeyboardModifier.AltModifier:
1243
+ scene_pos = self.mapToScene(event.pos())
1244
+ on_node = any(isinstance(it, QNodeItem) for it in self._scene.items(scene_pos))
1245
+ self.setCursor(
1246
+ Qt.CursorShape.SizeAllCursor if on_node
1247
+ else Qt.CursorShape.OpenHandCursor
1248
+ )
1249
+ elif self._space_panning:
1250
+ self.setCursor(Qt.CursorShape.OpenHandCursor)
1251
+ else:
1252
+ self.setCursor(Qt.CursorShape.ArrowCursor)
1253
+
1254
+ def mouseDoubleClickEvent(self, event: QMouseEvent) -> None:
1255
+ """鼠标双击事件"""
1256
+ scene_pos = self.mapToScene(event.pos())
1257
+ items = self._scene.items(scene_pos)
1258
+
1259
+ # 查找双击的节点
1260
+ for item in items:
1261
+ if isinstance(item, QNodeItem):
1262
+ # 如果节点有自定义编辑器,打开它
1263
+ if item.editorClass:
1264
+ editor = item.editorClass(item)
1265
+ editor.setWindowTitle(editor.getTitle())
1266
+ editor.show()
1267
+ else:
1268
+ # 默认行为:发射编辑请求信号
1269
+ item.editRequested.emit(item)
1270
+ event.accept()
1271
+ return
1272
+
1273
+ super().mouseDoubleClickEvent(event)
1274
+
1275
+ def wheelEvent(self, event: QWheelEvent) -> None:
1276
+ """鼠标滚轮事件 - 缩放"""
1277
+ # 获取当前缩放因子
1278
+ current_transform = self.transform()
1279
+ current_scale = current_transform.m11()
1280
+
1281
+ # 计算新的缩放因子
1282
+ delta = event.angleDelta().y()
1283
+ scale_factor = 1.1 if delta > 0 else 0.9
1284
+ new_scale = current_scale * scale_factor
1285
+
1286
+ # 限制缩放范围
1287
+ if 0.1 <= new_scale <= 5.0:
1288
+ self.scale(scale_factor, scale_factor)
1289
+
1290
+ event.accept()
1291
+
1292
+ def contextMenuEvent(self, event: QContextMenuEvent) -> None:
1293
+ """右键菜单事件 - 根据点击位置委托给对应的弹出菜单
1294
+
1295
+ - 命中节点 -> node.menu(QNodePopMenu)
1296
+ - 命中连线/箭头 -> lmenu(QLinePopMenu, 每次按两端点重构)
1297
+ - 空白画布 -> menu(QCanvasPopMenu)
1298
+ """
1299
+ scene_pos = self.mapToScene(event.pos())
1300
+ item = self._scene.itemAt(scene_pos, QTransform())
1301
+
1302
+ # 向上查找顶层图元
1303
+ top_item = item
1304
+ while top_item and top_item.parentItem():
1305
+ top_item = top_item.parentItem()
1306
+
1307
+ # 检查是否是连接线的子项(箭头)
1308
+ connection_parent = None
1309
+ check_item = item
1310
+ while check_item:
1311
+ if isinstance(check_item, ConnectionItem):
1312
+ connection_parent = check_item
1313
+ break
1314
+ check_item = check_item.parentItem()
1315
+
1316
+ if isinstance(top_item, QNodeItem):
1317
+ # 在节点上右键
1318
+ top_item.menu.exec(event.globalPos(), self)
1319
+ elif isinstance(top_item, (ArrowItem, ConnectionItem)) or connection_parent:
1320
+ # 在连线/箭头上右键(连线菜单每次按两端点重构)
1321
+ if isinstance(top_item, ArrowItem):
1322
+ conn = top_item.connection
1323
+ elif isinstance(top_item, ConnectionItem):
1324
+ conn = top_item
1325
+ else:
1326
+ conn = connection_parent
1327
+ self.lmenu = QLinePopMenu(
1328
+ self,
1329
+ conn.sourcePort or conn.sourceNode,
1330
+ conn.targetPort or conn.targetNode,
1331
+ )
1332
+ self.lmenu.exec(event.globalPos())
1333
+ else:
1334
+ # 在空白画布上右键
1335
+ self.menu.exec(event.globalPos(), scene_pos)
1336
+
1337
+ def _onAddNodeAt(self, pos: QPointF) -> None:
1338
+ """在指定位置添加节点"""
1339
+ node_count = len(self._nodes)
1340
+ name = f"Node{node_count + 1}"
1341
+ self.addNode(name, pos)
1342
+
1343
+ def _onLayout(self, strategy: str) -> None:
1344
+ """执行自动布局"""
1345
+ engine = DenseLayoutEngine(self)
1346
+ engine.autoLayout(strategy)
1347
+
1348
+ def _onFitViewport(self) -> None:
1349
+ """自动适应视口"""
1350
+ fitter = ViewportFitter(self)
1351
+ fitter.fitViewport()
1352
+
1353
+ def _is_on_node_edge(self, node: QNodeItem, pos: QPointF) -> bool:
1354
+ """检查位置是否在节点"外环"边缘区域内(用于发起/接收连线)。
1355
+
1356
+ 外环 = 节点可悬停命中区(shape(), 含描边半宽 + 外环厚度)内、且处于
1357
+ 内部 75% 区域之外的部分。统一用 node.shape().contains() 做外边界
1358
+ 判定(与 hover/hit 完全一致, 避免容差不一致), 内边界按各形状的 75%
1359
+ 缩放几何判定。
1360
+ """
1361
+ local_pos = node.mapFromScene(pos)
1362
+ # 外边界: 必须落在节点 shape() 内(即 hover/hit 能覆盖的区域)
1363
+ if not node.shape().contains(local_pos):
1364
+ return False
1365
+
1366
+ center = node.rect().center()
1367
+ shape = node.nodeShape
1368
+ rect = node.rect()
1369
+ # 内部 75% 区域(反向缩放: 以中心为锚, 各向 75%)
1370
+ inner_ratio = 0.75
1371
+
1372
+ if shape == NodeShape.ELLIPSE:
1373
+ radius = node.radius
1374
+ distance = math.sqrt(
1375
+ (local_pos.x() - center.x()) ** 2 +
1376
+ (local_pos.y() - center.y()) ** 2
1377
+ )
1378
+ # 边缘区域: 内圈 75% 半径之外(外圈已由 shape().contains 保证)
1379
+ return distance >= radius * inner_ratio
1380
+
1381
+ elif shape == NodeShape.RECTANGLE:
1382
+ half_w = rect.width() / 2
1383
+ half_h = rect.height() / 2
1384
+ dx = local_pos.x() - center.x()
1385
+ dy = local_pos.y() - center.y()
1386
+ adx = abs(dx)
1387
+ ady = abs(dy)
1388
+ # 内矩形(75%): 在其内则视为"内部", 不是边缘
1389
+ inner_w = half_w * inner_ratio
1390
+ inner_h = half_h * inner_ratio
1391
+ if adx <= inner_w and ady <= inner_h:
1392
+ return False
1393
+ return True
1394
+
1395
+ elif shape == NodeShape.DIAMOND:
1396
+ radius = node.radius
1397
+ dx = abs(local_pos.x() - center.x())
1398
+ dy = abs(local_pos.y() - center.y())
1399
+ # 菱形边界 |dx|+|dy| <= radius(外圈已由 shape 保证);
1400
+ # 内菱形(75%): |dx|+|dy| <= radius*0.75 -> 在其内视为内部
1401
+ return (dx + dy) > radius * inner_ratio
1402
+
1403
+ return False
1404
+
1405
+ def keyPressEvent(self, event: QKeyEvent) -> None:
1406
+ """键盘按下事件"""
1407
+ if event.key() == Qt.Key.Key_Space and not event.isAutoRepeat():
1408
+ # 按住 Space 进入平移模式(左键拖拽平移);此时不应自动重复触发
1409
+ if not self._space_panning:
1410
+ self._space_panning = True
1411
+ self.setCursor(Qt.CursorShape.OpenHandCursor)
1412
+ event.accept()
1413
+ return
1414
+
1415
+ if event.key() == Qt.Key.Key_Delete:
1416
+ # 删除选中的节点和连接
1417
+ self._deleteSelected()
1418
+ event.accept()
1419
+ return
1420
+
1421
+ super().keyPressEvent(event)
1422
+
1423
+ def keyReleaseEvent(self, event: QKeyEvent) -> None:
1424
+ """键盘释放事件"""
1425
+ if event.key() == Qt.Key.Key_Space and not event.isAutoRepeat():
1426
+ self._space_panning = False
1427
+ if not self._is_panning:
1428
+ self.setCursor(Qt.CursorShape.ArrowCursor)
1429
+ event.accept()
1430
+ return
1431
+ super().keyReleaseEvent(event)
1432
+
1433
+ def _deleteSelected(self) -> None:
1434
+ """删除选中的节点和连接"""
1435
+ # 删除选中的节点
1436
+ nodes_to_remove = [node for node in self._nodes if node.isSelected()]
1437
+ for node in nodes_to_remove:
1438
+ self.removeNode(node)
1439
+
1440
+ # 删除选中的连接(通过箭头)
1441
+ connections_to_remove = []
1442
+ for conn in self._connections:
1443
+ if conn.targetArrow.isSelected():
1444
+ connections_to_remove.append(conn)
1445
+ elif conn.sourceArrow and conn.sourceArrow.isSelected():
1446
+ connections_to_remove.append(conn)
1447
+
1448
+ for conn in connections_to_remove:
1449
+ self.removeConnection(conn)
1450
+
1451
+ def export(self, filter_empty: bool = False) -> dict:
1452
+ """
1453
+ 导出画布数据为字典
1454
+
1455
+ Args:
1456
+ filter_empty: 是否过滤空内容
1457
+
1458
+ Returns:
1459
+ 包含画布数据的字典
1460
+ """
1461
+ data = {
1462
+ "nodes": [],
1463
+ "connections": [],
1464
+ "start_node_id": None
1465
+ }
1466
+
1467
+ # 导出节点
1468
+ for node in self._nodes:
1469
+ node_data = node.toDict()
1470
+ data["nodes"].append(node_data)
1471
+
1472
+ # 导出连接
1473
+ for conn in self._connections:
1474
+ conn_data = {
1475
+ "source_id": conn.sourceNode.id,
1476
+ "target_id": conn.targetNode.id,
1477
+ "is_bidirectional": conn.isBidirectional,
1478
+ "connection_type": conn.connectionType.name
1479
+ }
1480
+ # port 绑定(若任一端锚定到 port): 记录 side + name, 供导入时按
1481
+ # name 在该侧找回 QNodePort。空 name 的端口无法引用, 不记录。
1482
+ sp = conn.sourcePort
1483
+ if sp is not None and sp.spec.name:
1484
+ conn_data["source_port_side"] = sp.side
1485
+ conn_data["source_port_name"] = sp.spec.name
1486
+ tp = conn.targetPort
1487
+ if tp is not None and tp.spec.name:
1488
+ conn_data["target_port_side"] = tp.side
1489
+ conn_data["target_port_name"] = tp.spec.name
1490
+ # 连线样式(颜色/线宽): 仅在被自定义过时序列化, 避免污染默认导出。
1491
+ # 此前连线 style 完全不持久化, 导出再导入后定制颜色/粗细退回默认。
1492
+ style_data = conn.styleToDict()
1493
+ if style_data:
1494
+ conn_data["style"] = style_data
1495
+ data["connections"].append(conn_data)
1496
+
1497
+ # 导出起点节点
1498
+ if self._start_node:
1499
+ data["start_node_id"] = self._start_node.id
1500
+
1501
+ # 导出跨节点可连接性规则(仅当与默认值不同时序列化, 避免污染默认导出)。
1502
+ # 与节点 selfrefs 的序列化策略一致。
1503
+ linkables_diff = {
1504
+ k: v for k, v in self._linkables.items()
1505
+ if v != _LINKABLES_DEFAULTS.get(k)
1506
+ }
1507
+ if linkables_diff:
1508
+ data["linkables"] = linkables_diff
1509
+
1510
+ return data
1511
+
1512
+ def exportToJson(self, filter_empty: bool = False) -> str:
1513
+ """
1514
+ 导出画布数据为 JSON 字符串
1515
+
1516
+ Args:
1517
+ filter_empty: 是否过滤空内容
1518
+
1519
+ Returns:
1520
+ JSON 字符串
1521
+ """
1522
+ return json.dumps(self.export(filter_empty), indent=2, ensure_ascii=False)
1523
+
1524
+ def _importDict(self, data: dict):
1525
+ """
1526
+ 从字典数据构造画布节点和连接。
1527
+ 将 code、targets、decorates 直接存入 node.data。
1528
+
1529
+ 参数:
1530
+ data (dict): 包含 'node_id': {code, targets, decorates} 的结构化数据
1531
+ """
1532
+ # 清除现有内容
1533
+ self.clear()
1534
+
1535
+ node_map = {} # 用于存储已创建的节点 ID 到 NodeItem 的映射
1536
+
1537
+ # 第一步:创建所有节点并存储自定义数据
1538
+ for node_id, node_info in data.items():
1539
+ # 提取数据字段
1540
+ code_val = node_info.get('code', '')
1541
+ targets_val = node_info.get('targets', [])
1542
+ decorates_val = node_info.get('decorates', [])
1543
+
1544
+ # 生成默认位置(避免重叠,实际可结合布局算法)
1545
+ x = random.randint(50, 800)
1546
+ y = random.randint(50, 600)
1547
+
1548
+ # 创建节点,名称使用 node_id
1549
+ new_node = self.addNode(node_id, QPointF(x, y))
1550
+ node_map[node_id] = new_node
1551
+
1552
+ # 将原始数据结构存入 node.data
1553
+ new_node.data['code'] = code_val
1554
+ new_node.data['targets'] = targets_val
1555
+ new_node.data['decorates'] = decorates_val
1556
+
1557
+ # 第二步:根据 targets 创建连接
1558
+ for node_id, node_info in data.items():
1559
+ source_node = node_map.get(node_id)
1560
+ if not source_node:
1561
+ continue
1562
+
1563
+ targets_list = node_info.get('targets', [])
1564
+ for target_id in targets_list:
1565
+ target_node = node_map.get(target_id)
1566
+ if target_node:
1567
+ # 添加单向连接
1568
+ self.addConnection(source_node, target_node, is_bidirectional=False)
1569
+
1570
+ # 第三步:处理特殊装饰器逻辑(如 'listen')
1571
+ # 此处仅做标记或初始化相关逻辑,具体实现取决于外部事件系统
1572
+ for node_id, node_info in data.items():
1573
+ decorates_list = node_info.get('decorates', [])
1574
+ if 'listen' in decorates_list:
1575
+ node_item = node_map.get(node_id)
1576
+ if node_item:
1577
+ # 标记该节点为监听器,方便后续逻辑判断
1578
+ node_item.data['is_listener'] = True
1579
+ # 可根据需要在此处绑定信号槽或初始化监听状态
1580
+
1581
+ # 可选:应用紧凑布局使节点整齐排列。
1582
+ # DenseLayoutEngine 已在模块顶部 import, 此前用 except ImportError 兜底
1583
+ # 是失效的(永远捕获不到); autoLayout 抛出的真实异常会冒泡到 importData
1584
+ # 的 except Exception, 被误报为"数据既非 json 也非 dict"。这里改为捕获
1585
+ # 所有异常, 布局失败仅告警、不影响已导入的节点与连接。
1586
+ try:
1587
+ engine = DenseLayoutEngine(self)
1588
+ engine.autoLayout("compact")
1589
+ except Exception as e:
1590
+ print(f"[chartflow] 紧凑布局失败, 节点保留随机位置: {e}")
1591
+
1592
+ def importData(self, data: dict | str) -> None:
1593
+ """
1594
+ 从数据导入画布
1595
+
1596
+ Args:
1597
+ data: 字典或 JSON 字符串
1598
+ """
1599
+ # 如果传入的是字符串,解析为字典
1600
+ if isinstance(data, str):
1601
+ try:
1602
+ data = json.loads(data)
1603
+ except json.JSONDecodeError as e:
1604
+ QMessageBox.critical(self, "Error", f"Invalid JSON: {e}")
1605
+ return
1606
+
1607
+ if data.get("nodes") is None:
1608
+ # 非 nodes/connections 结构, 尝试按 {node_id: {code,targets,decorates}}
1609
+ # 的扁平 dict 导入(由 _importDict 处理)。
1610
+ try:
1611
+ self._importDict(data)
1612
+ except Exception as e:
1613
+ print(
1614
+ f"[chartflow] 导入失败: 数据既非 nodes/connections 结构, "
1615
+ f"也无法按扁平 dict 解析。Details: {e}"
1616
+ )
1617
+ return
1618
+ # 清除现有内容
1619
+ self.clear()
1620
+
1621
+ # 创建节点映射(旧id -> 新节点)
1622
+ node_map = {}
1623
+
1624
+ # 导入节点
1625
+ for node_data in data.get("nodes", []):
1626
+ name = node_data.get("name", "Unnamed")
1627
+ pos_data = node_data.get("position", {"x": 0, "y": 0})
1628
+ position = QPointF(pos_data.get("x", 0), pos_data.get("y", 0))
1629
+ shape_name = node_data.get("shape", "ELLIPSE")
1630
+ try:
1631
+ shape = NodeShape[shape_name]
1632
+ except KeyError:
1633
+ shape = NodeShape.ELLIPSE
1634
+
1635
+ node = self.addNode(name, position, shape)
1636
+
1637
+ # 用 fromDict 统一恢复节点全部状态: name / shape / position / data /
1638
+ # ports / style / custom 标记 / 外环可见性 / 连线能力。
1639
+ # 此前 importData 逐字段手动复制, 遗漏了 ports 与 style, 导致导出后
1640
+ # 节点的端口丢失、port↔port 连接无法还原。
1641
+ node.fromDict(node_data)
1642
+
1643
+ old_id = node_data.get("id")
1644
+ if old_id is not None:
1645
+ node_map[old_id] = node
1646
+
1647
+ # 导入连接
1648
+ for conn_data in data.get("connections", []):
1649
+ source_id = conn_data.get("source_id")
1650
+ target_id = conn_data.get("target_id")
1651
+ is_bidirectional = conn_data.get("is_bidirectional", False)
1652
+ connection_type_name = conn_data.get("connection_type", "BEZIER")
1653
+ try:
1654
+ connection_type = ConnectionType[connection_type_name]
1655
+ except KeyError:
1656
+ connection_type = ConnectionType.BEZIER
1657
+
1658
+ if source_id in node_map and target_id in node_map:
1659
+ source_node = node_map[source_id]
1660
+ target_node = node_map[target_id]
1661
+ # 端点之一不可连线时, addConnection 会静默返回 None, 该连接被丢弃。
1662
+ # 此处显式告警, 便于定位外部/手工编辑产生的无法还原的连线。
1663
+ if not source_node.isConnectable() or not target_node.isConnectable():
1664
+ print(
1665
+ f"[chartflow] 警告: 导入连接 {source_id!r} -> {target_id!r} "
1666
+ f"被跳过 —— 端点节点 connectable=False (无法参与连线)。"
1667
+ )
1668
+ # 按导出时记录的 side + name 找回 port(找不到则退化为 node 边缘)
1669
+ source_port = _resolve_port(
1670
+ source_node,
1671
+ conn_data.get("source_port_side"),
1672
+ conn_data.get("source_port_name"),
1673
+ )
1674
+ target_port = _resolve_port(
1675
+ target_node,
1676
+ conn_data.get("target_port_side"),
1677
+ conn_data.get("target_port_name"),
1678
+ )
1679
+ conn = self.addConnection(
1680
+ source_node, target_node, is_bidirectional, connection_type,
1681
+ source_port=source_port, target_port=target_port,
1682
+ )
1683
+ # 还原连线样式(颜色/线宽); addConnection 可能因判重返回已存在
1684
+ # 的连线(也会返回 None, 此时跳过)。
1685
+ if conn is not None and conn_data.get("style"):
1686
+ conn.applyStyleFromDict(conn_data["style"])
1687
+
1688
+ # 设置起点节点
1689
+ start_node_id = data.get("start_node_id")
1690
+ if start_node_id in node_map:
1691
+ self.startNode = node_map[start_node_id]
1692
+
1693
+ # 恢复跨节点可连接性规则(以默认值打底, 叠加导入的差异项, 保证 7 键齐全)
1694
+ if isinstance(data.get("linkables"), dict):
1695
+ self._applyLinkables(
1696
+ {**_LINKABLES_DEFAULTS, **data["linkables"]}, merge=False
1697
+ )
1698
+ # 导入的连接可能违反新规则(例如旧文件允许某组合而当前规则禁止),
1699
+ # 交由 enforceLinkables 清理; 违规者被移除并告警。
1700
+ removed = self.enforceLinkables()
1701
+ for c in removed:
1702
+ print(
1703
+ f"[chartflow] 警告: 导入连接 {c.sourceNode.name!r} -> "
1704
+ f"{c.targetNode.name!r} 因 linkables 规则被移除。"
1705
+ )
1706
+
1707
+ self.changed = False
1708
+
1709
+ def clear(self) -> None:
1710
+ """清空画布"""
1711
+ # 移除所有连接
1712
+ for conn in self._connections[:]:
1713
+ self.removeConnection(conn)
1714
+
1715
+ # 移除所有节点
1716
+ for node in self._nodes[:]:
1717
+ self._scene.removeItem(node)
1718
+ self._nodes.clear()
1719
+
1720
+ self._start_node = None
1721
+ self.changed = False
1722
+
1723
+ def resizeEvent(self, event) -> None:
1724
+ """窗口大小改变事件 - 更新样式面板位置"""
1725
+ super().resizeEvent(event)
1726
+ # 更新样式面板位置(贴合底部,margin 10)
1727
+ if hasattr(self, '_style_panel'):
1728
+ panel_height = StylePanel.PANEL_HEIGHT
1729
+ panel_y = self.height() - panel_height - 10
1730
+ self._style_panel.setGeometry(10, panel_y, self.width() - 20, panel_height)