pycforge 0.15.2__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 (221) hide show
  1. pycforge/__init__.py +8 -0
  2. pycforge/__main__.py +2 -0
  3. pycforge/_version.py +1 -0
  4. pycforge/converter/__init__.py +0 -0
  5. pycforge/converter/analysis/__init__.py +5 -0
  6. pycforge/converter/analysis/functions.py +723 -0
  7. pycforge/converter/analysis/model.py +158 -0
  8. pycforge/converter/analysis/planning.py +464 -0
  9. pycforge/converter/analysis/stage.py +1976 -0
  10. pycforge/converter/analysis/symbols.py +237 -0
  11. pycforge/converter/analysis/validation.py +2166 -0
  12. pycforge/converter/c_output/__init__.py +2 -0
  13. pycforge/converter/c_output/conformance.py +297 -0
  14. pycforge/converter/c_output/renderer.py +220 -0
  15. pycforge/converter/conditional_regions/__init__.py +71 -0
  16. pycforge/converter/conditional_regions/analysis.py +481 -0
  17. pycforge/converter/conditional_regions/lowering.py +579 -0
  18. pycforge/converter/conditional_regions/model.py +243 -0
  19. pycforge/converter/conditional_regions/validation.py +690 -0
  20. pycforge/converter/containers/__init__.py +19 -0
  21. pycforge/converter/containers/analysis.py +542 -0
  22. pycforge/converter/containers/lowering.py +290 -0
  23. pycforge/converter/containers/model.py +146 -0
  24. pycforge/converter/contracts/__init__.py +0 -0
  25. pycforge/converter/contracts/configuration.py +178 -0
  26. pycforge/converter/contracts/identifiers.py +68 -0
  27. pycforge/converter/contracts/versions.py +71 -0
  28. pycforge/converter/core/__init__.py +0 -0
  29. pycforge/converter/core/artifact_io.py +87 -0
  30. pycforge/converter/core/cancellation.py +7 -0
  31. pycforge/converter/core/canonicalization.py +254 -0
  32. pycforge/converter/core/context.py +13 -0
  33. pycforge/converter/core/diagnostics.py +95 -0
  34. pycforge/converter/core/enums.py +23 -0
  35. pycforge/converter/core/fingerprint.py +22 -0
  36. pycforge/converter/core/pipeline.py +8 -0
  37. pycforge/converter/core/progress.py +25 -0
  38. pycforge/converter/core/request.py +50 -0
  39. pycforge/converter/core/resource_policy.py +31 -0
  40. pycforge/converter/core/result.py +23 -0
  41. pycforge/converter/core/serialization.py +36 -0
  42. pycforge/converter/core/stage.py +10 -0
  43. pycforge/converter/core/stage_artifact.py +80 -0
  44. pycforge/converter/core/stage_outcome.py +15 -0
  45. pycforge/converter/decision_trace/__init__.py +0 -0
  46. pycforge/converter/decision_trace/recorder.py +52 -0
  47. pycforge/converter/facade.py +459 -0
  48. pycforge/converter/frontend/__init__.py +4 -0
  49. pycforge/converter/frontend/normalizer.py +59 -0
  50. pycforge/converter/frontend/parser.py +20 -0
  51. pycforge/converter/frontend/source_document.py +93 -0
  52. pycforge/converter/frontend/stages.py +518 -0
  53. pycforge/converter/frontend/validation.py +76 -0
  54. pycforge/converter/io/__init__.py +0 -0
  55. pycforge/converter/io/atomic_writer.py +33 -0
  56. pycforge/converter/ir/__init__.py +1 -0
  57. pycforge/converter/ir/c_ir/__init__.py +4 -0
  58. pycforge/converter/ir/c_ir/builder.py +26 -0
  59. pycforge/converter/ir/c_ir/model.py +318 -0
  60. pycforge/converter/ir/c_ir/serialization.py +88 -0
  61. pycforge/converter/ir/c_ir/validation.py +834 -0
  62. pycforge/converter/ir/python_ir/__init__.py +17 -0
  63. pycforge/converter/ir/python_ir/nodes.py +86 -0
  64. pycforge/converter/keyword_calls/__init__.py +68 -0
  65. pycforge/converter/keyword_calls/analysis.py +458 -0
  66. pycforge/converter/keyword_calls/lowering.py +271 -0
  67. pycforge/converter/keyword_calls/model.py +304 -0
  68. pycforge/converter/keyword_calls/validation.py +802 -0
  69. pycforge/converter/keyword_only_calls/__init__.py +68 -0
  70. pycforge/converter/keyword_only_calls/analysis.py +640 -0
  71. pycforge/converter/keyword_only_calls/lowering.py +376 -0
  72. pycforge/converter/keyword_only_calls/model.py +328 -0
  73. pycforge/converter/keyword_only_calls/validation.py +1357 -0
  74. pycforge/converter/lowering.py +1000 -0
  75. pycforge/converter/modules/__init__.py +13 -0
  76. pycforge/converter/modules/analysis.py +1250 -0
  77. pycforge/converter/modules/lowering.py +200 -0
  78. pycforge/converter/modules/model.py +73 -0
  79. pycforge/converter/modules/stage.py +192 -0
  80. pycforge/converter/numeric_semantics/__init__.py +23 -0
  81. pycforge/converter/numeric_semantics/analysis.py +420 -0
  82. pycforge/converter/numeric_semantics/lowering.py +153 -0
  83. pycforge/converter/numeric_semantics/model.py +123 -0
  84. pycforge/converter/records/__init__.py +34 -0
  85. pycforge/converter/records/analysis.py +1394 -0
  86. pycforge/converter/records/lowering.py +393 -0
  87. pycforge/converter/records/model.py +319 -0
  88. pycforge/converter/rules/__init__.py +3 -0
  89. pycforge/converter/support_templates/__init__.py +49 -0
  90. pycforge/converter/support_templates/assembly.py +106 -0
  91. pycforge/converter/support_templates/factories.py +348 -0
  92. pycforge/converter/support_templates/model.py +159 -0
  93. pycforge/converter/support_templates/registry.py +388 -0
  94. pycforge/converter/telemetry/__init__.py +0 -0
  95. pycforge/converter/telemetry/sink.py +39 -0
  96. pycforge/ide/__init__.py +19 -0
  97. pycforge/ide/__main__.py +5 -0
  98. pycforge/ide/_worker_event_codec.py +276 -0
  99. pycforge/ide/_worker_protocol_json.py +197 -0
  100. pycforge/ide/_worker_protocol_types.py +90 -0
  101. pycforge/ide/_worker_request_codec.py +200 -0
  102. pycforge/ide/_worker_request_data.py +260 -0
  103. pycforge/ide/_worker_result_data.py +343 -0
  104. pycforge/ide/action_contract.py +469 -0
  105. pycforge/ide/action_validation.py +229 -0
  106. pycforge/ide/command_palette.py +223 -0
  107. pycforge/ide/controller.py +904 -0
  108. pycforge/ide/controller_conversion.py +546 -0
  109. pycforge/ide/controller_io.py +205 -0
  110. pycforge/ide/editor.py +951 -0
  111. pycforge/ide/editor_commands_qt.py +274 -0
  112. pycforge/ide/editor_lexical.py +43 -0
  113. pycforge/ide/editor_sidebars.py +236 -0
  114. pycforge/ide/editor_syntax.py +243 -0
  115. pycforge/ide/find_replace.py +535 -0
  116. pycforge/ide/icons.py +91 -0
  117. pycforge/ide/io_service.py +196 -0
  118. pycforge/ide/model.py +164 -0
  119. pycforge/ide/panels.py +903 -0
  120. pycforge/ide/positions.py +220 -0
  121. pycforge/ide/process_worker.py +130 -0
  122. pycforge/ide/qt.py +230 -0
  123. pycforge/ide/qt_actions.py +418 -0
  124. pycforge/ide/qt_close.py +169 -0
  125. pycforge/ide/qt_command_palette.py +297 -0
  126. pycforge/ide/qt_contract.py +220 -0
  127. pycforge/ide/qt_documents.py +590 -0
  128. pycforge/ide/qt_editor_buffers.py +113 -0
  129. pycforge/ide/qt_editor_surfaces.py +112 -0
  130. pycforge/ide/qt_menus.py +324 -0
  131. pycforge/ide/qt_projection.py +500 -0
  132. pycforge/ide/qt_shell.py +348 -0
  133. pycforge/ide/qt_shell_interactions.py +320 -0
  134. pycforge/ide/qt_state.py +393 -0
  135. pycforge/ide/qt_workspace_features.py +398 -0
  136. pycforge/ide/qt_workspace_navigation.py +208 -0
  137. pycforge/ide/qt_workspace_observers.py +173 -0
  138. pycforge/ide/qt_workspace_panels.py +545 -0
  139. pycforge/ide/qt_workspace_widgets.py +228 -0
  140. pycforge/ide/resources/icons/about.svg +1 -0
  141. pycforge/ide/resources/icons/add-document.svg +1 -0
  142. pycforge/ide/resources/icons/brand-mark.svg +1 -0
  143. pycforge/ide/resources/icons/bundle-search.svg +1 -0
  144. pycforge/ide/resources/icons/cancel.svg +1 -0
  145. pycforge/ide/resources/icons/check.svg +1 -0
  146. pycforge/ide/resources/icons/chevron-right.svg +1 -0
  147. pycforge/ide/resources/icons/close.svg +1 -0
  148. pycforge/ide/resources/icons/collapse-all.svg +1 -0
  149. pycforge/ide/resources/icons/command-palette.svg +1 -0
  150. pycforge/ide/resources/icons/convert.svg +1 -0
  151. pycforge/ide/resources/icons/copy.svg +1 -0
  152. pycforge/ide/resources/icons/cut.svg +1 -0
  153. pycforge/ide/resources/icons/decision-trace.svg +1 -0
  154. pycforge/ide/resources/icons/details.svg +1 -0
  155. pycforge/ide/resources/icons/diagnostics.svg +1 -0
  156. pycforge/ide/resources/icons/duplicate-line.svg +1 -0
  157. pycforge/ide/resources/icons/exit.svg +1 -0
  158. pycforge/ide/resources/icons/expand-all.svg +1 -0
  159. pycforge/ide/resources/icons/export.svg +1 -0
  160. pycforge/ide/resources/icons/find.svg +1 -0
  161. pycforge/ide/resources/icons/go-to-line.svg +1 -0
  162. pycforge/ide/resources/icons/go-to-output.svg +1 -0
  163. pycforge/ide/resources/icons/go-to-source.svg +1 -0
  164. pycforge/ide/resources/icons/history.svg +1 -0
  165. pycforge/ide/resources/icons/indent.svg +1 -0
  166. pycforge/ide/resources/icons/link-c.svg +1 -0
  167. pycforge/ide/resources/icons/mappings.svg +1 -0
  168. pycforge/ide/resources/icons/module.svg +1 -0
  169. pycforge/ide/resources/icons/move-down.svg +1 -0
  170. pycforge/ide/resources/icons/move-line-down.svg +1 -0
  171. pycforge/ide/resources/icons/move-line-up.svg +1 -0
  172. pycforge/ide/resources/icons/move-up.svg +1 -0
  173. pycforge/ide/resources/icons/next.svg +1 -0
  174. pycforge/ide/resources/icons/open.svg +1 -0
  175. pycforge/ide/resources/icons/outdent.svg +1 -0
  176. pycforge/ide/resources/icons/outline.svg +1 -0
  177. pycforge/ide/resources/icons/paste.svg +1 -0
  178. pycforge/ide/resources/icons/previous.svg +1 -0
  179. pycforge/ide/resources/icons/primary-module.svg +1 -0
  180. pycforge/ide/resources/icons/redo.svg +1 -0
  181. pycforge/ide/resources/icons/remove-document.svg +1 -0
  182. pycforge/ide/resources/icons/replace.svg +1 -0
  183. pycforge/ide/resources/icons/save-as.svg +1 -0
  184. pycforge/ide/resources/icons/save.svg +1 -0
  185. pycforge/ide/resources/icons/select-all.svg +1 -0
  186. pycforge/ide/resources/icons/settings.svg +1 -0
  187. pycforge/ide/resources/icons/show-c.svg +1 -0
  188. pycforge/ide/resources/icons/split-view.svg +1 -0
  189. pycforge/ide/resources/icons/summary.svg +1 -0
  190. pycforge/ide/resources/icons/telemetry.svg +1 -0
  191. pycforge/ide/resources/icons/toggle-comment.svg +1 -0
  192. pycforge/ide/resources/icons/toggle-fold.svg +1 -0
  193. pycforge/ide/resources/icons/undo.svg +1 -0
  194. pycforge/ide/resources/icons/whitespace.svg +1 -0
  195. pycforge/ide/revisions.py +400 -0
  196. pycforge/ide/search_service.py +280 -0
  197. pycforge/ide/session_history.py +150 -0
  198. pycforge/ide/source_editing.py +417 -0
  199. pycforge/ide/source_structure.py +558 -0
  200. pycforge/ide/source_structure_async.py +191 -0
  201. pycforge/ide/supervisor.py +503 -0
  202. pycforge/ide/theme.py +119 -0
  203. pycforge/ide/theme_stylesheet.py +598 -0
  204. pycforge/ide/theme_workspace_stylesheet.py +155 -0
  205. pycforge/ide/visual_tokens.py +142 -0
  206. pycforge/ide/worker_protocol.py +80 -0
  207. pycforge/ide/workspace_search.py +462 -0
  208. pycforge/ide/workspace_session.py +256 -0
  209. pycforge/laboratory/__init__.py +6 -0
  210. pycforge/laboratory/audits.py +2244 -0
  211. pycforge/laboratory/checkpoint_e.py +2685 -0
  212. pycforge/laboratory/cli.py +119 -0
  213. pycforge/laboratory/exit_codes.py +11 -0
  214. pycforge/laboratory/keyword_audit.py +661 -0
  215. pycforge/laboratory/keyword_only_audit.py +481 -0
  216. pycforge-0.15.2.dist-info/METADATA +209 -0
  217. pycforge-0.15.2.dist-info/RECORD +221 -0
  218. pycforge-0.15.2.dist-info/WHEEL +5 -0
  219. pycforge-0.15.2.dist-info/entry_points.txt +5 -0
  220. pycforge-0.15.2.dist-info/licenses/LICENSE +674 -0
  221. pycforge-0.15.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,590 @@
