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,307 @@
1
+ """
2
+ 布局工具模块 - 提供自动布局算法
3
+ """
4
+
5
+ import math
6
+ from typing import List, Dict, Optional, Tuple
7
+
8
+ from PyQt6.QtCore import QPointF, QRectF, Qt
9
+ from PyQt6.QtGui import QTransform
10
+
11
+ from chartflow.node.node_item import QNodeItem
12
+ from chartflow.node.connection_item import ConnectionItem
13
+
14
+
15
+ class DenseLayoutEngine:
16
+ """密集布局引擎 - 提供多种自动布局策略"""
17
+
18
+ def __init__(self, canvas: 'NodeCanvas') -> None:
19
+ self._canvas = canvas
20
+ self._nodes: List[QNodeItem] = canvas.nodes
21
+ self._connections: List[ConnectionItem] = canvas.connections
22
+
23
+ def autoLayout(self, strategy: str = "compact") -> None:
24
+ """
25
+ 自动布局
26
+
27
+ Args:
28
+ strategy: 布局策略 ("compact", "circular", "grid", "hierarchical", "force")
29
+ """
30
+ if not self._nodes:
31
+ return
32
+
33
+ if strategy == "compact":
34
+ self._compactLayout()
35
+ elif strategy == "circular":
36
+ self._circularLayout()
37
+ elif strategy == "grid":
38
+ self._gridLayout()
39
+ elif strategy == "hierarchical":
40
+ self._hierarchicalLayout()
41
+ elif strategy == "force":
42
+ self._forceDirectedLayout()
43
+
44
+ # 更新所有连接
45
+ for conn in self._connections:
46
+ conn.updatePath()
47
+
48
+ # 标记画布已修改
49
+ self._canvas.changed = True
50
+
51
+ def _compactLayout(self) -> None:
52
+ """紧凑布局 - 将节点排列成紧凑的网格"""
53
+ if not self._nodes:
54
+ return
55
+
56
+ # 计算节点大小
57
+ node_size = 100 # 假设节点大小为100x100
58
+ spacing = 20 # 节点间距
59
+
60
+ # 计算网格尺寸
61
+ node_count = len(self._nodes)
62
+ cols = int(math.sqrt(node_count))
63
+ if cols * cols < node_count:
64
+ cols += 1
65
+
66
+ # 计算起始位置(使布局居中)
67
+ start_x = -(cols * (node_size + spacing)) / 2
68
+ start_y = -(math.ceil(node_count / cols) * (node_size + spacing)) / 2
69
+
70
+ # 排列节点
71
+ for i, node in enumerate(self._nodes):
72
+ row = i // cols
73
+ col = i % cols
74
+ x = start_x + col * (node_size + spacing)
75
+ y = start_y + row * (node_size + spacing)
76
+ node.setPos(QPointF(x, y))
77
+
78
+ def _circularLayout(self) -> None:
79
+ """圆形布局 - 将节点排列成圆形"""
80
+ if not self._nodes:
81
+ return
82
+
83
+ node_count = len(self._nodes)
84
+ if node_count == 1:
85
+ self._nodes[0].setPos(QPointF(0, 0))
86
+ return
87
+
88
+ # 计算圆形半径
89
+ radius = max(200, node_count * 30)
90
+
91
+ # 排列节点
92
+ for i, node in enumerate(self._nodes):
93
+ angle = 2 * math.pi * i / node_count
94
+ x = radius * math.cos(angle)
95
+ y = radius * math.sin(angle)
96
+ node.setPos(QPointF(x, y))
97
+
98
+ def _gridLayout(self) -> None:
99
+ """网格布局 - 将节点排列成规则的网格"""
100
+ if not self._nodes:
101
+ return
102
+
103
+ # 计算节点大小
104
+ node_size = 100
105
+ spacing = 30
106
+
107
+ # 计算网格尺寸
108
+ node_count = len(self._nodes)
109
+ cols = int(math.sqrt(node_count * 1.5)) # 稍微宽一点的网格
110
+ if cols < 1:
111
+ cols = 1
112
+
113
+ # 计算起始位置
114
+ start_x = -(cols * (node_size + spacing)) / 2
115
+ start_y = -(math.ceil(node_count / cols) * (node_size + spacing)) / 2
116
+
117
+ # 排列节点
118
+ for i, node in enumerate(self._nodes):
119
+ row = i // cols
120
+ col = i % cols
121
+ x = start_x + col * (node_size + spacing)
122
+ y = start_y + row * (node_size + spacing)
123
+ node.setPos(QPointF(x, y))
124
+
125
+ def _hierarchicalLayout(self) -> None:
126
+ """层次布局 - 基于连接的层次结构"""
127
+ if not self._nodes:
128
+ return
129
+
130
+ # 构建邻接表
131
+ adjacency: Dict[QNodeItem, List[QNodeItem]] = {node: [] for node in self._nodes}
132
+ for conn in self._connections:
133
+ adjacency[conn.sourceNode].append(conn.targetNode)
134
+
135
+ # 计算每个节点的层次(距离起点的距离)
136
+ levels: Dict[QNodeItem, int] = {}
137
+
138
+ # 找到没有入边的节点作为起点
139
+ in_degree: Dict[QNodeItem, int] = {node: 0 for node in self._nodes}
140
+ for conn in self._connections:
141
+ in_degree[conn.targetNode] = in_degree.get(conn.targetNode, 0) + 1
142
+
143
+ # BFS计算层次
144
+ queue = [node for node in self._nodes if in_degree[node] == 0]
145
+ for node in queue:
146
+ levels[node] = 0
147
+
148
+ while queue:
149
+ current = queue.pop(0)
150
+ for neighbor in adjacency[current]:
151
+ if neighbor not in levels:
152
+ levels[neighbor] = levels[current] + 1
153
+ queue.append(neighbor)
154
+
155
+ # 为没有层次的节点分配层次
156
+ max_level = max(levels.values()) if levels else 0
157
+ for node in self._nodes:
158
+ if node not in levels:
159
+ levels[node] = max_level + 1
160
+
161
+ # 按层次分组
162
+ level_groups: Dict[int, List[QNodeItem]] = {}
163
+ for node, level in levels.items():
164
+ if level not in level_groups:
165
+ level_groups[level] = []
166
+ level_groups[level].append(node)
167
+
168
+ # 布局
169
+ node_size = 100
170
+ level_spacing = 150
171
+ node_spacing = 120
172
+
173
+ for level, nodes in level_groups.items():
174
+ y = level * level_spacing
175
+ count = len(nodes)
176
+ start_x = -(count * node_spacing) / 2
177
+
178
+ for i, node in enumerate(nodes):
179
+ x = start_x + i * node_spacing
180
+ node.setPos(QPointF(x, y))
181
+
182
+ def _forceDirectedLayout(self) -> None:
183
+ """力导向布局 - 模拟物理力"""
184
+ if not self._nodes:
185
+ return
186
+
187
+ # 参数
188
+ iterations = 100
189
+ repulsion_force = 1000
190
+ attraction_force = 0.01
191
+ damping = 0.8
192
+
193
+ # 初始化速度
194
+ velocities: Dict[QNodeItem, QPointF] = {node: QPointF(0, 0) for node in self._nodes}
195
+
196
+ for _ in range(iterations):
197
+ # 计算斥力
198
+ for i, node1 in enumerate(self._nodes):
199
+ for j, node2 in enumerate(self._nodes):
200
+ if i >= j:
201
+ continue
202
+
203
+ pos1 = node1.pos()
204
+ pos2 = node2.pos()
205
+ dx = pos1.x() - pos2.x()
206
+ dy = pos1.y() - pos2.y()
207
+ distance = math.sqrt(dx * dx + dy * dy)
208
+
209
+ if distance < 1:
210
+ distance = 1
211
+
212
+ force = repulsion_force / (distance * distance)
213
+ fx = force * dx / distance
214
+ fy = force * dy / distance
215
+
216
+ velocities[node1] = QPointF(
217
+ velocities[node1].x() + fx,
218
+ velocities[node1].y() + fy
219
+ )
220
+ velocities[node2] = QPointF(
221
+ velocities[node2].x() - fx,
222
+ velocities[node2].y() - fy
223
+ )
224
+
225
+ # 计算引力(连接的节点)
226
+ for conn in self._connections:
227
+ node1 = conn.sourceNode
228
+ node2 = conn.targetNode
229
+
230
+ pos1 = node1.pos()
231
+ pos2 = node2.pos()
232
+ dx = pos2.x() - pos1.x()
233
+ dy = pos2.y() - pos1.y()
234
+ distance = math.sqrt(dx * dx + dy * dy)
235
+
236
+ if distance < 1:
237
+ distance = 1
238
+
239
+ force = attraction_force * distance
240
+ fx = force * dx / distance
241
+ fy = force * dy / distance
242
+
243
+ velocities[node1] = QPointF(
244
+ velocities[node1].x() + fx,
245
+ velocities[node1].y() + fy
246
+ )
247
+ velocities[node2] = QPointF(
248
+ velocities[node2].x() - fx,
249
+ velocities[node2].y() - fy
250
+ )
251
+
252
+ # 应用速度和阻尼
253
+ for node in self._nodes:
254
+ vel = velocities[node]
255
+ new_pos = QPointF(
256
+ node.pos().x() + vel.x() * damping,
257
+ node.pos().y() + vel.y() * damping
258
+ )
259
+ node.setPos(new_pos)
260
+
261
+ # 应用阻尼
262
+ velocities[node] = QPointF(vel.x() * 0.9, vel.y() * 0.9)
263
+
264
+
265
+ class ViewportFitter:
266
+ """视口适配器 - 自动调整视图以适应所有节点"""
267
+
268
+ def __init__(self, canvas: 'NodeCanvas') -> None:
269
+ self._canvas = canvas
270
+
271
+ def fitViewport(self, margin: float = 50.0) -> None:
272
+ """
273
+ 自动调整视图以适应所有节点
274
+
275
+ Args:
276
+ margin: 边距
277
+ """
278
+ nodes = self._canvas.nodes
279
+ if not nodes:
280
+ return
281
+
282
+ # 计算边界框
283
+ rect = QRectF()
284
+ for node in nodes:
285
+ node_rect = node.sceneBoundingRect()
286
+ rect = rect.united(node_rect)
287
+
288
+ # 添加边距
289
+ rect.adjust(-margin, -margin, margin, margin)
290
+
291
+ # 适应视图
292
+ self._canvas.fitInView(rect, Qt.AspectRatioMode.KeepAspectRatio)
293
+
294
+ def centerOnNodes(self) -> None:
295
+ """将视图中心对准所有节点的中心"""
296
+ nodes = self._canvas.nodes
297
+ if not nodes:
298
+ return
299
+
300
+ # 计算中心点
301
+ center = QPointF(0, 0)
302
+ for node in nodes:
303
+ center += node.pos()
304
+ center /= len(nodes)
305
+
306
+ # 设置视图中心
307
+ self._canvas.centerOn(center)
@@ -0,0 +1,196 @@
1
+ """
2
+ 主窗口模块 - 提供完整的节点画布应用程序窗口
3
+ """
4
+
5
+ from PyQt6.QtWidgets import QFileDialog, QApplication
6
+
7
+
8
+ from typing import Optional
9
+
10
+ from PyQt6.QtWidgets import QMainWindow, QWidget, QVBoxLayout, QMessageBox
11
+ from PyQt6.QtCore import Qt
12
+
13
+ from chartflow.theme import applyTheme
14
+ from chartflow.node.node_canvas import QNodeCanvas
15
+ from chartflow.node.menu_bar import MenuBar
16
+
17
+
18
+ class NodeCanvasWindow(QMainWindow):
19
+ """
20
+ 节点画布窗口 - 提供完整的节点画布应用程序
21
+
22
+ Usage:
23
+ from PyQt6.QtWidgets import QApplication
24
+ from chartflow.node import NodeCanvasWindow
25
+
26
+ app = QApplication([])
27
+ window = NodeCanvasWindow()
28
+ window.show()
29
+ app.exec()
30
+ """
31
+
32
+ def __init__(self, parent: Optional[QWidget] = None) -> None:
33
+ super().__init__(parent)
34
+
35
+ # Apply the chartflow dark theme to the whole application, but only
36
+ # if the app has no stylesheet yet (don't clobber a user's own theme).
37
+ app = QApplication.instance()
38
+ if app is not None and not app.styleSheet():
39
+ applyTheme(app)
40
+
41
+ self.setWindowTitle("NodeCanvas")
42
+ self.setGeometry(100, 100, 1200, 800)
43
+
44
+ # 创建中央部件
45
+ central_widget = QWidget()
46
+ self.setCentralWidget(central_widget)
47
+
48
+ # 创建布局
49
+ layout = QVBoxLayout(central_widget)
50
+ layout.setContentsMargins(0, 0, 0, 0)
51
+
52
+ # 创建画布
53
+ self._canvas: QNodeCanvas = QNodeCanvas(self)
54
+ layout.addWidget(self._canvas)
55
+
56
+ # 创建菜单栏
57
+ self._menu_bar: MenuBar = MenuBar(self)
58
+ self.setMenuBar(self._menu_bar)
59
+
60
+ # 连接菜单信号
61
+ self._menu_bar.newGraphRequested.connect(self._onNewGraph)
62
+ self._menu_bar.openGraphRequested.connect(self._onOpenGraph)
63
+ self._menu_bar.saveGraphRequested.connect(self._onSaveGraph)
64
+ self._menu_bar.saveAsGraphRequested.connect(self._onSaveAsGraph)
65
+ self._menu_bar.exportGraphRequested.connect(self._onExportGraph)
66
+ self._menu_bar.importGraphRequested.connect(self._onImportGraph)
67
+ self._menu_bar.exitRequested.connect(self._onExit)
68
+
69
+ # 当前文件路径
70
+ self._current_file_path: Optional[str] = None
71
+
72
+ @property
73
+ def canvas(self) -> QNodeCanvas:
74
+ """获取画布"""
75
+ return self._canvas
76
+
77
+ def _onNewGraph(self) -> None:
78
+ """新建图"""
79
+ if self._canvas.changed:
80
+ reply = QMessageBox.question(
81
+ self, "Unsaved Changes",
82
+ "The current graph has unsaved changes. Do you want to save them?",
83
+ QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel
84
+ )
85
+
86
+ if reply == QMessageBox.StandardButton.Save:
87
+ self._onSaveGraph()
88
+ elif reply == QMessageBox.StandardButton.Cancel:
89
+ return
90
+
91
+ self._canvas.clear()
92
+ self._current_file_path = None
93
+ self._canvas.setJsonFilePath(None)
94
+
95
+ def _onOpenGraph(self, file_path: str) -> None:
96
+ """打开图"""
97
+ if self._canvas.changed:
98
+ reply = QMessageBox.question(
99
+ self, "Unsaved Changes",
100
+ "The current graph has unsaved changes. Do you want to save them?",
101
+ QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel
102
+ )
103
+
104
+ if reply == QMessageBox.StandardButton.Save:
105
+ self._onSaveGraph()
106
+ elif reply == QMessageBox.StandardButton.Cancel:
107
+ return
108
+
109
+ try:
110
+ with open(file_path, 'r', encoding='utf-8') as f:
111
+ json_data = f.read()
112
+ self._canvas.importData(json_data)
113
+ self._current_file_path = file_path
114
+ self._canvas.setJsonFilePath(file_path)
115
+ except Exception as e:
116
+ QMessageBox.critical(self, "Error", f"Failed to open file: {e}")
117
+
118
+ def _onSaveGraph(self) -> None:
119
+ """保存图"""
120
+ if self._current_file_path:
121
+ self._saveToFile(self._current_file_path)
122
+ else:
123
+ self._onSaveAsGraph("")
124
+
125
+ def _onSaveAsGraph(self, file_path: str) -> None:
126
+ """另存为"""
127
+ file_path, _ = QFileDialog.getSaveFileName(
128
+ self, "Save Graph", "", "JSON Files (*.json);;All Files (*)"
129
+ )
130
+ if file_path:
131
+ self._saveToFile(file_path)
132
+ self._current_file_path = file_path
133
+ self._canvas.setJsonFilePath(file_path)
134
+
135
+ def _saveToFile(self, file_path: str) -> None:
136
+ """保存到文件"""
137
+ try:
138
+ json_data = self._canvas.exportToJson()
139
+ with open(file_path, 'w', encoding='utf-8') as f:
140
+ f.write(json_data)
141
+ self._canvas.resetChangedState()
142
+ except Exception as e:
143
+ QMessageBox.critical(self, "Error", f"Failed to save file: {e}")
144
+
145
+ def _onExportGraph(self) -> None:
146
+ """导出图"""
147
+ file_path, _ = QFileDialog.getSaveFileName(
148
+ self, "Export Graph", "", "JSON Files (*.json);;All Files (*)"
149
+ )
150
+ if file_path:
151
+ self._saveToFile(file_path)
152
+
153
+ def _onImportGraph(self, file_path: str) -> None:
154
+ """导入图"""
155
+ try:
156
+ with open(file_path, 'r', encoding='utf-8') as f:
157
+ json_data = f.read()
158
+ self._canvas.importData(json_data)
159
+ except Exception as e:
160
+ QMessageBox.critical(self, "Error", f"Failed to import file: {e}")
161
+
162
+ def _onExit(self) -> None:
163
+ """退出"""
164
+ if self._canvas.changed:
165
+ reply = QMessageBox.question(
166
+ self, "Unsaved Changes",
167
+ "The current graph has unsaved changes. Do you want to save them?",
168
+ QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel
169
+ )
170
+
171
+ if reply == QMessageBox.StandardButton.Save:
172
+ self._onSaveGraph()
173
+ elif reply == QMessageBox.StandardButton.Cancel:
174
+ return
175
+
176
+ self.close()
177
+
178
+ def closeEvent(self, event) -> None:
179
+ """关闭事件"""
180
+ if self._canvas.changed:
181
+ reply = QMessageBox.question(
182
+ self, "Unsaved Changes",
183
+ "The current graph has unsaved changes. Do you want to save them?",
184
+ QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel
185
+ )
186
+
187
+ if reply == QMessageBox.StandardButton.Save:
188
+ self._onSaveGraph()
189
+ if self._canvas.changed: # 如果保存失败
190
+ event.ignore()
191
+ return
192
+ elif reply == QMessageBox.StandardButton.Cancel:
193
+ event.ignore()
194
+ return
195
+
196
+ event.accept()