statable 3.1.0__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 (117) hide show
  1. codegen/__init__.py +38 -0
  2. codegen/c_code_generator.py +1752 -0
  3. codegen/code_merger.py +371 -0
  4. codegen/code_templates.py +509 -0
  5. codegen/config.py +167 -0
  6. codegen/enum_generator.py +377 -0
  7. codegen/event_queue_generator.py +303 -0
  8. codegen/interrupt_generator.py +345 -0
  9. codegen/naming_convention.py +171 -0
  10. codegen/osal_generator.py +282 -0
  11. codegen/role_function_generator.py +1244 -0
  12. codegen/sample_data.py +269 -0
  13. codegen/state_actions_generator.py +395 -0
  14. codegen/struct_generator.py +721 -0
  15. codegen/timer_generator.py +299 -0
  16. codegen/transition_generator.py +907 -0
  17. codegen/type_mapper.py +69 -0
  18. codegen/validate/__init__.py +38 -0
  19. codegen/validate/change_actions.py +90 -0
  20. codegen/validate/change_applier.py +416 -0
  21. codegen/validate/clipboard_manager.py +47 -0
  22. codegen/validate/data/__init__.py +30 -0
  23. codegen/validate/data/action_definitions.py +200 -0
  24. codegen/validate/data/keywords.py +46 -0
  25. codegen/validate/data/prompt_templates.py +176 -0
  26. codegen/validate/data/validation_rules.py +205 -0
  27. codegen/validate/items/__init__.py +30 -0
  28. codegen/validate/items/base_validator.py +25 -0
  29. codegen/validate/items/cell_validator.py +275 -0
  30. codegen/validate/items/custom_type_validator.py +75 -0
  31. codegen/validate/items/event_validator.py +78 -0
  32. codegen/validate/items/flag_validator.py +74 -0
  33. codegen/validate/items/interrupt_validator.py +75 -0
  34. codegen/validate/items/queue_validator.py +73 -0
  35. codegen/validate/items/role_function_validator.py +175 -0
  36. codegen/validate/items/state_validator.py +88 -0
  37. codegen/validate/items/timer_validator.py +120 -0
  38. codegen/validate/items/transition_validator.py +113 -0
  39. codegen/validate/items/variable_validator.py +132 -0
  40. codegen/validate/logger.py +52 -0
  41. codegen/validate/models.py +177 -0
  42. codegen/validate/prompt_generator.py +724 -0
  43. codegen/validate/response_parser.py +154 -0
  44. codegen/validate/response_validator.py +275 -0
  45. codegen/validate/validation_dialog.py +322 -0
  46. codegen/validate/validator.py +94 -0
  47. codegen/variable_generator.py +419 -0
  48. statable/__init__.py +71 -0
  49. statable/__main__.py +49 -0
  50. statable/cli.py +251 -0
  51. statable/global_defs.py +256 -0
  52. statable/mermaid_gen.py +144 -0
  53. statable/model.py +426 -0
  54. statable/parser.py +64 -0
  55. statable/sample_data.py +435 -0
  56. statable/shared/__init__.py +24 -0
  57. statable/shared/condition_library.py +63 -0
  58. statable/shared/literal_library.py +66 -0
  59. statable/shared/role_function_library.py +150 -0
  60. statable/state_machine.py +121 -0
  61. statable/xml_io.py +1164 -0
  62. statable-3.1.0.dist-info/METADATA +151 -0
  63. statable-3.1.0.dist-info/RECORD +117 -0
  64. statable-3.1.0.dist-info/WHEEL +5 -0
  65. statable-3.1.0.dist-info/entry_points.txt +2 -0
  66. statable-3.1.0.dist-info/top_level.txt +3 -0
  67. statable_gui/__init__.py +23 -0
  68. statable_gui/action_edit_dialog.py +214 -0
  69. statable_gui/code_generation_dialog.py +486 -0
  70. statable_gui/code_generation_settings_dialog.py +522 -0
  71. statable_gui/common_widgets.py +537 -0
  72. statable_gui/condition_builder_dialog.py +500 -0
  73. statable_gui/condition_edit_dialog.py +123 -0
  74. statable_gui/condition_template_dialog.py +58 -0
  75. statable_gui/config.py +21 -0
  76. statable_gui/dialogs.py +439 -0
  77. statable_gui/event_definition_dialog.py +605 -0
  78. statable_gui/event_delivery_settings_dialog.py +191 -0
  79. statable_gui/event_queue_dialog.py +245 -0
  80. statable_gui/global_defs.py +167 -0
  81. statable_gui/global_defs_dialog.py +796 -0
  82. statable_gui/i18n/__init__.py +118 -0
  83. statable_gui/interrupt_handler_edit_dialog.py +810 -0
  84. statable_gui/layer_settings_dialog.py +180 -0
  85. statable_gui/libcntrl/__init__.py +23 -0
  86. statable_gui/libcntrl/condition_library.py +13 -0
  87. statable_gui/libcntrl/literal_library.py +13 -0
  88. statable_gui/libcntrl/literal_management_dialog.py +185 -0
  89. statable_gui/libcntrl/role_function_edit_dialog.py +145 -0
  90. statable_gui/libcntrl/role_function_library.py +13 -0
  91. statable_gui/literal_definition_dialog.py +62 -0
  92. statable_gui/logger.py +83 -0
  93. statable_gui/main_window.py +1380 -0
  94. statable_gui/matrix_table.py +413 -0
  95. statable_gui/preference_keys.py +18 -0
  96. statable_gui/preferences.py +74 -0
  97. statable_gui/role_function_dialog.py +244 -0
  98. statable_gui/sample_data.py +11 -0
  99. statable_gui/state_actions_dialog.py +398 -0
  100. statable_gui/symbol_picker.py +170 -0
  101. statable_gui/traceball.py +37 -0
  102. statable_gui/transition_editor_direct/__init__.py +15 -0
  103. statable_gui/transition_editor_direct/actions_tab.py +856 -0
  104. statable_gui/transition_editor_direct/code_widget.py +344 -0
  105. statable_gui/transition_editor_direct/coverage_analyzer.py +163 -0
  106. statable_gui/transition_editor_direct/dialog.py +335 -0
  107. statable_gui/transition_editor_direct/draft.py +270 -0
  108. statable_gui/transition_editor_direct/edit_dialogs.py +160 -0
  109. statable_gui/transition_editor_direct/overview_tab.py +173 -0
  110. statable_gui/transition_editor_direct/palette_widget.py +233 -0
  111. statable_gui/transition_editor_direct/relations_edit_dialog.py +156 -0
  112. statable_gui/transition_editor_direct/relations_tab.py +230 -0
  113. statable_gui/transition_editor_direct/system_global_dialog.py +52 -0
  114. statable_gui/transition_editor_direct/transition_actions_dialog.py +109 -0
  115. statable_gui/transition_editor_direct/transitions_tab.py +620 -0
  116. statable_gui/validation_dialog.py +285 -0
  117. statable_gui/widgets.py +1315 -0
