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.
- codegen/__init__.py +38 -0
- codegen/c_code_generator.py +1752 -0
- codegen/code_merger.py +371 -0
- codegen/code_templates.py +509 -0
- codegen/config.py +167 -0
- codegen/enum_generator.py +377 -0
- codegen/event_queue_generator.py +303 -0
- codegen/interrupt_generator.py +345 -0
- codegen/naming_convention.py +171 -0
- codegen/osal_generator.py +282 -0
- codegen/role_function_generator.py +1244 -0
- codegen/sample_data.py +269 -0
- codegen/state_actions_generator.py +395 -0
- codegen/struct_generator.py +721 -0
- codegen/timer_generator.py +299 -0
- codegen/transition_generator.py +907 -0
- codegen/type_mapper.py +69 -0
- codegen/validate/__init__.py +38 -0
- codegen/validate/change_actions.py +90 -0
- codegen/validate/change_applier.py +416 -0
- codegen/validate/clipboard_manager.py +47 -0
- codegen/validate/data/__init__.py +30 -0
- codegen/validate/data/action_definitions.py +200 -0
- codegen/validate/data/keywords.py +46 -0
- codegen/validate/data/prompt_templates.py +176 -0
- codegen/validate/data/validation_rules.py +205 -0
- codegen/validate/items/__init__.py +30 -0
- codegen/validate/items/base_validator.py +25 -0
- codegen/validate/items/cell_validator.py +275 -0
- codegen/validate/items/custom_type_validator.py +75 -0
- codegen/validate/items/event_validator.py +78 -0
- codegen/validate/items/flag_validator.py +74 -0
- codegen/validate/items/interrupt_validator.py +75 -0
- codegen/validate/items/queue_validator.py +73 -0
- codegen/validate/items/role_function_validator.py +175 -0
- codegen/validate/items/state_validator.py +88 -0
- codegen/validate/items/timer_validator.py +120 -0
- codegen/validate/items/transition_validator.py +113 -0
- codegen/validate/items/variable_validator.py +132 -0
- codegen/validate/logger.py +52 -0
- codegen/validate/models.py +177 -0
- codegen/validate/prompt_generator.py +724 -0
- codegen/validate/response_parser.py +154 -0
- codegen/validate/response_validator.py +275 -0
- codegen/validate/validation_dialog.py +322 -0
- codegen/validate/validator.py +94 -0
- codegen/variable_generator.py +419 -0
- statable/__init__.py +71 -0
- statable/__main__.py +49 -0
- statable/cli.py +251 -0
- statable/global_defs.py +256 -0
- statable/mermaid_gen.py +144 -0
- statable/model.py +426 -0
- statable/parser.py +64 -0
- statable/sample_data.py +435 -0
- statable/shared/__init__.py +24 -0
- statable/shared/condition_library.py +63 -0
- statable/shared/literal_library.py +66 -0
- statable/shared/role_function_library.py +150 -0
- statable/state_machine.py +121 -0
- statable/xml_io.py +1164 -0
- statable-3.1.0.dist-info/METADATA +151 -0
- statable-3.1.0.dist-info/RECORD +117 -0
- statable-3.1.0.dist-info/WHEEL +5 -0
- statable-3.1.0.dist-info/entry_points.txt +2 -0
- statable-3.1.0.dist-info/top_level.txt +3 -0
- statable_gui/__init__.py +23 -0
- statable_gui/action_edit_dialog.py +214 -0
- statable_gui/code_generation_dialog.py +486 -0
- statable_gui/code_generation_settings_dialog.py +522 -0
- statable_gui/common_widgets.py +537 -0
- statable_gui/condition_builder_dialog.py +500 -0
- statable_gui/condition_edit_dialog.py +123 -0
- statable_gui/condition_template_dialog.py +58 -0
- statable_gui/config.py +21 -0
- statable_gui/dialogs.py +439 -0
- statable_gui/event_definition_dialog.py +605 -0
- statable_gui/event_delivery_settings_dialog.py +191 -0
- statable_gui/event_queue_dialog.py +245 -0
- statable_gui/global_defs.py +167 -0
- statable_gui/global_defs_dialog.py +796 -0
- statable_gui/i18n/__init__.py +118 -0
- statable_gui/interrupt_handler_edit_dialog.py +810 -0
- statable_gui/layer_settings_dialog.py +180 -0
- statable_gui/libcntrl/__init__.py +23 -0
- statable_gui/libcntrl/condition_library.py +13 -0
- statable_gui/libcntrl/literal_library.py +13 -0
- statable_gui/libcntrl/literal_management_dialog.py +185 -0
- statable_gui/libcntrl/role_function_edit_dialog.py +145 -0
- statable_gui/libcntrl/role_function_library.py +13 -0
- statable_gui/literal_definition_dialog.py +62 -0
- statable_gui/logger.py +83 -0
- statable_gui/main_window.py +1380 -0
- statable_gui/matrix_table.py +413 -0
- statable_gui/preference_keys.py +18 -0
- statable_gui/preferences.py +74 -0
- statable_gui/role_function_dialog.py +244 -0
- statable_gui/sample_data.py +11 -0
- statable_gui/state_actions_dialog.py +398 -0
- statable_gui/symbol_picker.py +170 -0
- statable_gui/traceball.py +37 -0
- statable_gui/transition_editor_direct/__init__.py +15 -0
- statable_gui/transition_editor_direct/actions_tab.py +856 -0
- statable_gui/transition_editor_direct/code_widget.py +344 -0
- statable_gui/transition_editor_direct/coverage_analyzer.py +163 -0
- statable_gui/transition_editor_direct/dialog.py +335 -0
- statable_gui/transition_editor_direct/draft.py +270 -0
- statable_gui/transition_editor_direct/edit_dialogs.py +160 -0
- statable_gui/transition_editor_direct/overview_tab.py +173 -0
- statable_gui/transition_editor_direct/palette_widget.py +233 -0
- statable_gui/transition_editor_direct/relations_edit_dialog.py +156 -0
- statable_gui/transition_editor_direct/relations_tab.py +230 -0
- statable_gui/transition_editor_direct/system_global_dialog.py +52 -0
- statable_gui/transition_editor_direct/transition_actions_dialog.py +109 -0
- statable_gui/transition_editor_direct/transitions_tab.py +620 -0
- statable_gui/validation_dialog.py +285 -0
- 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()
|