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.
- pycforge/__init__.py +8 -0
- pycforge/__main__.py +2 -0
- pycforge/_version.py +1 -0
- pycforge/converter/__init__.py +0 -0
- pycforge/converter/analysis/__init__.py +5 -0
- pycforge/converter/analysis/functions.py +723 -0
- pycforge/converter/analysis/model.py +158 -0
- pycforge/converter/analysis/planning.py +464 -0
- pycforge/converter/analysis/stage.py +1976 -0
- pycforge/converter/analysis/symbols.py +237 -0
- pycforge/converter/analysis/validation.py +2166 -0
- pycforge/converter/c_output/__init__.py +2 -0
- pycforge/converter/c_output/conformance.py +297 -0
- pycforge/converter/c_output/renderer.py +220 -0
- pycforge/converter/conditional_regions/__init__.py +71 -0
- pycforge/converter/conditional_regions/analysis.py +481 -0
- pycforge/converter/conditional_regions/lowering.py +579 -0
- pycforge/converter/conditional_regions/model.py +243 -0
- pycforge/converter/conditional_regions/validation.py +690 -0
- pycforge/converter/containers/__init__.py +19 -0
- pycforge/converter/containers/analysis.py +542 -0
- pycforge/converter/containers/lowering.py +290 -0
- pycforge/converter/containers/model.py +146 -0
- pycforge/converter/contracts/__init__.py +0 -0
- pycforge/converter/contracts/configuration.py +178 -0
- pycforge/converter/contracts/identifiers.py +68 -0
- pycforge/converter/contracts/versions.py +71 -0
- pycforge/converter/core/__init__.py +0 -0
- pycforge/converter/core/artifact_io.py +87 -0
- pycforge/converter/core/cancellation.py +7 -0
- pycforge/converter/core/canonicalization.py +254 -0
- pycforge/converter/core/context.py +13 -0
- pycforge/converter/core/diagnostics.py +95 -0
- pycforge/converter/core/enums.py +23 -0
- pycforge/converter/core/fingerprint.py +22 -0
- pycforge/converter/core/pipeline.py +8 -0
- pycforge/converter/core/progress.py +25 -0
- pycforge/converter/core/request.py +50 -0
- pycforge/converter/core/resource_policy.py +31 -0
- pycforge/converter/core/result.py +23 -0
- pycforge/converter/core/serialization.py +36 -0
- pycforge/converter/core/stage.py +10 -0
- pycforge/converter/core/stage_artifact.py +80 -0
- pycforge/converter/core/stage_outcome.py +15 -0
- pycforge/converter/decision_trace/__init__.py +0 -0
- pycforge/converter/decision_trace/recorder.py +52 -0
- pycforge/converter/facade.py +459 -0
- pycforge/converter/frontend/__init__.py +4 -0
- pycforge/converter/frontend/normalizer.py +59 -0
- pycforge/converter/frontend/parser.py +20 -0
- pycforge/converter/frontend/source_document.py +93 -0
- pycforge/converter/frontend/stages.py +518 -0
- pycforge/converter/frontend/validation.py +76 -0
- pycforge/converter/io/__init__.py +0 -0
- pycforge/converter/io/atomic_writer.py +33 -0
- pycforge/converter/ir/__init__.py +1 -0
- pycforge/converter/ir/c_ir/__init__.py +4 -0
- pycforge/converter/ir/c_ir/builder.py +26 -0
- pycforge/converter/ir/c_ir/model.py +318 -0
- pycforge/converter/ir/c_ir/serialization.py +88 -0
- pycforge/converter/ir/c_ir/validation.py +834 -0
- pycforge/converter/ir/python_ir/__init__.py +17 -0
- pycforge/converter/ir/python_ir/nodes.py +86 -0
- pycforge/converter/keyword_calls/__init__.py +68 -0
- pycforge/converter/keyword_calls/analysis.py +458 -0
- pycforge/converter/keyword_calls/lowering.py +271 -0
- pycforge/converter/keyword_calls/model.py +304 -0
- pycforge/converter/keyword_calls/validation.py +802 -0
- pycforge/converter/keyword_only_calls/__init__.py +68 -0
- pycforge/converter/keyword_only_calls/analysis.py +640 -0
- pycforge/converter/keyword_only_calls/lowering.py +376 -0
- pycforge/converter/keyword_only_calls/model.py +328 -0
- pycforge/converter/keyword_only_calls/validation.py +1357 -0
- pycforge/converter/lowering.py +1000 -0
- pycforge/converter/modules/__init__.py +13 -0
- pycforge/converter/modules/analysis.py +1250 -0
- pycforge/converter/modules/lowering.py +200 -0
- pycforge/converter/modules/model.py +73 -0
- pycforge/converter/modules/stage.py +192 -0
- pycforge/converter/numeric_semantics/__init__.py +23 -0
- pycforge/converter/numeric_semantics/analysis.py +420 -0
- pycforge/converter/numeric_semantics/lowering.py +153 -0
- pycforge/converter/numeric_semantics/model.py +123 -0
- pycforge/converter/records/__init__.py +34 -0
- pycforge/converter/records/analysis.py +1394 -0
- pycforge/converter/records/lowering.py +393 -0
- pycforge/converter/records/model.py +319 -0
- pycforge/converter/rules/__init__.py +3 -0
- pycforge/converter/support_templates/__init__.py +49 -0
- pycforge/converter/support_templates/assembly.py +106 -0
- pycforge/converter/support_templates/factories.py +348 -0
- pycforge/converter/support_templates/model.py +159 -0
- pycforge/converter/support_templates/registry.py +388 -0
- pycforge/converter/telemetry/__init__.py +0 -0
- pycforge/converter/telemetry/sink.py +39 -0
- pycforge/ide/__init__.py +19 -0
- pycforge/ide/__main__.py +5 -0
- pycforge/ide/_worker_event_codec.py +276 -0
- pycforge/ide/_worker_protocol_json.py +197 -0
- pycforge/ide/_worker_protocol_types.py +90 -0
- pycforge/ide/_worker_request_codec.py +200 -0
- pycforge/ide/_worker_request_data.py +260 -0
- pycforge/ide/_worker_result_data.py +343 -0
- pycforge/ide/action_contract.py +469 -0
- pycforge/ide/action_validation.py +229 -0
- pycforge/ide/command_palette.py +223 -0
- pycforge/ide/controller.py +904 -0
- pycforge/ide/controller_conversion.py +546 -0
- pycforge/ide/controller_io.py +205 -0
- pycforge/ide/editor.py +951 -0
- pycforge/ide/editor_commands_qt.py +274 -0
- pycforge/ide/editor_lexical.py +43 -0
- pycforge/ide/editor_sidebars.py +236 -0
- pycforge/ide/editor_syntax.py +243 -0
- pycforge/ide/find_replace.py +535 -0
- pycforge/ide/icons.py +91 -0
- pycforge/ide/io_service.py +196 -0
- pycforge/ide/model.py +164 -0
- pycforge/ide/panels.py +903 -0
- pycforge/ide/positions.py +220 -0
- pycforge/ide/process_worker.py +130 -0
- pycforge/ide/qt.py +230 -0
- pycforge/ide/qt_actions.py +418 -0
- pycforge/ide/qt_close.py +169 -0
- pycforge/ide/qt_command_palette.py +297 -0
- pycforge/ide/qt_contract.py +220 -0
- pycforge/ide/qt_documents.py +590 -0
- pycforge/ide/qt_editor_buffers.py +113 -0
- pycforge/ide/qt_editor_surfaces.py +112 -0
- pycforge/ide/qt_menus.py +324 -0
- pycforge/ide/qt_projection.py +500 -0
- pycforge/ide/qt_shell.py +348 -0
- pycforge/ide/qt_shell_interactions.py +320 -0
- pycforge/ide/qt_state.py +393 -0
- pycforge/ide/qt_workspace_features.py +398 -0
- pycforge/ide/qt_workspace_navigation.py +208 -0
- pycforge/ide/qt_workspace_observers.py +173 -0
- pycforge/ide/qt_workspace_panels.py +545 -0
- pycforge/ide/qt_workspace_widgets.py +228 -0
- pycforge/ide/resources/icons/about.svg +1 -0
- pycforge/ide/resources/icons/add-document.svg +1 -0
- pycforge/ide/resources/icons/brand-mark.svg +1 -0
- pycforge/ide/resources/icons/bundle-search.svg +1 -0
- pycforge/ide/resources/icons/cancel.svg +1 -0
- pycforge/ide/resources/icons/check.svg +1 -0
- pycforge/ide/resources/icons/chevron-right.svg +1 -0
- pycforge/ide/resources/icons/close.svg +1 -0
- pycforge/ide/resources/icons/collapse-all.svg +1 -0
- pycforge/ide/resources/icons/command-palette.svg +1 -0
- pycforge/ide/resources/icons/convert.svg +1 -0
- pycforge/ide/resources/icons/copy.svg +1 -0
- pycforge/ide/resources/icons/cut.svg +1 -0
- pycforge/ide/resources/icons/decision-trace.svg +1 -0
- pycforge/ide/resources/icons/details.svg +1 -0
- pycforge/ide/resources/icons/diagnostics.svg +1 -0
- pycforge/ide/resources/icons/duplicate-line.svg +1 -0
- pycforge/ide/resources/icons/exit.svg +1 -0
- pycforge/ide/resources/icons/expand-all.svg +1 -0
- pycforge/ide/resources/icons/export.svg +1 -0
- pycforge/ide/resources/icons/find.svg +1 -0
- pycforge/ide/resources/icons/go-to-line.svg +1 -0
- pycforge/ide/resources/icons/go-to-output.svg +1 -0
- pycforge/ide/resources/icons/go-to-source.svg +1 -0
- pycforge/ide/resources/icons/history.svg +1 -0
- pycforge/ide/resources/icons/indent.svg +1 -0
- pycforge/ide/resources/icons/link-c.svg +1 -0
- pycforge/ide/resources/icons/mappings.svg +1 -0
- pycforge/ide/resources/icons/module.svg +1 -0
- pycforge/ide/resources/icons/move-down.svg +1 -0
- pycforge/ide/resources/icons/move-line-down.svg +1 -0
- pycforge/ide/resources/icons/move-line-up.svg +1 -0
- pycforge/ide/resources/icons/move-up.svg +1 -0
- pycforge/ide/resources/icons/next.svg +1 -0
- pycforge/ide/resources/icons/open.svg +1 -0
- pycforge/ide/resources/icons/outdent.svg +1 -0
- pycforge/ide/resources/icons/outline.svg +1 -0
- pycforge/ide/resources/icons/paste.svg +1 -0
- pycforge/ide/resources/icons/previous.svg +1 -0
- pycforge/ide/resources/icons/primary-module.svg +1 -0
- pycforge/ide/resources/icons/redo.svg +1 -0
- pycforge/ide/resources/icons/remove-document.svg +1 -0
- pycforge/ide/resources/icons/replace.svg +1 -0
- pycforge/ide/resources/icons/save-as.svg +1 -0
- pycforge/ide/resources/icons/save.svg +1 -0
- pycforge/ide/resources/icons/select-all.svg +1 -0
- pycforge/ide/resources/icons/settings.svg +1 -0
- pycforge/ide/resources/icons/show-c.svg +1 -0
- pycforge/ide/resources/icons/split-view.svg +1 -0
- pycforge/ide/resources/icons/summary.svg +1 -0
- pycforge/ide/resources/icons/telemetry.svg +1 -0
- pycforge/ide/resources/icons/toggle-comment.svg +1 -0
- pycforge/ide/resources/icons/toggle-fold.svg +1 -0
- pycforge/ide/resources/icons/undo.svg +1 -0
- pycforge/ide/resources/icons/whitespace.svg +1 -0
- pycforge/ide/revisions.py +400 -0
- pycforge/ide/search_service.py +280 -0
- pycforge/ide/session_history.py +150 -0
- pycforge/ide/source_editing.py +417 -0
- pycforge/ide/source_structure.py +558 -0
- pycforge/ide/source_structure_async.py +191 -0
- pycforge/ide/supervisor.py +503 -0
- pycforge/ide/theme.py +119 -0
- pycforge/ide/theme_stylesheet.py +598 -0
- pycforge/ide/theme_workspace_stylesheet.py +155 -0
- pycforge/ide/visual_tokens.py +142 -0
- pycforge/ide/worker_protocol.py +80 -0
- pycforge/ide/workspace_search.py +462 -0
- pycforge/ide/workspace_session.py +256 -0
- pycforge/laboratory/__init__.py +6 -0
- pycforge/laboratory/audits.py +2244 -0
- pycforge/laboratory/checkpoint_e.py +2685 -0
- pycforge/laboratory/cli.py +119 -0
- pycforge/laboratory/exit_codes.py +11 -0
- pycforge/laboratory/keyword_audit.py +661 -0
- pycforge/laboratory/keyword_only_audit.py +481 -0
- pycforge-0.15.2.dist-info/METADATA +209 -0
- pycforge-0.15.2.dist-info/RECORD +221 -0
- pycforge-0.15.2.dist-info/WHEEL +5 -0
- pycforge-0.15.2.dist-info/entry_points.txt +5 -0
- pycforge-0.15.2.dist-info/licenses/LICENSE +674 -0
- 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"]
|