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.
- chartflow/__init__.py +28 -0
- chartflow/_verify_importexport.py +169 -0
- chartflow/edit/__init__.py +119 -0
- chartflow/edit/code_editor.py +1007 -0
- chartflow/edit/code_folder.py +523 -0
- chartflow/edit/completer.py +2652 -0
- chartflow/edit/context_analyzer.py +521 -0
- chartflow/edit/demo.py +469 -0
- chartflow/edit/line_number_area.py +548 -0
- chartflow/edit/minimap.py +581 -0
- chartflow/edit/python_editor.py +509 -0
- chartflow/edit/syntax_highlighter.py +291 -0
- chartflow/edit/test_editor.py +190 -0
- chartflow/flow/__init__.py +31 -0
- chartflow/flow/_demo.py +98 -0
- chartflow/flow/chart.py +1101 -0
- chartflow/flow/editor.py +375 -0
- chartflow/flow/flow.py +6 -0
- chartflow/flow/qchart.py +1250 -0
- chartflow/flow/test/__init__.py +0 -0
- chartflow/flow/test/test_chart.py +537 -0
- chartflow/flow/test_decorator_sync.py +102 -0
- chartflow/fsm/__init__.py +84 -0
- chartflow/fsm/decorators.py +395 -0
- chartflow/fsm/demo.py +381 -0
- chartflow/fsm/demo_pyqt6.py +881 -0
- chartflow/fsm/demo_tree.py +46 -0
- chartflow/fsm/logic.py +447 -0
- chartflow/fsm/meta.py +569 -0
- chartflow/fsm/scheduler.py +427 -0
- chartflow/fsm/stage.py +2079 -0
- chartflow/fsm/stdio.py +66 -0
- chartflow/fsm/test/__init__.py +7 -0
- chartflow/fsm/test/test_decorators.py +210 -0
- chartflow/fsm/test/test_events.py +202 -0
- chartflow/fsm/test/test_integration.py +596 -0
- chartflow/fsm/test/test_logic.py +371 -0
- chartflow/fsm/test/test_meta.py +192 -0
- chartflow/fsm/test/test_runtime_param.py +336 -0
- chartflow/fsm/test/test_scheduler.py +280 -0
- chartflow/fsm/test/test_stage.py +314 -0
- chartflow/fsm/test_behavior_events.py +263 -0
- chartflow/fsm/test_nested_parallel.py +80 -0
- chartflow/fsm/test_stage_spec.py +448 -0
- chartflow/fsm/utils.py +34 -0
- chartflow/node/__init__.py +66 -0
- chartflow/node/_node_base.py +727 -0
- chartflow/node/_node_interaction.py +488 -0
- chartflow/node/_node_interface.py +426 -0
- chartflow/node/_node_markers.py +648 -0
- chartflow/node/_node_ports.py +536 -0
- chartflow/node/_port_interface.py +111 -0
- chartflow/node/arrow_item.py +287 -0
- chartflow/node/color_utils.py +113 -0
- chartflow/node/connection_item.py +939 -0
- chartflow/node/demo.py +258 -0
- chartflow/node/demo_arrow_drag.py +46 -0
- chartflow/node/demo_decorator.py +63 -0
- chartflow/node/demo_ports.py +69 -0
- chartflow/node/enums.py +35 -0
- chartflow/node/example.py +84 -0
- chartflow/node/layout_utils.py +307 -0
- chartflow/node/main_window.py +196 -0
- chartflow/node/menu_bar.py +240 -0
- chartflow/node/node_canvas.py +1730 -0
- chartflow/node/node_item.py +754 -0
- chartflow/node/popmenu.py +472 -0
- chartflow/node/port_item.py +591 -0
- chartflow/node/qnode_editor.py +73 -0
- chartflow/node/selection_rect.py +53 -0
- chartflow/node/style_panel.py +615 -0
- chartflow/node/styles.py +63 -0
- chartflow/node/test_qnode.py +2336 -0
- chartflow/showcase.py +528 -0
- chartflow/theme.py +508 -0
- chartflow-0.1.dist-info/METADATA +23 -0
- chartflow-0.1.dist-info/RECORD +79 -0
- chartflow-0.1.dist-info/WHEEL +5 -0
- 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)
|