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,856 @@
1
+ # statable_gui/transition_editor_direct/actions_tab.py
2
+ """Pre / Post Actions tab widget (v2.2, v2.5 role-func management).
3
+
4
+ Two groups:
5
+ - Pre : before_transitions (executed before the transition chain)
6
+ - Post : after_transitions (executed after the transition chain)
7
+
8
+ [v2.2 change]
9
+ - `always` trigger removed; old data migrated to `before_transitions`.
10
+ - Role function input is an editable ComboBox.
11
+
12
+ [v2.5 change]
13
+ - Each _ActionGroup gains three buttons:
14
+ + New Role Function -> RoleFunctionDialog, registers into
15
+ state_machine.role_functions and
16
+ appends a row to the group table.
17
+ Edit Role Function -> edits the role function bound to the
18
+ currently selected row.
19
+ Delete Role Function -> removes it from state_machine and
20
+ deletes all rows referencing it.
21
+ - New constructor args: role_function_library, literal_library,
22
+ layer_names_provider (all optional / keyword).
23
+
24
+ [v2.5 fix]
25
+ - StateMachine.role_functions is keyed by *bare* name (rf.name),
26
+ but this tab displays the *qualified* name (rf.namespace + '.' +
27
+ rf.name). Edit / Delete previously looked up the dict with the
28
+ qualified name and therefore always failed with "not registered".
29
+ The new helper _find_rf_by_display() resolves qualified_name ->
30
+ RoleFunction object, and the caller uses rf.name for the dict
31
+ operations.
32
+ - Extensive debug logging added to _on_new / _on_edit / _on_delete
33
+ so that failures can be diagnosed from the log alone.
34
+ """
35
+
36
+ import logging
37
+ from typing import List, Optional
38
+
39
+ from PySide6.QtCore import Qt, Signal
40
+ from PySide6.QtWidgets import (
41
+ QWidget, QVBoxLayout, QHBoxLayout, QTableWidget, QTableWidgetItem,
42
+ QPushButton, QComboBox, QHeaderView, QAbstractItemView, QLabel,
43
+ QGroupBox, QMessageBox, QDialog,
44
+ )
45
+
46
+ from statable.model import ActionStep
47
+ from statable_gui.role_function_dialog import RoleFunctionDialog
48
+ from .draft import ActionDraft
49
+
50
+ logger = logging.getLogger("transition_editor_direct.actions_tab")
51
+
52
+
53
+ # Supported triggers (always is deprecated)
54
+ TRIGGER_PRE = "before_transitions"
55
+ TRIGGER_POST = "after_transitions"
56
+
57
+ # Migration map
58
+ _LEGACY_TRIGGER_MAP = {
59
+ "always": TRIGGER_PRE, # v2.2: always -> before_transitions
60
+ }
61
+
62
+
63
+ def _migrate_trigger(trigger: str) -> str:
64
+ """Migrate legacy trigger names to the new ones."""
65
+ t = (trigger or "").strip()
66
+ if t in _LEGACY_TRIGGER_MAP:
67
+ return _LEGACY_TRIGGER_MAP[t]
68
+ if t in (TRIGGER_PRE, TRIGGER_POST):
69
+ return t
70
+ return TRIGGER_PRE
71
+
72
+
73
+ def _qualified_name(rf) -> str:
74
+ """Return qualified_name if available, else fall back to name."""
75
+ qn = getattr(rf, 'qualified_name', None)
76
+ if qn:
77
+ return qn
78
+ return getattr(rf, 'name', '') or ''
79
+
80
+
81
+ class _ActionGroup(QGroupBox):
82
+ """A QGroupBox containing a role function list for one trigger."""
83
+
84
+ changed = Signal()
85
+
86
+ def __init__(self, title: str, trigger: str,
87
+ role_functions: List[str],
88
+ role_function_library=None,
89
+ literal_library=None,
90
+ layer_names_provider=None,
91
+ global_defs=None,
92
+ state_machine=None,
93
+ parent=None):
94
+ super().__init__(title, parent)
95
+ self.trigger = trigger
96
+ self.role_functions = list(role_functions or [])
97
+
98
+ # v2.5: context for RoleFunctionDialog
99
+ self.role_function_library = role_function_library
100
+ self.literal_library = literal_library
101
+ self.layer_names_provider = layer_names_provider
102
+ self.global_defs = global_defs
103
+ self.state_machine = state_machine
104
+
105
+ logger.debug(
106
+ f"_ActionGroup.__init__: title='{title}', trigger='{trigger}', "
107
+ f"role_functions={len(self.role_functions)}, "
108
+ f"has_sm={state_machine is not None}, "
109
+ f"has_rflib={role_function_library is not None}, "
110
+ f"has_litlib={literal_library is not None}, "
111
+ f"has_lnp={layer_names_provider is not None}")
112
+
113
+ layout = QVBoxLayout(self)
114
+
115
+ self.table = QTableWidget(0, 1)
116
+ self.table.setHorizontalHeaderLabels(["Role function"])
117
+ self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
118
+ self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
119
+ self.table.setSelectionMode(QAbstractItemView.SingleSelection)
120
+ layout.addWidget(self.table)
121
+
122
+ # ---- Row 1: combo + row operations ----
123
+ btn_layout = QHBoxLayout()
124
+ self.combo = QComboBox()
125
+ self.combo.setEditable(True)
126
+ self.combo.addItems(self.role_functions)
127
+ btn_layout.addWidget(self.combo, stretch=1)
128
+
129
+ add_btn = QPushButton(self.tr("+ Add"))
130
+ add_btn.clicked.connect(self._on_add)
131
+ btn_layout.addWidget(add_btn)
132
+
133
+ del_btn = QPushButton(self.tr("Delete"))
134
+ del_btn.setToolTip(self.tr("Remove the selected row from this list "
135
+ "(does not delete the role function itself)"))
136
+ del_btn.clicked.connect(self._on_delete)
137
+ btn_layout.addWidget(del_btn)
138
+
139
+ up_btn = QPushButton(self.tr("Up"))
140
+ up_btn.clicked.connect(self._on_move_up)
141
+ btn_layout.addWidget(up_btn)
142
+
143
+ down_btn = QPushButton(self.tr("Down"))
144
+ down_btn.clicked.connect(self._on_move_down)
145
+ btn_layout.addWidget(down_btn)
146
+
147
+ layout.addLayout(btn_layout)
148
+
149
+ # ---- Row 2 (v2.5): role function management ----
150
+ role_btn_layout = QHBoxLayout()
151
+
152
+ self.new_role_btn = QPushButton(self.tr("+ New Role Function"))
153
+ self.new_role_btn.setToolTip(
154
+ self.tr("Create a new role function and add it to this list"))
155
+ self.new_role_btn.clicked.connect(self._on_new_role_function)
156
+ role_btn_layout.addWidget(self.new_role_btn)
157
+
158
+ self.edit_role_btn = QPushButton(self.tr("Edit Role Function"))
159
+ self.edit_role_btn.setToolTip(
160
+ self.tr("Edit the role function bound to the selected row"))
161
+ self.edit_role_btn.clicked.connect(self._on_edit_role_function)
162
+ role_btn_layout.addWidget(self.edit_role_btn)
163
+
164
+ self.delete_role_btn = QPushButton(self.tr("Delete Role Function"))
165
+ self.delete_role_btn.setToolTip(
166
+ self.tr("Delete the selected role function from the state machine"))
167
+ self.delete_role_btn.clicked.connect(self._on_delete_role_function)
168
+ role_btn_layout.addWidget(self.delete_role_btn)
169
+
170
+ role_btn_layout.addStretch()
171
+ layout.addLayout(role_btn_layout)
172
+
173
+ # ------------------------------------------------------------------
174
+ # Public setters
175
+ # ------------------------------------------------------------------
176
+ def set_role_functions(self, names: List[str]):
177
+ self.role_functions = list(names or [])
178
+ current = self.combo.currentText()
179
+ self.combo.clear()
180
+ self.combo.addItems(self.role_functions)
181
+ if current:
182
+ self.combo.setCurrentText(current)
183
+ logger.debug(
184
+ f"set_role_functions: {len(self.role_functions)} candidates")
185
+
186
+ def set_actions(self, actions: List[ActionStep]):
187
+ """Replace the list (filtered by trigger)."""
188
+ self.table.setRowCount(0)
189
+ for a in actions:
190
+ if _migrate_trigger(a.trigger) != self.trigger:
191
+ continue
192
+ self._append_row(a.role_function)
193
+ logger.debug(
194
+ f"set_actions: table rows={self.table.rowCount()}, "
195
+ f"trigger={self.trigger}")
196
+
197
+ def get_actions(self) -> List[ActionStep]:
198
+ result = []
199
+ for row in range(self.table.rowCount()):
200
+ item = self.table.item(row, 0)
201
+ name = item.text().strip() if item else ""
202
+ if name:
203
+ result.append(ActionStep(
204
+ role_function=name,
205
+ trigger=self.trigger,
206
+ title=name,
207
+ ))
208
+ return result
209
+
210
+ def row_count(self) -> int:
211
+ return self.table.rowCount()
212
+
213
+ # ------------------------------------------------------------------
214
+ # Slots (row operations - existing)
215
+ # ------------------------------------------------------------------
216
+ def _append_row(self, role_function: str):
217
+ row = self.table.rowCount()
218
+ self.table.insertRow(row)
219
+ self.table.setItem(row, 0, QTableWidgetItem(role_function))
220
+ logger.debug(
221
+ f"_append_row: '{role_function}' -> row {row}")
222
+
223
+ def _on_add(self):
224
+ name = self.combo.currentText().strip()
225
+ if not name:
226
+ return
227
+ self._append_row(name)
228
+ self.changed.emit()
229
+
230
+ def _on_delete(self):
231
+ row = self.table.currentRow()
232
+ if row >= 0:
233
+ self.table.removeRow(row)
234
+ self.changed.emit()
235
+
236
+ def _on_move_up(self):
237
+ row = self.table.currentRow()
238
+ if row <= 0:
239
+ return
240
+ self._swap(row, row - 1)
241
+ self.table.setCurrentCell(row - 1, 0)
242
+ self.changed.emit()
243
+
244
+ def _on_move_down(self):
245
+ row = self.table.currentRow()
246
+ if row < 0 or row >= self.table.rowCount() - 1:
247
+ return
248
+ self._swap(row, row + 1)
249
+ self.table.setCurrentCell(row + 1, 0)
250
+ self.changed.emit()
251
+
252
+ def _swap(self, r1: int, r2: int):
253
+ i1 = self.table.takeItem(r1, 0)
254
+ i2 = self.table.takeItem(r2, 0)
255
+ self.table.setItem(r1, 0, i2)
256
+ self.table.setItem(r2, 0, i1)
257
+
258
+ # ------------------------------------------------------------------
259
+ # v2.5: debug helper
260
+ # ------------------------------------------------------------------
261
+ def _dump_sm_roles(self, label: str):
262
+ """Log the current StateMachine.role_functions contents.
263
+
264
+ Useful for diagnosing qualified-vs-bare name mismatches.
265
+ """
266
+ if self.state_machine is None:
267
+ logger.debug(f"[{label}] state_machine is None")
268
+ return
269
+ try:
270
+ items = list(self.state_machine.role_functions.items())
271
+ except Exception as e:
272
+ logger.warning(f"[{label}] cannot list role_functions: {e}")
273
+ return
274
+ logger.debug(f"[{label}] state_machine.role_functions: "
275
+ f"{len(items)} entries")
276
+ for key, rf in items:
277
+ logger.debug(
278
+ f"[{label}] key='{key}' "
279
+ f"name='{getattr(rf, 'name', '?')}' "
280
+ f"namespace='{getattr(rf, 'namespace', '?')}' "
281
+ f"qualified='{_qualified_name(rf)}'")
282
+
283
+ # ------------------------------------------------------------------
284
+ # v2.5: role-function lookup
285
+ # ------------------------------------------------------------------
286
+ def _find_rf_by_display(self, display_name: str):
287
+ """Find a StateMachine role function whose qualified_name
288
+ (or bare name) matches `display_name`.
289
+
290
+ The StateMachine keys role functions by *bare* name
291
+ (`rf.name`), but this tab displays the qualified name
292
+ (`rf.namespace + '.' + rf.name`). This helper bridges the
293
+ two so that Edit / Delete can locate the correct entry.
294
+
295
+ Returns the RoleFunction object, or None if not found.
296
+ """
297
+ if self.state_machine is None:
298
+ logger.warning(
299
+ f"_find_rf_by_display('{display_name}'): "
300
+ "state_machine is None")
301
+ return None
302
+ if not display_name:
303
+ logger.debug("_find_rf_by_display: empty display_name")
304
+ return None
305
+
306
+ entries = list(self.state_machine.role_functions.values())
307
+ logger.debug(
308
+ f"_find_rf_by_display('{display_name}'): "
309
+ f"scanning {len(entries)} entries")
310
+
311
+ for rf in entries:
312
+ qn = _qualified_name(rf)
313
+ bare = getattr(rf, 'name', '') or ''
314
+ logger.debug(
315
+ f" candidate: bare='{bare}', qualified='{qn}'")
316
+ if qn == display_name:
317
+ logger.debug(
318
+ f" -> matched by qualified_name: '{qn}'")
319
+ return rf
320
+ if bare == display_name:
321
+ logger.debug(
322
+ f" -> matched by bare name: '{bare}'")
323
+ return rf
324
+
325
+ logger.warning(
326
+ f"_find_rf_by_display('{display_name}'): NOT FOUND "
327
+ f"among {len(entries)} entries")
328
+ return None
329
+
330
+ # ------------------------------------------------------------------
331
+ # v2.5: namespace / dialog helpers
332
+ # ------------------------------------------------------------------
333
+ def _get_namespace_choices(self) -> List[str]:
334
+ """Build namespace candidates for the RoleFunctionDialog.
335
+
336
+ Order:
337
+ 1. layer_names_provider (all tabs in the project)
338
+ 2. state_machine.layer_name
339
+ 3. state_machine.role_functions[].namespace
340
+ 4. role_function_library entries
341
+ """
342
+ ordered: List[str] = []
343
+
344
+ def _add(ns):
345
+ ns = (ns or "").strip()
346
+ if ns and ns not in ordered:
347
+ ordered.append(ns)
348
+
349
+ if self.layer_names_provider is not None:
350
+ try:
351
+ for name in self.layer_names_provider():
352
+ _add(name)
353
+ except Exception as e:
354
+ logger.warning(f"layer_names_provider failed: {e}")
355
+
356
+ if self.state_machine is not None:
357
+ _add(getattr(self.state_machine, 'layer_name', ''))
358
+ try:
359
+ for rf in self.state_machine.role_functions.values():
360
+ _add(getattr(rf, 'namespace', ''))
361
+ except Exception as e:
362
+ logger.warning(
363
+ f"state_machine.role_functions iteration failed: {e}")
364
+
365
+ if self.role_function_library is not None:
366
+ try:
367
+ for rf in self.role_function_library.list_all():
368
+ _add(getattr(rf, 'namespace', ''))
369
+ except Exception as e:
370
+ logger.warning(
371
+ f"role_function_library.list_all() failed: {e}")
372
+
373
+ logger.debug(
374
+ f"_get_namespace_choices: {len(ordered)} candidates "
375
+ f"{ordered}")
376
+ return ordered
377
+
378
+ def _dialog_kwargs(self) -> dict:
379
+ """Build kwargs for RoleFunctionDialog (mirrors SettingsPanel)."""
380
+ global_vars = []
381
+ if self.global_defs is not None:
382
+ global_vars = [
383
+ getattr(v, 'name', '')
384
+ for v in (getattr(self.global_defs, 'variables', []) or [])
385
+ if getattr(v, 'name', '')
386
+ ]
387
+
388
+ events = []
389
+ if self.state_machine is not None:
390
+ events = [
391
+ getattr(e, 'name', '')
392
+ for e in self.state_machine.events.values()
393
+ if getattr(e, 'name', '')
394
+ ]
395
+
396
+ literals = []
397
+ if self.literal_library is not None:
398
+ try:
399
+ literals = [
400
+ getattr(lit, 'name', '')
401
+ for lit in self.literal_library.list_all()
402
+ if getattr(lit, 'name', '')
403
+ ]
404
+ except Exception as e:
405
+ logger.warning(
406
+ f"literal_library.list_all() failed: {e}")
407
+
408
+ kwargs = dict(
409
+ global_vars=global_vars,
410
+ events=events,
411
+ literals=literals,
412
+ namespace_choices=self._get_namespace_choices(),
413
+ )
414
+ logger.debug(
415
+ f"_dialog_kwargs: global_vars={len(global_vars)}, "
416
+ f"events={len(events)}, literals={len(literals)}, "
417
+ f"namespace_choices={len(kwargs['namespace_choices'])}")
418
+ return kwargs
419
+
420
+ def _sync_combo(self):
421
+ """Rebuild the editable combo from self.role_functions."""
422
+ current = self.combo.currentText()
423
+ self.combo.clear()
424
+ self.combo.addItems(self.role_functions)
425
+ if current:
426
+ self.combo.setCurrentText(current)
427
+ logger.debug(
428
+ f"_sync_combo: {len(self.role_functions)} items, "
429
+ f"current='{current}'")
430
+
431
+ # ------------------------------------------------------------------
432
+ # v2.5: slot implementations
433
+ # ------------------------------------------------------------------
434
+ def _on_new_role_function(self):
435
+ """v2.5: create a new role function and append it to the list."""
436
+ logger.debug("=== _on_new_role_function: ENTER ===")
437
+ if self.state_machine is None:
438
+ logger.warning(
439
+ "_on_new_role_function: state_machine is None -> abort")
440
+ QMessageBox.information(
441
+ self, self.tr("Information"),
442
+ self.tr("StateMachine is not available; cannot create role "
443
+ "functions from here."))
444
+ return
445
+
446
+ self._dump_sm_roles("_on_new_role_function/before")
447
+
448
+ dlg = RoleFunctionDialog(self, **self._dialog_kwargs())
449
+ logger.debug("_on_new_role_function: dialog opened")
450
+ if dlg.exec() != QDialog.Accepted:
451
+ logger.debug("_on_new_role_function: dialog cancelled")
452
+ return
453
+
454
+ rf = dlg.get_role_function()
455
+ logger.debug(
456
+ f"_on_new_role_function: got rf "
457
+ f"name='{rf.name}', namespace='{rf.namespace}', "
458
+ f"title='{rf.title}', qualified='{_qualified_name(rf)}'")
459
+
460
+ if not rf.name:
461
+ logger.warning(
462
+ "_on_new_role_function: empty name -> abort")
463
+ QMessageBox.warning(
464
+ self, self.tr("Warning"),
465
+ self.tr("Role function name is required."))
466
+ return
467
+ if rf.name in self.state_machine.role_functions:
468
+ logger.warning(
469
+ f"_on_new_role_function: duplicate bare name "
470
+ f"'{rf.name}' in state_machine")
471
+ QMessageBox.warning(
472
+ self, self.tr("Warning"),
473
+ self.tr("A role function with the same name already exists."))
474
+ return
475
+
476
+ try:
477
+ self.state_machine.add_role_function(rf)
478
+ logger.debug(
479
+ f"_on_new_role_function: added to sm key='{rf.name}'")
480
+ except ValueError as e:
481
+ logger.warning(
482
+ f"_on_new_role_function: add_role_function failed: {e}")
483
+ QMessageBox.warning(self, self.tr("Warning"), str(e))
484
+ return
485
+
486
+ self._dump_sm_roles("_on_new_role_function/after_add")
487
+
488
+ qn = _qualified_name(rf)
489
+ if qn and qn not in self.role_functions:
490
+ self.role_functions.append(qn)
491
+ self._sync_combo()
492
+ logger.debug(
493
+ f"_on_new_role_function: appended '{qn}' to local "
494
+ f"role_functions (now {len(self.role_functions)})")
495
+
496
+ self._append_row(qn)
497
+ last_row = self.table.rowCount() - 1
498
+ self.table.setCurrentCell(last_row, 0)
499
+ self.changed.emit()
500
+ logger.info(f"Role function created: {qn}")
501
+ logger.debug("=== _on_new_role_function: EXIT (ok) ===")
502
+
503
+ def _on_edit_role_function(self):
504
+ """v2.5: edit the role function bound to the selected row."""
505
+ logger.debug("=== _on_edit_role_function: ENTER ===")
506
+ row = self.table.currentRow()
507
+ logger.debug(f"_on_edit_role_function: currentRow={row}")
508
+ if row < 0:
509
+ QMessageBox.information(
510
+ self, self.tr("Information"), self.tr("Please select a row first."))
511
+ return
512
+ item = self.table.item(row, 0)
513
+ if item is None:
514
+ logger.warning(
515
+ f"_on_edit_role_function: no item at row {row}")
516
+ return
517
+ display_name = item.text().strip()
518
+ logger.debug(
519
+ f"_on_edit_role_function: display_name='{display_name}'")
520
+ if not display_name:
521
+ logger.warning(
522
+ "_on_edit_role_function: empty display_name")
523
+ return
524
+
525
+ if self.state_machine is None:
526
+ logger.warning(
527
+ "_on_edit_role_function: state_machine is None")
528
+ QMessageBox.information(
529
+ self, self.tr("Information"),
530
+ self.tr("StateMachine is not available."))
531
+ return
532
+
533
+ self._dump_sm_roles("_on_edit_role_function/before_lookup")
534
+
535
+ # [fix] Resolve via qualified_name -> RoleFunction object,
536
+ # then use its bare name as the StateMachine dict key.
537
+ rf = self._find_rf_by_display(display_name)
538
+ if rf is None:
539
+ logger.warning(
540
+ f"_on_edit_role_function: could not resolve "
541
+ f"'{display_name}'")
542
+ QMessageBox.information(
543
+ self, self.tr("Information"),
544
+ f"Role function '{display_name}' is not registered in "
545
+ "this state machine.\nOnly state-machine-owned role "
546
+ "functions can be edited here.")
547
+ return
548
+ pure_name = rf.name
549
+ logger.debug(
550
+ f"_on_edit_role_function: resolved '{display_name}' -> "
551
+ f"pure_name='{pure_name}'")
552
+
553
+ dlg = RoleFunctionDialog(
554
+ self, role_function=rf, **self._dialog_kwargs())
555
+ logger.debug("_on_edit_role_function: dialog opened")
556
+ if dlg.exec() != QDialog.Accepted:
557
+ logger.debug("_on_edit_role_function: dialog cancelled")
558
+ return
559
+
560
+ updated = dlg.get_role_function()
561
+ logger.debug(
562
+ f"_on_edit_role_function: updated "
563
+ f"name='{updated.name}', namespace='{updated.namespace}', "
564
+ f"qualified='{_qualified_name(updated)}'")
565
+
566
+ if (updated.name != pure_name
567
+ and updated.name in self.state_machine.role_functions):
568
+ logger.warning(
569
+ f"_on_edit_role_function: rename to '{updated.name}' "
570
+ f"collides with existing entry")
571
+ QMessageBox.warning(
572
+ self, self.tr("Warning"),
573
+ self.tr("A role function with the same name already exists."))
574
+ return
575
+
576
+ # [fix] remove_role_function takes the BARE name (dict key).
577
+ logger.debug(
578
+ f"_on_edit_role_function: removing old key '{pure_name}'")
579
+ self.state_machine.remove_role_function(pure_name)
580
+ try:
581
+ self.state_machine.add_role_function(updated)
582
+ logger.debug(
583
+ f"_on_edit_role_function: added new key "
584
+ f"'{updated.name}'")
585
+ except ValueError as e:
586
+ logger.warning(
587
+ f"_on_edit_role_function: add_role_function failed: {e} "
588
+ f"-> rollback")
589
+ # Roll back: re-add the original to avoid losing it
590
+ try:
591
+ self.state_machine.add_role_function(rf)
592
+ logger.debug(
593
+ "_on_edit_role_function: rollback succeeded")
594
+ except Exception as e2:
595
+ logger.error(
596
+ f"_on_edit_role_function: rollback failed: {e2}")
597
+ QMessageBox.warning(self, self.tr("Warning"), str(e))
598
+ return
599
+
600
+ self._dump_sm_roles("_on_edit_role_function/after_update")
601
+
602
+ new_qn = _qualified_name(updated)
603
+ item.setText(new_qn)
604
+ logger.debug(
605
+ f"_on_edit_role_function: row {row} text -> '{new_qn}'")
606
+
607
+ # Keep the combo / role_functions list in sync
608
+ for i, n in enumerate(self.role_functions):
609
+ if n == display_name:
610
+ self.role_functions[i] = new_qn
611
+ logger.debug(
612
+ f"_on_edit_role_function: role_functions[{i}] "
613
+ f"'{n}' -> '{new_qn}'")
614
+ break
615
+ else:
616
+ if new_qn and new_qn not in self.role_functions:
617
+ self.role_functions.append(new_qn)
618
+ logger.debug(
619
+ f"_on_edit_role_function: appended '{new_qn}' "
620
+ f"to role_functions")
621
+ self._sync_combo()
622
+
623
+ # Also rewrite any other rows referencing the old name
624
+ for r in range(self.table.rowCount()):
625
+ if r == row:
626
+ continue
627
+ it = self.table.item(r, 0)
628
+ if it is not None and it.text().strip() == display_name:
629
+ it.setText(new_qn)
630
+ logger.debug(
631
+ f"_on_edit_role_function: row {r} also updated "
632
+ f"-> '{new_qn}'")
633
+
634
+ self.changed.emit()
635
+ logger.info(
636
+ f"Role function updated: '{display_name}' -> '{new_qn}' "
637
+ f"(pure name: '{pure_name}' -> '{updated.name}')")
638
+ logger.debug("=== _on_edit_role_function: EXIT (ok) ===")
639
+
640
+ def _on_delete_role_function(self):
641
+ """v2.5: delete the selected role function from the state machine."""
642
+ logger.debug("=== _on_delete_role_function: ENTER ===")
643
+ row = self.table.currentRow()
644
+ logger.debug(f"_on_delete_role_function: currentRow={row}")
645
+ if row < 0:
646
+ QMessageBox.information(
647
+ self, self.tr("Information"), self.tr("Please select a row first."))
648
+ return
649
+ item = self.table.item(row, 0)
650
+ if item is None:
651
+ logger.warning(
652
+ f"_on_delete_role_function: no item at row {row}")
653
+ return
654
+ display_name = item.text().strip()
655
+ logger.debug(
656
+ f"_on_delete_role_function: display_name='{display_name}'")
657
+ if not display_name:
658
+ logger.warning(
659
+ "_on_delete_role_function: empty display_name")
660
+ return
661
+
662
+ if self.state_machine is None:
663
+ logger.warning(
664
+ "_on_delete_role_function: state_machine is None")
665
+ QMessageBox.information(
666
+ self, self.tr("Information"),
667
+ self.tr("StateMachine is not available."))
668
+ return
669
+
670
+ self._dump_sm_roles("_on_delete_role_function/before_lookup")
671
+
672
+ # [fix] Resolve via qualified_name -> RoleFunction object.
673
+ rf = self._find_rf_by_display(display_name)
674
+ if rf is None:
675
+ logger.warning(
676
+ f"_on_delete_role_function: could not resolve "
677
+ f"'{display_name}'")
678
+ QMessageBox.information(
679
+ self, self.tr("Information"),
680
+ f"Role function '{display_name}' is not registered in "
681
+ "this state machine.")
682
+ return
683
+ pure_name = rf.name
684
+ logger.debug(
685
+ f"_on_delete_role_function: resolved '{display_name}' -> "
686
+ f"pure_name='{pure_name}'")
687
+
688
+ reply = QMessageBox.question(
689
+ self, self.tr("Confirm delete"),
690
+ f"Delete role function '{display_name}' from the state "
691
+ "machine?\nThis removes it from all cells in this layer.",
692
+ QMessageBox.Yes | QMessageBox.No,
693
+ QMessageBox.No)
694
+ if reply != QMessageBox.Yes:
695
+ logger.debug(
696
+ "_on_delete_role_function: user declined -> abort")
697
+ return
698
+
699
+ # [fix] remove_role_function takes the BARE name.
700
+ logger.debug(
701
+ f"_on_delete_role_function: removing key '{pure_name}'")
702
+ self.state_machine.remove_role_function(pure_name)
703
+
704
+ self._dump_sm_roles("_on_delete_role_function/after_remove")
705
+
706
+ # Remove all rows referencing this display name
707
+ removed_rows = 0
708
+ for r in range(self.table.rowCount() - 1, -1, -1):
709
+ it = self.table.item(r, 0)
710
+ if it is not None and it.text().strip() == display_name:
711
+ self.table.removeRow(r)
712
+ removed_rows += 1
713
+ logger.debug(
714
+ f"_on_delete_role_function: removed {removed_rows} row(s) "
715
+ f"from table")
716
+
717
+ if display_name in self.role_functions:
718
+ self.role_functions.remove(display_name)
719
+ logger.debug(
720
+ f"_on_delete_role_function: removed '{display_name}' "
721
+ f"from role_functions (now {len(self.role_functions)})")
722
+ self._sync_combo()
723
+
724
+ self.changed.emit()
725
+ logger.info(
726
+ f"Role function deleted: '{display_name}' "
727
+ f"(pure name: '{pure_name}')")
728
+ logger.debug("=== _on_delete_role_function: EXIT (ok) ===")
729
+
730
+
731
+ class ActionsTab(QWidget):
732
+ """Pre / Post Actions tab."""
733
+
734
+ actions_changed = Signal()
735
+
736
+ def __init__(self, draft: ActionDraft,
737
+ role_functions: Optional[List[str]] = None,
738
+ global_defs=None, state_machine=None,
739
+ role_function_library=None,
740
+ literal_library=None,
741
+ layer_names_provider=None,
742
+ parent=None):
743
+ super().__init__(parent)
744
+ self.draft = draft
745
+ self.role_functions = list(role_functions or [])
746
+ self.global_defs = global_defs
747
+ self.state_machine = state_machine
748
+ # v2.5: context for RoleFunctionDialog
749
+ self.role_function_library = role_function_library
750
+ self.literal_library = literal_library
751
+ self.layer_names_provider = layer_names_provider
752
+
753
+ logger.debug(
754
+ f"ActionsTab.__init__: role_functions={len(self.role_functions)}, "
755
+ f"has_sm={state_machine is not None}, "
756
+ f"has_rflib={role_function_library is not None}, "
757
+ f"has_litlib={literal_library is not None}, "
758
+ f"has_lnp={layer_names_provider is not None}")
759
+
760
+ self._build_ui()
761
+
762
+ def set_role_functions(self, names: List[str]):
763
+ self.role_functions = list(names or [])
764
+ self.pre_group.set_role_functions(self.role_functions)
765
+ self.post_group.set_role_functions(self.role_functions)
766
+ logger.debug(
767
+ f"ActionsTab.set_role_functions: {len(self.role_functions)} "
768
+ f"candidates")
769
+
770
+ def _build_ui(self):
771
+ layout = QVBoxLayout(self)
772
+ layout.addWidget(QLabel(
773
+ self.tr("Cell-level actions:\n"
774
+ " Pre = before the transition chain (runs even if no transition fires)\n"
775
+ " Post = after the transition chain (runs even after early return)")))
776
+
777
+ self.pre_group = _ActionGroup(
778
+ "Pre (before transitions)", TRIGGER_PRE, self.role_functions,
779
+ role_function_library=self.role_function_library,
780
+ literal_library=self.literal_library,
781
+ layer_names_provider=self.layer_names_provider,
782
+ global_defs=self.global_defs,
783
+ state_machine=self.state_machine,
784
+ )
785
+ self.pre_group.changed.connect(self._on_changed)
786
+ layout.addWidget(self.pre_group)
787
+
788
+ self.post_group = _ActionGroup(
789
+ "Post (after transitions)", TRIGGER_POST, self.role_functions,
790
+ role_function_library=self.role_function_library,
791
+ literal_library=self.literal_library,
792
+ layer_names_provider=self.layer_names_provider,
793
+ global_defs=self.global_defs,
794
+ state_machine=self.state_machine,
795
+ )
796
+ self.post_group.changed.connect(self._on_changed)
797
+ layout.addWidget(self.post_group)
798
+
799
+ # ------------------------------------------------------------------
800
+ # Public API
801
+ # ------------------------------------------------------------------
802
+ def row_count(self) -> int:
803
+ """Total rows across both groups (for compatibility)."""
804
+ return self.pre_group.row_count() + self.post_group.row_count()
805
+
806
+ def add_action(self, action: Optional[ActionStep] = None) -> int:
807
+ """Add a row to the appropriate group. Returns the row index within that group."""
808
+ if action is None:
809
+ action = ActionStep(role_function="", trigger=TRIGGER_PRE)
810
+ trigger = _migrate_trigger(action.trigger)
811
+ target = self.pre_group if trigger == TRIGGER_PRE else self.post_group
812
+ target._append_row(action.role_function)
813
+ self._on_changed()
814
+ return target.row_count() - 1
815
+
816
+ def get_actions(self) -> List[ActionStep]:
817
+ result = []
818
+ result.extend(self.pre_group.get_actions())
819
+ result.extend(self.post_group.get_actions())
820
+ return result
821
+
822
+ def set_actions(self, actions: List[ActionStep]) -> None:
823
+ self.pre_group.set_actions(actions or [])
824
+ self.post_group.set_actions(actions or [])
825
+
826
+ # Legacy helpers (kept for test compatibility)
827
+ def delete_action(self, row: int) -> None:
828
+ # Interpret row as combined index; delete from the first group first
829
+ pre_count = self.pre_group.row_count()
830
+ if 0 <= row < pre_count:
831
+ self.pre_group.table.setCurrentCell(row, 0)
832
+ self.pre_group._on_delete()
833
+ else:
834
+ post_row = row - pre_count
835
+ if 0 <= post_row < self.post_group.row_count():
836
+ self.post_group.table.setCurrentCell(post_row, 0)
837
+ self.post_group._on_delete()
838
+ self._on_changed()
839
+
840
+ def move_up(self, row: int) -> int:
841
+ pre_count = self.pre_group.row_count()
842
+ if 0 <= row < pre_count:
843
+ self.pre_group.table.setCurrentCell(row, 0)
844
+ self.pre_group._on_move_up()
845
+ self._on_changed()
846
+ return max(0, row - 1)
847
+ post_row = row - pre_count
848
+ if 0 <= post_row < self.post_group.row_count():
849
+ self.post_group.table.setCurrentCell(post_row, 0)
850
+ self.post_group._on_move_up()
851
+ self._on_changed()
852
+ return row - 1 if post_row > 0 else row
853
+ return row
854
+
855
+ def _on_changed(self):
856
+ self.actions_changed.emit()