chartflow 0.1.2__tar.gz → 0.1.4__tar.gz

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 (86) hide show
  1. {chartflow-0.1.2 → chartflow-0.1.4}/PKG-INFO +1 -1
  2. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/node/__init__.py +9 -0
  3. chartflow-0.1.4/chartflow/node/_connection_interface.py +196 -0
  4. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/node/_node_ports.py +50 -0
  5. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/node/connection_item.py +227 -4
  6. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/node/example.py +45 -6
  7. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/node/node_canvas.py +96 -11
  8. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/node/popmenu.py +26 -1
  9. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/node/port_item.py +23 -2
  10. chartflow-0.1.4/chartflow/node/qconnection_editor.py +280 -0
  11. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/node/test_qnode.py +233 -1
  12. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow.egg-info/PKG-INFO +1 -1
  13. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow.egg-info/SOURCES.txt +2 -0
  14. {chartflow-0.1.2 → chartflow-0.1.4}/setup.py +1 -1
  15. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/__init__.py +0 -0
  16. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/_verify_importexport.py +0 -0
  17. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/edit/__init__.py +0 -0
  18. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/edit/code_editor.py +0 -0
  19. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/edit/code_folder.py +0 -0
  20. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/edit/completer.py +0 -0
  21. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/edit/context_analyzer.py +0 -0
  22. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/edit/demo.py +0 -0
  23. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/edit/line_number_area.py +0 -0
  24. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/edit/minimap.py +0 -0
  25. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/edit/python_editor.py +0 -0
  26. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/edit/qcompletion.py +0 -0
  27. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/edit/syntax_highlighter.py +0 -0
  28. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/edit/test_editor.py +0 -0
  29. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/flow/__init__.py +0 -0
  30. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/flow/_demo.py +0 -0
  31. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/flow/chart.py +0 -0
  32. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/flow/editor.py +0 -0
  33. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/flow/flow.py +0 -0
  34. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/flow/qchart.py +0 -0
  35. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/flow/test/__init__.py +0 -0
  36. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/flow/test/test_chart.py +0 -0
  37. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/flow/test_decorator_sync.py +0 -0
  38. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/fsm/__init__.py +0 -0
  39. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/fsm/decorators.py +0 -0
  40. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/fsm/demo.py +0 -0
  41. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/fsm/demo_pyqt6.py +0 -0
  42. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/fsm/demo_tree.py +0 -0
  43. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/fsm/logic.py +0 -0
  44. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/fsm/meta.py +0 -0
  45. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/fsm/scheduler.py +0 -0
  46. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/fsm/stage.py +0 -0
  47. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/fsm/stdio.py +0 -0
  48. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/fsm/test/__init__.py +0 -0
  49. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/fsm/test/test_decorators.py +0 -0
  50. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/fsm/test/test_events.py +0 -0
  51. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/fsm/test/test_integration.py +0 -0
  52. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/fsm/test/test_logic.py +0 -0
  53. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/fsm/test/test_meta.py +0 -0
  54. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/fsm/test/test_runtime_param.py +0 -0
  55. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/fsm/test/test_scheduler.py +0 -0
  56. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/fsm/test/test_stage.py +0 -0
  57. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/fsm/test_behavior_events.py +0 -0
  58. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/fsm/test_nested_parallel.py +0 -0
  59. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/fsm/test_stage_spec.py +0 -0
  60. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/fsm/utils.py +0 -0
  61. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/node/_node_base.py +0 -0
  62. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/node/_node_interaction.py +0 -0
  63. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/node/_node_interface.py +0 -0
  64. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/node/_node_markers.py +0 -0
  65. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/node/_port_interface.py +0 -0
  66. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/node/arrow_item.py +0 -0
  67. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/node/color_utils.py +0 -0
  68. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/node/demo.py +0 -0
  69. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/node/demo_arrow_drag.py +0 -0
  70. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/node/demo_decorator.py +0 -0
  71. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/node/demo_ports.py +0 -0
  72. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/node/enums.py +0 -0
  73. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/node/layout_utils.py +0 -0
  74. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/node/main_window.py +0 -0
  75. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/node/menu_bar.py +0 -0
  76. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/node/node_item.py +0 -0
  77. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/node/qnode_editor.py +0 -0
  78. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/node/selection_rect.py +0 -0
  79. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/node/style_panel.py +0 -0
  80. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/node/styles.py +0 -0
  81. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/showcase.py +0 -0
  82. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow/theme.py +0 -0
  83. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow.egg-info/dependency_links.txt +0 -0
  84. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow.egg-info/requires.txt +0 -0
  85. {chartflow-0.1.2 → chartflow-0.1.4}/chartflow.egg-info/top_level.txt +0 -0
  86. {chartflow-0.1.2 → chartflow-0.1.4}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chartflow
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Self-use. with fsm. node. chart. edit. Used for fsm and run, structure graph. use pyqt to show and interact
5
5
  Author-email: 2229066748@qq.com