1
+ """Document, conversion, search, and navigation actions for the Qt workspace."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from concurrent.futures import Future
6
+ from pathlib import Path
7
+ from typing import Any, Callable
8
+
9
+ from PyQt5.QtCore import QTimer
10
+ from PyQt5.QtWidgets import QFileDialog
11
+
12
+ from .qt_contract import (
13
+ diagnostic_character_range,
14
+ mapping_character_range,
15
+ qt_range,
16
+ )
17
+
18
+
19
+ class QtDocumentActionsMixin:
20
+ """Cohesive document and conversion interaction surface for MainWindow."""
21
+ def _source_changed(self) -> None:
22
+ """Coalesce actual source edits without per-keystroke copies."""
23
+ document_id = self._displayed_document_id
24
+ if document_id is None or self._applying_source_text:
25
+ return
26
+ # Syntax highlighting emits textChanged without modifying characters.
27
+ if not self.source_buffers.is_modified(document_id):
28
+ return
29
+ self._source_sync_document_id = document_id
30
+ self._source_sync_pending = True
31
+ self._source_sync_timer.start()
32
+ self._invalidate_source_observers()
33
+ if self.controller.snapshot.can_cancel:
34
+ self.controller.cancel()
35
+ self._present_pending_source_sync()
36
+
37
+ def _present_pending_source_sync(self) -> None:
38
+ """Expose unsynchronized edits as immediately stale and unsavable."""
39
+ if not self._source_sync_pending:
40
+ return
41
+ self.action_registry.refresh()
42
+ self.state_chip.setText("EDIT PENDING")
43
+ self._set_dynamic_property(self.state_chip, "status", "warning")
44
+ if self.controller.snapshot.generated_c is not None:
45
+ self.output_state_label.setText("STALE · EDIT PENDING")
46
+ self._set_dynamic_property(self.output_state_label, "status", "warning")
47
+
48
+ def _present_pending_identity_edit(self, pending: bool) -> None:
49
+ """Project navigator identity edits before focus commits them."""
50
+
51
+ if pending and self.controller.snapshot.can_cancel:
52
+ self.controller.cancel()
53
+ self.action_registry.refresh()
54
+ if not pending:
55
+ if self._source_sync_pending:
56
+ self._present_pending_source_sync()
57
+ else:
58
+ snapshot = self.controller.snapshot
59
+ self._set_state_chip(snapshot.state.value)
60
+ self._set_output_state(snapshot)
61
+ return
62
+ if self._source_sync_pending:
63
+ self._present_pending_source_sync()
64
+ return
65
+ self.state_chip.setText("IDENTITY EDIT PENDING")
66
+ self._set_dynamic_property(self.state_chip, "status", "warning")
67
+ if self.controller.snapshot.generated_c is not None:
68
+ self.output_state_label.setText(
69
+ "STALE · IDENTITY EDIT PENDING"
70
+ )
71
+ self._set_dynamic_property(
72
+ self.output_state_label, "status", "warning"
73
+ )
74
+
75
+ def _flush_pending_source_sync(self) -> bool:
76
+ """Publish the latest editor text once before a semantic operation."""
77
+
78
+ if not self._source_sync_pending:
79
+ return True
80
+ document_id = self._source_sync_document_id
81
+ if document_id is None:
82
+ self._source_sync_pending = False
83
+ self._source_sync_timer.stop()
84
+ return True
85
+ text = self.source.toPlainText()
86
+ self._source_sync_pending = False
87
+ self._source_sync_timer.stop()
88
+ # update_document stores this exact immutable string. Pre-keying the
89
+ # projection prevents its synchronous snapshot from reloading the
90
+ # editor and moving the user's cursor after each coalesced edit.
91
+ self._displayed_source_key = (document_id, id(text))
92
+ try:
93
+ updated = self.controller.update_document(document_id, text)
94
+ except Exception as exc:
95
+ self._source_sync_pending = True
96
+ self._source_sync_timer.start()
97
+ self._show_error("Source edit could not be recorded", exc)
98
+ return False
99
+ # The edit may have returned to the controller's exact prior text, in
100
+ # which case update_document publishes no snapshot. Normalize the key
101
+ # to the retained object and refresh local chrome without reloading the
102
+ # editor or moving its cursor.
103
+ self._displayed_source_key = (
104
+ document_id,
105
+ id(updated.text),
106
+ )
107
+ self.source_buffers.mark_synchronized(
108
+ document_id, id(updated.text), dirty=updated.dirty
109
+ )
110
+ self._apply_snapshot(self.controller.snapshot)
111
+ return True
112
+
113
+ def _dispatch_io(
114
+ self, future: Future, callback: Callable[[Future], None]
115
+ ) -> None:
116
+ future.add_done_callback(
117
+ lambda completed: self.io_finished.emit(callback, completed)
118
+ )
119
+
120
+ def open_file(self) -> None:
121
+ if not self._commit_pending_identity():
122
+ return
123
+ paths, _ = QFileDialog.getOpenFileNames(
124
+ self,
125
+ "Open Python Source Bundle",
126
+ self._last_directory,
127
+ "Python (*.py);;All files (*)",
128
+ )
129
+ for value in paths:
130
+ self._open_path(Path(value))
131
+
132
+ def _open_path(self, path: Path) -> None:
133
+ if not self._commit_pending_identity():
134
+ return
135
+ try:
136
+ snapshot = self.controller.snapshot
137
+ existing = next(
138
+ (
139
+ item for item in snapshot.documents
140
+ if item.path is not None and Path(item.path) == path
141
+ ),
142
+ None,
143
+ )
144
+ if existing is not None:
145
+ if existing.dirty and not self._confirm_reload(
146
+ existing.logical_name
147
+ ):
148
+ return
149
+ future = self.controller.open_document_async(
150
+ path,
151
+ document_id=existing.document_id,
152
+ make_active=True,
153
+ )
154
+ message = (
155
+ f"Reloaded {path.name} from its linked Python file."
156
+ )
157
+ else:
158
+ active = snapshot.active_document
159
+ replace_blank = bool(
160
+ len(snapshot.documents) == 1
161
+ and not active.text
162
+ and active.path is None
163
+ and not active.dirty
164
+ )
165
+ module_id = self._unique_module_id(
166
+ path.stem,
167
+ active.document_id if replace_blank else None,
168
+ )
169
+ logical_name = self._unique_logical_name(
170
+ path.name,
171
+ active.document_id if replace_blank else None,
172
+ )
173
+ future = self.controller.open_document_async(
174
+ path,
175
+ module_id=module_id,
176
+ logical_name=logical_name,
177
+ document_id=active.document_id if replace_blank else None,
178
+ make_active=True,
179
+ )
180
+ message = f"Opened {path.name} in the source bundle."
181
+ except Exception as exc:
182
+ self._show_error("Could not open Python source", exc)
183
+ return
184
+
185
+ def opened(completed: Future) -> None:
186
+ try:
187
+ completed.result()
188
+ except Exception as exc:
189
+ self._show_error("Could not open Python source", exc)
190
+ return
191
+ self._remember_path(path)
192
+ self.toast.show_message(message, "info")
193
+
194
+ self._dispatch_io(future, opened)
195
+
196
+ def add_document(self) -> None:
197
+ if not self._commit_pending_identity():
198
+ return
199
+ snapshot = self.controller.snapshot
200
+ if not snapshot.can_add_document:
201
+ self.toast.show_message(
202
+ "The 64-document workspace capacity is full.", "warning"
203
+ )
204
+ return
205
+ index = len(snapshot.documents) + 1
206
+ try:
207
+ module_id = self._unique_module_id(f"module_{index}")
208
+ logical_name = self._unique_logical_name(f"module_{index}.py")
209
+ self.controller.add_document(module_id, logical_name)
210
+ except Exception as exc:
211
+ self._show_error("Could not add a module", exc)
212
+
213
+ def remove_active_document(self) -> None:
214
+ self._remove_document(self.controller.snapshot.active_document_id)
215
+
216
+ def _remove_document(self, document_id: str) -> None:
217
+ if not self._commit_pending_identity():
218
+ return
219
+ try:
220
+ document = next(
221
+ item for item in self.controller.snapshot.documents
222
+ if item.document_id == document_id
223
+ )
224
+ except StopIteration:
225
+ self.toast.show_message(
226
+ "The selected module no longer exists.", "warning"
227
+ )
228
+ return
229
+ if document.dirty and not self._confirm_discard(document.logical_name):
230
+ return
231
+ try:
232
+ self.controller.remove_document(document_id)
233
+ except Exception as exc:
234
+ self._show_error("Could not remove the module", exc)
235
+
236
+ def _select_document(self, document_id: str) -> None:
237
+ if not self._commit_pending_identity():
238
+ return
239
+ try:
240
+ self.controller.select_document(document_id)
241
+ except Exception as exc:
242
+ self._show_error("Could not select the module", exc)
243
+
244
+ def _change_document_identity(
245
+ self, document_id: str, module_id: str, logical_name: str
246
+ ) -> None:
247
+ if not self._flush_pending_source_sync():
248
+ return
249
+ try:
250
+ self.controller.set_document_identity(
251
+ document_id,
252
+ module_id=module_id,
253
+ logical_name=logical_name,
254
+ )
255
+ except Exception as exc:
256
+ self._show_error("Document identity was not accepted", exc)
257
+ self._navigator_key = None
258
+ self._apply_snapshot(self.controller.snapshot)
259
+
260
+ def _set_primary_document(self, document_id: str) -> None:
261
+ if not self._commit_pending_identity():
262
+ return
263
+ try:
264
+ self.controller.set_primary_document(document_id)
265
+ except Exception as exc:
266
+ self._show_error("Could not set the primary module", exc)
267
+
268
+ def _move_document_up(self, document_id: str | None = None) -> None:
269
+ self._move_document(document_id, -1)
270
+
271
+ def _move_document_down(self, document_id: str | None = None) -> None:
272
+ self._move_document(document_id, 1)
273
+
274
+ def _move_document(self, document_id: str | None, delta: int) -> None:
275
+ """Move one document while preserving the controller's full order."""
276
+
277
+ if not self._commit_pending_identity():
278
+ return
279
+ identifier = document_id or self.navigator.current_document_id
280
+ if identifier is None:
281
+ return
282
+ identifiers = [
283
+ item.document_id for item in self.controller.snapshot.documents
284
+ ]
285
+ try:
286
+ index = identifiers.index(identifier)
287
+ except ValueError:
288
+ self.toast.show_message(
289
+ "The selected module no longer exists.", "warning"
290
+ )
291
+ return
292
+ destination = index + delta
293
+ if destination < 0 or destination >= len(identifiers):
294
+ return
295
+ identifiers[index], identifiers[destination] = (
296
+ identifiers[destination],
297
+ identifiers[index],
298
+ )
299
+ try:
300
+ self.controller.reorder_documents(identifiers)
301
+ except Exception as exc:
302
+ self._show_error("Could not reorder the source bundle", exc)
303
+
304
+ def save_source(self) -> bool:
305
+ if not self._commit_pending_identity():
306
+ return False
307
+ return (
308
+ self._save_document(
309
+ self.controller.snapshot.active_document_id
310
+ )
311
+ is not None
312
+ )
313
+
314
+ def save_source_as(self) -> bool:
315
+ if not self._commit_pending_identity():
316
+ return False
317
+ return (
318
+ self._save_document(
319
+ self.controller.snapshot.active_document_id,
320
+ force_dialog=True,
321
+ )
322
+ is not None
323
+ )
324
+
325
+ def _save_document(
326
+ self, document_id: str, *, force_dialog: bool = False
327
+ ) -> Future | None:
328
+ document = next(
329
+ item for item in self.controller.snapshot.documents
330
+ if item.document_id == document_id
331
+ )
332
+ destination = None if force_dialog else document.path
333
+ if destination is None:
334
+ suggested = (
335
+ str(Path(self._last_directory) / document.logical_name)
336
+ if self._last_directory
337
+ else document.logical_name
338
+ )
339
+ destination, _ = QFileDialog.getSaveFileName(
340
+ self,
341
+ "Save Python Document",
342
+ suggested,
343
+ "Python (*.py)",
344
+ )
345
+ if not destination:
346
+ return None
347
+ try:
348
+ future = self.controller.save_document_async(
349
+ document_id,
350
+ Path(destination),
351
+ )
352
+ except Exception as exc:
353
+ self._show_error("Could not save Python source", exc)
354
+ return None
355
+
356
+ def saved(completed: Future) -> None:
357
+ try:
358
+ completed.result()
359
+ except Exception as exc:
360
+ self._show_error("Could not save Python source", exc)
361
+ return
362
+ self._remember_path(Path(destination))
363
+ self.toast.show_message(
364
+ f"Saved Python: {destination}",
365
+ "success",
366
+ )
367
+
368
+ self._dispatch_io(future, saved)
369
+ return future
370
+
371
+ def link_c_file(self) -> None:
372
+ snapshot = self.controller.snapshot
373
+ suggested = snapshot.linked_c_path or (
374
+ str(Path(self._last_directory) / "generated.c")
375
+ if self._last_directory else "generated.c"
376
+ )
377
+ path, _ = QFileDialog.getSaveFileName(
378
+ self,
379
+ "Link Generated C Destination",
380
+ suggested,
381
+ "C source (*.c)",
382
+ )
383
+ if not path:
384
+ return
385
+ try:
386
+ self.controller.link_generated_c(Path(path))
387
+ except Exception as exc:
388
+ self._show_error("Could not link the C destination", exc)
389
+ return
390
+ self._last_directory = str(Path(path).parent)
391
+ self.toast.show_message(
392
+ f"Generated C is linked to {path}. "
393
+ "Use Save C after a fresh transpilation.",
394
+ "success",
395
+ )
396
+
397
+ def save_c(self) -> bool:
398
+ # Identity edits are semantic bundle edits even while they remain
399
+ # in the navigator's line edits. Commit and validate them before
400
+ # consulting freshness so a shortcut cannot publish obsolete C.
401
+ if not self._commit_pending_identity():
402
+ return False
403
+ snapshot = self.controller.snapshot
404
+ if not snapshot.can_save_c:
405
+ self.toast.show_message(
406
+ "Generated C is unavailable or stale. "
407
+ "Transpile the current bundle first.",
408
+ "warning",
409
+ )
410
+ return False
411
+ if snapshot.linked_c_path is None:
412
+ self.link_c_file()
413
+ snapshot = self.controller.snapshot
414
+ if snapshot.linked_c_path is None:
415
+ return False
416
+ try:
417
+ future = self.controller.save_generated_c_linked_async()
418
+ except Exception as exc:
419
+ self._show_error("Could not save generated C", exc)
420
+ return False
421
+
422
+ def saved(completed: Future) -> None:
423
+ try:
424
+ destination = completed.result()
425
+ except Exception as exc:
426
+ self._show_error("Could not save generated C", exc)
427
+ return
428
+ self._last_directory = str(Path(destination).parent)
429
+ self.toast.show_message(
430
+ f"Saved fresh generated C: {destination}",
431
+ "success",
432
+ )
433
+
434
+ self._dispatch_io(future, saved)
435
+ return True
436
+
437
+ def convert(self) -> None:
438
+ if not self._flush_pending_source_sync():
439
+ return
440
+ if not self._commit_pending_identity():
441
+ return
442
+ try:
443
+ future = self.controller.convert_async()
444
+ except Exception as exc:
445
+ self._show_error("Transpilation could not start", exc)
446
+ return
447
+ future.add_done_callback(self._conversion_finished)
448
+
449
+ def _conversion_finished(self, future: Future) -> None:
450
+ try:
451
+ future.result()
452
+ except Exception as exc:
453
+ if self._closing:
454
+ return
455
+ self.operation_error.emit(
456
+ f"The transpilation worker stopped unexpectedly: {exc}"
457
+ )
458
+
459
+ def _open_find(self, replace: bool) -> None:
460
+ target = self._resolved_action_target()
461
+ editor = (
462
+ target if target in (self.source, self.source_secondary)
463
+ else self.output if target is self.output and not replace
464
+ else self.source
465
+ )
466
+ self.find_bar.attach_editor(editor)
467
+ self.find_bar.open_find(replace and not editor.isReadOnly())
468
+
469
+ def _activate_diagnostic(self, diagnostic: dict[str, Any]) -> None:
470
+ logical_name = diagnostic.get("source_logical_name")
471
+ module_id = diagnostic.get("source_module_id")
472
+ document = next(
473
+ (
474
+ item for item in self.controller.snapshot.documents
475
+ if item.logical_name == logical_name
476
+ or item.module_id == module_id
477
+ ),
478
+ None,
479
+ )
480
+ if document is None:
481
+ self.toast.show_message(
482
+ "The diagnostic's logical source is not present in this bundle.",
483
+ "warning",
484
+ )
485
+ return
486
+ self.controller.select_document(document.document_id)
487
+ revision = self.controller.committed_revision
488
+ position_index = None
489
+ if revision is not None:
490
+ try:
491
+ position_index = revision.index_for(
492
+ document.document_id
493
+ ).position_index
494
+ except KeyError:
495
+ position_index = None
496
+ start, end = diagnostic_character_range(
497
+ diagnostic,
498
+ document.text,
499
+ position_index,
500
+ )
501
+ start, end = qt_range(
502
+ document.text,
503
+ start,
504
+ end,
505
+ position_index,
506
+ )
507
+ QTimer.singleShot(
508
+ 0, lambda: self.source.go_to_position(start, end)
509
+ )
510
+ self.statusBar().showMessage(
511
+ f"{diagnostic.get('code', 'Diagnostic')} — "
512
+ f"{diagnostic.get('message', '')}",
513
+ 9000,
514
+ )
515
+
516
+ def _activate_mapping(self, mapping: dict[str, Any]) -> None:
517
+ output_text = self.controller.snapshot.generated_c or ""
518
+ if not output_text:
519
+ return
520
+ self._set_output_visible(True)
521
+ position_index = self.controller.result_output_index
522
+ if position_index is None:
523
+ self.toast.show_message(
524
+ "Generated-C position index is still preparing.",
525
+ "info",
526
+ )
527
+ return
528
+ start, end = mapping_character_range(
529
+ mapping,
530
+ output_text,
531
+ position_index,
532
+ )
533
+ start, end = qt_range(
534
+ output_text,
535
+ start,
536
+ end,
537
+ position_index,
538
+ )
539
+ QTimer.singleShot(
540
+ 0, lambda: self.output.go_to_position(start, end)
541
+ )
542
+ detail = str(
543
+ mapping.get("rule_plan_id") or mapping.get("origin_kind") or ""
544
+ ).strip()
545
+ # Serialized/adapted mapping labels may already carry the UI
546
+ # prefix. Keep status announcements concise and non-duplicated.
547
+ detail = re.sub(
548
+ r"^Mappings?\s*[—-]\s*",
549
+ "",
550
+ detail,
551
+ flags=re.IGNORECASE,
552
+ )
553
+ module = str(mapping.get("module_id") or "synthetic")
554
+ suffix = f" · {detail}" if detail else ""
555
+ self.statusBar().showMessage(
556
+ f"Mapping — {module}{suffix}", 8000
557
+ )
558
+
559
+ def _editor_marker_activated(
560
+ self, kind: str, marker_id: str, position: int
561
+ ) -> None:
562
+ del position
563
+ snapshot = self.controller.snapshot
564
+ if kind in {"diagnostic", "warning", "info"}:
565
+ diagnostic = next(
566
+ (
567
+ item for item in snapshot.diagnostics
568
+ if item.get("diagnostic_id") == marker_id
569
+ ),
570
+ None,
571
+ )
572
+ if diagnostic is not None:
573
+ self.statusBar().showMessage(
574
+ f"{diagnostic.get('code', '')} — "
575
+ f"{diagnostic.get('message', '')}",
576
+ 9000,
577
+ )
578
+ elif kind == "mapping":
579
+ mapping = next(
580
+ (
581
+ item for item in snapshot.mappings
582
+ if item.get("c_node_id") == marker_id
583
+ ),
584
+ None,
585
+ )
586
+ if mapping is not None:
587
+ self._activate_mapping(mapping)
588
+
589
+
590
+ __all__ = ["QtDocumentActionsMixin"]
@@ -0,0 +1,113 @@
1
+ """Persistent per-document Qt buffers for the closed SourceBundle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, Iterable
7
+
8
+ from PyQt5.QtCore import QObject
9
+ from PyQt5.QtGui import QTextDocument
10
+ from PyQt5.QtWidgets import QPlainTextDocumentLayout
11
+
12
+
13
+ MAX_SOURCE_BUFFERS = 64
14
+
15
+
16
+ @dataclass(slots=True)
17
+ class _SourceBuffer:
18
+ document: QTextDocument
19
+ source_key: int
20
+
21
+
22
+ class SourceBufferStore(QObject):
23
+ """Own one undo-capable ``QTextDocument`` per explicit source document."""
24
+
25
+ def __init__(self, parent: QObject) -> None:
26
+ super().__init__(parent)
27
+ self._buffers: dict[str, _SourceBuffer] = {}
28
+
29
+ @property
30
+ def document_ids(self) -> tuple[str, ...]:
31
+ return tuple(self._buffers)
32
+
33
+ def document_for(self, document_id: str) -> QTextDocument:
34
+ try:
35
+ return self._buffers[document_id].document
36
+ except KeyError as exc:
37
+ raise KeyError(
38
+ f"source buffer is unavailable: {document_id}"
39
+ ) from exc
40
+
41
+ def is_modified(self, document_id: str) -> bool:
42
+ buffer = self._buffers.get(document_id)
43
+ return buffer is not None and buffer.document.isModified()
44
+
45
+ def reconcile(
46
+ self,
47
+ documents: Iterable[Any],
48
+ *,
49
+ skip_document_id: str | None = None,
50
+ ) -> None:
51
+ records = tuple(documents)
52
+ if not 1 <= len(records) <= MAX_SOURCE_BUFFERS:
53
+ raise ValueError("source buffers require between 1 and 64 documents")
54
+ identifiers = tuple(item.document_id for item in records)
55
+ if len(identifiers) != len(set(identifiers)):
56
+ raise ValueError("source buffer document IDs must be unique")
57
+ for document_id in tuple(self._buffers):
58
+ if document_id in identifiers:
59
+ continue
60
+ buffer = self._buffers.pop(document_id)
61
+ buffer.document.setParent(None)
62
+ buffer.document.deleteLater()
63
+ for item in records:
64
+ key = id(item.text)
65
+ buffer = self._buffers.get(item.document_id)
66
+ if buffer is None:
67
+ document = QTextDocument(self)
68
+ document.setDocumentLayout(
69
+ QPlainTextDocumentLayout(document)
70
+ )
71
+ document.setPlainText(item.text)
72
+ # Controller ``dirty`` is disk-save authority. Qt's modified
73
+ # bit is reserved for text not yet synchronized back into the
74
+ # immutable workspace snapshot.
75
+ document.setModified(False)
76
+ self._buffers[item.document_id] = _SourceBuffer(
77
+ document,
78
+ key,
79
+ )
80
+ continue
81
+ if (
82
+ item.document_id != skip_document_id
83
+ and buffer.source_key != key
84
+ ):
85
+ undo_enabled = buffer.document.isUndoRedoEnabled()
86
+ buffer.document.setUndoRedoEnabled(False)
87
+ buffer.document.setPlainText(item.text)
88
+ buffer.document.setUndoRedoEnabled(undo_enabled)
89
+ buffer.document.setModified(False)
90
+ buffer.source_key = key
91
+
92
+ def mark_synchronized(
93
+ self,
94
+ document_id: str,
95
+ source_key: int,
96
+ *,
97
+ dirty: bool,
98
+ ) -> None:
99
+ buffer = self._buffers.get(document_id)
100
+ if buffer is None:
101
+ return
102
+ del dirty
103
+ buffer.source_key = int(source_key)
104
+ buffer.document.setModified(False)
105
+
106
+ def close(self) -> None:
107
+ for buffer in self._buffers.values():
108
+ buffer.document.setParent(None)
109
+ buffer.document.deleteLater()
110
+ self._buffers.clear()
111
+
112
+
113
+ __all__ = ["MAX_SOURCE_BUFFERS", "SourceBufferStore"]