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
pycforge/ide/editor.py
ADDED
|
@@ -0,0 +1,951 @@
|
|
|
1
|
+
"""Professional source editor widgets for the PyQt5 workspace.
|
|
2
|
+
|
|
3
|
+
The module deliberately has no dependency on the conversion pipeline. It is
|
|
4
|
+
safe to import in headless installations: the public Qt classes remain
|
|
5
|
+
available as fail-fast placeholders when the optional dependency is absent.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Sequence as SequenceABC
|
|
11
|
+
from dataclasses import dataclass, replace
|
|
12
|
+
from itertools import islice
|
|
13
|
+
from typing import Iterable, Sequence
|
|
14
|
+
|
|
15
|
+
from .editor_lexical import lexical_protected_spans
|
|
16
|
+
from .visual_tokens import PYCFORGE_COLORS
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Enter the plain-text large-file path before the active 1,000,000-byte /
|
|
20
|
+
# 100,000-line resource ceiling. The character threshold is deliberately
|
|
21
|
+
# conservative: a valid UTF-8 code point may occupy four bytes.
|
|
22
|
+
LARGE_FILE_CHARACTER_THRESHOLD = 250_000
|
|
23
|
+
LARGE_FILE_LINE_THRESHOLD = 80_000
|
|
24
|
+
LARGE_FILE_MARKER_STORAGE_LIMIT = 2_048
|
|
25
|
+
LARGE_FILE_EXTRA_SELECTION_LIMIT = 192
|
|
26
|
+
LARGE_FILE_RAIL_MARKER_LIMIT = 384
|
|
27
|
+
BRACKET_SCAN_LIMIT = 4_096
|
|
28
|
+
LARGE_FILE_BRACKET_SCAN_LIMIT = 1_024
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class EditorMarker:
|
|
33
|
+
"""A stable editor-position range displayed in the text and overview rail.
|
|
34
|
+
|
|
35
|
+
Qt text cursors count UTF-16 code units. Public widget callers therefore
|
|
36
|
+
convert source code-point offsets with :func:`qt_position_length` first.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
start: int
|
|
40
|
+
end: int
|
|
41
|
+
kind: str = "search"
|
|
42
|
+
message: str = ""
|
|
43
|
+
marker_id: str = ""
|
|
44
|
+
|
|
45
|
+
def normalized(self, text_length: int) -> "EditorMarker":
|
|
46
|
+
"""Return this marker clipped to a document of *text_length* characters."""
|
|
47
|
+
|
|
48
|
+
limit = max(0, int(text_length))
|
|
49
|
+
start = min(limit, max(0, int(self.start)))
|
|
50
|
+
end = min(limit, max(start, int(self.end)))
|
|
51
|
+
return replace(self, start=start, end=end)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def large_file_mode_required(*, character_count: int, line_count: int) -> bool:
|
|
55
|
+
"""Return whether editor work must use the bounded plain-text path.
|
|
56
|
+
|
|
57
|
+
Counts come from the Qt document's constant-time metadata in live widgets.
|
|
58
|
+
Keeping this policy helper Qt-free also makes the safety boundary available
|
|
59
|
+
to headless callers and tests.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
characters = max(0, int(character_count))
|
|
63
|
+
lines = max(0, int(line_count))
|
|
64
|
+
return (
|
|
65
|
+
characters >= LARGE_FILE_CHARACTER_THRESHOLD
|
|
66
|
+
or lines >= LARGE_FILE_LINE_THRESHOLD
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _projection_indices(size: int, limit: int) -> tuple[int, ...]:
|
|
71
|
+
"""Return deterministic, evenly distributed indexes with both endpoints."""
|
|
72
|
+
|
|
73
|
+
if limit < 0:
|
|
74
|
+
raise ValueError("marker projection limit must not be negative")
|
|
75
|
+
if size <= 0 or limit == 0:
|
|
76
|
+
return ()
|
|
77
|
+
if size <= limit:
|
|
78
|
+
return tuple(range(size))
|
|
79
|
+
if limit == 1:
|
|
80
|
+
return (0,)
|
|
81
|
+
return tuple(
|
|
82
|
+
(index * (size - 1)) // (limit - 1)
|
|
83
|
+
for index in range(limit)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def bounded_marker_projection(
|
|
88
|
+
markers: Sequence[EditorMarker],
|
|
89
|
+
*,
|
|
90
|
+
limit: int,
|
|
91
|
+
focus_start: int | None = None,
|
|
92
|
+
focus_end: int | None = None,
|
|
93
|
+
) -> tuple[EditorMarker, ...]:
|
|
94
|
+
"""Project marker data to a deterministic hard limit.
|
|
95
|
+
|
|
96
|
+
When a focus range is supplied, intersecting markers are preferred before
|
|
97
|
+
an evenly distributed overview sample. The helper never scans source
|
|
98
|
+
characters and never returns more than ``limit`` records.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
maximum = int(limit)
|
|
102
|
+
if maximum < 0:
|
|
103
|
+
raise ValueError("marker projection limit must not be negative")
|
|
104
|
+
values = tuple(markers)
|
|
105
|
+
if len(values) <= maximum:
|
|
106
|
+
return values
|
|
107
|
+
if maximum == 0:
|
|
108
|
+
return ()
|
|
109
|
+
|
|
110
|
+
focused_indexes: list[int] = []
|
|
111
|
+
if focus_start is not None or focus_end is not None:
|
|
112
|
+
start = max(0, int(focus_start or 0))
|
|
113
|
+
end = max(start, int(focus_end if focus_end is not None else start))
|
|
114
|
+
focused_indexes = [
|
|
115
|
+
index
|
|
116
|
+
for index, marker in enumerate(values)
|
|
117
|
+
if marker.end >= start and marker.start <= end
|
|
118
|
+
]
|
|
119
|
+
if len(focused_indexes) >= maximum:
|
|
120
|
+
selected = _projection_indices(len(focused_indexes), maximum)
|
|
121
|
+
return tuple(values[focused_indexes[index]] for index in selected)
|
|
122
|
+
|
|
123
|
+
selected_indexes = list(focused_indexes)
|
|
124
|
+
selected_set = set(selected_indexes)
|
|
125
|
+
remaining = maximum - len(selected_indexes)
|
|
126
|
+
if remaining:
|
|
127
|
+
for index in _projection_indices(len(values), remaining):
|
|
128
|
+
if index not in selected_set:
|
|
129
|
+
selected_indexes.append(index)
|
|
130
|
+
selected_set.add(index)
|
|
131
|
+
if len(selected_indexes) < maximum:
|
|
132
|
+
for index in range(len(values)):
|
|
133
|
+
if index in selected_set:
|
|
134
|
+
continue
|
|
135
|
+
selected_indexes.append(index)
|
|
136
|
+
selected_set.add(index)
|
|
137
|
+
if len(selected_indexes) == maximum:
|
|
138
|
+
break
|
|
139
|
+
return tuple(values[index] for index in selected_indexes[:maximum])
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def normalize_markers(
|
|
143
|
+
ranges: Iterable[EditorMarker | Sequence[int]],
|
|
144
|
+
*,
|
|
145
|
+
text_length: int,
|
|
146
|
+
kind: str,
|
|
147
|
+
limit: int | None = None,
|
|
148
|
+
) -> tuple[EditorMarker, ...]:
|
|
149
|
+
"""Normalize public marker input without requiring a Qt installation."""
|
|
150
|
+
|
|
151
|
+
maximum = None if limit is None else int(limit)
|
|
152
|
+
if maximum is not None and maximum < 0:
|
|
153
|
+
raise ValueError("marker projection limit must not be negative")
|
|
154
|
+
if maximum == 0:
|
|
155
|
+
return ()
|
|
156
|
+
if maximum is not None and isinstance(ranges, SequenceABC):
|
|
157
|
+
indexes = _projection_indices(len(ranges), maximum)
|
|
158
|
+
source: Iterable[EditorMarker | Sequence[int]] = (
|
|
159
|
+
ranges[index] for index in indexes
|
|
160
|
+
)
|
|
161
|
+
elif maximum is not None:
|
|
162
|
+
source = islice(ranges, maximum)
|
|
163
|
+
else:
|
|
164
|
+
source = ranges
|
|
165
|
+
|
|
166
|
+
normalized: list[EditorMarker] = []
|
|
167
|
+
for item in source:
|
|
168
|
+
if isinstance(item, EditorMarker):
|
|
169
|
+
marker = item if item.kind else replace(item, kind=kind)
|
|
170
|
+
else:
|
|
171
|
+
values = tuple(item)
|
|
172
|
+
if len(values) != 2:
|
|
173
|
+
raise ValueError("editor ranges must contain exactly start and end")
|
|
174
|
+
marker = EditorMarker(int(values[0]), int(values[1]), kind=kind)
|
|
175
|
+
marker = marker.normalized(text_length)
|
|
176
|
+
if marker.end > marker.start:
|
|
177
|
+
normalized.append(marker)
|
|
178
|
+
return tuple(normalized)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def qt_position_length(text: str) -> int:
|
|
182
|
+
"""Return the number of UTF-16 code units used by a Qt text cursor."""
|
|
183
|
+
|
|
184
|
+
if not isinstance(text, str):
|
|
185
|
+
raise TypeError("editor text must be a string")
|
|
186
|
+
return len(text.encode("utf-16-le")) // 2
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
from PyQt5.QtCore import QRect, Qt, pyqtSignal
|
|
191
|
+
from PyQt5.QtGui import (
|
|
192
|
+
QColor,
|
|
193
|
+
QFont,
|
|
194
|
+
QFontDatabase,
|
|
195
|
+
QKeySequence,
|
|
196
|
+
QLinearGradient,
|
|
197
|
+
QPainter,
|
|
198
|
+
QPen,
|
|
199
|
+
QTextCharFormat,
|
|
200
|
+
QTextCursor,
|
|
201
|
+
QTextFormat,
|
|
202
|
+
)
|
|
203
|
+
from PyQt5.QtWidgets import QPlainTextEdit, QTextEdit
|
|
204
|
+
from .editor_commands_qt import SourceCommandMixin
|
|
205
|
+
from .editor_sidebars import _LineNumberArea, _QuantumRail
|
|
206
|
+
from .editor_syntax import PyCForgeSyntaxHighlighter
|
|
207
|
+
except ImportError as exc: # pragma: no cover - exercised in the headless build
|
|
208
|
+
QT_AVAILABLE = False
|
|
209
|
+
_QT_ERROR = exc
|
|
210
|
+
else:
|
|
211
|
+
QT_AVAILABLE = True
|
|
212
|
+
_QT_ERROR = None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
if QT_AVAILABLE:
|
|
216
|
+
# Accessibility contract implemented by editor_sidebars:
|
|
217
|
+
# self.setObjectName("QuantumVisibilityRail")
|
|
218
|
+
# self.setFocusPolicy(Qt.StrongFocus)
|
|
219
|
+
# self.setAccessibleDescription(
|
|
220
|
+
|
|
221
|
+
def _token_color(value: str, alpha: int | None = None) -> QColor:
|
|
222
|
+
color = QColor(value)
|
|
223
|
+
if alpha is not None:
|
|
224
|
+
color.setAlpha(max(0, min(255, int(alpha))))
|
|
225
|
+
return color
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class CodeEditor(SourceCommandMixin, QPlainTextEdit):
|
|
229
|
+
"""Source editor with syntax, line numbers, selection layers and minimap rail."""
|
|
230
|
+
|
|
231
|
+
findRequested = pyqtSignal(bool)
|
|
232
|
+
markerActivated = pyqtSignal(str, str, int)
|
|
233
|
+
largeFileModeChanged = pyqtSignal(bool)
|
|
234
|
+
|
|
235
|
+
_RAIL_COLORS = {
|
|
236
|
+
"search": _token_color(PYCFORGE_COLORS.warning),
|
|
237
|
+
"diagnostic": _token_color(PYCFORGE_COLORS.error),
|
|
238
|
+
"warning": _token_color(PYCFORGE_COLORS.warm),
|
|
239
|
+
"info": _token_color(PYCFORGE_COLORS.blue),
|
|
240
|
+
"mapping": _token_color(PYCFORGE_COLORS.success),
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
def __init__(
|
|
244
|
+
self,
|
|
245
|
+
parent=None,
|
|
246
|
+
*,
|
|
247
|
+
language: str = "python",
|
|
248
|
+
highlighting: bool = True,
|
|
249
|
+
) -> None:
|
|
250
|
+
super().__init__(parent)
|
|
251
|
+
self._line_area = _LineNumberArea(self)
|
|
252
|
+
self._quantum_rail = _QuantumRail(self)
|
|
253
|
+
self._highlighter = PyCForgeSyntaxHighlighter(self.document(), language)
|
|
254
|
+
self._search_markers: tuple[EditorMarker, ...] = ()
|
|
255
|
+
self._diagnostic_markers: tuple[EditorMarker, ...] = ()
|
|
256
|
+
self._mapping_markers: tuple[EditorMarker, ...] = ()
|
|
257
|
+
self._rail_marker_cache: tuple[EditorMarker, ...] = ()
|
|
258
|
+
self._active_search_index = -1
|
|
259
|
+
self._large_file_mode = False
|
|
260
|
+
self._large_file_mode_requested = False
|
|
261
|
+
self._initialize_source_commands(
|
|
262
|
+
highlighting_enabled=highlighting
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
fixed = QFontDatabase.systemFont(QFontDatabase.FixedFont)
|
|
266
|
+
fixed.setStyleHint(QFont.Monospace)
|
|
267
|
+
fixed.setPointSize(max(10, fixed.pointSize()))
|
|
268
|
+
self.setFont(fixed)
|
|
269
|
+
self.setLineWrapMode(QPlainTextEdit.NoWrap)
|
|
270
|
+
self.setTabStopDistance(self.fontMetrics().horizontalAdvance(" ") * 4)
|
|
271
|
+
self.setFrameStyle(0)
|
|
272
|
+
self.setCenterOnScroll(True)
|
|
273
|
+
self.setProperty("role", "code-editor")
|
|
274
|
+
self.setAccessibleName("Source code editor")
|
|
275
|
+
|
|
276
|
+
self.blockCountChanged.connect(self._update_viewport_margins)
|
|
277
|
+
self.updateRequest.connect(self._update_side_areas)
|
|
278
|
+
self.cursorPositionChanged.connect(self._refresh_extra_selections)
|
|
279
|
+
self.textChanged.connect(self._document_changed)
|
|
280
|
+
self.verticalScrollBar().valueChanged.connect(self._quantum_rail.update)
|
|
281
|
+
self._update_viewport_margins()
|
|
282
|
+
self._refresh_extra_selections()
|
|
283
|
+
|
|
284
|
+
@property
|
|
285
|
+
def language(self) -> str:
|
|
286
|
+
return self._highlighter.language
|
|
287
|
+
|
|
288
|
+
@property
|
|
289
|
+
def large_file_mode(self) -> bool:
|
|
290
|
+
"""Whether bounded plain-text rendering is currently active."""
|
|
291
|
+
|
|
292
|
+
return self._large_file_mode
|
|
293
|
+
|
|
294
|
+
def set_large_file_mode(self, enabled: bool) -> None:
|
|
295
|
+
"""Request or release the editor's bounded large-file path.
|
|
296
|
+
|
|
297
|
+
``True`` explicitly enters large-file mode. ``False`` releases
|
|
298
|
+
that request, but threshold-sized documents remain protected by
|
|
299
|
+
automatic detection; callers cannot accidentally re-enable a
|
|
300
|
+
whole-document highlighter at the maximum-input envelope.
|
|
301
|
+
"""
|
|
302
|
+
|
|
303
|
+
requested = bool(enabled)
|
|
304
|
+
if requested == self._large_file_mode_requested:
|
|
305
|
+
return
|
|
306
|
+
self._large_file_mode_requested = requested
|
|
307
|
+
if self._update_large_file_mode():
|
|
308
|
+
self._refresh_extra_selections()
|
|
309
|
+
self._quantum_rail.markers_changed()
|
|
310
|
+
|
|
311
|
+
def set_language(self, language: str) -> None:
|
|
312
|
+
self._highlighter.set_language(language)
|
|
313
|
+
|
|
314
|
+
def setPlainText(self, text: str) -> None: # noqa: N802 - Qt API
|
|
315
|
+
"""Replace source text without first highlighting a huge document."""
|
|
316
|
+
|
|
317
|
+
if isinstance(text, str):
|
|
318
|
+
incoming_large = large_file_mode_required(
|
|
319
|
+
character_count=len(text),
|
|
320
|
+
line_count=text.count("\n") + 1,
|
|
321
|
+
)
|
|
322
|
+
if incoming_large or self._large_file_mode_requested:
|
|
323
|
+
self._apply_large_file_mode(True)
|
|
324
|
+
super().setPlainText(text)
|
|
325
|
+
|
|
326
|
+
def _document_text_length(self) -> int:
|
|
327
|
+
"""Return Qt's UTF-16 text length without copying the document."""
|
|
328
|
+
|
|
329
|
+
return max(0, int(self.document().characterCount()) - 1)
|
|
330
|
+
|
|
331
|
+
def _update_large_file_mode(self) -> bool:
|
|
332
|
+
required = self._large_file_mode_requested or large_file_mode_required(
|
|
333
|
+
character_count=self._document_text_length(),
|
|
334
|
+
line_count=self.document().blockCount(),
|
|
335
|
+
)
|
|
336
|
+
return self._apply_large_file_mode(required)
|
|
337
|
+
|
|
338
|
+
def _apply_large_file_mode(self, enabled: bool) -> bool:
|
|
339
|
+
active = bool(enabled)
|
|
340
|
+
if active == self._large_file_mode:
|
|
341
|
+
return False
|
|
342
|
+
self._large_file_mode = active
|
|
343
|
+
if active:
|
|
344
|
+
# Detaching the highlighter is the deterministic plain-text
|
|
345
|
+
# fallback. It prevents Qt from scheduling proportional
|
|
346
|
+
# whole-document work after edits or language changes.
|
|
347
|
+
self._highlighter.setDocument(None)
|
|
348
|
+
self._cap_large_file_markers()
|
|
349
|
+
else:
|
|
350
|
+
self._highlighter.setDocument(self.document())
|
|
351
|
+
self._rebuild_rail_marker_cache()
|
|
352
|
+
self.largeFileModeChanged.emit(active)
|
|
353
|
+
return True
|
|
354
|
+
|
|
355
|
+
def line_number_area_width(self) -> int:
|
|
356
|
+
digits = max(2, len(str(max(1, self.blockCount()))))
|
|
357
|
+
return 14 + self.fontMetrics().horizontalAdvance("9") * digits
|
|
358
|
+
|
|
359
|
+
def _update_viewport_margins(self, _count: int | None = None) -> None:
|
|
360
|
+
self.setViewportMargins(
|
|
361
|
+
self.line_number_area_width(), 0, self._quantum_rail.WIDTH, 0
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
def resizeEvent(self, event) -> None: # noqa: N802 - Qt API
|
|
365
|
+
super().resizeEvent(event)
|
|
366
|
+
contents = self.contentsRect()
|
|
367
|
+
left_width = self.line_number_area_width()
|
|
368
|
+
self._line_area.setGeometry(
|
|
369
|
+
QRect(contents.left(), contents.top(), left_width, contents.height())
|
|
370
|
+
)
|
|
371
|
+
self._quantum_rail.setGeometry(
|
|
372
|
+
QRect(
|
|
373
|
+
contents.right() - self._quantum_rail.WIDTH + 1,
|
|
374
|
+
contents.top(),
|
|
375
|
+
self._quantum_rail.WIDTH,
|
|
376
|
+
contents.height(),
|
|
377
|
+
)
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
def _update_side_areas(self, rect, dy: int) -> None:
|
|
381
|
+
if dy:
|
|
382
|
+
self._line_area.scroll(0, dy)
|
|
383
|
+
else:
|
|
384
|
+
self._line_area.update(0, rect.y(), self._line_area.width(), rect.height())
|
|
385
|
+
if rect.contains(self.viewport().rect()):
|
|
386
|
+
self._update_viewport_margins()
|
|
387
|
+
self._quantum_rail.update()
|
|
388
|
+
|
|
389
|
+
def paint_line_number_area(self, event) -> None:
|
|
390
|
+
painter = QPainter(self._line_area)
|
|
391
|
+
gradient = QLinearGradient(0, 0, self._line_area.width(), 0)
|
|
392
|
+
gradient.setColorAt(
|
|
393
|
+
0.0, _token_color(PYCFORGE_COLORS.void)
|
|
394
|
+
)
|
|
395
|
+
gradient.setColorAt(
|
|
396
|
+
1.0, _token_color(PYCFORGE_COLORS.surface_raised)
|
|
397
|
+
)
|
|
398
|
+
painter.fillRect(event.rect(), gradient)
|
|
399
|
+
painter.setPen(_token_color(PYCFORGE_COLORS.border))
|
|
400
|
+
painter.drawLine(
|
|
401
|
+
self._line_area.width() - 1,
|
|
402
|
+
event.rect().top(),
|
|
403
|
+
self._line_area.width() - 1,
|
|
404
|
+
event.rect().bottom(),
|
|
405
|
+
)
|
|
406
|
+
block = self.firstVisibleBlock()
|
|
407
|
+
number = block.blockNumber()
|
|
408
|
+
top = round(self.blockBoundingGeometry(block).translated(self.contentOffset()).top())
|
|
409
|
+
bottom = top + round(self.blockBoundingRect(block).height())
|
|
410
|
+
current = self.textCursor().blockNumber()
|
|
411
|
+
while block.isValid() and top <= event.rect().bottom():
|
|
412
|
+
if block.isVisible() and bottom >= event.rect().top():
|
|
413
|
+
painter.setPen(
|
|
414
|
+
_token_color(PYCFORGE_COLORS.blue_bright)
|
|
415
|
+
if number == current
|
|
416
|
+
else _token_color(PYCFORGE_COLORS.text_disabled)
|
|
417
|
+
)
|
|
418
|
+
painter.drawText(
|
|
419
|
+
0,
|
|
420
|
+
top,
|
|
421
|
+
self._line_area.width() - 8,
|
|
422
|
+
self.fontMetrics().height(),
|
|
423
|
+
Qt.AlignRight,
|
|
424
|
+
str(number + 1),
|
|
425
|
+
)
|
|
426
|
+
block = block.next()
|
|
427
|
+
top = bottom
|
|
428
|
+
bottom = top + round(self.blockBoundingRect(block).height())
|
|
429
|
+
number += 1
|
|
430
|
+
|
|
431
|
+
def go_to_line_at_y(self, y: int) -> None:
|
|
432
|
+
cursor = self.cursorForPosition(self.viewport().rect().topLeft())
|
|
433
|
+
block = self.firstVisibleBlock()
|
|
434
|
+
while block.isValid():
|
|
435
|
+
top = self.blockBoundingGeometry(block).translated(self.contentOffset()).top()
|
|
436
|
+
bottom = top + self.blockBoundingRect(block).height()
|
|
437
|
+
if top <= y < bottom:
|
|
438
|
+
cursor.setPosition(block.position())
|
|
439
|
+
self.setTextCursor(cursor)
|
|
440
|
+
self.setFocus()
|
|
441
|
+
return
|
|
442
|
+
block = block.next()
|
|
443
|
+
|
|
444
|
+
def _document_changed(self) -> None:
|
|
445
|
+
self._reset_source_command_state()
|
|
446
|
+
self._update_large_file_mode()
|
|
447
|
+
text_length = self._document_text_length()
|
|
448
|
+
active_marker = self._active_search_marker()
|
|
449
|
+
self._search_markers = tuple(
|
|
450
|
+
marker for marker in (m.normalized(text_length) for m in self._search_markers)
|
|
451
|
+
if marker.end > marker.start
|
|
452
|
+
)
|
|
453
|
+
if active_marker is not None:
|
|
454
|
+
active_marker = active_marker.normalized(text_length)
|
|
455
|
+
if active_marker.end <= active_marker.start:
|
|
456
|
+
active_marker = None
|
|
457
|
+
# Diagnostics and source mappings describe a particular conversion
|
|
458
|
+
# snapshot. An edit invalidates them instead of leaving a plausible
|
|
459
|
+
# but incorrect underline attached to shifted text.
|
|
460
|
+
self._diagnostic_markers = ()
|
|
461
|
+
self._mapping_markers = ()
|
|
462
|
+
self._search_markers = self._bounded_search_markers(
|
|
463
|
+
self._search_markers,
|
|
464
|
+
active_marker,
|
|
465
|
+
)
|
|
466
|
+
self._rebuild_rail_marker_cache()
|
|
467
|
+
self._refresh_extra_selections()
|
|
468
|
+
self._quantum_rail.markers_changed()
|
|
469
|
+
|
|
470
|
+
def set_search_ranges(
|
|
471
|
+
self,
|
|
472
|
+
ranges: Iterable[EditorMarker | Sequence[int]],
|
|
473
|
+
active_index: int = -1,
|
|
474
|
+
) -> None:
|
|
475
|
+
active_input = None
|
|
476
|
+
if (
|
|
477
|
+
isinstance(ranges, SequenceABC)
|
|
478
|
+
and 0 <= active_index < len(ranges)
|
|
479
|
+
):
|
|
480
|
+
active_input = ranges[active_index]
|
|
481
|
+
self._search_markers = normalize_markers(
|
|
482
|
+
ranges,
|
|
483
|
+
text_length=self._document_text_length(),
|
|
484
|
+
kind="search",
|
|
485
|
+
limit=LARGE_FILE_MARKER_STORAGE_LIMIT,
|
|
486
|
+
)
|
|
487
|
+
active_marker = None
|
|
488
|
+
if active_input is not None:
|
|
489
|
+
active_values = normalize_markers(
|
|
490
|
+
(active_input,),
|
|
491
|
+
text_length=self._document_text_length(),
|
|
492
|
+
kind="search",
|
|
493
|
+
)
|
|
494
|
+
if active_values:
|
|
495
|
+
active_marker = active_values[0]
|
|
496
|
+
elif 0 <= active_index < len(self._search_markers):
|
|
497
|
+
active_marker = self._search_markers[active_index]
|
|
498
|
+
self._search_markers = self._bounded_search_markers(
|
|
499
|
+
self._search_markers,
|
|
500
|
+
active_marker,
|
|
501
|
+
)
|
|
502
|
+
self._rebuild_rail_marker_cache()
|
|
503
|
+
self._refresh_extra_selections()
|
|
504
|
+
self._quantum_rail.markers_changed()
|
|
505
|
+
|
|
506
|
+
def set_diagnostic_ranges(
|
|
507
|
+
self, ranges: Iterable[EditorMarker | Sequence[int]]
|
|
508
|
+
) -> None:
|
|
509
|
+
self._diagnostic_markers = normalize_markers(
|
|
510
|
+
ranges,
|
|
511
|
+
text_length=self._document_text_length(),
|
|
512
|
+
kind="diagnostic",
|
|
513
|
+
limit=LARGE_FILE_MARKER_STORAGE_LIMIT,
|
|
514
|
+
)
|
|
515
|
+
self._rebuild_rail_marker_cache()
|
|
516
|
+
self._refresh_extra_selections()
|
|
517
|
+
self._quantum_rail.markers_changed()
|
|
518
|
+
|
|
519
|
+
def set_mapping_ranges(
|
|
520
|
+
self, ranges: Iterable[EditorMarker | Sequence[int]]
|
|
521
|
+
) -> None:
|
|
522
|
+
self._mapping_markers = normalize_markers(
|
|
523
|
+
ranges,
|
|
524
|
+
text_length=self._document_text_length(),
|
|
525
|
+
kind="mapping",
|
|
526
|
+
limit=LARGE_FILE_MARKER_STORAGE_LIMIT,
|
|
527
|
+
)
|
|
528
|
+
self._rebuild_rail_marker_cache()
|
|
529
|
+
self._refresh_extra_selections()
|
|
530
|
+
self._quantum_rail.markers_changed()
|
|
531
|
+
|
|
532
|
+
def clear_markers(self) -> None:
|
|
533
|
+
self._search_markers = ()
|
|
534
|
+
self._diagnostic_markers = ()
|
|
535
|
+
self._mapping_markers = ()
|
|
536
|
+
self._active_search_index = -1
|
|
537
|
+
self._rebuild_rail_marker_cache()
|
|
538
|
+
self._refresh_extra_selections()
|
|
539
|
+
self._quantum_rail.markers_changed()
|
|
540
|
+
|
|
541
|
+
def markers(self, kind: str | None = None) -> tuple[EditorMarker, ...]:
|
|
542
|
+
values = self._search_markers + self._diagnostic_markers + self._mapping_markers
|
|
543
|
+
if kind is None:
|
|
544
|
+
return values
|
|
545
|
+
return tuple(marker for marker in values if marker.kind == kind)
|
|
546
|
+
|
|
547
|
+
def _active_search_marker(self) -> EditorMarker | None:
|
|
548
|
+
if 0 <= self._active_search_index < len(self._search_markers):
|
|
549
|
+
return self._search_markers[self._active_search_index]
|
|
550
|
+
return None
|
|
551
|
+
|
|
552
|
+
def _bounded_search_markers(
|
|
553
|
+
self,
|
|
554
|
+
markers: Sequence[EditorMarker],
|
|
555
|
+
active_marker: EditorMarker | None,
|
|
556
|
+
) -> tuple[EditorMarker, ...]:
|
|
557
|
+
projected = bounded_marker_projection(
|
|
558
|
+
markers,
|
|
559
|
+
limit=LARGE_FILE_MARKER_STORAGE_LIMIT,
|
|
560
|
+
focus_start=active_marker.start if active_marker is not None else None,
|
|
561
|
+
focus_end=active_marker.end if active_marker is not None else None,
|
|
562
|
+
)
|
|
563
|
+
if active_marker is not None and active_marker not in projected:
|
|
564
|
+
projected = (
|
|
565
|
+
projected[:-1] + (active_marker,)
|
|
566
|
+
if len(projected) >= LARGE_FILE_MARKER_STORAGE_LIMIT
|
|
567
|
+
else projected + (active_marker,)
|
|
568
|
+
)
|
|
569
|
+
self._active_search_index = (
|
|
570
|
+
projected.index(active_marker)
|
|
571
|
+
if active_marker in projected
|
|
572
|
+
else -1
|
|
573
|
+
)
|
|
574
|
+
return projected
|
|
575
|
+
|
|
576
|
+
def _cap_large_file_markers(self) -> None:
|
|
577
|
+
active_marker = self._active_search_marker()
|
|
578
|
+
self._search_markers = self._bounded_search_markers(
|
|
579
|
+
self._search_markers,
|
|
580
|
+
active_marker,
|
|
581
|
+
)
|
|
582
|
+
self._diagnostic_markers = bounded_marker_projection(
|
|
583
|
+
self._diagnostic_markers,
|
|
584
|
+
limit=LARGE_FILE_MARKER_STORAGE_LIMIT,
|
|
585
|
+
)
|
|
586
|
+
self._mapping_markers = bounded_marker_projection(
|
|
587
|
+
self._mapping_markers,
|
|
588
|
+
limit=LARGE_FILE_MARKER_STORAGE_LIMIT,
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
def _rebuild_rail_marker_cache(self) -> None:
|
|
592
|
+
markers = self.markers()
|
|
593
|
+
if len(markers) <= LARGE_FILE_RAIL_MARKER_LIMIT:
|
|
594
|
+
self._rail_marker_cache = markers
|
|
595
|
+
return
|
|
596
|
+
per_layer = max(1, LARGE_FILE_RAIL_MARKER_LIMIT // 3)
|
|
597
|
+
active_marker = self._active_search_marker()
|
|
598
|
+
search = bounded_marker_projection(
|
|
599
|
+
self._search_markers,
|
|
600
|
+
limit=per_layer,
|
|
601
|
+
focus_start=active_marker.start if active_marker is not None else None,
|
|
602
|
+
focus_end=active_marker.end if active_marker is not None else None,
|
|
603
|
+
)
|
|
604
|
+
diagnostics = bounded_marker_projection(
|
|
605
|
+
self._diagnostic_markers,
|
|
606
|
+
limit=per_layer,
|
|
607
|
+
)
|
|
608
|
+
mappings = bounded_marker_projection(
|
|
609
|
+
self._mapping_markers,
|
|
610
|
+
limit=per_layer,
|
|
611
|
+
)
|
|
612
|
+
self._rail_marker_cache = search + diagnostics + mappings
|
|
613
|
+
|
|
614
|
+
def _rail_markers(self) -> tuple[EditorMarker, ...]:
|
|
615
|
+
"""Return the bounded projection used by rail paint/navigation."""
|
|
616
|
+
|
|
617
|
+
return self._rail_marker_cache
|
|
618
|
+
|
|
619
|
+
def _selection(self, marker: EditorMarker, char_format: QTextCharFormat):
|
|
620
|
+
selection = QTextEdit.ExtraSelection()
|
|
621
|
+
selection.cursor = QTextCursor(self.document())
|
|
622
|
+
selection.cursor.setPosition(marker.start)
|
|
623
|
+
selection.cursor.setPosition(marker.end, QTextCursor.KeepAnchor)
|
|
624
|
+
selection.format = char_format
|
|
625
|
+
return selection
|
|
626
|
+
|
|
627
|
+
def _viewport_projection_bounds(self) -> tuple[int, int]:
|
|
628
|
+
"""Return the visible block range plus a small deterministic margin."""
|
|
629
|
+
|
|
630
|
+
first = self.firstVisibleBlock()
|
|
631
|
+
last = self.cursorForPosition(self.viewport().rect().bottomRight()).block()
|
|
632
|
+
if not first.isValid() or not last.isValid():
|
|
633
|
+
position = self.textCursor().position()
|
|
634
|
+
return position, position
|
|
635
|
+
for _ in range(24):
|
|
636
|
+
previous = first.previous()
|
|
637
|
+
if not previous.isValid():
|
|
638
|
+
break
|
|
639
|
+
first = previous
|
|
640
|
+
for _ in range(24):
|
|
641
|
+
following = last.next()
|
|
642
|
+
if not following.isValid():
|
|
643
|
+
break
|
|
644
|
+
last = following
|
|
645
|
+
return (
|
|
646
|
+
max(0, first.position()),
|
|
647
|
+
min(
|
|
648
|
+
self._document_text_length(),
|
|
649
|
+
last.position() + max(0, last.length()),
|
|
650
|
+
),
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
def _extra_marker_projection(
|
|
654
|
+
self,
|
|
655
|
+
markers: Sequence[EditorMarker],
|
|
656
|
+
*,
|
|
657
|
+
limit: int,
|
|
658
|
+
include: EditorMarker | None = None,
|
|
659
|
+
) -> tuple[EditorMarker, ...]:
|
|
660
|
+
if len(markers) <= limit:
|
|
661
|
+
return tuple(markers)
|
|
662
|
+
start, end = self._viewport_projection_bounds()
|
|
663
|
+
projected = bounded_marker_projection(
|
|
664
|
+
markers,
|
|
665
|
+
limit=limit,
|
|
666
|
+
focus_start=start,
|
|
667
|
+
focus_end=end,
|
|
668
|
+
)
|
|
669
|
+
if include is not None and include not in projected:
|
|
670
|
+
projected = (
|
|
671
|
+
projected[:-1] + (include,)
|
|
672
|
+
if len(projected) >= limit
|
|
673
|
+
else projected + (include,)
|
|
674
|
+
)
|
|
675
|
+
return projected
|
|
676
|
+
|
|
677
|
+
def _refresh_extra_selections(self) -> None:
|
|
678
|
+
selections = []
|
|
679
|
+
current = QTextEdit.ExtraSelection()
|
|
680
|
+
current.format.setBackground(
|
|
681
|
+
_token_color(PYCFORGE_COLORS.selection, 150)
|
|
682
|
+
)
|
|
683
|
+
current.format.setProperty(QTextFormat.FullWidthSelection, True)
|
|
684
|
+
current.cursor = self.textCursor()
|
|
685
|
+
current.cursor.clearSelection()
|
|
686
|
+
selections.append(current)
|
|
687
|
+
|
|
688
|
+
bracket_positions = self._bracket_positions()
|
|
689
|
+
for position in bracket_positions:
|
|
690
|
+
bracket = QTextEdit.ExtraSelection()
|
|
691
|
+
bracket.cursor = QTextCursor(self.document())
|
|
692
|
+
bracket.cursor.setPosition(position)
|
|
693
|
+
bracket.cursor.setPosition(position + 1, QTextCursor.KeepAnchor)
|
|
694
|
+
bracket.format.setBackground(
|
|
695
|
+
_token_color(
|
|
696
|
+
PYCFORGE_COLORS.violet_dim
|
|
697
|
+
if len(bracket_positions) == 2
|
|
698
|
+
else PYCFORGE_COLORS.error_dim
|
|
699
|
+
)
|
|
700
|
+
)
|
|
701
|
+
bracket.format.setForeground(
|
|
702
|
+
_token_color(PYCFORGE_COLORS.text)
|
|
703
|
+
)
|
|
704
|
+
selections.append(bracket)
|
|
705
|
+
|
|
706
|
+
per_layer = max(1, LARGE_FILE_EXTRA_SELECTION_LIMIT // 3)
|
|
707
|
+
active_marker = self._active_search_marker()
|
|
708
|
+
search_markers = self._extra_marker_projection(
|
|
709
|
+
self._search_markers,
|
|
710
|
+
limit=per_layer,
|
|
711
|
+
include=active_marker,
|
|
712
|
+
)
|
|
713
|
+
mapping_markers = self._extra_marker_projection(
|
|
714
|
+
self._mapping_markers,
|
|
715
|
+
limit=per_layer,
|
|
716
|
+
)
|
|
717
|
+
diagnostic_markers = self._extra_marker_projection(
|
|
718
|
+
self._diagnostic_markers,
|
|
719
|
+
limit=per_layer,
|
|
720
|
+
)
|
|
721
|
+
for marker in search_markers:
|
|
722
|
+
char_format = QTextCharFormat()
|
|
723
|
+
char_format.setBackground(
|
|
724
|
+
_token_color(
|
|
725
|
+
PYCFORGE_COLORS.warning,
|
|
726
|
+
190 if marker == active_marker else 90,
|
|
727
|
+
)
|
|
728
|
+
)
|
|
729
|
+
char_format.setForeground(
|
|
730
|
+
_token_color(PYCFORGE_COLORS.text)
|
|
731
|
+
)
|
|
732
|
+
selections.append(self._selection(marker, char_format))
|
|
733
|
+
for marker in mapping_markers:
|
|
734
|
+
char_format = QTextCharFormat()
|
|
735
|
+
char_format.setBackground(
|
|
736
|
+
_token_color(PYCFORGE_COLORS.success, 38)
|
|
737
|
+
)
|
|
738
|
+
char_format.setUnderlineColor(
|
|
739
|
+
_token_color(PYCFORGE_COLORS.success)
|
|
740
|
+
)
|
|
741
|
+
char_format.setUnderlineStyle(QTextCharFormat.SingleUnderline)
|
|
742
|
+
selections.append(self._selection(marker, char_format))
|
|
743
|
+
for marker in diagnostic_markers:
|
|
744
|
+
char_format = QTextCharFormat()
|
|
745
|
+
color = self._RAIL_COLORS.get(marker.kind, self._RAIL_COLORS["diagnostic"])
|
|
746
|
+
char_format.setUnderlineColor(color)
|
|
747
|
+
char_format.setUnderlineStyle(QTextCharFormat.WaveUnderline)
|
|
748
|
+
selections.append(self._selection(marker, char_format))
|
|
749
|
+
self.setExtraSelections(selections)
|
|
750
|
+
|
|
751
|
+
def _bracket_positions(self) -> tuple[int, ...]:
|
|
752
|
+
"""Return the bracket at the caret and its deterministic partner."""
|
|
753
|
+
|
|
754
|
+
pairs = {"(": ")", "[": "]", "{": "}"}
|
|
755
|
+
reverse = {value: key for key, value in pairs.items()}
|
|
756
|
+
limit = max(0, self.document().characterCount() - 1)
|
|
757
|
+
cursor_position = min(limit, self.textCursor().position())
|
|
758
|
+
|
|
759
|
+
def character(position: int) -> str:
|
|
760
|
+
if 0 <= position < limit:
|
|
761
|
+
return str(self.document().characterAt(position))
|
|
762
|
+
return ""
|
|
763
|
+
|
|
764
|
+
at_cursor = character(cursor_position)
|
|
765
|
+
before_cursor = character(cursor_position - 1)
|
|
766
|
+
if at_cursor in pairs or at_cursor in reverse:
|
|
767
|
+
origin = cursor_position
|
|
768
|
+
token = at_cursor
|
|
769
|
+
elif before_cursor in pairs or before_cursor in reverse:
|
|
770
|
+
origin = cursor_position - 1
|
|
771
|
+
token = before_cursor
|
|
772
|
+
else:
|
|
773
|
+
return ()
|
|
774
|
+
|
|
775
|
+
if token in pairs:
|
|
776
|
+
opener, closer, direction = token, pairs[token], 1
|
|
777
|
+
else:
|
|
778
|
+
opener, closer, direction = reverse[token], token, -1
|
|
779
|
+
depth = 1
|
|
780
|
+
position = origin + direction
|
|
781
|
+
scan_limit = (
|
|
782
|
+
LARGE_FILE_BRACKET_SCAN_LIMIT
|
|
783
|
+
if self._large_file_mode
|
|
784
|
+
else BRACKET_SCAN_LIMIT
|
|
785
|
+
)
|
|
786
|
+
scanned = 0
|
|
787
|
+
while 0 <= position < limit and scanned < scan_limit:
|
|
788
|
+
candidate = character(position)
|
|
789
|
+
if candidate == (opener if direction > 0 else closer):
|
|
790
|
+
depth += 1
|
|
791
|
+
elif candidate == (closer if direction > 0 else opener):
|
|
792
|
+
depth -= 1
|
|
793
|
+
if depth == 0:
|
|
794
|
+
return (origin, position)
|
|
795
|
+
position += direction
|
|
796
|
+
scanned += 1
|
|
797
|
+
return (origin,)
|
|
798
|
+
|
|
799
|
+
def _marker_rail_y(self, marker: EditorMarker) -> int:
|
|
800
|
+
block_count = max(1, self.document().blockCount())
|
|
801
|
+
block = self.document().findBlock(marker.start).blockNumber()
|
|
802
|
+
usable = max(1, self._quantum_rail.height() - 4)
|
|
803
|
+
return 2 + round((block / max(1, block_count - 1)) * usable)
|
|
804
|
+
|
|
805
|
+
def paint_quantum_rail(self, event) -> None:
|
|
806
|
+
painter = QPainter(self._quantum_rail)
|
|
807
|
+
gradient = QLinearGradient(0, 0, self._quantum_rail.width(), 0)
|
|
808
|
+
gradient.setColorAt(
|
|
809
|
+
0.0, _token_color(PYCFORGE_COLORS.surface)
|
|
810
|
+
)
|
|
811
|
+
gradient.setColorAt(
|
|
812
|
+
0.55, _token_color(PYCFORGE_COLORS.surface_active)
|
|
813
|
+
)
|
|
814
|
+
gradient.setColorAt(
|
|
815
|
+
1.0, _token_color(PYCFORGE_COLORS.void)
|
|
816
|
+
)
|
|
817
|
+
painter.fillRect(event.rect(), gradient)
|
|
818
|
+
painter.setPen(
|
|
819
|
+
QPen(
|
|
820
|
+
_token_color(PYCFORGE_COLORS.border_strong, 100),
|
|
821
|
+
1,
|
|
822
|
+
)
|
|
823
|
+
)
|
|
824
|
+
painter.drawLine(1, event.rect().top(), 1, event.rect().bottom())
|
|
825
|
+
|
|
826
|
+
selected_marker = self._quantum_rail.selected_marker()
|
|
827
|
+
for marker in self._rail_markers():
|
|
828
|
+
color = self._RAIL_COLORS.get(marker.kind, self._RAIL_COLORS["diagnostic"])
|
|
829
|
+
painter.setPen(QPen(color, 4 if marker is selected_marker else 2))
|
|
830
|
+
y = self._marker_rail_y(marker)
|
|
831
|
+
inset = 2 if marker is selected_marker else 4
|
|
832
|
+
painter.drawLine(inset, y, self._quantum_rail.width() - inset, y)
|
|
833
|
+
|
|
834
|
+
bar = self.verticalScrollBar()
|
|
835
|
+
total = max(1, bar.maximum() + bar.pageStep())
|
|
836
|
+
height = max(10, round(self._quantum_rail.height() * bar.pageStep() / total))
|
|
837
|
+
travel = max(0, self._quantum_rail.height() - height)
|
|
838
|
+
top = 0 if bar.maximum() == 0 else round(travel * bar.value() / bar.maximum())
|
|
839
|
+
painter.setPen(
|
|
840
|
+
QPen(_token_color(PYCFORGE_COLORS.blue_bright), 1)
|
|
841
|
+
)
|
|
842
|
+
painter.setBrush(
|
|
843
|
+
_token_color(PYCFORGE_COLORS.blue, 40)
|
|
844
|
+
)
|
|
845
|
+
painter.drawRoundedRect(2, top, self._quantum_rail.width() - 5, height, 2, 2)
|
|
846
|
+
if self._quantum_rail.hasFocus():
|
|
847
|
+
painter.setBrush(Qt.NoBrush)
|
|
848
|
+
painter.setPen(
|
|
849
|
+
QPen(
|
|
850
|
+
_token_color(PYCFORGE_COLORS.focus_ring),
|
|
851
|
+
1,
|
|
852
|
+
Qt.DotLine,
|
|
853
|
+
)
|
|
854
|
+
)
|
|
855
|
+
painter.drawRect(self._quantum_rail.rect().adjusted(0, 0, -1, -1))
|
|
856
|
+
|
|
857
|
+
def marker_near_rail_position(self, y: int) -> EditorMarker | None:
|
|
858
|
+
candidates = sorted(
|
|
859
|
+
(
|
|
860
|
+
(abs(self._marker_rail_y(marker) - y), marker)
|
|
861
|
+
for marker in self._rail_markers()
|
|
862
|
+
),
|
|
863
|
+
key=lambda item: (item[0], item[1].start, item[1].kind),
|
|
864
|
+
)
|
|
865
|
+
if candidates and candidates[0][0] <= 5:
|
|
866
|
+
return candidates[0][1]
|
|
867
|
+
return None
|
|
868
|
+
|
|
869
|
+
def activate_rail_position(self, y: int) -> None:
|
|
870
|
+
marker = self.marker_near_rail_position(y)
|
|
871
|
+
if marker is not None:
|
|
872
|
+
self.activate_rail_marker(marker)
|
|
873
|
+
return
|
|
874
|
+
height = max(1, self._quantum_rail.height() - 1)
|
|
875
|
+
bar = self.verticalScrollBar()
|
|
876
|
+
bar.setValue(round(bar.maximum() * min(height, max(0, y)) / height))
|
|
877
|
+
|
|
878
|
+
def activate_rail_marker(
|
|
879
|
+
self,
|
|
880
|
+
marker: EditorMarker,
|
|
881
|
+
*,
|
|
882
|
+
focus: bool = True,
|
|
883
|
+
) -> None:
|
|
884
|
+
"""Navigate to *marker* and publish the same event as pointer activation."""
|
|
885
|
+
|
|
886
|
+
self.go_to_position(marker.start, marker.end, focus=focus)
|
|
887
|
+
self.markerActivated.emit(marker.kind, marker.marker_id, marker.start)
|
|
888
|
+
|
|
889
|
+
def go_to_position(
|
|
890
|
+
self,
|
|
891
|
+
start: int,
|
|
892
|
+
end: int | None = None,
|
|
893
|
+
*,
|
|
894
|
+
focus: bool = True,
|
|
895
|
+
) -> None:
|
|
896
|
+
limit = self._document_text_length()
|
|
897
|
+
cursor = QTextCursor(self.document())
|
|
898
|
+
cursor.setPosition(min(limit, max(0, int(start))))
|
|
899
|
+
if end is not None:
|
|
900
|
+
cursor.setPosition(min(limit, max(cursor.position(), int(end))), QTextCursor.KeepAnchor)
|
|
901
|
+
self.setTextCursor(cursor)
|
|
902
|
+
self.centerCursor()
|
|
903
|
+
if focus:
|
|
904
|
+
self.setFocus()
|
|
905
|
+
|
|
906
|
+
def keyPressEvent(self, event) -> None: # noqa: N802 - Qt API
|
|
907
|
+
if event.matches(QKeySequence.Find):
|
|
908
|
+
self.findRequested.emit(False)
|
|
909
|
+
event.accept()
|
|
910
|
+
return
|
|
911
|
+
if event.modifiers() & Qt.ControlModifier and event.key() == Qt.Key_H:
|
|
912
|
+
self.findRequested.emit(True)
|
|
913
|
+
event.accept()
|
|
914
|
+
return
|
|
915
|
+
super().keyPressEvent(event)
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
else:
|
|
919
|
+
class _QtRequired:
|
|
920
|
+
"""Headless placeholder retaining a predictable public API."""
|
|
921
|
+
|
|
922
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
923
|
+
raise RuntimeError("PyQt5 is required for editor widgets") from _QT_ERROR
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
class PyCForgeSyntaxHighlighter(_QtRequired):
|
|
927
|
+
pass
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
class CodeEditor(_QtRequired):
|
|
931
|
+
pass
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
__all__ = [
|
|
935
|
+
"BRACKET_SCAN_LIMIT",
|
|
936
|
+
"CodeEditor",
|
|
937
|
+
"EditorMarker",
|
|
938
|
+
"LARGE_FILE_BRACKET_SCAN_LIMIT",
|
|
939
|
+
"LARGE_FILE_CHARACTER_THRESHOLD",
|
|
940
|
+
"LARGE_FILE_EXTRA_SELECTION_LIMIT",
|
|
941
|
+
"LARGE_FILE_LINE_THRESHOLD",
|
|
942
|
+
"LARGE_FILE_MARKER_STORAGE_LIMIT",
|
|
943
|
+
"LARGE_FILE_RAIL_MARKER_LIMIT",
|
|
944
|
+
"QT_AVAILABLE",
|
|
945
|
+
"PyCForgeSyntaxHighlighter",
|
|
946
|
+
"bounded_marker_projection",
|
|
947
|
+
"large_file_mode_required",
|
|
948
|
+
"lexical_protected_spans",
|
|
949
|
+
"normalize_markers",
|
|
950
|
+
"qt_position_length",
|
|
951
|
+
]
|