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,605 @@
1
+ """State transition event definition dialog"""
2
+
3
+ from typing import Optional, List
4
+
5
+ from PySide6.QtCore import Qt
6
+ from PySide6.QtGui import QFont, QMouseEvent
7
+ from PySide6.QtWidgets import (
8
+ QDialog, QVBoxLayout, QHBoxLayout, QTableWidget, QTableWidgetItem,
9
+ QPushButton, QLabel, QHeaderView, QComboBox, QLineEdit,
10
+ QFormLayout, QDialogButtonBox, QMessageBox, QAbstractItemView,
11
+ QWidget, QRadioButton, QButtonGroup, QCheckBox, QSpinBox,
12
+ QGroupBox
13
+ )
14
+
15
+ from statable.state_machine import StateMachine
16
+ from statable.model import (Event, EventDeliveryType, EventSourceLayer,
17
+ EventKind, EventTrigger)
18
+ from statable.global_defs import GlobalDefinitions
19
+ from .common_widgets import TitleEditWidget, TypeComboBox
20
+ from .logger import StaTableLogger
21
+
22
+
23
+ class DoubleClickTable(QTableWidget):
24
+ """Table that reliably captures double-click events"""
25
+ def __init__(self, *args, **kwargs):
26
+ super().__init__(*args, **kwargs)
27
+ self.setEditTriggers(QAbstractItemView.NoEditTriggers)
28
+ self.setSelectionBehavior(QAbstractItemView.SelectRows)
29
+ self.setSelectionMode(QAbstractItemView.SingleSelection)
30
+
31
+ def mouseDoubleClickEvent(self, event: QMouseEvent):
32
+ pos = event.position().toPoint()
33
+ item = self.itemAt(pos)
34
+ if item:
35
+ row = item.row()
36
+ self.cellDoubleClicked.emit(row, item.column())
37
+
38
+
39
+ class EventEditDialog(QDialog):
40
+ """Edit state transition event dialog"""
41
+
42
+ def __init__(self, parent=None, event: Optional[Event] = None,
43
+ global_defs=None, state_machine=None):
44
+ super().__init__(parent)
45
+ self.global_defs = global_defs if global_defs else GlobalDefinitions()
46
+ self.state_machine = state_machine
47
+ self.setWindowTitle(self.tr("Edit state transition event"))
48
+ self.setMinimumWidth(500)
49
+
50
+ layout = QVBoxLayout(self)
51
+ form = QFormLayout()
52
+ layout.addLayout(form)
53
+
54
+ # Title input field (required / provisional title auto-set)
55
+ self.title_widget = TitleEditWidget(self, title=event.title if event else "")
56
+ form.addRow("", self.title_widget)
57
+
58
+ # Event name
59
+ self.name_edit = QLineEdit(event.name if event else "")
60
+ form.addRow("Event name", self.name_edit)
61
+
62
+ # Event ID
63
+ self.id_spin = QSpinBox()
64
+ self.id_spin.setRange(0, 65535)
65
+ if event and event.id is not None:
66
+ self.id_spin.setValue(event.id)
67
+ form.addRow("Event ID", self.id_spin)
68
+
69
+ # description
70
+ self.desc_edit = QLineEdit(event.description if event else "")
71
+ form.addRow("Description", self.desc_edit)
72
+
73
+ # Event kind
74
+ self.kind_combo = QComboBox()
75
+ for kind in EventKind:
76
+ self.kind_combo.addItem(kind.value, kind)
77
+ if event:
78
+ idx = self.kind_combo.findData(event.kind)
79
+ if idx >= 0:
80
+ self.kind_combo.setCurrentIndex(idx)
81
+ form.addRow("Event kind", self.kind_combo)
82
+
83
+ # Source layer
84
+ self.layer_driver = QRadioButton(self.tr("Driver layer"))
85
+ self.layer_middleware = QRadioButton(self.tr("Middle layer"))
86
+ group = QButtonGroup(self)
87
+ group.addButton(self.layer_driver)
88
+ group.addButton(self.layer_middleware)
89
+ if event and event.source_layer == EventSourceLayer.MIDDLEWARE:
90
+ self.layer_middleware.setChecked(True)
91
+ else:
92
+ self.layer_driver.setChecked(True)
93
+ layer_layout = QHBoxLayout()
94
+ layer_layout.addWidget(self.layer_driver)
95
+ layer_layout.addWidget(self.layer_middleware)
96
+ layer_layout.addStretch()
97
+ form.addRow("Source layer", layer_layout)
98
+
99
+ # Delivery type
100
+ self.delivery_combo = QComboBox()
101
+ self.delivery_combo.addItem(self.tr("DIRECT"), EventDeliveryType.DIRECT)
102
+ self.delivery_combo.addItem(self.tr("QUEUE"), EventDeliveryType.QUEUE)
103
+ self.delivery_combo.addItem(self.tr("DOUBLE"), EventDeliveryType.DOUBLE)
104
+ if event:
105
+ idx = self.delivery_combo.findData(event.delivery_type)
106
+ if idx >= 0:
107
+ self.delivery_combo.setCurrentIndex(idx)
108
+ form.addRow("Delivery type", self.delivery_combo)
109
+
110
+ # Attached data
111
+ self.data_check = QCheckBox(self.tr("Use attached data"))
112
+ self.data_check.setChecked(bool(event.data_type if event else False))
113
+ form.addRow("", self.data_check)
114
+
115
+ self.data_type_combo = TypeComboBox(self, global_defs=self.global_defs)
116
+ if event:
117
+ self.data_type_combo.set_current_text(event.data_type)
118
+ form.addRow("Data type", self.data_type_combo)
119
+
120
+ self.data_name_edit = QLineEdit(event.data_name if event else "")
121
+ form.addRow("Data variable name", self.data_name_edit)
122
+
123
+ self.data_check.toggled.connect(self._on_data_check_toggled)
124
+ self._on_data_check_toggled(self.data_check.isChecked())
125
+
126
+ # [C-51 Step 2] Trigger free-text field.
127
+ # Enabled only for kind in {signal, call, time};
128
+ # kind=change is handled inside transition conditions.
129
+ self.trigger_edit = QLineEdit(
130
+ getattr(event, 'trigger', '') if event else "")
131
+ self.trigger_edit.setPlaceholderText(
132
+ self.tr("e.g. GPIO edge (ISR), debounce 5ms / "
133
+ "periodic timer 10ms / direct call from Application"))
134
+ self.trigger_edit.setToolTip(
135
+ self.tr("Free-text description of when / from where this event fires.\n"
136
+ "Examples:\n"
137
+ " signal: GPIO edge (ISR), debounce 5ms\n"
138
+ " signal: from Application layer via OSAL queue\n"
139
+ " call: direct call from Middleware\n"
140
+ " time: periodic timer (10ms)\n"
141
+ "Not used for kind=change (use transition condition)."))
142
+ form.addRow("Trigger", self.trigger_edit)
143
+ # [C-51 Step 3] Structured trigger detail section
144
+ self._build_trigger_detail_section(layout, event)
145
+ self.kind_combo.currentIndexChanged.connect(
146
+ self._on_kind_changed)
147
+ self._on_kind_changed()
148
+
149
+ # OK/Cancel
150
+ buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
151
+ buttons.accepted.connect(self._on_accept)
152
+ buttons.rejected.connect(self.reject)
153
+ layout.addWidget(buttons)
154
+
155
+ StaTableLogger.debug("EventEditDialog initialized")
156
+
157
+ def _on_data_check_toggled(self, checked: bool):
158
+ self.data_type_combo.setEnabled(checked)
159
+ self.data_name_edit.setEnabled(checked)
160
+
161
+ def _on_kind_changed(self):
162
+ # [C-51 Step 2] trigger is meaningful only for
163
+ # signal / call / time. kind=change uses the
164
+ # transition condition instead.
165
+ kind = self.kind_combo.currentData()
166
+ enabled = kind in (
167
+ EventKind.SIGNAL, EventKind.CALL, EventKind.TIME)
168
+ self.trigger_edit.setEnabled(enabled)
169
+ if not enabled:
170
+ self.trigger_edit.setToolTip(
171
+ self.tr("kind=change fires via the transition condition; "
172
+ "trigger is not used."))
173
+ # [C-51 Step 3] keep the structured trigger section in sync
174
+ trig_group = getattr(self, 'trig_group', None)
175
+ if trig_group is not None:
176
+ trig_group.setEnabled(enabled)
177
+
178
+ # ------------------------------------------------------------------
179
+ # [C-51 Step 3] Structured trigger detail
180
+ # ------------------------------------------------------------------
181
+ def _build_trigger_detail_section(self, parent_layout, event):
182
+ """Add collapsible Trigger detail QGroupBox."""
183
+ group = QGroupBox(self.tr("Trigger detail (structured)"))
184
+ group.setCheckable(True)
185
+ group_layout = QFormLayout(group)
186
+
187
+ td = getattr(event, 'trigger_detail', None) if event else None
188
+ group.setChecked(td is not None)
189
+
190
+ # Type
191
+ self.trig_type_combo = QComboBox()
192
+ for t in ("manual", "edge", "polling", "timer",
193
+ "call", "comparison"):
194
+ self.trig_type_combo.addItem(t, t)
195
+ if td is not None:
196
+ idx = self.trig_type_combo.findData(td.type)
197
+ if idx >= 0:
198
+ self.trig_type_combo.setCurrentIndex(idx)
199
+ group_layout.addRow("Type", self.trig_type_combo)
200
+
201
+ # Source (editable: allows custom entry)
202
+ self.trig_source_combo = QComboBox()
203
+ self.trig_source_combo.setEditable(True)
204
+ self.trig_source_combo.setInsertPolicy(QComboBox.NoInsert)
205
+ if td is not None and td.source:
206
+ self.trig_source_combo.setCurrentText(td.source)
207
+ group_layout.addRow("Source", self.trig_source_combo)
208
+
209
+ # Edge (edge type)
210
+ self.trig_edge_combo = QComboBox()
211
+ for e in ("rising", "falling", "both"):
212
+ self.trig_edge_combo.addItem(e, e)
213
+ if td is not None and td.edge:
214
+ idx = self.trig_edge_combo.findData(td.edge)
215
+ if idx >= 0:
216
+ self.trig_edge_combo.setCurrentIndex(idx)
217
+ self.trig_edge_row = self._register_row(
218
+ group_layout, "Edge", self.trig_edge_combo)
219
+
220
+ # Debounce (edge type)
221
+ self.trig_debounce_spin = QSpinBox()
222
+ self.trig_debounce_spin.setRange(0, 100000)
223
+ self.trig_debounce_spin.setSuffix(" ms")
224
+ if td is not None:
225
+ self.trig_debounce_spin.setValue(td.debounce_ms)
226
+ self.trig_debounce_row = self._register_row(
227
+ group_layout, "Debounce", self.trig_debounce_spin)
228
+
229
+ # Period (polling / timer)
230
+ self.trig_period_spin = QSpinBox()
231
+ self.trig_period_spin.setRange(0, 10000000)
232
+ self.trig_period_spin.setSuffix(" ms")
233
+ if td is not None:
234
+ self.trig_period_spin.setValue(td.period_ms)
235
+ self.trig_period_row = self._register_row(
236
+ group_layout, "Period", self.trig_period_spin)
237
+
238
+ # Auto reload (timer)
239
+ self.trig_autoreload_check = QCheckBox(self.tr("Auto reload"))
240
+ self.trig_autoreload_check.setChecked(
241
+ td.auto_reload if td is not None else True)
242
+ self.trig_autoreload_row = self._register_row(
243
+ group_layout, "Timer", self.trig_autoreload_check)
244
+
245
+ # Caller (call)
246
+ self.trig_caller_combo = QComboBox()
247
+ self.trig_caller_combo.setEditable(True)
248
+ self.trig_caller_combo.setInsertPolicy(QComboBox.NoInsert)
249
+ if td is not None and td.caller:
250
+ self.trig_caller_combo.setCurrentText(td.caller)
251
+ self.trig_caller_row = self._register_row(
252
+ group_layout, "Caller", self.trig_caller_combo)
253
+
254
+ # Condition + Build (comparison)
255
+ self.trig_condition_edit = QLineEdit()
256
+ self.trig_condition_build_btn = QPushButton(self.tr("Build..."))
257
+ self.trig_condition_build_btn.clicked.connect(
258
+ self._open_trigger_condition_builder)
259
+ cond_widget = QWidget()
260
+ cond_layout = QHBoxLayout(cond_widget)
261
+ cond_layout.setContentsMargins(0, 0, 0, 0)
262
+ cond_layout.addWidget(self.trig_condition_edit, 1)
263
+ cond_layout.addWidget(self.trig_condition_build_btn)
264
+ if td is not None:
265
+ self.trig_condition_edit.setText(td.condition)
266
+ self.trig_condition_row = self._register_row(
267
+ group_layout, "Condition", cond_widget)
268
+
269
+ # Poll period (comparison)
270
+ self.trig_pollperiod_spin = QSpinBox()
271
+ self.trig_pollperiod_spin.setRange(0, 10000000)
272
+ self.trig_pollperiod_spin.setSuffix(" ms")
273
+ if td is not None:
274
+ self.trig_pollperiod_spin.setValue(td.poll_period_ms)
275
+ self.trig_pollperiod_row = self._register_row(
276
+ group_layout, "Poll period", self.trig_pollperiod_spin)
277
+
278
+ # Description (optional)
279
+ self.trig_desc_edit = QLineEdit()
280
+ if td is not None:
281
+ self.trig_desc_edit.setText(td.description)
282
+ group_layout.addRow("Description", self.trig_desc_edit)
283
+
284
+ # Wire up
285
+ self.trig_type_combo.currentIndexChanged.connect(
286
+ self._on_trigger_type_changed)
287
+ group.toggled.connect(lambda _c: self._on_trigger_type_changed())
288
+
289
+ parent_layout.addWidget(group)
290
+ self.trig_group = group
291
+
292
+ # Initial state
293
+ self._on_trigger_type_changed()
294
+
295
+ def _register_row(self, layout, label_text, widget):
296
+ """Add a QFormLayout row; return (label, widget) for visibility."""
297
+ layout.addRow(label_text, widget)
298
+ label = layout.labelForField(widget)
299
+ return (label, widget)
300
+
301
+ def _set_row_visible(self, row, visible):
302
+ lbl, w = row
303
+ if lbl is not None:
304
+ lbl.setVisible(visible)
305
+ w.setVisible(visible)
306
+
307
+ def _on_trigger_type_changed(self):
308
+ """Show/hide dynamic fields based on Type; refresh Source choices."""
309
+ t = self.trig_type_combo.currentData()
310
+
311
+ self._set_row_visible(self.trig_edge_row, t == "edge")
312
+ self._set_row_visible(self.trig_debounce_row, t == "edge")
313
+ self._set_row_visible(self.trig_period_row,
314
+ t in ("polling", "timer"))
315
+ self._set_row_visible(self.trig_autoreload_row, t == "timer")
316
+ self._set_row_visible(self.trig_caller_row, t == "call")
317
+ self._set_row_visible(self.trig_condition_row, t == "comparison")
318
+ self._set_row_visible(self.trig_pollperiod_row, t == "comparison")
319
+
320
+ self._populate_source_choices(t)
321
+
322
+ def _populate_source_choices(self, trigger_type):
323
+ """Populate Source dropdown from GlobalDefinitions / role_functions."""
324
+ current = self.trig_source_combo.currentText().strip()
325
+ self.trig_source_combo.blockSignals(True)
326
+ self.trig_source_combo.clear()
327
+ self.trig_source_combo.addItem(self.tr(""))
328
+
329
+ candidates = []
330
+ if trigger_type == "edge":
331
+ for intr in getattr(self.global_defs, 'interrupts', []) or []:
332
+ if getattr(intr, 'name', ''):
333
+ candidates.append(intr.name)
334
+ elif trigger_type in ("polling", "timer"):
335
+ tb = getattr(self.global_defs, 'timer_base', None)
336
+ if tb is not None and getattr(tb, 'variable_name', ''):
337
+ candidates.append(tb.variable_name)
338
+ for t in getattr(self.global_defs, 'extra_timers', []) or []:
339
+ if getattr(t, 'variable_name', ''):
340
+ candidates.append(t.variable_name)
341
+ elif trigger_type == "call":
342
+ if self.state_machine is not None:
343
+ for rf in self.state_machine.role_functions.values():
344
+ ns = getattr(rf, 'namespace', '')
345
+ n = getattr(rf, 'name', '')
346
+ qn = f"{ns}.{n}" if ns else n
347
+ if qn:
348
+ candidates.append(qn)
349
+ # comparison: no candidates (use Build button)
350
+
351
+ for c in candidates:
352
+ self.trig_source_combo.addItem(c)
353
+
354
+ if current:
355
+ self.trig_source_combo.setCurrentText(current)
356
+ self.trig_source_combo.blockSignals(False)
357
+
358
+ def _open_trigger_condition_builder(self):
359
+ """Open ConditionBuilderDialog for comparison-type condition."""
360
+ try:
361
+ from .condition_builder_dialog import ConditionBuilderDialog
362
+ except ImportError:
363
+ return
364
+ dlg = ConditionBuilderDialog(
365
+ condition=self.trig_condition_edit.text(),
366
+ event_name=self.name_edit.text().strip(),
367
+ global_defs=self.global_defs,
368
+ state_machine=self.state_machine,
369
+ parent=self,
370
+ )
371
+ if dlg.exec() == QDialog.Accepted:
372
+ self.trig_condition_edit.setText(dlg.get_condition_text())
373
+
374
+ def _get_trigger_detail(self):
375
+ """Build EventTrigger from UI, or None if section unchecked."""
376
+ if not self.trig_group.isChecked():
377
+ return None
378
+ t = self.trig_type_combo.currentData()
379
+ kwargs = {
380
+ "type": t,
381
+ "source": self.trig_source_combo.currentText().strip(),
382
+ "description": self.trig_desc_edit.text().strip(),
383
+ }
384
+ if t == "edge":
385
+ kwargs["edge"] = self.trig_edge_combo.currentData()
386
+ kwargs["debounce_ms"] = self.trig_debounce_spin.value()
387
+ elif t == "polling":
388
+ kwargs["period_ms"] = self.trig_period_spin.value()
389
+ elif t == "timer":
390
+ kwargs["period_ms"] = self.trig_period_spin.value()
391
+ kwargs["auto_reload"] = self.trig_autoreload_check.isChecked()
392
+ elif t == "call":
393
+ kwargs["caller"] = self.trig_caller_combo.currentText().strip()
394
+ elif t == "comparison":
395
+ kwargs["condition"] = self.trig_condition_edit.text().strip()
396
+ kwargs["poll_period_ms"] = self.trig_pollperiod_spin.value()
397
+ return EventTrigger(**kwargs)
398
+
399
+ def _on_accept(self):
400
+ """OK button: auto-set provisional title if title is empty"""
401
+ auto_title = f"Event: {self.name_edit.text().strip() or '(unnamed)'}"
402
+ self.title_widget.ensure_title(auto_title)
403
+ self.accept()
404
+
405
+ def get_event(self) -> Event:
406
+ source_layer = EventSourceLayer.MIDDLEWARE if self.layer_middleware.isChecked() else EventSourceLayer.DRIVER
407
+ data_type = self.data_type_combo.current_text() if self.data_check.isChecked() else ""
408
+ data_name = self.data_name_edit.text().strip() if self.data_check.isChecked() else ""
409
+ return Event(
410
+ name=self.name_edit.text().strip(),
411
+ id=self.id_spin.value(),
412
+ kind=self.kind_combo.currentData(),
413
+ description=self.desc_edit.text().strip(),
414
+ delivery_type=self.delivery_combo.currentData(),
415
+ source_layer=source_layer,
416
+ data_type=data_type,
417
+ data_name=data_name,
418
+ title=self.title_widget.get_title(),
419
+ # [C-51 Step 2] Only meaningful for signal/call/time.
420
+ # For other kinds, store "" regardless of UI state.
421
+ trigger=(self.trigger_edit.text().strip()
422
+ if self.kind_combo.currentData() in (
423
+ EventKind.SIGNAL, EventKind.CALL,
424
+ EventKind.TIME)
425
+ else ""),
426
+ # [C-51 Step 3] Structured trigger detail
427
+ trigger_detail=self._get_trigger_detail(),
428
+ )
429
+
430
+
431
+ class EventDefinitionDialog(QDialog):
432
+ """State transition event definition list dialog"""
433
+
434
+ def __init__(self, sm: StateMachine, global_defs: Optional[GlobalDefinitions] = None, parent=None):
435
+ super().__init__(parent)
436
+ self.sm = sm
437
+ self.global_defs = global_defs if global_defs else GlobalDefinitions()
438
+ self.setWindowTitle(self.tr("StateTransitionEvent definitions"))
439
+ self.setMinimumSize(1100, 650)
440
+
441
+ layout = QVBoxLayout(self)
442
+
443
+ # Search
444
+ search_layout = QHBoxLayout()
445
+ search_layout.addWidget(QLabel(self.tr("Search (prefix match):")))
446
+ self.search_edit = QLineEdit()
447
+ self.search_edit.setPlaceholderText(self.tr("Title, event name, description"))
448
+ self.search_edit.textChanged.connect(self.refresh_table)
449
+ search_layout.addWidget(self.search_edit)
450
+ layout.addLayout(search_layout)
451
+
452
+ # Event list table (title column added / direct edit)
453
+ self.table = DoubleClickTable(0, 7)
454
+ self.table.setHorizontalHeaderLabels(["Title", "Event name", "ID", "Source", "Delivery type", "Attached data", "Description"])
455
+ self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
456
+ self.table.setFont(QFont("Consolas", 10))
457
+ self.table.cellDoubleClicked.connect(self.on_double_clicked)
458
+ self.table.itemChanged.connect(self.on_item_changed)
459
+ layout.addWidget(self.table, stretch=1)
460
+
461
+ # Buttons
462
+ btn_layout = QHBoxLayout()
463
+ add_btn = QPushButton(self.tr("Add"))
464
+ add_btn.clicked.connect(self.add_event)
465
+ del_btn = QPushButton(self.tr("Delete"))
466
+ del_btn.clicked.connect(self.delete_event)
467
+ btn_layout.addWidget(add_btn)
468
+ btn_layout.addWidget(del_btn)
469
+ btn_layout.addStretch()
470
+ layout.addLayout(btn_layout)
471
+
472
+ # Close
473
+ close_btn = QPushButton(self.tr("Close"))
474
+ close_btn.clicked.connect(self.accept)
475
+ layout.addWidget(close_btn, alignment=Qt.AlignRight)
476
+
477
+ self.refresh_table()
478
+ StaTableLogger.debug("EventDefinitionDialog initialized")
479
+
480
+ def refresh_table(self):
481
+ query = self.search_edit.text().strip().lower() if hasattr(self, 'search_edit') else ""
482
+ self.table.setRowCount(0)
483
+ for event in self.sm.events.values():
484
+ name = event.name if event.name else "(completion)"
485
+ desc = event.description
486
+ title = event.title
487
+ if query and not (title.lower().startswith(query) or name.lower().startswith(query) or desc.lower().startswith(query)):
488
+ continue
489
+ row = self.table.rowCount()
490
+ self.table.insertRow(row)
491
+
492
+ # Title (directly editable)
493
+ title_item = QTableWidgetItem(title)
494
+ title_item.setToolTip(self.tr("Title of this event. Can be edited directly."))
495
+ self.table.setItem(row, 0, title_item)
496
+
497
+ self.table.setItem(row, 1, QTableWidgetItem(name))
498
+ self.table.setItem(row, 2, QTableWidgetItem(str(event.id) if event.id is not None else ""))
499
+ self.table.setItem(row, 3, QTableWidgetItem(event.source_layer.value))
500
+ self.table.setItem(row, 4, QTableWidgetItem(event.delivery_type.value))
501
+ data_text = f"{event.data_type} {event.data_name}" if event.data_type else ""
502
+ self.table.setItem(row, 5, QTableWidgetItem(data_text))
503
+ self.table.setItem(row, 6, QTableWidgetItem(event.description))
504
+
505
+ def on_item_changed(self, item):
506
+ """Handler when the title column is edited directly"""
507
+ if item.column() == 0:
508
+ row = item.row()
509
+ name_item = self.table.item(row, 1)
510
+ if name_item:
511
+ name = name_item.text()
512
+ if name == "(completion)":
513
+ name = ""
514
+ if name in self.sm.events:
515
+ new_title = item.text().strip() or f"Event: {name or '(Completion)'}"
516
+ self.sm.events[name].title = new_title
517
+
518
+ def _find_event_by_row(self, row: int) -> Optional[Event]:
519
+ name_item = self.table.item(row, 1)
520
+ if not name_item:
521
+ return None
522
+ name = name_item.text()
523
+ if name == "(completion)":
524
+ name = ""
525
+ return self.sm.events.get(name)
526
+
527
+ def on_double_clicked(self, row, col):
528
+ if col == 0:
529
+ return
530
+ event = self._find_event_by_row(row)
531
+ if not event:
532
+ return
533
+ dlg = EventEditDialog(self, event=event,
534
+ global_defs=self.global_defs,
535
+ state_machine=self.sm)
536
+ if dlg.exec() == QDialog.Accepted:
537
+ new_event = dlg.get_event()
538
+ if not new_event.name:
539
+ QMessageBox.warning(self, self.tr("Warning"), self.tr("Please enter an event name."))
540
+ return
541
+ # Handler when the name changes
542
+ old_name = event.name
543
+ if old_name != new_event.name:
544
+ if new_event.name in self.sm.events:
545
+ QMessageBox.warning(self, self.tr("Warning"), f"Event '{new_event.name}' already exists.")
546
+ return
547
+ #Rebuild dict preserving original positions
548
+ new_events = {}
549
+ for key, value in self.sm.events.items():
550
+ if key == old_name:
551
+ new_events[new_event.name] = new_event
552
+ else:
553
+ new_events[key] = value
554
+ self.sm.events = new_events
555
+ for trans in self.sm.transitions:
556
+ if trans.event == old_name:
557
+ trans.event = new_event.name
558
+ else:
559
+ self.sm.events[old_name] = new_event
560
+
561
+ self.refresh_table()
562
+ StaTableLogger.info(f"Event updated: {new_event.name}")
563
+
564
+ def add_event(self):
565
+ dlg = EventEditDialog(self, global_defs=self.global_defs)
566
+ if dlg.exec() == QDialog.Accepted:
567
+ event = dlg.get_event()
568
+ if not event.name:
569
+ QMessageBox.warning(self, self.tr("Warning"), self.tr("Please enter an event name."))
570
+ return
571
+ if event.name in self.sm.events:
572
+ QMessageBox.warning(self, self.tr("Warning"), f"Event '{event.name}' already exists.")
573
+ return
574
+ self.sm.add_event(event)
575
+ self.refresh_table()
576
+ StaTableLogger.info(f"Event added: {event.name}")
577
+
578
+ def delete_event(self):
579
+ row = self.table.currentRow()
580
+ if row < 0:
581
+ return
582
+ event = self._find_event_by_row(row)
583
+ if not event:
584
+ return
585
+
586
+ # Dependency check
587
+ transitions = self.sm.get_transitions_for_event(event.name)
588
+ if transitions:
589
+ msg = f"Event '{event.title}' cannot be deleted.\n\nUsed by the following transitions:\n\n"
590
+ for trans in transitions:
591
+ msg += f" - {trans.source} → {trans.target}\n"
592
+ msg += "\nPlease delete these transitions first."
593
+ QMessageBox.warning(self, self.tr("Warning"), msg)
594
+ return
595
+
596
+ # Interrupt handler check
597
+ for intr in self.global_defs.interrupts:
598
+ if event.name in intr.event_names:
599
+ QMessageBox.warning(self, self.tr("Warning"), f"Event '{event.title}' is used by interrupt handler '{intr.title}'.")
600
+ return
601
+ reply = QMessageBox.question(self, self.tr("Confirm"), f"Event '{event.title}': confirm delete?", QMessageBox.Yes | QMessageBox.No)
602
+ if reply == QMessageBox.Yes:
603
+ self.sm.remove_event(event.name)
604
+ self.refresh_table()
605
+ StaTableLogger.info(f"Event deleted: {event.name}")