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,727 @@
1
+ """
2
+ 节点核心基类模块
3
+
4
+ 定义 _NodeCore(QGraphicsEllipseItem) —— 节点项的运行时根基类。
5
+ 承载节点最基础的属性、常量、信号、几何核心与样式核心, 并提供
6
+ _init_ports / _init_interaction / _init_markers 三个初始化钩子,
7
+ 供 Mixin 子类覆写以填充各自层的属性。
8
+
9
+ 层次结构:
10
+ INodeItem (Protocol, 仅类型) <- _node_interface.py
11
+ |
12
+ _NodeCore(QGraphicsEllipseItem) <- 本模块
13
+ |
14
+ _NodePortMixin <- _node_ports.py
15
+ |
16
+ _NodeInteractionMixin <- _node_interaction.py
17
+ |
18
+ NodeItem <- node_item.py (paint/序列化)
19
+
20
+ 遵循代码规范: 本模块不含 paint/toDict/fromDict 等最终实现, 那些留在 node_item.py。
21
+ """
22
+
23
+ import math
24
+ import uuid
25
+ from typing import Optional, Dict, Any, TYPE_CHECKING
26
+
27
+ from PyQt6.QtWidgets import (
28
+ QGraphicsItem, QGraphicsEllipseItem, QGraphicsTextItem,
29
+ )
30
+ from PyQt6.QtCore import Qt, QPointF, QRectF, QTimer, pyqtSignal, QObject, \
31
+ QPropertyAnimation, QEasingCurve
32
+ from PyQt6.QtGui import QBrush, QPen, QColor, QFont, QPainterPath
33
+
34
+ from chartflow.node.styles import NodeStyle
35
+ from chartflow.node.enums import NodeState, NodeShape
36
+
37
+ if TYPE_CHECKING:
38
+ from chartflow.node.popmenu import QNodePopMenu
39
+
40
+
41
+ # 常量定义
42
+ MIN_RADIUS = 40.0
43
+ PADDING = 15.0
44
+ TEXT_EDIT_PADDING = 4.0
45
+ BUTTON_RADIUS_RATIO = 0.2
46
+ ANIMATION_DURATION_SHOW = 200
47
+ ANIMATION_DURATION_HIDE = 100
48
+ Z_VALUE_NODE = 50
49
+ Z_VALUE_BUTTON = 100
50
+ Z_VALUE_MARKER = 100
51
+
52
+ # 节点尺寸相关
53
+ INNER_RATIO = 0.75 # 内部区域占半径的比例(与边缘指示器一致)
54
+ PORT_MIN_GAP = 5.0 # 端口之间的最小垂直间距
55
+ NAME_INNER_PAD = 6.0 # 名称与内部区域边界的内边距
56
+ # 命中/hover 外环厚度(像素): 节点几何之外的可交互边缘带, 用于发起/接收
57
+ # 连线以及触发 hover 描边。须覆盖描边半宽 + 命中余量。
58
+ NODE_HIT_EDGE_BAND = 5.0
59
+
60
+ # "hover 显示全部端口" 要求鼠标静止: 移动超过该距离则打断并重置等待
61
+ HOVER_MOVE_THRESHOLD_SQ = 25.0 # 5px 的平方
62
+
63
+ # 圆形(椭圆)节点: 端口分布在左右侧弧的角度窗口(避开上下极点)
64
+ CIRCLE_PORT_WINDOW_DEG = 65.0 # 侧弧半角度(从侧中心向上下各 65°)
65
+ CIRCLE_PORT_MAX_STEP_DEG = 22.0 # 角度步进上限(端口少时向侧中心聚拢)
66
+
67
+
68
+ class NodeSignalHelper(QObject):
69
+ """
70
+ 信号辅助类 - 用于为 NodeItem 提供信号支持
71
+ 因为 QGraphicsEllipseItem 继承自 QGraphicsItem 而不是 QObject,
72
+ 所以需要通过组合方式来提供信号功能
73
+ """
74
+ editRequested = pyqtSignal(object) # 编辑请求信号,参数为节点自身
75
+ nameChanged = pyqtSignal(object, str) # 名称改变信号,参数为节点自身和新名称
76
+
77
+ def __init__(self, parent) -> None:
78
+ super().__init__()
79
+ self._parent = parent
80
+
81
+
82
+ class _NodeCore(QGraphicsEllipseItem):
83
+ """节点核心基类
84
+
85
+ 承载节点最基础的数据与行为: 身份(name/shape/state)、几何核心
86
+ (_calculateHalfSize/_setSize/_applySize/_centerText)、样式核心
87
+ (_applyStyle/setColor/setBorder)、字体、装饰器、信号、数据字典。
88
+
89
+ 提供 _init_ports / _init_interaction / _init_markers 三个空钩子,
90
+ 供 Mixin 子类覆写以初始化各自层的属性。__init__ 按依赖顺序调用它们。
91
+
92
+ 本类不含 paint/toDict/fromDict/getEdgePoint —— 它们依赖跨层方法,
93
+ 留给最终的 NodeItem 实现。
94
+ """
95
+
96
+ def __init__(
97
+ self,
98
+ name: str,
99
+ position: QPointF = QPointF(0, 0),
100
+ shape: NodeShape = NodeShape.ELLIPSE,
101
+ parent: Optional[QGraphicsItem] = None
102
+ ) -> None:
103
+ """初始化节点
104
+
105
+ Args:
106
+ name: 节点名称
107
+ position: 节点位置
108
+ shape: 节点形状
109
+ parent: 父项
110
+ """
111
+ # 先设置基本属性
112
+ self._name: str = name
113
+ # 稳定唯一标识(uuid4 hex 字符串), 跨进程持久化后仍可还原连线拓扑。
114
+ # 由 toDict 序列化、fromDict 还原; 不依赖 Python id()(内存地址, 进程
115
+ # 重启后会变, 导致重载 JSON 后所有连线 target/source 丢失)。
116
+ self._node_id: str = uuid.uuid4().hex
117
+ self._shape: NodeShape = shape
118
+ self._style: NodeStyle = NodeStyle()
119
+ self._state: NodeState = NodeState.IDLE
120
+ self._min_radius: float = MIN_RADIUS
121
+ self._padding: float = PADDING
122
+ # 连线进行中: 作为落点(目标节点)被高亮(画布手动驱动, hover 派发被压制)
123
+ self._drop_target: bool = False
124
+ # 不合规连接: 红色描边闪烁中
125
+ self._flash_error: bool = False
126
+ self._flash_timer: Optional[QTimer] = None
127
+
128
+ # 端口/hover 状态(尺寸计算可能依赖端口, 需先初始化)
129
+ self._ports_spec: Dict[str, list] = {"left": [], "right": []}
130
+ self._port_items: Dict[str, list] = {"left": [], "right": []}
131
+ self._node_hovered: bool = False
132
+ self._hovered_port_count: int = 0
133
+ self._hover_anchor: Optional[QPointF] = None # 判断鼠标是否静止的锚点
134
+
135
+ # 调用各 Mixin 的初始化钩子(填充本层属性: timer / 按钮 / 标记等)
136
+ self._init_ports()
137
+ self._init_interaction()
138
+ self._init_markers()
139
+
140
+ # 计算半宽/半高(矩形且有端口时可长宽不等)
141
+ self._rw, self._rh = self._calculateHalfSize()
142
+ self._radius: float = self._rw
143
+
144
+ # 初始化QGraphicsEllipseItem(以(0,0)为中心)
145
+ super().__init__(QRectF(-self._rw, -self._rh, self._rw * 2, self._rh * 2), parent)
146
+
147
+ # 使用setPos设置位置(节点的中心点)
148
+ self.setPos(position)
149
+
150
+ # 设置节点的Z值,确保在连线上方(连线Z=1,箭头Z=10)
151
+ self.setZValue(Z_VALUE_NODE)
152
+
153
+ # 创建信号辅助对象
154
+ self._signal_helper = NodeSignalHelper(self)
155
+
156
+ # 创建文本项
157
+ self._text_item: QGraphicsTextItem = QGraphicsTextItem(self._name, self)
158
+ self._text_item.setDefaultTextColor(self._style.text_color)
159
+ # 禁用文本项的所有鼠标事件处理,让事件完全传递给父节点
160
+ self._text_item.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction)
161
+ self._text_item.setAcceptHoverEvents(False)
162
+ self._text_item.setAcceptedMouseButtons(Qt.MouseButton.NoButton)
163
+ self._centerText()
164
+
165
+ # 设置可选项标志
166
+ self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable, True)
167
+ self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable, True)
168
+ self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges, True)
169
+ self.setAcceptHoverEvents(True)
170
+
171
+ # 此时 self 已是有效的 QGraphicsItem, 创建需要以本节点为父项的覆盖物
172
+ # (悬浮按钮等)。由 _NodeInteractionMixin 覆写。
173
+ self._setup_overlays()
174
+
175
+ # 数据存储(只读属性,用户可以自由添加数据)
176
+ self._data: Dict[str, Any] = {}
177
+
178
+ # 编辑器类(用户自定义编辑器)
179
+ self._editor_class: Optional[type] = None
180
+
181
+ # 节点右键菜单(由 NodeCanvas.addNode 注入; 构造时无 canvas 故默认 None)
182
+ self.menu: Optional["QNodePopMenu"] = None
183
+
184
+ # 名称编辑状态
185
+ self._is_editing_name: bool = False
186
+
187
+ # 更名区域(浅浅浅灰色虚线框)
188
+ self._name_edit_area: QRectF = self._calculateNameEditArea()
189
+
190
+ # 自定义样式属性(用于导出/导入和面板控制)
191
+ self._custom_fill_color: Optional[QColor] = None
192
+ self._custom_stroke_color: Optional[QColor] = None
193
+ self._custom_stroke_width: Optional[float] = None
194
+ self._custom_font_family: Optional[str] = None
195
+ self._custom_font_size: Optional[int] = None
196
+ self._custom_radius: Optional[float] = None
197
+
198
+ # 装饰器类型
199
+ self._decorator: Optional[str] = None
200
+
201
+ # 外环(可连线边缘指示器)可见性, 默认显示
202
+ self._edge_indicator_visible: bool = True
203
+
204
+ # 节点是否可参与连线(设为 False 时会清除其全部已有连线)
205
+ self._connectable: bool = True
206
+
207
+ # 自指规则: 控制同节点内 6 类自指连接是否允许创建。
208
+ # 默认值 = 既有逻辑(node→node / left→right / right→left 允许, 其余拒绝)。
209
+ # 通过 selfrefs property 暴露(返回内部 dict, 非副本, 可直接改值)。
210
+ self._selfrefs: Dict[str, bool] = {
211
+ "node->node": True, # 同节点, 两端均无 port(node 自环)
212
+ "port->port": False, # 同节点, 同极/同一 port 自指(不含异极)
213
+ "node->port": False, # 同节点, 源为节点边缘、目标为本节点 port
214
+ "port->node": False, # 同节点, 源为本节点 port、目标为节点边缘
215
+ "left->right": True, # 同节点, 源=left port、目标=right port
216
+ "right->left": True, # 同节点, 源=right port、目标=left port
217
+ }
218
+
219
+ # 应用样式
220
+ self._applyStyle()
221
+
222
+ # ------------------------------------------------------------------ #
223
+ # 初始化钩子(供 Mixin 子类覆写)
224
+ # ------------------------------------------------------------------ #
225
+ def _init_ports(self) -> None:
226
+ """端口层初始化钩子: _NodePortMixin 覆写以创建 port label timer 等"""
227
+
228
+ def _init_interaction(self) -> None:
229
+ """交互层初始化钩子: _NodeInteractionMixin 覆写以创建悬浮按钮等"""
230
+
231
+ def _init_markers(self) -> None:
232
+ """标记层初始化钩子: _NodeInteractionMixin 覆写以创建标记占位等"""
233
+
234
+ def _setup_overlays(self) -> None:
235
+ """覆盖物初始化钩子(在 QGraphicsItem 基类初始化完成后调用):
236
+ _NodeInteractionMixin 覆写以创建 HoverButton 等需以本节点为父项的对象"""
237
+
238
+ # ------------------------------------------------------------------ #
239
+ # 信号
240
+ # ------------------------------------------------------------------ #
241
+ @property
242
+ def editRequested(self):
243
+ """编辑请求信号"""
244
+ return self._signal_helper.editRequested
245
+
246
+ @property
247
+ def nameChanged(self):
248
+ """名称改变信号"""
249
+ return self._signal_helper.nameChanged
250
+
251
+ # ------------------------------------------------------------------ #
252
+ # 数据 / 编辑器
253
+ # ------------------------------------------------------------------ #
254
+ @property
255
+ def data(self) -> Dict[str, Any]:
256
+ """获取节点的数据字典(只读属性,但字典内容可修改)"""
257
+ return self._data
258
+
259
+ @property
260
+ def editorClass(self) -> Optional[type]:
261
+ """获取节点编辑器类"""
262
+ return self._editor_class
263
+
264
+ @editorClass.setter
265
+ def editorClass(self, value: Optional[type]) -> None:
266
+ """设置节点编辑器类"""
267
+ self._editor_class = value
268
+
269
+ # ------------------------------------------------------------------ #
270
+ # 装饰器
271
+ # ------------------------------------------------------------------ #
272
+ @property
273
+ def decorator(self) -> Optional[str]:
274
+ """获取节点装饰器类型"""
275
+ return self._decorator
276
+
277
+ @decorator.setter
278
+ def decorator(self, value: Optional[str]) -> None:
279
+ """设置节点装饰器类型
280
+
281
+ Args:
282
+ value: 装饰器类型,支持 'recursive', 'behavior', 'sequence', 'selector', 'parallel'
283
+ """
284
+ self._decorator = value
285
+ self.update() # 重绘节点以应用新的边框颜色
286
+
287
+ def _getDecoratorStrokeColor(self) -> Optional[QColor]:
288
+ """根据装饰器类型获取边框颜色
289
+
290
+ Returns:
291
+ 装饰器对应的边框颜色,如果没有装饰器则返回 None
292
+ """
293
+ if self._decorator is None:
294
+ return None
295
+
296
+ decorator = self._decorator.lower()
297
+
298
+ # recursive -> 紫罗兰色 (violet)
299
+ if decorator == 'recursive':
300
+ return QColor(238, 130, 238) # Violet
301
+
302
+ # behavior, sequence, selector, parallel -> 皇家蓝色 (royal blue)
303
+ if decorator in ('behavior', 'sequence', 'selector', 'parallel'):
304
+ return QColor(65, 105, 225) # Royal Blue
305
+
306
+ return None
307
+
308
+ # ------------------------------------------------------------------ #
309
+ # 字体
310
+ # ------------------------------------------------------------------ #
311
+ @property
312
+ def font(self) -> QFont:
313
+ """获取节点文本字体"""
314
+ return self._text_item.font()
315
+
316
+ @property
317
+ def fontFamily(self) -> str:
318
+ """获取当前字体家族"""
319
+ return self._text_item.font().family()
320
+
321
+ @property
322
+ def fontSize(self) -> int:
323
+ """获取当前字体大小"""
324
+ return self._text_item.font().pointSize()
325
+
326
+ def setFont(self, family: str = None, size: int = None) -> None:
327
+ """
328
+ 设置节点文本的字体
329
+
330
+ Args:
331
+ family: 字体家族,None 表示不改变
332
+ size: 字体大小,None 表示不改变
333
+ """
334
+ font = self._text_item.font()
335
+ if family is not None:
336
+ self._custom_font_family = family
337
+ font.setFamily(family)
338
+ if size is not None and size > 0:
339
+ self._custom_font_size = size
340
+ font.setPointSize(size)
341
+ self._text_item.setFont(font)
342
+
343
+ # 始终重新居中文字,确保位置正确
344
+ self._centerText()
345
+
346
+ # 重新计算大小以适应新字体(如果没有自定义半径)
347
+ if not getattr(self, '_custom_radius', None):
348
+ rw, rh = self._calculateHalfSize()
349
+ if (rw, rh) != (self._rw, self._rh):
350
+ self._applySize(rw, rh)
351
+
352
+ # 字体变化后刷新端口文本字号与位置(由 _NodePortMixin 提供)
353
+ self._refreshPortFonts()
354
+
355
+ def _nameTextSize(self):
356
+ """测量当前名称文本的 (宽, 高)"""
357
+ temp = QGraphicsTextItem(self._name)
358
+ text_item = getattr(self, "_text_item", None)
359
+ if text_item is not None:
360
+ temp.setFont(text_item.font())
361
+ r = temp.boundingRect()
362
+ return r.width(), r.height()
363
+
364
+ def _hitFont(self) -> QFont:
365
+ """hit 文本的字体(节点字号 - 2)"""
366
+ text_item = getattr(self, "_text_item", None)
367
+ f = QFont(text_item.font()) if text_item is not None else QFont()
368
+ ps = f.pointSize()
369
+ f.setPointSize(max(1, ps - 2) if ps > 0 else 8)
370
+ return f
371
+
372
+ def _textWidth(self, text: str, font: QFont) -> float:
373
+ if not text:
374
+ return 0.0
375
+ t = QGraphicsTextItem(text)
376
+ t.setFont(font)
377
+ return t.boundingRect().width()
378
+
379
+ # ------------------------------------------------------------------ #
380
+ # 几何 / 尺寸
381
+ # ------------------------------------------------------------------ #
382
+ def _hasPorts(self) -> bool:
383
+ """是否配置了任意端口(由 _NodePortMixin 覆写; 此处提供安全默认)"""
384
+ return bool(self._ports_spec.get("left")) or bool(self._ports_spec.get("right"))
385
+
386
+ def _calculateHalfSize(self):
387
+ """计算节点半宽/半高 (rw, rh)。
388
+
389
+ - 名称需容纳在内部区域(INNER_RATIO 以内); 有 hit 时额外预留
390
+ (dot 深度 + 间距 + hit 文本宽度), 避免 hit 与名称重叠。
391
+ - 矩形且有端口时: 宽度由名称(+hit)决定, 高度由最多端口的一侧决定(可长宽不等)。
392
+ - 其余(椭圆/菱形/无端口矩形): 保持方形。
393
+ """
394
+ tw, th = self._nameTextSize()
395
+ name_half = tw / 2.0
396
+ rw_name = (name_half + NAME_INNER_PAD) / INNER_RATIO
397
+ rh_name = (th / 2.0 + NAME_INNER_PAD) / INNER_RATIO
398
+ has_ports = self._hasPorts()
399
+
400
+ if self._shape == NodeShape.RECTANGLE and has_ports:
401
+ port_extent = self._portInwardExtent()
402
+ rw = max(rw_name, name_half + port_extent + NAME_INNER_PAD, self._min_radius)
403
+ rh = max(rh_name, self._halfHeightForPorts(rw), self._min_radius)
404
+ return rw, rh
405
+
406
+ # 方形
407
+ R = max(rw_name, rh_name, self._min_radius)
408
+ if has_ports:
409
+ if self._shape == NodeShape.ELLIPSE:
410
+ # 圆形: 半径需容纳侧弧窗口内的端口
411
+ R = max(R, self._circlePortRadius())
412
+ else:
413
+ R = max(R, self._halfHeightForPorts(R))
414
+ return R, R
415
+
416
+ def _setSize(self, rw: float, rh: float) -> None:
417
+ """应用新的半宽/半高, 更新矩形、文本、标记(不重排端口)"""
418
+ if self._shape == NodeShape.ELLIPSE:
419
+ # 圆形节点固定为标准圆(宽高相等), 保证端口沿弧线布局正确
420
+ side = max(rw, rh)
421
+ rw = rh = side
422
+ self._rw = rw
423
+ self._rh = rh
424
+ self._radius = rw # 兼容椭圆/菱形及旧代码
425
+ self.setRect(QRectF(-rw, -rh, rw * 2, rh * 2))
426
+ self._centerText()
427
+ for attr in ("_start_marker", "_single_marker", "_custom_marker"):
428
+ marker = getattr(self, attr, None)
429
+ if marker is not None:
430
+ marker._updatePosition()
431
+
432
+ def _applySize(self, rw: float, rh: float) -> None:
433
+ """应用新的半宽/半高, 并重排端口(端口项已存在时使用)"""
434
+ self._setSize(rw, rh)
435
+ self._layoutPorts()
436
+
437
+ def _hitShapeMargin(self) -> float:
438
+ """shape()/boundingRect() 在实际几何之外的外扩量(像素)。
439
+
440
+ = 描边半宽 + 外环厚度(NODE_HIT_EDGE_BAND), 使 hover 与命中覆盖到
441
+ 可连线的外环区域。
442
+ """
443
+ sw = getattr(self._style, 'stroke_width', 2.0)
444
+ return sw / 2.0 + NODE_HIT_EDGE_BAND
445
+
446
+ def boundingRect(self) -> QRectF:
447
+ """边界矩形: 须覆盖 shape(), 否则 Qt 不会在该区域重绘/派发 hover。
448
+
449
+ 返回外扩 _hitShapeMargin() 后的外接矩形(对圆/圆角矩形/菱形均成立)。
450
+ """
451
+ rect = self.rect()
452
+ m = self._hitShapeMargin()
453
+ return rect.adjusted(-m, -m, m, m)
454
+
455
+ def shape(self) -> QPainterPath:
456
+ """命中区域与 hover 区域: 贴合实际几何 + 外环(_hitShapeMargin)。
457
+
458
+ QGraphicsEllipseItem 默认 shape() 返回椭圆(与矩形/菱形不符), 且只含
459
+ 描边半宽; 导致矩形四角、菱形边缘以及"外环"区域既无法命中节点、也
460
+ 收不到 hover, 进而无法触发描边态、无法在角部/外环发起或接收连线。
461
+
462
+ 本方法按当前形状返回贴合的轮廓并外扩 _hitShapeMargin(描边半宽+外环):
463
+ - ELLIPSE: 圆(半径 + 外环)
464
+ - RECTANGLE: 圆角矩形(外扩外环)
465
+ - DIAMOND: 菱形(四顶点外扩外环)
466
+ """
467
+ rect = self.rect()
468
+ m = self._hitShapeMargin()
469
+ path = QPainterPath()
470
+ if self._shape == NodeShape.ELLIPSE:
471
+ c = rect.center()
472
+ r = rect.width() / 2.0 + m
473
+ path.addEllipse(c, r, r)
474
+ elif self._shape == NodeShape.RECTANGLE:
475
+ outer = rect.adjusted(-m, -m, m, m)
476
+ # 圆角半径与 paint() 一致(基于原始 rect, 否则圆角位置错位)
477
+ corner = min(rect.width(), rect.height()) * 0.2
478
+ path.addRoundedRect(outer, corner, corner)
479
+ else: # DIAMOND
480
+ c = rect.center()
481
+ hw = rect.width() / 2.0 + m
482
+ hh = rect.height() / 2.0 + m
483
+ path.moveTo(c.x(), c.y() - hh)
484
+ path.lineTo(c.x() + hw, c.y())
485
+ path.lineTo(c.x(), c.y() + hh)
486
+ path.lineTo(c.x() - hw, c.y())
487
+ path.closeSubpath()
488
+ return path
489
+
490
+ def _centerText(self) -> None:
491
+ """将文本居中到节点"""
492
+ if not getattr(self, "_text_item", None):
493
+ return
494
+ text_rect = self._text_item.boundingRect()
495
+ center = self.rect().center()
496
+
497
+ self._text_item.setPos(
498
+ center.x() - text_rect.width() / 2,
499
+ center.y() - text_rect.height() / 2
500
+ )
501
+
502
+ # 更新更名区域位置
503
+ self._name_edit_area = self._calculateNameEditArea()
504
+
505
+ def _calculateNameEditArea(self) -> QRectF:
506
+ """计算名称编辑区域(文本周围的虚线框区域)"""
507
+ text_rect = self._text_item.boundingRect()
508
+ text_pos = self._text_item.pos()
509
+ # 左右1px内边距,上下0px(文本本身已有足够空白)
510
+ hpad = 1.0
511
+ vpad = 0.0
512
+ return QRectF(
513
+ text_pos.x() + text_rect.x() - hpad,
514
+ text_pos.y() + text_rect.y() - vpad,
515
+ text_rect.width() + hpad * 2,
516
+ text_rect.height() + vpad * 2
517
+ )
518
+
519
+ def setRadius(self, radius: float) -> None:
520
+ """
521
+ 设置节点半径(大小)—— 以方形覆盖原尺寸
522
+
523
+ Args:
524
+ radius: 新的半径值(半边长)
525
+ """
526
+ if radius < MIN_RADIUS:
527
+ radius = MIN_RADIUS
528
+ self._custom_radius = radius
529
+ self._applySize(radius, radius)
530
+
531
+ @property
532
+ def radius(self) -> float:
533
+ """获取节点半径(半宽)"""
534
+ return self._rw
535
+
536
+ @radius.setter
537
+ def radius(self, value: float) -> None:
538
+ """设置节点半径(以方形覆盖)"""
539
+ self.setRadius(value)
540
+
541
+ @property
542
+ def center(self) -> QPointF:
543
+ """获取节点中心点"""
544
+ return self.sceneBoundingRect().center()
545
+
546
+ @property
547
+ def id(self) -> str:
548
+ """节点稳定唯一标识(uuid4 hex 字符串, 只读)。
549
+
550
+ 构造时生成, 由 :meth:`toDict` 序列化、:meth:`fromDict` 还原,
551
+ 用作画布导出/导入时连线的 source/target 引用键。不依赖 Python
552
+ ``id()``(内存地址, 跨进程不稳定)。
553
+ """
554
+ return self._node_id
555
+
556
+ # ------------------------------------------------------------------ #
557
+ # 名称 / 形状 / 状态 属性
558
+ # ------------------------------------------------------------------ #
559
+ @property
560
+ def name(self) -> str:
561
+ """获取节点名称"""
562
+ return self._name
563
+
564
+ @name.setter
565
+ def name(self, value: str) -> None:
566
+ """设置节点名称"""
567
+ old_name = self._name
568
+ self._name = value
569
+ self._text_item.setPlainText(value)
570
+
571
+ # 发射名称改变信号
572
+ if old_name != value:
573
+ self._signal_helper.nameChanged.emit(self, value)
574
+
575
+ # 重新计算尺寸
576
+ rw, rh = self._calculateHalfSize()
577
+ if (rw, rh) != (self._rw, self._rh):
578
+ self._applySize(rw, rh)
579
+ # 更新所有连线
580
+ self._updateConnections()
581
+ else:
582
+ self._centerText()
583
+ self._layoutPorts()
584
+
585
+ @property
586
+ def nodeShape(self) -> NodeShape:
587
+ """获取节点形状"""
588
+ return self._shape
589
+
590
+ @nodeShape.setter
591
+ def nodeShape(self, value: NodeShape) -> None:
592
+ """设置节点形状"""
593
+ if self._shape != value:
594
+ self._shape = value
595
+ # 切换形状后重算尺寸: 椭圆会被强制为标准圆(宽高相等),
596
+ # _applySize 内含 _layoutPorts(重新决定 dot 径向/轴向)
597
+ if not getattr(self, "_custom_radius", None):
598
+ rw, rh = self._calculateHalfSize()
599
+ self._applySize(rw, rh)
600
+ else:
601
+ self._setSize(self._custom_radius, self._custom_radius)
602
+ self._layoutPorts()
603
+ self.update()
604
+ # 形状改变后, 边界交点算法(getEdgePoint)随之变化; 重新计算所有
605
+ # 相关连线, 避免端点仍锚在旧形状的边缘位置
606
+ self._updateConnections()
607
+
608
+ @property
609
+ def state(self) -> NodeState:
610
+ """获取节点状态"""
611
+ return self._state
612
+
613
+ @state.setter
614
+ def state(self, value: NodeState) -> None:
615
+ """设置节点状态"""
616
+ if self._state != value:
617
+ self._state = value
618
+ self._applyStyle()
619
+
620
+ # ------------------------------------------------------------------ #
621
+ # 样式
622
+ # ------------------------------------------------------------------ #
623
+ def _applyStyle(self) -> None:
624
+ """应用当前状态的样式"""
625
+ # 基础填充色
626
+ base_color = self._style.idle_fill
627
+
628
+ if self._state == NodeState.IDLE:
629
+ fill_color = base_color
630
+ stroke_color = self._style.idle_stroke
631
+ elif self._state == NodeState.HOVER:
632
+ fill_color = self._blendColors(base_color, self._style.hover_overlay)
633
+ stroke_color = self._style.idle_stroke
634
+ elif self._state == NodeState.SELECTED:
635
+ fill_color = self._blendColors(base_color, self._style.selected_overlay)
636
+ stroke_color = self._style.selected_stroke
637
+ else:
638
+ fill_color = base_color
639
+ stroke_color = self._style.idle_stroke
640
+
641
+ self.setBrush(QBrush(fill_color))
642
+ self.setPen(QPen(stroke_color, self._style.stroke_width))
643
+
644
+ def setColor(self, color: QColor) -> None:
645
+ """
646
+ 设置节点的主颜色(填充色)
647
+
648
+ Args:
649
+ color: 主颜色
650
+ """
651
+ self._custom_fill_color = color
652
+ self._style.idle_fill = color
653
+ self._applyStyle()
654
+
655
+ def setBorder(self, width: int = 1, color: QColor = None) -> None:
656
+ """
657
+ 设置节点的边框宽度和颜色
658
+
659
+ Args:
660
+ width: 边框宽度,<=0 表示无边框
661
+ color: 边框颜色,None 表示使用默认颜色
662
+ """
663
+ if width <= 0:
664
+ self.setPen(QPen(Qt.PenStyle.NoPen))
665
+ self._custom_stroke_width = 0
666
+ else:
667
+ self._custom_stroke_width = width
668
+ self._style.stroke_width = width
669
+ if color is not None:
670
+ self._custom_stroke_color = color
671
+ self._style.idle_stroke = color
672
+ self._style.selected_stroke = color
673
+ self._applyStyle()
674
+
675
+ @property
676
+ def strokeWidth(self) -> float:
677
+ """获取当前边框宽度"""
678
+ return self._style.stroke_width
679
+
680
+ @property
681
+ def fillColor(self) -> QColor:
682
+ """获取当前填充颜色"""
683
+ return self._style.idle_fill
684
+
685
+ @property
686
+ def strokeColor(self) -> QColor:
687
+ """获取当前边框颜色"""
688
+ return self._style.idle_stroke
689
+
690
+ def _blendColors(self, base: QColor, overlay: QColor, overlay_alpha: float = 0.5) -> QColor:
691
+ """混合两种颜色"""
692
+ result = QColor(overlay)
693
+ result.setAlpha(int(overlay_alpha * 255))
694
+
695
+ # 简单的颜色混合
696
+ r = int(base.red() * (1 - overlay_alpha) + overlay.red() * overlay_alpha)
697
+ g = int(base.green() * (1 - overlay_alpha) + overlay.green() * overlay_alpha)
698
+ b = int(base.blue() * (1 - overlay_alpha) + overlay.blue() * overlay_alpha)
699
+
700
+ return QColor(r, g, b)
701
+
702
+ # ------------------------------------------------------------------ #
703
+ # 端口相关占位(由 _NodePortMixin 提供实际实现)
704
+ # ------------------------------------------------------------------ #
705
+ def _layoutPorts(self) -> None:
706
+ """分布端口(由 _NodePortMixin 覆写; 此处为安全空实现)"""
707
+
708
+ def _refreshPortFonts(self) -> None:
709
+ """刷新端口文本字号(由 _NodePortMixin 覆写; 此处为安全空实现)"""
710
+
711
+ def _updateConnections(self) -> None:
712
+ """更新相关连线(由 _NodeInteractionMixin 覆写; 此处为安全空实现)"""
713
+
714
+ # ------------------------------------------------------------------ #
715
+ # 几何辅助(端口相关计算, 实际由 _NodePortMixin 覆写; 这里提供回退)
716
+ # ------------------------------------------------------------------ #
717
+ def _portInwardExtent(self) -> float:
718
+ """端口向内占据的横向深度(由 _NodePortMixin 覆写)"""
719
+ return 0.0
720
+
721
+ def _halfHeightForPorts(self, rw: float) -> float:
722
+ """端口所需半高(由 _NodePortMixin 覆写)"""
723
+ return 0.0
724
+
725
+ def _circlePortRadius(self) -> float:
726
+ """圆形节点端口所需半径(由 _NodePortMixin 覆写)"""
727
+ return 0.0