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,591 @@
|
|
|
1
|
+
"""
|
|
2
|
+
端口项模块 - 节点的输入/输出端口
|
|
3
|
+
|
|
4
|
+
视觉参考 uipcweb(pyipcore 原生风格):
|
|
5
|
+
- 左侧(left/input)=绿色 dot,右侧(right/output)=橙色 dot
|
|
6
|
+
- dot 形状: triangle / circle / trapezoid / square
|
|
7
|
+
- hover 进入 dot 时使用 DodgerBlue 描边(outline 宽度,默认 2)
|
|
8
|
+
- hit: 常驻于 dot 靠近节点中心一侧的灰色小字(字号略小于节点)
|
|
9
|
+
- label: 默认隐藏,节点 hover>0.5s 或 hover 进入端口时显示于外侧;
|
|
10
|
+
未指定时默认等同 name
|
|
11
|
+
|
|
12
|
+
遵循代码规范: dataclass 仅承载数据,渲染/布局逻辑分离到 QNodePort
|
|
13
|
+
(原 ``_PortGraphic``,已公开化) 与模块级辅助函数。
|
|
14
|
+
|
|
15
|
+
类型分层(对外公开):
|
|
16
|
+
- ``QNodePortSpec``: @dataclass, 端口**数据**描述(name/shape/size/...)
|
|
17
|
+
- ``IQNodePort``: Protocol, 端口渲染项**契约**(见 ``_port_interface.py``)
|
|
18
|
+
- ``QNodePort``: QGraphicsObject, 端口**渲染项**实现, 满足 ``IQNodePort``
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import math
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Optional, List, Dict, Any, ClassVar, TYPE_CHECKING
|
|
24
|
+
|
|
25
|
+
from PyQt6.QtCore import Qt, QPointF, QRectF, QPropertyAnimation, QEasingCurve, QTimer
|
|
26
|
+
from PyQt6.QtGui import QBrush, QPen, QColor, QFont, QPainterPath, QPainter
|
|
27
|
+
from PyQt6.QtWidgets import QGraphicsObject, QGraphicsTextItem
|
|
28
|
+
|
|
29
|
+
from chartflow.node._node_base import (
|
|
30
|
+
ANIMATION_DURATION_SHOW,
|
|
31
|
+
ANIMATION_DURATION_HIDE,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from chartflow.node.node_item import QNodeItem
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---- label 弹出/收回动画 ----
|
|
39
|
+
# 出现: scale 0.85 -> 1.0(原地弹出); 消失: scale 1.0 -> 0.85(收回)
|
|
40
|
+
_PORT_LABEL_POP_SCALE = 0.85
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---- 配色(与 uipcweb / styles.py 对齐) ----
|
|
44
|
+
PORT_COLOR_LEFT = QColor("#1E7C5A") # input - SGGREEN
|
|
45
|
+
PORT_COLOR_RIGHT = QColor("#EE7942") # output - OLVORANGE
|
|
46
|
+
PORT_HOVER_OUTLINE = QColor("#1E90FF") # DodgerBlue
|
|
47
|
+
PORT_ERROR_OUTLINE = QColor("#E53935") # 红色(不合规连接闪烁)
|
|
48
|
+
PORT_HIT_COLOR = QColor("#9AA1AB") # 灰色小字
|
|
49
|
+
PORT_LABEL_COLOR = QColor("#1F232A") # 节点文本色
|
|
50
|
+
|
|
51
|
+
VALID_PORT_SHAPES = ("triangle", "circle", "trapezoid", "square")
|
|
52
|
+
DEFAULT_PORT_SIZE = 8
|
|
53
|
+
DEFAULT_PORT_OUTLINE = 2
|
|
54
|
+
|
|
55
|
+
# 节点 hover 多久后显示全部端口 label
|
|
56
|
+
PORT_LABEL_HOVER_DELAY_MS = 500
|
|
57
|
+
|
|
58
|
+
# 文本间距
|
|
59
|
+
HIT_GAP = 1.0 # dot 与 hit 文本的间距(hit 更贴近 dot)
|
|
60
|
+
LABEL_GAP = 3.0 # dot 与 label 文本的间距(label 在节点外侧)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class QNodePortSpec:
|
|
65
|
+
"""端口数据类型(纯数据,不含渲染逻辑)
|
|
66
|
+
|
|
67
|
+
用户配置端口时使用本类的实例(或同构 dict), 经节点 ``ports`` 属性
|
|
68
|
+
重建为 :class:`QNodePort` 渲染项。
|
|
69
|
+
|
|
70
|
+
Attributes:
|
|
71
|
+
name: 端口名称;当 label 未指定时,label 默认等同 name
|
|
72
|
+
shape: dot 形状,取值 "triangle" / "circle" / "trapezoid" / "square"
|
|
73
|
+
size: dot 尺寸(像素)
|
|
74
|
+
hit: 显示在 dot 靠近节点 y 轴一侧的灰色小字,字号略小于节点字号
|
|
75
|
+
label: 显示在外侧的显式文本,默认不显示,节点 hover>0.5s
|
|
76
|
+
或 hover 进入端口时显示;None 时等同 name
|
|
77
|
+
outline: hover 进入 dot 时 DodgerBlue 描边的宽度,<=0 表示不描边
|
|
78
|
+
color: 自定义 dot 填充色(QColor);None 时按所在侧使用
|
|
79
|
+
LEFT_COLOR(left/input)或 RIGHT_COLOR(right/output)
|
|
80
|
+
"""
|
|
81
|
+
# 默认配色静态字段: 左侧(input)/右侧(output)。color=None 时据此着色
|
|
82
|
+
LEFT_COLOR: ClassVar[QColor] = PORT_COLOR_LEFT
|
|
83
|
+
RIGHT_COLOR: ClassVar[QColor] = PORT_COLOR_RIGHT
|
|
84
|
+
|
|
85
|
+
name: str
|
|
86
|
+
shape: str = "circle"
|
|
87
|
+
size: int = DEFAULT_PORT_SIZE
|
|
88
|
+
hit: str = ""
|
|
89
|
+
label: Optional[str] = None
|
|
90
|
+
outline: int = DEFAULT_PORT_OUTLINE
|
|
91
|
+
color: Optional[QColor] = None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def port_effective_label(port: QNodePortSpec) -> str:
|
|
95
|
+
"""label 未指定时默认等同 name"""
|
|
96
|
+
return port.name if port.label is None else port.label
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def port_depth(port: QNodePortSpec) -> float:
|
|
100
|
+
"""dot 向内延伸的深度(横向像素): square=size, 其余=size/2"""
|
|
101
|
+
s = max(1, port.size)
|
|
102
|
+
return float(s) if port.shape == "square" else s / 2.0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def port_to_dict(port: QNodePortSpec) -> Dict[str, Any]:
|
|
106
|
+
"""将 QNodePortSpec 序列化为字典"""
|
|
107
|
+
return {
|
|
108
|
+
"name": port.name,
|
|
109
|
+
"shape": port.shape,
|
|
110
|
+
"size": port.size,
|
|
111
|
+
"hit": port.hit,
|
|
112
|
+
"label": port.label,
|
|
113
|
+
"outline": port.outline,
|
|
114
|
+
"color": port.color.name() if port.color is not None else None,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def port_from_dict(data: Dict[str, Any]) -> QNodePortSpec:
|
|
119
|
+
"""从字典构造 QNodePortSpec"""
|
|
120
|
+
raw_color = data.get("color")
|
|
121
|
+
color = QColor(raw_color) if raw_color else None
|
|
122
|
+
return QNodePortSpec(
|
|
123
|
+
name=data.get("name", ""),
|
|
124
|
+
shape=data.get("shape", "circle"),
|
|
125
|
+
size=data.get("size", DEFAULT_PORT_SIZE),
|
|
126
|
+
hit=data.get("hit", ""),
|
|
127
|
+
label=data.get("label"),
|
|
128
|
+
outline=data.get("outline", DEFAULT_PORT_OUTLINE),
|
|
129
|
+
color=color,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class QNodePort(QGraphicsObject):
|
|
134
|
+
"""端口渲染项
|
|
135
|
+
|
|
136
|
+
绘制 dot、hit 文本、label 文本,处理 hover 与连线拖拽。满足
|
|
137
|
+
:class:`~chartflow.node._port_interface.IQNodePort` 契约(结构子类型,
|
|
138
|
+
无需显式继承 Protocol)。
|
|
139
|
+
|
|
140
|
+
- dot 本地坐标系原点位于节点边缘上的端口锚点。
|
|
141
|
+
- 接受 hover 事件以驱动描边与 label 显示。
|
|
142
|
+
- 接受左键按下以发起 port↔port 连线拖拽(优先级高于 node 外环);
|
|
143
|
+
拖拽期间由画布接管 move/release, 本项仅负责起始按下与描边态。
|
|
144
|
+
- ``_active`` 标志: 连线进行中保持起点 port 描边, 不随 hover 离开消失。
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
def __init__(
|
|
148
|
+
self,
|
|
149
|
+
spec: QNodePortSpec,
|
|
150
|
+
side: str,
|
|
151
|
+
node: "QNodeItem",
|
|
152
|
+
parent=None,
|
|
153
|
+
) -> None:
|
|
154
|
+
super().__init__(parent if parent is not None else node)
|
|
155
|
+
self._spec: QNodePortSpec = spec
|
|
156
|
+
self._side: str = side # 'left' | 'right'
|
|
157
|
+
self._node: "QNodeItem" = node
|
|
158
|
+
self._radial: bool = False # 圆形节点: dot 沿弧线径向放置
|
|
159
|
+
self._diamond: bool = False # 菱形节点: dot 旋转贴合斜边法线
|
|
160
|
+
self._orient_angle: float = 0.0 # 径向/斜边放置时的旋转角(度), apex 对准外法线
|
|
161
|
+
self._hovered: bool = False # 鼠标是否进入 dot
|
|
162
|
+
self._show_all: bool = False # 节点级 hover>0.5s 显示全部 label
|
|
163
|
+
self._solo: bool = False # 本端口被 hover 时立即显示 label
|
|
164
|
+
self._active: bool = False # 连线进行中: 保持起点描边(不随 hover 离开消失)
|
|
165
|
+
self._drop_target: bool = False # 连线进行中: 作为落点(目标 port)被高亮
|
|
166
|
+
self._flash_error: bool = False # 不合规连接: 红色描边闪烁中
|
|
167
|
+
self._flash_timer: Optional[QTimer] = None
|
|
168
|
+
|
|
169
|
+
self.setAcceptHoverEvents(True)
|
|
170
|
+
self.setAcceptedMouseButtons(Qt.MouseButton.LeftButton)
|
|
171
|
+
self.setFlag(QGraphicsObject.GraphicsItemFlag.ItemIsSelectable, False)
|
|
172
|
+
self.setFlag(QGraphicsObject.GraphicsItemFlag.ItemIsFocusable, False)
|
|
173
|
+
self.setZValue(90)
|
|
174
|
+
|
|
175
|
+
# hit 文本: 常驻、灰色、字号略小、位于 dot 靠近节点中心一侧
|
|
176
|
+
self._hit_item: Optional[QGraphicsTextItem] = None
|
|
177
|
+
if spec.hit:
|
|
178
|
+
self._hit_item = QGraphicsTextItem(spec.hit, self)
|
|
179
|
+
self._hit_item.setDefaultTextColor(PORT_HIT_COLOR)
|
|
180
|
+
self._hit_item.setAcceptHoverEvents(False)
|
|
181
|
+
self._hit_item.setAcceptedMouseButtons(Qt.MouseButton.NoButton)
|
|
182
|
+
self._hit_item.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction)
|
|
183
|
+
|
|
184
|
+
# label 文本: 默认隐藏、外侧、hover 时显示
|
|
185
|
+
self._label_item: Optional[QGraphicsTextItem] = None
|
|
186
|
+
eff_label = port_effective_label(spec)
|
|
187
|
+
if eff_label:
|
|
188
|
+
self._label_item = QGraphicsTextItem(eff_label, self)
|
|
189
|
+
self._label_item.setDefaultTextColor(PORT_LABEL_COLOR)
|
|
190
|
+
self._label_item.setAcceptHoverEvents(False)
|
|
191
|
+
self._label_item.setAcceptedMouseButtons(Qt.MouseButton.NoButton)
|
|
192
|
+
self._label_item.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction)
|
|
193
|
+
# 初始即处于"消失末态": 隐藏 + 透明 + 收缩, 避免首次显示前闪现
|
|
194
|
+
self._label_item.setVisible(False)
|
|
195
|
+
self._label_item.setOpacity(0.0)
|
|
196
|
+
self._label_item.setScale(_PORT_LABEL_POP_SCALE)
|
|
197
|
+
|
|
198
|
+
# label 出现/消失动画状态(opacity 动画 + scale 动画各持一份引用, 避免 GC)
|
|
199
|
+
self._label_visible: bool = False # label 的逻辑目标可见态
|
|
200
|
+
self._label_show_anim: Optional[QPropertyAnimation] = None
|
|
201
|
+
self._label_hide_anim: Optional[QPropertyAnimation] = None
|
|
202
|
+
self._label_scale_show: Optional[QPropertyAnimation] = None
|
|
203
|
+
self._label_scale_hide: Optional[QPropertyAnimation] = None
|
|
204
|
+
|
|
205
|
+
self.applyNodeFont()
|
|
206
|
+
|
|
207
|
+
# ---- 公共接口 ----
|
|
208
|
+
@property
|
|
209
|
+
def spec(self) -> QNodePortSpec:
|
|
210
|
+
return self._spec
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def node(self) -> "QNodeItem":
|
|
214
|
+
"""端口所属的节点"""
|
|
215
|
+
return self._node
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
def side(self) -> str:
|
|
219
|
+
return self._side
|
|
220
|
+
|
|
221
|
+
def setNodeHoverShowAll(self, on: bool) -> None:
|
|
222
|
+
"""节点级 hover>0.5s: 显示/隐藏本端口 label"""
|
|
223
|
+
self._show_all = on
|
|
224
|
+
self._updateLabelVisible()
|
|
225
|
+
|
|
226
|
+
def applyNodeFont(self) -> None:
|
|
227
|
+
"""根据节点字体刷新 hit/label 字号并重排文本"""
|
|
228
|
+
nf = self._node.font
|
|
229
|
+
ps = nf.pointSize()
|
|
230
|
+
if self._hit_item is not None:
|
|
231
|
+
hit_font = QFont(nf)
|
|
232
|
+
hit_font.setPointSize(max(1, ps - 2) if ps > 0 else 8)
|
|
233
|
+
self._hit_item.setFont(hit_font)
|
|
234
|
+
if self._label_item is not None:
|
|
235
|
+
self._label_item.setFont(QFont(nf))
|
|
236
|
+
self._layoutText()
|
|
237
|
+
|
|
238
|
+
# ---- 几何 ----
|
|
239
|
+
def _is_left(self) -> bool:
|
|
240
|
+
return self._side == "left"
|
|
241
|
+
|
|
242
|
+
def _inward_sign(self) -> int:
|
|
243
|
+
"""dot 路径的朝向(用于绘制): 圆形(radial)/菱形(diamond)统一 +x
|
|
244
|
+
(由旋转对准外法线); 否则 left -> +1, right -> -1"""
|
|
245
|
+
if self._radial or self._diamond:
|
|
246
|
+
return 1
|
|
247
|
+
return 1 if self._is_left() else -1
|
|
248
|
+
|
|
249
|
+
def _text_sign(self) -> int:
|
|
250
|
+
"""文本水平排布的朝向: left -> +1(hit 在右/label 在左), right -> -1"""
|
|
251
|
+
return 1 if self._is_left() else -1
|
|
252
|
+
|
|
253
|
+
def _depth(self) -> float:
|
|
254
|
+
"""dot 向内延伸的深度(横向)"""
|
|
255
|
+
return port_depth(self._spec)
|
|
256
|
+
|
|
257
|
+
def _dot_path(self) -> QPainterPath:
|
|
258
|
+
"""dot 本地路径: 原点位于端口锚点(节点边界), 向内延伸(sign 方向)。
|
|
259
|
+
|
|
260
|
+
外缘贴在边界上, 整个 dot 在节点内侧, 不会超出形状边界。
|
|
261
|
+
圆形(radial)/菱形(diamond)模式下 sign 统一为 +1, 由 paint() 中的
|
|
262
|
+
rotate(_orient_angle) 把 dot 转到对准外法线(底边贴合该处切线/斜边)。
|
|
263
|
+
"""
|
|
264
|
+
s = max(1, self._spec.size)
|
|
265
|
+
half = s / 2.0
|
|
266
|
+
sign = self._inward_sign() # left -> +1(dot 向 +x 内延伸), right -> -1
|
|
267
|
+
path = QPainterPath()
|
|
268
|
+
sh = self._spec.shape
|
|
269
|
+
if sh == "circle":
|
|
270
|
+
# 内侧半圆: 平边(直径)贴在边界 x=0, 向内凸出
|
|
271
|
+
r = half
|
|
272
|
+
n = 24
|
|
273
|
+
path.moveTo(0.0, -r)
|
|
274
|
+
for i in range(1, n + 1):
|
|
275
|
+
ang = -math.pi / 2 + math.pi * i / n
|
|
276
|
+
path.lineTo(sign * r * math.cos(ang), r * math.sin(ang))
|
|
277
|
+
path.closeSubpath()
|
|
278
|
+
elif sh == "square":
|
|
279
|
+
# 整个方块贴边向内
|
|
280
|
+
x0 = 0.0 if sign > 0 else -s
|
|
281
|
+
path.addRect(x0, -half, s, s)
|
|
282
|
+
elif sh == "triangle":
|
|
283
|
+
# 底边贴边界, 顶点向内
|
|
284
|
+
d = half
|
|
285
|
+
path.moveTo(0.0, -half)
|
|
286
|
+
path.lineTo(0.0, half)
|
|
287
|
+
path.lineTo(sign * d, 0.0)
|
|
288
|
+
path.closeSubpath()
|
|
289
|
+
else: # trapezoid: 宽底贴边界, 窄端向内
|
|
290
|
+
d = half
|
|
291
|
+
q = half / 2.0
|
|
292
|
+
path.moveTo(0.0, -half)
|
|
293
|
+
path.lineTo(0.0, half)
|
|
294
|
+
path.lineTo(sign * d, q)
|
|
295
|
+
path.lineTo(sign * d, -q)
|
|
296
|
+
path.closeSubpath()
|
|
297
|
+
return path
|
|
298
|
+
|
|
299
|
+
def boundingRect(self) -> QRectF:
|
|
300
|
+
s = max(1, self._spec.size)
|
|
301
|
+
half = s / 2.0
|
|
302
|
+
m = max(self._spec.outline, 0) + 1
|
|
303
|
+
if self._radial or self._diamond:
|
|
304
|
+
# dot 旋转后, 以原点为中心的最大半径 = hypot(depth, half)
|
|
305
|
+
r = math.hypot(self._depth(), half) + m
|
|
306
|
+
return QRectF(-r, -r, r * 2, r * 2)
|
|
307
|
+
sign = self._inward_sign()
|
|
308
|
+
depth = self._depth()
|
|
309
|
+
xmin = min(0.0, sign * depth) - m
|
|
310
|
+
xmax = max(0.0, sign * depth) + m
|
|
311
|
+
ymin = -half - m
|
|
312
|
+
ymax = half + m
|
|
313
|
+
return QRectF(xmin, ymin, xmax - xmin, ymax - ymin)
|
|
314
|
+
|
|
315
|
+
def shape(self) -> QPainterPath:
|
|
316
|
+
"""命中区域: 覆盖 dot 并保证至少 12px 便于 hover(不影响点击)"""
|
|
317
|
+
s = max(1, self._spec.size)
|
|
318
|
+
depth = self._depth()
|
|
319
|
+
if self._radial or self._diamond:
|
|
320
|
+
r = max(12.0, math.hypot(depth, s / 2.0) + 2.0)
|
|
321
|
+
path = QPainterPath()
|
|
322
|
+
path.addRect(-r, -r, r * 2, r * 2)
|
|
323
|
+
return path
|
|
324
|
+
sign = self._inward_sign()
|
|
325
|
+
w = max(12.0, depth + 4.0)
|
|
326
|
+
h = max(12.0, s + 4.0)
|
|
327
|
+
x0 = 0.0 if sign > 0 else -w
|
|
328
|
+
path = QPainterPath()
|
|
329
|
+
path.addRect(x0, -h / 2.0, w, h)
|
|
330
|
+
return path
|
|
331
|
+
|
|
332
|
+
def isPointOnDot(self, scene_pos: QPointF) -> bool:
|
|
333
|
+
"""场景坐标是否落在 dot 实际几何(非 12px 放大命中区)内。
|
|
334
|
+
|
|
335
|
+
``shape()`` 为方便 hover 故意放大到至少 12px, 会导致节点外环点击
|
|
336
|
+
误命中 port; 本方法用 dot 的真实路径做判定, 供画布区分
|
|
337
|
+
"点击 port dot 本身" 与 "点击节点外环"。
|
|
338
|
+
"""
|
|
339
|
+
local = self.mapFromScene(scene_pos)
|
|
340
|
+
return self._dot_path().contains(local)
|
|
341
|
+
|
|
342
|
+
def _layoutText(self) -> None:
|
|
343
|
+
"""排布 hit(内侧, 节点内)与 label(外侧, 节点外)文本(始终水平)"""
|
|
344
|
+
sign = self._text_sign()
|
|
345
|
+
depth = self._depth()
|
|
346
|
+
if self._hit_item is not None:
|
|
347
|
+
r = self._hit_item.boundingRect()
|
|
348
|
+
# hit 位于 dot 内侧(朝向节点中心), 更贴近 dot
|
|
349
|
+
if sign > 0: # left -> dot 右侧
|
|
350
|
+
x = depth + HIT_GAP
|
|
351
|
+
else: # right -> dot 左侧
|
|
352
|
+
x = -(depth + HIT_GAP) - r.width()
|
|
353
|
+
self._hit_item.setPos(x, -r.height() / 2)
|
|
354
|
+
if self._label_item is not None:
|
|
355
|
+
r = self._label_item.boundingRect()
|
|
356
|
+
# label 位于边界外侧(节点外)
|
|
357
|
+
if sign > 0: # left -> 边界左侧
|
|
358
|
+
x = -LABEL_GAP - r.width()
|
|
359
|
+
else: # right -> 边界右侧
|
|
360
|
+
x = LABEL_GAP
|
|
361
|
+
self._label_item.setPos(x, -r.height() / 2)
|
|
362
|
+
# 文本变化后 boundingRect 改变, 重设缩放锰点保持"原地弹出"中心
|
|
363
|
+
if self._label_visible:
|
|
364
|
+
self._label_item.setTransformOriginPoint(r.center())
|
|
365
|
+
|
|
366
|
+
def _updateLabelVisible(self) -> None:
|
|
367
|
+
"""根据 _solo/_show_all 计算 label 目标可见态, 驱动弹出/收回动画。
|
|
368
|
+
|
|
369
|
+
用 _label_visible 去重: 目标态未变时直接返回, 避免 hover move 等
|
|
370
|
+
高频调用反复重启动画。
|
|
371
|
+
"""
|
|
372
|
+
if self._label_item is None:
|
|
373
|
+
return
|
|
374
|
+
target = self._solo or self._show_all
|
|
375
|
+
if target == self._label_visible:
|
|
376
|
+
return
|
|
377
|
+
if target:
|
|
378
|
+
self._animateLabelShow()
|
|
379
|
+
else:
|
|
380
|
+
self._animateLabelHide()
|
|
381
|
+
|
|
382
|
+
# ---- label 动画(弹出+淡入 / 淡出+收回) ----
|
|
383
|
+
def _animateLabelShow(self) -> None:
|
|
384
|
+
"""显示 label: 弹出(scale -> 1.0, OutBack 回弹) + 淡入(opacity -> 1.0)。
|
|
385
|
+
|
|
386
|
+
缩放以 label 自身中心为锰点(原地弹出, 文本不位移)。
|
|
387
|
+
"""
|
|
388
|
+
if self._label_item is None:
|
|
389
|
+
return
|
|
390
|
+
self._label_visible = True
|
|
391
|
+
self._stopLabelAnims()
|
|
392
|
+
# 缩放锰点 = label 自身中心(文本变化后需重设, 见 _layoutText)
|
|
393
|
+
r = self._label_item.boundingRect()
|
|
394
|
+
self._label_item.setTransformOriginPoint(r.center())
|
|
395
|
+
self._label_item.show()
|
|
396
|
+
|
|
397
|
+
# opacity: 当前值 -> 1.0(线性淡入)
|
|
398
|
+
anim = QPropertyAnimation(self._label_item, b"opacity")
|
|
399
|
+
anim.setDuration(ANIMATION_DURATION_SHOW)
|
|
400
|
+
anim.setStartValue(self._label_item.opacity())
|
|
401
|
+
anim.setEndValue(1.0)
|
|
402
|
+
|
|
403
|
+
# scale: 当前值 -> 1.0(OutBack 轻微回弹 = 弹出感)
|
|
404
|
+
s_anim = QPropertyAnimation(self._label_item, b"scale")
|
|
405
|
+
s_anim.setDuration(ANIMATION_DURATION_SHOW)
|
|
406
|
+
s_anim.setStartValue(max(_PORT_LABEL_POP_SCALE, self._label_item.scale()))
|
|
407
|
+
s_anim.setEndValue(1.0)
|
|
408
|
+
s_anim.setEasingCurve(QEasingCurve.Type.OutBack)
|
|
409
|
+
|
|
410
|
+
self._label_show_anim = anim
|
|
411
|
+
self._label_scale_show = s_anim
|
|
412
|
+
anim.start()
|
|
413
|
+
s_anim.start()
|
|
414
|
+
|
|
415
|
+
def _animateLabelHide(self) -> None:
|
|
416
|
+
"""隐藏 label: 淡出(opacity -> 0.0) + 收回(scale -> 0.85, InQuad)。
|
|
417
|
+
|
|
418
|
+
动画结束后才真正 hide item, 避免中途消失造成视觉跳变。
|
|
419
|
+
"""
|
|
420
|
+
if self._label_item is None:
|
|
421
|
+
return
|
|
422
|
+
self._label_visible = False
|
|
423
|
+
self._stopLabelAnims()
|
|
424
|
+
|
|
425
|
+
# opacity: 当前值 -> 0.0(线性淡出)
|
|
426
|
+
anim = QPropertyAnimation(self._label_item, b"opacity")
|
|
427
|
+
anim.setDuration(ANIMATION_DURATION_HIDE)
|
|
428
|
+
anim.setStartValue(self._label_item.opacity())
|
|
429
|
+
anim.setEndValue(0.0)
|
|
430
|
+
anim.finished.connect(self._onLabelHideFinished)
|
|
431
|
+
|
|
432
|
+
# scale: 当前值 -> 0.85(InQuad 平滑收回)
|
|
433
|
+
s_anim = QPropertyAnimation(self._label_item, b"scale")
|
|
434
|
+
s_anim.setDuration(ANIMATION_DURATION_HIDE)
|
|
435
|
+
s_anim.setStartValue(self._label_item.scale())
|
|
436
|
+
s_anim.setEndValue(_PORT_LABEL_POP_SCALE)
|
|
437
|
+
s_anim.setEasingCurve(QEasingCurve.Type.InQuad)
|
|
438
|
+
|
|
439
|
+
self._label_hide_anim = anim
|
|
440
|
+
self._label_scale_hide = s_anim
|
|
441
|
+
anim.start()
|
|
442
|
+
s_anim.start()
|
|
443
|
+
|
|
444
|
+
def _onLabelHideFinished(self) -> None:
|
|
445
|
+
"""淡出收回完成后, 真正隐藏 item(若期间未又被切回显示)。"""
|
|
446
|
+
if (not self._label_visible) and (self._label_item is not None):
|
|
447
|
+
self._label_item.hide()
|
|
448
|
+
|
|
449
|
+
def _stopLabelAnims(self) -> None:
|
|
450
|
+
"""停止所有进行中的 label 动画(切换方向时调用, 避免动画互相干扰)"""
|
|
451
|
+
for attr in ("_label_show_anim", "_label_hide_anim",
|
|
452
|
+
"_label_scale_show", "_label_scale_hide"):
|
|
453
|
+
a = getattr(self, attr, None)
|
|
454
|
+
if a is not None:
|
|
455
|
+
a.stop()
|
|
456
|
+
|
|
457
|
+
# ---- 绘制 ----
|
|
458
|
+
def _fill_color(self) -> QColor:
|
|
459
|
+
"""dot 填充色: 自定义色优先, 未指定时按所在侧回退到静态默认色"""
|
|
460
|
+
if self._spec.color is not None:
|
|
461
|
+
return self._spec.color
|
|
462
|
+
return QNodePortSpec.LEFT_COLOR if self._is_left() else QNodePortSpec.RIGHT_COLOR
|
|
463
|
+
|
|
464
|
+
def paint(self, painter: QPainter, option, widget=None) -> None:
|
|
465
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
|
466
|
+
fill = self._fill_color()
|
|
467
|
+
# 圆形/菱形节点: 把 dot 旋转到对准外法线(底边贴合该处切线/斜边)
|
|
468
|
+
if self._radial or self._diamond:
|
|
469
|
+
painter.save()
|
|
470
|
+
painter.rotate(self._orient_angle)
|
|
471
|
+
path = self._dot_path()
|
|
472
|
+
outlined = (self._hovered or self._active or self._drop_target
|
|
473
|
+
or self._flash_error)
|
|
474
|
+
if outlined and self._spec.outline > 0:
|
|
475
|
+
ow = self._spec.outline
|
|
476
|
+
# 外描边: 先以 2*ow 宽居中描边(不填), 再用 fill 覆盖路径内部,
|
|
477
|
+
# 只保留路径外侧 ow 宽的描边带 -> 形成纯外侧描边
|
|
478
|
+
# _flash_error 用红色(不合规反馈), 其余用 DodgerBlue
|
|
479
|
+
outline_color = PORT_ERROR_OUTLINE if self._flash_error else PORT_HOVER_OUTLINE
|
|
480
|
+
pen = QPen(outline_color, ow * 2)
|
|
481
|
+
pen.setJoinStyle(Qt.PenJoinStyle.MiterJoin)
|
|
482
|
+
painter.setPen(pen)
|
|
483
|
+
painter.setBrush(Qt.BrushStyle.NoBrush)
|
|
484
|
+
painter.drawPath(path)
|
|
485
|
+
painter.setPen(QPen(Qt.PenStyle.NoPen))
|
|
486
|
+
painter.setBrush(QBrush(fill))
|
|
487
|
+
painter.drawPath(path)
|
|
488
|
+
else:
|
|
489
|
+
painter.setPen(QPen(Qt.PenStyle.NoPen))
|
|
490
|
+
painter.setBrush(QBrush(fill))
|
|
491
|
+
painter.drawPath(path)
|
|
492
|
+
if self._radial or self._diamond:
|
|
493
|
+
painter.restore()
|
|
494
|
+
|
|
495
|
+
# ---- hover ----
|
|
496
|
+
def hoverEnterEvent(self, event) -> None:
|
|
497
|
+
self._hovered = True
|
|
498
|
+
self._solo = True
|
|
499
|
+
self._updateLabelVisible()
|
|
500
|
+
self.update()
|
|
501
|
+
# 通知节点: 悬停 dot 时只展示本 dot, 取消"显示全部"
|
|
502
|
+
self._node._onPortHoverEnter(self)
|
|
503
|
+
super().hoverEnterEvent(event)
|
|
504
|
+
|
|
505
|
+
def hoverLeaveEvent(self, event) -> None:
|
|
506
|
+
self._hovered = False
|
|
507
|
+
self._solo = False
|
|
508
|
+
self._updateLabelVisible()
|
|
509
|
+
self.update()
|
|
510
|
+
self._node._onPortHoverLeave(self)
|
|
511
|
+
super().hoverLeaveEvent(event)
|
|
512
|
+
|
|
513
|
+
def clearHoverVisuals(self) -> None:
|
|
514
|
+
"""清除本端口的 hover 视觉(描边/solo), 由节点在离开时统一调用"""
|
|
515
|
+
if self._hovered or self._solo:
|
|
516
|
+
self._hovered = False
|
|
517
|
+
self._solo = False
|
|
518
|
+
self._updateLabelVisible()
|
|
519
|
+
self.update()
|
|
520
|
+
|
|
521
|
+
# ---- 不合规连接反馈 ----
|
|
522
|
+
def flashError(self) -> None:
|
|
523
|
+
"""红色外描边闪烁(试图创建不合规连接时的反馈)。
|
|
524
|
+
|
|
525
|
+
QTimer 驱动 3 次闪烁(150ms 间隔), 在红色描边与无描边间 toggle。
|
|
526
|
+
"""
|
|
527
|
+
# 停掉正在进行的闪烁, 重新开始
|
|
528
|
+
if self._flash_timer is not None:
|
|
529
|
+
self._flash_timer.stop()
|
|
530
|
+
self._flash_timer.deleteLater()
|
|
531
|
+
self._flash_timer = None
|
|
532
|
+
self._flash_error = True
|
|
533
|
+
self.update()
|
|
534
|
+
|
|
535
|
+
flash_count = [0]
|
|
536
|
+
|
|
537
|
+
def toggleFlash():
|
|
538
|
+
flash_count[0] += 1
|
|
539
|
+
# 奇数次: 显示红色描边(_flash_error=True); 偶数次: 关闭
|
|
540
|
+
self._flash_error = (flash_count[0] % 2 == 0)
|
|
541
|
+
self.update()
|
|
542
|
+
if flash_count[0] >= 6: # 3 次闪烁 = 6 次切换
|
|
543
|
+
self._flash_timer.stop()
|
|
544
|
+
self._flash_timer.deleteLater()
|
|
545
|
+
self._flash_timer = None
|
|
546
|
+
self._flash_error = False
|
|
547
|
+
self.update()
|
|
548
|
+
|
|
549
|
+
self._flash_timer = QTimer()
|
|
550
|
+
self._flash_timer.timeout.connect(toggleFlash)
|
|
551
|
+
self._flash_timer.start(150)
|
|
552
|
+
|
|
553
|
+
# ---- 连线拖拽 ----
|
|
554
|
+
def setActive(self, active: bool) -> None:
|
|
555
|
+
"""标记/取消连线进行中的起点态。
|
|
556
|
+
|
|
557
|
+
置 ``True`` 时, 即使光标已离开本 port, 描边仍保持(复用 hover 描边视觉);
|
|
558
|
+
由画布在连线成功或失败后调用 ``setActive(False)`` 清除。
|
|
559
|
+
"""
|
|
560
|
+
self._active = active
|
|
561
|
+
self.update()
|
|
562
|
+
|
|
563
|
+
def setDropTarget(self, on: bool) -> None:
|
|
564
|
+
"""标记/取消本 port 作为连线落点(目标端)的高亮态。
|
|
565
|
+
|
|
566
|
+
连线拖拽期间画布接管鼠标移动事件, 光标下的目标 port 不会收到
|
|
567
|
+
hoverEnter/hoverLeave(场景 hover 派发被压制), 故由画布在
|
|
568
|
+
mouseMoveEvent 中手动命中检测后调用本方法驱动描边与 label。
|
|
569
|
+
复用 hover 描边视觉(DodgerBlue 外描边), 并立即显示 label。
|
|
570
|
+
"""
|
|
571
|
+
if self._drop_target == on:
|
|
572
|
+
return
|
|
573
|
+
self._drop_target = on
|
|
574
|
+
# 同步 solo 态: 落点高亮时显示 label, 离开时恢复(不覆盖节点级 show_all)
|
|
575
|
+
self._solo = on
|
|
576
|
+
self._updateLabelVisible()
|
|
577
|
+
self.update()
|
|
578
|
+
|
|
579
|
+
def mousePressEvent(self, event) -> None:
|
|
580
|
+
"""左键按下: 从本 port 发起连线拖拽(port 优先于 node 外环)。
|
|
581
|
+
|
|
582
|
+
拖拽期间的 move/release 由画布(NodeCanvas)统一接管, 与 node 外环
|
|
583
|
+
拖拽一致; 此处仅负责起始按下并通知画布。
|
|
584
|
+
"""
|
|
585
|
+
if event.button() == Qt.MouseButton.LeftButton:
|
|
586
|
+
self._active = True
|
|
587
|
+
self.update()
|
|
588
|
+
self._node._onPortDragStart(self, event.scenePos())
|
|
589
|
+
event.accept()
|
|
590
|
+
else:
|
|
591
|
+
super().mousePressEvent(event)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
节点编辑器基类模块
|
|
5
|
+
|
|
6
|
+
提供QNodeEditor基类,用户可以通过继承此类来创建自定义的节点编辑器
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from chartflow.node.node_item import QNodeItem
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
from PyQt6.QtWidgets import QWidget
|
|
13
|
+
from typing import Optional, TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
class QNodeEditor(QWidget):
|
|
16
|
+
"""
|
|
17
|
+
节点编辑器基类
|
|
18
|
+
|
|
19
|
+
用户可以通过继承此类来创建自定义的节点编辑器。
|
|
20
|
+
当节点被双击时,会实例化对应的编辑器,并将节点作为参数传入。
|
|
21
|
+
|
|
22
|
+
Example:
|
|
23
|
+
class MyNodeEditor(QNodeEditor):
|
|
24
|
+
def __init__(self, node: NodeItem, parent=None):
|
|
25
|
+
super().__init__(node, parent)
|
|
26
|
+
# 自定义初始化
|
|
27
|
+
|
|
28
|
+
def getTitle(self) -> str:
|
|
29
|
+
return f"Editing: {self.node.name}"
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, node: 'QNodeItem', parent: Optional[QWidget] = None) -> None:
|
|
33
|
+
"""
|
|
34
|
+
初始化节点编辑器
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
node: 要编辑的节点
|
|
38
|
+
parent: 父窗口
|
|
39
|
+
"""
|
|
40
|
+
super().__init__(parent)
|
|
41
|
+
self._node = node
|
|
42
|
+
self._setupUi()
|
|
43
|
+
|
|
44
|
+
def _setupUi(self) -> None:
|
|
45
|
+
"""设置UI界面,子类可以重写此方法"""
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def node(self) -> 'QNodeItem':
|
|
50
|
+
"""获取当前编辑的节点"""
|
|
51
|
+
return self._node
|
|
52
|
+
|
|
53
|
+
def getTitle(self) -> str:
|
|
54
|
+
"""获取编辑器窗口标题"""
|
|
55
|
+
return f"Node Editor - {self._node.name}"
|
|
56
|
+
|
|
57
|
+
def save(self) -> bool:
|
|
58
|
+
"""
|
|
59
|
+
保存编辑器内容到节点
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
True if save successful, False otherwise
|
|
63
|
+
"""
|
|
64
|
+
return True
|
|
65
|
+
|
|
66
|
+
def load(self) -> bool:
|
|
67
|
+
"""
|
|
68
|
+
从节点加载内容到编辑器
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
True if load successful, False otherwise
|
|
72
|
+
"""
|
|
73
|
+
return True
|