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,620 @@
1
+ # statable_gui/transition_editor_direct/transitions_tab.py
2
+ """Transitions tab widget (v2.2, v2.5 new role function button).
3
+
4
+ Ordered list of transitions with Mode (Commit / Tentative).
5
+ - Pre-actions / Else-actions columns (double-click to edit).
6
+ - Target / Else target: ComboBox (states + "(none)").
7
+ - Has else: ComboBox (Yes / No), linked to Else target enable state.
8
+
9
+ [v2.5 change]
10
+ - New "+ New Role Function" button.
11
+ Clicking it opens RoleFunctionDialog, registers the resulting
12
+ role function into state_machine.role_functions, and appends
13
+ its qualified_name to self.role_functions. The new name then
14
+ becomes available in the TransitionActionsDialog /
15
+ ConditionBuilderDialog candidates opened from this tab.
16
+ - New constructor args: role_function_library, literal_library,
17
+ layer_names_provider (all optional / keyword).
18
+ """
19
+
20
+ import logging
21
+ from typing import List, Optional
22
+
23
+ from PySide6.QtCore import Qt, Signal
24
+ from PySide6.QtWidgets import (
25
+ QWidget, QVBoxLayout, QHBoxLayout, QTableWidget, QTableWidgetItem,
26
+ QPushButton, QComboBox, QHeaderView, QAbstractItemView, QLabel,
27
+ QDialog, QMessageBox,
28
+ )
29
+
30
+ from statable.model import Transition
31
+ from statable_gui.role_function_dialog import RoleFunctionDialog
32
+ from .draft import ActionDraft
33
+ from .transition_actions_dialog import TransitionActionsDialog
34
+
35
+ logger = logging.getLogger("transition_editor_direct.transitions_tab")
36
+
37
+ # Column indices
38
+ COL_ORDER = 0
39
+ COL_LABEL = 1
40
+ COL_CONDITION = 2
41
+ COL_PRE = 3
42
+ COL_TARGET = 4
43
+ COL_HAS_ELSE = 5
44
+ COL_ELSE_TARGET = 6
45
+ COL_ELSE = 7
46
+ COL_MODE = 8
47
+ COLUMN_COUNT = 9
48
+
49
+ # Columns that use a QComboBox
50
+ WIDGET_COLUMNS = (COL_TARGET, COL_HAS_ELSE, COL_ELSE_TARGET, COL_MODE)
51
+
52
+ NONE_LABEL = "(none)"
53
+
54
+
55
+ def _qualified_name(rf) -> str:
56
+ """Return qualified_name if available, else fall back to name."""
57
+ qn = getattr(rf, 'qualified_name', None)
58
+ if qn:
59
+ return qn
60
+ return getattr(rf, 'name', '') or ''
61
+
62
+
63
+ class TransitionsTab(QWidget):
64
+ """Transitions tab (priority = row order)."""
65
+
66
+ transitions_changed = Signal()
67
+
68
+ def __init__(self, draft: ActionDraft, states: Optional[List[str]] = None,
69
+ global_defs=None, state_machine=None,
70
+ role_function_library=None,
71
+ literal_library=None,
72
+ condition_library=None,
73
+ layer_names_provider=None,
74
+ parent=None):
75
+ super().__init__(parent)
76
+ self.draft = draft
77
+ self.states = list(states or [])
78
+ self.global_defs = global_defs
79
+ self.state_machine = state_machine
80
+ self.role_functions: List[str] = []
81
+
82
+ # v2.5: context for RoleFunctionDialog
83
+ self.role_function_library = role_function_library
84
+ self.literal_library = literal_library
85
+ self.condition_library = condition_library
86
+ self.layer_names_provider = layer_names_provider
87
+
88
+ self._build_ui()
89
+
90
+ def set_role_functions(self, names: List[str]):
91
+ self.role_functions = list(names or [])
92
+
93
+ def set_states(self, states: List[str]):
94
+ """Replace the state list and rebuild all state combos."""
95
+ self.states = list(states or [])
96
+ for row in range(self.table.rowCount()):
97
+ self._refresh_state_combo(row, COL_TARGET)
98
+ self._refresh_state_combo(row, COL_ELSE_TARGET)
99
+
100
+ # ------------------------------------------------------------------
101
+ # UI
102
+ # ------------------------------------------------------------------
103
+ def _build_ui(self):
104
+ layout = QVBoxLayout(self)
105
+
106
+ info = QLabel(
107
+ self.tr("Transitions in this cell (top = highest priority, evaluated first)\n"
108
+ "Double-click on the Condition cell to open the condition builder.\n"
109
+ "Double-click on the Pre/Else cell to edit role functions."))
110
+ layout.addWidget(info)
111
+
112
+ self.table = QTableWidget(0, COLUMN_COUNT)
113
+ self.table.setHorizontalHeaderLabels([
114
+ "#", "Label", "Condition",
115
+ "Pre-actions", "Target", "Has else",
116
+ "Else target", "Else-actions", "Mode",
117
+ ])
118
+ self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
119
+ self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
120
+ self.table.setSelectionMode(QAbstractItemView.SingleSelection)
121
+ # [FIX] Disable inline editing so that double-click always triggers
122
+ # the dialog handler (ConditionBuilderDialog / TransitionActionsDialog).
123
+ # Without this, Qt starts inline editing after the first dialog
124
+ # closes, and subsequent double-clicks are swallowed by the
125
+ # inline editor (cellDoubleClicked no longer fires).
126
+ self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
127
+ self.table.cellDoubleClicked.connect(self._on_cell_double_clicked)
128
+ layout.addWidget(self.table)
129
+
130
+ btn_layout = QHBoxLayout()
131
+ add_btn = QPushButton(self.tr("+ Add"))
132
+ add_btn.clicked.connect(lambda: self.add_transition())
133
+ del_btn = QPushButton(self.tr("Delete"))
134
+ del_btn.clicked.connect(lambda: self.delete_transition(
135
+ self.table.currentRow()))
136
+ up_btn = QPushButton(self.tr("Move Up"))
137
+ up_btn.clicked.connect(lambda: self.move_up(
138
+ self.table.currentRow()))
139
+ down_btn = QPushButton(self.tr("Move Down"))
140
+ down_btn.clicked.connect(lambda: self.move_down(
141
+ self.table.currentRow()))
142
+ edit_actions_btn = QPushButton(self.tr("Edit actions..."))
143
+ edit_actions_btn.clicked.connect(self._edit_actions_current_row)
144
+
145
+ btn_layout.addWidget(add_btn)
146
+ btn_layout.addWidget(del_btn)
147
+ btn_layout.addWidget(up_btn)
148
+ btn_layout.addWidget(down_btn)
149
+ btn_layout.addWidget(edit_actions_btn)
150
+
151
+ # v2.5: create a new role function (available in subsequent dialogs)
152
+ new_role_btn = QPushButton(self.tr("+ New Role Function"))
153
+ new_role_btn.setToolTip(
154
+ self.tr("Create a new role function.\n"
155
+ "It is registered into the state machine and becomes\n"
156
+ "available in the Pre/Else action dialogs of this tab."))
157
+ new_role_btn.clicked.connect(self._on_new_role_function)
158
+ btn_layout.addWidget(new_role_btn)
159
+
160
+ btn_layout.addStretch()
161
+ layout.addLayout(btn_layout)
162
+
163
+ # ------------------------------------------------------------------
164
+ # v2.5: role function creation
165
+ # ------------------------------------------------------------------
166
+ def _get_namespace_choices(self) -> List[str]:
167
+ """Build namespace candidates for the RoleFunctionDialog.
168
+
169
+ Mirrors SettingsPanel._get_namespace_choices():
170
+ 1. layer_names_provider (all tabs in the project)
171
+ 2. state_machine.layer_name
172
+ 3. state_machine.role_functions[].namespace
173
+ 4. role_function_library entries
174
+ """
175
+ ordered: List[str] = []
176
+
177
+ def _add(ns):
178
+ ns = (ns or "").strip()
179
+ if ns and ns not in ordered:
180
+ ordered.append(ns)
181
+
182
+ if self.layer_names_provider is not None:
183
+ try:
184
+ for name in self.layer_names_provider():
185
+ _add(name)
186
+ except Exception as e:
187
+ logger.warning(f"layer_names_provider failed: {e}")
188
+
189
+ if self.state_machine is not None:
190
+ _add(getattr(self.state_machine, 'layer_name', ''))
191
+ try:
192
+ for rf in self.state_machine.role_functions.values():
193
+ _add(getattr(rf, 'namespace', ''))
194
+ except Exception as e:
195
+ logger.warning(
196
+ f"state_machine.role_functions iteration failed: {e}")
197
+
198
+ if self.role_function_library is not None:
199
+ try:
200
+ for rf in self.role_function_library.list_all():
201
+ _add(getattr(rf, 'namespace', ''))
202
+ except Exception as e:
203
+ logger.warning(
204
+ f"role_function_library.list_all() failed: {e}")
205
+
206
+ return ordered
207
+
208
+ def _dialog_kwargs(self) -> dict:
209
+ """Build kwargs for RoleFunctionDialog (mirrors SettingsPanel)."""
210
+ global_vars = []
211
+ if self.global_defs is not None:
212
+ global_vars = [
213
+ getattr(v, 'name', '')
214
+ for v in (getattr(self.global_defs, 'variables', []) or [])
215
+ if getattr(v, 'name', '')
216
+ ]
217
+
218
+ events = []
219
+ if self.state_machine is not None:
220
+ events = [
221
+ getattr(e, 'name', '')
222
+ for e in self.state_machine.events.values()
223
+ if getattr(e, 'name', '')
224
+ ]
225
+
226
+ literals = []
227
+ if self.literal_library is not None:
228
+ try:
229
+ literals = [
230
+ getattr(lit, 'name', '')
231
+ for lit in self.literal_library.list_all()
232
+ if getattr(lit, 'name', '')
233
+ ]
234
+ except Exception as e:
235
+ logger.warning(
236
+ f"literal_library.list_all() failed: {e}")
237
+
238
+ return dict(
239
+ global_vars=global_vars,
240
+ events=events,
241
+ literals=literals,
242
+ namespace_choices=self._get_namespace_choices(),
243
+ )
244
+
245
+ def _on_new_role_function(self):
246
+ """v2.5: create a new role function.
247
+
248
+ Registers the new RoleFunction into state_machine and appends
249
+ its qualified_name to self.role_functions so that subsequent
250
+ TransitionActionsDialog / ConditionBuilderDialog instances
251
+ opened from this tab can reference it.
252
+ """
253
+ if self.state_machine is None:
254
+ QMessageBox.information(
255
+ self, self.tr("Information"),
256
+ self.tr("StateMachine is not available; cannot create role "
257
+ "functions from here."))
258
+ return
259
+
260
+ dlg = RoleFunctionDialog(self, **self._dialog_kwargs())
261
+ if dlg.exec() != QDialog.Accepted:
262
+ return
263
+
264
+ rf = dlg.get_role_function()
265
+ if not rf.name:
266
+ QMessageBox.warning(
267
+ self, self.tr("Warning"),
268
+ self.tr("Role function name is required."))
269
+ return
270
+ if rf.name in self.state_machine.role_functions:
271
+ QMessageBox.warning(
272
+ self, self.tr("Warning"),
273
+ self.tr("A role function with the same name already exists."))
274
+ return
275
+
276
+ try:
277
+ self.state_machine.add_role_function(rf)
278
+ except ValueError as e:
279
+ QMessageBox.warning(self, self.tr("Warning"), str(e))
280
+ return
281
+
282
+ qn = _qualified_name(rf)
283
+ if qn and qn not in self.role_functions:
284
+ self.role_functions.append(qn)
285
+
286
+ logger.info(
287
+ f"Role function created: {qn} "
288
+ f"(available in subsequent action dialogs)")
289
+
290
+ # ------------------------------------------------------------------
291
+ # ComboBox helpers
292
+ # ------------------------------------------------------------------
293
+ def _make_state_combo(self, current_value: str) -> QComboBox:
294
+ combo = QComboBox()
295
+ combo.addItem(NONE_LABEL)
296
+ for s in self.states:
297
+ combo.addItem(s)
298
+
299
+ current = (current_value or "").strip()
300
+ if not current:
301
+ combo.setCurrentText(NONE_LABEL)
302
+ elif current in self.states:
303
+ combo.setCurrentText(current)
304
+ else:
305
+ combo.addItem(current)
306
+ combo.setCurrentText(current)
307
+ return combo
308
+
309
+ def _make_has_else_combo(self, current: bool) -> QComboBox:
310
+ combo = QComboBox()
311
+ combo.addItems(["Yes", "No"])
312
+ combo.setCurrentText("Yes" if current else "No")
313
+ return combo
314
+
315
+ def _refresh_state_combo(self, row: int, col: int):
316
+ old = self.table.cellWidget(row, col)
317
+ current_text = old.currentText() if old else ""
318
+ new_combo = self._make_state_combo(current_text)
319
+ self.table.setCellWidget(row, col, new_combo)
320
+ self._wire_else_link(row)
321
+
322
+ def _wire_else_link(self, row: int):
323
+ """Connect has_else combo to else_target enabled state (idempotent)."""
324
+ he = self.table.cellWidget(row, COL_HAS_ELSE)
325
+ et = self.table.cellWidget(row, COL_ELSE_TARGET)
326
+ if he is None or et is None:
327
+ return
328
+
329
+ # Initial enabled state
330
+ et.setEnabled(he.currentText().strip().lower() == "yes")
331
+
332
+ # Connect only once per widget (flag on the widget itself)
333
+ if getattr(he, "_else_link_connected", False):
334
+ return
335
+ he.currentTextChanged.connect(self._on_has_else_changed)
336
+ he._else_link_connected = True
337
+
338
+ def _on_has_else_changed(self, text: str):
339
+ """Slot for has_else combo change (uses sender() to find the row)."""
340
+ he = self.sender()
341
+ if he is None:
342
+ return
343
+ for row in range(self.table.rowCount()):
344
+ if self.table.cellWidget(row, COL_HAS_ELSE) is he:
345
+ et = self.table.cellWidget(row, COL_ELSE_TARGET)
346
+ if et is not None:
347
+ et.setEnabled(text.strip().lower() == "yes")
348
+ break
349
+
350
+ # ------------------------------------------------------------------
351
+ # Row helpers
352
+ # ------------------------------------------------------------------
353
+ def row_count(self) -> int:
354
+ return self.table.rowCount()
355
+
356
+ def _refresh_priority_column(self):
357
+ for row in range(self.table.rowCount()):
358
+ item = self.table.item(row, COL_ORDER)
359
+ if item is None:
360
+ item = QTableWidgetItem()
361
+ self.table.setItem(row, COL_ORDER, item)
362
+ item.setText(str(row + 1))
363
+ item.setFlags(item.flags() & ~Qt.ItemIsEditable)
364
+
365
+ def _emit_changed(self):
366
+ self._refresh_priority_column()
367
+ self.transitions_changed.emit()
368
+
369
+ # ------------------------------------------------------------------
370
+ # Double-click handling
371
+ # ------------------------------------------------------------------
372
+ def _on_cell_double_clicked(self, row: int, col: int):
373
+ if col == COL_CONDITION:
374
+ self._edit_condition(row)
375
+ elif col in (COL_PRE, COL_ELSE):
376
+ self._edit_actions(row)
377
+
378
+ def _edit_condition(self, row: int):
379
+ try:
380
+ from statable_gui.condition_builder_dialog import (
381
+ ConditionBuilderDialog,
382
+ )
383
+ except ImportError:
384
+ logger.warning("ConditionBuilderDialog not available")
385
+ return
386
+
387
+ cond_item = self.table.item(row, COL_CONDITION)
388
+ current = cond_item.text().strip() if cond_item else ""
389
+
390
+ dlg = ConditionBuilderDialog(
391
+ condition=current,
392
+ global_defs=self.global_defs,
393
+ state_machine=self.state_machine,
394
+ states=self.states,
395
+ literal_library=self.literal_library,
396
+ condition_library=self.condition_library,
397
+ parent=self.window(),
398
+ )
399
+ if dlg.exec() == QDialog.Accepted:
400
+ new_cond = dlg.get_condition_text()
401
+ if cond_item is None:
402
+ cond_item = QTableWidgetItem(new_cond)
403
+ cond_item.setFlags(cond_item.flags() & ~Qt.ItemIsEditable)
404
+ self.table.setItem(row, COL_CONDITION, cond_item)
405
+ else:
406
+ cond_item.setText(new_cond)
407
+ self._emit_changed()
408
+
409
+ def _edit_actions(self, row: int):
410
+ label_item = self.table.item(row, COL_LABEL)
411
+ label = label_item.text().strip() if label_item else f"T{row + 1}"
412
+
413
+ pre_item = self.table.item(row, COL_PRE)
414
+ else_item = self.table.item(row, COL_ELSE)
415
+ pre = self._parse_csv(pre_item.text() if pre_item else "")
416
+ els = self._parse_csv(else_item.text() if else_item else "")
417
+
418
+ dlg = TransitionActionsDialog(
419
+ parent=self.window(),
420
+ label=label,
421
+ pre_actions=pre,
422
+ else_actions=els,
423
+ role_functions=self.role_functions,
424
+ )
425
+ if dlg.exec() == QDialog.Accepted:
426
+ new_pre = dlg.get_pre_actions()
427
+ new_else = dlg.get_else_actions()
428
+ self._set_cell_csv(row, COL_PRE, new_pre)
429
+ self._set_cell_csv(row, COL_ELSE, new_else)
430
+ self._emit_changed()
431
+
432
+ def _edit_actions_current_row(self):
433
+ row = self.table.currentRow()
434
+ if row < 0:
435
+ QMessageBox.information(
436
+ self, self.tr("Info"), self.tr("Please select a row first."))
437
+ return
438
+ self._edit_actions(row)
439
+
440
+ # ------------------------------------------------------------------
441
+ # CSV helpers
442
+ # ------------------------------------------------------------------
443
+ @staticmethod
444
+ def _parse_csv(text: str) -> List[str]:
445
+ if not text:
446
+ return []
447
+ return [p.strip() for p in text.split(",") if p.strip()]
448
+
449
+ @staticmethod
450
+ def _csv(items: List[str]) -> str:
451
+ return ", ".join(items)
452
+
453
+ def _set_cell_csv(self, row: int, col: int, items: List[str]):
454
+ item = self.table.item(row, col)
455
+ text = self._csv(items)
456
+ if item is None:
457
+ item = QTableWidgetItem(text)
458
+ item.setFlags(item.flags() & ~Qt.ItemIsEditable)
459
+ self.table.setItem(row, col, item)
460
+ else:
461
+ item.setText(text)
462
+
463
+ # ------------------------------------------------------------------
464
+ # Public API
465
+ # ------------------------------------------------------------------
466
+ def add_transition(self, trans: Optional[Transition] = None) -> int:
467
+ row = self.table.rowCount()
468
+ self.table.insertRow(row)
469
+
470
+ order_item = QTableWidgetItem(str(row + 1))
471
+ order_item.setFlags(order_item.flags() & ~Qt.ItemIsEditable)
472
+ self.table.setItem(row, COL_ORDER, order_item)
473
+
474
+ label = getattr(trans, 'label', '') if trans else ""
475
+ if not label:
476
+ label = f"T{row + 1}"
477
+ label_item = QTableWidgetItem(label)
478
+ # Label is intentionally non-editable inline.
479
+ # (Reserved for future "Rename Label..." dialog.)
480
+ label_item.setFlags(label_item.flags() & ~Qt.ItemIsEditable)
481
+ self.table.setItem(row, COL_LABEL, label_item)
482
+
483
+ cond = getattr(trans, 'condition', '') if trans else ""
484
+ cond_item = QTableWidgetItem(cond)
485
+ # Condition is edited via dialog (double-click), not inline.
486
+ cond_item.setFlags(cond_item.flags() & ~Qt.ItemIsEditable)
487
+ self.table.setItem(row, COL_CONDITION, cond_item)
488
+
489
+ pre = getattr(trans, 'pre_actions', []) if trans else []
490
+ pre_item = QTableWidgetItem(self._csv(list(pre)))
491
+ pre_item.setFlags(pre_item.flags() & ~Qt.ItemIsEditable)
492
+ self.table.setItem(row, COL_PRE, pre_item)
493
+
494
+ target = getattr(trans, 'target', '') if trans else ""
495
+ self.table.setCellWidget(row, COL_TARGET,
496
+ self._make_state_combo(target))
497
+
498
+ has_else = getattr(trans, 'has_else', True) if trans else True
499
+ self.table.setCellWidget(row, COL_HAS_ELSE,
500
+ self._make_has_else_combo(has_else))
501
+
502
+ else_target = getattr(trans, 'else_target', '') if trans else ""
503
+ self.table.setCellWidget(row, COL_ELSE_TARGET,
504
+ self._make_state_combo(else_target))
505
+
506
+ ea = getattr(trans, 'else_actions', []) if trans else []
507
+ else_item = QTableWidgetItem(self._csv(list(ea)))
508
+ else_item.setFlags(else_item.flags() & ~Qt.ItemIsEditable)
509
+ self.table.setItem(row, COL_ELSE, else_item)
510
+
511
+ mode_combo = QComboBox()
512
+ mode_combo.addItems(["Commit", "Tentative"])
513
+ if trans is not None and getattr(trans, 'early_return', False):
514
+ mode_combo.setCurrentText("Commit")
515
+ else:
516
+ mode_combo.setCurrentText("Tentative")
517
+ self.table.setCellWidget(row, COL_MODE, mode_combo)
518
+
519
+ self._wire_else_link(row)
520
+ self._emit_changed()
521
+ return row
522
+
523
+ def delete_transition(self, row: int) -> None:
524
+ if 0 <= row < self.table.rowCount():
525
+ self.table.removeRow(row)
526
+ self._emit_changed()
527
+
528
+ def move_up(self, row: int) -> int:
529
+ if row <= 0 or row >= self.table.rowCount():
530
+ return row
531
+ self._swap_rows(row, row - 1)
532
+ self._emit_changed()
533
+ return row - 1
534
+
535
+ def move_down(self, row: int) -> int:
536
+ if row < 0 or row >= self.table.rowCount() - 1:
537
+ return row
538
+ self._swap_rows(row, row + 1)
539
+ self._emit_changed()
540
+ return row + 1
541
+
542
+ def _swap_rows(self, r1: int, r2: int):
543
+ for col in range(self.table.columnCount()):
544
+ if col in WIDGET_COLUMNS:
545
+ w1 = self.table.cellWidget(r1, col)
546
+ w2 = self.table.cellWidget(r2, col)
547
+ if w1 is not None and w2 is not None:
548
+ t1 = w1.currentText()
549
+ t2 = w2.currentText()
550
+ w1.setCurrentText(t2)
551
+ w2.setCurrentText(t1)
552
+ else:
553
+ i1 = self.table.takeItem(r1, col)
554
+ i2 = self.table.takeItem(r2, col)
555
+ self.table.setItem(r1, col, i2)
556
+ self.table.setItem(r2, col, i1)
557
+ self._wire_else_link(r1)
558
+ self._wire_else_link(r2)
559
+
560
+ # ------------------------------------------------------------------
561
+ # Cell value helpers
562
+ # ------------------------------------------------------------------
563
+ def _item_text(self, row: int, col: int) -> str:
564
+ it = self.table.item(row, col)
565
+ return it.text().strip() if it else ""
566
+
567
+ def _widget_text(self, row: int, col: int, default: str = "") -> str:
568
+ w = self.table.cellWidget(row, col)
569
+ if w is None:
570
+ return default
571
+ return w.currentText().strip()
572
+
573
+ # ------------------------------------------------------------------
574
+ # Public API (get / set)
575
+ # ------------------------------------------------------------------
576
+ def get_transitions(self) -> List[Transition]:
577
+ result = []
578
+ source = self.draft.source
579
+ event = self.draft.event
580
+ for row in range(self.table.rowCount()):
581
+ label = self._item_text(row, COL_LABEL)
582
+ cond = self._item_text(row, COL_CONDITION)
583
+ pre = self._parse_csv(self._item_text(row, COL_PRE))
584
+
585
+ target_text = self._widget_text(row, COL_TARGET, NONE_LABEL)
586
+ target = "" if target_text == NONE_LABEL else target_text
587
+
588
+ has_else_text = self._widget_text(row, COL_HAS_ELSE, "Yes")
589
+ has_else = has_else_text.lower() in ("yes", "true", "1")
590
+
591
+ else_target_text = self._widget_text(
592
+ row, COL_ELSE_TARGET, NONE_LABEL)
593
+ else_target = ("" if else_target_text == NONE_LABEL
594
+ else else_target_text)
595
+
596
+ else_actions = self._parse_csv(self._item_text(row, COL_ELSE))
597
+
598
+ mode_text = self._widget_text(row, COL_MODE, "Tentative")
599
+ early_return = (mode_text == "Commit")
600
+
601
+ result.append(Transition(
602
+ source=source,
603
+ event=event,
604
+ condition=cond,
605
+ pre_actions=pre,
606
+ target=target,
607
+ has_else=has_else,
608
+ else_target=else_target,
609
+ else_actions=else_actions,
610
+ early_return=early_return,
611
+ label=label,
612
+ title="(untitled transition)",
613
+ ))
614
+ return result
615
+
616
+ def set_transitions(self, transitions: List[Transition]) -> None:
617
+ self.table.setRowCount(0)
618
+ for t in transitions:
619
+ self.add_transition(t)
620
+ self._refresh_priority_column()