pycforge 0.15.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. pycforge/__init__.py +8 -0
  2. pycforge/__main__.py +2 -0
  3. pycforge/_version.py +1 -0
  4. pycforge/converter/__init__.py +0 -0
  5. pycforge/converter/analysis/__init__.py +5 -0
  6. pycforge/converter/analysis/functions.py +723 -0
  7. pycforge/converter/analysis/model.py +158 -0
  8. pycforge/converter/analysis/planning.py +464 -0
  9. pycforge/converter/analysis/stage.py +1976 -0
  10. pycforge/converter/analysis/symbols.py +237 -0
  11. pycforge/converter/analysis/validation.py +2166 -0
  12. pycforge/converter/c_output/__init__.py +2 -0
  13. pycforge/converter/c_output/conformance.py +297 -0
  14. pycforge/converter/c_output/renderer.py +220 -0
  15. pycforge/converter/conditional_regions/__init__.py +71 -0
  16. pycforge/converter/conditional_regions/analysis.py +481 -0
  17. pycforge/converter/conditional_regions/lowering.py +579 -0
  18. pycforge/converter/conditional_regions/model.py +243 -0
  19. pycforge/converter/conditional_regions/validation.py +690 -0
  20. pycforge/converter/containers/__init__.py +19 -0
  21. pycforge/converter/containers/analysis.py +542 -0
  22. pycforge/converter/containers/lowering.py +290 -0
  23. pycforge/converter/containers/model.py +146 -0
  24. pycforge/converter/contracts/__init__.py +0 -0
  25. pycforge/converter/contracts/configuration.py +178 -0
  26. pycforge/converter/contracts/identifiers.py +68 -0
  27. pycforge/converter/contracts/versions.py +71 -0
  28. pycforge/converter/core/__init__.py +0 -0
  29. pycforge/converter/core/artifact_io.py +87 -0
  30. pycforge/converter/core/cancellation.py +7 -0
  31. pycforge/converter/core/canonicalization.py +254 -0
  32. pycforge/converter/core/context.py +13 -0
  33. pycforge/converter/core/diagnostics.py +95 -0
  34. pycforge/converter/core/enums.py +23 -0
  35. pycforge/converter/core/fingerprint.py +22 -0
  36. pycforge/converter/core/pipeline.py +8 -0
  37. pycforge/converter/core/progress.py +25 -0
  38. pycforge/converter/core/request.py +50 -0
  39. pycforge/converter/core/resource_policy.py +31 -0
  40. pycforge/converter/core/result.py +23 -0
  41. pycforge/converter/core/serialization.py +36 -0
  42. pycforge/converter/core/stage.py +10 -0
  43. pycforge/converter/core/stage_artifact.py +80 -0
  44. pycforge/converter/core/stage_outcome.py +15 -0
  45. pycforge/converter/decision_trace/__init__.py +0 -0
  46. pycforge/converter/decision_trace/recorder.py +52 -0
  47. pycforge/converter/facade.py +459 -0
  48. pycforge/converter/frontend/__init__.py +4 -0
  49. pycforge/converter/frontend/normalizer.py +59 -0
  50. pycforge/converter/frontend/parser.py +20 -0
  51. pycforge/converter/frontend/source_document.py +93 -0
  52. pycforge/converter/frontend/stages.py +518 -0
  53. pycforge/converter/frontend/validation.py +76 -0
  54. pycforge/converter/io/__init__.py +0 -0
  55. pycforge/converter/io/atomic_writer.py +33 -0
  56. pycforge/converter/ir/__init__.py +1 -0
  57. pycforge/converter/ir/c_ir/__init__.py +4 -0
  58. pycforge/converter/ir/c_ir/builder.py +26 -0
  59. pycforge/converter/ir/c_ir/model.py +318 -0
  60. pycforge/converter/ir/c_ir/serialization.py +88 -0
  61. pycforge/converter/ir/c_ir/validation.py +834 -0
  62. pycforge/converter/ir/python_ir/__init__.py +17 -0
  63. pycforge/converter/ir/python_ir/nodes.py +86 -0
  64. pycforge/converter/keyword_calls/__init__.py +68 -0
  65. pycforge/converter/keyword_calls/analysis.py +458 -0
  66. pycforge/converter/keyword_calls/lowering.py +271 -0
  67. pycforge/converter/keyword_calls/model.py +304 -0
  68. pycforge/converter/keyword_calls/validation.py +802 -0
  69. pycforge/converter/keyword_only_calls/__init__.py +68 -0
  70. pycforge/converter/keyword_only_calls/analysis.py +640 -0
  71. pycforge/converter/keyword_only_calls/lowering.py +376 -0
  72. pycforge/converter/keyword_only_calls/model.py +328 -0
  73. pycforge/converter/keyword_only_calls/validation.py +1357 -0
  74. pycforge/converter/lowering.py +1000 -0
  75. pycforge/converter/modules/__init__.py +13 -0
  76. pycforge/converter/modules/analysis.py +1250 -0
  77. pycforge/converter/modules/lowering.py +200 -0
  78. pycforge/converter/modules/model.py +73 -0
  79. pycforge/converter/modules/stage.py +192 -0
  80. pycforge/converter/numeric_semantics/__init__.py +23 -0
  81. pycforge/converter/numeric_semantics/analysis.py +420 -0
  82. pycforge/converter/numeric_semantics/lowering.py +153 -0
  83. pycforge/converter/numeric_semantics/model.py +123 -0
  84. pycforge/converter/records/__init__.py +34 -0
  85. pycforge/converter/records/analysis.py +1394 -0
  86. pycforge/converter/records/lowering.py +393 -0
  87. pycforge/converter/records/model.py +319 -0
  88. pycforge/converter/rules/__init__.py +3 -0
  89. pycforge/converter/support_templates/__init__.py +49 -0
  90. pycforge/converter/support_templates/assembly.py +106 -0
  91. pycforge/converter/support_templates/factories.py +348 -0
  92. pycforge/converter/support_templates/model.py +159 -0
  93. pycforge/converter/support_templates/registry.py +388 -0
  94. pycforge/converter/telemetry/__init__.py +0 -0
  95. pycforge/converter/telemetry/sink.py +39 -0
  96. pycforge/ide/__init__.py +19 -0
  97. pycforge/ide/__main__.py +5 -0
  98. pycforge/ide/_worker_event_codec.py +276 -0
  99. pycforge/ide/_worker_protocol_json.py +197 -0
  100. pycforge/ide/_worker_protocol_types.py +90 -0
  101. pycforge/ide/_worker_request_codec.py +200 -0
  102. pycforge/ide/_worker_request_data.py +260 -0
  103. pycforge/ide/_worker_result_data.py +343 -0
  104. pycforge/ide/action_contract.py +469 -0
  105. pycforge/ide/action_validation.py +229 -0
  106. pycforge/ide/command_palette.py +223 -0
  107. pycforge/ide/controller.py +904 -0
  108. pycforge/ide/controller_conversion.py +546 -0
  109. pycforge/ide/controller_io.py +205 -0
  110. pycforge/ide/editor.py +951 -0
  111. pycforge/ide/editor_commands_qt.py +274 -0
  112. pycforge/ide/editor_lexical.py +43 -0
  113. pycforge/ide/editor_sidebars.py +236 -0
  114. pycforge/ide/editor_syntax.py +243 -0
  115. pycforge/ide/find_replace.py +535 -0
  116. pycforge/ide/icons.py +91 -0
  117. pycforge/ide/io_service.py +196 -0
  118. pycforge/ide/model.py +164 -0
  119. pycforge/ide/panels.py +903 -0
  120. pycforge/ide/positions.py +220 -0
  121. pycforge/ide/process_worker.py +130 -0
  122. pycforge/ide/qt.py +230 -0
  123. pycforge/ide/qt_actions.py +418 -0
  124. pycforge/ide/qt_close.py +169 -0
  125. pycforge/ide/qt_command_palette.py +297 -0
  126. pycforge/ide/qt_contract.py +220 -0
  127. pycforge/ide/qt_documents.py +590 -0
  128. pycforge/ide/qt_editor_buffers.py +113 -0
  129. pycforge/ide/qt_editor_surfaces.py +112 -0
  130. pycforge/ide/qt_menus.py +324 -0
  131. pycforge/ide/qt_projection.py +500 -0
  132. pycforge/ide/qt_shell.py +348 -0
  133. pycforge/ide/qt_shell_interactions.py +320 -0
  134. pycforge/ide/qt_state.py +393 -0
  135. pycforge/ide/qt_workspace_features.py +398 -0
  136. pycforge/ide/qt_workspace_navigation.py +208 -0
  137. pycforge/ide/qt_workspace_observers.py +173 -0
  138. pycforge/ide/qt_workspace_panels.py +545 -0
  139. pycforge/ide/qt_workspace_widgets.py +228 -0
  140. pycforge/ide/resources/icons/about.svg +1 -0
  141. pycforge/ide/resources/icons/add-document.svg +1 -0
  142. pycforge/ide/resources/icons/brand-mark.svg +1 -0
  143. pycforge/ide/resources/icons/bundle-search.svg +1 -0
  144. pycforge/ide/resources/icons/cancel.svg +1 -0
  145. pycforge/ide/resources/icons/check.svg +1 -0
  146. pycforge/ide/resources/icons/chevron-right.svg +1 -0
  147. pycforge/ide/resources/icons/close.svg +1 -0
  148. pycforge/ide/resources/icons/collapse-all.svg +1 -0
  149. pycforge/ide/resources/icons/command-palette.svg +1 -0
  150. pycforge/ide/resources/icons/convert.svg +1 -0
  151. pycforge/ide/resources/icons/copy.svg +1 -0
  152. pycforge/ide/resources/icons/cut.svg +1 -0
  153. pycforge/ide/resources/icons/decision-trace.svg +1 -0
  154. pycforge/ide/resources/icons/details.svg +1 -0
  155. pycforge/ide/resources/icons/diagnostics.svg +1 -0
  156. pycforge/ide/resources/icons/duplicate-line.svg +1 -0
  157. pycforge/ide/resources/icons/exit.svg +1 -0
  158. pycforge/ide/resources/icons/expand-all.svg +1 -0
  159. pycforge/ide/resources/icons/export.svg +1 -0
  160. pycforge/ide/resources/icons/find.svg +1 -0
  161. pycforge/ide/resources/icons/go-to-line.svg +1 -0
  162. pycforge/ide/resources/icons/go-to-output.svg +1 -0
  163. pycforge/ide/resources/icons/go-to-source.svg +1 -0
  164. pycforge/ide/resources/icons/history.svg +1 -0
  165. pycforge/ide/resources/icons/indent.svg +1 -0
  166. pycforge/ide/resources/icons/link-c.svg +1 -0
  167. pycforge/ide/resources/icons/mappings.svg +1 -0
  168. pycforge/ide/resources/icons/module.svg +1 -0
  169. pycforge/ide/resources/icons/move-down.svg +1 -0
  170. pycforge/ide/resources/icons/move-line-down.svg +1 -0
  171. pycforge/ide/resources/icons/move-line-up.svg +1 -0
  172. pycforge/ide/resources/icons/move-up.svg +1 -0
  173. pycforge/ide/resources/icons/next.svg +1 -0
  174. pycforge/ide/resources/icons/open.svg +1 -0
  175. pycforge/ide/resources/icons/outdent.svg +1 -0
  176. pycforge/ide/resources/icons/outline.svg +1 -0
  177. pycforge/ide/resources/icons/paste.svg +1 -0
  178. pycforge/ide/resources/icons/previous.svg +1 -0
  179. pycforge/ide/resources/icons/primary-module.svg +1 -0
  180. pycforge/ide/resources/icons/redo.svg +1 -0
  181. pycforge/ide/resources/icons/remove-document.svg +1 -0
  182. pycforge/ide/resources/icons/replace.svg +1 -0
  183. pycforge/ide/resources/icons/save-as.svg +1 -0
  184. pycforge/ide/resources/icons/save.svg +1 -0
  185. pycforge/ide/resources/icons/select-all.svg +1 -0
  186. pycforge/ide/resources/icons/settings.svg +1 -0
  187. pycforge/ide/resources/icons/show-c.svg +1 -0
  188. pycforge/ide/resources/icons/split-view.svg +1 -0
  189. pycforge/ide/resources/icons/summary.svg +1 -0
  190. pycforge/ide/resources/icons/telemetry.svg +1 -0
  191. pycforge/ide/resources/icons/toggle-comment.svg +1 -0
  192. pycforge/ide/resources/icons/toggle-fold.svg +1 -0
  193. pycforge/ide/resources/icons/undo.svg +1 -0
  194. pycforge/ide/resources/icons/whitespace.svg +1 -0
  195. pycforge/ide/revisions.py +400 -0
  196. pycforge/ide/search_service.py +280 -0
  197. pycforge/ide/session_history.py +150 -0
  198. pycforge/ide/source_editing.py +417 -0
  199. pycforge/ide/source_structure.py +558 -0
  200. pycforge/ide/source_structure_async.py +191 -0
  201. pycforge/ide/supervisor.py +503 -0
  202. pycforge/ide/theme.py +119 -0
  203. pycforge/ide/theme_stylesheet.py +598 -0
  204. pycforge/ide/theme_workspace_stylesheet.py +155 -0
  205. pycforge/ide/visual_tokens.py +142 -0
  206. pycforge/ide/worker_protocol.py +80 -0
  207. pycforge/ide/workspace_search.py +462 -0
  208. pycforge/ide/workspace_session.py +256 -0
  209. pycforge/laboratory/__init__.py +6 -0
  210. pycforge/laboratory/audits.py +2244 -0
  211. pycforge/laboratory/checkpoint_e.py +2685 -0
  212. pycforge/laboratory/cli.py +119 -0
  213. pycforge/laboratory/exit_codes.py +11 -0
  214. pycforge/laboratory/keyword_audit.py +661 -0
  215. pycforge/laboratory/keyword_only_audit.py +481 -0
  216. pycforge-0.15.2.dist-info/METADATA +209 -0
  217. pycforge-0.15.2.dist-info/RECORD +221 -0
  218. pycforge-0.15.2.dist-info/WHEEL +5 -0
  219. pycforge-0.15.2.dist-info/entry_points.txt +5 -0
  220. pycforge-0.15.2.dist-info/licenses/LICENSE +674 -0
  221. pycforge-0.15.2.dist-info/top_level.txt +1 -0
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
+ ]