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,413 @@
1
+ # statable_gui/matrix_table.py
2
+ """State transition table widget (D&D editor direct launch support / v2.2 multi-transition).
3
+
4
+ [R-6 change]
5
+ MatrixTableWidget now accepts `layer_names_provider` and forwards
6
+ it to ActionEditorDialog, completing the all-tabs namespace wiring.
7
+ """
8
+
9
+ from typing import List, Callable
10
+
11
+ from PySide6.QtCore import Qt, Signal
12
+ from PySide6.QtGui import QFont, QKeyEvent
13
+ from PySide6.QtWidgets import (
14
+ QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView, QDialog
15
+ )
16
+
17
+ from statable.model import Transition, EventDeliveryType
18
+ from statable.state_machine import StateMachine
19
+
20
+ from .logger import StaTableLogger
21
+ from .config import MAX_COLUMN_WIDTH, MIN_ROW_HEIGHT, MAX_ROW_HEIGHT
22
+ from .global_defs import GlobalDefinitions
23
+
24
+ from .transition_editor_direct.dialog import ActionEditorDialog
25
+ from .state_actions_dialog import StateActionsDialog
26
+ from .transition_editor_direct.draft import (
27
+ ActionDraft, transition_to_flow_item, flow_item_to_transition,
28
+ )
29
+
30
+ from statable_gui.libcntrl.role_function_library import RoleFunctionLibrary
31
+ from statable_gui.libcntrl.condition_library import ConditionLibrary
32
+ from statable_gui.libcntrl.literal_library import LiteralLibrary
33
+
34
+
35
+ # ======================================================================
36
+ # Helpers
37
+ # ======================================================================
38
+ def _truncate_text(text: str, max_chars: int = 40) -> str:
39
+ if not text:
40
+ return ""
41
+ lines = text.split('\n')
42
+ first_line = lines[0].strip() if lines else ""
43
+ if len(first_line) > max_chars:
44
+ return first_line[:max_chars].rstrip() + "..."
45
+ if len(lines) > 1:
46
+ return first_line + " ..."
47
+ return first_line
48
+
49
+
50
+ def _mode_marker(trans: Transition) -> str:
51
+ """Return the mode marker for a transition.
52
+
53
+ " [C]" -> Commit (early_return=True)
54
+ " [T]" -> Tentative (early_return=False)
55
+ "" -> not specified (backward compat)
56
+ """
57
+ er = getattr(trans, 'early_return', None)
58
+ if er is True:
59
+ return " [C]"
60
+ if er is False:
61
+ return " [T]"
62
+ return ""
63
+
64
+
65
+ def _mode_label(trans: Transition) -> str:
66
+ """Human-readable mode label for tooltips."""
67
+ er = getattr(trans, 'early_return', None)
68
+ if er is True:
69
+ return "Commit"
70
+ if er is False:
71
+ return "Tentative"
72
+ return "(unset)"
73
+
74
+
75
+ def _build_transition_tooltip(trans: Transition) -> str:
76
+ parts = []
77
+ parts.append(f"Title: {trans.title}")
78
+ parts.append(f"Target: {trans.target if trans.target else '(internal)'}")
79
+ if trans.event:
80
+ parts.append(f"Event: {trans.event}")
81
+ else:
82
+ parts.append("Event: Completion transition")
83
+ if trans.condition:
84
+ parts.append(f"State transition condition:\n{trans.condition}")
85
+ parts.append(f"Mode: {_mode_label(trans)}")
86
+ if getattr(trans, 'has_else', False):
87
+ else_t = getattr(trans, 'else_target', '') or '(not set)'
88
+ parts.append(f"else target: {else_t}")
89
+ lbl = getattr(trans, 'label', '')
90
+ if lbl:
91
+ parts.append(f"Label: {lbl}")
92
+ return "\n".join(parts)
93
+
94
+
95
+ def _event_header_label(event_name: str, delivery_type) -> str:
96
+ if delivery_type == EventDeliveryType.QUEUE:
97
+ return f"[Q] {event_name}"
98
+ elif delivery_type == EventDeliveryType.DOUBLE:
99
+ return f"[D] {event_name}"
100
+ return event_name
101
+
102
+
103
+ class MatrixTableWidget(QTableWidget):
104
+ transition_changed = Signal()
105
+
106
+ def __init__(self, sm: StateMachine, global_defs: GlobalDefinitions = None,
107
+ role_function_library: RoleFunctionLibrary = None,
108
+ condition_library: ConditionLibrary = None,
109
+ literal_library: LiteralLibrary = None,
110
+ layer_names_provider: Callable[[], List[str]] = None,
111
+ parent=None):
112
+ super().__init__(0, 0, parent)
113
+ self.sm = sm
114
+ self.global_defs = global_defs if global_defs else GlobalDefinitions()
115
+
116
+ self.role_function_library = role_function_library if role_function_library else RoleFunctionLibrary()
117
+ self.condition_library = condition_library if condition_library else ConditionLibrary()
118
+ self.literal_library = literal_library if literal_library else LiteralLibrary()
119
+ # [R-6] All-tabs namespace provider (forwarded to ActionEditorDialog).
120
+ self.layer_names_provider = layer_names_provider
121
+
122
+ StaTableLogger.debug(
123
+ f"MatrixTableWidget.__init__: roles={len(self.role_function_library.list_all())}, "
124
+ f"conditions={len(self.condition_library.list_all())}, "
125
+ f"literals={len(self.literal_library.list_all())}, "
126
+ f"has_lnp={layer_names_provider is not None}"
127
+ )
128
+
129
+ self.horizontalHeader().setSectionResizeMode(QHeaderView.Interactive)
130
+ self.verticalHeader().setSectionResizeMode(QHeaderView.Interactive)
131
+ self.horizontalHeader().setStretchLastSection(True)
132
+ self.verticalHeader().setMinimumWidth(120)
133
+
134
+ self.setSelectionMode(QAbstractItemView.SingleSelection)
135
+ self.setFocusPolicy(Qt.StrongFocus)
136
+ self.setEditTriggers(QAbstractItemView.NoEditTriggers)
137
+ self.cellDoubleClicked.connect(self.open_transition_dialog)
138
+ # [v2.7.1] Horizontal header (state name) double-click
139
+ # opens StateActionsDialog.
140
+ self.horizontalHeader().sectionDoubleClicked.connect(
141
+ self.open_state_actions_for_header)
142
+ self.setFont(QFont("Consolas", 10))
143
+
144
+ self.populate()
145
+ StaTableLogger.debug("MatrixTableWidget initialized")
146
+
147
+ def populate(self):
148
+ states = list(self.sm.states.keys())
149
+ events = list(self.sm.events.keys())
150
+ self.clear()
151
+ self.setRowCount(len(events))
152
+ self.setColumnCount(len(states))
153
+ self.setHorizontalHeaderLabels(states)
154
+
155
+ event_labels = []
156
+ for event_name in events:
157
+ event_obj = self.sm.events.get(event_name)
158
+ delivery = event_obj.delivery_type if event_obj else EventDeliveryType.DIRECT
159
+ event_labels.append(_event_header_label(
160
+ event_name if event_name else "Completion", delivery))
161
+ self.setVerticalHeaderLabels(event_labels)
162
+
163
+ for row, event in enumerate(events):
164
+ for col, state in enumerate(states):
165
+ trans_list = self._find_transitions(state, event)
166
+ if trans_list:
167
+ titles = [self._generate_cell_label(t, event) for t in trans_list]
168
+ display = "\n".join(titles)
169
+ item = QTableWidgetItem(display)
170
+ item.setData(Qt.UserRole, trans_list)
171
+ tooltips = [_build_transition_tooltip(t) for t in trans_list]
172
+ full_tooltip = "\n\n".join(tooltips)
173
+ item.setToolTip(full_tooltip)
174
+ self.setItem(row, col, item)
175
+ else:
176
+ item = QTableWidgetItem("")
177
+ item.setData(Qt.UserRole, [])
178
+ item.setToolTip(self.tr("TransitionNone"))
179
+ self.setItem(row, col, item)
180
+
181
+ self.resizeColumnsToContents()
182
+ self.resizeRowsToContents()
183
+
184
+ for col in range(self.columnCount()):
185
+ current_width = self.columnWidth(col)
186
+ if current_width > MAX_COLUMN_WIDTH:
187
+ self.setColumnWidth(col, MAX_COLUMN_WIDTH)
188
+
189
+ for row in range(self.rowCount()):
190
+ current_height = self.rowHeight(row)
191
+ if current_height < MIN_ROW_HEIGHT:
192
+ self.setRowHeight(row, MIN_ROW_HEIGHT)
193
+ elif current_height > MAX_ROW_HEIGHT:
194
+ self.setRowHeight(row, MAX_ROW_HEIGHT)
195
+
196
+ StaTableLogger.debug(
197
+ f"MatrixTable populated: {len(events)} events, {len(states)} states")
198
+
199
+ def _find_transitions(self, state: str, event: str) -> List[Transition]:
200
+ return self.sm.get_transitions_for_cell(state, event)
201
+
202
+ def _generate_cell_label(self, trans: Transition, event: str) -> str:
203
+ parts = []
204
+ if trans.title and trans.title != "(untitled transition)":
205
+ parts.append(trans.title)
206
+ else:
207
+ if trans.target:
208
+ parts.append(trans.target)
209
+ else:
210
+ parts.append("(internal)")
211
+ if event:
212
+ parts.append(f"({event})")
213
+ if trans.condition:
214
+ condition_display = _truncate_text(trans.condition, 30)
215
+ parts.append(f"[{condition_display}]")
216
+
217
+ marker = _mode_marker(trans)
218
+ if marker:
219
+ parts.append(marker.strip())
220
+
221
+ lbl = getattr(trans, 'label', '')
222
+ if lbl:
223
+ parts.append(f"<{lbl}>")
224
+
225
+ return " ".join(parts)
226
+
227
+ def _generate_title(self, trans: Transition) -> str:
228
+ parts = []
229
+ if trans.target:
230
+ parts.append(trans.target)
231
+ else:
232
+ parts.append("(internal)")
233
+ if trans.condition:
234
+ condition_display = _truncate_text(trans.condition, 30)
235
+ parts.append(f"[{condition_display}]")
236
+ marker = _mode_marker(trans)
237
+ if marker:
238
+ parts.append(marker.strip())
239
+ return " ".join(parts)
240
+
241
+ def open_transition_dialog(self, row: int, col: int):
242
+ """Open the transition edit dialog."""
243
+ state = self.horizontalHeaderItem(col).text() if self.horizontalHeaderItem(col) else ""
244
+ raw_event = self.verticalHeaderItem(row).text() if self.verticalHeaderItem(row) else ""
245
+ event_name = raw_event
246
+ if event_name.startswith("[Q] "):
247
+ event_name = event_name[4:]
248
+ elif event_name.startswith("[D] "):
249
+ event_name = event_name[4:]
250
+ if event_name == "Completion":
251
+ event_name = ""
252
+
253
+ StaTableLogger.debug(f"=== MatrixTableWidget.open_transition_dialog ===")
254
+ StaTableLogger.debug(f"state={state}, event={event_name}")
255
+
256
+ existing_list = self._find_transitions(state, event_name)
257
+ StaTableLogger.debug(f"existing transitions count = {len(existing_list)}")
258
+
259
+ layer_name = getattr(self.sm, 'layer_name', '') or ''
260
+
261
+ draft = ActionDraft(
262
+ source=state,
263
+ event=event_name,
264
+ layer_name=layer_name,
265
+ )
266
+ for trans in existing_list:
267
+ fi = transition_to_flow_item(trans)
268
+ draft.flow_items.append(fi)
269
+
270
+ # === v2.2: populate cell_actions / cell_relations from SM ===
271
+ draft.cell_actions = list(
272
+ self.sm.get_actions_for_cell(state, event_name)
273
+ )
274
+ draft.cell_relations = list(
275
+ self.sm.get_relations_for_cell(state, event_name)
276
+ )
277
+
278
+ # Merge shared library and current SM role functions
279
+ role_func_names = []
280
+ _seen = set()
281
+ for rf in self.role_function_library.list_all():
282
+ qn = getattr(rf, 'qualified_name', None) or rf.name
283
+ if qn and qn not in _seen:
284
+ _seen.add(qn)
285
+ role_func_names.append(qn)
286
+ for rf in self.sm.role_functions.values():
287
+ qn = getattr(rf, 'qualified_name', None) or rf.name
288
+ if qn and qn not in _seen:
289
+ _seen.add(qn)
290
+ role_func_names.append(qn)
291
+
292
+ states = list(self.sm.states.keys())
293
+
294
+ StaTableLogger.debug(
295
+ f"open_transition_dialog: merged roles={len(role_func_names)} "
296
+ f"(library={len(self.role_function_library.list_all())}, "
297
+ f"sm={len(self.sm.role_functions)}), "
298
+ f"conditions={len(self.condition_library.list_all())}, "
299
+ f"literals={len(self.literal_library.list_all())}, "
300
+ f"has_lnp={self.layer_names_provider is not None}"
301
+ )
302
+
303
+ dialog = ActionEditorDialog(
304
+ draft,
305
+ role_functions=role_func_names,
306
+ transition_events=[event_name] if event_name else [],
307
+ states=states,
308
+ global_defs=self.global_defs,
309
+ state_machine=self.sm,
310
+ role_function_library=self.role_function_library,
311
+ condition_library=self.condition_library,
312
+ literal_library=self.literal_library,
313
+ # [R-6] Forward provider so TransitionsTab / ActionsTab
314
+ # inside the dialog list every layer in the project.
315
+ layer_names_provider=self.layer_names_provider,
316
+ parent=self
317
+ )
318
+
319
+ if dialog.exec() == QDialog.Accepted:
320
+ new_transitions = []
321
+ for item in draft.flow_items:
322
+ if item.item_type == "transition":
323
+ new_transitions.append(
324
+ flow_item_to_transition(item, state, event_name)
325
+ )
326
+
327
+ StaTableLogger.debug(
328
+ f" -> D&D editor accepted, {len(new_transitions)} transitions")
329
+
330
+ # === v2.2: write back cell metadata from draft ===
331
+ self.sm.set_actions_for_cell(state, event_name,
332
+ list(draft.cell_actions))
333
+ self.sm.set_relations_for_cell(state, event_name,
334
+ list(draft.cell_relations))
335
+
336
+ # Replace transitions in the cell
337
+ self.sm.transitions = [
338
+ t for t in self.sm.transitions
339
+ if not (t.source == state and t.event == event_name)
340
+ ]
341
+ for trans in new_transitions:
342
+ self.sm.add_transition(trans)
343
+
344
+ self.populate()
345
+ self.transition_changed.emit()
346
+ StaTableLogger.info(
347
+ f"Transition updated: {state} -{event_name or 'Completion'}-> "
348
+ f"{len(new_transitions)} transition(s), "
349
+ f"actions={len(draft.cell_actions)}, "
350
+ f"relations={len(draft.cell_relations)}")
351
+ else:
352
+ StaTableLogger.debug(" -> D&D editor cancelled")
353
+
354
+ def open_state_actions_for_header(self, logical_index: int):
355
+ """[v2.7.1] Horizontal header (state name) double-click.
356
+
357
+ Opens StateActionsDialog for the state at the given column.
358
+ Cell double-click (existing) still opens ActionEditorDialog.
359
+ """
360
+ header_item = self.horizontalHeaderItem(logical_index)
361
+ if header_item is None:
362
+ return
363
+ state_name = header_item.text().strip()
364
+ if not state_name or state_name not in self.sm.states:
365
+ StaTableLogger.debug(
366
+ f"open_state_actions_for_header: unknown state "
367
+ f"'{state_name}' (col={logical_index})")
368
+ return
369
+
370
+ state = self.sm.states[state_name]
371
+ dlg = StateActionsDialog(
372
+ parent=self, state=state, state_machine=self.sm)
373
+ if dlg.exec() == QDialog.Accepted:
374
+ self.populate()
375
+ self.transition_changed.emit()
376
+ StaTableLogger.info(
377
+ f"StateActionsDialog applied for state '{state_name}' "
378
+ f"(entry={len(state.entry or [])}, "
379
+ f"exit={len(state.exit or [])}, "
380
+ f"do={len(state.do_actions or [])})")
381
+
382
+ def keyPressEvent(self, event: QKeyEvent):
383
+ if event.key() in (Qt.Key_Return, Qt.Key_Enter, Qt.Key_F2):
384
+ current = self.currentItem()
385
+ if current:
386
+ self.open_transition_dialog(current.row(), current.column())
387
+ return
388
+ elif event.key() in (Qt.Key_Delete, Qt.Key_Backspace):
389
+ current = self.currentItem()
390
+ if current:
391
+ trans_list = current.data(Qt.UserRole)
392
+ if trans_list:
393
+ for trans in trans_list:
394
+ self.sm.remove_transition(trans)
395
+ state = self.horizontalHeaderItem(
396
+ current.column()).text() if self.horizontalHeaderItem(current.column()) else ""
397
+ raw_event = self.verticalHeaderItem(
398
+ current.row()).text() if self.verticalHeaderItem(current.row()) else ""
399
+ event_name = raw_event
400
+ if event_name.startswith("[Q] "):
401
+ event_name = event_name[4:]
402
+ elif event_name.startswith("[D] "):
403
+ event_name = event_name[4:]
404
+ if event_name == "Completion":
405
+ event_name = ""
406
+ self.sm.remove_cell_metadata(state, event_name)
407
+
408
+ self.populate()
409
+ self.transition_changed.emit()
410
+ StaTableLogger.info(
411
+ f"Transition deleted: {len(trans_list)} transition(s)")
412
+ return
413
+ super().keyPressEvent(event)
@@ -0,0 +1,18 @@
1
+ """StaTable preference definitions (key names and default values)\n\nTo add or change a setting, just add/edit an entry in\nPREFERENCE_DEFINITIONS in this file.\n"""
2
+
3
+ from pathlib import Path
4
+
5
+ # ----------------------------------------------------------------------
6
+ #Setting item definitions
7
+ # Adding an entry here is enough to enable attribute access from the Preferences class.
8
+ # ----------------------------------------------------------------------
9
+ PREFERENCE_DEFINITIONS = {
10
+ # Last used folder
11
+ "last_project_dir": str(Path.home()), # Project XML
12
+ "last_c_source_dir": str(Path.home()), # C source output dir
13
+ "last_spec_doc_dir": str(Path.home()), # Spec documents location
14
+ "last_export_dir": str(Path.home()), # Export dir
15
+
16
+ # ★ Event delivery settings
17
+ "auto_convert_isr_direct_to_double": True, # Automatically convert ISR-used DIRECT to DOUBLE
18
+ }
@@ -0,0 +1,74 @@
1
+ import json
2
+ from pathlib import Path
3
+ from typing import Any, Optional
4
+
5
+ from .preference_keys import PREFERENCE_DEFINITIONS
6
+
7
+
8
+ class Preferences:
9
+ """Store application settings in a JSON file (key names defined in a separate file)\n\n Keys registered in preference_keys.py's PREFERENCE_DEFINITIONS can be\n read/written via attribute access such as prefs.last_project_dir.\n """
10
+
11
+ DEFAULT_FILE = Path.home() / ".statable" / "preferences.json"
12
+
13
+ def __init__(self, filepath: Optional[Path] = None):
14
+ self.filepath = Path(filepath) if filepath else self.DEFAULT_FILE
15
+ self.data: dict = {}
16
+ self.load()
17
+ self._apply_defaults()
18
+
19
+ def _apply_defaults(self):
20
+ """Set default values for keys that have not yet been saved"""
21
+ changed = False
22
+ for key, default in PREFERENCE_DEFINITIONS.items():
23
+ if key not in self.data:
24
+ self.data[key] = default
25
+ changed = True
26
+ if changed:
27
+ self.save()
28
+
29
+ def load(self):
30
+ """Load settings from JSON file"""
31
+ if self.filepath.exists():
32
+ try:
33
+ with open(self.filepath, "r", encoding="utf-8") as f:
34
+ self.data = json.load(f)
35
+ except (json.JSONDecodeError, OSError):
36
+ self.data = {}
37
+
38
+ def save(self):
39
+ """Save current settings to a JSON file"""
40
+ try:
41
+ self.filepath.parent.mkdir(parents=True, exist_ok=True)
42
+ with open(self.filepath, "w", encoding="utf-8") as f:
43
+ json.dump(self.data, f, indent=2, ensure_ascii=False)
44
+ except OSError:
45
+ #Ignore write failures
46
+ pass
47
+
48
+ def get(self, key: str, default: Any = None) -> Any:
49
+ """Get setting by key name (legacy style)"""
50
+ return self.data.get(key, default)
51
+
52
+ def set(self, key: str, value: Any):
53
+ """Update the setting value by key name and save immediately"""
54
+ self.data[key] = value
55
+ self.save()
56
+
57
+ # ------------------------------------------------------------------
58
+ # Attribute access (prefs.last_project_dir etc.)
59
+ # ------------------------------------------------------------------
60
+ def __getattr__(self, name: str) -> Any:
61
+ # If key is defined, return its value
62
+ if name in PREFERENCE_DEFINITIONS:
63
+ return self.data.get(name, PREFERENCE_DEFINITIONS[name])
64
+ # Undefined attributes raise a normal error
65
+ raise AttributeError(f"'Preferences' object has no attribute '{name}'")
66
+
67
+ def __setattr__(self, name: str, value: Any):
68
+ # If key is defined, apply and save the setting
69
+ if name in PREFERENCE_DEFINITIONS:
70
+ self.data[name] = value
71
+ self.save()
72
+ else:
73
+ #Set undefined ones as normal attributes
74
+ super().__setattr__(name, value)