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,1315 @@
1
+ # statable_gui/widgets.py
2
+ """StaTable main widget (shared library support / v2.2 entry-exit list)
3
+
4
+ Version: 3.11 (2026-09-22)
5
+ - [C-38 fix] SettingsPanel.add_state() now emits settings_changed
6
+ so that the window's modified flag ([*]) updates when a state
7
+ is added.
8
+
9
+ - [v3.11] Namespace combo box: full project-wide candidates.
10
+ * SettingsPanel now accepts `layer_names_provider`, a
11
+ zero-argument callable returning every tab name (= every
12
+ layer name) in the project.
13
+ * StateMachineTab forwards `layer_names_provider` to
14
+ SettingsPanel.
15
+ * _get_namespace_choices() merges candidates from:
16
+ 1. layer_names_provider (all tabs: Application, Driver, ...)
17
+ 2. sm.layer_name (current tab, in case provider
18
+ is not supplied)
19
+ 3. role_function_library namespaces
20
+ 4. sm.role_functions namespaces
21
+ Deduplicated while preserving order, so the inline combo
22
+ box and RoleFunctionDialog show the same complete list.
23
+
24
+ [v3.10 behavior retained]
25
+ * Role function table: inline editing restricted to the
26
+ Namespace column (editable QComboBox delegate).
27
+ Title / Function name / Description are read-only there.
28
+ * _NamespaceDelegate uses _get_namespace_choices() on every
29
+ editor creation, so the candidate list stays fresh.
30
+
31
+ [v3.9 behavior retained]
32
+ * SettingsPanel receives role_function_library and
33
+ literal_library from StateMachineTab.
34
+ * RoleFunctionDialog renders namespace as an editable
35
+ QComboBox populated with candidates.
36
+
37
+ [v3.8 behavior retained]
38
+ * "Edit" button + row double-click for role functions.
39
+ * used_global_vars / used_events / used_literals persisted.
40
+ * apply_changes() uses getattr(...) for defensive access.
41
+
42
+ [v3.7 behavior retained]
43
+ * State list: 6 -> 5 columns (removed "do function").
44
+ * Role function: 9 -> 4 columns (removed "Return type",
45
+ "Arg 1 type", "Arg 1 name", "Arg 2 type", "Arg 2 name").
46
+ * StateMachineTab: QSplitter(Qt.Horizontal).
47
+ * apply_changes() carries reserved values forward.
48
+
49
+ [v3.6 behavior retained]
50
+ * SettingsPanel: entry / exit columns are non-editable
51
+ inline (edited via ActionEditDialog).
52
+ [v3.5 behavior retained]
53
+ * MermaidWidget: QScrollArea alignment VCenter.
54
+ [v3.4 behavior retained]
55
+ * getSvgSize() returns a JSON string.
56
+ [v3.3 behavior retained]
57
+ * QScrollArea viewport background #fafafa.
58
+ [v3.2 behavior retained]
59
+ * Async render; JS console forwarded to StaTableLogger.
60
+ [v3.0] MAX_SVG_WIDTH 1200 -> 3000.
61
+ [v2.8] Persistent debug copy retained.
62
+ [v2.2] entry / exit are List[str]; "; " separator.
63
+
64
+ [v2.3 change]
65
+ - StateMachineTab: dataModified signal relays child modifications
66
+ to MainWindow (for the [*] window-modified marker).
67
+ """
68
+
69
+ import os
70
+ import json
71
+ import shutil
72
+ import tempfile
73
+ from typing import Optional, List, Callable
74
+
75
+ from PySide6.QtCore import Qt, Signal, QUrl, QTimer
76
+ from PySide6.QtGui import QFont, QKeyEvent, QPalette, QColor
77
+ from PySide6.QtWidgets import (
78
+ QWidget, QVBoxLayout, QHBoxLayout, QTableWidget, QTableWidgetItem,
79
+ QPushButton, QPlainTextEdit, QSplitter, QLabel, QHeaderView,
80
+ QTabWidget, QAbstractItemView, QMessageBox, QDialog, QScrollArea,
81
+ QComboBox, QStyledItemDelegate
82
+ )
83
+
84
+ # ============================================================
85
+ # A1: branch WebEngine import itself by environment variable
86
+ # ============================================================
87
+ _DISABLE_MERMAID = os.environ.get("STATABLE_DISABLE_MERMAID") == "1"
88
+
89
+ if not _DISABLE_MERMAID:
90
+ try:
91
+ from PySide6.QtWebEngineWidgets import QWebEngineView
92
+ from PySide6.QtWebEngineCore import (
93
+ QWebEngineSettings, QWebEnginePage)
94
+ WEBENGINE_AVAILABLE = True
95
+ except ImportError:
96
+ WEBENGINE_AVAILABLE = False
97
+ QWebEngineView = None
98
+ QWebEngineSettings = None
99
+ QWebEnginePage = None
100
+ else:
101
+ WEBENGINE_AVAILABLE = False
102
+ QWebEngineView = None
103
+ QWebEngineSettings = None
104
+ QWebEnginePage = None
105
+
106
+ from statable.model import State, Event, Transition, StateType, EventKind, RoleFunction, ActionStep
107
+ from statable.state_machine import StateMachine
108
+ from statable.mermaid_gen import generate_mermaid
109
+
110
+ from .logger import StaTableLogger
111
+ from .config import (
112
+ get_resource_path, TABLE_PREVIEW_RATIO, WINDOW_HEIGHT,
113
+ MERMAID_PREVIEW_MIN_HEIGHT, MAX_COLUMN_WIDTH, MIN_ROW_HEIGHT, MAX_ROW_HEIGHT
114
+ )
115
+ from .matrix_table import MatrixTableWidget
116
+ from .role_function_dialog import RoleFunctionDialog
117
+ from .action_edit_dialog import ActionEditDialog
118
+ from .state_actions_dialog import StateActionsDialog
119
+ from .global_defs import GlobalDefinitions
120
+ from .event_definition_dialog import EventDefinitionDialog
121
+
122
+ from statable_gui.libcntrl.role_function_library import RoleFunctionLibrary
123
+ from statable_gui.libcntrl.condition_library import ConditionLibrary
124
+ from statable_gui.libcntrl.literal_library import LiteralLibrary
125
+
126
+ # ======================================================================
127
+ # v2.2: List[str] <-> display helpers for entry / exit
128
+ # ======================================================================
129
+ _ENTRY_EXIT_SEP = "; "
130
+ _MERMAID_BG = "#fafafa"
131
+
132
+
133
+ def _extract_action_name(action) -> str:
134
+ """[v2.7.0] Extract display name from ActionStep or str.
135
+
136
+ For ActionStep:
137
+ - action_type="role" -> role_function
138
+ - action_type="fire_event" -> event_name
139
+ - custom -> ""
140
+ """
141
+ if action is None:
142
+ return ""
143
+ if isinstance(action, str):
144
+ return action.strip()
145
+ atype = getattr(action, "action_type", "role") or "role"
146
+ if atype == "fire_event":
147
+ return (getattr(action, "event_name", "") or "").strip()
148
+ if atype == "custom":
149
+ return ""
150
+ return (getattr(action, "role_function", "") or "").strip()
151
+
152
+
153
+ def _list_to_display(items) -> str:
154
+ """[v2.7.0] Convert List[ActionStep] (or List[str]) to display string."""
155
+ if items is None:
156
+ return ""
157
+ if isinstance(items, str):
158
+ return items
159
+ if isinstance(items, list):
160
+ names = [_extract_action_name(x) for x in items]
161
+ return _ENTRY_EXIT_SEP.join(n for n in names if n)
162
+ return str(items)
163
+
164
+
165
+ def _display_to_list(text) -> List[ActionStep]:
166
+ """[v2.7.0] Parse a display string ('A; B; C') into List[ActionStep].
167
+
168
+ Legacy List[str] input is also accepted and converted.
169
+ """
170
+ if not text:
171
+ return []
172
+ if isinstance(text, list):
173
+ result = []
174
+ for x in text:
175
+ if isinstance(x, ActionStep):
176
+ result.append(x)
177
+ elif isinstance(x, str) and x.strip():
178
+ result.append(ActionStep(role_function=x.strip()))
179
+ return result
180
+ parts = str(text).split(';')
181
+ return [ActionStep(role_function=p.strip())
182
+ for p in parts if p.strip()]
183
+
184
+
185
+ # ======================================================================
186
+ # [v3.2-debug] Custom page to forward JS console to StaTableLogger
187
+ # ======================================================================
188
+ if WEBENGINE_AVAILABLE:
189
+ class _DebugWebEnginePage(QWebEnginePage):
190
+ """Forward JS console messages to StaTableLogger."""
191
+
192
+ def javaScriptConsoleMessage(self, level, message, line, source):
193
+ try:
194
+ lvl_name = {
195
+ 0: "INFO",
196
+ 1: "WARN",
197
+ 2: "ERROR",
198
+ }.get(int(level), str(level))
199
+ except (TypeError, ValueError):
200
+ lvl_name = str(level)
201
+ StaTableLogger.debug(
202
+ f"[JSConsole:{lvl_name}] {message} "
203
+ f"(line {line}, {os.path.basename(source)})")
204
+
205
+
206
+ # ======================================================================
207
+ # [v3.10] Namespace column delegate (editable QComboBox)
208
+ # ======================================================================
209
+ class _NamespaceDelegate(QStyledItemDelegate):
210
+ """Inline editor for the Role-function Namespace column.
211
+
212
+ [v3.10]
213
+ Renders an editable QComboBox populated with the current
214
+ namespace candidates (supplied by `choices_provider`). The
215
+ user can either pick a candidate or type a custom value.
216
+
217
+ [v3.11]
218
+ `choices_provider` is re-evaluated on every editor creation,
219
+ so newly added layers / namespaces appear immediately.
220
+ """
221
+
222
+ def __init__(self, choices_provider, parent=None):
223
+ super().__init__(parent)
224
+ self._choices_provider = choices_provider
225
+
226
+ def createEditor(self, parent, option, index):
227
+ combo = QComboBox(parent)
228
+ combo.setEditable(True)
229
+ combo.setInsertPolicy(QComboBox.NoInsert)
230
+ # First entry: empty namespace (no layer)
231
+ combo.addItem(self.tr(""))
232
+ try:
233
+ for ns in (self._choices_provider() or []):
234
+ ns = (ns or "").strip()
235
+ if ns and combo.findText(ns) < 0:
236
+ combo.addItem(ns)
237
+ except Exception as e:
238
+ StaTableLogger.warning(
239
+ f"_NamespaceDelegate.choices_provider failed: {e}")
240
+ return combo
241
+
242
+ def setEditorData(self, editor, index):
243
+ value = index.data(Qt.EditRole) or ""
244
+ value = str(value)
245
+ idx = editor.findText(value)
246
+ if idx >= 0:
247
+ editor.setCurrentIndex(idx)
248
+ else:
249
+ editor.setEditText(value)
250
+
251
+ def setModelData(self, editor, model, index):
252
+ model.setData(index, editor.currentText().strip(), Qt.EditRole)
253
+
254
+
255
+ class MermaidWidget(QWidget):
256
+ """Mermaid diagram preview widget.
257
+
258
+ [v3.5] Diagram vertically centered in the viewport.
259
+ QScrollArea alignment = Qt.AlignLeft | Qt.AlignVCenter.
260
+
261
+ [v3.4] getSvgSize() returns a JSON string.
262
+
263
+ [v3.3] QScrollArea viewport background set to _MERMAID_BG.
264
+
265
+ [v3.2] Async render (mermaid.init/run + polling) and
266
+ retry-based sizing; JS console forwarded to StaTableLogger.
267
+
268
+ [v3.0] MAX_SVG_WIDTH raised 1200 -> 3000.
269
+
270
+ [v2.8] Persistent debug copy of the generated HTML is written to
271
+ %USERPROFILE%\\statable_mermaid_debug.html
272
+ """
273
+
274
+ MAX_SVG_WIDTH = 3000
275
+ MAX_CONTENT_W = 20000
276
+ MAX_CONTENT_H = 20000
277
+ SIZE_RETRY_MAX = 5
278
+ SIZE_RETRY_DELAY_MS = 200
279
+
280
+ def __init__(self, parent=None):
281
+ super().__init__(parent)
282
+ layout = QVBoxLayout(self)
283
+ layout.setContentsMargins(0, 0, 0, 0)
284
+ layout.setSpacing(0)
285
+
286
+ self.web_view = None
287
+ self.text_view = None
288
+ self.scroll_area = None
289
+ self._disabled = (
290
+ os.environ.get("STATABLE_DISABLE_MERMAID") == "1"
291
+ )
292
+
293
+ StaTableLogger.debug(
294
+ f"MermaidWidget.__init__: MAX_SVG_WIDTH={self.MAX_SVG_WIDTH}, "
295
+ f"disabled={self._disabled}, webengine_available={WEBENGINE_AVAILABLE}"
296
+ )
297
+
298
+ if self._disabled:
299
+ placeholder = QLabel(self.tr("Mermaid rendering disabled (test mode)"))
300
+ placeholder.setAlignment(Qt.AlignCenter)
301
+ layout.addWidget(placeholder)
302
+ StaTableLogger.debug(
303
+ "MermaidWidget: disabled via STATABLE_DISABLE_MERMAID")
304
+ return
305
+
306
+ if WEBENGINE_AVAILABLE:
307
+ self.scroll_area = QScrollArea()
308
+ self.scroll_area.setWidgetResizable(False)
309
+ self.scroll_area.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
310
+ self.scroll_area.setHorizontalScrollBarPolicy(
311
+ Qt.ScrollBarAsNeeded)
312
+ self.scroll_area.setVerticalScrollBarPolicy(
313
+ Qt.ScrollBarAsNeeded)
314
+
315
+ self.scroll_area.viewport().setAutoFillBackground(True)
316
+ pal = self.scroll_area.viewport().palette()
317
+ pal.setColor(QPalette.Window, QColor(_MERMAID_BG))
318
+ self.scroll_area.viewport().setPalette(pal)
319
+
320
+ self.web_view = QWebEngineView()
321
+
322
+ try:
323
+ self.web_view.setPage(_DebugWebEnginePage(self.web_view))
324
+ StaTableLogger.debug(
325
+ "MermaidWidget: _DebugWebEnginePage attached")
326
+ except Exception as e:
327
+ StaTableLogger.warning(
328
+ f"Failed to attach debug page: {e}")
329
+
330
+ self.web_view.setFixedSize(100, 100)
331
+
332
+ try:
333
+ self.web_view.setZoomFactor(1.0)
334
+ except Exception as e:
335
+ StaTableLogger.warning(f"setZoomFactor failed: {e}")
336
+
337
+ settings = self.web_view.settings()
338
+ settings.setAttribute(
339
+ QWebEngineSettings.LocalContentCanAccessFileUrls, True)
340
+ settings.setAttribute(
341
+ QWebEngineSettings.LocalContentCanAccessRemoteUrls, True)
342
+ self.web_view.loadFinished.connect(self._on_load_finished)
343
+
344
+ self.scroll_area.setWidget(self.web_view)
345
+ layout.addWidget(self.scroll_area)
346
+ else:
347
+ self.text_view = QPlainTextEdit()
348
+ self.text_view.setReadOnly(True)
349
+ layout.addWidget(self.text_view)
350
+ self.text_view.setPlainText(
351
+ "QWebEngineView is not available.\n"
352
+ "Please install PySide6-Addons.\n"
353
+ "pip install PySide6-Addons"
354
+ )
355
+ StaTableLogger.warning("MermaidWidget: WebEngine NOT available")
356
+
357
+ def set_mermaid_code(self, code: str):
358
+ StaTableLogger.debug("MermaidWidget.set_mermaid_code called")
359
+ if self._disabled:
360
+ return
361
+
362
+ if self.web_view:
363
+ mermaid_js_path = get_resource_path("mermaidwin.js")
364
+ if not mermaid_js_path.exists():
365
+ StaTableLogger.error(
366
+ f"mermaidwin.js NOT found: {mermaid_js_path}")
367
+ return
368
+
369
+ js_abs_url = QUrl.fromLocalFile(str(mermaid_js_path)).toString()
370
+
371
+ html = f"""<!DOCTYPE html>
372
+ <html>
373
+ <head>
374
+ <meta charset="utf-8">
375
+ <meta name="viewport"
376
+ content="width=device-width, initial-scale=1.0">
377
+ <style>
378
+ html, body {{
379
+ margin: 0;
380
+ padding: 0;
381
+ overflow: hidden;
382
+ background-color: {_MERMAID_BG};
383
+ }}
384
+ .mermaid {{
385
+ padding: 20px;
386
+ margin: 0;
387
+ background-color: transparent;
388
+ display: inline-block;
389
+ width: max-content;
390
+ }}
391
+ </style>
392
+ <script src="{js_abs_url}"></script>
393
+ <script>
394
+ var MAX_SVG_WIDTH = {self.MAX_SVG_WIDTH};
395
+ console.log('[MermaidDebug] script loaded');
396
+ console.log('[MermaidDebug] MAX_SVG_WIDTH =', MAX_SVG_WIDTH);
397
+ console.log('[MermaidDebug] typeof mermaid =', typeof mermaid);
398
+ if (typeof mermaid !== 'undefined') {{
399
+ console.log('[MermaidDebug] mermaid.run type =',
400
+ typeof mermaid.run);
401
+ console.log('[MermaidDebug] mermaid.init type =',
402
+ typeof mermaid.init);
403
+ console.log('[MermaidDebug] mermaid.initialize type =',
404
+ typeof mermaid.initialize);
405
+ }}
406
+
407
+ try {{
408
+ mermaid.initialize({{
409
+ startOnLoad: false,
410
+ theme: 'default',
411
+ themeVariables: {{
412
+ fontSize: '16px',
413
+ fontFamily: 'Segoe UI, Yu Gothic UI, sans-serif',
414
+ }},
415
+ 'stateDiagram-v2': {{
416
+ nodeSpacing: 50,
417
+ rankSpacing: 25,
418
+ padding: 8,
419
+ useMaxWidth: false,
420
+ }},
421
+ stateDiagram: {{
422
+ nodeSpacing: 50,
423
+ rankSpacing: 25,
424
+ padding: 8,
425
+ useMaxWidth: false,
426
+ }},
427
+ }});
428
+ console.log('[MermaidDebug] mermaid.initialize done');
429
+ }} catch (e) {{
430
+ console.error('[MermaidDebug] mermaid.initialize failed:', e);
431
+ }}
432
+
433
+ function waitForSvg(timeoutMs) {{
434
+ return new Promise(function(resolve) {{
435
+ var start = Date.now();
436
+ (function poll() {{
437
+ var svg = document.querySelector('.mermaid svg');
438
+ if (svg && svg.getAttribute('viewBox')) {{
439
+ resolve(true);
440
+ return;
441
+ }}
442
+ if (Date.now() - start > timeoutMs) {{
443
+ resolve(false);
444
+ return;
445
+ }}
446
+ setTimeout(poll, 50);
447
+ }})();
448
+ }});
449
+ }}
450
+
451
+ async function renderMermaid() {{
452
+ console.log('[MermaidDebug] renderMermaid: ENTER');
453
+
454
+ var nodes = document.querySelectorAll('.mermaid');
455
+ console.log('[MermaidDebug] .mermaid node count =', nodes.length);
456
+ if (!nodes.length) {{
457
+ console.warn('[MermaidDebug] no .mermaid nodes; abort');
458
+ return 'no-nodes';
459
+ }}
460
+
461
+ try {{
462
+ var promise = null;
463
+ if (typeof mermaid.run === 'function') {{
464
+ console.log('[MermaidDebug] branch: mermaid.run');
465
+ promise = mermaid.run({{ nodes: nodes }});
466
+ }} else if (typeof mermaid.init === 'function') {{
467
+ console.log('[MermaidDebug] branch: mermaid.init');
468
+ promise = mermaid.init(undefined, nodes);
469
+ }} else {{
470
+ console.warn('[MermaidDebug] no run/init available');
471
+ }}
472
+
473
+ if (promise && typeof promise.then === 'function') {{
474
+ console.log('[MermaidDebug] awaiting promise');
475
+ await promise;
476
+ console.log('[MermaidDebug] promise resolved');
477
+ }} else {{
478
+ console.log(
479
+ '[MermaidDebug] no promise; polling for SVG');
480
+ var ok = await waitForSvg(3000);
481
+ console.log('[MermaidDebug] waitForSvg =', ok);
482
+ }}
483
+ }} catch (e) {{
484
+ console.error('[MermaidDebug] render failed:', e);
485
+ console.error('[MermaidDebug] stack:', e && e.stack);
486
+ return 'render-failed';
487
+ }}
488
+
489
+ var svgs = document.querySelectorAll('.mermaid svg');
490
+ console.log(
491
+ '[MermaidDebug] SVG count after render =', svgs.length);
492
+
493
+ svgs.forEach(function(svg, i) {{
494
+ var vb = svg.getAttribute('viewBox');
495
+ console.log('[MermaidDebug] svg[' + i + '] viewBox =', vb);
496
+ if (!vb) return;
497
+ var parts = vb.split(/\\s+/);
498
+ if (parts.length !== 4) return;
499
+ var w = parseFloat(parts[2]);
500
+ var h = parseFloat(parts[3]);
501
+ if (!w || !h) return;
502
+ var scale = 1.0;
503
+ if (w > MAX_SVG_WIDTH) scale = MAX_SVG_WIDTH / w;
504
+ var newW = Math.round(w * scale);
505
+ var newH = Math.round(h * scale);
506
+ svg.removeAttribute('style');
507
+ svg.setAttribute('width', newW);
508
+ svg.setAttribute('height', newH);
509
+ console.log('[MermaidDebug] svg[' + i +
510
+ '] forced ' + newW + 'x' + newH +
511
+ ' (natural ' + w + 'x' + h + ')');
512
+ }});
513
+
514
+ console.log('[MermaidDebug] renderMermaid: EXIT');
515
+ return 'ok';
516
+ }}
517
+
518
+ /* [v3.4] Returns a JSON string instead of a bare array.
519
+ PySide6's runJavaScript callback sometimes converts JS
520
+ arrays to '' at the Qt boundary; a string is reliable. */
521
+ function getSvgSize() {{
522
+ console.log('[MermaidDebug] getSvgSize: ENTER');
523
+ var svg = document.querySelector('.mermaid svg');
524
+ console.log('[MermaidDebug] getSvgSize: svg exists =', !!svg);
525
+ if (!svg) return JSON.stringify([0, 0]);
526
+
527
+ var w = parseFloat(svg.getAttribute('width'));
528
+ var h = parseFloat(svg.getAttribute('height'));
529
+ console.log('[MermaidDebug] getSvgSize: raw width/height =',
530
+ w, h);
531
+
532
+ if (!w || !h) {{
533
+ var vb = svg.getAttribute('viewBox');
534
+ console.log('[MermaidDebug] getSvgSize: viewBox =', vb);
535
+ if (vb) {{
536
+ var parts = vb.split(/\\s+/);
537
+ if (parts.length === 4) {{
538
+ var nw = parseFloat(parts[2]);
539
+ var nh = parseFloat(parts[3]);
540
+ var sc = 1.0;
541
+ if (nw > MAX_SVG_WIDTH) sc = MAX_SVG_WIDTH / nw;
542
+ w = Math.round(nw * sc);
543
+ h = Math.round(nh * sc);
544
+ }}
545
+ }}
546
+ }}
547
+
548
+ if (!w || !h) {{
549
+ console.log('[MermaidDebug] getSvgSize: returning [0,0]');
550
+ return JSON.stringify([0, 0]);
551
+ }}
552
+
553
+ var pad = 40;
554
+ var result = [Math.round(w + pad), Math.round(h + pad)];
555
+ var jsonStr = JSON.stringify(result);
556
+ console.log('[MermaidDebug] getSvgSize: returning ' + jsonStr);
557
+ return jsonStr;
558
+ }}
559
+ </script>
560
+ </head>
561
+ <body>
562
+ <pre class="mermaid">
563
+ {code}
564
+ </pre>
565
+ <script>
566
+ console.log('[MermaidDebug] body loaded; .mermaid count =',
567
+ document.querySelectorAll('.mermaid').length);
568
+ </script>
569
+ </body>
570
+ </html>"""
571
+
572
+ try:
573
+ with tempfile.NamedTemporaryFile(
574
+ mode='w', suffix='.html',
575
+ delete=False, encoding='utf-8') as f:
576
+ f.write(html)
577
+ temp_path = f.name
578
+ StaTableLogger.debug(
579
+ f"MermaidWidget: temp HTML written: {temp_path}")
580
+
581
+ try:
582
+ debug_path = os.path.join(
583
+ os.path.expanduser("~"),
584
+ "statable_mermaid_debug.html")
585
+ shutil.copy(temp_path, debug_path)
586
+ StaTableLogger.info(
587
+ f"[DEBUG] Mermaid HTML saved to: {debug_path}")
588
+ except Exception as e:
589
+ StaTableLogger.warning(
590
+ f"[DEBUG] failed to save debug HTML: {e}")
591
+
592
+ self.web_view.load(QUrl.fromLocalFile(temp_path))
593
+ except Exception as e:
594
+ StaTableLogger.error(
595
+ f"Failed to create temporary HTML: {e}")
596
+ elif self.text_view:
597
+ self.text_view.setPlainText(code)
598
+
599
+ def _on_load_finished(self, ok: bool):
600
+ StaTableLogger.debug(
601
+ f"MermaidWidget._on_load_finished: ok={ok}")
602
+ if ok and self.web_view:
603
+ self.web_view.page().runJavaScript(
604
+ "renderMermaid();",
605
+ lambda r: StaTableLogger.debug(
606
+ f"renderMermaid() returned: {r!r}"),
607
+ )
608
+ QTimer.singleShot(
609
+ self.SIZE_RETRY_DELAY_MS,
610
+ lambda: self._request_svg_size(retry=0),
611
+ )
612
+ elif not ok:
613
+ StaTableLogger.warning(
614
+ "MermaidWidget: WebEngine load FAILED")
615
+
616
+ def _request_svg_size(self, retry: int = 0):
617
+ StaTableLogger.debug(
618
+ f"MermaidWidget._request_svg_size: retry={retry}")
619
+ if not self.web_view:
620
+ return
621
+ self.web_view.page().runJavaScript(
622
+ "getSvgSize();",
623
+ lambda result: self._apply_svg_size(result, retry),
624
+ )
625
+
626
+ def _apply_svg_size(self, result, retry: int = 0):
627
+ StaTableLogger.debug(
628
+ f"MermaidWidget._apply_svg_size: retry={retry}, "
629
+ f"result={result!r}, type={type(result).__name__}")
630
+
631
+ values = None
632
+ if isinstance(result, (list, tuple)) and len(result) >= 2:
633
+ values = result
634
+ elif isinstance(result, str):
635
+ s = result.strip()
636
+ if s:
637
+ try:
638
+ parsed = json.loads(s)
639
+ if isinstance(parsed, (list, tuple)) and len(parsed) >= 2:
640
+ values = parsed
641
+ except (json.JSONDecodeError, ValueError):
642
+ pass
643
+
644
+ valid = False
645
+ w = h = 0
646
+ if values is not None:
647
+ try:
648
+ w = int(values[0])
649
+ h = int(values[1])
650
+ if w > 0 and h > 0:
651
+ valid = True
652
+ except (ValueError, TypeError):
653
+ pass
654
+
655
+ if not valid:
656
+ if retry < self.SIZE_RETRY_MAX:
657
+ next_retry = retry + 1
658
+ QTimer.singleShot(
659
+ self.SIZE_RETRY_DELAY_MS,
660
+ lambda: self._request_svg_size(retry=next_retry),
661
+ )
662
+ else:
663
+ StaTableLogger.warning(
664
+ f"MermaidWidget: size query failed after "
665
+ f"{self.SIZE_RETRY_MAX} retries; keeping current size")
666
+ return
667
+
668
+ w = min(w, self.MAX_CONTENT_W)
669
+ h = min(h, self.MAX_CONTENT_H)
670
+ self.web_view.setFixedSize(w, h)
671
+
672
+
673
+ class SettingsPanel(QWidget):
674
+ """State / role function settings panel.
675
+
676
+ [C-38 fix]
677
+ add_state() now emits settings_changed so that the parent
678
+ StateMachineTab / MainWindow updated the window's modified
679
+ flag when a state is added.
680
+
681
+ [v3.11]
682
+ Accepts `layer_names_provider`, a zero-argument callable that
683
+ returns all tab names (all layer names in the project). It is
684
+ used by _get_namespace_choices() to populate the namespace
685
+ combo box with every layer, not just the current one.
686
+
687
+ [v3.10]
688
+ Role function table: only the Namespace column is editable
689
+ inline (via _NamespaceDelegate).
690
+
691
+ [v3.9]
692
+ Receives role_function_library and literal_library.
693
+
694
+ [v3.8]
695
+ "Edit" button + row double-click for role functions.
696
+
697
+ [v3.7]
698
+ State list: 5 columns. Role function: 4 columns.
699
+ Reserved fields carried forward in apply_changes().
700
+ """
701
+ settings_changed = Signal()
702
+
703
+ # [v2.7.1] State list is now 3 columns: 0=Name, 1=Description, 2=Type.
704
+ # Double-click on Name opens StateActionsDialog; Description / Type
705
+ # are edited inline.
706
+ STATE_COL_NAME = 0
707
+ STATE_COL_DESC = 1
708
+ STATE_COL_TYPE = 2
709
+
710
+ # [v3.10] Role-function table layout: 0=Title, 1=Function name,
711
+ # 2=Namespace, 3=Description. Only column 2 is editable.
712
+ ROLE_NAMESPACE_COL = 2
713
+
714
+ def __init__(self, sm: StateMachine,
715
+ global_defs: GlobalDefinitions = None,
716
+ literal_library: LiteralLibrary = None,
717
+ role_function_library: RoleFunctionLibrary = None,
718
+ layer_names_provider: Callable[[], List[str]] = None,
719
+ parent=None):
720
+ super().__init__(parent)
721
+ self.sm = sm
722
+ self.global_defs = global_defs if global_defs else GlobalDefinitions()
723
+ self.literal_library = literal_library
724
+ self.role_function_library = role_function_library
725
+ # [v3.11] Callable returning all tab names (= all layer names)
726
+ self.layer_names_provider = layer_names_provider
727
+ self._updating = False
728
+
729
+ self._debounce_timer = QTimer()
730
+ self._debounce_timer.setSingleShot(True)
731
+ self._debounce_timer.setInterval(300)
732
+ self._debounce_timer.timeout.connect(self._emit_settings_changed)
733
+
734
+ layout = QVBoxLayout(self)
735
+ self.tab = QTabWidget()
736
+ layout.addWidget(self.tab)
737
+
738
+ # ---- State tab ----
739
+ state_tab = QWidget()
740
+ state_layout = QVBoxLayout(state_tab)
741
+ # [v2.7.1] 3 columns: Name / Description / Type.
742
+ # entry / exit are edited only via StateActionsDialog.
743
+ self.state_table = QTableWidget(0, 3)
744
+ self.state_table.setHorizontalHeaderLabels([
745
+ "Name", "Description", "Type"
746
+ ])
747
+ self.state_table.horizontalHeader().setSectionResizeMode(
748
+ QHeaderView.Stretch)
749
+ self.state_table.setFont(QFont("Consolas", 10))
750
+ state_layout.addWidget(self.state_table)
751
+ btn_state = QHBoxLayout()
752
+ add_state_btn = QPushButton(self.tr("Add"))
753
+ add_state_btn.clicked.connect(self.add_state)
754
+ del_state_btn = QPushButton(self.tr("Delete"))
755
+ del_state_btn.clicked.connect(self.delete_state)
756
+ btn_state.addWidget(add_state_btn)
757
+ btn_state.addWidget(del_state_btn)
758
+ state_layout.addLayout(btn_state)
759
+ self.tab.addTab(state_tab, self.tr("State list"))
760
+
761
+ # ---- Role function tab ----
762
+ role_tab = QWidget()
763
+ role_layout = QVBoxLayout(role_tab)
764
+ self.role_table = QTableWidget(0, 4)
765
+ self.role_table.setHorizontalHeaderLabels([
766
+ "Title", "Function name", "Namespace", "Description"
767
+ ])
768
+ self.role_table.horizontalHeader().setSectionResizeMode(
769
+ QHeaderView.Stretch)
770
+ self.role_table.setFont(QFont("Consolas", 10))
771
+
772
+ # [v3.10] Namespace column: editable QComboBox delegate.
773
+ self._namespace_delegate = _NamespaceDelegate(
774
+ self._get_namespace_choices, self.role_table)
775
+ self.role_table.setItemDelegateForColumn(
776
+ self.ROLE_NAMESPACE_COL, self._namespace_delegate)
777
+
778
+ role_layout.addWidget(self.role_table)
779
+ btn_role = QHBoxLayout()
780
+ add_role_btn = QPushButton(self.tr("Add"))
781
+ add_role_btn.clicked.connect(self.add_role_function)
782
+ edit_role_btn = QPushButton(self.tr("Edit"))
783
+ edit_role_btn.clicked.connect(self.edit_role_function)
784
+ del_role_btn = QPushButton(self.tr("Delete"))
785
+ del_role_btn.clicked.connect(self.delete_role_function)
786
+ btn_role.addWidget(add_role_btn)
787
+ btn_role.addWidget(edit_role_btn)
788
+ btn_role.addWidget(del_role_btn)
789
+ role_layout.addLayout(btn_role)
790
+
791
+ event_btn = QPushButton(self.tr("Event definitions..."))
792
+ event_btn.clicked.connect(self.open_event_definition)
793
+ role_layout.addWidget(event_btn)
794
+
795
+ self.tab.addTab(role_tab, self.tr("Role function"))
796
+
797
+ self.state_table.itemChanged.connect(self.on_state_table_item_changed)
798
+ self.role_table.itemChanged.connect(self.on_role_table_item_changed)
799
+ self.state_table.cellDoubleClicked.connect(
800
+ self.on_state_table_cell_double_clicked)
801
+ self.role_table.cellDoubleClicked.connect(
802
+ self.on_role_table_cell_double_clicked)
803
+
804
+ self.populate()
805
+ StaTableLogger.debug("SettingsPanel initialized")
806
+
807
+ def _emit_settings_changed(self):
808
+ if not self._updating:
809
+ self.settings_changed.emit()
810
+
811
+ @staticmethod
812
+ def _make_state_item(text: str, col: int) -> QTableWidgetItem:
813
+ # [v2.7.1] All 3 columns are inline-editable; Name is also
814
+ # opened by double-click (see on_state_table_cell_double_clicked).
815
+ return QTableWidgetItem(text)
816
+
817
+ # ------------------------------------------------------------------
818
+ # Namespace choices provider (shared by delegate + dialog)
819
+ # ------------------------------------------------------------------
820
+ def _get_namespace_choices(self) -> List[str]:
821
+ """Return namespace candidates (deduped, order preserved).
822
+
823
+ [v3.11]
824
+ Candidates come from (in order):
825
+ 1. layer_names_provider (all tabs: Application, Driver,
826
+ Middleware, ...)
827
+ 2. self.sm.layer_name (current tab, in case provider
828
+ is unavailable)
829
+ 3. role_function_library (shared library)
830
+ 4. sm.role_functions (this StateMachine)
831
+
832
+ Used both by the inline namespace combo-box delegate and
833
+ by _role_function_dialog_kwargs() for RoleFunctionDialog,
834
+ so both show the same complete list.
835
+ """
836
+ ordered: List[str] = []
837
+
838
+ def _add(ns):
839
+ ns = (ns or "").strip()
840
+ if ns and ns not in ordered:
841
+ ordered.append(ns)
842
+
843
+ # 1. All tab names (= all layer names in the project)
844
+ if self.layer_names_provider is not None:
845
+ try:
846
+ for name in self.layer_names_provider():
847
+ _add(name)
848
+ except Exception as e:
849
+ StaTableLogger.warning(
850
+ f"layer_names_provider failed: {e}")
851
+
852
+ # 2. Current tab's layer name (fallback)
853
+ _add(getattr(self.sm, 'layer_name', ''))
854
+
855
+ # 3. Shared library namespaces
856
+ if self.role_function_library is not None:
857
+ try:
858
+ for rf in self.role_function_library.list_all():
859
+ _add(getattr(rf, 'namespace', ''))
860
+ except Exception as e:
861
+ StaTableLogger.warning(
862
+ f"role_function_library.list_all() failed: {e}")
863
+
864
+ # 4. Namespaces used in this StateMachine
865
+ for rf in self.sm.role_functions.values():
866
+ _add(getattr(rf, 'namespace', ''))
867
+
868
+ return ordered
869
+
870
+ # ------------------------------------------------------------------
871
+ # Populate
872
+ # ------------------------------------------------------------------
873
+ def populate(self):
874
+ self._updating = True
875
+
876
+ states = list(self.sm.states.values())
877
+ self.state_table.setRowCount(len(states))
878
+ for row, state in enumerate(states):
879
+ name_item = QTableWidgetItem(state.name)
880
+ name_item.setToolTip(
881
+ self.tr("Double-click to edit actions (Entry / Exit / Do)"))
882
+ self.state_table.setItem(row, self.STATE_COL_NAME, name_item)
883
+ self.state_table.setItem(
884
+ row, self.STATE_COL_DESC,
885
+ QTableWidgetItem(state.description))
886
+ self.state_table.setItem(
887
+ row, self.STATE_COL_TYPE,
888
+ QTableWidgetItem(state.type.value))
889
+
890
+ self.populate_role_table()
891
+
892
+ self._updating = False
893
+ StaTableLogger.debug(
894
+ f"Settings populated: {len(states)} states, "
895
+ f"{len(self.sm.role_functions)} roles")
896
+
897
+ def populate_role_table(self):
898
+ """Refresh the role function table.
899
+
900
+ [v3.10]
901
+ Only the Namespace column is editable inline.
902
+ """
903
+ roles = list(self.sm.role_functions.values())
904
+ self.role_table.setRowCount(len(roles))
905
+ for row, rf in enumerate(roles):
906
+ values = [rf.title, rf.name, rf.namespace, rf.description]
907
+ for col, text in enumerate(values):
908
+ item = QTableWidgetItem(text)
909
+ if col != self.ROLE_NAMESPACE_COL:
910
+ item.setFlags(item.flags() & ~Qt.ItemIsEditable)
911
+ self.role_table.setItem(row, col, item)
912
+
913
+ # ------------------------------------------------------------------
914
+ # State editing
915
+ # ------------------------------------------------------------------
916
+ def on_state_table_cell_double_clicked(self, row, col):
917
+ """[v2.7.1] Name column double-click opens StateActionsDialog.
918
+
919
+ Name (0): StateActionsDialog
920
+ Description (1): inline edit (default behavior)
921
+ Type (2): inline edit (default behavior)
922
+ """
923
+ StaTableLogger.debug(
924
+ f"SettingsPanel.on_state_table_cell_double_clicked: "
925
+ f"row={row}, col={col}")
926
+ if col == self.STATE_COL_NAME:
927
+ self._open_state_actions(row)
928
+
929
+ def _open_state_actions(self, row: int):
930
+ """[v2.7.1] Open the StateActionsDialog for the state in this row."""
931
+ name_item = self.state_table.item(row, self.STATE_COL_NAME)
932
+ name = name_item.text().strip() if name_item else ""
933
+ if not name or name not in self.sm.states:
934
+ StaTableLogger.debug(
935
+ f"_open_state_actions: unknown state '{name}'")
936
+ return
937
+
938
+ state = self.sm.states[name]
939
+ dlg = StateActionsDialog(
940
+ parent=self, state=state, state_machine=self.sm)
941
+ if dlg.exec() == QDialog.Accepted:
942
+ self.settings_changed.emit()
943
+ StaTableLogger.info(
944
+ f"StateActionsDialog applied for state '{name}' "
945
+ f"(entry={len(state.entry or [])}, "
946
+ f"exit={len(state.exit or [])}, "
947
+ f"do={len(state.do_actions or [])})")
948
+
949
+ def add_state(self):
950
+ row = self.state_table.rowCount()
951
+ self.state_table.insertRow(row)
952
+ defaults = ["", "", "normal"] # Name / Description / Type
953
+ for col, default in enumerate(defaults):
954
+ self.state_table.setItem(
955
+ row, col, self._make_state_item(default, col))
956
+ self.state_table.editItem(self.state_table.item(row, 0))
957
+ # [C-38 fix] Notify listeners that a state was added
958
+ self.settings_changed.emit()
959
+ StaTableLogger.debug("Add state row")
960
+
961
+ def delete_state(self):
962
+ row = self.state_table.currentRow()
963
+ if row >= 0:
964
+ name = (self.state_table.item(row, 0).text().strip()
965
+ if self.state_table.item(row, 0) else "")
966
+ if name and name in self.sm.states:
967
+ self.sm.transitions = [
968
+ t for t in self.sm.transitions
969
+ if t.source != name and t.target != name
970
+ ]
971
+ for (src, evt) in list(self.sm.get_cell_keys()):
972
+ if src == name:
973
+ self.sm.remove_cell_metadata(src, evt)
974
+ del self.sm.states[name]
975
+ self.populate()
976
+ self.settings_changed.emit()
977
+ StaTableLogger.info(f"State deleted: {name}")
978
+ else:
979
+ self.state_table.removeRow(row)
980
+
981
+ def open_event_definition(self):
982
+ StaTableLogger.debug("SettingsPanel.open_event_definition called")
983
+ dlg = EventDefinitionDialog(self.sm, self.global_defs, self)
984
+ if dlg.exec() == QDialog.Accepted:
985
+ self.settings_changed.emit()
986
+ StaTableLogger.info("Event definitions updated")
987
+
988
+ # ------------------------------------------------------------------
989
+ # Role function dialog helpers
990
+ # ------------------------------------------------------------------
991
+ def _role_function_dialog_kwargs(self):
992
+ """Build kwargs for RoleFunctionDialog."""
993
+ global_vars = [
994
+ getattr(v, 'name', '') for v in
995
+ (getattr(self.global_defs, 'variables', []) or [])
996
+ if getattr(v, 'name', '')
997
+ ]
998
+
999
+ events = [
1000
+ getattr(e, 'name', '') for e in self.sm.events.values()
1001
+ if getattr(e, 'name', '')
1002
+ ]
1003
+
1004
+ literals = []
1005
+ if self.literal_library is not None:
1006
+ try:
1007
+ literals = [
1008
+ getattr(lit, 'name', '')
1009
+ for lit in self.literal_library.list_all()
1010
+ if getattr(lit, 'name', '')
1011
+ ]
1012
+ except Exception as e:
1013
+ StaTableLogger.warning(
1014
+ f"literal_library.list_all() failed: {e}")
1015
+
1016
+ return dict(
1017
+ global_vars=global_vars,
1018
+ events=events,
1019
+ literals=literals,
1020
+ namespace_choices=self._get_namespace_choices(),
1021
+ # [R-7] pass library so "+ New Literal" works
1022
+ literal_library=self.literal_library,
1023
+ )
1024
+
1025
+ def add_role_function(self):
1026
+ StaTableLogger.debug("SettingsPanel.add_role_function called")
1027
+ dlg = RoleFunctionDialog(
1028
+ self, **self._role_function_dialog_kwargs())
1029
+ if dlg.exec() == QDialog.Accepted:
1030
+ rf = dlg.get_role_function()
1031
+ if rf.name in self.sm.role_functions:
1032
+ QMessageBox.warning(
1033
+ self, self.tr("Warning"),
1034
+ self.tr("A role function with the same name already exists."))
1035
+ return
1036
+ self.sm.add_role_function(rf)
1037
+ self.populate_role_table()
1038
+ self.settings_changed.emit()
1039
+
1040
+ def edit_role_function(self):
1041
+ row = self.role_table.currentRow()
1042
+ if row < 0:
1043
+ StaTableLogger.debug("edit_role_function: no row selected")
1044
+ return
1045
+
1046
+ name = (self.role_table.item(row, 1).text().strip()
1047
+ if self.role_table.item(row, 1) else "")
1048
+ if not name or name not in self.sm.role_functions:
1049
+ StaTableLogger.debug(
1050
+ f"edit_role_function: unknown name '{name}'")
1051
+ return
1052
+
1053
+ existing = self.sm.role_functions[name]
1054
+ dlg = RoleFunctionDialog(
1055
+ self,
1056
+ role_function=existing,
1057
+ **self._role_function_dialog_kwargs(),
1058
+ )
1059
+ if dlg.exec() != QDialog.Accepted:
1060
+ return
1061
+
1062
+ updated = dlg.get_role_function()
1063
+
1064
+ if updated.name != name and updated.name in self.sm.role_functions:
1065
+ QMessageBox.warning(
1066
+ self, self.tr("Warning"),
1067
+ self.tr("A role function with the same name already exists."))
1068
+ return
1069
+
1070
+ self.sm.remove_role_function(name)
1071
+ self.sm.add_role_function(updated)
1072
+ self.populate_role_table()
1073
+ self.settings_changed.emit()
1074
+ StaTableLogger.info(
1075
+ f"Role function updated: '{name}' -> '{updated.name}'")
1076
+
1077
+ def on_role_table_cell_double_clicked(self, row, col):
1078
+ """Row double-click handler.
1079
+
1080
+ [v3.10]
1081
+ If the Namespace column was double-clicked, the inline
1082
+ combo-box delegate handles it; do not open the dialog.
1083
+ """
1084
+ StaTableLogger.debug(
1085
+ f"SettingsPanel.on_role_table_cell_double_clicked: "
1086
+ f"row={row}, col={col}")
1087
+
1088
+ if col == self.ROLE_NAMESPACE_COL:
1089
+ return
1090
+
1091
+ self.role_table.selectRow(row)
1092
+ self.edit_role_function()
1093
+
1094
+ def delete_role_function(self):
1095
+ row = self.role_table.currentRow()
1096
+ if row >= 0:
1097
+ name = (self.role_table.item(row, 1).text().strip()
1098
+ if self.role_table.item(row, 1) else "")
1099
+ if name and name in self.sm.role_functions:
1100
+ self.sm.remove_role_function(name)
1101
+ self.populate_role_table()
1102
+ self.settings_changed.emit()
1103
+ StaTableLogger.info(f"Role function deleted: {name}")
1104
+ else:
1105
+ self.role_table.removeRow(row)
1106
+
1107
+ def on_state_table_item_changed(self, item):
1108
+ self._debounce_timer.start()
1109
+
1110
+ def on_role_table_item_changed(self, item):
1111
+ self._debounce_timer.start()
1112
+
1113
+ def apply_changes(self):
1114
+ """Apply the UI edits back into the StateMachine.
1115
+
1116
+ [v3.10]
1117
+ The Namespace column may have been edited inline via the
1118
+ combo-box delegate. This method picks up the change and
1119
+ rebuilds the role functions accordingly.
1120
+ """
1121
+ # ---- States ----
1122
+ # [v2.7.1] State list is 3 columns: Name / Description / Type.
1123
+ # entry / exit are preserved on the state object (they are
1124
+ # edited only via StateActionsDialog).
1125
+ for row in range(self.state_table.rowCount()):
1126
+ name = (self.state_table.item(row, self.STATE_COL_NAME).text().strip()
1127
+ if self.state_table.item(row, self.STATE_COL_NAME) else "")
1128
+ desc = (self.state_table.item(row, self.STATE_COL_DESC).text().strip()
1129
+ if self.state_table.item(row, self.STATE_COL_DESC) else "")
1130
+ type_str = (self.state_table.item(row, self.STATE_COL_TYPE).text().strip()
1131
+ if self.state_table.item(row, self.STATE_COL_TYPE) else "normal")
1132
+
1133
+ if name:
1134
+ if name in self.sm.states:
1135
+ st = self.sm.states[name]
1136
+ st.description = desc
1137
+ try:
1138
+ st.type = StateType(type_str)
1139
+ except ValueError:
1140
+ st.type = StateType.NORMAL
1141
+ else:
1142
+ self.sm.add_state(State(
1143
+ name, type=StateType(type_str), description=desc,
1144
+ ))
1145
+
1146
+ # ---- Role functions ----
1147
+ existing_roles = dict(self.sm.role_functions)
1148
+ self.sm.role_functions.clear()
1149
+
1150
+ for row in range(self.role_table.rowCount()):
1151
+ title = (self.role_table.item(row, 0).text().strip()
1152
+ if self.role_table.item(row, 0) else "")
1153
+ name = (self.role_table.item(row, 1).text().strip()
1154
+ if self.role_table.item(row, 1) else "")
1155
+ if name:
1156
+ namespace = (self.role_table.item(row, 2).text().strip()
1157
+ if self.role_table.item(row, 2) else "")
1158
+ desc = (self.role_table.item(row, 3).text().strip()
1159
+ if self.role_table.item(row, 3) else "")
1160
+
1161
+ prev = existing_roles.get(name)
1162
+ self.sm.add_role_function(RoleFunction(
1163
+ name=name,
1164
+ namespace=namespace,
1165
+ description=desc,
1166
+ title=title,
1167
+ # [Reserved] carry forward previous values
1168
+ return_type=(
1169
+ getattr(prev, 'return_type', 'void')
1170
+ if prev else "void"),
1171
+ arg1_type=(
1172
+ getattr(prev, 'arg1_type', '') if prev else ""),
1173
+ arg1_name=(
1174
+ getattr(prev, 'arg1_name', '') if prev else ""),
1175
+ arg2_type=(
1176
+ getattr(prev, 'arg2_type', '') if prev else ""),
1177
+ arg2_name=(
1178
+ getattr(prev, 'arg2_name', '') if prev else ""),
1179
+ # [v3.8] carry forward symbol references
1180
+ used_global_vars=list(
1181
+ getattr(prev, 'used_global_vars', []) or []
1182
+ ) if prev else [],
1183
+ used_events=list(
1184
+ getattr(prev, 'used_events', []) or []
1185
+ ) if prev else [],
1186
+ used_literals=list(
1187
+ getattr(prev, 'used_literals', []) or []
1188
+ ) if prev else [],
1189
+ ))
1190
+ StaTableLogger.debug("Settings changes applied")
1191
+
1192
+
1193
+ class StateMachineTab(QWidget):
1194
+ """Tab hosting one state machine (matrix + mermaid + settings).
1195
+
1196
+ [v3.11]
1197
+ Accepts and forwards `layer_names_provider` to SettingsPanel.
1198
+
1199
+ [v3.9]
1200
+ Passes role_function_library and literal_library to SettingsPanel.
1201
+
1202
+ [v3.7 change]
1203
+ Layout: QSplitter(Qt.Horizontal).
1204
+
1205
+ [v2.3 change]
1206
+ dataModified signal relays child modifications to MainWindow.
1207
+ """
1208
+
1209
+ dataModified = Signal()
1210
+
1211
+ SETTINGS_MIN_WIDTH = 260
1212
+ SETTINGS_MAX_WIDTH = 560
1213
+
1214
+ def __init__(self, sm: StateMachine,
1215
+ global_defs: GlobalDefinitions = None,
1216
+ role_function_library: RoleFunctionLibrary = None,
1217
+ condition_library: ConditionLibrary = None,
1218
+ literal_library: LiteralLibrary = None,
1219
+ layer_names_provider: Callable[[], List[str]] = None,
1220
+ parent=None):
1221
+ super().__init__(parent)
1222
+ self.sm = sm
1223
+ self.global_defs = global_defs if global_defs else GlobalDefinitions()
1224
+
1225
+ self.role_function_library = (
1226
+ role_function_library if role_function_library
1227
+ else RoleFunctionLibrary())
1228
+ self.condition_library = (
1229
+ condition_library if condition_library
1230
+ else ConditionLibrary())
1231
+ self.literal_library = (
1232
+ literal_library if literal_library
1233
+ else LiteralLibrary())
1234
+ # [v3.11] forwarded to SettingsPanel
1235
+ self.layer_names_provider = layer_names_provider
1236
+
1237
+ StaTableLogger.debug(
1238
+ f"StateMachineTab.__init__: global_defs id={id(self.global_defs)}, "
1239
+ f"vars={len(self.global_defs.variables)}, "
1240
+ f"flags={len(self.global_defs.flags)}"
1241
+ )
1242
+ StaTableLogger.debug(
1243
+ f"StateMachineTab shared libraries: "
1244
+ f"roles={len(self.role_function_library.list_all())}, "
1245
+ f"conditions={len(self.condition_library.list_all())}, "
1246
+ f"literals={len(self.literal_library.list_all())}"
1247
+ )
1248
+
1249
+ outer = QVBoxLayout(self)
1250
+ outer.setContentsMargins(0, 0, 0, 0)
1251
+ outer.setSpacing(0)
1252
+
1253
+ main_split = QSplitter(Qt.Horizontal)
1254
+ main_split.setChildrenCollapsible(False)
1255
+
1256
+ left_split = QSplitter(Qt.Vertical)
1257
+ # [R-6] Forward layer_names_provider to MatrixTableWidget so
1258
+ # that ActionEditorDialog / TransitionsTab / ActionsTab
1259
+ # can list every layer in the project.
1260
+ self.table = MatrixTableWidget(
1261
+ sm,
1262
+ global_defs=self.global_defs,
1263
+ role_function_library=self.role_function_library,
1264
+ condition_library=self.condition_library,
1265
+ literal_library=self.literal_library,
1266
+ layer_names_provider=self.layer_names_provider,
1267
+ )
1268
+ self.mermaid = MermaidWidget()
1269
+ left_split.addWidget(self.table)
1270
+ left_split.addWidget(self.mermaid)
1271
+
1272
+ total_height = WINDOW_HEIGHT
1273
+ table_height = int(total_height * TABLE_PREVIEW_RATIO)
1274
+ mermaid_height = total_height - table_height
1275
+ left_split.setSizes([table_height, mermaid_height])
1276
+
1277
+ self.table.setMinimumHeight(300)
1278
+ self.mermaid.setMinimumHeight(MERMAID_PREVIEW_MIN_HEIGHT)
1279
+
1280
+ main_split.addWidget(left_split)
1281
+
1282
+ # [v3.11] Forward layer_names_provider to SettingsPanel.
1283
+ self.settings = SettingsPanel(
1284
+ sm,
1285
+ global_defs=self.global_defs,
1286
+ literal_library=self.literal_library,
1287
+ role_function_library=self.role_function_library,
1288
+ layer_names_provider=self.layer_names_provider,
1289
+ )
1290
+ self.settings.setMinimumWidth(self.SETTINGS_MIN_WIDTH)
1291
+ self.settings.setMaximumWidth(self.SETTINGS_MAX_WIDTH)
1292
+
1293
+ main_split.addWidget(self.settings)
1294
+
1295
+ main_split.setStretchFactor(0, 4)
1296
+ main_split.setStretchFactor(1, 1)
1297
+ main_split.setSizes([WINDOW_HEIGHT, self.SETTINGS_MIN_WIDTH])
1298
+
1299
+ outer.addWidget(main_split)
1300
+
1301
+ self.table.transition_changed.connect(self.update_mermaid)
1302
+ self.settings.settings_changed.connect(self.update_mermaid)
1303
+
1304
+ self.table.transition_changed.connect(self.dataModified)
1305
+ self.settings.settings_changed.connect(self.dataModified)
1306
+
1307
+ self.update_mermaid()
1308
+ StaTableLogger.debug("StateMachineTab created")
1309
+
1310
+ def update_mermaid(self):
1311
+ self.settings.apply_changes()
1312
+ self.table.populate()
1313
+ code = generate_mermaid(self.sm)
1314
+ self.mermaid.set_mermaid_code(code)
1315
+ StaTableLogger.info("Mermaid updated for current tab")