@@ -0,0 +1,335 @@
1
+ # statable_gui/transition_editor_direct/dialog.py
2
+ """Action edit main dialog (v2.2: 5-tab structure).
3
+
4
+ Tabs:
5
+ - Transitions : ordered list of transitions
6
+ - Pre / Post Actions: cell-level actions (before / after transitions)
7
+ - Relations : relations between transitions
8
+ - Overview : coverage / reachability report
9
+ - Preview : generated C code
10
+
11
+ [v2.2 fix]
12
+ - _load_draft() order: Actions -> Relations -> Transitions (last)
13
+ to prevent intermediate signals from wiping cell_actions /
14
+ cell_relations before they are loaded.
15
+ - _loading flag suppresses _on_content_changed during initial load.
16
+ - _sync_member_details() propagates {label: "cond -> target"} to
17
+ RelationsTab so the member list shows identifiable entries.
18
+
19
+ [v2.5 change]
20
+ - TransitionsTab and ActionsTab now receive `role_function_library`
21
+ and `literal_library` from this dialog.
22
+
23
+ [R-6 change]
24
+ - ActionEditorDialog now accepts `layer_names_provider` and forwards
25
+ it to TransitionsTab and ActionsTab. This completes the 4-hop
26
+ wiring:
27
+ MainWindow → StateMachineTab → MatrixTableWidget
28
+ → ActionEditorDialog → TransitionsTab / ActionsTab
29
+ so the namespace combo box on those tabs lists every layer in
30
+ the project, not just the current tab's layer.
31
+ - Previously this dialog intentionally did NOT forward the
32
+ provider (v2.5 scope). R-6 completes the wiring.
33
+ """
34
+
35
+ import logging
36
+ import sys
37
+ import os
38
+ from typing import List, Callable
39
+
40
+ current_dir = os.path.dirname(os.path.abspath(__file__))
41
+ parent_dir = os.path.dirname(current_dir)
42
+ if parent_dir not in sys.path:
43
+ sys.path.insert(0, parent_dir)
44
+
45
+ from PySide6.QtWidgets import (
46
+ QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QTabWidget,
47
+ QWidget, QMessageBox, QToolBar, QLabel,
48
+ )
49
+ from PySide6.QtCore import Qt
50
+
51
+ from .draft import (
52
+ ActionDraft, FlowItem, transition_to_flow_item, flow_item_to_transition,
53
+ )
54
+ from .transitions_tab import TransitionsTab
55
+ from .actions_tab import ActionsTab
56
+ from .relations_tab import RelationsTab
57
+ from .overview_tab import OverviewTab
58
+ from .code_widget import CodeWidget
59
+
60
+ from statable_gui.libcntrl.role_function_library import (
61
+ RoleFunctionLibrary, RoleFunction,
62
+ )
63
+ from statable_gui.libcntrl.condition_library import (
64
+ ConditionLibrary, ConditionTemplate,
65
+ )
66
+ from statable_gui.libcntrl.literal_library import LiteralLibrary
67
+
68
+ logger = logging.getLogger("transition_editor_direct.dialog")
69
+
70
+
71
+ class ActionEditorDialog(QDialog):
72
+ """Main dialog for editing a transition cell (v2.2 / 5 tabs)."""
73
+
74
+ TAB_NAMES = ("Transitions", "Pre / Post Actions",
75
+ "Relations", "Overview", "Preview")
76
+
77
+ def __init__(self, draft: ActionDraft,
78
+ role_functions=None, transition_events=None,
79
+ states=None, global_defs=None, state_machine=None,
80
+ role_function_library=None, condition_library=None,
81
+ literal_library=None,
82
+ layer_names_provider: Callable[[], List[str]] = None,
83
+ parent=None):
84
+ super().__init__(parent)
85
+ self.draft = draft
86
+ self.role_functions = role_functions or []
87
+ self.states = states or []
88
+ self.global_defs = global_defs
89
+ self.state_machine = state_machine
90
+
91
+ self.role_function_library = (
92
+ role_function_library if role_function_library
93
+ else RoleFunctionLibrary()
94
+ )
95
+ self.condition_library = (
96
+ condition_library if condition_library
97
+ else ConditionLibrary()
98
+ )
99
+ self.literal_library = (
100
+ literal_library if literal_library
101
+ else LiteralLibrary()
102
+ )
103
+ # [R-6] All-tabs namespace provider (forwarded to tabs).
104
+ self.layer_names_provider = layer_names_provider
105
+
106
+ # v2.2 fix: guard flag for _load_draft
107
+ self._loading = False
108
+
109
+ logger.debug("=== ActionEditorDialog init ===")
110
+ logger.debug(f"source={draft.source}, event={draft.event}")
111
+ logger.debug(
112
+ f"has_layer_names_provider="
113
+ f"{layer_names_provider is not None}")
114
+
115
+ self.setWindowTitle(
116
+ f"Action edit: {draft.source} --[{draft.event}]--> ?")
117
+ self.setMinimumSize(1000, 700)
118
+
119
+ self._setup_ui()
120
+ self._load_draft()
121
+
122
+ logger.debug("=== ActionEditorDialog init end ===")
123
+
124
+ # ------------------------------------------------------------------
125
+ # UI
126
+ # ------------------------------------------------------------------
127
+ def _setup_ui(self):
128
+ main_layout = QVBoxLayout(self)
129
+
130
+ toolbar = QToolBar()
131
+ refresh_btn = QPushButton(self.tr("Refresh preview"))
132
+ refresh_btn.clicked.connect(self._on_refresh_preview)
133
+ toolbar.addWidget(refresh_btn)
134
+ main_layout.addWidget(toolbar)
135
+
136
+ self.tabs = QTabWidget()
137
+ main_layout.addWidget(self.tabs)
138
+
139
+ # ==============================================================
140
+ # Tab 1: Transitions
141
+ # ==============================================================
142
+ # [v2.5] Pass shared libraries so "+ New Role Function" on this
143
+ # tab can build the same RoleFunctionDialog kwargs as
144
+ # SettingsPanel (global_vars / events / literals /
145
+ # namespace_choices).
146
+ # [R-6] Forward layer_names_provider so namespace_choices
147
+ # include every layer in the project.
148
+ self.transitions_tab = TransitionsTab(
149
+ self.draft,
150
+ states=self.states,
151
+ global_defs=self.global_defs,
152
+ state_machine=self.state_machine,
153
+ role_function_library=self.role_function_library,
154
+ literal_library=self.literal_library,
155
+ condition_library=self.condition_library,
156
+ layer_names_provider=self.layer_names_provider,
157
+ )
158
+ # v2.2: provide role function candidates to the TransitionsTab
159
+ self.transitions_tab.set_role_functions(self.role_functions)
160
+ self.tabs.addTab(self.transitions_tab, self.TAB_NAMES[0])
161
+
162
+ # ==============================================================
163
+ # Tab 2: Pre / Post Actions
164
+ # ==============================================================
165
+ # [v2.5] Same context propagation as Tab 1.
166
+ # [R-6] Forward layer_names_provider.
167
+ self.actions_tab = ActionsTab(
168
+ self.draft,
169
+ role_functions=self.role_functions,
170
+ global_defs=self.global_defs,
171
+ state_machine=self.state_machine,
172
+ role_function_library=self.role_function_library,
173
+ literal_library=self.literal_library,
174
+ layer_names_provider=self.layer_names_provider,
175
+ )
176
+ self.tabs.addTab(self.actions_tab, self.TAB_NAMES[1])
177
+
178
+ # ==============================================================
179
+ # Tab 3: Relations
180
+ # ==============================================================
181
+ self.relations_tab = RelationsTab(self.draft)
182
+ self.tabs.addTab(self.relations_tab, self.TAB_NAMES[2])
183
+
184
+ # ==============================================================
185
+ # Tab 4: Overview
186
+ # ==============================================================
187
+ self.overview_tab = OverviewTab(
188
+ self.draft,
189
+ state_machine=self.state_machine,
190
+ )
191
+ self.tabs.addTab(self.overview_tab, self.TAB_NAMES[3])
192
+
193
+ # ==============================================================
194
+ # Tab 5: Preview
195
+ # ==============================================================
196
+ self.code_widget = CodeWidget(self.draft)
197
+ self.tabs.addTab(self.code_widget, self.TAB_NAMES[4])
198
+
199
+ # ==============================================================
200
+ # Signal connections
201
+ # ==============================================================
202
+ self.transitions_tab.transitions_changed.connect(
203
+ self._on_content_changed)
204
+ self.actions_tab.actions_changed.connect(
205
+ self._on_content_changed)
206
+ self.relations_tab.relations_changed.connect(
207
+ self._on_content_changed)
208
+
209
+ # ==============================================================
210
+ # OK / Cancel
211
+ # ==============================================================
212
+ btn_layout = QHBoxLayout()
213
+ btn_layout.addStretch()
214
+ cancel_btn = QPushButton(self.tr("Cancel"))
215
+ cancel_btn.clicked.connect(self.reject)
216
+ btn_layout.addWidget(cancel_btn)
217
+ ok_btn = QPushButton(self.tr("OK"))
218
+ ok_btn.clicked.connect(self._on_accept)
219
+ btn_layout.addWidget(ok_btn)
220
+ main_layout.addLayout(btn_layout)
221
+
222
+ # ------------------------------------------------------------------
223
+ # Load / save
224
+ # ------------------------------------------------------------------
225
+ def _load_draft(self):
226
+ """Populate tabs from the current draft state.
227
+
228
+ [v2.2 fix]
229
+ Order: Actions -> Relations -> Transitions (last).
230
+ """
231
+ self._loading = True
232
+ try:
233
+ # 1. Actions first
234
+ actions = list(self.draft.cell_actions)
235
+ logger.debug(
236
+ f"_load_draft: actions={len(actions)}, "
237
+ f"relations={len(self.draft.cell_relations)}")
238
+ self.actions_tab.set_actions(actions)
239
+
240
+ # 2. Relations
241
+ relations = list(self.draft.cell_relations)
242
+ self.relations_tab.set_relations(relations)
243
+
244
+ # 3. Transitions last (emits signals)
245
+ transitions = []
246
+ for item in self.draft.flow_items:
247
+ if item.item_type == "transition":
248
+ t = flow_item_to_transition(
249
+ item, self.draft.source, self.draft.event)
250
+ transitions.append(t)
251
+ self.transitions_tab.set_transitions(transitions)
252
+ finally:
253
+ self._loading = False
254
+
255
+ # v2.2: sync member details before refreshing preview
256
+ self._sync_member_details()
257
+
258
+ # Refresh preview / overview once after load
259
+ try:
260
+ self.code_widget.update_code()
261
+ except Exception as e:
262
+ logger.warning(f"code update failed: {e}")
263
+ try:
264
+ self.overview_tab.refresh()
265
+ except Exception as e:
266
+ logger.warning(f"overview refresh failed: {e}")
267
+
268
+ def _save_draft(self):
269
+ """Sync tab contents back to the draft."""
270
+ transitions = self.transitions_tab.get_transitions()
271
+ self.draft.flow_items = [
272
+ transition_to_flow_item(t) for t in transitions
273
+ ]
274
+ self.draft.cell_actions = list(self.actions_tab.get_actions())
275
+ self.draft.cell_relations = list(self.relations_tab.get_relations())
276
+
277
+ # ------------------------------------------------------------------
278
+ # v2.2: member details propagation
279
+ # ------------------------------------------------------------------
280
+ def _sync_member_details(self):
281
+ """Build {label: 'cond -> target [mode]'} from the TransitionsTab
282
+ and pass it to RelationsTab so members are identifiable.
283
+ """
284
+ details = {}
285
+ try:
286
+ transitions = self.transitions_tab.get_transitions()
287
+ except Exception as e:
288
+ logger.warning(f"_sync_member_details failed: {e}")
289
+ return
290
+
291
+ for t in transitions:
292
+ label = getattr(t, 'label', '') or ''
293
+ if not label:
294
+ continue
295
+ cond = (getattr(t, 'condition', '') or '').strip() or "(no cond)"
296
+ target = (getattr(t, 'target', '') or '').strip() or "(none)"
297
+ mode = "Commit" if getattr(t, 'early_return', False) else "Tentative"
298
+ details[label] = f"{cond} -> {target} [{mode}]"
299
+
300
+ self.relations_tab.set_member_details(details)
301
+
302
+ # ------------------------------------------------------------------
303
+ # Slots
304
+ # ------------------------------------------------------------------
305
+ def _on_content_changed(self):
306
+ # v2.2 fix: ignore signals emitted during initial load
307
+ if getattr(self, "_loading", False):
308
+ return
309
+
310
+ self._save_draft()
311
+ self._sync_member_details()
312
+ self.code_widget.update_code()
313
+ try:
314
+ self.overview_tab.refresh()
315
+ except Exception as e:
316
+ logger.warning(f"overview refresh failed: {e}")
317
+
318
+ def _on_refresh_preview(self):
319
+ self._save_draft()
320
+ self._sync_member_details()
321
+ self.code_widget.update_code()
322
+ try:
323
+ self.overview_tab.refresh()
324
+ except Exception as e:
325
+ logger.warning(f"overview refresh failed: {e}")
326
+
327
+ def _on_accept(self):
328
+ self._save_draft()
329
+ self.accept()
330
+
331
+ # ------------------------------------------------------------------
332
+ # Public API (test-friendly)
333
+ # ------------------------------------------------------------------
334
+ def get_tab_names(self) -> List[str]:
335
+ return [self.tabs.tabText(i) for i in range(self.tabs.count())]
@@ -0,0 +1,270 @@
1
+ # statable_gui/transition_editor_direct/draft.py
2
+ """Action edit draft model.
3
+
4
+ [v2.2 additions]
5
+ - ActionDraft.cell_actions / cell_relations
6
+ - FlowItem.params['early_return'] / ['label']
7
+ """
8
+
9
+ import logging
10
+ from dataclasses import dataclass, field
11
+ from typing import List, Dict, Any, Optional
12
+
13
+ from statable.model import (
14
+ ActionStep, TransitionRelation, Transition as ModelTransition,
15
+ )
16
+
17
+ logger = logging.getLogger("transition_editor_direct.draft")
18
+
19
+
20
+ def ensure_list(value) -> List[str]:
21
+ if isinstance(value, list):
22
+ return value
23
+ if value is None:
24
+ return []
25
+ if isinstance(value, str):
26
+ stripped = value.strip()
27
+ if not stripped:
28
+ return []
29
+ return [stripped]
30
+ logger.warning(
31
+ f"Unexpected type for list: {type(value)}. Returning empty list.")
32
+ return []
33
+
34
+
35
+ @dataclass
36
+ class SystemGlobal:
37
+ name: str
38
+ type: str = "uint16_t"
39
+ initial_value: str = "0"
40
+ description: str = ""
41
+
42
+ def to_dict(self) -> dict:
43
+ return {
44
+ 'name': self.name,
45
+ 'type': self.type,
46
+ 'initial_value': self.initial_value,
47
+ 'description': self.description,
48
+ }
49
+
50
+ @classmethod
51
+ def from_dict(cls, data: dict) -> 'SystemGlobal':
52
+ return cls(
53
+ name=data.get('name', ''),
54
+ type=data.get('type', 'uint16_t'),
55
+ initial_value=data.get('initial_value', '0'),
56
+ description=data.get('description', ''),
57
+ )
58
+
59
+
60
+ @dataclass
61
+ class TransitionParams:
62
+ event: str = "NewEvent"
63
+ condition: str = ""
64
+ pre_actions: List[str] = field(default_factory=list)
65
+ target: str = ""
66
+ has_else: bool = True
67
+ else_target: str = ""
68
+ else_actions: List[str] = field(default_factory=list)
69
+ early_return: bool = False # v2.2
70
+ label: str = "" # v2.2
71
+
72
+
73
+ @dataclass
74
+ class FlowItem:
75
+ item_type: str = ""
76
+ name: str = ""
77
+ edited_text: str = ""
78
+ params: Dict[str, Any] = field(default_factory=dict)
79
+ pos_x: Optional[float] = None
80
+ pos_y: Optional[float] = None
81
+
82
+ def display_text(self) -> str:
83
+ return self.edited_text or self.name
84
+
85
+ def to_dict(self) -> dict:
86
+ return {
87
+ 'item_type': self.item_type,
88
+ 'name': self.name,
89
+ 'edited_text': self.edited_text,
90
+ 'params': self.params,
91
+ 'pos_x': self.pos_x,
92
+ 'pos_y': self.pos_y,
93
+ }
94
+
95
+ @classmethod
96
+ def from_dict(cls, data: dict) -> 'FlowItem':
97
+ return cls(
98
+ item_type=data.get('item_type', ''),
99
+ name=data.get('name', ''),
100
+ edited_text=data.get('edited_text', ''),
101
+ params=data.get('params', {}),
102
+ pos_x=data.get('pos_x'),
103
+ pos_y=data.get('pos_y'),
104
+ )
105
+
106
+
107
+ @dataclass
108
+ class ActionDraft:
109
+ source: str = ""
110
+ event: str = ""
111
+ layer_name: str = ""
112
+
113
+ flow_items: List[FlowItem] = field(default_factory=list)
114
+ default_target: str = ""
115
+
116
+ system_globals: List[SystemGlobal] = field(default_factory=list)
117
+ generated_code: str = ""
118
+
119
+ role_func_map: Dict[str, str] = field(default_factory=dict)
120
+ user_code: Dict[str, str] = field(default_factory=dict)
121
+
122
+ # === v2.2: cell-level metadata ===
123
+ cell_actions: List[ActionStep] = field(default_factory=list)
124
+ cell_relations: List[TransitionRelation] = field(default_factory=list)
125
+
126
+ def clear(self):
127
+ self.flow_items = []
128
+ self.default_target = ""
129
+ self.system_globals = []
130
+ self.generated_code = ""
131
+ self.role_func_map = {}
132
+ self.user_code = {}
133
+ self.cell_actions = []
134
+ self.cell_relations = []
135
+
136
+ def get_role_func_name(self, base_name: str, phase: str) -> str:
137
+ key = f"{self.source}|{self.event}|{phase}|{base_name}"
138
+ if key not in self.role_func_map:
139
+ func_name = f"RoleFunc_{base_name}_{self.source}_{self.event}_{phase}"
140
+ self.role_func_map[key] = func_name
141
+ return self.role_func_map[key]
142
+
143
+ def to_dict(self) -> dict:
144
+ return {
145
+ 'source': self.source,
146
+ 'event': self.event,
147
+ 'layer_name': self.layer_name,
148
+ 'flow_items': [i.to_dict() for i in self.flow_items],
149
+ 'default_target': self.default_target,
150
+ 'system_globals': [g.to_dict() for g in self.system_globals],
151
+ 'generated_code': self.generated_code,
152
+ 'role_func_map': self.role_func_map,
153
+ 'user_code': self.user_code,
154
+ # v2.2
155
+ 'cell_actions': [
156
+ {'role_function': a.role_function, 'trigger': a.trigger,
157
+ 'title': a.title}
158
+ for a in self.cell_actions
159
+ ],
160
+ 'cell_relations': [
161
+ {'kind': r.kind, 'members': list(r.members),
162
+ 'shared_condition': r.shared_condition, 'note': r.note}
163
+ for r in self.cell_relations
164
+ ],
165
+ }
166
+
167
+ @classmethod
168
+ def from_dict(cls, data: dict) -> 'ActionDraft':
169
+ d = cls(
170
+ source=data.get('source', ''),
171
+ event=data.get('event', ''),
172
+ layer_name=data.get('layer_name', ''),
173
+ flow_items=[FlowItem.from_dict(i)
174
+ for i in data.get('flow_items', [])],
175
+ default_target=data.get('default_target', ''),
176
+ system_globals=[SystemGlobal.from_dict(g)
177
+ for g in data.get('system_globals', [])],
178
+ generated_code=data.get('generated_code', ''),
179
+ role_func_map=data.get('role_func_map', {}),
180
+ user_code=data.get('user_code', {}),
181
+ )
182
+ d.cell_actions = [
183
+ ActionStep(
184
+ role_function=a.get('role_function', ''),
185
+ trigger=a.get('trigger', 'always'),
186
+ title=a.get('title', ''),
187
+ )
188
+ for a in data.get('cell_actions', [])
189
+ ]
190
+ d.cell_relations = [
191
+ TransitionRelation(
192
+ kind=r.get('kind', 'sequential'),
193
+ members=list(r.get('members', [])),
194
+ shared_condition=r.get('shared_condition', ''),
195
+ note=r.get('note', ''),
196
+ )
197
+ for r in data.get('cell_relations', [])
198
+ ]
199
+ return d
200
+
201
+
202
+ def transition_to_flow_item(trans) -> FlowItem:
203
+ """Transition -> FlowItem (v2.2: preserves early_return / label)."""
204
+ pre_actions = ensure_list(getattr(trans, 'pre_actions', []))
205
+ else_actions = ensure_list(getattr(trans, 'else_actions', []))
206
+ condition = getattr(trans, 'condition', '')
207
+ if not isinstance(condition, str):
208
+ logger.error(f"Condition is not str: {type(condition)}. Using empty string.")
209
+ condition = ""
210
+
211
+ event_name = trans.event if trans.event else "NewEvent"
212
+
213
+ logger.debug(
214
+ f"transition_to_flow_item: event='{event_name}', "
215
+ f"condition='{condition}', pre_actions={pre_actions}, "
216
+ f"else_actions={else_actions}")
217
+
218
+ return FlowItem(
219
+ item_type="transition",
220
+ name=event_name,
221
+ edited_text=(
222
+ trans.title if trans.title != "(untitled transition)"
223
+ else event_name
224
+ ),
225
+ params={
226
+ "event": event_name,
227
+ "condition": condition,
228
+ "pre_actions": pre_actions,
229
+ "target": trans.target,
230
+ "has_else": getattr(trans, 'has_else', True),
231
+ "else_target": getattr(trans, 'else_target', ''),
232
+ "else_actions": else_actions,
233
+ # v2.2
234
+ "early_return": getattr(trans, 'early_return', False),
235
+ "label": getattr(trans, 'label', ''),
236
+ }
237
+ )
238
+
239
+
240
+ def flow_item_to_transition(item: FlowItem, source: str, event: str):
241
+ """FlowItem -> Transition (v2.2: preserves early_return / label)."""
242
+ from statable.model import Transition
243
+ params = item.params
244
+ pre_actions = ensure_list(params.get('pre_actions', []))
245
+ else_actions = ensure_list(params.get('else_actions', []))
246
+ condition = params.get('condition', '')
247
+ if not isinstance(condition, str):
248
+ logger.error(f"Condition is not str: {type(condition)}. Using empty string.")
249
+ condition = ""
250
+
251
+ logger.debug(
252
+ f"flow_item_to_transition: condition='{condition}', "
253
+ f"pre_actions={pre_actions}, else_actions={else_actions}")
254
+
255
+ return Transition(
256
+ source=source,
257
+ event=event,
258
+ condition=condition,
259
+ pre_actions=pre_actions,
260
+ target=params.get('target', ''),
261
+ has_else=params.get('has_else', True),
262
+ else_target=params.get('else_target', ''),
263
+ else_actions=else_actions,
264
+ early_return=params.get('early_return', False),
265
+ label=params.get('label', ''),
266
+ action="",
267
+ transition_type="external",
268
+ title=item.edited_text if item.edited_text and item.edited_text != item.name
269
+ else "(untitled transition)",
270
+ )