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
chartflow/flow/qchart.py
ADDED
|
@@ -0,0 +1,1250 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
"""
|
|
3
|
+
qflow.qchart - PyQt6 widget for visualizing qfsm Stage/Logic state machines
|
|
4
|
+
|
|
5
|
+
This module provides QFlowChart, a reusable widget that visualizes qfsm state
|
|
6
|
+
machines with interactive execution controls.
|
|
7
|
+
|
|
8
|
+
Example:
|
|
9
|
+
from chartflow.flow.qchart import QFlowChart
|
|
10
|
+
from chartflow.fsm import Logic
|
|
11
|
+
from PyQt6.QtWidgets import QApplication
|
|
12
|
+
|
|
13
|
+
class MyLogic(Logic):
|
|
14
|
+
NAME = "MyLogic"
|
|
15
|
+
|
|
16
|
+
def start(self, inst):
|
|
17
|
+
return "running"
|
|
18
|
+
|
|
19
|
+
def running(self, inst):
|
|
20
|
+
return None # Stay in this state
|
|
21
|
+
|
|
22
|
+
app = QApplication([])
|
|
23
|
+
logic = MyLogic()
|
|
24
|
+
chart = QFlowChart(logic=logic)
|
|
25
|
+
chart.show()
|
|
26
|
+
app.exec()
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import re
|
|
30
|
+
import sys
|
|
31
|
+
from PyQt6.QtCore import QTime
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
from typing import Any, Optional
|
|
36
|
+
|
|
37
|
+
from PyQt6.QtWidgets import (
|
|
38
|
+
QWidget, QVBoxLayout, QHBoxLayout,
|
|
39
|
+
QPushButton, QLabel, QDialog, QTabWidget,
|
|
40
|
+
QTextEdit, QSplitter, QApplication
|
|
41
|
+
)
|
|
42
|
+
from PyQt6.QtCore import Qt, QPointF, pyqtSignal, QObject, QTimer, QVariantAnimation, QEasingCurve
|
|
43
|
+
from PyQt6.QtGui import QColor, QFont
|
|
44
|
+
|
|
45
|
+
from chartflow.fsm.stage import Stage
|
|
46
|
+
from chartflow.fsm.scheduler import Scheduler
|
|
47
|
+
from chartflow.fsm.utils import GetES
|
|
48
|
+
from chartflow.node.node_canvas import QNodeCanvas
|
|
49
|
+
from chartflow.node.enums import NodeShape, ConnectionType
|
|
50
|
+
from chartflow.node.layout_utils import DenseLayoutEngine, ViewportFitter
|
|
51
|
+
from chartflow.theme import PALETTE
|
|
52
|
+
|
|
53
|
+
from .editor import StageEditor, StageEditorDialog
|
|
54
|
+
from .chart import Chart, ChartNode, ChartEdge, ChartLogic
|
|
55
|
+
|
|
56
|
+
# Module-level QApplication reference to prevent garbage collection
|
|
57
|
+
_qapp: QApplication | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class NodeDetailDialog(QDialog):
|
|
61
|
+
"""
|
|
62
|
+
Dialog to display and edit node documentation and source code.
|
|
63
|
+
|
|
64
|
+
Uses StageEditor for code editing with syntax highlighting,
|
|
65
|
+
code completion, and context-aware analysis.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
node_name: str,
|
|
71
|
+
doc: str,
|
|
72
|
+
code: str,
|
|
73
|
+
stage_class: type[Stage] | None = None,
|
|
74
|
+
parent: QWidget | None = None
|
|
75
|
+
) -> None:
|
|
76
|
+
super().__init__(parent)
|
|
77
|
+
self._nodename = node_name
|
|
78
|
+
self._doc = doc
|
|
79
|
+
self._code = code
|
|
80
|
+
self._stage_class = stage_class
|
|
81
|
+
self._setupUi()
|
|
82
|
+
|
|
83
|
+
def _setupUi(self) -> None:
|
|
84
|
+
self.setWindowTitle(f"Node: {self._nodename}")
|
|
85
|
+
self.setGeometry(100, 100, 1000, 750)
|
|
86
|
+
self.setStyleSheet(f"""
|
|
87
|
+
QDialog {{
|
|
88
|
+
background-color: {PALETTE.window};
|
|
89
|
+
}}
|
|
90
|
+
""")
|
|
91
|
+
|
|
92
|
+
layout = QVBoxLayout(self)
|
|
93
|
+
layout.setContentsMargins(10, 10, 10, 10)
|
|
94
|
+
layout.setSpacing(10)
|
|
95
|
+
|
|
96
|
+
# Check if we can use StageEditor (need stage_class)
|
|
97
|
+
if self._stage_class is not None:
|
|
98
|
+
self._setup_editor_view(layout)
|
|
99
|
+
else:
|
|
100
|
+
self._setup_simple_view(layout)
|
|
101
|
+
|
|
102
|
+
def _setup_editor_view(self, layout: QVBoxLayout) -> None:
|
|
103
|
+
"""Setup view with StageEditor for editing."""
|
|
104
|
+
# Doc header
|
|
105
|
+
if self._doc:
|
|
106
|
+
doc_label = QLabel(f"Documentation: {self._doc}")
|
|
107
|
+
doc_label.setStyleSheet(f"""
|
|
108
|
+
QLabel {{
|
|
109
|
+
color: {PALETTE.text_dim};
|
|
110
|
+
font-size: 12px;
|
|
111
|
+
padding: 6px 8px;
|
|
112
|
+
background-color: {PALETTE.panel};
|
|
113
|
+
border: 1px solid {PALETTE.border};
|
|
114
|
+
border-radius: 4px;
|
|
115
|
+
}}
|
|
116
|
+
""")
|
|
117
|
+
doc_label.setWordWrap(True)
|
|
118
|
+
layout.addWidget(doc_label)
|
|
119
|
+
|
|
120
|
+
# StageEditor for code editing
|
|
121
|
+
self._editor = StageEditor()
|
|
122
|
+
self._editor.set_stage_context(self._stage_class, self._nodename)
|
|
123
|
+
|
|
124
|
+
if self._code:
|
|
125
|
+
self._editor.set_code(self._code)
|
|
126
|
+
|
|
127
|
+
layout.addWidget(self._editor, stretch=1)
|
|
128
|
+
|
|
129
|
+
# Buttons
|
|
130
|
+
btn_layout = QHBoxLayout()
|
|
131
|
+
btn_layout.addStretch()
|
|
132
|
+
|
|
133
|
+
self._btn_close = QPushButton("Close")
|
|
134
|
+
self._btn_close.setObjectName("primary")
|
|
135
|
+
self._btn_close.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
136
|
+
self._btn_close.clicked.connect(self.accept)
|
|
137
|
+
btn_layout.addWidget(self._btn_close)
|
|
138
|
+
|
|
139
|
+
layout.addLayout(btn_layout)
|
|
140
|
+
|
|
141
|
+
def _setup_simple_view(self, layout: QVBoxLayout) -> None:
|
|
142
|
+
"""Setup simple read-only view when stage_class is not available."""
|
|
143
|
+
# Tab widget
|
|
144
|
+
tabs = QTabWidget()
|
|
145
|
+
layout.addWidget(tabs)
|
|
146
|
+
|
|
147
|
+
# Doc tab
|
|
148
|
+
doc_edit = QTextEdit()
|
|
149
|
+
doc_edit.setReadOnly(True)
|
|
150
|
+
doc_edit.setPlainText(self._doc if self._doc else "No documentation available.")
|
|
151
|
+
doc_edit.setStyleSheet(self._textStyle())
|
|
152
|
+
tabs.addTab(doc_edit, "Documentation")
|
|
153
|
+
|
|
154
|
+
# Code tab
|
|
155
|
+
code_edit = QTextEdit()
|
|
156
|
+
code_edit.setReadOnly(True)
|
|
157
|
+
code_edit.setPlainText(self._code if self._code else "No source code available.")
|
|
158
|
+
code_edit.setStyleSheet(self._textStyle())
|
|
159
|
+
tabs.addTab(code_edit, "Source Code")
|
|
160
|
+
|
|
161
|
+
# Close button
|
|
162
|
+
btn_layout = QHBoxLayout()
|
|
163
|
+
btn_layout.addStretch()
|
|
164
|
+
btn_close = QPushButton("Close")
|
|
165
|
+
btn_close.setObjectName("primary")
|
|
166
|
+
btn_close.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
167
|
+
btn_close.clicked.connect(self.accept)
|
|
168
|
+
btn_layout.addWidget(btn_close)
|
|
169
|
+
layout.addLayout(btn_layout)
|
|
170
|
+
|
|
171
|
+
def _textStyle(self) -> str:
|
|
172
|
+
return f"""
|
|
173
|
+
QTextEdit {{
|
|
174
|
+
background-color: {PALETTE.code_bg};
|
|
175
|
+
color: {PALETTE.code_text};
|
|
176
|
+
border: 1px solid {PALETTE.border};
|
|
177
|
+
border-radius: 9px;
|
|
178
|
+
font-family: 'JetBrains Mono', 'IBM Plex Mono', 'Consolas', monospace;
|
|
179
|
+
font-size: 12.5px;
|
|
180
|
+
padding: 11px 14px;
|
|
181
|
+
}}
|
|
182
|
+
QTextEdit QScrollBar:vertical {{
|
|
183
|
+
background-color: {PALETTE.code_bg};
|
|
184
|
+
width: 9px;
|
|
185
|
+
}}
|
|
186
|
+
QTextEdit QScrollBar::handle:vertical {{
|
|
187
|
+
background-color: #2A3B52;
|
|
188
|
+
border-radius: 8px;
|
|
189
|
+
min-height: 20px;
|
|
190
|
+
}}
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class _EventBridge(QObject):
|
|
195
|
+
"""Bridge to marshal events from any thread to main thread via Qt signals."""
|
|
196
|
+
stateChanged = pyqtSignal(dict)
|
|
197
|
+
|
|
198
|
+
def __init__(self, handler: callable) -> None:
|
|
199
|
+
super().__init__()
|
|
200
|
+
self._handler = handler
|
|
201
|
+
self.stateChanged.connect(self._onStateChanged)
|
|
202
|
+
|
|
203
|
+
def _onStateChanged(self, data: dict) -> None:
|
|
204
|
+
self._handler(data)
|
|
205
|
+
|
|
206
|
+
def onEvent(self, data: dict) -> None:
|
|
207
|
+
"""Called from any thread - emits signal to main thread."""
|
|
208
|
+
self.stateChanged.emit(data)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class QFlowChart(QWidget):
|
|
212
|
+
"""
|
|
213
|
+
PyQt6 widget for visualizing and interacting with qfsm state machines.
|
|
214
|
+
|
|
215
|
+
Features:
|
|
216
|
+
- Visual node graph display (auto-generated from Stage/Logic states)
|
|
217
|
+
- Step-by-step execution with manual control
|
|
218
|
+
- Auto-execution mode
|
|
219
|
+
- State highlighting and transitions
|
|
220
|
+
- Thread-safe event handling
|
|
221
|
+
|
|
222
|
+
Attributes:
|
|
223
|
+
stage: The Stage/Logic instance being visualized
|
|
224
|
+
scheduler: The Scheduler singleton instance
|
|
225
|
+
|
|
226
|
+
Example:
|
|
227
|
+
# Simple usage with default controls
|
|
228
|
+
logic = MyLogic()
|
|
229
|
+
chart = QFlowChart(logic=logic)
|
|
230
|
+
chart.show()
|
|
231
|
+
|
|
232
|
+
# Custom control (no built-in buttons)
|
|
233
|
+
chart = QFlowChart(logic=logic, show_controls=False)
|
|
234
|
+
chart.doStep() # Manual step
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
# Default colors
|
|
238
|
+
# 卡片底色沿用 uipcweb 白底;执行中(start)节点保持之前的亮绿色
|
|
239
|
+
COLOR_DEFAULT = "#FFFFFF"
|
|
240
|
+
COLOR_BORDER = "#D6D9DF"
|
|
241
|
+
COLOR_ACTIVE = "#4CAF50"
|
|
242
|
+
COLOR_ACTIVE_BORDER = "#2E7D32"
|
|
243
|
+
COLOR_FINISHED = "#FF6B6B"
|
|
244
|
+
|
|
245
|
+
def __init__(
|
|
246
|
+
self,
|
|
247
|
+
stage: Stage | None = None,
|
|
248
|
+
show_controls: bool = True,
|
|
249
|
+
parent: QWidget | None = None
|
|
250
|
+
) -> None:
|
|
251
|
+
"""
|
|
252
|
+
Initialize QFlowChart.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
stage: Stage/Logic instance to visualize (can be set later via setStage)
|
|
256
|
+
show_controls: Whether to show control buttons (step)
|
|
257
|
+
parent: Parent widget
|
|
258
|
+
"""
|
|
259
|
+
# Ensure QApplication exists (for headless/test environments)
|
|
260
|
+
global _qapp
|
|
261
|
+
if QApplication.instance() is None:
|
|
262
|
+
_qapp = QApplication(sys.argv)
|
|
263
|
+
|
|
264
|
+
# Apply the chartflow dark theme if the app has no stylesheet yet.
|
|
265
|
+
from chartflow.theme import applyTheme
|
|
266
|
+
_app = QApplication.instance()
|
|
267
|
+
if _app is not None and not _app.styleSheet():
|
|
268
|
+
applyTheme(_app)
|
|
269
|
+
|
|
270
|
+
super().__init__(parent)
|
|
271
|
+
|
|
272
|
+
# Core components
|
|
273
|
+
self._stage: Stage | None = None
|
|
274
|
+
self._scheduler: Scheduler = Scheduler()
|
|
275
|
+
self._event_bridge: _EventBridge | None = None
|
|
276
|
+
|
|
277
|
+
# Chart data model (for IChart interface compatibility)
|
|
278
|
+
self._chart = Chart()
|
|
279
|
+
|
|
280
|
+
# Node mapping: state_name -> NodeItem
|
|
281
|
+
self._nodemap: dict[str, Any] = {}
|
|
282
|
+
self._current_node: Any | None = None
|
|
283
|
+
|
|
284
|
+
# Animation tracking
|
|
285
|
+
self._fade_timer: QTimer | None = None
|
|
286
|
+
self._fade_animations: list = []
|
|
287
|
+
|
|
288
|
+
# UI options
|
|
289
|
+
self._show_controls = show_controls
|
|
290
|
+
|
|
291
|
+
# Layout pending flag (for deferred layout when viewport not ready)
|
|
292
|
+
self._layout_pending = False
|
|
293
|
+
|
|
294
|
+
# Setup UI
|
|
295
|
+
self._setupUi()
|
|
296
|
+
|
|
297
|
+
# Set stage if provided
|
|
298
|
+
if stage is not None:
|
|
299
|
+
self.setStage(stage)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _setupUi(self) -> None:
|
|
303
|
+
"""Setup the user interface."""
|
|
304
|
+
# Main horizontal layout: left (canvas + controls) | right (event log)
|
|
305
|
+
self._main_layout = QHBoxLayout(self)
|
|
306
|
+
self._main_layout.setContentsMargins(10, 10, 10, 10)
|
|
307
|
+
self._main_layout.setSpacing(10)
|
|
308
|
+
|
|
309
|
+
# Left side: vertical layout with canvas and controls
|
|
310
|
+
self._left_layout = QVBoxLayout()
|
|
311
|
+
self._left_layout.setSpacing(10)
|
|
312
|
+
|
|
313
|
+
# Node Canvas
|
|
314
|
+
self._canvas = QNodeCanvas(self)
|
|
315
|
+
self._left_layout.addWidget(self._canvas, stretch=1)
|
|
316
|
+
|
|
317
|
+
# Control panel
|
|
318
|
+
self._ctrl_layout = QHBoxLayout()
|
|
319
|
+
self._ctrl_layout.setSpacing(10)
|
|
320
|
+
|
|
321
|
+
# Info label
|
|
322
|
+
self._infolbl = QLabel("State: - | Now: 0")
|
|
323
|
+
self._infolbl.setStyleSheet(f"""
|
|
324
|
+
QLabel {{
|
|
325
|
+
background-color: {PALETTE.input};
|
|
326
|
+
color: {PALETTE.success};
|
|
327
|
+
padding: 8px 15px;
|
|
328
|
+
border: 1px solid {PALETTE.border};
|
|
329
|
+
border-radius: 4px;
|
|
330
|
+
font-family: Consolas, Monaco, monospace;
|
|
331
|
+
font-size: 12px;
|
|
332
|
+
font-weight: bold;
|
|
333
|
+
}}
|
|
334
|
+
""")
|
|
335
|
+
self._ctrl_layout.addWidget(self._infolbl)
|
|
336
|
+
self._ctrl_layout.addStretch()
|
|
337
|
+
|
|
338
|
+
# Control buttons
|
|
339
|
+
if self._show_controls:
|
|
340
|
+
self._btn_step = QPushButton("Next Step")
|
|
341
|
+
self._btn_step.setToolTip("Execute one step")
|
|
342
|
+
self._btn_step.clicked.connect(self.doStep)
|
|
343
|
+
self._btn_step.setObjectName("primary")
|
|
344
|
+
self._btn_step.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
345
|
+
self._ctrl_layout.addWidget(self._btn_step)
|
|
346
|
+
|
|
347
|
+
self._left_layout.addLayout(self._ctrl_layout)
|
|
348
|
+
self._main_layout.addLayout(self._left_layout, stretch=3)
|
|
349
|
+
|
|
350
|
+
# Right side: event log
|
|
351
|
+
self._event_log = QTextEdit()
|
|
352
|
+
self._event_log.setReadOnly(True)
|
|
353
|
+
self._event_log.setMaximumWidth(300)
|
|
354
|
+
self._event_log.setStyleSheet(f"""
|
|
355
|
+
QTextEdit {{
|
|
356
|
+
background-color: {PALETTE.code_bg};
|
|
357
|
+
color: {PALETTE.code_text};
|
|
358
|
+
border: 1px solid {PALETTE.border};
|
|
359
|
+
border-radius: 9px;
|
|
360
|
+
font-family: "JetBrains Mono", "IBM Plex Mono", Consolas, monospace;
|
|
361
|
+
font-size: 11.5px;
|
|
362
|
+
padding: 9px 12px;
|
|
363
|
+
}}
|
|
364
|
+
QTextEdit QScrollBar:vertical {{
|
|
365
|
+
background-color: {PALETTE.code_bg};
|
|
366
|
+
width: 9px;
|
|
367
|
+
}}
|
|
368
|
+
QTextEdit QScrollBar::handle:vertical {{
|
|
369
|
+
background-color: #2A3B52;
|
|
370
|
+
border-radius: 8px;
|
|
371
|
+
min-height: 20px;
|
|
372
|
+
}}
|
|
373
|
+
""")
|
|
374
|
+
self._event_log.setPlaceholderText("Event log...")
|
|
375
|
+
self._main_layout.addWidget(self._event_log, stretch=1)
|
|
376
|
+
|
|
377
|
+
# Button cooldown timer
|
|
378
|
+
self._btn_cooldown_timer: QTimer | None = None
|
|
379
|
+
|
|
380
|
+
@property
|
|
381
|
+
def stage(self) -> Stage | None:
|
|
382
|
+
"""Get the current Stage/Logic instance."""
|
|
383
|
+
return self._stage
|
|
384
|
+
|
|
385
|
+
@property
|
|
386
|
+
def scheduler(self) -> Scheduler:
|
|
387
|
+
"""Get the scheduler instance."""
|
|
388
|
+
return self._scheduler
|
|
389
|
+
|
|
390
|
+
def setStage(self, stage: Stage) -> None:
|
|
391
|
+
"""
|
|
392
|
+
Set or change the Stage/Logic instance to visualize.
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
stage: Stage/Logic instance to visualize
|
|
396
|
+
"""
|
|
397
|
+
# Cleanup previous
|
|
398
|
+
self._cleanup()
|
|
399
|
+
|
|
400
|
+
self._stage = stage
|
|
401
|
+
|
|
402
|
+
# Build node graph from stage states
|
|
403
|
+
self._buildGraph()
|
|
404
|
+
|
|
405
|
+
# Setup event bridges for async updates
|
|
406
|
+
self._event_bridge = _EventBridge(self._onStateChanged)
|
|
407
|
+
stage.listenFor("changed", self._event_bridge.onEvent)
|
|
408
|
+
|
|
409
|
+
self._enter_bridge = _EventBridge(self._onStateEnter)
|
|
410
|
+
stage.listenFor("enter", self._enter_bridge.onEvent)
|
|
411
|
+
|
|
412
|
+
self._behavior_bridge = _EventBridge(self._onCompleted)
|
|
413
|
+
stage.listenFor("behavior_completed", self._behavior_bridge.onEvent)
|
|
414
|
+
|
|
415
|
+
self._compisited_bridge = _EventBridge(self._onCompisited)
|
|
416
|
+
stage.listenFor("compisited", self._compisited_bridge.onEvent)
|
|
417
|
+
|
|
418
|
+
# Initial highlight and info update
|
|
419
|
+
current = stage.current
|
|
420
|
+
self._highlightNode(current or "-")
|
|
421
|
+
self._updateInfo(current)
|
|
422
|
+
|
|
423
|
+
self._updateButtons()
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _buildGraph(self) -> None:
|
|
427
|
+
"""Build node graph from stage states with auto-layout."""
|
|
428
|
+
self._canvas.clear()
|
|
429
|
+
self._nodemap.clear()
|
|
430
|
+
self._current_node = None
|
|
431
|
+
|
|
432
|
+
if self._stage is None:
|
|
433
|
+
return
|
|
434
|
+
|
|
435
|
+
states = self._stage.states
|
|
436
|
+
if not states:
|
|
437
|
+
return
|
|
438
|
+
|
|
439
|
+
# Create nodes at origin first (layout engine will position them)
|
|
440
|
+
# 获取 Stage 类的装饰器信息
|
|
441
|
+
stage_class = self._stage.__class__
|
|
442
|
+
recursive_states = getattr(stage_class, "__recursive__", set())
|
|
443
|
+
behaviors = getattr(stage_class, "__behaviors__", {})
|
|
444
|
+
|
|
445
|
+
for state in states:
|
|
446
|
+
shape = self._getShapeForState(state)
|
|
447
|
+
node = self._canvas.addNode(state, QPointF(0, 0), shape)
|
|
448
|
+
self._nodemap[state] = node
|
|
449
|
+
|
|
450
|
+
# 设置 recursive 装饰器
|
|
451
|
+
if state in recursive_states:
|
|
452
|
+
node.decorator = "recursive"
|
|
453
|
+
# 设置 behavior/sequence/selector/parallel 装饰器
|
|
454
|
+
elif state in behaviors:
|
|
455
|
+
behavior = behaviors[state]
|
|
456
|
+
composite = behavior.get("composite")
|
|
457
|
+
if composite in ("sequence", "selector", "parallel"):
|
|
458
|
+
node.decorator = composite
|
|
459
|
+
else:
|
|
460
|
+
node.decorator = "behavior"
|
|
461
|
+
|
|
462
|
+
# Connect node edit request to show details
|
|
463
|
+
node.editRequested.connect(self._onNodeEditRequested)
|
|
464
|
+
|
|
465
|
+
# Create edges between states
|
|
466
|
+
self._createEdges(states)
|
|
467
|
+
|
|
468
|
+
# Apply auto layout and fit viewport
|
|
469
|
+
self._doAutoLayout()
|
|
470
|
+
|
|
471
|
+
def _doAutoLayout(self) -> None:
|
|
472
|
+
"""Apply auto-layout and fit viewport to show all nodes."""
|
|
473
|
+
if not self._nodemap:
|
|
474
|
+
return
|
|
475
|
+
|
|
476
|
+
# Check if viewport is ready (has valid size)
|
|
477
|
+
viewport = self._canvas.viewport()
|
|
478
|
+
if viewport.width() <= 0 or viewport.height() <= 0:
|
|
479
|
+
# Viewport not ready, defer layout
|
|
480
|
+
self._layout_pending = True
|
|
481
|
+
return
|
|
482
|
+
|
|
483
|
+
self._layout_pending = False
|
|
484
|
+
node_count = len(self._nodemap)
|
|
485
|
+
|
|
486
|
+
if node_count == 1:
|
|
487
|
+
# Single node: just center it
|
|
488
|
+
ViewportFitter(self._canvas).centerOnNodes()
|
|
489
|
+
elif node_count > 1:
|
|
490
|
+
# Multiple nodes: apply hierarchical layout
|
|
491
|
+
engine = DenseLayoutEngine(self._canvas)
|
|
492
|
+
engine.autoLayout("hierarchical")
|
|
493
|
+
# Fit viewport with margin to show all nodes
|
|
494
|
+
ViewportFitter(self._canvas).fitViewport(margin=50)
|
|
495
|
+
|
|
496
|
+
# Force update
|
|
497
|
+
self._canvas.viewport().update()
|
|
498
|
+
|
|
499
|
+
def relayout(self) -> None:
|
|
500
|
+
"""
|
|
501
|
+
Re-apply auto-layout to current graph.
|
|
502
|
+
|
|
503
|
+
Call this after manual node movement or when the graph needs reorganization.
|
|
504
|
+
"""
|
|
505
|
+
self._doAutoLayout()
|
|
506
|
+
|
|
507
|
+
def _fitViewport(self) -> None:
|
|
508
|
+
"""Fit viewport to show all nodes without recalculating layout."""
|
|
509
|
+
if not self._nodemap:
|
|
510
|
+
return
|
|
511
|
+
|
|
512
|
+
viewport = self._canvas.viewport()
|
|
513
|
+
if viewport.width() <= 0 or viewport.height() <= 0:
|
|
514
|
+
return
|
|
515
|
+
|
|
516
|
+
node_count = len(self._nodemap)
|
|
517
|
+
if node_count == 1:
|
|
518
|
+
ViewportFitter(self._canvas).centerOnNodes()
|
|
519
|
+
else:
|
|
520
|
+
ViewportFitter(self._canvas).fitViewport(margin=50)
|
|
521
|
+
|
|
522
|
+
self._canvas.viewport().update()
|
|
523
|
+
|
|
524
|
+
def showEvent(self, event) -> None:
|
|
525
|
+
"""Handle widget show event - perform deferred layout if needed."""
|
|
526
|
+
super().showEvent(event)
|
|
527
|
+
|
|
528
|
+
# If layout was deferred due to viewport not being ready, do it now
|
|
529
|
+
if self._layout_pending and self._nodemap:
|
|
530
|
+
# Use single shot timer to ensure viewport has correct size
|
|
531
|
+
QTimer.singleShot(0, self._doAutoLayout)
|
|
532
|
+
|
|
533
|
+
def resizeEvent(self, event) -> None:
|
|
534
|
+
"""Handle resize event - re-fit viewport to new size."""
|
|
535
|
+
super().resizeEvent(event)
|
|
536
|
+
|
|
537
|
+
# Only refit if we have nodes and layout is not pending
|
|
538
|
+
if self._nodemap and not self._layout_pending:
|
|
539
|
+
# Use timer to avoid excessive refitting during resize
|
|
540
|
+
QTimer.singleShot(100, self._fitViewport)
|
|
541
|
+
|
|
542
|
+
def _getShapeForState(self, state: str) -> NodeShape:
|
|
543
|
+
"""Determine node shape based on state name."""
|
|
544
|
+
lower = state.lower()
|
|
545
|
+
if "entry" in lower or lower == "start":
|
|
546
|
+
return NodeShape.ELLIPSE
|
|
547
|
+
if "exit" in lower or lower == "end" or lower == "stop":
|
|
548
|
+
return NodeShape.ELLIPSE
|
|
549
|
+
if "decision" in lower or "check" in lower or "if" in lower:
|
|
550
|
+
return NodeShape.DIAMOND
|
|
551
|
+
return NodeShape.RECTANGLE
|
|
552
|
+
|
|
553
|
+
def _createEdges(self, states: list[str]) -> None:
|
|
554
|
+
"""
|
|
555
|
+
Create edges between states based on possible transitions.
|
|
556
|
+
|
|
557
|
+
Edge types:
|
|
558
|
+
- Behavior tree parent -> child (structural relationship)
|
|
559
|
+
- Regular state -> return target (execution flow)
|
|
560
|
+
"""
|
|
561
|
+
|
|
562
|
+
if self._stage is None:
|
|
563
|
+
return
|
|
564
|
+
|
|
565
|
+
# Get behavior tree info
|
|
566
|
+
stage_class = self._stage.__class__
|
|
567
|
+
behaviors = getattr(stage_class, "__behaviors__", {})
|
|
568
|
+
|
|
569
|
+
# Get node info with source code
|
|
570
|
+
try:
|
|
571
|
+
nodes_info = self._stage.extractNodes()
|
|
572
|
+
except Exception:
|
|
573
|
+
nodes_info = {}
|
|
574
|
+
|
|
575
|
+
# Pattern to match return "state_name" or return 'state_name'
|
|
576
|
+
return_pattern = re.compile(r'return\s+["\']([a-zA-Z_][a-zA-Z0-9_]*)["\']')
|
|
577
|
+
|
|
578
|
+
# First: Create behavior tree structural edges
|
|
579
|
+
for state in states:
|
|
580
|
+
if state not in self._nodemap:
|
|
581
|
+
continue
|
|
582
|
+
|
|
583
|
+
if state in behaviors:
|
|
584
|
+
behavior = behaviors[state]
|
|
585
|
+
children = behavior.get("children", [])
|
|
586
|
+
composite = behavior.get("composite")
|
|
587
|
+
|
|
588
|
+
# Normalize children to list
|
|
589
|
+
if isinstance(children, str):
|
|
590
|
+
children = [children]
|
|
591
|
+
elif not children:
|
|
592
|
+
children = []
|
|
593
|
+
|
|
594
|
+
# Create edges based on behavior type
|
|
595
|
+
if composite in ("sequence", "selector", "parallel"):
|
|
596
|
+
# Composite: connect parent to all children (structural)
|
|
597
|
+
for child in children:
|
|
598
|
+
if child in self._nodemap and child != state:
|
|
599
|
+
self._canvas.addConnection(
|
|
600
|
+
self._nodemap[state],
|
|
601
|
+
self._nodemap[child],
|
|
602
|
+
connection_type=ConnectionType.BEZIER
|
|
603
|
+
)
|
|
604
|
+
else:
|
|
605
|
+
# Simple behavior: single child transition
|
|
606
|
+
for child in children:
|
|
607
|
+
if child in self._nodemap and child != state:
|
|
608
|
+
self._canvas.addConnection(
|
|
609
|
+
self._nodemap[state],
|
|
610
|
+
self._nodemap[child],
|
|
611
|
+
connection_type=ConnectionType.BEZIER
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
# Second: Create regular state transition edges from return statements
|
|
615
|
+
for state in states:
|
|
616
|
+
if state not in self._nodemap:
|
|
617
|
+
continue
|
|
618
|
+
|
|
619
|
+
# Skip behavior tree nodes - their children are handled above
|
|
620
|
+
if state in behaviors:
|
|
621
|
+
continue
|
|
622
|
+
|
|
623
|
+
# Regular state: parse return statements
|
|
624
|
+
info = nodes_info.get(state, {})
|
|
625
|
+
code = info.get("code", "")
|
|
626
|
+
targets = return_pattern.findall(code)
|
|
627
|
+
|
|
628
|
+
for target in targets:
|
|
629
|
+
if target in self._nodemap and target != state:
|
|
630
|
+
self._canvas.addConnection(
|
|
631
|
+
self._nodemap[state],
|
|
632
|
+
self._nodemap[target],
|
|
633
|
+
connection_type=ConnectionType.BEZIER
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
def _highlightNode(self, state: str) -> None:
|
|
637
|
+
"""Highlight the current active node."""
|
|
638
|
+
# Reset previous: clear highlight and single marker
|
|
639
|
+
if self._current_node is not None:
|
|
640
|
+
self._current_node.setColor(QColor(self.COLOR_DEFAULT))
|
|
641
|
+
self._current_node.setBorder(2, QColor(self.COLOR_BORDER))
|
|
642
|
+
self._current_node.setSingleMarkerVisible(False)
|
|
643
|
+
|
|
644
|
+
# Highlight new
|
|
645
|
+
if state in self._nodemap:
|
|
646
|
+
node = self._nodemap[state]
|
|
647
|
+
node.setColor(QColor(self.COLOR_ACTIVE))
|
|
648
|
+
node.setBorder(2, QColor(self.COLOR_ACTIVE_BORDER))
|
|
649
|
+
self._current_node = node
|
|
650
|
+
|
|
651
|
+
self._canvas.viewport().update()
|
|
652
|
+
|
|
653
|
+
def _updateInfo(self, state: str | None = None) -> None:
|
|
654
|
+
"""Update the info label."""
|
|
655
|
+
if state is None:
|
|
656
|
+
state = self._stage.current if self._stage else "-"
|
|
657
|
+
now = self._stage.now if self._stage else 0
|
|
658
|
+
self._infolbl.setText(f"State: {state} | Now: {now}")
|
|
659
|
+
|
|
660
|
+
def _isFinished(self) -> bool:
|
|
661
|
+
"""Check if execution has finished."""
|
|
662
|
+
if self._stage is None:
|
|
663
|
+
return True
|
|
664
|
+
# Check for Logic instances
|
|
665
|
+
if hasattr(self._stage, "alive"):
|
|
666
|
+
return not self._stage.alive
|
|
667
|
+
# Check if current state is exit-like
|
|
668
|
+
if self._stage.current in ("exit", "end", "stop", None):
|
|
669
|
+
return True
|
|
670
|
+
return False
|
|
671
|
+
|
|
672
|
+
def _updateButtons(self) -> None:
|
|
673
|
+
"""Update button states."""
|
|
674
|
+
if not self._show_controls:
|
|
675
|
+
return
|
|
676
|
+
self._btn_step.setEnabled(not self._isFinished())
|
|
677
|
+
|
|
678
|
+
def doStep(self) -> None:
|
|
679
|
+
"""
|
|
680
|
+
Execute one step of the state machine.
|
|
681
|
+
|
|
682
|
+
Pushes 'system_next' event to trigger scheduler tick (non-blocking).
|
|
683
|
+
Safe to call from UI thread.
|
|
684
|
+
"""
|
|
685
|
+
if self._isFinished():
|
|
686
|
+
return
|
|
687
|
+
|
|
688
|
+
# Double-check alive status before lighting up marker
|
|
689
|
+
if hasattr(self._stage, "alive") and not self._stage.alive:
|
|
690
|
+
return
|
|
691
|
+
|
|
692
|
+
# Light up single marker on current node (execution starting)
|
|
693
|
+
current = self._stage.current if self._stage else None
|
|
694
|
+
if current and current in self._nodemap:
|
|
695
|
+
self._nodemap[current].setSingleMarkerVisible(True)
|
|
696
|
+
|
|
697
|
+
self._scheduler.pushEvent("system_next", {})
|
|
698
|
+
|
|
699
|
+
def doRun(self, ticks: int = 1) -> None:
|
|
700
|
+
"""
|
|
701
|
+
Execute multiple steps (non-blocking via events).
|
|
702
|
+
|
|
703
|
+
Args:
|
|
704
|
+
ticks: Number of ticks to execute
|
|
705
|
+
"""
|
|
706
|
+
for _ in range(ticks):
|
|
707
|
+
if self._isFinished():
|
|
708
|
+
break
|
|
709
|
+
self.doStep()
|
|
710
|
+
|
|
711
|
+
def _logEvent(self, event_type: str, details: str) -> None:
|
|
712
|
+
"""Log an event to the event log panel with colored event types."""
|
|
713
|
+
if hasattr(self, '_event_log') and self._event_log:
|
|
714
|
+
time_str = QTime.currentTime().toString("hh:mm:ss.zzz")
|
|
715
|
+
|
|
716
|
+
# Color mapping for event types (tuned for the dark event-log bg)
|
|
717
|
+
color_map = {
|
|
718
|
+
"ENTER": "#4FC1FF", # light blue
|
|
719
|
+
"COMPISITED": "#FFA940", # orange (accent)
|
|
720
|
+
"CHANGED": "#7ee787", # light green
|
|
721
|
+
"COMPLETED": "#C792EA", # light purple
|
|
722
|
+
}
|
|
723
|
+
color = color_map.get(event_type, PALETTE.code_text)
|
|
724
|
+
|
|
725
|
+
# Use HTML for colored event type (light text on dark log)
|
|
726
|
+
html = (f"<span style='color:{PALETTE.code_dim}'>[{time_str}]</span> "
|
|
727
|
+
f"<span style='color:{color};font-weight:bold'>{event_type}</span>: "
|
|
728
|
+
f"<span style='color:{PALETTE.code_text}'>{details}</span>")
|
|
729
|
+
self._event_log.append(html)
|
|
730
|
+
|
|
731
|
+
# Auto-scroll to bottom
|
|
732
|
+
scrollbar = self._event_log.verticalScrollBar()
|
|
733
|
+
scrollbar.setValue(scrollbar.maximum())
|
|
734
|
+
|
|
735
|
+
def _onStateEnter(self, data: dict) -> None:
|
|
736
|
+
"""Handle state enter event from event system.
|
|
737
|
+
|
|
738
|
+
This is called before a state executes, allowing the UI to show
|
|
739
|
+
which state is about to run.
|
|
740
|
+
"""
|
|
741
|
+
state_name = data.get("state")
|
|
742
|
+
self._logEvent("ENTER", state_name or "-")
|
|
743
|
+
|
|
744
|
+
if state_name and state_name in self._nodemap:
|
|
745
|
+
# Clear ALL nodes' marker and green highlight (insurance cleanup)
|
|
746
|
+
for node in self._nodemap.values():
|
|
747
|
+
node.setSingleMarkerVisible(False)
|
|
748
|
+
node.setColor(QColor(self.COLOR_DEFAULT))
|
|
749
|
+
node.setBorder(2, QColor(self.COLOR_BORDER))
|
|
750
|
+
|
|
751
|
+
node = self._nodemap[state_name]
|
|
752
|
+
# Add lightning marker and green highlight
|
|
753
|
+
node.setSingleMarkerVisible(True)
|
|
754
|
+
node.setColor(QColor(self.COLOR_ACTIVE))
|
|
755
|
+
node.setBorder(2, QColor(self.COLOR_ACTIVE_BORDER))
|
|
756
|
+
self._current_node = node
|
|
757
|
+
self._updateInfo(state_name)
|
|
758
|
+
|
|
759
|
+
def _onStateChanged(self, data: dict) -> None:
|
|
760
|
+
"""Handle state change event from event system.
|
|
761
|
+
|
|
762
|
+
Logic:
|
|
763
|
+
- If old != new: remove old's marker and highlight, highlight new (green)
|
|
764
|
+
- If old == new: only remove marker
|
|
765
|
+
"""
|
|
766
|
+
to_state = data.get("new")
|
|
767
|
+
from_state = data.get("old")
|
|
768
|
+
event_type = data.get("type", "-")
|
|
769
|
+
result = data.get("result")
|
|
770
|
+
|
|
771
|
+
self._logEvent("CHANGED", f"{from_state} -> {to_state} ({event_type}, result={result})")
|
|
772
|
+
|
|
773
|
+
if not to_state:
|
|
774
|
+
return
|
|
775
|
+
|
|
776
|
+
# For behavior events: from_state is the node that just FINISHED executing
|
|
777
|
+
# Should clear its marker, then highlight the new state
|
|
778
|
+
if event_type == "behavior":
|
|
779
|
+
# Clear lightning from the state that just finished executing
|
|
780
|
+
if from_state and from_state in self._nodemap:
|
|
781
|
+
self._nodemap[from_state].setSingleMarkerVisible(False)
|
|
782
|
+
# Continue to highlight the new state (to_state)
|
|
783
|
+
|
|
784
|
+
# For regular state transitions: from_state -> to_state
|
|
785
|
+
if from_state and from_state in self._nodemap:
|
|
786
|
+
old_node = self._nodemap[from_state]
|
|
787
|
+
# Always remove lightning marker from old state
|
|
788
|
+
old_node.setSingleMarkerVisible(False)
|
|
789
|
+
|
|
790
|
+
if from_state != to_state:
|
|
791
|
+
# old != new: also remove highlight (green) from old state
|
|
792
|
+
old_node.setColor(QColor(self.COLOR_DEFAULT))
|
|
793
|
+
old_node.setBorder(2, QColor(self.COLOR_BORDER))
|
|
794
|
+
|
|
795
|
+
# Highlight new state (green)
|
|
796
|
+
if to_state in self._nodemap:
|
|
797
|
+
new_node = self._nodemap[to_state]
|
|
798
|
+
new_node.setColor(QColor(self.COLOR_ACTIVE))
|
|
799
|
+
new_node.setBorder(2, QColor(self.COLOR_ACTIVE_BORDER))
|
|
800
|
+
self._current_node = new_node
|
|
801
|
+
|
|
802
|
+
self._updateInfo(to_state)
|
|
803
|
+
self._updateButtons()
|
|
804
|
+
|
|
805
|
+
# Visual feedback for finished state
|
|
806
|
+
if to_state in ("exit", "end", "stop"):
|
|
807
|
+
self._infolbl.setStyleSheet(f"""
|
|
808
|
+
QLabel {{
|
|
809
|
+
background-color: {PALETTE.input};
|
|
810
|
+
color: {PALETTE.error};
|
|
811
|
+
padding: 8px 15px;
|
|
812
|
+
border: 1px solid {PALETTE.border};
|
|
813
|
+
border-radius: 4px;
|
|
814
|
+
font-family: Consolas, Monaco, monospace;
|
|
815
|
+
font-size: 12px;
|
|
816
|
+
font-weight: bold;
|
|
817
|
+
}}
|
|
818
|
+
""")
|
|
819
|
+
|
|
820
|
+
def _onCompisited(self, data: dict) -> None:
|
|
821
|
+
"""
|
|
822
|
+
Handle compisited event from stage.
|
|
823
|
+
|
|
824
|
+
When a composite node exits:
|
|
825
|
+
- Composite node itself triggers gradient color effect
|
|
826
|
+
- Clear child node's color and marker (but not gradient effect)
|
|
827
|
+
"""
|
|
828
|
+
composite_name = data.get("name")
|
|
829
|
+
child_name = data.get("child")
|
|
830
|
+
result = data.get("result")
|
|
831
|
+
self._logEvent("COMPISITED", f"{composite_name} (child={child_name}, result={result})")
|
|
832
|
+
|
|
833
|
+
# Composite node triggers gradient effect
|
|
834
|
+
if composite_name and composite_name in self._nodemap:
|
|
835
|
+
composite_node = self._nodemap[composite_name]
|
|
836
|
+
# Apply gradient based on result
|
|
837
|
+
self._applyNodeResult(composite_name, result)
|
|
838
|
+
|
|
839
|
+
# Clear child node's color and marker (insurance cleanup)
|
|
840
|
+
if child_name and child_name in self._nodemap:
|
|
841
|
+
child_node = self._nodemap[child_name]
|
|
842
|
+
# Remove lightning marker
|
|
843
|
+
child_node.setSingleMarkerVisible(False)
|
|
844
|
+
# Remove green highlight (but keep gradient if any)
|
|
845
|
+
# Only reset if it's the current node
|
|
846
|
+
if self._current_node and child_node == self._current_node:
|
|
847
|
+
child_node.setColor(QColor(self.COLOR_DEFAULT))
|
|
848
|
+
child_node.setBorder(2, QColor(self.COLOR_BORDER))
|
|
849
|
+
self._current_node = None
|
|
850
|
+
|
|
851
|
+
def _onCompleted(self, data: dict) -> None:
|
|
852
|
+
"""
|
|
853
|
+
Handle behavior tree completion event.
|
|
854
|
+
|
|
855
|
+
Shows success/failure visualization:
|
|
856
|
+
- Success nodes: green for 1s, then fade out over 2s
|
|
857
|
+
- Failure nodes: red for 1s, then fade out over 2s
|
|
858
|
+
- Current node is excluded (stays green as active)
|
|
859
|
+
"""
|
|
860
|
+
success = data.get("success", False)
|
|
861
|
+
history = data.get("history", [])
|
|
862
|
+
self._logEvent("COMPLETED", f"success={success}, history={len(history)} nodes")
|
|
863
|
+
|
|
864
|
+
# Colors
|
|
865
|
+
COLOR_SUCCESS = "#4CAF50" # Green
|
|
866
|
+
COLOR_FAIL = "#F44336" # Red
|
|
867
|
+
|
|
868
|
+
# Track nodes to animate
|
|
869
|
+
marked_nodes = []
|
|
870
|
+
|
|
871
|
+
for node_name, status in history:
|
|
872
|
+
if node_name not in self._nodemap:
|
|
873
|
+
continue
|
|
874
|
+
|
|
875
|
+
node = self._nodemap[node_name]
|
|
876
|
+
# success 和 break 都视为成功(绿色),其他(fail)视为失败(红色)
|
|
877
|
+
is_success = status in ("success", "break")
|
|
878
|
+
color = QColor(COLOR_SUCCESS) if is_success else QColor(COLOR_FAIL)
|
|
879
|
+
|
|
880
|
+
# Set color immediately
|
|
881
|
+
node.setColor(color)
|
|
882
|
+
|
|
883
|
+
# Skip current node for animation (it stays active)
|
|
884
|
+
if self._current_node and node == self._current_node:
|
|
885
|
+
continue
|
|
886
|
+
|
|
887
|
+
# Skip nodes with break status (they should stay green, not fade)
|
|
888
|
+
if status == "break":
|
|
889
|
+
continue
|
|
890
|
+
|
|
891
|
+
marked_nodes.append(node)
|
|
892
|
+
|
|
893
|
+
# Clear running marker from ALL nodes when behavior tree completes
|
|
894
|
+
# (execution stopped but marker might still be showing on any node)
|
|
895
|
+
for node in self._nodemap.values():
|
|
896
|
+
node.setSingleMarkerVisible(False)
|
|
897
|
+
|
|
898
|
+
# Clear any existing fade timer
|
|
899
|
+
if self._fade_timer is not None:
|
|
900
|
+
self._fade_timer.stop()
|
|
901
|
+
self._fade_timer.deleteLater()
|
|
902
|
+
|
|
903
|
+
for anim in self._fade_animations:
|
|
904
|
+
anim.stop()
|
|
905
|
+
self._fade_animations = []
|
|
906
|
+
|
|
907
|
+
# Create timer to start fade after 1s
|
|
908
|
+
self._fade_timer = QTimer(self)
|
|
909
|
+
self._fade_timer.setSingleShot(True)
|
|
910
|
+
self._fade_timer.timeout.connect(lambda: self._startFadeAnimation(marked_nodes))
|
|
911
|
+
self._fade_timer.start(300) # 0.3 second delay
|
|
912
|
+
|
|
913
|
+
def _startFadeAnimation(self, nodes: list) -> None:
|
|
914
|
+
"""Start fade out animation for marked nodes."""
|
|
915
|
+
self._fade_animations = []
|
|
916
|
+
COLOR_DEFAULT = self.COLOR_DEFAULT
|
|
917
|
+
|
|
918
|
+
for node in nodes:
|
|
919
|
+
# Get current color
|
|
920
|
+
current_color = node._custom_fill_color if hasattr(node, '_custom_fill_color') else QColor(COLOR_DEFAULT)
|
|
921
|
+
|
|
922
|
+
# Create QVariantAnimation (works with any object, not just QObject)
|
|
923
|
+
anim = QVariantAnimation()
|
|
924
|
+
anim.setDuration(700) # 0.7 seconds fade
|
|
925
|
+
anim.setStartValue(current_color)
|
|
926
|
+
anim.setEndValue(QColor(COLOR_DEFAULT))
|
|
927
|
+
anim.setEasingCurve(QEasingCurve.Type.InOutQuad)
|
|
928
|
+
|
|
929
|
+
# Connect valueChanged to update node color
|
|
930
|
+
anim.valueChanged.connect(lambda color, n=node: n.setColor(color))
|
|
931
|
+
|
|
932
|
+
anim.start()
|
|
933
|
+
self._fade_animations.append(anim)
|
|
934
|
+
|
|
935
|
+
# After all animations finish, re-highlight current node to ensure it stays green
|
|
936
|
+
if self._fade_animations:
|
|
937
|
+
last_anim = self._fade_animations[-1]
|
|
938
|
+
last_anim.finished.connect(lambda: self._rehighlightCurrentNode())
|
|
939
|
+
|
|
940
|
+
def _applyNodeResult(self, node_name: str, result: Any) -> None:
|
|
941
|
+
"""Apply immediate success/failure color to a node and fade it out.
|
|
942
|
+
|
|
943
|
+
This is called for behavior tree child nodes to show immediate feedback.
|
|
944
|
+
"""
|
|
945
|
+
if node_name not in self._nodemap:
|
|
946
|
+
return
|
|
947
|
+
|
|
948
|
+
node = self._nodemap[node_name]
|
|
949
|
+
|
|
950
|
+
# Determine color based on result
|
|
951
|
+
COLOR_SUCCESS = "#4CAF50"
|
|
952
|
+
COLOR_FAIL = "#F44336"
|
|
953
|
+
COLOR_DEFAULT = self.COLOR_DEFAULT
|
|
954
|
+
|
|
955
|
+
if result is False:
|
|
956
|
+
color = QColor(COLOR_FAIL)
|
|
957
|
+
elif result is True or result is None:
|
|
958
|
+
color = QColor(COLOR_SUCCESS)
|
|
959
|
+
else:
|
|
960
|
+
# String or other result type - treat as success (transition)
|
|
961
|
+
color = QColor(COLOR_SUCCESS)
|
|
962
|
+
|
|
963
|
+
# Set color immediately
|
|
964
|
+
node.setColor(color)
|
|
965
|
+
|
|
966
|
+
# Skip animation if this is the current active node
|
|
967
|
+
if self._current_node and node == self._current_node:
|
|
968
|
+
return
|
|
969
|
+
|
|
970
|
+
# Create fade animation after a short delay
|
|
971
|
+
QTimer.singleShot(300, lambda: self._fadeNodeToDefault(node))
|
|
972
|
+
|
|
973
|
+
def _fadeNodeToDefault(self, node) -> None:
|
|
974
|
+
"""Fade a single node back to default color."""
|
|
975
|
+
COLOR_DEFAULT = self.COLOR_DEFAULT
|
|
976
|
+
|
|
977
|
+
# Get current color
|
|
978
|
+
current_color = node._custom_fill_color if hasattr(node, '_custom_fill_color') else QColor(COLOR_DEFAULT)
|
|
979
|
+
|
|
980
|
+
# Create fade animation
|
|
981
|
+
anim = QVariantAnimation()
|
|
982
|
+
anim.setDuration(700)
|
|
983
|
+
anim.setStartValue(current_color)
|
|
984
|
+
anim.setEndValue(QColor(COLOR_DEFAULT))
|
|
985
|
+
anim.setEasingCurve(QEasingCurve.Type.InOutQuad)
|
|
986
|
+
anim.valueChanged.connect(lambda color, n=node: n.setColor(color))
|
|
987
|
+
anim.start()
|
|
988
|
+
|
|
989
|
+
# Track animation
|
|
990
|
+
if not hasattr(self, '_fade_animations'):
|
|
991
|
+
self._fade_animations = []
|
|
992
|
+
self._fade_animations.append(anim)
|
|
993
|
+
|
|
994
|
+
# Clean up finished animations
|
|
995
|
+
anim.finished.connect(lambda: self._fade_animations.remove(anim) if anim in self._fade_animations else None)
|
|
996
|
+
|
|
997
|
+
def _rehighlightCurrentNode(self) -> None:
|
|
998
|
+
"""Re-highlight current node after animations complete."""
|
|
999
|
+
if self._current_node is not None:
|
|
1000
|
+
self._current_node.setColor(QColor(self.COLOR_ACTIVE))
|
|
1001
|
+
self._current_node.setBorder(2, QColor(self.COLOR_ACTIVE_BORDER))
|
|
1002
|
+
|
|
1003
|
+
def _onNodeEditRequested(self, node) -> None:
|
|
1004
|
+
"""Handle node edit request - show node details dialog with StageEditor."""
|
|
1005
|
+
if self._stage is None:
|
|
1006
|
+
return
|
|
1007
|
+
|
|
1008
|
+
node_name = node.name
|
|
1009
|
+
|
|
1010
|
+
# Extract node info from stage
|
|
1011
|
+
try:
|
|
1012
|
+
nodes_info = self._stage.extractNodes()
|
|
1013
|
+
info = nodes_info.get(node_name, {})
|
|
1014
|
+
doc = info.get("doc", "")
|
|
1015
|
+
code = info.get("code", "")
|
|
1016
|
+
except Exception:
|
|
1017
|
+
doc = ""
|
|
1018
|
+
code = ""
|
|
1019
|
+
|
|
1020
|
+
# Get stage class for context-aware editing
|
|
1021
|
+
stage_class = self._stage.__class__
|
|
1022
|
+
|
|
1023
|
+
# Show detail dialog with StageEditor
|
|
1024
|
+
dialog = NodeDetailDialog(node_name, doc, code, stage_class, self)
|
|
1025
|
+
dialog.exec()
|
|
1026
|
+
|
|
1027
|
+
def _cleanup(self) -> None:
|
|
1028
|
+
"""Cleanup resources."""
|
|
1029
|
+
# Cancel listener from current stage
|
|
1030
|
+
if self._stage is not None and self._event_bridge is not None:
|
|
1031
|
+
try:
|
|
1032
|
+
self._stage.cancelListen("changed", self._event_bridge.onEvent)
|
|
1033
|
+
except Exception:
|
|
1034
|
+
pass
|
|
1035
|
+
|
|
1036
|
+
if self._stage is not None and hasattr(self, '_enter_bridge') and self._enter_bridge is not None:
|
|
1037
|
+
try:
|
|
1038
|
+
self._stage.cancelListen("enter", self._enter_bridge.onEvent)
|
|
1039
|
+
except Exception:
|
|
1040
|
+
pass
|
|
1041
|
+
|
|
1042
|
+
if self._stage is not None and hasattr(self, '_behavior_bridge') and self._behavior_bridge is not None:
|
|
1043
|
+
try:
|
|
1044
|
+
self._stage.cancelListen("behavior_completed", self._behavior_bridge.onEvent)
|
|
1045
|
+
except Exception:
|
|
1046
|
+
pass
|
|
1047
|
+
|
|
1048
|
+
if self._stage is not None and hasattr(self, '_compisited_bridge') and self._compisited_bridge is not None:
|
|
1049
|
+
try:
|
|
1050
|
+
self._stage.cancelListen("compisited", self._compisited_bridge.onEvent)
|
|
1051
|
+
except Exception:
|
|
1052
|
+
pass
|
|
1053
|
+
|
|
1054
|
+
# Delete event bridges
|
|
1055
|
+
if self._event_bridge is not None:
|
|
1056
|
+
self._event_bridge.deleteLater()
|
|
1057
|
+
self._event_bridge = None
|
|
1058
|
+
|
|
1059
|
+
if hasattr(self, '_enter_bridge') and self._enter_bridge is not None:
|
|
1060
|
+
self._enter_bridge.deleteLater()
|
|
1061
|
+
self._enter_bridge = None
|
|
1062
|
+
|
|
1063
|
+
if hasattr(self, '_behavior_bridge') and self._behavior_bridge is not None:
|
|
1064
|
+
self._behavior_bridge.deleteLater()
|
|
1065
|
+
self._behavior_bridge = None
|
|
1066
|
+
|
|
1067
|
+
if hasattr(self, '_compisited_bridge') and self._compisited_bridge is not None:
|
|
1068
|
+
self._compisited_bridge.deleteLater()
|
|
1069
|
+
self._compisited_bridge = None
|
|
1070
|
+
|
|
1071
|
+
self._stage = None
|
|
1072
|
+
|
|
1073
|
+
def kill(self) -> None:
|
|
1074
|
+
"""
|
|
1075
|
+
Kill the underlying Stage/Logic instance.
|
|
1076
|
+
|
|
1077
|
+
This marks the logic as not alive and triggers cleanup.
|
|
1078
|
+
Safe to call multiple times.
|
|
1079
|
+
"""
|
|
1080
|
+
if self._stage is not None and hasattr(self._stage, "kill"):
|
|
1081
|
+
self._stage.kill()
|
|
1082
|
+
|
|
1083
|
+
def closeEvent(self, event) -> None:
|
|
1084
|
+
"""Handle widget close - automatically kills the stage."""
|
|
1085
|
+
self.kill()
|
|
1086
|
+
self._cleanup()
|
|
1087
|
+
event.accept()
|
|
1088
|
+
|
|
1089
|
+
# -------------------------------------------------------------------------
|
|
1090
|
+
# IChart Interface Methods (delegated to internal Chart instance)
|
|
1091
|
+
# -------------------------------------------------------------------------
|
|
1092
|
+
|
|
1093
|
+
@property
|
|
1094
|
+
def nodes(self) -> list[ChartNode]:
|
|
1095
|
+
"""Get all nodes in the chart."""
|
|
1096
|
+
return self._chart.nodes
|
|
1097
|
+
|
|
1098
|
+
@property
|
|
1099
|
+
def edges(self) -> list[ChartEdge]:
|
|
1100
|
+
"""Get all edges in the chart."""
|
|
1101
|
+
return self._chart.edges
|
|
1102
|
+
|
|
1103
|
+
@property
|
|
1104
|
+
def logics(self) -> list[ChartLogic]:
|
|
1105
|
+
"""Get all logic configurations."""
|
|
1106
|
+
return self._chart.logics
|
|
1107
|
+
|
|
1108
|
+
@property
|
|
1109
|
+
def startnode(self) -> str | None:
|
|
1110
|
+
"""Get start node ID."""
|
|
1111
|
+
return self._chart.startnode
|
|
1112
|
+
|
|
1113
|
+
@startnode.setter
|
|
1114
|
+
def startnode(self, nid: str | None) -> None:
|
|
1115
|
+
"""Set start node ID."""
|
|
1116
|
+
self._chart.startnode = nid
|
|
1117
|
+
|
|
1118
|
+
def addNode(self, node: ChartNode) -> None:
|
|
1119
|
+
"""Add a node to the chart."""
|
|
1120
|
+
self._chart.addNode(node)
|
|
1121
|
+
|
|
1122
|
+
def removeNode(self, nid: str) -> ChartNode | None:
|
|
1123
|
+
"""Remove a node and return it, or None if not found."""
|
|
1124
|
+
return self._chart.removeNode(nid)
|
|
1125
|
+
|
|
1126
|
+
def getNode(self, nid: str) -> ChartNode | None:
|
|
1127
|
+
"""Get node by ID."""
|
|
1128
|
+
return self._chart.getNode(nid)
|
|
1129
|
+
|
|
1130
|
+
def addEdge(self, edge: ChartEdge) -> None:
|
|
1131
|
+
"""Add an edge to the chart."""
|
|
1132
|
+
self._chart.addEdge(edge)
|
|
1133
|
+
|
|
1134
|
+
def removeEdge(self, eid: str) -> ChartEdge | None:
|
|
1135
|
+
"""Remove an edge and return it, or None if not found."""
|
|
1136
|
+
return self._chart.removeEdge(eid)
|
|
1137
|
+
|
|
1138
|
+
def getEdge(self, eid: str) -> ChartEdge | None:
|
|
1139
|
+
"""Get edge by ID."""
|
|
1140
|
+
return self._chart.getEdge(eid)
|
|
1141
|
+
|
|
1142
|
+
def addLogic(self, logic: ChartLogic) -> None:
|
|
1143
|
+
"""Add logic configuration for a node."""
|
|
1144
|
+
self._chart.addLogic(logic)
|
|
1145
|
+
|
|
1146
|
+
def getLogic(self, nid: str) -> ChartLogic | None:
|
|
1147
|
+
"""Get logic configuration for node."""
|
|
1148
|
+
return self._chart.getLogic(nid)
|
|
1149
|
+
|
|
1150
|
+
def clearChart(self) -> None:
|
|
1151
|
+
"""Clear all nodes, edges and logics."""
|
|
1152
|
+
self._chart.clearChart()
|
|
1153
|
+
|
|
1154
|
+
def getOutgoing(self, nid: str) -> list[ChartEdge]:
|
|
1155
|
+
"""Get all outgoing edges from a node."""
|
|
1156
|
+
return self._chart.getOutgoing(nid)
|
|
1157
|
+
|
|
1158
|
+
def getIncoming(self, nid: str) -> list[ChartEdge]:
|
|
1159
|
+
"""Get all incoming edges to a node."""
|
|
1160
|
+
return self._chart.getIncoming(nid)
|
|
1161
|
+
|
|
1162
|
+
def getConnected(self, nid: str) -> list[str]:
|
|
1163
|
+
"""Get IDs of all nodes connected to given node."""
|
|
1164
|
+
return self._chart.getConnected(nid)
|
|
1165
|
+
|
|
1166
|
+
def validChart(self) -> tuple[bool, str]:
|
|
1167
|
+
"""
|
|
1168
|
+
Validate chart integrity.
|
|
1169
|
+
|
|
1170
|
+
Returns:
|
|
1171
|
+
Tuple of (is_valid, error_message)
|
|
1172
|
+
"""
|
|
1173
|
+
return self._chart.validChart()
|
|
1174
|
+
|
|
1175
|
+
def fromJson(self, data: dict[str, Any]) -> 'QFlowChart':
|
|
1176
|
+
"""
|
|
1177
|
+
Load chart from JSON format.
|
|
1178
|
+
|
|
1179
|
+
Args:
|
|
1180
|
+
data: JSON data as dict
|
|
1181
|
+
|
|
1182
|
+
Returns:
|
|
1183
|
+
Self for chaining
|
|
1184
|
+
"""
|
|
1185
|
+
self._chart.fromJson(data)
|
|
1186
|
+
return self
|
|
1187
|
+
|
|
1188
|
+
def toJson(self) -> dict[str, Any]:
|
|
1189
|
+
"""
|
|
1190
|
+
Export chart to qnode JSON format.
|
|
1191
|
+
|
|
1192
|
+
Returns:
|
|
1193
|
+
Dict compatible with qnode import
|
|
1194
|
+
"""
|
|
1195
|
+
return self._chart.toJson()
|
|
1196
|
+
|
|
1197
|
+
def toStage(self, inst: Any | None = None) -> 'Stage':
|
|
1198
|
+
"""
|
|
1199
|
+
Create qfsm Stage instance from chart.
|
|
1200
|
+
|
|
1201
|
+
Args:
|
|
1202
|
+
inst: Optional bound instance
|
|
1203
|
+
|
|
1204
|
+
Returns:
|
|
1205
|
+
qfsm Stage instance
|
|
1206
|
+
"""
|
|
1207
|
+
return self._chart.toStage(inst)
|
|
1208
|
+
|
|
1209
|
+
def toLogic(self, inst: Any | None = None) -> 'Logic':
|
|
1210
|
+
"""
|
|
1211
|
+
Create qfsm Logic instance from chart.
|
|
1212
|
+
|
|
1213
|
+
Args:
|
|
1214
|
+
inst: Optional bound instance
|
|
1215
|
+
|
|
1216
|
+
Returns:
|
|
1217
|
+
qfsm Logic instance
|
|
1218
|
+
"""
|
|
1219
|
+
return self._chart.toLogic(inst)
|
|
1220
|
+
|
|
1221
|
+
def fromStage(self, stage: 'Stage') -> 'QFlowChart':
|
|
1222
|
+
"""
|
|
1223
|
+
Load chart from chartflow.fsm Stage instance.
|
|
1224
|
+
|
|
1225
|
+
Args:
|
|
1226
|
+
stage: qfsm Stage instance to parse
|
|
1227
|
+
|
|
1228
|
+
Returns:
|
|
1229
|
+
Self for chaining
|
|
1230
|
+
"""
|
|
1231
|
+
self._chart.fromStage(stage)
|
|
1232
|
+
return self
|
|
1233
|
+
|
|
1234
|
+
def fromLogic(self, logic: 'Logic') -> 'QFlowChart':
|
|
1235
|
+
"""
|
|
1236
|
+
Load chart from chartflow.fsm Logic instance.
|
|
1237
|
+
|
|
1238
|
+
Args:
|
|
1239
|
+
logic: qfsm Logic instance to parse
|
|
1240
|
+
|
|
1241
|
+
Returns:
|
|
1242
|
+
Self for chaining
|
|
1243
|
+
"""
|
|
1244
|
+
self._chart.fromLogic(logic)
|
|
1245
|
+
return self
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
__all__ = [
|
|
1249
|
+
"QFlowChart",
|
|
1250
|
+
]
|