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,244 @@
1
+ """
2
+ statable_gui/role_function_dialog.py
3
+ Role function create / edit dialog.
4
+
5
+ Version History
6
+ ---------------
7
+ v1.0 - Initial dialog.
8
+ v1.5 - Added namespace field.
9
+ v3.7 - Removed reserved fields from the UI.
10
+ v3.8 - Layout restructured (3 QGroupBox sections).
11
+ v3.9 - Namespace field as editable QComboBox.
12
+
13
+ [R-7]
14
+ - Added "+ New Literal" button below the "Used literals" group.
15
+ Opens NewLiteralDialog, adds the new LiteralDefinition to the
16
+ shared literal_library, and appends it (pre-checked) to the
17
+ list widget.
18
+ - New constructor argument `literal_library=None`. When None, the
19
+ button is hidden and behavior is unchanged (backward compat).
20
+ """
21
+
22
+ from PySide6.QtWidgets import (
23
+ QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, QLineEdit, QComboBox,
24
+ QListWidget, QListWidgetItem, QDialogButtonBox, QGroupBox,
25
+ QPushButton, QMessageBox,
26
+ )
27
+ from PySide6.QtCore import Qt
28
+
29
+ from statable.model import RoleFunction
30
+ from .logger import StaTableLogger
31
+
32
+ try:
33
+ from .literal_definition_dialog import NewLiteralDialog
34
+ except ImportError:
35
+ from literal_definition_dialog import NewLiteralDialog
36
+
37
+
38
+ class RoleFunctionDialog(QDialog):
39
+ """Dialog for creating / editing role functions."""
40
+
41
+ def __init__(self, parent=None, role_function=None,
42
+ global_vars=None, events=None, literals=None,
43
+ namespace_choices=None,
44
+ literal_library=None):
45
+ super().__init__(parent)
46
+ self.setWindowTitle(self.tr("Edit role function"))
47
+ self.setMinimumWidth(500)
48
+
49
+ self._original = role_function
50
+ self._global_vars = list(global_vars or [])
51
+ self._events = list(events or [])
52
+ self._literals = list(literals or [])
53
+ self._namespace_choices = list(namespace_choices or [])
54
+ # [R-7] shared library for creating new literals
55
+ self._literal_library = literal_library
56
+
57
+ self._setup_ui()
58
+ self._load_data()
59
+ StaTableLogger.debug("RoleFunctionDialog initialized")
60
+
61
+ # ------------------------------------------------------------------
62
+ # UI construction
63
+ # ------------------------------------------------------------------
64
+ def _setup_ui(self):
65
+ main_layout = QVBoxLayout(self)
66
+
67
+ # ---- Top: form fields ----
68
+ form = QFormLayout()
69
+
70
+ self.name_edit = QLineEdit()
71
+ form.addRow("Function name:", self.name_edit)
72
+
73
+ self.namespace_combo = QComboBox()
74
+ self.namespace_combo.setEditable(True)
75
+ self.namespace_combo.setInsertPolicy(QComboBox.NoInsert)
76
+ self.namespace_combo.addItem(self.tr(""))
77
+ for ns in self._namespace_choices:
78
+ if ns and ns != "":
79
+ self.namespace_combo.addItem(ns)
80
+ self.namespace_combo.setToolTip(
81
+ self.tr("Namespace (layer name / feature group name).\n"
82
+ "If specified, it can be referenced as 'Driver.Init'.\n"
83
+ "If empty, it is treated as having no layer ('Init').\n"
84
+ "Select from the list, or type a new value.")
85
+ )
86
+ form.addRow("Namespace:", self.namespace_combo)
87
+
88
+ self.title_edit = QLineEdit()
89
+ self.title_edit.setPlaceholderText(
90
+ self.tr("Label shown in the list (auto-set if empty)"))
91
+ form.addRow("Display name:", self.title_edit)
92
+
93
+ self.desc_edit = QLineEdit()
94
+ form.addRow("Description:", self.desc_edit)
95
+
96
+ main_layout.addLayout(form)
97
+
98
+ # ---- Middle: three checkbox groups ----
99
+ self.global_list = self._make_check_list(
100
+ main_layout, "Used global variables", self._global_vars)
101
+ self.event_list = self._make_check_list(
102
+ main_layout, "Used events", self._events)
103
+ self.literal_list = self._make_check_list(
104
+ main_layout, "Used literals (select from existing)",
105
+ self._literals)
106
+
107
+ # [R-7] "+ New Literal" button (only when library available)
108
+ if self._literal_library is not None:
109
+ btn_row = QHBoxLayout()
110
+ btn_row.addStretch()
111
+ self.new_literal_btn = QPushButton(self.tr("+ New Literal"))
112
+ self.new_literal_btn.setToolTip(
113
+ self.tr("Create a new literal and add it to the list above"))
114
+ self.new_literal_btn.clicked.connect(self._on_new_literal)
115
+ btn_row.addWidget(self.new_literal_btn)
116
+ main_layout.addLayout(btn_row)
117
+
118
+ # ---- Bottom: buttons ----
119
+ buttons = QDialogButtonBox(
120
+ QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
121
+ buttons.accepted.connect(self._on_accept)
122
+ buttons.rejected.connect(self.reject)
123
+ main_layout.addWidget(buttons)
124
+
125
+ @staticmethod
126
+ def _make_check_list(parent_layout, title, items):
127
+ group = QGroupBox(title)
128
+ group_layout = QVBoxLayout(group)
129
+ lst = QListWidget()
130
+ lst.setSelectionMode(QListWidget.NoSelection)
131
+ for text in items:
132
+ item = QListWidgetItem(text)
133
+ item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
134
+ item.setCheckState(Qt.Unchecked)
135
+ lst.addItem(item)
136
+ group_layout.addWidget(lst)
137
+ parent_layout.addWidget(group)
138
+ return lst
139
+
140
+ # ------------------------------------------------------------------
141
+ # Data binding
142
+ # ------------------------------------------------------------------
143
+ def _load_data(self):
144
+ rf = self._original
145
+ if rf is None:
146
+ return
147
+
148
+ self.name_edit.setText(rf.name)
149
+ self.title_edit.setText(rf.title)
150
+ self.desc_edit.setText(rf.description)
151
+
152
+ ns = getattr(rf, 'namespace', '') or ''
153
+ idx = self.namespace_combo.findText(ns)
154
+ if idx >= 0:
155
+ self.namespace_combo.setCurrentIndex(idx)
156
+ else:
157
+ self.namespace_combo.setEditText(ns)
158
+
159
+ self._check_items(
160
+ self.global_list,
161
+ getattr(rf, 'used_global_vars', []) or [])
162
+ self._check_items(
163
+ self.event_list,
164
+ getattr(rf, 'used_events', []) or [])
165
+ self._check_items(
166
+ self.literal_list,
167
+ getattr(rf, 'used_literals', []) or [])
168
+
169
+ @staticmethod
170
+ def _check_items(lst, selected):
171
+ sel = set(selected)
172
+ for i in range(lst.count()):
173
+ item = lst.item(i)
174
+ if item.text() in sel:
175
+ item.setCheckState(Qt.Checked)
176
+
177
+ @staticmethod
178
+ def _collect_checked(lst):
179
+ result = []
180
+ for i in range(lst.count()):
181
+ item = lst.item(i)
182
+ if item.checkState() == Qt.Checked:
183
+ result.append(item.text())
184
+ return result
185
+
186
+ # ------------------------------------------------------------------
187
+ # [R-7] New literal handler
188
+ # ------------------------------------------------------------------
189
+ def _on_new_literal(self):
190
+ if self._literal_library is None:
191
+ return
192
+
193
+ dlg = NewLiteralDialog(self)
194
+ if dlg.exec() != QDialog.Accepted:
195
+ return
196
+
197
+ lit = dlg.get_literal()
198
+ try:
199
+ self._literal_library.add(lit)
200
+ except ValueError:
201
+ QMessageBox.warning(
202
+ self, self.tr("Warning"),
203
+ f"A literal named '{lit.name}' already exists.")
204
+ return
205
+
206
+ # Append to the list widget, pre-checked
207
+ item = QListWidgetItem(lit.name)
208
+ item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
209
+ item.setCheckState(Qt.Checked)
210
+ self.literal_list.addItem(item)
211
+ StaTableLogger.info(f"New literal created: {lit.name}")
212
+
213
+ # ------------------------------------------------------------------
214
+ # OK / Cancel
215
+ # ------------------------------------------------------------------
216
+ def _on_accept(self):
217
+ if not self.title_edit.text().strip():
218
+ auto_title = (
219
+ f"Role function: "
220
+ f"{self.name_edit.text().strip() or '(unnamed)'}")
221
+ self.title_edit.setText(auto_title)
222
+ self.accept()
223
+
224
+ # ------------------------------------------------------------------
225
+ # Result
226
+ # ------------------------------------------------------------------
227
+ def get_role_function(self) -> RoleFunction:
228
+ orig = self._original
229
+ namespace = self.namespace_combo.currentText().strip()
230
+
231
+ return RoleFunction(
232
+ name=self.name_edit.text().strip(),
233
+ namespace=namespace,
234
+ description=self.desc_edit.text().strip(),
235
+ title=self.title_edit.text().strip(),
236
+ return_type=orig.return_type if orig else "void",
237
+ arg1_type=orig.arg1_type if orig else "",
238
+ arg1_name=orig.arg1_name if orig else "",
239
+ arg2_type=orig.arg2_type if orig else "",
240
+ arg2_name=orig.arg2_name if orig else "",
241
+ used_global_vars=self._collect_checked(self.global_list),
242
+ used_events=self._collect_checked(self.event_list),
243
+ used_literals=self._collect_checked(self.literal_list),
244
+ )
@@ -0,0 +1,11 @@
1
+ """Sample data generation (GUI layer re-export)"""
2
+
3
+ from statable.sample_data import (
4
+ create_sample_state_machine,
5
+ create_sample_global_defs,
6
+ )
7
+
8
+ __all__ = [
9
+ "create_sample_state_machine",
10
+ "create_sample_global_defs",
11
+ ]
@@ -0,0 +1,398 @@
1
+ # statable_gui/state_actions_dialog.py
2
+ """
3
+ State actions (Entry / Exit / Do) editing dialog.
4
+
5
+ Version: 1.0 (2026-09-26 / Phase 4a)
6
+
7
+ Provides StateActionsDialog: a 4-tab editor for per-state actions.
8
+
9
+ Tabs:
10
+ - Entry: actions executed once when entering the state
11
+ - Exit: actions executed once when leaving the state
12
+ - Do: actions executed every loop while in the state
13
+ - Preview: generated C code preview
14
+
15
+ Each action tab contains:
16
+ - Action list (QTableWidget: Type / Target / Condition)
17
+ - Add / Delete / Up / Down buttons
18
+ - Custom code marker hint (read-only)
19
+
20
+ On OK, updates State.entry / State.exit / State.do_actions.
21
+
22
+ Design notes:
23
+ - Uses statable.model.ActionStep.
24
+ - action_type: "role" (default) or "fire_event".
25
+ - Empty Target rows are dropped on OK.
26
+ - Custom code editing is done in the generated file
27
+ (marker: [[STABLE_USER_CODE_START:<Layer>_<Kind>_<State>_custom]]).
28
+ """
29
+
30
+ from typing import List, Optional
31
+
32
+ from PySide6.QtCore import Qt
33
+ from PySide6.QtGui import QFont
34
+ from PySide6.QtWidgets import (
35
+ QDialog, QVBoxLayout, QHBoxLayout, QTableWidget, QTableWidgetItem,
36
+ QPushButton, QDialogButtonBox, QTabWidget, QWidget,
37
+ QLabel, QGroupBox, QHeaderView, QPlainTextEdit,
38
+ )
39
+
40
+ from statable.model import State, ActionStep
41
+ from statable.state_machine import StateMachine
42
+
43
+ from .logger import StaTableLogger
44
+
45
+
46
+ KIND_ENTRY = "Entry"
47
+ KIND_EXIT = "Exit"
48
+ KIND_DO = "Do"
49
+
50
+
51
+ class _ActionListWidget(QWidget):
52
+ """Reusable action list editor.
53
+
54
+ Columns:
55
+ 0: Type ("role" or "fire_event")
56
+ 1: Target (RoleFunc name, or Event name)
57
+ 2: Condition (C expression; empty = unconditional)
58
+ """
59
+
60
+ COL_TYPE = 0
61
+ COL_TARGET = 1
62
+ COL_CONDITION = 2
63
+
64
+ VALID_TYPES = ("role", "fire_event")
65
+
66
+ def __init__(self, parent=None):
67
+ super().__init__(parent)
68
+ self._setup_ui()
69
+
70
+ def _setup_ui(self):
71
+ layout = QVBoxLayout(self)
72
+ layout.setContentsMargins(0, 0, 0, 0)
73
+
74
+ self.table = QTableWidget(0, 3)
75
+ self.table.setHorizontalHeaderLabels(["Type", "Target", "Condition"])
76
+ self.table.horizontalHeader().setSectionResizeMode(
77
+ self.COL_TYPE, QHeaderView.ResizeToContents)
78
+ self.table.horizontalHeader().setSectionResizeMode(
79
+ self.COL_TARGET, QHeaderView.Stretch)
80
+ self.table.horizontalHeader().setSectionResizeMode(
81
+ self.COL_CONDITION, QHeaderView.Stretch)
82
+ self.table.setFont(QFont("Consolas", 10))
83
+ layout.addWidget(self.table)
84
+
85
+ btn_row = QHBoxLayout()
86
+ self.add_btn = QPushButton(self.tr("+ Add"))
87
+ self.add_btn.clicked.connect(self._on_add)
88
+ self.del_btn = QPushButton(self.tr("Delete"))
89
+ self.del_btn.clicked.connect(self._on_delete)
90
+ self.up_btn = QPushButton(self.tr("Up"))
91
+ self.up_btn.clicked.connect(self._on_up)
92
+ self.down_btn = QPushButton(self.tr("Down"))
93
+ self.down_btn.clicked.connect(self._on_down)
94
+ btn_row.addWidget(self.add_btn)
95
+ btn_row.addWidget(self.del_btn)
96
+ btn_row.addStretch()
97
+ btn_row.addWidget(self.up_btn)
98
+ btn_row.addWidget(self.down_btn)
99
+ layout.addLayout(btn_row)
100
+
101
+ # ------------------------------------------------------------------
102
+ # Actions
103
+ # ------------------------------------------------------------------
104
+ def _on_add(self):
105
+ row = self.table.rowCount()
106
+ self.table.insertRow(row)
107
+ self.table.setItem(row, self.COL_TYPE,
108
+ QTableWidgetItem("role"))
109
+ self.table.setItem(row, self.COL_TARGET,
110
+ QTableWidgetItem(""))
111
+ self.table.setItem(row, self.COL_CONDITION,
112
+ QTableWidgetItem(""))
113
+ self.table.editItem(self.table.item(row, self.COL_TARGET))
114
+
115
+ def _on_delete(self):
116
+ row = self.table.currentRow()
117
+ if row >= 0:
118
+ self.table.removeRow(row)
119
+
120
+ def _on_up(self):
121
+ row = self.table.currentRow()
122
+ if row > 0:
123
+ self._swap_rows(row, row - 1)
124
+ self.table.selectRow(row - 1)
125
+
126
+ def _on_down(self):
127
+ row = self.table.currentRow()
128
+ if 0 <= row < self.table.rowCount() - 1:
129
+ self._swap_rows(row, row + 1)
130
+ self.table.selectRow(row + 1)
131
+
132
+ def _swap_rows(self, r1: int, r2: int):
133
+ for col in range(self.table.columnCount()):
134
+ i1 = self.table.takeItem(r1, col)
135
+ i2 = self.table.takeItem(r2, col)
136
+ self.table.setItem(r1, col, i2)
137
+ self.table.setItem(r2, col, i1)
138
+
139
+ # ------------------------------------------------------------------
140
+ # Data binding
141
+ # ------------------------------------------------------------------
142
+ def set_actions(self, actions: List[ActionStep]):
143
+ """Populate the table from a List[ActionStep]."""
144
+ self.table.setRowCount(0)
145
+ for a in (actions or []):
146
+ row = self.table.rowCount()
147
+ self.table.insertRow(row)
148
+
149
+ atype = (getattr(a, "action_type", "role") or "role")
150
+ if atype not in self.VALID_TYPES:
151
+ atype = "role"
152
+
153
+ if atype == "fire_event":
154
+ target = getattr(a, "event_name", "") or ""
155
+ else:
156
+ target = getattr(a, "role_function", "") or ""
157
+
158
+ cond = getattr(a, "condition", "") or ""
159
+
160
+ self.table.setItem(row, self.COL_TYPE,
161
+ QTableWidgetItem(atype))
162
+ self.table.setItem(row, self.COL_TARGET,
163
+ QTableWidgetItem(target))
164
+ self.table.setItem(row, self.COL_CONDITION,
165
+ QTableWidgetItem(cond))
166
+
167
+ def get_actions(self) -> List[ActionStep]:
168
+ """Return the current table contents as List[ActionStep]."""
169
+ result: List[ActionStep] = []
170
+ for row in range(self.table.rowCount()):
171
+ type_item = self.table.item(row, self.COL_TYPE)
172
+ target_item = self.table.item(row, self.COL_TARGET)
173
+ cond_item = self.table.item(row, self.COL_CONDITION)
174
+
175
+ atype = (type_item.text().strip()
176
+ if type_item else "role") or "role"
177
+ if atype not in self.VALID_TYPES:
178
+ atype = "role"
179
+ target = target_item.text().strip() if target_item else ""
180
+ cond = cond_item.text().strip() if cond_item else ""
181
+
182
+ if not target:
183
+ # Empty target rows are dropped
184
+ continue
185
+
186
+ if atype == "fire_event":
187
+ result.append(ActionStep(
188
+ action_type="fire_event",
189
+ event_name=target,
190
+ condition=cond,
191
+ ))
192
+ else:
193
+ result.append(ActionStep(
194
+ action_type="role",
195
+ role_function=target,
196
+ condition=cond,
197
+ ))
198
+ return result
199
+
200
+
201
+ class StateActionsDialog(QDialog):
202
+ """State actions (Entry / Exit / Do) editor.
203
+
204
+ [Phase 4a]
205
+ 4 tabs: Entry / Exit / Do / Preview.
206
+ Each action tab hosts an _ActionListWidget.
207
+ On OK, updates State.entry / State.exit / State.do_actions.
208
+ """
209
+
210
+ KIND_TO_ATTR = {
211
+ KIND_ENTRY: "entry",
212
+ KIND_EXIT: "exit",
213
+ KIND_DO: "do_actions",
214
+ }
215
+
216
+ @staticmethod
217
+ def _to_pascal(name: str) -> str:
218
+ """Convert snake_case / lowerCamelCase to PascalCase."""
219
+ if not name:
220
+ return ""
221
+ # Simple heuristic: capitalize each '_'-separated segment
222
+ parts = [p for p in name.replace("-", "_").split("_") if p]
223
+ return "".join(p[:1].upper() + p[1:] for p in parts)
224
+
225
+ @classmethod
226
+ def _rf_call_preview(cls, layer: str, role_function: str) -> str:
227
+ """Build a preview-only RoleFunc_<NS>_<Name>(NULL, ctx) string."""
228
+ if not role_function:
229
+ return ""
230
+ rf = role_function.strip()
231
+ if rf.startswith("RoleFunc_"):
232
+ full = rf
233
+ elif "." in rf:
234
+ ns, name = rf.split(".", 1)
235
+ full = (f"RoleFunc_{cls._to_pascal(ns)}_"
236
+ f"{cls._to_pascal(name)}")
237
+ else:
238
+ full = (f"RoleFunc_{layer}_{cls._to_pascal(rf)}"
239
+ if layer else f"RoleFunc_{cls._to_pascal(rf)}")
240
+ return f"(void){full}(NULL, ctx)"
241
+
242
+ def __init__(self, parent=None, state: Optional[State] = None,
243
+ state_machine: Optional[StateMachine] = None):
244
+ super().__init__(parent)
245
+ self.state = state
246
+ self.state_machine = state_machine
247
+
248
+ title_state = state.name if state is not None else "(unknown)"
249
+ self.setWindowTitle(f"State actions - {title_state}")
250
+ self.setMinimumSize(800, 600)
251
+
252
+ self._setup_ui()
253
+ self._load_state()
254
+ StaTableLogger.debug(
255
+ f"StateActionsDialog initialized: state={title_state}")
256
+
257
+ # ------------------------------------------------------------------
258
+ # UI
259
+ # ------------------------------------------------------------------
260
+ def _setup_ui(self):
261
+ layout = QVBoxLayout(self)
262
+
263
+ # Info label
264
+ info = QLabel(
265
+ self.tr("Entry / Exit / Do actions for this state.\n"
266
+ "Custom C code can be edited in the generated file "
267
+ "([[STABLE_USER_CODE_..._custom]] marker).")
268
+ )
269
+ info.setWordWrap(True)
270
+ layout.addWidget(info)
271
+
272
+ self.tabs = QTabWidget()
273
+ layout.addWidget(self.tabs)
274
+
275
+ # Entry / Exit / Do tabs
276
+ self.entry_widget = _ActionListWidget()
277
+ self.exit_widget = _ActionListWidget()
278
+ self.do_widget = _ActionListWidget()
279
+
280
+ self.tabs.addTab(self.entry_widget, KIND_ENTRY)
281
+ self.tabs.addTab(self.exit_widget, KIND_EXIT)
282
+ self.tabs.addTab(self.do_widget, KIND_DO)
283
+
284
+ # Preview tab
285
+ self.preview_widget = QPlainTextEdit()
286
+ self.preview_widget.setReadOnly(True)
287
+ self.preview_widget.setFont(QFont("Consolas", 10))
288
+ self.preview_widget.setPlaceholderText(
289
+ self.tr("Click 'Refresh' to preview the generated C code."))
290
+ preview_container = QWidget()
291
+ preview_layout = QVBoxLayout(preview_container)
292
+ refresh_btn = QPushButton(self.tr("Refresh preview"))
293
+ refresh_btn.clicked.connect(self._refresh_preview)
294
+ preview_layout.addWidget(refresh_btn)
295
+ preview_layout.addWidget(self.preview_widget)
296
+ self.tabs.addTab(preview_container, self.tr("Preview"))
297
+
298
+ # Buttons
299
+ buttons = QDialogButtonBox(
300
+ QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
301
+ buttons.accepted.connect(self._on_accept)
302
+ buttons.rejected.connect(self.reject)
303
+ layout.addWidget(buttons)
304
+
305
+ # ------------------------------------------------------------------
306
+ # Data binding
307
+ # ------------------------------------------------------------------
308
+ def _load_state(self):
309
+ if self.state is None:
310
+ return
311
+ self.entry_widget.set_actions(
312
+ list(getattr(self.state, "entry", []) or []))
313
+ self.exit_widget.set_actions(
314
+ list(getattr(self.state, "exit", []) or []))
315
+ self.do_widget.set_actions(
316
+ list(getattr(self.state, "do_actions", []) or []))
317
+
318
+ def _on_accept(self):
319
+ if self.state is not None:
320
+ self.state.entry = self.entry_widget.get_actions()
321
+ self.state.exit = self.exit_widget.get_actions()
322
+ self.state.do_actions = self.do_widget.get_actions()
323
+ StaTableLogger.debug(
324
+ f"StateActionsDialog OK: state={self.state.name}, "
325
+ f"entry={len(self.state.entry)}, "
326
+ f"exit={len(self.state.exit)}, "
327
+ f"do={len(self.state.do_actions)}")
328
+ self.accept()
329
+
330
+ # ------------------------------------------------------------------
331
+ # Preview
332
+ # ------------------------------------------------------------------
333
+ def _refresh_preview(self):
334
+ """Generate a small preview of what would be emitted for this state."""
335
+ if self.state is None:
336
+ self.preview_widget.setPlainText("(no state)")
337
+ return
338
+
339
+ state_name = self.state.name
340
+ layer = "Layer"
341
+ if self.state_machine is not None:
342
+ layer = getattr(self.state_machine, "layer_name", "") or "Layer"
343
+
344
+ lines = []
345
+ for kind, actions in (
346
+ (KIND_ENTRY, self.entry_widget.get_actions()),
347
+ (KIND_EXIT, self.exit_widget.get_actions()),
348
+ (KIND_DO, self.do_widget.get_actions()),
349
+ ):
350
+ func_name = f"{layer}_{kind}_{state_name}"
351
+ marker = f"{layer}_{kind}_{state_name}_custom"
352
+ lines.append(f"static void {func_name}(SystemContext_t *ctx)")
353
+ lines.append("{")
354
+ if actions:
355
+ lines.append(" /* --- GUI-edited actions (regenerated) --- */")
356
+ for a in actions:
357
+ atype = getattr(a, "action_type", "role") or "role"
358
+ cond = (getattr(a, "condition", "") or "").strip()
359
+ if atype == "fire_event":
360
+ evt = getattr(a, "event_name", "") or ""
361
+ # Mirror the codegen: FIRE_EVENT_<Layer>(ctx,
362
+ # EVENT_<Layer>_<UPPER_SNAKE>)
363
+ if "." in evt:
364
+ evt_layer, evt_name = evt.split(".", 1)
365
+ else:
366
+ evt_layer, evt_name = layer, evt
367
+ evt_upper = evt_name.upper().replace(" ", "_")
368
+ call = (f"FIRE_EVENT_{evt_layer}(ctx, "
369
+ f"EVENT_{evt_layer}_{evt_upper})")
370
+ else:
371
+ rf = getattr(a, "role_function", "") or ""
372
+ call = self._rf_call_preview(layer, rf)
373
+ if cond:
374
+ lines.append(f" if ({cond}) {{")
375
+ lines.append(f" {call};")
376
+ lines.append(" }")
377
+ else:
378
+ lines.append(f" {call};")
379
+ lines.append(" /* --- end GUI-edited actions --- */")
380
+ lines.append("")
381
+ lines.append(" /* --- user custom code (preserved) --- */")
382
+ lines.append(f" /* [[STABLE_USER_CODE_START:{marker}]] */")
383
+ lines.append(" (void)ctx;")
384
+ lines.append(f" /* [[STABLE_USER_CODE_END:{marker}]] */")
385
+ lines.append("}")
386
+ lines.append("")
387
+
388
+ self.preview_widget.setPlainText("\n".join(lines))
389
+
390
+
391
+ # ======================================================================
392
+ # Convenience entry point
393
+ # ======================================================================
394
+ def open_state_actions_dialog(parent, state, state_machine):
395
+ """Open the dialog and return True if the user accepted."""
396
+ dlg = StateActionsDialog(
397
+ parent=parent, state=state, state_machine=state_machine)
398
+ return dlg.exec() == QDialog.Accepted