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,274 @@
|
|
|
1
|
+
"""Qt adapter for bounded, undoable Python source-editor commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
|
|
7
|
+
from PyQt5.QtGui import QTextCursor, QTextOption
|
|
8
|
+
|
|
9
|
+
from .source_editing import (
|
|
10
|
+
SourceEditResult,
|
|
11
|
+
duplicate_lines,
|
|
12
|
+
indent_lines,
|
|
13
|
+
move_lines_down,
|
|
14
|
+
move_lines_up,
|
|
15
|
+
outdent_lines,
|
|
16
|
+
toggle_python_line_comment,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
FOLD_BLOCK_LIMIT = 2_048
|
|
21
|
+
FOLD_TOTAL_BLOCK_LIMIT = 8_192
|
|
22
|
+
MAX_FOLDS = 128
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _python_offset(text: str, qt_offset: int) -> int:
|
|
26
|
+
"""Convert a clamped Qt UTF-16 position to a Python string offset."""
|
|
27
|
+
|
|
28
|
+
target = max(0, int(qt_offset))
|
|
29
|
+
if text.isascii():
|
|
30
|
+
return min(len(text), target)
|
|
31
|
+
units = 0
|
|
32
|
+
for index, character in enumerate(text):
|
|
33
|
+
width = 2 if ord(character) > 0xFFFF else 1
|
|
34
|
+
if units + width > target:
|
|
35
|
+
return index
|
|
36
|
+
units += width
|
|
37
|
+
if units == target:
|
|
38
|
+
return index + 1
|
|
39
|
+
return len(text)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _qt_offset(text: str, python_offset: int) -> int:
|
|
43
|
+
position = min(len(text), max(0, int(python_offset)))
|
|
44
|
+
if text.isascii():
|
|
45
|
+
return position
|
|
46
|
+
return len(text[:position].encode("utf-16-le")) // 2
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _changed_span(before: str, after: str) -> tuple[int, int, int]:
|
|
50
|
+
"""Return common prefix, old suffix start, and new suffix start."""
|
|
51
|
+
|
|
52
|
+
prefix = 0
|
|
53
|
+
limit = min(len(before), len(after))
|
|
54
|
+
while prefix < limit and before[prefix] == after[prefix]:
|
|
55
|
+
prefix += 1
|
|
56
|
+
old_suffix = len(before)
|
|
57
|
+
new_suffix = len(after)
|
|
58
|
+
while (
|
|
59
|
+
old_suffix > prefix
|
|
60
|
+
and new_suffix > prefix
|
|
61
|
+
and before[old_suffix - 1] == after[new_suffix - 1]
|
|
62
|
+
):
|
|
63
|
+
old_suffix -= 1
|
|
64
|
+
new_suffix -= 1
|
|
65
|
+
return prefix, old_suffix, new_suffix
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _indentation(text: str) -> int:
|
|
69
|
+
return len(text) - len(text.lstrip(" \t"))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class SourceCommandMixin:
|
|
73
|
+
"""Editing, navigation, whitespace, and folding for ``CodeEditor``."""
|
|
74
|
+
|
|
75
|
+
def _initialize_source_commands(
|
|
76
|
+
self,
|
|
77
|
+
*,
|
|
78
|
+
highlighting_enabled: bool,
|
|
79
|
+
) -> None:
|
|
80
|
+
self._highlighting_enabled = bool(highlighting_enabled)
|
|
81
|
+
self._whitespace_visible = False
|
|
82
|
+
self._folded_blocks: dict[int, tuple[object, ...]] = {}
|
|
83
|
+
if not self._highlighting_enabled:
|
|
84
|
+
self._highlighter.setDocument(None)
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def whitespace_visible(self) -> bool:
|
|
88
|
+
return self._whitespace_visible
|
|
89
|
+
|
|
90
|
+
def bind_text_document(self, document) -> None:
|
|
91
|
+
"""Bind a pre-existing Qt document while retaining editor behavior."""
|
|
92
|
+
|
|
93
|
+
if document is self.document():
|
|
94
|
+
return
|
|
95
|
+
self.unfold_all()
|
|
96
|
+
self._highlighter.setDocument(None)
|
|
97
|
+
# QSyntaxHighlighter is initially parented to the editor's original
|
|
98
|
+
# QTextDocument. Reparent it before QPlainTextEdit releases that
|
|
99
|
+
# document so shared-buffer rebinding cannot delete the highlighter.
|
|
100
|
+
self._highlighter.setParent(self)
|
|
101
|
+
super().setDocument(document)
|
|
102
|
+
self._update_large_file_mode()
|
|
103
|
+
if self._highlighting_enabled and not self.large_file_mode:
|
|
104
|
+
self._highlighter.setDocument(document)
|
|
105
|
+
self.clear_markers()
|
|
106
|
+
self._apply_whitespace_option()
|
|
107
|
+
self._update_viewport_margins()
|
|
108
|
+
self._refresh_extra_selections()
|
|
109
|
+
|
|
110
|
+
def duplicate_line_or_selection(self) -> bool:
|
|
111
|
+
return self._apply_source_operation(duplicate_lines)
|
|
112
|
+
|
|
113
|
+
def move_source_lines_up(self) -> bool:
|
|
114
|
+
return self._apply_source_operation(move_lines_up)
|
|
115
|
+
|
|
116
|
+
def move_source_lines_down(self) -> bool:
|
|
117
|
+
return self._apply_source_operation(move_lines_down)
|
|
118
|
+
|
|
119
|
+
def indent_source_lines(self) -> bool:
|
|
120
|
+
return self._apply_source_operation(indent_lines)
|
|
121
|
+
|
|
122
|
+
def outdent_source_lines(self) -> bool:
|
|
123
|
+
return self._apply_source_operation(outdent_lines)
|
|
124
|
+
|
|
125
|
+
def toggle_source_comment(self) -> bool:
|
|
126
|
+
return self._apply_source_operation(toggle_python_line_comment)
|
|
127
|
+
|
|
128
|
+
def _apply_source_operation(
|
|
129
|
+
self,
|
|
130
|
+
operation: Callable[[str, int, int], SourceEditResult],
|
|
131
|
+
) -> bool:
|
|
132
|
+
if self.isReadOnly() or self.language != "python":
|
|
133
|
+
return False
|
|
134
|
+
before = self.toPlainText()
|
|
135
|
+
active = self.textCursor()
|
|
136
|
+
start = _python_offset(before, active.selectionStart())
|
|
137
|
+
end = _python_offset(before, active.selectionEnd())
|
|
138
|
+
result = operation(before, start, end)
|
|
139
|
+
if result.text == before:
|
|
140
|
+
return False
|
|
141
|
+
prefix, old_suffix, new_suffix = _changed_span(
|
|
142
|
+
before,
|
|
143
|
+
result.text,
|
|
144
|
+
)
|
|
145
|
+
cursor = QTextCursor(self.document())
|
|
146
|
+
cursor.beginEditBlock()
|
|
147
|
+
cursor.setPosition(_qt_offset(before, prefix))
|
|
148
|
+
cursor.setPosition(
|
|
149
|
+
_qt_offset(before, old_suffix),
|
|
150
|
+
QTextCursor.KeepAnchor,
|
|
151
|
+
)
|
|
152
|
+
cursor.insertText(result.text[prefix:new_suffix])
|
|
153
|
+
cursor.endEditBlock()
|
|
154
|
+
selection = QTextCursor(self.document())
|
|
155
|
+
selection.setPosition(
|
|
156
|
+
_qt_offset(result.text, result.selection_start)
|
|
157
|
+
)
|
|
158
|
+
selection.setPosition(
|
|
159
|
+
_qt_offset(result.text, result.selection_end),
|
|
160
|
+
QTextCursor.KeepAnchor,
|
|
161
|
+
)
|
|
162
|
+
self.setTextCursor(selection)
|
|
163
|
+
self.ensureCursorVisible()
|
|
164
|
+
return True
|
|
165
|
+
|
|
166
|
+
def go_to_line_number(self, line: int) -> int:
|
|
167
|
+
"""Go to a clamped one-based line without copying source text."""
|
|
168
|
+
|
|
169
|
+
if isinstance(line, bool) or not isinstance(line, int):
|
|
170
|
+
raise TypeError("line must be an integer")
|
|
171
|
+
maximum = max(1, self.document().blockCount())
|
|
172
|
+
resolved = min(maximum, max(1, line))
|
|
173
|
+
block = self.document().findBlockByNumber(resolved - 1)
|
|
174
|
+
cursor = QTextCursor(block)
|
|
175
|
+
self.setTextCursor(cursor)
|
|
176
|
+
self.centerCursor()
|
|
177
|
+
self.setFocus()
|
|
178
|
+
return resolved
|
|
179
|
+
|
|
180
|
+
def set_whitespace_visible(self, visible: bool) -> None:
|
|
181
|
+
enabled = bool(visible)
|
|
182
|
+
if enabled == self._whitespace_visible:
|
|
183
|
+
return
|
|
184
|
+
self._whitespace_visible = enabled
|
|
185
|
+
self._apply_whitespace_option()
|
|
186
|
+
|
|
187
|
+
def _apply_whitespace_option(self) -> None:
|
|
188
|
+
option = self.document().defaultTextOption()
|
|
189
|
+
flags = option.flags()
|
|
190
|
+
markers = (
|
|
191
|
+
QTextOption.ShowTabsAndSpaces
|
|
192
|
+
| QTextOption.ShowLineAndParagraphSeparators
|
|
193
|
+
)
|
|
194
|
+
option.setFlags(flags | markers if self._whitespace_visible else flags & ~markers)
|
|
195
|
+
self.document().setDefaultTextOption(option)
|
|
196
|
+
self.viewport().update()
|
|
197
|
+
|
|
198
|
+
def toggle_fold_at_cursor(self) -> bool:
|
|
199
|
+
"""Fold or unfold one indentation region under absolute budgets."""
|
|
200
|
+
|
|
201
|
+
if self.language != "python":
|
|
202
|
+
return False
|
|
203
|
+
start = self.textCursor().block()
|
|
204
|
+
key = start.position()
|
|
205
|
+
retained = self._folded_blocks.pop(key, None)
|
|
206
|
+
if retained is not None:
|
|
207
|
+
self._set_blocks_visible(retained, True)
|
|
208
|
+
return True
|
|
209
|
+
if (
|
|
210
|
+
len(self._folded_blocks) >= MAX_FOLDS
|
|
211
|
+
or sum(len(value) for value in self._folded_blocks.values())
|
|
212
|
+
>= FOLD_TOTAL_BLOCK_LIMIT
|
|
213
|
+
):
|
|
214
|
+
return False
|
|
215
|
+
base = _indentation(start.text())
|
|
216
|
+
block = start.next()
|
|
217
|
+
candidates: list[object] = []
|
|
218
|
+
found_body = False
|
|
219
|
+
while block.isValid() and len(candidates) < FOLD_BLOCK_LIMIT:
|
|
220
|
+
text = block.text()
|
|
221
|
+
if text.strip():
|
|
222
|
+
indent = _indentation(text)
|
|
223
|
+
if indent <= base:
|
|
224
|
+
break
|
|
225
|
+
found_body = True
|
|
226
|
+
candidates.append(block)
|
|
227
|
+
block = block.next()
|
|
228
|
+
if not found_body:
|
|
229
|
+
return False
|
|
230
|
+
remaining = FOLD_TOTAL_BLOCK_LIMIT - sum(
|
|
231
|
+
len(value) for value in self._folded_blocks.values()
|
|
232
|
+
)
|
|
233
|
+
retained = tuple(candidates[:remaining])
|
|
234
|
+
if not retained:
|
|
235
|
+
return False
|
|
236
|
+
self._folded_blocks[key] = retained
|
|
237
|
+
self._set_blocks_visible(retained, False)
|
|
238
|
+
return True
|
|
239
|
+
|
|
240
|
+
def unfold_all(self) -> None:
|
|
241
|
+
for blocks in tuple(self._folded_blocks.values()):
|
|
242
|
+
self._set_blocks_visible(blocks, True)
|
|
243
|
+
self._folded_blocks.clear()
|
|
244
|
+
|
|
245
|
+
def _set_blocks_visible(
|
|
246
|
+
self,
|
|
247
|
+
blocks: tuple[object, ...],
|
|
248
|
+
visible: bool,
|
|
249
|
+
) -> None:
|
|
250
|
+
first = None
|
|
251
|
+
last = None
|
|
252
|
+
for block in blocks:
|
|
253
|
+
if not block.isValid():
|
|
254
|
+
continue
|
|
255
|
+
first = block.position() if first is None else first
|
|
256
|
+
last = block.position() + block.length()
|
|
257
|
+
block.setVisible(visible)
|
|
258
|
+
block.setLineCount(1 if visible else 0)
|
|
259
|
+
if first is not None and last is not None:
|
|
260
|
+
self.document().markContentsDirty(first, max(1, last - first))
|
|
261
|
+
self.viewport().update()
|
|
262
|
+
self._update_viewport_margins()
|
|
263
|
+
|
|
264
|
+
def _reset_source_command_state(self) -> None:
|
|
265
|
+
if self._folded_blocks:
|
|
266
|
+
self.unfold_all()
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
__all__ = [
|
|
270
|
+
"FOLD_BLOCK_LIMIT",
|
|
271
|
+
"FOLD_TOTAL_BLOCK_LIMIT",
|
|
272
|
+
"MAX_FOLDS",
|
|
273
|
+
"SourceCommandMixin",
|
|
274
|
+
]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Qt-free lexical protection used by the optional syntax highlighter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def lexical_protected_spans(
|
|
7
|
+
text: str, language: str
|
|
8
|
+
) -> tuple[tuple[tuple[int, int], ...], int | None]:
|
|
9
|
+
"""Lex strings and the first real line comment without overlap."""
|
|
10
|
+
|
|
11
|
+
if language not in {"python", "c"}:
|
|
12
|
+
raise ValueError("syntax language must be 'python' or 'c'")
|
|
13
|
+
strings: list[tuple[int, int]] = []
|
|
14
|
+
index = 0
|
|
15
|
+
while index < len(text):
|
|
16
|
+
if language == "python" and text[index] == "#":
|
|
17
|
+
return tuple(strings), index
|
|
18
|
+
if language == "c" and text.startswith("//", index):
|
|
19
|
+
return tuple(strings), index
|
|
20
|
+
quote = text[index]
|
|
21
|
+
if quote not in {"'", '"'}:
|
|
22
|
+
index += 1
|
|
23
|
+
continue
|
|
24
|
+
delimiter = quote
|
|
25
|
+
if language == "python" and text.startswith(
|
|
26
|
+
quote * 3, index
|
|
27
|
+
):
|
|
28
|
+
delimiter = quote * 3
|
|
29
|
+
start = index
|
|
30
|
+
index += len(delimiter)
|
|
31
|
+
while index < len(text):
|
|
32
|
+
if text[index] == "\\":
|
|
33
|
+
index = min(len(text), index + 2)
|
|
34
|
+
continue
|
|
35
|
+
if text.startswith(delimiter, index):
|
|
36
|
+
index += len(delimiter)
|
|
37
|
+
break
|
|
38
|
+
index += 1
|
|
39
|
+
strings.append((start, index))
|
|
40
|
+
return tuple(strings), None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
__all__ = ["lexical_protected_spans"]
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Line-number and quantum-rail widgets used by the PyCForge editor."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from PyQt5.QtCore import QSize, Qt
|
|
6
|
+
from PyQt5.QtWidgets import QWidget
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class _LineNumberArea(QWidget):
|
|
10
|
+
"""Narrow line-number gutter delegated to its owning editor."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, editor) -> None:
|
|
13
|
+
super().__init__(editor)
|
|
14
|
+
self.editor = editor
|
|
15
|
+
self.setCursor(Qt.PointingHandCursor)
|
|
16
|
+
|
|
17
|
+
def sizeHint(self): # noqa: N802 - Qt API
|
|
18
|
+
return QSize(self.editor.line_number_area_width(), 0)
|
|
19
|
+
|
|
20
|
+
def paintEvent(self, event) -> None: # noqa: N802 - Qt API
|
|
21
|
+
self.editor.paint_line_number_area(event)
|
|
22
|
+
|
|
23
|
+
def mousePressEvent(self, event) -> None: # noqa: N802 - Qt API
|
|
24
|
+
self.editor.go_to_line_at_y(event.pos().y())
|
|
25
|
+
super().mousePressEvent(event)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _QuantumRail(QWidget):
|
|
29
|
+
"""Accessible bounded overview/navigation rail for editor markers."""
|
|
30
|
+
|
|
31
|
+
WIDTH = 18
|
|
32
|
+
|
|
33
|
+
def __init__(self, editor) -> None:
|
|
34
|
+
super().__init__(editor)
|
|
35
|
+
self.editor = editor
|
|
36
|
+
self._keyboard_marker_index = -1
|
|
37
|
+
self.setObjectName("QuantumVisibilityRail")
|
|
38
|
+
self.setMouseTracking(True)
|
|
39
|
+
self.setCursor(Qt.PointingHandCursor)
|
|
40
|
+
self.setFocusPolicy(Qt.StrongFocus)
|
|
41
|
+
self.setAccessibleName("Source quantum visibility rail")
|
|
42
|
+
self.setAccessibleDescription(
|
|
43
|
+
"Overview of search, diagnostic, mapping, and viewport "
|
|
44
|
+
"positions. Use arrow keys to navigate markers, Page Up "
|
|
45
|
+
"and Page Down to scroll, and Enter or Space to activate "
|
|
46
|
+
"a marker."
|
|
47
|
+
)
|
|
48
|
+
self.setToolTip(
|
|
49
|
+
"Source overview: arrows navigate markers; Enter activates"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def sizeHint(self): # noqa: N802 - Qt API
|
|
53
|
+
return QSize(self.WIDTH, 0)
|
|
54
|
+
|
|
55
|
+
def paintEvent(self, event) -> None: # noqa: N802 - Qt API
|
|
56
|
+
self.editor.paint_quantum_rail(event)
|
|
57
|
+
|
|
58
|
+
def mousePressEvent(self, event) -> None: # noqa: N802 - Qt API
|
|
59
|
+
self.setFocus(Qt.MouseFocusReason)
|
|
60
|
+
marker = self.editor.marker_near_rail_position(
|
|
61
|
+
event.pos().y()
|
|
62
|
+
)
|
|
63
|
+
if marker is not None:
|
|
64
|
+
markers = self._ordered_markers()
|
|
65
|
+
self._keyboard_marker_index = markers.index(marker)
|
|
66
|
+
self.editor.activate_rail_position(event.pos().y())
|
|
67
|
+
event.accept()
|
|
68
|
+
|
|
69
|
+
def mouseMoveEvent(self, event) -> None: # noqa: N802 - Qt API
|
|
70
|
+
marker = self.editor.marker_near_rail_position(
|
|
71
|
+
event.pos().y()
|
|
72
|
+
)
|
|
73
|
+
self.setToolTip(
|
|
74
|
+
marker.message
|
|
75
|
+
if marker is not None else "Scroll overview"
|
|
76
|
+
)
|
|
77
|
+
super().mouseMoveEvent(event)
|
|
78
|
+
|
|
79
|
+
def focusInEvent(self, event) -> None: # noqa: N802 - Qt API
|
|
80
|
+
super().focusInEvent(event)
|
|
81
|
+
self.update()
|
|
82
|
+
|
|
83
|
+
def focusOutEvent(self, event) -> None: # noqa: N802 - Qt API
|
|
84
|
+
super().focusOutEvent(event)
|
|
85
|
+
self.update()
|
|
86
|
+
|
|
87
|
+
def keyPressEvent(self, event) -> None: # noqa: N802 - Qt API
|
|
88
|
+
key = event.key()
|
|
89
|
+
if key in (Qt.Key_Up, Qt.Key_Left):
|
|
90
|
+
self._select_relative_marker(-1)
|
|
91
|
+
elif key in (Qt.Key_Down, Qt.Key_Right):
|
|
92
|
+
self._select_relative_marker(1)
|
|
93
|
+
elif key == Qt.Key_Home:
|
|
94
|
+
self._select_endpoint_marker(first=True)
|
|
95
|
+
elif key == Qt.Key_End:
|
|
96
|
+
self._select_endpoint_marker(first=False)
|
|
97
|
+
elif key == Qt.Key_PageUp:
|
|
98
|
+
self._scroll_page(-1)
|
|
99
|
+
elif key == Qt.Key_PageDown:
|
|
100
|
+
self._scroll_page(1)
|
|
101
|
+
elif key in (
|
|
102
|
+
Qt.Key_Return,
|
|
103
|
+
Qt.Key_Enter,
|
|
104
|
+
Qt.Key_Space,
|
|
105
|
+
):
|
|
106
|
+
self._activate_keyboard_marker()
|
|
107
|
+
else:
|
|
108
|
+
super().keyPressEvent(event)
|
|
109
|
+
return
|
|
110
|
+
event.accept()
|
|
111
|
+
|
|
112
|
+
def markers_changed(self) -> None:
|
|
113
|
+
"""Retire a keyboard selection that may no longer exist."""
|
|
114
|
+
|
|
115
|
+
self._keyboard_marker_index = -1
|
|
116
|
+
self.update()
|
|
117
|
+
|
|
118
|
+
def selected_marker(self):
|
|
119
|
+
markers = self._ordered_markers()
|
|
120
|
+
if 0 <= self._keyboard_marker_index < len(markers):
|
|
121
|
+
return markers[self._keyboard_marker_index]
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
def _ordered_markers(self) -> tuple:
|
|
125
|
+
return tuple(
|
|
126
|
+
sorted(
|
|
127
|
+
self.editor._rail_markers(),
|
|
128
|
+
key=lambda marker: (
|
|
129
|
+
marker.start,
|
|
130
|
+
marker.end,
|
|
131
|
+
marker.kind,
|
|
132
|
+
marker.marker_id,
|
|
133
|
+
),
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def _select_relative_marker(self, step: int) -> None:
|
|
138
|
+
markers = self._ordered_markers()
|
|
139
|
+
if not markers:
|
|
140
|
+
self._scroll_line(step)
|
|
141
|
+
return
|
|
142
|
+
if 0 <= self._keyboard_marker_index < len(markers):
|
|
143
|
+
index = min(
|
|
144
|
+
len(markers) - 1,
|
|
145
|
+
max(0, self._keyboard_marker_index + step),
|
|
146
|
+
)
|
|
147
|
+
else:
|
|
148
|
+
position = self.editor.textCursor().position()
|
|
149
|
+
if step > 0:
|
|
150
|
+
index = next(
|
|
151
|
+
(
|
|
152
|
+
marker_index
|
|
153
|
+
for marker_index, marker in enumerate(markers)
|
|
154
|
+
if marker.start >= position
|
|
155
|
+
),
|
|
156
|
+
len(markers) - 1,
|
|
157
|
+
)
|
|
158
|
+
else:
|
|
159
|
+
index = next(
|
|
160
|
+
(
|
|
161
|
+
marker_index
|
|
162
|
+
for marker_index in range(
|
|
163
|
+
len(markers) - 1, -1, -1
|
|
164
|
+
)
|
|
165
|
+
if markers[marker_index].start <= position
|
|
166
|
+
),
|
|
167
|
+
0,
|
|
168
|
+
)
|
|
169
|
+
self._select_marker(index, markers)
|
|
170
|
+
|
|
171
|
+
def _select_endpoint_marker(self, *, first: bool) -> None:
|
|
172
|
+
markers = self._ordered_markers()
|
|
173
|
+
if markers:
|
|
174
|
+
self._select_marker(
|
|
175
|
+
0 if first else len(markers) - 1,
|
|
176
|
+
markers,
|
|
177
|
+
)
|
|
178
|
+
return
|
|
179
|
+
bar = self.editor.verticalScrollBar()
|
|
180
|
+
bar.setValue(bar.minimum() if first else bar.maximum())
|
|
181
|
+
self.update()
|
|
182
|
+
|
|
183
|
+
def _select_marker(
|
|
184
|
+
self,
|
|
185
|
+
index: int,
|
|
186
|
+
markers: tuple,
|
|
187
|
+
) -> None:
|
|
188
|
+
self._keyboard_marker_index = index
|
|
189
|
+
marker = markers[index]
|
|
190
|
+
self.editor.go_to_position(
|
|
191
|
+
marker.start, marker.end, focus=False
|
|
192
|
+
)
|
|
193
|
+
self.setToolTip(
|
|
194
|
+
marker.message
|
|
195
|
+
or (
|
|
196
|
+
f"{marker.kind.capitalize()} marker at source "
|
|
197
|
+
f"position {marker.start}"
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
self.update()
|
|
201
|
+
|
|
202
|
+
def _activate_keyboard_marker(self) -> None:
|
|
203
|
+
marker = self.selected_marker()
|
|
204
|
+
if marker is None:
|
|
205
|
+
markers = self._ordered_markers()
|
|
206
|
+
if not markers:
|
|
207
|
+
return
|
|
208
|
+
position = self.editor.textCursor().position()
|
|
209
|
+
index = min(
|
|
210
|
+
range(len(markers)),
|
|
211
|
+
key=lambda item: (
|
|
212
|
+
abs(markers[item].start - position),
|
|
213
|
+
item,
|
|
214
|
+
),
|
|
215
|
+
)
|
|
216
|
+
self._select_marker(index, markers)
|
|
217
|
+
marker = markers[index]
|
|
218
|
+
self.editor.activate_rail_marker(marker, focus=False)
|
|
219
|
+
self.update()
|
|
220
|
+
|
|
221
|
+
def _scroll_line(self, step: int) -> None:
|
|
222
|
+
bar = self.editor.verticalScrollBar()
|
|
223
|
+
bar.setValue(
|
|
224
|
+
bar.value() + step * max(1, bar.singleStep())
|
|
225
|
+
)
|
|
226
|
+
self.update()
|
|
227
|
+
|
|
228
|
+
def _scroll_page(self, step: int) -> None:
|
|
229
|
+
bar = self.editor.verticalScrollBar()
|
|
230
|
+
bar.setValue(
|
|
231
|
+
bar.value() + step * max(1, bar.pageStep())
|
|
232
|
+
)
|
|
233
|
+
self.update()
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
__all__ = ["_LineNumberArea", "_QuantumRail"]
|