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,393 @@
1
+ """Bounded settings, file observation, dialogs, and shutdown for Qt."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from concurrent.futures import Future
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from PyQt5.QtCore import QByteArray, QTimer
11
+
12
+ from pycforge._version import __version__
13
+
14
+ from .action_contract import DynamicActionEntry, MAX_DYNAMIC_LABEL_CHARS
15
+ from .qt_contract import (
16
+ MAX_RECENT_PATHS,
17
+ MAX_SETTINGS_BLOB_BYTES,
18
+ MAX_SETTINGS_PATH_CHARS,
19
+ PRESENTATION_SETTING_KEYS,
20
+ SETTINGS_ORGANIZATION,
21
+ SETTINGS_SCHEMA_VERSION,
22
+ coerce_settings_schema_version,
23
+ )
24
+
25
+
26
+ class QtStateMixin:
27
+ """Own presentation persistence and guarded filesystem observation."""
28
+
29
+ def _restore_workspace_state(self) -> None:
30
+ restorations = (
31
+ ("window/geometry", self.restoreGeometry),
32
+ ("window/state", self.restoreState),
33
+ ("splitter/workspace", self.workspace_splitter.restoreState),
34
+ ("splitter/editors", self.editor_splitter.restoreState),
35
+ ("splitter/main", self.main_splitter.restoreState),
36
+ ("splitter/source", self.source_splitter.restoreState),
37
+ )
38
+ for key, restore in restorations:
39
+ value = self._setting_blob(key)
40
+ if value is None:
41
+ continue
42
+ try:
43
+ restore(value)
44
+ except Exception:
45
+ # Presentation settings are untrusted, optional input and
46
+ # can never prevent construction of the workspace.
47
+ continue
48
+ self._set_navigator_visible(
49
+ self._setting_bool("view/bundle", True)
50
+ )
51
+ self._set_output_visible(
52
+ self._setting_bool("view/generated_c", False)
53
+ )
54
+ self._set_details_visible(
55
+ self._setting_bool("view/details", False)
56
+ )
57
+ self._set_source_split_visible(
58
+ self._setting_bool("view/source_split", False)
59
+ )
60
+ self._set_whitespace_visible(
61
+ self._setting_bool("view/whitespace", False)
62
+ )
63
+
64
+ def _persist_workspace_state(self) -> None:
65
+ values = {
66
+ "settings/schema_version": SETTINGS_SCHEMA_VERSION,
67
+ "window/geometry": self.saveGeometry(),
68
+ "window/state": self.saveState(),
69
+ "splitter/workspace": self.workspace_splitter.saveState(),
70
+ "splitter/editors": self.editor_splitter.saveState(),
71
+ "splitter/main": self.main_splitter.saveState(),
72
+ "splitter/source": self.source_splitter.saveState(),
73
+ "view/bundle": self.navigator.isVisible(),
74
+ "view/generated_c": self.output_panel.isVisible(),
75
+ "view/details": self.tabs.isVisible(),
76
+ "view/source_split": self.source_secondary.isVisible(),
77
+ "view/whitespace": self.source.whitespace_visible,
78
+ "workspace/last_directory": self._last_directory,
79
+ "workspace/recent_paths": self._recent_paths,
80
+ }
81
+ for key, value in values.items():
82
+ self._set_setting(key, value)
83
+ try:
84
+ self.settings.sync()
85
+ except Exception:
86
+ pass
87
+
88
+ def _prepare_settings(self) -> None:
89
+ """Validate stored presentation state or clear an incompatible schema."""
90
+
91
+ raw_version = self._settings_value(
92
+ "settings/schema_version", None
93
+ )
94
+ if raw_version is None:
95
+ # v0.12.1 had the same bounded presentation keys but no schema
96
+ # marker. Accept it once through the typed readers below.
97
+ version = SETTINGS_SCHEMA_VERSION
98
+ else:
99
+ version = coerce_settings_schema_version(raw_version)
100
+ if version != SETTINGS_SCHEMA_VERSION:
101
+ for key in PRESENTATION_SETTING_KEYS:
102
+ try:
103
+ self.settings.remove(key)
104
+ except Exception:
105
+ pass
106
+ self._set_setting(
107
+ "settings/schema_version", SETTINGS_SCHEMA_VERSION
108
+ )
109
+
110
+ def _settings_value(self, key: str, default: Any) -> Any:
111
+ try:
112
+ return self.settings.value(key, default)
113
+ except Exception:
114
+ return default
115
+
116
+ def _set_setting(self, key: str, value: Any) -> None:
117
+ try:
118
+ self.settings.setValue(key, value)
119
+ except Exception:
120
+ pass
121
+
122
+ def _setting_blob(self, key: str) -> QByteArray | None:
123
+ value = self._settings_value(key, None)
124
+ if not isinstance(value, (QByteArray, bytes, bytearray)):
125
+ return None
126
+ if not 0 < len(value) <= MAX_SETTINGS_BLOB_BYTES:
127
+ return None
128
+ return QByteArray(value)
129
+
130
+ def _setting_bool(self, key: str, default: bool) -> bool:
131
+ value = self._settings_value(key, default)
132
+ if isinstance(value, bool):
133
+ return value
134
+ if isinstance(value, int) and value in (0, 1):
135
+ return bool(value)
136
+ if isinstance(value, str) and len(value) <= 16:
137
+ normalized = value.casefold()
138
+ if normalized in {"1", "true", "yes", "on"}:
139
+ return True
140
+ if normalized in {"0", "false", "no", "off"}:
141
+ return False
142
+ return default
143
+
144
+ def _setting_text(self, key: str, default: str) -> str:
145
+ value = self._settings_value(key, default)
146
+ if (
147
+ not isinstance(value, str)
148
+ or len(value) > MAX_SETTINGS_PATH_CHARS
149
+ or "\0" in value
150
+ ):
151
+ return default
152
+ return value
153
+
154
+ def _load_recent_paths(self) -> list[str]:
155
+ value = self._settings_value("workspace/recent_paths", [])
156
+ if isinstance(value, str):
157
+ candidates = (value,)
158
+ elif isinstance(value, (list, tuple)):
159
+ candidates = value
160
+ else:
161
+ return []
162
+ paths: list[str] = []
163
+ for item in candidates[: MAX_RECENT_PATHS * 10]:
164
+ if (
165
+ not isinstance(item, str)
166
+ or not item
167
+ or len(item) > MAX_SETTINGS_PATH_CHARS
168
+ or "\0" in item
169
+ or item in paths
170
+ ):
171
+ continue
172
+ paths.append(item)
173
+ if len(paths) == MAX_RECENT_PATHS:
174
+ break
175
+ return paths
176
+
177
+ def _remember_path(self, path: Path) -> None:
178
+ value = str(path)
179
+ self._recent_paths = [
180
+ value,
181
+ *(item for item in self._recent_paths if item != value),
182
+ ][:MAX_RECENT_PATHS]
183
+ self._last_directory = str(path.parent)
184
+ self._set_setting(
185
+ "workspace/recent_paths", self._recent_paths
186
+ )
187
+ self._set_setting(
188
+ "workspace/last_directory", self._last_directory
189
+ )
190
+ self._rebuild_recent_menu()
191
+
192
+ def _rebuild_recent_menu(self) -> None:
193
+ if not hasattr(self, "action_registry"):
194
+ return
195
+ entries = []
196
+ for path in self._recent_paths:
197
+ label = path
198
+ if len(label) > MAX_DYNAMIC_LABEL_CHARS:
199
+ label = "…" + label[-(MAX_DYNAMIC_LABEL_CHARS - 1):]
200
+ name = Path(path).name or path
201
+ entries.append(
202
+ DynamicActionEntry(
203
+ key=path,
204
+ label=label,
205
+ tooltip=path,
206
+ accessible_name=(
207
+ f"Open recent Python document {name}"
208
+ ),
209
+ payload=path,
210
+ )
211
+ )
212
+ self.action_registry.replace_dynamic(
213
+ "recent_python",
214
+ entries,
215
+ self._open_recent_path,
216
+ )
217
+
218
+ def _open_recent_path(self, value: str) -> None:
219
+ self._open_path(Path(value))
220
+
221
+ def _refresh_watchers(self, snapshot) -> None:
222
+ key = (
223
+ snapshot.revision_generation,
224
+ snapshot.revision_authenticated,
225
+ tuple(item.path for item in snapshot.documents),
226
+ )
227
+ if key == self._watcher_projection_key:
228
+ return
229
+ revision = self.controller.committed_revision
230
+ if revision is None:
231
+ return
232
+ self._watcher_projection_key = key
233
+ linked = {
234
+ item.path: revision.index_for(
235
+ item.document_id
236
+ ).utf8_sha256
237
+ for item in snapshot.documents
238
+ if item.path is not None
239
+ }
240
+ desired = set(linked)
241
+ self._known_file_fingerprints = {
242
+ path: fingerprint for path, fingerprint in linked.items()
243
+ }
244
+ current = set(self.file_watcher.files())
245
+ remove = sorted(current - desired)
246
+ add = sorted(desired - current)
247
+ if remove:
248
+ self.file_watcher.removePaths(remove)
249
+ if add:
250
+ self.file_watcher.addPaths(add)
251
+
252
+ def _external_file_changed(self, path: str) -> None:
253
+ # Atomic replacement can briefly remove the watched path. Assess
254
+ # decoded contents after the filesystem settles instead of using a
255
+ # timing-only self-save suppression window.
256
+ QTimer.singleShot(
257
+ 120,
258
+ lambda value=path: self._assess_external_file_change(value),
259
+ )
260
+
261
+ def _assess_external_file_change(self, path: str) -> None:
262
+ expected = self._known_file_fingerprints.get(path)
263
+ try:
264
+ future = self.controller.observe_linked_file_async(path)
265
+ except Exception as exc:
266
+ self._show_error(
267
+ "Could not inspect the linked Python file", exc
268
+ )
269
+ return
270
+
271
+ def assessed(completed: Future) -> None:
272
+ try:
273
+ observation = completed.result()
274
+ except Exception as exc:
275
+ self.toast.show_message(
276
+ f"{path} became unavailable or unreadable: {exc}",
277
+ "warning",
278
+ )
279
+ else:
280
+ observed = observation.utf8_sha256
281
+ if expected is not None and observed != expected:
282
+ self.toast.show_message(
283
+ f"{path} changed outside PyCForge. "
284
+ "Reopen it explicitly to review the disk version.",
285
+ "warning",
286
+ )
287
+ self._watcher_projection_key = None
288
+ self._refresh_watchers(self.controller.snapshot)
289
+
290
+ self._dispatch_io(future, assessed)
291
+
292
+ def _unique_module_id(
293
+ self,
294
+ seed: str,
295
+ replacing_document_id: str | None = None,
296
+ ) -> str:
297
+ base = re.sub(
298
+ r"[^a-z0-9_]+", "_", seed.casefold()
299
+ ).strip("_")
300
+ if not base or not base[0].isalpha():
301
+ base = "module_" + (base or "source")
302
+ base = base[:55]
303
+ used = {
304
+ item.module_id for item in self.controller.snapshot.documents
305
+ if item.document_id != replacing_document_id
306
+ }
307
+ candidate = base
308
+ index = 2
309
+ while candidate in used:
310
+ candidate = f"{base[:59]}_{index}"
311
+ index += 1
312
+ return candidate
313
+
314
+ def _unique_logical_name(
315
+ self,
316
+ seed: str,
317
+ replacing_document_id: str | None = None,
318
+ ) -> str:
319
+ safe = Path(seed).name or "source.py"
320
+ if not safe.endswith(".py"):
321
+ safe += ".py"
322
+ used = {
323
+ item.logical_name
324
+ for item in self.controller.snapshot.documents
325
+ if item.document_id != replacing_document_id
326
+ }
327
+ candidate = safe
328
+ index = 2
329
+ path = Path(safe)
330
+ while candidate in used:
331
+ candidate = f"{path.stem}_{index}{path.suffix}"
332
+ index += 1
333
+ return candidate
334
+
335
+ def _commit_pending_identity(self) -> bool:
336
+ if not self._flush_pending_source_sync():
337
+ return False
338
+ document_id = self.navigator.current_document_id
339
+ if document_id is None:
340
+ return True
341
+ module_id, logical_name = self.navigator.pending_identity()
342
+ try:
343
+ document = next(
344
+ item for item in self.controller.snapshot.documents
345
+ if item.document_id == document_id
346
+ )
347
+ except StopIteration:
348
+ self.toast.show_message(
349
+ "The selected module no longer exists.", "warning"
350
+ )
351
+ return False
352
+ if (
353
+ module_id == document.module_id
354
+ and logical_name == document.logical_name
355
+ ):
356
+ return True
357
+ try:
358
+ self.controller.set_document_identity(
359
+ document_id,
360
+ module_id=module_id,
361
+ logical_name=logical_name,
362
+ )
363
+ except Exception as exc:
364
+ self._show_error(
365
+ "Document identity was not accepted", exc
366
+ )
367
+ self._navigator_key = None
368
+ self._apply_snapshot(self.controller.snapshot)
369
+ return False
370
+ return True
371
+
372
+ def _show_error(self, heading: str, exc: Exception) -> None:
373
+ message = str(exc).strip() or exc.__class__.__name__
374
+ self.toast.show_message(
375
+ f"{heading}: {message}", "error"
376
+ )
377
+ self.statusBar().showMessage(heading, 9000)
378
+
379
+ @staticmethod
380
+ def _linked_c_display(path: str | None) -> str:
381
+ """Keep the destination filename visible; tooltip has full path."""
382
+
383
+ if not path:
384
+ return "not set"
385
+ return Path(path).name or path
386
+
387
+ def _update_window_title(self, active) -> None:
388
+ dirty = " •" if active.dirty else ""
389
+ self.setWindowTitle(
390
+ f"{active.logical_name}{dirty} — PyCForge {__version__}"
391
+ )
392
+
393
+ __all__ = ["QtStateMixin"]