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,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
|