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