6
6
  Maintainer: Eagle'sBaby
@@ -19,12 +19,16 @@ from chartflow.node.node_item import QNodeItem
19
19
  from chartflow.node.port_item import QNodePort, QNodePortSpec
20
20
  from chartflow.node._port_interface import IQNodePort
21
21
  from chartflow.node.connection_item import ConnectionItem
22
+ from chartflow.node._connection_interface import IConnectionItem, IQConnectionEditor
22
23
  from chartflow.node.arrow_item import ArrowItem
23
24
  from chartflow.node.selection_rect import SelectionRect
24
25
  from chartflow.node.style_panel import StylePanel
25
26
  from chartflow.node.node_canvas import QNodeCanvas
26
27
  from chartflow.node.main_window import NodeCanvasWindow
27
28
  from chartflow.node.qnode_editor import QNodeEditor
29
+ from chartflow.node.qconnection_editor import (
30
+ QConnectionEditor, QConnectionNameEditor, openConnectionEditor,
31
+ )
28
32
  from chartflow.node.popmenu import (
29
33
  ICanvasPopMenu, QCanvasPopMenu,
30
34
  INodePopMenu, QNodePopMenu,
@@ -50,6 +54,7 @@ __all__ = [
50
54
  "QNodePortSpec",
51
55
  "IQNodePort",
52
56
  "ConnectionItem",
57
+ "IConnectionItem",
53
58
  "ArrowItem",
54
59
  "SelectionRect",
55
60
  # UI Components
@@ -59,6 +64,10 @@ __all__ = [
59
64
  "NodeCanvasWindow",
60
65
  # Editor
61
66
  "QNodeEditor",
67
+ "QConnectionEditor",
68
+ "QConnectionNameEditor",
69
+ "IQConnectionEditor",
70
+ "openConnectionEditor",
62
71
  # Pop Menus
63
72
  "ICanvasPopMenu", "QCanvasPopMenu",
64
73
  "INodePopMenu", "QNodePopMenu",
@@ -0,0 +1,196 @@
1
+ """
2
+ 连接项接口契约模块
3
+
4
+ 定义 IConnectionItem(Protocol) 与 IQConnectionEditor(Protocol) ——
5
+ 连接项(连线)及其编辑器的对外类型契约。
6
+
7
+ Protocol 仅用于静态类型检查与文档, 不参与运行时继承(不进入 MRO),
8
+ 因此可安全地与 QGraphicsPathItem / QWidget 继承体系并存。
9
+
10
+ IConnectionItem 声明了 ConnectionItem 对外暴露的名称/数据/编辑器/
11
+ 信号/序列化接口; IQConnectionEditor 声明了连线编辑器(见
12
+ qconnection_editor.QConnectionEditor)的对外接口, 与节点编辑器
13
+ QNodeEditor 的接口形态保持一致。
14
+
15
+ 约定: 本模块只含类型声明, 不得有运行时依赖, 以避免循环导入。
16
+ """
17
+
18
+ from typing import Any, Dict, Optional, Protocol, runtime_checkable
19
+
20
+ from PyQt6.QtCore import QPointF, pyqtSignal
21
+
22
+
23
+ @runtime_checkable
24
+ class IConnectionItem(Protocol):
25
+ """连接项接口契约
26
+
27
+ 声明一条连线(ConnectionItem)在 名称 / 编辑器 / 序列化 方面应具备的
28
+ 对外属性与方法。几何/样式/箭头/端口锚定等既有接口不在此重复声明。
29
+
30
+ 契约分组:
31
+
32
+ - **名称与数据**: ``name`` / ``data``
33
+ - **编辑器**: ``editorClass`` / ``editorEmbedded`` / ``isEditWindowOpen`` /
34
+ :meth:`setEditWindowOpen` / :meth:`closeEditor`
35
+ - **几何**: ``center``(连线中点, 编辑器/名称 label 的锚点)
36
+ - **信号**: ``editRequested`` / ``nameChanged``
37
+ - **序列化**: :meth:`toDict` / :meth:`fromDict`
38
+
39
+ 所有坐标默认为 **场景坐标(scene coordinate)**, 几何单位为像素。
40
+ """
41
+
42
+ # ---- 名称与数据 ----
43
+ @property
44
+ def name(self) -> str:
45
+ """连线名称(可读写)。
46
+
47
+ 读取返回当前名称(可为空串); 赋值会更新连线中点的名称 label
48
+ (空串时隐藏)并发出 :attr:`nameChanged` 信号。名称会被
49
+ :meth:`toDict` / :meth:`fromDict` 序列化。
50
+ """
51
+ ...
52
+
53
+ @name.setter
54
+ def name(self, value: str) -> None: ...
55
+
56
+ @property
57
+ def data(self) -> Dict[str, Any]:
58
+ """连线自定义数据字典(只读引用, 内容可改)。
59
+
60
+ 返回内部 ``_data`` 字典本身而非副本, 调用方可直接增删键值,
61
+ 供自定义编辑器(如保存备注/参数)持久化内容使用。
62
+ 会被 :meth:`toDict` / :meth:`fromDict` 序列化。
63
+ """
64
+ ...
65
+
66
+ # ---- 编辑器 ----
67
+ @property
68
+ def editorClass(self) -> Optional[type]:
69
+ """连线编辑器类(可读写)。
70
+
71
+ 设为某个 ``QWidget`` 子类 ``type`` 时, 双击连线(或右键菜单
72
+ Edit Connection)会通过 :attr:`editRequested` 信号请求由该类创建
73
+ 编辑面板; ``None`` 表示使用内置默认名称编辑器
74
+ (:class:`~chartflow.node.qconnection_editor.QConnectionNameEditor`)。
75
+ 编辑器类本身不可序列化(与节点 editorClass 一致)。
76
+ """
77
+ ...
78
+
79
+ @editorClass.setter
80
+ def editorClass(self, value: Optional[type]) -> None: ...
81
+
82
+ @property
83
+ def editorEmbedded(self) -> bool:
84
+ """编辑器是否嵌入连线中点(可读写, 默认 ``True``)。
85
+
86
+ - ``True``: 编辑器经 ``QGraphicsProxyWidget`` 嵌入连线中点, 随连线
87
+ 路径更新自动跟随;
88
+ - ``False``: 编辑器作为独立浮动窗口打开(与节点编辑器一致)。
89
+
90
+ 该状态会被 :meth:`toDict` / :meth:`fromDict` 序列化(仅在
91
+ ``False`` 时落盘, 避免污染默认导出)。
92
+ """
93
+ ...
94
+
95
+ @editorEmbedded.setter
96
+ def editorEmbedded(self, value: bool) -> None: ...
97
+
98
+ @property
99
+ def isEditWindowOpen(self) -> bool:
100
+ """当前是否有编辑器窗口/嵌入面板打开(只读)。"""
101
+ ...
102
+
103
+ def setEditWindowOpen(self, open: bool) -> None:
104
+ """切换编辑窗口打开状态, 进而控制中点名称 label 的高亮提示。
105
+
106
+ 由编辑器(如 :class:`QConnectionEditor`)在 show/close 时自动驱动;
107
+ 打开时名称 label 显示高亮底衬(类比节点的 📖 悬浮标记)。
108
+
109
+ Args:
110
+ open: ``True`` 表示编辑器已打开, ``False`` 已关闭。
111
+ """
112
+ ...
113
+
114
+ def closeEditor(self) -> None:
115
+ """关闭当前打开的编辑器并清理嵌入代理(无打开编辑器时为空操作)。
116
+
117
+ 画布在删除连线(:meth:`NodeCanvas.removeConnection`)时调用,
118
+ 防止编辑器窗口/嵌入代理悬挂。
119
+ """
120
+ ...
121
+
122
+ # ---- 几何 ----
123
+ @property
124
+ def center(self) -> QPointF:
125
+ """连线中点的 **场景坐标**(只读), 取自 ``path().pointAtPercent(0.5)``。
126
+
127
+ 作为名称 label 与嵌入编辑器的锚点; 自环连线同样按路径 50% 处取值。
128
+ """
129
+ ...
130
+
131
+ # ---- 信号(只读属性, 返回 pyqtSignal) ----
132
+ @property
133
+ def editRequested(self) -> pyqtSignal:
134
+ """编辑请求信号(只读)。
135
+
136
+ 双击连线或点击右键菜单 Edit Connection 时发射, 载荷为本连线实例;
137
+ 画布默认连接到打开编辑器的处理器(无 editorClass 时打开内置名称
138
+ 编辑器), 宿主可自行断开/接管。
139
+ """
140
+ ...
141
+
142
+ @property
143
+ def nameChanged(self) -> pyqtSignal:
144
+ """名称变更信号(只读)。名称被修改时发射, 载荷为 (连线, 新名称)。"""
145
+ ...
146
+
147
+ # ---- 序列化 ----
148
+ def toDict(self) -> dict:
149
+ """将连线的 名称/数据/编辑器布局 序列化为字典。
150
+
151
+ 仅包含非默认键: ``name``(非空) / ``data``(非空) /
152
+ ``editor_embedded``(仅 ``False``)。拓扑(source/target/port)与样式
153
+ 由画布 export 与 styleToDict 负责, 不在此重复。
154
+
155
+ Returns:
156
+ 连线数据字典(深拷贝, 修改返回值不影响连线)。
157
+ """
158
+ ...
159
+
160
+ def fromDict(self, data: dict) -> None:
161
+ """从字典恢复连线的 名称/数据/编辑器布局(增量合并, 忽略未知键)。
162
+
163
+ Args:
164
+ data: 由 :meth:`toDict` 产生(或同构)的字典。
165
+ """
166
+ ...
167
+
168
+
169
+ @runtime_checkable
170
+ class IQConnectionEditor(Protocol):
171
+ """连线编辑器接口契约
172
+
173
+ 声明连线编辑器应具备的对外接口, 与节点编辑器
174
+ :class:`~chartflow.node.qnode_editor.QNodeEditor` 形态保持一致:
175
+ 构造时传入被编辑对象, 提供 ``getTitle`` / ``save`` / ``load``。
176
+
177
+ 具体实现由 :class:`~chartflow.node.qconnection_editor.QConnectionEditor`
178
+ 承担; 用户自定义编辑器继承该类(或满足本契约)即可。
179
+ """
180
+
181
+ @property
182
+ def connection(self) -> IConnectionItem:
183
+ """获取当前编辑的连线(只读)。"""
184
+ ...
185
+
186
+ def getTitle(self) -> str:
187
+ """获取编辑器窗口标题(嵌入模式下一般不可见)。"""
188
+ ...
189
+
190
+ def save(self) -> bool:
191
+ """保存编辑器内容到连线。返回是否成功。"""
192
+ ...
193
+
194
+ def load(self) -> bool:
195
+ """从连线加载内容到编辑器。返回是否成功。"""
196
+ ...
@@ -56,6 +56,11 @@ class _NodePortMixin:
56
56
  self._port_label_timer.setSingleShot(True)
57
57
  self._port_label_timer.setInterval(PORT_LABEL_HOVER_DELAY_MS)
58
58
  self._port_label_timer.timeout.connect(self._showAllPortLabels)
59
+ # label 钉住态(始终可见)与闪现计时器(flashPortLabels 用)
60
+ self._port_labels_pinned: bool = False
61
+ self._port_flash_timer: QTimer = QTimer()
62
+ self._port_flash_timer.setSingleShot(True)
63
+ self._port_flash_timer.timeout.connect(self._unflashPortLabels)
59
64
 
60
65
  # ------------------------------------------------------------------ #
61
66
  # ports 属性
@@ -126,6 +131,50 @@ class _NodePortMixin:
126
131
  result[key] = ports
127
132
  return result
128
133
 
134
+ # ------------------------------------------------------------------ #
135
+ # 端口 label 显隐(钉住 / 闪现)
136
+ # ------------------------------------------------------------------ #
137
+ @property
138
+ def portLabelsVisible(self) -> bool:
139
+ """端口 label 是否始终可见(读写)。
140
+
141
+ True 时全部端口 label 钉住常显(优先于 hover 规则); False 时交还
142
+ hover 规则(节点 hover>0.5s 显示全部 / hover 单个 dot 显示该条)。
143
+ 对重建端口(ports 重赋值)后新建的端口项同样生效。
144
+ """
145
+ return self._port_labels_pinned
146
+
147
+ @portLabelsVisible.setter
148
+ def portLabelsVisible(self, on: bool) -> None:
149
+ self._port_labels_pinned = bool(on)
150
+ self._setAllPortsLabelPinned(on)
151
+
152
+ def _setAllPortsLabelPinned(self, on: bool) -> None:
153
+ for side in ("left", "right"):
154
+ for item in self._port_items[side]:
155
+ item.setLabelPinned(on)
156
+
157
+ def flashPortLabels(self, continue_time: int = 1500) -> None:
158
+ """模拟弹出全部端口 label 并持续 ``continue_time`` 毫秒, 到期自动收回。
159
+
160
+ 重复调用会重新计时。与 hover 规则正交: 闪现收回后 label 回到
161
+ hover/钉住所决定的可见态。continue_time <= 0 时立即收回。
162
+ """
163
+ for side in ("left", "right"):
164
+ for item in self._port_items[side]:
165
+ item.setLabelFlashed(True)
166
+ if continue_time <= 0:
167
+ self._unflashPortLabels()
168
+ return
169
+ self._port_flash_timer.start(max(0, int(continue_time)))
170
+
171
+ def _unflashPortLabels(self) -> None:
172
+ """闪现到期: 解除全部端口的闪现态(label 回到 hover/钉住规则)。"""
173
+ self._port_flash_timer.stop()
174
+ for side in ("left", "right"):
175
+ for item in self._port_items[side]:
176
+ item.setLabelFlashed(False)
177
+
129
178
  # ------------------------------------------------------------------ #
130
179
  # 端口渲染项构建/移除
131
180
  # ------------------------------------------------------------------ #
@@ -142,6 +191,7 @@ class _NodePortMixin:
142
191
  for side in ("left", "right"):
143
192
  for spec in self._ports_spec.get(side, []):
144
193
  item = QNodePort(spec, side, self)
194
+ item.setLabelPinned(self._port_labels_pinned) # 继承钉住态
145
195
  self._port_items[side].append(item)
146
196
  self._layoutPorts()
147
197
 
@@ -1,13 +1,17 @@
1
1
  """
2
2
  连接项模块 - 表示两个节点之间的连线
3
- 包含两个独立的箭头项
3
+ 包含两个独立的箭头项, 以及中点的名称 label / 嵌入编辑器
4
4
  """
5
5
 
6
+ import copy
6
7
  import math
7
- from typing import Optional, List, Callable
8
+ from typing import Optional, List, Callable, Dict, Any
8
9
 
9
- from PyQt6.QtWidgets import QGraphicsItem, QGraphicsPathItem
10
- from PyQt6.QtCore import Qt, QPointF, QTimer, QRectF
10
+ from PyQt6.QtWidgets import (
11
+ QGraphicsItem, QGraphicsPathItem, QGraphicsTextItem, QGraphicsRectItem,
12
+ QGraphicsProxyWidget, QWidget,
13
+ )
14
+ from PyQt6.QtCore import Qt, QPointF, QTimer, QRectF, QObject, pyqtSignal
11
15
  from PyQt6.QtGui import QPainterPath, QPainterPathStroker, QPen, QColor, QLinearGradient, QBrush
12
16
 
13
17
  from chartflow.node.styles import (
@@ -34,6 +38,26 @@ SELF_LOOP_RING_RADIUS = 18.0
34
38
  # 四分之一圆的贝塞尔控制点常数: 4/3*(sqrt(2)-1)
35
39
  _QUARTER_ARC_K = 0.5522847498
36
40
 
41
+ # 名称 label 文本颜色(深岩灰, 与节点名称文本同色系)
42
+ CONNECTION_LABEL_COLOR = QColor("#4A5568")
43
+ # 编辑器打开时名称 label 的高亮底衬(类比节点的 📖 悬浮标记)
44
+ CONNECTION_LABEL_EDIT_BG = QColor("#E3F0FF")
45
+ CONNECTION_LABEL_EDIT_BORDER = QColor("#7FB2F0")
46
+
47
+
48
+ class ConnectionSignalHelper(QObject):
49
+ """
50
+ 信号辅助类 - 为 ConnectionItem 提供信号支持
51
+ 因为 QGraphicsPathItem 继承自 QGraphicsItem 而不是 QObject,
52
+ 所以需要通过组合方式来提供信号功能(与 NodeSignalHelper 同构)
53
+ """
54
+ editRequested = pyqtSignal(object) # 编辑请求信号, 参数为连线自身
55
+ nameChanged = pyqtSignal(object, str) # 名称改变信号, 参数为连线自身和新名称
56
+
57
+ def __init__(self, parent) -> None:
58
+ super().__init__()
59
+ self._parent = parent
60
+
37
61
 
38
62
  def _quarter_arc(path: QPainterPath, center: QPointF, r: float,
39
63
  u_from: QPointF, u_to: QPointF) -> None:
@@ -194,6 +218,35 @@ class ConnectionItem(QGraphicsPathItem):
194
218
  self._arrow_drag_moved_callback: Optional[Callable[[QPointF], None]] = None
195
219
  self._arrow_drag_finished_callback: Optional[Callable[[QPointF, bool], None]] = None
196
220
 
221
+ # ------------------------------------------------------------------ #
222
+ # 名称 / 编辑器体系(镜像节点: 信号辅助 + 中点名称 label + 编辑器配置)
223
+ # ------------------------------------------------------------------ #
224
+ self._signal_helper = ConnectionSignalHelper(self)
225
+ self._name: str = ""
226
+ self._data: Dict[str, Any] = {}
227
+ self._editor_class: Optional[type] = None
228
+ # 编辑器嵌入(True, 默认, 嵌在连线中点) / 浮动(False, 独立窗口)
229
+ self._editor_embedded: bool = True
230
+ self._edit_window_open: bool = False
231
+ self._open_editor: Optional[QWidget] = None
232
+ self._embedded_proxy: Optional[QGraphicsProxyWidget] = None
233
+
234
+ # 编辑器打开时名称 label 的高亮底衬(子项, z=-1 位于 label 之下)
235
+ self._label_bg: QGraphicsRectItem = QGraphicsRectItem(self)
236
+ self._label_bg.setZValue(-1)
237
+ self._label_bg.setBrush(QBrush(CONNECTION_LABEL_EDIT_BG))
238
+ self._label_bg.setPen(QPen(CONNECTION_LABEL_EDIT_BORDER, 1.0))
239
+ self._label_bg.setVisible(False)
240
+
241
+ # 中点名称 label(子项; 连线 item 无位置偏移, 子项坐标即场景坐标)
242
+ self._text_item: QGraphicsTextItem = QGraphicsTextItem("", self)
243
+ self._text_item.setDefaultTextColor(CONNECTION_LABEL_COLOR)
244
+ # 禁用文本项的鼠标/hover 事件, 让事件完全传递给连线本体
245
+ self._text_item.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction)
246
+ self._text_item.setAcceptHoverEvents(False)
247
+ self._text_item.setAcceptedMouseButtons(Qt.MouseButton.NoButton)
248
+ self._text_item.setVisible(False)
249
+
197
250
  # 更新路径
198
251
  self.updatePath()
199
252
 
@@ -409,6 +462,173 @@ class ConnectionItem(QGraphicsPathItem):
409
462
  self._connection_type = ConnectionType.BEZIER
410
463
  self.updatePath()
411
464
 
465
+ # ------------------------------------------------------------------ #
466
+ # 名称 / 数据 / 编辑器(镜像节点 QNodeItem 的同名体系)
467
+ # ------------------------------------------------------------------ #
468
+ @property
469
+ def name(self) -> str:
470
+ """获取连线名称(可为空串, 空串时中点名称 label 隐藏)"""
471
+ return self._name
472
+
473
+ @name.setter
474
+ def name(self, value: str) -> None:
475
+ """设置连线名称: 更新中点 label 并发 :attr:`nameChanged` 信号"""
476
+ value = value or ""
477
+ old_name = self._name
478
+ self._name = value
479
+ self._text_item.setPlainText(value)
480
+ if old_name != value:
481
+ self._signal_helper.nameChanged.emit(self, value)
482
+ self._layoutLabel()
483
+
484
+ @property
485
+ def data(self) -> Dict[str, Any]:
486
+ """连线自定义数据字典(只读引用, 内容可改; 供编辑器持久化内容)"""
487
+ return self._data
488
+
489
+ @property
490
+ def editorClass(self) -> Optional[type]:
491
+ """获取连线编辑器类(None = 使用内置默认名称编辑器)"""
492
+ return self._editor_class
493
+
494
+ @editorClass.setter
495
+ def editorClass(self, value: Optional[type]) -> None:
496
+ """设置连线编辑器类"""
497
+ self._editor_class = value
498
+
499
+ @property
500
+ def editorEmbedded(self) -> bool:
501
+ """编辑器是否嵌入连线中点(True, 默认) / 独立浮动窗口(False)"""
502
+ return self._editor_embedded
503
+
504
+ @editorEmbedded.setter
505
+ def editorEmbedded(self, value: bool) -> None:
506
+ """设置编辑器嵌入/浮动布局(对已打开的编辑器不追溯生效)"""
507
+ self._editor_embedded = bool(value)
508
+
509
+ @property
510
+ def center(self) -> QPointF:
511
+ """连线中点(场景坐标): 名称 label 与嵌入编辑器的锚点"""
512
+ return self.path().pointAtPercent(0.5)
513
+
514
+ @property
515
+ def isEditWindowOpen(self) -> bool:
516
+ """当前是否有编辑器(嵌入或浮动)打开"""
517
+ return self._edit_window_open
518
+
519
+ def setEditWindowOpen(self, open: bool) -> None:
520
+ """切换编辑窗口打开状态, 控制名称 label 的高亮底衬(类比节点📖)"""
521
+ open = bool(open)
522
+ if self._edit_window_open == open:
523
+ return
524
+ self._edit_window_open = open
525
+ self._layoutLabel()
526
+
527
+ def closeEditor(self) -> None:
528
+ """关闭当前打开的编辑器并清理嵌入代理(无打开编辑器时为空操作)。
529
+
530
+ 画布删除连线(:meth:`NodeCanvas.removeConnection`)时调用, 防止
531
+ 编辑器窗口 / QGraphicsProxyWidget 悬挂。
532
+ """
533
+ editor = self._open_editor
534
+ if editor is not None:
535
+ self._open_editor = None
536
+ try:
537
+ editor.close() # closeEvent -> setEditWindowOpen(False)
538
+ except RuntimeError:
539
+ pass
540
+ # 兜底清理嵌入代理(正常由 editor.destroyed 回调清理, 此处防悬挂)
541
+ proxy = self._embedded_proxy
542
+ if proxy is not None:
543
+ self._embedded_proxy = None
544
+ try:
545
+ if proxy.scene() is not None:
546
+ proxy.scene().removeItem(proxy)
547
+ proxy.setParentItem(None)
548
+ except RuntimeError:
549
+ pass
550
+ self._layoutLabel()
551
+
552
+ # ---- 信号(只读属性, 返回 pyqtSignal) ----
553
+ @property
554
+ def editRequested(self) -> pyqtSignal:
555
+ """编辑请求信号: 双击连线或右键菜单 Edit Connection 时发射"""
556
+ return self._signal_helper.editRequested
557
+
558
+ @property
559
+ def nameChanged(self) -> pyqtSignal:
560
+ """名称变更信号: 载荷为 (连线, 新名称)"""
561
+ return self._signal_helper.nameChanged
562
+
563
+ def _layoutLabel(self) -> None:
564
+ """将名称 label / 嵌入编辑器定位到连线中点。
565
+
566
+ 在 updatePath()(含自环路径)、name 变更、编辑器开关时调用。
567
+ label 与 proxy 均为本连线子项; 连线 item 无位置偏移(path 直接建在
568
+ 场景坐标), 故子项本地坐标即场景坐标。嵌入编辑器打开期间隐藏 label,
569
+ 避免名称双重显示。
570
+ """
571
+ label = getattr(self, "_text_item", None)
572
+ if label is None:
573
+ return # 构造早期防御(updatePath 先于 label 创建时不应发生)
574
+ mid = self.path().pointAtPercent(0.5)
575
+ proxy = self._embedded_proxy
576
+ show_label = bool(self._name) and proxy is None
577
+ label.setVisible(show_label)
578
+ bg = self._label_bg
579
+ if show_label:
580
+ rect = label.boundingRect()
581
+ label.setPos(mid.x() - rect.width() / 2.0,
582
+ mid.y() - rect.height() / 2.0)
583
+ show_bg = self._edit_window_open
584
+ bg.setVisible(show_bg)
585
+ if show_bg:
586
+ pad = 3.0
587
+ bg.setRect(label.pos().x() - pad, label.pos().y() - pad,
588
+ rect.width() + 2 * pad, rect.height() + 2 * pad)
589
+ else:
590
+ bg.setVisible(False)
591
+ if proxy is not None:
592
+ size = proxy.size()
593
+ proxy.setPos(mid.x() - size.width() / 2.0,
594
+ mid.y() - size.height() / 2.0)
595
+
596
+ # ------------------------------------------------------------------ #
597
+ # 序列化(拓扑/样式由画布 export 与 styleToDict 负责)
598
+ # ------------------------------------------------------------------ #
599
+ def toDict(self) -> dict:
600
+ """将连线的 名称/数据/编辑器布局 序列化为字典(仅非默认键)。
601
+
602
+ Returns:
603
+ 连线数据字典; 默认连线(无名称/数据, 嵌入布局)返回空 dict,
604
+ 避免污染导出。
605
+ """
606
+ result: dict = {}
607
+ if self._name:
608
+ result["name"] = self._name
609
+ if self._data:
610
+ # deepcopy: data 可能含列表/字典等可变嵌套, 浅拷贝会让外部
611
+ # 就地修改污染原连线 _data(与节点 toDict 策略一致)。
612
+ result["data"] = copy.deepcopy(self._data)
613
+ if not self._editor_embedded:
614
+ result["editor_embedded"] = False
615
+ return result
616
+
617
+ def fromDict(self, data: dict) -> None:
618
+ """从字典恢复连线的 名称/数据/编辑器布局(增量合并, 忽略未知键)。
619
+
620
+ Args:
621
+ data: 由 :meth:`toDict` 产生(或同构)的字典。
622
+ """
623
+ if not isinstance(data, dict):
624
+ return
625
+ if "name" in data:
626
+ self.name = data["name"] or ""
627
+ if isinstance(data.get("data"), dict):
628
+ self._data.update(data["data"])
629
+ if "editor_embedded" in data:
630
+ self.editorEmbedded = bool(data["editor_embedded"])
631
+
412
632
  def swapDirection(self) -> None:
413
633
  """
414
634
  交换源节点和目标节点,用于保留反向连接时
@@ -508,6 +728,7 @@ class ConnectionItem(QGraphicsPathItem):
508
728
  self._source_edge = QPointF()
509
729
  self._target_edge = QPointF()
510
730
  self._updateSelfLoopPath()
731
+ self._layoutLabel()
511
732
  return
512
733
 
513
734
  if self._source_port is not None:
@@ -542,6 +763,8 @@ class ConnectionItem(QGraphicsPathItem):
542
763
 
543
764
  # 端点变化后刷新 pen(端口渐变需按新端点重建渐变画刷)
544
765
  self._refreshPen()
766
+ # 路径变化后重定位中点名称 label / 嵌入编辑器
767
+ self._layoutLabel()
545
768
 
546
769
  def _updateSelfLoopPath(self) -> None:
547
770
  """绘制自环: 圆弧+直线组合, 端点吸附实际边线。
@@ -4,7 +4,7 @@ qnode库使用示例
4
4
  展示如何使用qnode库创建节点图
5
5
  """
6
6
 
7
- from PyQt6.QtWidgets import QVBoxLayout, QLabel, QTextEdit
7
+ from PyQt6.QtWidgets import QVBoxLayout, QLabel, QTextEdit, QPushButton
8
8
 
9
9
 
10
10
  import sys
@@ -13,7 +13,7 @@ from PyQt6.QtCore import QPointF
13
13
 
14
14
  from chartflow.node import (
15
15
  NodeCanvasWindow, QNodeCanvas, QNodeItem,
16
- NodeShape, ConnectionType, QNodeEditor
16
+ NodeShape, ConnectionType, QNodeEditor, QConnectionEditor
17
17
  )
18
18
 
19
19
 
@@ -42,6 +42,31 @@ class MyNodeEditor(QNodeEditor):
42
42
  return True
43
43
 
44
44
 
45
+ class MyConnectionEditor(QConnectionEditor):
46
+ """自定义连线编辑器示例(编辑连线备注, 经 connection.data 持久化)"""
47
+
48
+ def _setupUi(self) -> None:
49
+ layout = QVBoxLayout(self)
50
+
51
+ info_label = QLabel(f"Editing connection: {self.connection.name or '(unnamed)'}")
52
+ layout.addWidget(info_label)
53
+
54
+ # 备注编辑(存入 connection.data, 随画布 export/import 保存)
55
+ self.text_edit = QTextEdit()
56
+ self.text_edit.setPlaceholderText("Connection note...")
57
+ self.text_edit.setPlainText(self.connection.data.get("note", ""))
58
+ layout.addWidget(self.text_edit)
59
+
60
+ save_button = QPushButton("Save")
61
+ save_button.clicked.connect(self.save)
62
+ layout.addWidget(save_button)
63
+
64
+ def save(self) -> bool:
65
+ """保存编辑器内容到连线数据"""
66
+ self.connection.data["note"] = self.text_edit.toPlainText()
67
+ return True
68
+
69
+
45
70
  def main():
46
71
  """主函数"""
47
72
  app = QApplication(sys.argv)
@@ -64,10 +89,24 @@ def main():
64
89
  node2.data["content"] = "Process logic here..."
65
90
 
66
91
  # 创建连接
67
- canvas.addConnection(node1, node2)
68
- canvas.addConnection(node2, node3)
69
- canvas.addConnection(node3, node4)
70
-
92
+ conn12 = canvas.addConnection(node1, node2)
93
+ conn23 = canvas.addConnection(node2, node3)
94
+ conn34 = canvas.addConnection(node3, node4)
95
+
96
+ # 连线命名: 名称持久显示在连线中点(双击可编辑)
97
+ conn12.name = "init"
98
+ conn23.name = "process"
99
+
100
+ # 连线编辑器示例:
101
+ # - conn23: 自定义编辑器, 默认嵌入连线中点(QGraphicsProxyWidget)
102
+ # - conn34: 自定义编辑器, editorEmbedded=False 作为浮动窗口打开
103
+ conn23.editorClass = MyConnectionEditor
104
+ conn34.name = "finish"
105
+ conn34.editorClass = MyConnectionEditor
106
+ conn34.editorEmbedded = False
107
+
108
+ # 未设置 editorClass 的连线(如 conn12): 双击打开内置名称编辑器
109
+
71
110
  # 设置双向连接示例
72
111
  canvas.addConnection(node2, node4, is_bidirectional=True)
73
112