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