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,536 @@
|
|
|
1
|
+
"""
|
|
2
|
+
节点端口 Mixin 模块
|
|
3
|
+
|
|
4
|
+
定义 _NodePortMixin —— 端口(port)相关的全部行为。
|
|
5
|
+
本类不含 __init__, 通过覆写 _NodeCore._init_ports 钩子创建端口
|
|
6
|
+
label 计时器; 并提供 ports 属性、端口布局、端口 hover、端口字体刷新等。
|
|
7
|
+
|
|
8
|
+
依赖关系: _NodePortMixin 不继承 _NodeCore(避免循环导入与 MRO 问题);
|
|
9
|
+
运行时由 NodeItem(_NodePortMixin, _NodeInteractionMixin, _NodeCore) 组合,
|
|
10
|
+
所有 self.xxx 调用经 MRO 在 _NodeCore/NodeItem 上解析。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import math
|
|
14
|
+
from typing import Optional, Dict
|
|
15
|
+
|
|
16
|
+
from PyQt6.QtCore import Qt, QPointF, QRectF, QTimer
|
|
17
|
+
|
|
18
|
+
from chartflow.node.enums import NodeShape
|
|
19
|
+
from chartflow.node.port_item import (
|
|
20
|
+
QNodePortSpec,
|
|
21
|
+
QNodePort,
|
|
22
|
+
VALID_PORT_SHAPES,
|
|
23
|
+
PORT_LABEL_HOVER_DELAY_MS,
|
|
24
|
+
HIT_GAP,
|
|
25
|
+
port_from_dict,
|
|
26
|
+
port_to_dict,
|
|
27
|
+
port_depth,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# 端口布局相关常量(与 _node_base 保持一致; 此处单独导入以防散落)
|
|
31
|
+
from chartflow.node._node_base import (
|
|
32
|
+
PORT_MIN_GAP,
|
|
33
|
+
CIRCLE_PORT_WINDOW_DEG,
|
|
34
|
+
CIRCLE_PORT_MAX_STEP_DEG,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _NodePortMixin:
|
|
39
|
+
"""端口 Mixin
|
|
40
|
+
|
|
41
|
+
提供 ports 属性(get/set)、端口规范化、端口渲染项的构建/移除/布局
|
|
42
|
+
(矩形/菱形轴向排布、圆形径向排布)、端口 hover(label 显示全部/solo)、
|
|
43
|
+
端口字体刷新, 以及供 _NodeCore._calculateHalfSize 使用的几何辅助计算
|
|
44
|
+
(_hasPorts/_halfHeightForPorts/_portInwardExtent/_circlePortRadius)。
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
# ------------------------------------------------------------------ #
|
|
48
|
+
# 初始化钩子(覆写 _NodeCore._init_ports)
|
|
49
|
+
# ------------------------------------------------------------------ #
|
|
50
|
+
def _init_ports(self) -> None:
|
|
51
|
+
"""创建端口 label 计时器(节点 hover 超过阈值后显示全部端口 label)"""
|
|
52
|
+
# 端口 spec/items 在 _NodeCore.__init__ 中已初始化, 此处只补 timer
|
|
53
|
+
self._port_label_timer: QTimer = QTimer()
|
|
54
|
+
self._port_label_timer.setSingleShot(True)
|
|
55
|
+
self._port_label_timer.setInterval(PORT_LABEL_HOVER_DELAY_MS)
|
|
56
|
+
self._port_label_timer.timeout.connect(self._showAllPortLabels)
|
|
57
|
+
|
|
58
|
+
# ------------------------------------------------------------------ #
|
|
59
|
+
# ports 属性
|
|
60
|
+
# ------------------------------------------------------------------ #
|
|
61
|
+
@property
|
|
62
|
+
def ports(self) -> Dict[str, list]:
|
|
63
|
+
"""端口配置: {"left": [QNodePortSpec...], "right": [QNodePortSpec...]}
|
|
64
|
+
|
|
65
|
+
仅识别 'left' 与 'right' 两个键。可赋值为同类字典以重建端口;
|
|
66
|
+
列表元素可为 QNodePortSpec 或 dict(自动转换为 QNodePortSpec)。
|
|
67
|
+
"""
|
|
68
|
+
return self._ports_spec
|
|
69
|
+
|
|
70
|
+
@ports.setter
|
|
71
|
+
def ports(self, value: Optional[Dict[str, list]]) -> None:
|
|
72
|
+
self._ports_spec = self._normalize_ports(value)
|
|
73
|
+
self._rebuildPortItems()
|
|
74
|
+
|
|
75
|
+
def setPorts(self, value: Optional[Dict[str, list]]) -> None:
|
|
76
|
+
"""设置端口(等价于直接赋值 ``node.ports = value``)"""
|
|
77
|
+
self.ports = value
|
|
78
|
+
|
|
79
|
+
def _normalize_ports(self, value) -> Dict[str, list]:
|
|
80
|
+
"""校验并规范化端口配置"""
|
|
81
|
+
result: Dict[str, list] = {"left": [], "right": []}
|
|
82
|
+
if value is None:
|
|
83
|
+
return result
|
|
84
|
+
if not isinstance(value, dict):
|
|
85
|
+
raise TypeError(f"ports 必须是 dict,得到 {type(value).__name__}")
|
|
86
|
+
|
|
87
|
+
for key in ("left", "right"):
|
|
88
|
+
raw = value.get(key, [])
|
|
89
|
+
if raw is None:
|
|
90
|
+
raw = []
|
|
91
|
+
if not isinstance(raw, (list, tuple)):
|
|
92
|
+
raise TypeError(f"ports['{key}'] 必须是列表,得到 {type(raw).__name__}")
|
|
93
|
+
ports: list = []
|
|
94
|
+
seen_names: set = set() # 本侧已出现的非空 name(用于查重)
|
|
95
|
+
for p in raw:
|
|
96
|
+
if isinstance(p, QNodePortSpec):
|
|
97
|
+
port = p
|
|
98
|
+
elif isinstance(p, dict):
|
|
99
|
+
port = port_from_dict(p)
|
|
100
|
+
else:
|
|
101
|
+
raise TypeError(
|
|
102
|
+
f"端口项必须是 QNodePortSpec 或 dict,得到 {type(p).__name__}"
|
|
103
|
+
)
|
|
104
|
+
if port.shape not in VALID_PORT_SHAPES:
|
|
105
|
+
raise ValueError(
|
|
106
|
+
f"QNodePortSpec.shape 必须是 {VALID_PORT_SHAPES} 之一,"
|
|
107
|
+
f"得到: {port.shape!r}"
|
|
108
|
+
)
|
|
109
|
+
if not port.name and port.label is None:
|
|
110
|
+
# name 与 label 均空时无法显示任何文本,但仍然允许(仅 dot)
|
|
111
|
+
pass
|
|
112
|
+
# 同侧 name 唯一性: 非空 name 不得在同侧重复(用作连线的稳定标识)。
|
|
113
|
+
# 空 name 的端口可多个(仅 dot, 无法被连线按 name 引用)。
|
|
114
|
+
if port.name:
|
|
115
|
+
if port.name in seen_names:
|
|
116
|
+
raise ValueError(
|
|
117
|
+
f"同侧({key})端口 name 重复: {port.name!r};"
|
|
118
|
+
f"同一侧的端口 name 必须唯一"
|
|
119
|
+
)
|
|
120
|
+
seen_names.add(port.name)
|
|
121
|
+
ports.append(port)
|
|
122
|
+
result[key] = ports
|
|
123
|
+
return result
|
|
124
|
+
|
|
125
|
+
# ------------------------------------------------------------------ #
|
|
126
|
+
# 端口渲染项构建/移除
|
|
127
|
+
# ------------------------------------------------------------------ #
|
|
128
|
+
def _rebuildPortItems(self) -> None:
|
|
129
|
+
"""根据 _ports_spec 重建端口渲染项(端口变化时重算节点尺寸)"""
|
|
130
|
+
self._removePortItems()
|
|
131
|
+
self._hovered_port_count = 0
|
|
132
|
+
# 端口变化可能改变所需尺寸(矩形+端口 -> 长宽可不等); 此时端口项已清空,
|
|
133
|
+
# 仅更新矩形, 待重建端口项后再 _layoutPorts
|
|
134
|
+
if not getattr(self, "_custom_radius", None):
|
|
135
|
+
rw, rh = self._calculateHalfSize()
|
|
136
|
+
if (rw, rh) != (self._rw, self._rh):
|
|
137
|
+
self._setSize(rw, rh)
|
|
138
|
+
for side in ("left", "right"):
|
|
139
|
+
for spec in self._ports_spec.get(side, []):
|
|
140
|
+
item = QNodePort(spec, side, self)
|
|
141
|
+
self._port_items[side].append(item)
|
|
142
|
+
self._layoutPorts()
|
|
143
|
+
|
|
144
|
+
def _removePortItems(self) -> None:
|
|
145
|
+
"""移除全部端口渲染项"""
|
|
146
|
+
for side in ("left", "right"):
|
|
147
|
+
for item in self._port_items[side]:
|
|
148
|
+
try:
|
|
149
|
+
scene = item.scene()
|
|
150
|
+
if scene is not None:
|
|
151
|
+
scene.removeItem(item)
|
|
152
|
+
else:
|
|
153
|
+
item.setParentItem(None)
|
|
154
|
+
except RuntimeError:
|
|
155
|
+
pass
|
|
156
|
+
self._port_items[side] = []
|
|
157
|
+
|
|
158
|
+
# ------------------------------------------------------------------ #
|
|
159
|
+
# 端口布局
|
|
160
|
+
# ------------------------------------------------------------------ #
|
|
161
|
+
def _layoutPorts(self) -> None:
|
|
162
|
+
"""分布端口: 圆形沿侧弧径向; 菱形沿左/右斜边贴合排布; 矩形沿边缘轴向。"""
|
|
163
|
+
is_circle = self._shape == NodeShape.ELLIPSE
|
|
164
|
+
is_diamond = self._shape == NodeShape.DIAMOND
|
|
165
|
+
for side in ("left", "right"):
|
|
166
|
+
for it in self._port_items[side]:
|
|
167
|
+
# 互斥的排布模式: _radial(圆形) / _diamond(菱形) / 皆 False(矩形)
|
|
168
|
+
if it._radial != is_circle:
|
|
169
|
+
it._radial = is_circle
|
|
170
|
+
it.update()
|
|
171
|
+
if it._diamond != is_diamond:
|
|
172
|
+
it._diamond = is_diamond
|
|
173
|
+
it.update()
|
|
174
|
+
if is_circle:
|
|
175
|
+
self._layoutPortsOnCircle()
|
|
176
|
+
elif is_diamond:
|
|
177
|
+
self._layoutPortsOnDiamond()
|
|
178
|
+
else:
|
|
179
|
+
self._layoutPortsAxisAligned()
|
|
180
|
+
|
|
181
|
+
def _layoutPortsAxisAligned(self) -> None:
|
|
182
|
+
"""矩形/菱形: 紧凑排列(PORT_MIN_GAP 间距), 围绕 y=0 居中, dot 外缘贴边界、向内。"""
|
|
183
|
+
rect = self.rect()
|
|
184
|
+
cy = rect.center().y()
|
|
185
|
+
for side in ("left", "right"):
|
|
186
|
+
items = self._port_items[side]
|
|
187
|
+
n = len(items)
|
|
188
|
+
if n == 0:
|
|
189
|
+
continue
|
|
190
|
+
sizes = [max(1, it.spec.size) for it in items]
|
|
191
|
+
total = sum(sizes) + (n - 1) * PORT_MIN_GAP
|
|
192
|
+
running = -total / 2.0 # 整组居中于 y=0
|
|
193
|
+
for it, s in zip(items, sizes):
|
|
194
|
+
y = running + s / 2.0
|
|
195
|
+
running += s + PORT_MIN_GAP
|
|
196
|
+
ax = self._portAnchorX(side, y, s)
|
|
197
|
+
it.setPos(QPointF(ax, cy + y))
|
|
198
|
+
|
|
199
|
+
def _layoutPortsOnCircle(self) -> None:
|
|
200
|
+
"""圆形: 端口分布在左右侧弧(CIRCLE_PORT_WINDOW_DEG 窗口, 避开极点),
|
|
201
|
+
dot 放置在圆上并对准圆心(底边贴合该处切线)。"""
|
|
202
|
+
rect = self.rect()
|
|
203
|
+
cx, cy = rect.center().x(), rect.center().y()
|
|
204
|
+
R = rect.width() / 2.0
|
|
205
|
+
W = CIRCLE_PORT_WINDOW_DEG
|
|
206
|
+
max_step = CIRCLE_PORT_MAX_STEP_DEG
|
|
207
|
+
for side in ("left", "right"):
|
|
208
|
+
items = self._port_items[side]
|
|
209
|
+
n = len(items)
|
|
210
|
+
if n == 0:
|
|
211
|
+
continue
|
|
212
|
+
center_deg = 180.0 if side == "left" else 0.0
|
|
213
|
+
if n == 1:
|
|
214
|
+
offs = [0.0]
|
|
215
|
+
else:
|
|
216
|
+
step = min(max_step, (2.0 * W) / (n - 1))
|
|
217
|
+
offs = [(i - (n - 1) / 2.0) * step for i in range(n)]
|
|
218
|
+
for it, off in zip(items, offs):
|
|
219
|
+
th = math.radians(center_deg + off)
|
|
220
|
+
x = cx + R * math.cos(th)
|
|
221
|
+
y = cy + R * math.sin(th)
|
|
222
|
+
it._orient_angle = center_deg + off + 180.0
|
|
223
|
+
it.setPos(QPointF(x, y))
|
|
224
|
+
it.update()
|
|
225
|
+
|
|
226
|
+
def _layoutPortsOnDiamond(self) -> None:
|
|
227
|
+
"""菱形: 端口沿左/右两条斜边贴合排布, dot 旋转使底边贴合斜边、
|
|
228
|
+
apex 沿斜边内法线(指向节点内部), 出射方向沿斜边外法线。
|
|
229
|
+
|
|
230
|
+
每侧(right/left)对应两条斜边(上、下), 总弧长 2L(L=单条斜边长)。
|
|
231
|
+
端口沿弧长以 PORT_MIN_GAP 紧凑排布, 整组围绕侧顶点(右顶点/左顶点,
|
|
232
|
+
即赤道)居中。**所有四个顶点(top/side/bottom 及对侧顶点)附近都预留
|
|
233
|
+
顶点间隙**, 故端口自然落在斜边中段, 不压在任何菱角上。
|
|
234
|
+
|
|
235
|
+
注意: 菱形斜边的内法线是**该斜边的常量法线**(垂直于斜边), 而非"指向
|
|
236
|
+
圆心"的方向 —— 二者仅在斜边中点重合。dot 底边须平行于斜边, 故
|
|
237
|
+
_orient_angle 必须取斜边内法线方向(使旋转后底边贴合斜边)。
|
|
238
|
+
"""
|
|
239
|
+
rect = self.rect()
|
|
240
|
+
cx, cy = rect.center().x(), rect.center().y()
|
|
241
|
+
hw = rect.width() / 2.0
|
|
242
|
+
hh = rect.height() / 2.0
|
|
243
|
+
if hw <= 0 or hh <= 0:
|
|
244
|
+
return
|
|
245
|
+
L = math.hypot(hw, hh) # 单条斜边长
|
|
246
|
+
# 斜边内法线(指向节点内部, 垂直于斜边)。菱形四条斜边, 内法线方向固定。
|
|
247
|
+
# 右上(TR: top->right) 斜边方向 (hw, hh), 内法线 (-hh, hw)
|
|
248
|
+
# 右下(RB: right->bottom) 方向 (-hw, hh), 内法线 (-hh, -hw)
|
|
249
|
+
# 左下(BL: bottom->left) 方向 (-hw, -hh), 内法线 (hh, -hw)
|
|
250
|
+
# 左上(LT: left->top) 方向 (hw, -hh), 内法线 (hh, hw)
|
|
251
|
+
def inward_normal(ix, iy):
|
|
252
|
+
nl = math.hypot(ix, iy)
|
|
253
|
+
return (ix / nl, iy / nl) if nl > 1e-6 else (0.0, 0.0)
|
|
254
|
+
|
|
255
|
+
NORM_TR = inward_normal(-hh, hw) # 右上斜边内法线
|
|
256
|
+
NORM_RB = inward_normal(-hh, -hw) # 右下斜边内法线
|
|
257
|
+
NORM_BL = inward_normal(hh, -hw) # 左下斜边内法线
|
|
258
|
+
NORM_LT = inward_normal(hh, hw) # 左上斜边内法线
|
|
259
|
+
|
|
260
|
+
# 四个顶点处的弧长预留(像素, 沿弧长方向): 避免端口压在任何菱角上。
|
|
261
|
+
# top/bottom 顶点处一条斜边交汇, side 顶点处两条斜边交汇, 均需避让。
|
|
262
|
+
vertex_gap = self._diamondVertexGap()
|
|
263
|
+
# 弧长参数 s∈[0,2L]: top=0, 侧顶点=L, bottom=2L。
|
|
264
|
+
# 每条斜边的中段 = [vertex_gap, L-vertex_gap](上) / [L+vertex_gap, 2L-vertex_gap](下)。
|
|
265
|
+
# 端口按"上半斜边优先填满"分配到上下两条斜边: 前 n_up 个走上半,
|
|
266
|
+
# 其余走下半。每条斜边内的端口围绕一个**偏向侧顶点的逻辑中点**居中
|
|
267
|
+
# (t=bias, bias∈(0.5,1)): 既避开四个顶点, 又贴近左右两侧(符合"左右两侧"
|
|
268
|
+
# 构想), 不会像物理中点那样分散到斜边正中。
|
|
269
|
+
bias = self._diamondSideBias() # 逻辑中点在斜边上的参数(0=远端顶点, 1=侧顶点)
|
|
270
|
+
for side in ("left", "right"):
|
|
271
|
+
items = self._port_items[side]
|
|
272
|
+
n = len(items)
|
|
273
|
+
if n == 0:
|
|
274
|
+
continue
|
|
275
|
+
sizes = [max(1, it.spec.size) for it in items]
|
|
276
|
+
seg_len = max(0.0, L - 2.0 * vertex_gap)
|
|
277
|
+
# 上半斜边端口数: ceil(n/2); 下半: floor(n/2)
|
|
278
|
+
n_up = (n + 1) // 2
|
|
279
|
+
n_lo = n - n_up
|
|
280
|
+
# 每段内紧凑排布(间距 PORT_MIN_GAP, 超出则压缩); 围绕"逻辑中点"居中。
|
|
281
|
+
# upper 段: t 沿 [0,1] 从 top 顶点(vertex_gap) 到侧顶点(L-vertex_gap),
|
|
282
|
+
# 逻辑中点 t=bias -> 段内偏移 bias*seg_len。
|
|
283
|
+
# lower 段: t 沿 [0,1] 从侧顶点(L+vertex_gap) 到 bottom(2L-vertex_gap),
|
|
284
|
+
# 逻辑中点(靠近侧顶点) t=1-bias -> 段内偏移 (1-bias)*seg_len。
|
|
285
|
+
def seg_positions(count: int, seg_sizes: list, center_off: float) -> list:
|
|
286
|
+
# 返回该段内各端口中心相对于段起点(vertex_gap)的偏移
|
|
287
|
+
if count == 0:
|
|
288
|
+
return []
|
|
289
|
+
span = sum(seg_sizes) + (count - 1) * PORT_MIN_GAP
|
|
290
|
+
if span > seg_len and count > 1:
|
|
291
|
+
g = max(0.0, (seg_len - sum(seg_sizes)) / (count - 1))
|
|
292
|
+
else:
|
|
293
|
+
g = PORT_MIN_GAP
|
|
294
|
+
span = sum(seg_sizes) + (count - 1) * g
|
|
295
|
+
base = center_off - span / 2.0 # 围绕逻辑中点居中
|
|
296
|
+
offs = []
|
|
297
|
+
acc = base
|
|
298
|
+
for sz in seg_sizes:
|
|
299
|
+
offs.append(acc + sz / 2.0)
|
|
300
|
+
acc += sz + g
|
|
301
|
+
return offs
|
|
302
|
+
|
|
303
|
+
up_offs = seg_positions(n_up, sizes[:n_up], bias * seg_len)
|
|
304
|
+
lo_offs = seg_positions(n_lo, sizes[n_up:], (1.0 - bias) * seg_len)
|
|
305
|
+
# 组装每个端口的物理弧长 s, 并钳制到该斜边中段内(双保险: 避开顶点)
|
|
306
|
+
s_values = []
|
|
307
|
+
for off in up_offs:
|
|
308
|
+
off_c = max(0.0, min(seg_len, off))
|
|
309
|
+
s_values.append(vertex_gap + off_c) # 上半中段内
|
|
310
|
+
for off in lo_offs:
|
|
311
|
+
off_c = max(0.0, min(seg_len, off))
|
|
312
|
+
s_values.append(L + vertex_gap + off_c) # 下半中段内
|
|
313
|
+
for it, s in zip(items, s_values):
|
|
314
|
+
# s ∈ [0, 2L]; 上半斜边 s∈[0,L](top→侧顶点), 下半 s∈[L,2L](侧顶点→bottom)
|
|
315
|
+
if side == "right":
|
|
316
|
+
if s <= L:
|
|
317
|
+
# 上半(TR): top(0,-hh) -> right(+hw,0), t=s/L
|
|
318
|
+
t = max(0.0, min(1.0, s / L))
|
|
319
|
+
px = cx + hw * t
|
|
320
|
+
py = cy + (-hh) + hh * t
|
|
321
|
+
nx, ny = NORM_TR
|
|
322
|
+
else:
|
|
323
|
+
# 下半(RB): right(+hw,0) -> bottom(0,+hh), t=(s-L)/L
|
|
324
|
+
t = max(0.0, min(1.0, (s - L) / L))
|
|
325
|
+
px = cx + hw + (-hw) * t
|
|
326
|
+
py = cy + hh * t
|
|
327
|
+
nx, ny = NORM_RB
|
|
328
|
+
else: # left
|
|
329
|
+
if s <= L:
|
|
330
|
+
# 上半(LT): top(0,-hh) -> left(-hw,0), t=s/L
|
|
331
|
+
t = max(0.0, min(1.0, s / L))
|
|
332
|
+
px = cx + (-hw) * t
|
|
333
|
+
py = cy + (-hh) + hh * t
|
|
334
|
+
nx, ny = NORM_LT
|
|
335
|
+
else:
|
|
336
|
+
# 下半(BL): left(-hw,0) -> bottom(0,+hh), t=(s-L)/L
|
|
337
|
+
t = max(0.0, min(1.0, (s - L) / L))
|
|
338
|
+
px = cx + (-hw) + hw * t
|
|
339
|
+
py = cy + hh * t
|
|
340
|
+
nx, ny = NORM_BL
|
|
341
|
+
it._orient_angle = math.degrees(math.atan2(ny, nx))
|
|
342
|
+
it.setPos(QPointF(px, py))
|
|
343
|
+
it.update()
|
|
344
|
+
|
|
345
|
+
def _diamondSideBias(self) -> float:
|
|
346
|
+
"""菱形每条斜边上端口组的"逻辑中点"参数(0=远端顶点, 1=侧顶点)。
|
|
347
|
+
|
|
348
|
+
取 0.7: 端口组偏向侧顶点(左右两角), 体现"左右两侧"的视觉聚类,
|
|
349
|
+
同时仍处于斜边中段(避开顶点预留区), 不会压在任何菱角上。
|
|
350
|
+
"""
|
|
351
|
+
return 0.7
|
|
352
|
+
|
|
353
|
+
def _diamondVertexGap(self) -> float:
|
|
354
|
+
"""菱形每个顶点处沿弧长预留的间隙(像素)。
|
|
355
|
+
|
|
356
|
+
四个顶点(top/底/左/右)处斜边交汇, 法线方向不连续, 端口压在顶点上
|
|
357
|
+
视觉突兀。预留固定像素间隙使端口落在斜边中段。取 10px: 对常见尺寸
|
|
358
|
+
的菱形足以避开顶点, 又不过度压缩可用弧长。
|
|
359
|
+
"""
|
|
360
|
+
return 10.0
|
|
361
|
+
|
|
362
|
+
def _portVerticalMargin(self) -> float:
|
|
363
|
+
"""端口纵向分布时避开上下圆角/尖端的边距(相对中心 y=0)"""
|
|
364
|
+
rect = self.rect()
|
|
365
|
+
half_h = rect.height() / 2.0
|
|
366
|
+
if self._shape == NodeShape.RECTANGLE:
|
|
367
|
+
corner = min(rect.width(), rect.height()) * 0.2
|
|
368
|
+
return corner + 4.0
|
|
369
|
+
if self._shape == NodeShape.DIAMOND:
|
|
370
|
+
return half_h * 0.30
|
|
371
|
+
# ELLIPSE
|
|
372
|
+
return half_h * 0.22
|
|
373
|
+
|
|
374
|
+
def _portAnchorX(self, side: str, y: float, dot_h: float) -> float:
|
|
375
|
+
"""端口锚点 x(本地坐标): dot 外缘贴在节点当前形状边界上, 向内延伸。
|
|
376
|
+
|
|
377
|
+
用 dot 纵向跨度最外侧的 y(更靠近上下边界)计算边界 x, 保证整个 dot
|
|
378
|
+
纵向跨度都不超出凸形状边界。
|
|
379
|
+
"""
|
|
380
|
+
rect = self.rect()
|
|
381
|
+
half_w = rect.width() / 2.0
|
|
382
|
+
half_h = rect.height() / 2.0
|
|
383
|
+
y_tip = min(abs(y) + dot_h / 2.0, max(0.0, half_h - 1.0))
|
|
384
|
+
if self._shape == NodeShape.RECTANGLE:
|
|
385
|
+
mag = half_w
|
|
386
|
+
elif self._shape == NodeShape.DIAMOND:
|
|
387
|
+
mag = half_w * max(0.0, 1.0 - y_tip / half_h) if half_h > 0 else 0.0
|
|
388
|
+
else: # ELLIPSE
|
|
389
|
+
ratio = 1.0 - (y_tip / half_h) ** 2 if half_h > 0 else 1.0
|
|
390
|
+
mag = half_w * math.sqrt(max(0.0, ratio))
|
|
391
|
+
return -mag if side == "left" else mag
|
|
392
|
+
|
|
393
|
+
# ------------------------------------------------------------------ #
|
|
394
|
+
# 端口字体刷新
|
|
395
|
+
# ------------------------------------------------------------------ #
|
|
396
|
+
def _refreshPortFonts(self) -> None:
|
|
397
|
+
"""节点字体变化后刷新端口文本字号"""
|
|
398
|
+
for side in ("left", "right"):
|
|
399
|
+
for item in self._port_items[side]:
|
|
400
|
+
item.applyNodeFont()
|
|
401
|
+
self._layoutPorts()
|
|
402
|
+
|
|
403
|
+
# ------------------------------------------------------------------ #
|
|
404
|
+
# 端口 hover(label 显示全部 / solo)
|
|
405
|
+
# ------------------------------------------------------------------ #
|
|
406
|
+
def _setAllPortsShowAll(self, on: bool) -> None:
|
|
407
|
+
for side in ("left", "right"):
|
|
408
|
+
for item in self._port_items[side]:
|
|
409
|
+
item.setNodeHoverShowAll(on)
|
|
410
|
+
|
|
411
|
+
def _showAllPortLabels(self) -> None:
|
|
412
|
+
"""节点 hover 计时到期: 显示全部端口 label(仅当未悬停某个 dot 时)"""
|
|
413
|
+
if self._hovered_port_count == 0:
|
|
414
|
+
self._setAllPortsShowAll(True)
|
|
415
|
+
|
|
416
|
+
def _maybeStartPortShowAll(self) -> None:
|
|
417
|
+
"""仅当悬停节点本体(非 dot)时, 才计时显示全部端口 label"""
|
|
418
|
+
if self._node_hovered and self._hovered_port_count == 0:
|
|
419
|
+
self._port_label_timer.start()
|
|
420
|
+
else:
|
|
421
|
+
self._port_label_timer.stop()
|
|
422
|
+
|
|
423
|
+
def _onPortHoverEnter(self, port) -> None:
|
|
424
|
+
"""某个 dot 被悬停: 取消'显示全部', 仅保留该 dot(solo)"""
|
|
425
|
+
self._hovered_port_count += 1
|
|
426
|
+
self._port_label_timer.stop()
|
|
427
|
+
self._setAllPortsShowAll(False)
|
|
428
|
+
|
|
429
|
+
def _onPortHoverLeave(self, port) -> None:
|
|
430
|
+
"""离开某个 dot: 若回到节点本体则恢复'显示全部'计时"""
|
|
431
|
+
self._hovered_port_count = max(0, self._hovered_port_count - 1)
|
|
432
|
+
self._hover_anchor = None # 回到本体后重新建立静止锚点
|
|
433
|
+
self._maybeStartPortShowAll()
|
|
434
|
+
|
|
435
|
+
def _resetPortHover(self) -> None:
|
|
436
|
+
"""重置全部端口的 hover 视觉(离开节点时调用)"""
|
|
437
|
+
self._hovered_port_count = 0
|
|
438
|
+
self._setAllPortsShowAll(False)
|
|
439
|
+
for side in ("left", "right"):
|
|
440
|
+
for item in self._port_items[side]:
|
|
441
|
+
item.clearHoverVisuals()
|
|
442
|
+
|
|
443
|
+
# ------------------------------------------------------------------ #
|
|
444
|
+
# port 连线拖拽(转发到画布)
|
|
445
|
+
# ------------------------------------------------------------------ #
|
|
446
|
+
def _findCanvas(self):
|
|
447
|
+
"""定位本节点所属的 NodeCanvas(通过 scene().views() 查找)。
|
|
448
|
+
|
|
449
|
+
连线只存在于画布, 节点/port 本身不持有; 需要 canvas 时调用本方法。
|
|
450
|
+
返回 ``None`` 表示节点尚未加入场景或所在视图非 NodeCanvas。
|
|
451
|
+
"""
|
|
452
|
+
from chartflow.node.node_canvas import QNodeCanvas # deferred: 避免循环导入
|
|
453
|
+
scene = self.scene()
|
|
454
|
+
if scene is None:
|
|
455
|
+
return None
|
|
456
|
+
for view in scene.views():
|
|
457
|
+
if isinstance(view, QNodeCanvas):
|
|
458
|
+
return view
|
|
459
|
+
return None
|
|
460
|
+
|
|
461
|
+
def _onPortDragStart(self, port_graphic, scene_pos) -> None:
|
|
462
|
+
"""某个 dot 被左键按下: 转发到画布开始 port 连线拖拽。
|
|
463
|
+
|
|
464
|
+
Args:
|
|
465
|
+
port_graphic: 被按下的 ``QNodePort``(起点端点)。
|
|
466
|
+
scene_pos: 按下位置(场景坐标)。
|
|
467
|
+
"""
|
|
468
|
+
canvas = self._findCanvas()
|
|
469
|
+
if canvas is not None:
|
|
470
|
+
canvas._onPortConnectionDragStart(port_graphic, scene_pos)
|
|
471
|
+
|
|
472
|
+
# ------------------------------------------------------------------ #
|
|
473
|
+
# 端口几何辅助(供 _NodeCore._calculateHalfSize 使用)
|
|
474
|
+
# ------------------------------------------------------------------ #
|
|
475
|
+
def _hasPorts(self) -> bool:
|
|
476
|
+
"""是否配置了任意端口"""
|
|
477
|
+
return bool(self._ports_spec.get("left")) or bool(self._ports_spec.get("right"))
|
|
478
|
+
|
|
479
|
+
def _halfHeightForPorts(self, rw: float) -> float:
|
|
480
|
+
"""根据端口最密集的一侧计算所需半高(矩形)。
|
|
481
|
+
|
|
482
|
+
端口紧凑排列, 间距 PORT_MIN_GAP, 并避开圆角区域(margin = corner + 4)。
|
|
483
|
+
"""
|
|
484
|
+
max_total = 0.0
|
|
485
|
+
for side in ("left", "right"):
|
|
486
|
+
sizes = [max(1, p.size) for p in self._ports_spec.get(side, [])]
|
|
487
|
+
if not sizes:
|
|
488
|
+
continue
|
|
489
|
+
total = sum(sizes) + (len(sizes) - 1) * PORT_MIN_GAP
|
|
490
|
+
if total > max_total:
|
|
491
|
+
max_total = total
|
|
492
|
+
if max_total <= 0:
|
|
493
|
+
return 0.0
|
|
494
|
+
# 迭代求解 rh >= max_total/2 + corner + 4, corner = 0.4*min(rw,rh)
|
|
495
|
+
rh = max_total / 2.0 + 4.0
|
|
496
|
+
for _ in range(4):
|
|
497
|
+
corner = 0.4 * min(rw, rh)
|
|
498
|
+
rh = max_total / 2.0 + corner + 4.0
|
|
499
|
+
return rh
|
|
500
|
+
|
|
501
|
+
def _portInwardExtent(self) -> float:
|
|
502
|
+
"""端口向内占据的横向深度: dot 深度 + HIT_GAP + 最宽 hit 文本宽度。
|
|
503
|
+
|
|
504
|
+
取 left/right 两侧中的较大值, 用于在计算宽度时为 hit 预留空间。
|
|
505
|
+
"""
|
|
506
|
+
hit_font = self._hitFont()
|
|
507
|
+
max_extent = 0.0
|
|
508
|
+
for side in ("left", "right"):
|
|
509
|
+
ports = self._ports_spec.get(side, [])
|
|
510
|
+
if not ports:
|
|
511
|
+
continue
|
|
512
|
+
max_depth = max(port_depth(p) for p in ports)
|
|
513
|
+
max_hit_w = 0.0
|
|
514
|
+
for p in ports:
|
|
515
|
+
if p.hit:
|
|
516
|
+
w = self._textWidth(p.hit, hit_font)
|
|
517
|
+
if w > max_hit_w:
|
|
518
|
+
max_hit_w = w
|
|
519
|
+
extent = max_depth + HIT_GAP + max_hit_w
|
|
520
|
+
if extent > max_extent:
|
|
521
|
+
max_extent = extent
|
|
522
|
+
return max_extent
|
|
523
|
+
|
|
524
|
+
def _circlePortRadius(self) -> float:
|
|
525
|
+
"""圆形节点为容纳端口所需的半径(按侧弧窗口长度容纳紧凑排列的端口)"""
|
|
526
|
+
w_rad = math.radians(CIRCLE_PORT_WINDOW_DEG)
|
|
527
|
+
denom = 2.0 * w_rad if w_rad > 0 else 1.0
|
|
528
|
+
max_total = 0.0
|
|
529
|
+
for side in ("left", "right"):
|
|
530
|
+
sizes = [max(1, p.size) for p in self._ports_spec.get(side, [])]
|
|
531
|
+
if not sizes:
|
|
532
|
+
continue
|
|
533
|
+
total = sum(sizes) + (len(sizes) - 1) * PORT_MIN_GAP
|
|
534
|
+
if total > max_total:
|
|
535
|
+
max_total = total
|
|
536
|
+
return max_total / denom if max_total > 0 else 0.0
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""
|
|
2
|
+
端口项接口契约模块
|
|
3
|
+
|
|
4
|
+
定义 IQNodePort(Protocol) —— 端口渲染项的对外类型契约。
|
|
5
|
+
Protocol 仅用于静态类型检查与文档, 不参与运行时继承(不进入 MRO),
|
|
6
|
+
因此可安全地与 QGraphicsObject 继承体系并存。
|
|
7
|
+
|
|
8
|
+
IQNodePort 声明了 QNodePort(原 _PortGraphic) 对外暴露的全部公开属性与方法签名,
|
|
9
|
+
是端口渲染项的"契约说明"。具体实现 QNodePort 在运行时通过继承
|
|
10
|
+
QGraphicsObject 实现该契约, 但并不显式继承 Protocol。
|
|
11
|
+
|
|
12
|
+
约定: 本模块只含类型声明, 不得有运行时依赖, 以避免循环导入。
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from typing import Optional, Protocol, runtime_checkable, TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from PyQt6.QtCore import QPointF
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from chartflow.node.node_item import QNodeItem
|
|
21
|
+
from chartflow.node.port_item import QNodePortSpec
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@runtime_checkable
|
|
25
|
+
class IQNodePort(Protocol):
|
|
26
|
+
"""端口渲染项接口契约
|
|
27
|
+
|
|
28
|
+
声明一个端口渲染项(场景中的 dot / hit 文本 / label 文本)应具备的
|
|
29
|
+
对外属性与方法。具体实现由 :class:`~chartflow.node.port_item.QNodePort`
|
|
30
|
+
承担(原 ``_PortGraphic`` 公开化)。
|
|
31
|
+
|
|
32
|
+
注: Protocol 不参与运行时继承, 实现类无需显式声明 ``class X(IQNodePort)``;
|
|
33
|
+
只要结构(属性/方法签名)匹配即视为满足契约(结构子类型, duck typing)。
|
|
34
|
+
|
|
35
|
+
契约按职责分为若干组:
|
|
36
|
+
|
|
37
|
+
- **身份与归属**: :attr:`side` / :attr:`spec` / :attr:`node`
|
|
38
|
+
- **几何与命中**: :meth:`scenePos` / :meth:`isPointOnDot`
|
|
39
|
+
- **hover/连线态**: :meth:`setActive` / :meth:`setDropTarget`
|
|
40
|
+
|
|
41
|
+
所有坐标默认为 **场景坐标(scene coordinate)**, 几何单位为像素。
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
# ---- 身份与归属 ----
|
|
45
|
+
@property
|
|
46
|
+
def side(self) -> str:
|
|
47
|
+
"""端口所在侧(只读), 取值 ``'left'``(input) 或 ``'right'``(output)。
|
|
48
|
+
|
|
49
|
+
决定 dot 的默认配色(左侧绿/右侧橙)与文本排布朝向。
|
|
50
|
+
"""
|
|
51
|
+
...
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def spec(self) -> "QNodePortSpec":
|
|
55
|
+
"""端口的纯数据描述(只读), 返回 :class:`~chartflow.node.port_item.QNodePortSpec`
|
|
56
|
+
(字段: name / shape / size / hit / label / outline / color)。
|
|
57
|
+
|
|
58
|
+
修改 spec 需通过节点重新 ``ports = {...}`` 重建, 而非直接改返回值。
|
|
59
|
+
"""
|
|
60
|
+
...
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def node(self) -> "QNodeItem":
|
|
64
|
+
"""端口所属的节点(只读)。"""
|
|
65
|
+
...
|
|
66
|
+
|
|
67
|
+
# ---- 几何与命中 ----
|
|
68
|
+
def scenePos(self) -> QPointF:
|
|
69
|
+
"""端口锚点(节点边界上)的 **场景坐标**(只读)。
|
|
70
|
+
|
|
71
|
+
连线端点锚定时取此点作为该端端点。
|
|
72
|
+
"""
|
|
73
|
+
...
|
|
74
|
+
|
|
75
|
+
def isPointOnDot(self, scene_pos: QPointF) -> bool:
|
|
76
|
+
"""场景坐标是否落在 dot 的真实几何(非放大的 12px 命中区)内。
|
|
77
|
+
|
|
78
|
+
供画布区分"点击 port dot 本身"与"点击节点外环"—— ``shape()`` 为方便
|
|
79
|
+
hover 故意放大到至少 12px, 会误吞节点外环点击。
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
scene_pos: 待判定的场景坐标。
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
``True`` 落在 dot 真实几何内, ``False`` 否则。
|
|
86
|
+
"""
|
|
87
|
+
...
|
|
88
|
+
|
|
89
|
+
# ---- hover/连线态 ----
|
|
90
|
+
def setActive(self, active: bool) -> None:
|
|
91
|
+
"""标记/取消连线进行中的起点态。
|
|
92
|
+
|
|
93
|
+
置 ``True`` 时, 即使光标已离开本 port, 描边仍保持(复用 hover 描边视觉);
|
|
94
|
+
由画布在连线成功或失败后调用 ``setActive(False)`` 清除。
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
active: ``True`` 保持起点描边, ``False`` 清除。
|
|
98
|
+
"""
|
|
99
|
+
...
|
|
100
|
+
|
|
101
|
+
def setDropTarget(self, on: bool) -> None:
|
|
102
|
+
"""标记/取消本 port 作为连线落点(目标端)的高亮态。
|
|
103
|
+
|
|
104
|
+
连线拖拽期间画布接管鼠标移动事件, 光标下的目标 port 不会收到
|
|
105
|
+
hoverEnter/hoverLeave(场景 hover 派发被压制), 故由画布在
|
|
106
|
+
mouseMoveEvent 中手动命中检测后调用本方法驱动描边与 label。
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
on: ``True`` 高亮为落点(并显示 label), ``False`` 取消。
|
|
110
|
+
"""
|
|
111
|
+
...
|