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,939 @@
1
+ """
2
+ 连接项模块 - 表示两个节点之间的连线
3
+ 包含两个独立的箭头项
4
+ """
5
+
6
+ import math
7
+ from typing import Optional, List, Callable
8
+
9
+ from PyQt6.QtWidgets import QGraphicsItem, QGraphicsPathItem
10
+ from PyQt6.QtCore import Qt, QPointF, QTimer
11
+ from PyQt6.QtGui import QPainterPath, QPen, QColor, QLinearGradient, QBrush
12
+
13
+ from chartflow.node.styles import ConnectionStyle
14
+ from chartflow.node.arrow_item import ArrowItem
15
+ from chartflow.node.enums import ConnectionType, NodeShape
16
+ from chartflow.node.color_utils import (
17
+ PORT_GRADIENT_NEUTRAL_LUMINANCE,
18
+ color_at_relative_luminance,
19
+ neutral_grey_at_relative_luminance,
20
+ )
21
+
22
+ # 箭头尖端距离目标边缘的偏移(像素): 0 = 尖端紧贴目标边缘
23
+ ARROW_TIP_EDGE_OFFSET: float = 0.0
24
+
25
+
26
+ class ConnectionItem(QGraphicsPathItem):
27
+ """
28
+ 连接项 - 表示两个节点之间的连线
29
+ 包含两个独立的箭头项,可以单独选中
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ source_node: 'NodeItem',
35
+ target_node: 'NodeItem',
36
+ is_bidirectional: bool = False,
37
+ connection_type: ConnectionType = ConnectionType.BEZIER,
38
+ parent: Optional[QGraphicsItem] = None,
39
+ *,
40
+ source_port=None,
41
+ target_port=None,
42
+ ) -> None:
43
+ """
44
+ 初始化连接线
45
+
46
+ Args:
47
+ source_node: 源节点
48
+ target_node: 目标节点
49
+ is_bidirectional: 是否是双向连接
50
+ connection_type: 连线类型(贝塞尔曲线或直线)
51
+ parent: 父项
52
+ source_port: 源端端口(QNodePort), 锚定到该 port 中心; None 表示用节点边缘
53
+ target_port: 目标端端口(QNodePort), 锚定到该 port 中心; None 表示用节点边缘
54
+ """
55
+ super().__init__(parent)
56
+
57
+ self._source_node: 'NodeItem' = source_node
58
+ self._target_node: 'NodeItem' = target_node
59
+ self._source_port = source_port # Optional[QNodePort], None=节点边缘
60
+ self._target_port = target_port # Optional[QNodePort], None=节点边缘
61
+ self._is_bidirectional: bool = is_bidirectional
62
+ self._connection_type: ConnectionType = connection_type
63
+ self._style: ConnectionStyle = ConnectionStyle()
64
+
65
+ # 状态标志(须在 _setupStyle 前初始化: _setupStyle -> _refreshPen 读取它们)
66
+ self._flash_timer: Optional[QTimer] = None
67
+ self._is_flashing: bool = False # flashError/flashHighlight 期间临时禁用渐变
68
+ self._is_selected: bool = False # setSelected 维护的选中态(用于禁用渐变)
69
+ # 最近一次 updatePath 计算出的端点(场景坐标), 供 _refreshPen 构造渐变用
70
+ self._source_edge: QPointF = QPointF()
71
+ self._target_edge: QPointF = QPointF()
72
+
73
+ # 设置样式
74
+ self._setupStyle()
75
+
76
+ # 设置可选项标志(连接线本身可选中,用于右键菜单)
77
+ self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable, True)
78
+ self.setAcceptHoverEvents(True)
79
+ self.setZValue(1)
80
+
81
+ # 创建箭头项
82
+ self._target_arrow: ArrowItem = ArrowItem(self, is_source_arrow=False)
83
+ self._source_arrow: Optional[ArrowItem] = None
84
+
85
+ # 箭头拖拽回调
86
+ self._arrow_drag_started_callback: Optional[Callable[[QPointF], None]] = None
87
+ self._arrow_drag_moved_callback: Optional[Callable[[QPointF], None]] = None
88
+ self._arrow_drag_finished_callback: Optional[Callable[[QPointF, bool], None]] = None
89
+
90
+ # 更新路径
91
+ self.updatePath()
92
+
93
+ def setArrowDragCallbacks(
94
+ self,
95
+ started: Optional[Callable[[QPointF], None]] = None,
96
+ moved: Optional[Callable[[QPointF], None]] = None,
97
+ finished: Optional[Callable[[QPointF, bool], None]] = None
98
+ ) -> None:
99
+ """设置箭头拖拽回调函数"""
100
+ self._arrow_drag_started_callback = started
101
+ self._arrow_drag_moved_callback = moved
102
+ self._arrow_drag_finished_callback = finished
103
+
104
+ # 更新现有箭头的回调
105
+ if self._target_arrow:
106
+ self._target_arrow.setDragCallbacks(started, moved, finished)
107
+ if self._source_arrow:
108
+ self._source_arrow.setDragCallbacks(started, moved, finished)
109
+
110
+ def _setupStyle(self) -> None:
111
+ """设置连接线样式"""
112
+ self._normal_pen = QPen(self._style.normal_color, self._style.stroke_width)
113
+ self._normal_pen.setCapStyle(Qt.PenCapStyle.RoundCap)
114
+
115
+ self._selected_pen = QPen(self._style.selected_color, self._style.selected_stroke_width)
116
+ self._selected_pen.setCapStyle(Qt.PenCapStyle.RoundCap)
117
+
118
+ self._hover_pen = QPen(self._style.hover_color, self._style.stroke_width + 1)
119
+ self._hover_pen.setCapStyle(Qt.PenCapStyle.RoundCap)
120
+
121
+ self._refreshPen()
122
+
123
+ def styleToDict(self) -> dict:
124
+ """将连线样式序列化为字典(仅含与默认值不同的字段)。
125
+
126
+ 用于 export/import 往返。颜色存为 ``#rrggbb`` 字符串, 宽度存为数值;
127
+ 字段名与 ``ConnectionStyle`` dataclass 一致。默认连线不产生任何键,
128
+ 避免污染导出。
129
+ """
130
+ from chartflow.node.styles import (
131
+ DEFAULT_CONNECTION_NORMAL, DEFAULT_CONNECTION_SELECTED,
132
+ DEFAULT_CONNECTION_HOVER,
133
+ )
134
+ result: dict = {}
135
+ if self._style.normal_color.name() != DEFAULT_CONNECTION_NORMAL:
136
+ result["normal_color"] = self._style.normal_color.name()
137
+ if self._style.selected_color.name() != DEFAULT_CONNECTION_SELECTED:
138
+ result["selected_color"] = self._style.selected_color.name()
139
+ if self._style.hover_color.name() != DEFAULT_CONNECTION_HOVER:
140
+ result["hover_color"] = self._style.hover_color.name()
141
+ if self._style.stroke_width != 2.0:
142
+ result["stroke_width"] = self._style.stroke_width
143
+ if self._style.selected_stroke_width != 3.0:
144
+ result["selected_stroke_width"] = self._style.selected_stroke_width
145
+ return result
146
+
147
+ def applyStyleFromDict(self, style: dict) -> None:
148
+ """从字典恢复连线样式(增量, 忽略未知键), 并重建 pen。
149
+
150
+ Args:
151
+ style: 由 :meth:`styleToDict` 产生(或同构)的字典; 可为空。
152
+ """
153
+ if not style or not isinstance(style, dict):
154
+ return
155
+ if "normal_color" in style:
156
+ self._style.normal_color = QColor(style["normal_color"])
157
+ if "selected_color" in style:
158
+ self._style.selected_color = QColor(style["selected_color"])
159
+ if "hover_color" in style:
160
+ self._style.hover_color = QColor(style["hover_color"])
161
+ if "stroke_width" in style:
162
+ self._style.stroke_width = style["stroke_width"]
163
+ if "selected_stroke_width" in style:
164
+ self._style.selected_stroke_width = style["selected_stroke_width"]
165
+ self._setupStyle()
166
+ self.updatePath()
167
+
168
+ @property
169
+ def sourceNode(self) -> 'NodeItem':
170
+ """获取源节点"""
171
+ return self._source_node
172
+
173
+ @property
174
+ def targetNode(self) -> 'NodeItem':
175
+ """获取目标节点"""
176
+ return self._target_node
177
+
178
+ @property
179
+ def sourcePort(self):
180
+ """获取源端端口(QNodePort), 无端口连线返回 None"""
181
+ return self._source_port
182
+
183
+ @property
184
+ def targetPort(self):
185
+ """获取目标端端口(QNodePort), 无端口连线返回 None"""
186
+ return self._target_port
187
+
188
+ @property
189
+ def targetArrow(self) -> ArrowItem:
190
+ """获取目标端箭头"""
191
+ return self._target_arrow
192
+
193
+ @property
194
+ def sourceArrow(self) -> Optional[ArrowItem]:
195
+ """获取源端箭头(如果是双向连接)"""
196
+ return self._source_arrow
197
+
198
+ @property
199
+ def arrows(self) -> List[ArrowItem]:
200
+ """获取所有箭头"""
201
+ arrows = [self._target_arrow]
202
+ if self._source_arrow:
203
+ arrows.append(self._source_arrow)
204
+ return arrows
205
+
206
+ @property
207
+ def isBidirectional(self) -> bool:
208
+ """是否是双向连接"""
209
+ return self._is_bidirectional
210
+
211
+ @isBidirectional.setter
212
+ def isBidirectional(self, value: bool) -> None:
213
+ """设置是否为双向连接"""
214
+ self._is_bidirectional = value
215
+ self.updatePath()
216
+
217
+ @property
218
+ def connectionType(self) -> ConnectionType:
219
+ """获取连线类型"""
220
+ return self._connection_type
221
+
222
+ @connectionType.setter
223
+ def connectionType(self, value: ConnectionType) -> None:
224
+ """设置连线类型"""
225
+ self._connection_type = value
226
+ self.updatePath()
227
+
228
+ def toggleConnectionType(self) -> None:
229
+ """切换连线类型(贝塞尔曲线 <-> 直线)"""
230
+ if self._connection_type == ConnectionType.BEZIER:
231
+ self._connection_type = ConnectionType.STRAIGHT
232
+ else:
233
+ self._connection_type = ConnectionType.BEZIER
234
+ self.updatePath()
235
+
236
+ def swapDirection(self) -> None:
237
+ """
238
+ 交换源节点和目标节点,用于保留反向连接时
239
+ 调用后,原来的 target->source 变成 source->target
240
+ """
241
+ # 交换节点引用
242
+ self._source_node, self._target_node = self._target_node, self._source_node
243
+
244
+ # 交换箭头引用(因为箭头方向也变了)
245
+ self._target_arrow, self._source_arrow = self._source_arrow, self._target_arrow
246
+
247
+ # 更新箭头的 is_source_arrow 标志
248
+ if self._target_arrow:
249
+ self._target_arrow._is_source_arrow = False
250
+ if self._source_arrow:
251
+ self._source_arrow._is_source_arrow = True
252
+
253
+ # 如果交换后目标箭头为None(原本是源箭头),需要创建新的目标箭头
254
+ if self._target_arrow is None:
255
+ self._target_arrow = ArrowItem(self, is_source_arrow=False)
256
+ if self.scene():
257
+ self.scene().addItem(self._target_arrow)
258
+ # 设置箭头拖拽回调
259
+ self._target_arrow.setDragCallbacks(
260
+ started=self._arrow_drag_started_callback,
261
+ moved=self._arrow_drag_moved_callback,
262
+ finished=self._arrow_drag_finished_callback
263
+ )
264
+
265
+ # 更新路径
266
+ self.updatePath()
267
+
268
+ def isSelfLoop(self) -> bool:
269
+ """是否为自环(同一节点)。
270
+
271
+ 任意 source_node is target_node 的连线都视为自环(NodeCanvas.addConnection
272
+ 已按 node.selfrefs 过滤, 凡能创建的自指都允许)。渲染为绕节点外侧的
273
+ 环/椭圆环, 不经过节点自身。覆盖五类自指: node→node / port→port /
274
+ node→port / port→node / left→right。
275
+ """
276
+ return self._source_node is self._target_node
277
+
278
+ def _portRadialDirection(self, port) -> Optional[QPointF]:
279
+ """port 所在端的外法线方向, 作为该端连线的出射方向。
280
+
281
+ - 圆形(ELLIPSE): port 位于圆弧上, 外法线 = 从圆心指向 port 的单位向量
282
+ (与该处切线垂直)。
283
+ - 菱形(DIAMOND): port 贴在斜边上, 外法线 = 该处斜边的外法线。布局阶段
284
+ 已将 ``port._orient_angle`` 设为"指向圆心"(内法线)方向, 故外法线为其
285
+ 反方向(角度 + 180°)。用 scene 坐标系下的方向向量返回。
286
+ - 矩形(RECTANGLE): port 位于左/右垂直边缘上, 外法线恒为水平方向
287
+ (left -> -x, right -> +x), 即垂直于边缘。返回此方向使连线在该端
288
+ 总是垂直于边, 而非沿用会随另一端位置变化的水平/垂直启发式。
289
+ - port 为空: 返回 None(沿用既有水平/垂直启发式)。
290
+
291
+ Args:
292
+ port: 某端的 ``QNodePort``, 可为 None。
293
+
294
+ Returns:
295
+ 外法线单位向量(场景坐标), 或 None。
296
+ """
297
+ if port is None:
298
+ return None
299
+ node = port._node
300
+ shape = getattr(node, "_shape", None)
301
+ if shape == NodeShape.ELLIPSE:
302
+ cx = node.sceneBoundingRect().center()
303
+ p = port.scenePos()
304
+ dx = p.x() - cx.x()
305
+ dy = p.y() - cx.y()
306
+ length = math.hypot(dx, dy)
307
+ if length < 1e-6:
308
+ return None
309
+ return QPointF(dx / length, dy / length)
310
+ if shape == NodeShape.DIAMOND:
311
+ # _orient_angle 为内法线(apex 指向圆心); 外法线 = 反方向。
312
+ # 注意 node 可能有旋转, 故用 mapToScene 把本地方向转到场景。
313
+ ang = math.radians(getattr(port, "_orient_angle", 0.0) + 180.0)
314
+ local_dir = QPointF(math.cos(ang), math.sin(ang))
315
+ # 将本地方向向量经 node 的变换映射到场景(仅方向, 忽略平移)
316
+ scene_dir = node.mapToScene(local_dir) - node.mapToScene(QPointF(0, 0))
317
+ length = math.hypot(scene_dir.x(), scene_dir.y())
318
+ if length < 1e-6:
319
+ return None
320
+ return QPointF(scene_dir.x() / length, scene_dir.y() / length)
321
+ if shape == NodeShape.RECTANGLE:
322
+ # 矩形 port 位于左/右垂直边缘, 外法线恒为水平(垂直于边)。
323
+ # 用 port.side 判定方向(left -> -x, right -> +x); 经 node 变换
324
+ # 映射到场景以兼容节点旋转。
325
+ sign = -1.0 if port.side == "left" else 1.0
326
+ local_dir = QPointF(sign, 0.0)
327
+ scene_dir = node.mapToScene(local_dir) - node.mapToScene(QPointF(0, 0))
328
+ length = math.hypot(scene_dir.x(), scene_dir.y())
329
+ if length < 1e-6:
330
+ return None
331
+ return QPointF(scene_dir.x() / length, scene_dir.y() / length)
332
+ return None
333
+
334
+ def updatePath(self) -> None:
335
+ """更新连线路径和箭头位置。
336
+
337
+ 端点锚定规则: 某端若绑定了 port, 则直接用该 port 的场景中心
338
+ (scenePos); 否则回退到节点中心 + getEdgePoint 的边缘交点。
339
+ port↔port / port↔node / node↔node 三种组合均由此自然支持。
340
+ 自环(isSelfLoop)绘制为绕节点外侧的环(不经过节点自身)。
341
+ """
342
+ # 自环特殊绘制: 从节点边缘/port 出发, 向外绕一圈后指回
343
+ if self.isSelfLoop():
344
+ self._source_edge = QPointF()
345
+ self._target_edge = QPointF()
346
+ self._updateSelfLoopPath()
347
+ return
348
+
349
+ if self._source_port is not None:
350
+ source_edge = self._source_port.scenePos()
351
+ else:
352
+ target_pos = (self._target_node.sceneBoundingRect().center()
353
+ if self._target_node is not None else QPointF())
354
+ source_edge = self._source_node.getEdgePoint(target_pos)
355
+
356
+ if self._target_port is not None:
357
+ target_edge = self._target_port.scenePos()
358
+ else:
359
+ source_pos = (self._source_node.sceneBoundingRect().center()
360
+ if self._source_node is not None else QPointF())
361
+ target_edge = self._target_node.getEdgePoint(source_pos)
362
+
363
+ # 端点处连线的"出射方向"(None = 用既有水平/垂直启发式)。
364
+ # 当该端 port 所在 node 为圆形时, 出射方向沿径向(圆心 -> port),
365
+ # 即与该处切线垂直。
366
+ source_dir = self._portRadialDirection(self._source_port)
367
+ target_dir = self._portRadialDirection(self._target_port)
368
+
369
+ # 记录端点(场景坐标), 供 _refreshPen 构造端口渐变
370
+ self._source_edge = QPointF(source_edge)
371
+ self._target_edge = QPointF(target_edge)
372
+
373
+ # 根据连线类型创建路径
374
+ if self._connection_type == ConnectionType.BEZIER:
375
+ self._updateBezierPath(source_edge, target_edge, source_dir, target_dir)
376
+ else:
377
+ self._updateStraightPath(source_edge, target_edge)
378
+
379
+ # 端点变化后刷新 pen(端口渐变需按新端点重建渐变画刷)
380
+ self._refreshPen()
381
+
382
+ def _updateSelfLoopPath(self) -> None:
383
+ """绘制自环: 从源端点出发, 绕节点外侧一圈后落到目标端点。
384
+
385
+ 覆盖全部 5 类自指(node→node / port→port / node→port / port→node /
386
+ left→right)。端点取值: 某端若绑定 port 则用其 scenePos, 否则取节点
387
+ 边缘上偏离中心的一个点, 保证两端不重合。
388
+
389
+ 环方向按主轴判定(左右优先):
390
+ - **左右环**: 两端 x 都在节点同一侧(左/右)且含 port 时, 环画在该侧
391
+ (如 left→left 画左侧, right→right 画右侧)。
392
+ - **上下环**: 两端跨左右(如 left→right)或 node→node 时, 环画上方/下方。
393
+ 下方判定: 含 port 且两端相对 y 均值(自然轴) < 0 时画下方, 否则上方。
394
+
395
+ 环不经过节点自身; 大小按节点尺寸成比例, 适配矩形/菱形等非正方形。
396
+ 自环不支持双向。
397
+ """
398
+ node = self._source_node
399
+ if node is None:
400
+ return
401
+ rect = node.sceneBoundingRect()
402
+ w = rect.width()
403
+ h = rect.height()
404
+ cx = rect.center().x()
405
+ cy = rect.center().y()
406
+
407
+ # ---- 确定源/目标端点 ----
408
+ # 某端绑定 port: 用 port 场景中心; 否则取节点边缘上偏离中心的点。
409
+ # 非 port 端点的初始取值仅占位, 实际边缘(顶/底/左/右)由后续方向判定决定。
410
+ if self._source_port is not None:
411
+ source_edge = self._source_port.scenePos()
412
+ else:
413
+ source_edge = QPointF(cx, rect.top())
414
+ if self._target_port is not None:
415
+ target_edge = self._target_port.scenePos()
416
+ else:
417
+ target_edge = QPointF(cx, rect.top())
418
+
419
+ has_port = (self._source_port is not None
420
+ or self._target_port is not None)
421
+ spread = max(w * 0.18, 14.0)
422
+ spread_h = max(h * 0.18, 14.0) # 左右环时 node 端点纵向偏离间距
423
+
424
+ # ---- 主轴方向判定: 左右优先, 其次上下 ----
425
+ # 用 port.side 判定(而非端点 x): node 端点无可靠 x 信息(初始在 cx),
426
+ # 其"侧"由关联的 port 端推断。node<->node 两端均无 port -> 无侧(上环)。
427
+ # 左右环: 两端同侧(left/left 或 right/right, 含 node<->port 单侧)。
428
+ # 上下环: 两端异侧(如 left->right)或两端均 node。
429
+ def _port_side(port):
430
+ return port.side if port is not None else None
431
+
432
+ src_side = _port_side(self._source_port)
433
+ tgt_side = _port_side(self._target_port)
434
+ # node 端的侧跟随另一端 port; 两端均无 port 则无侧
435
+ eff_src = src_side or tgt_side
436
+ eff_tgt = tgt_side or src_side
437
+ if has_port and eff_src == "left" and eff_tgt == "left":
438
+ side_dir = "left"
439
+ elif has_port and eff_src == "right" and eff_tgt == "right":
440
+ side_dir = "right"
441
+ else:
442
+ side_dir = None # 上下环
443
+
444
+ if side_dir is not None:
445
+ # ---- 左右环(水平拱起, 与上下环正交对偶) ----
446
+ # base_x: 环所贴的节点左/右边缘; arch_sign: x 偏移方向(左=-1/右=+1)
447
+ base_x = rect.left() if side_dir == "left" else rect.right()
448
+ arch_sign = -1.0 if side_dir == "left" else 1.0
449
+ # loop_w: 水平拱起深度(w/h 互换的 loop_h 公式)
450
+ loop_w = max(w * 0.75, h * 0.55, 50.0) * 1.5
451
+ side = max(h * 0.5, 30.0) * 1.5 # 控制点纵向外拉量
452
+ apex = QPointF(base_x + arch_sign * loop_w, cy)
453
+ # 非 port 端点: 贴左/右边缘, 纵向偏离中心(源偏上、目标偏下)
454
+ if self._source_port is None:
455
+ source_edge = QPointF(base_x, cy - spread_h / 2.0)
456
+ if self._target_port is None:
457
+ target_edge = QPointF(base_x, cy + spread_h / 2.0)
458
+ # 两端点重合(如同一 port 自指)时, 沿纵向人为分离, 让环有可见宽度
459
+ _sep = spread_h if spread_h >= 14.0 else 14.0
460
+ if (source_edge - target_edge).manhattanLength() < 1.0:
461
+ source_edge = QPointF(source_edge.x(), source_edge.y() - _sep / 2.0)
462
+ target_edge = QPointF(target_edge.x(), target_edge.y() + _sep / 2.0)
463
+ vertical = False # 主轴为水平
464
+ else:
465
+ # ---- 上下环(垂直拱起, 原有逻辑) ----
466
+ # 相对 y 均值(自然轴, 上正下负): port 在下半部时 < 0。
467
+ _BELOW_TOL = 1.0 # 容差: 排除浮点误差(矩形中部 port 的 ~1e-15 残留)
468
+
469
+ def _rel_y(edge, port):
470
+ return (cy - edge.y()) if port is not None else 0.0
471
+
472
+ rel_y_avg = (_rel_y(source_edge, self._source_port)
473
+ + _rel_y(target_edge, self._target_port)) / 2.0
474
+ below = has_port and (rel_y_avg < -_BELOW_TOL)
475
+
476
+ arch_sign = 1.0 if below else -1.0 # y 偏移(上=-1/下=+1)
477
+ base_y = rect.bottom() if below else rect.top()
478
+ loop_h = max(h * 0.75, w * 0.55, 50.0) * 1.5
479
+ side = max(w * 0.5, 30.0) * 1.5
480
+ apex = QPointF(cx, base_y + arch_sign * loop_h)
481
+ # 非 port 端点: 贴顶/底边缘, 水平偏离中心(源偏左、目标偏右)
482
+ if self._source_port is None:
483
+ source_edge = QPointF(cx - spread / 2.0, base_y)
484
+ if self._target_port is None:
485
+ target_edge = QPointF(cx + spread / 2.0, base_y)
486
+ # 两端点重合(如同一 port 自指)时, 沿横向人为分离, 让环有可见宽度
487
+ _sep = spread if spread >= 14.0 else 14.0
488
+ if (source_edge - target_edge).manhattanLength() < 1.0:
489
+ source_edge = QPointF(source_edge.x() - _sep / 2.0, source_edge.y())
490
+ target_edge = QPointF(target_edge.x() + _sep / 2.0, target_edge.y())
491
+ vertical = True # 主轴为垂直
492
+
493
+ # ---- 端点出射方向 ----
494
+ # port 端: 沿 port 外法线(矩形 port = 垂直边界), 与普通 port 连线一致;
495
+ # 非 port 端: None -> 沿用启发式(按主轴方向偏离边缘出射)。
496
+ source_dir = self._portRadialDirection(self._source_port)
497
+ target_dir = self._portRadialDirection(self._target_port)
498
+
499
+ # ---- 两段三次贝塞尔, 在拱顶 apex 处 C¹ 连续拼接 ----
500
+ # 单段贝塞尔无法同时满足"两端切线沿法线"与"中间拱起"
501
+ # (控制点与端点同高/宽时曲线贴边), 故用两段: 每段负责"端点出射 -> 弯向拱顶"。
502
+ #
503
+ # 段1: source -> c1a -> c1b -> apex (source 端切线 = c1a - source)
504
+ # 段2: apex -> c2a -> c2b -> target (target 端切线 = target - c2b)
505
+ # 拱顶 C¹ 连续: c2a = 2·apex − c1b (段1终端切线 = 段2源端切线, 无折角)
506
+ #
507
+ # 控制点构造(c_a 第一控制点):
508
+ # port: 沿外法线外推 side 距离 -> 切线 = 法线(垂直边界)
509
+ # 非 port: 沿主轴 arch_sign 方向偏离边缘, 外推 loop·0.45
510
+ # c_b(第二控制点): c_a 与 apex 间插值(BEZEL=0.6), 引导曲线弯向拱顶
511
+ BEZEL = 0.6 # c_b 在 c_a 与 apex 间的插值比(0.6 在稳定区, flips=0)
512
+ loop_len = loop_h if vertical else loop_w
513
+
514
+ def _ctrl_a(edge, dir_):
515
+ if dir_ is not None:
516
+ # port: 沿外法线外推, 切线 = 法线方向(垂直边界)
517
+ return QPointF(edge.x() + side * dir_.x(),
518
+ edge.y() + side * dir_.y())
519
+ # 非 port: 沿主轴 arch_sign 方向外推 loop·0.45
520
+ if vertical:
521
+ return QPointF(edge.x(), edge.y() + arch_sign * loop_len * 0.45)
522
+ return QPointF(edge.x() + arch_sign * loop_len * 0.45, edge.y())
523
+
524
+ c1a = _ctrl_a(source_edge, source_dir)
525
+ c1b = QPointF(c1a.x() * (1 - BEZEL) + apex.x() * BEZEL,
526
+ c1a.y() * (1 - BEZEL) + apex.y() * BEZEL)
527
+ # C¹ 镜像: 段2第一控制点 = 2·apex − c1b
528
+ c2a = QPointF(2 * apex.x() - c1b.x(), 2 * apex.y() - c1b.y())
529
+ c2b = _ctrl_a(target_edge, target_dir)
530
+
531
+ path = QPainterPath()
532
+ path.moveTo(source_edge)
533
+ path.cubicTo(c1a, c1b, apex) # 段1: source → 拱顶
534
+ path.cubicTo(c2a, c2b, target_edge) # 段2: 拱顶 → target
535
+ self.setPath(path)
536
+
537
+ # 记录端点供渐变/绘制
538
+ self._source_edge = QPointF(source_edge)
539
+ self._target_edge = QPointF(target_edge)
540
+
541
+ # ---- 箭头: 终点处沿曲线切线(c2b -> target_edge) ----
542
+ # 两段贝塞尔在终点(target, 段2 t=1)的切线方向 = 最后控制点(c2b)指向终点
543
+ dx = target_edge.x() - c2b.x()
544
+ dy = target_edge.y() - c2b.y()
545
+ dlen = math.hypot(dx, dy)
546
+ if dlen > 0:
547
+ target_direction = QPointF(dx / dlen, dy / dlen)
548
+ else:
549
+ target_direction = QPointF(0.0, 1.0)
550
+
551
+ arrow_tip_offset = ARROW_TIP_EDGE_OFFSET
552
+ arrow_tip = QPointF(
553
+ target_edge.x() - arrow_tip_offset * target_direction.x(),
554
+ target_edge.y() - arrow_tip_offset * target_direction.y()
555
+ )
556
+ self._target_arrow.updateArrow(arrow_tip, target_direction, c2b)
557
+ if self._target_arrow.scene() is None and self.scene():
558
+ self.scene().addItem(self._target_arrow)
559
+
560
+ # 自环不支持双向; 若曾被设为双向, 移除源端箭头
561
+ if self._source_arrow is not None:
562
+ if self._source_arrow.scene():
563
+ self._source_arrow.scene().removeItem(self._source_arrow)
564
+ self._source_arrow = None
565
+
566
+ # 端点变化后刷新 pen(端口渐变需按新端点重建渐变画刷)
567
+ self._refreshPen()
568
+
569
+ def _updateBezierPath(
570
+ self,
571
+ source_edge: QPointF,
572
+ target_edge: QPointF,
573
+ source_dir: Optional[QPointF] = None,
574
+ target_dir: Optional[QPointF] = None,
575
+ ) -> None:
576
+ """更新贝塞尔曲线路径。
577
+
578
+ Args:
579
+ source_edge / target_edge: 两端点(场景坐标)。
580
+ source_dir / target_dir: 端点处连线的出射方向(单位向量, 场景坐标)。
581
+ 非空时, 该端控制点沿此方向放置, 使曲线在该端点的切线 = 此方向
582
+ (用于圆形节点的 port: 径向 = 与切线垂直)。为 None 时, 该端沿用
583
+ 既有水平/垂直主轴启发式(控制点朝向另一端)。
584
+ """
585
+ # 计算控制点(三次贝塞尔曲线)
586
+ dx = target_edge.x() - source_edge.x()
587
+ dy = target_edge.y() - source_edge.y()
588
+ distance = math.sqrt(dx * dx + dy * dy)
589
+
590
+ # 控制点偏移量(基于距离的比例)
591
+ offset = min(distance * 0.5, 150)
592
+
593
+ # 源端控制点: 优先按指定出射方向; 否则沿主轴(水平/垂直)朝向目标延伸
594
+ if source_dir is not None:
595
+ ctrl1 = QPointF(
596
+ source_edge.x() + offset * source_dir.x(),
597
+ source_edge.y() + offset * source_dir.y(),
598
+ )
599
+ elif abs(dx) > abs(dy):
600
+ ctrl1 = QPointF(source_edge.x() + offset * (1 if dx > 0 else -1), source_edge.y())
601
+ else:
602
+ ctrl1 = QPointF(source_edge.x(), source_edge.y() + offset * (1 if dy > 0 else -1))
603
+
604
+ # 目标端控制点: 优先按指定出射方向(径向外法线)放置, 使曲线从外侧沿径向
605
+ # 进入端点; 否则沿主轴朝向源端延伸
606
+ if target_dir is not None:
607
+ ctrl2 = QPointF(
608
+ target_edge.x() + offset * target_dir.x(),
609
+ target_edge.y() + offset * target_dir.y(),
610
+ )
611
+ elif abs(dx) > abs(dy):
612
+ ctrl2 = QPointF(target_edge.x() - offset * (1 if dx > 0 else -1), target_edge.y())
613
+ else:
614
+ ctrl2 = QPointF(target_edge.x(), target_edge.y() - offset * (1 if dy > 0 else -1))
615
+
616
+ # 创建路径
617
+ path = QPainterPath()
618
+ path.moveTo(source_edge)
619
+ path.cubicTo(ctrl1, ctrl2, target_edge)
620
+
621
+ self.setPath(path)
622
+
623
+ # 更新目标端箭头(正方向)
624
+ target_direction = QPointF(
625
+ (target_edge.x() - ctrl2.x()) / distance if distance > 0 else 1,
626
+ (target_edge.y() - ctrl2.y()) / distance if distance > 0 else 0
627
+ )
628
+ # 归一化
629
+ dir_len = math.sqrt(target_direction.x() ** 2 + target_direction.y() ** 2)
630
+ if dir_len > 0:
631
+ target_direction = QPointF(target_direction.x() / dir_len, target_direction.y() / dir_len)
632
+
633
+ arrow_tip_offset = ARROW_TIP_EDGE_OFFSET # 箭头尖端距离边缘的偏移
634
+ arrow_tip = QPointF(
635
+ target_edge.x() - arrow_tip_offset * target_direction.x(),
636
+ target_edge.y() - arrow_tip_offset * target_direction.y()
637
+ )
638
+
639
+ self._target_arrow.updateArrow(arrow_tip, target_direction, ctrl2)
640
+
641
+ # 确保箭头在场景中
642
+ if self._target_arrow.scene() is None and self.scene():
643
+ self.scene().addItem(self._target_arrow)
644
+
645
+ # 如果是双向连线,更新源端箭头
646
+ if self._is_bidirectional:
647
+ if self._source_arrow is None:
648
+ self._source_arrow = ArrowItem(self, is_source_arrow=True)
649
+ if self.scene():
650
+ self.scene().addItem(self._source_arrow)
651
+ # 设置箭头拖拽回调(如果已设置)
652
+ if self._arrow_drag_started_callback:
653
+ self._source_arrow.setDragCallbacks(
654
+ self._arrow_drag_started_callback,
655
+ self._arrow_drag_moved_callback,
656
+ self._arrow_drag_finished_callback
657
+ )
658
+
659
+ # 计算源端箭头方向:曲线在源端点的切线方向(使用控制点计算)
660
+ source_direction = QPointF(
661
+ (source_edge.x() - ctrl1.x()) / distance if distance > 0 else -1,
662
+ (source_edge.y() - ctrl1.y()) / distance if distance > 0 else 0
663
+ )
664
+ # 归一化
665
+ dir_len = math.sqrt(source_direction.x() ** 2 + source_direction.y() ** 2)
666
+ if dir_len > 0:
667
+ source_direction = QPointF(source_direction.x() / dir_len, source_direction.y() / dir_len)
668
+
669
+ source_arrow_tip = QPointF(
670
+ source_edge.x() - arrow_tip_offset * source_direction.x(),
671
+ source_edge.y() - arrow_tip_offset * source_direction.y()
672
+ )
673
+
674
+ self._source_arrow.updateArrow(source_arrow_tip, source_direction, ctrl1)
675
+ else:
676
+ # 如果不是双向,移除源端箭头
677
+ if self._source_arrow:
678
+ if self._source_arrow.scene():
679
+ self._source_arrow.scene().removeItem(self._source_arrow)
680
+ self._source_arrow = None
681
+
682
+ def _updateStraightPath(self, source_edge: QPointF, target_edge: QPointF) -> None:
683
+ """更新直线路径"""
684
+ # 创建直线路径
685
+ path = QPainterPath()
686
+ path.moveTo(source_edge)
687
+ path.lineTo(target_edge)
688
+
689
+ self.setPath(path)
690
+
691
+ # 计算方向向量
692
+ dx = target_edge.x() - source_edge.x()
693
+ dy = target_edge.y() - source_edge.y()
694
+ distance = math.sqrt(dx * dx + dy * dy)
695
+
696
+ # 归一化方向向量
697
+ if distance > 0:
698
+ direction = QPointF(dx / distance, dy / distance)
699
+ else:
700
+ direction = QPointF(1, 0)
701
+
702
+ # 更新目标端箭头
703
+ arrow_tip_offset = ARROW_TIP_EDGE_OFFSET
704
+ arrow_tip = QPointF(
705
+ target_edge.x() - arrow_tip_offset * direction.x(),
706
+ target_edge.y() - arrow_tip_offset * direction.y()
707
+ )
708
+
709
+ self._target_arrow.updateArrow(arrow_tip, direction, source_edge)
710
+
711
+ # 确保箭头在场景中
712
+ if self._target_arrow.scene() is None and self.scene():
713
+ self.scene().addItem(self._target_arrow)
714
+
715
+ # 如果是双向连线,更新源端箭头
716
+ if self._is_bidirectional:
717
+ if self._source_arrow is None:
718
+ self._source_arrow = ArrowItem(self, is_source_arrow=True)
719
+ if self.scene():
720
+ self.scene().addItem(self._source_arrow)
721
+ # 设置箭头拖拽回调(如果已设置)
722
+ if self._arrow_drag_started_callback:
723
+ self._source_arrow.setDragCallbacks(
724
+ self._arrow_drag_started_callback,
725
+ self._arrow_drag_moved_callback,
726
+ self._arrow_drag_finished_callback
727
+ )
728
+
729
+ # 源端箭头方向相反
730
+ source_direction = QPointF(-direction.x(), -direction.y())
731
+
732
+ source_arrow_tip = QPointF(
733
+ source_edge.x() - arrow_tip_offset * source_direction.x(),
734
+ source_edge.y() - arrow_tip_offset * source_direction.y()
735
+ )
736
+
737
+ self._source_arrow.updateArrow(source_arrow_tip, source_direction, target_edge)
738
+ else:
739
+ # 如果不是双向,移除源端箭头
740
+ if self._source_arrow:
741
+ if self._source_arrow.scene():
742
+ self._source_arrow.scene().removeItem(self._source_arrow)
743
+ self._source_arrow = None
744
+
745
+ def _hasPortGradient(self) -> bool:
746
+ """是否应绘制端口渐变(至少一端绑定 port)。
747
+
748
+ 自环也适用: port→port / port→node / node→port 自环的水平线性渐变
749
+ 沿 source_edge→target_edge 分布, 左 port 端为端口色、拱顶为中性灰、
750
+ 右 port 端为端口色(两端均 port 时), 符合端口配色语义。
751
+ """
752
+ return (self._source_port is not None
753
+ or self._target_port is not None)
754
+
755
+ def _portSideColor(self, source_side: bool) -> Optional[QColor]:
756
+ """取端口侧的端口色(自定义色优先, 否则所在侧默认色)。
757
+
758
+ Args:
759
+ source_side: True 取源端 port, False 取目标端 port。
760
+
761
+ Returns:
762
+ 该端为 port 时返回端口色(已调整到 PORT_GRADIENT_NEUTRAL_LUMINANCE);
763
+ 该端非 port 时返回 None。
764
+ """
765
+ port = self._source_port if source_side else self._target_port
766
+ if port is None:
767
+ return None
768
+ base = port._fill_color() # 自定义色 or 所在侧默认色
769
+ return color_at_relative_luminance(base, PORT_GRADIENT_NEUTRAL_LUMINANCE)
770
+
771
+ def _buildGradientBrush(self) -> Optional[QBrush]:
772
+ """构造端口渐变画刷。
773
+
774
+ 渐变沿 source_edge -> target_edge 方向:
775
+ - source 端为 port: source 端为端口色(L0), 向中点过渡到中性灰(L0);
776
+ - target 端为 port: target 端为端口色(L0), 向中点过渡到中性灰(L0);
777
+ - 两端均 port: 两端各为端口色(L0), 中点为中性灰(L0)。
778
+ - 仅一端 port: 另一端(非端口端)取中性灰(L0)。
779
+ 两端点同相对亮度(中间 RGB 插值不严格保证, 近似)。
780
+ """
781
+ if not self._hasPortGradient():
782
+ return None
783
+ neutral = neutral_grey_at_relative_luminance(PORT_GRADIENT_NEUTRAL_LUMINANCE)
784
+ src_color = self._portSideColor(True) or neutral
785
+ tgt_color = self._portSideColor(False) or neutral
786
+
787
+ p1, p2 = self._source_edge, self._target_edge
788
+ # 退化(两端点重合)时给定一个默认方向, 避免渐变面积为 0
789
+ if p1 == p2:
790
+ p2 = QPointF(p1.x() + 1.0, p1.y())
791
+ grad = QLinearGradient(p1, p2)
792
+ grad.setColorAt(0.0, src_color)
793
+ grad.setColorAt(1.0, tgt_color)
794
+ # 中点为中性灰: 用三个 stop 让两侧各自向中点过渡
795
+ grad.setColorAt(0.5, neutral)
796
+ return QBrush(grad)
797
+
798
+ def _refreshPen(self) -> None:
799
+ """根据当前状态刷新连线 pen。
800
+
801
+ 选中 -> 选中色 pen; 闪烁中 -> 保持 flash pen(由 flash* 设置); 其余:
802
+ - 含 port 且非自环 -> 带端口渐变画刷的 normal pen
803
+ - 否则 -> 普通 normal pen
804
+ pen 由 QGraphicsPathItem 默认 paint() 直接消费, 故无需覆写 paint。
805
+ """
806
+ if self._is_selected:
807
+ self.setPen(self._selected_pen)
808
+ return
809
+ if self._is_flashing:
810
+ return # flashError/flashHighlight 已临时 setPen, 不覆盖
811
+ if self._hasPortGradient():
812
+ brush = self._buildGradientBrush()
813
+ pen = QPen(self._normal_pen)
814
+ pen.setBrush(brush)
815
+ pen.setCapStyle(Qt.PenCapStyle.RoundCap)
816
+ self.setPen(pen)
817
+ else:
818
+ self.setPen(self._normal_pen)
819
+
820
+ def setSelected(self, selected: bool) -> None:
821
+ """设置连接线和所有箭头的选中状态"""
822
+ self._is_selected = bool(selected)
823
+ self._refreshPen()
824
+
825
+ # 同时选中/取消选中所有箭头
826
+ self._target_arrow.setSelected(selected)
827
+ if self._source_arrow:
828
+ self._source_arrow.setSelected(selected)
829
+ self.update()
830
+
831
+ def flashError(self) -> None:
832
+ """闪烁显示错误(连线更改失败)"""
833
+ error_color = QColor("#8B0000") # 暗灰红色
834
+ error_pen = QPen(error_color, self._style.stroke_width + 2)
835
+ error_pen.setCapStyle(Qt.PenCapStyle.RoundCap)
836
+
837
+ # 闪烁3次
838
+ flash_count = [0]
839
+
840
+ def toggleFlash():
841
+ flash_count[0] += 1
842
+ if flash_count[0] % 2 == 1:
843
+ self.setPen(error_pen)
844
+ else:
845
+ # 闪烁期间用纯色 normal pen(不带渐变), 渐变由结束时的 _refreshPen 恢复
846
+ self.setPen(self._normal_pen)
847
+
848
+ if flash_count[0] >= 6: # 3次闪烁 = 6次切换
849
+ self._flash_timer.stop()
850
+ self._flash_timer.deleteLater()
851
+ self._flash_timer = None
852
+ self._is_flashing = False
853
+ self._refreshPen()
854
+
855
+ self._is_flashing = True
856
+ self.setPen(error_pen) # 立即显示错误色, 不等首次 timeout(也避免渐变残留)
857
+ self._flash_timer = QTimer()
858
+ self._flash_timer.timeout.connect(toggleFlash)
859
+ self._flash_timer.start(150) # 150ms间隔
860
+
861
+ def flashHighlight(self) -> None:
862
+ """天蓝灰色高亮闪烁(用于子窗口中双击定位连线)"""
863
+ highlight_color = QColor("#87CEEB") # 天蓝灰色 (Sky Blue)
864
+ highlight_pen = QPen(highlight_color, self._style.stroke_width + 4)
865
+ highlight_pen.setCapStyle(Qt.PenCapStyle.RoundCap)
866
+
867
+ original_z = self.zValue()
868
+
869
+ # 提升到最上层显示
870
+ self.setZValue(100)
871
+
872
+ # 闪烁3次
873
+ flash_count = [0]
874
+
875
+ def toggleFlash():
876
+ flash_count[0] += 1
877
+ if flash_count[0] % 2 == 1:
878
+ self.setPen(highlight_pen)
879
+ else:
880
+ self.setPen(self._normal_pen)
881
+
882
+ if flash_count[0] >= 6: # 3次闪烁 = 6次切换
883
+ self._flash_timer.stop()
884
+ self._flash_timer.deleteLater()
885
+ self._flash_timer = None
886
+ self._is_flashing = False
887
+ self._refreshPen()
888
+ self.setZValue(original_z) # 恢复原始层级
889
+
890
+ self._is_flashing = True
891
+ self.setPen(highlight_pen) # 立即显示高亮色, 不等首次 timeout
892
+ self._flash_timer = QTimer()
893
+ self._flash_timer.timeout.connect(toggleFlash)
894
+ self._flash_timer.start(150) # 150ms间隔
895
+
896
+ def setColor(self, color: QColor) -> None:
897
+ """
898
+ 设置连接线的主颜色
899
+
900
+ Args:
901
+ color: 主颜色
902
+ """
903
+ self._style.normal_color = color
904
+ self._style.selected_color = color
905
+ self._style.hover_color = color
906
+ self._setupStyle()
907
+
908
+ def setBorder(self, width: int = 1, color: QColor = None) -> None:
909
+ """
910
+ 设置连接线的边框宽度(线宽)和颜色
911
+
912
+ Args:
913
+ width: 线宽,<=0 使用默认宽度
914
+ color: 线颜色,None 表示使用默认颜色
915
+ """
916
+ if width > 0:
917
+ self._style.stroke_width = width
918
+ self._style.selected_stroke_width = width + 1
919
+ if color is not None:
920
+ self._style.normal_color = color
921
+ self._style.selected_color = color
922
+ self._style.hover_color = color
923
+ self._setupStyle()
924
+
925
+ def hoverEnterEvent(self, event) -> None:
926
+ """鼠标进入事件"""
927
+ super().hoverEnterEvent(event)
928
+
929
+ def hoverLeaveEvent(self, event) -> None:
930
+ """鼠标离开事件"""
931
+ super().hoverLeaveEvent(event)
932
+
933
+ def removeArrows(self) -> None:
934
+ """移除所有箭头项"""
935
+ if self._target_arrow and self._target_arrow.scene():
936
+ self._target_arrow.scene().removeItem(self._target_arrow)
937
+
938
+ if self._source_arrow and self._source_arrow.scene():
939
+ self._source_arrow.scene().removeItem(self._source_arrow)